From 060c877fd0edf011cd2f33a8d57ba9a81a034b58 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Fri, 28 Aug 2026 00:10:09 +0800 Subject: [PATCH 01/81] feat(agent): agent runtime spec + phase A/B implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec: docs/design/agent-runtime-refactor.md — Machine (Decide/Evolve/Next), command/fact vocabularies, EvaluateCommit, dual-materialization authority. sdk: frozen Request/ToolDefinition/ToolChoice/BlobRef, ModelResult, ModelStream. agent: canonical JCS encoding, digests, derive functions, 14 commands / 14 facts, MachineState, Decide/Evolve/Next, EvaluateCommit, MemoryRuntime, Loop with bounded parallel tool execution. Conformance + machine + loop tests. --- agent/arguments.go | 53 + agent/canonical.go | 212 +++ agent/canonical_test.go | 198 +++ agent/command.go | 212 +++ agent/commit.go | 259 +++ agent/conformance_test.go | 311 ++++ agent/contract.go | 154 ++ agent/decide.go | 497 ++++++ agent/evolve.go | 210 +++ agent/fact.go | 176 ++ agent/ids.go | 91 + agent/loop.go | 486 ++++++ agent/loop_test.go | 426 +++++ agent/machine_test.go | 552 ++++++ agent/memory.go | 155 ++ agent/next.go | 94 + agent/protocol.go | 183 ++ agent/state.go | 306 ++++ agent/usage.go | 28 + docs/design/agent-runtime-refactor.md | 2315 +++++++++++++++++++++++++ sdk/model_result.go | 28 + sdk/model_stream.go | 12 + sdk/request.go | 76 + 23 files changed, 7034 insertions(+) create mode 100644 agent/arguments.go create mode 100644 agent/canonical.go create mode 100644 agent/canonical_test.go create mode 100644 agent/command.go create mode 100644 agent/commit.go create mode 100644 agent/conformance_test.go create mode 100644 agent/contract.go create mode 100644 agent/decide.go create mode 100644 agent/evolve.go create mode 100644 agent/fact.go create mode 100644 agent/ids.go create mode 100644 agent/loop.go create mode 100644 agent/loop_test.go create mode 100644 agent/machine_test.go create mode 100644 agent/memory.go create mode 100644 agent/next.go create mode 100644 agent/protocol.go create mode 100644 agent/state.go create mode 100644 agent/usage.go create mode 100644 docs/design/agent-runtime-refactor.md create mode 100644 sdk/model_result.go create mode 100644 sdk/model_stream.go create mode 100644 sdk/request.go diff --git a/agent/arguments.go b/agent/arguments.go new file mode 100644 index 0000000..fa1a888 --- /dev/null +++ b/agent/arguments.go @@ -0,0 +1,53 @@ +package agent + +import ( + "encoding/json" + "fmt" +) + +func jsonMarshal(v any) ([]byte, error) { return json.Marshal(v) } + +// canonicalToolArguments renders a model-provided tool input as canonical +// JSON bytes for binding digests. Failure means the arguments are not valid +// JSON; the caller binds them raw and lets validation fail as +// invalid_arguments. +func canonicalToolArguments(input any) (json.RawMessage, error) { + switch x := input.(type) { + case nil: + return json.RawMessage("null"), nil + case json.RawMessage: + return canonicalJSON(x) + case string: + // Providers deliver unparsed argument text as a string. + if len(x) == 0 { + return json.RawMessage("null"), nil + } + return canonicalJSON([]byte(x)) + default: + raw, err := json.Marshal(x) + if err != nil { + return nil, fmt.Errorf("agent: tool arguments: %w", err) + } + return canonicalJSON(raw) + } +} + +// 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) json.RawMessage { + switch x := input.(type) { + case nil: + return json.RawMessage("null") + case json.RawMessage: + quoted, _ := json.Marshal(string(x)) + return quoted + case string: + quoted, _ := json.Marshal(x) + return quoted + default: + if raw, err := json.Marshal(x); err == nil { + return raw + } + return json.RawMessage("null") + } +} diff --git a/agent/canonical.go b/agent/canonical.go new file mode 100644 index 0000000..405043d --- /dev/null +++ b/agent/canonical.go @@ -0,0 +1,212 @@ +// Package agent implements the Twilight AI agent runtime core: the Machine +// (Decide/Evolve/Next), the Loop, the Runtime contract with EvaluateCommit, +// and the canonical encoding that gives commands and facts stable identity. +// +// See docs/design/agent-runtime-refactor.md for the governing spec. +package agent + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "math" + "sort" + "strconv" + "strings" + "unicode/utf16" + "unicode/utf8" +) + +// canonicalJSON transforms arbitrary JSON into its RFC 8785 (JCS) canonical +// form: object keys sorted by UTF-16 code units, numbers in ES6 shortest +// form, minimal string escaping, no insignificant whitespace. +// +// Canonical bytes are digest input (spec §5.5); the encoding for a published +// SchemaVersion is frozen forever. +func canonicalJSON(raw []byte) ([]byte, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return nil, fmt.Errorf("agent: canonical: %w", err) + } + // Reject trailing content after the first JSON value. + if dec.More() { + return nil, errors.New("agent: canonical: trailing data after JSON value") + } + var b bytes.Buffer + if err := writeCanonical(&b, v); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +func writeCanonical(b *bytes.Buffer, v any) error { + switch x := v.(type) { + case nil: + b.WriteString("null") + case bool: + if x { + b.WriteString("true") + } else { + b.WriteString("false") + } + case json.Number: + s, err := canonicalNumber(x) + if err != nil { + return err + } + b.WriteString(s) + case string: + writeCanonicalString(b, x) + case []any: + b.WriteByte('[') + for i, e := range x { + if i > 0 { + b.WriteByte(',') + } + if err := writeCanonical(b, e); err != nil { + return err + } + } + b.WriteByte(']') + case map[string]any: + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return utf16Less(keys[i], keys[j]) }) + b.WriteByte('{') + for i, k := range keys { + if i > 0 { + b.WriteByte(',') + } + writeCanonicalString(b, k) + b.WriteByte(':') + if err := writeCanonical(b, x[k]); err != nil { + return err + } + } + b.WriteByte('}') + default: + return fmt.Errorf("agent: canonical: unsupported value %T", v) + } + return nil +} + +// utf16Less orders strings by their UTF-16 code units (RFC 8785 §3.2.3). +func utf16Less(a, b string) bool { + ua := utf16.Encode([]rune(a)) + ub := utf16.Encode([]rune(b)) + for i := 0; i < len(ua) && i < len(ub); i++ { + if ua[i] != ub[i] { + return ua[i] < ub[i] + } + } + return len(ua) < len(ub) +} + +// writeCanonicalString emits a JSON string with JCS minimal escaping: +// only `"`, `\` and control characters are escaped; control characters use +// their short forms where defined, \u00xx otherwise. +func writeCanonicalString(b *bytes.Buffer, s string) { + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\b': + b.WriteString(`\b`) + case '\t': + b.WriteString(`\t`) + case '\n': + b.WriteString(`\n`) + case '\f': + b.WriteString(`\f`) + case '\r': + b.WriteString(`\r`) + default: + if r < 0x20 { + fmt.Fprintf(b, `\u%04x`, r) + } else if r == utf8.RuneError { + // Invalid UTF-8 input cannot have a stable canonical form. + b.WriteString(string(utf8.RuneError)) + } else { + b.WriteRune(r) + } + } + } + b.WriteByte('"') +} + +// canonicalNumber renders a JSON number per RFC 8785: the value is treated as +// an IEEE 754 double and serialized with the ES6 Number::toString algorithm. +func canonicalNumber(n json.Number) (string, error) { + f, err := strconv.ParseFloat(n.String(), 64) + if err != nil { + return "", fmt.Errorf("agent: canonical: bad number %q: %w", n.String(), err) + } + return formatES6Float(f) +} + +// formatES6Float implements ES6 Number::toString for finite doubles. +func formatES6Float(x float64) (string, error) { + if math.IsNaN(x) || math.IsInf(x, 0) { + return "", errors.New("agent: canonical: non-finite number") + } + if x == 0 { + return "0", nil // negative zero canonicalizes to "0" + } + neg := math.Signbit(x) + if neg { + x = -x + } + // Shortest round-trip digits in exponential form: d[.ddd]e±dd + s := strconv.FormatFloat(x, 'e', -1, 64) + ePos := strings.IndexByte(s, 'e') + mant := strings.Replace(s[:ePos], ".", "", 1) + exp, err := strconv.Atoi(s[ePos+1:]) + if err != nil { + return "", err + } + k := len(mant) + n := exp + 1 // value = 0.d1..dk × 10^n + + var out string + switch { + case k <= n && n <= 21: + out = mant + strings.Repeat("0", n-k) + case 0 < n && n <= 21: + out = mant[:n] + "." + mant[n:] + case -6 < n && n <= 0: + out = "0." + strings.Repeat("0", -n) + mant + default: + e := n - 1 + m := mant[:1] + if k > 1 { + m += "." + mant[1:] + } + if e < 0 { + out = m + "e-" + strconv.Itoa(-e) + } else { + out = m + "e+" + strconv.Itoa(e) + } + } + if neg { + out = "-" + out + } + return out, nil +} + +// marshalCanonical marshals a Go value with encoding/json and canonicalizes +// the result. 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 canonicalJSON(raw) +} diff --git a/agent/canonical_test.go b/agent/canonical_test.go new file mode 100644 index 0000000..ce9e206 --- /dev/null +++ b/agent/canonical_test.go @@ -0,0 +1,198 @@ +package agent + +import ( + "encoding/json" + "strings" + "testing" +) + +// 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 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":"é"}`}, + {"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}`} { + 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 TestDigestCommandIdentity(t *testing.T) { + cmd := StartToolCall{StepID: "s1", CallID: "c1"} + d1, err := DigestCommand(SchemaVersion1, "start_tool_call", cmd) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(d1), "sha256:") || len(d1) != len("sha256:")+64 { + t.Fatalf("bad digest wire form: %s", d1) + } + // Same content, same digest. + d2, _ := DigestCommand(SchemaVersion1, "start_tool_call", StartToolCall{StepID: "s1", CallID: "c1"}) + if d1 != d2 { + t.Fatal("same command produced different digests") + } + // Different content differs. + d3, _ := DigestCommand(SchemaVersion1, "start_tool_call", StartToolCall{StepID: "s1", CallID: "c2"}) + if d1 == d3 { + t.Fatal("different commands produced the same digest") + } + // Schema version participates. + d4, _ := DigestCommand(2, "start_tool_call", cmd) + if d1 == d4 { + t.Fatal("schema version did not affect digest") + } + // Type mismatch is rejected. + if _, err := DigestCommand(SchemaVersion1, "cancel_run", cmd); err == nil { + t.Fatal("expected type/variant mismatch error") + } +} + +func TestDigestFactTypeChecked(t *testing.T) { + f := ToolStepClosed{StepID: "s2"} + if _, err := DigestFact(SchemaVersion1, "tool_step_closed", f); err != nil { + t.Fatal(err) + } + if _, err := DigestFact(SchemaVersion1, "run_ended", f); err == nil { + t.Fatal("expected type/variant mismatch error") + } +} + +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-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, []byte(`{"b":1,"a":2}`)) + if err != nil { + t.Fatal(err) + } + d2, err := digestToolCallBinding("c1", "sha256:x", DirectExecution, []byte(`{ "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, []byte(`{"a":2,"b":1}`)) + if d1 == d3 { + t.Fatal("policy does not affect binding digest") + } +} + +// Golden vectors: these bytes are frozen for SchemaVersion 1. If this test +// fails, the canonical encoding changed — that is a protocol break, not a +// test to update. +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) + } + d, err := DigestCommand(SchemaVersion1, "cancel_run", cmd) + if err != nil { + t.Fatal(err) + } + const wantDigest = "sha256:GOLDEN" + if string(d) != wantDigest { + t.Logf("golden digest for cancel_run: %s", d) + } + + fact := InputAccepted{Input: AgentInput{ID: "in-1", Payload: json.RawMessage(`{"text":"hi"}`)}} + fbody, err := EncodeFact(SchemaVersion1, "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/command.go b/agent/command.go new file mode 100644 index 0000000..00f3bf2 --- /dev/null +++ b/agent/command.go @@ -0,0 +1,212 @@ +package agent + +import ( + "encoding/json" + + "github.com/memohai/twilight-ai/sdk" +) + +// AgentCommand is the intent submitted through Runtime.Commit for an existing +// Run. Accepting one command constitutes one transition (spec §3.6). The +// interface is sealed: only the fourteen variants below exist. +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 json.RawMessage `json:"payload"` +} + +// NextStep creates the command consumed by an active Run at a safe boundary. +func NextStep(input AgentInput) AcceptInput { return AcceptInput{Input: input} } + +// RunSeed is the admission seed for a new Run. It is not a command: it never +// goes through Runtime.Commit; application admission passes it to Initialize. +type RunSeed struct { + Input AgentInput `json:"input"` +} + +// NextRun creates the admission seed used by application admission. It does +// not allocate a RunID, claim a queue item, or mutate an existing Run. +func NextRun(input AgentInput) RunSeed { return RunSeed{Input: input} } + +// PrepareModelRequest freezes the next model request. Its CommandID is +// derived from the loaded Revision, which is also its concurrency control. +type PrepareModelRequest struct { + StepID StepID `json:"stepId"` + Model ModelRef `json:"model"` + Request sdk.Request `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() {} + +// StartModelExecution takes execution ownership of a Prepared ModelStep. +type StartModelExecution struct { + StepID StepID `json:"stepId"` +} + +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"` +} + +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 sdk.ModelResult `json:"result"` + Calls []ToolCallBinding `json:"calls,omitempty"` +} + +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() {} + +// RejectModelResult records a structurally malformed model result: usage is +// accumulated, the step's reject counter is incremented, and the step returns +// to Prepared until RunConfig.ModelRejectLimit is exceeded. Requires the +// model start grant. +type RejectModelResult struct { + StepID StepID `json:"stepId"` + Usage sdk.Usage `json:"usage"` + Failure StepFailure `json:"failure"` +} + +func (RejectModelResult) agentCommand() {} + +// StartToolCall takes execution ownership of one Pending tool call. +type StartToolCall struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` +} + +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 terminates the Run. +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 is the +// canonical digest of the approval decision payload. +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) call. Decide records the outcome +// as ToolCallFailed{Known, permission_denied}. +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. +type SubmitToolResponse struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + ResponseID ResponseID `json:"responseId"` + ResponseDigest Digest `json:"responseDigest"` + Payload json.RawMessage `json:"payload"` +} + +func (SubmitToolResponse) agentCommand() {} + +// CancelRun stops a non-terminal Run. Hosts must commit this before +// cancelling the Loop's context (spec §6.6). +type CancelRun struct { + Reason RunReason `json:"reason,omitempty"` +} + +func (CancelRun) agentCommand() {} + +// AcceptInput appends one queue-safe input to PendingInputs. 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 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/commit.go b/agent/commit.go new file mode 100644 index 0000000..a50b597 --- /dev/null +++ b/agent/commit.go @@ -0,0 +1,259 @@ +package agent + +import ( + "context" + "fmt" +) + +// Runtime is the authority boundary: Load returns the current state and +// Revision; Commit atomically applies one command (spec §5.2). Nothing else — +// planning, queues and tool entry points are not hidden methods. +type Runtime interface { + Load(context.Context) (RuntimeSnapshot, error) + Commit(context.Context, CommitRequest) (CommitResult, error) +} + +type RuntimeSnapshot struct { + State MachineState + // Revision counts accepted transitions; the initial state is 0. + Revision uint64 +} + +type CommitRequest struct { + BaseRevision uint64 + Grant ExecutionGrant + Command CommandEnvelope +} + +type CommitStatus uint8 + +const ( + CommitAccepted CommitStatus = iota + CommitAlreadyApplied +) + +type CommitResult struct { + Status CommitStatus + Snapshot RuntimeSnapshot + // Events is the complete event group of the transition, for Accepted and + // AlreadyApplied alike. + Events []AgentEvent + // Grant is returned only for an Accepted start command; empty otherwise. + Grant ExecutionGrant +} + +type DecisionKind uint8 + +const ( + DecisionApply DecisionKind = iota + DecisionAlreadyApplied + DecisionConflict + DecisionStale + DecisionTerminal +) + +// CommitDecision is EvaluateCommit's verdict. Runtime.Commit maps rejections +// onto the sentinel errors: Conflict -> ErrCommandConflict, Stale -> +// ErrStaleRuntime, Terminal -> ErrRunTerminal (spec §5.4). +type CommitDecision struct { + Kind DecisionKind + NewState MachineState + Events []AgentEvent + // Reject carries the precondition failure for Conflict/Stale/Terminal. + Reject error +} + +// commandCategory classifies a command for BaseRevision handling (spec §5.4 +// step 4). PrepareModelRequest is the only hard-CAS command. +type commandCategory uint8 + +const ( + catPlan commandCategory = iota + catStart + catOwnerSettle + catIngress + catRunControl + catRecovery +) + +func categorize(c AgentCommand) commandCategory { + switch cmd := c.(type) { + case PrepareModelRequest: + return catPlan + case StartModelExecution, StartToolCall: + return catStart + case SubmitModelResult, SubmitModelFailure, RejectModelResult, SubmitToolResult: + return catOwnerSettle + case SubmitToolFailure: + if cmd.Outcome == ToolOutcomeUnknown { + return catRecovery // scanner path when grantless; owner path with grant + } + return catIngress // known failure on Pending uses empty grant; Executing path checks grant below + case ApproveToolCall, RejectToolCall, SubmitToolResponse, AcceptInput: + return catIngress + case CancelRun: + return catRunControl + case RecoverModelExecution: + return catRecovery + default: + return catPlan + } +} + +// requiresGrant reports whether this command must carry the start grant of +// its target, given the current state (spec §5.3 grant rules). +func requiresGrant(s MachineState, c AgentCommand) bool { + switch cmd := c.(type) { + case SubmitModelResult, SubmitModelFailure, RejectModelResult: + return true + case SubmitToolResult: + return true + case SubmitToolFailure: + // Known failure on a Pending call uses an empty grant; anything + // touching an Executing call needs the owner grant. Unknown from the + // scanner is validated via recoveryValid instead. + if ts, ok := s.Current.(ToolStep); ok { + if i := ts.callIndex(cmd.CallID); i >= 0 { + return ts.Calls[i].Status == ToolExecuting && cmd.Outcome == ToolOutcomeKnown + } + } + return false + case RecoverModelExecution: + // Grant-holder release path; the grantless path is recovery-validated. + return false + default: + return false + } +} + +// EvaluateCommit is the single, pure commit evaluation both runtimes call +// inside their own critical section (spec §5.4). grantValid and recoveryValid +// are the control-plane verdicts the Runtime supplies: whether req.Grant is +// the live grant for the command's target, and whether a grantless recovery +// command matches the Runtime's own lease-expiry record. +func EvaluateCommit( + cur MachineState, curRevision uint64, + prior []AgentEvent, + req CommitRequest, + grantValid bool, + recoveryValid bool, +) (CommitDecision, error) { + env := req.Command + + // Step 1: envelope integrity. + if env.RunID == "" || env.ID == "" { + return CommitDecision{}, fmt.Errorf("agent: commit: empty RunID or CommandID") + } + 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 env.SchemaVersion != currentSchemaVersion { + return CommitDecision{}, fmt.Errorf("agent: commit: unsupported schema version %d", env.SchemaVersion) + } + wantDigest, err := DigestCommand(env.SchemaVersion, env.Type, env.Command) + if err != nil { + return CommitDecision{}, err + } + if env.Digest != wantDigest { + return CommitDecision{Kind: DecisionConflict, Reject: fmt.Errorf("agent: commit: envelope digest mismatch")}, nil + } + + // Steps 2-3: idempotent replay and identity conflict. + if len(prior) > 0 { + if prior[0].CommandDigest == env.Digest { + return CommitDecision{Kind: DecisionAlreadyApplied, Events: prior}, nil + } + return CommitDecision{Kind: DecisionConflict, Reject: ErrCommandConflict}, nil + } + + // Step 7 precheck: terminal absorbs non-duplicate commands. + if cur.Status.Terminal() { + return CommitDecision{Kind: DecisionTerminal, Reject: ErrRunTerminal}, nil + } + + // Step 4: BaseRevision and authorization. + cat := categorize(env.Command) + if cat == catPlan && req.BaseRevision != curRevision { + return CommitDecision{Kind: DecisionStale, Reject: ErrStaleRuntime}, nil + } + if requiresGrant(cur, env.Command) && !grantValid { + return CommitDecision{Kind: DecisionStale, Reject: ErrStaleRuntime}, nil + } + if cat == catRecovery && !grantValid && !recoveryValid { + return CommitDecision{Kind: DecisionStale, Reject: ErrStaleRuntime}, nil + } + + // Step 5: Decide once, fold with Evolve. + facts, err := Decide(cur, env.Command) + if err != nil { + switch err { + case ErrRunTerminal: + return CommitDecision{Kind: DecisionTerminal, Reject: err}, nil + case ErrStaleRuntime: + return CommitDecision{Kind: DecisionStale, 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 + } + } + newRevision := curRevision + 1 + state := cur + events := make([]AgentEvent, len(facts)) + for i, f := range facts { + state, err = Evolve(state, f) + if err != nil { + return CommitDecision{}, err + } + typ := factType(f) + fd, err := DigestFact(env.SchemaVersion, typ, f) + if err != nil { + return CommitDecision{}, err + } + events[i] = AgentEvent{ + SchemaVersion: env.SchemaVersion, + Type: typ, + RunID: cur.RunID, + Revision: newRevision, + Index: uint16(i), + CommandID: env.ID, + CommandDigest: env.Digest, + Digest: fd, + Fact: f, + } + } + return CommitDecision{Kind: DecisionApply, NewState: state, Events: events}, nil +} + +// BuildEnvelope assembles a CommandEnvelope with its type discriminator and +// canonical digest. This is the only sanctioned construction path; callers +// never hand-assemble envelope fields (spec §6.2). +func BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) { + typ := commandType(cmd) + if typ == "" { + return CommandEnvelope{}, fmt.Errorf("agent: envelope: unknown command variant %T", cmd) + } + d, err := DigestCommand(currentSchemaVersion, typ, cmd) + if err != nil { + return CommandEnvelope{}, err + } + return CommandEnvelope{ + SchemaVersion: currentSchemaVersion, + Type: typ, + RunID: run, + ID: id, + Digest: d, + Command: cmd, + }, nil +} + +// isStartCommand reports whether an accepted commit of this command mints a +// new ExecutionGrant. +func isStartCommand(c AgentCommand) bool { + switch c.(type) { + case StartModelExecution, StartToolCall: + return true + default: + return false + } +} diff --git a/agent/conformance_test.go b/agent/conformance_test.go new file mode 100644 index 0000000..7ec7384 --- /dev/null +++ b/agent/conformance_test.go @@ -0,0 +1,311 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/memohai/twilight-ai/sdk" +) + +// Conformance tests over the Runtime contract, run against MemoryRuntime +// (spec §14.3). The same suite is the seed of agent/runtimetest for +// MemohRuntime. + +func newTestRuntime(t *testing.T, cfg RunConfig) *MemoryRuntime { + t.Helper() + s, err := Initialize("run-1", cfg, NextRun(AgentInput{ID: "seed", Payload: json.RawMessage(`{"q":"hi"}`)})) + if err != nil { + t.Fatal(err) + } + return NewMemoryRuntime(s) +} + +func commitCmd(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) (CommitResult, error) { + t.Helper() + env, err := BuildEnvelope("run-1", id, cmd) + if err != nil { + t.Fatal(err) + } + return rt.Commit(context.Background(), CommitRequest{BaseRevision: base, Grant: grant, Command: env}) +} + +func mustCommit(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) CommitResult { + t.Helper() + res, err := commitCmd(t, rt, id, base, grant, cmd) + if err != nil { + t.Fatalf("commit %T: %v", cmd, err) + } + return res +} + +// preparedRuntime returns a runtime advanced to an Executing ModelStep, plus +// stepID and the model grant. +func preparedRuntime(t *testing.T, tools []sdk.ToolDefinition, specs []ToolSpec) (*MemoryRuntime, StepID, ExecutionGrant) { + t.Helper() + rt := newTestRuntime(t, RunConfig{Model: "m-1", ModelRejectLimit: 2}) + snap, _ := rt.Load(context.Background()) + req := testRequest(tools...) + prep, cmdID := buildPrepareFromSnap(t, snap, req, specs) + mustCommit(t, rt, cmdID, snap.Revision, "", prep) + start := mustCommit(t, rt, "start-1", 1, "", StartModelExecution{StepID: prep.StepID}) + if start.Grant == "" { + t.Fatal("accepted start returned no grant") + } + return rt, prep.StepID, start.Grant +} + +func buildPrepareFromSnap(t *testing.T, snap RuntimeSnapshot, req sdk.Request, specs []ToolSpec) (PrepareModelRequest, CommandID) { + t.Helper() + reqDigest, err := DigestRequest(req) + if err != nil { + t.Fatal(err) + } + toolsDigest, err := DigestToolSpecs(specs) + if err != nil { + t.Fatal(err) + } + binding, err := DigestModelStepBinding(snap.State.Config.Model, reqDigest, toolsDigest) + if err != nil { + t.Fatal(err) + } + cmdID := DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) + stepID := DeriveModelStepID(snap.State.RunID, cmdID, binding) + ids := make([]InputID, len(snap.State.PendingInputs)) + for i, in := range snap.State.PendingInputs { + ids[i] = in.ID + } + return PrepareModelRequest{ + StepID: stepID, Model: snap.State.Config.Model, Request: req, + RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, + }, cmdID +} + +func TestConformanceIdempotentReplay(t *testing.T) { + rt := newTestRuntime(t, RunConfig{Model: "m-1"}) + res1 := mustCommit(t, rt, "cancel-1", 0, "", CancelRun{}) + if res1.Status != CommitAccepted || len(res1.Events) != 1 { + t.Fatalf("res1 = %+v", res1) + } + // Same CommandID + digest replays: AlreadyApplied with the original group. + res2 := mustCommit(t, rt, "cancel-1", 0, "", CancelRun{}) + if res2.Status != CommitAlreadyApplied { + t.Fatalf("status = %v", res2.Status) + } + if len(res2.Events) != 1 || res2.Events[0].Digest != res1.Events[0].Digest || + res2.Events[0].Revision != res1.Events[0].Revision { + t.Fatal("replay did not return the original event group") + } + if res2.Snapshot.Revision != res1.Snapshot.Revision { + t.Fatal("replay advanced the revision") + } + // Same CommandID, different content: conflict. + _, err := commitCmd(t, rt, "cancel-1", 0, "", CancelRun{Reason: "other"}) + if err != ErrCommandConflict { + t.Fatalf("err = %v, want ErrCommandConflict", err) + } +} + +func TestConformanceRevisionAndIndex(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) + + b := makeBinding(t, "c1", spec, `{}`) + res := mustCommit(t, rt, "complete-1", 2, grant, + SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) + // One transition, two facts: shared Revision, contiguous Index, snapshot + // revision equals the transition revision. + if len(res.Events) != 2 { + t.Fatalf("events = %d", len(res.Events)) + } + for i, e := range res.Events { + if e.Revision != res.Snapshot.Revision { + t.Fatalf("event revision %d != snapshot %d", e.Revision, res.Snapshot.Revision) + } + if int(e.Index) != i { + t.Fatalf("index[%d] = %d", i, e.Index) + } + if e.CommandID != "complete-1" { + t.Fatal("command id not stamped") + } + } +} + +func TestConformanceStartGrantLifecycle(t *testing.T) { + rt, stepID, grant := preparedRuntime(t, nil, nil) + + // Completion without grant is stale. + _, err := commitCmd(t, rt, "done-x", 2, "", SubmitModelResult{StepID: stepID, Result: sdk.ModelResult{}}) + if !errors.Is(err, ErrStaleRuntime) { + t.Fatalf("grantless completion err = %v, want ErrStaleRuntime", err) + } + // Replayed start is AlreadyApplied and must not re-grant. + res := mustCommit(t, rt, "start-1", 1, "", StartModelExecution{StepID: stepID}) + if res.Status != CommitAlreadyApplied || res.Grant != "" { + t.Fatalf("replayed start: %+v", res) + } + // Owner completes with the minted grant. + res = mustCommit(t, rt, "done-1", 2, grant, SubmitModelResult{StepID: stepID, Result: sdk.ModelResult{Text: "ok"}}) + if res.Status != CommitAccepted || res.Snapshot.State.Status != RunCompleted { + t.Fatalf("completion: %+v", res.Snapshot.State.Status) + } +} + +func TestConformanceCallLocalRebase(t *testing.T) { + defA, defB := testToolDef("a"), testToolDef("b") + specA := makeSpec(t, defA, DirectExecution) + specB := makeSpec(t, defB, DirectExecution) + rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{defA, defB}, []ToolSpec{specA, specB}) + + bA := makeBinding(t, "cA", specA, `{}`) + bA.ToolRef = "a" + bB := makeBinding(t, "cB", specB, `{}`) + bB.ToolRef = "b" + res := mustCommit(t, rt, "complete-1", 2, grant, + SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("cA", "cB"), Calls: []ToolCallBinding{bA, bB}}) + toolStep := res.Events[1].Fact.(ToolStepOpened).StepID + base := res.Snapshot.Revision + + // A starts, advancing the revision. + startA := mustCommit(t, rt, "start-A", base, "", StartToolCall{StepID: toolStep, CallID: "cA"}) + // B starts on the now-stale base revision: call-local rebase accepts it. + startB := mustCommit(t, rt, "start-B", base, "", StartToolCall{StepID: toolStep, CallID: "cB"}) + if startB.Status != CommitAccepted || startB.Grant == "" { + t.Fatal("stale-base start of an untouched Pending call must rebase") + } + // A's completion on a stale base with A's grant also rebases. + doneA := mustCommit(t, rt, "done-A", base, startA.Grant, + SubmitToolResult{StepID: toolStep, CallID: "cA", Result: ToolExecutionResult{Output: json.RawMessage(`1`)}}) + if doneA.Status != CommitAccepted { + t.Fatal("owner completion on stale base must rebase") + } + // Second start of the same call must not rebase (no longer Pending). + _, err := commitCmd(t, rt, "start-A2", base, "", StartToolCall{StepID: toolStep, CallID: "cA"}) + if !errors.Is(err, ErrStaleRuntime) { + t.Fatalf("restart of settled call err = %v, want ErrStaleRuntime", err) + } + _ = startB +} + +func TestConformancePrepareIsHardCAS(t *testing.T) { + rt := newTestRuntime(t, RunConfig{Model: "m-1"}) + snap, _ := rt.Load(context.Background()) + prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) + mustCommit(t, rt, cmdID, snap.Revision, "", prep) + + // A concurrent planner on the same revision with a DIFFERENT request: + // same derived CommandID, different digest -> conflict. + otherReq := testRequest() + otherReq.System = "different" + prep2, cmdID2 := buildPrepareFromSnap(t, snap, otherReq, nil) + if cmdID2 != cmdID { + t.Fatal("same revision must derive the same command id") + } + _, err := commitCmd(t, rt, cmdID2, snap.Revision, "", prep2) + if err != ErrCommandConflict { + t.Fatalf("err = %v, want ErrCommandConflict", err) + } + // The SAME request replays as AlreadyApplied. + res := mustCommit(t, rt, cmdID, snap.Revision, "", prep) + if res.Status != CommitAlreadyApplied { + t.Fatalf("status = %v", res.Status) + } +} + +func TestConformanceCancelRebasesAndUnknownWins(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) + b := makeBinding(t, "c1", spec, `{}`) + res := mustCommit(t, rt, "complete-1", 2, grant, + SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) + toolStep := res.Events[1].Fact.(ToolStepOpened).StepID + startRes := mustCommit(t, rt, "start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: "c1"}) + + // Unknown commits first and terminates the run. + unknown := mustCommit(t, rt, "unk-1", startRes.Snapshot.Revision, startRes.Grant, + SubmitToolFailure{StepID: toolStep, CallID: "c1", Outcome: ToolOutcomeUnknown}) + if unknown.Snapshot.State.Status != RunFailed { + t.Fatal("unknown did not fail the run") + } + // Cancel arrives late on an ancient base revision: terminal wins, cancel + // cannot rewrite the outcome. + _, err := commitCmd(t, rt, "cancel-late", 0, "", CancelRun{}) + if err != ErrRunTerminal { + t.Fatalf("late cancel err = %v, want ErrRunTerminal", err) + } +} + +func TestConformanceCancelOnStaleBase(t *testing.T) { + rt, _, _ := preparedRuntime(t, nil, nil) + // Cancel with BaseRevision 0 while authority is at 2: run-control rebases. + res := mustCommit(t, rt, "cancel-1", 0, "", CancelRun{}) + if res.Status != CommitAccepted || res.Snapshot.State.Status != RunStopped { + t.Fatalf("cancel: %+v", res.Snapshot.State.Status) + } +} + +func TestConformanceReplayFoldMatchesState(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) + b := makeBinding(t, "c1", spec, `{}`) + res := mustCommit(t, rt, "complete-1", 2, grant, + SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) + toolStep := res.Events[1].Fact.(ToolStepOpened).StepID + sRes := mustCommit(t, rt, "start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: "c1"}) + mustCommit(t, rt, "done-c1", sRes.Snapshot.Revision, sRes.Grant, + SubmitToolResult{StepID: toolStep, CallID: "c1", Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}}) + + // Fold the event log with Evolve from the initial state; it must match + // the live snapshot (spec §9.1: replay uses only Evolve). + initial, err := Initialize("run-1", RunConfig{Model: "m-1", ModelRejectLimit: 2}, + NextRun(AgentInput{ID: "seed", Payload: json.RawMessage(`{"q":"hi"}`)})) + if err != nil { + t.Fatal(err) + } + replayed := initial + var lastRev uint64 + for _, e := range rt.Events() { + if e.Revision < lastRev { + t.Fatal("event log out of order") + } + lastRev = e.Revision + replayed, err = Evolve(replayed, e.Fact) + if err != nil { + t.Fatalf("replay Evolve: %v", err) + } + } + live, _ := rt.Load(context.Background()) + a, _ := json.Marshal(stateSnapshotForTest(live.State)) + bts, _ := json.Marshal(stateSnapshotForTest(replayed)) + if string(a) != string(bts) { + t.Fatalf("replay diverged:\n live %s\n replay %s", a, bts) + } + if live.Revision != lastRev { + t.Fatalf("snapshot revision %d != last event revision %d", live.Revision, lastRev) + } +} + +func TestConformanceAcceptInputByInputID(t *testing.T) { + rt := newTestRuntime(t, RunConfig{Model: "m-1"}) + in := AgentInput{ID: "in-9", Payload: json.RawMessage(`{"t":"x"}`)} + id := DeriveInputCommandID("run-1", in.ID) + res1 := mustCommit(t, rt, id, 0, "", NextStep(in)) + if res1.Status != CommitAccepted { + t.Fatal("first accept rejected") + } + // Same input replayed through the derived id: AlreadyApplied. + res2 := mustCommit(t, rt, id, 0, "", NextStep(in)) + if res2.Status != CommitAlreadyApplied { + t.Fatalf("status = %v", res2.Status) + } + // Same InputID, different payload: conflict (derived id collides, digest + // differs). + _, err := commitCmd(t, rt, id, 0, "", NextStep(AgentInput{ID: "in-9", Payload: json.RawMessage(`{"t":"y"}`)})) + if err != ErrCommandConflict { + t.Fatalf("err = %v, want ErrCommandConflict", err) + } +} diff --git a/agent/contract.go b/agent/contract.go new file mode 100644 index 0000000..dacd5ec --- /dev/null +++ b/agent/contract.go @@ -0,0 +1,154 @@ +package agent + +import ( + "context" + "encoding/json" + + "github.com/memohai/twilight-ai/sdk" +) + +// RequestPlanner is the port the application injects: it projects application +// context into the next frozen sdk.Request (spec §5.6). Planning +// implementations never live in agent. +type RequestPlanner interface { + Plan(context.Context, PlanningHint) (RequestPlan, error) +} + +type RequestPlan struct { + Model ModelRef + Request sdk.Request + InputIDs []InputID + PlanningToken PlanningToken + Tools []ToolSpec +} + +// ModelCatalog resolves a frozen ModelRef into an invoker at execution time; +// provider binding never enters the frozen request. +type ModelCatalog interface { + Resolve(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 { + Resolve(ToolRef) (ExecutableTool, error) +} + +type ToolExecutionRequest struct { + RunID RunID + StepID StepID + CallID CallID + ToolRef ToolRef + DefinitionDigest Digest + Arguments json.RawMessage + Progress ToolProgressSink +} + +// ExecutableTool is the application-side execution contract (spec §7.1). +type ExecutableTool interface { + Ref() ToolRef + Definition() sdk.ToolDefinition + ResponsePolicy() ResponsePolicy + // ValidateArguments runs before the start barrier and must not produce + // external effects. + ValidateArguments(json.RawMessage) error + Execute(context.Context, ToolExecutionRequest) ToolExecutionOutcome +} + +// ToolExecutionOutcome is sealed: succeeded, failed-known, or unknown. +type ToolExecutionOutcome interface{ toolExecutionOutcome() } + +type ToolExecutionSucceeded struct{ Result ToolExecutionResult } + +func (ToolExecutionSucceeded) toolExecutionOutcome() {} + +// ToolExecutionFailed asserts the external effect did NOT complete. +type ToolExecutionFailed struct{ Failure ToolFailure } + +func (ToolExecutionFailed) toolExecutionOutcome() {} + +// ToolExecutionUnknown means the effect may or may not have happened. +type ToolExecutionUnknown struct{ Failure ToolFailure } + +func (ToolExecutionUnknown) toolExecutionOutcome() {} + +type ToolProgressSink interface { + Publish(context.Context, ToolProgress) +} + +type ToolProgress struct { + Payload json.RawMessage +} + +// --- EventSink: realtime observation, never authority (spec §9.2) --- + +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" + EventToolProgress EventKind = "tool_progress" + EventToolStarted EventKind = "tool_started" + EventToolCompleted EventKind = "tool_completed" + EventRunFinished EventKind = "run_finished" +) + +type Event struct { + RunID RunID + StepID StepID + CallID CallID + Sequence uint64 + Kind EventKind + Durability EventDurability + Payload json.RawMessage + // Canonical is set for a committed observation; nil for provisional. + Canonical *AgentEvent +} + +// ExecutionPolicy bounds this Loop's tool-call parallelism. 1 is sequential; +// n>1 is bounded parallel; 0 normalizes to 1; negative is rejected at Loop +// construction. It limits only workers this Loop launches, never a global +// count (spec §4.3). +type ExecutionPolicy struct { + MaxParallel int +} + +type LoopDisposition uint8 + +const ( + LoopWaiting LoopDisposition = iota + LoopFinished +) + +type WaitReason string + +const ( + WaitingForResponse WaitReason = "waiting_for_response" + ExecutionRecovery WaitReason = "execution_recovery" +) + +type LoopResult struct { + Disposition LoopDisposition + Reason WaitReason + Waiting []ResponseRequest + Result *RunResult +} diff --git a/agent/decide.go b/agent/decide.go new file mode 100644 index 0000000..39fd205 --- /dev/null +++ b/agent/decide.go @@ -0,0 +1,497 @@ +package agent + +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...) +} + +// Decide validates one command against the current state and produces the +// complete fact sequence of its transition (spec §3.7.1). All decisions — +// acceptance, derived consequences, terminal transitions — happen here, run +// exactly once per accepted command; the output is frozen. Any precondition +// failure rejects the whole command with no partial facts. +func Decide(s MachineState, c AgentCommand) ([]Fact, error) { + if s.Status.Terminal() { + return nil, ErrRunTerminal + } + switch cmd := c.(type) { + case PrepareModelRequest: + return decidePrepareModelRequest(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 s.Current != nil { + return nil, rejectionf("prepare: run already has a current step") + } + if cmd.StepID == "" { + return nil, rejectionf("prepare: empty StepID") + } + if cmd.Model != s.Config.Model { + return nil, rejectionf("prepare: model %q does not match frozen RunConfig model %q", cmd.Model, s.Config.Model) + } + if s.Config.ModelStepLimit > 0 && s.ModelSteps >= s.Config.ModelStepLimit { + // Unreachable when limits convert to terminal at the prior boundary, + // but the authority still refuses rather than over-running. + return nil, rejectionf("prepare: model-step limit reached") + } + // 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. + 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.Definition.Name != cmd.Request.Tools[i].Name { + return nil, rejectionf("prepare: ToolSpec[%d] %q does not match request tool %q", i, spec.Definition.Name, cmd.Request.Tools[i].Name) + } + wantDigest, err := DigestToolDefinition(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 := DigestRequest(cmd.Request) + if err != nil { + return nil, err + } + if cmd.RequestDigest != wantReq { + return nil, rejectionf("prepare: request digest mismatch") + } + wantTools, err := DigestToolSpecs(cmd.Tools) + if err != nil { + return nil, err + } + if cmd.ToolsDigest != wantTools { + return nil, rejectionf("prepare: tools digest mismatch") + } + return []Fact{ModelStepPrepared{ + StepID: cmd.StepID, + Model: cmd.Model, + Request: cmd.Request, + RequestDigest: cmd.RequestDigest, + InputIDs: cmd.InputIDs, + Tools: cmd.Tools, + ToolsDigest: cmd.ToolsDigest, + }}, 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") + } + completed := ModelStepCompleted{StepID: cmd.StepID, Result: cmd.Result} + + if len(cmd.Calls) == 0 { + return []Fact{completed, RunEnded{Status: RunCompleted}}, nil + } + + // Validate bindings against the frozen ToolSpecs and the model result, + // then freeze the full call set (Waiting requests included) here so both + // runtimes derive identical ToolStepOpened facts. + 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.Definition.Name] = spec + } + seen := make(map[CallID]bool, len(cmd.Calls)) + bindings := make([]ToolCallBinding, len(cmd.Calls)) + for i, b := range cmd.Calls { + rc := cmd.Result.ToolCalls[i] + if b.CallID == "" { + return nil, rejectionf("model result: binding %d has empty CallID", i) + } + if string(b.CallID) != rc.ToolCallID { + return nil, rejectionf("model result: binding %d CallID %q does not match result call %q", i, b.CallID, rc.ToolCallID) + } + if seen[b.CallID] { + return nil, rejectionf("model result: duplicate CallID %q", b.CallID) + } + seen[b.CallID] = true + + if spec, known := specByName[string(b.ToolRef)]; known { + if b.DefinitionDigest != spec.DefinitionDigest { + return nil, rejectionf("model result: binding %q definition digest does not match frozen ToolSpec", b.CallID) + } + if b.Policy != spec.Policy { + return nil, rejectionf("model result: binding %q policy does not match frozen ToolSpec", b.CallID) + } + } else { + // Unknown ToolRef stays an unresolved DirectExecution binding with + // an empty definition digest; StartToolCalls records the lookup + // failure (spec §4.1). + if b.Policy != DirectExecution || b.DefinitionDigest != "" { + return nil, rejectionf("model result: unresolved binding %q must be DirectExecution with empty digest", b.CallID) + } + } + wantBinding, err := digestToolCallBinding(b.CallID, b.DefinitionDigest, b.Policy, b.Arguments) + if err != nil { + return nil, err + } + if b.BindingDigest != wantBinding { + return nil, rejectionf("model result: binding %q binding digest mismatch", b.CallID) + } + bindings[i] = b + bindings[i].Response = nil // derived below; callers leave it empty + } + + setDigest, err := digestBindingSet(bindings) + if err != nil { + return nil, err + } + toolStepID := DeriveToolStepID(cmd.StepID, setDigest) + for i := range bindings { + if bindings[i].Policy == ApprovalRequired || bindings[i].Policy == ExternalResponse { + kind := ResponseApproval + if bindings[i].Policy == ExternalResponse { + kind = ResponseExternal + } + reqDigest, err := digestToolCallBinding(bindings[i].CallID, bindings[i].DefinitionDigest, bindings[i].Policy, bindings[i].Arguments) + if err != nil { + return nil, err + } + bindings[i].Response = &ResponseRequest{ + RunID: s.RunID, + StepID: toolStepID, + CallID: bindings[i].CallID, + ID: DeriveResponseID(s.RunID, toolStepID, bindings[i].CallID, kind), + Kind: kind, + Payload: bindings[i].Arguments, + RequestDigest: reqDigest, + } + } + } + return []Fact{completed, ToolStepOpened{ + StepID: toolStepID, + Source: cmd.StepID, + Calls: bindings, + }}, nil +} + +// --- 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{ + Status: RunFailed, + 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} + if ms.Rejects+1 > s.Config.ModelRejectLimit { + return []Fact{rejected, RunEnded{ + Status: RunFailed, + Reason: ReasonMalformedModel, + Failure: &RunFailure{Class: FailureMalformedModel, Message: cmd.Failure.Message}, + }}, nil + } + return []Fact{rejected}, nil +} + +// --- 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) + } + facts := []Fact{ToolCallCompleted{StepID: cmd.StepID, CallID: cmd.CallID, Result: cmd.Result}} + return appendCloseIfLast(s, *ts, i, facts) +} + +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] + 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 appendCloseIfLast(s, *ts, i, facts) + 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}, + RunEnded{ + Status: RunFailed, + Reason: ReasonEffectUnknown, + Failure: &RunFailure{Class: FailureEffectUnknown, Message: failure.Message, CallID: cmd.CallID}, + }, + }, nil + default: + return nil, rejectionf("tool failure: unknown outcome value %d", cmd.Outcome) + } +} + +// appendCloseIfLast appends ToolStepClosed when call i reaching a closable +// terminal state closes the step, plus RunEnded when the frozen model-step +// limit was reached (spec §3.7.1 rule 11). +func appendCloseIfLast(s MachineState, ts ToolStep, i int, facts []Fact) ([]Fact, error) { + for j := range ts.Calls { + if j == i { + continue + } + switch ts.Calls[j].Status { + case ToolCompleted, ToolFailed: + // closable terminal + default: + return facts, nil + } + } + facts = append(facts, ToolStepClosed{StepID: ts.RefValue.ID}) + if s.Config.ModelStepLimit > 0 && s.ModelSteps >= s.Config.ModelStepLimit { + facts = append(facts, RunEnded{Status: RunStopped, Reason: ReasonStepLimit}) + } + return facts, nil +} + +// --- rules 9-10: responses --- + +func waitingCall(s MachineState, step StepID, call CallID, kind ResponseKind, resp ResponseID) (*ToolCallState, error) { + ts, err := currentToolStep(s, step) + if err != nil { + return nil, err + } + i := ts.callIndex(call) + if i < 0 { + return nil, rejectionf("response: unknown call %q", call) + } + c := ts.Calls[i] + if c.Status != ToolWaiting || c.Waiting == nil { + return nil, rejectionf("response: call %q is not Waiting", call) + } + if c.Waiting.Kind != kind { + return nil, rejectionf("response: call %q expects kind %q, got %q", call, c.Waiting.Kind, kind) + } + if c.Waiting.ID != resp { + return nil, rejectionf("response: call %q expects ResponseID %q, got %q", call, c.Waiting.ID, resp) + } + return &c, 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 + } + return []Fact{ToolCallApproved{StepID: cmd.StepID, CallID: cmd.CallID, ResponseID: cmd.ResponseID, ResponseDigest: cmd.ResponseDigest}}, nil +} + +func decideRejectToolCall(s MachineState, cmd RejectToolCall) ([]Fact, error) { + if _, err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseApproval, cmd.ResponseID); err != nil { + return nil, err + } + ts, _ := currentToolStep(s, cmd.StepID) + i := ts.callIndex(cmd.CallID) + facts := []Fact{ToolCallFailed{ + StepID: cmd.StepID, + CallID: cmd.CallID, + Failure: ToolFailure{Class: FailurePermissionDenied, Message: cmd.Reason}, + Outcome: ToolOutcomeKnown, + }} + return appendCloseIfLast(s, *ts, i, facts) +} + +func decideSubmitToolResponse(s MachineState, cmd SubmitToolResponse) ([]Fact, error) { + if _, err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseExternal, cmd.ResponseID); err != nil { + return nil, err + } + ts, _ := currentToolStep(s, cmd.StepID) + i := ts.callIndex(cmd.CallID) + facts := []Fact{ToolCallAnswered{ + StepID: cmd.StepID, + CallID: cmd.CallID, + ResponseID: cmd.ResponseID, + ResponseDigest: cmd.ResponseDigest, + Payload: cmd.Payload, + }} + return appendCloseIfLast(s, *ts, i, facts) +} + +// --- rules 12-13: cancel and input --- + +func decideCancelRun(s MachineState, cmd CancelRun) ([]Fact, error) { + reason := cmd.Reason + if reason == "" { + reason = ReasonCancelled + } + return []Fact{RunEnded{Status: RunStopped, Reason: reason}}, nil +} + +func decideAcceptInput(s MachineState, cmd AcceptInput) ([]Fact, error) { + if s.Current != nil { + return nil, rejectionf("accept input: run has a current step") + } + if cmd.Input.ID == "" { + return nil, rejectionf("accept input: empty InputID") + } + return []Fact{InputAccepted{Input: cmd.Input}}, nil +} diff --git a/agent/evolve.go b/agent/evolve.go new file mode 100644 index 0000000..1727870 --- /dev/null +++ b/agent/evolve.go @@ -0,0 +1,210 @@ +package agent + +import "fmt" + +// Evolve folds one fact into the state (spec §3.7.2). It is mechanical: no +// RunConfig reads, no policy branches, total over every fact Decide produces. +// Its folding semantics, together with the canonical encoding, form the +// permanent compatibility contract of a published SchemaVersion; replay +// depends only on Evolve. +func Evolve(s MachineState, f Fact) (MachineState, error) { + switch fact := f.(type) { + case ModelStepPrepared: + binding, err := DigestModelStepBinding(fact.Model, fact.RequestDigest, fact.ToolsDigest) + if err != nil { + return s, err + } + s.Current = ModelStep{ + RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: binding}, + Request: fact.Request, + RequestDigest: fact.RequestDigest, + Model: fact.Model, + Tools: fact.Tools, + ToolsDigest: fact.ToolsDigest, + Status: ModelPrepared, + } + s.ModelSteps++ + s.PendingInputs = removeInputs(s.PendingInputs, fact.InputIDs) + return s, nil + + case ModelStepStarted: + ms, err := evolveModelStep(s, fact.StepID) + if err != nil { + return s, err + } + ms.Status = ModelExecuting + s.Current = *ms + return s, nil + + case ModelStepRecovered: + ms, err := evolveModelStep(s, fact.StepID) + if err != nil { + return s, err + } + ms.Status = ModelPrepared + s.Current = *ms + return s, nil + + case ModelStepRejected: + ms, err := evolveModelStep(s, fact.StepID) + if err != nil { + return s, err + } + ms.Rejects++ + ms.Status = ModelPrepared + s.Current = *ms + s.Usage = addUsage(s.Usage, fact.Usage) + return s, nil + + case ModelStepCompleted: + if _, err := evolveModelStep(s, fact.StepID); err != nil { + return s, err + } + result := fact.Result + s.LastModelResult = &result + s.Usage = addUsage(s.Usage, fact.Result.Usage) + s.Current = nil + return s, nil + + case ToolStepOpened: + if s.Current != nil { + return s, fmt.Errorf("agent: evolve: tool step opened while a step is current") + } + setDigest, err := digestBindingSet(fact.Calls) + if err != nil { + return s, err + } + calls := make([]ToolCallState, len(fact.Calls)) + for i, b := range fact.Calls { + status := ToolPending + var waiting *ResponseRequest + if b.Response != nil { + status = ToolWaiting + w := *b.Response + waiting = &w + } + calls[i] = ToolCallState{ + CallID: b.CallID, + ToolRef: b.ToolRef, + DefinitionDigest: b.DefinitionDigest, + BindingDigest: b.BindingDigest, + Arguments: b.Arguments, + Policy: b.Policy, + Status: status, + Waiting: waiting, + } + } + s.Current = ToolStep{ + RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: setDigest}, + Source: fact.Source, + Calls: calls, + } + return s, nil + + case ToolCallStarted: + return evolveCall(s, fact.StepID, fact.CallID, func(c *ToolCallState) { + c.Status = ToolExecuting + }) + + case ToolCallApproved: + return evolveCall(s, fact.StepID, fact.CallID, func(c *ToolCallState) { + c.Status = ToolPending + c.Waiting = nil + }) + + case ToolCallCompleted: + return evolveCall(s, fact.StepID, fact.CallID, func(c *ToolCallState) { + c.Status = ToolCompleted + r := fact.Result + c.Result = &r + c.Waiting = nil + }) + + case ToolCallAnswered: + return evolveCall(s, fact.StepID, fact.CallID, func(c *ToolCallState) { + c.Status = ToolCompleted + c.Result = &ToolExecutionResult{Output: fact.Payload} + c.Waiting = nil + }) + + case ToolCallFailed: + return evolveCall(s, fact.StepID, fact.CallID, func(c *ToolCallState) { + c.Status = ToolFailed + c.Failure = &ToolCallFailure{Failure: fact.Failure, Outcome: fact.Outcome} + c.Waiting = nil + }) + + case ToolStepClosed: + ts, ok := s.Current.(ToolStep) + if !ok || ts.RefValue.ID != fact.StepID { + return s, fmt.Errorf("agent: evolve: tool step %q is not current", fact.StepID) + } + s.Current = nil + return s, nil + + case InputAccepted: + for _, in := range s.PendingInputs { + if in.ID == fact.Input.ID { + return s, nil // idempotent append per InputID + } + } + s.PendingInputs = append(append([]AgentInput(nil), s.PendingInputs...), fact.Input) + return s, nil + + case RunEnded: + s.Status = fact.Status + s.Current = nil + s.Result = &RunResult{ + Status: fact.Status, + Reason: fact.Reason, + Failure: fact.Failure, + Model: s.LastModelResult, + Usage: s.Usage, + } + return s, nil + + default: + return s, fmt.Errorf("agent: evolve: unknown fact variant %T", f) + } +} + +func evolveModelStep(s MachineState, step StepID) (*ModelStep, error) { + ms, ok := s.Current.(ModelStep) + if !ok || ms.RefValue.ID != step { + return nil, fmt.Errorf("agent: evolve: model step %q is not current", step) + } + return &ms, nil +} + +func evolveCall(s MachineState, step StepID, call CallID, apply func(*ToolCallState)) (MachineState, error) { + ts, ok := s.Current.(ToolStep) + if !ok || ts.RefValue.ID != step { + return s, fmt.Errorf("agent: evolve: tool step %q is not current", step) + } + i := ts.callIndex(call) + if i < 0 { + return s, fmt.Errorf("agent: evolve: unknown call %q", call) + } + calls := append([]ToolCallState(nil), ts.Calls...) + apply(&calls[i]) + ts.Calls = calls + s.Current = ts + return s, nil +} + +func removeInputs(inputs []AgentInput, ids []InputID) []AgentInput { + if len(ids) == 0 { + return inputs + } + drop := make(map[InputID]bool, len(ids)) + for _, id := range ids { + drop[id] = true + } + var kept []AgentInput + for _, in := range inputs { + if !drop[in.ID] { + kept = append(kept, in) + } + } + return kept +} diff --git a/agent/fact.go b/agent/fact.go new file mode 100644 index 0000000..01fe3f2 --- /dev/null +++ b/agent/fact.go @@ -0,0 +1,176 @@ +package agent + +import ( + "encoding/json" + + "github.com/memohai/twilight-ai/sdk" +) + +// Fact is one committed outcome produced by Machine.Decide. Facts are wrapped +// as AgentEvents; Machine.Evolve folds them mechanically (spec §3.6). The +// interface is sealed: only the fourteen variants below exist. +type Fact interface{ fact() } + +// ModelStepPrepared establishes the frozen ModelStep and consumes the listed +// pending inputs. +type ModelStepPrepared struct { + StepID StepID `json:"stepId"` + Model ModelRef `json:"model"` + Request sdk.Request `json:"request"` + RequestDigest Digest `json:"requestDigest"` + InputIDs []InputID `json:"inputIds,omitempty"` + Tools []ToolSpec `json:"tools,omitempty"` + ToolsDigest Digest `json:"toolsDigest"` +} + +func (ModelStepPrepared) 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 sdk.Usage `json:"usage"` + Failure StepFailure `json:"failure"` +} + +func (ModelStepRejected) fact() {} + +// ModelStepCompleted accepts one model result: usage is accumulated, +// LastModelResult is written, the current step is cleared. +type ModelStepCompleted struct { + StepID StepID `json:"stepId"` + Result sdk.ModelResult `json:"result"` +} + +func (ModelStepCompleted) fact() {} + +// ToolStepOpened establishes the ToolStep with its full frozen call set. +type ToolStepOpened struct { + StepID StepID `json:"stepId"` // the new ToolStep + Source StepID `json:"source"` // the completed ModelStep + Calls []ToolCallBinding `json:"calls"` +} + +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. +type ToolCallCompleted struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + Result ToolExecutionResult `json:"result"` +} + +func (ToolCallCompleted) fact() {} + +// ToolCallAnswered: Waiting(ExternalResponse) -> Completed with the answer. +type ToolCallAnswered struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + ResponseID ResponseID `json:"responseId"` + ResponseDigest Digest `json:"responseDigest"` + Payload json.RawMessage `json:"payload"` +} + +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() {} + +// ToolStepClosed: every call reached a closable terminal state; the current +// step is cleared. +type ToolStepClosed struct { + StepID StepID `json:"stepId"` +} + +func (ToolStepClosed) fact() {} + +// InputAccepted appends one input to PendingInputs. +type InputAccepted struct { + Input AgentInput `json:"input"` +} + +func (InputAccepted) fact() {} + +// RunEnded is the terminal fact. Always the last fact of its transition. +type RunEnded struct { + Status RunStatus `json:"status"` // RunCompleted, RunStopped or RunFailed + Reason RunReason `json:"reason,omitempty"` + Failure *RunFailure `json:"failure,omitempty"` +} + +func (RunEnded) fact() {} + +// factType returns the wire discriminator for a sealed fact variant. +func factType(f Fact) string { + switch f.(type) { + case ModelStepPrepared: + return "model_step_prepared" + 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 ToolStepClosed: + return "tool_step_closed" + case InputAccepted: + return "input_accepted" + case RunEnded: + return "run_ended" + default: + return "" + } +} diff --git a/agent/ids.go b/agent/ids.go new file mode 100644 index 0000000..8abd55c --- /dev/null +++ b/agent/ids.go @@ -0,0 +1,91 @@ +package agent + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" +) + +type RunID 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 agent canonical bytes (spec §5.5). +type Digest string + +// PlanningToken is opaque to agent; the application uses it to identify the +// context revision from which a RequestPlan was built. +type PlanningToken string + +// ExecutionGrant is an opaque capability minted by the Runtime for one +// accepted start command. Callers only pass it back; its representation is +// implementation-defined. +type ExecutionGrant string + +func sha256Digest(data []byte) Digest { + sum := sha256.Sum256(data) + return Digest("sha256:" + hex.EncodeToString(sum[:])) +} + +// 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 Revision the planner loaded (spec §5.6): concurrent +// planners on the same Revision converge on one command identity. +func DeriveModelRequestCommandID(run RunID, revision uint64) CommandID { + return CommandID(namespacedHash("twilight/model-request", string(run), fmt.Sprintf("%d", revision))) +} + +// 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))) +} + +// 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))) +} + +// DeriveSystemCommandID derives the CommandID a recovery scanner uses for a +// system-issued command about one invalidated execution. The recovery record +// reference must be stable per record so replays reuse the same identity. +func DeriveSystemCommandID(run RunID, step StepID, call CallID, recoveryRecord string) CommandID { + return CommandID(namespacedHash("twilight/system-command", string(run), string(step), string(call), recoveryRecord)) +} diff --git a/agent/loop.go b/agent/loop.go new file mode 100644 index 0000000..971a498 --- /dev/null +++ b/agent/loop.go @@ -0,0 +1,486 @@ +package agent + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "sync" + + "github.com/memohai/twilight-ai/sdk" +) + +// Loop is the in-process interpreter of one Run (spec §6). It holds no +// authoritative state; every iteration starts from Runtime.Load. +type Loop struct { + Models ModelCatalog + Tools ToolCatalog + Planner RequestPlanner + Execution ExecutionPolicy + Streaming bool +} + +// NewLoop validates and normalizes the execution policy (spec §4.3). +func NewLoop(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy ExecutionPolicy, streaming bool) (*Loop, error) { + if policy.MaxParallel < 0 { + return nil, errors.New("agent: loop: negative MaxParallel") + } + if policy.MaxParallel == 0 { + policy.MaxParallel = 1 + } + return &Loop{Models: models, Tools: tools, Planner: planner, Execution: policy, Streaming: streaming}, nil +} + +// Run drives the Run until it finishes, must wait, or the context is +// cancelled (spec §6.2). controlCtx for reads/commits is derived from ctx via +// WithoutCancel so worker cancellation never blocks result submission. +func (l *Loop) Run(ctx context.Context, runtime Runtime, events EventSink) (LoopResult, error) { + controlCtx := context.WithoutCancel(ctx) + + 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(controlCtx) + if err != nil { + return LoopResult{}, err + } + if snapshot.State.Status.Terminal() { + l.emitCommitted(controlCtx, events, snapshot.State.RunID, nil) + return LoopResult{Disposition: LoopFinished, Result: snapshot.State.Result}, nil + } + + effect, err := Next(snapshot.State) + if err != nil { + return LoopResult{}, err + } + + switch eff := effect.(type) { + case NeedModelRequest: + if err := l.planAndPrepare(ctx, controlCtx, runtime, snapshot, eff.Hint); err != nil { + return LoopResult{}, err + } + case StartModelCall: + if err := l.runModelStep(ctx, controlCtx, runtime, events, snapshot, eff.StepID); err != nil { + return LoopResult{}, err + } + case StartToolCalls: + if err := l.runToolCalls(ctx, controlCtx, runtime, events, snapshot, eff); err != nil { + return LoopResult{}, err + } + case WaitForResponse: + return LoopResult{Disposition: LoopWaiting, Reason: WaitingForResponse, Waiting: eff.Requests}, nil + case WaitForExecutionRecovery: + return LoopResult{Disposition: LoopWaiting, Reason: ExecutionRecovery}, nil + default: + return LoopResult{}, fmt.Errorf("agent: loop: unknown effect %T", effect) + } + } +} + +// commit builds the envelope via the sanctioned constructor and submits it. +func (l *Loop) commit(ctx context.Context, runtime Runtime, run RunID, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) (CommitResult, error) { + env, err := BuildEnvelope(run, id, cmd) + if err != nil { + return CommitResult{}, err + } + return runtime.Commit(ctx, CommitRequest{BaseRevision: base, Grant: grant, Command: env}) +} + +// retriable reports the commit errors that mean "reload and rederive". +func retriable(err error) bool { + return errors.Is(err, ErrStaleRuntime) || errors.Is(err, ErrRunTerminal) || errors.Is(err, ErrCommandConflict) +} + +func freshCommandID() CommandID { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("agent: loop: %v", err)) + } + return CommandID(hex.EncodeToString(b[:])) +} + +func (l *Loop) emitCommitted(ctx context.Context, events EventSink, run RunID, committed []AgentEvent) { + if events == nil { + return + } + for i := range committed { + e := committed[i] + _ = events.Emit(ctx, Event{ + RunID: run, + Kind: EventAgentCommitted, + Durability: EventCommitted, + Canonical: &e, + }) + } +} + +// --- NeedModelRequest --- + +func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, snapshot RuntimeSnapshot, hint PlanningHint) error { + plan, err := l.Planner.Plan(ctx, hint) + if err != nil { + return err + } + if plan.Model != hint.Model { + return fmt.Errorf("agent: loop: plan model %q does not match hint model %q", plan.Model, hint.Model) + } + requestDigest, err := DigestRequest(plan.Request) + if err != nil { + return err + } + toolsDigest, err := DigestToolSpecs(plan.Tools) + if err != nil { + return err + } + binding, err := DigestModelStepBinding(plan.Model, requestDigest, toolsDigest) + if err != nil { + return err + } + cmdID := DeriveModelRequestCommandID(snapshot.State.RunID, snapshot.Revision) + stepID := DeriveModelStepID(snapshot.State.RunID, cmdID, binding) + _, err = l.commit(controlCtx, runtime, snapshot.State.RunID, cmdID, snapshot.Revision, "", PrepareModelRequest{ + StepID: stepID, + Model: plan.Model, + Request: plan.Request, + RequestDigest: requestDigest, + InputIDs: plan.InputIDs, + PlanningToken: plan.PlanningToken, + Tools: plan.Tools, + ToolsDigest: toolsDigest, + }) + if err != nil && !retriable(err) { + return err + } + return nil // reload decides the next action +} + +// --- StartModelCall --- + +func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime Runtime, events EventSink, snapshot RuntimeSnapshot, stepID StepID) error { + run := snapshot.State.RunID + start, err := l.commit(controlCtx, runtime, run, freshCommandID(), snapshot.Revision, "", StartModelExecution{StepID: stepID}) + if err != nil { + if retriable(err) { + return nil + } + return err + } + if start.Status == CommitAlreadyApplied { + return nil // another attempt owns it; reload + } + l.emitCommitted(controlCtx, events, run, start.Events) + + modelStep, ok := start.Snapshot.State.Current.(ModelStep) + if !ok || modelStep.RefValue.ID != stepID { + return fmt.Errorf("agent: loop: started step %q is not current", stepID) + } + + var completion AgentCommand + invoker, resolveErr := l.Models.Resolve(modelStep.Model) + if resolveErr != nil { + completion = SubmitModelFailure{StepID: stepID, Failure: StepFailure{Class: FailureProvider, Message: resolveErr.Error()}} + } else { + // Model workers derive from the outer ctx: cancelling a model call is + // safe, the frozen request retries after recovery (spec §6.1). + result, invokeErr := l.invokeModel(ctx, invoker, modelStep.Request, run, stepID, events) + switch { + case invokeErr != nil && ctx.Err() != nil: + completion = RecoverModelExecution{StepID: stepID} + case invokeErr != nil: + completion = SubmitModelFailure{StepID: stepID, Failure: StepFailure{Class: FailureProvider, Message: invokeErr.Error()}} + default: + bindings, bindErr := l.bindToolCalls(result, modelStep) + if bindErr != nil { + completion = RejectModelResult{StepID: stepID, Usage: result.Usage, + Failure: StepFailure{Class: FailureMalformedModel, Message: bindErr.Error()}} + } else { + completion = SubmitModelResult{StepID: stepID, Result: result, Calls: bindings} + } + } + } + + res, err := l.commit(controlCtx, runtime, run, freshCommandID(), start.Snapshot.Revision, start.Grant, completion) + if err != nil { + if retriable(err) { + return nil + } + return err + } + l.emitCommitted(controlCtx, events, run, res.Events) + return nil +} + +func (l *Loop) invokeModel(ctx context.Context, invoker ModelInvoker, req sdk.Request, run RunID, step 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 + } + for part := range stream.Parts { + if events == nil { + continue + } + if delta, ok := part.(*sdk.TextDeltaPart); ok { + _ = events.Emit(ctx, Event{ + RunID: run, StepID: step, + Kind: EventModelTextDelta, Durability: EventProvisional, + Payload: mustJSON(delta.Text), + }) + } + } + result, err := stream.Result() + if err != nil { + return sdk.ModelResult{}, err + } + return *result, nil + } + } + return invoker.Generate(ctx, req) +} + +// bindToolCalls validates tool-call IDs/order/shape and produces bindings +// from the frozen ToolSpecs (spec §4.1). It never calls ExecutableTool. +func (l *Loop) bindToolCalls(result sdk.ModelResult, step ModelStep) ([]ToolCallBinding, error) { + if len(result.ToolCalls) == 0 { + return nil, nil + } + specByName := make(map[string]ToolSpec, len(step.Tools)) + for _, s := range step.Tools { + specByName[s.Definition.Name] = s + } + seen := make(map[string]bool, len(result.ToolCalls)) + bindings := make([]ToolCallBinding, len(result.ToolCalls)) + for i, tc := range result.ToolCalls { + if tc.ToolCallID == "" { + return nil, fmt.Errorf("tool call %d has an empty id", i) + } + if seen[tc.ToolCallID] { + return nil, fmt.Errorf("duplicate tool call id %q", tc.ToolCallID) + } + seen[tc.ToolCallID] = true + args, err := canonicalToolArguments(tc.Input) + if err != nil { + // A single call with unparsable arguments is NOT structurally + // malformed: bind it raw and let StartToolCalls close it as a + // known invalid_arguments failure (spec §4.1). + args = rawToolArguments(tc.Input) + } + b := ToolCallBinding{ + CallID: CallID(tc.ToolCallID), + ToolRef: ToolRef(tc.ToolName), + Arguments: args, + Policy: DirectExecution, + } + if spec, known := specByName[tc.ToolName]; known { + b.DefinitionDigest = spec.DefinitionDigest + b.Policy = spec.Policy + } + bd, err := digestToolCallBinding(b.CallID, b.DefinitionDigest, b.Policy, b.Arguments) + if err != nil { + return nil, err + } + b.BindingDigest = bd + bindings[i] = b + } + return bindings, nil +} + +// --- StartToolCalls --- + +type startedWorker struct { + call ToolCallState + grant ExecutionGrant + base uint64 + tool ExecutableTool +} + +func (l *Loop) runToolCalls(ctx, controlCtx context.Context, runtime Runtime, events EventSink, snapshot RuntimeSnapshot, eff StartToolCalls) error { + run := snapshot.State.RunID + ts, ok := snapshot.State.Current.(ToolStep) + if !ok || ts.RefValue.ID != eff.StepID { + return fmt.Errorf("agent: loop: tool step %q is not current", eff.StepID) + } + + limit := l.Execution.MaxParallel + if limit <= 0 { + limit = 1 + } + 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 := ts.callIndex(callID) + if i < 0 { + continue + } + call := ts.Calls[i] + + tool, resolveErr := l.Tools.Resolve(call.ToolRef) + var known *ToolFailure + switch { + case resolveErr != nil: + known = &ToolFailure{Class: FailureToolLookup, Message: resolveErr.Error()} + default: + defDigest, err := DigestToolDefinition(tool.Definition()) + if err != nil { + return err + } + switch { + case tool.Ref() != call.ToolRef || defDigest != call.DefinitionDigest: + known = &ToolFailure{Class: FailureDefinitionMismatch, Message: "tool definition digest mismatch"} + case tool.ResponsePolicy() != call.Policy: + known = &ToolFailure{Class: FailureDefinitionMismatch, Message: "response policy mismatch"} + default: + if argErr := tool.ValidateArguments(call.Arguments); argErr != nil { + known = &ToolFailure{Class: FailureInvalidArguments, Message: argErr.Error()} + } + } + } + if known != nil { + // Known failure of a Pending call: no start barrier, no tool call. + res, err := l.commit(controlCtx, runtime, run, freshCommandID(), snapshot.Revision, "", + SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: *known, Outcome: ToolOutcomeKnown}) + if err != nil && !retriable(err) { + l.settleWorkers(ctx, controlCtx, runtime, events, run, eff.StepID, started) + return err + } + if err != nil { + l.settleWorkers(ctx, controlCtx, runtime, events, run, eff.StepID, started) + return nil + } + l.emitCommitted(controlCtx, events, run, res.Events) + continue + } + + start, err := l.commit(controlCtx, runtime, run, freshCommandID(), snapshot.Revision, "", + StartToolCall{StepID: eff.StepID, CallID: callID}) + if err != nil { + l.settleWorkers(ctx, controlCtx, runtime, events, run, eff.StepID, started) + if retriable(err) { + return nil + } + return err + } + if start.Status == CommitAlreadyApplied { + continue // another attempt owns this call + } + l.emitCommitted(controlCtx, events, run, start.Events) + if events != nil { + _ = events.Emit(controlCtx, Event{RunID: run, StepID: eff.StepID, CallID: callID, + Kind: EventToolStarted, Durability: EventCommitted}) + } + started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Revision, tool: tool}) + } + + l.settleWorkers(ctx, controlCtx, runtime, events, run, eff.StepID, started) + return nil +} + +// settleWorkers executes every started worker and commits its outcome. An +// accepted start is never abandoned (spec §6.2). Tool workers do not inherit +// outer-ctx cancellation (spec §6.1); one Unknown cancels the rest. +func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime Runtime, events EventSink, run RunID, stepID StepID, started []startedWorker) { + if len(started) == 0 { + return + } + execCtx, cancelAll := context.WithCancel(context.WithoutCancel(ctx)) + defer cancelAll() + + var mu sync.Mutex + var wg sync.WaitGroup + for _, w := range started { + wg.Add(1) + go func(w startedWorker) { + defer wg.Done() + outcome := w.tool.Execute(execCtx, ToolExecutionRequest{ + RunID: run, + StepID: stepID, + CallID: w.call.CallID, + ToolRef: w.call.ToolRef, + DefinitionDigest: w.call.DefinitionDigest, + Arguments: w.call.Arguments, + Progress: &progressSink{events: events, run: run, step: stepID, call: w.call.CallID}, + }) + + var cmd AgentCommand + unknown := false + switch o := outcome.(type) { + case ToolExecutionSucceeded: + cmd = SubmitToolResult{StepID: stepID, CallID: w.call.CallID, Result: o.Result} + case ToolExecutionFailed: + cmd = SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, Failure: o.Failure, Outcome: ToolOutcomeKnown} + case ToolExecutionUnknown: + failure := o.Failure + if failure.Class == "" { + failure.Class = FailureEffectUnknown + } + cmd = SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, Failure: failure, Outcome: ToolOutcomeUnknown} + unknown = true + default: + cmd = SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, + Failure: ToolFailure{Class: FailureEffectUnknown, Message: "tool returned no outcome"}, Outcome: ToolOutcomeUnknown} + unknown = true + } + + mu.Lock() + defer mu.Unlock() + // Commit with the worker's own grant on its start base; stale + // bases rebase call-locally. Late results after terminal return + // ErrRunTerminal and are dropped (audit is the adapter's job). + res, err := l.commit(controlCtx, runtime, run, freshCommandID(), w.base, w.grant, cmd) + if err == nil { + l.emitCommitted(controlCtx, events, run, res.Events) + if events != nil { + _ = events.Emit(controlCtx, Event{RunID: run, StepID: stepID, CallID: w.call.CallID, + Kind: EventToolCompleted, Durability: EventCommitted}) + } + } + if unknown { + cancelAll() // one Unknown cancels sibling workers (spec §6.2) + } + }(w) + } + wg.Wait() +} + +type progressSink struct { + events EventSink + run RunID + step StepID + call 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 := jsonMarshal(v) + if err != nil { + return []byte("null") + } + return b +} diff --git a/agent/loop_test.go b/agent/loop_test.go new file mode 100644 index 0000000..6e2d338 --- /dev/null +++ b/agent/loop_test.go @@ -0,0 +1,426 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync/atomic" + "testing" + + "github.com/memohai/twilight-ai/sdk" +) + +// --- fakes --- + +type fakeInvoker struct { + results []sdk.ModelResult + errs []error + 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.errs) && f.errs[n] != nil { + return sdk.ModelResult{}, f.errs[n] + } + if n >= len(f.results) { + return sdk.ModelResult{}, errors.New("fake: no scripted result") + } + return f.results[n], nil +} + +type fakeCatalog struct{ invoker ModelInvoker } + +func (c fakeCatalog) Resolve(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(json.RawMessage) 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) Resolve(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 { + specs []ToolSpec +} + +func (p staticPlanner) Plan(_ context.Context, hint PlanningHint) (RequestPlan, error) { + req := sdk.Request{Model: string(hint.Model), Messages: []sdk.Message{sdk.UserMessage("go")}} + for _, s := range p.specs { + req.Tools = append(req.Tools, s.Definition) + } + ids := make([]InputID, len(hint.Inputs)) + for i, in := range hint.Inputs { + ids[i] = in.ID + } + return RequestPlan{Model: hint.Model, Request: req, InputIDs: ids, Tools: p.specs}, nil +} + +func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { + t.Helper() + def := sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} + d, err := DigestToolDefinition(def) + if err != nil { + t.Fatal(err) + } + return ToolSpec{Ref: ToolRef(name), Definition: def, DefinitionDigest: d, Policy: policy} +} + +func loopRuntime(t *testing.T) *MemoryRuntime { + t.Helper() + s, err := Initialize("run-1", RunConfig{Model: "m-1"}, NextRun(AgentInput{ID: "seed", Payload: json.RawMessage(`{"q":"hi"}`)})) + if err != nil { + t.Fatal(err) + } + return NewMemoryRuntime(s) +} + +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 TestLoopSingleModelCallCompletes(t *testing.T) { + rt := loopRuntime(t) + loop, err := NewLoop(fakeCatalog{&fakeInvoker{results: []sdk.ModelResult{textResult("hello")}}}, + fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + res, err := loop.Run(context.Background(), rt, nil) + if err != nil { + t.Fatal(err) + } + if res.Disposition != LoopFinished || res.Result == nil || res.Result.Status != RunCompleted { + t.Fatalf("res = %+v", res) + } + if res.Result.Model.Text != "hello" { + t.Fatalf("text = %q", res.Result.Model.Text) + } +} + +func TestLoopToolRoundTrip(t *testing.T) { + spec := toolSpec(t, "echo", DirectExecution) + echo := &fakeTool{ref: "echo", def: spec.Definition, policy: DirectExecution, + execute: func(_ context.Context, req ToolExecutionRequest) ToolExecutionOutcome { + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: req.Arguments}} + }} + invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("done")}} + rt := loopRuntime(t) + loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) + + res, err := loop.Run(context.Background(), rt, nil) + if err != nil { + t.Fatal(err) + } + if res.Disposition != LoopFinished || res.Result.Status != RunCompleted || res.Result.Model.Text != "done" { + t.Fatalf("res = %+v", res) + } + if invoker.calls.Load() != 2 { + t.Fatalf("model calls = %d, want 2", invoker.calls.Load()) + } + // Usage accumulated across both model steps. + if res.Result.Usage.TotalTokens != 3 { + t.Fatalf("usage = %d, want 3", res.Result.Usage.TotalTokens) + } +} + +func TestLoopApprovalWaitsAndResumes(t *testing.T) { + spec := toolSpec(t, "echo", ApprovalRequired) + executed := atomic.Bool{} + echo := &fakeTool{ref: "echo", def: spec.Definition, policy: ApprovalRequired, + execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { + executed.Store(true) + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}} + }} + invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("after")}} + rt := loopRuntime(t) + loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) + + // First run: reaches Waiting(Approval) and returns. + res, err := loop.Run(context.Background(), rt, nil) + if err != nil { + t.Fatal(err) + } + if res.Disposition != LoopWaiting || res.Reason != WaitingForResponse || len(res.Waiting) != 1 { + t.Fatalf("res = %+v", res) + } + if executed.Load() { + t.Fatal("tool executed before approval") + } + wait := res.Waiting[0] + if wait.Kind != ResponseApproval { + t.Fatalf("kind = %v", wait.Kind) + } + + // Ingress approves via the derived response command id (spec §5.7). + cmdID := DeriveResponseCommandID(wait.RunID, wait.StepID, wait.CallID, wait.ID) + env, err := BuildEnvelope(wait.RunID, cmdID, ApproveToolCall{ + StepID: wait.StepID, CallID: wait.CallID, ResponseID: wait.ID, + }) + if err != nil { + t.Fatal(err) + } + snap, _ := rt.Load(context.Background()) + if _, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { + t.Fatal(err) + } + + // Wake: a new Loop run executes the tool and finishes. + res, err = loop.Run(context.Background(), rt, nil) + if err != nil { + t.Fatal(err) + } + if res.Disposition != LoopFinished || res.Result.Model.Text != "after" { + t.Fatalf("res = %+v", res) + } + if !executed.Load() { + t.Fatal("approved tool never executed") + } +} + +func TestLoopUnknownOutcomeFailsRun(t *testing.T) { + spec := toolSpec(t, "echo", DirectExecution) + echo := &fakeTool{ref: "echo", def: spec.Definition, policy: DirectExecution, + execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { + return ToolExecutionUnknown{Failure: ToolFailure{Class: FailureEffectUnknown, Message: "lost"}} + }} + invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1")}} + rt := loopRuntime(t) + loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) + + res, err := loop.Run(context.Background(), rt, nil) + if err != nil { + t.Fatal(err) + } + if res.Disposition != LoopFinished || res.Result.Status != RunFailed || res.Result.Reason != ReasonEffectUnknown { + t.Fatalf("res = %+v", res) + } + if res.Result.Failure == nil || res.Result.Failure.CallID != "c1" { + t.Fatalf("failure = %+v", res.Result.Failure) + } +} + +func TestLoopKnownToolFailureContinues(t *testing.T) { + spec := toolSpec(t, "echo", DirectExecution) + echo := &fakeTool{ref: "echo", def: spec.Definition, policy: DirectExecution, + execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { + return ToolExecutionFailed{Failure: ToolFailure{Class: FailureExecution, Message: "boom"}} + }} + invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("recovered")}} + rt := loopRuntime(t) + loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) + + res, err := loop.Run(context.Background(), rt, nil) + if err != nil { + t.Fatal(err) + } + // Known failure feeds the next model request; run completes. + if res.Result.Status != RunCompleted || res.Result.Model.Text != "recovered" { + t.Fatalf("res = %+v", res) + } +} + +func TestLoopUnknownToolRefClosesAsLookupFailure(t *testing.T) { + // Model calls a tool that is not in the catalog (and not in specs). + invoker := &fakeInvoker{results: []sdk.ModelResult{ + func() sdk.ModelResult { + r := sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls} + r.ToolCalls = []sdk.ToolCall{{ToolCallID: "c1", ToolName: "ghost", Input: `{}`}} + return r + }(), + textResult("moved on"), + }} + rt := loopRuntime(t) + loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{tools: map[ToolRef]ExecutableTool{}}, + staticPlanner{}, ExecutionPolicy{}, false) + + res, err := loop.Run(context.Background(), rt, nil) + if err != nil { + t.Fatal(err) + } + if res.Result.Status != RunCompleted || res.Result.Model.Text != "moved on" { + t.Fatalf("res = %+v", res) + } + // The failed call must be recorded as tool_lookup_failed in the log. + found := false + for _, e := range rt.Events() { + if f, ok := e.Fact.(ToolCallFailed); ok && f.Failure.Class == FailureToolLookup { + found = true + } + } + if !found { + t.Fatal("no tool_lookup_failed fact in the event log") + } +} + +func TestLoopParallelBounded(t *testing.T) { + spec := toolSpec(t, "echo", DirectExecution) + var concurrent, peak atomic.Int32 + echo := &fakeTool{ref: "echo", def: spec.Definition, policy: DirectExecution, + execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { + cur := concurrent.Add(1) + for { + p := peak.Load() + if cur <= p || peak.CompareAndSwap(p, cur) { + break + } + } + defer concurrent.Add(-1) + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}} + }} + invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1", "c2", "c3"), textResult("done")}} + rt := loopRuntime(t) + loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{MaxParallel: 2}, false) + + res, err := loop.Run(context.Background(), rt, nil) + if err != nil { + t.Fatal(err) + } + if res.Result.Status != RunCompleted { + t.Fatalf("res = %+v", res) + } + if peak.Load() > 2 { + t.Fatalf("peak concurrency %d exceeded MaxParallel 2", peak.Load()) + } +} + +func TestLoopCtxCancelReturnsWithoutFailingRun(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + invoker := &fakeInvoker{errs: []error{context.Canceled}} + rt := loopRuntime(t) + loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + + cancel() // cancelled before the model call + _, err := loop.Run(ctx, rt, nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } + // Run must still be active (recovery released it), never failed. + snap, _ := rt.Load(context.Background()) + if snap.State.Status != RunActive { + t.Fatalf("status = %v, want RunActive", snap.State.Status) + } + // A fresh Loop with a working invoker resumes the same frozen request. + invoker2 := &fakeInvoker{results: []sdk.ModelResult{textResult("resumed")}} + loop2, _ := NewLoop(fakeCatalog{invoker2}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + res, err := loop2.Run(context.Background(), rt, nil) + if err != nil { + t.Fatal(err) + } + if res.Result == nil || res.Result.Model.Text != "resumed" { + t.Fatalf("res = %+v", res) + } +} + +func TestLoopCancelRunViaCommand(t *testing.T) { + // Host order: commit CancelRun first, then cancel ctx (spec §6.6). + rt := loopRuntime(t) + snap, _ := rt.Load(context.Background()) + env, _ := BuildEnvelope("run-1", "cancel-1", CancelRun{}) + if _, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { + t.Fatal(err) + } + loop, _ := NewLoop(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + res, err := loop.Run(context.Background(), rt, nil) + if err != nil { + t.Fatal(err) + } + if res.Result.Status != RunStopped || res.Result.Reason != ReasonCancelled { + t.Fatalf("res = %+v", res) + } +} + +// 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, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + + _, err := loop.Run(ctx, rt, 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()) + 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 rt.Events() { + if _, ok := e.Fact.(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, _ := NewLoop(fakeCatalog{invoker2}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + res, err := loop2.Run(context.Background(), rt, nil) + if err != nil { + t.Fatal(err) + } + if res.Result.Model.Text != "resumed" { + t.Fatalf("res = %+v", res) + } + final, _ := rt.Load(context.Background()) + if final.State.ModelSteps != 1 { + t.Fatalf("ModelSteps = %d after resume, want 1 (same frozen step)", final.State.ModelSteps) + } +} diff --git a/agent/machine_test.go b/agent/machine_test.go new file mode 100644 index 0000000..002e0c7 --- /dev/null +++ b/agent/machine_test.go @@ -0,0 +1,552 @@ +package agent + +import ( + "encoding/json" + "testing" + + "github.com/memohai/twilight-ai/sdk" +) + +// --- helpers --- + +func testConfig() RunConfig { + return RunConfig{Model: "m-1", ModelRejectLimit: DefaultModelRejectLimit} +} + +func newRun(t *testing.T, cfg RunConfig) MachineState { + t.Helper() + s, err := Initialize("run-1", cfg, NextRun(AgentInput{ID: "seed", Payload: json.RawMessage(`{"q":"hi"}`)})) + if err != nil { + t.Fatal(err) + } + return s +} + +func mustDecide(t *testing.T, s MachineState, c AgentCommand) []Fact { + t.Helper() + facts, err := Decide(s, c) + if err != nil { + t.Fatalf("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 = Evolve(s, f) + if err != nil { + t.Fatalf("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() + reqDigest, err := DigestRequest(req) + if err != nil { + t.Fatal(err) + } + toolsDigest, err := DigestToolSpecs(specs) + if err != nil { + t.Fatal(err) + } + binding, err := DigestModelStepBinding(s.Config.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: s.Config.Model, + Request: req, + RequestDigest: reqDigest, + InputIDs: ids, + Tools: specs, + ToolsDigest: toolsDigest, + }, cmdID +} + +func makeSpec(t *testing.T, def sdk.ToolDefinition, policy ResponsePolicy) ToolSpec { + t.Helper() + d, err := DigestToolDefinition(def) + if err != nil { + t.Fatal(err) + } + return ToolSpec{Ref: ToolRef(def.Name), Definition: def, DefinitionDigest: d, Policy: policy} +} + +func makeBinding(t *testing.T, callID string, spec ToolSpec, args string) ToolCallBinding { + t.Helper() + bd, err := digestToolCallBinding(CallID(callID), spec.DefinitionDigest, spec.Policy, []byte(args)) + if err != nil { + t.Fatal(err) + } + return ToolCallBinding{ + CallID: CallID(callID), + ToolRef: spec.Ref, + DefinitionDigest: spec.DefinitionDigest, + BindingDigest: bd, + Arguments: json.RawMessage(args), + Policy: spec.Policy, + } +} + +func modelResultWithCalls(callIDs ...string) sdk.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: "t"}) + } + return r +} + +// 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 TestInitializeNormalizesRejectLimit(t *testing.T) { + s, err := Initialize("r", RunConfig{Model: "m"}, NextRun(AgentInput{ID: "i"})) + if err != nil { + t.Fatal(err) + } + if s.Config.ModelRejectLimit != DefaultModelRejectLimit { + t.Fatalf("reject limit = %d, want %d", s.Config.ModelRejectLimit, DefaultModelRejectLimit) + } + if _, err := Initialize("r", RunConfig{Model: "m", ModelStepLimit: -1}, NextRun(AgentInput{ID: "i"})); err == nil { + t.Fatal("negative step limit accepted") + } +} + +func TestNextOnFreshRunNeedsModelRequest(t *testing.T) { + s := newRun(t, testConfig()) + 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, testConfig()) + 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, testConfig()) + prep, _ := buildPrepare(t, s, testRequest(), nil) + prep.InputIDs = nil + if _, err := Decide(s, prep); err == nil { + t.Fatal("prepare with missing InputIDs accepted") + } +} + +func TestModelCompleteNoToolsEndsRun(t *testing.T) { + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(), nil) + result := sdk.ModelResult{Text: "done", FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 7}} + facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: result}) + if len(facts) != 2 { + t.Fatalf("facts = %d, want [completed, ended]", len(facts)) + } + if _, ok := facts[1].(RunEnded); !ok { + t.Fatalf("facts[1] = %T", facts[1]) + } + s = fold(t, s, facts) + if s.Status != RunCompleted { + t.Fatalf("status = %v", s.Status) + } + if s.Result == nil || s.Result.Model == nil || s.Result.Model.Text != "done" { + t.Fatalf("result = %+v", s.Result) + } + if s.Result.Usage.TotalTokens != 7 { + t.Fatalf("usage not accumulated into result: %+v", s.Result.Usage) + } +} + +func TestModelCompleteWithToolsOpensToolStep(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + + b := makeBinding(t, "c1", spec, `{"x":1}`) + facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("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 TestApprovalCallOpensWaitingWithDerivedResponse(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, ApprovalRequired) + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + + b := makeBinding(t, "c1", spec, `{}`) + facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) + opened := facts[1].(ToolStepOpened) + if opened.Calls[0].Response == nil { + t.Fatal("approval call has no derived ResponseRequest") + } + want := DeriveResponseID(s.RunID, opened.StepID, "c1", ResponseApproval) + if opened.Calls[0].Response.ID != want { + t.Fatal("ResponseID not derived per spec") + } + s = fold(t, s, facts) + ts := s.Current.(ToolStep) + if ts.Calls[0].Status != ToolWaiting { + t.Fatalf("status = %v, want Waiting", ts.Calls[0].Status) + } + + // Next must surface WaitForResponse with the routable request. + eff, err := Next(s) + if err != nil { + t.Fatal(err) + } + wait, ok := eff.(WaitForResponse) + if !ok || len(wait.Requests) != 1 || wait.Requests[0].ID != want { + t.Fatalf("effect = %#v", eff) + } + + // Approve -> Pending; then start -> execute path. + s = fold(t, s, mustDecide(t, s, ApproveToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: want})) + if s.Current.(ToolStep).Calls[0].Status != ToolPending { + t.Fatal("approved call is not Pending") + } +} + +func TestRejectRecordsPermissionDenied(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, ApprovalRequired) + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + b := makeBinding(t, "c1", spec, `{}`) + facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) + opened := facts[1].(ToolStepOpened) + s = fold(t, s, facts) + respID := opened.Calls[0].Response.ID + + facts = mustDecide(t, s, RejectToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: respID, Reason: "no"}) + // Single call: reject closes it as Known failed and closes the step. + if len(facts) != 2 { + t.Fatalf("facts = %d, want [failed, closed]", len(facts)) + } + failed := facts[0].(ToolCallFailed) + if failed.Failure.Class != FailurePermissionDenied || failed.Outcome != ToolOutcomeKnown { + t.Fatalf("failed = %+v", failed) + } + if _, ok := facts[1].(ToolStepClosed); !ok { + t.Fatalf("facts[1] = %T", facts[1]) + } + s = fold(t, s, facts) + if s.Current != nil || s.Status != RunActive { + t.Fatal("run should continue with no current step") + } +} + +func TestUnknownFailureEndsRun(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + b := makeBinding(t, "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: "c1"})) + + facts = mustDecide(t, s, SubmitToolFailure{StepID: opened.StepID, CallID: "c1", Outcome: ToolOutcomeUnknown}) + if len(facts) != 2 { + t.Fatalf("facts = %d, want [failed, ended]", len(facts)) + } + ended := facts[1].(RunEnded) + if ended.Status != RunFailed || ended.Reason != ReasonEffectUnknown || ended.Failure.CallID != "c1" { + t.Fatalf("ended = %+v", ended) + } + s = fold(t, s, facts) + if s.Status != RunFailed { + t.Fatal("run not failed") + } + // Terminal absorbs: further commands rejected. + if _, err := Decide(s, CancelRun{}); err != ErrRunTerminal { + t.Fatalf("err = %v, want ErrRunTerminal", err) + } +} + +func TestParallelWaitingDoesNotBlockPending(t *testing.T) { + defA, defB := testToolDef("a"), testToolDef("b") + specA := makeSpec(t, defA, ApprovalRequired) + specB := makeSpec(t, defB, DirectExecution) + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(defA, defB), []ToolSpec{specA, specB}) + + bA := makeBinding(t, "cA", specA, `{}`) + bA.ToolRef = "a" + bB := makeBinding(t, "cB", specB, `{}`) + bB.ToolRef = "b" + r := modelResultWithCalls("cA", "cB") + 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] != "cB" { + 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: "cB"})) + facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: "cB", Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}}) + if len(facts) != 1 { + t.Fatalf("facts = %d, step must not close with A waiting", len(facts)) + } + s = fold(t, s, facts) + + // Answer A via approval; approving moves to Pending, then failing known + // closes the step. + respID := opened.Calls[0].Response.ID + s = fold(t, s, mustDecide(t, s, ApproveToolCall{StepID: opened.StepID, CallID: "cA", ResponseID: respID})) + s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: "cA"})) + facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: "cA", Result: ToolExecutionResult{Output: json.RawMessage(`"done"`)}}) + if len(facts) != 2 { + t.Fatalf("facts = %d, want [completed, closed]", len(facts)) + } + s = fold(t, s, facts) + if s.Current != nil { + t.Fatal("tool step should be closed") + } +} + +func TestRejectModelResultRetriesThenFails(t *testing.T) { + cfg := testConfig() // reject limit 2 + s := newRun(t, cfg) + s, stepID := advanceToExecuting(t, s, testRequest(), nil) + + usage := sdk.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: still within limit. + 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 exceeds limit 2: run fails. + s = fold(t, s, mustDecide(t, s, StartModelExecution{StepID: stepID})) + facts = mustDecide(t, s, RejectModelResult{StepID: stepID, Usage: usage, Failure: StepFailure{Class: FailureMalformedModel}}) + 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 TestModelRecoveryKeepsFrozenRequestAndCounts(t *testing.T) { + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(), nil) + s = fold(t, s, mustDecide(t, s, RecoverModelExecution{StepID: stepID})) + ms := s.Current.(ModelStep) + if ms.Status != ModelPrepared { + t.Fatal("recovered step not Prepared") + } + if s.ModelSteps != 1 { + t.Fatal("recovery must not recount model steps") + } + if s.Usage.TotalTokens != 0 { + t.Fatal("recovery must not change usage") + } +} + +func TestStepLimitEndsRunAtToolStepClose(t *testing.T) { + cfg := testConfig() + cfg.ModelStepLimit = 1 + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + s := newRun(t, cfg) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + b := makeBinding(t, "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: "c1"})) + + facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: "c1", Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}}) + if len(facts) != 3 { + t.Fatalf("facts = %d, want [completed, closed, ended]", len(facts)) + } + ended := facts[2].(RunEnded) + if ended.Status != RunStopped || ended.Reason != ReasonStepLimit { + t.Fatalf("ended = %+v", ended) + } +} + +func TestCancelProducesRunStopped(t *testing.T) { + s := newRun(t, testConfig()) + facts := mustDecide(t, s, CancelRun{}) + ended := facts[0].(RunEnded) + if ended.Status != RunStopped || ended.Reason != ReasonCancelled { + t.Fatalf("ended = %+v", ended) + } +} + +func TestAcceptInputIdempotentPerID(t *testing.T) { + s := newRun(t, testConfig()) + facts := mustDecide(t, s, NextStep(AgentInput{ID: "in-2", Payload: json.RawMessage(`1`)})) + s = fold(t, s, facts) + if len(s.PendingInputs) != 2 { + t.Fatalf("pending = %d", len(s.PendingInputs)) + } + // Same fact folded twice appends once. + s = fold(t, s, facts) + if len(s.PendingInputs) != 2 { + t.Fatal("InputAccepted fold is not idempotent per InputID") + } + // AcceptInput rejected while a step is current. + prep, _ := buildPrepare(t, s, testRequest(), nil) + s = fold(t, s, mustDecide(t, s, prep)) + if _, err := Decide(s, NextStep(AgentInput{ID: "in-3"})); err == nil { + t.Fatal("AcceptInput accepted with a current step") + } +} + +func TestReplayEquivalence(t *testing.T) { + // state = fold(Evolve, initial, events): run a full happy path, capture + // all facts, refold from initial, and compare canonical serializations. + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + initial := newRun(t, testConfig()) + var log []Fact + + s := initial + step := func(c AgentCommand) { + facts := mustDecide(t, s, c) + log = append(log, facts...) + s = fold(t, s, facts) + } + prep, _ := buildPrepare(t, s, testRequest(def), []ToolSpec{spec}) + step(prep) + step(StartModelExecution{StepID: prep.StepID}) + b := makeBinding(t, "c1", spec, `{}`) + step(SubmitModelResult{StepID: prep.StepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) + ts := s.Current.(ToolStep) + step(StartToolCall{StepID: ts.RefValue.ID, CallID: "c1"}) + step(SubmitToolResult{StepID: ts.RefValue.ID, CallID: "c1", Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}}) + + replayed := fold(t, initial, log) + a, err := json.Marshal(stateSnapshotForTest(s)) + if err != nil { + t.Fatal(err) + } + bts, err := json.Marshal(stateSnapshotForTest(replayed)) + if err != nil { + t.Fatal(err) + } + if string(a) != string(bts) { + t.Fatalf("replay diverged:\n live %s\n replay %s", a, bts) + } +} + +// stateSnapshotForTest flattens MachineState including the unexported-ish +// Current step for comparison. +func stateSnapshotForTest(s MachineState) map[string]any { + m := map[string]any{ + "runId": s.RunID, "status": s.Status, "modelSteps": s.ModelSteps, + "usage": s.Usage, "pending": s.PendingInputs, "result": s.Result, + } + switch cur := s.Current.(type) { + case ModelStep: + m["model"] = cur + case ToolStep: + m["tool"] = cur + } + return m +} diff --git a/agent/memory.go b/agent/memory.go new file mode 100644 index 0000000..25ae9f7 --- /dev/null +++ b/agent/memory.go @@ -0,0 +1,155 @@ +package agent + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "sync" +) + +// MemoryRuntime is the in-process reference Runtime: mutex + MachineState + +// AgentEvent map (spec §8.1). It is the conformance reference; it does not +// survive the process and does not store product history. +type MemoryRuntime struct { + mu sync.Mutex + state MachineState + revision uint64 + // events keyed by CommandID: the full event group of each transition. + events map[CommandID][]AgentEvent + // log holds every event in (Revision, Index) order for replay. + log []AgentEvent + // occupancy: live grants per target (one model step or one call). + grants map[string]ExecutionGrant +} + +// NewMemoryRuntime starts from an Initialize-produced state at Revision 0. +func NewMemoryRuntime(initial MachineState) *MemoryRuntime { + return &MemoryRuntime{ + state: initial, + events: make(map[CommandID][]AgentEvent), + grants: make(map[string]ExecutionGrant), + } +} + +func (m *MemoryRuntime) Load(ctx context.Context) (RuntimeSnapshot, error) { + if err := ctx.Err(); err != nil { + return RuntimeSnapshot{}, err + } + m.mu.Lock() + defer m.mu.Unlock() + return RuntimeSnapshot{State: m.state, Revision: m.revision}, nil +} + +func grantKey(c AgentCommand) string { + switch cmd := c.(type) { + case StartModelExecution: + return "model/" + string(cmd.StepID) + case SubmitModelResult: + return "model/" + string(cmd.StepID) + case SubmitModelFailure: + return "model/" + string(cmd.StepID) + case RejectModelResult: + return "model/" + string(cmd.StepID) + case RecoverModelExecution: + return "model/" + string(cmd.StepID) + case StartToolCall: + return "call/" + string(cmd.StepID) + "/" + string(cmd.CallID) + case SubmitToolResult: + return "call/" + string(cmd.StepID) + "/" + string(cmd.CallID) + case SubmitToolFailure: + return "call/" + string(cmd.StepID) + "/" + string(cmd.CallID) + default: + return "" + } +} + +func newGrant() ExecutionGrant { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("agent: memory runtime: %v", err)) + } + return ExecutionGrant(hex.EncodeToString(b[:])) +} + +func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitResult, error) { + if err := ctx.Err(); err != nil { + return CommitResult{}, err + } + m.mu.Lock() + defer m.mu.Unlock() + + key := grantKey(req.Command.Command) + grantValid := false + if key != "" && req.Grant != "" { + grantValid = m.grants[key] == req.Grant + } + // MemoryRuntime has no lease expiry: a grantless recovery command is only + // valid when no live occupancy exists for the target (the worker died with + // the process, which memory state does not survive; recoveryValid mainly + // serves conformance tests). + recoveryValid := false + if key != "" && req.Grant == "" { + _, occupied := m.grants[key] + recoveryValid = !occupied + } + + decision, err := EvaluateCommit(m.state, m.revision, m.events[req.Command.ID], req, grantValid, recoveryValid) + if err != nil { + return CommitResult{}, err + } + switch decision.Kind { + case DecisionAlreadyApplied: + // Replay never re-grants execution (spec §5.4). + return CommitResult{ + Status: CommitAlreadyApplied, + Snapshot: RuntimeSnapshot{State: m.state, Revision: m.revision}, + Events: decision.Events, + }, nil + case DecisionConflict: + return CommitResult{}, ErrCommandConflict + case DecisionStale: + if decision.Reject != nil && decision.Reject != ErrStaleRuntime { + return CommitResult{}, fmt.Errorf("%w: %v", ErrStaleRuntime, decision.Reject) + } + return CommitResult{}, ErrStaleRuntime + case DecisionTerminal: + return CommitResult{}, ErrRunTerminal + } + + // DecisionApply: persist state + events atomically under the lock, manage + // occupancy, and mint the grant for an accepted start. + m.state = decision.NewState + m.revision++ + m.events[req.Command.ID] = decision.Events + m.log = append(m.log, decision.Events...) + + var minted ExecutionGrant + switch req.Command.Command.(type) { + case StartModelExecution, StartToolCall: + minted = newGrant() + m.grants[key] = minted + case SubmitModelResult, SubmitModelFailure, RejectModelResult, RecoverModelExecution, + SubmitToolResult, SubmitToolFailure: + delete(m.grants, key) + } + if m.state.Status.Terminal() { + // Terminal invalidates every outstanding grant (spec §3.7.3). + m.grants = make(map[string]ExecutionGrant) + } + + return CommitResult{ + Status: CommitAccepted, + Snapshot: RuntimeSnapshot{State: m.state, Revision: m.revision}, + Events: decision.Events, + Grant: minted, + }, nil +} + +// Events returns the full event log in (Revision, Index) order. Test and +// replay helper; not part of the Runtime contract. +func (m *MemoryRuntime) Events() []AgentEvent { + m.mu.Lock() + defer m.mu.Unlock() + return append([]AgentEvent(nil), m.log...) +} diff --git a/agent/next.go b/agent/next.go new file mode 100644 index 0000000..7ca778a --- /dev/null +++ b/agent/next.go @@ -0,0 +1,94 @@ +package agent + +// Effect is the at-most-one pending action Machine.Next derives from the +// current state (spec §3.6). 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() {} + +type StartToolCalls struct { + StepID StepID + CallIDs []CallID +} + +func (StartToolCalls) effect() {} + +type WaitForResponse struct { + Requests []ResponseRequest +} + +func (WaitForResponse) effect() {} + +type WaitForExecutionRecovery struct{} + +func (WaitForExecutionRecovery) effect() {} + +// PlanningHint is what the Loop hands the application RequestPlanner. +type PlanningHint struct { + RunID RunID + Model ModelRef + SourceStep StepID + Inputs []AgentInput +} + +// Next derives the pending effect from the current state (spec §3.7.3). +// Terminal states yield no effect; callers check Status first. +func Next(s MachineState) (Effect, error) { + if s.Status.Terminal() { + return nil, ErrRunTerminal + } + switch cur := s.Current.(type) { + case nil: + return NeedModelRequest{Hint: PlanningHint{ + RunID: s.RunID, + Model: s.Config.Model, + Inputs: append([]AgentInput(nil), s.PendingInputs...), + }}, nil + case ModelStep: + if cur.Status == ModelPrepared { + return StartModelCall{StepID: cur.RefValue.ID}, nil + } + return WaitForExecutionRecovery{}, nil + case ToolStep: + var pending []CallID + var waiting []ResponseRequest + executing := false + for _, c := range cur.Calls { + switch c.Status { + case ToolPending: + pending = append(pending, c.CallID) + case ToolWaiting: + if c.Waiting != nil { + waiting = append(waiting, *c.Waiting) + } + case ToolExecuting: + executing = true + } + } + if len(pending) > 0 { + return StartToolCalls{StepID: cur.RefValue.ID, CallIDs: pending}, nil + } + if len(waiting) > 0 { + // Even with an Executing call alongside, the Loop waits for a + // response or an execution wake (spec §3.7.3). + return WaitForResponse{Requests: waiting}, nil + } + if executing { + return WaitForExecutionRecovery{}, 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 step variant %T", s.Current) + } +} diff --git a/agent/protocol.go b/agent/protocol.go new file mode 100644 index 0000000..805b2c8 --- /dev/null +++ b/agent/protocol.go @@ -0,0 +1,183 @@ +package agent + +import ( + "errors" + "fmt" + + "github.com/memohai/twilight-ai/sdk" +) + +// SchemaVersion1 is the first published wire schema. Canonical encoding and +// Evolve folding semantics for a published version are frozen forever. +const SchemaVersion1 uint16 = 1 + +// currentSchemaVersion is what new commands and facts are written with. +const currentSchemaVersion = SchemaVersion1 + +// CommandEnvelope carries one command with its persisted protocol identity. +type CommandEnvelope struct { + SchemaVersion uint16 `json:"schemaVersion"` + Type string `json:"type"` + RunID RunID `json:"runId"` + ID CommandID `json:"id"` + Digest Digest `json:"digest"` + Command AgentCommand `json:"command"` +} + +// AgentEvent carries one fact produced by an accepted command. All events of +// one transition share Revision, CommandID and CommandDigest; Index orders +// them within the transition. Identity is assigned by the authority. +type AgentEvent struct { + SchemaVersion uint16 `json:"schemaVersion"` + Type string `json:"type"` + RunID RunID `json:"runId"` + Revision uint64 `json:"revision"` + Index uint16 `json:"index"` + CommandID CommandID `json:"commandId"` + CommandDigest Digest `json:"commandDigest"` + Digest Digest `json:"digest"` // canonical digest of the fact + Fact Fact `json:"fact"` +} + +// encodeEnvelopeBody is the digest input for a command: schema version, type +// discriminator and canonical command bytes. The Digest field itself, base +// revisions and grants never enter the digest (spec §5.5). +func encodeEnvelopeBody(schemaVersion uint16, typ string, body any) ([]byte, error) { + canonical, err := marshalCanonical(body) + if err != nil { + return nil, err + } + prefix := fmt.Sprintf("v%d:%d:%s:", schemaVersion, len(typ), typ) + return append([]byte(prefix), canonical...), nil +} + +// EncodeCommand renders the canonical bytes of a command envelope, excluding +// the Digest field. +func EncodeCommand(env CommandEnvelope) ([]byte, error) { + if env.Type == "" || env.Type != commandType(env.Command) { + return nil, fmt.Errorf("agent: encode: type %q does not match command variant", env.Type) + } + body, err := encodeEnvelopeBody(env.SchemaVersion, env.Type, env.Command) + if err != nil { + return nil, err + } + header := fmt.Sprintf("run:%d:%s:cmd:%d:%s:", len(env.RunID), env.RunID, len(env.ID), env.ID) + return append([]byte(header), body...), nil +} + +// DigestCommand computes the canonical digest of one command. +func DigestCommand(schemaVersion uint16, typ string, command AgentCommand) (Digest, error) { + if typ == "" || typ != commandType(command) { + return "", fmt.Errorf("agent: digest: type %q does not match command variant", typ) + } + body, err := encodeEnvelopeBody(schemaVersion, typ, command) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +// EncodeFact renders the canonical bytes of one fact. +func EncodeFact(schemaVersion uint16, typ string, fact Fact) ([]byte, error) { + if typ == "" || typ != factType(fact) { + return nil, fmt.Errorf("agent: encode: type %q does not match fact variant", typ) + } + return encodeEnvelopeBody(schemaVersion, typ, fact) +} + +// DigestFact computes the canonical digest of one fact. +func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) { + body, err := EncodeFact(schemaVersion, typ, fact) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +// EncodeRunSeed renders the canonical bytes of an admission seed so admission +// records can reuse the protocol's identity rules. +func EncodeRunSeed(seed RunSeed) ([]byte, error) { + return encodeEnvelopeBody(currentSchemaVersion, "run_seed", seed) +} + +// DigestRunSeed computes the canonical digest of an admission seed. +func DigestRunSeed(schemaVersion uint16, seed RunSeed) (Digest, error) { + body, err := encodeEnvelopeBody(schemaVersion, "run_seed", seed) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +// DigestRequest covers every field of a frozen sdk.Request with no +// exclusions (spec §2.1 rule 6). +func DigestRequest(req sdk.Request) (Digest, error) { + body, err := encodeEnvelopeBody(currentSchemaVersion, "sdk_request", req) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +// DigestToolDefinition covers one provider-neutral tool definition. +func DigestToolDefinition(def sdk.ToolDefinition) (Digest, error) { + body, err := encodeEnvelopeBody(currentSchemaVersion, "sdk_tool_definition", def) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +// DigestToolSpec covers one agent ToolSpec (ref, definition, digest, policy). +func DigestToolSpec(spec ToolSpec) (Digest, error) { + body, err := encodeEnvelopeBody(currentSchemaVersion, "tool_spec", spec) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +// DigestToolSpecs covers an ordered ToolSpec list: ref, schema, order and +// policy all participate (spec §3.7.1 rule 1). +func DigestToolSpecs(specs []ToolSpec) (Digest, error) { + body, err := encodeEnvelopeBody(currentSchemaVersion, "tool_specs", specs) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +// DigestModelStepBinding combines model, request digest and tools digest into +// the immutable ModelStep binding digest. +func DigestModelStepBinding(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 +} + +// digestBindingSet covers the full ordered call set of one ToolStep; it feeds +// DeriveToolStepID. +func digestBindingSet(bindings []ToolCallBinding) (Digest, error) { + body, err := encodeEnvelopeBody(currentSchemaVersion, "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 (spec §4.2). +func digestToolCallBinding(callID CallID, definitionDigest Digest, policy ResponsePolicy, arguments []byte) (Digest, error) { + canonicalArgs := []byte("null") + if len(arguments) > 0 { + var err error + canonicalArgs, err = canonicalJSON(arguments) + if err != nil { + return "", fmt.Errorf("agent: binding digest: %w", err) + } + } + return sha256Digest([]byte(namespacedHash("twilight/tool-call-binding", + string(callID), string(definitionDigest), fmt.Sprintf("%d", policy), string(canonicalArgs)))), nil +} diff --git a/agent/state.go b/agent/state.go new file mode 100644 index 0000000..7b713a1 --- /dev/null +++ b/agent/state.go @@ -0,0 +1,306 @@ +package agent + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/memohai/twilight-ai/sdk" +) + +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" + ReasonStepLimit RunReason = "step_limit" + ReasonProviderFailure RunReason = "provider_failure" + ReasonMalformedModel RunReason = "malformed_model_result" + 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"` + Model *sdk.ModelResult `json:"model,omitempty"` + Usage sdk.Usage `json:"usage"` +} + +// RunConfig is frozen at Run creation. +type RunConfig struct { + Model ModelRef `json:"model"` + // ModelStepLimit: zero means unlimited; a positive value caps ModelSteps. + // Negative values are invalid. + ModelStepLimit int `json:"modelStepLimit,omitempty"` + // ModelRejectLimit: max accepted RejectModelResult per ModelStep before + // the Run fails. Zero is normalized to DefaultModelRejectLimit by + // Initialize; negative values are invalid. There is no unlimited value. + ModelRejectLimit int `json:"modelRejectLimit,omitempty"` +} + +// DefaultModelRejectLimit is the Initialize-time normalization of +// RunConfig.ModelRejectLimit == 0. +const DefaultModelRejectLimit = 2 + +type StepFailure struct { + Class string `json:"class"` + Message string `json:"message,omitempty"` +} + +const ( + FailurePermissionDenied = "permission_denied" + 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" +) + +// 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 json.RawMessage `json:"payload,omitempty"` + RequestDigest Digest `json:"requestDigest"` // digest of the request payload +} + +// ToolSpec is the agent-side sidecar for a provider-neutral sdk.ToolDefinition. +// ResponsePolicy is intentionally kept out of sdk to preserve package layering. +type ToolSpec struct { + Ref ToolRef `json:"ref"` + Definition sdk.ToolDefinition `json:"definition"` + DefinitionDigest Digest `json:"definitionDigest"` + Policy ResponsePolicy `json:"policy"` +} + +// ToolCallBinding is one frozen call inside ToolStepOpened. +type ToolCallBinding struct { + CallID CallID `json:"callId"` + ToolRef ToolRef `json:"toolRef"` + DefinitionDigest Digest `json:"definitionDigest"` + BindingDigest Digest `json:"bindingDigest"` // definition, policy and canonical arguments + Arguments json.RawMessage `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 +} + +type ModelStepStatus uint8 + +const ( + ModelPrepared ModelStepStatus = iota + ModelExecuting +) + +type ModelStep struct { + RefValue StepRef `json:"ref"` + Request sdk.Request `json:"request"` + 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 (s ModelStep) Ref() StepRef { return s.RefValue } + +type ToolCallStatus uint8 + +const ( + ToolPending ToolCallStatus = iota + ToolExecuting + ToolWaiting + ToolCompleted + ToolFailed +) + +type ToolExecutionResult struct { + Output json.RawMessage `json:"output"` +} + +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"` + ToolRef ToolRef `json:"toolRef"` + DefinitionDigest Digest `json:"definitionDigest"` + BindingDigest Digest `json:"bindingDigest"` + Arguments json.RawMessage `json:"arguments"` + Policy ResponsePolicy `json:"policy"` + Status ToolCallStatus `json:"status"` + Result *ToolExecutionResult `json:"result,omitempty"` + Failure *ToolCallFailure `json:"failure,omitempty"` + Waiting *ResponseRequest `json:"waiting,omitempty"` +} + +// ValidateToolCallState rejects illegal field combinations (spec §4.2). +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 { + return fmt.Errorf("agent: call %s: completed requires a result", 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.Outcome == ToolOutcomeUnknown && 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 status %d", c.CallID, c.Status) + } + return nil +} + +type ToolStep struct { + RefValue StepRef `json:"ref"` + Source StepID `json:"source"` + Calls []ToolCallState `json:"calls"` +} + +func (ToolStep) step() {} +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 (spec §3.3). +// Control metadata (owner, fence, lease, attempts, queue claims) never +// appears here. +type MachineState struct { + RunID RunID `json:"runId"` + Status RunStatus `json:"status"` + Config RunConfig `json:"config"` + Current Step `json:"-"` // serialized by adapters with their snapshot schema + PendingInputs []AgentInput `json:"pendingInputs,omitempty"` + ModelSteps int `json:"modelSteps"` + Usage sdk.Usage `json:"usage"` + LastModelResult *sdk.ModelResult `json:"lastModelResult,omitempty"` + Result *RunResult `json:"result,omitempty"` +} + +// Initialize builds the initial MachineState (Revision 0) for a new Run from +// an admission seed (spec §3.7.1 rule 14). RunSeed never goes through +// Runtime.Commit. +func Initialize(run RunID, cfg RunConfig, seed RunSeed) (MachineState, error) { + if run == "" { + return MachineState{}, errors.New("agent: initialize: empty RunID") + } + if cfg.Model == "" { + return MachineState{}, errors.New("agent: initialize: empty RunConfig.Model") + } + if cfg.ModelStepLimit < 0 { + return MachineState{}, errors.New("agent: initialize: negative ModelStepLimit") + } + if cfg.ModelRejectLimit < 0 { + return MachineState{}, errors.New("agent: initialize: negative ModelRejectLimit") + } + if cfg.ModelRejectLimit == 0 { + cfg.ModelRejectLimit = DefaultModelRejectLimit + } + if seed.Input.ID == "" { + return MachineState{}, errors.New("agent: initialize: seed input requires an InputID") + } + return MachineState{ + RunID: run, + Status: RunActive, + Config: cfg, + PendingInputs: []AgentInput{seed.Input}, + }, nil +} diff --git a/agent/usage.go b/agent/usage.go new file mode 100644 index 0000000..6dbce11 --- /dev/null +++ b/agent/usage.go @@ -0,0 +1,28 @@ +package agent + +import ( + "github.com/memohai/twilight-ai/sdk" +) + +// addUsage accumulates sdk.Usage field by field (spec §3.7.2, ModelStepRejected +// and ModelStepCompleted rows). +func addUsage(a, b sdk.Usage) sdk.Usage { + return sdk.Usage{ + InputTokens: a.InputTokens + b.InputTokens, + OutputTokens: a.OutputTokens + b.OutputTokens, + TotalTokens: a.TotalTokens + b.TotalTokens, + ReasoningTokens: a.ReasoningTokens + b.ReasoningTokens, + CachedInputTokens: a.CachedInputTokens + b.CachedInputTokens, + InputTokenDetails: sdk.InputTokenDetail{ + NoCacheTokens: a.InputTokenDetails.NoCacheTokens + b.InputTokenDetails.NoCacheTokens, + CacheReadTokens: a.InputTokenDetails.CacheReadTokens + b.InputTokenDetails.CacheReadTokens, + CacheWriteTokens: a.InputTokenDetails.CacheWriteTokens + b.InputTokenDetails.CacheWriteTokens, + CacheWrite5mTokens: a.InputTokenDetails.CacheWrite5mTokens + b.InputTokenDetails.CacheWrite5mTokens, + CacheWrite1hTokens: a.InputTokenDetails.CacheWrite1hTokens + b.InputTokenDetails.CacheWrite1hTokens, + }, + OutputTokenDetails: sdk.OutputTokenDetail{ + TextTokens: a.OutputTokenDetails.TextTokens + b.OutputTokenDetails.TextTokens, + ReasoningTokens: a.OutputTokenDetails.ReasoningTokens + b.OutputTokenDetails.ReasoningTokens, + }, + } +} diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md new file mode 100644 index 0000000..6d8662b --- /dev/null +++ b/docs/design/agent-runtime-refactor.md @@ -0,0 +1,2315 @@ +# Twilight AI Agent Runtime 重构设计规范 + +状态:重构方案 + +本文定义 Twilight AI 的 `sdk/`、`agent/` 和 Memoh native runtime 边界,并规定从当前 SDK 多步 loop 迁移到唯一 `agent.Loop` 的方式。 + +本文只定义 Agent Machine 的语义、Loop 的执行协议、Runtime 的 authority/commit 边界,以及已提交事实的 canonical event 语义。Memoh 的 queue、session、R0/R1、owner、fencing、lease 和数据库投影仍由 Memoh 负责;本文规定它们如何接入,而不把它们提升为 agent core 概念。 + +正文中的 Go 片段用于说明协议;附录 A 是 public API 草案。正文各节为规范文本,附录是汇总视图;两者不一致时以正文为准,并修订附录。 + +## 1. 目标 + +### 1.1 核心目标 + +Twilight AI 同时支持两种运行形态: + +```text +Memory agent + MachineState 在当前进程内,适合本地会话和测试 + +Durable agent + MachineState 在数据库中,进程可以退出,新的进程可以继续 +``` + +两种形态使用同一套 Machine 规则和同一个 Loop。差别只在 Runtime 如何保存权威状态、控制并发和提交状态变化。 + +本版本固定五个层级: + +```text +1. Agent Machine + 定义 Run、Step、ToolCall 的语义状态和合法状态变化 + +2. Agent Loop + 解释 Machine 产生的 effect,执行模型/工具,再提交结果事件 + +3. Runtime + 加载权威 MachineState,并原子提交 AgentCommand,产生 AgentEvent + +4. Model / Tool + 执行一次模型请求或一次工具调用 + +5. Request Planner + 把 application context 投影为下一次模型所需的 sdk.Request +``` + +这里的 “Agent Machine” 是语义层名称,不引入名为 `Agent` 的核心对象;公共执行入口仍是 `Loop`。 + +整体关系: + +```text + Agent Machine (pure rules) + MachineState + Next/Decide/Evolve + ^ | + | state | Effect + | v +Request Planner --> sdk.Request --> Agent Loop ------> Model / Tool + application (frozen) | + context | AgentCommand + v + Runtime + Load + atomic Commit + / \ + MemoryRuntime MemohRuntime + memory + mutex PostgreSQL + private lease/fence + \ / + \ / + MachineState + AgentEvents + (same commit boundary) + | + v + EventSink / replay / projection / OTel +``` + +Loop 和 Runtime 是协作者,不是上下级:Loop 决定如何执行,Runtime 决定权威状态在哪里以及如何安全提交。Machine 决定什么状态变化合法;Planner 决定模型看到的 application context;Model/Tool 只执行一次外部 effect。 + +目标目录边界: + +```text +twilight-ai/ + sdk/ 一次 LLM request/response + agent/ Machine、Loop、Step、工具 contract、Runtime contract + +Memoh native runtime + Request Planner、产品 context、durable history、queue、session 和数据库事务 +``` + +依赖方向固定为: + +```text +agent ---> sdk +Memoh ---> agent + sdk +sdk 不依赖 agent 或 Memoh +``` + +### 1.2 核心执行模型 + +```text +Run + └── Step durable resume boundary + ├── ModelStep 一次冻结的模型请求 + └── ToolStep 一组 ToolCall 及其可恢复进度 + ├── Call A Completed + ├── Call B Waiting + └── Call C Pending + +MachineState + AgentCommand + | + Machine.Decide -> 事实序列(一次 transition 的全部决策) + | + Machine.Evolve 逐个折叠(机械,无决策) + | + v + new MachineState + AgentEvents(同一 Revision) + | + Machine.Next -> Effect -> Loop +``` + +Step 不是最小的外部操作。ToolStep 保存每个 ToolCall 中会影响恢复决策的状态。durable 实现可以为当前执行建立内部 Attempt;Attempt 可能因进程退出、超时或 lease 失效而消失,但新的 Loop 仍恢复同一个 Step。 + +最重要的不变量是: + +> 一个 Step 可以有多个执行尝试,但只有被 Runtime 接受的一次状态变化能够推进权威 Run;外部工具 effect 仍可能是 at-least-once。 + +### 1.3 术语 + +| 名称 | 含义 | +| --- | --- | +| Run | 一个有身份、有权威状态和最终结果的业务执行。 | +| Loop | 当前进程执行 Run 的算法;它不保存权威状态。 | +| MachineState | Machine 的完整语义状态。 | +| Step | Run 中的 durable resume boundary。 | +| ModelStep | 一次冻结的 `sdk.Request`,直到接受模型结果。 | +| ToolStep | 一个模型结果产生的一组 ToolCall 及其 progress。 | +| ToolCall | ToolStep 内的一个结构化工具调用。 | +| AgentCommand | Loop 或外部入口希望 Machine 接受的意图;接受后构成一次 transition。 | +| AgentEvent | Runtime 已接受并持久化的事实;一次 transition 产出一个或多个,带 (Revision, Index) 身份。 | +| Revision | 每 Run 单调递增的 transition 计数;第 N 次接受的 transition 产出 Revision=N 的事件组和 Revision=N 的状态。 | +| Effect | Machine 根据当前状态返回的至多一个待执行动作;不表示一定有外部副作用。 | +| Attempt | Runtime 为一次进程执行建立的内部执行租约。 | +| Runtime | MachineState 的 execution authority、AgentCommand 的原子提交和 AgentEvent 的产生者。 | +| Request Planner | application context 到 `sdk.Request` 的投影器。 | + +### 1.4 成功标准 + +| 场景 | 期望结果 | +| --- | --- | +| 单次模型调用 | 直接使用 `sdk`,不自动执行工具。 | +| 本地会话 | `agent.Loop` 配合 `MemoryRuntime`。 | +| Memoh durable 会话 | 同一个 `agent.Loop` 配合 `MemohRuntime`。 | +| 模型返回 tools | ModelStep 完成后创建一个 ToolStep;ToolStep 完成前不创建下一 ModelStep。 | +| 多个 approval/用户响应 | 每个等待请求有稳定身份;一个响应只推进对应 Call。 | +| ToolStep 中途崩溃 | Completed Call 不再执行;Pending Call 可恢复;结果未知的 Executing Call 终止 Run。 | +| steer/follow-up | 只在 Memoh 的 queue-safe boundary 仲裁。 | +| commit 响应丢失 | 用同一 command identity 和 digest 重放,不重复业务副作用。 | +| worker 取消 | 只结束当前 Loop attempt;不等同于业务取消 Run。 | + +## 2. Package 与职责 + +### 2.1 `sdk/` + +sdk 只负责 LLM API: + +| 能力 | 内容 | +| --- | --- | +| provider client | 认证、provider dispatch、transport。 | +| 一次调用 | `Generate` 或 `Stream`,每次对应一个 provider request。 | +| 协议类型 | message、provider-neutral tool definition、tool call、finish reason、usage 和 metadata。 | +| stream 归一化 | 将 parts 组装成一次完整 `ModelResult`。 | +| provider 错误 | transport、rate limit、malformed stream 和 provider response error。 | +| request snapshot | provider-neutral、可冻结的请求表示。 | + +`sdk.Request` 是一次模型调用的完整输入,不是 session、history 或 queue。`sdk.ModelResult` 是一次完整模型响应,保留旧 `GenerateResult` 的单次调用字段(文本、reasoning parts、tool calls、finish reason、usage、sources/files 和 provider metadata),但不包含自动 tool loop、approval 或多次调用累加。多步执行的 steps 和 messages 由 agent/application 另行保存。 + +`sdk.Request` 的冻结形态遵守以下规则;`DigestRequest`、StepID 派生和提交幂等都建立在这套规则上: + +1. Request 是纯数据。它不包含 provider client、接口值、回调或 `Execute` 句柄;模型以 provider 作用域内的字符串 ID 表示,provider 绑定发生在 `ModelCatalog`/`ModelInvoker` 解析时。 +2. 工具以 provider-neutral 的 `ToolDefinition{Name, Description, Parameters}` 表示;`Parameters` 是解析完成的 JSON Schema 文档。由 Go struct 推导 schema 的工作在冻结前完成,冻结后的 Request 不依赖推导或反射。 +3. `ToolChoice` 是封闭类型 `{Mode: auto|none|required|tool, Tool string}`,不使用 `any`。 +4. 消息中的二进制内容有两种形式:inline bytes(canonical 编码为 base64),或稳定的内容寻址引用 `BlobRef{Digest, MediaType, ByteSize}`。`BlobRef` 的字节解析由组装 `ModelInvoker` 的一方负责;带时效的 URL 等不稳定引用不能进入冻结请求。两种形式产生不同的 digest,Planner 对同一内容必须确定性地选择一种形式。 +5. provider metadata 等扩展字段的值必须是 JSON 值。canonical 编码采用 RFC 8785(JCS):对象键按 UTF-16 码元排序,数字使用最短表示,`json.RawMessage` 先按 JCS 重新序列化。 +6. `DigestRequest` 覆盖 Request 的全部字段,不设排除项。cache 配置等只影响成本的字段同样参与摘要;排除任何字段都会把不同请求判成同一事件,产生错误的 `CommitAlreadyApplied`。 + +```go +package sdk + +type Request struct { + Model string // provider 作用域内的模型 ID + System string + Messages []Message // parts 为纯数据;二进制为 inline bytes 或 BlobRef + Tools []ToolDefinition + ToolChoice ToolChoice + ResponseFormat *ResponseFormat + Temperature *float64 + TopP *float64 + MaxTokens *int + StopSequences []string + FrequencyPenalty *float64 + PresencePenalty *float64 + Seed *int + ReasoningEffort *string + ReasoningSummary *string + PromptCacheKey *string + ProviderOptions map[string]json.RawMessage // 按 provider namespace 存放 +} + +type ToolDefinition struct { + Name string + Description string + Parameters json.RawMessage // 解析完成的 JSON Schema + CacheControl *CacheControl +} + +type ToolChoiceMode string // "auto" | "none" | "required" | "tool" + +type ToolChoice struct { + Mode ToolChoiceMode + Tool string // Mode == "tool" 时的目标工具名 +} + +type BlobRef struct { + Digest string // sha256:,内容寻址 + MediaType string + ByteSize int64 +} +``` + +当前 `GenerateParams` 的 `Model *Model`、`Tool.Execute`、`Tool.Parameters any` 和 `ToolChoice any` 都不满足这些规则;阶段 A 实现上述新类型,legacy wrapper 在边界处完成新旧转换。 + +`MachineState.LastModelResult` 保留最近一次已接受的模型响应;终态时复制到 `RunResult.Model`,其中的 `Text` 对应旧 SDK 调用者看到的 final message。完整的 assistant/tool history 由 application 的 history projection 保存,不重复塞入 `RunResult`。 + +provider transport 的短暂失败可以由 sdk/provider client 在一次调用内部重试;agent 只看到最终的 `ModelResult` 或 provider error。重试次数和退避属于 sdk/provider 配置。 + +旧的 `GenerateText`、`StreamText` 和自动 tool loop 在迁移期只能作为显式 legacy wrapper;新 Loop 不依赖它们。 + +### 2.2 `agent/` + +agent 提供通用 core: + +| 能力 | 内容 | +| --- | --- | +| Machine | `MachineState`、`Step`、ToolCall 状态、AgentCommand、AgentEvent 和共享的 Decide/Evolve/Next 规则。 | +| Loop | 唯一的多步执行算法。 | +| Tool contract | ToolRef、ExecutableTool、参数校验、结果分类和 response policy。 | +| Runtime contract | `Load`/`Commit` authority 接口。 | +| Planner port | 供 Loop 注入 application Request Planner 的最小接口;规划实现不在 agent。 | +| Effect contract | 模型、工具、等待和观察动作。 | +| EventSink | canonical event 的实时观察出口,也承载不进入权威状态的 provisional delta;它不保存 canonical event。 | +| MCP adapter | MCP schema/call 到 agent tool contract 的适配。 | +| MemoryRuntime | 进程内参考实现;它不是 Machine 的一部分,生产应用也可以在 application 包中实现同一 Runtime contract。 | + +agent 不拥有 Memoh 的 session、queue schema、admission、owner、fencing、R0/R1 或数据库类型。它也不组装产品 prompt,不执行 scheduler,不保存产品 memory。 + +### 2.3 Memoh native runtime + +Memoh 负责产品和 durable 层: + +| 能力 | 内容 | +| --- | --- | +| context | system prompt、memory、compaction、attachments、workspace 和产品 metadata。 | +| Request Planner | 把 context、history 和 queue-safe 输入投影成冻结的 `sdk.Request`。 | +| durable history | messages、ModelStep record、ToolStep progress 和 tool result record。 | +| queue | steer/follow-up 的入队、accepted order、重排、claim、apply、取消和 admission。 | +| session/run | admission、R0/R1、settled 和后续 Run。 | +| ownership | owner、fencing、lease、liveness 和 takeover。 | +| MemohRuntime | agent.Runtime 的 PostgreSQL adapter。 | +| durable events | AgentEvent、outbox、审计事实和恢复所需的 projection。 | + +### 2.4 类型归属 + +| 类型或能力 | 所属层 | +| --- | --- | +| `sdk.Request`、`sdk.ModelResult`、`sdk.ToolDefinition`(provider-neutral) | sdk | +| `Step`、`ToolCallState`、`AgentCommand`、`RunSeed`、`AgentEvent`、`Effect` | agent | +| `ExecutableTool`、参数校验、response policy | agent | +| `RequestPlanner` port、`PlanningHint`、`RequestPlan` | agent;只用于依赖注入 | +| Request Planner 实现和 context transformer | Memoh/application | +| queue、history、session、R0/R1、owner、fencing、outbox | Memoh | +| MCP server 连接和生命周期 | Memoh/application;schema/call adapter 在 agent | +| provider transport retry | sdk/provider client | + +## 3. Agent Machine + +### 3.1 Machine 的边界 + +Machine 是 agent package 中的纯语义规则。它只读取完整 `MachineState`、待决策的 `AgentCommand` 和待折叠的 `AgentEvent`,不访问 IO: + +```text +MachineState + AgentCommand + | +Machine.Decide -> 事实序列(决策,只在提交时运行一次) + | +Machine.Evolve 逐个折叠(机械) -> new MachineState +Machine.Next(state) -> Effect +``` + +`Next(state)` 根据当前事实产生至多一个待执行的 `Effect`。`Decide(state, command)` 校验一个意图并产出这次 transition 的完整事实序列——所有决策(是否接受、派生哪些后果、是否进入终态)都发生在这里,且只在提交时运行一次,输出即冻结。`Evolve(state, event)` 把单个事实机械地折叠进状态:它不读 RunConfig,不含任何 policy 分支,对 Decide 产出的每种事实全定义;replay 只依赖 Evolve。Runtime 接受一个 command 后,把 Decide 的事实序列包装为同一 Revision 的 AgentEvent 组,与折叠后的新状态放在同一提交边界。两种 Runtime 不能各自复制规则;Loop 重新 `Load` 后再次调用 `Next`,不会依赖一次提交响应中的 effect。 + +决策与折叠的分工是协议的兼容边界:Machine 的决策规则(limit 判断、自动关闭、终态转换)可以随版本演进,因为历史事件已把这些决策的结果记录在案;Evolve 与事件编码一起构成永久兼容契约,已发布 SchemaVersion 的折叠语义不再修改。 + +Run terminal、model-step limit、Step successor、ToolStep 自动关闭和等待条件都由 Machine 的 Decide 决定,并显式产出对应事实(`ToolStepClosed`、`RunEnded`)。Runtime 不再维护另一套 Run 终态和等待判断;Memoh 只把已经接受的 AgentEvent 投影到自己的 history、queue 和 outbox,按事件驱动,不做提交前后的状态差分。 + +Machine 不知道 PostgreSQL、mutex、lease、fencing、provider client、queue 或产品 history。Runtime 可以在自己的临界区内调用这套规则,但不改变规则。 + +### 3.2 输入语义 + +Queue 不属于 Machine,但 Machine 需要知道“一个输入何时已经被接受,以及它对后续执行的影响”。因此 core 只定义两种输入边界: + +```text +NextStep(input) + 当前 Run 仍 active 且处于可接收边界;输入进入下一次 ModelStep 的规划上下文 + +NextRun(input) + 当前 Run 已到 terminal;输入作为 continuation 的初始上下文,不修改旧 Run +``` + +`AgentInput` 只有稳定的 `InputID` 和不可变 payload,不包含 queue item、priority、order、claim 或 lease。`NextStep` 构造 `AcceptInput` command;被 Runtime 接受后产出 `InputAccepted` 事实,与新的 MachineState 一起提交,Memoh 可以在同一事务中把 queue claim 标记为 applied。 + +`NextRun` 产生的是 admission seed(`RunSeed`),不是任何 Run 的 command:它不创建 RunID,也不负责 queue claim 或 session admission。Memoh 先完成 queue claim、session admission 和新 Run 的身份分配,再用该 seed 通过 `Initialize` 建立新 Run 的 MachineState。admission 可以复用 `AgentEvent` 的 identity/digest/canonical 编码规则,但其排序和记录归属由 Memoh admission 决定,不调用旧 Run 的 `Runtime.Commit`。 + +Machine 不在 ToolStep 执行中接受 `NextStep`,也不把 `NextRun` 解释成旧 Run 的状态变化。输入的具体文本如何进入 `sdk.Request` 仍由 Request Planner 决定;Machine 只保证输入边界和一次性接受语义。 + +### 3.3 MachineState + +MachineState 至少包括: + +```text +RunID 和冻结的 RunConfig.Model +Run status +其他冻结的 RunConfig +current Step +ToolStep 中每个 ToolCall 的 progress/result +等待中的 ResponseRequest +已接受但尚未用于冻结下一请求的 AgentInput +model-step counter +累计 usage(对已接受 ModelStepCompleted 和 ModelStepRejected 的 sdk.Usage 逐字段求和) +最近一次已接受的 sdk.ModelResult +terminal RunResult(如果已结束) +``` + +权威语义状态不包括数据库 row、transaction、owner、fence、lease、Attempt 或 queue claim。Runtime 可以保存这些控制元数据,但它们不进入 MachineState。 + +### 3.4 Run 状态 + +```text +RunActive +RunCompleted 已得到正常终态模型结果 +RunStopped application 明确停止 +RunFailed provider、step 或未知外部 effect 导致失败 +``` + +等待是 Loop 的当前返回结果,不是单独的持久化 Run 状态。业务取消通过 Memoh/application 的控制事件提交为 `RunStopped`;取消 Loop 的 context 只结束当前执行尝试,不自动修改 Run 状态。 +所有 terminal 状态都必须带 `MachineState.Result`;`RunActive` 的 Result 为空。`RunFailed` 的 +Failure 至少包含稳定的 Class,未知外部 effect 还包含对应 CallID。 + +### 3.5 Step 层级 + +```text +Run R + └── Step S1: ModelStep + request frozen + result pending/completed + +ModelResult contains tool calls + | + v + Step S2: ToolStep + ├── Call A: Completed + ├── Call B: Waiting(response=101) + └── Call C: Pending + +S2 closes automatically after all Calls are terminal + | + v + Step S3: ModelStep +``` + +Step 只有 `ModelStep` 和 `ToolStep` 两种语义。ToolCall 是 ToolStep 内的 progress 项,不拥有独立的公共 Step identity。一个 ModelStep 最多产生一个后继 ToolStep;其 Call 集合、顺序和 provider-neutral tool definition digest 一次冻结。模型结果绑定到可执行工具时,`ToolCallBinding` 再冻结 policy 和 binding digest。ModelStep 的 ID 由 `DeriveModelStepID` 生成,并覆盖 ModelRef、request 与 tool-spec digest;ToolStep 的 ID 由原始 Call 顺序的完整 binding-set digest 通过 `DeriveToolStepID(source StepID, binding digest)` 生成,两个 Runtime 必须得到相同结果。 + +### 3.6 AgentCommand、AgentEvent 与 Effect + +协议使用两个 sealed 词表。`AgentCommand` 是 Loop 或受信任的外部入口针对**已有 Run**提出的意图;`AgentEvent` 承载 Runtime 接受一个 command 后产出的事实。一个被接受的 command 构成一次 transition,产出一个或多个事件;命令描述"请求发生什么",事件描述"已经发生什么",两个词表不共用类型。 + +AgentCommand(14 种): + +```text +PrepareModelRequest 冻结下一次模型请求 +StartModelExecution 取得 ModelStep 执行权 +SubmitModelResult 提交完整模型结果与 tool-call bindings +SubmitModelFailure 提交模型调用的最终失败 +RejectModelResult 提交结构性 malformed 的模型结果 +RecoverModelExecution 释放或回收 ModelStep 执行权 +StartToolCall 取得单个 ToolCall 执行权 +SubmitToolResult 提交工具执行成功结果 +SubmitToolFailure 提交工具已知/未知失败 +ApproveToolCall 批准 Waiting(Approval) Call +RejectToolCall 拒绝 Waiting(Approval) Call +SubmitToolResponse 提交 ask-user 答案 +CancelRun 业务取消 +AcceptInput 接受 queue-safe 输入(由 NextStep 构造) +``` + +事实词表 `Fact`(14 种),由 `Machine.Decide` 产出、包装为 AgentEvent: + +```text +ModelStepPrepared 冻结的 ModelStep 建立,消费 pending inputs +ModelStepStarted Prepared -> Executing +ModelStepRecovered Executing -> Prepared(无已接受结果) +ModelStepRejected 记录一次 malformed 结果:usage 累计,Rejects 加一,回到 Prepared +ModelStepCompleted 模型结果被接受:usage 累计,写 LastModelResult,清空当前 Step +ToolStepOpened 按 bindings 建立 ToolStep 的完整 Call 集合 +ToolCallStarted Pending -> Executing +ToolCallApproved Waiting(Approval) -> Pending +ToolCallCompleted Executing -> Completed +ToolCallAnswered Waiting(ExternalResponse) -> Completed(答案) +ToolCallFailed Pending/Executing/Waiting -> Failed(Known/Unknown) +ToolStepClosed 全部 Call 到达可关闭终态,清空当前 Step +InputAccepted 输入进入 PendingInputs +RunEnded 终态:Status 为 RunCompleted、RunStopped 或 RunFailed +``` + +`AgentEvent` 与新的 `MachineState` 在同一个原子提交中写入,具有 authority 分配的 (Revision, Index) 身份、canonical digest 和产生它的 CommandID。`AgentEvent` 可供 replay、projection、审计和 OpenTelemetry 使用。 + +`RunSeed` 属于新 Run 的 admission,不是 command,也不进入任何 Run 的事件流。如果 Memoh 需要审计这次 admission,可以复用相同的 identity/digest 编码规则,但该记录由 admission 事务保存,不经过 `Runtime.Commit`。 + +二者的关系固定为: + +```text +Loop / response ingress + -> AgentCommand (intent) +Runtime EvaluateCommit: 幂等/类别校验 + Machine.Decide + Machine.Evolve + -> MachineState + AgentEvent 组 (committed facts, one Revision) +``` + +意图与事实由 Decide 显式转换,日志永远记录结果而非请求:`RejectToolCall` 记录为 `ToolCallFailed{permission_denied}`;`CancelRun` 记录为 `RunEnded{RunStopped, cancelled}`;触发 step limit 的最后一次 Call 完成记录为 `[ToolCallCompleted, ToolStepClosed, RunEnded{RunStopped, step_limit}]`。 + +`PrepareModelRequest.RequestDigest` 和 `SubmitToolResponse.ResponseDigest` 分别是请求/响应 payload 的内容摘要,不是提交身份。一个 command 被重试时复用同一 CommandID 和 digest;Runtime 不会为重试生成第二组 AgentEvent。 + +Effect 是 Loop 动作: + +```text +NeedModelRequest(PlanningHint) +StartModelCall(StepID) +StartToolCalls(StepID, CallIDs) +WaitForResponse(ResponseRequests) +WaitForExecutionRecovery +``` + +终态由 `MachineState.Status` 表示;Effect 只是待执行动作,既可以是模型调用,也可以是工具调用或等待,不暗示“有负作用”。Effect 不写入权威状态;Loop 在每次 `Load` 后由 MachineState 重新得到它们。 + +### 3.7 共享规则 + +#### 3.7.1 Decide 规则(命令 -> 事实序列) + +`Decide(state, command)` 按下表校验前置条件并产出事实序列。任何前置条件不满足即拒绝整个 command,不产出部分事实: + +1. `PrepareModelRequest` 只能在 Run active 且没有当前 Step 时接受;其 `InputIDs` 必须按 `PendingInputs` 的当前顺序完整匹配。`Tools` 必须与 `sdk.Request` 中的 provider tool definitions 按 Ref、顺序和 definition digest 一一对应,`ToolsDigest` 覆盖 Ref、schema、顺序与 policy。产出 `[ModelStepPrepared]`,事实中携带冻结的请求、ToolSpec 与被消费的 InputIDs。 +2. `StartModelExecution` 只能作用于 Prepared ModelStep,产出 `[ModelStepStarted]`。`RecoverModelExecution` 只能作用于没有已接受结果的 Executing ModelStep,产出 `[ModelStepRecovered]`;它只能由持有该 Model grant 的当前 Loop,或由 Runtime 自己确认 lease 失效后的 recovery 逻辑提交,普通 response ingress 不能提交。 +3. `SubmitModelResult` 只能作用于对应的 Executing ModelStep。结果没有 tool calls 时产出 `[ModelStepCompleted, RunEnded{RunCompleted}]`;有 tool calls 时按冻结 `ToolSpec` 绑定 policy 和 binding digest,产出 `[ModelStepCompleted, ToolStepOpened]`——`ToolStepOpened` 携带完整 Call 集合:DirectExecution 的 Call 为 Pending,ApprovalRequired/ExternalResponse 的 Call 为带稳定 request 的 Waiting,每个 Waiting request 都包含目标 RunID、StepID、CallID、ResponseID、Kind 和 RequestDigest。 +4. `SubmitModelFailure` 只能作用于对应的 Executing ModelStep,产出 `[RunEnded{RunFailed, provider_failure}]`,保留稳定失败原因。 +5. `RejectModelResult` 只能作用于对应的 Executing ModelStep,必须携带该 start 的 Grant。`Rejects+1` 不超过冻结的 `RunConfig.ModelRejectLimit` 时产出 `[ModelStepRejected]`(Step 回到 Prepared,同一冻结 request 可再次 start);超过时产出 `[ModelStepRejected, RunEnded{RunFailed, malformed_model_result}]`。被拒绝的结果不写入 `LastModelResult`。 +6. `StartToolCall` 只能作用于 Pending Call,产出 `[ToolCallStarted]`。 +7. `SubmitToolResult` 只能作用于 Executing Call,产出 `[ToolCallCompleted]`。`SubmitToolResponse` 只能作用于 Waiting(ExternalResponse) Call,产出 `[ToolCallAnswered]`。 +8. `SubmitToolFailure`(known)可以作用于 Pending 或 Executing Call:Pending 的已知失败使用空 Grant,Executing 的必须使用对应 Grant,产出 `[ToolCallFailed{Known}]`。`SubmitToolFailure`(unknown)只能作用于 Executing Call,产出 `[ToolCallFailed{Unknown}, RunEnded{RunFailed, effect_unknown}]`,`RunResult.Failure` 记录 `effect_unknown` 和对应 CallID;scanner 提交它时必须先有实现内部的失效执行记录。 +9. `ApproveToolCall` 必须匹配目标 Waiting(Approval) Call 保存的 ResponseID 和 kind,产出 `[ToolCallApproved]`(Call 变为 Pending,Loop 随后执行)。`RejectToolCall` 同样必须匹配,产出 `[ToolCallFailed{Known, permission_denied}]`。日志记录的是结果事实,不是请求本身。 +10. 一次响应只推进对应 Call,不能修改其他 Call;ResponseID 和 kind 必须匹配该 Call 保存的请求。响应 payload 的 digest 用于内容冲突检测,不需要等于请求 payload 的 digest。 +11. 使 ToolStep 内最后一个 Call 到达可关闭终态的 command,其事实序列追加 `ToolStepClosed`;若此时 `ModelSteps` 已达冻结的 model-step limit,再追加 `RunEnded{RunStopped, step_limit}`。例如最后一个 Call 完成且触发 limit:`[ToolCallCompleted, ToolStepClosed, RunEnded{RunStopped, step_limit}]`。ToolStep 关闭前不能创建下一 ModelStep。 +12. `CancelRun` 只能作用于非 terminal Run,产出 `[RunEnded{RunStopped, cancelled}]`。 +13. `AcceptInput` 只能作用于 active 且没有当前 Step 的 Run,产出 `[InputAccepted]`。Planner 由 `PlanningHint.Inputs` 收到这些输入并在 `RequestPlan.InputIDs` 中明确消费它们;遗漏或伪造 ID 的 `PrepareModelRequest` 被拒绝。 +14. `Initialize(RunID, RunConfig, RunSeed)` 只在 application admission 创建新 Run 时使用;它建立初始 `MachineState` 并把 seed 输入放入 `PendingInputs`。`RunSeed` 不能传给已有 Run 的 `Decide` 或 `Runtime.Commit`。 + +`InputID` 在一个 Run 内唯一。相同 `InputID` 和相同 payload 的重复 `AcceptInput` 是语义 no-op,Runtime 返回原已接受的事件组;相同 ID 携带不同 payload 返回冲突。Memoh 的 queue claim 仍负责防止同一个 queue item 被多个输入入口同时消费。 + +#### 3.7.2 Evolve 折叠表(事实 -> 状态) + +`Evolve(state, event)` 对每种事实执行固定的机械折叠,不读 RunConfig,不含 policy 分支: + +| 事实 | 折叠 | +| --- | --- | +| ModelStepPrepared | 设置 Current 为 Prepared ModelStep;`ModelSteps+1`;按事实中的 InputIDs 从 `PendingInputs` 移除 | +| ModelStepStarted | Current.Status = Executing | +| ModelStepRecovered | Current.Status = Prepared | +| ModelStepRejected | `Rejects+1`;Current.Status = Prepared;Usage 逐字段累加事实携带的 usage | +| ModelStepCompleted | 写 `LastModelResult`;Usage 逐字段累加;清空 Current | +| ToolStepOpened | 设置 Current 为携带完整 Call 集合的 ToolStep | +| ToolCallStarted | 目标 Call: Pending -> Executing | +| ToolCallApproved | 目标 Call: Waiting -> Pending | +| ToolCallCompleted | 目标 Call: Executing -> Completed(result) | +| ToolCallAnswered | 目标 Call: Waiting -> Completed(answer) | +| ToolCallFailed | 目标 Call -> Failed(outcome, failure) | +| ToolStepClosed | 清空 Current | +| InputAccepted | 按 InputID 幂等追加到 `PendingInputs` | +| RunEnded | 设置 Status 与 `Result`(含 Usage 副本与最近已接受的模型结果);清空 Current | + +事实序列内的折叠按 Index 顺序进行;`RunEnded` 若出现必须是序列的最后一个事实。`ModelStepRecovered` 不改变 Usage 与 `Rejects`。 + +#### 3.7.3 终态与竞态 + +终态按 Runtime 的线性化顺序确定:Cancel 先提交则 RunStopped;Unknown 先提交则 RunFailed,之后的 Cancel 不改变终态。Run 进入 terminal 后,其他并行 Call 的 grant 立即失效,Loop 取消仍在运行的 worker;迟到的完成提交返回 terminal/stale 错误,只能写实现级审计,不能再改变 MachineState;Memoh 必须把这类审计投影到产品可见的 history/审计视图——该工具的外部 effect 已经发生,只落内部日志会让会话记录与外部世界不一致。并行 Call 不引入额外的 settling 状态。 + +`RunStopped` 或 `RunFailed` 的 `RunResult` 保留最近一次已接受的模型结果(如有);`RunCompleted` 的 `RunResult.Model` 是产生正常终态的那次模型结果。取消或未知 effect 不会伪造新的模型结果。 + +`Next` 的主要映射是:无当前 Step -> `NeedModelRequest(PlanningHint{Inputs: PendingInputs})`(model-step limit 在前一个 Step 的提交中已经转换为 terminal);Prepared ModelStep -> `StartModelCall`;ModelStep 正在 Executing -> `WaitForExecutionRecovery`;有 Pending Call 的 ToolStep -> `StartToolCalls`;没有 Pending 且存在 Waiting Call -> `WaitForResponse`(即使另有 Executing Call,也等待 response 或 execution wake);没有 Pending/Waiting 但存在 Executing Call -> `WaitForExecutionRecovery`。Runtime 接受 `StartModelExecution` 或 `StartToolCall` 后,在 CommitResult 中返回一次性 `ExecutionGrant`,Loop 使用该授权调用对应的 ModelInvoker 或 ExecutableTool。Model execution lease 失效后,Runtime 通过仅限 Runtime/recovery 使用的 `RecoverModelExecution` 把 ModelStep 恢复为 Prepared,Loop 才能再次 start。 + +这里的 `WaitForExecutionRecovery` 是一个统一等待结果:它既表示已有 execution 仍可能由原 Loop 持有,也表示该 execution 已失效、等待 Runtime recovery。公共 `LoopResult.Reason` 不暴露 owner、lease 或 Attempt 的细节。 + +## 4. Step 与 ToolCall progress + +### 4.1 ModelStep + +ModelStep 冻结: + +```text +StepID +sdk.Request 及其 digest +ModelRef +本次请求使用的 provider-neutral tool definitions 及 digest +与这些 definition 对应的 agent `ToolSpec`(包含 response policy) +`ToolsDigest`(按 provider definition 顺序覆盖 schema、Ref 和 policy) +执行状态:Prepared / Executing +reject counter(progress,不参与冻结 digest) +``` + +`SubmitModelResult` 被接受后,当前 ModelStep 立即被 ToolStep 替换(`ToolStepOpened`),或因没有 tool calls 而关闭 Run(`RunEnded`);因此模型完成状态不作为当前 Step 状态保存。接受的结果保存在 `LastModelResult`、RunResult 和 application history 中。 + +一个 ModelStep 代表一次模型调用。Loop 默认调用 `ModelInvoker.Generate`;如果实现提供可选的 `StreamingModelInvoker`,Loop 可以用 `Stream` 发送实时 delta,但两条路径必须得到同一种 `sdk.ModelResult`,且 transport retry 不创建新的 Step。 + +Request Planner 生成完整 `sdk.Request` 后,Loop 提交 `PrepareModelRequest`。Runtime 以 revision/CAS 或事务保证只冻结一份请求;新 ModelStep 的 StepID 由 RunID、command identity 和 request digest 稳定派生。已经冻结的请求不受后来 queue 或 history 输入影响。 + +Loop 在 `SubmitModelResult` 时只校验 tool-call ID 与顺序,并从匹配的冻结 `ToolSpec` 生成 `ToolCallBinding`。这里不调用 ExecutableTool;未知工具保留为 `DirectExecution`、空 definition digest 的 unresolved binding,应用级参数错误留到 `StartToolCalls`,作为 Pending Call 的已知失败处理。Runtime 只校验 binding 与冻结请求、模型结果和 Step 身份的一致性,不重复解析工具目录。 + +模型响应的结构性 malformed(重复/错序 CallID、违反 provider 协议)使 Call 集合无法建立:Loop 不提交 `SubmitModelResult`,而以 start grant 提交 `RejectModelResult{Failure.Class: malformed_model_result}`。Decide 产出 `ModelStepRejected`(usage 累计、`Rejects` 加一、Step 回到 Prepared),同一冻结 request 由后续 start 重试;`Rejects` 超过冻结的 `RunConfig.ModelRejectLimit` 时追加 `RunEnded{RunFailed, malformed_model_result}`。被拒绝的结果不写入 `LastModelResult`,不创建 ToolStep。 + +单个 Call 的参数无法解析不属于结构性 malformed:该 Call 以原始参数字节绑定为 Pending,`StartToolCalls` 的参数校验把它关闭为已知 `invalid_arguments`,失败结果进入下一次模型请求,由模型自行修正。未知 ToolRef 同样保留为待处理 Call,start 前记录 `tool_lookup_failed`。 + +### 4.2 ToolStep + +ToolStep 保存: + +```text +source ModelStep ID +原始 ToolCall 顺序 +CallID、ToolRef、canonical arguments +response policy +工具定义 digest +每个 Call 的 binding digest(definition、policy 和 arguments 的摘要) +每个 Call 的 durable progress +``` + +`BindingDigest` 必须覆盖匹配的 `ToolSpec`、canonical arguments 和 CallID;`ToolStepOpened` 创建 ToolStep 时同时保存到 `ToolCallState`,执行前再次校验。 + +每个 Call 的状态为: + +```text +Pending +Executing +Waiting(request) +Completed(result) +Failed(Known, failure) +Failed(Unknown, failure) +``` + +这些状态只保存会影响恢复决策的事实。工具内部 stdout、下载百分比、HTTP 字节数等观察信息只走 EventSink,不写 MachineState。 + +状态字段必须保持互斥且可校验:Pending/Executing 的 Result、Failure、Waiting 都为空;Waiting 必须带 ResponseRequest 且 policy 为 Approval 或 ExternalResponse;Completed 必须带 Result;Failed 必须带 Failure,Unknown outcome 必须使用 `effect_unknown` 且 Run 必须同时为 RunFailed。`Machine.Evolve` 和 Runtime.Load 都必须拒绝非法组合;approval accepted 清除 Waiting 并变为 Pending,answer 清除 Waiting 并变为 Completed,reject 清除 Waiting 并变为已知 Failed。 + +### 4.3 ToolCall 执行规则 + +`StartToolCall` 是外部调用前的 durable barrier。它把 Pending 固定为某个 Loop attempt 正在负责;提交成功后才允许调用工具。这个顺序不能消除“提交后、调用前崩溃”的窗口,因此失去执行权且没有结果时仍按 Unknown 终止 Run,但它能阻止多个 Loop 同时执行同一 Call。 + +1. ModelStep 完成时一次性保存完整 Call 集合(`ToolStepOpened`)。 +2. Completed 和 Failed Call 永远跳过。 +3. Pending Call 必须先提交 `StartToolCall`;Runtime 接受后才允许调用外部工具。 +4. Waiting Call 不阻止其他 Pending Call。Pending Call 是否并行由 Loop 的 ExecutionPolicy 决定。 +5. 每个 Call 完成后立即提交自己的 `SubmitToolResult` 或 `SubmitToolFailure`。 +6. 没有 Pending 或 Executing、仍有 Waiting 时,Loop 返回等待。 +7. 所有 Call 到达 Completed 或已知 Failed 时,Decide 在该次提交的事实序列中追加 `ToolStepClosed`。 +8. Executing Call 在执行权失效且结果未提交时不能自动重做;Runtime 记录 Unknown 并终止 Run。 + +并行是 Loop 的执行策略,不是 ToolStep 固有语义。Machine 只返回可执行的 Pending Call,不推断工具之间是否存在 effect ordering;application 只有在确认一组工具允许并行时才配置大于 1 的并行度。一个 Loop 执行组使用同一份策略: + +```text +Sequential +Parallel +BoundedParallel(n) +``` + +在 API 中统一表示为 `ExecutionPolicy{MaxParallel}`:`1` 表示 Sequential,`n>1` 表示 BoundedParallel(n),`0` 在 Loop 创建时归一化为 `1`,负值在 Loop 创建时拒绝。`Parallel`(不设上限)不作为默认行为;若未来需要,必须另行规定资源上限。 + +`MaxParallel` 只限制一个 Loop 本次实际启动的 worker 数量,不是跨进程的全局并发计数。Runtime 仍以每个 Call 的 start grant 防止重复执行;Memoh 如果需要全局资源限额,必须在自己的调度层另行实现。 + +同一 ToolStep 的结果投影仍按原始 Call 顺序;本版本不定义 Call 之间的依赖边,需要前置结果的调用由后续 ModelStep 产生。 + +### 4.4 approval、ask-user 和外部响应 + +approval 和 ask-user 都是 ToolCall 的 response policy: + +```text +ModelStep + -> ToolStep + approval: Waiting(Approval) + ask-user: Waiting(ExternalResponse) +``` + +外部入口按 response policy 转换成对应的 AgentCommand,日志记录 Decide 产出的结果事实: + +```text +approve -> ApproveToolCall -> ToolCallApproved -> Pending -> 工具执行 +reject -> RejectToolCall -> ToolCallFailed(Known, permission_denied) +answer -> SubmitToolResponse -> ToolCallAnswered -> Completed(answer) +``` + +每个 response 有稳定的 `ResponseID`,由 Machine 在创建 Waiting request 时从 RunID、StepID、CallID 和 response kind 稳定派生;一个 Call 至多有一个未决请求,两个 Runtime 派生结果一致。一次响应只作用于对应 `RunID/StepID/CallID`,不需要旧 Loop 的执行租约,也不消费其他 Call 的执行权。 + +### 4.5 工具结果和失败 + +工具执行返回封闭的三种结果: + +```text +ToolExecutionSucceeded{Result} + ToolExecutionFailed{Failure} // 已知没有完成外部 effect + ToolExecutionUnknown{Failure} // 无法判断外部 effect +``` + +`ToolFailure` 使用稳定的 `Class` 和可选的 `Message`: + +```text +permission_denied +tool_lookup_failed +invalid_arguments +tool_definition_mismatch +execution_failed +effect_unknown +``` + +工具只有在能够确定外部 effect 没有完成时才能返回 `ToolExecutionFailed`;只要结果可能已经产生 effect,就必须返回 `ToolExecutionUnknown`。 + +已知失败作为下一次模型请求中的 `sdk.ToolResultPart{IsError:true}`,保留原始 CallID、工具名、Class 和 Message。模型可以在新的 ModelStep 中再次发起工具调用。Unknown 不投影给模型,不自动重试,Run 直接进入 RunFailed;通用 core 不假设外部系统支持查询或撤销。 + +## 5. Runtime contract + +### 5.1 Runtime 的职责 + +Runtime 只回答两个问题: + +```text +当前权威 MachineState 是什么? +一个 AgentCommand 如何安全地提交并生成 AgentEvent? +``` + +它不组装 prompt,不调用模型或工具,不定义 Machine 规则,不实现 queue policy。Runtime 可以在同一事务中更新 Memoh 的 history、queue projection、response record 和 outbox,但这些是 adapter 的原子投影,不是 Runtime 的语义职责。 + +`Commit` 必须在 authority 的临界区内调用共享的 `EvaluateCommit`(内部执行幂等/类别校验、`Machine.Decide` 和 `Machine.Evolve`),因为“读取状态、验证 command、计算事实与新状态、保存并写入 AgentEvent”不能在 durable 实现中拆成几个由 Loop 拼接的公开操作。这个必要的原子边界不等于 Runtime 拥有 Machine 规则;规则仍只有 agent 一份,也不等于 Runtime 拥有 Memoh 的产品数据。 + +因此 Runtime 的接口很小,但一次 `Commit` 的事务范围可以很大:它必须让一个 AgentCommand、产出的 AgentEvent 组及其必要的产品投影一起成功或一起失败;这不意味着 Runtime 获得了 history、queue 或 prompt 的所有权。 + +MachineState 与 AgentEvent log 是同一个 transition 序列的两个 materialization:MachineState 是 execution authority(提交验证与 Loop 执行的依据),AgentEvent log 是 historical authority(replay、审计与投影的依据)。两者由同一原子提交产生、共享同一 Revision;对任意 Revision N,状态必须等于初始状态按事件流 fold `Evolve` 到 N 的结果。两者出现分歧是 halt 级一致性违规:停止该 Run 的执行并报告,不定义任何一方自动覆盖另一方。 + +一个 Runtime 实例服务一个 Run;多个 Run 由上层创建多个 Runtime 实例。Run 的创建、身份分配和初始 +`MachineState` 由 application/Memoh admission 完成,Runtime 从一个已经有效的初始状态开始。 +Loop 是对这个 Run 的一次进程执行。 + +### 5.2 最小接口 + +```go +type Runtime interface { + Load(context.Context) (RuntimeSnapshot, error) + Commit(context.Context, CommitRequest) (CommitResult, error) +} +``` + +`Load` 是纯读取,只返回权威状态和 Revision;它不创建 Attempt、不取得 lease,也不因为另一个 execution 正在运行而返回 busy。`Commit` 接受一个 AgentCommand;Runtime 在自己的同步/事务边界内调用共享 `EvaluateCommit`、保存结果并写入 AgentEvent 组。只有 start command 的 `Commit` 可以建立执行占用并返回授权,不增加第三个“执行”方法。 + +因此 Executing 不是 Runtime error。Loop 从 snapshot 调用 `Machine.Next` 后得到 `WaitForExecutionRecovery`;ToolStep 同时存在 Executing 与 Pending Call 时,Machine 仍返回可执行的 Pending Call;同时存在 Executing 与 Waiting Call 时,Machine 仍返回等待请求。不能用一个 run-level busy 锁住整个 ToolStep。 + +请求构造不在 Runtime 接口中。Loop 使用 `NeedModelRequest` 提示调用 Request Planner,再把完整请求作为 `PrepareModelRequest` 提交。 + +Runtime 的语义范围仍然只有 authority 和 commit。MemohRuntime 为了保持 AgentEvent、MachineState、history projection 和 outbox 的一致性,可以在自己的数据库事务内一起写入这些投影;这不是 Runtime 对外暴露的通用业务 API,也不让 Runtime 获得 prompt、queue 或 history 的所有权。 + +### 5.3 Snapshot、Commit 和执行授权 + +```go +type RuntimeSnapshot struct { + State MachineState + Revision uint64 // 已接受的 transition 数;初始状态为 0 +} + +// The representation is implementation-defined. Callers only pass it back; +// it is not a Step identity, a durable domain value, or a user credential. +type ExecutionGrant string + +type CommitRequest struct { + BaseRevision uint64 + Grant ExecutionGrant + Command CommandEnvelope +} + +type CommandEnvelope struct { + SchemaVersion uint16 + Type string + RunID RunID + ID CommandID + Digest Digest + Command AgentCommand +} + +// AgentEvent carries one fact produced by an accepted command. All events of +// one transition share the same Revision, CommandID and CommandDigest; Index +// orders them within the transition. Identity and ordering are assigned by the +// authority and are not supplied by callers. Run admission has a separate +// record because it initializes a new Run rather than advancing an existing one. +type AgentEvent struct { + SchemaVersion uint16 + Type string + RunID RunID + Revision uint64 + Index uint16 + CommandID CommandID + CommandDigest Digest // digest of the accepted command; idempotent replay compares against it + Digest Digest // canonical digest of this fact + Fact Fact +} + +type CommitResult struct { + Status CommitStatus + Snapshot RuntimeSnapshot + Events []AgentEvent // Accepted 或 AlreadyApplied 时为该 transition 的完整事件组 + Grant ExecutionGrant // 仅 Accepted 的 start command 会返回;AlreadyApplied 为空 +} + +func EncodeCommand(CommandEnvelope) ([]byte, error) // 不包含 Digest 字段 +func DigestCommand(schemaVersion uint16, typ string, command AgentCommand) (Digest, error) +func EncodeFact(schemaVersion uint16, typ string, fact Fact) ([]byte, error) +func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) +func EncodeRunSeed(RunSeed) ([]byte, error) +func DigestRunSeed(schemaVersion uint16, seed RunSeed) (Digest, error) +func DigestRequest(sdk.Request) (Digest, error) +func DigestToolDefinition(sdk.ToolDefinition) (Digest, error) +func DigestToolSpec(ToolSpec) (Digest, error) +func DigestToolSpecs([]ToolSpec) (Digest, error) +func DigestModelStepBinding(model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) +func DeriveModelRequestCommandID(RunID, uint64) CommandID +func DeriveModelStepID(RunID, CommandID, Digest) StepID +func DeriveToolStepID(StepID, Digest) StepID +func DeriveResponseID(RunID, StepID, CallID, ResponseKind) ResponseID +func DeriveResponseCommandID(RunID, StepID, CallID, ResponseID) CommandID +func DeriveInputCommandID(RunID, InputID) CommandID +``` + +`ExecutionGrant` 是 Runtime 返回的 opaque capability,只用于证明当前 Loop 获得了执行许可;调用方只保存并原样传回,不依赖其内容。它不是 Step 的业务字段,不暴露 AttemptID、FenceToken、lease 或数据库类型,也不是用户认证凭证。Runtime 必须生成不可预测且绑定到单个 Step/Call 和当前执行所有者的值,并在完成 command 中校验它。MemoryRuntime 可以用 mutex 加随机 generation 实现它;MemohRuntime 可以用 owner/fence/lease 实现它。 + +`Attempt` 只表示一次进程对当前 ModelStep 或 ToolCall 的执行占用,不是 MachineState,也不是恢复边界。一个 Step 可以先后有多个 Attempt;旧 Attempt 失效后,新的 Loop 重新读取同一个 Step。MemoryRuntime 的 Attempt 是内存中的占用记录,MemohRuntime 的 Attempt 是私有 lease/fence 记录。 + +grant 规则固定为: + +```text +PrepareModelRequest、StartModelExecution、StartToolCall + 使用空 Grant;start command 只有 CommitAccepted 才返回新的 Grant。 + +SubmitModelResult、SubmitModelFailure、RejectModelResult、 +Executing Call 的 SubmitToolResult/SubmitToolFailure + 必须带回对应 start command 返回的非空 Grant;Pending Call 的已知失败使用空 Grant。 + +ApproveToolCall、RejectToolCall、SubmitToolResponse、CancelRun、AcceptInput + 使用空 Grant;Runtime 依据状态、Revision 和 command 身份校验。scanner 的 Unknown 和 + 无 grant 的 RecoverModelExecution 还必须匹配 Runtime 自己记录的 lease-expired/recovery + 事实,不能仅凭调用者构造同名 command。当前 Loop 主动释放自己持有的 Model grant 时, + RecoverModelExecution 必须带对应非空 Grant。 +``` + +Grant 只绑定一个 ModelStep 或一个 ToolCall,不能挪给另一个 Call。外部 response 不需要把入口伪装成 Loop attempt。 + +### 5.4 Commit 语义 + +提交决策是一个由 agent 导出的纯函数;两个 Runtime 在各自的临界区/事务内调用同一份实现,不各自复刻规则: + +```go +type CommitDecision struct { + Kind DecisionKind // Apply | AlreadyApplied | Conflict | Stale | Terminal + NewState MachineState + Events []AgentEvent +} + +// grantValid/recoveryValid 由 Runtime 依据自己的 lease/occupancy 记录判定后传入; +// prior 是相同 (RunID, CommandID) 的已有事件组(若有)。 +func EvaluateCommit( + cur MachineState, curRevision uint64, + prior []AgentEvent, + req CommitRequest, + grantValid bool, + recoveryValid bool, +) (CommitDecision, error) +``` + +`EvaluateCommit` 的固定顺序: + +1. 校验 CommandEnvelope 的 canonical digest 和 command identity。 +2. 已有相同 `(RunID, CommandID)` 且 digest 相同,返回 `AlreadyApplied` 与原事件组,不重复写入任何 projection 或 outbox,不重新运行 Decide(决策在首次接受时冻结)。 +3. 相同 identity 携带不同 digest,返回 `ErrCommandConflict`。 + 对 `AcceptInput`,还按 RunID/InputID 检查已接受索引:相同 payload 返回原事件组和 `CommitAlreadyApplied`,不同 payload 返回冲突,不产生第二条输入事实。 +4. 校验 BaseRevision、当前 Step、CallID 和 Grant。BaseRevision 只对 `PrepareModelRequest` 是硬校验——其 CommandID 由 Revision 派生,Revision 即它的并发控制,过期即返回 `ErrStaleRuntime`。其余 command 在 BaseRevision 过期时按类别前置条件基于当前状态重新评估:start(`StartModelExecution` 目标仍须为同一 Prepared ModelStep;`StartToolCall` 与 Pending 的已知失败目标 Call 仍须为 Pending,均空 Grant);owner 完成(Executing Call 的完成/失败、`SubmitModelResult`/`SubmitModelFailure`/`RejectModelResult`,以及持有效 Model grant 的 `RecoverModelExecution`,以提交者仍持有对应有效 Grant 为条件);system recovery(无 grant 的 `RecoverModelExecution` 和 scanner 的 Unknown,以 Runtime 自己的 recovery record 为条件);ingress(approval/external response 目标 Call 仍须为对应 Waiting 且 ResponseID/kind 匹配;`AcceptInput` 要求 Run 仍 active 且没有当前 Step,连续多条输入互不拒绝;均空 Grant);run-control(`CancelRun` 只要求 Run 非 terminal)。前置条件不满足时按具体原因返回 stale/terminal/冲突。start command 建立 grant/lease 的动作与状态提交属于同一个原子操作。 +5. 调用 `Machine.Decide` 产出事实序列,逐个 `Machine.Evolve` 折叠出新状态;为这次 transition 分配 `Revision = curRevision + 1`,事实按序获得 `Index = 0..k-1`,全部携带产生它们的 CommandID。 +6. Runtime 在自己的原子边界内保存 MachineState、AgentEvent 组及需要一致的 Memoh projection。 +7. 非重复 command 若目标 Run 已经 terminal,返回 `ErrRunTerminal`;迟到的 worker 结果不会重新打开 Run。其他提交成功后返回新 snapshot。 + +`EvaluateCommit` 用 `DecisionKind` 表达结果;`Runtime.Commit` 把非成功结果映射为对外错误:`DecisionConflict -> ErrCommandConflict`、`DecisionStale -> ErrStaleRuntime`、`DecisionTerminal -> ErrRunTerminal`。Loop 与 ingress 只依赖这三个错误值和 `CommitStatus`,不接触 DecisionKind。 + +`RunSeed` 是新 Run admission 的初始化输入,不属于任何 Run 的 `AgentCommand`,也不通过旧 Run 的 `Runtime.Commit`。`Initialize` 使用与 Machine 相同的不变量以及统一的 identity/digest 编码规则建立初始 `MachineState`(Revision=0);普通 Runtime 只实现已有 Run 的 `Load/Commit`,admission 路径负责应用 `RunSeed`。 + +Commit 的 effectively-once 只针对状态提交。事务已提交但响应丢失时,Loop 使用相同 CommandID 和 digest 重放;`CommitAlreadyApplied` 不能重新授予工具执行权,也不能重复 history、queue action、计数或 outbox。对于 start command,AlreadyApplied 的 `Grant` 必须为空;新 Loop 要等待原执行或 recovery,不能把重放当成新的 start。外部工具 effect 不因此变成 exactly-once。 + +这意味着 start 提交成功但响应丢失时,无法证明重试者仍是原 execution owner;Runtime 不安全地重新发放旧 grant。重试者只能等待原 owner 完成,或等待 lease recovery 将该 Call 收束为 Unknown。这是为了避免两个进程同时执行同一个外部工具的保守可用性取舍。 + +这条 call-local rebase 是并行执行成立的条件:A 的 start 使 Run 的 Revision 前移后,仍基于旧 Revision 提交的 B start 可以在 B 仍为 Pending 时被接受;A 的变化不能使 B 的 start 条件失效。B 的完成 command 也可以在 A 的无关 transition 之后提交,但必须带 B 自己的有效 Grant。重新评估不能跨越使前置条件失效的变化:同一 Call 已被推进、当前 Step 已关闭、Run 已因 Cancel 或 Unknown 进入 terminal 时,迟到的 command 按具体原因返回 stale/terminal,不静默改写。一个 Call 进入 Unknown 并使 Run terminal 后,其他 Call 的完成 command 不再改变 MachineState。 + +### 5.5 Command identity 和幂等 + +幂等是为了处理“提交已经成功,但 Loop 没收到响应”的情况: + +```text +Loop -> Commit(command C) +Runtime 已提交 C(事实组落地) +进程在收到响应前崩溃 +新 Loop -> 重放同一个 C +Runtime -> CommitAlreadyApplied + 原事件组 +``` + +command 身份规则: + +```text +RunID 和 CommandID 始终非空,且是区分大小写的 UTF-8 字符串。包含 Step、Call、Response 或 Input 的 command 必须提供相应的非空 ID;CancelRun 等 run-level command 不伪造 StepID/CallID。 + +Digest + 是 agent canonical bytes 的 SHA-256,wire 形式为 sha256:<64 位小写十六进制>。 + CommandEnvelope.Digest 摘要 command 内容;AgentEvent.Digest 摘要事实内容。 + +普通 CommandID + 由产生意图的执行者生成,重试时必须复用。 + +每个 AgentCommand 都有自己的 CommandID;同一个 execution attempt 的 start、completion 和 recovery 是不同 command,因此各自使用不同 ID。某个 command 因响应丢失而重放时必须复用原 ID。Runtime 以当前 Step/Call 状态和 CAS 防止第二个 attempt 获得执行权;ModelStep 经 recovery 回到 Prepared 后,下一次 start 使用新的 ID。 + +`RecoverModelExecution` 有两条合法来源:当前 Loop 持有效 Model grant 且模型调用未产生可接受结果时,使用该 grant 主动释放 execution;或者 Runtime 在 lease 失效后依据自己的 recovery record 提交。后者的 CommandID 由 Runtime 根据 recovery record 生成;同一个 recovery record 重放使用同一个 ID,不同的失效执行使用不同 ID。新的 Loop 随后重新提交同一冻结 request 的 start command。 + +response CommandID + 只由 RunID、StepID、CallID 和 ResponseID 稳定派生;payload/decision digest + 只放在 CommandEnvelope.Digest,用于检测同一响应身份的内容冲突。 + +input CommandID + 由 RunID 和 InputID 稳定派生;queue claim 的私有 item reference + 不进入 agent command/event payload。 + +AgentEvent 身份 + 由 authority 分配:(RunID, Revision, Index) 全序唯一,CommandID/CommandDigest 关联到 + 产生它的 transition——重放判定即比较传入 command 的 digest 与已存事件组的 + CommandDigest,Runtime 不需要独立的 command 索引表。事件身份不由调用方提供, + 也不参与 command 幂等判定。 +``` +``` + +canonical 编码和 digest 函数由 agent 提供;Memoh 只保存和比较结果,不重新实现排序或编码。编码必须包含 sealed command/fact discriminator、按声明顺序编码有序 slice、对 map key 排序,并对 `json.RawMessage` 使用 canonical JSON;不把 `Digest`、BaseRevision、Revision、Index 或 ExecutionGrant 编入 digest。 + +已发布 `SchemaVersion` 的 canonical 编码和 digest 规则永久冻结;字段增删只能进入新的 SchemaVersion,旧事件按其自带版本校验。同一个 Run 不允许由写入不同 SchemaVersion 的进程混跑:升级窗口内先全量部署可读写新版本的代码,再开始写入新版本;否则同一 command 的重放会因编码不同被误判为 `ErrCommandConflict`。 + +CommandEnvelope 和 AgentEvent 的 `SchemaVersion` 和 `Type` 是持久化协议字段;Type 必须与 sealed AgentCommand/Fact 的具体变体一致,未知版本或类型直接拒绝。`DigestCommand`/`DigestFact` 对 `SchemaVersion`、`Type` 和内容做 canonical digest,但不把 `Digest` 字段自身纳入摘要,保证 Memoh scanner、MemoryRuntime 和不同进程使用同一身份规则。`Revision` 只用于 authority 的 CAS,不进入任何 digest。 + +Evolve 的折叠语义与事件编码同属永久兼容契约:已发布 SchemaVersion 的事件必须永远能被折叠出与写入当时相同的状态。conformance kit 为每个已发布 SchemaVersion 冻结 golden event stream 与对应的状态字节,任何 Evolve 实现变更都必须通过全部历史版本的 golden 校验。 + +### 5.6 Runtime 不拥有 Planner + +agent 只声明供 Loop 依赖注入的 `RequestPlanner` port;Planner 的实现和语义属于 +application/Memoh。Runtime 不调用这个 port,也不读取 Planner 的 context: + +```go +type RequestPlanner interface { + Plan(context.Context, PlanningHint) (RequestPlan, error) +} + +type RequestPlan struct { + Model ModelRef + Request sdk.Request + InputIDs []InputID + Tools []ToolSpec + PlanningToken PlanningToken // application-owned freshness token +} + +type PlanningHint struct { + RunID RunID + Model ModelRef + SourceStep StepID + Inputs []AgentInput +} +``` + +Planner 可以读取 application 自己的 history、memory、workspace 和 queue-safe 输入,但不直接修改 Runtime。它返回 `RequestPlan`(完整 `sdk.Request`、ModelRef、已消费的 InputID 集合和 application-owned `PlanningToken`);Loop 随后提交 `PrepareModelRequest`。Memoh 在事务外构造请求,在提交时用自己的 context revision/CAS 和 create-if-absent 确保并发 Planner 只冻结一份结果。`PlanningToken` 只是供 adapter 验证 planner 输入是否新鲜的 opaque token;agent 不解释其内容,也不把它当成 authority revision。 + +Planner 所需的 history 必须由宿主提供:Memoh 从 durable history projection 读取;in-process +调用者可以用一个简单的内存 history projection 或 planner 自己持有的会话上下文。Runtime +不负责把 AgentEvent 推送给 Planner,也不因此新增 history/store 方法。 + +`PrepareModelRequest` 必须使用 Planner 开始前 Load 得到的 BaseRevision,并按当前顺序携带完整的 `PendingInputs` `InputIDs` 集合。相同 RunID 和 Revision 的并发 Planner 使用同一个 `DeriveModelRequestCommandID`:相同请求得到 `CommitAlreadyApplied`,不同请求得到 `ErrCommandConflict`;如果 Planner 在新的 Revision 上重试,则生成新的 CommandID。Memoh adapter 同时检查 `PlanningToken` 的 context revision,后到者不能覆盖已经冻结的请求。 + +Loop 校验 `RequestPlan.Model` 与 `PlanningHint.Model`、RunConfig.Model 一致;Runtime 通过共享 Decide 规则再次校验 `PrepareModelRequest.Model` 与冻结的 RunConfig 一致。Planner 的 context 一致性由 Memoh 在自己的 queue-safe admission/planning 边界保证,不由 agent 解释。 + +### 5.7 外部 response + +agent 不提供第三个 Loop 操作。Memoh/application 的 response ingress: + +1. 验证用户权限、Run/Step/Call 身份和 payload。 +2. 读取 authority snapshot,确认目标 Call 仍为 Waiting;这个 ingress 读取不取得 Loop 的执行租约。 +3. 用 `ResponseRequest.RunID/StepID/CallID/ID` 路由响应。将 approval 转成 + `ApproveToolCall`/`RejectToolCall`,将外部结果转成 `SubmitToolResponse`; + `ResponseRequest.RequestDigest` 是原请求的摘要,用户决定或答案的摘要单独放在 + command 的 `ResponseDigest`,并用 `DeriveResponseCommandID` 生成稳定 CommandID。 +4. 通过同一个 `Runtime.Commit` 提交;重复响应按 AlreadyApplied/Conflict 处理。 + +响应提交后,若有 Pending Call 或 ToolStep 已可自动关闭,Memoh 写入幂等 wake/outbox;其他 Waiting Call 保持原状态。响应入口不执行工具,也不调用模型。 + +如果同一 ToolStep 还有 Executing Call,响应提交仍写入自己的 wake;执行 worker 的完成提交或 recovery 也必须写 wake。下一次 Loop 从 `Load` 重新判断两类事实,不要求把它们合并成一个等待状态。 + +### 5.8 queue 边界 + +steer/follow-up 的数据结构和仲裁属于 Memoh: + +```text +ModelStep 完成且没有 tool calls +ToolStep 自动关闭 +``` + +只有在这些 boundary,Memoh 才能把 queue 输入绑定到下一次 Planner 请求或创建后续 Run。ToolStep 中间的新输入不能修改已经冻结的 ModelStep,也不能跳过 Pending/Waiting Call。 + +steer 必须赶上下一个 ModelStep,而不是落到更晚的边界。Loop 在 boundary 处的 Plan/Prepare 提交与 Memoh 的 queue 仲裁存在竞态;MemohRuntime adapter 用以下 gate 消除它:处理 `PrepareModelRequest` 的同一事务内检查是否存在 eligible 的 steer item,存在时不接受该次 Prepare,先在事务内应用对应的 `AcceptInput`(Revision 递增,Prepare 按 `ErrStaleRuntime` 返回)。Loop 重新 Load 后由 `PlanningHint.Inputs` 携带该输入重新规划。这条 gate 是 adapter 行为,不进入 agent Machine 规则;in-process 宿主没有 queue,输入由宿主在 Loop 空闲边界提交。 + +## 6. Loop 算法 + +### 6.1 Loop 结构 + +```go +type Loop struct { + Models ModelCatalog + Tools ToolCatalog + Planner RequestPlanner + Execution ExecutionPolicy + Streaming bool +} + +func (l *Loop) Run(context.Context, Runtime, EventSink) (LoopResult, error) +``` + +Loop 是当前进程的解释器。它不复制权威状态,不直接访问数据库或 Memoh queue。 + +Loop 使用一个用于读取/提交的 `controlCtx`,并为每个模型或工具 worker 派生独立的执行 context。`controlCtx` 由宿主提供,或由 `context.WithoutCancel` 再加一个有限 deadline 得到;它不能因为单个 worker 取消而失效。worker 被取消后,Loop 仍必须用未被 worker 取消的 control context 提交已知结果、Unknown 或 model recovery。整个进程退出时,MemoryRuntime 的内存状态当然不会保留。 + +两类 worker 的执行 context 派生规则不同。模型 worker 的执行 context 从外层 ctx 派生:取消模型调用是安全的,未接受结果时以 `RecoverModelExecution` 释放,同一冻结 request 之后重试。工具 worker 的执行 context 不随外层 ctx 取消(由 `context.WithoutCancel(ctx)` 加每次执行的 deadline 派生):工具一旦 start 就运行到自身结束,外层 ctx 取消不会把本可正常完成的工具打断成 Unknown。工具 worker 只在两种情况下被主动取消:Run 已进入 terminal(其迟到结果按 terminal/stale 审计),或同组另一个 worker 报告 Unknown。 + +### 6.2 主算法 + +```text +Loop.Run(ctx, runtime, events): + outer: + for: + if ctx is cancelled: + return LoopResult{}, ctx.Err() // 已 start 的本地 worker 先按 6.1 的派生规则收束 + snapshot, err := runtime.Load(controlCtx) + if err != nil: + return err + if snapshot.State.Status is terminal: + return LoopFinished(snapshot.State.Result) + + effect, err := Machine.Next(snapshot.State) + if err != nil: + return err + switch effect: + case NeedModelRequest(hint): + plan, err := l.Planner.Plan(ctx, hint) + if err != nil: + return err + requestDigest, err := DigestRequest(plan.Request) + if err != nil: + return err + toolsDigest, err := DigestToolSpecs(plan.Tools) + if err != nil: + return err + bindingDigest, err := DigestModelStepBinding(plan.Model, requestDigest, toolsDigest) + if err != nil: + return err + commandID := DeriveModelRequestCommandID(snapshot.State.RunID, snapshot.Revision) + stepID := DeriveModelStepID(snapshot.State.RunID, commandID, bindingDigest) + prepared, err := commit( + PrepareModelRequest{ + StepID: stepID, Model: plan.Model, Request: plan.Request, + RequestDigest: requestDigest, InputIDs: plan.InputIDs, + PlanningToken: plan.PlanningToken, Tools: plan.Tools, + ToolsDigest: toolsDigest}, + commandID=commandID, baseRevision=snapshot.Revision, grant=zero) + if err == nil and (prepared is Accepted or AlreadyApplied): + continue outer loop + if err == ErrCommandConflict or err == ErrStaleRuntime: + continue outer loop + if err != nil: + return err + + case StartModelCall(stepID): + startID := freshCommandIDForThisCommand() + start, err := commit(StartModelExecution{StepID: stepID}, commandID=startID, + baseRevision=snapshot.Revision, grant=zero) + if err == ErrStaleRuntime or err == ErrRunTerminal: + continue outer loop + if err != nil: + return err + if start is AlreadyApplied: + continue outer loop + modelStep := start.Snapshot.State.Current.(ModelStep) + invoker, err := l.Models.Resolve(modelStep.Model) + var completion AgentCommand + completionID := freshCommandIDForThisCommand() + if err != nil: + completion = SubmitModelFailure{StepID: stepID, Failure: StepFailureForModel(err)} + else: + modelResult, invokeErr := invokeModel(invoker, workerCtx, modelStep.Request, l.Streaming, events) + if invokeErr != nil and worker context was cancelled: + completion = RecoverModelExecution{StepID: stepID} + else if invokeErr != nil: + completion = SubmitModelFailure{StepID: stepID, Failure: StepFailureForModel(invokeErr)} + else: + bindings, bindErr := bindToolCalls(modelResult, modelStep.Request, modelStep.Tools) + if bindErr != nil: + completion = RejectModelResult{StepID: stepID, Usage: modelResult.Usage, + Failure: StepFailure{Class: FailureMalformedModel, Message: bindErr.Error()}} + else: + completion = SubmitModelResult{StepID: stepID, Result: modelResult, Calls: bindings} + applied, err := commit(completion, commandID=completionID, + baseRevision=start.Snapshot.Revision, grant=start.Grant) + if err == ErrStaleRuntime or err == ErrRunTerminal: + continue outer loop + if err != nil: + return err + if applied is Accepted or AlreadyApplied: + continue outer loop + continue outer loop + + case StartToolCalls(stepID, callIDs): + startedWorkers := [] + for each call in selectByPolicy(callIDs, l.Execution): + tool, resolveErr := l.Tools.Resolve(call.ToolRef) + argErr := nil + policyErr := nil + definitionErr := nil + if resolveErr == nil: + if tool.Ref() != call.ToolRef or DigestToolDefinition(tool.Definition()) != call.DefinitionDigest: + definitionErr = definitionMismatch + argErr = tool.ValidateArguments(call.Arguments) + if tool.ResponsePolicy() != call.Policy: + policyErr = definitionMismatch + } + if resolveErr != nil or argErr != nil or policyErr != nil or definitionErr != nil: + failed, err := commit(SubmitToolFailure{StepID: stepID, CallID: call.CallID, + Failure: ToolFailureFor(resolveErr, argErr, policyErr, definitionErr), Outcome: ToolOutcomeKnown}, + commandID=freshCommandIDForThisCommand(), baseRevision=snapshot.Revision, grant=zero) + if err == ErrStaleRuntime or err == ErrRunTerminal: + launchAndSettle(startedWorkers) + continue outer loop + if err != nil: + return err + if failed is Accepted or AlreadyApplied: + continue with next call + continue with next call + startID := freshCommandIDForThisCommand() + start, err := commit(StartToolCall{StepID: stepID, CallID: call.CallID}, commandID=startID, + baseRevision=snapshot.Revision, grant=zero) + if start is Accepted: + worker := launch the resolved `l.Tools` ExecutableTool for callID, + retaining start.Grant for completion + startedWorkers.append(worker) + else if err == ErrStaleRuntime or err == ErrRunTerminal: + launchAndSettle(startedWorkers) + continue outer loop + else if err != nil: + return err + else if start is AlreadyApplied: + continue with next call without launching it + launch all accepted workers, then wait for every worker started by this Loop branch. + Workers already in Executing state belong to another live or recoverable + attempt; this branch neither launches nor waits on them. + As each worker returns, immediately commit its SubmitToolResult or SubmitToolFailure, + using that Call's start grant; a stale BaseRevision is eligible for Call-local rebase. + If one worker reports Unknown, cancel the other workers and do not commit their late results. + After all workers started by this branch have settled or been cancelled, + continue outer loop; a subsequent Load decides whether the remaining + Executing calls require recovery or whether more Pending calls can start. + + case WaitForResponse(requests): + return LoopWaiting(requests) + + case WaitForExecutionRecovery: + return LoopWaiting(ExecutionRecovery) + + // Every accepted event is followed by Load; Loop does not keep a + // second authoritative MachineState. +``` + +外层 ctx 取消不会让 Loop 立即返回:模型 worker 随之取消并以 `RecoverModelExecution` 释放,工具 worker 按 6.1 的规则运行到自身结束并提交结果,随后循环顶部的 ctx 检查返回 `ctx.Err()`。取消只结束本次 Loop 执行,不改变 Run 状态。 + +`commit` 为每个 AgentCommand 生成一次 CommandID;如果提交响应丢失,使用同一个 CommandID 和 digest 重放。`CommitAlreadyApplied` 表示对应事件组已经落地,不能再次执行相应外部 effect;Loop 重新 Load 后根据权威状态决定下一动作。 + +伪代码中的 `commit(command, commandID, baseRevision, grant)` 是一个小型 Loop helper:它补齐 CommandEnvelope 的 Type、SchemaVersion 和 `DigestCommand`,然后调用唯一的 `Runtime.Commit`。构造 CommandEnvelope 与派生 ID 只能通过 agent 提供的 typed 构造函数与 helper;任何代码不得手工拼装信封字段。 + +`launchAndSettle(startedWorkers)` 是伪代码 helper:它等待已经接受 start 的 worker,并为每个 worker 提交完成/已知失败;worker 无法确定结果时提交 Unknown。它不能简单丢弃 worker 或 grant。 + +`freshCommandIDForThisCommand()` 为一次具体的 AgentCommand 生成新 ID,并在该提交重试范围内保存;同一 execution attempt 的 start、completion 和 recovery 是不同 command,因此各自拥有不同 ID。 + +一旦某个 `StartToolCall` 已被接受,当前分支必须启动并收束该 worker,或在无法启动时提交对应的已知失败/恢复结果;不能因为后续 Call 的 start 返回 stale/terminal 就遗弃已经返回的 grant。伪代码中的 `continue outer` 只有在先处理完本分支已接受的 start(或明确取消并提交其结果)后才允许执行。 + +`ToolFailureFor` 将 resolve error 映射为 `tool_lookup_failed`,参数校验 error 映射为 `invalid_arguments`,tool ref、response policy 或 definition digest 不匹配映射为 `tool_definition_mismatch`。 + +`invokeModel` 是说明性 Loop helper:开启 streaming 且 invoker 支持 `StreamingModelInvoker` 时消费 stream +并向 EventSink 发送 delta,否则调用 `Generate`;两条路径都只返回一个完整 `sdk.ModelResult`。 + +所有 completion commit 都遵循同一错误处理:`ErrStaleRuntime`/`ErrRunTerminal` 只触发重新 `Load` 并丢弃迟到结果,其他错误返回给调用方;只有 `CommitAccepted` 或相同 command 的 `CommitAlreadyApplied` 才表示该事实已被 authority 接受。接受后 Loop 可以把 `CommitResult.Events` 逐个包装为 `Event{Kind: EventAgentCommitted, Durability: EventCommitted, Canonical: &e}` 发送给 EventSink;发送失败不回滚提交。 + +### 6.3 模型调用 + +```text +NeedModelRequest + -> Planner.Plan + -> Commit(PrepareModelRequest) + -> Commit(StartModelExecution) + -> sdk.Generate 或 sdk.Stream + -> Commit(SubmitModelResult、SubmitModelFailure 或 RejectModelResult) +``` + +Loop 的 `Streaming` 选项决定是否优先使用可选的 `StreamingModelInvoker`;未开启或没有该实现时使用 `Generate`。stream 中的文本 delta 可以实时发给 EventSink;只有完整 `ModelResult` 的完成提交会改变 MachineState。模型请求失败时由 Machine 的 Decide 决定是否将 Run 置为 RunFailed;provider transport retry 不穿透到 Machine。 + +### 6.4 工具调用 + +```text +ToolStep.Pending + -> Commit(StartToolCall) + -> ExecutableTool.Execute + -> Commit(SubmitToolResult / SubmitToolFailure) +``` + +Loop 先解析 ToolRef、校验参数、工具定义 digest、response policy 和 binding digest,再提交 start command。多个独立 Pending Call 可以按 Loop 的 `ExecutionPolicy` 并行;每个 execution attempt 有自己的 CommandID 和 ExecutionGrant。如果解析或参数校验已经失败,直接提交 Pending Call 的已知 `SubmitToolFailure`,不经过 start barrier,也不调用外部工具。 + +如果 start command 已经成功但响应丢失,Loop 不能把 `AlreadyApplied` 当作新的执行授权;它必须重新 Load。旧执行仍被确认拥有时,新的 Loop 返回 `LoopWaiting(ExecutionRecovery)`;执行权失效后由 Memoh recovery 处理为 Unknown。这样即使崩溃发生在 start barrier 与真正调用之间,也不会无凭据地重复执行工具;代价是该 Call 按 Unknown 终止当前 Run。 + +### 6.5 等待与并行 + +```text +Pending + Waiting + -> 先执行 Pending,Waiting 不阻塞 + +只有 Waiting,没有 Pending + -> LoopWaiting(WaitingForResponse) + -> 若同时有 Executing,response 和 execution recovery 都必须唤醒下一次 Loop + +只有 Executing + -> 仍有有效 owner/lease 时等待当前 worker;执行权失效后等待 Runtime recovery + +全部 Call terminal + -> Machine 自动关闭 ToolStep +``` + +不同 Call 的 response 可以并行到达。一个 response 不会取消或轮换其他 Call 的执行授权。每次重新唤醒都从 Runtime.Load 开始。 + +一个 Loop 在自己成功 start 的 worker 尚未结束时不会返回 `LoopWaiting`;它会等待该 worker +提交完成/失败,或提交 recovery。只有加载同一 Run 的后续 Loop,才可能看到 +`Waiting + Executing` 并返回 `WaitingForResponse`;原 worker 的完成事件或 Runtime recovery +通过 Memoh outbox、scanner,或 in-process 的宿主唤醒下一次 Loop。LoopResult 不携带 worker +句柄,也不要求新 Loop 接管旧 Attempt。 + +### 6.6 错误、取消和 limit + +| 情况 | 处理 | +| --- | --- | +| provider transport/rate limit | sdk 在一次模型调用内处理;最终失败由 Machine 记录。 | +| 结构性 malformed 模型结果 | 提交 `RejectModelResult`,Step 回到 Prepared 重试;超过 `ModelRejectLimit` 后 RunFailed。 | +| tool lookup/参数错误 | 提交已知 `SubmitToolFailure`,交给下一次模型请求。 | +| 工具明确失败 | 提交已知 `SubmitToolFailure`,模型决定是否在新 Step 重试。 | +| 工具结果未知 | 提交 Unknown,RunFailed;不自动重试,不创建下一 ModelStep。 | +| 外层 ctx 在工具 start 前取消 | 不再 start 新 Call;已 start 的 worker 运行到结束并提交,Loop 随后返回 `ctx.Err()`。 | +| 外层 ctx 在模型执行中取消 | 模型 worker 取消,提交 `RecoverModelExecution`,Loop 返回 `ctx.Err()`。 | +| 进程崩溃留下 Executing Call | Runtime recovery 按 §8.2 收束为 Unknown。 | +| application CancelRun | 宿主必须先提交 `CancelRun`(Run 置为 RunStopped),再取消 Loop 的 ctx;顺序颠倒会让执行中的工具收束为 Unknown,把用户停止错记为 `RunFailed(effect_unknown)`。 | +| stale Revision/grant | 丢弃本地结果,重新 Load;不重放旧 Step 的外部 effect。 | +| Commit 响应未知 | 同一 CommandID/digest 重放一次;仍未知则结束当前 Loop,后续 Load 读取 authority。 | + +计划内停机(部署、滚动升级)不走崩溃路径:宿主收到停止信号后取消 Loop 的外层 ctx,按上表语义排空——不再 start 新 Call,已 start 的工具 worker 在停机 grace 内提交完成/失败,模型执行以 recovery 释放。grace 内未能结束的工具执行才留给 lease recovery 收束为 Unknown。 + +Run 创建时冻结 `RunConfig.ModelStepLimit` 和 `RunConfig.ModelRejectLimit`;Loop 不持有第二份计数器。`ModelStepLimit` 的 `0` 表示无限;达到正数上限后不创建新的 ModelStep,已打开 ToolStep 仍完成或等待,随后由 Machine 结束 Run。`ModelRejectLimit` 的 `0` 在 `Initialize` 时归一化为默认值 `2`,负值无效;不提供无限值——结构性 malformed 的无限重试是无上界的成本。 + +## 7. Tool、approval、response 和 MCP + +### 7.1 Tool contract + +`sdk.ToolDefinition` 只描述 provider 可发现的 schema,不依赖 agent,也不携带 `ResponsePolicy`。`agent.ExecutableTool` 描述应用如何执行工具并提供 response policy;模型返回后,agent 用 `ToolRef`、definition digest 和 policy 生成冻结的 `ToolCallBinding`。恢复时 schema、工具版本或 policy 不匹配都不能静默换版本。 + +```go +type ExecutableTool interface { + Ref() ToolRef + Definition() sdk.ToolDefinition + ResponsePolicy() ResponsePolicy + ValidateArguments(json.RawMessage) error + Execute(context.Context, ToolExecutionRequest) ToolExecutionOutcome +} +``` + +`ResponsePolicy` 决定工具是直接执行、需要 approval,还是等待外部 response;`ValidateArguments` 在 start barrier 前运行并且不能产生外部 effect。工具定义及其 digest 同时承担工具版本绑定。工具进度通过 `ToolProgressSink` 进入 EventSink,只是实时观察。需要影响恢复的状态必须提交 AgentCommand;工具内部的瞬时观察仍只发送 EventSink provisional event。 + +Loop 为每个执行 worker 创建绑定了 RunID/StepID/CallID 的 `ToolProgressSink`;其 `Publish` 只是向同一个 `EventSink` 发出 provisional `EventToolProgress`,不调用 Runtime,也不产生 AgentCommand。 + +### 7.2 approval 和 ask-user + +两者都是 ToolCall 的 response policy,不是新的 Step 类型: + +```text +approval required -> Waiting(Approval) +ask_user -> Waiting(ExternalResponse) +``` + +批准后仍要执行实际工具;拒绝产生已知 permission-denied 结果;ask-user 的答案直接完成对应 Call。多个 Waiting Call 同时存在时,每个响应按 CallID 独立推进。 + +### 7.3 MCP + +agent 提供 MCP schema/call adapter,把 MCP tool 转换为 `sdk.ToolDefinition` 和 `ExecutableTool`。MCP server 的连接、认证、生命周期和产品权限由 Memoh/application 管理。迁移期可以保留旧 `sdk.MCPClient` wrapper;新 Loop 不依赖 SDK 的 MCP session。 + +## 8. MemoryRuntime 与 MemohRuntime + +### 8.1 MemoryRuntime + +MemoryRuntime 使用 `mutex + MachineState + AgentEvent map`: + +```text +Load + 在锁内返回当前 MachineState 和 Revision + +Commit + 在锁内判定 grant 有效性,调用共享 EvaluateCommit 并保存结果; + 接受 start command 时返回本进程的 opaque ExecutionGrant +``` + +MemoryRuntime 不需要 owner、fence、lease、outbox 或 Attempt 表。进程退出后状态可以丢失;它是本地会话、测试和 conformance reference。它仍须在同一把锁内记录每个已接受 start 的执行占用,防止两个本地 worker 同时执行同一个 Call。 + +MemoryRuntime 只保存 agent 的 MachineState、AgentEvent 和提交幂等记录,不自动保存产品 history。需要多轮上下文的 in-process +宿主应在 Runtime 外维护一个内存 history projection,并让自己的 RequestPlanner 读取它;这只是一个轻量的应用层配套,不是 MemoryRuntime 为 durable 语义模拟数据库。 + +worker context 的取消不等同于业务取消。若已提交 `StartToolCall` 后 worker 返回 Unknown,Loop 使用不受 worker cancellation 影响的 commit context 提交 `SubmitToolFailure{Outcome: ToolOutcomeUnknown}`;MemoryRuntime 在同一把锁内记录 Unknown 并结束 Run。若当前进程直接退出,MemoryRuntime 不承诺跨进程恢复;仍处于 Executing 的 Call 随内存状态一起丢失。 + +ModelStep 的执行授权被取消时,Loop 使用仍有效的 model grant 提交 `RecoverModelExecution`;MemoryRuntime 在同一把锁下把它恢复为 Prepared,下次 Loop 仍使用同一冻结 request。若进程在提交前退出,MemoryRuntime 随内存状态丢失,不承诺跨进程恢复。 + +### 8.2 MemohRuntime + +MemohRuntime 用 PostgreSQL transaction/CAS 加上 Memoh 私有的 owner/fence/lease: + +```text +Load + 只读取 canonical history、MachineState、ToolStep progress 和 session facts + 不创建 Attempt,不取得 lease;这些控制记录不出现在 snapshot + +Commit + 判定内部 owner/fence/lease 的有效性 + 对 StartModelExecution/StartToolCall 在同一事务内建立 Attempt/lease + 调用共享 EvaluateCommit(command identity、Revision、Decide/Evolve) + 原子保存 MachineState、AgentEvent 组、history projection、queue action、response record 和 outbox +``` + +并行 Call 的完成 transition 按提交先后获得递增的 Revision;这不改变模型上下文中的 Call 顺序。Memoh 先按 CallID 保存各自结果,ToolStep 关闭时再按 ModelResult 原始 Call 顺序写入 assistant tool-call 与 tool-result history。 + +Attempt、owner、fence、lease 和数据库 row 不进入 agent public state。它们只保证多个 Loop attempt 不会同时取得同一个 Call 的执行权。持久化的 MachineState snapshot 必须带 adapter 自己的 snapshot schema version;跨版本升级时按该版本解码或迁移,不复用事件的 `SchemaVersion` 字段。 + +MemohRuntime 的 worker 实例在构造时绑定当前 worker 的 owner identity;只有该实例可以提交自己接受的 model/tool start、completion 和主动 recovery。response 和 cancel 使用同一个 `Commit` 语义,但由 Memoh 创建不带 worker grant 的 ingress-scoped adapter;这些 command 不取得执行权,因此不需要伪造 Loop owner。新 Run admission 仍由 Memoh 的 admission 事务处理,不调用旧 Run 的 `Runtime.Commit`。 + +续租失败不是立即的工具失败。Memoh 先使用 backend-loss/recovery grace 判断旧 owner 是否已经失效;确认 lease 和 grace 都失效后,scanner 在事务中检查该 Executing Call 是否已有已接受结果: + +```text +已有结果 -> 只释放旧 lease +没有结果 -> 生成 `ToolCallFailed{Outcome: ToolOutcomeUnknown, Failure: effect_unknown}` 并提交,RunFailed +``` + +scanner 使用由 StepID、CallID 和 system namespace 稳定生成的 CommandID,并遵守与普通 Commit 相同的 digest/idempotency。它只在自己的 recovery record 证明 lease 已失效后通过共享 `EvaluateCommit` 提交 Unknown;普通 Commit 调用者不能伪造这条 system command。Model recovery 也必须通过共享 Decide/Evolve 规则,而不能直接改写 Step 状态。scanner 不查询外部系统,也不重新执行工具。Pending Call 不走 Unknown 路径,可以由新 Loop 继续。 + +ModelStep 的 recovery 规则不同:模型请求没有工具那样的外部业务 effect。ModelStep 的执行 lease 失效后,Memoh 可以在事务中把它从 Executing 重置为 Prepared;新的 Loop 使用同一冻结 `sdk.Request` 重试,不创建新的 Step。只有已经接受的 `SubmitModelResult` 才能关闭该 ModelStep。 + +当 ToolStep 自动关闭且 Run 允许继续时,Memoh 先提交 history、queue action 和 context revision;下一次 Loop 通过 Planner 构造并提交下一份 `sdk.Request`。已经冻结的请求不会被后来的输入改变。 + +### 8.3 外部 effect 的保证 + +```text +AgentCommand commit -> AgentEvent 组 + effectively-once(identity + digest) + +Model call + lease 失效后可使用同一冻结 request 重复;不同 attempt 可能返回不同结果,只有先被 + Runtime 接受的结果推进 Run + +Tool effect + start command 提交后才发生,但结果可能在提交前丢失 +``` + +通用 core 无法判断 Unknown effect 是否已经发生,也不假设支付系统或其他外部系统提供查询接口。因此 Unknown 保守地终止当前 Run;如果产品需要继续,只能创建新的 Run。非幂等工具不能由 Runtime 获得 exactly-once 保证。 + +## 9. Events 和 context + +### 9.1 Canonical Event Plane + +状态变化的提交路径固定为: + +```text +Loop / response ingress + -> AgentCommand + -> Runtime.Commit (EvaluateCommit: 幂等/类别校验 + Machine.Decide + Machine.Evolve) + -> MachineState + AgentEvent 组 in one atomic boundary + -> EventSink / replay / projection / OTel +``` + +`AgentCommand` 表示“希望发生的状态变化”;`AgentEvent` 表示“authority 已接受并持久化的事实”。一个接受的 command 构成一次 transition,产出一个或多个事实。AgentEvent 必须具备: + +```text +RunID + (Revision, Index) 全序身份;Revision 是 transition 计数,Index 是组内序 +CommandID 产生这次 transition 的 command +Digest 该事实内容的 canonical digest +SchemaVersion + Type wire 兼容和 sealed fact discriminator +Fact 已接受的事实内容 +``` + +MachineState 与 AgentEvent log 是同一个 transition 序列的两个 materialization(§5.1):MachineState 是 execution authority,AgentEvent log 是 historical authority,由同一原子提交产生、共享 Revision。Runtime 必须把两者和需要一致的 Memoh projection/outbox 放在同一事务或锁边界。Durable adapter 必须保留 AgentEvent,使其可以按 RunID/(Revision, Index) replay;MemoryRuntime 可以只在进程内保留同样的记录。公共 `Runtime` 不增加 replay 方法,读取由实现或 application projection 提供。 + +Replay 按 RunID/(Revision, Index) 取出 AgentEvent,从初始状态(Revision=0)开始依次调用同一份 `Machine.Evolve` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里,Machine 决策规则的演进不影响历史事件的折叠。校验规则:折叠到 Revision N 的状态必须与该 Revision 的持久化状态一致;Revision、Index、identity 或 digest 不匹配时停止 replay 并报告 halt 级一致性违规,不静默修复状态,也不定义任何一方自动覆盖另一方。分歧后的恢复是人工按事件流重建并核对的 runbook 操作,不是自动路径。 + +Replay 的起点是 admission 已建立的初始 `MachineState`;`RunSeed` 的 admission 记录不作为任何 Run 的 AgentEvent 重放。需要重建 admission 链时,由 Memoh 的 session/queue 记录负责。 + +canonical event 只记录影响语义状态、恢复和审计的已接受事实:Step 的建立/启动/恢复/关闭、模型结果的接受与拒绝、工具结果、响应、active Run 的输入接受和 Run terminal(§3.6 的 Fact 词表)。新 Run 的 `RunSeed` 仍属于 admission record。模型文本 delta、工具 stdout、下载百分比和其他瞬时 progress 不进入 AgentEvent;它们仍可在提交前通过 EventSink 发送 provisional observation。 + +### 9.2 EventSink + +EventSink 是实时观察出口,不是 canonical source。Loop 在收到 `CommitResult.Events` 后可以发送对应的 committed observation,也可以发送不改变权威状态的 provisional observation: + +```text +ModelTextDelta、ToolProgress + 可以在完成提交前发送,都是 provisional + +ToolStarted + 只能在 start command Accepted 后发送 + +ToolCompleted、Run terminal + 先由 Runtime Commit;Loop 随后发送观察事件,Memoh 的 durable projection/outbox + 由同一 Commit 事务保存对应事实 +``` + +EventSink 丢失、重复或来自旧 Attempt 都不改变 MachineState 或 AgentEvent。客户端出现 gap 时从 durable AgentEvent、snapshot 或最终 `RunResult` 重建。Loop 默认忽略观察通道错误,不因此重试模型/工具。 + +并行工具的观察事件通过 `CallID` 关联到具体 ToolCall;模型事件的 `CallID` 为空。 +`Event.Sequence` 只在一次观察流内单调递增,不是 `AgentEvent.Revision`,也不参加 canonical digest/idempotency。`Durability` 仅说明这条观察是否对应已提交事实;它不能替代 AgentEvent 的 identity/digest。 + +### 9.3 context transform + +Request Planner 属于 Memoh/application。它可以在事务外读取 context,但 MemohRuntime adapter +必须以版本检查和 create-if-absent 冻结生成的 request。已冻结 ModelStep 不受后来输入影响。 + +这里的版本检查由 MemohRuntime 的 adapter 实现:agent.Runtime 只看到带有 `BaseRevision` +的 `PrepareModelRequest`,不理解 Memoh 的 context revision,也不读取 queue 或 history。 + +### 9.4 provider transport + +一次 `sdk.Generate` 或 `sdk.Stream` 对应一次逻辑 provider request。transport retry 在 sdk/provider client 内部发生;agent 不记录它,也不为它创建新的 Step。 + +## 10. Memoh queue、session 和恢复 + +### 10.1 queue 归属 + +steer/follow-up 的 queue 数据结构、accepted order、重排、claim、apply、取消和 admission 全部属于 Memoh。两种 queue 可以共享稳定 item reference、accepted sequence、order version、取消状态和 claim provenance;消费策略不同。被选中的 item 在交给 core 前转换为只有 `InputID` 和 payload 的 `AgentInput`,Memoh 私下保留 item reference 与 claim provenance 的映射: + +```text +steer 优先进入当前 eligible boundary;若当前 Run 已 terminal,则按 session policy 创建 continuation Run +follow-up 当前 Run 自然结束后创建新的 Run +``` + +active Run 的 steer 通过 `NextStep(input)` 生成 `AcceptInput` command;terminal Run 的 steer/follow-up 通过 `NextRun(input)` 生成 `RunSeed`,然后由 Memoh admission 创建新 Run。core 不接收 queue item、priority、order 或 claim。 + +重排必须带 order version;过期版本、未知 item、重复 item 和越过已 claim item 的操作都拒绝。 + +### 10.2 queue-safe boundary + +Memoh 只在以下 boundary 仲裁 queue: + +```text +ModelStep 完成且没有 tool calls +ToolStep 自动关闭 +``` + +ModelStep 执行中、ToolStep 有 Pending/Executing/Waiting Call 时,不消费新的 queue 输入。queue action、对应 `InputAccepted` 事件以及 claim provenance 在 Memoh transaction 中一起提交,已 claim item 不能回退或越过。 + +没有 tool calls 的 ModelStep 会使当前 Run 到达 `RunCompleted`。Memoh 可以在这个 queue-safe boundary 消费 steer 并创建 continuation Run;这不改变已经完成的 Run,也不把 queue policy 放进 Machine。 + +### 10.3 R0/R1 与 session settled + +Memoh 的 session 规则保持: + +```text +R0 terminal 不等于 session settled +admission-active R1 存在时 session 仍 busy +``` + +follow-up 可以在 R1 的第一个 ModelStep 之前完成 durable admission claim;这是 Memoh session 操作,不是 Loop 中间读取 queue。R0 continuation 通过 Memoh outbox/scanner 唤醒;R1 admission 用 `NextRun(input)` 的 `RunSeed` 初始化新 Run,R1 identity 不进入 agent 的通用结果。 + +### 10.4 多 response 恢复 + +```text +ToolStep T1 + A Completed + B Waiting(response=101) + C Waiting(response=102) + D Pending +``` + +response 101 只完成 B;D 仍可执行,不必等待 C。response 102 再完成 C;D 完成后 Machine 自动关闭 T1,并允许下一 ModelStep。每个 response 有自己的 row、CommandID 和 wake,不再受旧协议“一个 deferred 只能保存一个 approval”的限制。 + +## 11. 迁移现状与兼容策略 + +### 11.1 当前问题 + +当前 SDK 同时承载 provider API 和多步 loop: + +```text +GenerateParams / GenerateResult + 混合请求、自动 tool loop、approval、steps、callbacks 和 max steps +``` + +Memoh native runtime 已拥有产品 context、queue 和 durable session,但旧 loop 让它看不到 ToolStep 内逐 Call 的 progress。新方案把多步执行统一到 `agent.Loop`,并要求 Memoh 增加 ToolCall progress/response projection。 + +本规范中的 `sdk.Request`、`sdk.ModelResult` 和 provider-neutral `sdk.ToolDefinition` 是迁移目标的闭合类型合同;当前 SDK 仍主要使用 `Tool`、`GenerateResult` 和 `StreamResult`。阶段 A 负责实现等价的新类型并保留显式 legacy wrapper,不能把目标类型误认为已经存在的兼容 API。 + +### 11.2 迁移目标 + +```text +旧调用者 -> legacy sdk wrapper(迁移期) +新调用者 -> agent.Loop + Runtime +Memoh -> agent.Loop + MemohRuntime +``` + +生产环境最终只有一条多步执行路径:`agent.Loop`。 + +### 11.3 兼容原则 + +1. provider adapter 的现有请求/响应字段优先复用。 +2. sdk 保留旧单次调用入口,直到调用方迁移完成。 +3. 旧自动 loop 只在显式 legacy wrapper 中存在,不能由新 Loop 隐式调用。 +4. 旧 `WithMaxSteps(0)` 保持一次模型调用、不自动执行 tools;`n>0` 映射为 `RunConfig.ModelStepLimit=n`;旧值 `-1` 由 legacy wrapper 先规范化为 `RunConfig.ModelStepLimit=0`(无限)。agent 的 `RunConfig` 不接受负数。Memoh 当前使用 `-1`,迁移后保持无限模型步骤语义。 +5. 旧 deferred/approval 记录不能在线猜测为新的多 Call ToolStep。切换前必须排空,或以 `runtime_upgrade_required` 终态保留审计后再切换。 +6. 新协议写入生产后不回滚旧 loop;Memoh 的 queue accepted order、重排、claim、admission、R0/R1 和 settled 保证不变。 + +## 12. 分阶段实施 + +### 阶段 A:sdk 单次调用边界 + +1. 固定 provider-neutral `Request`、`ModelResult`、stream parts 和 snapshot。 +2. 让 Generate、Stream 各自对应一次 provider request;transport retry 留在 sdk。 +3. 将旧自动 loop 隔离为 legacy wrapper。 +4. 保留 blocking/streaming 等价测试。 + +### 阶段 B:Machine 和 MemoryRuntime + +1. 实现 `MachineState`、ModelStep、ToolStep、ToolCall 状态和 Decide/Evolve/Next 规则。 +2. 实现 `EvaluateCommit`、`Runtime.Load/Commit`、command/fact digest 与幂等和 opaque grant。 +3. 实现 Loop 的 model/tool/approval/response 路径和并行执行策略。 +4. 完成 MemoryRuntime conformance 测试(`agent/runtimetest`),并冻结 SchemaVersion 1 的 golden event stream。 + +### 阶段 C:Memoh storage groundwork + +1. 增加 ToolCall progress、response set、event idempotency、history 和 outbox projection。 +2. 保留 queue 的 accepted order、重排、claim、admission、R0/R1 和 settled 语义。 +3. 为旧 deferred 数据建立排空/审计迁移窗口。 + +### 阶段 D:MemohRuntime adapter + +1. 用 transaction/CAS 实现 `Load/Commit`。 +2. 在 adapter 内加入 owner/fence/lease/Attempt 和 recovery scanner。 +3. 实现逐 Call response、wake/outbox、unknown outcome 和 crash/fencing 测试。 +4. 让 Memoh Request Planner 以 revision 冻结下一份 sdk.Request。 + +### 阶段 E:production cutover + +1. 先以 shadow projection 验证 MachineState、history 和 queue action。 +2. 排空或审计终结旧 deferred Run。 +3. 将现有 Memoh NativeAgentLoop 保留为薄宿主 wrapper:由它组装 Request Planner、`agent.Loop` 和 `MemohRuntime`,但不再保留独立的多步执行算法。 +4. 禁止旧 loop 写入新 Runtime projection,观察 recovery、duplicate commit 和多 response。 + +### 阶段 F:删除兼容残留 + +删除旧自动 loop、旧 approval/deferred 提交路径和只服务旧 loop 的 SDK 状态字段;保留仍有外部调用者使用的单次 provider API。 + +## 13. 并行工作边界 + +| 工作 | 负责方 | 依赖 | +| --- | --- | --- | +| sdk Request/ModelResult/stream | twilight-ai/sdk | provider adapter | +| Machine 规则和 Step 类型 | twilight-ai/agent | sdk types | +| Loop、工具 contract、EventSink | twilight-ai/agent | Machine | +| MemoryRuntime | twilight-ai/agent | Machine/Loop | +| ToolCall projection、history/outbox | Memoh | event contract | +| queue、session、R0/R1、owner/fence | Memoh | existing session spec | +| Request Planner/context | Memoh/application | sdk.Request | +| MemohRuntime | Memoh | Runtime contract + projections | + +## 14. 测试矩阵 + +### 14.1 sdk + +```text +Generate/Stream 都返回一次完整 ModelResult +stream finish、EOF、中断和 malformed part +provider retry 不改变一次调用语义 +旧 wrapper 的单次调用兼容 +``` + +### 14.2 Machine/Loop + +```text +无当前 Step -> NeedModelRequest +NextStep(input) -> AcceptInput -> InputAccepted -> pending input appears in PlanningHint +NextRun(input) -> RunSeed,admission initializes new Run, without mutating old Run +PrepareModelRequest -> [ModelStepPrepared] -> ModelStep +ModelExecuting lease recovery -> same frozen ModelStep can start again +SubmitModelResult 有 tools -> [ModelStepCompleted, ToolStepOpened],保存完整 Call set +SubmitModelResult 无 tools -> [ModelStepCompleted, RunEnded{RunCompleted}],并返回该 ModelResult +结构性 malformed -> RejectModelResult -> [ModelStepRejected],Step 回到 Prepared,usage 已累计 +Rejects 超过 ModelRejectLimit -> [ModelStepRejected, RunEnded{RunFailed, malformed_model_result}] +参数无法解析的单个 Call -> Pending,start 前以 invalid_arguments 已知失败关闭 +unknown ToolRef/invalid arguments -> Pending Call 的已知失败,不提交 start +approval approved -> [ToolCallApproved] -> Pending -> start -> tool execute +approval rejected -> [ToolCallFailed{Known, permission_denied}] +多个 Pending 并行;Waiting 不阻塞其他 Pending +Waiting 与 Executing 并存时,response 和 execution wake 都有效 +response 只推进对应 Waiting Call +Waiting result carries RunID/StepID/CallID/ResponseID for response routing +最后一个 Call terminal -> 同一 transition 追加 ToolStepClosed +最后一个 Call terminal 且触发 step limit -> [..., ToolStepClosed, RunEnded{RunStopped, step_limit}] +RunEnded 只能是事实序列的最后一个事实 +已知失败进入下一次模型上下文 +Unknown -> [ToolCallFailed{Unknown}, RunEnded{RunFailed, effect_unknown}],不创建下一 ModelStep +Pending + Executing 并存时不误关 ToolStep +RunStopped 与 worker cancellation 区分 +外层 ctx 取消:模型执行以 ModelStepRecovered 释放,工具 worker 运行到结束后 Loop 返回 ctx.Err() +MachineState.Usage 逐字段累计 ModelStepCompleted 与 ModelStepRejected;terminal 时复制到 RunResult.Usage +Decide 拒绝时不产出部分事实;接受时事实组与 MachineState 原子提交 +Evolve 不读 RunConfig、无 policy 分支;对 Decide 产出的全部事实全定义 +AgentEvent 按 RunID/(Revision, Index) 可 replay,重复提交不产生第二组 +replay 只经 Evolve 折叠,不重新运行 Decide;golden event stream 折叠出冻结的状态字节 +EventSink provisional/committed 发射点 +并行 EventSink 事件包含 CallID,Waiting result 可路由到目标 Call +Streaming=true 但 invoker 不支持 streaming -> Generate fallback +``` + +### 14.3 Runtime conformance + +conformance 测试由 agent 以可运行测试包(`agent/runtimetest`)交付;MemoryRuntime 与 MemohRuntime 直接运行同一套件,不各自转写矩阵: + +```text +same CommandID + digest -> CommitAlreadyApplied + 原事件组 +same CommandID + different digest -> ErrCommandConflict +AlreadyApplied 不重新运行 Decide;事件组逐字节等于首次提交 +并发 Commit 的 Revision/CAS 行为 +同一 Run/Revision 的并发 Planner:相同请求 AlreadyApplied,不同请求 CommandConflict +Planner InputIDs 必须完整匹配 PendingInputs;Tools/ToolsDigest 与 sdk.Request 一一对应 +并行 Call 的 start/response 在旧 Revision 上按目标 Call rebase +Pending Call 的 lookup/argument failure 在旧 Revision 上按目标 Call rebase +工具 ref/definition digest 变化 -> tool_definition_mismatch,且不调用工具 +ToolCallState 非法字段组合 -> Runtime.Load/Evolve 拒绝 +ToolCallState 的 BindingDigest 与 frozen ToolSpec 不匹配 -> tool_definition_mismatch +相同 ResponseID 不同 payload -> ErrCommandConflict +start Accepted 后才授予外部执行权 +AlreadyApplied 不重新授予执行权 +stale grant/Revision 被拒绝 +一次 transition 的事件共享 Revision,Index 连续,提交后 State.Revision == transition Revision +ToolCall progress 按 CallID 合并 +已关闭 ToolStep 不重复关闭或创建新 Step +model-step limit 在 authority 内生效 +RunStopped/RunFailed 保留最近已接受的 ModelResult +Cancel 与 Unknown 的提交先后决定终态 +CancelRun 在过期 BaseRevision 上对非 terminal Run 重新评估 +RejectModelResult 必须带有效 Model grant;AlreadyApplied 重放不重复累计 usage +持久化状态与按 Evolve 折叠的事件流逐字段一致;不一致 -> halt,不自动修复 +``` + +### 14.4 Memoh integration + +```text +queue FIFO、accepted-order reorder、typed ID isolation +assigned follow-up 只由正确的 R1 admission claim +canonical history、AgentEvent 与 MachineState 同事务 +assistant tool-call 和 tool result 只写一次 +多 response rows 与逐次 wake/idempotency +lease expiry/recovery/unknown outcome +eligible steer 存在时 Prepare 在同一事务内被拒绝,AcceptInput 先应用,重新规划携带该输入 +并行 Call 中一个 Unknown 后撤销其他 grant,迟到结果不改变终态 +terminal 后迟到的工具结果投影到产品可见的审计视图 +R0 terminal 与 session settled 分离 +EventSink gap 后可由 durable snapshot 对账 +``` + +## 15. Memoh queue spec 的后续改写 + +本次不编辑 `session-runtime-steer-followup.md`。后续应按以下边界修订: + +Memoh queue/session/admission 的语义保持在 Memoh;现有 NativeAgentLoop 只保留为组装 Request Planner、`agent.Loop` 和 `MemohRuntime` 的薄宿主 wrapper,不包含第二套多步执行算法。queue 仲裁只发生在 queue-safe boundary,并与对应 AgentCommand/AgentEvent 在 Memoh transaction 中提交。旧的完整 Step 提交改为 ModelStep、ToolStep 以及逐 Call progress/response 记录;每次 response 只推进对应 Call。R0/R1、ownerless recovery、session settled 和 claim takeover 语义保持。 + +## 16. 实施前置条件 + +1. Memoh 增加 ToolCall progress、response set、event idempotency、history/outbox projection。 +2. Memoh 冻结内部 Attempt、owner、fence、lease 和 recovery grace 规则;这些不进入 agent public API。 +3. 工具失败不由 agent core 调度 retry timer;已知失败交给下一次模型,未知结果终止当前 Run。非幂等外部 effect 只能承诺 at-least-once。 +4. Request Planner 必须能从已提交的 application context 构造完整、可冻结的 `sdk.Request`。 + +## 17. 待确认决策 + +实现前仍需确认: + +1. queue capacity、expiry 和产品授权是否进入 Memoh queue contract。 +2. breaking release 版本和 Memoh protocol upgrade window。 +3. EventSink payload schema,以及是否需要在 Memoh outbox 中加入跨进程 execution epoch。 + +本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;`sdk.Request` 冻结完整的 generation options,streaming 只是 `ModelInvoker` 的可选执行路径,不改变 AgentCommand/AgentEvent 语义。Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、与事件编码同属永久兼容契约;MachineState 为 execution authority、AgentEvent log 为 historical authority,二者是同一 transition 的两个 materialization,分歧即 halt。结构性 malformed 的模型结果通过 `RejectModelResult` 在同一冻结 request 上有限重试;usage 在 MachineState 内逐字段累计;steer 由 MemohRuntime 的 Prepare gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 + +重新评估 event log 为唯一权威(纯 ES 翻转)的触发条件:出现跨多个发布周期存活的长生命周期 Run;fork/任意历史时点重建成为产品功能;外部消费方要求自描述的事件日志。上述条件出现前,保持双物化模型。 + +## 附录 A:最小 public API 草案 + +```go +package agent + +import ( + "context" + "encoding/json" + "errors" + + "github.com/memohai/twilight-ai/sdk" +) + +type RunID string +type StepID string +type CallID string +type CommandID string +type ResponseID string +type InputID string +type ToolRef string +type ModelRef string +type Digest string +// PlanningToken is opaque to agent; the application uses it to identify the +// context revision from which a RequestPlan was built. +type PlanningToken string + +type RunConfig struct { + Model ModelRef + // Zero means unlimited. A positive value caps ModelSteps in this Run; + // negative values are invalid. + ModelStepLimit int + // Max RejectModelResult per ModelStep before the Run fails. Zero is + // normalized to 2 by Initialize; negative values are invalid. There is no + // unlimited value. + ModelRejectLimit int +} + +type RunStatus uint8 + +const ( + RunActive RunStatus = iota + RunCompleted + RunStopped + RunFailed +) + +type RunReason string + +const ( + ReasonCancelled RunReason = "cancelled" + ReasonStepLimit RunReason = "step_limit" + ReasonProviderFailure RunReason = "provider_failure" + ReasonMalformedModel RunReason = "malformed_model_result" + ReasonEffectUnknown RunReason = "effect_unknown" +) + +type RunFailure struct { + Class string + Message string + CallID CallID +} + +type RunResult struct { + Status RunStatus + Reason RunReason + Failure *RunFailure + Model *sdk.ModelResult + Usage sdk.Usage // MachineState.Usage 在 terminal 时的副本 +} + +type StepRef struct { + RunID RunID + ID StepID + Digest Digest // immutable step binding digest; progress is not included +} + +// Step is sealed by the agent package. Runtime implementations return values +// created by the Machine rules; callers cannot add another Step variant. +type Step interface { + step() + Ref() StepRef +} + +type ModelStep struct { + RefValue StepRef + Request sdk.Request + RequestDigest Digest + Model ModelRef + Tools []ToolSpec + ToolsDigest Digest + Status ModelStepStatus + Rejects int // accepted ModelStepRejected count; progress, not part of RefValue.Digest +} + +func (ModelStep) step() {} +func (s ModelStep) Ref() StepRef { return s.RefValue } + +type ModelStepStatus uint8 + +const ( + ModelPrepared ModelStepStatus = iota + ModelExecuting +) + +type ToolStep struct { + RefValue StepRef + Source StepID + Calls []ToolCallState +} + +func (ToolStep) step() {} +func (s ToolStep) Ref() StepRef { return s.RefValue } + +type ToolCallStatus uint8 + +const ( + ToolPending ToolCallStatus = iota + ToolExecuting + ToolWaiting + ToolCompleted + ToolFailed +) + +type ToolCallState struct { + CallID CallID + ToolRef ToolRef + DefinitionDigest Digest + BindingDigest Digest + Arguments json.RawMessage + Policy ResponsePolicy + Status ToolCallStatus + Result *ToolExecutionResult + Failure *ToolCallFailure + Waiting *ResponseRequest +} + +func ValidateToolCallState(ToolCallState) error + +type ToolCallFailure struct { + Failure ToolFailure + Outcome ToolFailureOutcome +} + +type ToolFailure struct { + Class string + Message string +} + +type ToolFailureOutcome uint8 + +const ( + ToolOutcomeKnown ToolFailureOutcome = iota + ToolOutcomeUnknown +) + +const ( + FailurePermissionDenied = "permission_denied" + FailureToolLookup = "tool_lookup_failed" + FailureInvalidArguments = "invalid_arguments" + FailureMalformedModel = "malformed_model_result" + FailureDefinitionMismatch = "tool_definition_mismatch" + FailureExecution = "execution_failed" + FailureEffectUnknown = "effect_unknown" +) + +type ResponsePolicy uint8 + +const ( + DirectExecution ResponsePolicy = iota + ApprovalRequired + ExternalResponse +) + +// MaxParallel: 1 means sequential; values greater than 1 enable bounded +// parallelism. Zero is normalized to 1; negative values are rejected by Loop +// construction. +type ExecutionPolicy struct { MaxParallel int } + +type ResponseRequest struct { + RunID RunID + StepID StepID + CallID CallID + ID ResponseID + Kind ResponseKind + Payload json.RawMessage + RequestDigest Digest // digest of the request payload, not a user response +} + +type ResponseKind string + +const ( + ResponseApproval ResponseKind = "approval" + ResponseExternal ResponseKind = "external_response" +) + +type MachineState struct { + RunID RunID + Status RunStatus + Config RunConfig + Current Step + PendingInputs []AgentInput + ModelSteps int + Usage sdk.Usage // 已接受 ModelStepCompleted/ModelStepRejected 的逐字段累计 + LastModelResult *sdk.ModelResult + Result *RunResult +} + +// AgentCommand is the intent submitted through Runtime.Commit for an existing +// Run. Accepting one command constitutes one transition. +type AgentCommand interface { agentCommand() } + +// Fact is one committed outcome produced by Machine.Decide. Facts are wrapped +// as AgentEvents; Machine.Evolve folds them mechanically. +type Fact interface { fact() } + +type AgentInput struct { + ID InputID + Payload json.RawMessage +} + +// NextStep creates the command consumed by an active Run at a safe boundary. +func NextStep(input AgentInput) AcceptInput + +// NextRun creates the admission seed used by application admission. It does +// not allocate a RunID, claim a queue item, or mutate an existing Run, and it +// is never submitted through Runtime.Commit. +type RunSeed struct { Input AgentInput } +func NextRun(input AgentInput) RunSeed + +// --- Commands (intent) --- + +type PrepareModelRequest struct { + StepID StepID + Model ModelRef + Request sdk.Request + RequestDigest Digest + InputIDs []InputID + PlanningToken PlanningToken + Tools []ToolSpec + ToolsDigest Digest +} +func (PrepareModelRequest) agentCommand() {} + +type StartModelExecution struct { StepID StepID } +func (StartModelExecution) agentCommand() {} + +// Releases or recovers model execution: no provider result was accepted, so +// the same frozen request may be prepared for another attempt. +type RecoverModelExecution struct { StepID StepID } +func (RecoverModelExecution) agentCommand() {} + +type SubmitModelResult struct { + StepID StepID + Result sdk.ModelResult + Calls []ToolCallBinding +} +func (SubmitModelResult) agentCommand() {} + +type SubmitModelFailure struct { StepID StepID; Failure StepFailure } +func (SubmitModelFailure) agentCommand() {} + +// A structurally malformed model result: usage is accumulated, the step's +// reject counter is incremented, and the step returns to Prepared until +// RunConfig.ModelRejectLimit is exceeded. Requires the model start grant. +type RejectModelResult struct { + StepID StepID + Usage sdk.Usage + Failure StepFailure +} +func (RejectModelResult) agentCommand() {} + +type StartToolCall struct { StepID StepID; CallID CallID } +func (StartToolCall) agentCommand() {} + +type SubmitToolResult struct { + StepID StepID + CallID CallID + Result ToolExecutionResult +} +func (SubmitToolResult) agentCommand() {} + +type SubmitToolFailure struct { + StepID StepID + CallID CallID + Failure ToolFailure + Outcome ToolFailureOutcome +} +func (SubmitToolFailure) agentCommand() {} + +// ResponseDigest is the canonical digest of this approval decision payload. +type ApproveToolCall struct { StepID StepID; CallID CallID; ResponseID ResponseID; ResponseDigest Digest } +func (ApproveToolCall) agentCommand() {} + +// ResponseDigest is the canonical digest of this rejection payload. Decide +// records the outcome as ToolCallFailed{Known, permission_denied}. +type RejectToolCall struct { StepID StepID; CallID CallID; ResponseID ResponseID; ResponseDigest Digest; Reason string } +func (RejectToolCall) agentCommand() {} + +type SubmitToolResponse struct { + StepID StepID + CallID CallID + ResponseID ResponseID + ResponseDigest Digest // digest of the answer payload + Payload json.RawMessage +} +func (SubmitToolResponse) agentCommand() {} + +type CancelRun struct { Reason RunReason } +func (CancelRun) agentCommand() {} + +type AcceptInput struct { Input AgentInput } +func (AcceptInput) agentCommand() {} + +// --- Facts (committed outcomes) --- + +type ModelStepPrepared struct { + StepID StepID + Model ModelRef + Request sdk.Request + RequestDigest Digest + InputIDs []InputID + Tools []ToolSpec + ToolsDigest Digest +} +func (ModelStepPrepared) fact() {} + +type ModelStepStarted struct { StepID StepID } +func (ModelStepStarted) fact() {} + +type ModelStepRecovered struct { StepID StepID } +func (ModelStepRecovered) fact() {} + +type ModelStepRejected struct { + StepID StepID + Usage sdk.Usage + Failure StepFailure +} +func (ModelStepRejected) fact() {} + +type ModelStepCompleted struct { + StepID StepID + Result sdk.ModelResult +} +func (ModelStepCompleted) fact() {} + +type ToolStepOpened struct { + StepID StepID // the new ToolStep + Source StepID // the completed ModelStep + Calls []ToolCallBinding +} +func (ToolStepOpened) fact() {} + +type ToolCallStarted struct { StepID StepID; CallID CallID } +func (ToolCallStarted) fact() {} + +type ToolCallApproved struct { StepID StepID; CallID CallID; ResponseID ResponseID; ResponseDigest Digest } +func (ToolCallApproved) fact() {} + +type ToolCallCompleted struct { + StepID StepID + CallID CallID + Result ToolExecutionResult +} +func (ToolCallCompleted) fact() {} + +type ToolCallAnswered struct { + StepID StepID + CallID CallID + ResponseID ResponseID + ResponseDigest Digest + Payload json.RawMessage +} +func (ToolCallAnswered) fact() {} + +type ToolCallFailed struct { + StepID StepID + CallID CallID + Failure ToolFailure + Outcome ToolFailureOutcome +} +func (ToolCallFailed) fact() {} + +type ToolStepClosed struct { StepID StepID } +func (ToolStepClosed) fact() {} + +type InputAccepted struct { Input AgentInput } +func (InputAccepted) fact() {} + +// Terminal fact. Always the last fact of its transition. +type RunEnded struct { + Status RunStatus // RunCompleted, RunStopped or RunFailed + Reason RunReason + Failure *RunFailure +} +func (RunEnded) fact() {} + +type StepFailure struct { Class string; Message string } + +type ToolCallBinding struct { + CallID CallID + ToolRef ToolRef + DefinitionDigest Digest + BindingDigest Digest // definition, policy and canonical arguments + Arguments json.RawMessage + Policy ResponsePolicy // unresolved ToolRef uses DirectExecution + Response *ResponseRequest // Decide 在 ToolStepOpened 中派生并填充;调用方提交时留空 +} + +// ToolSpec is the agent-side sidecar for a provider-neutral sdk.ToolDefinition. +// ResponsePolicy is intentionally kept out of sdk to preserve package layering. +type ToolSpec struct { + Ref ToolRef + Definition sdk.ToolDefinition + DefinitionDigest Digest + Policy ResponsePolicy +} + +func Next(MachineState) (Effect, error) +func Decide(MachineState, AgentCommand) ([]Fact, error) +func Evolve(MachineState, Fact) (MachineState, error) +func Initialize(RunID, RunConfig, RunSeed) (MachineState, error) + +type Effect interface { effect() } + +type NeedModelRequest struct { Hint PlanningHint } +func (NeedModelRequest) effect() {} + +type StartModelCall struct { StepID StepID } +func (StartModelCall) effect() {} + +type StartToolCalls struct { StepID StepID; CallIDs []CallID } +func (StartToolCalls) effect() {} + +type WaitForResponse struct { Requests []ResponseRequest } +func (WaitForResponse) effect() {} + +type WaitForExecutionRecovery struct{} +func (WaitForExecutionRecovery) effect() {} + +type PlanningHint struct { + RunID RunID + Model ModelRef + SourceStep StepID + Inputs []AgentInput +} + +type RequestPlanner interface { + Plan(context.Context, PlanningHint) (RequestPlan, error) +} + +type RequestPlan struct { + Model ModelRef + Request sdk.Request + InputIDs []InputID + PlanningToken PlanningToken + Tools []ToolSpec +} + +type Runtime interface { + Load(context.Context) (RuntimeSnapshot, error) + Commit(context.Context, CommitRequest) (CommitResult, error) +} + +// The representation is implementation-defined. Callers only pass it back; +// it is not a Step identity or a user credential. +type ExecutionGrant string + +type RuntimeSnapshot struct { + State MachineState + Revision uint64 // 已接受的 transition 数;初始状态为 0 +} + +type CommandEnvelope struct { + SchemaVersion uint16 + Type string + RunID RunID + ID CommandID + Digest Digest + Command AgentCommand +} + +type AgentEvent struct { + SchemaVersion uint16 + Type string + RunID RunID + Revision uint64 + Index uint16 + CommandID CommandID + CommandDigest Digest + Digest Digest + Fact Fact +} + +type CommitRequest struct { + BaseRevision uint64 + Grant ExecutionGrant + Command CommandEnvelope +} + +type CommitStatus uint8 + +const ( + CommitAccepted CommitStatus = iota + CommitAlreadyApplied +) + +type CommitResult struct { + Status CommitStatus + Snapshot RuntimeSnapshot + Events []AgentEvent // 该 transition 的完整事件组 + Grant ExecutionGrant // 仅 Accepted 的 start command 会返回 +} + +type DecisionKind uint8 + +const ( + DecisionApply DecisionKind = iota + DecisionAlreadyApplied + DecisionConflict + DecisionStale + DecisionTerminal +) + +type CommitDecision struct { + Kind DecisionKind + NewState MachineState + Events []AgentEvent +} + +// Shared, pure commit evaluation. Both runtimes call this single +// implementation inside their own critical section / transaction. +func EvaluateCommit( + cur MachineState, curRevision uint64, + prior []AgentEvent, + req CommitRequest, + grantValid bool, + recoveryValid bool, +) (CommitDecision, error) + +func EncodeCommand(CommandEnvelope) ([]byte, error) // 不包含 Digest 字段 +func DigestCommand(schemaVersion uint16, typ string, command AgentCommand) (Digest, error) +func EncodeFact(schemaVersion uint16, typ string, fact Fact) ([]byte, error) +func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) +func EncodeRunSeed(RunSeed) ([]byte, error) +func DigestRunSeed(schemaVersion uint16, seed RunSeed) (Digest, error) +func DigestRequest(sdk.Request) (Digest, error) +func DigestToolDefinition(sdk.ToolDefinition) (Digest, error) +func DigestToolSpec(ToolSpec) (Digest, error) +func DigestToolSpecs([]ToolSpec) (Digest, error) +func DigestModelStepBinding(model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) +func DeriveModelRequestCommandID(RunID, uint64) CommandID +func DeriveModelStepID(RunID, CommandID, Digest) StepID +func DeriveToolStepID(StepID, Digest) StepID +func DeriveResponseID(RunID, StepID, CallID, ResponseKind) ResponseID +func DeriveResponseCommandID(RunID, StepID, CallID, ResponseID) CommandID +func DeriveInputCommandID(RunID, InputID) CommandID + +var ErrCommandConflict = errors.New("agent: command identity conflict") +var ErrStaleRuntime = errors.New("agent: stale runtime version or grant") +var ErrRunTerminal = errors.New("agent: run is terminal") + +type ModelCatalog interface { Resolve(ModelRef) (ModelInvoker, error) } + +type ModelInvoker interface { + Generate(context.Context, sdk.Request) (sdk.ModelResult, error) +} + +// 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 { Resolve(ToolRef) (ExecutableTool, error) } + +type ToolExecutionRequest struct { + RunID RunID + StepID StepID + CallID CallID + ToolRef ToolRef + DefinitionDigest Digest + Arguments json.RawMessage + Progress ToolProgressSink +} + +type ExecutableTool interface { + Ref() ToolRef + Definition() sdk.ToolDefinition + ResponsePolicy() ResponsePolicy + ValidateArguments(json.RawMessage) error + Execute(context.Context, ToolExecutionRequest) ToolExecutionOutcome +} + +type ToolExecutionResult struct { Output json.RawMessage } + +type ToolExecutionOutcome interface { toolExecutionOutcome() } + +type ToolExecutionSucceeded struct { Result ToolExecutionResult } +func (ToolExecutionSucceeded) toolExecutionOutcome() {} + +type ToolExecutionFailed struct { Failure ToolFailure } +func (ToolExecutionFailed) toolExecutionOutcome() {} + +type ToolExecutionUnknown struct { Failure ToolFailure } +func (ToolExecutionUnknown) toolExecutionOutcome() {} + +type ToolProgressSink interface { Publish(context.Context, ToolProgress) } +type ToolProgress struct { Payload json.RawMessage } + +type EventSink interface { Emit(context.Context, Event) error } + +type Event struct { + RunID RunID + StepID StepID + CallID CallID + Sequence uint64 + Kind EventKind + Durability EventDurability + Payload json.RawMessage + Canonical *AgentEvent // set for a committed observation; nil for provisional +} + +type EventDurability uint8 + +const ( + EventProvisional EventDurability = iota + EventCommitted +) + +type EventKind string + +const ( + EventAgentCommitted EventKind = "agent_committed" + EventModelTextDelta EventKind = "model_text_delta" + EventToolProgress EventKind = "tool_progress" + EventToolStarted EventKind = "tool_started" + EventToolCompleted EventKind = "tool_completed" + EventRunFinished EventKind = "run_finished" +) + +type LoopDisposition uint8 + +const ( + LoopWaiting LoopDisposition = iota + LoopFinished +) + +type WaitReason string + +const ( + WaitingForResponse WaitReason = "waiting_for_response" + ExecutionRecovery WaitReason = "execution_recovery" // 当前执行仍在运行或等待失效恢复 +) + +type LoopResult struct { + Disposition LoopDisposition + Reason WaitReason + Waiting []ResponseRequest + Result *RunResult +} + +type Loop struct { + Models ModelCatalog + Tools ToolCatalog + Planner RequestPlanner + Execution ExecutionPolicy + Streaming bool +} + +func (l *Loop) Run(context.Context, Runtime, EventSink) (LoopResult, error) +``` + +实现必须保证所有返回的 Step、Call、Request、Result 和等待 payload 具有只读快照语义;调用方不能通过修改 slice、map 或 `json.RawMessage` 改变 Runtime 状态。`AgentCommand`、`Fact`、`Effect` 和 ToolExecutionOutcome 使用 agent 的 sealed interface,外部实现不能添加未定义变体。构造 CommandEnvelope 与派生 CommandID/ResponseID 只能通过 agent 提供的 typed 构造函数;手工拼装信封字段属于实现错误。 + +## 附录 B:核心不变量 + +1. sdk 的一次 `Generate` 或 `Stream` 对应一次 provider request;transport retry 不创建新的 Step。 +2. `agent.Loop` 是唯一的多步执行算法;Run 的权威状态由 Runtime 持有,Loop 不保存第二份。 +3. Runtime 对 Loop 只公开 `Load` 和 `Commit`;Planner、queue 和工具入口不是 Runtime 的隐藏第三、第四个方法。 +4. Machine 是完整的 Run/Step/ToolCall 语义规则;决策只在 `Decide` 中、只在提交时运行一次,`Evolve` 是机械折叠。Runtime 通过共享 `EvaluateCommit` 调用它们,不复刻规则。 +5. Step 是 durable resume boundary,只有 ModelStep 和 ToolStep;ToolCall 是 ToolStep 内的 progress。 +6. ModelStep 完成有 tool calls 时产出 `ToolStepOpened`;全部 Call 到达可关闭终态时,同一 transition 产出 `ToolStepClosed`。终态一律以 `RunEnded` 显式产出,且它是其 transition 的最后一个事实。 +7. Pending Call 必须在 start command Accepted 后才可执行;多个独立 Pending Call 可按 ExecutionPolicy 并行。 +8. Waiting response 只推进对应 Call;approval approved 先变 Pending,随后由 Loop 执行工具。日志记录结果事实(`ToolCallFailed{permission_denied}`、`RunEnded{cancelled}`),不记录请求本身。 +9. 幂等按 command 判定:相同 CommandID/digest 重放返回 CommitAlreadyApplied 与原事件组(不重新运行 Decide),不重复写入 projection、history、queue action 或 outbox;相同 CommandID 不同 digest 冲突。 +10. 一次接受的 transition 使 Revision 恰好加一;其全部事实共享该 Revision,Index 组内连续,提交后 `Snapshot.Revision` 等于该 Revision。 +11. MachineState 是 execution authority,AgentEvent log 是 historical authority;二者是同一 transition 序列的两个 materialization,由同一原子提交产生。对任意 Revision,状态必须等于初始状态经 `Evolve` 折叠事件流的结果;分歧即 halt,不定义自动覆盖方向。 +12. Evolve 的折叠语义与事件编码同属永久兼容契约,按 SchemaVersion 冻结;Decide 的决策规则可随版本演进,因为决策结果已记录为事实。 +13. 已知工具失败交给下一次模型请求;Unknown 终止 Run,不自动重试、不查询外部系统。 +14. worker cancellation 不等于 RunStopped;业务停止必须提交控制 command。宿主的业务停止先提交 `CancelRun`,再取消 Loop 的 ctx;ctx 取消本身只结束执行尝试,工具 worker 运行到自身结束。 +15. EventSink 只是实时观察;AgentEvent、durable snapshot 和 outbox 才是 replay/recovery 依据。 +16. MemoryRuntime 用进程内同步;MemohRuntime 用事务、CAS 和内部 Attempt/owner/fence/lease;两者共享 `EvaluateCommit` 与 Machine 规则,但不共享存储实现。 +17. 结构性 malformed 的模型结果以 `RejectModelResult` 回到 Prepared,在同一冻结 request 上重试并累计 usage;超过 `RunConfig.ModelRejectLimit` 才 RunFailed。单个 Call 的参数解析失败不是 malformed,按已知 `invalid_arguments` 进入下一次模型请求。 diff --git a/sdk/model_result.go b/sdk/model_result.go new file mode 100644 index 0000000..5c5289a --- /dev/null +++ b/sdk/model_result.go @@ -0,0 +1,28 @@ +package sdk + +// ModelResult is one complete model response (spec §2.1): 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 agent or +// the application, never here. +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 ResponseMetadata `json:"response,omitempty"` +} diff --git a/sdk/model_stream.go b/sdk/model_stream.go new file mode 100644 index 0000000..d419fba --- /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; both execution paths +// of a ModelInvoker must produce the same final result (spec §4.1). +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..12900b1 --- /dev/null +++ b/sdk/request.go @@ -0,0 +1,76 @@ +package sdk + +import "encoding/json" + +// Request is the complete, frozen input of one model call (spec §2.1). +// +// It is pure data: no provider client, no interface values, no callbacks. +// The model is a provider-scoped string ID; provider binding happens when a +// ModelCatalog resolves a ModelInvoker. Everything here participates in +// DigestRequest with no exclusions, so any field change produces a different +// request identity. +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"` +} From 92a55f5525f1a3d3c37885b2858805628654aab9 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Fri, 28 Aug 2026 00:22:51 +0800 Subject: [PATCH 02/81] fix(agent): enforce derived CommandIDs and guard prepare livelock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EvaluateCommit now rejects AcceptInput/Approve/Reject/SubmitToolResponse carrying a caller-minted CommandID: the derivation IS the idempotency index (spec §5.5), so a random ID silently bypassed duplicate-input detection. Loop.planAndPrepare distinguishes concurrency rejections from content rejections: a retriable Prepare error with no authority progress (same Revision after reload) is surfaced instead of retrying the same plan forever. --- agent/commit.go | 31 +++++++++++++++++++++++++++++++ agent/conformance_test.go | 38 ++++++++++++++++++++++++++++++++++++++ agent/loop.go | 17 +++++++++++++++-- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/agent/commit.go b/agent/commit.go index a50b597..42ec735 100644 --- a/agent/commit.go +++ b/agent/commit.go @@ -157,6 +157,12 @@ func EvaluateCommit( if env.Digest != wantDigest { return CommitDecision{Kind: DecisionConflict, Reject: fmt.Errorf("agent: commit: envelope digest mismatch")}, nil } + // Derived-identity families must use their derived CommandID (spec §5.5): + // the derivation IS the idempotency index for inputs and responses, so a + // caller-minted random ID would silently bypass duplicate detection. + if err := checkDerivedCommandID(env); err != nil { + return CommitDecision{}, err + } // Steps 2-3: idempotent replay and identity conflict. if len(prior) > 0 { @@ -247,6 +253,31 @@ func BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, }, nil } +// checkDerivedCommandID enforces the derived-identity rules of spec §5.5. +// AcceptInput derives from (RunID, InputID); approval/rejection/answer derive +// from (RunID, StepID, CallID, ResponseID). Approve and reject of the same +// response share one identity by design, so a decision change surfaces as +// ErrCommandConflict instead of a second fact. +func checkDerivedCommandID(env CommandEnvelope) error { + var want CommandID + switch cmd := env.Command.(type) { + case AcceptInput: + want = DeriveInputCommandID(env.RunID, cmd.Input.ID) + 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) + default: + return nil + } + if env.ID != want { + return fmt.Errorf("agent: commit: %s requires its derived CommandID", env.Type) + } + return nil +} + // isStartCommand reports whether an accepted commit of this command mints a // new ExecutionGrant. func isStartCommand(c AgentCommand) bool { diff --git a/agent/conformance_test.go b/agent/conformance_test.go index 7ec7384..c700707 100644 --- a/agent/conformance_test.go +++ b/agent/conformance_test.go @@ -309,3 +309,41 @@ func TestConformanceAcceptInputByInputID(t *testing.T) { t.Fatalf("err = %v, want ErrCommandConflict", err) } } + +func TestConformanceDerivedCommandIDEnforced(t *testing.T) { + // Derived-identity families cannot bypass their idempotency index with a + // caller-minted CommandID (spec §5.5). + rt := newTestRuntime(t, RunConfig{Model: "m-1"}) + _, err := commitCmd(t, rt, "random-id", 0, "", NextStep(AgentInput{ID: "in-1", Payload: json.RawMessage(`1`)})) + if err == nil { + t.Fatal("AcceptInput with non-derived CommandID accepted") + } + // Response commands are checked the same way. + _, err = commitCmd(t, rt, "random-id-2", 0, "", ApproveToolCall{StepID: "s", CallID: "c", ResponseID: "r"}) + if err == nil { + t.Fatal("ApproveToolCall with non-derived CommandID accepted") + } +} + +func TestConformancePrepareRejectionDoesNotLivelock(t *testing.T) { + // A planner that omits pending InputIDs produces a Prepare the authority + // rejects at the same revision forever; the Loop must surface an error + // instead of spinning (loop.go planAndPrepare guard). + rt := newTestRuntime(t, RunConfig{Model: "m-1"}) + loop, err := NewLoop(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{}, + badPlanner{}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + _, err = loop.Run(context.Background(), rt, nil) + if err == nil { + t.Fatal("prepare livelock not surfaced") + } +} + +// badPlanner never consumes pending inputs, so its Prepare is always rejected. +type badPlanner struct{} + +func (badPlanner) Plan(_ context.Context, hint PlanningHint) (RequestPlan, error) { + return RequestPlan{Model: hint.Model, Request: sdk.Request{Model: string(hint.Model)}}, nil +} diff --git a/agent/loop.go b/agent/loop.go index 971a498..d330e7f 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -152,10 +152,23 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, Tools: plan.Tools, ToolsDigest: toolsDigest, }) - if err != nil && !retriable(err) { + if err == nil { + return nil + } + if !retriable(err) { return err } - return nil // reload decides the next action + // 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(controlCtx) + if loadErr != nil { + return loadErr + } + if after.Revision == snapshot.Revision { + 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 --- From 1ec27a5c3eeab868b48ae63276ad1ea1b19ab406 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Fri, 28 Aug 2026 02:47:03 +0800 Subject: [PATCH 03/81] fix(agent): close all confirmed code-review findings Authority correctness: - SubmitModelResult gates on the result's own tool calls: a result with calls and zero bindings no longer silently completes the run - bindings are cross-checked against the model result (tool name via the frozen spec, canonical arguments); a self-consistent binding for a tool the model never called is rejected - ToolStepOpened carries BindingSetDigest; Evolve folds it verbatim, so DeriveToolStepID(Source, StepRef.Digest) reproduces the step ID and a future schema bump cannot change how v1 events fold - Evolve and ToolStepOpened validate ToolCallState combinations (spec 4.2) - CancelRun's reason is fixed to cancelled; ingress cannot forge step_limit - RejectToolCall accepts Waiting calls of either kind, giving abandoned ask-user calls a known-failure exit instead of stranding the run Canonical encoding: - integer tokens keep arbitrary precision (64-bit IDs above 2^53 no longer corrupt or collide digests); floats keep the ES6 double form - duplicate object keys, trailing data and invalid UTF-8 are rejected instead of silently merging distinct payloads into one digest Loop: - settleWorkers replays a failed completion commit once, then surfaces the error instead of silently wedging the call in Executing - a panicking tool settles as ToolExecutionUnknown instead of crashing the process - streaming consumption has a ctx escape and a nil-result guard; reasoning deltas are forwarded (EventModelReasoningDelta) - EventRunFinished fires on terminal - bindings use ToolSpec.Ref (catalog key), fixing aliased tools - PlanningHint.SourceStep populated from the new LastClosedStep field Runtime: - MemoryRuntime Load/Commit/Events return deep copies; caller mutation can no longer reach authoritative state or committed event bytes Tests: golden digest assertion is now fatal; parallel-bound test gates workers to prove real concurrency; regression test per finding. Also: sdk.Usage.Add replaces the duplicated adder; ModelResult.Response is a pointer so absent metadata stays out of fact digests; four wording fixes in the design doc. --- agent/canonical.go | 132 ++++++++-- agent/canonical_test.go | 6 +- agent/clone.go | 226 ++++++++++++++++ agent/conformance_test.go | 11 +- agent/contract.go | 13 +- agent/decide.go | 74 +++++- agent/evolve.go | 19 +- agent/fact.go | 11 +- agent/loop.go | 117 +++++++-- agent/loop_test.go | 31 ++- agent/machine_test.go | 21 +- agent/memory.go | 26 +- agent/next.go | 7 +- agent/protocol.go | 8 +- agent/regression_test.go | 362 ++++++++++++++++++++++++++ agent/state.go | 15 +- agent/usage.go | 28 -- docs/design/agent-runtime-refactor.md | 8 +- sdk/model_result.go | 6 +- sdk/step_helpers.go | 15 +- sdk/usage.go | 17 ++ 21 files changed, 993 insertions(+), 160 deletions(-) create mode 100644 agent/clone.go create mode 100644 agent/regression_test.go delete mode 100644 agent/usage.go diff --git a/agent/canonical.go b/agent/canonical.go index 405043d..45def6c 100644 --- a/agent/canonical.go +++ b/agent/canonical.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "math" "sort" "strconv" @@ -18,21 +19,31 @@ import ( "unicode/utf8" ) -// canonicalJSON transforms arbitrary JSON into its RFC 8785 (JCS) canonical -// form: object keys sorted by UTF-16 code units, numbers in ES6 shortest -// form, minimal string escaping, no insignificant whitespace. +// canonicalJSON transforms JSON into the protocol's canonical form: RFC 8785 +// (JCS) object-key ordering and string escaping, with two deliberate +// deviations required for digest identity — integer tokens keep arbitrary +// precision instead of collapsing through float64, and inputs JCS tolerates +// but that would merge distinct payloads into one digest (duplicate object +// keys, trailing data, invalid UTF-8) are rejected outright. // // Canonical bytes are digest input (spec §5.5); the encoding for a published // SchemaVersion is frozen forever. func canonicalJSON(raw []byte) ([]byte, error) { + // Reject invalid UTF-8 up front: encoding/json silently rewrites broken + // bytes to U+FFFD during decode, which would merge distinct payloads into + // one canonical identity before writeCanonicalString could see them. + if !utf8.Valid(raw) { + return nil, errors.New("agent: canonical: invalid UTF-8 input") + } dec := json.NewDecoder(bytes.NewReader(raw)) dec.UseNumber() - var v any - if err := dec.Decode(&v); err != nil { + v, err := parseCanonicalValue(dec) + if err != nil { return nil, fmt.Errorf("agent: canonical: %w", err) } - // Reject trailing content after the first JSON value. - if dec.More() { + // Strict end: any trailing token — including a stray ']' or '}' the + // decoder's More() would miss — rejects the input. + if _, err := dec.Token(); err != io.EOF { return nil, errors.New("agent: canonical: trailing data after JSON value") } var b bytes.Buffer @@ -42,6 +53,65 @@ func canonicalJSON(raw []byte) ([]byte, error) { return b.Bytes(), nil } +// parseCanonicalValue decodes one JSON value from the token stream, rejecting +// duplicate object keys: last-wins collapsing would let two byte-distinct +// payloads share one digest. +func parseCanonicalValue(dec *json.Decoder) (any, error) { + tok, err := dec.Token() + if err != nil { + return nil, err + } + return parseFromToken(dec, tok) +} + +func parseFromToken(dec *json.Decoder, tok json.Token) (any, error) { + delim, ok := tok.(json.Delim) + if !ok { + return tok, nil // string, json.Number, bool, or nil + } + switch delim { + case '{': + m := make(map[string]any) + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, err + } + key, ok := keyTok.(string) + if !ok { + return nil, fmt.Errorf("object key %v is not a string", keyTok) + } + if _, dup := m[key]; dup { + return nil, fmt.Errorf("duplicate object key %q", key) + } + val, err := parseCanonicalValue(dec) + if err != nil { + return nil, err + } + m[key] = val + } + if _, err := dec.Token(); err != nil { // consume '}' + return nil, err + } + return m, nil + case '[': + a := []any{} + for dec.More() { + val, err := parseCanonicalValue(dec) + if err != nil { + return nil, err + } + a = append(a, val) + } + if _, err := dec.Token(); err != nil { // consume ']' + return nil, err + } + return a, nil + default: + return nil, fmt.Errorf("unexpected delimiter %v", delim) + } +} + func writeCanonical(b *bytes.Buffer, v any) error { switch x := v.(type) { case nil: @@ -59,7 +129,9 @@ func writeCanonical(b *bytes.Buffer, v any) error { } b.WriteString(s) case string: - writeCanonicalString(b, x) + if err := writeCanonicalString(b, x); err != nil { + return err + } case []any: b.WriteByte('[') for i, e := range x { @@ -82,7 +154,9 @@ func writeCanonical(b *bytes.Buffer, v any) error { if i > 0 { b.WriteByte(',') } - writeCanonicalString(b, k) + if err := writeCanonicalString(b, k); err != nil { + return err + } b.WriteByte(':') if err := writeCanonical(b, x[k]); err != nil { return err @@ -107,12 +181,12 @@ func utf16Less(a, b string) bool { return len(ua) < len(ub) } -// writeCanonicalString emits a JSON string with JCS minimal escaping: -// only `"`, `\` and control characters are escaped; control characters use -// their short forms where defined, \u00xx otherwise. -func writeCanonicalString(b *bytes.Buffer, s string) { +// writeCanonicalString emits a JSON string with JCS minimal escaping. Invalid +// UTF-8 is rejected: silently replacing broken bytes with U+FFFD would merge +// distinct payloads into one canonical identity. +func writeCanonicalString(b *bytes.Buffer, s string) error { b.WriteByte('"') - for _, r := range s { + for i, r := range s { switch r { case '"': b.WriteString(`\"`) @@ -132,22 +206,40 @@ func writeCanonicalString(b *bytes.Buffer, s string) { if r < 0x20 { fmt.Fprintf(b, `\u%04x`, r) } else if r == utf8.RuneError { - // Invalid UTF-8 input cannot have a stable canonical form. - b.WriteString(string(utf8.RuneError)) + if _, size := utf8.DecodeRuneInString(s[i:]); size == 1 { + return errors.New("agent: canonical: invalid UTF-8 in string") + } + b.WriteRune(r) // a genuine U+FFFD character } else { b.WriteRune(r) } } } b.WriteByte('"') + return nil } -// canonicalNumber renders a JSON number per RFC 8785: the value is treated as -// an IEEE 754 double and serialized with the ES6 Number::toString algorithm. +// canonicalNumber renders a JSON number. Integer tokens keep their exact +// digits (arbitrary precision): forcing them through float64 per strict JCS +// would corrupt 64-bit identifiers above 2^53 and collide near-adjacent +// values into one digest. Non-integer tokens use the ES6 double form. func canonicalNumber(n json.Number) (string, error) { - f, err := strconv.ParseFloat(n.String(), 64) + s := n.String() + if !strings.ContainsAny(s, ".eE") { + neg := strings.HasPrefix(s, "-") + digits := strings.TrimPrefix(s, "-") + digits = strings.TrimLeft(digits, "0") + if digits == "" { + return "0", nil // 0 and -0 canonicalize to "0" + } + if neg { + return "-" + digits, nil + } + return digits, nil + } + f, err := strconv.ParseFloat(s, 64) if err != nil { - return "", fmt.Errorf("agent: canonical: bad number %q: %w", n.String(), err) + return "", fmt.Errorf("agent: canonical: bad number %q: %w", s, err) } return formatES6Float(f) } diff --git a/agent/canonical_test.go b/agent/canonical_test.go index ce9e206..2a086c1 100644 --- a/agent/canonical_test.go +++ b/agent/canonical_test.go @@ -181,9 +181,11 @@ func TestSchemaVersion1Golden(t *testing.T) { if err != nil { t.Fatal(err) } - const wantDigest = "sha256:GOLDEN" + // Frozen for SchemaVersion 1. A mismatch here is a protocol break that + // invalidates every persisted digest — fix the code, not this constant. + const wantDigest = "sha256:a7770a5443f180ec1935bfa4498af75375b8d5f182f239f587917b28b78ee80c" if string(d) != wantDigest { - t.Logf("golden digest for cancel_run: %s", d) + t.Fatalf("golden digest changed:\n got %s\nwant %s", d, wantDigest) } fact := InputAccepted{Input: AgentInput{ID: "in-1", Payload: json.RawMessage(`{"text":"hi"}`)}} diff --git a/agent/clone.go b/agent/clone.go new file mode 100644 index 0000000..af25340 --- /dev/null +++ b/agent/clone.go @@ -0,0 +1,226 @@ +package agent + +import ( + "encoding/json" + + "github.com/memohai/twilight-ai/sdk" +) + +// Deep-copy helpers: Runtime return values must be read-only snapshots (spec +// appendix A) — a caller mutating a returned slice, map, or json.RawMessage +// must never reach authoritative storage or committed event bytes. + +func cloneRaw(m json.RawMessage) json.RawMessage { + if m == nil { + return nil + } + return append(json.RawMessage(nil), m...) +} + +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 cloneToolExecutionResult(r *ToolExecutionResult) *ToolExecutionResult { + if r == nil { + return nil + } + c := *r + c.Output = cloneRaw(c.Output) + return &c +} + +func cloneToolCallFailure(f *ToolCallFailure) *ToolCallFailure { + if f == nil { + return nil + } + c := *f + return &c +} + +func cloneToolCallState(c ToolCallState) ToolCallState { + c.Arguments = cloneRaw(c.Arguments) + c.Result = cloneToolExecutionResult(c.Result) + c.Failure = cloneToolCallFailure(c.Failure) + c.Waiting = cloneResponseRequest(c.Waiting) + return c +} + +func cloneToolCallBinding(b ToolCallBinding) ToolCallBinding { + b.Arguments = cloneRaw(b.Arguments) + b.Response = cloneResponseRequest(b.Response) + return b +} + +func cloneToolCallBindings(bs []ToolCallBinding) []ToolCallBinding { + if bs == nil { + return nil + } + out := make([]ToolCallBinding, len(bs)) + for i, b := range bs { + out[i] = cloneToolCallBinding(b) + } + return out +} + +func cloneToolSpecs(specs []ToolSpec) []ToolSpec { + if specs == nil { + return nil + } + out := make([]ToolSpec, len(specs)) + for i, s := range specs { + s.Definition = cloneToolDefinition(s.Definition) + out[i] = s + } + return out +} + +func cloneToolDefinition(d sdk.ToolDefinition) sdk.ToolDefinition { + d.Parameters = cloneRaw(d.Parameters) + if d.CacheControl != nil { + cc := *d.CacheControl + d.CacheControl = &cc + } + return d +} + +func cloneRequest(r sdk.Request) sdk.Request { + r.Messages = append([]sdk.Message(nil), r.Messages...) + tools := make([]sdk.ToolDefinition, len(r.Tools)) + for i, t := range r.Tools { + tools[i] = cloneToolDefinition(t) + } + if r.Tools != nil { + r.Tools = tools + } + r.StopSequences = append([]string(nil), r.StopSequences...) + if r.ProviderOptions != nil { + opts := make(map[string]json.RawMessage, len(r.ProviderOptions)) + for k, v := range r.ProviderOptions { + opts[k] = cloneRaw(v) + } + r.ProviderOptions = opts + } + return r +} + +func cloneModelResult(r *sdk.ModelResult) *sdk.ModelResult { + if r == nil { + return nil + } + c := *r + c.ReasoningParts = append([]sdk.ReasoningPart(nil), c.ReasoningParts...) + c.Sources = append([]sdk.Source(nil), c.Sources...) + c.Files = append([]sdk.GeneratedFile(nil), c.Files...) + c.ToolCalls = append([]sdk.ToolCall(nil), c.ToolCalls...) + return &c +} + +func cloneRunResult(r *RunResult) *RunResult { + if r == nil { + return nil + } + c := *r + if c.Failure != nil { + f := *c.Failure + c.Failure = &f + } + c.Model = cloneModelResult(c.Model) + return &c +} + +func cloneStep(s Step) Step { + switch step := s.(type) { + case ModelStep: + step.Request = cloneRequest(step.Request) + step.Tools = cloneToolSpecs(step.Tools) + return step + case ToolStep: + calls := make([]ToolCallState, len(step.Calls)) + for i, c := range step.Calls { + calls[i] = cloneToolCallState(c) + } + step.Calls = calls + return step + default: + return s + } +} + +func cloneMachineState(s MachineState) MachineState { + if s.Current != nil { + s.Current = cloneStep(s.Current) + } + s.PendingInputs = cloneAgentInputs(s.PendingInputs) + s.LastModelResult = cloneModelResult(s.LastModelResult) + s.Result = cloneRunResult(s.Result) + return s +} + +func cloneFact(f Fact) Fact { + switch fact := f.(type) { + case ModelStepPrepared: + fact.Request = cloneRequest(fact.Request) + fact.InputIDs = append([]InputID(nil), fact.InputIDs...) + fact.Tools = cloneToolSpecs(fact.Tools) + return fact + case ModelStepCompleted: + if r := cloneModelResult(&fact.Result); r != nil { + fact.Result = *r + } + return fact + case ToolStepOpened: + fact.Calls = cloneToolCallBindings(fact.Calls) + return fact + case ToolCallCompleted: + fact.Result.Output = cloneRaw(fact.Result.Output) + return fact + case ToolCallAnswered: + fact.Payload = cloneRaw(fact.Payload) + return fact + case InputAccepted: + fact.Input = cloneAgentInput(fact.Input) + return fact + case RunEnded: + if fact.Failure != nil { + f := *fact.Failure + fact.Failure = &f + } + return fact + default: + return f + } +} + +func cloneEvents(events []AgentEvent) []AgentEvent { + if events == nil { + return nil + } + out := make([]AgentEvent, len(events)) + for i, e := range events { + e.Fact = cloneFact(e.Fact) + out[i] = e + } + return out +} diff --git a/agent/conformance_test.go b/agent/conformance_test.go index c700707..01c30dc 100644 --- a/agent/conformance_test.go +++ b/agent/conformance_test.go @@ -160,11 +160,16 @@ func TestConformanceCallLocalRebase(t *testing.T) { rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{defA, defB}, []ToolSpec{specA, specB}) bA := makeBinding(t, "cA", specA, `{}`) - bA.ToolRef = "a" bB := makeBinding(t, "cB", specB, `{}`) - bB.ToolRef = "b" + r := sdk.ModelResult{ + FinishReason: sdk.FinishReasonToolCalls, + ToolCalls: []sdk.ToolCall{ + {ToolCallID: "cA", ToolName: "a", Input: `{}`}, + {ToolCallID: "cB", ToolName: "b", Input: `{}`}, + }, + } res := mustCommit(t, rt, "complete-1", 2, grant, - SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("cA", "cB"), Calls: []ToolCallBinding{bA, bB}}) + SubmitModelResult{StepID: stepID, Result: r, Calls: []ToolCallBinding{bA, bB}}) toolStep := res.Events[1].Fact.(ToolStepOpened).StepID base := res.Snapshot.Revision diff --git a/agent/contract.go b/agent/contract.go index dacd5ec..d1370d0 100644 --- a/agent/contract.go +++ b/agent/contract.go @@ -104,12 +104,13 @@ const ( type EventKind string const ( - EventAgentCommitted EventKind = "agent_committed" - EventModelTextDelta EventKind = "model_text_delta" - EventToolProgress EventKind = "tool_progress" - EventToolStarted EventKind = "tool_started" - EventToolCompleted EventKind = "tool_completed" - EventRunFinished EventKind = "run_finished" + 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 { diff --git a/agent/decide.go b/agent/decide.go index 39fd205..3d743b2 100644 --- a/agent/decide.go +++ b/agent/decide.go @@ -178,7 +178,13 @@ func decideSubmitModelResult(s MachineState, cmd SubmitModelResult) ([]Fact, err } completed := ModelStepCompleted{StepID: cmd.StepID, Result: cmd.Result} - if len(cmd.Calls) == 0 { + // 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)) + } return []Fact{completed, RunEnded{Status: RunCompleted}}, nil } @@ -207,7 +213,13 @@ func decideSubmitModelResult(s MachineState, cmd SubmitModelResult) ([]Fact, err } seen[b.CallID] = true - if spec, known := specByName[string(b.ToolRef)]; known { + // Cross-check the binding against the model result: the authority + // accepts only bindings for the tool the model actually named, with + // the arguments the model actually produced (spec §4.1). + if spec, known := specByName[rc.ToolName]; known { + if b.ToolRef != spec.Ref { + return nil, 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 nil, rejectionf("model result: binding %q definition digest does not match frozen ToolSpec", b.CallID) } @@ -218,10 +230,20 @@ func decideSubmitModelResult(s MachineState, cmd SubmitModelResult) ([]Fact, err // Unknown ToolRef stays an unresolved DirectExecution binding with // an empty definition digest; StartToolCalls records the lookup // failure (spec §4.1). + if string(b.ToolRef) != rc.ToolName { + return nil, 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 nil, rejectionf("model result: unresolved binding %q must be DirectExecution with empty digest", b.CallID) } } + wantArgs, argsCanonical := canonicalArgumentsForCompare(rc.Input) + if argsCanonical { + gotArgs, err := canonicalJSON(b.Arguments) + if err != nil || string(gotArgs) != string(wantArgs) { + return nil, 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 nil, err @@ -260,12 +282,25 @@ func decideSubmitModelResult(s MachineState, cmd SubmitModelResult) ([]Fact, err } } return []Fact{completed, ToolStepOpened{ - StepID: toolStepID, - Source: cmd.StepID, - Calls: bindings, + StepID: toolStepID, + Source: cmd.StepID, + BindingSetDigest: setDigest, + Calls: bindings, }}, nil } +// canonicalArgumentsForCompare canonicalizes a model result's tool input for +// cross-checking a binding. The second return is false when the input is not +// valid JSON — those calls bind raw and close as invalid_arguments, so there +// is no canonical form to compare. +func canonicalArgumentsForCompare(input any) ([]byte, bool) { + got, err := canonicalToolArguments(input) + if err != nil { + return nil, false + } + return got, true +} + // --- rule 4: SubmitModelFailure --- func decideSubmitModelFailure(s MachineState, cmd SubmitModelFailure) ([]Fact, error) { @@ -446,11 +481,26 @@ func decideApproveToolCall(s MachineState, cmd ApproveToolCall) ([]Fact, error) } func decideRejectToolCall(s MachineState, cmd RejectToolCall) ([]Fact, error) { - if _, err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseApproval, cmd.ResponseID); err != nil { + // 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. Spec §4.2 lists Waiting -> Failed(Known) + // as 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 } - ts, _ := currentToolStep(s, cmd.StepID) 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) + } facts := []Fact{ToolCallFailed{ StepID: cmd.StepID, CallID: cmd.CallID, @@ -479,11 +529,13 @@ func decideSubmitToolResponse(s MachineState, cmd SubmitToolResponse) ([]Fact, e // --- rules 12-13: cancel and input --- func decideCancelRun(s MachineState, cmd CancelRun) ([]Fact, error) { - reason := cmd.Reason - if reason == "" { - reason = ReasonCancelled + // Spec rule 12 fixes the mapping: CancelRun always records + // RunStopped(cancelled). A caller-chosen reason would let ingress forge + // step_limit or other system reasons into the log. + if cmd.Reason != "" && cmd.Reason != ReasonCancelled { + return nil, rejectionf("cancel: reason must be empty or %q", ReasonCancelled) } - return []Fact{RunEnded{Status: RunStopped, Reason: reason}}, nil + return []Fact{RunEnded{Status: RunStopped, Reason: ReasonCancelled}}, nil } func decideAcceptInput(s MachineState, cmd AcceptInput) ([]Fact, error) { diff --git a/agent/evolve.go b/agent/evolve.go index 1727870..a80c606 100644 --- a/agent/evolve.go +++ b/agent/evolve.go @@ -53,7 +53,7 @@ func Evolve(s MachineState, f Fact) (MachineState, error) { ms.Rejects++ ms.Status = ModelPrepared s.Current = *ms - s.Usage = addUsage(s.Usage, fact.Usage) + s.Usage = s.Usage.Add(fact.Usage) return s, nil case ModelStepCompleted: @@ -62,7 +62,7 @@ func Evolve(s MachineState, f Fact) (MachineState, error) { } result := fact.Result s.LastModelResult = &result - s.Usage = addUsage(s.Usage, fact.Result.Usage) + s.Usage = s.Usage.Add(fact.Result.Usage) s.Current = nil return s, nil @@ -70,10 +70,6 @@ func Evolve(s MachineState, f Fact) (MachineState, error) { if s.Current != nil { return s, fmt.Errorf("agent: evolve: tool step opened while a step is current") } - setDigest, err := digestBindingSet(fact.Calls) - if err != nil { - return s, err - } calls := make([]ToolCallState, len(fact.Calls)) for i, b := range fact.Calls { status := ToolPending @@ -93,9 +89,12 @@ func Evolve(s MachineState, f Fact) (MachineState, error) { Status: status, Waiting: waiting, } + if err := ValidateToolCallState(calls[i]); err != nil { + return s, err + } } s.Current = ToolStep{ - RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: setDigest}, + RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: fact.BindingSetDigest}, Source: fact.Source, Calls: calls, } @@ -140,6 +139,7 @@ func Evolve(s MachineState, f Fact) (MachineState, error) { return s, fmt.Errorf("agent: evolve: tool step %q is not current", fact.StepID) } s.Current = nil + s.LastClosedStep = fact.StepID return s, nil case InputAccepted: @@ -187,6 +187,11 @@ func evolveCall(s MachineState, step StepID, call CallID, apply func(*ToolCallSt } calls := append([]ToolCallState(nil), ts.Calls...) apply(&calls[i]) + // Spec §4.2: Evolve must reject illegal field combinations, e.g. an + // unknown-outcome failure whose class is not effect_unknown. + if err := ValidateToolCallState(calls[i]); err != nil { + return s, err + } ts.Calls = calls s.Current = ts return s, nil diff --git a/agent/fact.go b/agent/fact.go index 01fe3f2..cac2e86 100644 --- a/agent/fact.go +++ b/agent/fact.go @@ -59,10 +59,15 @@ type ModelStepCompleted struct { 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 - Calls []ToolCallBinding `json:"calls"` + StepID StepID `json:"stepId"` // the new ToolStep + Source StepID `json:"source"` // the completed ModelStep + BindingSetDigest Digest `json:"bindingSetDigest"` + Calls []ToolCallBinding `json:"calls"` } func (ToolStepOpened) fact() {} diff --git a/agent/loop.go b/agent/loop.go index d330e7f..bbaa7a3 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -49,7 +49,13 @@ func (l *Loop) Run(ctx context.Context, runtime Runtime, events EventSink) (Loop return LoopResult{}, err } if snapshot.State.Status.Terminal() { - l.emitCommitted(controlCtx, events, snapshot.State.RunID, nil) + if events != nil { + _ = events.Emit(controlCtx, Event{ + RunID: snapshot.State.RunID, + Kind: EventRunFinished, + Durability: EventCommitted, + }) + } return LoopResult{Disposition: LoopFinished, Result: snapshot.State.Result}, nil } @@ -234,22 +240,44 @@ func (l *Loop) invokeModel(ctx context.Context, invoker ModelInvoker, req sdk.Re if err != nil { return sdk.ModelResult{}, err } - for part := range stream.Parts { - if events == nil { - continue - } - if delta, ok := part.(*sdk.TextDeltaPart); ok { - _ = events.Emit(ctx, Event{ - RunID: run, StepID: step, - Kind: EventModelTextDelta, Durability: EventProvisional, - Payload: mustJSON(delta.Text), - }) + // 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. + consume: + for { + select { + case part, open := <-stream.Parts: + if !open { + break consume + } + if events == nil { + continue + } + switch p := part.(type) { + case *sdk.TextDeltaPart: + _ = events.Emit(ctx, Event{ + RunID: run, StepID: step, + Kind: EventModelTextDelta, Durability: EventProvisional, + Payload: mustJSON(p.Text), + }) + case *sdk.ReasoningDeltaPart: + _ = events.Emit(ctx, Event{ + RunID: run, StepID: step, + Kind: EventModelReasoningDelta, Durability: EventProvisional, + Payload: mustJSON(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 } } @@ -290,6 +318,10 @@ func (l *Loop) bindToolCalls(result sdk.ModelResult, step ModelStep) ([]ToolCall Policy: 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 } @@ -363,13 +395,12 @@ func (l *Loop) runToolCalls(ctx, controlCtx context.Context, runtime Runtime, ev // Known failure of a Pending call: no start barrier, no tool call. res, err := l.commit(controlCtx, runtime, run, freshCommandID(), snapshot.Revision, "", SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: *known, Outcome: ToolOutcomeKnown}) - if err != nil && !retriable(err) { - l.settleWorkers(ctx, controlCtx, runtime, events, run, eff.StepID, started) - return err - } if err != nil { - l.settleWorkers(ctx, controlCtx, runtime, events, run, eff.StepID, started) - return nil + settleErr := l.settleWorkers(ctx, controlCtx, runtime, events, run, eff.StepID, started) + if !retriable(err) { + return err + } + return settleErr } l.emitCommitted(controlCtx, events, run, res.Events) continue @@ -378,9 +409,9 @@ func (l *Loop) runToolCalls(ctx, controlCtx context.Context, runtime Runtime, ev start, err := l.commit(controlCtx, runtime, run, freshCommandID(), snapshot.Revision, "", StartToolCall{StepID: eff.StepID, CallID: callID}) if err != nil { - l.settleWorkers(ctx, controlCtx, runtime, events, run, eff.StepID, started) + settleErr := l.settleWorkers(ctx, controlCtx, runtime, events, run, eff.StepID, started) if retriable(err) { - return nil + return settleErr } return err } @@ -395,27 +426,30 @@ func (l *Loop) runToolCalls(ctx, controlCtx context.Context, runtime Runtime, ev started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Revision, tool: tool}) } - l.settleWorkers(ctx, controlCtx, runtime, events, run, eff.StepID, started) - return nil + return l.settleWorkers(ctx, controlCtx, runtime, events, run, eff.StepID, started) } // settleWorkers executes every started worker and commits its outcome. An // accepted start is never abandoned (spec §6.2). Tool workers do not inherit -// outer-ctx cancellation (spec §6.1); one Unknown cancels the rest. -func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime Runtime, events EventSink, run RunID, stepID StepID, started []startedWorker) { +// outer-ctx cancellation (spec §6.1); one Unknown cancels the rest. A commit +// that fails with a non-sentinel error is replayed once with the same +// CommandID (spec §6.6 "commit response unknown"); a still-failing commit is +// reported so the host does not mistake a wedged call for progress. +func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime Runtime, events EventSink, run RunID, stepID StepID, started []startedWorker) error { if len(started) == 0 { - return + return nil } execCtx, cancelAll := context.WithCancel(context.WithoutCancel(ctx)) defer cancelAll() var mu sync.Mutex var wg sync.WaitGroup + var firstErr error for _, w := range started { wg.Add(1) go func(w startedWorker) { defer wg.Done() - outcome := w.tool.Execute(execCtx, ToolExecutionRequest{ + outcome := executeToolSafely(execCtx, w.tool, ToolExecutionRequest{ RunID: run, StepID: stepID, CallID: w.call.CallID, @@ -450,13 +484,26 @@ func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime Runtime, e // Commit with the worker's own grant on its start base; stale // bases rebase call-locally. Late results after terminal return // ErrRunTerminal and are dropped (audit is the adapter's job). - res, err := l.commit(controlCtx, runtime, run, freshCommandID(), w.base, w.grant, cmd) - if err == nil { + cmdID := freshCommandID() + res, err := l.commit(controlCtx, runtime, run, cmdID, w.base, w.grant, cmd) + if err != nil && !retriable(err) { + // One replay with the same CommandID and digest. + res, err = l.commit(controlCtx, runtime, run, cmdID, w.base, w.grant, cmd) + } + switch { + case err == nil: l.emitCommitted(controlCtx, events, run, res.Events) if events != nil { _ = events.Emit(controlCtx, Event{RunID: run, StepID: stepID, CallID: w.call.CallID, Kind: EventToolCompleted, Durability: EventCommitted}) } + case retriable(err): + // Terminal/stale: the authority already settled this call or + // the run; the result is intentionally dropped. + default: + if firstErr == nil { + firstErr = fmt.Errorf("agent: loop: settling call %q: %w", w.call.CallID, err) + } } if unknown { cancelAll() // one Unknown cancels sibling workers (spec §6.2) @@ -464,6 +511,22 @@ func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime Runtime, e }(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: ToolFailure{ + Class: FailureEffectUnknown, + Message: fmt.Sprintf("tool panic: %v", r), + }} + } + }() + return tool.Execute(ctx, req) } type progressSink struct { diff --git a/agent/loop_test.go b/agent/loop_test.go index 6e2d338..fa64cc3 100644 --- a/agent/loop_test.go +++ b/agent/loop_test.go @@ -294,6 +294,8 @@ func TestLoopUnknownToolRefClosesAsLookupFailure(t *testing.T) { 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: spec.Definition, policy: DirectExecution, execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { cur := concurrent.Add(1) @@ -303,7 +305,9 @@ func TestLoopParallelBounded(t *testing.T) { break } } - defer concurrent.Add(-1) + started <- struct{}{} + <-gate // hold every worker until released so concurrency is real + concurrent.Add(-1) return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}} }} invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1", "c2", "c3"), textResult("done")}} @@ -311,15 +315,30 @@ func TestLoopParallelBounded(t *testing.T) { loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{MaxParallel: 2}, false) - res, err := loop.Run(context.Background(), rt, nil) - if err != nil { - t.Fatal(err) + done := make(chan struct{}) + var res LoopResult + var runErr error + go func() { + res, runErr = loop.Run(context.Background(), rt, 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 exceeded MaxParallel 2", peak.Load()) + if peak.Load() != 2 { + t.Fatalf("peak concurrency = %d, want exactly 2 (bounded and actually parallel)", peak.Load()) } } diff --git a/agent/machine_test.go b/agent/machine_test.go index 002e0c7..bec9e31 100644 --- a/agent/machine_test.go +++ b/agent/machine_test.go @@ -112,13 +112,19 @@ func makeBinding(t *testing.T, callID string, spec ToolSpec, args string) ToolCa } func modelResultWithCalls(callIDs ...string) sdk.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) sdk.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: "t"}) + r.ToolCalls = append(r.ToolCalls, sdk.ToolCall{ToolCallID: id, ToolName: toolName, Input: args}) } return r } @@ -220,7 +226,7 @@ func TestModelCompleteWithToolsOpensToolStep(t *testing.T) { s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) b := makeBinding(t, "c1", spec, `{"x":1}`) - facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) + 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)) } @@ -349,10 +355,15 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { s, stepID := advanceToExecuting(t, s, testRequest(defA, defB), []ToolSpec{specA, specB}) bA := makeBinding(t, "cA", specA, `{}`) - bA.ToolRef = "a" bB := makeBinding(t, "cB", specB, `{}`) - bB.ToolRef = "b" - r := modelResultWithCalls("cA", "cB") + r := sdk.ModelResult{ + FinishReason: sdk.FinishReasonToolCalls, + Usage: sdk.Usage{TotalTokens: 15}, + ToolCalls: []sdk.ToolCall{ + {ToolCallID: "cA", ToolName: "a", Input: `{}`}, + {ToolCallID: "cB", ToolName: "b", Input: `{}`}, + }, + } facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: r, Calls: []ToolCallBinding{bA, bB}}) opened := facts[1].(ToolStepOpened) s = fold(t, s, facts) diff --git a/agent/memory.go b/agent/memory.go index 25ae9f7..5904b10 100644 --- a/agent/memory.go +++ b/agent/memory.go @@ -38,7 +38,9 @@ func (m *MemoryRuntime) Load(ctx context.Context) (RuntimeSnapshot, error) { } m.mu.Lock() defer m.mu.Unlock() - return RuntimeSnapshot{State: m.state, Revision: m.revision}, nil + // Deep copy: returned snapshots are read-only views; caller mutation must + // never reach authoritative storage (spec appendix A). + return RuntimeSnapshot{State: cloneMachineState(m.state), Revision: m.revision}, nil } func grantKey(c AgentCommand) string { @@ -103,8 +105,8 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe // Replay never re-grants execution (spec §5.4). return CommitResult{ Status: CommitAlreadyApplied, - Snapshot: RuntimeSnapshot{State: m.state, Revision: m.revision}, - Events: decision.Events, + Snapshot: RuntimeSnapshot{State: cloneMachineState(m.state), Revision: m.revision}, + Events: cloneEvents(decision.Events), }, nil case DecisionConflict: return CommitResult{}, ErrCommandConflict @@ -118,11 +120,13 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe } // DecisionApply: persist state + events atomically under the lock, manage - // occupancy, and mint the grant for an accepted start. + // occupancy, and mint the grant for an accepted start. Facts are cloned on + // the way in so caller-held command buffers cannot mutate stored events. m.state = decision.NewState m.revision++ - m.events[req.Command.ID] = decision.Events - m.log = append(m.log, decision.Events...) + stored := cloneEvents(decision.Events) + m.events[req.Command.ID] = stored + m.log = append(m.log, stored...) var minted ExecutionGrant switch req.Command.Command.(type) { @@ -140,16 +144,16 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe return CommitResult{ Status: CommitAccepted, - Snapshot: RuntimeSnapshot{State: m.state, Revision: m.revision}, - Events: decision.Events, + Snapshot: RuntimeSnapshot{State: cloneMachineState(m.state), Revision: m.revision}, + Events: cloneEvents(decision.Events), Grant: minted, }, nil } -// Events returns the full event log in (Revision, Index) order. Test and -// replay helper; not part of the Runtime contract. +// Events returns a deep copy of the full event log in (Revision, Index) +// order. Test and replay helper; not part of the Runtime contract. func (m *MemoryRuntime) Events() []AgentEvent { m.mu.Lock() defer m.mu.Unlock() - return append([]AgentEvent(nil), m.log...) + return cloneEvents(m.log) } diff --git a/agent/next.go b/agent/next.go index 7ca778a..0275080 100644 --- a/agent/next.go +++ b/agent/next.go @@ -51,9 +51,10 @@ func Next(s MachineState) (Effect, error) { switch cur := s.Current.(type) { case nil: return NeedModelRequest{Hint: PlanningHint{ - RunID: s.RunID, - Model: s.Config.Model, - Inputs: append([]AgentInput(nil), s.PendingInputs...), + RunID: s.RunID, + Model: s.Config.Model, + SourceStep: s.LastClosedStep, + Inputs: append([]AgentInput(nil), s.PendingInputs...), }}, nil case ModelStep: if cur.Status == ModelPrepared { diff --git a/agent/protocol.go b/agent/protocol.go index 805b2c8..c022b10 100644 --- a/agent/protocol.go +++ b/agent/protocol.go @@ -157,10 +157,12 @@ func DigestModelStepBinding(model ModelRef, requestDigest, toolsDigest Digest) ( string(model), string(requestDigest), string(toolsDigest)))), nil } -// digestBindingSet covers the full ordered call set of one ToolStep; it feeds -// DeriveToolStepID. +// 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(currentSchemaVersion, "tool_call_bindings", bindings) + body, err := encodeEnvelopeBody(SchemaVersion1, "tool_call_bindings", bindings) if err != nil { return "", err } diff --git a/agent/regression_test.go b/agent/regression_test.go new file mode 100644 index 0000000..9bf64ce --- /dev/null +++ b/agent/regression_test.go @@ -0,0 +1,362 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync/atomic" + "testing" + + "github.com/memohai/twilight-ai/sdk" +) + +// Regression tests for the code-review findings on the phase A/B +// implementation. Each test pins one fixed defect. + +// Finding 1: a result carrying tool calls with zero bindings must not +// silently complete the run. +func TestRegressionZeroBindingsWithToolCallsRejected(t *testing.T) { + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(), nil) + result := modelResultWithCalls("c1") // has tool calls + if _, err := Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: nil}); err == nil { + t.Fatal("result with tool calls and no bindings completed the run") + } +} + +// Finding 2: a binding naming a different tool / different arguments than the +// model result must be rejected even with self-consistent digests. +func TestRegressionBindingMustMatchModelResult(t *testing.T) { + safe := testToolDef("safe") + danger := testToolDef("danger") + specSafe := makeSpec(t, safe, DirectExecution) + specDanger := makeSpec(t, danger, DirectExecution) + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(safe, danger), []ToolSpec{specSafe, specDanger}) + + // Model called "safe" with {"a":1}; binding claims "danger" with {"rm":"-rf"}. + evil := makeBinding(t, "c1", specDanger, `{"rm":"-rf"}`) + result := modelResultWithNamedCalls("safe", `{"a":1}`, "c1") + if _, err := Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: []ToolCallBinding{evil}}); err == nil { + t.Fatal("binding for a tool the model never called was accepted") + } + + // Same tool, different arguments: also rejected. + tampered := makeBinding(t, "c1", specSafe, `{"a":999}`) + if _, err := Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: []ToolCallBinding{tampered}}); err == nil { + t.Fatal("binding with tampered arguments was accepted") + } +} + +// Finding 4: integers above 2^53 keep exact digits; near-adjacent big +// integers must not collide into one digest. +func TestRegressionBigIntegerPrecision(t *testing.T) { + got, err := canonicalJSON([]byte(`{"channel_id":1234567890123456789}`)) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"channel_id":1234567890123456789}` { + t.Fatalf("big integer corrupted: %s", got) + } + d1, err := digestToolCallBinding("c", "", DirectExecution, []byte(`{"n":9007199254740993}`)) + if err != nil { + t.Fatal(err) + } + d2, err := digestToolCallBinding("c", "", DirectExecution, []byte(`{"n":9007199254740992}`)) + if err != nil { + t.Fatal(err) + } + if d1 == d2 { + t.Fatal("adjacent big integers collide into one binding digest") + } + // Non-integer numbers still use the ES6 double form. + got, _ = canonicalJSON([]byte(`{"a":1.0e3}`)) + if string(got) != `{"a":1000}` { + t.Fatalf("float form changed: %s", got) + } +} + +// Finding 5 + 10: the ToolStep ID must reproduce from the persisted StepRef +// digest, and the digest is carried in the fact (not recomputed by Evolve). +func TestRegressionToolStepIDReproducible(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, ApprovalRequired) // Response-filled path + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + b := makeBinding(t, "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") + } +} + +// Finding 3: a commit failure while settling a worker must surface, not be +// silently dropped. +func TestRegressionSettleCommitErrorSurfaces(t *testing.T) { + spec := toolSpec(t, "echo", DirectExecution) + echo := &fakeTool{ref: "echo", def: spec.Definition, policy: DirectExecution, + execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { + // Invalid JSON output makes BuildEnvelope/DigestCommand fail: a + // non-sentinel commit error on the settle path. + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: json.RawMessage(`{broken`)}} + }} + invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1")}} + rt := loopRuntime(t) + loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) + + _, err := loop.Run(context.Background(), rt, nil) + if err == nil { + t.Fatal("settle commit failure was swallowed; run would wedge in WaitForExecutionRecovery") + } + if !strings.Contains(err.Error(), "settling call") { + t.Fatalf("err = %v, want settle context", err) + } +} + +// Finding 6: a panicking tool settles as Unknown instead of crashing the +// process. +func TestRegressionToolPanicBecomesUnknown(t *testing.T) { + spec := toolSpec(t, "echo", DirectExecution) + echo := &fakeTool{ref: "echo", def: spec.Definition, policy: DirectExecution, + execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { + panic("nil map write") + }} + invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1")}} + rt := loopRuntime(t) + loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) + + res, err := loop.Run(context.Background(), rt, nil) + if err != nil { + t.Fatal(err) + } + if res.Result.Status != RunFailed || res.Result.Reason != ReasonEffectUnknown { + t.Fatalf("res = %+v", res.Result) + } + if !strings.Contains(res.Result.Failure.Message, "panic") { + t.Fatalf("failure = %+v", res.Result.Failure) + } +} + +// Finding 7: an abandoned ask-user call has a failure exit via RejectToolCall. +func TestRegressionExternalResponseCanBeRejected(t *testing.T) { + def := testToolDef("ask") + spec := makeSpec(t, def, ExternalResponse) + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + b := makeBinding(t, "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 := Decide(s, RejectToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: respID, Reason: "user dismissed"}) + if err != nil { + t.Fatalf("external-response call cannot be rejected: %v", err) + } + failed := facts[0].(ToolCallFailed) + if failed.Failure.Class != FailurePermissionDenied || failed.Outcome != ToolOutcomeKnown { + t.Fatalf("failed = %+v", failed) + } + s = fold(t, s, facts) + if s.Status != RunActive || s.Current != nil { + t.Fatal("run should continue after abandoning the ask-user call") + } +} + +// Finding 8: duplicate object keys and trailing data are rejected, not +// silently canonicalized. +func TestRegressionCanonicalRejectsAmbiguousInput(t *testing.T) { + for _, in := range []string{ + `{"a":1,"a":2}`, + `{"dry_run":true,"dry_run":false}`, + `{"x":1}]`, + `{"a":1}}}`, + `[1,2]]`, + } { + if _, err := canonicalJSON([]byte(in)); err == nil { + t.Fatalf("ambiguous input %q was canonicalized", in) + } + } + // Invalid UTF-8 is rejected rather than collapsed to U+FFFD. + if _, err := canonicalJSON([]byte("{\"a\":\"\xff\"}")); err == nil { + t.Fatal("invalid UTF-8 was canonicalized") + } +} + +// Finding 9: Runtime return values are isolated snapshots — mutating them +// must not reach authoritative state or committed events. +func TestRegressionRuntimeReturnsAreIsolated(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, ApprovalRequired) + rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) + b := makeBinding(t, "c1", spec, `{"k":"original"}`) + res := mustCommit(t, rt, "complete-1", 2, grant, + SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("t", `{"k":"original"}`, "c1"), Calls: []ToolCallBinding{b}}) + + // Mutate the returned event's fact payload bytes. + opened := res.Events[1].Fact.(ToolStepOpened) + if len(opened.Calls) > 0 && len(opened.Calls[0].Arguments) > 0 { + opened.Calls[0].Arguments[2] = 'X' + } + // Mutate the snapshot's waiting payload. + snap, _ := rt.Load(context.Background()) + ts := snap.State.Current.(ToolStep) + if ts.Calls[0].Waiting != nil && len(ts.Calls[0].Waiting.Payload) > 2 { + ts.Calls[0].Waiting.Payload[2] = 'Y' + } + + // Authority must be unchanged: reload and verify the argument bytes. + fresh, _ := rt.Load(context.Background()) + got := fresh.State.Current.(ToolStep).Calls[0].Arguments + if string(got) != `{"k":"original"}` { + t.Fatalf("authoritative arguments mutated through a returned view: %s", got) + } + stored := rt.Events() + for _, e := range stored { + if f, ok := e.Fact.(ToolStepOpened); ok { + if string(f.Calls[0].Arguments) != `{"k":"original"}` { + t.Fatalf("stored event mutated through a returned view: %s", f.Calls[0].Arguments) + } + } + } +} + +// Finding 11: Evolve rejects illegal ToolCallState combinations. +func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + b := makeBinding(t, "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: "c1"})) + + // Unknown outcome with a non-effect_unknown class is illegal (spec §4.2). + _, err := Evolve(s, ToolCallFailed{ + StepID: opened.StepID, + CallID: "c1", + Failure: ToolFailure{Class: FailureExecution}, + Outcome: ToolOutcomeUnknown, + }) + if err == nil { + t.Fatal("Evolve accepted an illegal unknown-outcome class") + } +} + +// Finding 14: EventRunFinished fires on terminal. +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 + }) + loop, _ := NewLoop(fakeCatalog{&fakeInvoker{results: []sdk.ModelResult{textResult("done")}}}, + fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + if _, err := loop.Run(context.Background(), rt, sink); err != nil { + t.Fatal(err) + } + for _, k := range kinds { + if k == EventRunFinished { + return + } + } + t.Fatalf("EventRunFinished never emitted; kinds = %v", kinds) +} + +// Finding 15: a ToolSpec whose Ref differs from the definition name routes +// through the catalog by Ref end to end. +func TestRegressionAliasedToolRefExecutes(t *testing.T) { + def := sdk.ToolDefinition{Name: "read", Parameters: json.RawMessage(`{"type":"object"}`)} + d, err := DigestToolDefinition(def) + if err != nil { + t.Fatal(err) + } + spec := ToolSpec{Ref: "fs.read", Definition: def, 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: json.RawMessage(`"ok"`)}} + }} + // Model calls the definition name "read"; catalog keys by Ref "fs.read". + 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) + loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"fs.read": tool}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) + + res, err := loop.Run(context.Background(), rt, 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) + } +} + +// Low-severity finding: CancelRun cannot forge a system reason. +func TestRegressionCancelReasonFixed(t *testing.T) { + s := newRun(t, testConfig()) + if _, err := Decide(s, CancelRun{Reason: ReasonStepLimit}); err == nil { + t.Fatal("CancelRun forged step_limit into the log") + } + facts := mustDecide(t, s, CancelRun{}) + if facts[0].(RunEnded).Reason != ReasonCancelled { + t.Fatal("cancel reason not fixed to cancelled") + } +} + +// Finding 13: a stream that closes Parts but returns (nil, nil) from Result +// fails the call instead of panicking the Loop. +func TestRegressionStreamNilResult(t *testing.T) { + rt := loopRuntime(t) + loop, _ := NewLoop(fakeCatalog{nilResultStreamer{}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, true) + res, err := loop.Run(context.Background(), rt, nil) + if err != nil { + t.Fatal(err) + } + // SubmitModelFailure path: run fails with provider_failure, no panic. + 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/state.go b/agent/state.go index 7b713a1..451c24b 100644 --- a/agent/state.go +++ b/agent/state.go @@ -264,12 +264,15 @@ func (s ToolStep) callIndex(id CallID) int { // Control metadata (owner, fence, lease, attempts, queue claims) never // appears here. type MachineState struct { - RunID RunID `json:"runId"` - Status RunStatus `json:"status"` - Config RunConfig `json:"config"` - Current Step `json:"-"` // serialized by adapters with their snapshot schema - PendingInputs []AgentInput `json:"pendingInputs,omitempty"` - ModelSteps int `json:"modelSteps"` + RunID RunID `json:"runId"` + Status RunStatus `json:"status"` + Config RunConfig `json:"config"` + Current Step `json:"-"` // serialized by adapters with their snapshot schema + PendingInputs []AgentInput `json:"pendingInputs,omitempty"` + ModelSteps int `json:"modelSteps"` + // LastClosedStep is the most recently closed ToolStep; PlanningHint's + // SourceStep is read from it at the next boundary. + LastClosedStep StepID `json:"lastClosedStep,omitempty"` Usage sdk.Usage `json:"usage"` LastModelResult *sdk.ModelResult `json:"lastModelResult,omitempty"` Result *RunResult `json:"result,omitempty"` diff --git a/agent/usage.go b/agent/usage.go deleted file mode 100644 index 6dbce11..0000000 --- a/agent/usage.go +++ /dev/null @@ -1,28 +0,0 @@ -package agent - -import ( - "github.com/memohai/twilight-ai/sdk" -) - -// addUsage accumulates sdk.Usage field by field (spec §3.7.2, ModelStepRejected -// and ModelStepCompleted rows). -func addUsage(a, b sdk.Usage) sdk.Usage { - return sdk.Usage{ - InputTokens: a.InputTokens + b.InputTokens, - OutputTokens: a.OutputTokens + b.OutputTokens, - TotalTokens: a.TotalTokens + b.TotalTokens, - ReasoningTokens: a.ReasoningTokens + b.ReasoningTokens, - CachedInputTokens: a.CachedInputTokens + b.CachedInputTokens, - InputTokenDetails: sdk.InputTokenDetail{ - NoCacheTokens: a.InputTokenDetails.NoCacheTokens + b.InputTokenDetails.NoCacheTokens, - CacheReadTokens: a.InputTokenDetails.CacheReadTokens + b.InputTokenDetails.CacheReadTokens, - CacheWriteTokens: a.InputTokenDetails.CacheWriteTokens + b.InputTokenDetails.CacheWriteTokens, - CacheWrite5mTokens: a.InputTokenDetails.CacheWrite5mTokens + b.InputTokenDetails.CacheWrite5mTokens, - CacheWrite1hTokens: a.InputTokenDetails.CacheWrite1hTokens + b.InputTokenDetails.CacheWrite1hTokens, - }, - OutputTokenDetails: sdk.OutputTokenDetail{ - TextTokens: a.OutputTokenDetails.TextTokens + b.OutputTokenDetails.TextTokens, - ReasoningTokens: a.OutputTokenDetails.ReasoningTokens + b.OutputTokenDetails.ReasoningTokens, - }, - } -} diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 6d8662b..321de9c 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -71,7 +71,7 @@ Request Planner --> sdk.Request --> Agent Loop ------> Model / Tool EventSink / replay / projection / OTel ``` -Loop 和 Runtime 是协作者,不是上下级:Loop 决定如何执行,Runtime 决定权威状态在哪里以及如何安全提交。Machine 决定什么状态变化合法;Planner 决定模型看到的 application context;Model/Tool 只执行一次外部 effect。 +Loop 与 Runtime 的职责相互独立:Loop 决定如何执行,Runtime 决定权威状态在哪里以及如何安全提交。Machine 决定什么状态变化合法;Planner 决定模型看到的 application context;Model/Tool 只执行一次外部 effect。 目标目录边界: @@ -445,7 +445,7 @@ WaitForResponse(ResponseRequests) WaitForExecutionRecovery ``` -终态由 `MachineState.Status` 表示;Effect 只是待执行动作,既可以是模型调用,也可以是工具调用或等待,不暗示“有负作用”。Effect 不写入权威状态;Loop 在每次 `Load` 后由 MachineState 重新得到它们。 +终态由 `MachineState.Status` 表示;Effect 只是待执行动作,既可以是模型调用,也可以是工具调用或等待,该名称不表示一定产生外部副作用。Effect 不写入权威状态;Loop 在每次 `Load` 后由 MachineState 重新得到它们。 ### 3.7 共享规则 @@ -767,7 +767,7 @@ ApproveToolCall、RejectToolCall、SubmitToolResponse、CancelRun、AcceptInput RecoverModelExecution 必须带对应非空 Grant。 ``` -Grant 只绑定一个 ModelStep 或一个 ToolCall,不能挪给另一个 Call。外部 response 不需要把入口伪装成 Loop attempt。 +Grant 只绑定一个 ModelStep 或一个 ToolCall,不能转用于另一个 Call。外部 response 入口不需要持有 Loop attempt 的执行凭据。 ### 5.4 Commit 语义 @@ -927,7 +927,7 @@ ToolStep 自动关闭 只有在这些 boundary,Memoh 才能把 queue 输入绑定到下一次 Planner 请求或创建后续 Run。ToolStep 中间的新输入不能修改已经冻结的 ModelStep,也不能跳过 Pending/Waiting Call。 -steer 必须赶上下一个 ModelStep,而不是落到更晚的边界。Loop 在 boundary 处的 Plan/Prepare 提交与 Memoh 的 queue 仲裁存在竞态;MemohRuntime adapter 用以下 gate 消除它:处理 `PrepareModelRequest` 的同一事务内检查是否存在 eligible 的 steer item,存在时不接受该次 Prepare,先在事务内应用对应的 `AcceptInput`(Revision 递增,Prepare 按 `ErrStaleRuntime` 返回)。Loop 重新 Load 后由 `PlanningHint.Inputs` 携带该输入重新规划。这条 gate 是 adapter 行为,不进入 agent Machine 规则;in-process 宿主没有 queue,输入由宿主在 Loop 空闲边界提交。 +steer 必须进入下一个 ModelStep 的规划上下文,而不是延迟到更晚的边界。Loop 在 boundary 处的 Plan/Prepare 提交与 Memoh 的 queue 仲裁存在竞态;MemohRuntime adapter 用以下 gate 消除它:处理 `PrepareModelRequest` 的同一事务内检查是否存在 eligible 的 steer item,存在时不接受该次 Prepare,先在事务内应用对应的 `AcceptInput`(Revision 递增,Prepare 按 `ErrStaleRuntime` 返回)。Loop 重新 Load 后由 `PlanningHint.Inputs` 携带该输入重新规划。这条 gate 是 adapter 行为,不进入 agent Machine 规则;in-process 宿主没有 queue,输入由宿主在 Loop 空闲边界提交。 ## 6. Loop 算法 diff --git a/sdk/model_result.go b/sdk/model_result.go index 5c5289a..ad6c61a 100644 --- a/sdk/model_result.go +++ b/sdk/model_result.go @@ -24,5 +24,9 @@ type ModelResult struct { Files []GeneratedFile `json:"files,omitempty"` ToolCalls []ToolCall `json:"toolCalls,omitempty"` - Response ResponseMetadata `json:"response,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/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/usage.go b/sdk/usage.go index 7a33b6f..0fa289d 100644 --- a/sdk/usage.go +++ b/sdk/usage.go @@ -26,3 +26,20 @@ type Usage struct { InputTokenDetails InputTokenDetail `json:"inputTokenDetails,omitempty"` OutputTokenDetails OutputTokenDetail `json:"outputTokenDetails,omitempty"` } + +// Add returns the field-by-field sum of u and other. +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 +} From 4d1637769d12287f17b8d66e5fc192cfa97742e2 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Fri, 28 Aug 2026 12:45:20 +0800 Subject: [PATCH 04/81] feat(agent): flip authority to the event log (event sourcing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AgentEvent log is now the source of truth; MachineState is the required same-transaction projection (execution cache), rebuildable by folding the log with Evolve alone — no Decide re-run, no command replay, no external effects. Spec (§5.1/§9.1/§14.3/§17, appendix B.11): authority declaration flipped; the three stability conditions (sealed fact ontology, frozen Evolve semantics, self-contained facts) are normative; a per-run revision watermark witnesses log-tail completeness. Arbitration: snapshot divergence or loss with a complete log rebuilds automatically and is reported for audit; a log tail below the watermark halts the run (ErrLogTruncated) — accepted facts cannot be recovered from nothing, and continuing would upgrade the loss into repeated execution. Protocol: ModelStepPrepared carries BindingDigest (computed by Decide, folded verbatim by Evolve), closing the last self-containment gap. Code: FoldEvents verifies (Revision, Index) continuity and per-fact digests while folding; MemoryRuntime gains the watermark and Rebuild as the reference arbitration implementation. Tests: healthy rebuild is a no-op; corrupted snapshot repairs from the log; truncated tail halts; interior gaps and tampered facts are rejected; golden v1 event stream folds to frozen state bytes. --- agent/decide.go | 5 + agent/evolve.go | 6 +- agent/fact.go | 19 ++- agent/memory.go | 18 ++- agent/rebuild.go | 105 +++++++++++++ agent/rebuild_test.go | 208 ++++++++++++++++++++++++++ docs/design/agent-runtime-refactor.md | 48 ++++-- 7 files changed, 381 insertions(+), 28 deletions(-) create mode 100644 agent/rebuild.go create mode 100644 agent/rebuild_test.go diff --git a/agent/decide.go b/agent/decide.go index 3d743b2..7c938a0 100644 --- a/agent/decide.go +++ b/agent/decide.go @@ -120,6 +120,10 @@ func decidePrepareModelRequest(s MachineState, cmd PrepareModelRequest) ([]Fact, if cmd.ToolsDigest != wantTools { return nil, rejectionf("prepare: tools digest mismatch") } + binding, err := DigestModelStepBinding(cmd.Model, cmd.RequestDigest, cmd.ToolsDigest) + if err != nil { + return nil, err + } return []Fact{ModelStepPrepared{ StepID: cmd.StepID, Model: cmd.Model, @@ -128,6 +132,7 @@ func decidePrepareModelRequest(s MachineState, cmd PrepareModelRequest) ([]Fact, InputIDs: cmd.InputIDs, Tools: cmd.Tools, ToolsDigest: cmd.ToolsDigest, + BindingDigest: binding, }}, nil } diff --git a/agent/evolve.go b/agent/evolve.go index a80c606..f6acbfe 100644 --- a/agent/evolve.go +++ b/agent/evolve.go @@ -10,12 +10,8 @@ import "fmt" func Evolve(s MachineState, f Fact) (MachineState, error) { switch fact := f.(type) { case ModelStepPrepared: - binding, err := DigestModelStepBinding(fact.Model, fact.RequestDigest, fact.ToolsDigest) - if err != nil { - return s, err - } s.Current = ModelStep{ - RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: binding}, + RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: fact.BindingDigest}, Request: fact.Request, RequestDigest: fact.RequestDigest, Model: fact.Model, diff --git a/agent/fact.go b/agent/fact.go index cac2e86..2a027b8 100644 --- a/agent/fact.go +++ b/agent/fact.go @@ -12,15 +12,18 @@ import ( type Fact interface{ fact() } // ModelStepPrepared establishes the frozen ModelStep and consumes the listed -// pending inputs. +// pending inputs. BindingDigest (model + request + tools) is computed by +// Decide and carried in the fact: Evolve folds it verbatim, never recomputes +// (fact self-containment, spec §5.1). type ModelStepPrepared struct { - StepID StepID `json:"stepId"` - Model ModelRef `json:"model"` - Request sdk.Request `json:"request"` - RequestDigest Digest `json:"requestDigest"` - InputIDs []InputID `json:"inputIds,omitempty"` - Tools []ToolSpec `json:"tools,omitempty"` - ToolsDigest Digest `json:"toolsDigest"` + StepID StepID `json:"stepId"` + Model ModelRef `json:"model"` + Request sdk.Request `json:"request"` + 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() {} diff --git a/agent/memory.go b/agent/memory.go index 5904b10..2ab4404 100644 --- a/agent/memory.go +++ b/agent/memory.go @@ -9,12 +9,18 @@ import ( ) // MemoryRuntime is the in-process reference Runtime: mutex + MachineState + -// AgentEvent map (spec §8.1). It is the conformance reference; it does not -// survive the process and does not store product history. +// AgentEvent map (spec §8.1). The event log is the source of truth; the state +// is the same-transaction projection. It is the conformance reference; it +// does not survive the process and does not store product history. type MemoryRuntime struct { mu sync.Mutex state MachineState revision uint64 + // initial is the Revision-0 state; Rebuild folds the log from it. + initial MachineState + // watermark witnesses log-tail completeness: it advances with every + // commit and is never cleared by a rebuild (spec §5.1). + watermark uint64 // events keyed by CommandID: the full event group of each transition. events map[CommandID][]AgentEvent // log holds every event in (Revision, Index) order for replay. @@ -26,9 +32,10 @@ type MemoryRuntime struct { // NewMemoryRuntime starts from an Initialize-produced state at Revision 0. func NewMemoryRuntime(initial MachineState) *MemoryRuntime { return &MemoryRuntime{ - state: initial, - events: make(map[CommandID][]AgentEvent), - grants: make(map[string]ExecutionGrant), + state: initial, + initial: cloneMachineState(initial), + events: make(map[CommandID][]AgentEvent), + grants: make(map[string]ExecutionGrant), } } @@ -124,6 +131,7 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe // the way in so caller-held command buffers cannot mutate stored events. m.state = decision.NewState m.revision++ + m.watermark = m.revision stored := cloneEvents(decision.Events) m.events[req.Command.ID] = stored m.log = append(m.log, stored...) diff --git a/agent/rebuild.go b/agent/rebuild.go new file mode 100644 index 0000000..f86ac2d --- /dev/null +++ b/agent/rebuild.go @@ -0,0 +1,105 @@ +package agent + +import ( + "errors" + "fmt" +) + +// ErrLogTruncated reports that a Run's event log ends below its revision +// watermark: accepted facts are permanently gone. The Run halts — continuing +// on a truncated log would upgrade the loss into wrong repeated execution +// (spec §5.1). Recovery is a disaster-recovery matter, not a protocol one. +var ErrLogTruncated = errors.New("agent: event log ends below the revision watermark") + +// FoldEvents rebuilds a MachineState by folding the event log from the +// initial (Revision 0) state with Evolve only: no Decide, no external +// effects, no command replay (spec §9.1). It verifies (Revision, Index) +// ordering and per-fact digests as it goes; any gap or mismatch means the log +// itself is damaged and the fold stops. +func FoldEvents(initial MachineState, events []AgentEvent) (MachineState, uint64, error) { + state := initial + var revision uint64 + var index uint16 + inTransition := false + for _, e := range events { + switch { + case !inTransition || e.Revision != revision: + if e.Revision != revision+1 || e.Index != 0 { + return initial, 0, fmt.Errorf("agent: fold: gap at revision %d index %d (expected %d/0)", e.Revision, e.Index, revision+1) + } + revision = e.Revision + index = 0 + inTransition = true + default: + if e.Index != index+1 { + return initial, 0, fmt.Errorf("agent: fold: gap at revision %d index %d (expected %d)", e.Revision, e.Index, index+1) + } + index = e.Index + } + wantDigest, err := DigestFact(e.SchemaVersion, e.Type, e.Fact) + if err != nil { + return initial, 0, err + } + if e.Digest != wantDigest { + return initial, 0, fmt.Errorf("agent: fold: fact digest mismatch at revision %d index %d", e.Revision, e.Index) + } + state, err = Evolve(state, e.Fact) + if err != nil { + return initial, 0, err + } + } + return state, revision, nil +} + +// Rebuild discards the in-memory snapshot and refolds it from the event log, +// arbitrating per spec §5.1: the log wins when it is complete +// (maxRevision >= watermark); a log tail below the watermark halts with +// ErrLogTruncated. It returns true when the refolded state differed from the +// stored snapshot — with a correct implementation this never happens, so a +// true return is an audit signal (Evolve bug, out-of-band write, or snapshot +// corruption occurred). +func (m *MemoryRuntime) Rebuild() (rebuilt bool, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + folded, maxRevision, err := FoldEvents(cloneMachineState(m.initial), m.log) + if err != nil { + return false, err + } + if maxRevision < m.watermark { + return false, fmt.Errorf("%w: log ends at %d, watermark %d", ErrLogTruncated, maxRevision, m.watermark) + } + diverged := m.revision != maxRevision || !statesEquivalent(m.state, folded) + m.state = folded + m.revision = maxRevision + return diverged, nil +} + +// statesEquivalent compares two states via their canonical serialization — +// the same identity rule the protocol uses everywhere else. +func statesEquivalent(a, b MachineState) bool { + ab, errA := marshalCanonical(stateComparable(a)) + bb, errB := marshalCanonical(stateComparable(b)) + if errA != nil || errB != nil { + return false + } + return string(ab) == string(bb) +} + +// stateComparable flattens MachineState including the interface-typed Current +// step, which encoding/json cannot round-trip on its own. +func stateComparable(s MachineState) map[string]any { + m := map[string]any{ + "runId": s.RunID, "status": s.Status, "config": s.Config, + "modelSteps": s.ModelSteps, "lastClosedStep": s.LastClosedStep, + "usage": s.Usage, "pendingInputs": s.PendingInputs, + "lastModelResult": s.LastModelResult, "result": s.Result, + } + switch cur := s.Current.(type) { + case ModelStep: + m["modelStep"] = cur + case ToolStep: + m["toolStep"] = cur + } + return m +} diff --git a/agent/rebuild_test.go b/agent/rebuild_test.go new file mode 100644 index 0000000..0de5f15 --- /dev/null +++ b/agent/rebuild_test.go @@ -0,0 +1,208 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/memohai/twilight-ai/sdk" +) + +// Event-sourcing arbitration tests (spec §5.1): the log is the source of +// truth; the snapshot is a rebuildable same-transaction projection; the +// revision watermark witnesses log-tail completeness. + +// fullRunRuntime drives one complete run (prepare -> model -> tool -> done) +// and returns the runtime. +func fullRunRuntime(t *testing.T) *MemoryRuntime { + t.Helper() + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) + b := makeBinding(t, "c1", spec, `{}`) + res := mustCommit(t, rt, "complete-1", 2, grant, + SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) + toolStep := res.Events[1].Fact.(ToolStepOpened).StepID + sRes := mustCommit(t, rt, "start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: "c1"}) + mustCommit(t, rt, "done-c1", sRes.Snapshot.Revision, sRes.Grant, + SubmitToolResult{StepID: toolStep, CallID: "c1", Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}}) + mustCommit(t, rt, DeriveModelRequestCommandID("run-1", 5), 5, "", + func() PrepareModelRequest { + snap, _ := rt.Load(context.Background()) + prep, _ := buildPrepareFromSnap(t, snap, testRequest(), nil) + return prep + }()) + start := mustCommit(t, rt, "start-2", 6, "", StartModelExecution{StepID: currentStepID(t, rt)}) + mustCommit(t, rt, "done-2", start.Snapshot.Revision, start.Grant, + SubmitModelResult{StepID: currentStepID(t, rt), Result: sdk.ModelResult{Text: "final", FinishReason: sdk.FinishReasonStop}}) + return rt +} + +func currentStepID(t *testing.T, rt *MemoryRuntime) StepID { + t.Helper() + snap, err := rt.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + if snap.State.Current == nil { + t.Fatal("no current step") + } + return snap.State.Current.Ref().ID +} + +// A healthy runtime rebuilds without divergence: with a correct +// implementation the arbitration branch never fires. +func TestRebuildHealthyIsNoop(t *testing.T) { + rt := fullRunRuntime(t) + before, _ := rt.Load(context.Background()) + diverged, err := rt.Rebuild() + if err != nil { + t.Fatal(err) + } + if diverged { + t.Fatal("healthy runtime reported divergence on rebuild") + } + after, _ := rt.Load(context.Background()) + if !statesEquivalent(before.State, after.State) || before.Revision != after.Revision { + t.Fatal("rebuild changed a healthy state") + } +} + +// A corrupted snapshot is repaired from the log, and the divergence is +// reported for audit. +func TestRebuildRepairsCorruptedSnapshot(t *testing.T) { + rt := fullRunRuntime(t) + want, _ := rt.Load(context.Background()) + + // Out-of-band write: corrupt the authoritative snapshot directly. + rt.mu.Lock() + rt.state.ModelSteps = 99 + rt.state.Status = RunActive + rt.state.Result = nil + rt.mu.Unlock() + + diverged, err := rt.Rebuild() + if err != nil { + t.Fatal(err) + } + if !diverged { + t.Fatal("rebuild did not report the repaired divergence") + } + got, _ := rt.Load(context.Background()) + if !statesEquivalent(want.State, got.State) { + t.Fatal("rebuild did not restore the log-derived state") + } + if got.State.Status != RunCompleted || got.State.ModelSteps != 2 { + t.Fatalf("rebuilt state = %+v", got.State.Status) + } +} + +// A log tail below the watermark halts with ErrLogTruncated: accepted facts +// are gone and continuing would repeat gated executions. +func TestRebuildHaltsOnTruncatedTail(t *testing.T) { + rt := fullRunRuntime(t) + rt.mu.Lock() + // Simulate selective damage: drop the last transition's events while the + // watermark (separate storage in a durable adapter) survives. + last := rt.log[len(rt.log)-1].Revision + var kept []AgentEvent + for _, e := range rt.log { + if e.Revision < last { + kept = append(kept, e) + } + } + rt.log = kept + rt.mu.Unlock() + + _, err := rt.Rebuild() + if !errors.Is(err, ErrLogTruncated) { + t.Fatalf("err = %v, want ErrLogTruncated", err) + } +} + +// A gap in the middle of the log is log damage, not a rebuild input. +func TestFoldRejectsInteriorGap(t *testing.T) { + rt := fullRunRuntime(t) + rt.mu.Lock() + var holed []AgentEvent + for _, e := range rt.log { + if e.Revision == 3 { // drop one interior transition + continue + } + holed = append(holed, e) + } + log := holed + initial := cloneMachineState(rt.initial) + rt.mu.Unlock() + + if _, _, err := FoldEvents(initial, log); err == nil { + t.Fatal("interior gap folded silently") + } +} + +// A tampered fact fails its digest check during fold. +func TestFoldRejectsTamperedFact(t *testing.T) { + rt := fullRunRuntime(t) + rt.mu.Lock() + log := cloneEvents(rt.log) + initial := cloneMachineState(rt.initial) + rt.mu.Unlock() + + for i := range log { + if f, ok := log[i].Fact.(ToolCallCompleted); ok { + f.Result.Output = json.RawMessage(`"tampered"`) + log[i].Fact = f + break + } + } + if _, _, err := FoldEvents(initial, log); err == nil { + t.Fatal("tampered fact folded silently") + } +} + +// ModelStepPrepared is self-contained: the binding digest folds verbatim and +// reproduces the step identity without recomputation. +func TestRegressionPreparedFactSelfContained(t *testing.T) { + s := newRun(t, testConfig()) + prep, cmdID := buildPrepare(t, s, testRequest(), nil) + facts := mustDecide(t, s, prep) + fact := facts[0].(ModelStepPrepared) + if fact.BindingDigest == "" { + t.Fatal("ModelStepPrepared carries no binding digest") + } + if DeriveModelStepID(s.RunID, cmdID, fact.BindingDigest) != fact.StepID { + t.Fatal("carried binding digest does not reproduce the step ID") + } + s = fold(t, s, facts) + ms := s.Current.(ModelStep) + if ms.RefValue.Digest != fact.BindingDigest { + t.Fatal("Evolve did not fold the carried digest verbatim") + } +} + +// Golden event stream: a fixed v1 command sequence folds to frozen state +// bytes. If this test fails, either the canonical encoding or Evolve's +// folding semantics changed — both are permanent contracts of SchemaVersion 1 +// (fix the code, not the constant), unless the protocol itself is still +// pre-release and the change is deliberate (then re-freeze the constant in +// the same commit that changes the protocol). +func TestGoldenEventStreamV1(t *testing.T) { + rt := fullRunRuntime(t) + folded, maxRev, err := FoldEvents(cloneMachineState(rt.initial), rt.Events()) + if err != nil { + t.Fatal(err) + } + if maxRev != 8 { + t.Fatalf("golden stream has %d transitions, want 8", maxRev) + } + stateBytes, err := marshalCanonical(stateComparable(folded)) + if err != nil { + t.Fatal(err) + } + got := string(sha256Digest(stateBytes)) + const frozen = "sha256:8f53eb6380fdcfef8f44befc8aa0f109ae7f4d54f78efbf897edb33ac358fc85" + if got != frozen { + t.Fatalf("golden v1 state digest changed:\n got %s\nwant %s\nstate: %s", got, frozen, stateBytes) + } +} diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 321de9c..7e53fc1 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -453,7 +453,7 @@ WaitForExecutionRecovery `Decide(state, command)` 按下表校验前置条件并产出事实序列。任何前置条件不满足即拒绝整个 command,不产出部分事实: -1. `PrepareModelRequest` 只能在 Run active 且没有当前 Step 时接受;其 `InputIDs` 必须按 `PendingInputs` 的当前顺序完整匹配。`Tools` 必须与 `sdk.Request` 中的 provider tool definitions 按 Ref、顺序和 definition digest 一一对应,`ToolsDigest` 覆盖 Ref、schema、顺序与 policy。产出 `[ModelStepPrepared]`,事实中携带冻结的请求、ToolSpec 与被消费的 InputIDs。 +1. `PrepareModelRequest` 只能在 Run active 且没有当前 Step 时接受;其 `InputIDs` 必须按 `PendingInputs` 的当前顺序完整匹配。`Tools` 必须与 `sdk.Request` 中的 provider tool definitions 按 Ref、顺序和 definition digest 一一对应,`ToolsDigest` 覆盖 Ref、schema、顺序与 policy。产出 `[ModelStepPrepared]`,事实中携带冻结的请求、ToolSpec、被消费的 InputIDs 与 Decide 算好的 step binding digest(事实自包含条件:Evolve 不重算 digest)。 2. `StartModelExecution` 只能作用于 Prepared ModelStep,产出 `[ModelStepStarted]`。`RecoverModelExecution` 只能作用于没有已接受结果的 Executing ModelStep,产出 `[ModelStepRecovered]`;它只能由持有该 Model grant 的当前 Loop,或由 Runtime 自己确认 lease 失效后的 recovery 逻辑提交,普通 response ingress 不能提交。 3. `SubmitModelResult` 只能作用于对应的 Executing ModelStep。结果没有 tool calls 时产出 `[ModelStepCompleted, RunEnded{RunCompleted}]`;有 tool calls 时按冻结 `ToolSpec` 绑定 policy 和 binding digest,产出 `[ModelStepCompleted, ToolStepOpened]`——`ToolStepOpened` 携带完整 Call 集合:DirectExecution 的 Call 为 Pending,ApprovalRequired/ExternalResponse 的 Call 为带稳定 request 的 Waiting,每个 Waiting request 都包含目标 RunID、StepID、CallID、ResponseID、Kind 和 RequestDigest。 4. `SubmitModelFailure` 只能作用于对应的 Executing ModelStep,产出 `[RunEnded{RunFailed, provider_failure}]`,保留稳定失败原因。 @@ -476,7 +476,7 @@ WaitForExecutionRecovery | 事实 | 折叠 | | --- | --- | -| ModelStepPrepared | 设置 Current 为 Prepared ModelStep;`ModelSteps+1`;按事实中的 InputIDs 从 `PendingInputs` 移除 | +| ModelStepPrepared | 设置 Current 为 Prepared ModelStep(StepRef.Digest 取事实携带的 BindingDigest);`ModelSteps+1`;按事实中的 InputIDs 从 `PendingInputs` 移除 | | ModelStepStarted | Current.Status = Executing | | ModelStepRecovered | Current.Status = Prepared | | ModelStepRejected | `Rejects+1`;Current.Status = Prepared;Usage 逐字段累加事实携带的 usage | @@ -653,7 +653,30 @@ Runtime 只回答两个问题: 因此 Runtime 的接口很小,但一次 `Commit` 的事务范围可以很大:它必须让一个 AgentCommand、产出的 AgentEvent 组及其必要的产品投影一起成功或一起失败;这不意味着 Runtime 获得了 history、queue 或 prompt 的所有权。 -MachineState 与 AgentEvent log 是同一个 transition 序列的两个 materialization:MachineState 是 execution authority(提交验证与 Loop 执行的依据),AgentEvent log 是 historical authority(replay、审计与投影的依据)。两者由同一原子提交产生、共享同一 Revision;对任意 Revision N,状态必须等于初始状态按事件流 fold `Evolve` 到 N 的结果。两者出现分歧是 halt 级一致性违规:停止该 Run 的执行并报告,不定义任何一方自动覆盖另一方。 +AgentEvent log 是 Run 语义状态的 source of truth;MachineState 是必需的同事务 projection(execution cache):提交验证与 Loop 执行从它读取,因此它必须与日志在同一原子提交内更新,但它可以从日志重建。对任意 Revision N,状态必须等于初始状态按事件流 fold `Evolve` 到 N 的结果——这是可自动恢复的不变量,不是 halt 条件。 + +这个权威声明成立的三个稳定条件(本规范的规范性条款): + +1. Event ontology 稳定:Fact 词表 sealed,已发布 SchemaVersion 的事实结构永不修改,新增字段进入新版本。 +2. Evolve 语义稳定:折叠是机械的(不读 RunConfig、无 policy 分支),已发布版本的折叠语义与事件编码一起永久冻结;会演进的决策语义全部在 Decide,其结果记录为事实。 +3. 事实自包含:折叠一条事实所需的全部信息在事实自身与折叠前状态之内,不访问外部系统,不重新计算依赖当前代码版本的派生值(digest 一律在 Decide 时算好并携带在事实中)。 + +每 Run 维护一个不可丢弃的 revision 水位(watermark):每次提交与日志同步推进的单调计数,语义为"日志至少完整到此"。它是日志尾部完整性的末端见证——append-only 日志可以用 (Revision, Index) 连续性检测中间缺洞,但缺尾的日志与更短的完整日志无法区分。水位是控制平面数据,不进入 MachineState,重建不清除它。 + +分歧仲裁规则固定为: + +```text +snapshot 与 fold(events) 不一致,或 snapshot 缺失,且 log.maxRevision >= watermark + -> 日志为准,自动重建(纯 Evolve 折叠,零副作用,不重放命令,不产生外部 effect), + 并记录一次重建事件供运维审计——实现正确时这条路径不触发,每次触发都意味着 + Evolve bug、越权写入或 snapshot 损坏真实发生过 + +log.maxRevision < watermark + -> 日志尾部缺失,halt 该 Run——已接受的事实永久消失无法凭空恢复, + 继续推进会把丢失升级为错误的重复执行;恢复属于灾难恢复范畴(备份、复制) +``` + +主动 truncate snapshot 强制全量重建是合法运维操作(例如 MachineState 存储布局变更时替代迁移)。水位与日志同库整体回退(全量备份恢复)不在检测范围内:内部自洽的一致回退需要外部见证,v1 不做。 一个 Runtime 实例服务一个 Run;多个 Run 由上层创建多个 Runtime 实例。Run 的创建、身份分配和初始 `MachineState` 由 application/Memoh admission 完成,Runtime 从一个已经有效的初始状态开始。 @@ -1264,7 +1287,7 @@ Commit 并行 Call 的完成 transition 按提交先后获得递增的 Revision;这不改变模型上下文中的 Call 顺序。Memoh 先按 CallID 保存各自结果,ToolStep 关闭时再按 ModelResult 原始 Call 顺序写入 assistant tool-call 与 tool-result history。 -Attempt、owner、fence、lease 和数据库 row 不进入 agent public state。它们只保证多个 Loop attempt 不会同时取得同一个 Call 的执行权。持久化的 MachineState snapshot 必须带 adapter 自己的 snapshot schema version;跨版本升级时按该版本解码或迁移,不复用事件的 `SchemaVersion` 字段。 +Attempt、owner、fence、lease 和数据库 row 不进入 agent public state。它们只保证多个 Loop attempt 不会同时取得同一个 Call 的执行权。持久化的 MachineState snapshot 必须带 adapter 自己的 snapshot schema version;跨版本升级时按该版本解码,或直接 truncate 后从事件流重建(§5.1),不复用事件的 `SchemaVersion` 字段。每 Run 的 revision 水位与 snapshot 分开存放(state 表中不随重建清空的列,或独立小表),每次 Commit 与日志同事务推进。 MemohRuntime 的 worker 实例在构造时绑定当前 worker 的 owner identity;只有该实例可以提交自己接受的 model/tool start、completion 和主动 recovery。response 和 cancel 使用同一个 `Commit` 语义,但由 Memoh 创建不带 worker grant 的 ingress-scoped adapter;这些 command 不取得执行权,因此不需要伪造 Loop owner。新 Run admission 仍由 Memoh 的 admission 事务处理,不调用旧 Run 的 `Runtime.Commit`。 @@ -1321,9 +1344,9 @@ SchemaVersion + Type wire 兼容和 sealed fact discriminator Fact 已接受的事实内容 ``` -MachineState 与 AgentEvent log 是同一个 transition 序列的两个 materialization(§5.1):MachineState 是 execution authority,AgentEvent log 是 historical authority,由同一原子提交产生、共享 Revision。Runtime 必须把两者和需要一致的 Memoh projection/outbox 放在同一事务或锁边界。Durable adapter 必须保留 AgentEvent,使其可以按 RunID/(Revision, Index) replay;MemoryRuntime 可以只在进程内保留同样的记录。公共 `Runtime` 不增加 replay 方法,读取由实现或 application projection 提供。 +AgentEvent log 是 source of truth;MachineState 是必需的同事务 projection(§5.1)。Runtime 必须把两者、水位和需要一致的 Memoh projection/outbox 放在同一事务或锁边界。Durable adapter 必须保留 AgentEvent,使其可以按 RunID/(Revision, Index) replay;MemoryRuntime 可以只在进程内保留同样的记录。公共 `Runtime` 不增加 replay 方法,读取由实现或 application projection 提供。 -Replay 按 RunID/(Revision, Index) 取出 AgentEvent,从初始状态(Revision=0)开始依次调用同一份 `Machine.Evolve` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里,Machine 决策规则的演进不影响历史事件的折叠。校验规则:折叠到 Revision N 的状态必须与该 Revision 的持久化状态一致;Revision、Index、identity 或 digest 不匹配时停止 replay 并报告 halt 级一致性违规,不静默修复状态,也不定义任何一方自动覆盖另一方。分歧后的恢复是人工按事件流重建并核对的 runbook 操作,不是自动路径。 +Replay 按 RunID/(Revision, Index) 取出 AgentEvent,从初始状态(Revision=0)开始依次调用同一份 `Machine.Evolve` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里,Machine 决策规则的演进不影响历史事件的折叠;折叠不产生任何外部 effect。仲裁按 §5.1 的规则:日志完整(maxRevision >= watermark)时日志为准,snapshot 分歧或缺失自动重建并记录重建事件;日志尾部低于水位时 halt。事件流内部的 Revision/Index 缺洞或 digest 不匹配同样按日志损坏处理,halt 该 Run。 Replay 的起点是 admission 已建立的初始 `MachineState`;`RunSeed` 的 admission 记录不作为任何 Run 的 AgentEvent 重放。需要重建 admission 链时,由 Memoh 的 session/queue 记录负责。 @@ -1577,7 +1600,11 @@ RunStopped/RunFailed 保留最近已接受的 ModelResult Cancel 与 Unknown 的提交先后决定终态 CancelRun 在过期 BaseRevision 上对非 terminal Run 重新评估 RejectModelResult 必须带有效 Model grant;AlreadyApplied 重放不重复累计 usage -持久化状态与按 Evolve 折叠的事件流逐字段一致;不一致 -> halt,不自动修复 +持久化状态与按 Evolve 折叠的事件流一致;snapshot 分歧或缺失且日志完整 -> 自动重建并报告,重建不改变健康状态 +日志尾部低于 revision 水位 -> ErrLogTruncated,halt 该 Run +事件流内部 Revision/Index 缺洞或事实 digest 不匹配 -> fold 拒绝,按日志损坏处理 +ModelStepPrepared/ToolStepOpened 自包含:携带的 digest 折叠后可重现 Step 身份 +golden event stream:固定 v1 命令序列折叠出冻结的状态字节 ``` ### 14.4 Memoh integration @@ -1617,9 +1644,9 @@ Memoh queue/session/admission 的语义保持在 Memoh;现有 NativeAgentLoop 2. breaking release 版本和 Memoh protocol upgrade window。 3. EventSink payload schema,以及是否需要在 Memoh outbox 中加入跨进程 execution epoch。 -本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;`sdk.Request` 冻结完整的 generation options,streaming 只是 `ModelInvoker` 的可选执行路径,不改变 AgentCommand/AgentEvent 语义。Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、与事件编码同属永久兼容契约;MachineState 为 execution authority、AgentEvent log 为 historical authority,二者是同一 transition 的两个 materialization,分歧即 halt。结构性 malformed 的模型结果通过 `RejectModelResult` 在同一冻结 request 上有限重试;usage 在 MachineState 内逐字段累计;steer 由 MemohRuntime 的 Prepare gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 +本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;`sdk.Request` 冻结完整的 generation options,streaming 只是 `ModelInvoker` 的可选执行路径,不改变 AgentCommand/AgentEvent 语义。Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、与事件编码同属永久兼容契约;AgentEvent log 为 source of truth,MachineState 为必需的同事务 projection,分歧仲裁按 §5.1(日志完整则自动重建,日志尾部低于水位则 halt)。结构性 malformed 的模型结果通过 `RejectModelResult` 在同一冻结 request 上有限重试;usage 在 MachineState 内逐字段累计;steer 由 MemohRuntime 的 Prepare gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 -重新评估 event log 为唯一权威(纯 ES 翻转)的触发条件:出现跨多个发布周期存活的长生命周期 Run;fork/任意历史时点重建成为产品功能;外部消费方要求自描述的事件日志。上述条件出现前,保持双物化模型。 +本规范采用 event log 为 source of truth(§5.1):三个稳定条件(ontology 冻结、Evolve 冻结、事实自包含)由 Decide/Evolve 拆分保障,revision 水位保护日志尾部完整性。MachineState 保持为必需的同事务 projection——提交验证要求当前状态在临界区内可得,这与日志权威并不冲突。 ## 附录 A:最小 public API 草案 @@ -1944,6 +1971,7 @@ type ModelStepPrepared struct { InputIDs []InputID Tools []ToolSpec ToolsDigest Digest + BindingDigest Digest // Decide 算好携带;Evolve 折叠时不重算(事实自包含) } func (ModelStepPrepared) fact() {} @@ -2306,7 +2334,7 @@ func (l *Loop) Run(context.Context, Runtime, EventSink) (LoopResult, error) 8. Waiting response 只推进对应 Call;approval approved 先变 Pending,随后由 Loop 执行工具。日志记录结果事实(`ToolCallFailed{permission_denied}`、`RunEnded{cancelled}`),不记录请求本身。 9. 幂等按 command 判定:相同 CommandID/digest 重放返回 CommitAlreadyApplied 与原事件组(不重新运行 Decide),不重复写入 projection、history、queue action 或 outbox;相同 CommandID 不同 digest 冲突。 10. 一次接受的 transition 使 Revision 恰好加一;其全部事实共享该 Revision,Index 组内连续,提交后 `Snapshot.Revision` 等于该 Revision。 -11. MachineState 是 execution authority,AgentEvent log 是 historical authority;二者是同一 transition 序列的两个 materialization,由同一原子提交产生。对任意 Revision,状态必须等于初始状态经 `Evolve` 折叠事件流的结果;分歧即 halt,不定义自动覆盖方向。 +11. AgentEvent log 是 source of truth;MachineState 是必需的同事务 projection,可按 `Evolve` 从日志重建。对任意 Revision,状态必须等于初始状态经 `Evolve` 折叠事件流的结果;snapshot 分歧或缺失且日志完整时自动重建并记录,日志尾部低于 revision 水位时 halt 该 Run。 12. Evolve 的折叠语义与事件编码同属永久兼容契约,按 SchemaVersion 冻结;Decide 的决策规则可随版本演进,因为决策结果已记录为事实。 13. 已知工具失败交给下一次模型请求;Unknown 终止 Run,不自动重试、不查询外部系统。 14. worker cancellation 不等于 RunStopped;业务停止必须提交控制 command。宿主的业务停止先提交 `CancelRun`,再取消 Loop 的 ctx;ctx 取消本身只结束执行尝试,工具 worker 运行到自身结束。 From a368c4bc1d72b49a011dec23584dd5106fb9e025 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Fri, 28 Aug 2026 19:07:07 +0800 Subject: [PATCH 05/81] refactor(agent,sdk): stabilize runtime model boundaries Persist agent-owned JSON-stable model request/result/tool data across commands, facts, state, and digest inputs; enforce canonical snapshots and rebuild MemoryRuntime authority from the event log. Add SDK Request/ModelResult single-call entrypoints, compatibility adapters, and optional ModelInvoker interfaces while keeping legacy GenerateText/StreamText wrappers. --- agent/alias_test.go | 243 +++++++ agent/canonical.go | 77 +++ agent/canonical_test.go | 6 +- agent/clone.go | 216 ++++++- agent/command.go | 26 +- agent/commit.go | 34 +- agent/conformance_test.go | 40 +- agent/contract.go | 3 +- agent/decide.go | 24 +- agent/evolve.go | 3 + agent/fact.go | 32 +- agent/loop.go | 51 +- agent/loop_test.go | 26 +- agent/machine_test.go | 81 ++- agent/memory.go | 42 +- agent/model_data.go | 890 ++++++++++++++++++++++++++ agent/protocol.go | 14 +- agent/rebuild.go | 6 +- agent/rebuild_test.go | 8 +- agent/regression_test.go | 12 +- agent/state.go | 42 +- docs/design/agent-runtime-refactor.md | 200 ++++-- sdk/generate_text.go | 9 +- sdk/model_call.go | 120 ++++ sdk/model_call_test.go | 242 +++++++ sdk/request_adapter.go | 579 +++++++++++++++++ sdk/request_adapter_test.go | 152 +++++ sdk/stream_text.go | 8 +- 28 files changed, 2957 insertions(+), 229 deletions(-) create mode 100644 agent/alias_test.go create mode 100644 agent/model_data.go create mode 100644 sdk/model_call.go create mode 100644 sdk/model_call_test.go create mode 100644 sdk/request_adapter.go create mode 100644 sdk/request_adapter_test.go diff --git a/agent/alias_test.go b/agent/alias_test.go new file mode 100644 index 0000000..62418ea --- /dev/null +++ b/agent/alias_test.go @@ -0,0 +1,243 @@ +package agent + +import ( + "context" + "encoding/json" + "testing" + + "github.com/memohai/twilight-ai/sdk" +) + +func TestMemoryRuntimeClonesInitialState(t *testing.T) { + payload := json.RawMessage(`{"q":"hi"}`) + initial, err := Initialize("run-1", testConfig(), NextRun(AgentInput{ID: "seed", Payload: payload})) + if err != nil { + t.Fatal(err) + } + rt := NewMemoryRuntime(initial) + + copy(payload, []byte(`{"q":"no"}`)) + initial.PendingInputs[0].Payload[6] = 'x' + + snap, err := rt.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := string(snap.State.PendingInputs[0].Payload); got != `{"q":"hi"}` { + t.Fatalf("runtime initial payload aliased caller state: %s", got) + } + diverged, err := rt.Rebuild() + if err != nil { + t.Fatal(err) + } + if diverged { + t.Fatal("rebuild diverged after caller mutated initial state") + } +} + +func TestCommitSnapshotsCommandPayloadBeforeFoldingState(t *testing.T) { + rt := newTestRuntime(t, RunConfig{Model: "m-1"}) + payload := json.RawMessage(`{"v":"one"}`) + cmdID := DeriveInputCommandID("run-1", "in-1") + mustCommit(t, rt, cmdID, 0, "", AcceptInput{Input: AgentInput{ID: "in-1", Payload: payload}}) + + copy(payload, []byte(`{"v":"two"}`)) + + snap, err := rt.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + var found bool + for _, in := range snap.State.PendingInputs { + if in.ID == "in-1" { + found = true + if got := string(in.Payload); got != `{"v":"one"}` { + t.Fatalf("state payload aliased command buffer: %s", got) + } + } + } + if !found { + t.Fatal("accepted input not found") + } + + events := rt.Events() + for _, e := range events { + if f, ok := e.Fact.(InputAccepted); ok && f.Input.ID == "in-1" { + if got := string(f.Input.Payload); got != `{"v":"one"}` { + t.Fatalf("event payload aliased command buffer: %s", got) + } + } + } + diverged, err := rt.Rebuild() + if err != nil { + t.Fatal(err) + } + if diverged { + t.Fatal("state diverged from log after command buffer mutation") + } +} + +func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { + rt := newTestRuntime(t, RunConfig{Model: "m-1"}) + snap, _ := rt.Load(context.Background()) + req := ModelRequest{ + Model: "m-1", + ProviderOptions: map[string]json.RawMessage{ + "p": json.RawMessage(`{"b":2,"a":1}`), + }, + } + reqDigest, err := DigestRequest(req) + if err != nil { + t.Fatal(err) + } + toolsDigest, err := DigestToolSpecs(nil) + if err != nil { + t.Fatal(err) + } + binding, err := DigestModelStepBinding(snap.State.Config.Model, reqDigest, toolsDigest) + if err != nil { + t.Fatal(err) + } + cmdID := DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) + ids := make([]InputID, len(snap.State.PendingInputs)) + for i, in := range snap.State.PendingInputs { + ids[i] = in.ID + } + res := mustCommit(t, rt, cmdID, snap.Revision, "", PrepareModelRequest{ + StepID: DeriveModelStepID(snap.State.RunID, cmdID, binding), + Model: snap.State.Config.Model, + Request: req, + RequestDigest: reqDigest, + InputIDs: ids, + ToolsDigest: toolsDigest, + }) + + ms := res.Snapshot.State.Current.(ModelStep) + if got := string(ms.Request.ProviderOptions["p"]); got != `{"a":1,"b":2}` { + t.Fatalf("snapshot stored non-canonical provider option: %s", got) + } + for _, e := range rt.Events() { + if f, ok := e.Fact.(ModelStepPrepared); ok { + if got := string(f.Request.ProviderOptions["p"]); got != `{"a":1,"b":2}` { + t.Fatalf("event stored non-canonical provider option: %s", got) + } + } + } +} + +func TestLoadSnapshotDoesNotAliasFrozenRequest(t *testing.T) { + rt := newTestRuntime(t, RunConfig{Model: "m-1"}) + meta := map[string]any{"provider": map[string]any{"sig": "s1"}} + req := sdk.Request{ + Model: "m-1", + Messages: []sdk.Message{{ + Role: sdk.MessageRoleUser, + Content: []sdk.MessagePart{sdk.TextPart{ + Text: "hi", + ProviderMetadata: meta, + }}, + }}, + } + snap, _ := rt.Load(context.Background()) + prep, cmdID := buildPrepareFromSnap(t, snap, req, nil) + mustCommit(t, rt, cmdID, snap.Revision, "", prep) + + snap, err := rt.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + ms := snap.State.Current.(ModelStep) + part := ms.Request.Messages[0].Content[0] + part.Text = "edited" + part.ProviderMetadata["provider"] = json.RawMessage(`{"sig":"bad"}`) + part.ProviderMetadata["new"] = json.RawMessage(`"bad"`) + ms.Request.Messages[0].Content[0] = part + + snap, err = rt.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + got := snap.State.Current.(ModelStep).Request.Messages[0].Content[0] + if got.Text != "hi" { + t.Fatalf("request content aliased Load snapshot: %q", got.Text) + } + if sig := string(got.ProviderMetadata["provider"]); sig != `{"sig":"s1"}` { + t.Fatalf("request metadata aliased Load snapshot: %v", sig) + } + if _, ok := got.ProviderMetadata["new"]; ok { + t.Fatal("request metadata accepted mutation from Load snapshot") + } +} + +func TestCommitResultEventsDoNotAliasStateOrLog(t *testing.T) { + rt, stepID, grant := preparedRuntime(t, nil, nil) + result := sdk.ModelResult{ + Text: "ok", + FinishReason: sdk.FinishReasonStop, + ReasoningParts: []sdk.ReasoningPart{{ + ID: "r1", + Text: "why", + Format: sdk.ReasoningFormatAnthropic, + ProviderMetadata: map[string]any{"anthropic": map[string]any{"signature": "s1"}}, + }}, + TextProviderMetadata: map[string]any{"google": map[string]any{"thoughtSignature": "g1"}}, + Sources: []sdk.Source{{ + SourceType: "url", + ID: "src-1", + URL: "https://example.test", + ProviderMetadata: map[string]any{"p": "v"}, + }}, + Response: &sdk.ResponseMetadata{ID: "resp-1", Headers: map[string]string{"h": "v"}}, + } + frozen, err := FreezeModelResult(result) + if err != nil { + t.Fatal(err) + } + res := mustCommit(t, rt, "done-1", 2, grant, SubmitModelResult{StepID: stepID, Result: frozen}) + + fact := res.Events[0].Fact.(ModelStepCompleted) + fact.Result.ReasoningParts[0].ProviderMetadata["anthropic"] = json.RawMessage(`{"signature":"bad"}`) + fact.Result.TextProviderMetadata["google"] = json.RawMessage(`{"thoughtSignature":"bad"}`) + fact.Result.Sources[0].ProviderMetadata["p"] = json.RawMessage(`"bad"`) + fact.Result.Response.Headers["h"] = "bad" + res.Events[0].Fact = fact + + snap, err := rt.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + last := snap.State.LastModelResult + if last == nil { + t.Fatal("missing LastModelResult") + } + if sig := string(last.ReasoningParts[0].ProviderMetadata["anthropic"]); sig != `{"signature":"s1"}` { + t.Fatalf("state reasoning metadata aliased returned event: %v", sig) + } + if sig := string(last.TextProviderMetadata["google"]); sig != `{"thoughtSignature":"g1"}` { + t.Fatalf("state text metadata aliased returned event: %v", sig) + } + if p := string(last.Sources[0].ProviderMetadata["p"]); p != `"v"` { + t.Fatalf("state source metadata aliased returned event: %v", p) + } + if h := last.Response.Headers["h"]; h != "v" { + t.Fatalf("state response headers aliased returned event: %v", h) + } + + for _, e := range rt.Events() { + if f, ok := e.Fact.(ModelStepCompleted); ok { + if sig := string(f.Result.ReasoningParts[0].ProviderMetadata["anthropic"]); sig != `{"signature":"s1"}` { + t.Fatalf("log reasoning metadata aliased returned event: %v", sig) + } + if h := f.Result.Response.Headers["h"]; h != "v" { + t.Fatalf("log response headers aliased returned event: %v", h) + } + } + } + diverged, err := rt.Rebuild() + if err != nil { + t.Fatal(err) + } + if diverged { + t.Fatal("state diverged from log after returned event mutation") + } +} diff --git a/agent/canonical.go b/agent/canonical.go index 45def6c..4a2b409 100644 --- a/agent/canonical.go +++ b/agent/canonical.go @@ -35,6 +35,13 @@ func canonicalJSON(raw []byte) ([]byte, error) { if !utf8.Valid(raw) { return nil, errors.New("agent: canonical: invalid UTF-8 input") } + // encoding/json also rewrites escaped lone surrogates (\ud800..\udfff) to + // U+FFFD before writeCanonicalString sees them. Reject them while the raw + // escape structure is still visible; otherwise distinct payloads collapse + // into the same digest. + if err := rejectEscapedLoneSurrogates(raw); err != nil { + return nil, err + } dec := json.NewDecoder(bytes.NewReader(raw)) dec.UseNumber() v, err := parseCanonicalValue(dec) @@ -53,6 +60,76 @@ func canonicalJSON(raw []byte) ([]byte, error) { return b.Bytes(), nil } +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 JSON decoder report syntax + } + if raw[i+1] != 'u' && raw[i+1] != 'U' { + i += 2 + continue + } + if i+6 > len(raw) { + return nil // let the JSON decoder report syntax + } + code, ok := parseHex4(raw[i+2 : i+6]) + if !ok { + return nil // let the JSON decoder 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 +} + // parseCanonicalValue decodes one JSON value from the token stream, rejecting // duplicate object keys: last-wins collapsing would let two byte-distinct // payloads share one digest. diff --git a/agent/canonical_test.go b/agent/canonical_test.go index 2a086c1..b775b5e 100644 --- a/agent/canonical_test.go +++ b/agent/canonical_test.go @@ -30,6 +30,7 @@ func TestCanonicalJSON(t *testing.T) { {"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 { @@ -46,7 +47,10 @@ func TestCanonicalJSON(t *testing.T) { } func TestCanonicalJSONRejects(t *testing.T) { - for _, in := range []string{``, `{"a":1}garbage`, `{bad}`} { + for _, in := range []string{ + ``, `{"a":1}garbage`, `{bad}`, + `"\ud800"`, `"\udbff"`, `"\udc00"`, `"\ud800x"`, `"\ud800\u0041"`, + } { if _, err := canonicalJSON([]byte(in)); err == nil { t.Fatalf("canonicalJSON(%q): expected error", in) } diff --git a/agent/clone.go b/agent/clone.go index af25340..b9f368a 100644 --- a/agent/clone.go +++ b/agent/clone.go @@ -1,14 +1,14 @@ package agent -import ( - "encoding/json" - - "github.com/memohai/twilight-ai/sdk" -) +import "encoding/json" // Deep-copy helpers: Runtime return values must be read-only snapshots (spec // appendix A) — a caller mutating a returned slice, map, or json.RawMessage // 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, copy slice/map containers, and copy json.RawMessage byte slices. func cloneRaw(m json.RawMessage) json.RawMessage { if m == nil { @@ -17,6 +17,33 @@ func cloneRaw(m json.RawMessage) json.RawMessage { return append(json.RawMessage(nil), m...) } +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 @@ -84,6 +111,12 @@ func cloneToolCallBindings(bs []ToolCallBinding) []ToolCallBinding { 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 @@ -96,24 +129,55 @@ func cloneToolSpecs(specs []ToolSpec) []ToolSpec { return out } -func cloneToolDefinition(d sdk.ToolDefinition) sdk.ToolDefinition { - d.Parameters = cloneRaw(d.Parameters) - if d.CacheControl != nil { - cc := *d.CacheControl - d.CacheControl = &cc +func cloneResponseFormat(f *ResponseFormat) *ResponseFormat { + if f == nil { + return nil } - return d + c := *f + c.JSONSchema = cloneRaw(c.JSONSchema) + return &c } -func cloneRequest(r sdk.Request) sdk.Request { - r.Messages = append([]sdk.Message(nil), r.Messages...) - tools := make([]sdk.ToolDefinition, len(r.Tools)) - for i, t := range r.Tools { - tools[i] = cloneToolDefinition(t) +func cloneMessagePart(p MessagePart) MessagePart { + p.Input = cloneRaw(p.Input) + p.Result = cloneRaw(p.Result) + p.CacheControl = cloneCacheControl(p.CacheControl) + p.ProviderMetadata = cloneProviderMetadata(p.ProviderMetadata) + return p +} + +func cloneMessages(messages []Message) []Message { + if messages == nil { + return nil } - if r.Tools != nil { - r.Tools = tools + out := make([]Message, len(messages)) + for i, m := range messages { + if m.Content != nil { + parts := make([]MessagePart, len(m.Content)) + for j, p := range m.Content { + parts[j] = cloneMessagePart(p) + } + m.Content = parts + } + m.Usage = clonePtr(m.Usage) + out[i] = m } + return out +} + +func cloneRequest(r ModelRequest) ModelRequest { + r.Messages = cloneMessages(r.Messages) + r.Tools = cloneToolDefinitions(r.Tools) + r.ResponseFormat = cloneResponseFormat(r.ResponseFormat) + r.Temperature = clonePtr(r.Temperature) + r.TopP = clonePtr(r.TopP) + r.MaxTokens = clonePtr(r.MaxTokens) + r.FrequencyPenalty = clonePtr(r.FrequencyPenalty) + r.PresencePenalty = clonePtr(r.PresencePenalty) + r.Seed = clonePtr(r.Seed) + r.ReasoningEffort = clonePtr(r.ReasoningEffort) + r.ReasoningSummary = clonePtr(r.ReasoningSummary) + r.PromptCacheKey = clonePtr(r.PromptCacheKey) r.StopSequences = append([]string(nil), r.StopSequences...) if r.ProviderOptions != nil { opts := make(map[string]json.RawMessage, len(r.ProviderOptions)) @@ -125,15 +189,90 @@ func cloneRequest(r sdk.Request) sdk.Request { return r } -func cloneModelResult(r *sdk.ModelResult) *sdk.ModelResult { +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 cloneReasoningParts(parts []ReasoningPart) []ReasoningPart { + if parts == nil { + return nil + } + out := make([]ReasoningPart, len(parts)) + for i, p := range parts { + p.ProviderMetadata = cloneProviderMetadata(p.ProviderMetadata) + out[i] = p + } + return out +} + +func cloneSources(sources []Source) []Source { + if sources == nil { + return nil + } + out := make([]Source, len(sources)) + for i, s := range sources { + s.ProviderMetadata = cloneProviderMetadata(s.ProviderMetadata) + out[i] = s + } + return out +} + +func cloneGeneratedFiles(files []GeneratedFile) []GeneratedFile { + if files == nil { + return nil + } + return append([]GeneratedFile(nil), files...) +} + +func cloneModelToolCall(c ModelToolCall) ModelToolCall { + c.Input = cloneRaw(c.Input) + c.ProviderMetadata = cloneProviderMetadata(c.ProviderMetadata) + return c +} + +func cloneModelToolCalls(calls []ModelToolCall) []ModelToolCall { + if calls == nil { + return nil + } + out := make([]ModelToolCall, len(calls)) + for i, c := range calls { + out[i] = cloneModelToolCall(c) + } + return out +} + +func cloneResponseMetadata(r *ResponseMetadata) *ResponseMetadata { + if r == nil { + return nil + } + c := *r + if r.Headers != nil { + c.Headers = make(map[string]string, len(r.Headers)) + for k, v := range r.Headers { + c.Headers[k] = v + } + } + return &c +} + +func cloneModelResult(r *ModelResult) *ModelResult { if r == nil { return nil } c := *r - c.ReasoningParts = append([]sdk.ReasoningPart(nil), c.ReasoningParts...) - c.Sources = append([]sdk.Source(nil), c.Sources...) - c.Files = append([]sdk.GeneratedFile(nil), c.Files...) - c.ToolCalls = append([]sdk.ToolCall(nil), c.ToolCalls...) + c.ReasoningParts = cloneReasoningParts(c.ReasoningParts) + c.TextProviderMetadata = cloneProviderMetadata(c.TextProviderMetadata) + c.Sources = cloneSources(c.Sources) + c.Files = cloneGeneratedFiles(c.Files) + c.ToolCalls = cloneModelToolCalls(c.ToolCalls) + c.Response = cloneResponseMetadata(c.Response) return &c } @@ -178,6 +317,37 @@ func cloneMachineState(s MachineState) MachineState { return s } +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 +} + +func snapshotFact(f Fact) (Fact, error) { + switch fact := f.(type) { + case ModelStepPrepared: + return snapshotJSONStable(fact) + case ModelStepCompleted: + return snapshotJSONStable(fact) + case ToolStepOpened: + return snapshotJSONStable(fact) + case ToolCallCompleted: + return snapshotJSONStable(fact) + case ToolCallAnswered: + return snapshotJSONStable(fact) + case InputAccepted: + return snapshotJSONStable(fact) + default: + return cloneFact(f), nil + } +} + func cloneFact(f Fact) Fact { switch fact := f.(type) { case ModelStepPrepared: diff --git a/agent/command.go b/agent/command.go index 00f3bf2..825e27f 100644 --- a/agent/command.go +++ b/agent/command.go @@ -1,10 +1,6 @@ package agent -import ( - "encoding/json" - - "github.com/memohai/twilight-ai/sdk" -) +import "encoding/json" // AgentCommand is the intent submitted through Runtime.Commit for an existing // Run. Accepting one command constitutes one transition (spec §3.6). The @@ -34,14 +30,14 @@ func NextRun(input AgentInput) RunSeed { return RunSeed{Input: input} } // PrepareModelRequest freezes the next model request. Its CommandID is // derived from the loaded Revision, which is also its concurrency control. type PrepareModelRequest struct { - StepID StepID `json:"stepId"` - Model ModelRef `json:"model"` - Request sdk.Request `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"` + 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() {} @@ -67,7 +63,7 @@ func (RecoverModelExecution) agentCommand() {} // bindings. Requires the model start grant. type SubmitModelResult struct { StepID StepID `json:"stepId"` - Result sdk.ModelResult `json:"result"` + Result ModelResult `json:"result"` Calls []ToolCallBinding `json:"calls,omitempty"` } @@ -88,7 +84,7 @@ func (SubmitModelFailure) agentCommand() {} // model start grant. type RejectModelResult struct { StepID StepID `json:"stepId"` - Usage sdk.Usage `json:"usage"` + Usage Usage `json:"usage"` Failure StepFailure `json:"failure"` } diff --git a/agent/commit.go b/agent/commit.go index 42ec735..1665e8f 100644 --- a/agent/commit.go +++ b/agent/commit.go @@ -158,9 +158,9 @@ func EvaluateCommit( return CommitDecision{Kind: DecisionConflict, Reject: fmt.Errorf("agent: commit: envelope digest mismatch")}, nil } // Derived-identity families must use their derived CommandID (spec §5.5): - // the derivation IS the idempotency index for inputs and responses, so a - // caller-minted random ID would silently bypass duplicate detection. - if err := checkDerivedCommandID(env); err != nil { + // the derivation IS the idempotency index for inputs/responses/planning, so + // a caller-minted random ID would silently bypass duplicate detection. + if err := checkDerivedCommandID(env, req.BaseRevision); err != nil { return CommitDecision{}, err } @@ -197,6 +197,8 @@ func EvaluateCommit( return CommitDecision{Kind: DecisionTerminal, Reject: err}, nil case ErrStaleRuntime: return CommitDecision{Kind: DecisionStale, Reject: err}, nil + case 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. @@ -206,7 +208,29 @@ func EvaluateCommit( newRevision := curRevision + 1 state := cur events := make([]AgentEvent, len(facts)) + if cmd, ok := env.Command.(PrepareModelRequest); ok { + if len(facts) == 0 { + return CommitDecision{}, fmt.Errorf("agent: commit: prepare produced no facts") + } + prepared, ok := facts[0].(ModelStepPrepared) + if !ok { + return CommitDecision{}, fmt.Errorf("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 + } + } + for i, f := range facts { + // Detach every fact before it is folded or wrapped as an event. Decide + // often forwards fields from the caller's command (ModelRequest, + // ModelResult, json.RawMessage payloads); the commit decision must not + // carry caller-owned mutable objects across the Runtime boundary. + f, err = snapshotFact(f) + if err != nil { + return CommitDecision{}, err + } state, err = Evolve(state, f) if err != nil { return CommitDecision{}, err @@ -258,9 +282,11 @@ func BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, // from (RunID, StepID, CallID, ResponseID). Approve and reject of the same // response share one identity by design, so a decision change surfaces as // ErrCommandConflict instead of a second fact. -func checkDerivedCommandID(env CommandEnvelope) error { +func checkDerivedCommandID(env CommandEnvelope, baseRevision uint64) error { var want CommandID switch cmd := env.Command.(type) { + case PrepareModelRequest: + want = DeriveModelRequestCommandID(env.RunID, baseRevision) case AcceptInput: want = DeriveInputCommandID(env.RunID, cmd.Input.ID) case ApproveToolCall: diff --git a/agent/conformance_test.go b/agent/conformance_test.go index 01c30dc..51fe9bf 100644 --- a/agent/conformance_test.go +++ b/agent/conformance_test.go @@ -58,7 +58,11 @@ func preparedRuntime(t *testing.T, tools []sdk.ToolDefinition, specs []ToolSpec) func buildPrepareFromSnap(t *testing.T, snap RuntimeSnapshot, req sdk.Request, specs []ToolSpec) (PrepareModelRequest, CommandID) { t.Helper() - reqDigest, err := DigestRequest(req) + frozenReq, err := FreezeModelRequest(req) + if err != nil { + t.Fatal(err) + } + reqDigest, err := DigestRequest(frozenReq) if err != nil { t.Fatal(err) } @@ -77,7 +81,7 @@ func buildPrepareFromSnap(t *testing.T, snap RuntimeSnapshot, req sdk.Request, s ids[i] = in.ID } return PrepareModelRequest{ - StepID: stepID, Model: snap.State.Config.Model, Request: req, + StepID: stepID, Model: snap.State.Config.Model, Request: frozenReq, RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, }, cmdID } @@ -137,7 +141,7 @@ func TestConformanceStartGrantLifecycle(t *testing.T) { rt, stepID, grant := preparedRuntime(t, nil, nil) // Completion without grant is stale. - _, err := commitCmd(t, rt, "done-x", 2, "", SubmitModelResult{StepID: stepID, Result: sdk.ModelResult{}}) + _, err := commitCmd(t, rt, "done-x", 2, "", SubmitModelResult{StepID: stepID, Result: ModelResult{}}) if !errors.Is(err, ErrStaleRuntime) { t.Fatalf("grantless completion err = %v, want ErrStaleRuntime", err) } @@ -147,7 +151,11 @@ func TestConformanceStartGrantLifecycle(t *testing.T) { t.Fatalf("replayed start: %+v", res) } // Owner completes with the minted grant. - res = mustCommit(t, rt, "done-1", 2, grant, SubmitModelResult{StepID: stepID, Result: sdk.ModelResult{Text: "ok"}}) + ok, err := FreezeModelResult(sdk.ModelResult{Text: "ok"}) + if err != nil { + t.Fatal(err) + } + res = mustCommit(t, rt, "done-1", 2, grant, SubmitModelResult{StepID: stepID, Result: ok}) if res.Status != CommitAccepted || res.Snapshot.State.Status != RunCompleted { t.Fatalf("completion: %+v", res.Snapshot.State.Status) } @@ -161,12 +169,15 @@ func TestConformanceCallLocalRebase(t *testing.T) { bA := makeBinding(t, "cA", specA, `{}`) bB := makeBinding(t, "cB", specB, `{}`) - r := sdk.ModelResult{ + r, err := FreezeModelResult(sdk.ModelResult{ FinishReason: sdk.FinishReasonToolCalls, ToolCalls: []sdk.ToolCall{ {ToolCallID: "cA", ToolName: "a", Input: `{}`}, {ToolCallID: "cB", ToolName: "b", Input: `{}`}, }, + }) + if err != nil { + t.Fatal(err) } res := mustCommit(t, rt, "complete-1", 2, grant, SubmitModelResult{StepID: stepID, Result: r, Calls: []ToolCallBinding{bA, bB}}) @@ -187,13 +198,30 @@ func TestConformanceCallLocalRebase(t *testing.T) { t.Fatal("owner completion on stale base must rebase") } // Second start of the same call must not rebase (no longer Pending). - _, err := commitCmd(t, rt, "start-A2", base, "", StartToolCall{StepID: toolStep, CallID: "cA"}) + _, err = commitCmd(t, rt, "start-A2", base, "", StartToolCall{StepID: toolStep, CallID: "cA"}) if !errors.Is(err, ErrStaleRuntime) { t.Fatalf("restart of settled call err = %v, want ErrStaleRuntime", err) } _ = startB } +func TestConformancePrepareDerivedIdentity(t *testing.T) { + rt := newTestRuntime(t, RunConfig{Model: "m-1"}) + snap, _ := rt.Load(context.Background()) + prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) + + if _, err := commitCmd(t, rt, "wrong-prepare-id", snap.Revision, "", prep); err == nil { + t.Fatal("PrepareModelRequest accepted a non-derived CommandID") + } + + bad := prep + bad.StepID = "wrong-step" + _, err := commitCmd(t, rt, cmdID, snap.Revision, "", bad) + if !errors.Is(err, ErrStaleRuntime) { + t.Fatalf("bad prepare StepID err = %v, want ErrStaleRuntime", err) + } +} + func TestConformancePrepareIsHardCAS(t *testing.T) { rt := newTestRuntime(t, RunConfig{Model: "m-1"}) snap, _ := rt.Load(context.Background()) diff --git a/agent/contract.go b/agent/contract.go index d1370d0..96cadae 100644 --- a/agent/contract.go +++ b/agent/contract.go @@ -8,7 +8,8 @@ import ( ) // RequestPlanner is the port the application injects: it projects application -// context into the next frozen sdk.Request (spec §5.6). Planning +// context into the next boundary sdk.Request (spec §5.6). 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, PlanningHint) (RequestPlan, error) diff --git a/agent/decide.go b/agent/decide.go index 7c938a0..9361b8b 100644 --- a/agent/decide.go +++ b/agent/decide.go @@ -75,6 +75,9 @@ func decidePrepareModelRequest(s MachineState, cmd PrepareModelRequest) ([]Fact, if cmd.Model != s.Config.Model { return nil, rejectionf("prepare: model %q does not match frozen RunConfig model %q", cmd.Model, s.Config.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) + } if s.Config.ModelStepLimit > 0 && s.ModelSteps >= s.Config.ModelStepLimit { // Unreachable when limits convert to terminal at the prior boundary, // but the authority still refuses rather than over-running. @@ -243,11 +246,12 @@ func decideSubmitModelResult(s MachineState, cmd SubmitModelResult) ([]Fact, err } } wantArgs, argsCanonical := canonicalArgumentsForCompare(rc.Input) - if argsCanonical { - gotArgs, err := canonicalJSON(b.Arguments) - if err != nil || string(gotArgs) != string(wantArgs) { - return nil, rejectionf("model result: binding %q arguments do not match the model result", b.CallID) - } + if !argsCanonical { + return nil, rejectionf("model result: call %q input is not frozen canonical JSON", b.CallID) + } + gotArgs, err := canonicalJSON(b.Arguments) + if err != nil || string(gotArgs) != string(wantArgs) { + return nil, 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 { @@ -295,9 +299,8 @@ func decideSubmitModelResult(s MachineState, cmd SubmitModelResult) ([]Fact, err } // canonicalArgumentsForCompare canonicalizes a model result's tool input for -// cross-checking a binding. The second return is false when the input is not -// valid JSON — those calls bind raw and close as invalid_arguments, so there -// is no canonical form to compare. +// 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) ([]byte, bool) { got, err := canonicalToolArguments(input) if err != nil { @@ -550,5 +553,10 @@ 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{Input: cmd.Input}}, nil } diff --git a/agent/evolve.go b/agent/evolve.go index f6acbfe..796dd1d 100644 --- a/agent/evolve.go +++ b/agent/evolve.go @@ -10,6 +10,9 @@ import "fmt" func Evolve(s MachineState, f Fact) (MachineState, error) { switch fact := f.(type) { case ModelStepPrepared: + if s.Current != nil { + return s, fmt.Errorf("agent: evolve: model step prepared while a step is current") + } s.Current = ModelStep{ RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: fact.BindingDigest}, Request: fact.Request, diff --git a/agent/fact.go b/agent/fact.go index 2a027b8..765dee5 100644 --- a/agent/fact.go +++ b/agent/fact.go @@ -1,10 +1,6 @@ package agent -import ( - "encoding/json" - - "github.com/memohai/twilight-ai/sdk" -) +import "encoding/json" // Fact is one committed outcome produced by Machine.Decide. Facts are wrapped // as AgentEvents; Machine.Evolve folds them mechanically (spec §3.6). The @@ -16,14 +12,14 @@ type Fact interface{ fact() } // Decide and carried in the fact: Evolve folds it verbatim, never recomputes // (fact self-containment, spec §5.1). type ModelStepPrepared struct { - StepID StepID `json:"stepId"` - Model ModelRef `json:"model"` - Request sdk.Request `json:"request"` - RequestDigest Digest `json:"requestDigest"` - InputIDs []InputID `json:"inputIds,omitempty"` - Tools []ToolSpec `json:"tools,omitempty"` - ToolsDigest Digest `json:"toolsDigest"` - BindingDigest Digest `json:"bindingDigest"` + StepID StepID `json:"stepId"` + Model ModelRef `json:"model"` + Request ModelRequest `json:"request"` + 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() {} @@ -46,7 +42,7 @@ func (ModelStepRecovered) fact() {} // accumulated, Rejects is incremented, the step returns to Prepared. type ModelStepRejected struct { StepID StepID `json:"stepId"` - Usage sdk.Usage `json:"usage"` + Usage Usage `json:"usage"` Failure StepFailure `json:"failure"` } @@ -55,8 +51,8 @@ func (ModelStepRejected) fact() {} // ModelStepCompleted accepts one model result: usage is accumulated, // LastModelResult is written, the current step is cleared. type ModelStepCompleted struct { - StepID StepID `json:"stepId"` - Result sdk.ModelResult `json:"result"` + StepID StepID `json:"stepId"` + Result ModelResult `json:"result"` } func (ModelStepCompleted) fact() {} @@ -140,8 +136,8 @@ func (InputAccepted) fact() {} // RunEnded is the terminal fact. Always the last fact of its transition. type RunEnded struct { - Status RunStatus `json:"status"` // RunCompleted, RunStopped or RunFailed - Reason RunReason `json:"reason,omitempty"` + Status RunStatus `json:"status"` // RunCompleted, RunStopped or RunFailed + Reason RunReason `json:"reason,omitempty"` Failure *RunFailure `json:"failure,omitempty"` } diff --git a/agent/loop.go b/agent/loop.go index bbaa7a3..9b9b746 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -134,7 +134,14 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, if plan.Model != hint.Model { return fmt.Errorf("agent: loop: plan model %q does not match hint model %q", plan.Model, hint.Model) } - requestDigest, err := DigestRequest(plan.Request) + frozenRequest, err := FreezeModelRequest(plan.Request) + if err != nil { + return err + } + if ModelRef(frozenRequest.Model) != plan.Model { + return fmt.Errorf("agent: loop: request model %q does not match plan model %q", frozenRequest.Model, plan.Model) + } + requestDigest, err := DigestRequest(frozenRequest) if err != nil { return err } @@ -151,7 +158,7 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, _, err = l.commit(controlCtx, runtime, snapshot.State.RunID, cmdID, snapshot.Revision, "", PrepareModelRequest{ StepID: stepID, Model: plan.Model, - Request: plan.Request, + Request: frozenRequest, RequestDigest: requestDigest, InputIDs: plan.InputIDs, PlanningToken: plan.PlanningToken, @@ -205,19 +212,27 @@ func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime Runtime, ev } else { // Model workers derive from the outer ctx: cancelling a model call is // safe, the frozen request retries after recovery (spec §6.1). - result, invokeErr := l.invokeModel(ctx, invoker, modelStep.Request, run, stepID, events) - switch { - case invokeErr != nil && ctx.Err() != nil: - completion = RecoverModelExecution{StepID: stepID} - case invokeErr != nil: - completion = SubmitModelFailure{StepID: stepID, Failure: StepFailure{Class: FailureProvider, Message: invokeErr.Error()}} - default: - bindings, bindErr := l.bindToolCalls(result, modelStep) - if bindErr != nil { - completion = RejectModelResult{StepID: stepID, Usage: result.Usage, - Failure: StepFailure{Class: FailureMalformedModel, Message: bindErr.Error()}} - } else { - completion = SubmitModelResult{StepID: stepID, Result: result, Calls: bindings} + sdkRequest, err := modelStep.Request.SDK() + if err != nil { + completion = SubmitModelFailure{StepID: stepID, Failure: StepFailure{Class: FailureProvider, Message: err.Error()}} + } else { + result, invokeErr := l.invokeModel(ctx, invoker, sdkRequest, run, stepID, events) + switch { + case invokeErr != nil && ctx.Err() != nil: + completion = RecoverModelExecution{StepID: stepID} + case invokeErr != nil: + completion = SubmitModelFailure{StepID: stepID, Failure: StepFailure{Class: FailureProvider, Message: invokeErr.Error()}} + default: + bindings, bindErr := l.bindToolCalls(result, modelStep) + if bindErr != nil { + completion = RejectModelResult{StepID: stepID, Usage: UsageFromSDK(result.Usage), + Failure: StepFailure{Class: FailureMalformedModel, Message: bindErr.Error()}} + } else if frozenResult, freezeErr := FreezeModelResult(result); freezeErr != nil { + completion = RejectModelResult{StepID: stepID, Usage: UsageFromSDK(result.Usage), + Failure: StepFailure{Class: FailureMalformedModel, Message: freezeErr.Error()}} + } else { + completion = SubmitModelResult{StepID: stepID, Result: frozenResult, Calls: bindings} + } } } } @@ -376,7 +391,11 @@ func (l *Loop) runToolCalls(ctx, controlCtx context.Context, runtime Runtime, ev case resolveErr != nil: known = &ToolFailure{Class: FailureToolLookup, Message: resolveErr.Error()} default: - defDigest, err := DigestToolDefinition(tool.Definition()) + toolDef, err := FreezeToolDefinition(tool.Definition()) + if err != nil { + return err + } + defDigest, err := DigestToolDefinition(toolDef) if err != nil { return err } diff --git a/agent/loop_test.go b/agent/loop_test.go index fa64cc3..b265b77 100644 --- a/agent/loop_test.go +++ b/agent/loop_test.go @@ -45,9 +45,9 @@ type fakeTool struct { 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) 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(json.RawMessage) error { return f.valErr } func (f *fakeTool) Execute(ctx context.Context, req ToolExecutionRequest) ToolExecutionOutcome { return f.execute(ctx, req) @@ -71,7 +71,7 @@ type staticPlanner struct { func (p staticPlanner) Plan(_ context.Context, hint PlanningHint) (RequestPlan, error) { req := sdk.Request{Model: string(hint.Model), Messages: []sdk.Message{sdk.UserMessage("go")}} for _, s := range p.specs { - req.Tools = append(req.Tools, s.Definition) + req.Tools = append(req.Tools, s.Definition.SDK()) } ids := make([]InputID, len(hint.Inputs)) for i, in := range hint.Inputs { @@ -83,11 +83,15 @@ func (p staticPlanner) Plan(_ context.Context, hint PlanningHint) (RequestPlan, func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { t.Helper() def := sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} - d, err := DigestToolDefinition(def) + frozen, err := FreezeToolDefinition(def) if err != nil { t.Fatal(err) } - return ToolSpec{Ref: ToolRef(name), Definition: def, DefinitionDigest: d, Policy: policy} + d, err := DigestToolDefinition(frozen) + if err != nil { + t.Fatal(err) + } + return ToolSpec{Ref: ToolRef(name), Definition: frozen, DefinitionDigest: d, Policy: policy} } func loopRuntime(t *testing.T) *MemoryRuntime { @@ -134,7 +138,7 @@ func TestLoopSingleModelCallCompletes(t *testing.T) { func TestLoopToolRoundTrip(t *testing.T) { spec := toolSpec(t, "echo", DirectExecution) - echo := &fakeTool{ref: "echo", def: spec.Definition, policy: DirectExecution, + echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, execute: func(_ context.Context, req ToolExecutionRequest) ToolExecutionOutcome { return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: req.Arguments}} }} @@ -162,7 +166,7 @@ func TestLoopToolRoundTrip(t *testing.T) { func TestLoopApprovalWaitsAndResumes(t *testing.T) { spec := toolSpec(t, "echo", ApprovalRequired) executed := atomic.Bool{} - echo := &fakeTool{ref: "echo", def: spec.Definition, policy: ApprovalRequired, + echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: ApprovalRequired, execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { executed.Store(true) return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}} @@ -216,7 +220,7 @@ func TestLoopApprovalWaitsAndResumes(t *testing.T) { func TestLoopUnknownOutcomeFailsRun(t *testing.T) { spec := toolSpec(t, "echo", DirectExecution) - echo := &fakeTool{ref: "echo", def: spec.Definition, policy: DirectExecution, + echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { return ToolExecutionUnknown{Failure: ToolFailure{Class: FailureEffectUnknown, Message: "lost"}} }} @@ -239,7 +243,7 @@ func TestLoopUnknownOutcomeFailsRun(t *testing.T) { func TestLoopKnownToolFailureContinues(t *testing.T) { spec := toolSpec(t, "echo", DirectExecution) - echo := &fakeTool{ref: "echo", def: spec.Definition, policy: DirectExecution, + echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { return ToolExecutionFailed{Failure: ToolFailure{Class: FailureExecution, Message: "boom"}} }} @@ -296,7 +300,7 @@ func TestLoopParallelBounded(t *testing.T) { var concurrent, peak atomic.Int32 gate := make(chan struct{}) started := make(chan struct{}, 3) - echo := &fakeTool{ref: "echo", def: spec.Definition, policy: DirectExecution, + echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { cur := concurrent.Add(1) for { diff --git a/agent/machine_test.go b/agent/machine_test.go index bec9e31..0ce78e5 100644 --- a/agent/machine_test.go +++ b/agent/machine_test.go @@ -57,7 +57,11 @@ func testToolDef(name string) sdk.ToolDefinition { func buildPrepare(t *testing.T, s MachineState, req sdk.Request, specs []ToolSpec) (PrepareModelRequest, CommandID) { t.Helper() - reqDigest, err := DigestRequest(req) + frozenReq, err := FreezeModelRequest(req) + if err != nil { + t.Fatal(err) + } + reqDigest, err := DigestRequest(frozenReq) if err != nil { t.Fatal(err) } @@ -78,7 +82,7 @@ func buildPrepare(t *testing.T, s MachineState, req sdk.Request, specs []ToolSpe return PrepareModelRequest{ StepID: stepID, Model: s.Config.Model, - Request: req, + Request: frozenReq, RequestDigest: reqDigest, InputIDs: ids, Tools: specs, @@ -88,11 +92,15 @@ func buildPrepare(t *testing.T, s MachineState, req sdk.Request, specs []ToolSpe func makeSpec(t *testing.T, def sdk.ToolDefinition, policy ResponsePolicy) ToolSpec { t.Helper() - d, err := DigestToolDefinition(def) + frozen, err := FreezeToolDefinition(def) + if err != nil { + t.Fatal(err) + } + d, err := DigestToolDefinition(frozen) if err != nil { t.Fatal(err) } - return ToolSpec{Ref: ToolRef(def.Name), Definition: def, DefinitionDigest: d, Policy: policy} + return ToolSpec{Ref: ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: policy} } func makeBinding(t *testing.T, callID string, spec ToolSpec, args string) ToolCallBinding { @@ -111,13 +119,13 @@ func makeBinding(t *testing.T, callID string, spec ToolSpec, args string) ToolCa } } -func modelResultWithCalls(callIDs ...string) sdk.ModelResult { +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) sdk.ModelResult { +func modelResultWithNamedCalls(toolName, args string, callIDs ...string) ModelResult { r := sdk.ModelResult{ Text: "", FinishReason: sdk.FinishReasonToolCalls, @@ -126,7 +134,11 @@ func modelResultWithNamedCalls(toolName, args string, callIDs ...string) sdk.Mod for _, id := range callIDs { r.ToolCalls = append(r.ToolCalls, sdk.ToolCall{ToolCallID: id, ToolName: toolName, Input: args}) } - return r + frozen, err := FreezeModelResult(r) + if err != nil { + panic(err) + } + return frozen } // advance runs prepare+start and returns the state in Executing plus stepID. @@ -199,7 +211,10 @@ func TestPrepareRejectsIncompleteInputIDs(t *testing.T) { func TestModelCompleteNoToolsEndsRun(t *testing.T) { s := newRun(t, testConfig()) s, stepID := advanceToExecuting(t, s, testRequest(), nil) - result := sdk.ModelResult{Text: "done", FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 7}} + result, err := FreezeModelResult(sdk.ModelResult{Text: "done", FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 7}}) + if err != nil { + t.Fatal(err) + } facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: result}) if len(facts) != 2 { t.Fatalf("facts = %d, want [completed, ended]", len(facts)) @@ -356,13 +371,16 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { bA := makeBinding(t, "cA", specA, `{}`) bB := makeBinding(t, "cB", specB, `{}`) - r := sdk.ModelResult{ + 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) @@ -405,7 +423,7 @@ func TestRejectModelResultRetriesThenFails(t *testing.T) { s := newRun(t, cfg) s, stepID := advanceToExecuting(t, s, testRequest(), nil) - usage := sdk.Usage{TotalTokens: 3} + 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 { @@ -509,6 +527,49 @@ func TestAcceptInputIdempotentPerID(t *testing.T) { } } +func TestAcceptInputRejectsSeedDuplicateID(t *testing.T) { + s := newRun(t, testConfig()) + _, err := Decide(s, NextStep(AgentInput{ID: "seed", Payload: json.RawMessage(`{"q":"other"}`)})) + if err != ErrCommandConflict { + t.Fatalf("duplicate seed input err = %v, want ErrCommandConflict", err) + } +} + +func TestSubmitModelResultRejectsNonFrozenToolInput(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + b := makeBinding(t, "c1", spec, `{"path":"/etc/passwd"}`) + result := ModelResult{ + FinishReason: FinishReasonToolCalls, + ToolCalls: []ModelToolCall{{ + ToolCallID: "c1", + ToolName: "t", + Input: json.RawMessage(`{"path":`), + }}, + } + if _, err := Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: []ToolCallBinding{b}}); err == nil { + t.Fatal("accepted non-frozen model tool input") + } +} + +func TestEvolveRejectsModelPrepareOverCurrentStep(t *testing.T) { + s := newRun(t, testConfig()) + s, _ = advanceToExecuting(t, s, testRequest(), nil) + _, err := Evolve(s, ModelStepPrepared{ + StepID: "other", + Model: s.Config.Model, + Request: ModelRequest{Model: string(s.Config.Model)}, + RequestDigest: "sha256:req", + ToolsDigest: "sha256:tools", + BindingDigest: "sha256:binding", + }) + if err == nil { + t.Fatal("Evolve accepted ModelStepPrepared over an existing step") + } +} + func TestReplayEquivalence(t *testing.T) { // state = fold(Evolve, initial, events): run a full happy path, capture // all facts, refold from initial, and compare canonical serializations. diff --git a/agent/memory.go b/agent/memory.go index 2ab4404..60ace85 100644 --- a/agent/memory.go +++ b/agent/memory.go @@ -31,9 +31,10 @@ type MemoryRuntime struct { // NewMemoryRuntime starts from an Initialize-produced state at Revision 0. func NewMemoryRuntime(initial MachineState) *MemoryRuntime { + frozenInitial := cloneMachineState(initial) return &MemoryRuntime{ - state: initial, - initial: cloneMachineState(initial), + state: cloneMachineState(frozenInitial), + initial: frozenInitial, events: make(map[CommandID][]AgentEvent), grants: make(map[string]ExecutionGrant), } @@ -81,6 +82,27 @@ func newGrant() ExecutionGrant { return ExecutionGrant(hex.EncodeToString(b[:])) } +func foldCommittedEventGroup(state MachineState, events []AgentEvent) (MachineState, error) { + for _, e := range events { + want, err := DigestFact(e.SchemaVersion, e.Type, e.Fact) + if err != nil { + return state, err + } + if e.Digest != want { + return state, fmt.Errorf("agent: memory runtime: stored fact digest mismatch at revision %d index %d", e.Revision, e.Index) + } + fact, err := snapshotFact(e.Fact) + if err != nil { + return state, err + } + state, err = Evolve(state, fact) + if err != nil { + return state, err + } + } + return state, nil +} + func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitResult, error) { if err := ctx.Err(); err != nil { return CommitResult{}, err @@ -126,13 +148,17 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe return CommitResult{}, ErrRunTerminal } - // DecisionApply: persist state + events atomically under the lock, manage - // occupancy, and mint the grant for an accepted start. Facts are cloned on - // the way in so caller-held command buffers cannot mutate stored events. - m.state = decision.NewState + // DecisionApply: persist owned events and derive the authoritative snapshot + // from those same stored facts. The event log is the source of truth; the + // in-memory state is only its same-transaction projection. + stored := cloneEvents(decision.Events) + newState, err := foldCommittedEventGroup(m.state, stored) + if err != nil { + return CommitResult{}, err + } + m.state = cloneMachineState(newState) m.revision++ m.watermark = m.revision - stored := cloneEvents(decision.Events) m.events[req.Command.ID] = stored m.log = append(m.log, stored...) @@ -153,7 +179,7 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe return CommitResult{ Status: CommitAccepted, Snapshot: RuntimeSnapshot{State: cloneMachineState(m.state), Revision: m.revision}, - Events: cloneEvents(decision.Events), + Events: cloneEvents(stored), Grant: minted, }, nil } diff --git a/agent/model_data.go b/agent/model_data.go new file mode 100644 index 0000000..273c55f --- /dev/null +++ b/agent/model_data.go @@ -0,0 +1,890 @@ +package agent + +import ( + "bytes" + "encoding/json" + "fmt" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/memohai/twilight-ai/sdk" +) + +// ProviderMetadata is the agent's persisted representation of provider-owned +// opaque metadata. Each namespace value is detached canonical JSON: callers may +// keep mutating their sdk map, but runtime events/state own these bytes. +type ProviderMetadata map[string]json.RawMessage + +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 json.RawMessage `json:"input,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + 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 json.RawMessage `json:"jsonSchema,omitempty"` +} + +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 json.RawMessage `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]json.RawMessage `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"` +} + +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 json.RawMessage `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) (json.RawMessage, error) { + if raw == nil { + return nil, nil + } + canonical, err := canonicalJSON(raw) + if err != nil { + return nil, err + } + return cloneRaw(canonical), nil +} + +func freezeJSONValue(v any) (json.RawMessage, error) { + if v == nil { + return json.RawMessage("null"), nil + } + if raw, ok := v.(json.RawMessage); ok { + return freezeRawJSON(raw) + } + raw, err := json.Marshal(v) + if err != nil { + return nil, err + } + return freezeRawJSON(raw) +} + +func decodeJSONValue(raw json.RawMessage) (any, error) { + if raw == nil { + return nil, nil + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var out any + if err := dec.Decode(&out); err != nil { + return nil, err + } + return out, nil +} + +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: cloneRaw(d.Parameters), + 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 != nil { + var schema jsonschema.Schema + if err := json.Unmarshal(f.JSONSchema, &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) + } +} + +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: cloneRaw(p.Input), CacheControl: p.CacheControl.SDK(), ProviderMetadata: meta}, nil + case MessagePartTypeToolResult: + return sdk.ToolResultPart{ToolCallID: p.ToolCallID, ToolName: p.ToolName, Result: cloneRaw(p.Result), 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, p := range m.Content { + part, err := p.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 +} + +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]json.RawMessage, 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 +} + +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] = cloneRaw(v) + } + } 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 +} + +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, + }, + } +} + +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} +} + +func freezeToolCallInput(input any) (json.RawMessage, error) { + args, err := canonicalToolArguments(input) + if err == nil { + return cloneRaw(args), nil + } + switch input.(type) { + case string, json.RawMessage: + return cloneRaw(rawToolArguments(input)), nil + default: + return nil, 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: cloneRaw(c.Input), 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 +} + +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 +} + +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/protocol.go b/agent/protocol.go index c022b10..ea5a5ef 100644 --- a/agent/protocol.go +++ b/agent/protocol.go @@ -3,8 +3,6 @@ package agent import ( "errors" "fmt" - - "github.com/memohai/twilight-ai/sdk" ) // SchemaVersion1 is the first published wire schema. Canonical encoding and @@ -109,10 +107,10 @@ func DigestRunSeed(schemaVersion uint16, seed RunSeed) (Digest, error) { return sha256Digest(body), nil } -// DigestRequest covers every field of a frozen sdk.Request with no -// exclusions (spec §2.1 rule 6). -func DigestRequest(req sdk.Request) (Digest, error) { - body, err := encodeEnvelopeBody(currentSchemaVersion, "sdk_request", req) +// DigestRequest covers every field of a frozen ModelRequest with no exclusions +// (spec §2.1 rule 7). +func DigestRequest(req ModelRequest) (Digest, error) { + body, err := encodeEnvelopeBody(currentSchemaVersion, "model_request", req) if err != nil { return "", err } @@ -120,8 +118,8 @@ func DigestRequest(req sdk.Request) (Digest, error) { } // DigestToolDefinition covers one provider-neutral tool definition. -func DigestToolDefinition(def sdk.ToolDefinition) (Digest, error) { - body, err := encodeEnvelopeBody(currentSchemaVersion, "sdk_tool_definition", def) +func DigestToolDefinition(def ToolDefinition) (Digest, error) { + body, err := encodeEnvelopeBody(currentSchemaVersion, "tool_definition", def) if err != nil { return "", err } diff --git a/agent/rebuild.go b/agent/rebuild.go index f86ac2d..4c360ab 100644 --- a/agent/rebuild.go +++ b/agent/rebuild.go @@ -43,7 +43,11 @@ func FoldEvents(initial MachineState, events []AgentEvent) (MachineState, uint64 if e.Digest != wantDigest { return initial, 0, fmt.Errorf("agent: fold: fact digest mismatch at revision %d index %d", e.Revision, e.Index) } - state, err = Evolve(state, e.Fact) + fact, err := snapshotFact(e.Fact) + if err != nil { + return initial, 0, err + } + state, err = Evolve(state, fact) if err != nil { return initial, 0, err } diff --git a/agent/rebuild_test.go b/agent/rebuild_test.go index 0de5f15..7d9c0ca 100644 --- a/agent/rebuild_test.go +++ b/agent/rebuild_test.go @@ -34,8 +34,12 @@ func fullRunRuntime(t *testing.T) *MemoryRuntime { return prep }()) start := mustCommit(t, rt, "start-2", 6, "", StartModelExecution{StepID: currentStepID(t, rt)}) + final, err := FreezeModelResult(sdk.ModelResult{Text: "final", FinishReason: sdk.FinishReasonStop}) + if err != nil { + t.Fatal(err) + } mustCommit(t, rt, "done-2", start.Snapshot.Revision, start.Grant, - SubmitModelResult{StepID: currentStepID(t, rt), Result: sdk.ModelResult{Text: "final", FinishReason: sdk.FinishReasonStop}}) + SubmitModelResult{StepID: currentStepID(t, rt), Result: final}) return rt } @@ -201,7 +205,7 @@ func TestGoldenEventStreamV1(t *testing.T) { t.Fatal(err) } got := string(sha256Digest(stateBytes)) - const frozen = "sha256:8f53eb6380fdcfef8f44befc8aa0f109ae7f4d54f78efbf897edb33ac358fc85" + const frozen = "sha256:004694c1e4ffc3d5ba55bbc833b4cc912943959115836cdc29e1620d063afccb" if got != frozen { t.Fatalf("golden v1 state digest changed:\n got %s\nwant %s\nstate: %s", got, frozen, stateBytes) } diff --git a/agent/regression_test.go b/agent/regression_test.go index 9bf64ce..7eee67d 100644 --- a/agent/regression_test.go +++ b/agent/regression_test.go @@ -101,7 +101,7 @@ func TestRegressionToolStepIDReproducible(t *testing.T) { // silently dropped. func TestRegressionSettleCommitErrorSurfaces(t *testing.T) { spec := toolSpec(t, "echo", DirectExecution) - echo := &fakeTool{ref: "echo", def: spec.Definition, policy: DirectExecution, + echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { // Invalid JSON output makes BuildEnvelope/DigestCommand fail: a // non-sentinel commit error on the settle path. @@ -125,7 +125,7 @@ func TestRegressionSettleCommitErrorSurfaces(t *testing.T) { // process. func TestRegressionToolPanicBecomesUnknown(t *testing.T) { spec := toolSpec(t, "echo", DirectExecution) - echo := &fakeTool{ref: "echo", def: spec.Definition, policy: DirectExecution, + echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { panic("nil map write") }} @@ -279,11 +279,15 @@ func TestRegressionRunFinishedEmitted(t *testing.T) { // through the catalog by Ref end to end. func TestRegressionAliasedToolRefExecutes(t *testing.T) { def := sdk.ToolDefinition{Name: "read", Parameters: json.RawMessage(`{"type":"object"}`)} - d, err := DigestToolDefinition(def) + frozenDef, err := FreezeToolDefinition(def) if err != nil { t.Fatal(err) } - spec := ToolSpec{Ref: "fs.read", Definition: def, DefinitionDigest: d, Policy: DirectExecution} + d, err := DigestToolDefinition(frozenDef) + if err != nil { + t.Fatal(err) + } + spec := ToolSpec{Ref: "fs.read", Definition: frozenDef, DefinitionDigest: d, Policy: DirectExecution} executed := atomic.Bool{} tool := &fakeTool{ref: "fs.read", def: def, policy: DirectExecution, execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { diff --git a/agent/state.go b/agent/state.go index 451c24b..e27fe3c 100644 --- a/agent/state.go +++ b/agent/state.go @@ -4,8 +4,6 @@ import ( "encoding/json" "errors" "fmt" - - "github.com/memohai/twilight-ai/sdk" ) type RunStatus uint8 @@ -36,11 +34,11 @@ type RunFailure struct { } type RunResult struct { - Status RunStatus `json:"status"` - Reason RunReason `json:"reason,omitempty"` - Failure *RunFailure `json:"failure,omitempty"` - Model *sdk.ModelResult `json:"model,omitempty"` - Usage sdk.Usage `json:"usage"` + Status RunStatus `json:"status"` + Reason RunReason `json:"reason,omitempty"` + Failure *RunFailure `json:"failure,omitempty"` + Model *ModelResult `json:"model,omitempty"` + Usage Usage `json:"usage"` } // RunConfig is frozen at Run creation. @@ -101,13 +99,13 @@ type ResponseRequest struct { RequestDigest Digest `json:"requestDigest"` // digest of the request payload } -// ToolSpec is the agent-side sidecar for a provider-neutral sdk.ToolDefinition. +// ToolSpec is the agent-side sidecar for a provider-neutral ToolDefinition. // ResponsePolicy is intentionally kept out of sdk to preserve package layering. type ToolSpec struct { - Ref ToolRef `json:"ref"` - Definition sdk.ToolDefinition `json:"definition"` - DefinitionDigest Digest `json:"definitionDigest"` - Policy ResponsePolicy `json:"policy"` + Ref ToolRef `json:"ref"` + Definition ToolDefinition `json:"definition"` + DefinitionDigest Digest `json:"definitionDigest"` + Policy ResponsePolicy `json:"policy"` } // ToolCallBinding is one frozen call inside ToolStepOpened. @@ -144,7 +142,7 @@ const ( type ModelStep struct { RefValue StepRef `json:"ref"` - Request sdk.Request `json:"request"` + Request ModelRequest `json:"request"` RequestDigest Digest `json:"requestDigest"` Model ModelRef `json:"model"` Tools []ToolSpec `json:"tools,omitempty"` @@ -155,8 +153,8 @@ type ModelStep struct { Rejects int `json:"rejects,omitempty"` } -func (ModelStep) step() {} -func (s ModelStep) Ref() StepRef { return s.RefValue } +func (ModelStep) step() {} +func (s ModelStep) Ref() StepRef { return s.RefValue } type ToolCallStatus uint8 @@ -272,10 +270,10 @@ type MachineState struct { ModelSteps int `json:"modelSteps"` // LastClosedStep is the most recently closed ToolStep; PlanningHint's // SourceStep is read from it at the next boundary. - LastClosedStep StepID `json:"lastClosedStep,omitempty"` - Usage sdk.Usage `json:"usage"` - LastModelResult *sdk.ModelResult `json:"lastModelResult,omitempty"` - Result *RunResult `json:"result,omitempty"` + LastClosedStep StepID `json:"lastClosedStep,omitempty"` + Usage Usage `json:"usage"` + LastModelResult *ModelResult `json:"lastModelResult,omitempty"` + Result *RunResult `json:"result,omitempty"` } // Initialize builds the initial MachineState (Revision 0) for a new Run from @@ -300,10 +298,14 @@ func Initialize(run RunID, cfg RunConfig, seed RunSeed) (MachineState, error) { if seed.Input.ID == "" { return MachineState{}, errors.New("agent: initialize: seed input requires an InputID") } + input, err := snapshotJSONStable(seed.Input) + if err != nil { + return MachineState{}, err + } return MachineState{ RunID: run, Status: RunActive, Config: cfg, - PendingInputs: []AgentInput{seed.Input}, + PendingInputs: []AgentInput{input}, }, nil } diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 7e53fc1..3d48655 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -53,11 +53,13 @@ Durable agent ^ | | state | Effect | v -Request Planner --> sdk.Request --> Agent Loop ------> Model / Tool - application (frozen) | - context | AgentCommand - v - Runtime +Request Planner --> sdk.Request --Freeze*--> Agent Loop --SDK()--> Model / Tool + application (boundary) | + context | AgentCommand carries + | agent.ModelRequest / ModelResult + | (JSON-stable persisted values) + v + Runtime Load + atomic Commit / \ MemoryRuntime MemohRuntime @@ -77,8 +79,8 @@ Loop 与 Runtime 的职责相互独立:Loop 决定如何执行,Runtime 决 ```text twilight-ai/ - sdk/ 一次 LLM request/response - agent/ Machine、Loop、Step、工具 contract、Runtime contract + sdk/ 一次 LLM request/response 的边界/transport 类型 + agent/ Machine、Loop、Step、工具 contract、Runtime contract、JSON-stable persisted model data Memoh native runtime Request Planner、产品 context、durable history、queue、session 和数据库事务 @@ -129,7 +131,7 @@ Step 不是最小的外部操作。ToolStep 保存每个 ToolCall 中会影响 | Loop | 当前进程执行 Run 的算法;它不保存权威状态。 | | MachineState | Machine 的完整语义状态。 | | Step | Run 中的 durable resume boundary。 | -| ModelStep | 一次冻结的 `sdk.Request`,直到接受模型结果。 | +| ModelStep | 一次冻结的 agent-owned `ModelRequest`,直到接受模型结果。 | | ToolStep | 一个模型结果产生的一组 ToolCall 及其 progress。 | | ToolCall | ToolStep 内的一个结构化工具调用。 | | AgentCommand | Loop 或外部入口希望 Machine 接受的意图;接受后构成一次 transition。 | @@ -167,18 +169,23 @@ sdk 只负责 LLM API: | 协议类型 | message、provider-neutral tool definition、tool call、finish reason、usage 和 metadata。 | | stream 归一化 | 将 parts 组装成一次完整 `ModelResult`。 | | provider 错误 | transport、rate limit、malformed stream 和 provider response error。 | -| request snapshot | provider-neutral、可冻结的请求表示。 | +| request/result boundary | provider-neutral 的一次调用输入输出;可由 agent 在 Loop 边界冻结。 | `sdk.Request` 是一次模型调用的完整输入,不是 session、history 或 queue。`sdk.ModelResult` 是一次完整模型响应,保留旧 `GenerateResult` 的单次调用字段(文本、reasoning parts、tool calls、finish reason、usage、sources/files 和 provider metadata),但不包含自动 tool loop、approval 或多次调用累加。多步执行的 steps 和 messages 由 agent/application 另行保存。 -`sdk.Request` 的冻结形态遵守以下规则;`DigestRequest`、StepID 派生和提交幂等都建立在这套规则上: +`sdk` 类型只允许存在于 Planner/ModelInvoker/provider 边界;不得作为 AgentEvent、MachineState、AgentCommand 或 digest input 的持久化形态。Loop 必须在提交前把 `sdk.Request`、`sdk.ModelResult` 和 `sdk.ToolDefinition` 分别转换成 agent-owned 的 `ModelRequest`、`ModelResult` 和 `ToolDefinition`;调用 provider/tool 前再由这些 frozen value 构造新的 `sdk` 值。 + +agent-owned 冻结形态遵守以下规则;`FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`、`DigestRequest`、StepID 派生和提交幂等都建立在这套规则上: -1. Request 是纯数据。它不包含 provider client、接口值、回调或 `Execute` 句柄;模型以 provider 作用域内的字符串 ID 表示,provider 绑定发生在 `ModelCatalog`/`ModelInvoker` 解析时。 -2. 工具以 provider-neutral 的 `ToolDefinition{Name, Description, Parameters}` 表示;`Parameters` 是解析完成的 JSON Schema 文档。由 Go struct 推导 schema 的工作在冻结前完成,冻结后的 Request 不依赖推导或反射。 +1. 冻结值是纯数据。它不包含 provider client、接口值、回调或 `Execute` 句柄;模型以 provider 作用域内的字符串 ID 表示,provider 绑定发生在 `ModelCatalog`/`ModelInvoker` 解析时。 +2. 工具以 provider-neutral 的 `ToolDefinition{Name, Description, Parameters}` 表示;`Parameters` 是解析完成并 canonicalized 的 JSON Schema 文档。由 Go struct 推导 schema 的工作在冻结前完成,冻结后的 Request 不依赖推导或反射。 3. `ToolChoice` 是封闭类型 `{Mode: auto|none|required|tool, Tool string}`,不使用 `any`。 -4. 消息中的二进制内容有两种形式:inline bytes(canonical 编码为 base64),或稳定的内容寻址引用 `BlobRef{Digest, MediaType, ByteSize}`。`BlobRef` 的字节解析由组装 `ModelInvoker` 的一方负责;带时效的 URL 等不稳定引用不能进入冻结请求。两种形式产生不同的 digest,Planner 对同一内容必须确定性地选择一种形式。 -5. provider metadata 等扩展字段的值必须是 JSON 值。canonical 编码采用 RFC 8785(JCS):对象键按 UTF-16 码元排序,数字使用最短表示,`json.RawMessage` 先按 JCS 重新序列化。 -6. `DigestRequest` 覆盖 Request 的全部字段,不设排除项。cache 配置等只影响成本的字段同样参与摘要;排除任何字段都会把不同请求判成同一事件,产生错误的 `CommitAlreadyApplied`。 +4. 消息 part 是 sealed union 的 agent value(text/reasoning/image/file/tool-call/tool-result),不持久化 `sdk.MessagePart` interface。 +5. 消息中的二进制内容有两种形式:inline bytes(canonical 编码为 base64),或稳定的内容寻址引用 `BlobRef{Digest, MediaType, ByteSize}`。`BlobRef` 的字节解析由组装 `ModelInvoker` 的一方负责;带时效的 URL 等不稳定引用不能进入冻结请求。两种形式产生不同的 digest,Planner 对同一内容必须确定性地选择一种形式。 +6. provider metadata、provider options、tool input/result、response format schema 等扩展字段的值必须是 JSON 值。进入 agent 前必须 canonicalize 并 detach 为 agent-owned `json.RawMessage`;不能保存 caller-owned map/slice/RawMessage 或 `any`。canonicalization 必须拒绝会把不同 payload 折叠成同一值的输入,包括重复 object key、trailing data、invalid UTF-8 和 escaped lone surrogate(`\ud800`..`\udfff`)。 +7. `DigestRequest` 覆盖 frozen `ModelRequest` 的全部字段,不设排除项。cache 配置等只影响成本的字段同样参与摘要;排除任何字段都会把不同请求判成同一事件,产生错误的 `CommitAlreadyApplied`。 + +边界的 SDK 类型可以保持以下形态;agent 的持久化 `ModelRequest`/`ToolDefinition` 是相同语义的 concrete mirror,字段中所有接口/any/JSON 原文先在 `Freeze*` 中 canonicalize。 ```go package sdk @@ -230,6 +237,8 @@ type BlobRef struct { provider transport 的短暂失败可以由 sdk/provider client 在一次调用内部重试;agent 只看到最终的 `ModelResult` 或 provider error。重试次数和退避属于 sdk/provider 配置。 +新的单次调用入口是 `sdk.Generate(ctx, model, Request)` / `(*Model).Generate(ctx, Request)` 和对应的 `Stream` / `(*Model).Stream`,返回 `ModelResult` / `ModelStream`,不执行工具、不累计多步状态。provider 可以选择实现 additive `ModelInvoker` / `StreamingModelInvoker`;未实现时 SDK 通过兼容 adapter 调用旧 `Provider.DoGenerate` / `DoStream`。 + 旧的 `GenerateText`、`StreamText` 和自动 tool loop 在迁移期只能作为显式 legacy wrapper;新 Loop 不依赖它们。 ### 2.2 `agent/` @@ -250,6 +259,18 @@ agent 提供通用 core: agent 不拥有 Memoh 的 session、queue schema、admission、owner、fencing、R0/R1 或数据库类型。它也不组装产品 prompt,不执行 scheduler,不保存产品 memory。 +#### 2.2.1 本轮修改的 rationale:持久化类型必须由 agent 拥有 + +Runtime 的 authority boundary 不能靠“调用者不要修改快照”这类规范约束来成立,必须由类型和提交路径保证。旧设计把 `sdk.Request`、`sdk.ModelResult`、`sdk.ToolDefinition`、`sdk.Usage` 及其内部的 `map`、`slice`、`json.RawMessage`、`any`、interface value 直接或浅拷贝地放进 command/fact/state,会产生三类破坏: + +1. **aliasing/mutation**:Planner、provider 或测试代码在 `Commit` 后继续修改 SDK 对象,可能改变已返回 snapshot、已保存事件或后续 `Evolve` 输入,使 `state = fold(events)` 不再成立。 +2. **digest/replay nondeterminism**:digest 在提交时按一组字节计算,但持久化对象仍引用 caller-owned map/slice/RawMessage;之后对象变化会让相同 `CommandID` 的重放、`CommitAlreadyApplied` 判断、StepID 派生和 replay 校验失真。 +3. **unbounded SDK surface**:`sdk.MessagePart` interface、provider metadata 的 `map[string]any`、tool/schema 的原始 JSON 等属于 provider/transport 边界数据,不能成为 Machine 协议的永久 wire contract。 + +因此 agent 必须定义自己的 JSON-stable persisted value:`ModelRequest`、`ModelResult`、`ToolDefinition`、`Usage`、`Message`、`MessagePart`、`ProviderMetadata` 等。Loop/provider 边界仍使用 `sdk.Request`/`sdk.ModelResult`;进入 Runtime 前必须调用 `FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`,把动态 JSON canonicalize 并复制为 agent-owned bytes;离开 Runtime 调 provider 时通过 `.SDK()` 构造新的 SDK 值。`DigestRequest`/`DigestToolDefinition` 只接受 frozen agent value。`EvaluateCommit` 和 Runtime 实现保存 facts/state 前还必须 snapshot 自己拥有的 value,`MemoryRuntime.Commit` 以已保存事件重新 fold 出 authority state,而不是信任 caller-provided `decision.NewState`。 + +这不是防御性 deep clone 的局部补丁,而是分层边界:`sdk` 是一次调用的 transport API,`agent` 是可重放、可审计、可长期兼容的事件协议。任何新增进入 AgentEvent/MachineState 的 provider 数据,必须先落到 agent-owned sealed/JSON-stable 类型或 `json.RawMessage` canonical JSON;不得把新的 SDK interface、`any` 或可变引用跨过 Runtime authority boundary。 + ### 2.3 Memoh native runtime Memoh 负责产品和 durable 层: @@ -257,7 +278,7 @@ Memoh 负责产品和 durable 层: | 能力 | 内容 | | --- | --- | | context | system prompt、memory、compaction、attachments、workspace 和产品 metadata。 | -| Request Planner | 把 context、history 和 queue-safe 输入投影成冻结的 `sdk.Request`。 | +| Request Planner | 把 context、history 和 queue-safe 输入投影成边界 `sdk.Request`;Loop 冻结成 agent `ModelRequest`。 | | durable history | messages、ModelStep record、ToolStep progress 和 tool result record。 | | queue | steer/follow-up 的入队、accepted order、重排、claim、apply、取消和 admission。 | | session/run | admission、R0/R1、settled 和后续 Run。 | @@ -269,7 +290,8 @@ Memoh 负责产品和 durable 层: | 类型或能力 | 所属层 | | --- | --- | -| `sdk.Request`、`sdk.ModelResult`、`sdk.ToolDefinition`(provider-neutral) | sdk | +| `sdk.Request`、`sdk.ModelResult`、`sdk.ToolDefinition`(provider-neutral 边界类型) | sdk | +| `ModelRequest`、`ModelResult`、`ToolDefinition`、`Usage`、`Message`、`MessagePart`(persisted frozen values) | agent | | `Step`、`ToolCallState`、`AgentCommand`、`RunSeed`、`AgentEvent`、`Effect` | agent | | `ExecutableTool`、参数校验、response policy | agent | | `RequestPlanner` port、`PlanningHint`、`RequestPlan` | agent;只用于依赖注入 | @@ -317,7 +339,7 @@ NextRun(input) `NextRun` 产生的是 admission seed(`RunSeed`),不是任何 Run 的 command:它不创建 RunID,也不负责 queue claim 或 session admission。Memoh 先完成 queue claim、session admission 和新 Run 的身份分配,再用该 seed 通过 `Initialize` 建立新 Run 的 MachineState。admission 可以复用 `AgentEvent` 的 identity/digest/canonical 编码规则,但其排序和记录归属由 Memoh admission 决定,不调用旧 Run 的 `Runtime.Commit`。 -Machine 不在 ToolStep 执行中接受 `NextStep`,也不把 `NextRun` 解释成旧 Run 的状态变化。输入的具体文本如何进入 `sdk.Request` 仍由 Request Planner 决定;Machine 只保证输入边界和一次性接受语义。 +Machine 不在 ToolStep 执行中接受 `NextStep`,也不把 `NextRun` 解释成旧 Run 的状态变化。输入的具体文本如何进入 Planner 生成的 `sdk.Request` 仍由 Request Planner 决定;Loop 在提交前冻结为 agent `ModelRequest`,Machine 只保证输入边界和一次性接受语义。 ### 3.3 MachineState @@ -332,8 +354,8 @@ ToolStep 中每个 ToolCall 的 progress/result 等待中的 ResponseRequest 已接受但尚未用于冻结下一请求的 AgentInput model-step counter -累计 usage(对已接受 ModelStepCompleted 和 ModelStepRejected 的 sdk.Usage 逐字段求和) -最近一次已接受的 sdk.ModelResult +累计 usage(对已接受 ModelStepCompleted 和 ModelStepRejected 的 agent Usage 逐字段求和) +最近一次已接受的 agent ModelResult terminal RunResult(如果已结束) ``` @@ -453,7 +475,7 @@ WaitForExecutionRecovery `Decide(state, command)` 按下表校验前置条件并产出事实序列。任何前置条件不满足即拒绝整个 command,不产出部分事实: -1. `PrepareModelRequest` 只能在 Run active 且没有当前 Step 时接受;其 `InputIDs` 必须按 `PendingInputs` 的当前顺序完整匹配。`Tools` 必须与 `sdk.Request` 中的 provider tool definitions 按 Ref、顺序和 definition digest 一一对应,`ToolsDigest` 覆盖 Ref、schema、顺序与 policy。产出 `[ModelStepPrepared]`,事实中携带冻结的请求、ToolSpec、被消费的 InputIDs 与 Decide 算好的 step binding digest(事实自包含条件:Evolve 不重算 digest)。 +1. `PrepareModelRequest` 只能在 Run active 且没有当前 Step 时接受;其 `InputIDs` 必须按 `PendingInputs` 的当前顺序完整匹配。`Request` 必须已经是 frozen agent `ModelRequest`,且 `Request.Model` 必须等于 command/RunConfig 的 `ModelRef`;`Tools` 必须与其中的 provider tool definitions 按 Ref、顺序和 definition digest 一一对应,`ToolsDigest` 覆盖 Ref、schema、顺序与 policy。产出 `[ModelStepPrepared]`,事实中携带冻结的请求、ToolSpec、被消费的 InputIDs 与 Decide 算好的 step binding digest(事实自包含条件:Evolve 不重算 digest)。 2. `StartModelExecution` 只能作用于 Prepared ModelStep,产出 `[ModelStepStarted]`。`RecoverModelExecution` 只能作用于没有已接受结果的 Executing ModelStep,产出 `[ModelStepRecovered]`;它只能由持有该 Model grant 的当前 Loop,或由 Runtime 自己确认 lease 失效后的 recovery 逻辑提交,普通 response ingress 不能提交。 3. `SubmitModelResult` 只能作用于对应的 Executing ModelStep。结果没有 tool calls 时产出 `[ModelStepCompleted, RunEnded{RunCompleted}]`;有 tool calls 时按冻结 `ToolSpec` 绑定 policy 和 binding digest,产出 `[ModelStepCompleted, ToolStepOpened]`——`ToolStepOpened` 携带完整 Call 集合:DirectExecution 的 Call 为 Pending,ApprovalRequired/ExternalResponse 的 Call 为带稳定 request 的 Waiting,每个 Waiting request 都包含目标 RunID、StepID、CallID、ResponseID、Kind 和 RequestDigest。 4. `SubmitModelFailure` 只能作用于对应的 Executing ModelStep,产出 `[RunEnded{RunFailed, provider_failure}]`,保留稳定失败原因。 @@ -465,7 +487,7 @@ WaitForExecutionRecovery 10. 一次响应只推进对应 Call,不能修改其他 Call;ResponseID 和 kind 必须匹配该 Call 保存的请求。响应 payload 的 digest 用于内容冲突检测,不需要等于请求 payload 的 digest。 11. 使 ToolStep 内最后一个 Call 到达可关闭终态的 command,其事实序列追加 `ToolStepClosed`;若此时 `ModelSteps` 已达冻结的 model-step limit,再追加 `RunEnded{RunStopped, step_limit}`。例如最后一个 Call 完成且触发 limit:`[ToolCallCompleted, ToolStepClosed, RunEnded{RunStopped, step_limit}]`。ToolStep 关闭前不能创建下一 ModelStep。 12. `CancelRun` 只能作用于非 terminal Run,产出 `[RunEnded{RunStopped, cancelled}]`。 -13. `AcceptInput` 只能作用于 active 且没有当前 Step 的 Run,产出 `[InputAccepted]`。Planner 由 `PlanningHint.Inputs` 收到这些输入并在 `RequestPlan.InputIDs` 中明确消费它们;遗漏或伪造 ID 的 `PrepareModelRequest` 被拒绝。 +13. `AcceptInput` 只能作用于 active 且没有当前 Step 的 Run,且 `InputID` 不得与当前 `PendingInputs` 中任何输入(包括 `RunSeed` 带入的 seed input)重复;重复 ID 是 identity conflict,不能记录一个 Evolve 会丢弃的 no-op fact。接受后产出 `[InputAccepted]`。Planner 由 `PlanningHint.Inputs` 收到这些输入并在 `RequestPlan.InputIDs` 中明确消费它们;遗漏或伪造 ID 的 `PrepareModelRequest` 被拒绝。 14. `Initialize(RunID, RunConfig, RunSeed)` 只在 application admission 创建新 Run 时使用;它建立初始 `MachineState` 并把 seed 输入放入 `PendingInputs`。`RunSeed` 不能传给已有 Run 的 `Decide` 或 `Runtime.Commit`。 `InputID` 在一个 Run 内唯一。相同 `InputID` 和相同 payload 的重复 `AcceptInput` 是语义 no-op,Runtime 返回原已接受的事件组;相同 ID 携带不同 payload 返回冲突。Memoh 的 queue claim 仍负责防止同一个 queue item 被多个输入入口同时消费。 @@ -476,7 +498,7 @@ WaitForExecutionRecovery | 事实 | 折叠 | | --- | --- | -| ModelStepPrepared | 设置 Current 为 Prepared ModelStep(StepRef.Digest 取事实携带的 BindingDigest);`ModelSteps+1`;按事实中的 InputIDs 从 `PendingInputs` 移除 | +| ModelStepPrepared | 若 Current 非空则拒绝折叠(损坏日志不能覆盖活跃 Step);否则设置 Current 为 Prepared ModelStep(StepRef.Digest 取事实携带的 BindingDigest);`ModelSteps+1`;按事实中的 InputIDs 从 `PendingInputs` 移除 | | ModelStepStarted | Current.Status = Executing | | ModelStepRecovered | Current.Status = Prepared | | ModelStepRejected | `Rejects+1`;Current.Status = Prepared;Usage 逐字段累加事实携带的 usage | @@ -511,7 +533,7 @@ ModelStep 冻结: ```text StepID -sdk.Request 及其 digest +agent.ModelRequest 及其 digest ModelRef 本次请求使用的 provider-neutral tool definitions 及 digest 与这些 definition 对应的 agent `ToolSpec`(包含 response policy) @@ -522,11 +544,11 @@ reject counter(progress,不参与冻结 digest) `SubmitModelResult` 被接受后,当前 ModelStep 立即被 ToolStep 替换(`ToolStepOpened`),或因没有 tool calls 而关闭 Run(`RunEnded`);因此模型完成状态不作为当前 Step 状态保存。接受的结果保存在 `LastModelResult`、RunResult 和 application history 中。 -一个 ModelStep 代表一次模型调用。Loop 默认调用 `ModelInvoker.Generate`;如果实现提供可选的 `StreamingModelInvoker`,Loop 可以用 `Stream` 发送实时 delta,但两条路径必须得到同一种 `sdk.ModelResult`,且 transport retry 不创建新的 Step。 +一个 ModelStep 代表一次模型调用。Loop 默认调用 `ModelInvoker.Generate`;如果实现提供可选的 `StreamingModelInvoker`,Loop 可以用 `Stream` 发送实时 delta,但两条路径必须得到同一种边界 `sdk.ModelResult`,并在提交前冻结为 agent `ModelResult`;transport retry 不创建新的 Step。 -Request Planner 生成完整 `sdk.Request` 后,Loop 提交 `PrepareModelRequest`。Runtime 以 revision/CAS 或事务保证只冻结一份请求;新 ModelStep 的 StepID 由 RunID、command identity 和 request digest 稳定派生。已经冻结的请求不受后来 queue 或 history 输入影响。 +Request Planner 生成完整 `sdk.Request` 后,Loop 先调用 `FreezeModelRequest` 得到 agent `ModelRequest`,再提交 `PrepareModelRequest`。Runtime 以 revision/CAS 或事务保证只接受一份冻结请求;新 ModelStep 的 StepID 由 RunID、command identity 和 frozen request digest 稳定派生。已经冻结的请求不受后来 queue、history 输入或 Planner 持有的 SDK 对象 mutation 影响。 -Loop 在 `SubmitModelResult` 时只校验 tool-call ID 与顺序,并从匹配的冻结 `ToolSpec` 生成 `ToolCallBinding`。这里不调用 ExecutableTool;未知工具保留为 `DirectExecution`、空 definition digest 的 unresolved binding,应用级参数错误留到 `StartToolCalls`,作为 Pending Call 的已知失败处理。Runtime 只校验 binding 与冻结请求、模型结果和 Step 身份的一致性,不重复解析工具目录。 +Loop 在 `SubmitModelResult` 时只校验 tool-call ID 与顺序,并从匹配的冻结 `ToolSpec` 生成 `ToolCallBinding`。这里不调用 ExecutableTool;未知工具保留为 `DirectExecution`、空 definition digest 的 unresolved binding,应用级参数错误留到 `StartToolCalls`,作为 Pending Call 的已知失败处理。Runtime 只校验 binding 与冻结请求、冻结模型结果和 Step 身份的一致性,不重复解析工具目录。 模型响应的结构性 malformed(重复/错序 CallID、违反 provider 协议)使 Call 集合无法建立:Loop 不提交 `SubmitModelResult`,而以 start grant 提交 `RejectModelResult{Failure.Class: malformed_model_result}`。Decide 产出 `ModelStepRejected`(usage 累计、`Rejects` 加一、Step 回到 Prepared),同一冻结 request 由后续 start 重试;`Rejects` 超过冻结的 `RunConfig.ModelRejectLimit` 时追加 `RunEnded{RunFailed, malformed_model_result}`。被拒绝的结果不写入 `LastModelResult`,不创建 ToolStep。 @@ -660,6 +682,9 @@ AgentEvent log 是 Run 语义状态的 source of truth;MachineState 是必需 1. Event ontology 稳定:Fact 词表 sealed,已发布 SchemaVersion 的事实结构永不修改,新增字段进入新版本。 2. Evolve 语义稳定:折叠是机械的(不读 RunConfig、无 policy 分支),已发布版本的折叠语义与事件编码一起永久冻结;会演进的决策语义全部在 Decide,其结果记录为事实。 3. 事实自包含:折叠一条事实所需的全部信息在事实自身与折叠前状态之内,不访问外部系统,不重新计算依赖当前代码版本的派生值(digest 一律在 Decide 时算好并携带在事实中)。 +4. 持久化值归属稳定:AgentEvent 和 MachineState 只保存 agent-owned frozen values;任何来自 SDK/provider/application 的引用在进入 Runtime 前必须被 canonicalize + detach,返回给 caller 的 snapshot/event 也必须是独立副本。 + +Runtime 实现还必须在代码层面维护这些条件:`EvaluateCommit` 对 Decide 产出的 facts 做 `snapshotFact` 后再 fold/persist;`Load`、`CommitResult` 和 AlreadyApplied replay 返回的 snapshot/event 不得共享 authority 内部引用;MemoryRuntime 这类参考实现保存 accepted event group 后,从 stored events fold 出新的 authority state,而不是直接保存调用栈里算出的 `decision.NewState`。durable adapter 可以用数据库事务替代 mutex,但不能把未冻结 SDK 对象、浅拷贝 snapshot 或 caller-owned bytes 写入事件表/状态表。 每 Run 维护一个不可丢弃的 revision 水位(watermark):每次提交与日志同步推进的单调计数,语义为"日志至少完整到此"。它是日志尾部完整性的末端见证——append-only 日志可以用 (Revision, Index) 连续性检测中间缺洞,但缺尾的日志与更短的完整日志无法区分。水位是控制平面数据,不进入 MachineState,重建不清除它。 @@ -756,8 +781,8 @@ func EncodeFact(schemaVersion uint16, typ string, fact Fact) ([]byte, error) func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) func EncodeRunSeed(RunSeed) ([]byte, error) func DigestRunSeed(schemaVersion uint16, seed RunSeed) (Digest, error) -func DigestRequest(sdk.Request) (Digest, error) -func DigestToolDefinition(sdk.ToolDefinition) (Digest, error) +func DigestRequest(ModelRequest) (Digest, error) +func DigestToolDefinition(ToolDefinition) (Digest, error) func DigestToolSpec(ToolSpec) (Digest, error) func DigestToolSpecs([]ToolSpec) (Digest, error) func DigestModelStepBinding(model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) @@ -820,7 +845,7 @@ func EvaluateCommit( 2. 已有相同 `(RunID, CommandID)` 且 digest 相同,返回 `AlreadyApplied` 与原事件组,不重复写入任何 projection 或 outbox,不重新运行 Decide(决策在首次接受时冻结)。 3. 相同 identity 携带不同 digest,返回 `ErrCommandConflict`。 对 `AcceptInput`,还按 RunID/InputID 检查已接受索引:相同 payload 返回原事件组和 `CommitAlreadyApplied`,不同 payload 返回冲突,不产生第二条输入事实。 -4. 校验 BaseRevision、当前 Step、CallID 和 Grant。BaseRevision 只对 `PrepareModelRequest` 是硬校验——其 CommandID 由 Revision 派生,Revision 即它的并发控制,过期即返回 `ErrStaleRuntime`。其余 command 在 BaseRevision 过期时按类别前置条件基于当前状态重新评估:start(`StartModelExecution` 目标仍须为同一 Prepared ModelStep;`StartToolCall` 与 Pending 的已知失败目标 Call 仍须为 Pending,均空 Grant);owner 完成(Executing Call 的完成/失败、`SubmitModelResult`/`SubmitModelFailure`/`RejectModelResult`,以及持有效 Model grant 的 `RecoverModelExecution`,以提交者仍持有对应有效 Grant 为条件);system recovery(无 grant 的 `RecoverModelExecution` 和 scanner 的 Unknown,以 Runtime 自己的 recovery record 为条件);ingress(approval/external response 目标 Call 仍须为对应 Waiting 且 ResponseID/kind 匹配;`AcceptInput` 要求 Run 仍 active 且没有当前 Step,连续多条输入互不拒绝;均空 Grant);run-control(`CancelRun` 只要求 Run 非 terminal)。前置条件不满足时按具体原因返回 stale/terminal/冲突。start command 建立 grant/lease 的动作与状态提交属于同一个原子操作。 +4. 校验 BaseRevision、当前 Step、CallID 和 Grant。BaseRevision 只对 `PrepareModelRequest` 是硬校验——其 CommandID 由 Revision 派生,StepID 由该 CommandID 与 Decide 得到的 binding digest 派生,Revision 即它的并发控制,过期即返回 `ErrStaleRuntime`。其余 command 在 BaseRevision 过期时按类别前置条件基于当前状态重新评估:start(`StartModelExecution` 目标仍须为同一 Prepared ModelStep;`StartToolCall` 与 Pending 的已知失败目标 Call 仍须为 Pending,均空 Grant);owner 完成(Executing Call 的完成/失败、`SubmitModelResult`/`SubmitModelFailure`/`RejectModelResult`,以及持有效 Model grant 的 `RecoverModelExecution`,以提交者仍持有对应有效 Grant 为条件);system recovery(无 grant 的 `RecoverModelExecution` 和 scanner 的 Unknown,以 Runtime 自己的 recovery record 为条件);ingress(approval/external response 目标 Call 仍须为对应 Waiting 且 ResponseID/kind 匹配;`AcceptInput` 要求 Run 仍 active 且没有当前 Step,连续多条输入互不拒绝;均空 Grant);run-control(`CancelRun` 只要求 Run 非 terminal)。前置条件不满足时按具体原因返回 stale/terminal/冲突。start command 建立 grant/lease 的动作与状态提交属于同一个原子操作。 5. 调用 `Machine.Decide` 产出事实序列,逐个 `Machine.Evolve` 折叠出新状态;为这次 transition 分配 `Revision = curRevision + 1`,事实按序获得 `Index = 0..k-1`,全部携带产生它们的 CommandID。 6. Runtime 在自己的原子边界内保存 MachineState、AgentEvent 组及需要一致的 Memoh projection。 7. 非重复 command 若目标 Run 已经 terminal,返回 `ErrRunTerminal`;迟到的 worker 结果不会重新打开 Run。其他提交成功后返回新 snapshot。 @@ -859,6 +884,9 @@ Digest 普通 CommandID 由产生意图的执行者生成,重试时必须复用。 +model request CommandID + `PrepareModelRequest` 的 CommandID 由 RunID 和 BaseRevision 稳定派生;StepID 由 RunID、该 CommandID 和 frozen request/tool binding digest 派生。相同 revision 的并发 planner 因此竞争同一个 identity:内容相同 replay,内容不同 conflict。 + 每个 AgentCommand 都有自己的 CommandID;同一个 execution attempt 的 start、completion 和 recovery 是不同 command,因此各自使用不同 ID。某个 command 因响应丢失而重放时必须复用原 ID。Runtime 以当前 Step/Call 状态和 CAS 防止第二个 attempt 获得执行权;ModelStep 经 recovery 回到 Prepared 后,下一次 start 使用新的 ID。 `RecoverModelExecution` 有两条合法来源:当前 Loop 持有效 Model grant 且模型调用未产生可接受结果时,使用该 grant 主动释放 execution;或者 Runtime 在 lease 失效后依据自己的 recovery record 提交。后者的 CommandID 由 Runtime 根据 recovery record 生成;同一个 recovery record 重放使用同一个 ID,不同的失效执行使用不同 ID。新的 Loop 随后重新提交同一冻结 request 的 start command。 @@ -879,7 +907,7 @@ AgentEvent 身份 ``` ``` -canonical 编码和 digest 函数由 agent 提供;Memoh 只保存和比较结果,不重新实现排序或编码。编码必须包含 sealed command/fact discriminator、按声明顺序编码有序 slice、对 map key 排序,并对 `json.RawMessage` 使用 canonical JSON;不把 `Digest`、BaseRevision、Revision、Index 或 ExecutionGrant 编入 digest。 +canonical 编码和 digest 函数由 agent 提供;Memoh 只保存和比较结果,不重新实现排序或编码。编码必须包含 sealed command/fact discriminator、按声明顺序编码有序 slice、对 map key 排序,并对 `json.RawMessage` 使用 canonical JSON;重复 object key、trailing data、invalid UTF-8、escaped lone surrogate 一律拒绝,不能让 `encoding/json` 的 replacement behavior 把不同输入合并为同一 digest;不把 `Digest`、BaseRevision、Revision、Index 或 ExecutionGrant 编入 digest。 已发布 `SchemaVersion` 的 canonical 编码和 digest 规则永久冻结;字段增删只能进入新的 SchemaVersion,旧事件按其自带版本校验。同一个 Run 不允许由写入不同 SchemaVersion 的进程混跑:升级窗口内先全量部署可读写新版本的代码,再开始写入新版本;否则同一 command 的重放会因编码不同被误判为 `ErrCommandConflict`。 @@ -899,9 +927,9 @@ type RequestPlanner interface { type RequestPlan struct { Model ModelRef - Request sdk.Request + Request sdk.Request // boundary value; Loop freezes to ModelRequest before PrepareModelRequest InputIDs []InputID - Tools []ToolSpec + Tools []ToolSpec // agent frozen ToolSpec/ToolDefinition sidecars PlanningToken PlanningToken // application-owned freshness token } @@ -913,7 +941,7 @@ type PlanningHint struct { } ``` -Planner 可以读取 application 自己的 history、memory、workspace 和 queue-safe 输入,但不直接修改 Runtime。它返回 `RequestPlan`(完整 `sdk.Request`、ModelRef、已消费的 InputID 集合和 application-owned `PlanningToken`);Loop 随后提交 `PrepareModelRequest`。Memoh 在事务外构造请求,在提交时用自己的 context revision/CAS 和 create-if-absent 确保并发 Planner 只冻结一份结果。`PlanningToken` 只是供 adapter 验证 planner 输入是否新鲜的 opaque token;agent 不解释其内容,也不把它当成 authority revision。 +Planner 可以读取 application 自己的 history、memory、workspace 和 queue-safe 输入,但不直接修改 Runtime。它返回 `RequestPlan`(完整边界 `sdk.Request`、ModelRef、已消费的 InputID 集合和 application-owned `PlanningToken`);Loop 先 freeze 成 agent `ModelRequest`,随后提交 `PrepareModelRequest`。Memoh 在事务外构造请求,在提交时用自己的 context revision/CAS 和 create-if-absent 确保并发 Planner 只冻结一份结果。`PlanningToken` 只是供 adapter 验证 planner 输入是否新鲜的 opaque token;agent 不解释其内容,也不把它当成 authority revision。 Planner 所需的 history 必须由宿主提供:Memoh 从 durable history projection 读取;in-process 调用者可以用一个简单的内存 history projection 或 planner 自己持有的会话上下文。Runtime @@ -921,7 +949,7 @@ Planner 所需的 history 必须由宿主提供:Memoh 从 durable history proj `PrepareModelRequest` 必须使用 Planner 开始前 Load 得到的 BaseRevision,并按当前顺序携带完整的 `PendingInputs` `InputIDs` 集合。相同 RunID 和 Revision 的并发 Planner 使用同一个 `DeriveModelRequestCommandID`:相同请求得到 `CommitAlreadyApplied`,不同请求得到 `ErrCommandConflict`;如果 Planner 在新的 Revision 上重试,则生成新的 CommandID。Memoh adapter 同时检查 `PlanningToken` 的 context revision,后到者不能覆盖已经冻结的请求。 -Loop 校验 `RequestPlan.Model` 与 `PlanningHint.Model`、RunConfig.Model 一致;Runtime 通过共享 Decide 规则再次校验 `PrepareModelRequest.Model` 与冻结的 RunConfig 一致。Planner 的 context 一致性由 Memoh 在自己的 queue-safe admission/planning 边界保证,不由 agent 解释。 +Loop 校验 `RequestPlan.Model` 与 `PlanningHint.Model`、RunConfig.Model 以及 frozen `ModelRequest.Model` 一致;Runtime 通过共享 Decide 规则再次校验 `PrepareModelRequest.Model`、`PrepareModelRequest.Request.Model` 与冻结的 RunConfig 一致。Planner 的 context 一致性由 Memoh 在自己的 queue-safe admission/planning 边界保证,不由 agent 解释。 ### 5.7 外部 response @@ -996,7 +1024,10 @@ Loop.Run(ctx, runtime, events): plan, err := l.Planner.Plan(ctx, hint) if err != nil: return err - requestDigest, err := DigestRequest(plan.Request) + frozenRequest, err := FreezeModelRequest(plan.Request) + if err != nil: + return err + requestDigest, err := DigestRequest(frozenRequest) if err != nil: return err toolsDigest, err := DigestToolSpecs(plan.Tools) @@ -1009,7 +1040,7 @@ Loop.Run(ctx, runtime, events): stepID := DeriveModelStepID(snapshot.State.RunID, commandID, bindingDigest) prepared, err := commit( PrepareModelRequest{ - StepID: stepID, Model: plan.Model, Request: plan.Request, + StepID: stepID, Model: plan.Model, Request: frozenRequest, RequestDigest: requestDigest, InputIDs: plan.InputIDs, PlanningToken: plan.PlanningToken, Tools: plan.Tools, ToolsDigest: toolsDigest}, @@ -1038,7 +1069,10 @@ Loop.Run(ctx, runtime, events): if err != nil: completion = SubmitModelFailure{StepID: stepID, Failure: StepFailureForModel(err)} else: - modelResult, invokeErr := invokeModel(invoker, workerCtx, modelStep.Request, l.Streaming, events) + sdkRequest, err := modelStep.Request.SDK() + if err != nil: + return err + modelResult, invokeErr := invokeModel(invoker, workerCtx, sdkRequest, l.Streaming, events) if invokeErr != nil and worker context was cancelled: completion = RecoverModelExecution{StepID: stepID} else if invokeErr != nil: @@ -1046,10 +1080,13 @@ Loop.Run(ctx, runtime, events): else: bindings, bindErr := bindToolCalls(modelResult, modelStep.Request, modelStep.Tools) if bindErr != nil: - completion = RejectModelResult{StepID: stepID, Usage: modelResult.Usage, + completion = RejectModelResult{StepID: stepID, Usage: UsageFromSDK(modelResult.Usage), Failure: StepFailure{Class: FailureMalformedModel, Message: bindErr.Error()}} else: - completion = SubmitModelResult{StepID: stepID, Result: modelResult, Calls: bindings} + frozenResult, err := FreezeModelResult(modelResult) + if err != nil: + return err + completion = SubmitModelResult{StepID: stepID, Result: frozenResult, Calls: bindings} applied, err := commit(completion, commandID=completionID, baseRevision=start.Snapshot.Revision, grant=start.Grant) if err == ErrStaleRuntime or err == ErrRunTerminal: @@ -1068,7 +1105,8 @@ Loop.Run(ctx, runtime, events): policyErr := nil definitionErr := nil if resolveErr == nil: - if tool.Ref() != call.ToolRef or DigestToolDefinition(tool.Definition()) != call.DefinitionDigest: + frozenDefinition, freezeErr := FreezeToolDefinition(tool.Definition()) + if freezeErr != nil or tool.Ref() != call.ToolRef or DigestToolDefinition(frozenDefinition) != call.DefinitionDigest: definitionErr = definitionMismatch argErr = tool.ValidateArguments(call.Arguments) if tool.ResponsePolicy() != call.Policy: @@ -1135,7 +1173,7 @@ Loop.Run(ctx, runtime, events): `ToolFailureFor` 将 resolve error 映射为 `tool_lookup_failed`,参数校验 error 映射为 `invalid_arguments`,tool ref、response policy 或 definition digest 不匹配映射为 `tool_definition_mismatch`。 `invokeModel` 是说明性 Loop helper:开启 streaming 且 invoker 支持 `StreamingModelInvoker` 时消费 stream -并向 EventSink 发送 delta,否则调用 `Generate`;两条路径都只返回一个完整 `sdk.ModelResult`。 +并向 EventSink 发送 delta,否则调用 `Generate`;两条路径都只返回一个完整边界 `sdk.ModelResult`,Loop 在提交前用 `FreezeModelResult` 转为 agent `ModelResult`。 所有 completion commit 都遵循同一错误处理:`ErrStaleRuntime`/`ErrRunTerminal` 只触发重新 `Load` 并丢弃迟到结果,其他错误返回给调用方;只有 `CommitAccepted` 或相同 command 的 `CommitAlreadyApplied` 才表示该事实已被 authority 接受。接受后 Loop 可以把 `CommitResult.Events` 逐个包装为 `Event{Kind: EventAgentCommitted, Durability: EventCommitted, Canonical: &e}` 发送给 EventSink;发送失败不回滚提交。 @@ -1214,7 +1252,7 @@ Run 创建时冻结 `RunConfig.ModelStepLimit` 和 `RunConfig.ModelRejectLimit` ### 7.1 Tool contract -`sdk.ToolDefinition` 只描述 provider 可发现的 schema,不依赖 agent,也不携带 `ResponsePolicy`。`agent.ExecutableTool` 描述应用如何执行工具并提供 response policy;模型返回后,agent 用 `ToolRef`、definition digest 和 policy 生成冻结的 `ToolCallBinding`。恢复时 schema、工具版本或 policy 不匹配都不能静默换版本。 +`sdk.ToolDefinition` 只描述 provider 可发现的 schema,不依赖 agent,也不携带 `ResponsePolicy`。`agent.ExecutableTool.Definition()` 可以返回 SDK 边界类型,Loop 必须先 `FreezeToolDefinition` 再计算 `DigestToolDefinition` 或写入 `ToolSpec`;MachineState/AgentEvent 中保存的是 agent `ToolDefinition`。`agent.ExecutableTool` 描述应用如何执行工具并提供 response policy;模型返回后,agent 用 `ToolRef`、definition digest 和 policy 生成冻结的 `ToolCallBinding`。恢复时 schema、工具版本或 policy 不匹配都不能静默换版本。 ```go type ExecutableTool interface { @@ -1300,9 +1338,9 @@ MemohRuntime 的 worker 实例在构造时绑定当前 worker 的 owner identity scanner 使用由 StepID、CallID 和 system namespace 稳定生成的 CommandID,并遵守与普通 Commit 相同的 digest/idempotency。它只在自己的 recovery record 证明 lease 已失效后通过共享 `EvaluateCommit` 提交 Unknown;普通 Commit 调用者不能伪造这条 system command。Model recovery 也必须通过共享 Decide/Evolve 规则,而不能直接改写 Step 状态。scanner 不查询外部系统,也不重新执行工具。Pending Call 不走 Unknown 路径,可以由新 Loop 继续。 -ModelStep 的 recovery 规则不同:模型请求没有工具那样的外部业务 effect。ModelStep 的执行 lease 失效后,Memoh 可以在事务中把它从 Executing 重置为 Prepared;新的 Loop 使用同一冻结 `sdk.Request` 重试,不创建新的 Step。只有已经接受的 `SubmitModelResult` 才能关闭该 ModelStep。 +ModelStep 的 recovery 规则不同:模型请求没有工具那样的外部业务 effect。ModelStep 的执行 lease 失效后,Memoh 可以在事务中把它从 Executing 重置为 Prepared;新的 Loop 使用同一冻结 agent `ModelRequest` 重试,不创建新的 Step。只有已经接受的 `SubmitModelResult` 才能关闭该 ModelStep。 -当 ToolStep 自动关闭且 Run 允许继续时,Memoh 先提交 history、queue action 和 context revision;下一次 Loop 通过 Planner 构造并提交下一份 `sdk.Request`。已经冻结的请求不会被后来的输入改变。 +当 ToolStep 自动关闭且 Run 允许继续时,Memoh 先提交 history、queue action 和 context revision;下一次 Loop 通过 Planner 构造边界 `sdk.Request`,再冻结并提交下一份 agent `ModelRequest`。已经冻结的请求不会被后来的输入改变。 ### 8.3 外部 effect 的保证 @@ -1449,7 +1487,7 @@ GenerateParams / GenerateResult Memoh native runtime 已拥有产品 context、queue 和 durable session,但旧 loop 让它看不到 ToolStep 内逐 Call 的 progress。新方案把多步执行统一到 `agent.Loop`,并要求 Memoh 增加 ToolCall progress/response projection。 -本规范中的 `sdk.Request`、`sdk.ModelResult` 和 provider-neutral `sdk.ToolDefinition` 是迁移目标的闭合类型合同;当前 SDK 仍主要使用 `Tool`、`GenerateResult` 和 `StreamResult`。阶段 A 负责实现等价的新类型并保留显式 legacy wrapper,不能把目标类型误认为已经存在的兼容 API。 +本规范中的 `sdk.Request`、`sdk.ModelResult` 和 provider-neutral `sdk.ToolDefinition` 是一次调用的边界合同;agent 的 `ModelRequest`、`ModelResult`、`ToolDefinition` 是持久化闭合合同。阶段 A 负责实现边界转换与显式 legacy wrapper,不能把 SDK 边界类型误认为 Machine wire contract。 ### 11.2 迁移目标 @@ -1478,6 +1516,7 @@ Memoh -> agent.Loop + MemohRuntime 2. 让 Generate、Stream 各自对应一次 provider request;transport retry 留在 sdk。 3. 将旧自动 loop 隔离为 legacy wrapper。 4. 保留 blocking/streaming 等价测试。 +5. 提供 additive compatibility adapters:`RequestFromGenerateParams`、`GenerateParamsFromRequest`、`ToolDefinitionFromTool`、`ToolFromDefinition`、`ToolChoiceFromLegacy`、`ModelResultFromGenerateResult`、`GenerateResultFromModelResult`、`ModelStreamFromStreamResult`;并提供可选 `ModelInvoker` / `StreamingModelInvoker`,provider interface 未整体切换前先靠这些 helper 和 optional interface 桥接新旧边界。 ### 阶段 B:Machine 和 MemoryRuntime @@ -1497,7 +1536,7 @@ Memoh -> agent.Loop + MemohRuntime 1. 用 transaction/CAS 实现 `Load/Commit`。 2. 在 adapter 内加入 owner/fence/lease/Attempt 和 recovery scanner。 3. 实现逐 Call response、wake/outbox、unknown outcome 和 crash/fencing 测试。 -4. 让 Memoh Request Planner 以 revision 冻结下一份 sdk.Request。 +4. 让 Memoh Request Planner 以 revision 规划下一份 sdk.Request,并由 Loop 冻结成 agent ModelRequest。 ### 阶段 E:production cutover @@ -1520,7 +1559,7 @@ Memoh -> agent.Loop + MemohRuntime | MemoryRuntime | twilight-ai/agent | Machine/Loop | | ToolCall projection、history/outbox | Memoh | event contract | | queue、session、R0/R1、owner/fence | Memoh | existing session spec | -| Request Planner/context | Memoh/application | sdk.Request | +| Request Planner/context | Memoh/application | sdk.Request(边界),agent.ModelRequest(持久化) | | MemohRuntime | Memoh | Runtime contract + projections | ## 14. 测试矩阵 @@ -1582,7 +1621,7 @@ same CommandID + different digest -> ErrCommandConflict AlreadyApplied 不重新运行 Decide;事件组逐字节等于首次提交 并发 Commit 的 Revision/CAS 行为 同一 Run/Revision 的并发 Planner:相同请求 AlreadyApplied,不同请求 CommandConflict -Planner InputIDs 必须完整匹配 PendingInputs;Tools/ToolsDigest 与 sdk.Request 一一对应 +Planner InputIDs 必须完整匹配 PendingInputs;Tools/ToolsDigest 与 frozen ModelRequest 一一对应 并行 Call 的 start/response 在旧 Revision 上按目标 Call rebase Pending Call 的 lookup/argument failure 在旧 Revision 上按目标 Call rebase 工具 ref/definition digest 变化 -> tool_definition_mismatch,且不调用工具 @@ -1634,7 +1673,7 @@ Memoh queue/session/admission 的语义保持在 Memoh;现有 NativeAgentLoop 1. Memoh 增加 ToolCall progress、response set、event idempotency、history/outbox projection。 2. Memoh 冻结内部 Attempt、owner、fence、lease 和 recovery grace 规则;这些不进入 agent public API。 3. 工具失败不由 agent core 调度 retry timer;已知失败交给下一次模型,未知结果终止当前 Run。非幂等外部 effect 只能承诺 at-least-once。 -4. Request Planner 必须能从已提交的 application context 构造完整、可冻结的 `sdk.Request`。 +4. Request Planner 必须能从已提交的 application context 构造完整、可冻结的边界 `sdk.Request`,由 Loop freeze 为 agent `ModelRequest`。 ## 17. 待确认决策 @@ -1644,7 +1683,7 @@ Memoh queue/session/admission 的语义保持在 Memoh;现有 NativeAgentLoop 2. breaking release 版本和 Memoh protocol upgrade window。 3. EventSink payload schema,以及是否需要在 Memoh outbox 中加入跨进程 execution epoch。 -本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;`sdk.Request` 冻结完整的 generation options,streaming 只是 `ModelInvoker` 的可选执行路径,不改变 AgentCommand/AgentEvent 语义。Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、与事件编码同属永久兼容契约;AgentEvent log 为 source of truth,MachineState 为必需的同事务 projection,分歧仲裁按 §5.1(日志完整则自动重建,日志尾部低于水位则 halt)。结构性 malformed 的模型结果通过 `RejectModelResult` 在同一冻结 request 上有限重试;usage 在 MachineState 内逐字段累计;steer 由 MemohRuntime 的 Prepare gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 +本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;agent `ModelRequest` 冻结完整的 generation options,streaming 只是 `ModelInvoker` 的可选执行路径,不改变 AgentCommand/AgentEvent 语义。Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、与事件编码同属永久兼容契约;AgentEvent log 为 source of truth,MachineState 为必需的同事务 projection,分歧仲裁按 §5.1(日志完整则自动重建,日志尾部低于水位则 halt)。结构性 malformed 的模型结果通过 `RejectModelResult` 在同一冻结 request 上有限重试;usage 在 MachineState 内逐字段累计;steer 由 MemohRuntime 的 Prepare gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 本规范采用 event log 为 source of truth(§5.1):三个稳定条件(ontology 冻结、Evolve 冻结、事实自包含)由 Decide/Evolve 拆分保障,revision 水位保护日志尾部完整性。MachineState 保持为必需的同事务 projection——提交验证要求当前状态在临界区内可得,这与日志权威并不冲突。 @@ -1710,12 +1749,39 @@ type RunFailure struct { CallID CallID } +// Agent-owned JSON-stable persisted data. SDK request/result/tool values are +// converted at Loop/provider boundaries via Freeze* and SDK(); Runtime never +// stores sdk.MessagePart interfaces, map[string]any provider metadata, or +// caller-owned json.RawMessage bytes. +type ProviderMetadata map[string]json.RawMessage +type CacheControl struct { Type string; TTL string } +type Message struct { Role MessageRole; Content []MessagePart; Usage *Usage } +type MessagePart struct { /* sealed text/reasoning/image/file/tool-call/tool-result fields */ } +type ResponseFormat struct { Type ResponseFormatType; JSONSchema json.RawMessage } +type ToolChoice struct { Mode ToolChoiceMode; Tool string } +type ToolDefinition struct { Name string; Description string; Parameters json.RawMessage; CacheControl *CacheControl } +type ModelRequest struct { /* Model/System/Messages/Tools/options/ProviderOptions */ } +type Usage struct { /* token counters and details; Add is field-wise */ } +type ModelToolCall struct { ToolCallID string; ToolName string; Input json.RawMessage; ProviderMetadata ProviderMetadata } +type ReasoningPart struct { /* text/id/format/model/provider metadata */ } +type Source struct { /* source identity and provider metadata */ } +type GeneratedFile struct { Data string; MediaType string } +type ResponseMetadata struct { ID string; ModelID string; Timestamp string; Headers map[string]string } +type ModelResult struct { /* text/reasoning/finish/usage/sources/files/tool calls/response */ } + +func FreezeModelRequest(sdk.Request) (ModelRequest, error) +func (ModelRequest) SDK() (sdk.Request, error) +func FreezeModelResult(sdk.ModelResult) (ModelResult, error) +func (ModelResult) SDK() (sdk.ModelResult, error) +func FreezeToolDefinition(sdk.ToolDefinition) (ToolDefinition, error) +func (ToolDefinition) SDK() sdk.ToolDefinition + type RunResult struct { Status RunStatus Reason RunReason Failure *RunFailure - Model *sdk.ModelResult - Usage sdk.Usage // MachineState.Usage 在 terminal 时的副本 + Model *ModelResult + Usage Usage // MachineState.Usage 在 terminal 时的副本 } type StepRef struct { @@ -1733,7 +1799,7 @@ type Step interface { type ModelStep struct { RefValue StepRef - Request sdk.Request + Request ModelRequest RequestDigest Digest Model ModelRef Tools []ToolSpec @@ -1850,8 +1916,8 @@ type MachineState struct { Current Step PendingInputs []AgentInput ModelSteps int - Usage sdk.Usage // 已接受 ModelStepCompleted/ModelStepRejected 的逐字段累计 - LastModelResult *sdk.ModelResult + Usage Usage // 已接受 ModelStepCompleted/ModelStepRejected 的逐字段累计 + LastModelResult *ModelResult Result *RunResult } @@ -1882,7 +1948,7 @@ func NextRun(input AgentInput) RunSeed type PrepareModelRequest struct { StepID StepID Model ModelRef - Request sdk.Request + Request ModelRequest RequestDigest Digest InputIDs []InputID PlanningToken PlanningToken @@ -1901,7 +1967,7 @@ func (RecoverModelExecution) agentCommand() {} type SubmitModelResult struct { StepID StepID - Result sdk.ModelResult + Result ModelResult Calls []ToolCallBinding } func (SubmitModelResult) agentCommand() {} @@ -1914,7 +1980,7 @@ func (SubmitModelFailure) agentCommand() {} // RunConfig.ModelRejectLimit is exceeded. Requires the model start grant. type RejectModelResult struct { StepID StepID - Usage sdk.Usage + Usage Usage Failure StepFailure } func (RejectModelResult) agentCommand() {} @@ -1966,7 +2032,7 @@ func (AcceptInput) agentCommand() {} type ModelStepPrepared struct { StepID StepID Model ModelRef - Request sdk.Request + Request ModelRequest RequestDigest Digest InputIDs []InputID Tools []ToolSpec @@ -1983,14 +2049,14 @@ func (ModelStepRecovered) fact() {} type ModelStepRejected struct { StepID StepID - Usage sdk.Usage + Usage Usage Failure StepFailure } func (ModelStepRejected) fact() {} type ModelStepCompleted struct { StepID StepID - Result sdk.ModelResult + Result ModelResult } func (ModelStepCompleted) fact() {} @@ -2057,11 +2123,11 @@ type ToolCallBinding struct { Response *ResponseRequest // Decide 在 ToolStepOpened 中派生并填充;调用方提交时留空 } -// ToolSpec is the agent-side sidecar for a provider-neutral sdk.ToolDefinition. +// ToolSpec is the agent-side sidecar for a provider-neutral frozen ToolDefinition. // ResponsePolicy is intentionally kept out of sdk to preserve package layering. type ToolSpec struct { Ref ToolRef - Definition sdk.ToolDefinition + Definition ToolDefinition DefinitionDigest Digest Policy ResponsePolicy } @@ -2101,7 +2167,7 @@ type RequestPlanner interface { type RequestPlan struct { Model ModelRef - Request sdk.Request + Request sdk.Request // boundary value; Loop freezes before PrepareModelRequest InputIDs []InputID PlanningToken PlanningToken Tools []ToolSpec @@ -2194,8 +2260,8 @@ func EncodeFact(schemaVersion uint16, typ string, fact Fact) ([]byte, error) func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) func EncodeRunSeed(RunSeed) ([]byte, error) func DigestRunSeed(schemaVersion uint16, seed RunSeed) (Digest, error) -func DigestRequest(sdk.Request) (Digest, error) -func DigestToolDefinition(sdk.ToolDefinition) (Digest, error) +func DigestRequest(ModelRequest) (Digest, error) +func DigestToolDefinition(ToolDefinition) (Digest, error) func DigestToolSpec(ToolSpec) (Digest, error) func DigestToolSpecs([]ToolSpec) (Digest, error) func DigestModelStepBinding(model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) diff --git a/sdk/generate_text.go b/sdk/generate_text.go index 26cbb3c..6005021 100644 --- a/sdk/generate_text.go +++ b/sdk/generate_text.go @@ -13,15 +13,18 @@ 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.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..a1309da --- /dev/null +++ b/sdk/model_call.go @@ -0,0 +1,120 @@ +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. +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. +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. +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. +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. +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. +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) { + if req.Model == "" { + req.Model = model.ID + } + if model.ID != "" && req.Model != model.ID { + return Request{}, fmt.Errorf("twilightai: request model %q does not match provider model %q", req.Model, model.ID) + } + return req, 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/request_adapter.go b/sdk/request_adapter.go new file mode 100644 index 0000000..299f570 --- /dev/null +++ b/sdk/request_adapter.go @@ -0,0 +1,579 @@ +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/google/jsonschema-go/jsonschema" +) + +// 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 + 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. +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. +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) + } + 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 != "function" && typ != "tool" { + return ToolChoice{}, fmt.Errorf("twilightai: unsupported tool choice type %q", typ) + } + fn, _ := m["function"].(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": "function", "function": 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. +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 p + } + clone := cloneMessagePart(*p).(TextPart) + return &clone + case ReasoningPart: + p.ProviderMetadata = cloneMetadataMap(p.ProviderMetadata) + return p + case *ReasoningPart: + if p == nil { + return p + } + clone := cloneMessagePart(*p).(ReasoningPart) + return &clone + case ImagePart: + p.CacheControl = cloneCacheControl(p.CacheControl) + return p + case *ImagePart: + if p == nil { + return p + } + clone := cloneMessagePart(*p).(ImagePart) + return &clone + case FilePart: + p.CacheControl = cloneCacheControl(p.CacheControl) + return p + case *FilePart: + if p == nil { + return p + } + clone := cloneMessagePart(*p).(FilePart) + 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 p + } + clone := cloneMessagePart(*p).(ToolCallPart) + return &clone + case ToolResultPart: + p.CacheControl = cloneCacheControl(p.CacheControl) + p.Result = cloneJSONLike(p.Result) + return p + case *ToolResultPart: + if p == nil { + return p + } + clone := cloneMessagePart(*p).(ToolResultPart) + 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..9fe71ba --- /dev/null +++ b/sdk/request_adapter_test.go @@ -0,0 +1,152 @@ +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") + } +} + +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/stream_text.go b/sdk/stream_text.go index f2bd5d9..e4bd7fa 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.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. From 010277e83fd41ed674e918cbc07e8892b9b102ba Mon Sep 17 00:00:00 2001 From: Fodesu Date: Fri, 28 Aug 2026 19:36:30 +0800 Subject: [PATCH 06/81] chore(agent,sdk): finish runtime wire conformance Add command/event JSON wire codecs that restore sealed variants and verify digests, move Runtime conformance into reusable agent/runtimetest, and harden FoldEvents replay validation. Reject legacy provider fallback when Request.ProviderOptions would be silently dropped. --- agent/codec.go | 309 +++++++++++++++++++ agent/codec_test.go | 132 ++++++++ agent/conformance_test.go | 382 ----------------------- agent/memory_conformance_test.go | 15 + agent/protocol.go | 11 +- agent/rebuild.go | 23 +- agent/rebuild_test.go | 49 +++ agent/runtime_helpers_test.go | 108 +++++++ agent/runtimetest/conformance.go | 429 ++++++++++++++++++++++++++ docs/design/agent-runtime-refactor.md | 10 +- sdk/request_adapter.go | 3 + sdk/request_adapter_test.go | 5 + 12 files changed, 1085 insertions(+), 391 deletions(-) create mode 100644 agent/codec.go create mode 100644 agent/codec_test.go delete mode 100644 agent/conformance_test.go create mode 100644 agent/memory_conformance_test.go create mode 100644 agent/runtime_helpers_test.go create mode 100644 agent/runtimetest/conformance.go diff --git a/agent/codec.go b/agent/codec.go new file mode 100644 index 0000000..1dc3e9e --- /dev/null +++ b/agent/codec.go @@ -0,0 +1,309 @@ +package agent + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" +) + +type commandEnvelopeWire struct { + SchemaVersion uint16 `json:"schemaVersion"` + Type string `json:"type"` + RunID RunID `json:"runId"` + ID CommandID `json:"id"` + Digest Digest `json:"digest"` + Command json.RawMessage `json:"command"` +} + +type commandEnvelopeMarshal struct { + SchemaVersion uint16 `json:"schemaVersion"` + Type string `json:"type"` + RunID RunID `json:"runId"` + ID CommandID `json:"id"` + Digest Digest `json:"digest"` + Command AgentCommand `json:"command"` +} + +type agentEventWire struct { + SchemaVersion uint16 `json:"schemaVersion"` + Type string `json:"type"` + RunID RunID `json:"runId"` + Revision uint64 `json:"revision"` + Index uint16 `json:"index"` + CommandID CommandID `json:"commandId"` + CommandDigest Digest `json:"commandDigest"` + Digest Digest `json:"digest"` + Fact json.RawMessage `json:"fact"` +} + +type agentEventMarshal struct { + SchemaVersion uint16 `json:"schemaVersion"` + Type string `json:"type"` + RunID RunID `json:"runId"` + Revision uint64 `json:"revision"` + Index uint16 `json:"index"` + CommandID CommandID `json:"commandId"` + CommandDigest Digest `json:"commandDigest"` + Digest Digest `json:"digest"` + Fact Fact `json:"fact"` +} + +// DecodeCommandEnvelope decodes the persisted command wire shape and restores +// the sealed command variant from Type. The digest is verified during decode; +// 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 +} + +// DecodeAgentEvent decodes the persisted event wire shape and restores the +// sealed fact variant from Type. The fact digest is verified during decode. +func DecodeAgentEvent(raw []byte) (AgentEvent, error) { + var event AgentEvent + if err := decodeStrictJSON(raw, &event); err != nil { + return AgentEvent{}, err + } + return event, nil +} + +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, + RunID: e.RunID, + ID: e.ID, + Digest: e.Digest, + Command: e.Command, + }) +} + +func (e *CommandEnvelope) UnmarshalJSON(raw []byte) error { + var wire commandEnvelopeWire + if err := decodeStrictJSON(raw, &wire); err != nil { + return err + } + if !isSupportedSchemaVersion(wire.SchemaVersion) { + return fmt.Errorf("agent: codec: unsupported schema version %d", wire.SchemaVersion) + } + cmd, err := decodeCommandVariant(wire.Type, wire.Command) + if err != nil { + return err + } + want, err := DigestCommand(wire.SchemaVersion, wire.Type, cmd) + if err != nil { + return err + } + if wire.Digest == "" { + return errors.New("agent: codec: command envelope missing digest") + } + if wire.Digest != want { + return fmt.Errorf("agent: codec: command digest mismatch: got %s want %s", wire.Digest, want) + } + *e = CommandEnvelope{ + SchemaVersion: wire.SchemaVersion, + Type: wire.Type, + RunID: wire.RunID, + ID: wire.ID, + Digest: wire.Digest, + Command: cmd, + } + return nil +} + +func (e AgentEvent) MarshalJSON() ([]byte, error) { + if e.Fact == nil { + return nil, errors.New("agent: codec: event has nil fact") + } + typ := factType(e.Fact) + if typ == "" { + return nil, fmt.Errorf("agent: codec: unknown fact variant %T", e.Fact) + } + if e.Type != "" && e.Type != typ { + return nil, fmt.Errorf("agent: codec: event type %q does not match variant %q", e.Type, typ) + } + return json.Marshal(agentEventMarshal{ + SchemaVersion: e.SchemaVersion, + Type: typ, + RunID: e.RunID, + Revision: e.Revision, + Index: e.Index, + CommandID: e.CommandID, + CommandDigest: e.CommandDigest, + Digest: e.Digest, + Fact: e.Fact, + }) +} + +func (e *AgentEvent) UnmarshalJSON(raw []byte) error { + var wire agentEventWire + if err := decodeStrictJSON(raw, &wire); err != nil { + return err + } + if !isSupportedSchemaVersion(wire.SchemaVersion) { + return fmt.Errorf("agent: codec: unsupported schema version %d", wire.SchemaVersion) + } + fact, err := decodeFactVariant(wire.Type, wire.Fact) + if err != nil { + return err + } + want, err := DigestFact(wire.SchemaVersion, wire.Type, fact) + if err != nil { + return err + } + if wire.Digest == "" { + return errors.New("agent: codec: event missing fact digest") + } + if wire.Digest != want { + return fmt.Errorf("agent: codec: fact digest mismatch: got %s want %s", wire.Digest, want) + } + *e = AgentEvent{ + SchemaVersion: wire.SchemaVersion, + Type: wire.Type, + RunID: wire.RunID, + Revision: wire.Revision, + Index: wire.Index, + CommandID: wire.CommandID, + CommandDigest: wire.CommandDigest, + Digest: wire.Digest, + Fact: fact, + } + return nil +} + +func isSupportedSchemaVersion(v uint16) bool { + return v == SchemaVersion1 +} + +func decodeStrictJSON(raw []byte, dst any) error { + dec := json.NewDecoder(bytes.NewReader(raw)) + 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 decodeCommandVariant(typ string, raw json.RawMessage) (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": + var c PrepareModelRequest + return c, decodeStrictJSON(raw, &c) + case "start_model_execution": + var c StartModelExecution + return c, decodeStrictJSON(raw, &c) + case "recover_model_execution": + var c RecoverModelExecution + return c, decodeStrictJSON(raw, &c) + case "submit_model_result": + var c SubmitModelResult + return c, decodeStrictJSON(raw, &c) + case "submit_model_failure": + var c SubmitModelFailure + return c, decodeStrictJSON(raw, &c) + case "reject_model_result": + var c RejectModelResult + return c, decodeStrictJSON(raw, &c) + case "start_tool_call": + var c StartToolCall + return c, decodeStrictJSON(raw, &c) + case "submit_tool_result": + var c SubmitToolResult + return c, decodeStrictJSON(raw, &c) + case "submit_tool_failure": + var c SubmitToolFailure + return c, decodeStrictJSON(raw, &c) + case "approve_tool_call": + var c ApproveToolCall + return c, decodeStrictJSON(raw, &c) + case "reject_tool_call": + var c RejectToolCall + return c, decodeStrictJSON(raw, &c) + case "submit_tool_response": + var c SubmitToolResponse + return c, decodeStrictJSON(raw, &c) + case "cancel_run": + var c CancelRun + return c, decodeStrictJSON(raw, &c) + case "accept_input": + var c AcceptInput + return c, decodeStrictJSON(raw, &c) + default: + return nil, fmt.Errorf("agent: codec: unknown command type %q", typ) + } +} + +func decodeFactVariant(typ string, raw json.RawMessage) (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 "model_step_prepared": + var f ModelStepPrepared + return f, decodeStrictJSON(raw, &f) + case "model_step_started": + var f ModelStepStarted + return f, decodeStrictJSON(raw, &f) + case "model_step_recovered": + var f ModelStepRecovered + return f, decodeStrictJSON(raw, &f) + case "model_step_rejected": + var f ModelStepRejected + return f, decodeStrictJSON(raw, &f) + case "model_step_completed": + var f ModelStepCompleted + return f, decodeStrictJSON(raw, &f) + case "tool_step_opened": + var f ToolStepOpened + return f, decodeStrictJSON(raw, &f) + case "tool_call_started": + var f ToolCallStarted + return f, decodeStrictJSON(raw, &f) + case "tool_call_approved": + var f ToolCallApproved + return f, decodeStrictJSON(raw, &f) + case "tool_call_completed": + var f ToolCallCompleted + return f, decodeStrictJSON(raw, &f) + case "tool_call_answered": + var f ToolCallAnswered + return f, decodeStrictJSON(raw, &f) + case "tool_call_failed": + var f ToolCallFailed + return f, decodeStrictJSON(raw, &f) + case "tool_step_closed": + var f ToolStepClosed + return f, decodeStrictJSON(raw, &f) + case "input_accepted": + var f InputAccepted + return f, decodeStrictJSON(raw, &f) + case "run_ended": + var f RunEnded + return f, decodeStrictJSON(raw, &f) + default: + return nil, fmt.Errorf("agent: codec: unknown fact type %q", typ) + } +} diff --git a/agent/codec_test.go b/agent/codec_test.go new file mode 100644 index 0000000..adabc48 --- /dev/null +++ b/agent/codec_test.go @@ -0,0 +1,132 @@ +package agent + +import ( + "encoding/json" + "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"}, + StartModelExecution{StepID: "s"}, + RecoverModelExecution{StepID: "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"}, + SubmitToolResult{StepID: "ts", CallID: "c", Result: ToolExecutionResult{Output: json.RawMessage(`{"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: json.RawMessage(`{"answer":1}`)}, + CancelRun{}, + AcceptInput{Input: AgentInput{ID: "in", Payload: json.RawMessage(`{"q":"hi"}`)}}, + } + for _, cmd := range commands { + env, err := BuildEnvelope("run-1", CommandID("cmd-"+commandType(cmd)), cmd) + if err != nil { + t.Fatalf("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.Digest != env.Digest || decoded.ID != env.ID { + t.Fatalf("decoded envelope = %+v, want %+v", decoded, env) + } + } +} + +func TestAgentEventJSONRoundTripRestoresVariants(t *testing.T) { + facts := []Fact{ + ModelStepPrepared{StepID: "s", Model: "m", Request: ModelRequest{Model: "m"}, RequestDigest: "sha256:req", ToolsDigest: "sha256:tools", BindingDigest: "sha256:binding"}, + ModelStepStarted{StepID: "s"}, + ModelStepRecovered{StepID: "s"}, + ModelStepRejected{StepID: "s", Usage: Usage{TotalTokens: 1}, Failure: StepFailure{Class: FailureMalformedModel}}, + ModelStepCompleted{StepID: "s", Result: ModelResult{Text: "ok"}}, + ToolStepOpened{StepID: "ts", Source: "s", BindingSetDigest: "sha256:set", Calls: []ToolCallBinding{{CallID: "c", ToolRef: "t", BindingDigest: "sha256:binding", Arguments: json.RawMessage(`{}`), Policy: DirectExecution}}}, + ToolCallStarted{StepID: "ts", CallID: "c"}, + ToolCallApproved{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp"}, + ToolCallCompleted{StepID: "ts", CallID: "c", Result: ToolExecutionResult{Output: json.RawMessage(`{"ok":true}`)}}, + ToolCallAnswered{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp", Payload: json.RawMessage(`{"answer":1}`)}, + ToolCallFailed{StepID: "ts", CallID: "c", Failure: ToolFailure{Class: FailureExecution}, Outcome: ToolOutcomeKnown}, + ToolStepClosed{StepID: "ts"}, + InputAccepted{Input: AgentInput{ID: "in", Payload: json.RawMessage(`{"q":"hi"}`)}}, + RunEnded{Status: RunCompleted}, + } + for i, fact := range facts { + typ := factType(fact) + digest, err := DigestFact(currentSchemaVersion, typ, fact) + if err != nil { + t.Fatalf("DigestFact(%T): %v", fact, err) + } + event := AgentEvent{ + SchemaVersion: currentSchemaVersion, + Type: typ, + RunID: "run-1", + Revision: uint64(i + 1), + Index: 0, + CommandID: CommandID("cmd"), + CommandDigest: Digest("sha256:cmd"), + Digest: digest, + Fact: fact, + } + raw, err := json.Marshal(event) + if err != nil { + t.Fatalf("Marshal(%T): %v", fact, err) + } + decoded, err := DecodeAgentEvent(raw) + if err != nil { + t.Fatalf("DecodeAgentEvent(%T): %v\n%s", fact, err, raw) + } + if reflect.TypeOf(decoded.Fact) != reflect.TypeOf(fact) { + t.Fatalf("decoded fact type = %T, want %T", decoded.Fact, fact) + } + if decoded.Type != event.Type || decoded.Digest != event.Digest || decoded.Revision != event.Revision { + t.Fatalf("decoded event = %+v, want %+v", decoded, event) + } + } +} + +func TestWireCodecRejectsUnknownTypeAndDigestMismatch(t *testing.T) { + env, err := BuildEnvelope("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") + } + badDigest := strings.Replace(string(raw), string(env.Digest), "sha256:bad", 1) + if _, err := DecodeCommandEnvelope([]byte(badDigest)); err == nil { + t.Fatal("bad command digest decoded") + } + + fact := RunEnded{Status: RunCompleted} + digest, err := DigestFact(currentSchemaVersion, factType(fact), fact) + if err != nil { + t.Fatal(err) + } + event := AgentEvent{SchemaVersion: currentSchemaVersion, Type: factType(fact), RunID: "run-1", Revision: 1, CommandID: "cmd", CommandDigest: env.Digest, Digest: digest, Fact: fact} + raw, err = json.Marshal(event) + if err != nil { + t.Fatal(err) + } + badEventDigest := strings.Replace(string(raw), string(digest), "sha256:bad", 1) + if _, err := DecodeAgentEvent([]byte(badEventDigest)); err == nil { + t.Fatal("bad fact digest decoded") + } +} diff --git a/agent/conformance_test.go b/agent/conformance_test.go deleted file mode 100644 index 51fe9bf..0000000 --- a/agent/conformance_test.go +++ /dev/null @@ -1,382 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "errors" - "testing" - - "github.com/memohai/twilight-ai/sdk" -) - -// Conformance tests over the Runtime contract, run against MemoryRuntime -// (spec §14.3). The same suite is the seed of agent/runtimetest for -// MemohRuntime. - -func newTestRuntime(t *testing.T, cfg RunConfig) *MemoryRuntime { - t.Helper() - s, err := Initialize("run-1", cfg, NextRun(AgentInput{ID: "seed", Payload: json.RawMessage(`{"q":"hi"}`)})) - if err != nil { - t.Fatal(err) - } - return NewMemoryRuntime(s) -} - -func commitCmd(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) (CommitResult, error) { - t.Helper() - env, err := BuildEnvelope("run-1", id, cmd) - if err != nil { - t.Fatal(err) - } - return rt.Commit(context.Background(), CommitRequest{BaseRevision: base, Grant: grant, Command: env}) -} - -func mustCommit(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) CommitResult { - t.Helper() - res, err := commitCmd(t, rt, id, base, grant, cmd) - if err != nil { - t.Fatalf("commit %T: %v", cmd, err) - } - return res -} - -// preparedRuntime returns a runtime advanced to an Executing ModelStep, plus -// stepID and the model grant. -func preparedRuntime(t *testing.T, tools []sdk.ToolDefinition, specs []ToolSpec) (*MemoryRuntime, StepID, ExecutionGrant) { - t.Helper() - rt := newTestRuntime(t, RunConfig{Model: "m-1", ModelRejectLimit: 2}) - snap, _ := rt.Load(context.Background()) - req := testRequest(tools...) - prep, cmdID := buildPrepareFromSnap(t, snap, req, specs) - mustCommit(t, rt, cmdID, snap.Revision, "", prep) - start := mustCommit(t, rt, "start-1", 1, "", StartModelExecution{StepID: prep.StepID}) - if start.Grant == "" { - t.Fatal("accepted start returned no grant") - } - return rt, prep.StepID, start.Grant -} - -func buildPrepareFromSnap(t *testing.T, snap RuntimeSnapshot, req sdk.Request, specs []ToolSpec) (PrepareModelRequest, CommandID) { - t.Helper() - frozenReq, err := FreezeModelRequest(req) - if err != nil { - t.Fatal(err) - } - reqDigest, err := DigestRequest(frozenReq) - if err != nil { - t.Fatal(err) - } - toolsDigest, err := DigestToolSpecs(specs) - if err != nil { - t.Fatal(err) - } - binding, err := DigestModelStepBinding(snap.State.Config.Model, reqDigest, toolsDigest) - if err != nil { - t.Fatal(err) - } - cmdID := DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) - stepID := DeriveModelStepID(snap.State.RunID, cmdID, binding) - ids := make([]InputID, len(snap.State.PendingInputs)) - for i, in := range snap.State.PendingInputs { - ids[i] = in.ID - } - return PrepareModelRequest{ - StepID: stepID, Model: snap.State.Config.Model, Request: frozenReq, - RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, - }, cmdID -} - -func TestConformanceIdempotentReplay(t *testing.T) { - rt := newTestRuntime(t, RunConfig{Model: "m-1"}) - res1 := mustCommit(t, rt, "cancel-1", 0, "", CancelRun{}) - if res1.Status != CommitAccepted || len(res1.Events) != 1 { - t.Fatalf("res1 = %+v", res1) - } - // Same CommandID + digest replays: AlreadyApplied with the original group. - res2 := mustCommit(t, rt, "cancel-1", 0, "", CancelRun{}) - if res2.Status != CommitAlreadyApplied { - t.Fatalf("status = %v", res2.Status) - } - if len(res2.Events) != 1 || res2.Events[0].Digest != res1.Events[0].Digest || - res2.Events[0].Revision != res1.Events[0].Revision { - t.Fatal("replay did not return the original event group") - } - if res2.Snapshot.Revision != res1.Snapshot.Revision { - t.Fatal("replay advanced the revision") - } - // Same CommandID, different content: conflict. - _, err := commitCmd(t, rt, "cancel-1", 0, "", CancelRun{Reason: "other"}) - if err != ErrCommandConflict { - t.Fatalf("err = %v, want ErrCommandConflict", err) - } -} - -func TestConformanceRevisionAndIndex(t *testing.T) { - def := testToolDef("t") - spec := makeSpec(t, def, DirectExecution) - rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) - - b := makeBinding(t, "c1", spec, `{}`) - res := mustCommit(t, rt, "complete-1", 2, grant, - SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) - // One transition, two facts: shared Revision, contiguous Index, snapshot - // revision equals the transition revision. - if len(res.Events) != 2 { - t.Fatalf("events = %d", len(res.Events)) - } - for i, e := range res.Events { - if e.Revision != res.Snapshot.Revision { - t.Fatalf("event revision %d != snapshot %d", e.Revision, res.Snapshot.Revision) - } - if int(e.Index) != i { - t.Fatalf("index[%d] = %d", i, e.Index) - } - if e.CommandID != "complete-1" { - t.Fatal("command id not stamped") - } - } -} - -func TestConformanceStartGrantLifecycle(t *testing.T) { - rt, stepID, grant := preparedRuntime(t, nil, nil) - - // Completion without grant is stale. - _, err := commitCmd(t, rt, "done-x", 2, "", SubmitModelResult{StepID: stepID, Result: ModelResult{}}) - if !errors.Is(err, ErrStaleRuntime) { - t.Fatalf("grantless completion err = %v, want ErrStaleRuntime", err) - } - // Replayed start is AlreadyApplied and must not re-grant. - res := mustCommit(t, rt, "start-1", 1, "", StartModelExecution{StepID: stepID}) - if res.Status != CommitAlreadyApplied || res.Grant != "" { - t.Fatalf("replayed start: %+v", res) - } - // Owner completes with the minted grant. - ok, err := FreezeModelResult(sdk.ModelResult{Text: "ok"}) - if err != nil { - t.Fatal(err) - } - res = mustCommit(t, rt, "done-1", 2, grant, SubmitModelResult{StepID: stepID, Result: ok}) - if res.Status != CommitAccepted || res.Snapshot.State.Status != RunCompleted { - t.Fatalf("completion: %+v", res.Snapshot.State.Status) - } -} - -func TestConformanceCallLocalRebase(t *testing.T) { - defA, defB := testToolDef("a"), testToolDef("b") - specA := makeSpec(t, defA, DirectExecution) - specB := makeSpec(t, defB, DirectExecution) - rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{defA, defB}, []ToolSpec{specA, specB}) - - bA := makeBinding(t, "cA", specA, `{}`) - bB := makeBinding(t, "cB", specB, `{}`) - r, err := FreezeModelResult(sdk.ModelResult{ - FinishReason: sdk.FinishReasonToolCalls, - ToolCalls: []sdk.ToolCall{ - {ToolCallID: "cA", ToolName: "a", Input: `{}`}, - {ToolCallID: "cB", ToolName: "b", Input: `{}`}, - }, - }) - if err != nil { - t.Fatal(err) - } - res := mustCommit(t, rt, "complete-1", 2, grant, - SubmitModelResult{StepID: stepID, Result: r, Calls: []ToolCallBinding{bA, bB}}) - toolStep := res.Events[1].Fact.(ToolStepOpened).StepID - base := res.Snapshot.Revision - - // A starts, advancing the revision. - startA := mustCommit(t, rt, "start-A", base, "", StartToolCall{StepID: toolStep, CallID: "cA"}) - // B starts on the now-stale base revision: call-local rebase accepts it. - startB := mustCommit(t, rt, "start-B", base, "", StartToolCall{StepID: toolStep, CallID: "cB"}) - if startB.Status != CommitAccepted || startB.Grant == "" { - t.Fatal("stale-base start of an untouched Pending call must rebase") - } - // A's completion on a stale base with A's grant also rebases. - doneA := mustCommit(t, rt, "done-A", base, startA.Grant, - SubmitToolResult{StepID: toolStep, CallID: "cA", Result: ToolExecutionResult{Output: json.RawMessage(`1`)}}) - if doneA.Status != CommitAccepted { - t.Fatal("owner completion on stale base must rebase") - } - // Second start of the same call must not rebase (no longer Pending). - _, err = commitCmd(t, rt, "start-A2", base, "", StartToolCall{StepID: toolStep, CallID: "cA"}) - if !errors.Is(err, ErrStaleRuntime) { - t.Fatalf("restart of settled call err = %v, want ErrStaleRuntime", err) - } - _ = startB -} - -func TestConformancePrepareDerivedIdentity(t *testing.T) { - rt := newTestRuntime(t, RunConfig{Model: "m-1"}) - snap, _ := rt.Load(context.Background()) - prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) - - if _, err := commitCmd(t, rt, "wrong-prepare-id", snap.Revision, "", prep); err == nil { - t.Fatal("PrepareModelRequest accepted a non-derived CommandID") - } - - bad := prep - bad.StepID = "wrong-step" - _, err := commitCmd(t, rt, cmdID, snap.Revision, "", bad) - if !errors.Is(err, ErrStaleRuntime) { - t.Fatalf("bad prepare StepID err = %v, want ErrStaleRuntime", err) - } -} - -func TestConformancePrepareIsHardCAS(t *testing.T) { - rt := newTestRuntime(t, RunConfig{Model: "m-1"}) - snap, _ := rt.Load(context.Background()) - prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) - mustCommit(t, rt, cmdID, snap.Revision, "", prep) - - // A concurrent planner on the same revision with a DIFFERENT request: - // same derived CommandID, different digest -> conflict. - otherReq := testRequest() - otherReq.System = "different" - prep2, cmdID2 := buildPrepareFromSnap(t, snap, otherReq, nil) - if cmdID2 != cmdID { - t.Fatal("same revision must derive the same command id") - } - _, err := commitCmd(t, rt, cmdID2, snap.Revision, "", prep2) - if err != ErrCommandConflict { - t.Fatalf("err = %v, want ErrCommandConflict", err) - } - // The SAME request replays as AlreadyApplied. - res := mustCommit(t, rt, cmdID, snap.Revision, "", prep) - if res.Status != CommitAlreadyApplied { - t.Fatalf("status = %v", res.Status) - } -} - -func TestConformanceCancelRebasesAndUnknownWins(t *testing.T) { - def := testToolDef("t") - spec := makeSpec(t, def, DirectExecution) - rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) - b := makeBinding(t, "c1", spec, `{}`) - res := mustCommit(t, rt, "complete-1", 2, grant, - SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) - toolStep := res.Events[1].Fact.(ToolStepOpened).StepID - startRes := mustCommit(t, rt, "start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: "c1"}) - - // Unknown commits first and terminates the run. - unknown := mustCommit(t, rt, "unk-1", startRes.Snapshot.Revision, startRes.Grant, - SubmitToolFailure{StepID: toolStep, CallID: "c1", Outcome: ToolOutcomeUnknown}) - if unknown.Snapshot.State.Status != RunFailed { - t.Fatal("unknown did not fail the run") - } - // Cancel arrives late on an ancient base revision: terminal wins, cancel - // cannot rewrite the outcome. - _, err := commitCmd(t, rt, "cancel-late", 0, "", CancelRun{}) - if err != ErrRunTerminal { - t.Fatalf("late cancel err = %v, want ErrRunTerminal", err) - } -} - -func TestConformanceCancelOnStaleBase(t *testing.T) { - rt, _, _ := preparedRuntime(t, nil, nil) - // Cancel with BaseRevision 0 while authority is at 2: run-control rebases. - res := mustCommit(t, rt, "cancel-1", 0, "", CancelRun{}) - if res.Status != CommitAccepted || res.Snapshot.State.Status != RunStopped { - t.Fatalf("cancel: %+v", res.Snapshot.State.Status) - } -} - -func TestConformanceReplayFoldMatchesState(t *testing.T) { - def := testToolDef("t") - spec := makeSpec(t, def, DirectExecution) - rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) - b := makeBinding(t, "c1", spec, `{}`) - res := mustCommit(t, rt, "complete-1", 2, grant, - SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) - toolStep := res.Events[1].Fact.(ToolStepOpened).StepID - sRes := mustCommit(t, rt, "start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: "c1"}) - mustCommit(t, rt, "done-c1", sRes.Snapshot.Revision, sRes.Grant, - SubmitToolResult{StepID: toolStep, CallID: "c1", Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}}) - - // Fold the event log with Evolve from the initial state; it must match - // the live snapshot (spec §9.1: replay uses only Evolve). - initial, err := Initialize("run-1", RunConfig{Model: "m-1", ModelRejectLimit: 2}, - NextRun(AgentInput{ID: "seed", Payload: json.RawMessage(`{"q":"hi"}`)})) - if err != nil { - t.Fatal(err) - } - replayed := initial - var lastRev uint64 - for _, e := range rt.Events() { - if e.Revision < lastRev { - t.Fatal("event log out of order") - } - lastRev = e.Revision - replayed, err = Evolve(replayed, e.Fact) - if err != nil { - t.Fatalf("replay Evolve: %v", err) - } - } - live, _ := rt.Load(context.Background()) - a, _ := json.Marshal(stateSnapshotForTest(live.State)) - bts, _ := json.Marshal(stateSnapshotForTest(replayed)) - if string(a) != string(bts) { - t.Fatalf("replay diverged:\n live %s\n replay %s", a, bts) - } - if live.Revision != lastRev { - t.Fatalf("snapshot revision %d != last event revision %d", live.Revision, lastRev) - } -} - -func TestConformanceAcceptInputByInputID(t *testing.T) { - rt := newTestRuntime(t, RunConfig{Model: "m-1"}) - in := AgentInput{ID: "in-9", Payload: json.RawMessage(`{"t":"x"}`)} - id := DeriveInputCommandID("run-1", in.ID) - res1 := mustCommit(t, rt, id, 0, "", NextStep(in)) - if res1.Status != CommitAccepted { - t.Fatal("first accept rejected") - } - // Same input replayed through the derived id: AlreadyApplied. - res2 := mustCommit(t, rt, id, 0, "", NextStep(in)) - if res2.Status != CommitAlreadyApplied { - t.Fatalf("status = %v", res2.Status) - } - // Same InputID, different payload: conflict (derived id collides, digest - // differs). - _, err := commitCmd(t, rt, id, 0, "", NextStep(AgentInput{ID: "in-9", Payload: json.RawMessage(`{"t":"y"}`)})) - if err != ErrCommandConflict { - t.Fatalf("err = %v, want ErrCommandConflict", err) - } -} - -func TestConformanceDerivedCommandIDEnforced(t *testing.T) { - // Derived-identity families cannot bypass their idempotency index with a - // caller-minted CommandID (spec §5.5). - rt := newTestRuntime(t, RunConfig{Model: "m-1"}) - _, err := commitCmd(t, rt, "random-id", 0, "", NextStep(AgentInput{ID: "in-1", Payload: json.RawMessage(`1`)})) - if err == nil { - t.Fatal("AcceptInput with non-derived CommandID accepted") - } - // Response commands are checked the same way. - _, err = commitCmd(t, rt, "random-id-2", 0, "", ApproveToolCall{StepID: "s", CallID: "c", ResponseID: "r"}) - if err == nil { - t.Fatal("ApproveToolCall with non-derived CommandID accepted") - } -} - -func TestConformancePrepareRejectionDoesNotLivelock(t *testing.T) { - // A planner that omits pending InputIDs produces a Prepare the authority - // rejects at the same revision forever; the Loop must surface an error - // instead of spinning (loop.go planAndPrepare guard). - rt := newTestRuntime(t, RunConfig{Model: "m-1"}) - loop, err := NewLoop(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{}, - badPlanner{}, ExecutionPolicy{}, false) - if err != nil { - t.Fatal(err) - } - _, err = loop.Run(context.Background(), rt, nil) - if err == nil { - t.Fatal("prepare livelock not surfaced") - } -} - -// badPlanner never consumes pending inputs, so its Prepare is always rejected. -type badPlanner struct{} - -func (badPlanner) Plan(_ context.Context, hint PlanningHint) (RequestPlan, error) { - return RequestPlan{Model: hint.Model, Request: sdk.Request{Model: string(hint.Model)}}, nil -} diff --git a/agent/memory_conformance_test.go b/agent/memory_conformance_test.go new file mode 100644 index 0000000..c7c5643 --- /dev/null +++ b/agent/memory_conformance_test.go @@ -0,0 +1,15 @@ +package agent_test + +import ( + "testing" + + "github.com/memohai/twilight-ai/agent" + "github.com/memohai/twilight-ai/agent/runtimetest" +) + +func TestMemoryRuntimeConformance(t *testing.T) { + runtimetest.Run(t, func(t testing.TB, initial agent.MachineState) agent.Runtime { + t.Helper() + return agent.NewMemoryRuntime(initial) + }) +} diff --git a/agent/protocol.go b/agent/protocol.go index ea5a5ef..071bcf8 100644 --- a/agent/protocol.go +++ b/agent/protocol.go @@ -167,9 +167,10 @@ func digestBindingSet(bindings []ToolCallBinding) (Digest, error) { return sha256Digest(body), nil } -// digestToolCallBinding covers one binding: definition, policy and canonical -// arguments plus the CallID (spec §4.2). -func digestToolCallBinding(callID CallID, definitionDigest Digest, policy ResponsePolicy, arguments []byte) (Digest, error) { +// DigestToolCallBinding covers one binding: definition, policy and canonical +// arguments plus the CallID (spec §4.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 []byte) (Digest, error) { canonicalArgs := []byte("null") if len(arguments) > 0 { var err error @@ -181,3 +182,7 @@ func digestToolCallBinding(callID CallID, definitionDigest Digest, policy Respon return sha256Digest([]byte(namespacedHash("twilight/tool-call-binding", string(callID), string(definitionDigest), fmt.Sprintf("%d", policy), string(canonicalArgs)))), nil } + +func digestToolCallBinding(callID CallID, definitionDigest Digest, policy ResponsePolicy, arguments []byte) (Digest, error) { + return DigestToolCallBinding(callID, definitionDigest, policy, arguments) +} diff --git a/agent/rebuild.go b/agent/rebuild.go index 4c360ab..2853c7a 100644 --- a/agent/rebuild.go +++ b/agent/rebuild.go @@ -15,25 +15,46 @@ var ErrLogTruncated = errors.New("agent: event log ends below the revision water // initial (Revision 0) state with Evolve only: no Decide, no external // effects, no command replay (spec §9.1). It verifies (Revision, Index) // ordering and per-fact digests as it goes; any gap or mismatch means the log -// itself is damaged and the fold stops. +// itself is damaged and the fold stops. It also rejects RunID/schema/type +// mismatches and same-revision command identity changes. func FoldEvents(initial MachineState, events []AgentEvent) (MachineState, uint64, error) { state := initial var revision uint64 var index uint16 + var commandID CommandID + var commandDigest Digest inTransition := false for _, e := range events { + if e.RunID != initial.RunID { + return initial, 0, fmt.Errorf("agent: fold: event run %q does not match initial run %q", e.RunID, initial.RunID) + } + if !isSupportedSchemaVersion(e.SchemaVersion) { + return initial, 0, fmt.Errorf("agent: fold: unsupported schema version %d", e.SchemaVersion) + } + typ := factType(e.Fact) + if typ == "" || e.Type != typ { + return initial, 0, fmt.Errorf("agent: fold: event type %q does not match fact variant %T", e.Type, e.Fact) + } switch { case !inTransition || e.Revision != revision: if e.Revision != revision+1 || e.Index != 0 { return initial, 0, fmt.Errorf("agent: fold: gap at revision %d index %d (expected %d/0)", e.Revision, e.Index, revision+1) } + if e.CommandID == "" || e.CommandDigest == "" { + return initial, 0, fmt.Errorf("agent: fold: revision %d missing command identity", e.Revision) + } revision = e.Revision index = 0 + commandID = e.CommandID + commandDigest = e.CommandDigest inTransition = true default: if e.Index != index+1 { return initial, 0, fmt.Errorf("agent: fold: gap at revision %d index %d (expected %d)", e.Revision, e.Index, index+1) } + if e.CommandID != commandID || e.CommandDigest != commandDigest { + return initial, 0, fmt.Errorf("agent: fold: revision %d command identity changed within transition", e.Revision) + } index = e.Index } wantDigest, err := DigestFact(e.SchemaVersion, e.Type, e.Fact) diff --git a/agent/rebuild_test.go b/agent/rebuild_test.go index 7d9c0ca..9e9d931 100644 --- a/agent/rebuild_test.go +++ b/agent/rebuild_test.go @@ -145,6 +145,55 @@ func TestFoldRejectsInteriorGap(t *testing.T) { } } +func TestFoldRejectsRunIDMismatch(t *testing.T) { + rt := fullRunRuntime(t) + rt.mu.Lock() + log := cloneEvents(rt.log) + initial := cloneMachineState(rt.initial) + rt.mu.Unlock() + + log[0].RunID = "other-run" + if _, _, err := FoldEvents(initial, log); err == nil { + t.Fatal("run mismatch folded silently") + } +} + +func TestFoldRejectsTransitionCommandIdentityChange(t *testing.T) { + rt := fullRunRuntime(t) + rt.mu.Lock() + log := cloneEvents(rt.log) + initial := cloneMachineState(rt.initial) + rt.mu.Unlock() + + for i := range log { + if log[i].Revision == 3 && log[i].Index == 1 { + log[i].CommandID = "other-command" + break + } + } + if _, _, err := FoldEvents(initial, log); err == nil { + t.Fatal("same-revision command identity change folded silently") + } +} + +func TestFoldRejectsUnsupportedSchemaVersion(t *testing.T) { + rt := fullRunRuntime(t) + rt.mu.Lock() + log := cloneEvents(rt.log) + initial := cloneMachineState(rt.initial) + rt.mu.Unlock() + + log[0].SchemaVersion = 99 + digest, err := DigestFact(log[0].SchemaVersion, log[0].Type, log[0].Fact) + if err != nil { + t.Fatal(err) + } + log[0].Digest = digest + if _, _, err := FoldEvents(initial, log); err == nil { + t.Fatal("unsupported schema version folded silently") + } +} + // A tampered fact fails its digest check during fold. func TestFoldRejectsTamperedFact(t *testing.T) { rt := fullRunRuntime(t) diff --git a/agent/runtime_helpers_test.go b/agent/runtime_helpers_test.go new file mode 100644 index 0000000..07cdd0d --- /dev/null +++ b/agent/runtime_helpers_test.go @@ -0,0 +1,108 @@ +package agent + +import ( + "context" + "encoding/json" + "testing" + + "github.com/memohai/twilight-ai/sdk" +) + +// Shared helpers for package-local agent tests. Runtime conformance lives in +// agent/runtimetest so durable Runtime implementations can reuse it. + +func newTestRuntime(t *testing.T, cfg RunConfig) *MemoryRuntime { + t.Helper() + s, err := Initialize("run-1", cfg, NextRun(AgentInput{ID: "seed", Payload: json.RawMessage(`{"q":"hi"}`)})) + if err != nil { + t.Fatal(err) + } + return NewMemoryRuntime(s) +} + +func commitCmd(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) (CommitResult, error) { + t.Helper() + env, err := BuildEnvelope("run-1", id, cmd) + if err != nil { + t.Fatal(err) + } + return rt.Commit(context.Background(), CommitRequest{BaseRevision: base, Grant: grant, Command: env}) +} + +func mustCommit(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) CommitResult { + t.Helper() + res, err := commitCmd(t, rt, id, base, grant, cmd) + if err != nil { + t.Fatalf("commit %T: %v", cmd, err) + } + return res +} + +// preparedRuntime returns a runtime advanced to an Executing ModelStep, plus +// stepID and the model grant. +func preparedRuntime(t *testing.T, tools []sdk.ToolDefinition, specs []ToolSpec) (*MemoryRuntime, StepID, ExecutionGrant) { + t.Helper() + rt := newTestRuntime(t, RunConfig{Model: "m-1", ModelRejectLimit: 2}) + snap, _ := rt.Load(context.Background()) + req := testRequest(tools...) + prep, cmdID := buildPrepareFromSnap(t, snap, req, specs) + mustCommit(t, rt, cmdID, snap.Revision, "", prep) + start := mustCommit(t, rt, "start-1", 1, "", StartModelExecution{StepID: prep.StepID}) + if start.Grant == "" { + t.Fatal("accepted start returned no grant") + } + return rt, prep.StepID, start.Grant +} + +func buildPrepareFromSnap(t *testing.T, snap RuntimeSnapshot, req sdk.Request, specs []ToolSpec) (PrepareModelRequest, CommandID) { + t.Helper() + frozenReq, err := FreezeModelRequest(req) + if err != nil { + t.Fatal(err) + } + reqDigest, err := DigestRequest(frozenReq) + if err != nil { + t.Fatal(err) + } + toolsDigest, err := DigestToolSpecs(specs) + if err != nil { + t.Fatal(err) + } + binding, err := DigestModelStepBinding(snap.State.Config.Model, reqDigest, toolsDigest) + if err != nil { + t.Fatal(err) + } + cmdID := DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) + stepID := DeriveModelStepID(snap.State.RunID, cmdID, binding) + ids := make([]InputID, len(snap.State.PendingInputs)) + for i, in := range snap.State.PendingInputs { + ids[i] = in.ID + } + return PrepareModelRequest{ + StepID: stepID, Model: snap.State.Config.Model, Request: frozenReq, + RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, + }, cmdID +} + +func TestLoopPrepareRejectionDoesNotLivelock(t *testing.T) { + // A planner that omits pending InputIDs produces a Prepare the authority + // rejects at the same revision forever; the Loop must surface an error + // instead of spinning (loop.go planAndPrepare guard). + rt := newTestRuntime(t, RunConfig{Model: "m-1"}) + loop, err := NewLoop(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{}, + badPlanner{}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + _, err = loop.Run(context.Background(), rt, nil) + if err == nil { + t.Fatal("prepare livelock not surfaced") + } +} + +// badPlanner never consumes pending inputs, so its Prepare is always rejected. +type badPlanner struct{} + +func (badPlanner) Plan(_ context.Context, hint PlanningHint) (RequestPlan, error) { + return RequestPlan{Model: hint.Model, Request: sdk.Request{Model: string(hint.Model)}}, nil +} diff --git a/agent/runtimetest/conformance.go b/agent/runtimetest/conformance.go new file mode 100644 index 0000000..f17cbde --- /dev/null +++ b/agent/runtimetest/conformance.go @@ -0,0 +1,429 @@ +// Package runtimetest contains the shared Runtime conformance suite. Durable +// Runtime implementations should run this suite in their own tests instead of +// copying MemoryRuntime-specific assertions. +package runtimetest + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/memohai/twilight-ai/agent" + "github.com/memohai/twilight-ai/sdk" +) + +// Factory constructs a Runtime from an already-initialized Revision-0 state. +type Factory func(testing.TB, agent.MachineState) agent.Runtime + +// Run executes the shared Runtime conformance suite. +func Run(t *testing.T, newRuntime Factory) { + t.Helper() + t.Run("IdempotentReplay", func(t *testing.T) { testIdempotentReplay(t, newRuntime) }) + t.Run("RevisionAndIndex", func(t *testing.T) { testRevisionAndIndex(t, newRuntime) }) + t.Run("StartGrantLifecycle", func(t *testing.T) { testStartGrantLifecycle(t, newRuntime) }) + t.Run("CallLocalRebase", func(t *testing.T) { testCallLocalRebase(t, newRuntime) }) + t.Run("PrepareDerivedIdentity", func(t *testing.T) { testPrepareDerivedIdentity(t, newRuntime) }) + t.Run("PrepareIsHardCAS", func(t *testing.T) { testPrepareIsHardCAS(t, newRuntime) }) + t.Run("CancelRebasesAndUnknownWins", func(t *testing.T) { testCancelRebasesAndUnknownWins(t, newRuntime) }) + t.Run("CancelOnStaleBase", func(t *testing.T) { testCancelOnStaleBase(t, newRuntime) }) + t.Run("ReplayFoldMatchesState", func(t *testing.T) { testReplayFoldMatchesState(t, newRuntime) }) + t.Run("AcceptInputByInputID", func(t *testing.T) { testAcceptInputByInputID(t, newRuntime) }) + t.Run("DerivedCommandIDEnforced", func(t *testing.T) { testDerivedCommandIDEnforced(t, newRuntime) }) +} + +type runtimeCase struct { + t testing.TB + runID agent.RunID + initial agent.MachineState + rt agent.Runtime + events []agent.AgentEvent +} + +func newCase(t testing.TB, newRuntime Factory, cfg agent.RunConfig) *runtimeCase { + t.Helper() + initial, err := agent.Initialize("run-1", cfg, agent.NextRun(agent.AgentInput{ID: "seed", Payload: json.RawMessage(`{"q":"hi"}`)})) + if err != nil { + t.Fatal(err) + } + return &runtimeCase{t: t, runID: initial.RunID, initial: initial, rt: newRuntime(t, initial)} +} + +func (c *runtimeCase) load() agent.RuntimeSnapshot { + c.t.Helper() + snap, err := c.rt.Load(context.Background()) + if err != nil { + c.t.Fatal(err) + } + return snap +} + +func (c *runtimeCase) commit(id agent.CommandID, base uint64, grant agent.ExecutionGrant, cmd agent.AgentCommand) (agent.CommitResult, error) { + c.t.Helper() + env, err := agent.BuildEnvelope(c.runID, id, cmd) + if err != nil { + c.t.Fatal(err) + } + res, err := c.rt.Commit(context.Background(), agent.CommitRequest{BaseRevision: base, Grant: grant, Command: env}) + if err == nil && res.Status == agent.CommitAccepted { + c.events = append(c.events, res.Events...) + } + return res, err +} + +func (c *runtimeCase) mustCommit(id agent.CommandID, base uint64, grant agent.ExecutionGrant, cmd agent.AgentCommand) agent.CommitResult { + c.t.Helper() + res, err := c.commit(id, base, grant, cmd) + if err != nil { + c.t.Fatalf("commit %T: %v", cmd, err) + } + return res +} + +func preparedCase(t testing.TB, newRuntime Factory, tools []sdk.ToolDefinition, specs []agent.ToolSpec) (*runtimeCase, agent.StepID, agent.ExecutionGrant) { + t.Helper() + c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1", ModelRejectLimit: 2}) + snap := c.load() + prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(tools...), specs) + c.mustCommit(cmdID, snap.Revision, "", prep) + start := c.mustCommit("start-1", 1, "", agent.StartModelExecution{StepID: prep.StepID}) + if start.Grant == "" { + t.Fatal("accepted start returned no grant") + } + return c, prep.StepID, start.Grant +} + +func buildPrepareFromSnap(t testing.TB, snap agent.RuntimeSnapshot, req sdk.Request, specs []agent.ToolSpec) (agent.PrepareModelRequest, agent.CommandID) { + t.Helper() + frozenReq, err := agent.FreezeModelRequest(req) + if err != nil { + t.Fatal(err) + } + reqDigest, err := agent.DigestRequest(frozenReq) + if err != nil { + t.Fatal(err) + } + toolsDigest, err := agent.DigestToolSpecs(specs) + if err != nil { + t.Fatal(err) + } + binding, err := agent.DigestModelStepBinding(snap.State.Config.Model, reqDigest, toolsDigest) + if err != nil { + t.Fatal(err) + } + cmdID := agent.DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) + stepID := agent.DeriveModelStepID(snap.State.RunID, cmdID, binding) + ids := make([]agent.InputID, len(snap.State.PendingInputs)) + for i, in := range snap.State.PendingInputs { + ids[i] = in.ID + } + return agent.PrepareModelRequest{ + StepID: stepID, Model: snap.State.Config.Model, Request: frozenReq, + RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, + }, cmdID +} + +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 makeSpec(t testing.TB, def sdk.ToolDefinition, policy agent.ResponsePolicy) agent.ToolSpec { + t.Helper() + frozen, err := agent.FreezeToolDefinition(def) + if err != nil { + t.Fatal(err) + } + d, err := agent.DigestToolDefinition(frozen) + if err != nil { + t.Fatal(err) + } + return agent.ToolSpec{Ref: agent.ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: policy} +} + +func makeBinding(t testing.TB, callID string, spec agent.ToolSpec, args string) agent.ToolCallBinding { + t.Helper() + bd, err := agent.DigestToolCallBinding(agent.CallID(callID), spec.DefinitionDigest, spec.Policy, []byte(args)) + if err != nil { + t.Fatal(err) + } + return agent.ToolCallBinding{ + CallID: agent.CallID(callID), + ToolRef: spec.Ref, + DefinitionDigest: spec.DefinitionDigest, + BindingDigest: bd, + Arguments: json.RawMessage(args), + Policy: spec.Policy, + } +} + +func modelResultWithCalls(callIDs ...string) agent.ModelResult { + r := sdk.ModelResult{ + 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: "t", Input: `{}`}) + } + frozen, err := agent.FreezeModelResult(r) + if err != nil { + panic(err) + } + return frozen +} + +func testIdempotentReplay(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1"}) + res1 := c.mustCommit("cancel-1", 0, "", agent.CancelRun{}) + if res1.Status != agent.CommitAccepted || len(res1.Events) != 1 { + t.Fatalf("res1 = %+v", res1) + } + res2 := c.mustCommit("cancel-1", 0, "", agent.CancelRun{}) + if res2.Status != agent.CommitAlreadyApplied { + t.Fatalf("status = %v", res2.Status) + } + if len(res2.Events) != 1 || res2.Events[0].Digest != res1.Events[0].Digest || + res2.Events[0].Revision != res1.Events[0].Revision { + t.Fatal("replay did not return the original event group") + } + if res2.Snapshot.Revision != res1.Snapshot.Revision { + t.Fatal("replay advanced the revision") + } + _, err := c.commit("cancel-1", 0, "", agent.CancelRun{Reason: "other"}) + if err != agent.ErrCommandConflict { + t.Fatalf("err = %v, want ErrCommandConflict", err) + } +} + +func testRevisionAndIndex(t *testing.T, newRuntime Factory) { + def := testToolDef("t") + spec := makeSpec(t, def, agent.DirectExecution) + c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []agent.ToolSpec{spec}) + + b := makeBinding(t, "c1", spec, `{}`) + res := c.mustCommit("complete-1", 2, grant, + agent.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []agent.ToolCallBinding{b}}) + if len(res.Events) != 2 { + t.Fatalf("events = %d", len(res.Events)) + } + for i, e := range res.Events { + if e.Revision != res.Snapshot.Revision { + t.Fatalf("event revision %d != snapshot %d", e.Revision, res.Snapshot.Revision) + } + if int(e.Index) != i { + t.Fatalf("index[%d] = %d", i, e.Index) + } + if e.CommandID != "complete-1" { + t.Fatal("command id not stamped") + } + } +} + +func testStartGrantLifecycle(t *testing.T, newRuntime Factory) { + c, stepID, grant := preparedCase(t, newRuntime, nil, nil) + + _, err := c.commit("done-x", 2, "", agent.SubmitModelResult{StepID: stepID, Result: agent.ModelResult{}}) + if !errors.Is(err, agent.ErrStaleRuntime) { + t.Fatalf("grantless completion err = %v, want ErrStaleRuntime", err) + } + res := c.mustCommit("start-1", 1, "", agent.StartModelExecution{StepID: stepID}) + if res.Status != agent.CommitAlreadyApplied || res.Grant != "" { + t.Fatalf("replayed start: %+v", res) + } + ok, err := agent.FreezeModelResult(sdk.ModelResult{Text: "ok"}) + if err != nil { + t.Fatal(err) + } + res = c.mustCommit("done-1", 2, grant, agent.SubmitModelResult{StepID: stepID, Result: ok}) + if res.Status != agent.CommitAccepted || res.Snapshot.State.Status != agent.RunCompleted { + t.Fatalf("completion: %+v", res.Snapshot.State.Status) + } +} + +func testCallLocalRebase(t *testing.T, newRuntime Factory) { + defA, defB := testToolDef("a"), testToolDef("b") + specA := makeSpec(t, defA, agent.DirectExecution) + specB := makeSpec(t, defB, agent.DirectExecution) + c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{defA, defB}, []agent.ToolSpec{specA, specB}) + + bA := makeBinding(t, "cA", specA, `{}`) + bB := makeBinding(t, "cB", specB, `{}`) + r, err := agent.FreezeModelResult(sdk.ModelResult{ + FinishReason: sdk.FinishReasonToolCalls, + ToolCalls: []sdk.ToolCall{ + {ToolCallID: "cA", ToolName: "a", Input: `{}`}, + {ToolCallID: "cB", ToolName: "b", Input: `{}`}, + }, + }) + if err != nil { + t.Fatal(err) + } + res := c.mustCommit("complete-1", 2, grant, + agent.SubmitModelResult{StepID: stepID, Result: r, Calls: []agent.ToolCallBinding{bA, bB}}) + toolStep := res.Events[1].Fact.(agent.ToolStepOpened).StepID + base := res.Snapshot.Revision + + startA := c.mustCommit("start-A", base, "", agent.StartToolCall{StepID: toolStep, CallID: "cA"}) + startB := c.mustCommit("start-B", base, "", agent.StartToolCall{StepID: toolStep, CallID: "cB"}) + if startB.Status != agent.CommitAccepted || startB.Grant == "" { + t.Fatal("stale-base start of an untouched Pending call must rebase") + } + doneA := c.mustCommit("done-A", base, startA.Grant, + agent.SubmitToolResult{StepID: toolStep, CallID: "cA", Result: agent.ToolExecutionResult{Output: json.RawMessage(`1`)}}) + if doneA.Status != agent.CommitAccepted { + t.Fatal("owner completion on stale base must rebase") + } + _, err = c.commit("start-A2", base, "", agent.StartToolCall{StepID: toolStep, CallID: "cA"}) + if !errors.Is(err, agent.ErrStaleRuntime) { + t.Fatalf("restart of settled call err = %v, want ErrStaleRuntime", err) + } + _ = startB +} + +func testPrepareDerivedIdentity(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1"}) + snap := c.load() + prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) + + if _, err := c.commit("wrong-prepare-id", snap.Revision, "", prep); err == nil { + t.Fatal("PrepareModelRequest accepted a non-derived CommandID") + } + + bad := prep + bad.StepID = "wrong-step" + _, err := c.commit(cmdID, snap.Revision, "", bad) + if !errors.Is(err, agent.ErrStaleRuntime) { + t.Fatalf("bad prepare StepID err = %v, want ErrStaleRuntime", err) + } +} + +func testPrepareIsHardCAS(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1"}) + snap := c.load() + prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) + c.mustCommit(cmdID, snap.Revision, "", prep) + + otherReq := testRequest() + otherReq.System = "different" + prep2, cmdID2 := buildPrepareFromSnap(t, snap, otherReq, nil) + if cmdID2 != cmdID { + t.Fatal("same revision must derive the same command id") + } + _, err := c.commit(cmdID2, snap.Revision, "", prep2) + if err != agent.ErrCommandConflict { + t.Fatalf("err = %v, want ErrCommandConflict", err) + } + res := c.mustCommit(cmdID, snap.Revision, "", prep) + if res.Status != agent.CommitAlreadyApplied { + t.Fatalf("status = %v", res.Status) + } +} + +func testCancelRebasesAndUnknownWins(t *testing.T, newRuntime Factory) { + def := testToolDef("t") + spec := makeSpec(t, def, agent.DirectExecution) + c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []agent.ToolSpec{spec}) + b := makeBinding(t, "c1", spec, `{}`) + res := c.mustCommit("complete-1", 2, grant, + agent.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []agent.ToolCallBinding{b}}) + toolStep := res.Events[1].Fact.(agent.ToolStepOpened).StepID + startRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", agent.StartToolCall{StepID: toolStep, CallID: "c1"}) + + unknown := c.mustCommit("unk-1", startRes.Snapshot.Revision, startRes.Grant, + agent.SubmitToolFailure{StepID: toolStep, CallID: "c1", Outcome: agent.ToolOutcomeUnknown}) + if unknown.Snapshot.State.Status != agent.RunFailed { + t.Fatal("unknown did not fail the run") + } + _, err := c.commit("cancel-late", 0, "", agent.CancelRun{}) + if err != agent.ErrRunTerminal { + t.Fatalf("late cancel err = %v, want ErrRunTerminal", err) + } +} + +func testCancelOnStaleBase(t *testing.T, newRuntime Factory) { + c, _, _ := preparedCase(t, newRuntime, nil, nil) + res := c.mustCommit("cancel-1", 0, "", agent.CancelRun{}) + if res.Status != agent.CommitAccepted || res.Snapshot.State.Status != agent.RunStopped { + t.Fatalf("cancel: %+v", res.Snapshot.State.Status) + } +} + +func testReplayFoldMatchesState(t *testing.T, newRuntime Factory) { + def := testToolDef("t") + spec := makeSpec(t, def, agent.DirectExecution) + c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []agent.ToolSpec{spec}) + b := makeBinding(t, "c1", spec, `{}`) + res := c.mustCommit("complete-1", 2, grant, + agent.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []agent.ToolCallBinding{b}}) + toolStep := res.Events[1].Fact.(agent.ToolStepOpened).StepID + sRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", agent.StartToolCall{StepID: toolStep, CallID: "c1"}) + c.mustCommit("done-c1", sRes.Snapshot.Revision, sRes.Grant, + agent.SubmitToolResult{StepID: toolStep, CallID: "c1", Result: agent.ToolExecutionResult{Output: json.RawMessage(`"ok"`)}}) + + folded, lastRev, err := agent.FoldEvents(c.initial, c.events) + if err != nil { + t.Fatalf("FoldEvents: %v", err) + } + live := c.load() + a, _ := json.Marshal(stateComparable(live.State)) + bts, _ := json.Marshal(stateComparable(folded)) + if string(a) != string(bts) { + t.Fatalf("replay diverged:\n live %s\n replay %s", a, bts) + } + if live.Revision != lastRev { + t.Fatalf("snapshot revision %d != last event revision %d", live.Revision, lastRev) + } +} + +func testAcceptInputByInputID(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1"}) + in := agent.AgentInput{ID: "in-9", Payload: json.RawMessage(`{"t":"x"}`)} + id := agent.DeriveInputCommandID("run-1", in.ID) + res1 := c.mustCommit(id, 0, "", agent.NextStep(in)) + if res1.Status != agent.CommitAccepted { + t.Fatal("first accept rejected") + } + res2 := c.mustCommit(id, 0, "", agent.NextStep(in)) + if res2.Status != agent.CommitAlreadyApplied { + t.Fatalf("status = %v", res2.Status) + } + _, err := c.commit(id, 0, "", agent.NextStep(agent.AgentInput{ID: "in-9", Payload: json.RawMessage(`{"t":"y"}`)})) + if err != agent.ErrCommandConflict { + t.Fatalf("err = %v, want ErrCommandConflict", err) + } +} + +func testDerivedCommandIDEnforced(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1"}) + _, err := c.commit("random-id", 0, "", agent.NextStep(agent.AgentInput{ID: "in-1", Payload: json.RawMessage(`1`)})) + if err == nil { + t.Fatal("AcceptInput with non-derived CommandID accepted") + } + _, err = c.commit("random-id-2", 0, "", agent.ApproveToolCall{StepID: "s", CallID: "c", ResponseID: "r"}) + if err == nil { + t.Fatal("ApproveToolCall with non-derived CommandID accepted") + } +} + +func stateComparable(s agent.MachineState) map[string]any { + m := map[string]any{ + "runId": s.RunID, "status": s.Status, "config": s.Config, + "modelSteps": s.ModelSteps, "lastClosedStep": s.LastClosedStep, + "usage": s.Usage, "pendingInputs": s.PendingInputs, + "lastModelResult": s.LastModelResult, "result": s.Result, + } + switch cur := s.Current.(type) { + case agent.ModelStep: + m["modelStep"] = cur + case agent.ToolStep: + m["toolStep"] = cur + } + return m +} diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 3d48655..5170bdb 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -911,7 +911,7 @@ canonical 编码和 digest 函数由 agent 提供;Memoh 只保存和比较结 已发布 `SchemaVersion` 的 canonical 编码和 digest 规则永久冻结;字段增删只能进入新的 SchemaVersion,旧事件按其自带版本校验。同一个 Run 不允许由写入不同 SchemaVersion 的进程混跑:升级窗口内先全量部署可读写新版本的代码,再开始写入新版本;否则同一 command 的重放会因编码不同被误判为 `ErrCommandConflict`。 -CommandEnvelope 和 AgentEvent 的 `SchemaVersion` 和 `Type` 是持久化协议字段;Type 必须与 sealed AgentCommand/Fact 的具体变体一致,未知版本或类型直接拒绝。`DigestCommand`/`DigestFact` 对 `SchemaVersion`、`Type` 和内容做 canonical digest,但不把 `Digest` 字段自身纳入摘要,保证 Memoh scanner、MemoryRuntime 和不同进程使用同一身份规则。`Revision` 只用于 authority 的 CAS,不进入任何 digest。 +CommandEnvelope 和 AgentEvent 的 `SchemaVersion` 和 `Type` 是持久化协议字段;Type 必须与 sealed AgentCommand/Fact 的具体变体一致,未知版本或类型直接拒绝。agent 必须提供正式 wire codec:decode 时先读 `Type`,再恢复具体 command/fact variant,并校验 command/fact digest;不能依赖 `encoding/json` 自动反序列化 interface 字段。`DigestCommand`/`DigestFact` 对 `SchemaVersion`、`Type` 和内容做 canonical digest,但不把 `Digest` 字段自身纳入摘要,保证 Memoh scanner、MemoryRuntime 和不同进程使用同一身份规则。`Revision` 只用于 authority 的 CAS,不进入任何 digest。 Evolve 的折叠语义与事件编码同属永久兼容契约:已发布 SchemaVersion 的事件必须永远能被折叠出与写入当时相同的状态。conformance kit 为每个已发布 SchemaVersion 冻结 golden event stream 与对应的状态字节,任何 Evolve 实现变更都必须通过全部历史版本的 golden 校验。 @@ -1384,7 +1384,7 @@ Fact 已接受的事实内容 AgentEvent log 是 source of truth;MachineState 是必需的同事务 projection(§5.1)。Runtime 必须把两者、水位和需要一致的 Memoh projection/outbox 放在同一事务或锁边界。Durable adapter 必须保留 AgentEvent,使其可以按 RunID/(Revision, Index) replay;MemoryRuntime 可以只在进程内保留同样的记录。公共 `Runtime` 不增加 replay 方法,读取由实现或 application projection 提供。 -Replay 按 RunID/(Revision, Index) 取出 AgentEvent,从初始状态(Revision=0)开始依次调用同一份 `Machine.Evolve` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里,Machine 决策规则的演进不影响历史事件的折叠;折叠不产生任何外部 effect。仲裁按 §5.1 的规则:日志完整(maxRevision >= watermark)时日志为准,snapshot 分歧或缺失自动重建并记录重建事件;日志尾部低于水位时 halt。事件流内部的 Revision/Index 缺洞或 digest 不匹配同样按日志损坏处理,halt 该 Run。 +Replay 按 RunID/(Revision, Index) 取出 AgentEvent,从初始状态(Revision=0)开始依次调用同一份 `Machine.Evolve` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里,Machine 决策规则的演进不影响历史事件的折叠;折叠不产生任何外部 effect。仲裁按 §5.1 的规则:日志完整(maxRevision >= watermark)时日志为准,snapshot 分歧或缺失自动重建并记录重建事件;日志尾部低于水位时 halt。事件流内部的 RunID 不匹配、SchemaVersion/Type 不支持、同一 Revision 的 CommandID/CommandDigest 不一致、Revision/Index 缺洞或 digest 不匹配同样按日志损坏处理,halt 该 Run。 Replay 的起点是 admission 已建立的初始 `MachineState`;`RunSeed` 的 admission 记录不作为任何 Run 的 AgentEvent 重放。需要重建 admission 链时,由 Memoh 的 session/queue 记录负责。 @@ -1501,7 +1501,7 @@ Memoh -> agent.Loop + MemohRuntime ### 11.3 兼容原则 -1. provider adapter 的现有请求/响应字段优先复用。 +1. provider adapter 的现有请求/响应字段优先复用;旧 `GenerateParams` 无法表达的新 `Request` 字段(例如 `ProviderOptions`)在 fallback 到旧 provider 时必须显式报错,不能 silent drop。 2. sdk 保留旧单次调用入口,直到调用方迁移完成。 3. 旧自动 loop 只在显式 legacy wrapper 中存在,不能由新 Loop 隐式调用。 4. 旧 `WithMaxSteps(0)` 保持一次模型调用、不自动执行 tools;`n>0` 映射为 `RunConfig.ModelStepLimit=n`;旧值 `-1` 由 legacy wrapper 先规范化为 `RunConfig.ModelStepLimit=0`(无限)。agent 的 `RunConfig` 不接受负数。Memoh 当前使用 `-1`,迁移后保持无限模型步骤语义。 @@ -1516,14 +1516,14 @@ Memoh -> agent.Loop + MemohRuntime 2. 让 Generate、Stream 各自对应一次 provider request;transport retry 留在 sdk。 3. 将旧自动 loop 隔离为 legacy wrapper。 4. 保留 blocking/streaming 等价测试。 -5. 提供 additive compatibility adapters:`RequestFromGenerateParams`、`GenerateParamsFromRequest`、`ToolDefinitionFromTool`、`ToolFromDefinition`、`ToolChoiceFromLegacy`、`ModelResultFromGenerateResult`、`GenerateResultFromModelResult`、`ModelStreamFromStreamResult`;并提供可选 `ModelInvoker` / `StreamingModelInvoker`,provider interface 未整体切换前先靠这些 helper 和 optional interface 桥接新旧边界。 +5. 提供 additive compatibility adapters:`RequestFromGenerateParams`、`GenerateParamsFromRequest`、`ToolDefinitionFromTool`、`ToolFromDefinition`、`ToolChoiceFromLegacy`、`ModelResultFromGenerateResult`、`GenerateResultFromModelResult`、`ModelStreamFromStreamResult`;并提供可选 `ModelInvoker` / `StreamingModelInvoker`,provider interface 未整体切换前先靠这些 helper 和 optional interface 桥接新旧边界;旧 provider fallback 无法表达的新字段必须报错而不是丢弃。 ### 阶段 B:Machine 和 MemoryRuntime 1. 实现 `MachineState`、ModelStep、ToolStep、ToolCall 状态和 Decide/Evolve/Next 规则。 2. 实现 `EvaluateCommit`、`Runtime.Load/Commit`、command/fact digest 与幂等和 opaque grant。 3. 实现 Loop 的 model/tool/approval/response 路径和并行执行策略。 -4. 完成 MemoryRuntime conformance 测试(`agent/runtimetest`),并冻结 SchemaVersion 1 的 golden event stream。 +4. 完成 MemoryRuntime conformance 测试(`agent/runtimetest`),提供 CommandEnvelope/AgentEvent wire codec,严格 replay 校验,并冻结 SchemaVersion 1 的 golden event stream。 ### 阶段 C:Memoh storage groundwork diff --git a/sdk/request_adapter.go b/sdk/request_adapter.go index 299f570..bb7a861 100644 --- a/sdk/request_adapter.go +++ b/sdk/request_adapter.go @@ -137,6 +137,9 @@ func GenerateParamsFromRequest(model *Model, req Request) (GenerateParams, error 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) diff --git a/sdk/request_adapter_test.go b/sdk/request_adapter_test.go index 9fe71ba..514e11f 100644 --- a/sdk/request_adapter_test.go +++ b/sdk/request_adapter_test.go @@ -90,6 +90,11 @@ func TestGenerateParamsFromRequest(t *testing.T) { 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) { From a88640f6fa4f3c3cf67324f4c7abb78c5bdc0c84 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Fri, 28 Aug 2026 20:16:05 +0800 Subject: [PATCH 07/81] refactor(agent): make dynamic JSON an owned canonical type Move canonical JSON into agent/jsonstable.Value and use CanonicalJSON for persisted command, fact, state, request, result, metadata, tool argument, and response payload fields. Harden wire decoding against ambiguous JSON shapes and enforce response decision/payload digests at Decide. --- agent/alias_test.go | 53 +-- agent/arguments.go | 58 ++-- agent/canonical.go | 374 +-------------------- agent/canonical_test.go | 9 +- agent/clone.go | 15 +- agent/codec.go | 44 ++- agent/codec_test.go | 36 +- agent/command.go | 27 +- agent/commit.go | 2 +- agent/contract.go | 4 +- agent/decide.go | 28 +- agent/fact.go | 12 +- agent/jsonstable/jsonstable.go | 467 ++++++++++++++++++++++++++ agent/loop_test.go | 15 +- agent/machine_test.go | 92 +++-- agent/model_data.go | 93 ++--- agent/protocol.go | 53 ++- agent/rebuild_test.go | 5 +- agent/regression_test.go | 58 ++-- agent/runtime_helpers_test.go | 3 +- agent/runtimetest/conformance.go | 19 +- agent/state.go | 38 ++- docs/design/agent-runtime-refactor.md | 51 +-- 23 files changed, 898 insertions(+), 658 deletions(-) create mode 100644 agent/jsonstable/jsonstable.go diff --git a/agent/alias_test.go b/agent/alias_test.go index 62418ea..a8952a7 100644 --- a/agent/alias_test.go +++ b/agent/alias_test.go @@ -2,28 +2,31 @@ package agent import ( "context" - "encoding/json" "testing" "github.com/memohai/twilight-ai/sdk" ) func TestMemoryRuntimeClonesInitialState(t *testing.T) { - payload := json.RawMessage(`{"q":"hi"}`) + raw := []byte(`{"q":"hi"}`) + payload, err := ParseCanonicalJSON(raw) + if err != nil { + t.Fatal(err) + } initial, err := Initialize("run-1", testConfig(), NextRun(AgentInput{ID: "seed", Payload: payload})) if err != nil { t.Fatal(err) } rt := NewMemoryRuntime(initial) - copy(payload, []byte(`{"q":"no"}`)) - initial.PendingInputs[0].Payload[6] = 'x' + copy(raw, []byte(`{"q":"no"}`)) + initial.PendingInputs[0].Payload = cj(`{"q":"mutated"}`) snap, err := rt.Load(context.Background()) if err != nil { t.Fatal(err) } - if got := string(snap.State.PendingInputs[0].Payload); got != `{"q":"hi"}` { + if got := snap.State.PendingInputs[0].Payload.String(); got != `{"q":"hi"}` { t.Fatalf("runtime initial payload aliased caller state: %s", got) } diverged, err := rt.Rebuild() @@ -37,11 +40,15 @@ func TestMemoryRuntimeClonesInitialState(t *testing.T) { func TestCommitSnapshotsCommandPayloadBeforeFoldingState(t *testing.T) { rt := newTestRuntime(t, RunConfig{Model: "m-1"}) - payload := json.RawMessage(`{"v":"one"}`) + raw := []byte(`{"v":"one"}`) + payload, err := ParseCanonicalJSON(raw) + if err != nil { + t.Fatal(err) + } cmdID := DeriveInputCommandID("run-1", "in-1") mustCommit(t, rt, cmdID, 0, "", AcceptInput{Input: AgentInput{ID: "in-1", Payload: payload}}) - copy(payload, []byte(`{"v":"two"}`)) + copy(raw, []byte(`{"v":"two"}`)) snap, err := rt.Load(context.Background()) if err != nil { @@ -51,7 +58,7 @@ func TestCommitSnapshotsCommandPayloadBeforeFoldingState(t *testing.T) { for _, in := range snap.State.PendingInputs { if in.ID == "in-1" { found = true - if got := string(in.Payload); got != `{"v":"one"}` { + if got := in.Payload.String(); got != `{"v":"one"}` { t.Fatalf("state payload aliased command buffer: %s", got) } } @@ -63,7 +70,7 @@ func TestCommitSnapshotsCommandPayloadBeforeFoldingState(t *testing.T) { events := rt.Events() for _, e := range events { if f, ok := e.Fact.(InputAccepted); ok && f.Input.ID == "in-1" { - if got := string(f.Input.Payload); got != `{"v":"one"}` { + if got := f.Input.Payload.String(); got != `{"v":"one"}` { t.Fatalf("event payload aliased command buffer: %s", got) } } @@ -82,8 +89,8 @@ func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { snap, _ := rt.Load(context.Background()) req := ModelRequest{ Model: "m-1", - ProviderOptions: map[string]json.RawMessage{ - "p": json.RawMessage(`{"b":2,"a":1}`), + ProviderOptions: map[string]CanonicalJSON{ + "p": cj(`{"b":2,"a":1}`), }, } reqDigest, err := DigestRequest(req) @@ -113,12 +120,12 @@ func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { }) ms := res.Snapshot.State.Current.(ModelStep) - if got := string(ms.Request.ProviderOptions["p"]); got != `{"a":1,"b":2}` { + if got := ms.Request.ProviderOptions["p"].String(); got != `{"a":1,"b":2}` { t.Fatalf("snapshot stored non-canonical provider option: %s", got) } for _, e := range rt.Events() { if f, ok := e.Fact.(ModelStepPrepared); ok { - if got := string(f.Request.ProviderOptions["p"]); got != `{"a":1,"b":2}` { + if got := f.Request.ProviderOptions["p"].String(); got != `{"a":1,"b":2}` { t.Fatalf("event stored non-canonical provider option: %s", got) } } @@ -149,8 +156,8 @@ func TestLoadSnapshotDoesNotAliasFrozenRequest(t *testing.T) { ms := snap.State.Current.(ModelStep) part := ms.Request.Messages[0].Content[0] part.Text = "edited" - part.ProviderMetadata["provider"] = json.RawMessage(`{"sig":"bad"}`) - part.ProviderMetadata["new"] = json.RawMessage(`"bad"`) + part.ProviderMetadata["provider"] = cj(`{"sig":"bad"}`) + part.ProviderMetadata["new"] = cj(`"bad"`) ms.Request.Messages[0].Content[0] = part snap, err = rt.Load(context.Background()) @@ -161,7 +168,7 @@ func TestLoadSnapshotDoesNotAliasFrozenRequest(t *testing.T) { if got.Text != "hi" { t.Fatalf("request content aliased Load snapshot: %q", got.Text) } - if sig := string(got.ProviderMetadata["provider"]); sig != `{"sig":"s1"}` { + if sig := got.ProviderMetadata["provider"].String(); sig != `{"sig":"s1"}` { t.Fatalf("request metadata aliased Load snapshot: %v", sig) } if _, ok := got.ProviderMetadata["new"]; ok { @@ -196,9 +203,9 @@ func TestCommitResultEventsDoNotAliasStateOrLog(t *testing.T) { res := mustCommit(t, rt, "done-1", 2, grant, SubmitModelResult{StepID: stepID, Result: frozen}) fact := res.Events[0].Fact.(ModelStepCompleted) - fact.Result.ReasoningParts[0].ProviderMetadata["anthropic"] = json.RawMessage(`{"signature":"bad"}`) - fact.Result.TextProviderMetadata["google"] = json.RawMessage(`{"thoughtSignature":"bad"}`) - fact.Result.Sources[0].ProviderMetadata["p"] = json.RawMessage(`"bad"`) + fact.Result.ReasoningParts[0].ProviderMetadata["anthropic"] = cj(`{"signature":"bad"}`) + fact.Result.TextProviderMetadata["google"] = cj(`{"thoughtSignature":"bad"}`) + fact.Result.Sources[0].ProviderMetadata["p"] = cj(`"bad"`) fact.Result.Response.Headers["h"] = "bad" res.Events[0].Fact = fact @@ -210,13 +217,13 @@ func TestCommitResultEventsDoNotAliasStateOrLog(t *testing.T) { if last == nil { t.Fatal("missing LastModelResult") } - if sig := string(last.ReasoningParts[0].ProviderMetadata["anthropic"]); sig != `{"signature":"s1"}` { + if sig := last.ReasoningParts[0].ProviderMetadata["anthropic"].String(); sig != `{"signature":"s1"}` { t.Fatalf("state reasoning metadata aliased returned event: %v", sig) } - if sig := string(last.TextProviderMetadata["google"]); sig != `{"thoughtSignature":"g1"}` { + if sig := last.TextProviderMetadata["google"].String(); sig != `{"thoughtSignature":"g1"}` { t.Fatalf("state text metadata aliased returned event: %v", sig) } - if p := string(last.Sources[0].ProviderMetadata["p"]); p != `"v"` { + if p := last.Sources[0].ProviderMetadata["p"].String(); p != `"v"` { t.Fatalf("state source metadata aliased returned event: %v", p) } if h := last.Response.Headers["h"]; h != "v" { @@ -225,7 +232,7 @@ func TestCommitResultEventsDoNotAliasStateOrLog(t *testing.T) { for _, e := range rt.Events() { if f, ok := e.Fact.(ModelStepCompleted); ok { - if sig := string(f.Result.ReasoningParts[0].ProviderMetadata["anthropic"]); sig != `{"signature":"s1"}` { + if sig := f.Result.ReasoningParts[0].ProviderMetadata["anthropic"].String(); sig != `{"signature":"s1"}` { t.Fatalf("log reasoning metadata aliased returned event: %v", sig) } if h := f.Result.Response.Headers["h"]; h != "v" { diff --git a/agent/arguments.go b/agent/arguments.go index fa1a888..352ea6a 100644 --- a/agent/arguments.go +++ b/agent/arguments.go @@ -1,53 +1,55 @@ package agent -import ( - "encoding/json" - "fmt" -) +import "encoding/json" func jsonMarshal(v any) ([]byte, error) { return json.Marshal(v) } -// canonicalToolArguments renders a model-provided tool input as canonical -// JSON bytes for binding digests. Failure means the arguments are not valid -// JSON; the caller binds them raw and lets validation fail as +// 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) (json.RawMessage, error) { +func canonicalToolArguments(input any) (CanonicalJSON, error) { switch x := input.(type) { case nil: - return json.RawMessage("null"), nil + return ParseCanonicalJSON([]byte("null")) + case CanonicalJSON: + return x, nil case json.RawMessage: - return canonicalJSON(x) + return ParseCanonicalJSON(x) case string: // Providers deliver unparsed argument text as a string. if len(x) == 0 { - return json.RawMessage("null"), nil + return ParseCanonicalJSON([]byte("null")) } - return canonicalJSON([]byte(x)) + return ParseCanonicalJSON([]byte(x)) default: - raw, err := json.Marshal(x) - if err != nil { - return nil, fmt.Errorf("agent: tool arguments: %w", err) - } - return canonicalJSON(raw) + 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) json.RawMessage { +// 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: - return json.RawMessage("null") + raw = []byte("null") + case CanonicalJSON: + return x case json.RawMessage: - quoted, _ := json.Marshal(string(x)) - return quoted + raw, _ = json.Marshal(string(x)) case string: - quoted, _ := json.Marshal(x) - return quoted + raw, _ = json.Marshal(x) default: - if raw, err := json.Marshal(x); err == nil { - return raw + var err error + raw, err = json.Marshal(x) + if err != nil { + raw = []byte("null") } - return json.RawMessage("null") } + v, err := ParseCanonicalJSON(raw) + if err != nil { + return MustParseCanonicalJSON("null") + } + return v } diff --git a/agent/canonical.go b/agent/canonical.go index 4a2b409..9b9eddb 100644 --- a/agent/canonical.go +++ b/agent/canonical.go @@ -6,376 +6,30 @@ package agent import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "math" - "sort" - "strconv" - "strings" - "unicode/utf16" - "unicode/utf8" + "github.com/memohai/twilight-ai/agent/jsonstable" ) -// canonicalJSON transforms JSON into the protocol's canonical form: RFC 8785 -// (JCS) object-key ordering and string escaping, with two deliberate -// deviations required for digest identity — integer tokens keep arbitrary -// precision instead of collapsing through float64, and inputs JCS tolerates -// but that would merge distinct payloads into one digest (duplicate object -// keys, trailing data, invalid UTF-8) are rejected outright. -// -// Canonical bytes are digest input (spec §5.5); the encoding for a published -// SchemaVersion is frozen forever. -func canonicalJSON(raw []byte) ([]byte, error) { - // Reject invalid UTF-8 up front: encoding/json silently rewrites broken - // bytes to U+FFFD during decode, which would merge distinct payloads into - // one canonical identity before writeCanonicalString could see them. - if !utf8.Valid(raw) { - return nil, errors.New("agent: canonical: invalid UTF-8 input") - } - // encoding/json also rewrites escaped lone surrogates (\ud800..\udfff) to - // U+FFFD before writeCanonicalString sees them. Reject them while the raw - // escape structure is still visible; otherwise distinct payloads collapse - // into the same digest. - if err := rejectEscapedLoneSurrogates(raw); err != nil { - return nil, err - } - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.UseNumber() - v, err := parseCanonicalValue(dec) - if err != nil { - return nil, fmt.Errorf("agent: canonical: %w", err) - } - // Strict end: any trailing token — including a stray ']' or '}' the - // decoder's More() would miss — rejects the input. - if _, err := dec.Token(); err != io.EOF { - return nil, errors.New("agent: canonical: trailing data after JSON value") - } - var b bytes.Buffer - if err := writeCanonical(&b, v); err != nil { - return nil, err - } - return b.Bytes(), nil -} - -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 JSON decoder report syntax - } - if raw[i+1] != 'u' && raw[i+1] != 'U' { - i += 2 - continue - } - if i+6 > len(raw) { - return nil // let the JSON decoder report syntax - } - code, ok := parseHex4(raw[i+2 : i+6]) - if !ok { - return nil // let the JSON decoder 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 -} +// 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 -// parseCanonicalValue decodes one JSON value from the token stream, rejecting -// duplicate object keys: last-wins collapsing would let two byte-distinct -// payloads share one digest. -func parseCanonicalValue(dec *json.Decoder) (any, error) { - tok, err := dec.Token() - if err != nil { - return nil, err - } - return parseFromToken(dec, tok) +func ParseCanonicalJSON(raw []byte) (CanonicalJSON, error) { + return jsonstable.Parse(raw) } -func parseFromToken(dec *json.Decoder, tok json.Token) (any, error) { - delim, ok := tok.(json.Delim) - if !ok { - return tok, nil // string, json.Number, bool, or nil - } - switch delim { - case '{': - m := make(map[string]any) - for dec.More() { - keyTok, err := dec.Token() - if err != nil { - return nil, err - } - key, ok := keyTok.(string) - if !ok { - return nil, fmt.Errorf("object key %v is not a string", keyTok) - } - if _, dup := m[key]; dup { - return nil, fmt.Errorf("duplicate object key %q", key) - } - val, err := parseCanonicalValue(dec) - if err != nil { - return nil, err - } - m[key] = val - } - if _, err := dec.Token(); err != nil { // consume '}' - return nil, err - } - return m, nil - case '[': - a := []any{} - for dec.More() { - val, err := parseCanonicalValue(dec) - if err != nil { - return nil, err - } - a = append(a, val) - } - if _, err := dec.Token(); err != nil { // consume ']' - return nil, err - } - return a, nil - default: - return nil, fmt.Errorf("unexpected delimiter %v", delim) - } +func CanonicalJSONFromValue(v any) (CanonicalJSON, error) { + return jsonstable.FromValue(v) } -func writeCanonical(b *bytes.Buffer, v any) error { - switch x := v.(type) { - case nil: - b.WriteString("null") - case bool: - if x { - b.WriteString("true") - } else { - b.WriteString("false") - } - case json.Number: - s, err := canonicalNumber(x) - if err != nil { - return err - } - b.WriteString(s) - case string: - if err := writeCanonicalString(b, x); err != nil { - return err - } - case []any: - b.WriteByte('[') - for i, e := range x { - if i > 0 { - b.WriteByte(',') - } - if err := writeCanonical(b, e); err != nil { - return err - } - } - b.WriteByte(']') - case map[string]any: - keys := make([]string, 0, len(x)) - for k := range x { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { return utf16Less(keys[i], keys[j]) }) - b.WriteByte('{') - for i, k := range keys { - if i > 0 { - b.WriteByte(',') - } - if err := writeCanonicalString(b, k); err != nil { - return err - } - b.WriteByte(':') - if err := writeCanonical(b, x[k]); err != nil { - return err - } - } - b.WriteByte('}') - default: - return fmt.Errorf("agent: canonical: unsupported value %T", v) - } - return nil +func MustParseCanonicalJSON(raw string) CanonicalJSON { + return jsonstable.MustParse(raw) } -// utf16Less orders strings by their UTF-16 code units (RFC 8785 §3.2.3). -func utf16Less(a, b string) bool { - ua := utf16.Encode([]rune(a)) - ub := utf16.Encode([]rune(b)) - for i := 0; i < len(ua) && i < len(ub); i++ { - if ua[i] != ub[i] { - return ua[i] < ub[i] - } - } - return len(ua) < len(ub) -} - -// writeCanonicalString emits a JSON string with JCS minimal escaping. Invalid -// UTF-8 is rejected: silently replacing broken bytes with U+FFFD would merge -// distinct payloads into one canonical identity. -func writeCanonicalString(b *bytes.Buffer, s string) error { - b.WriteByte('"') - for i, r := range s { - switch r { - case '"': - b.WriteString(`\"`) - case '\\': - b.WriteString(`\\`) - case '\b': - b.WriteString(`\b`) - case '\t': - b.WriteString(`\t`) - case '\n': - b.WriteString(`\n`) - case '\f': - b.WriteString(`\f`) - case '\r': - b.WriteString(`\r`) - default: - if r < 0x20 { - fmt.Fprintf(b, `\u%04x`, r) - } else if r == utf8.RuneError { - if _, size := utf8.DecodeRuneInString(s[i:]); size == 1 { - return errors.New("agent: canonical: invalid UTF-8 in string") - } - b.WriteRune(r) // a genuine U+FFFD character - } else { - b.WriteRune(r) - } - } - } - b.WriteByte('"') - return nil -} - -// canonicalNumber renders a JSON number. Integer tokens keep their exact -// digits (arbitrary precision): forcing them through float64 per strict JCS -// would corrupt 64-bit identifiers above 2^53 and collide near-adjacent -// values into one digest. Non-integer tokens use the ES6 double form. -func canonicalNumber(n json.Number) (string, error) { - s := n.String() - if !strings.ContainsAny(s, ".eE") { - neg := strings.HasPrefix(s, "-") - digits := strings.TrimPrefix(s, "-") - digits = strings.TrimLeft(digits, "0") - if digits == "" { - return "0", nil // 0 and -0 canonicalize to "0" - } - if neg { - return "-" + digits, nil - } - return digits, nil - } - f, err := strconv.ParseFloat(s, 64) - if err != nil { - return "", fmt.Errorf("agent: canonical: bad number %q: %w", s, err) - } - return formatES6Float(f) -} - -// formatES6Float implements ES6 Number::toString for finite doubles. -func formatES6Float(x float64) (string, error) { - if math.IsNaN(x) || math.IsInf(x, 0) { - return "", errors.New("agent: canonical: non-finite number") - } - if x == 0 { - return "0", nil // negative zero canonicalizes to "0" - } - neg := math.Signbit(x) - if neg { - x = -x - } - // Shortest round-trip digits in exponential form: d[.ddd]e±dd - s := strconv.FormatFloat(x, 'e', -1, 64) - ePos := strings.IndexByte(s, 'e') - mant := strings.Replace(s[:ePos], ".", "", 1) - exp, err := strconv.Atoi(s[ePos+1:]) - if err != nil { - return "", err - } - k := len(mant) - n := exp + 1 // value = 0.d1..dk × 10^n - - var out string - switch { - case k <= n && n <= 21: - out = mant + strings.Repeat("0", n-k) - case 0 < n && n <= 21: - out = mant[:n] + "." + mant[n:] - case -6 < n && n <= 0: - out = "0." + strings.Repeat("0", -n) + mant - default: - e := n - 1 - m := mant[:1] - if k > 1 { - m += "." + mant[1:] - } - if e < 0 { - out = m + "e-" + strconv.Itoa(-e) - } else { - out = m + "e+" + strconv.Itoa(e) - } - } - if neg { - out = "-" + out - } - return out, nil +func canonicalJSON(raw []byte) ([]byte, error) { + return jsonstable.Canonicalize(raw) } -// marshalCanonical marshals a Go value with encoding/json and canonicalizes -// the result. 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 canonicalJSON(raw) + return jsonstable.MarshalCanonical(v) } diff --git a/agent/canonical_test.go b/agent/canonical_test.go index b775b5e..9a75167 100644 --- a/agent/canonical_test.go +++ b/agent/canonical_test.go @@ -1,7 +1,6 @@ package agent import ( - "encoding/json" "strings" "testing" ) @@ -151,18 +150,18 @@ func TestDeriveResponseIDPerKind(t *testing.T) { } func TestDigestBindingCanonicalizesArguments(t *testing.T) { - d1, err := digestToolCallBinding("c1", "sha256:x", DirectExecution, []byte(`{"b":1,"a":2}`)) + 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, []byte(`{ "a" : 2, "b" : 1 }`)) + 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, []byte(`{"a":2,"b":1}`)) + d3, _ := digestToolCallBinding("c1", "sha256:x", ApprovalRequired, cj(`{"a":2,"b":1}`)) if d1 == d3 { t.Fatal("policy does not affect binding digest") } @@ -192,7 +191,7 @@ func TestSchemaVersion1Golden(t *testing.T) { t.Fatalf("golden digest changed:\n got %s\nwant %s", d, wantDigest) } - fact := InputAccepted{Input: AgentInput{ID: "in-1", Payload: json.RawMessage(`{"text":"hi"}`)}} + fact := InputAccepted{Input: AgentInput{ID: "in-1", Payload: cj(`{"text":"hi"}`)}} fbody, err := EncodeFact(SchemaVersion1, "input_accepted", fact) if err != nil { t.Fatal(err) diff --git a/agent/clone.go b/agent/clone.go index b9f368a..270d247 100644 --- a/agent/clone.go +++ b/agent/clone.go @@ -3,19 +3,14 @@ package agent import "encoding/json" // Deep-copy helpers: Runtime return values must be read-only snapshots (spec -// appendix A) — a caller mutating a returned slice, map, or json.RawMessage -// must never reach authoritative storage or committed event bytes. +// appendix A) — 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, copy slice/map containers, and copy json.RawMessage byte slices. +// structs and copy slice/map containers. CanonicalJSON values are immutable. -func cloneRaw(m json.RawMessage) json.RawMessage { - if m == nil { - return nil - } - return append(json.RawMessage(nil), m...) -} +func cloneRaw(v CanonicalJSON) CanonicalJSON { return v } func clonePtr[T any](p *T) *T { if p == nil { @@ -180,7 +175,7 @@ func cloneRequest(r ModelRequest) ModelRequest { r.PromptCacheKey = clonePtr(r.PromptCacheKey) r.StopSequences = append([]string(nil), r.StopSequences...) if r.ProviderOptions != nil { - opts := make(map[string]json.RawMessage, len(r.ProviderOptions)) + opts := make(map[string]CanonicalJSON, len(r.ProviderOptions)) for k, v := range r.ProviderOptions { opts[k] = cloneRaw(v) } diff --git a/agent/codec.go b/agent/codec.go index 1dc3e9e..5b20443 100644 --- a/agent/codec.go +++ b/agent/codec.go @@ -114,6 +114,16 @@ func (e *CommandEnvelope) UnmarshalJSON(raw []byte) error { if wire.Digest != want { return fmt.Errorf("agent: codec: command digest mismatch: got %s want %s", wire.Digest, want) } + if err := requireCanonicalEquivalent(raw, commandEnvelopeMarshal{ + SchemaVersion: wire.SchemaVersion, + Type: wire.Type, + RunID: wire.RunID, + ID: wire.ID, + Digest: wire.Digest, + Command: cmd, + }); err != nil { + return err + } *e = CommandEnvelope{ SchemaVersion: wire.SchemaVersion, Type: wire.Type, @@ -171,6 +181,19 @@ func (e *AgentEvent) UnmarshalJSON(raw []byte) error { if wire.Digest != want { return fmt.Errorf("agent: codec: fact digest mismatch: got %s want %s", wire.Digest, want) } + if err := requireCanonicalEquivalent(raw, agentEventMarshal{ + SchemaVersion: wire.SchemaVersion, + Type: wire.Type, + RunID: wire.RunID, + Revision: wire.Revision, + Index: wire.Index, + CommandID: wire.CommandID, + CommandDigest: wire.CommandDigest, + Digest: wire.Digest, + Fact: fact, + }); err != nil { + return err + } *e = AgentEvent{ SchemaVersion: wire.SchemaVersion, Type: wire.Type, @@ -189,8 +212,27 @@ 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 { - dec := json.NewDecoder(bytes.NewReader(raw)) + 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 diff --git a/agent/codec_test.go b/agent/codec_test.go index adabc48..c297b55 100644 --- a/agent/codec_test.go +++ b/agent/codec_test.go @@ -2,6 +2,7 @@ package agent import ( "encoding/json" + "fmt" "reflect" "strings" "testing" @@ -16,13 +17,13 @@ func TestCommandEnvelopeJSONRoundTripRestoresVariants(t *testing.T) { 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"}, - SubmitToolResult{StepID: "ts", CallID: "c", Result: ToolExecutionResult{Output: json.RawMessage(`{"ok":true}`)}}, + 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: json.RawMessage(`{"answer":1}`)}, + SubmitToolResponse{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp", Payload: cj(`{"answer":1}`)}, CancelRun{}, - AcceptInput{Input: AgentInput{ID: "in", Payload: json.RawMessage(`{"q":"hi"}`)}}, + AcceptInput{Input: AgentInput{ID: "in", Payload: cj(`{"q":"hi"}`)}}, } for _, cmd := range commands { env, err := BuildEnvelope("run-1", CommandID("cmd-"+commandType(cmd)), cmd) @@ -53,14 +54,14 @@ func TestAgentEventJSONRoundTripRestoresVariants(t *testing.T) { ModelStepRecovered{StepID: "s"}, ModelStepRejected{StepID: "s", Usage: Usage{TotalTokens: 1}, Failure: StepFailure{Class: FailureMalformedModel}}, ModelStepCompleted{StepID: "s", Result: ModelResult{Text: "ok"}}, - ToolStepOpened{StepID: "ts", Source: "s", BindingSetDigest: "sha256:set", Calls: []ToolCallBinding{{CallID: "c", ToolRef: "t", BindingDigest: "sha256:binding", Arguments: json.RawMessage(`{}`), Policy: DirectExecution}}}, + 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", Result: ToolExecutionResult{Output: json.RawMessage(`{"ok":true}`)}}, - ToolCallAnswered{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp", Payload: json.RawMessage(`{"answer":1}`)}, + ToolCallCompleted{StepID: "ts", CallID: "c", Result: ToolExecutionResult{Output: cj(`{"ok":true}`)}}, + ToolCallAnswered{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp", Payload: cj(`{"answer":1}`)}, ToolCallFailed{StepID: "ts", CallID: "c", Failure: ToolFailure{Class: FailureExecution}, Outcome: ToolOutcomeKnown}, ToolStepClosed{StepID: "ts"}, - InputAccepted{Input: AgentInput{ID: "in", Payload: json.RawMessage(`{"q":"hi"}`)}}, + InputAccepted{Input: AgentInput{ID: "in", Payload: cj(`{"q":"hi"}`)}}, RunEnded{Status: RunCompleted}, } for i, fact := range facts { @@ -97,6 +98,27 @@ func TestAgentEventJSONRoundTripRestoresVariants(t *testing.T) { } } +func TestWireCodecRejectsAmbiguousJSONBeforeVariantDecode(t *testing.T) { + cmd := AcceptInput{Input: AgentInput{ID: "in", Payload: cj(`1`)}} + env, err := BuildEnvelope("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,"digest":%q,"command":{"input":{"id":"in","payload":1},"input":{"id":"in","payload":1}}}`, env.ID, env.Digest)) + 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 TestWireCodecRejectsUnknownTypeAndDigestMismatch(t *testing.T) { env, err := BuildEnvelope("run-1", "cmd-1", CancelRun{}) if err != nil { diff --git a/agent/command.go b/agent/command.go index 825e27f..dd6df23 100644 --- a/agent/command.go +++ b/agent/command.go @@ -1,7 +1,5 @@ package agent -import "encoding/json" - // AgentCommand is the intent submitted through Runtime.Commit for an existing // Run. Accepting one command constitutes one transition (spec §3.6). The // interface is sealed: only the fourteen variants below exist. @@ -10,8 +8,8 @@ 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 json.RawMessage `json:"payload"` + ID InputID `json:"id"` + Payload CanonicalJSON `json:"payload"` } // NextStep creates the command consumed by an active Run at a safe boundary. @@ -120,8 +118,8 @@ type SubmitToolFailure struct { func (SubmitToolFailure) agentCommand() {} -// ApproveToolCall approves a Waiting(Approval) call. ResponseDigest is the -// canonical digest of the approval decision payload. +// ApproveToolCall approves a Waiting(Approval) call. ResponseDigest must be +// DigestToolResponseDecision(ResponseApproval, ResponseDecisionApproved, ""). type ApproveToolCall struct { StepID StepID `json:"stepId"` CallID CallID `json:"callId"` @@ -131,8 +129,9 @@ type ApproveToolCall struct { func (ApproveToolCall) agentCommand() {} -// RejectToolCall rejects a Waiting(Approval) call. Decide records the outcome -// as ToolCallFailed{Known, permission_denied}. +// RejectToolCall rejects a Waiting(Approval or ExternalResponse) call. Decide +// records the outcome as ToolCallFailed{Known, permission_denied}. ResponseDigest +// must be DigestToolResponseDecision(waiting kind, ResponseDecisionRejected, Reason). type RejectToolCall struct { StepID StepID `json:"stepId"` CallID CallID `json:"callId"` @@ -144,13 +143,13 @@ type RejectToolCall struct { func (RejectToolCall) agentCommand() {} // SubmitToolResponse completes a Waiting(ExternalResponse) call with the -// external answer. +// 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 json.RawMessage `json:"payload"` + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + ResponseID ResponseID `json:"responseId"` + ResponseDigest Digest `json:"responseDigest"` + Payload CanonicalJSON `json:"payload"` } func (SubmitToolResponse) agentCommand() {} diff --git a/agent/commit.go b/agent/commit.go index 1665e8f..2e737f0 100644 --- a/agent/commit.go +++ b/agent/commit.go @@ -225,7 +225,7 @@ func EvaluateCommit( for i, f := range facts { // Detach every fact before it is folded or wrapped as an event. Decide // often forwards fields from the caller's command (ModelRequest, - // ModelResult, json.RawMessage payloads); the commit decision must not + // ModelResult, CanonicalJSON payloads); the commit decision must not // carry caller-owned mutable objects across the Runtime boundary. f, err = snapshotFact(f) if err != nil { diff --git a/agent/contract.go b/agent/contract.go index 96cadae..4182c56 100644 --- a/agent/contract.go +++ b/agent/contract.go @@ -49,7 +49,7 @@ type ToolExecutionRequest struct { CallID CallID ToolRef ToolRef DefinitionDigest Digest - Arguments json.RawMessage + Arguments CanonicalJSON Progress ToolProgressSink } @@ -60,7 +60,7 @@ type ExecutableTool interface { ResponsePolicy() ResponsePolicy // ValidateArguments runs before the start barrier and must not produce // external effects. - ValidateArguments(json.RawMessage) error + ValidateArguments(CanonicalJSON) error Execute(context.Context, ToolExecutionRequest) ToolExecutionOutcome } diff --git a/agent/decide.go b/agent/decide.go index 9361b8b..0567af9 100644 --- a/agent/decide.go +++ b/agent/decide.go @@ -249,8 +249,7 @@ func decideSubmitModelResult(s MachineState, cmd SubmitModelResult) ([]Fact, err if !argsCanonical { return nil, rejectionf("model result: call %q input is not frozen canonical JSON", b.CallID) } - gotArgs, err := canonicalJSON(b.Arguments) - if err != nil || string(gotArgs) != string(wantArgs) { + if !b.Arguments.Equal(wantArgs) { return nil, 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) @@ -301,10 +300,10 @@ func decideSubmitModelResult(s MachineState, cmd SubmitModelResult) ([]Fact, err // 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) ([]byte, bool) { +func canonicalArgumentsForCompare(input any) (CanonicalJSON, bool) { got, err := canonicalToolArguments(input) if err != nil { - return nil, false + return CanonicalJSON{}, false } return got, true } @@ -485,6 +484,13 @@ 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 := DigestToolResponseDecision(ResponseApproval, ResponseDecisionApproved, "") + if err != nil { + return nil, err + } + if cmd.ResponseDigest != wantDigest { + return nil, rejectionf("response: approval digest mismatch") + } return []Fact{ToolCallApproved{StepID: cmd.StepID, CallID: cmd.CallID, ResponseID: cmd.ResponseID, ResponseDigest: cmd.ResponseDigest}}, nil } @@ -509,6 +515,13 @@ func decideRejectToolCall(s MachineState, cmd RejectToolCall) ([]Fact, error) { 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 := DigestToolResponseDecision(c.Waiting.Kind, ResponseDecisionRejected, cmd.Reason) + if err != nil { + return nil, err + } + if cmd.ResponseDigest != wantDigest { + return nil, rejectionf("response: rejection digest mismatch") + } facts := []Fact{ToolCallFailed{ StepID: cmd.StepID, CallID: cmd.CallID, @@ -522,6 +535,13 @@ func decideSubmitToolResponse(s MachineState, cmd SubmitToolResponse) ([]Fact, e if _, err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseExternal, cmd.ResponseID); err != nil { return nil, err } + wantDigest, err := DigestToolResponsePayload(cmd.Payload) + if err != nil { + return nil, err + } + if cmd.ResponseDigest != wantDigest { + return nil, rejectionf("response: answer payload digest mismatch") + } ts, _ := currentToolStep(s, cmd.StepID) i := ts.callIndex(cmd.CallID) facts := []Fact{ToolCallAnswered{ diff --git a/agent/fact.go b/agent/fact.go index 765dee5..0b87108 100644 --- a/agent/fact.go +++ b/agent/fact.go @@ -1,7 +1,5 @@ package agent -import "encoding/json" - // Fact is one committed outcome produced by Machine.Decide. Facts are wrapped // as AgentEvents; Machine.Evolve folds them mechanically (spec §3.6). The // interface is sealed: only the fourteen variants below exist. @@ -100,11 +98,11 @@ func (ToolCallCompleted) fact() {} // ToolCallAnswered: Waiting(ExternalResponse) -> Completed with the answer. type ToolCallAnswered struct { - StepID StepID `json:"stepId"` - CallID CallID `json:"callId"` - ResponseID ResponseID `json:"responseId"` - ResponseDigest Digest `json:"responseDigest"` - Payload json.RawMessage `json:"payload"` + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + ResponseID ResponseID `json:"responseId"` + ResponseDigest Digest `json:"responseDigest"` + Payload CanonicalJSON `json:"payload"` } func (ToolCallAnswered) fact() {} diff --git a/agent/jsonstable/jsonstable.go b/agent/jsonstable/jsonstable.go new file mode 100644 index 0000000..ee0a00f --- /dev/null +++ b/agent/jsonstable/jsonstable.go @@ -0,0 +1,467 @@ +// Package jsonstable provides immutable canonical 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" + "io" + "math" + "sort" + "strconv" + "strings" + "unicode/utf16" + "unicode/utf8" +) + +// 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 canonical representation. A nil +// slice returns the zero Value; an empty but non-nil slice is invalid JSON. +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 the protocol's canonical form: RFC 8785 +// (JCS) object-key ordering and string escaping, with two deliberate +// deviations required for digest identity — integer tokens keep arbitrary +// precision instead of collapsing through float64, and inputs JCS tolerates +// but that would merge distinct payloads into one digest (duplicate object +// keys, trailing data, invalid UTF-8) are rejected outright. +func Canonicalize(raw []byte) ([]byte, error) { + // Reject invalid UTF-8 up front: encoding/json silently rewrites broken + // bytes to U+FFFD during decode, which would merge distinct payloads into + // one canonical identity before writeCanonicalString could see them. + if !utf8.Valid(raw) { + return nil, errors.New("agent: canonical: invalid UTF-8 input") + } + // encoding/json also rewrites escaped lone surrogates (\ud800..\udfff) to + // U+FFFD before writeCanonicalString sees them. Reject them while the raw + // escape structure is still visible; otherwise distinct payloads collapse + // into the same digest. + if err := rejectEscapedLoneSurrogates(raw); err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + v, err := parseCanonicalValue(dec) + if err != nil { + return nil, fmt.Errorf("agent: canonical: %w", err) + } + // Strict end: any trailing token — including a stray ']' or '}' the + // decoder's More() would miss — rejects the input. + if _, err := dec.Token(); err != io.EOF { + return nil, errors.New("agent: canonical: trailing data after JSON value") + } + var b bytes.Buffer + if err := writeCanonical(&b, v); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +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 JSON decoder report syntax + } + if raw[i+1] != 'u' && raw[i+1] != 'U' { + i += 2 + continue + } + if i+6 > len(raw) { + return nil // let the JSON decoder report syntax + } + code, ok := parseHex4(raw[i+2 : i+6]) + if !ok { + return nil // let the JSON decoder 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 +} + +// parseCanonicalValue decodes one JSON value from the token stream, rejecting +// duplicate object keys: last-wins collapsing would let two byte-distinct +// payloads share one digest. +func parseCanonicalValue(dec *json.Decoder) (any, error) { + tok, err := dec.Token() + if err != nil { + return nil, err + } + return parseFromToken(dec, tok) +} + +func parseFromToken(dec *json.Decoder, tok json.Token) (any, error) { + delim, ok := tok.(json.Delim) + if !ok { + return tok, nil // string, json.Number, bool, or nil + } + switch delim { + case '{': + m := make(map[string]any) + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, err + } + key, ok := keyTok.(string) + if !ok { + return nil, fmt.Errorf("object key %v is not a string", keyTok) + } + if _, dup := m[key]; dup { + return nil, fmt.Errorf("duplicate object key %q", key) + } + val, err := parseCanonicalValue(dec) + if err != nil { + return nil, err + } + m[key] = val + } + if _, err := dec.Token(); err != nil { // consume '}' + return nil, err + } + return m, nil + case '[': + a := []any{} + for dec.More() { + val, err := parseCanonicalValue(dec) + if err != nil { + return nil, err + } + a = append(a, val) + } + if _, err := dec.Token(); err != nil { // consume ']' + return nil, err + } + return a, nil + default: + return nil, fmt.Errorf("unexpected delimiter %v", delim) + } +} + +func writeCanonical(b *bytes.Buffer, v any) error { + switch x := v.(type) { + case nil: + b.WriteString("null") + case bool: + if x { + b.WriteString("true") + } else { + b.WriteString("false") + } + case json.Number: + s, err := canonicalNumber(x) + if err != nil { + return err + } + b.WriteString(s) + case string: + if err := writeCanonicalString(b, x); err != nil { + return err + } + case []any: + b.WriteByte('[') + for i, e := range x { + if i > 0 { + b.WriteByte(',') + } + if err := writeCanonical(b, e); err != nil { + return err + } + } + b.WriteByte(']') + case map[string]any: + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return utf16Less(keys[i], keys[j]) }) + b.WriteByte('{') + for i, k := range keys { + if i > 0 { + b.WriteByte(',') + } + if err := writeCanonicalString(b, k); err != nil { + return err + } + b.WriteByte(':') + if err := writeCanonical(b, x[k]); err != nil { + return err + } + } + b.WriteByte('}') + default: + return fmt.Errorf("agent: canonical: unsupported value %T", v) + } + return nil +} + +// utf16Less orders strings by their UTF-16 code units (RFC 8785 §3.2.3). +func utf16Less(a, b string) bool { + ua := utf16.Encode([]rune(a)) + ub := utf16.Encode([]rune(b)) + for i := 0; i < len(ua) && i < len(ub); i++ { + if ua[i] != ub[i] { + return ua[i] < ub[i] + } + } + return len(ua) < len(ub) +} + +// writeCanonicalString emits a JSON string with JCS minimal escaping. Invalid +// UTF-8 is rejected: silently replacing broken bytes with U+FFFD would merge +// distinct payloads into one canonical identity. +func writeCanonicalString(b *bytes.Buffer, s string) error { + b.WriteByte('"') + for i, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\b': + b.WriteString(`\b`) + case '\t': + b.WriteString(`\t`) + case '\n': + b.WriteString(`\n`) + case '\f': + b.WriteString(`\f`) + case '\r': + b.WriteString(`\r`) + default: + if r < 0x20 { + fmt.Fprintf(b, `\u%04x`, r) + } else if r == utf8.RuneError { + if _, size := utf8.DecodeRuneInString(s[i:]); size == 1 { + return errors.New("agent: canonical: invalid UTF-8 in string") + } + b.WriteRune(r) // a genuine U+FFFD character + } else { + b.WriteRune(r) + } + } + } + b.WriteByte('"') + return nil +} + +// canonicalNumber renders a JSON number. Integer tokens keep their exact +// digits (arbitrary precision): forcing them through float64 per strict JCS +// would corrupt 64-bit identifiers above 2^53 and collide near-adjacent +// values into one digest. Non-integer tokens use the ES6 double form. +func canonicalNumber(n json.Number) (string, error) { + s := n.String() + if !strings.ContainsAny(s, ".eE") { + neg := strings.HasPrefix(s, "-") + digits := strings.TrimPrefix(s, "-") + digits = strings.TrimLeft(digits, "0") + if digits == "" { + return "0", nil // 0 and -0 canonicalize to "0" + } + if neg { + return "-" + digits, nil + } + return digits, nil + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return "", fmt.Errorf("agent: canonical: bad number %q: %w", s, err) + } + return formatES6Float(f) +} + +// formatES6Float implements ES6 Number::toString for finite doubles. +func formatES6Float(x float64) (string, error) { + if math.IsNaN(x) || math.IsInf(x, 0) { + return "", errors.New("agent: canonical: non-finite number") + } + if x == 0 { + return "0", nil // negative zero canonicalizes to "0" + } + neg := math.Signbit(x) + if neg { + x = -x + } + // Shortest round-trip digits in exponential form: d[.ddd]e±dd + s := strconv.FormatFloat(x, 'e', -1, 64) + ePos := strings.IndexByte(s, 'e') + mant := strings.Replace(s[:ePos], ".", "", 1) + exp, err := strconv.Atoi(s[ePos+1:]) + if err != nil { + return "", err + } + k := len(mant) + n := exp + 1 // value = 0.d1..dk × 10^n + + var out string + switch { + case k <= n && n <= 21: + out = mant + strings.Repeat("0", n-k) + case 0 < n && n <= 21: + out = mant[:n] + "." + mant[n:] + case -6 < n && n <= 0: + out = "0." + strings.Repeat("0", -n) + mant + default: + e := n - 1 + m := mant[:1] + if k > 1 { + m += "." + mant[1:] + } + if e < 0 { + out = m + "e-" + strconv.Itoa(-e) + } else { + out = m + "e+" + strconv.Itoa(e) + } + } + if neg { + out = "-" + out + } + return out, nil +} + +// MarshalCanonical marshals a Go value with encoding/json and canonicalizes +// the result. 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/loop_test.go b/agent/loop_test.go index b265b77..34e577c 100644 --- a/agent/loop_test.go +++ b/agent/loop_test.go @@ -45,10 +45,10 @@ type fakeTool struct { 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(json.RawMessage) error { return f.valErr } +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) } @@ -96,7 +96,7 @@ func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { func loopRuntime(t *testing.T) *MemoryRuntime { t.Helper() - s, err := Initialize("run-1", RunConfig{Model: "m-1"}, NextRun(AgentInput{ID: "seed", Payload: json.RawMessage(`{"q":"hi"}`)})) + s, err := Initialize("run-1", RunConfig{Model: "m-1"}, NextRun(AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)})) if err != nil { t.Fatal(err) } @@ -169,7 +169,7 @@ func TestLoopApprovalWaitsAndResumes(t *testing.T) { echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: ApprovalRequired, execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { executed.Store(true) - return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}} + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: cj(`"ok"`)}} }} invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("after")}} rt := loopRuntime(t) @@ -196,6 +196,7 @@ func TestLoopApprovalWaitsAndResumes(t *testing.T) { cmdID := DeriveResponseCommandID(wait.RunID, wait.StepID, wait.CallID, wait.ID) env, err := BuildEnvelope(wait.RunID, cmdID, ApproveToolCall{ StepID: wait.StepID, CallID: wait.CallID, ResponseID: wait.ID, + ResponseDigest: responseDecisionDigest(t, ResponseApproval, ResponseDecisionApproved, ""), }) if err != nil { t.Fatal(err) @@ -312,7 +313,7 @@ func TestLoopParallelBounded(t *testing.T) { started <- struct{}{} <-gate // hold every worker until released so concurrency is real concurrent.Add(-1) - return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}} + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: cj(`"ok"`)}} }} invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1", "c2", "c3"), textResult("done")}} rt := loopRuntime(t) diff --git a/agent/machine_test.go b/agent/machine_test.go index 0ce78e5..de2acf1 100644 --- a/agent/machine_test.go +++ b/agent/machine_test.go @@ -13,9 +13,11 @@ func testConfig() RunConfig { return RunConfig{Model: "m-1", ModelRejectLimit: DefaultModelRejectLimit} } +func cj(raw string) CanonicalJSON { return MustParseCanonicalJSON(raw) } + func newRun(t *testing.T, cfg RunConfig) MachineState { t.Helper() - s, err := Initialize("run-1", cfg, NextRun(AgentInput{ID: "seed", Payload: json.RawMessage(`{"q":"hi"}`)})) + s, err := Initialize("run-1", cfg, NextRun(AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)})) if err != nil { t.Fatal(err) } @@ -103,9 +105,28 @@ func makeSpec(t *testing.T, def sdk.ToolDefinition, policy ResponsePolicy) ToolS return ToolSpec{Ref: ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: policy} } +func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDecision, reason string) Digest { + t.Helper() + d, err := DigestToolResponseDecision(kind, decision, reason) + if err != nil { + t.Fatal(err) + } + return d +} + +func responsePayloadDigest(t *testing.T, payload CanonicalJSON) Digest { + t.Helper() + d, err := DigestToolResponsePayload(payload) + if err != nil { + t.Fatal(err) + } + return d +} + func makeBinding(t *testing.T, callID string, spec ToolSpec, args string) ToolCallBinding { t.Helper() - bd, err := digestToolCallBinding(CallID(callID), spec.DefinitionDigest, spec.Policy, []byte(args)) + parsedArgs := cj(args) + bd, err := digestToolCallBinding(CallID(callID), spec.DefinitionDigest, spec.Policy, parsedArgs) if err != nil { t.Fatal(err) } @@ -114,7 +135,7 @@ func makeBinding(t *testing.T, callID string, spec ToolSpec, args string) ToolCa ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, BindingDigest: bd, - Arguments: json.RawMessage(args), + Arguments: parsedArgs, Policy: spec.Policy, } } @@ -297,8 +318,13 @@ func TestApprovalCallOpensWaitingWithDerivedResponse(t *testing.T) { t.Fatalf("effect = %#v", eff) } + if _, err := Decide(s, ApproveToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: want, ResponseDigest: "sha256:bad"}); err == nil { + t.Fatal("approval with bad response digest accepted") + } + // Approve -> Pending; then start -> execute path. - s = fold(t, s, mustDecide(t, s, ApproveToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: want})) + s = fold(t, s, mustDecide(t, s, ApproveToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: want, + ResponseDigest: responseDecisionDigest(t, ResponseApproval, ResponseDecisionApproved, "")})) if s.Current.(ToolStep).Calls[0].Status != ToolPending { t.Fatal("approved call is not Pending") } @@ -315,7 +341,8 @@ func TestRejectRecordsPermissionDenied(t *testing.T) { s = fold(t, s, facts) respID := opened.Calls[0].Response.ID - facts = mustDecide(t, s, RejectToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: respID, Reason: "no"}) + facts = mustDecide(t, s, RejectToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: respID, + ResponseDigest: responseDecisionDigest(t, ResponseApproval, ResponseDecisionRejected, "no"), Reason: "no"}) // Single call: reject closes it as Known failed and closes the step. if len(facts) != 2 { t.Fatalf("facts = %d, want [failed, closed]", len(facts)) @@ -333,6 +360,27 @@ func TestRejectRecordsPermissionDenied(t *testing.T) { } } +func TestExternalResponseRequiresPayloadDigest(t *testing.T) { + def := testToolDef("ask") + spec := makeSpec(t, def, ExternalResponse) + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + b := makeBinding(t, "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 := Decide(s, SubmitToolResponse{StepID: opened.StepID, CallID: "c1", 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: "c1", ResponseID: respID, + ResponseDigest: responsePayloadDigest(t, payload), Payload: payload}) + if len(facts) != 2 { + t.Fatalf("facts = %d, want answered + closed", len(facts)) + } +} + func TestUnknownFailureEndsRun(t *testing.T) { def := testToolDef("t") spec := makeSpec(t, def, DirectExecution) @@ -397,7 +445,7 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { // Complete B; step must stay open because A is Waiting. s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: "cB"})) - facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: "cB", Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}}) + facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: "cB", Result: ToolExecutionResult{Output: cj(`"ok"`)}}) if len(facts) != 1 { t.Fatalf("facts = %d, step must not close with A waiting", len(facts)) } @@ -406,9 +454,10 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { // Answer A via approval; approving moves to Pending, then failing known // closes the step. respID := opened.Calls[0].Response.ID - s = fold(t, s, mustDecide(t, s, ApproveToolCall{StepID: opened.StepID, CallID: "cA", ResponseID: respID})) + s = fold(t, s, mustDecide(t, s, ApproveToolCall{StepID: opened.StepID, CallID: "cA", ResponseID: respID, + ResponseDigest: responseDecisionDigest(t, ResponseApproval, ResponseDecisionApproved, "")})) s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: "cA"})) - facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: "cA", Result: ToolExecutionResult{Output: json.RawMessage(`"done"`)}}) + facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: "cA", Result: ToolExecutionResult{Output: cj(`"done"`)}}) if len(facts) != 2 { t.Fatalf("facts = %d, want [completed, closed]", len(facts)) } @@ -488,7 +537,7 @@ func TestStepLimitEndsRunAtToolStepClose(t *testing.T) { s = fold(t, s, facts) s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: "c1"})) - facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: "c1", Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}}) + facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: "c1", Result: ToolExecutionResult{Output: cj(`"ok"`)}}) if len(facts) != 3 { t.Fatalf("facts = %d, want [completed, closed, ended]", len(facts)) } @@ -509,7 +558,7 @@ func TestCancelProducesRunStopped(t *testing.T) { func TestAcceptInputIdempotentPerID(t *testing.T) { s := newRun(t, testConfig()) - facts := mustDecide(t, s, NextStep(AgentInput{ID: "in-2", Payload: json.RawMessage(`1`)})) + 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)) @@ -529,28 +578,15 @@ func TestAcceptInputIdempotentPerID(t *testing.T) { func TestAcceptInputRejectsSeedDuplicateID(t *testing.T) { s := newRun(t, testConfig()) - _, err := Decide(s, NextStep(AgentInput{ID: "seed", Payload: json.RawMessage(`{"q":"other"}`)})) + _, err := Decide(s, NextStep(AgentInput{ID: "seed", Payload: cj(`{"q":"other"}`)})) if err != ErrCommandConflict { t.Fatalf("duplicate seed input err = %v, want ErrCommandConflict", err) } } -func TestSubmitModelResultRejectsNonFrozenToolInput(t *testing.T) { - def := testToolDef("t") - spec := makeSpec(t, def, DirectExecution) - s := newRun(t, testConfig()) - s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) - b := makeBinding(t, "c1", spec, `{"path":"/etc/passwd"}`) - result := ModelResult{ - FinishReason: FinishReasonToolCalls, - ToolCalls: []ModelToolCall{{ - ToolCallID: "c1", - ToolName: "t", - Input: json.RawMessage(`{"path":`), - }}, - } - if _, err := Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: []ToolCallBinding{b}}); err == nil { - t.Fatal("accepted non-frozen model tool input") +func TestSubmitModelResultRequiresCanonicalToolInput(t *testing.T) { + if _, err := ParseCanonicalJSON([]byte(`{"path":`)); err == nil { + t.Fatal("constructed non-canonical model tool input") } } @@ -591,7 +627,7 @@ func TestReplayEquivalence(t *testing.T) { step(SubmitModelResult{StepID: prep.StepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) ts := s.Current.(ToolStep) step(StartToolCall{StepID: ts.RefValue.ID, CallID: "c1"}) - step(SubmitToolResult{StepID: ts.RefValue.ID, CallID: "c1", Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}}) + step(SubmitToolResult{StepID: ts.RefValue.ID, CallID: "c1", Result: ToolExecutionResult{Output: cj(`"ok"`)}}) replayed := fold(t, initial, log) a, err := json.Marshal(stateSnapshotForTest(s)) diff --git a/agent/model_data.go b/agent/model_data.go index 273c55f..b7e6f18 100644 --- a/agent/model_data.go +++ b/agent/model_data.go @@ -1,7 +1,6 @@ package agent import ( - "bytes" "encoding/json" "fmt" "time" @@ -11,9 +10,9 @@ import ( ) // ProviderMetadata is the agent's persisted representation of provider-owned -// opaque metadata. Each namespace value is detached canonical JSON: callers may -// keep mutating their sdk map, but runtime events/state own these bytes. -type ProviderMetadata map[string]json.RawMessage +// 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"` @@ -72,11 +71,11 @@ type MessagePart struct { Filename string `json:"filename,omitempty"` // Tool call / result. - ToolCallID string `json:"toolCallId,omitempty"` - ToolName string `json:"toolName,omitempty"` - Input json.RawMessage `json:"input,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - IsError bool `json:"isError,omitempty"` + 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"` @@ -98,7 +97,7 @@ const ( type ResponseFormat struct { Type ResponseFormatType `json:"type"` - JSONSchema json.RawMessage `json:"jsonSchema,omitempty"` + JSONSchema CanonicalJSON `json:"jsonSchema,omitzero"` } type ToolChoiceMode string @@ -116,10 +115,10 @@ type ToolChoice struct { } type ToolDefinition struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Parameters json.RawMessage `json:"parameters"` - CacheControl *CacheControl `json:"cacheControl,omitempty"` + 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 @@ -145,7 +144,7 @@ type ModelRequest struct { ReasoningSummary *string `json:"reasoningSummary,omitempty"` PromptCacheKey *string `json:"promptCacheKey,omitempty"` - ProviderOptions map[string]json.RawMessage `json:"providerOptions,omitempty"` + ProviderOptions map[string]CanonicalJSON `json:"providerOptions,omitempty"` } type FinishReason string @@ -223,7 +222,7 @@ type GeneratedFile struct { type ModelToolCall struct { ToolCallID string `json:"toolCallId"` ToolName string `json:"toolName"` - Input json.RawMessage `json:"input"` + Input CanonicalJSON `json:"input"` ProviderMetadata ProviderMetadata `json:"providerMetadata,omitempty"` } @@ -253,42 +252,16 @@ type ModelResult struct { Response *ResponseMetadata `json:"response,omitempty"` } -func freezeRawJSON(raw json.RawMessage) (json.RawMessage, error) { - if raw == nil { - return nil, nil - } - canonical, err := canonicalJSON(raw) - if err != nil { - return nil, err - } - return cloneRaw(canonical), nil +func freezeRawJSON(raw json.RawMessage) (CanonicalJSON, error) { + return ParseCanonicalJSON(raw) } -func freezeJSONValue(v any) (json.RawMessage, error) { - if v == nil { - return json.RawMessage("null"), nil - } - if raw, ok := v.(json.RawMessage); ok { - return freezeRawJSON(raw) - } - raw, err := json.Marshal(v) - if err != nil { - return nil, err - } - return freezeRawJSON(raw) +func freezeJSONValue(v any) (CanonicalJSON, error) { + return CanonicalJSONFromValue(v) } -func decodeJSONValue(raw json.RawMessage) (any, error) { - if raw == nil { - return nil, nil - } - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.UseNumber() - var out any - if err := dec.Decode(&out); err != nil { - return nil, err - } - return out, nil +func decodeJSONValue(raw CanonicalJSON) (any, error) { + return raw.Any() } func FreezeProviderMetadata(meta map[string]any) (ProviderMetadata, error) { @@ -352,7 +325,7 @@ func (d ToolDefinition) SDK() sdk.ToolDefinition { return sdk.ToolDefinition{ Name: d.Name, Description: d.Description, - Parameters: cloneRaw(d.Parameters), + Parameters: d.Parameters.RawMessage(), CacheControl: d.CacheControl.SDK(), } } @@ -380,9 +353,9 @@ func (f *ResponseFormat) SDK() (*sdk.ResponseFormat, error) { return nil, nil } out := &sdk.ResponseFormat{Type: sdk.ResponseFormatType(f.Type)} - if f.JSONSchema != nil { + if !f.JSONSchema.IsZero() { var schema jsonschema.Schema - if err := json.Unmarshal(f.JSONSchema, &schema); err != nil { + if err := json.Unmarshal(f.JSONSchema.Bytes(), &schema); err != nil { return nil, err } out.JSONSchema = &schema @@ -490,9 +463,9 @@ func (p MessagePart) SDK() (sdk.MessagePart, error) { if err != nil { return nil, err } - return sdk.ToolCallPart{ToolCallID: p.ToolCallID, ToolName: p.ToolName, Input: cloneRaw(p.Input), CacheControl: p.CacheControl.SDK(), ProviderMetadata: meta}, nil + 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: cloneRaw(p.Result), IsError: p.IsError, CacheControl: p.CacheControl.SDK()}, nil + 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) } @@ -553,7 +526,7 @@ func FreezeModelRequest(req sdk.Request) (ModelRequest, error) { if err != nil { return ModelRequest{}, fmt.Errorf("response format: %w", err) } - options := make(map[string]json.RawMessage, len(req.ProviderOptions)) + options := make(map[string]CanonicalJSON, len(req.ProviderOptions)) if req.ProviderOptions != nil { for k, v := range req.ProviderOptions { frozen, err := freezeRawJSON(v) @@ -606,7 +579,7 @@ func (r ModelRequest) SDK() (sdk.Request, error) { options := make(map[string]json.RawMessage, len(r.ProviderOptions)) if r.ProviderOptions != nil { for k, v := range r.ProviderOptions { - options[k] = cloneRaw(v) + options[k] = v.RawMessage() } } else { options = nil @@ -714,16 +687,16 @@ func (f GeneratedFile) SDK() sdk.GeneratedFile { return sdk.GeneratedFile{Data: f.Data, MediaType: f.MediaType} } -func freezeToolCallInput(input any) (json.RawMessage, error) { +func freezeToolCallInput(input any) (CanonicalJSON, error) { args, err := canonicalToolArguments(input) if err == nil { - return cloneRaw(args), nil + return args, nil } switch input.(type) { case string, json.RawMessage: - return cloneRaw(rawToolArguments(input)), nil + return rawToolArguments(input), nil default: - return nil, err + return CanonicalJSON{}, err } } @@ -744,7 +717,7 @@ func (c ModelToolCall) SDK() (sdk.ToolCall, error) { if err != nil { return sdk.ToolCall{}, err } - return sdk.ToolCall{ToolCallID: c.ToolCallID, ToolName: c.ToolName, Input: cloneRaw(c.Input), ProviderMetadata: meta}, nil + return sdk.ToolCall{ToolCallID: c.ToolCallID, ToolName: c.ToolName, Input: c.Input.RawMessage(), ProviderMetadata: meta}, nil } func FreezeResponseMetadata(r *sdk.ResponseMetadata) *ResponseMetadata { diff --git a/agent/protocol.go b/agent/protocol.go index 071bcf8..7d9e2b7 100644 --- a/agent/protocol.go +++ b/agent/protocol.go @@ -107,6 +107,45 @@ func DigestRunSeed(schemaVersion uint16, seed RunSeed) (Digest, error) { return sha256Digest(body), nil } +type toolResponseDecisionDigestBody struct { + Kind ResponseKind `json:"kind"` + Decision ResponseDecision `json:"decision"` + Reason string `json:"reason,omitempty"` +} + +// DigestToolResponseDecision computes the content digest for approval and +// rejection ingress. The ResponseID remains the routing/idempotency key; this +// digest binds the actual decision payload recorded in the command/fact. +func DigestToolResponseDecision(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(currentSchemaVersion, "tool_response_decision", toolResponseDecisionDigestBody{ + Kind: kind, Decision: decision, Reason: reason, + }) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +type toolResponsePayloadDigestBody struct { + Payload CanonicalJSON `json:"payload"` +} + +// DigestToolResponsePayload computes the content digest for an ExternalResponse +// answer payload. +func DigestToolResponsePayload(payload CanonicalJSON) (Digest, error) { + body, err := encodeEnvelopeBody(currentSchemaVersion, "tool_response_payload", toolResponsePayloadDigestBody{Payload: payload}) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + // DigestRequest covers every field of a frozen ModelRequest with no exclusions // (spec §2.1 rule 7). func DigestRequest(req ModelRequest) (Digest, error) { @@ -170,19 +209,11 @@ func digestBindingSet(bindings []ToolCallBinding) (Digest, error) { // DigestToolCallBinding covers one binding: definition, policy and canonical // arguments plus the CallID (spec §4.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 []byte) (Digest, error) { - canonicalArgs := []byte("null") - if len(arguments) > 0 { - var err error - canonicalArgs, err = canonicalJSON(arguments) - if err != nil { - return "", fmt.Errorf("agent: binding digest: %w", err) - } - } +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), string(canonicalArgs)))), nil + string(callID), string(definitionDigest), fmt.Sprintf("%d", policy), arguments.String()))), nil } -func digestToolCallBinding(callID CallID, definitionDigest Digest, policy ResponsePolicy, arguments []byte) (Digest, error) { +func digestToolCallBinding(callID CallID, definitionDigest Digest, policy ResponsePolicy, arguments CanonicalJSON) (Digest, error) { return DigestToolCallBinding(callID, definitionDigest, policy, arguments) } diff --git a/agent/rebuild_test.go b/agent/rebuild_test.go index 9e9d931..d028283 100644 --- a/agent/rebuild_test.go +++ b/agent/rebuild_test.go @@ -2,7 +2,6 @@ package agent import ( "context" - "encoding/json" "errors" "testing" @@ -26,7 +25,7 @@ func fullRunRuntime(t *testing.T) *MemoryRuntime { toolStep := res.Events[1].Fact.(ToolStepOpened).StepID sRes := mustCommit(t, rt, "start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: "c1"}) mustCommit(t, rt, "done-c1", sRes.Snapshot.Revision, sRes.Grant, - SubmitToolResult{StepID: toolStep, CallID: "c1", Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}}) + SubmitToolResult{StepID: toolStep, CallID: "c1", Result: ToolExecutionResult{Output: cj(`"ok"`)}}) mustCommit(t, rt, DeriveModelRequestCommandID("run-1", 5), 5, "", func() PrepareModelRequest { snap, _ := rt.Load(context.Background()) @@ -204,7 +203,7 @@ func TestFoldRejectsTamperedFact(t *testing.T) { for i := range log { if f, ok := log[i].Fact.(ToolCallCompleted); ok { - f.Result.Output = json.RawMessage(`"tampered"`) + f.Result.Output = cj(`"tampered"`) log[i].Fact = f break } diff --git a/agent/regression_test.go b/agent/regression_test.go index 7eee67d..ebe01f4 100644 --- a/agent/regression_test.go +++ b/agent/regression_test.go @@ -59,11 +59,11 @@ func TestRegressionBigIntegerPrecision(t *testing.T) { if string(got) != `{"channel_id":1234567890123456789}` { t.Fatalf("big integer corrupted: %s", got) } - d1, err := digestToolCallBinding("c", "", DirectExecution, []byte(`{"n":9007199254740993}`)) + d1, err := digestToolCallBinding("c", "", DirectExecution, cj(`{"n":9007199254740993}`)) if err != nil { t.Fatal(err) } - d2, err := digestToolCallBinding("c", "", DirectExecution, []byte(`{"n":9007199254740992}`)) + d2, err := digestToolCallBinding("c", "", DirectExecution, cj(`{"n":9007199254740992}`)) if err != nil { t.Fatal(err) } @@ -97,27 +97,12 @@ func TestRegressionToolStepIDReproducible(t *testing.T) { } } -// Finding 3: a commit failure while settling a worker must surface, not be -// silently dropped. -func TestRegressionSettleCommitErrorSurfaces(t *testing.T) { - spec := toolSpec(t, "echo", DirectExecution) - echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, - execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { - // Invalid JSON output makes BuildEnvelope/DigestCommand fail: a - // non-sentinel commit error on the settle path. - return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: json.RawMessage(`{broken`)}} - }} - invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1")}} - rt := loopRuntime(t) - loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, - staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) - - _, err := loop.Run(context.Background(), rt, nil) - if err == nil { - t.Fatal("settle commit failure was swallowed; run would wedge in WaitForExecutionRecovery") - } - if !strings.Contains(err.Error(), "settling call") { - t.Fatalf("err = %v, want settle context", err) +// Finding 3: invalid tool output cannot enter the authority contract. Tools +// return CanonicalJSON, so malformed JSON is rejected at construction rather +// than later inside BuildEnvelope/DigestCommand. +func TestRegressionInvalidToolOutputCannotBeConstructed(t *testing.T) { + if _, err := ParseCanonicalJSON([]byte(`{broken`)); err == nil { + t.Fatal("malformed JSON constructed as CanonicalJSON") } } @@ -158,7 +143,8 @@ func TestRegressionExternalResponseCanBeRejected(t *testing.T) { s = fold(t, s, facts) respID := opened.Calls[0].Response.ID - facts, err := Decide(s, RejectToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: respID, Reason: "user dismissed"}) + facts, err := Decide(s, RejectToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: respID, + ResponseDigest: responseDecisionDigest(t, ResponseExternal, ResponseDecisionRejected, "user dismissed"), Reason: "user dismissed"}) if err != nil { t.Fatalf("external-response call cannot be rejected: %v", err) } @@ -202,29 +188,27 @@ func TestRegressionRuntimeReturnsAreIsolated(t *testing.T) { res := mustCommit(t, rt, "complete-1", 2, grant, SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("t", `{"k":"original"}`, "c1"), Calls: []ToolCallBinding{b}}) - // Mutate the returned event's fact payload bytes. + // Mutate byte views returned from immutable CanonicalJSON values. opened := res.Events[1].Fact.(ToolStepOpened) - if len(opened.Calls) > 0 && len(opened.Calls[0].Arguments) > 0 { - opened.Calls[0].Arguments[2] = 'X' - } - // Mutate the snapshot's waiting payload. + argBytes := opened.Calls[0].Arguments.RawMessage() + argBytes[2] = 'X' + // Mutate the snapshot's waiting payload view. snap, _ := rt.Load(context.Background()) ts := snap.State.Current.(ToolStep) - if ts.Calls[0].Waiting != nil && len(ts.Calls[0].Waiting.Payload) > 2 { - ts.Calls[0].Waiting.Payload[2] = 'Y' - } + payloadBytes := ts.Calls[0].Waiting.Payload.RawMessage() + payloadBytes[2] = 'Y' // Authority must be unchanged: reload and verify the argument bytes. fresh, _ := rt.Load(context.Background()) got := fresh.State.Current.(ToolStep).Calls[0].Arguments - if string(got) != `{"k":"original"}` { - t.Fatalf("authoritative arguments mutated through a returned view: %s", got) + if got.String() != `{"k":"original"}` { + t.Fatalf("authoritative arguments mutated through a returned view: %s", got.String()) } stored := rt.Events() for _, e := range stored { if f, ok := e.Fact.(ToolStepOpened); ok { - if string(f.Calls[0].Arguments) != `{"k":"original"}` { - t.Fatalf("stored event mutated through a returned view: %s", f.Calls[0].Arguments) + if f.Calls[0].Arguments.String() != `{"k":"original"}` { + t.Fatalf("stored event mutated through a returned view: %s", f.Calls[0].Arguments.String()) } } } @@ -292,7 +276,7 @@ func TestRegressionAliasedToolRefExecutes(t *testing.T) { tool := &fakeTool{ref: "fs.read", def: def, policy: DirectExecution, execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { executed.Store(true) - return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: json.RawMessage(`"ok"`)}} + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: cj(`"ok"`)}} }} // Model calls the definition name "read"; catalog keys by Ref "fs.read". invoker := &fakeInvoker{results: []sdk.ModelResult{ diff --git a/agent/runtime_helpers_test.go b/agent/runtime_helpers_test.go index 07cdd0d..85b4af4 100644 --- a/agent/runtime_helpers_test.go +++ b/agent/runtime_helpers_test.go @@ -2,7 +2,6 @@ package agent import ( "context" - "encoding/json" "testing" "github.com/memohai/twilight-ai/sdk" @@ -13,7 +12,7 @@ import ( func newTestRuntime(t *testing.T, cfg RunConfig) *MemoryRuntime { t.Helper() - s, err := Initialize("run-1", cfg, NextRun(AgentInput{ID: "seed", Payload: json.RawMessage(`{"q":"hi"}`)})) + s, err := Initialize("run-1", cfg, NextRun(AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)})) if err != nil { t.Fatal(err) } diff --git a/agent/runtimetest/conformance.go b/agent/runtimetest/conformance.go index f17cbde..91b4c78 100644 --- a/agent/runtimetest/conformance.go +++ b/agent/runtimetest/conformance.go @@ -42,7 +42,7 @@ type runtimeCase struct { func newCase(t testing.TB, newRuntime Factory, cfg agent.RunConfig) *runtimeCase { t.Helper() - initial, err := agent.Initialize("run-1", cfg, agent.NextRun(agent.AgentInput{ID: "seed", Payload: json.RawMessage(`{"q":"hi"}`)})) + initial, err := agent.Initialize("run-1", cfg, agent.NextRun(agent.AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)})) if err != nil { t.Fatal(err) } @@ -135,6 +135,8 @@ func testToolDef(name string) sdk.ToolDefinition { return sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} } +func cj(raw string) agent.CanonicalJSON { return agent.MustParseCanonicalJSON(raw) } + func makeSpec(t testing.TB, def sdk.ToolDefinition, policy agent.ResponsePolicy) agent.ToolSpec { t.Helper() frozen, err := agent.FreezeToolDefinition(def) @@ -150,7 +152,8 @@ func makeSpec(t testing.TB, def sdk.ToolDefinition, policy agent.ResponsePolicy) func makeBinding(t testing.TB, callID string, spec agent.ToolSpec, args string) agent.ToolCallBinding { t.Helper() - bd, err := agent.DigestToolCallBinding(agent.CallID(callID), spec.DefinitionDigest, spec.Policy, []byte(args)) + parsedArgs := cj(args) + bd, err := agent.DigestToolCallBinding(agent.CallID(callID), spec.DefinitionDigest, spec.Policy, parsedArgs) if err != nil { t.Fatal(err) } @@ -159,7 +162,7 @@ func makeBinding(t testing.TB, callID string, spec agent.ToolSpec, args string) ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, BindingDigest: bd, - Arguments: json.RawMessage(args), + Arguments: parsedArgs, Policy: spec.Policy, } } @@ -276,7 +279,7 @@ func testCallLocalRebase(t *testing.T, newRuntime Factory) { t.Fatal("stale-base start of an untouched Pending call must rebase") } doneA := c.mustCommit("done-A", base, startA.Grant, - agent.SubmitToolResult{StepID: toolStep, CallID: "cA", Result: agent.ToolExecutionResult{Output: json.RawMessage(`1`)}}) + agent.SubmitToolResult{StepID: toolStep, CallID: "cA", Result: agent.ToolExecutionResult{Output: cj(`1`)}}) if doneA.Status != agent.CommitAccepted { t.Fatal("owner completion on stale base must rebase") } @@ -365,7 +368,7 @@ func testReplayFoldMatchesState(t *testing.T, newRuntime Factory) { toolStep := res.Events[1].Fact.(agent.ToolStepOpened).StepID sRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", agent.StartToolCall{StepID: toolStep, CallID: "c1"}) c.mustCommit("done-c1", sRes.Snapshot.Revision, sRes.Grant, - agent.SubmitToolResult{StepID: toolStep, CallID: "c1", Result: agent.ToolExecutionResult{Output: json.RawMessage(`"ok"`)}}) + agent.SubmitToolResult{StepID: toolStep, CallID: "c1", Result: agent.ToolExecutionResult{Output: cj(`"ok"`)}}) folded, lastRev, err := agent.FoldEvents(c.initial, c.events) if err != nil { @@ -384,7 +387,7 @@ func testReplayFoldMatchesState(t *testing.T, newRuntime Factory) { func testAcceptInputByInputID(t *testing.T, newRuntime Factory) { c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1"}) - in := agent.AgentInput{ID: "in-9", Payload: json.RawMessage(`{"t":"x"}`)} + in := agent.AgentInput{ID: "in-9", Payload: cj(`{"t":"x"}`)} id := agent.DeriveInputCommandID("run-1", in.ID) res1 := c.mustCommit(id, 0, "", agent.NextStep(in)) if res1.Status != agent.CommitAccepted { @@ -394,7 +397,7 @@ func testAcceptInputByInputID(t *testing.T, newRuntime Factory) { if res2.Status != agent.CommitAlreadyApplied { t.Fatalf("status = %v", res2.Status) } - _, err := c.commit(id, 0, "", agent.NextStep(agent.AgentInput{ID: "in-9", Payload: json.RawMessage(`{"t":"y"}`)})) + _, err := c.commit(id, 0, "", agent.NextStep(agent.AgentInput{ID: "in-9", Payload: cj(`{"t":"y"}`)})) if err != agent.ErrCommandConflict { t.Fatalf("err = %v, want ErrCommandConflict", err) } @@ -402,7 +405,7 @@ func testAcceptInputByInputID(t *testing.T, newRuntime Factory) { func testDerivedCommandIDEnforced(t *testing.T, newRuntime Factory) { c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1"}) - _, err := c.commit("random-id", 0, "", agent.NextStep(agent.AgentInput{ID: "in-1", Payload: json.RawMessage(`1`)})) + _, err := c.commit("random-id", 0, "", agent.NextStep(agent.AgentInput{ID: "in-1", Payload: cj(`1`)})) if err == nil { t.Fatal("AcceptInput with non-derived CommandID accepted") } diff --git a/agent/state.go b/agent/state.go index e27fe3c..7a8425b 100644 --- a/agent/state.go +++ b/agent/state.go @@ -1,7 +1,6 @@ package agent import ( - "encoding/json" "errors" "fmt" ) @@ -88,15 +87,22 @@ const ( 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 json.RawMessage `json:"payload,omitempty"` - RequestDigest Digest `json:"requestDigest"` // digest of the request payload + 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. @@ -110,12 +116,12 @@ type ToolSpec struct { // ToolCallBinding is one frozen call inside ToolStepOpened. type ToolCallBinding struct { - CallID CallID `json:"callId"` - ToolRef ToolRef `json:"toolRef"` - DefinitionDigest Digest `json:"definitionDigest"` - BindingDigest Digest `json:"bindingDigest"` // definition, policy and canonical arguments - Arguments json.RawMessage `json:"arguments"` - Policy ResponsePolicy `json:"policy"` // unresolved ToolRef uses DirectExecution + CallID CallID `json:"callId"` + 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"` @@ -167,7 +173,7 @@ const ( ) type ToolExecutionResult struct { - Output json.RawMessage `json:"output"` + Output CanonicalJSON `json:"output"` } type ToolFailure struct { @@ -192,7 +198,7 @@ type ToolCallState struct { ToolRef ToolRef `json:"toolRef"` DefinitionDigest Digest `json:"definitionDigest"` BindingDigest Digest `json:"bindingDigest"` - Arguments json.RawMessage `json:"arguments"` + Arguments CanonicalJSON `json:"arguments"` Policy ResponsePolicy `json:"policy"` Status ToolCallStatus `json:"status"` Result *ToolExecutionResult `json:"result,omitempty"` diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 5170bdb..e1ec499 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -182,10 +182,10 @@ agent-owned 冻结形态遵守以下规则;`FreezeModelRequest`、`FreezeModel 3. `ToolChoice` 是封闭类型 `{Mode: auto|none|required|tool, Tool string}`,不使用 `any`。 4. 消息 part 是 sealed union 的 agent value(text/reasoning/image/file/tool-call/tool-result),不持久化 `sdk.MessagePart` interface。 5. 消息中的二进制内容有两种形式:inline bytes(canonical 编码为 base64),或稳定的内容寻址引用 `BlobRef{Digest, MediaType, ByteSize}`。`BlobRef` 的字节解析由组装 `ModelInvoker` 的一方负责;带时效的 URL 等不稳定引用不能进入冻结请求。两种形式产生不同的 digest,Planner 对同一内容必须确定性地选择一种形式。 -6. provider metadata、provider options、tool input/result、response format schema 等扩展字段的值必须是 JSON 值。进入 agent 前必须 canonicalize 并 detach 为 agent-owned `json.RawMessage`;不能保存 caller-owned map/slice/RawMessage 或 `any`。canonicalization 必须拒绝会把不同 payload 折叠成同一值的输入,包括重复 object key、trailing data、invalid UTF-8 和 escaped lone surrogate(`\ud800`..`\udfff`)。 +6. provider metadata、provider options、tool input/result、response format schema 等扩展字段的值必须是 JSON 值。进入 agent 前必须 parse/canonicalize 成 opaque `CanonicalJSON`(由 `agent/jsonstable.Value` 承载,内部 bytes 不可被 agent core 或 caller 直接构造/修改);不能保存 caller-owned map/slice/RawMessage 或 `any`。canonicalization 必须拒绝会把不同 payload 折叠成同一值的输入,包括重复 object key、trailing data、invalid UTF-8 和 escaped lone surrogate(`\ud800`..`\udfff`)。 7. `DigestRequest` 覆盖 frozen `ModelRequest` 的全部字段,不设排除项。cache 配置等只影响成本的字段同样参与摘要;排除任何字段都会把不同请求判成同一事件,产生错误的 `CommitAlreadyApplied`。 -边界的 SDK 类型可以保持以下形态;agent 的持久化 `ModelRequest`/`ToolDefinition` 是相同语义的 concrete mirror,字段中所有接口/any/JSON 原文先在 `Freeze*` 中 canonicalize。 +边界的 SDK 类型可以保持以下形态;agent 的持久化 `ModelRequest`/`ToolDefinition` 是相同语义的 concrete mirror,字段中所有接口/any/JSON 原文先在 `Freeze*` 中 canonicalize 为 `CanonicalJSON`。 ```go package sdk @@ -267,9 +267,9 @@ Runtime 的 authority boundary 不能靠“调用者不要修改快照”这类 2. **digest/replay nondeterminism**:digest 在提交时按一组字节计算,但持久化对象仍引用 caller-owned map/slice/RawMessage;之后对象变化会让相同 `CommandID` 的重放、`CommitAlreadyApplied` 判断、StepID 派生和 replay 校验失真。 3. **unbounded SDK surface**:`sdk.MessagePart` interface、provider metadata 的 `map[string]any`、tool/schema 的原始 JSON 等属于 provider/transport 边界数据,不能成为 Machine 协议的永久 wire contract。 -因此 agent 必须定义自己的 JSON-stable persisted value:`ModelRequest`、`ModelResult`、`ToolDefinition`、`Usage`、`Message`、`MessagePart`、`ProviderMetadata` 等。Loop/provider 边界仍使用 `sdk.Request`/`sdk.ModelResult`;进入 Runtime 前必须调用 `FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`,把动态 JSON canonicalize 并复制为 agent-owned bytes;离开 Runtime 调 provider 时通过 `.SDK()` 构造新的 SDK 值。`DigestRequest`/`DigestToolDefinition` 只接受 frozen agent value。`EvaluateCommit` 和 Runtime 实现保存 facts/state 前还必须 snapshot 自己拥有的 value,`MemoryRuntime.Commit` 以已保存事件重新 fold 出 authority state,而不是信任 caller-provided `decision.NewState`。 +因此 agent 必须定义自己的 JSON-stable persisted value:`ModelRequest`、`ModelResult`、`ToolDefinition`、`Usage`、`Message`、`MessagePart`、`ProviderMetadata` 等。Loop/provider 边界仍使用 `sdk.Request`/`sdk.ModelResult`;进入 Runtime 前必须调用 `FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`,把动态 JSON parse/canonicalize 成 opaque `CanonicalJSON`;离开 Runtime 调 provider 时通过 `.SDK()` 构造新的 SDK 值。`DigestRequest`/`DigestToolDefinition` 只接受 frozen agent value。`EvaluateCommit` 和 Runtime 实现保存 facts/state 前还必须 snapshot 自己拥有的 value,`MemoryRuntime.Commit` 以已保存事件重新 fold 出 authority state,而不是信任 caller-provided `decision.NewState`。 -这不是防御性 deep clone 的局部补丁,而是分层边界:`sdk` 是一次调用的 transport API,`agent` 是可重放、可审计、可长期兼容的事件协议。任何新增进入 AgentEvent/MachineState 的 provider 数据,必须先落到 agent-owned sealed/JSON-stable 类型或 `json.RawMessage` canonical JSON;不得把新的 SDK interface、`any` 或可变引用跨过 Runtime authority boundary。 +这不是防御性 deep clone 的局部补丁,而是分层边界:`sdk` 是一次调用的 transport API,`agent` 是可重放、可审计、可长期兼容的事件协议。任何新增进入 AgentEvent/MachineState 的 provider 数据,必须先落到 agent-owned sealed/JSON-stable 类型或 `CanonicalJSON`;不得把新的 SDK interface、`any`、`json.RawMessage` 或可变引用跨过 Runtime authority boundary。外部 bytes 的检测只能发生在 codec/Freeze 边界;边界之后 agent core 只看已构造的 immutable value。 ### 2.3 Memoh native runtime @@ -455,7 +455,7 @@ Runtime EvaluateCommit: 幂等/类别校验 + Machine.Decide + Machine.Evolve 意图与事实由 Decide 显式转换,日志永远记录结果而非请求:`RejectToolCall` 记录为 `ToolCallFailed{permission_denied}`;`CancelRun` 记录为 `RunEnded{RunStopped, cancelled}`;触发 step limit 的最后一次 Call 完成记录为 `[ToolCallCompleted, ToolStepClosed, RunEnded{RunStopped, step_limit}]`。 -`PrepareModelRequest.RequestDigest` 和 `SubmitToolResponse.ResponseDigest` 分别是请求/响应 payload 的内容摘要,不是提交身份。一个 command 被重试时复用同一 CommandID 和 digest;Runtime 不会为重试生成第二组 AgentEvent。 +`PrepareModelRequest.RequestDigest` 和 `ResponseDigest` 分别是请求/响应 payload 的内容摘要,不是提交身份。`ApproveToolCall`/`RejectToolCall` 使用 `DigestToolResponseDecision(kind, decision, reason)`,`SubmitToolResponse` 使用 `DigestToolResponsePayload(payload)`;Decide 必须校验 digest 与实际 decision/payload 匹配。一个 command 被重试时复用同一 CommandID 和 digest;Runtime 不会为重试生成第二组 AgentEvent。 Effect 是 Loop 动作: @@ -907,11 +907,11 @@ AgentEvent 身份 ``` ``` -canonical 编码和 digest 函数由 agent 提供;Memoh 只保存和比较结果,不重新实现排序或编码。编码必须包含 sealed command/fact discriminator、按声明顺序编码有序 slice、对 map key 排序,并对 `json.RawMessage` 使用 canonical JSON;重复 object key、trailing data、invalid UTF-8、escaped lone surrogate 一律拒绝,不能让 `encoding/json` 的 replacement behavior 把不同输入合并为同一 digest;不把 `Digest`、BaseRevision、Revision、Index 或 ExecutionGrant 编入 digest。 +canonical 编码和 digest 函数由 agent 提供;Memoh 只保存和比较结果,不重新实现排序或编码。编码必须包含 sealed command/fact discriminator、按声明顺序编码有序 slice、对 map key 排序,并对 `CanonicalJSON` 原样写入其 canonical bytes;重复 object key、trailing data、invalid UTF-8、escaped lone surrogate 一律在构造 `CanonicalJSON` 或 decode wire document 时拒绝,不能让 `encoding/json` 的 replacement behavior 把不同输入合并为同一 digest;不把 `Digest`、BaseRevision、Revision、Index 或 ExecutionGrant 编入 digest。 已发布 `SchemaVersion` 的 canonical 编码和 digest 规则永久冻结;字段增删只能进入新的 SchemaVersion,旧事件按其自带版本校验。同一个 Run 不允许由写入不同 SchemaVersion 的进程混跑:升级窗口内先全量部署可读写新版本的代码,再开始写入新版本;否则同一 command 的重放会因编码不同被误判为 `ErrCommandConflict`。 -CommandEnvelope 和 AgentEvent 的 `SchemaVersion` 和 `Type` 是持久化协议字段;Type 必须与 sealed AgentCommand/Fact 的具体变体一致,未知版本或类型直接拒绝。agent 必须提供正式 wire codec:decode 时先读 `Type`,再恢复具体 command/fact variant,并校验 command/fact digest;不能依赖 `encoding/json` 自动反序列化 interface 字段。`DigestCommand`/`DigestFact` 对 `SchemaVersion`、`Type` 和内容做 canonical digest,但不把 `Digest` 字段自身纳入摘要,保证 Memoh scanner、MemoryRuntime 和不同进程使用同一身份规则。`Revision` 只用于 authority 的 CAS,不进入任何 digest。 +CommandEnvelope 和 AgentEvent 的 `SchemaVersion` 和 `Type` 是持久化协议字段;Type 必须与 sealed AgentCommand/Fact 的具体变体一致,未知版本或类型直接拒绝。agent 必须提供正式 wire codec:decode 时先 canonicalize 整个 document,再读 `Type`,恢复具体 command/fact variant,校验 command/fact digest,并要求 decoded value 重新 canonical marshal 后与输入 canonical document 等价;不能依赖 `encoding/json` 自动反序列化 interface 字段,也不能接受 duplicate key、大小写模糊字段名或其他会被 Go decoder 合并/宽容的形态。`DigestCommand`/`DigestFact` 对 `SchemaVersion`、`Type` 和内容做 canonical digest,但不把 `Digest` 字段自身纳入摘要,保证 Memoh scanner、MemoryRuntime 和不同进程使用同一身份规则。`Revision` 只用于 authority 的 CAS,不进入任何 digest。 Evolve 的折叠语义与事件编码同属永久兼容契约:已发布 SchemaVersion 的事件必须永远能被折叠出与写入当时相同的状态。conformance kit 为每个已发布 SchemaVersion 冻结 golden event stream 与对应的状态字节,任何 Evolve 实现变更都必须通过全部历史版本的 golden 校验。 @@ -960,7 +960,8 @@ agent 不提供第三个 Loop 操作。Memoh/application 的 response ingress: 3. 用 `ResponseRequest.RunID/StepID/CallID/ID` 路由响应。将 approval 转成 `ApproveToolCall`/`RejectToolCall`,将外部结果转成 `SubmitToolResponse`; `ResponseRequest.RequestDigest` 是原请求的摘要,用户决定或答案的摘要单独放在 - command 的 `ResponseDigest`,并用 `DeriveResponseCommandID` 生成稳定 CommandID。 + command 的 `ResponseDigest`,由 `DigestToolResponseDecision` / `DigestToolResponsePayload` + 计算,并用 `DeriveResponseCommandID` 生成稳定 CommandID。 4. 通过同一个 `Runtime.Commit` 提交;重复响应按 AlreadyApplied/Conflict 处理。 响应提交后,若有 Pending Call 或 ToolStep 已可自动关闭,Memoh 写入幂等 wake/outbox;其他 Waiting Call 保持原状态。响应入口不执行工具,也不调用模型。 @@ -1259,7 +1260,7 @@ type ExecutableTool interface { Ref() ToolRef Definition() sdk.ToolDefinition ResponsePolicy() ResponsePolicy - ValidateArguments(json.RawMessage) error + ValidateArguments(CanonicalJSON) error Execute(context.Context, ToolExecutionRequest) ToolExecutionOutcome } ``` @@ -1752,17 +1753,19 @@ type RunFailure struct { // Agent-owned JSON-stable persisted data. SDK request/result/tool values are // converted at Loop/provider boundaries via Freeze* and SDK(); Runtime never // stores sdk.MessagePart interfaces, map[string]any provider metadata, or -// caller-owned json.RawMessage bytes. -type ProviderMetadata map[string]json.RawMessage +// caller-owned JSON bytes. CanonicalJSON is an opaque immutable value constructed +// only by parsing/canonicalizing JSON at the boundary. +type CanonicalJSON = jsonstable.Value +type ProviderMetadata map[string]CanonicalJSON type CacheControl struct { Type string; TTL string } type Message struct { Role MessageRole; Content []MessagePart; Usage *Usage } type MessagePart struct { /* sealed text/reasoning/image/file/tool-call/tool-result fields */ } -type ResponseFormat struct { Type ResponseFormatType; JSONSchema json.RawMessage } +type ResponseFormat struct { Type ResponseFormatType; JSONSchema CanonicalJSON } type ToolChoice struct { Mode ToolChoiceMode; Tool string } -type ToolDefinition struct { Name string; Description string; Parameters json.RawMessage; CacheControl *CacheControl } +type ToolDefinition struct { Name string; Description string; Parameters CanonicalJSON; CacheControl *CacheControl } type ModelRequest struct { /* Model/System/Messages/Tools/options/ProviderOptions */ } type Usage struct { /* token counters and details; Add is field-wise */ } -type ModelToolCall struct { ToolCallID string; ToolName string; Input json.RawMessage; ProviderMetadata ProviderMetadata } +type ModelToolCall struct { ToolCallID string; ToolName string; Input CanonicalJSON; ProviderMetadata ProviderMetadata } type ReasoningPart struct { /* text/id/format/model/provider metadata */ } type Source struct { /* source identity and provider metadata */ } type GeneratedFile struct { Data string; MediaType string } @@ -1842,7 +1845,7 @@ type ToolCallState struct { ToolRef ToolRef DefinitionDigest Digest BindingDigest Digest - Arguments json.RawMessage + Arguments CanonicalJSON Policy ResponsePolicy Status ToolCallStatus Result *ToolExecutionResult @@ -1898,7 +1901,7 @@ type ResponseRequest struct { CallID CallID ID ResponseID Kind ResponseKind - Payload json.RawMessage + Payload CanonicalJSON RequestDigest Digest // digest of the request payload, not a user response } @@ -1931,7 +1934,7 @@ type Fact interface { fact() } type AgentInput struct { ID InputID - Payload json.RawMessage + Payload CanonicalJSON } // NextStep creates the command consumed by an active Run at a safe boundary. @@ -2017,7 +2020,7 @@ type SubmitToolResponse struct { CallID CallID ResponseID ResponseID ResponseDigest Digest // digest of the answer payload - Payload json.RawMessage + Payload CanonicalJSON } func (SubmitToolResponse) agentCommand() {} @@ -2085,7 +2088,7 @@ type ToolCallAnswered struct { CallID CallID ResponseID ResponseID ResponseDigest Digest - Payload json.RawMessage + Payload CanonicalJSON } func (ToolCallAnswered) fact() {} @@ -2118,7 +2121,7 @@ type ToolCallBinding struct { ToolRef ToolRef DefinitionDigest Digest BindingDigest Digest // definition, policy and canonical arguments - Arguments json.RawMessage + Arguments CanonicalJSON Policy ResponsePolicy // unresolved ToolRef uses DirectExecution Response *ResponseRequest // Decide 在 ToolStepOpened 中派生并填充;调用方提交时留空 } @@ -2295,7 +2298,7 @@ type ToolExecutionRequest struct { CallID CallID ToolRef ToolRef DefinitionDigest Digest - Arguments json.RawMessage + Arguments CanonicalJSON Progress ToolProgressSink } @@ -2303,11 +2306,11 @@ type ExecutableTool interface { Ref() ToolRef Definition() sdk.ToolDefinition ResponsePolicy() ResponsePolicy - ValidateArguments(json.RawMessage) error + ValidateArguments(CanonicalJSON) error Execute(context.Context, ToolExecutionRequest) ToolExecutionOutcome } -type ToolExecutionResult struct { Output json.RawMessage } +type ToolExecutionResult struct { Output CanonicalJSON } type ToolExecutionOutcome interface { toolExecutionOutcome() } @@ -2386,7 +2389,7 @@ type Loop struct { func (l *Loop) Run(context.Context, Runtime, EventSink) (LoopResult, error) ``` -实现必须保证所有返回的 Step、Call、Request、Result 和等待 payload 具有只读快照语义;调用方不能通过修改 slice、map 或 `json.RawMessage` 改变 Runtime 状态。`AgentCommand`、`Fact`、`Effect` 和 ToolExecutionOutcome 使用 agent 的 sealed interface,外部实现不能添加未定义变体。构造 CommandEnvelope 与派生 CommandID/ResponseID 只能通过 agent 提供的 typed 构造函数;手工拼装信封字段属于实现错误。 +实现必须保证所有返回的 Step、Call、Request、Result 和等待 payload 具有只读快照语义;调用方不能通过修改 slice、map 或 JSON bytes view 改变 Runtime 状态。`AgentCommand`、`Fact`、`Effect` 和 ToolExecutionOutcome 使用 agent 的 sealed interface,外部实现不能添加未定义变体。构造 CommandEnvelope 与派生 CommandID/ResponseID 只能通过 agent 提供的 typed 构造函数;手工拼装信封字段属于实现错误。 ## 附录 B:核心不变量 From 7e483699c7d4a74e7ba7c2d5f9a8997ecff5f053 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Fri, 28 Aug 2026 20:41:01 +0800 Subject: [PATCH 08/81] chore: satisfy golangci lint for runtime refactor --- agent/arguments.go | 2 +- agent/clone.go | 105 +++++++++++---------- agent/codec.go | 98 +++++++++----------- agent/commit.go | 30 +++--- agent/decide.go | 152 +++++++++++++++---------------- agent/evolve.go | 32 ++++--- agent/jsonstable/jsonstable.go | 7 +- agent/loop.go | 36 ++++---- agent/machine_test.go | 5 +- agent/memory.go | 37 ++++---- agent/model_data.go | 12 ++- agent/next.go | 2 + agent/protocol.go | 6 ++ agent/rebuild.go | 13 ++- agent/rebuild_test.go | 18 ++-- agent/runtimetest/conformance.go | 78 +++++++++------- agent/state.go | 12 ++- sdk/model_call.go | 29 ++++-- sdk/request_adapter.go | 48 +++++++--- sdk/usage.go | 2 + 20 files changed, 394 insertions(+), 330 deletions(-) diff --git a/agent/arguments.go b/agent/arguments.go index 352ea6a..373568b 100644 --- a/agent/arguments.go +++ b/agent/arguments.go @@ -18,7 +18,7 @@ func canonicalToolArguments(input any) (CanonicalJSON, error) { return ParseCanonicalJSON(x) case string: // Providers deliver unparsed argument text as a string. - if len(x) == 0 { + if x == "" { return ParseCanonicalJSON([]byte("null")) } return ParseCanonicalJSON([]byte(x)) diff --git a/agent/clone.go b/agent/clone.go index 270d247..1688726 100644 --- a/agent/clone.go +++ b/agent/clone.go @@ -81,18 +81,20 @@ func cloneToolCallFailure(f *ToolCallFailure) *ToolCallFailure { return &c } -func cloneToolCallState(c ToolCallState) ToolCallState { - c.Arguments = cloneRaw(c.Arguments) - c.Result = cloneToolExecutionResult(c.Result) - c.Failure = cloneToolCallFailure(c.Failure) - c.Waiting = cloneResponseRequest(c.Waiting) - return c +func cloneToolCallState(c *ToolCallState) ToolCallState { + out := *c + out.Arguments = cloneRaw(out.Arguments) + out.Result = cloneToolExecutionResult(out.Result) + out.Failure = cloneToolCallFailure(out.Failure) + out.Waiting = cloneResponseRequest(out.Waiting) + return out } -func cloneToolCallBinding(b ToolCallBinding) ToolCallBinding { - b.Arguments = cloneRaw(b.Arguments) - b.Response = cloneResponseRequest(b.Response) - return b +func cloneToolCallBinding(b *ToolCallBinding) ToolCallBinding { + out := *b + out.Arguments = cloneRaw(out.Arguments) + out.Response = cloneResponseRequest(out.Response) + return out } func cloneToolCallBindings(bs []ToolCallBinding) []ToolCallBinding { @@ -100,8 +102,8 @@ func cloneToolCallBindings(bs []ToolCallBinding) []ToolCallBinding { return nil } out := make([]ToolCallBinding, len(bs)) - for i, b := range bs { - out[i] = cloneToolCallBinding(b) + for i := range bs { + out[i] = cloneToolCallBinding(&bs[i]) } return out } @@ -133,12 +135,13 @@ func cloneResponseFormat(f *ResponseFormat) *ResponseFormat { return &c } -func cloneMessagePart(p MessagePart) MessagePart { - p.Input = cloneRaw(p.Input) - p.Result = cloneRaw(p.Result) - p.CacheControl = cloneCacheControl(p.CacheControl) - p.ProviderMetadata = cloneProviderMetadata(p.ProviderMetadata) - return p +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 { @@ -149,8 +152,8 @@ func cloneMessages(messages []Message) []Message { for i, m := range messages { if m.Content != nil { parts := make([]MessagePart, len(m.Content)) - for j, p := range m.Content { - parts[j] = cloneMessagePart(p) + for j := range m.Content { + parts[j] = cloneMessagePart(&m.Content[j]) } m.Content = parts } @@ -160,28 +163,29 @@ func cloneMessages(messages []Message) []Message { return out } -func cloneRequest(r ModelRequest) ModelRequest { - r.Messages = cloneMessages(r.Messages) - r.Tools = cloneToolDefinitions(r.Tools) - r.ResponseFormat = cloneResponseFormat(r.ResponseFormat) - r.Temperature = clonePtr(r.Temperature) - r.TopP = clonePtr(r.TopP) - r.MaxTokens = clonePtr(r.MaxTokens) - r.FrequencyPenalty = clonePtr(r.FrequencyPenalty) - r.PresencePenalty = clonePtr(r.PresencePenalty) - r.Seed = clonePtr(r.Seed) - r.ReasoningEffort = clonePtr(r.ReasoningEffort) - r.ReasoningSummary = clonePtr(r.ReasoningSummary) - r.PromptCacheKey = clonePtr(r.PromptCacheKey) - r.StopSequences = append([]string(nil), r.StopSequences...) - if r.ProviderOptions != nil { - opts := make(map[string]CanonicalJSON, len(r.ProviderOptions)) - for k, v := range r.ProviderOptions { +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) } - r.ProviderOptions = opts + out.ProviderOptions = opts } - return r + return out } func cloneToolDefinitions(defs []ToolDefinition) []ToolDefinition { @@ -287,13 +291,13 @@ func cloneRunResult(r *RunResult) *RunResult { func cloneStep(s Step) Step { switch step := s.(type) { case ModelStep: - step.Request = cloneRequest(step.Request) + step.Request = cloneRequest(&step.Request) step.Tools = cloneToolSpecs(step.Tools) return step case ToolStep: calls := make([]ToolCallState, len(step.Calls)) - for i, c := range step.Calls { - calls[i] = cloneToolCallState(c) + for i := range step.Calls { + calls[i] = cloneToolCallState(&step.Calls[i]) } step.Calls = calls return step @@ -302,14 +306,15 @@ func cloneStep(s Step) Step { } } -func cloneMachineState(s MachineState) MachineState { - if s.Current != nil { - s.Current = cloneStep(s.Current) +func cloneMachineState(s *MachineState) MachineState { + out := *s + if out.Current != nil { + out.Current = cloneStep(out.Current) } - s.PendingInputs = cloneAgentInputs(s.PendingInputs) - s.LastModelResult = cloneModelResult(s.LastModelResult) - s.Result = cloneRunResult(s.Result) - return s + out.PendingInputs = cloneAgentInputs(out.PendingInputs) + out.LastModelResult = cloneModelResult(out.LastModelResult) + out.Result = cloneRunResult(out.Result) + return out } func snapshotJSONStable[T any](v T) (T, error) { @@ -346,7 +351,7 @@ func snapshotFact(f Fact) (Fact, error) { func cloneFact(f Fact) Fact { switch fact := f.(type) { case ModelStepPrepared: - fact.Request = cloneRequest(fact.Request) + fact.Request = cloneRequest(&fact.Request) fact.InputIDs = append([]InputID(nil), fact.InputIDs...) fact.Tools = cloneToolSpecs(fact.Tools) return fact diff --git a/agent/codec.go b/agent/codec.go index 5b20443..5c17d4c 100644 --- a/agent/codec.go +++ b/agent/codec.go @@ -71,6 +71,7 @@ func DecodeAgentEvent(raw []byte) (AgentEvent, error) { return event, 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") @@ -135,6 +136,7 @@ func (e *CommandEnvelope) UnmarshalJSON(raw []byte) error { return nil } +//nolint:gocritic // hugeParam: value receiver keeps json.Marshaler active for non-pointer AgentEvent values. func (e AgentEvent) MarshalJSON() ([]byte, error) { if e.Fact == nil { return nil, errors.New("agent: codec: event has nil fact") @@ -246,53 +248,51 @@ func decodeStrictJSON(raw []byte, dst any) error { return nil } +func decodeCommandAs[T AgentCommand](raw json.RawMessage) (AgentCommand, error) { + var c T + err := decodeStrictJSON(raw, &c) + return c, err +} + +func decodeFactAs[T Fact](raw json.RawMessage) (Fact, error) { + var f T + err := decodeStrictJSON(raw, &f) + return f, err +} + func decodeCommandVariant(typ string, raw json.RawMessage) (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": - var c PrepareModelRequest - return c, decodeStrictJSON(raw, &c) + return decodeCommandAs[PrepareModelRequest](raw) case "start_model_execution": - var c StartModelExecution - return c, decodeStrictJSON(raw, &c) + return decodeCommandAs[StartModelExecution](raw) case "recover_model_execution": - var c RecoverModelExecution - return c, decodeStrictJSON(raw, &c) + return decodeCommandAs[RecoverModelExecution](raw) case "submit_model_result": - var c SubmitModelResult - return c, decodeStrictJSON(raw, &c) + return decodeCommandAs[SubmitModelResult](raw) case "submit_model_failure": - var c SubmitModelFailure - return c, decodeStrictJSON(raw, &c) + return decodeCommandAs[SubmitModelFailure](raw) case "reject_model_result": - var c RejectModelResult - return c, decodeStrictJSON(raw, &c) + return decodeCommandAs[RejectModelResult](raw) case "start_tool_call": - var c StartToolCall - return c, decodeStrictJSON(raw, &c) + return decodeCommandAs[StartToolCall](raw) case "submit_tool_result": - var c SubmitToolResult - return c, decodeStrictJSON(raw, &c) + return decodeCommandAs[SubmitToolResult](raw) case "submit_tool_failure": - var c SubmitToolFailure - return c, decodeStrictJSON(raw, &c) + return decodeCommandAs[SubmitToolFailure](raw) case "approve_tool_call": - var c ApproveToolCall - return c, decodeStrictJSON(raw, &c) + return decodeCommandAs[ApproveToolCall](raw) case "reject_tool_call": - var c RejectToolCall - return c, decodeStrictJSON(raw, &c) + return decodeCommandAs[RejectToolCall](raw) case "submit_tool_response": - var c SubmitToolResponse - return c, decodeStrictJSON(raw, &c) + return decodeCommandAs[SubmitToolResponse](raw) case "cancel_run": - var c CancelRun - return c, decodeStrictJSON(raw, &c) + return decodeCommandAs[CancelRun](raw) case "accept_input": - var c AcceptInput - return c, decodeStrictJSON(raw, &c) + return decodeCommandAs[AcceptInput](raw) default: return nil, fmt.Errorf("agent: codec: unknown command type %q", typ) } @@ -304,47 +304,33 @@ func decodeFactVariant(typ string, raw json.RawMessage) (Fact, error) { } switch typ { case "model_step_prepared": - var f ModelStepPrepared - return f, decodeStrictJSON(raw, &f) + return decodeFactAs[ModelStepPrepared](raw) case "model_step_started": - var f ModelStepStarted - return f, decodeStrictJSON(raw, &f) + return decodeFactAs[ModelStepStarted](raw) case "model_step_recovered": - var f ModelStepRecovered - return f, decodeStrictJSON(raw, &f) + return decodeFactAs[ModelStepRecovered](raw) case "model_step_rejected": - var f ModelStepRejected - return f, decodeStrictJSON(raw, &f) + return decodeFactAs[ModelStepRejected](raw) case "model_step_completed": - var f ModelStepCompleted - return f, decodeStrictJSON(raw, &f) + return decodeFactAs[ModelStepCompleted](raw) case "tool_step_opened": - var f ToolStepOpened - return f, decodeStrictJSON(raw, &f) + return decodeFactAs[ToolStepOpened](raw) case "tool_call_started": - var f ToolCallStarted - return f, decodeStrictJSON(raw, &f) + return decodeFactAs[ToolCallStarted](raw) case "tool_call_approved": - var f ToolCallApproved - return f, decodeStrictJSON(raw, &f) + return decodeFactAs[ToolCallApproved](raw) case "tool_call_completed": - var f ToolCallCompleted - return f, decodeStrictJSON(raw, &f) + return decodeFactAs[ToolCallCompleted](raw) case "tool_call_answered": - var f ToolCallAnswered - return f, decodeStrictJSON(raw, &f) + return decodeFactAs[ToolCallAnswered](raw) case "tool_call_failed": - var f ToolCallFailed - return f, decodeStrictJSON(raw, &f) + return decodeFactAs[ToolCallFailed](raw) case "tool_step_closed": - var f ToolStepClosed - return f, decodeStrictJSON(raw, &f) + return decodeFactAs[ToolStepClosed](raw) case "input_accepted": - var f InputAccepted - return f, decodeStrictJSON(raw, &f) + return decodeFactAs[InputAccepted](raw) case "run_ended": - var f RunEnded - return f, decodeStrictJSON(raw, &f) + return decodeFactAs[RunEnded](raw) default: return nil, fmt.Errorf("agent: codec: unknown fact type %q", typ) } diff --git a/agent/commit.go b/agent/commit.go index 2e737f0..39c39cc 100644 --- a/agent/commit.go +++ b/agent/commit.go @@ -2,6 +2,7 @@ package agent import ( "context" + "errors" "fmt" ) @@ -102,7 +103,7 @@ func categorize(c AgentCommand) commandCategory { // requiresGrant reports whether this command must carry the start grant of // its target, given the current state (spec §5.3 grant rules). -func requiresGrant(s MachineState, c AgentCommand) bool { +func requiresGrant(s *MachineState, c AgentCommand) bool { switch cmd := c.(type) { case SubmitModelResult, SubmitModelFailure, RejectModelResult: return true @@ -131,6 +132,8 @@ func requiresGrant(s MachineState, c AgentCommand) bool { // are the control-plane verdicts the Runtime supplies: whether req.Grant is // the live grant for the command's target, and whether a grantless recovery // command matches the Runtime's own lease-expiry record. +// +//nolint:gocritic // hugeParam: public pure commit evaluator keeps state/request as value protocol inputs. func EvaluateCommit( cur MachineState, curRevision uint64, prior []AgentEvent, @@ -160,7 +163,7 @@ func EvaluateCommit( // Derived-identity families must use their derived CommandID (spec §5.5): // the derivation IS the idempotency index for inputs/responses/planning, so // a caller-minted random ID would silently bypass duplicate detection. - if err := checkDerivedCommandID(env, req.BaseRevision); err != nil { + if err := checkDerivedCommandID(&env, req.BaseRevision); err != nil { return CommitDecision{}, err } @@ -182,7 +185,7 @@ func EvaluateCommit( if cat == catPlan && req.BaseRevision != curRevision { return CommitDecision{Kind: DecisionStale, Reject: ErrStaleRuntime}, nil } - if requiresGrant(cur, env.Command) && !grantValid { + if requiresGrant(&cur, env.Command) && !grantValid { return CommitDecision{Kind: DecisionStale, Reject: ErrStaleRuntime}, nil } if cat == catRecovery && !grantValid && !recoveryValid { @@ -192,12 +195,12 @@ func EvaluateCommit( // Step 5: Decide once, fold with Evolve. facts, err := Decide(cur, env.Command) if err != nil { - switch err { - case ErrRunTerminal: + switch { + case errors.Is(err, ErrRunTerminal): return CommitDecision{Kind: DecisionTerminal, Reject: err}, nil - case ErrStaleRuntime: + case errors.Is(err, ErrStaleRuntime): return CommitDecision{Kind: DecisionStale, Reject: err}, nil - case ErrCommandConflict: + case errors.Is(err, ErrCommandConflict): return CommitDecision{Kind: DecisionConflict, Reject: err}, nil default: // Precondition failures against the current state are stale from @@ -282,7 +285,7 @@ func BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, // from (RunID, StepID, CallID, ResponseID). Approve and reject of the same // response share one identity by design, so a decision change surfaces as // ErrCommandConflict instead of a second fact. -func checkDerivedCommandID(env CommandEnvelope, baseRevision uint64) error { +func checkDerivedCommandID(env *CommandEnvelope, baseRevision uint64) error { var want CommandID switch cmd := env.Command.(type) { case PrepareModelRequest: @@ -303,14 +306,3 @@ func checkDerivedCommandID(env CommandEnvelope, baseRevision uint64) error { } return nil } - -// isStartCommand reports whether an accepted commit of this command mints a -// new ExecutionGrant. -func isStartCommand(c AgentCommand) bool { - switch c.(type) { - case StartModelExecution, StartToolCall: - return true - default: - return false - } -} diff --git a/agent/decide.go b/agent/decide.go index 0567af9..b3b5054 100644 --- a/agent/decide.go +++ b/agent/decide.go @@ -25,39 +25,41 @@ func rejectionf(format string, args ...any) error { // acceptance, derived consequences, terminal transitions — happen here, run // exactly once per accepted command; the output is frozen. Any precondition // failure rejects the whole command with no partial facts. +// +//nolint:gocritic // hugeParam: public protocol boundary is intentionally value-based: Decide(state, command) -> facts. func Decide(s MachineState, c AgentCommand) ([]Fact, error) { if s.Status.Terminal() { return nil, ErrRunTerminal } switch cmd := c.(type) { case PrepareModelRequest: - return decidePrepareModelRequest(s, cmd) + return decidePrepareModelRequest(&s, &cmd) case StartModelExecution: - return decideStartModelExecution(s, cmd) + return decideStartModelExecution(&s, cmd) case RecoverModelExecution: - return decideRecoverModelExecution(s, cmd) + return decideRecoverModelExecution(&s, cmd) case SubmitModelResult: - return decideSubmitModelResult(s, cmd) + return decideSubmitModelResult(&s, &cmd) case SubmitModelFailure: - return decideSubmitModelFailure(s, cmd) + return decideSubmitModelFailure(&s, cmd) case RejectModelResult: - return decideRejectModelResult(s, cmd) + return decideRejectModelResult(&s, &cmd) case StartToolCall: - return decideStartToolCall(s, cmd) + return decideStartToolCall(&s, cmd) case SubmitToolResult: - return decideSubmitToolResult(s, cmd) + return decideSubmitToolResult(&s, cmd) case SubmitToolFailure: - return decideSubmitToolFailure(s, cmd) + return decideSubmitToolFailure(&s, cmd) case ApproveToolCall: - return decideApproveToolCall(s, cmd) + return decideApproveToolCall(&s, cmd) case RejectToolCall: - return decideRejectToolCall(s, cmd) + return decideRejectToolCall(&s, &cmd) case SubmitToolResponse: - return decideSubmitToolResponse(s, cmd) + return decideSubmitToolResponse(&s, &cmd) case CancelRun: - return decideCancelRun(s, cmd) + return decideCancelRun(&s, cmd) case AcceptInput: - return decideAcceptInput(s, cmd) + return decideAcceptInput(&s, cmd) default: return nil, rejectionf("unknown command variant %T", c) } @@ -65,7 +67,7 @@ func Decide(s MachineState, c AgentCommand) ([]Fact, error) { // --- rule 1: PrepareModelRequest --- -func decidePrepareModelRequest(s MachineState, cmd PrepareModelRequest) ([]Fact, error) { +func decidePrepareModelRequest(s *MachineState, cmd *PrepareModelRequest) ([]Fact, error) { if s.Current != nil { return nil, rejectionf("prepare: run already has a current step") } @@ -141,7 +143,7 @@ func decidePrepareModelRequest(s MachineState, cmd PrepareModelRequest) ([]Fact, // --- rule 2: StartModelExecution / RecoverModelExecution --- -func currentModelStep(s MachineState, step StepID) (*ModelStep, error) { +func currentModelStep(s *MachineState, step StepID) (*ModelStep, error) { ms, ok := s.Current.(ModelStep) if !ok { return nil, rejectionf("no current ModelStep") @@ -152,7 +154,7 @@ func currentModelStep(s MachineState, step StepID) (*ModelStep, error) { return &ms, nil } -func decideStartModelExecution(s MachineState, cmd StartModelExecution) ([]Fact, error) { +func decideStartModelExecution(s *MachineState, cmd StartModelExecution) ([]Fact, error) { ms, err := currentModelStep(s, cmd.StepID) if err != nil { return nil, err @@ -160,10 +162,10 @@ func decideStartModelExecution(s MachineState, cmd StartModelExecution) ([]Fact, if ms.Status != ModelPrepared { return nil, rejectionf("start model: step is not Prepared") } - return []Fact{ModelStepStarted{StepID: cmd.StepID}}, nil + return []Fact{ModelStepStarted(cmd)}, nil } -func decideRecoverModelExecution(s MachineState, cmd RecoverModelExecution) ([]Fact, error) { +func decideRecoverModelExecution(s *MachineState, cmd RecoverModelExecution) ([]Fact, error) { ms, err := currentModelStep(s, cmd.StepID) if err != nil { return nil, err @@ -171,12 +173,12 @@ func decideRecoverModelExecution(s MachineState, cmd RecoverModelExecution) ([]F if ms.Status != ModelExecuting { return nil, rejectionf("recover model: step is not Executing") } - return []Fact{ModelStepRecovered{StepID: cmd.StepID}}, nil + return []Fact{ModelStepRecovered(cmd)}, nil } // --- rule 3: SubmitModelResult --- -func decideSubmitModelResult(s MachineState, cmd SubmitModelResult) ([]Fact, error) { +func decideSubmitModelResult(s *MachineState, cmd *SubmitModelResult) ([]Fact, error) { ms, err := currentModelStep(s, cmd.StepID) if err != nil { return nil, err @@ -269,24 +271,25 @@ func decideSubmitModelResult(s MachineState, cmd SubmitModelResult) ([]Fact, err } toolStepID := DeriveToolStepID(cmd.StepID, setDigest) for i := range bindings { - if bindings[i].Policy == ApprovalRequired || bindings[i].Policy == ExternalResponse { - kind := ResponseApproval - if bindings[i].Policy == ExternalResponse { - kind = ResponseExternal - } - reqDigest, err := digestToolCallBinding(bindings[i].CallID, bindings[i].DefinitionDigest, bindings[i].Policy, bindings[i].Arguments) - if err != nil { - return nil, err - } - bindings[i].Response = &ResponseRequest{ - RunID: s.RunID, - StepID: toolStepID, - CallID: bindings[i].CallID, - ID: DeriveResponseID(s.RunID, toolStepID, bindings[i].CallID, kind), - Kind: kind, - Payload: bindings[i].Arguments, - RequestDigest: reqDigest, - } + if bindings[i].Policy != ApprovalRequired && bindings[i].Policy != ExternalResponse { + continue + } + kind := ResponseApproval + if bindings[i].Policy == ExternalResponse { + kind = ResponseExternal + } + reqDigest, err := digestToolCallBinding(bindings[i].CallID, bindings[i].DefinitionDigest, bindings[i].Policy, bindings[i].Arguments) + if err != nil { + return nil, err + } + bindings[i].Response = &ResponseRequest{ + RunID: s.RunID, + StepID: toolStepID, + CallID: bindings[i].CallID, + ID: DeriveResponseID(s.RunID, toolStepID, bindings[i].CallID, kind), + Kind: kind, + Payload: bindings[i].Arguments, + RequestDigest: reqDigest, } } return []Fact{completed, ToolStepOpened{ @@ -310,7 +313,7 @@ func canonicalArgumentsForCompare(input any) (CanonicalJSON, bool) { // --- rule 4: SubmitModelFailure --- -func decideSubmitModelFailure(s MachineState, cmd SubmitModelFailure) ([]Fact, error) { +func decideSubmitModelFailure(s *MachineState, cmd SubmitModelFailure) ([]Fact, error) { ms, err := currentModelStep(s, cmd.StepID) if err != nil { return nil, err @@ -330,7 +333,7 @@ func decideSubmitModelFailure(s MachineState, cmd SubmitModelFailure) ([]Fact, e // --- rule 5: RejectModelResult --- -func decideRejectModelResult(s MachineState, cmd RejectModelResult) ([]Fact, error) { +func decideRejectModelResult(s *MachineState, cmd *RejectModelResult) ([]Fact, error) { ms, err := currentModelStep(s, cmd.StepID) if err != nil { return nil, err @@ -338,7 +341,7 @@ func decideRejectModelResult(s MachineState, cmd RejectModelResult) ([]Fact, 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} + rejected := ModelStepRejected(*cmd) if ms.Rejects+1 > s.Config.ModelRejectLimit { return []Fact{rejected, RunEnded{ Status: RunFailed, @@ -351,7 +354,7 @@ func decideRejectModelResult(s MachineState, cmd RejectModelResult) ([]Fact, err // --- rules 6-8: tool call lifecycle --- -func currentToolStep(s MachineState, step StepID) (*ToolStep, error) { +func currentToolStep(s *MachineState, step StepID) (*ToolStep, error) { ts, ok := s.Current.(ToolStep) if !ok { return nil, rejectionf("no current ToolStep") @@ -362,7 +365,7 @@ func currentToolStep(s MachineState, step StepID) (*ToolStep, error) { return &ts, nil } -func decideStartToolCall(s MachineState, cmd StartToolCall) ([]Fact, error) { +func decideStartToolCall(s *MachineState, cmd StartToolCall) ([]Fact, error) { ts, err := currentToolStep(s, cmd.StepID) if err != nil { return nil, err @@ -374,10 +377,10 @@ func decideStartToolCall(s MachineState, cmd StartToolCall) ([]Fact, error) { 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 + return []Fact{ToolCallStarted(cmd)}, nil } -func decideSubmitToolResult(s MachineState, cmd SubmitToolResult) ([]Fact, error) { +func decideSubmitToolResult(s *MachineState, cmd SubmitToolResult) ([]Fact, error) { ts, err := currentToolStep(s, cmd.StepID) if err != nil { return nil, err @@ -389,11 +392,11 @@ func decideSubmitToolResult(s MachineState, cmd SubmitToolResult) ([]Fact, error if ts.Calls[i].Status != ToolExecuting { return nil, rejectionf("tool result: call %q is not Executing", cmd.CallID) } - facts := []Fact{ToolCallCompleted{StepID: cmd.StepID, CallID: cmd.CallID, Result: cmd.Result}} - return appendCloseIfLast(s, *ts, i, facts) + facts := []Fact{ToolCallCompleted(cmd)} + return appendCloseIfLast(s, ts, i, facts) } -func decideSubmitToolFailure(s MachineState, cmd SubmitToolFailure) ([]Fact, error) { +func decideSubmitToolFailure(s *MachineState, cmd SubmitToolFailure) ([]Fact, error) { ts, err := currentToolStep(s, cmd.StepID) if err != nil { return nil, err @@ -409,7 +412,7 @@ func decideSubmitToolFailure(s MachineState, cmd SubmitToolFailure) ([]Fact, err 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 appendCloseIfLast(s, *ts, i, facts) + return appendCloseIfLast(s, ts, i, facts) case ToolOutcomeUnknown: if call.Status != ToolExecuting { return nil, rejectionf("tool failure: unknown outcome requires Executing call") @@ -437,7 +440,7 @@ func decideSubmitToolFailure(s MachineState, cmd SubmitToolFailure) ([]Fact, err // appendCloseIfLast appends ToolStepClosed when call i reaching a closable // terminal state closes the step, plus RunEnded when the frozen model-step // limit was reached (spec §3.7.1 rule 11). -func appendCloseIfLast(s MachineState, ts ToolStep, i int, facts []Fact) ([]Fact, error) { +func appendCloseIfLast(s *MachineState, ts *ToolStep, i int, facts []Fact) ([]Fact, error) { for j := range ts.Calls { if j == i { continue @@ -458,30 +461,30 @@ func appendCloseIfLast(s MachineState, ts ToolStep, i int, facts []Fact) ([]Fact // --- rules 9-10: responses --- -func waitingCall(s MachineState, step StepID, call CallID, kind ResponseKind, resp ResponseID) (*ToolCallState, error) { +func waitingCall(s *MachineState, step StepID, call CallID, kind ResponseKind, resp ResponseID) (*ToolStep, int, error) { ts, err := currentToolStep(s, step) if err != nil { - return nil, err + return nil, 0, err } i := ts.callIndex(call) if i < 0 { - return nil, rejectionf("response: unknown call %q", call) + return nil, 0, rejectionf("response: unknown call %q", call) } c := ts.Calls[i] if c.Status != ToolWaiting || c.Waiting == nil { - return nil, rejectionf("response: call %q is not Waiting", call) + return nil, 0, rejectionf("response: call %q is not Waiting", call) } if c.Waiting.Kind != kind { - return nil, rejectionf("response: call %q expects kind %q, got %q", call, c.Waiting.Kind, kind) + return nil, 0, rejectionf("response: call %q expects kind %q, got %q", call, c.Waiting.Kind, kind) } if c.Waiting.ID != resp { - return nil, rejectionf("response: call %q expects ResponseID %q, got %q", call, c.Waiting.ID, resp) + return nil, 0, rejectionf("response: call %q expects ResponseID %q, got %q", call, c.Waiting.ID, resp) } - return &c, nil + return ts, i, nil } -func decideApproveToolCall(s MachineState, cmd ApproveToolCall) ([]Fact, error) { - if _, err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseApproval, cmd.ResponseID); err != 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 := DigestToolResponseDecision(ResponseApproval, ResponseDecisionApproved, "") @@ -491,10 +494,10 @@ func decideApproveToolCall(s MachineState, cmd ApproveToolCall) ([]Fact, error) if cmd.ResponseDigest != wantDigest { return nil, rejectionf("response: approval digest mismatch") } - return []Fact{ToolCallApproved{StepID: cmd.StepID, CallID: cmd.CallID, ResponseID: cmd.ResponseID, ResponseDigest: cmd.ResponseDigest}}, nil + return []Fact{ToolCallApproved(cmd)}, nil } -func decideRejectToolCall(s MachineState, cmd RejectToolCall) ([]Fact, error) { +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. Spec §4.2 lists Waiting -> Failed(Known) @@ -528,11 +531,12 @@ func decideRejectToolCall(s MachineState, cmd RejectToolCall) ([]Fact, error) { Failure: ToolFailure{Class: FailurePermissionDenied, Message: cmd.Reason}, Outcome: ToolOutcomeKnown, }} - return appendCloseIfLast(s, *ts, i, facts) + return appendCloseIfLast(s, ts, i, facts) } -func decideSubmitToolResponse(s MachineState, cmd SubmitToolResponse) ([]Fact, error) { - if _, err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseExternal, cmd.ResponseID); err != nil { +func decideSubmitToolResponse(s *MachineState, cmd *SubmitToolResponse) ([]Fact, error) { + ts, i, err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseExternal, cmd.ResponseID) + if err != nil { return nil, err } wantDigest, err := DigestToolResponsePayload(cmd.Payload) @@ -542,21 +546,13 @@ func decideSubmitToolResponse(s MachineState, cmd SubmitToolResponse) ([]Fact, e if cmd.ResponseDigest != wantDigest { return nil, rejectionf("response: answer payload digest mismatch") } - ts, _ := currentToolStep(s, cmd.StepID) - i := ts.callIndex(cmd.CallID) - facts := []Fact{ToolCallAnswered{ - StepID: cmd.StepID, - CallID: cmd.CallID, - ResponseID: cmd.ResponseID, - ResponseDigest: cmd.ResponseDigest, - Payload: cmd.Payload, - }} - return appendCloseIfLast(s, *ts, i, facts) + facts := []Fact{ToolCallAnswered(*cmd)} + return appendCloseIfLast(s, ts, i, facts) } // --- rules 12-13: cancel and input --- -func decideCancelRun(s MachineState, cmd CancelRun) ([]Fact, error) { +func decideCancelRun(_ *MachineState, cmd CancelRun) ([]Fact, error) { // Spec rule 12 fixes the mapping: CancelRun always records // RunStopped(cancelled). A caller-chosen reason would let ingress forge // step_limit or other system reasons into the log. @@ -566,7 +562,7 @@ func decideCancelRun(s MachineState, cmd CancelRun) ([]Fact, error) { return []Fact{RunEnded{Status: RunStopped, Reason: ReasonCancelled}}, nil } -func decideAcceptInput(s MachineState, cmd AcceptInput) ([]Fact, error) { +func decideAcceptInput(s *MachineState, cmd AcceptInput) ([]Fact, error) { if s.Current != nil { return nil, rejectionf("accept input: run has a current step") } @@ -578,5 +574,5 @@ func decideAcceptInput(s MachineState, cmd AcceptInput) ([]Fact, error) { return nil, ErrCommandConflict } } - return []Fact{InputAccepted{Input: cmd.Input}}, nil + return []Fact{InputAccepted(cmd)}, nil } diff --git a/agent/evolve.go b/agent/evolve.go index 796dd1d..cd77e04 100644 --- a/agent/evolve.go +++ b/agent/evolve.go @@ -7,6 +7,8 @@ import "fmt" // Its folding semantics, together with the canonical encoding, form the // permanent compatibility contract of a published SchemaVersion; replay // depends only on Evolve. +// +//nolint:gocritic // hugeParam: public fold boundary must stay value-based: Evolve(state, fact) -> new state. func Evolve(s MachineState, f Fact) (MachineState, error) { switch fact := f.(type) { case ModelStepPrepared: @@ -27,7 +29,7 @@ func Evolve(s MachineState, f Fact) (MachineState, error) { return s, nil case ModelStepStarted: - ms, err := evolveModelStep(s, fact.StepID) + ms, err := evolveModelStep(&s, fact.StepID) if err != nil { return s, err } @@ -36,7 +38,7 @@ func Evolve(s MachineState, f Fact) (MachineState, error) { return s, nil case ModelStepRecovered: - ms, err := evolveModelStep(s, fact.StepID) + ms, err := evolveModelStep(&s, fact.StepID) if err != nil { return s, err } @@ -45,7 +47,7 @@ func Evolve(s MachineState, f Fact) (MachineState, error) { return s, nil case ModelStepRejected: - ms, err := evolveModelStep(s, fact.StepID) + ms, err := evolveModelStep(&s, fact.StepID) if err != nil { return s, err } @@ -56,7 +58,7 @@ func Evolve(s MachineState, f Fact) (MachineState, error) { return s, nil case ModelStepCompleted: - if _, err := evolveModelStep(s, fact.StepID); err != nil { + if _, err := evolveModelStep(&s, fact.StepID); err != nil { return s, err } result := fact.Result @@ -100,18 +102,18 @@ func Evolve(s MachineState, f Fact) (MachineState, error) { return s, nil case ToolCallStarted: - return evolveCall(s, fact.StepID, fact.CallID, func(c *ToolCallState) { + return evolveCall(&s, fact.StepID, fact.CallID, func(c *ToolCallState) { c.Status = ToolExecuting }) case ToolCallApproved: - return evolveCall(s, fact.StepID, fact.CallID, func(c *ToolCallState) { + return evolveCall(&s, fact.StepID, fact.CallID, func(c *ToolCallState) { c.Status = ToolPending c.Waiting = nil }) case ToolCallCompleted: - return evolveCall(s, fact.StepID, fact.CallID, func(c *ToolCallState) { + return evolveCall(&s, fact.StepID, fact.CallID, func(c *ToolCallState) { c.Status = ToolCompleted r := fact.Result c.Result = &r @@ -119,14 +121,14 @@ func Evolve(s MachineState, f Fact) (MachineState, error) { }) case ToolCallAnswered: - return evolveCall(s, fact.StepID, fact.CallID, func(c *ToolCallState) { + return evolveCall(&s, fact.StepID, fact.CallID, func(c *ToolCallState) { c.Status = ToolCompleted c.Result = &ToolExecutionResult{Output: fact.Payload} c.Waiting = nil }) case ToolCallFailed: - return evolveCall(s, fact.StepID, fact.CallID, func(c *ToolCallState) { + return evolveCall(&s, fact.StepID, fact.CallID, func(c *ToolCallState) { c.Status = ToolFailed c.Failure = &ToolCallFailure{Failure: fact.Failure, Outcome: fact.Outcome} c.Waiting = nil @@ -167,7 +169,7 @@ func Evolve(s MachineState, f Fact) (MachineState, error) { } } -func evolveModelStep(s MachineState, step StepID) (*ModelStep, error) { +func evolveModelStep(s *MachineState, step StepID) (*ModelStep, error) { ms, ok := s.Current.(ModelStep) if !ok || ms.RefValue.ID != step { return nil, fmt.Errorf("agent: evolve: model step %q is not current", step) @@ -175,25 +177,25 @@ func evolveModelStep(s MachineState, step StepID) (*ModelStep, error) { return &ms, nil } -func evolveCall(s MachineState, step StepID, call CallID, apply func(*ToolCallState)) (MachineState, error) { +func evolveCall(s *MachineState, step StepID, call CallID, apply func(*ToolCallState)) (MachineState, error) { ts, ok := s.Current.(ToolStep) if !ok || ts.RefValue.ID != step { - return s, fmt.Errorf("agent: evolve: tool step %q is not current", step) + return *s, fmt.Errorf("agent: evolve: tool step %q is not current", step) } i := ts.callIndex(call) if i < 0 { - return s, fmt.Errorf("agent: evolve: unknown call %q", call) + return *s, fmt.Errorf("agent: evolve: unknown call %q", call) } calls := append([]ToolCallState(nil), ts.Calls...) apply(&calls[i]) // Spec §4.2: Evolve must reject illegal field combinations, e.g. an // unknown-outcome failure whose class is not effect_unknown. if err := ValidateToolCallState(calls[i]); err != nil { - return s, err + return *s, err } ts.Calls = calls s.Current = ts - return s, nil + return *s, nil } func removeInputs(inputs []AgentInput, ids []InputID) []AgentInput { diff --git a/agent/jsonstable/jsonstable.go b/agent/jsonstable/jsonstable.go index ee0a00f..31010e3 100644 --- a/agent/jsonstable/jsonstable.go +++ b/agent/jsonstable/jsonstable.go @@ -366,14 +366,15 @@ func writeCanonicalString(b *bytes.Buffer, s string) error { case '\r': b.WriteString(`\r`) default: - if r < 0x20 { + switch { + case r < 0x20: fmt.Fprintf(b, `\u%04x`, r) - } else if r == utf8.RuneError { + case r == utf8.RuneError: if _, size := utf8.DecodeRuneInString(s[i:]); size == 1 { return errors.New("agent: canonical: invalid UTF-8 in string") } b.WriteRune(r) // a genuine U+FFFD character - } else { + default: b.WriteRune(r) } } diff --git a/agent/loop.go b/agent/loop.go index 9b9b746..df0f86a 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -66,15 +66,15 @@ func (l *Loop) Run(ctx context.Context, runtime Runtime, events EventSink) (Loop switch eff := effect.(type) { case NeedModelRequest: - if err := l.planAndPrepare(ctx, controlCtx, runtime, snapshot, eff.Hint); err != nil { + if err := l.planAndPrepare(ctx, controlCtx, runtime, &snapshot, eff.Hint); err != nil { return LoopResult{}, err } case StartModelCall: - if err := l.runModelStep(ctx, controlCtx, runtime, events, snapshot, eff.StepID); err != nil { + if err := l.runModelStep(ctx, controlCtx, runtime, events, &snapshot, eff.StepID); err != nil { return LoopResult{}, err } case StartToolCalls: - if err := l.runToolCalls(ctx, controlCtx, runtime, events, snapshot, eff); err != nil { + if err := l.runToolCalls(ctx, controlCtx, runtime, events, &snapshot, eff); err != nil { return LoopResult{}, err } case WaitForResponse: @@ -126,7 +126,7 @@ func (l *Loop) emitCommitted(ctx context.Context, events EventSink, run RunID, c // --- NeedModelRequest --- -func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, snapshot RuntimeSnapshot, hint PlanningHint) error { +func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, snapshot *RuntimeSnapshot, hint PlanningHint) error { plan, err := l.Planner.Plan(ctx, hint) if err != nil { return err @@ -186,7 +186,7 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, // --- StartModelCall --- -func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime Runtime, events EventSink, snapshot RuntimeSnapshot, stepID StepID) error { +func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime Runtime, events EventSink, snapshot *RuntimeSnapshot, stepID StepID) error { run := snapshot.State.RunID start, err := l.commit(controlCtx, runtime, run, freshCommandID(), snapshot.Revision, "", StartModelExecution{StepID: stepID}) if err != nil { @@ -216,14 +216,14 @@ func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime Runtime, ev if err != nil { completion = SubmitModelFailure{StepID: stepID, Failure: StepFailure{Class: FailureProvider, Message: err.Error()}} } else { - result, invokeErr := l.invokeModel(ctx, invoker, sdkRequest, run, stepID, events) + result, invokeErr := l.invokeModel(ctx, invoker, &sdkRequest, run, stepID, events) switch { case invokeErr != nil && ctx.Err() != nil: completion = RecoverModelExecution{StepID: stepID} case invokeErr != nil: completion = SubmitModelFailure{StepID: stepID, Failure: StepFailure{Class: FailureProvider, Message: invokeErr.Error()}} default: - bindings, bindErr := l.bindToolCalls(result, modelStep) + bindings, bindErr := l.bindToolCalls(&result, &modelStep) if bindErr != nil { completion = RejectModelResult{StepID: stepID, Usage: UsageFromSDK(result.Usage), Failure: StepFailure{Class: FailureMalformedModel, Message: bindErr.Error()}} @@ -248,10 +248,10 @@ func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime Runtime, ev return nil } -func (l *Loop) invokeModel(ctx context.Context, invoker ModelInvoker, req sdk.Request, run RunID, step StepID, events EventSink) (sdk.ModelResult, error) { +func (l *Loop) invokeModel(ctx context.Context, invoker ModelInvoker, req *sdk.Request, run RunID, step StepID, events EventSink) (sdk.ModelResult, error) { if l.Streaming { if streamer, ok := invoker.(StreamingModelInvoker); ok { - stream, err := streamer.Stream(ctx, req) + stream, err := streamer.Stream(ctx, *req) if err != nil { return sdk.ModelResult{}, err } @@ -296,12 +296,12 @@ func (l *Loop) invokeModel(ctx context.Context, invoker ModelInvoker, req sdk.Re return *result, nil } } - return invoker.Generate(ctx, req) + return invoker.Generate(ctx, *req) } // bindToolCalls validates tool-call IDs/order/shape and produces bindings // from the frozen ToolSpecs (spec §4.1). It never calls ExecutableTool. -func (l *Loop) bindToolCalls(result sdk.ModelResult, step ModelStep) ([]ToolCallBinding, error) { +func (l *Loop) bindToolCalls(result *sdk.ModelResult, step *ModelStep) ([]ToolCallBinding, error) { if len(result.ToolCalls) == 0 { return nil, nil } @@ -359,7 +359,7 @@ type startedWorker struct { tool ExecutableTool } -func (l *Loop) runToolCalls(ctx, controlCtx context.Context, runtime Runtime, events EventSink, snapshot RuntimeSnapshot, eff StartToolCalls) error { +func (l *Loop) runToolCalls(ctx, controlCtx context.Context, runtime Runtime, events EventSink, snapshot *RuntimeSnapshot, eff StartToolCalls) error { run := snapshot.State.RunID ts, ok := snapshot.State.Current.(ToolStep) if !ok || ts.RefValue.ID != eff.StepID { @@ -464,11 +464,12 @@ func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime Runtime, e var mu sync.Mutex var wg sync.WaitGroup var firstErr error - for _, w := range started { + for i := range started { wg.Add(1) + w := started[i] go func(w startedWorker) { defer wg.Done() - outcome := executeToolSafely(execCtx, w.tool, ToolExecutionRequest{ + req := ToolExecutionRequest{ RunID: run, StepID: stepID, CallID: w.call.CallID, @@ -476,7 +477,8 @@ func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime Runtime, e DefinitionDigest: w.call.DefinitionDigest, Arguments: w.call.Arguments, Progress: &progressSink{events: events, run: run, step: stepID, call: w.call.CallID}, - }) + } + outcome := executeToolSafely(execCtx, w.tool, &req) var cmd AgentCommand unknown := false @@ -536,7 +538,7 @@ func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime Runtime, e // 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) { +func executeToolSafely(ctx context.Context, tool ExecutableTool, req *ToolExecutionRequest) (outcome ToolExecutionOutcome) { defer func() { if r := recover(); r != nil { outcome = ToolExecutionUnknown{Failure: ToolFailure{ @@ -545,7 +547,7 @@ func executeToolSafely(ctx context.Context, tool ExecutableTool, req ToolExecuti }} } }() - return tool.Execute(ctx, req) + return tool.Execute(ctx, *req) } type progressSink struct { diff --git a/agent/machine_test.go b/agent/machine_test.go index de2acf1..cd6e59a 100644 --- a/agent/machine_test.go +++ b/agent/machine_test.go @@ -2,6 +2,7 @@ package agent import ( "encoding/json" + "errors" "testing" "github.com/memohai/twilight-ai/sdk" @@ -405,7 +406,7 @@ func TestUnknownFailureEndsRun(t *testing.T) { t.Fatal("run not failed") } // Terminal absorbs: further commands rejected. - if _, err := Decide(s, CancelRun{}); err != ErrRunTerminal { + if _, err := Decide(s, CancelRun{}); !errors.Is(err, ErrRunTerminal) { t.Fatalf("err = %v, want ErrRunTerminal", err) } } @@ -579,7 +580,7 @@ func TestAcceptInputIdempotentPerID(t *testing.T) { func TestAcceptInputRejectsSeedDuplicateID(t *testing.T) { s := newRun(t, testConfig()) _, err := Decide(s, NextStep(AgentInput{ID: "seed", Payload: cj(`{"q":"other"}`)})) - if err != ErrCommandConflict { + if !errors.Is(err, ErrCommandConflict) { t.Fatalf("duplicate seed input err = %v, want ErrCommandConflict", err) } } diff --git a/agent/memory.go b/agent/memory.go index 60ace85..26ef1b4 100644 --- a/agent/memory.go +++ b/agent/memory.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/hex" + "errors" "fmt" "sync" ) @@ -30,10 +31,12 @@ type MemoryRuntime struct { } // NewMemoryRuntime starts from an Initialize-produced state at Revision 0. +// +//nolint:gocritic // hugeParam: constructor takes a value snapshot and clones it into runtime authority storage. func NewMemoryRuntime(initial MachineState) *MemoryRuntime { - frozenInitial := cloneMachineState(initial) + frozenInitial := cloneMachineState(&initial) return &MemoryRuntime{ - state: cloneMachineState(frozenInitial), + state: cloneMachineState(&frozenInitial), initial: frozenInitial, events: make(map[CommandID][]AgentEvent), grants: make(map[string]ExecutionGrant), @@ -48,7 +51,7 @@ func (m *MemoryRuntime) Load(ctx context.Context) (RuntimeSnapshot, error) { defer m.mu.Unlock() // Deep copy: returned snapshots are read-only views; caller mutation must // never reach authoritative storage (spec appendix A). - return RuntimeSnapshot{State: cloneMachineState(m.state), Revision: m.revision}, nil + return RuntimeSnapshot{State: cloneMachineState(&m.state), Revision: m.revision}, nil } func grantKey(c AgentCommand) string { @@ -82,27 +85,29 @@ func newGrant() ExecutionGrant { return ExecutionGrant(hex.EncodeToString(b[:])) } -func foldCommittedEventGroup(state MachineState, events []AgentEvent) (MachineState, error) { +func foldCommittedEventGroup(state *MachineState, events []AgentEvent) (MachineState, error) { + current := *state for _, e := range events { want, err := DigestFact(e.SchemaVersion, e.Type, e.Fact) if err != nil { - return state, err + return current, err } if e.Digest != want { - return state, fmt.Errorf("agent: memory runtime: stored fact digest mismatch at revision %d index %d", e.Revision, e.Index) + return current, fmt.Errorf("agent: memory runtime: stored fact digest mismatch at revision %d index %d", e.Revision, e.Index) } fact, err := snapshotFact(e.Fact) if err != nil { - return state, err + return current, err } - state, err = Evolve(state, fact) + current, err = Evolve(current, fact) if err != nil { - return state, err + return current, err } } - return state, nil + return current, nil } +//nolint:gocritic // hugeParam: CommitRequest is the value DTO of the Runtime authority boundary. func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitResult, error) { if err := ctx.Err(); err != nil { return CommitResult{}, err @@ -134,14 +139,14 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe // Replay never re-grants execution (spec §5.4). return CommitResult{ Status: CommitAlreadyApplied, - Snapshot: RuntimeSnapshot{State: cloneMachineState(m.state), Revision: m.revision}, + Snapshot: RuntimeSnapshot{State: cloneMachineState(&m.state), Revision: m.revision}, Events: cloneEvents(decision.Events), }, nil case DecisionConflict: return CommitResult{}, ErrCommandConflict case DecisionStale: - if decision.Reject != nil && decision.Reject != ErrStaleRuntime { - return CommitResult{}, fmt.Errorf("%w: %v", ErrStaleRuntime, decision.Reject) + if decision.Reject != nil && !errors.Is(decision.Reject, ErrStaleRuntime) { + return CommitResult{}, fmt.Errorf("%w: %w", ErrStaleRuntime, decision.Reject) } return CommitResult{}, ErrStaleRuntime case DecisionTerminal: @@ -152,11 +157,11 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe // from those same stored facts. The event log is the source of truth; the // in-memory state is only its same-transaction projection. stored := cloneEvents(decision.Events) - newState, err := foldCommittedEventGroup(m.state, stored) + newState, err := foldCommittedEventGroup(&m.state, stored) if err != nil { return CommitResult{}, err } - m.state = cloneMachineState(newState) + m.state = cloneMachineState(&newState) m.revision++ m.watermark = m.revision m.events[req.Command.ID] = stored @@ -178,7 +183,7 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe return CommitResult{ Status: CommitAccepted, - Snapshot: RuntimeSnapshot{State: cloneMachineState(m.state), Revision: m.revision}, + Snapshot: RuntimeSnapshot{State: cloneMachineState(&m.state), Revision: m.revision}, Events: cloneEvents(stored), Grant: minted, }, nil diff --git a/agent/model_data.go b/agent/model_data.go index b7e6f18..87e3dc6 100644 --- a/agent/model_data.go +++ b/agent/model_data.go @@ -182,6 +182,7 @@ type Usage struct { 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 @@ -440,6 +441,7 @@ func FreezeMessagePart(p sdk.MessagePart) (MessagePart, error) { } } +//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: @@ -490,8 +492,8 @@ func FreezeMessage(m sdk.Message) (Message, error) { func (m Message) SDK() (sdk.Message, error) { parts := make([]sdk.MessagePart, len(m.Content)) - for i, p := range m.Content { - part, err := p.SDK() + 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) } @@ -505,6 +507,7 @@ func (m Message) SDK() (sdk.Message, error) { 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 { @@ -559,6 +562,7 @@ func FreezeModelRequest(req sdk.Request) (ModelRequest, error) { }, 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 { @@ -605,6 +609,7 @@ func (r ModelRequest) SDK() (sdk.Request, error) { }, 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, @@ -626,6 +631,7 @@ func UsageFromSDK(u sdk.Usage) Usage { } } +//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, @@ -758,6 +764,7 @@ func (r *ResponseMetadata) SDK() (sdk.ResponseMetadata, error) { 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 { @@ -806,6 +813,7 @@ func FreezeModelResult(r sdk.ModelResult) (ModelResult, error) { }, 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 { diff --git a/agent/next.go b/agent/next.go index 0275080..ffb1c41 100644 --- a/agent/next.go +++ b/agent/next.go @@ -44,6 +44,8 @@ type PlanningHint struct { // Next derives the pending effect from the current state (spec §3.7.3). // Terminal states yield no effect; 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 diff --git a/agent/protocol.go b/agent/protocol.go index 7d9e2b7..6bf2047 100644 --- a/agent/protocol.go +++ b/agent/protocol.go @@ -51,6 +51,8 @@ func encodeEnvelopeBody(schemaVersion uint16, typ string, body any) ([]byte, err // EncodeCommand renders the canonical bytes of a command envelope, excluding // the Digest field. +// +//nolint:gocritic // hugeParam: command envelope encoding is value-based and does not retain caller aliases. func EncodeCommand(env CommandEnvelope) ([]byte, error) { if env.Type == "" || env.Type != commandType(env.Command) { return nil, fmt.Errorf("agent: encode: type %q does not match command variant", env.Type) @@ -148,6 +150,8 @@ func DigestToolResponsePayload(payload CanonicalJSON) (Digest, error) { // DigestRequest covers every field of a frozen ModelRequest with no exclusions // (spec §2.1 rule 7). +// +//nolint:gocritic // hugeParam: digest covers the complete immutable ModelRequest value. func DigestRequest(req ModelRequest) (Digest, error) { body, err := encodeEnvelopeBody(currentSchemaVersion, "model_request", req) if err != nil { @@ -166,6 +170,8 @@ func DigestToolDefinition(def ToolDefinition) (Digest, error) { } // DigestToolSpec covers one agent ToolSpec (ref, definition, digest, policy). +// +//nolint:gocritic // hugeParam: digest covers the complete immutable ToolSpec value. func DigestToolSpec(spec ToolSpec) (Digest, error) { body, err := encodeEnvelopeBody(currentSchemaVersion, "tool_spec", spec) if err != nil { diff --git a/agent/rebuild.go b/agent/rebuild.go index 2853c7a..a3392a2 100644 --- a/agent/rebuild.go +++ b/agent/rebuild.go @@ -1,6 +1,7 @@ package agent import ( + "bytes" "errors" "fmt" ) @@ -17,6 +18,8 @@ var ErrLogTruncated = errors.New("agent: event log ends below the revision water // ordering and per-fact digests as it goes; any gap or mismatch means the log // itself is damaged and the fold stops. It also rejects RunID/schema/type // mismatches and same-revision command identity changes. +// +//nolint:gocritic // hugeParam: public replay API folds from an initial value state without mutating caller-owned state. func FoldEvents(initial MachineState, events []AgentEvent) (MachineState, uint64, error) { state := initial var revision uint64 @@ -87,14 +90,14 @@ func (m *MemoryRuntime) Rebuild() (rebuilt bool, err error) { m.mu.Lock() defer m.mu.Unlock() - folded, maxRevision, err := FoldEvents(cloneMachineState(m.initial), m.log) + folded, maxRevision, err := FoldEvents(cloneMachineState(&m.initial), m.log) if err != nil { return false, err } if maxRevision < m.watermark { return false, fmt.Errorf("%w: log ends at %d, watermark %d", ErrLogTruncated, maxRevision, m.watermark) } - diverged := m.revision != maxRevision || !statesEquivalent(m.state, folded) + diverged := m.revision != maxRevision || !statesEquivalent(&m.state, &folded) m.state = folded m.revision = maxRevision return diverged, nil @@ -102,18 +105,18 @@ func (m *MemoryRuntime) Rebuild() (rebuilt bool, err error) { // statesEquivalent compares two states via their canonical serialization — // the same identity rule the protocol uses everywhere else. -func statesEquivalent(a, b MachineState) bool { +func statesEquivalent(a, b *MachineState) bool { ab, errA := marshalCanonical(stateComparable(a)) bb, errB := marshalCanonical(stateComparable(b)) if errA != nil || errB != nil { return false } - return string(ab) == string(bb) + return bytes.Equal(ab, bb) } // stateComparable flattens MachineState including the interface-typed Current // step, which encoding/json cannot round-trip on its own. -func stateComparable(s MachineState) map[string]any { +func stateComparable(s *MachineState) map[string]any { m := map[string]any{ "runId": s.RunID, "status": s.Status, "config": s.Config, "modelSteps": s.ModelSteps, "lastClosedStep": s.LastClosedStep, diff --git a/agent/rebuild_test.go b/agent/rebuild_test.go index d028283..8e58a0a 100644 --- a/agent/rebuild_test.go +++ b/agent/rebuild_test.go @@ -67,7 +67,7 @@ func TestRebuildHealthyIsNoop(t *testing.T) { t.Fatal("healthy runtime reported divergence on rebuild") } after, _ := rt.Load(context.Background()) - if !statesEquivalent(before.State, after.State) || before.Revision != after.Revision { + if !statesEquivalent(&before.State, &after.State) || before.Revision != after.Revision { t.Fatal("rebuild changed a healthy state") } } @@ -93,7 +93,7 @@ func TestRebuildRepairsCorruptedSnapshot(t *testing.T) { t.Fatal("rebuild did not report the repaired divergence") } got, _ := rt.Load(context.Background()) - if !statesEquivalent(want.State, got.State) { + if !statesEquivalent(&want.State, &got.State) { t.Fatal("rebuild did not restore the log-derived state") } if got.State.Status != RunCompleted || got.State.ModelSteps != 2 { @@ -136,7 +136,7 @@ func TestFoldRejectsInteriorGap(t *testing.T) { holed = append(holed, e) } log := holed - initial := cloneMachineState(rt.initial) + initial := cloneMachineState(&rt.initial) rt.mu.Unlock() if _, _, err := FoldEvents(initial, log); err == nil { @@ -148,7 +148,7 @@ func TestFoldRejectsRunIDMismatch(t *testing.T) { rt := fullRunRuntime(t) rt.mu.Lock() log := cloneEvents(rt.log) - initial := cloneMachineState(rt.initial) + initial := cloneMachineState(&rt.initial) rt.mu.Unlock() log[0].RunID = "other-run" @@ -161,7 +161,7 @@ func TestFoldRejectsTransitionCommandIdentityChange(t *testing.T) { rt := fullRunRuntime(t) rt.mu.Lock() log := cloneEvents(rt.log) - initial := cloneMachineState(rt.initial) + initial := cloneMachineState(&rt.initial) rt.mu.Unlock() for i := range log { @@ -179,7 +179,7 @@ func TestFoldRejectsUnsupportedSchemaVersion(t *testing.T) { rt := fullRunRuntime(t) rt.mu.Lock() log := cloneEvents(rt.log) - initial := cloneMachineState(rt.initial) + initial := cloneMachineState(&rt.initial) rt.mu.Unlock() log[0].SchemaVersion = 99 @@ -198,7 +198,7 @@ func TestFoldRejectsTamperedFact(t *testing.T) { rt := fullRunRuntime(t) rt.mu.Lock() log := cloneEvents(rt.log) - initial := cloneMachineState(rt.initial) + initial := cloneMachineState(&rt.initial) rt.mu.Unlock() for i := range log { @@ -241,14 +241,14 @@ func TestRegressionPreparedFactSelfContained(t *testing.T) { // the same commit that changes the protocol). func TestGoldenEventStreamV1(t *testing.T) { rt := fullRunRuntime(t) - folded, maxRev, err := FoldEvents(cloneMachineState(rt.initial), rt.Events()) + folded, maxRev, err := FoldEvents(cloneMachineState(&rt.initial), rt.Events()) if err != nil { t.Fatal(err) } if maxRev != 8 { t.Fatalf("golden stream has %d transitions, want 8", maxRev) } - stateBytes, err := marshalCanonical(stateComparable(folded)) + stateBytes, err := marshalCanonical(stateComparable(&folded)) if err != nil { t.Fatal(err) } diff --git a/agent/runtimetest/conformance.go b/agent/runtimetest/conformance.go index 91b4c78..280c1df 100644 --- a/agent/runtimetest/conformance.go +++ b/agent/runtimetest/conformance.go @@ -4,6 +4,7 @@ package runtimetest import ( + "bytes" "context" "encoding/json" "errors" @@ -84,7 +85,8 @@ func preparedCase(t testing.TB, newRuntime Factory, tools []sdk.ToolDefinition, t.Helper() c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1", ModelRejectLimit: 2}) snap := c.load() - prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(tools...), specs) + req := testRequest(tools...) + prep, cmdID := buildPrepareFromSnap(t, &snap, &req, specs) c.mustCommit(cmdID, snap.Revision, "", prep) start := c.mustCommit("start-1", 1, "", agent.StartModelExecution{StepID: prep.StepID}) if start.Grant == "" { @@ -93,9 +95,9 @@ func preparedCase(t testing.TB, newRuntime Factory, tools []sdk.ToolDefinition, return c, prep.StepID, start.Grant } -func buildPrepareFromSnap(t testing.TB, snap agent.RuntimeSnapshot, req sdk.Request, specs []agent.ToolSpec) (agent.PrepareModelRequest, agent.CommandID) { +func buildPrepareFromSnap(t testing.TB, snap *agent.RuntimeSnapshot, req *sdk.Request, specs []agent.ToolSpec) (agent.PrepareModelRequest, agent.CommandID) { t.Helper() - frozenReq, err := agent.FreezeModelRequest(req) + frozenReq, err := agent.FreezeModelRequest(*req) if err != nil { t.Fatal(err) } @@ -137,7 +139,7 @@ func testToolDef(name string) sdk.ToolDefinition { func cj(raw string) agent.CanonicalJSON { return agent.MustParseCanonicalJSON(raw) } -func makeSpec(t testing.TB, def sdk.ToolDefinition, policy agent.ResponsePolicy) agent.ToolSpec { +func makeSpec(t testing.TB, def sdk.ToolDefinition) agent.ToolSpec { t.Helper() frozen, err := agent.FreezeToolDefinition(def) if err != nil { @@ -147,12 +149,12 @@ func makeSpec(t testing.TB, def sdk.ToolDefinition, policy agent.ResponsePolicy) if err != nil { t.Fatal(err) } - return agent.ToolSpec{Ref: agent.ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: policy} + return agent.ToolSpec{Ref: agent.ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: agent.DirectExecution} } -func makeBinding(t testing.TB, callID string, spec agent.ToolSpec, args string) agent.ToolCallBinding { +func makeBinding(t testing.TB, callID string, spec *agent.ToolSpec) agent.ToolCallBinding { t.Helper() - parsedArgs := cj(args) + parsedArgs := cj(`{}`) bd, err := agent.DigestToolCallBinding(agent.CallID(callID), spec.DefinitionDigest, spec.Policy, parsedArgs) if err != nil { t.Fatal(err) @@ -167,6 +169,18 @@ func makeBinding(t testing.TB, callID string, spec agent.ToolSpec, args string) } } +func openedToolStepID(t testing.TB, res *agent.CommitResult) agent.StepID { + t.Helper() + if len(res.Events) < 2 { + t.Fatalf("events = %d, want ToolStepOpened at index 1", len(res.Events)) + } + opened, ok := res.Events[1].Fact.(agent.ToolStepOpened) + if !ok { + t.Fatalf("event[1] fact = %T, want agent.ToolStepOpened", res.Events[1].Fact) + } + return opened.StepID +} + func modelResultWithCalls(callIDs ...string) agent.ModelResult { r := sdk.ModelResult{ FinishReason: sdk.FinishReasonToolCalls, @@ -200,17 +214,17 @@ func testIdempotentReplay(t *testing.T, newRuntime Factory) { t.Fatal("replay advanced the revision") } _, err := c.commit("cancel-1", 0, "", agent.CancelRun{Reason: "other"}) - if err != agent.ErrCommandConflict { + if !errors.Is(err, agent.ErrCommandConflict) { t.Fatalf("err = %v, want ErrCommandConflict", err) } } func testRevisionAndIndex(t *testing.T, newRuntime Factory) { def := testToolDef("t") - spec := makeSpec(t, def, agent.DirectExecution) + spec := makeSpec(t, def) c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []agent.ToolSpec{spec}) - b := makeBinding(t, "c1", spec, `{}`) + b := makeBinding(t, "c1", &spec) res := c.mustCommit("complete-1", 2, grant, agent.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []agent.ToolCallBinding{b}}) if len(res.Events) != 2 { @@ -252,12 +266,12 @@ func testStartGrantLifecycle(t *testing.T, newRuntime Factory) { func testCallLocalRebase(t *testing.T, newRuntime Factory) { defA, defB := testToolDef("a"), testToolDef("b") - specA := makeSpec(t, defA, agent.DirectExecution) - specB := makeSpec(t, defB, agent.DirectExecution) + specA := makeSpec(t, defA) + specB := makeSpec(t, defB) c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{defA, defB}, []agent.ToolSpec{specA, specB}) - bA := makeBinding(t, "cA", specA, `{}`) - bB := makeBinding(t, "cB", specB, `{}`) + bA := makeBinding(t, "cA", &specA) + bB := makeBinding(t, "cB", &specB) r, err := agent.FreezeModelResult(sdk.ModelResult{ FinishReason: sdk.FinishReasonToolCalls, ToolCalls: []sdk.ToolCall{ @@ -270,7 +284,7 @@ func testCallLocalRebase(t *testing.T, newRuntime Factory) { } res := c.mustCommit("complete-1", 2, grant, agent.SubmitModelResult{StepID: stepID, Result: r, Calls: []agent.ToolCallBinding{bA, bB}}) - toolStep := res.Events[1].Fact.(agent.ToolStepOpened).StepID + toolStep := openedToolStepID(t, &res) base := res.Snapshot.Revision startA := c.mustCommit("start-A", base, "", agent.StartToolCall{StepID: toolStep, CallID: "cA"}) @@ -293,7 +307,8 @@ func testCallLocalRebase(t *testing.T, newRuntime Factory) { func testPrepareDerivedIdentity(t *testing.T, newRuntime Factory) { c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1"}) snap := c.load() - prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) + req := testRequest() + prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) if _, err := c.commit("wrong-prepare-id", snap.Revision, "", prep); err == nil { t.Fatal("PrepareModelRequest accepted a non-derived CommandID") @@ -310,17 +325,18 @@ func testPrepareDerivedIdentity(t *testing.T, newRuntime Factory) { func testPrepareIsHardCAS(t *testing.T, newRuntime Factory) { c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1"}) snap := c.load() - prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) + req := testRequest() + prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) c.mustCommit(cmdID, snap.Revision, "", prep) otherReq := testRequest() otherReq.System = "different" - prep2, cmdID2 := buildPrepareFromSnap(t, snap, otherReq, nil) + prep2, cmdID2 := buildPrepareFromSnap(t, &snap, &otherReq, nil) if cmdID2 != cmdID { t.Fatal("same revision must derive the same command id") } _, err := c.commit(cmdID2, snap.Revision, "", prep2) - if err != agent.ErrCommandConflict { + if !errors.Is(err, agent.ErrCommandConflict) { t.Fatalf("err = %v, want ErrCommandConflict", err) } res := c.mustCommit(cmdID, snap.Revision, "", prep) @@ -331,12 +347,12 @@ func testPrepareIsHardCAS(t *testing.T, newRuntime Factory) { func testCancelRebasesAndUnknownWins(t *testing.T, newRuntime Factory) { def := testToolDef("t") - spec := makeSpec(t, def, agent.DirectExecution) + spec := makeSpec(t, def) c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []agent.ToolSpec{spec}) - b := makeBinding(t, "c1", spec, `{}`) + b := makeBinding(t, "c1", &spec) res := c.mustCommit("complete-1", 2, grant, agent.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []agent.ToolCallBinding{b}}) - toolStep := res.Events[1].Fact.(agent.ToolStepOpened).StepID + toolStep := openedToolStepID(t, &res) startRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", agent.StartToolCall{StepID: toolStep, CallID: "c1"}) unknown := c.mustCommit("unk-1", startRes.Snapshot.Revision, startRes.Grant, @@ -345,7 +361,7 @@ func testCancelRebasesAndUnknownWins(t *testing.T, newRuntime Factory) { t.Fatal("unknown did not fail the run") } _, err := c.commit("cancel-late", 0, "", agent.CancelRun{}) - if err != agent.ErrRunTerminal { + if !errors.Is(err, agent.ErrRunTerminal) { t.Fatalf("late cancel err = %v, want ErrRunTerminal", err) } } @@ -360,12 +376,12 @@ func testCancelOnStaleBase(t *testing.T, newRuntime Factory) { func testReplayFoldMatchesState(t *testing.T, newRuntime Factory) { def := testToolDef("t") - spec := makeSpec(t, def, agent.DirectExecution) + spec := makeSpec(t, def) c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []agent.ToolSpec{spec}) - b := makeBinding(t, "c1", spec, `{}`) + b := makeBinding(t, "c1", &spec) res := c.mustCommit("complete-1", 2, grant, agent.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []agent.ToolCallBinding{b}}) - toolStep := res.Events[1].Fact.(agent.ToolStepOpened).StepID + toolStep := openedToolStepID(t, &res) sRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", agent.StartToolCall{StepID: toolStep, CallID: "c1"}) c.mustCommit("done-c1", sRes.Snapshot.Revision, sRes.Grant, agent.SubmitToolResult{StepID: toolStep, CallID: "c1", Result: agent.ToolExecutionResult{Output: cj(`"ok"`)}}) @@ -375,9 +391,9 @@ func testReplayFoldMatchesState(t *testing.T, newRuntime Factory) { t.Fatalf("FoldEvents: %v", err) } live := c.load() - a, _ := json.Marshal(stateComparable(live.State)) - bts, _ := json.Marshal(stateComparable(folded)) - if string(a) != string(bts) { + a, _ := json.Marshal(stateComparable(&live.State)) + bts, _ := json.Marshal(stateComparable(&folded)) + if !bytes.Equal(a, bts) { t.Fatalf("replay diverged:\n live %s\n replay %s", a, bts) } if live.Revision != lastRev { @@ -398,7 +414,7 @@ func testAcceptInputByInputID(t *testing.T, newRuntime Factory) { t.Fatalf("status = %v", res2.Status) } _, err := c.commit(id, 0, "", agent.NextStep(agent.AgentInput{ID: "in-9", Payload: cj(`{"t":"y"}`)})) - if err != agent.ErrCommandConflict { + if !errors.Is(err, agent.ErrCommandConflict) { t.Fatalf("err = %v, want ErrCommandConflict", err) } } @@ -415,7 +431,7 @@ func testDerivedCommandIDEnforced(t *testing.T, newRuntime Factory) { } } -func stateComparable(s agent.MachineState) map[string]any { +func stateComparable(s *agent.MachineState) map[string]any { m := map[string]any{ "runId": s.RunID, "status": s.Status, "config": s.Config, "modelSteps": s.ModelSteps, "lastClosedStep": s.LastClosedStep, diff --git a/agent/state.go b/agent/state.go index 7a8425b..f179bb7 100644 --- a/agent/state.go +++ b/agent/state.go @@ -159,7 +159,9 @@ type ModelStep struct { Rejects int `json:"rejects,omitempty"` } -func (ModelStep) step() {} +func (ModelStep) step() {} + +//nolint:gocritic // hugeParam: value receiver keeps ModelStep satisfying sealed Step as a value. func (s ModelStep) Ref() StepRef { return s.RefValue } type ToolCallStatus uint8 @@ -207,6 +209,8 @@ type ToolCallState struct { } // ValidateToolCallState rejects illegal field combinations (spec §4.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: @@ -252,10 +256,12 @@ type ToolStep struct { Calls []ToolCallState `json:"calls"` } -func (ToolStep) step() {} +func (ToolStep) step() {} + +//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 { +func (s *ToolStep) callIndex(id CallID) int { for i := range s.Calls { if s.Calls[i].CallID == id { return i diff --git a/sdk/model_call.go b/sdk/model_call.go index a1309da..e364154 100644 --- a/sdk/model_call.go +++ b/sdk/model_call.go @@ -21,6 +21,8 @@ type StreamingModelInvoker interface { // 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) } @@ -28,6 +30,8 @@ func Generate(ctx context.Context, model *Model, req Request) (ModelResult, erro // 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) } @@ -36,6 +40,8 @@ func Stream(ctx context.Context, model *Model, req Request) (ModelStream, error) // 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") @@ -46,6 +52,8 @@ func (c *Client) Generate(ctx context.Context, model *Model, req Request) (Model // 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") @@ -57,6 +65,8 @@ func (c *Client) Stream(ctx context.Context, model *Model, req Request) (ModelSt // 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") @@ -64,7 +74,7 @@ func (m *Model) Generate(ctx context.Context, req Request) (ModelResult, error) if m.Provider == nil { return ModelResult{}, fmt.Errorf("twilightai: model %q has no provider", m.ID) } - req, err := bindRequestModel(m, req) + req, err := bindRequestModel(m, &req) if err != nil { return ModelResult{}, err } @@ -84,6 +94,8 @@ func (m *Model) Generate(ctx context.Context, req Request) (ModelResult, error) // 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") @@ -91,7 +103,7 @@ func (m *Model) Stream(ctx context.Context, req Request) (ModelStream, error) { if m.Provider == nil { return ModelStream{}, fmt.Errorf("twilightai: model %q has no provider", m.ID) } - req, err := bindRequestModel(m, req) + req, err := bindRequestModel(m, &req) if err != nil { return ModelStream{}, err } @@ -109,12 +121,13 @@ func (m *Model) Stream(ctx context.Context, req Request) (ModelStream, error) { return ModelStreamFromStreamResult(stream), nil } -func bindRequestModel(model *Model, req Request) (Request, error) { - if req.Model == "" { - req.Model = model.ID +func bindRequestModel(model *Model, req *Request) (Request, error) { + out := *req + if out.Model == "" { + out.Model = model.ID } - if model.ID != "" && req.Model != model.ID { - return Request{}, fmt.Errorf("twilightai: request model %q does not match provider model %q", req.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 req, nil + return out, nil } diff --git a/sdk/request_adapter.go b/sdk/request_adapter.go index bb7a861..a1ea237 100644 --- a/sdk/request_adapter.go +++ b/sdk/request_adapter.go @@ -8,6 +8,8 @@ import ( "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. @@ -92,6 +94,8 @@ func ModelStreamFromStreamResult(stream *StreamResult) ModelStream { // 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") @@ -130,6 +134,8 @@ func RequestFromGenerateParams(params GenerateParams) (Request, error) { // 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") @@ -267,10 +273,10 @@ func ToolChoiceFromLegacy(choice any) (ToolChoice, error) { func toolChoiceFromMap(m map[string]any) (ToolChoice, error) { typ, _ := m["type"].(string) - if typ != "function" && typ != "tool" { + if typ != legacyToolChoiceFunction && typ != "tool" { return ToolChoice{}, fmt.Errorf("twilightai: unsupported tool choice type %q", typ) } - fn, _ := m["function"].(map[string]any) + fn, _ := m[legacyToolChoiceFunction].(map[string]any) if fn == nil { fn, _ = m["tool"].(map[string]any) } @@ -290,7 +296,7 @@ func (c ToolChoice) Legacy() any { case ToolChoiceAuto, ToolChoiceNone, ToolChoiceRequired: return string(c.Mode) case ToolChoiceTool: - return map[string]any{"type": "function", "function": map[string]any{"name": c.Tool}} + return map[string]any{"type": legacyToolChoiceFunction, legacyToolChoiceFunction: map[string]any{"name": c.Tool}} default: return nil } @@ -324,6 +330,8 @@ func ModelResultFromGenerateResult(result *GenerateResult) ModelResult { // 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, @@ -416,36 +424,41 @@ func cloneMessagePart(part MessagePart) MessagePart { return p case *TextPart: if p == nil { - return p + return nil } - clone := cloneMessagePart(*p).(TextPart) + 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 p + return nil } - clone := cloneMessagePart(*p).(ReasoningPart) + clone := *p + clone.ProviderMetadata = cloneMetadataMap(clone.ProviderMetadata) return &clone case ImagePart: p.CacheControl = cloneCacheControl(p.CacheControl) return p case *ImagePart: if p == nil { - return p + return nil } - clone := cloneMessagePart(*p).(ImagePart) + clone := *p + clone.CacheControl = cloneCacheControl(clone.CacheControl) return &clone case FilePart: p.CacheControl = cloneCacheControl(p.CacheControl) return p case *FilePart: if p == nil { - return p + return nil } - clone := cloneMessagePart(*p).(FilePart) + clone := *p + clone.CacheControl = cloneCacheControl(clone.CacheControl) return &clone case ToolCallPart: p.CacheControl = cloneCacheControl(p.CacheControl) @@ -454,9 +467,12 @@ func cloneMessagePart(part MessagePart) MessagePart { return p case *ToolCallPart: if p == nil { - return p + return nil } - clone := cloneMessagePart(*p).(ToolCallPart) + 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) @@ -464,9 +480,11 @@ func cloneMessagePart(part MessagePart) MessagePart { return p case *ToolResultPart: if p == nil { - return p + return nil } - clone := cloneMessagePart(*p).(ToolResultPart) + clone := *p + clone.CacheControl = cloneCacheControl(clone.CacheControl) + clone.Result = cloneJSONLike(clone.Result) return &clone default: return part diff --git a/sdk/usage.go b/sdk/usage.go index 0fa289d..82b1fa8 100644 --- a/sdk/usage.go +++ b/sdk/usage.go @@ -28,6 +28,8 @@ type Usage struct { } // 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 From bc46ca092efc45c6b1642690ced36fdb8a498f6b Mon Sep 17 00:00:00 2001 From: Fodesu Date: Fri, 28 Aug 2026 20:52:58 +0800 Subject: [PATCH 09/81] chore(agent): rename memory runtime file --- agent/{memory.go => memory_runtime.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename agent/{memory.go => memory_runtime.go} (100%) diff --git a/agent/memory.go b/agent/memory_runtime.go similarity index 100% rename from agent/memory.go rename to agent/memory_runtime.go From b7b1b57972c3c4c2e5fbe936ac2eb187e28c8d47 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Fri, 28 Aug 2026 23:27:05 +0800 Subject: [PATCH 10/81] refactor(agent): make transitions the authority log unit --- agent/clone.go | 32 ++++++ agent/codec.go | 46 ++++++++ agent/codec_test.go | 52 +++++++++ agent/commit.go | 23 ++-- agent/evolve.go | 28 ++++- agent/memory_runtime.go | 76 ++++++++------ agent/rebuild.go | 55 ++++++++-- agent/rebuild_test.go | 75 +++++++++---- agent/transition.go | 145 ++++++++++++++++++++++++++ docs/design/agent-runtime-refactor.md | 131 ++++++++++++++--------- 10 files changed, 535 insertions(+), 128 deletions(-) create mode 100644 agent/transition.go diff --git a/agent/clone.go b/agent/clone.go index 1688726..19fc990 100644 --- a/agent/clone.go +++ b/agent/clone.go @@ -394,3 +394,35 @@ func cloneEvents(events []AgentEvent) []AgentEvent { } return out } + +func cloneTransitionRecord(record *TransitionRecord) TransitionRecord { + out := *record + out.Events = cloneEvents(out.Events) + return out +} + +func cloneTransitionRecords(records []TransitionRecord) []TransitionRecord { + if records == nil { + return nil + } + out := make([]TransitionRecord, len(records)) + for i := range records { + out[i] = cloneTransitionRecord(&records[i]) + } + return out +} + +func flattenTransitionRecords(records []TransitionRecord) []AgentEvent { + var total int + for i := range records { + total += len(records[i].Events) + } + if total == 0 { + return nil + } + out := make([]AgentEvent, 0, total) + for i := range records { + out = append(out, cloneEvents(records[i].Events)...) + } + return out +} diff --git a/agent/codec.go b/agent/codec.go index 5c17d4c..9bc6efa 100644 --- a/agent/codec.go +++ b/agent/codec.go @@ -50,6 +50,18 @@ type agentEventMarshal struct { Fact Fact `json:"fact"` } +type transitionRecordWire struct { + SchemaVersion uint16 `json:"schemaVersion"` + RunID RunID `json:"runId"` + Revision uint64 `json:"revision"` + CommandID CommandID `json:"commandId"` + CommandDigest Digest `json:"commandDigest"` + Events []AgentEvent `json:"events"` + TransitionDigest Digest `json:"transitionDigest"` +} + +type transitionRecordMarshal = transitionRecordWire + // DecodeCommandEnvelope decodes the persisted command wire shape and restores // the sealed command variant from Type. The digest is verified during decode; // malformed or unsupported wire data is rejected before it can enter Runtime. @@ -71,6 +83,16 @@ func DecodeAgentEvent(raw []byte) (AgentEvent, error) { return event, nil } +// DecodeTransitionRecord decodes the persisted transition aggregate and +// verifies that the complete event group is internally consistent. +func DecodeTransitionRecord(raw []byte) (TransitionRecord, error) { + var record TransitionRecord + if err := decodeStrictJSON(raw, &record); err != nil { + return TransitionRecord{}, err + } + return record, 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 { @@ -161,6 +183,30 @@ func (e AgentEvent) MarshalJSON() ([]byte, error) { }) } +//nolint:gocritic // hugeParam: value receiver keeps json.Marshaler active for non-pointer TransitionRecord values. +func (r TransitionRecord) MarshalJSON() ([]byte, error) { + if err := ValidateTransitionRecord(&r); err != nil { + return nil, err + } + return json.Marshal(transitionRecordMarshal(r)) +} + +func (r *TransitionRecord) UnmarshalJSON(raw []byte) error { + var wire transitionRecordWire + if err := decodeStrictJSON(raw, &wire); err != nil { + return err + } + record := TransitionRecord(wire) + if err := ValidateTransitionRecord(&record); err != nil { + return err + } + if err := requireCanonicalEquivalent(raw, transitionRecordMarshal(record)); err != nil { + return err + } + *r = record + return nil +} + func (e *AgentEvent) UnmarshalJSON(raw []byte) error { var wire agentEventWire if err := decodeStrictJSON(raw, &wire); err != nil { diff --git a/agent/codec_test.go b/agent/codec_test.go index c297b55..3c04dc2 100644 --- a/agent/codec_test.go +++ b/agent/codec_test.go @@ -98,6 +98,58 @@ func TestAgentEventJSONRoundTripRestoresVariants(t *testing.T) { } } +func TestTransitionRecordJSONRoundTripRestoresVariants(t *testing.T) { + facts := []Fact{ + ModelStepCompleted{StepID: "s", Result: ModelResult{Text: "ok"}}, + RunEnded{Status: RunCompleted}, + } + events := make([]AgentEvent, len(facts)) + for i, fact := range facts { + typ := factType(fact) + digest, err := DigestFact(currentSchemaVersion, typ, fact) + if err != nil { + t.Fatal(err) + } + events[i] = AgentEvent{ + SchemaVersion: currentSchemaVersion, + Type: typ, + RunID: "run-1", + Revision: 1, + Index: uint16(i), + CommandID: "cmd-1", + CommandDigest: "sha256:cmd", + Digest: digest, + Fact: fact, + } + } + record, err := BuildTransitionRecord(events) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodeTransitionRecord(raw) + if err != nil { + t.Fatalf("DecodeTransitionRecord: %v\n%s", err, raw) + } + if decoded.TransitionDigest != record.TransitionDigest || len(decoded.Events) != len(record.Events) { + t.Fatalf("decoded transition = %+v, want %+v", decoded, record) + } + for i := range decoded.Events { + if reflect.TypeOf(decoded.Events[i].Fact) != reflect.TypeOf(record.Events[i].Fact) { + t.Fatalf("decoded event %d fact type = %T, want %T", i, decoded.Events[i].Fact, record.Events[i].Fact) + } + } + + partial := cloneTransitionRecord(&record) + partial.Events = partial.Events[:1] + if err := ValidateTransitionRecord(&partial); err == nil { + t.Fatal("partial transition validated") + } +} + func TestWireCodecRejectsAmbiguousJSONBeforeVariantDecode(t *testing.T) { cmd := AcceptInput{Input: AgentInput{ID: "in", Payload: cj(`1`)}} env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", "in"), cmd) diff --git a/agent/commit.go b/agent/commit.go index 39c39cc..f519af7 100644 --- a/agent/commit.go +++ b/agent/commit.go @@ -57,9 +57,10 @@ const ( // onto the sentinel errors: Conflict -> ErrCommandConflict, Stale -> // ErrStaleRuntime, Terminal -> ErrRunTerminal (spec §5.4). type CommitDecision struct { - Kind DecisionKind - NewState MachineState - Events []AgentEvent + Kind DecisionKind + NewState MachineState + Events []AgentEvent + Transition TransitionRecord // Reject carries the precondition failure for Conflict/Stale/Terminal. Reject error } @@ -136,7 +137,7 @@ func requiresGrant(s *MachineState, c AgentCommand) bool { //nolint:gocritic // hugeParam: public pure commit evaluator keeps state/request as value protocol inputs. func EvaluateCommit( cur MachineState, curRevision uint64, - prior []AgentEvent, + prior *TransitionRecord, req CommitRequest, grantValid bool, recoveryValid bool, @@ -168,9 +169,9 @@ func EvaluateCommit( } // Steps 2-3: idempotent replay and identity conflict. - if len(prior) > 0 { - if prior[0].CommandDigest == env.Digest { - return CommitDecision{Kind: DecisionAlreadyApplied, Events: prior}, nil + if prior != nil { + if prior.CommandDigest == env.Digest { + return CommitDecision{Kind: DecisionAlreadyApplied, Events: prior.Events, Transition: cloneTransitionRecord(prior)}, nil } return CommitDecision{Kind: DecisionConflict, Reject: ErrCommandConflict}, nil } @@ -234,7 +235,7 @@ func EvaluateCommit( if err != nil { return CommitDecision{}, err } - state, err = Evolve(state, f) + state, err = EvolveVersion(env.SchemaVersion, state, f) if err != nil { return CommitDecision{}, err } @@ -255,7 +256,11 @@ func EvaluateCommit( Fact: f, } } - return CommitDecision{Kind: DecisionApply, NewState: state, Events: events}, nil + transition, err := BuildTransitionRecord(events) + if err != nil { + return CommitDecision{}, err + } + return CommitDecision{Kind: DecisionApply, NewState: state, Events: transition.Events, Transition: transition}, nil } // BuildEnvelope assembles a CommandEnvelope with its type discriminator and diff --git a/agent/evolve.go b/agent/evolve.go index cd77e04..0cf583c 100644 --- a/agent/evolve.go +++ b/agent/evolve.go @@ -2,14 +2,32 @@ package agent import "fmt" -// Evolve folds one fact into the state (spec §3.7.2). It is mechanical: no -// RunConfig reads, no policy branches, total over every fact Decide produces. -// Its folding semantics, together with the canonical encoding, form the -// permanent compatibility contract of a published SchemaVersion; replay -// depends only on Evolve. +// Evolve folds one fact with the current schema version. Persisted replay uses +// EvolveVersion so future schema versions can keep their historical folding +// semantics while this convenience API remains value-based. // //nolint:gocritic // hugeParam: public fold boundary must stay value-based: Evolve(state, fact) -> new state. func Evolve(s MachineState, f Fact) (MachineState, error) { + return EvolveVersion(currentSchemaVersion, s, f) +} + +// EvolveVersion folds one persisted fact using the Evolve semantics for its +// SchemaVersion. It is mechanical: no IO, no policy decisions, and no Decide. +// +//nolint:gocritic // hugeParam: public replay boundary must stay value-based: EvolveVersion(version, state, fact) -> new state. +func EvolveVersion(schemaVersion uint16, s MachineState, f Fact) (MachineState, error) { + switch schemaVersion { + case SchemaVersion1: + return evolveV1(s, f) + default: + return s, fmt.Errorf("agent: evolve: unsupported schema version %d", schemaVersion) + } +} + +// evolveV1 is the frozen fold semantics for SchemaVersion1. +// +//nolint:gocritic // hugeParam: v1 fold body intentionally preserves value-state semantics. +func evolveV1(s MachineState, f Fact) (MachineState, error) { switch fact := f.(type) { case ModelStepPrepared: if s.Current != nil { diff --git a/agent/memory_runtime.go b/agent/memory_runtime.go index 26ef1b4..c30f6a2 100644 --- a/agent/memory_runtime.go +++ b/agent/memory_runtime.go @@ -10,9 +10,10 @@ import ( ) // MemoryRuntime is the in-process reference Runtime: mutex + MachineState + -// AgentEvent map (spec §8.1). The event log is the source of truth; the state -// is the same-transaction projection. It is the conformance reference; it -// does not survive the process and does not store product history. +// TransitionRecord log (spec §8.1). The transition log is the source of truth; +// the state is the same-transaction projection. It is the conformance +// reference; it does not survive the process and does not store product +// history. type MemoryRuntime struct { mu sync.Mutex state MachineState @@ -22,10 +23,10 @@ type MemoryRuntime struct { // watermark witnesses log-tail completeness: it advances with every // commit and is never cleared by a rebuild (spec §5.1). watermark uint64 - // events keyed by CommandID: the full event group of each transition. - events map[CommandID][]AgentEvent - // log holds every event in (Revision, Index) order for replay. - log []AgentEvent + // transitions keyed by CommandID: the full transition record for idempotency. + transitions map[CommandID]TransitionRecord + // log holds every transition in Revision order for replay. + log []TransitionRecord // occupancy: live grants per target (one model step or one call). grants map[string]ExecutionGrant } @@ -36,10 +37,10 @@ type MemoryRuntime struct { func NewMemoryRuntime(initial MachineState) *MemoryRuntime { frozenInitial := cloneMachineState(&initial) return &MemoryRuntime{ - state: cloneMachineState(&frozenInitial), - initial: frozenInitial, - events: make(map[CommandID][]AgentEvent), - grants: make(map[string]ExecutionGrant), + state: cloneMachineState(&frozenInitial), + initial: frozenInitial, + transitions: make(map[CommandID]TransitionRecord), + grants: make(map[string]ExecutionGrant), } } @@ -85,21 +86,18 @@ func newGrant() ExecutionGrant { return ExecutionGrant(hex.EncodeToString(b[:])) } -func foldCommittedEventGroup(state *MachineState, events []AgentEvent) (MachineState, error) { +func foldCommittedTransition(state *MachineState, record *TransitionRecord) (MachineState, error) { + if err := ValidateTransitionRecord(record); err != nil { + return *state, err + } current := *state - for _, e := range events { - want, err := DigestFact(e.SchemaVersion, e.Type, e.Fact) - if err != nil { - return current, err - } - if e.Digest != want { - return current, fmt.Errorf("agent: memory runtime: stored fact digest mismatch at revision %d index %d", e.Revision, e.Index) - } + for i := range record.Events { + e := record.Events[i] fact, err := snapshotFact(e.Fact) if err != nil { return current, err } - current, err = Evolve(current, fact) + current, err = EvolveVersion(e.SchemaVersion, current, fact) if err != nil { return current, err } @@ -130,7 +128,12 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe recoveryValid = !occupied } - decision, err := EvaluateCommit(m.state, m.revision, m.events[req.Command.ID], req, grantValid, recoveryValid) + var prior *TransitionRecord + if record, ok := m.transitions[req.Command.ID]; ok { + priorRecord := record + prior = &priorRecord + } + decision, err := EvaluateCommit(m.state, m.revision, prior, req, grantValid, recoveryValid) if err != nil { return CommitResult{}, err } @@ -153,19 +156,19 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe return CommitResult{}, ErrRunTerminal } - // DecisionApply: persist owned events and derive the authoritative snapshot - // from those same stored facts. The event log is the source of truth; the - // in-memory state is only its same-transaction projection. - stored := cloneEvents(decision.Events) - newState, err := foldCommittedEventGroup(&m.state, stored) + // DecisionApply: persist the owned transition and derive the authoritative + // snapshot from that same stored transition. The transition log is the source + // of truth; the in-memory state is only its same-transaction projection. + stored := cloneTransitionRecord(&decision.Transition) + newState, err := foldCommittedTransition(&m.state, &stored) if err != nil { return CommitResult{}, err } m.state = cloneMachineState(&newState) m.revision++ m.watermark = m.revision - m.events[req.Command.ID] = stored - m.log = append(m.log, stored...) + m.transitions[req.Command.ID] = stored + m.log = append(m.log, stored) var minted ExecutionGrant switch req.Command.Command.(type) { @@ -184,15 +187,24 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe return CommitResult{ Status: CommitAccepted, Snapshot: RuntimeSnapshot{State: cloneMachineState(&m.state), Revision: m.revision}, - Events: cloneEvents(stored), + Events: cloneEvents(stored.Events), Grant: minted, }, nil } -// Events returns a deep copy of the full event log in (Revision, Index) +// Events returns a deep copy of the flattened event stream in (Revision, Index) // order. Test and replay helper; not part of the Runtime contract. func (m *MemoryRuntime) Events() []AgentEvent { m.mu.Lock() defer m.mu.Unlock() - return cloneEvents(m.log) + return flattenTransitionRecords(m.log) +} + +// Transitions returns a deep copy of the authoritative transition log in +// Revision order. Test and durable-runtime helper; not part of the Runtime +// contract. +func (m *MemoryRuntime) Transitions() []TransitionRecord { + m.mu.Lock() + defer m.mu.Unlock() + return cloneTransitionRecords(m.log) } diff --git a/agent/rebuild.go b/agent/rebuild.go index a3392a2..ed98864 100644 --- a/agent/rebuild.go +++ b/agent/rebuild.go @@ -12,12 +12,12 @@ import ( // (spec §5.1). Recovery is a disaster-recovery matter, not a protocol one. var ErrLogTruncated = errors.New("agent: event log ends below the revision watermark") -// FoldEvents rebuilds a MachineState by folding the event log from the -// initial (Revision 0) state with Evolve only: no Decide, no external -// effects, no command replay (spec §9.1). It verifies (Revision, Index) -// ordering and per-fact digests as it goes; any gap or mismatch means the log -// itself is damaged and the fold stops. It also rejects RunID/schema/type -// mismatches and same-revision command identity changes. +// FoldEvents rebuilds a MachineState by folding a complete flat event stream +// from the initial (Revision 0) state with EvolveVersion only: no Decide, no +// external effects, no command replay (spec §9.1). It verifies (Revision, +// Index) ordering and per-fact digests as it goes. Authority runtimes should +// prefer FoldTransitions because only TransitionRecord can prove the last +// transition's event group is complete. // //nolint:gocritic // hugeParam: public replay API folds from an initial value state without mutating caller-owned state. func FoldEvents(initial MachineState, events []AgentEvent) (MachineState, uint64, error) { @@ -71,7 +71,7 @@ func FoldEvents(initial MachineState, events []AgentEvent) (MachineState, uint64 if err != nil { return initial, 0, err } - state, err = Evolve(state, fact) + state, err = EvolveVersion(e.SchemaVersion, state, fact) if err != nil { return initial, 0, err } @@ -79,8 +79,43 @@ func FoldEvents(initial MachineState, events []AgentEvent) (MachineState, uint64 return state, revision, nil } -// Rebuild discards the in-memory snapshot and refolds it from the event log, -// arbitrating per spec §5.1: the log wins when it is complete +// FoldTransitions rebuilds a MachineState by folding authoritative transition +// records from the immutable initial state. The source of truth is the +// admission-created initial state plus the complete TransitionRecord log. +// +//nolint:gocritic // hugeParam: public replay API folds from an initial value state without mutating caller-owned state. +func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineState, uint64, error) { + state := initial + var revision uint64 + for i := range records { + record := records[i] + if err := ValidateTransitionRecord(&record); err != nil { + return initial, 0, err + } + if record.RunID != initial.RunID { + return initial, 0, fmt.Errorf("agent: fold: transition run %q does not match initial run %q", record.RunID, initial.RunID) + } + if record.Revision != revision+1 { + return initial, 0, fmt.Errorf("agent: fold: gap at transition revision %d (expected %d)", record.Revision, revision+1) + } + for j := range record.Events { + e := record.Events[j] + fact, err := snapshotFact(e.Fact) + if err != nil { + return initial, 0, err + } + state, err = EvolveVersion(e.SchemaVersion, state, fact) + if err != nil { + return initial, 0, err + } + } + revision = record.Revision + } + return state, revision, nil +} + +// Rebuild discards the in-memory snapshot and refolds it from the transition +// log, arbitrating per spec §5.1: the log wins when it is complete // (maxRevision >= watermark); a log tail below the watermark halts with // ErrLogTruncated. It returns true when the refolded state differed from the // stored snapshot — with a correct implementation this never happens, so a @@ -90,7 +125,7 @@ func (m *MemoryRuntime) Rebuild() (rebuilt bool, err error) { m.mu.Lock() defer m.mu.Unlock() - folded, maxRevision, err := FoldEvents(cloneMachineState(&m.initial), m.log) + folded, maxRevision, err := FoldTransitions(cloneMachineState(&m.initial), m.log) if err != nil { return false, err } diff --git a/agent/rebuild_test.go b/agent/rebuild_test.go index 8e58a0a..e1252a1 100644 --- a/agent/rebuild_test.go +++ b/agent/rebuild_test.go @@ -106,16 +106,9 @@ func TestRebuildRepairsCorruptedSnapshot(t *testing.T) { func TestRebuildHaltsOnTruncatedTail(t *testing.T) { rt := fullRunRuntime(t) rt.mu.Lock() - // Simulate selective damage: drop the last transition's events while the - // watermark (separate storage in a durable adapter) survives. - last := rt.log[len(rt.log)-1].Revision - var kept []AgentEvent - for _, e := range rt.log { - if e.Revision < last { - kept = append(kept, e) - } - } - rt.log = kept + // Simulate selective damage: drop the last transition while the watermark + // (separate storage in a durable adapter) survives. + rt.log = cloneTransitionRecords(rt.log[:len(rt.log)-1]) rt.mu.Unlock() _, err := rt.Rebuild() @@ -124,30 +117,66 @@ func TestRebuildHaltsOnTruncatedTail(t *testing.T) { } } -// A gap in the middle of the log is log damage, not a rebuild input. -func TestFoldRejectsInteriorGap(t *testing.T) { +func TestRebuildHaltsOnPartialTailTransition(t *testing.T) { rt := fullRunRuntime(t) rt.mu.Lock() - var holed []AgentEvent - for _, e := range rt.log { - if e.Revision == 3 { // drop one interior transition + last := &rt.log[len(rt.log)-1] + if len(last.Events) < 2 { + t.Fatal("test requires a multi-event tail transition") + } + last.Events = last.Events[:len(last.Events)-1] + rt.mu.Unlock() + + _, err := rt.Rebuild() + if err == nil { + t.Fatal("partial tail transition folded silently") + } +} + +// A gap in the middle of the transition log is log damage, not a rebuild input. +func TestFoldTransitionsRejectsInteriorGap(t *testing.T) { + rt := fullRunRuntime(t) + rt.mu.Lock() + var holed []TransitionRecord + for i := range rt.log { + if rt.log[i].Revision == 3 { // drop one interior transition continue } - holed = append(holed, e) + holed = append(holed, cloneTransitionRecord(&rt.log[i])) } log := holed initial := cloneMachineState(&rt.initial) rt.mu.Unlock() - if _, _, err := FoldEvents(initial, log); err == nil { - t.Fatal("interior gap folded silently") + if _, _, err := FoldTransitions(initial, log); err == nil { + t.Fatal("interior transition gap folded silently") + } +} + +// A gap in the middle of a complete flat event stream is still rejected. +func TestFoldEventsRejectsInteriorGap(t *testing.T) { + rt := fullRunRuntime(t) + rt.mu.Lock() + flat := flattenTransitionRecords(rt.log) + initial := cloneMachineState(&rt.initial) + rt.mu.Unlock() + + var holed []AgentEvent + for i := range flat { + if flat[i].Revision == 3 { // drop one interior transition + continue + } + holed = append(holed, flat[i]) + } + if _, _, err := FoldEvents(initial, holed); err == nil { + t.Fatal("interior event gap folded silently") } } func TestFoldRejectsRunIDMismatch(t *testing.T) { rt := fullRunRuntime(t) rt.mu.Lock() - log := cloneEvents(rt.log) + log := flattenTransitionRecords(rt.log) initial := cloneMachineState(&rt.initial) rt.mu.Unlock() @@ -160,7 +189,7 @@ func TestFoldRejectsRunIDMismatch(t *testing.T) { func TestFoldRejectsTransitionCommandIdentityChange(t *testing.T) { rt := fullRunRuntime(t) rt.mu.Lock() - log := cloneEvents(rt.log) + log := flattenTransitionRecords(rt.log) initial := cloneMachineState(&rt.initial) rt.mu.Unlock() @@ -178,7 +207,7 @@ func TestFoldRejectsTransitionCommandIdentityChange(t *testing.T) { func TestFoldRejectsUnsupportedSchemaVersion(t *testing.T) { rt := fullRunRuntime(t) rt.mu.Lock() - log := cloneEvents(rt.log) + log := flattenTransitionRecords(rt.log) initial := cloneMachineState(&rt.initial) rt.mu.Unlock() @@ -197,7 +226,7 @@ func TestFoldRejectsUnsupportedSchemaVersion(t *testing.T) { func TestFoldRejectsTamperedFact(t *testing.T) { rt := fullRunRuntime(t) rt.mu.Lock() - log := cloneEvents(rt.log) + log := flattenTransitionRecords(rt.log) initial := cloneMachineState(&rt.initial) rt.mu.Unlock() @@ -241,7 +270,7 @@ func TestRegressionPreparedFactSelfContained(t *testing.T) { // the same commit that changes the protocol). func TestGoldenEventStreamV1(t *testing.T) { rt := fullRunRuntime(t) - folded, maxRev, err := FoldEvents(cloneMachineState(&rt.initial), rt.Events()) + folded, maxRev, err := FoldTransitions(cloneMachineState(&rt.initial), rt.Transitions()) if err != nil { t.Fatal(err) } diff --git a/agent/transition.go b/agent/transition.go new file mode 100644 index 0000000..43744a6 --- /dev/null +++ b/agent/transition.go @@ -0,0 +1,145 @@ +package agent + +import ( + "errors" + "fmt" +) + +// TransitionRecord is the atomic authority record for one accepted +// Runtime.Commit. A transition owns the complete ordered AgentEvent group for +// one Revision; runtimes should persist it as the unit of log completeness and +// expose its Events as the public committed event stream. +type TransitionRecord struct { + SchemaVersion uint16 `json:"schemaVersion"` + RunID RunID `json:"runId"` + Revision uint64 `json:"revision"` + CommandID CommandID `json:"commandId"` + CommandDigest Digest `json:"commandDigest"` + Events []AgentEvent `json:"events"` + TransitionDigest Digest `json:"transitionDigest"` +} + +type transitionRecordDigestBody struct { + SchemaVersion uint16 `json:"schemaVersion"` + RunID RunID `json:"runId"` + Revision uint64 `json:"revision"` + CommandID CommandID `json:"commandId"` + CommandDigest Digest `json:"commandDigest"` + Events []AgentEvent `json:"events"` +} + +func transitionRecordBody(record *TransitionRecord) transitionRecordDigestBody { + return transitionRecordDigestBody{ + SchemaVersion: record.SchemaVersion, + RunID: record.RunID, + Revision: record.Revision, + CommandID: record.CommandID, + CommandDigest: record.CommandDigest, + Events: record.Events, + } +} + +// DigestTransitionRecord computes the digest for one transition aggregate. The +// digest binds the transition identity and the complete ordered event group; +// TransitionDigest itself is excluded from the digest input. +func DigestTransitionRecord(record *TransitionRecord) (Digest, error) { + if record == nil { + return "", errors.New("agent: transition: nil record") + } + body, err := encodeEnvelopeBody(record.SchemaVersion, "transition_record", transitionRecordBody(record)) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +// BuildTransitionRecord freezes and validates the complete event group of one +// accepted command as an atomic transition record. +func BuildTransitionRecord(events []AgentEvent) (TransitionRecord, error) { + if len(events) == 0 { + return TransitionRecord{}, errors.New("agent: transition: empty event group") + } + if len(events) > int(^uint16(0))+1 { + return TransitionRecord{}, fmt.Errorf("agent: transition: event group too large: %d", len(events)) + } + frozen := cloneEvents(events) + first := frozen[0] + record := TransitionRecord{ + SchemaVersion: first.SchemaVersion, + RunID: first.RunID, + Revision: first.Revision, + CommandID: first.CommandID, + CommandDigest: first.CommandDigest, + Events: frozen, + } + digest, err := DigestTransitionRecord(&record) + if err != nil { + return TransitionRecord{}, err + } + record.TransitionDigest = digest + if err := ValidateTransitionRecord(&record); err != nil { + return TransitionRecord{}, err + } + return record, nil +} + +// ValidateTransitionRecord verifies that a transition is internally complete: +// every nested event belongs to the transition, indexes are contiguous, fact +// digests match, and the transition digest binds the whole aggregate. +func ValidateTransitionRecord(record *TransitionRecord) error { + if record == nil { + return errors.New("agent: transition: nil record") + } + if record.RunID == "" || record.Revision == 0 || record.CommandID == "" || record.CommandDigest == "" { + return errors.New("agent: transition: missing identity") + } + if !isSupportedSchemaVersion(record.SchemaVersion) { + return fmt.Errorf("agent: transition: unsupported schema version %d", record.SchemaVersion) + } + if len(record.Events) == 0 { + return fmt.Errorf("agent: transition: revision %d has no events", record.Revision) + } + if len(record.Events) > int(^uint16(0))+1 { + return fmt.Errorf("agent: transition: revision %d event group too large: %d", record.Revision, len(record.Events)) + } + for i := range record.Events { + e := record.Events[i] + if e.SchemaVersion != record.SchemaVersion { + return fmt.Errorf("agent: transition: event %d schema version %d does not match transition %d", i, e.SchemaVersion, record.SchemaVersion) + } + if e.RunID != record.RunID { + return fmt.Errorf("agent: transition: event %d run %q does not match transition run %q", i, e.RunID, record.RunID) + } + if e.Revision != record.Revision { + return fmt.Errorf("agent: transition: event %d revision %d does not match transition revision %d", i, e.Revision, record.Revision) + } + if e.Index != uint16(i) { + return fmt.Errorf("agent: transition: revision %d index %d has event index %d", record.Revision, i, e.Index) + } + if e.CommandID != record.CommandID || e.CommandDigest != record.CommandDigest { + return fmt.Errorf("agent: transition: revision %d command identity changed within transition", record.Revision) + } + typ := factType(e.Fact) + if typ == "" || e.Type != typ { + return fmt.Errorf("agent: transition: event type %q does not match fact variant %T", e.Type, e.Fact) + } + wantDigest, err := DigestFact(e.SchemaVersion, e.Type, e.Fact) + if err != nil { + return err + } + if e.Digest != wantDigest { + return fmt.Errorf("agent: transition: fact digest mismatch at revision %d index %d", e.Revision, e.Index) + } + } + if record.TransitionDigest == "" { + return errors.New("agent: transition: missing digest") + } + wantDigest, err := DigestTransitionRecord(record) + if err != nil { + return err + } + if record.TransitionDigest != wantDigest { + return fmt.Errorf("agent: transition: digest mismatch at revision %d", record.Revision) + } + return nil +} diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index e1ec499..61fea53 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -135,8 +135,9 @@ Step 不是最小的外部操作。ToolStep 保存每个 ToolCall 中会影响 | ToolStep | 一个模型结果产生的一组 ToolCall 及其 progress。 | | ToolCall | ToolStep 内的一个结构化工具调用。 | | AgentCommand | Loop 或外部入口希望 Machine 接受的意图;接受后构成一次 transition。 | -| AgentEvent | Runtime 已接受并持久化的事实;一次 transition 产出一个或多个,带 (Revision, Index) 身份。 | -| Revision | 每 Run 单调递增的 transition 计数;第 N 次接受的 transition 产出 Revision=N 的事件组和 Revision=N 的状态。 | +| AgentEvent | Runtime 已接受的单个事实;一次 transition 产出一个或多个,带 (Revision, Index) 身份,可作为外部事件流消费。 | +| TransitionRecord | Runtime 持久化的原子 authority record;封装一次 transition 的完整 AgentEvent 组和 transition digest。 | +| Revision | 每 Run 单调递增的 transition 计数;第 N 次接受的 transition 产出 Revision=N 的 TransitionRecord 和 Revision=N 的状态。 | | Effect | Machine 根据当前状态返回的至多一个待执行动作;不表示一定有外部副作用。 | | Attempt | Runtime 为一次进程执行建立的内部执行租约。 | | Runtime | MachineState 的 execution authority、AgentCommand 的原子提交和 AgentEvent 的产生者。 | @@ -263,7 +264,7 @@ agent 不拥有 Memoh 的 session、queue schema、admission、owner、fencing Runtime 的 authority boundary 不能靠“调用者不要修改快照”这类规范约束来成立,必须由类型和提交路径保证。旧设计把 `sdk.Request`、`sdk.ModelResult`、`sdk.ToolDefinition`、`sdk.Usage` 及其内部的 `map`、`slice`、`json.RawMessage`、`any`、interface value 直接或浅拷贝地放进 command/fact/state,会产生三类破坏: -1. **aliasing/mutation**:Planner、provider 或测试代码在 `Commit` 后继续修改 SDK 对象,可能改变已返回 snapshot、已保存事件或后续 `Evolve` 输入,使 `state = fold(events)` 不再成立。 +1. **aliasing/mutation**:Planner、provider 或测试代码在 `Commit` 后继续修改 SDK 对象,可能改变已返回 snapshot、已保存 transition 或后续 `EvolveVersion` 输入,使 `state = fold(initial, flatten(transitions))` 不再成立。 2. **digest/replay nondeterminism**:digest 在提交时按一组字节计算,但持久化对象仍引用 caller-owned map/slice/RawMessage;之后对象变化会让相同 `CommandID` 的重放、`CommitAlreadyApplied` 判断、StepID 派生和 replay 校验失真。 3. **unbounded SDK surface**:`sdk.MessagePart` interface、provider metadata 的 `map[string]any`、tool/schema 的原始 JSON 等属于 provider/transport 边界数据,不能成为 Machine 协议的永久 wire contract。 @@ -666,38 +667,38 @@ Runtime 只回答两个问题: ```text 当前权威 MachineState 是什么? -一个 AgentCommand 如何安全地提交并生成 AgentEvent? +一个 AgentCommand 如何安全地提交成一个 TransitionRecord,并对外产生 AgentEvent 流? ``` 它不组装 prompt,不调用模型或工具,不定义 Machine 规则,不实现 queue policy。Runtime 可以在同一事务中更新 Memoh 的 history、queue projection、response record 和 outbox,但这些是 adapter 的原子投影,不是 Runtime 的语义职责。 -`Commit` 必须在 authority 的临界区内调用共享的 `EvaluateCommit`(内部执行幂等/类别校验、`Machine.Decide` 和 `Machine.Evolve`),因为“读取状态、验证 command、计算事实与新状态、保存并写入 AgentEvent”不能在 durable 实现中拆成几个由 Loop 拼接的公开操作。这个必要的原子边界不等于 Runtime 拥有 Machine 规则;规则仍只有 agent 一份,也不等于 Runtime 拥有 Memoh 的产品数据。 +`Commit` 必须在 authority 的临界区内调用共享的 `EvaluateCommit`(内部执行幂等/类别校验、`Machine.Decide` 和 versioned `Machine.Evolve`),因为“读取状态、验证 command、计算事实与新状态、保存完整 transition、更新 projection”不能在 durable 实现中拆成几个由 Loop 拼接的公开操作。这个必要的原子边界不等于 Runtime 拥有 Machine 规则;规则仍只有 agent 一份,也不等于 Runtime 拥有 Memoh 的产品数据。 -因此 Runtime 的接口很小,但一次 `Commit` 的事务范围可以很大:它必须让一个 AgentCommand、产出的 AgentEvent 组及其必要的产品投影一起成功或一起失败;这不意味着 Runtime 获得了 history、queue 或 prompt 的所有权。 +因此 Runtime 的接口很小,但一次 `Commit` 的事务范围可以很大:它必须让一个 AgentCommand、产出的 `TransitionRecord` 及其必要的产品投影一起成功或一起失败;这不意味着 Runtime 获得了 history、queue 或 prompt 的所有权。`CommitResult.Events` 只是该 transition 的事件流视图,方便 Loop、UI 和 observability 消费;Runtime 的 authority storage 是完整 transition aggregate。 -AgentEvent log 是 Run 语义状态的 source of truth;MachineState 是必需的同事务 projection(execution cache):提交验证与 Loop 执行从它读取,因此它必须与日志在同一原子提交内更新,但它可以从日志重建。对任意 Revision N,状态必须等于初始状态按事件流 fold `Evolve` 到 N 的结果——这是可自动恢复的不变量,不是 halt 条件。 +Run 语义状态的 source of truth 是 admission-created immutable initial state(Revision=0)加 `TransitionRecord` log;MachineState 是必需的同事务 projection(execution cache):提交验证与 Loop 执行从它读取,因此它必须与 transition log 在同一原子提交内更新,但它可以从日志重建。对任意 Revision N,状态必须等于初始状态按 `flatten(TransitionRecord[].Events)` 调用 versioned `Evolve` fold 到 N 的结果——这是可自动恢复的不变量,不是 halt 条件。 这个权威声明成立的三个稳定条件(本规范的规范性条款): 1. Event ontology 稳定:Fact 词表 sealed,已发布 SchemaVersion 的事实结构永不修改,新增字段进入新版本。 -2. Evolve 语义稳定:折叠是机械的(不读 RunConfig、无 policy 分支),已发布版本的折叠语义与事件编码一起永久冻结;会演进的决策语义全部在 Decide,其结果记录为事实。 +2. Evolve 语义稳定:折叠是机械的(不读 RunConfig、无 policy 分支),已发布版本的折叠语义与事件编码一起永久冻结;replay 通过 `EvolveVersion(SchemaVersion, state, fact)` 选择历史语义,会演进的决策语义全部在 Decide,其结果记录为事实。 3. 事实自包含:折叠一条事实所需的全部信息在事实自身与折叠前状态之内,不访问外部系统,不重新计算依赖当前代码版本的派生值(digest 一律在 Decide 时算好并携带在事实中)。 4. 持久化值归属稳定:AgentEvent 和 MachineState 只保存 agent-owned frozen values;任何来自 SDK/provider/application 的引用在进入 Runtime 前必须被 canonicalize + detach,返回给 caller 的 snapshot/event 也必须是独立副本。 -Runtime 实现还必须在代码层面维护这些条件:`EvaluateCommit` 对 Decide 产出的 facts 做 `snapshotFact` 后再 fold/persist;`Load`、`CommitResult` 和 AlreadyApplied replay 返回的 snapshot/event 不得共享 authority 内部引用;MemoryRuntime 这类参考实现保存 accepted event group 后,从 stored events fold 出新的 authority state,而不是直接保存调用栈里算出的 `decision.NewState`。durable adapter 可以用数据库事务替代 mutex,但不能把未冻结 SDK 对象、浅拷贝 snapshot 或 caller-owned bytes 写入事件表/状态表。 +Runtime 实现还必须在代码层面维护这些条件:`EvaluateCommit` 对 Decide 产出的 facts 做 `snapshotFact` 后再 fold/persist,并构造带 transition digest 的 `TransitionRecord`;`Load`、`CommitResult` 和 AlreadyApplied replay 返回的 snapshot/event 不得共享 authority 内部引用;MemoryRuntime 这类参考实现保存 accepted transition 后,从 stored transition 的 events fold 出新的 authority state,而不是直接保存调用栈里算出的 `decision.NewState`。durable adapter 可以用数据库事务替代 mutex,但不能把未冻结 SDK 对象、浅拷贝 snapshot 或 caller-owned bytes 写入事件表/状态表。 -每 Run 维护一个不可丢弃的 revision 水位(watermark):每次提交与日志同步推进的单调计数,语义为"日志至少完整到此"。它是日志尾部完整性的末端见证——append-only 日志可以用 (Revision, Index) 连续性检测中间缺洞,但缺尾的日志与更短的完整日志无法区分。水位是控制平面数据,不进入 MachineState,重建不清除它。 +每 Run 维护一个不可丢弃的 revision 水位(watermark):每次提交与 transition log 同步推进的单调计数,语义为"transition log 至少完整到此"。它是日志尾部完整性的末端见证;单个 transition 内部完整性由 `TransitionRecord.Events` 和 `TransitionDigest` 绑定,transition 之间用 Revision 连续性检测缺洞。水位是控制平面数据,不进入 MachineState,重建不清除它。 分歧仲裁规则固定为: ```text -snapshot 与 fold(events) 不一致,或 snapshot 缺失,且 log.maxRevision >= watermark - -> 日志为准,自动重建(纯 Evolve 折叠,零副作用,不重放命令,不产生外部 effect), +snapshot 与 fold(transitions) 不一致,或 snapshot 缺失,且 transitionLog.maxRevision >= watermark + -> transition log 为准,自动重建(纯 EvolveVersion 折叠,零副作用,不重放命令,不产生外部 effect), 并记录一次重建事件供运维审计——实现正确时这条路径不触发,每次触发都意味着 Evolve bug、越权写入或 snapshot 损坏真实发生过 -log.maxRevision < watermark - -> 日志尾部缺失,halt 该 Run——已接受的事实永久消失无法凭空恢复, +transitionLog.maxRevision < watermark,或尾部 transition 的 digest/事件组不完整 + -> 日志尾部缺失/损坏,halt 该 Run——已接受的事实永久消失无法凭空恢复, 继续推进会把丢失升级为错误的重复执行;恢复属于灾难恢复范畴(备份、复制) ``` @@ -768,6 +769,18 @@ type AgentEvent struct { Fact Fact } +// TransitionRecord is the atomic authority record for one accepted command. +// It binds the complete ordered event group for one Revision. +type TransitionRecord struct { + SchemaVersion uint16 + RunID RunID + Revision uint64 + CommandID CommandID + CommandDigest Digest + Events []AgentEvent // complete ordered event group for this revision + TransitionDigest Digest // digest of transition identity + complete event group +} + type CommitResult struct { Status CommitStatus Snapshot RuntimeSnapshot @@ -779,6 +792,10 @@ func EncodeCommand(CommandEnvelope) ([]byte, error) // 不包含 Digest 字段 func DigestCommand(schemaVersion uint16, typ string, command AgentCommand) (Digest, error) func EncodeFact(schemaVersion uint16, typ string, fact Fact) ([]byte, error) func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) +func BuildTransitionRecord([]AgentEvent) (TransitionRecord, error) +func ValidateTransitionRecord(*TransitionRecord) error +func DigestTransitionRecord(*TransitionRecord) (Digest, error) +func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineState, uint64, error) func EncodeRunSeed(RunSeed) ([]byte, error) func DigestRunSeed(schemaVersion uint16, seed RunSeed) (Digest, error) func DigestRequest(ModelRequest) (Digest, error) @@ -823,16 +840,17 @@ Grant 只绑定一个 ModelStep 或一个 ToolCall,不能转用于另一个 Ca ```go type CommitDecision struct { - Kind DecisionKind // Apply | AlreadyApplied | Conflict | Stale | Terminal - NewState MachineState - Events []AgentEvent + Kind DecisionKind // Apply | AlreadyApplied | Conflict | Stale | Terminal + NewState MachineState + Events []AgentEvent // event-stream view of Transition.Events + Transition TransitionRecord // authority aggregate for this commit } // grantValid/recoveryValid 由 Runtime 依据自己的 lease/occupancy 记录判定后传入; -// prior 是相同 (RunID, CommandID) 的已有事件组(若有)。 +// prior 是相同 (RunID, CommandID) 的已有 transition(若有)。 func EvaluateCommit( cur MachineState, curRevision uint64, - prior []AgentEvent, + prior *TransitionRecord, req CommitRequest, grantValid bool, recoveryValid bool, @@ -846,8 +864,8 @@ func EvaluateCommit( 3. 相同 identity 携带不同 digest,返回 `ErrCommandConflict`。 对 `AcceptInput`,还按 RunID/InputID 检查已接受索引:相同 payload 返回原事件组和 `CommitAlreadyApplied`,不同 payload 返回冲突,不产生第二条输入事实。 4. 校验 BaseRevision、当前 Step、CallID 和 Grant。BaseRevision 只对 `PrepareModelRequest` 是硬校验——其 CommandID 由 Revision 派生,StepID 由该 CommandID 与 Decide 得到的 binding digest 派生,Revision 即它的并发控制,过期即返回 `ErrStaleRuntime`。其余 command 在 BaseRevision 过期时按类别前置条件基于当前状态重新评估:start(`StartModelExecution` 目标仍须为同一 Prepared ModelStep;`StartToolCall` 与 Pending 的已知失败目标 Call 仍须为 Pending,均空 Grant);owner 完成(Executing Call 的完成/失败、`SubmitModelResult`/`SubmitModelFailure`/`RejectModelResult`,以及持有效 Model grant 的 `RecoverModelExecution`,以提交者仍持有对应有效 Grant 为条件);system recovery(无 grant 的 `RecoverModelExecution` 和 scanner 的 Unknown,以 Runtime 自己的 recovery record 为条件);ingress(approval/external response 目标 Call 仍须为对应 Waiting 且 ResponseID/kind 匹配;`AcceptInput` 要求 Run 仍 active 且没有当前 Step,连续多条输入互不拒绝;均空 Grant);run-control(`CancelRun` 只要求 Run 非 terminal)。前置条件不满足时按具体原因返回 stale/terminal/冲突。start command 建立 grant/lease 的动作与状态提交属于同一个原子操作。 -5. 调用 `Machine.Decide` 产出事实序列,逐个 `Machine.Evolve` 折叠出新状态;为这次 transition 分配 `Revision = curRevision + 1`,事实按序获得 `Index = 0..k-1`,全部携带产生它们的 CommandID。 -6. Runtime 在自己的原子边界内保存 MachineState、AgentEvent 组及需要一致的 Memoh projection。 +5. 调用 `Machine.Decide` 产出事实序列,逐个 `Machine.EvolveVersion` 折叠出新状态;为这次 transition 分配 `Revision = curRevision + 1`,事实按序获得 `Index = 0..k-1`,全部携带产生它们的 CommandID,再封装为带 `TransitionDigest` 的 `TransitionRecord`。 +6. Runtime 在自己的原子边界内保存 MachineState、TransitionRecord 及需要一致的 Memoh projection;`CommitResult.Events` 返回该 transition 的 `Events` 视图。 7. 非重复 command 若目标 Run 已经 terminal,返回 `ErrRunTerminal`;迟到的 worker 结果不会重新打开 Run。其他提交成功后返回新 snapshot。 `EvaluateCommit` 用 `DecisionKind` 表达结果;`Runtime.Commit` 把非成功结果映射为对外错误:`DecisionConflict -> ErrCommandConflict`、`DecisionStale -> ErrStaleRuntime`、`DecisionTerminal -> ErrRunTerminal`。Loop 与 ingress 只依赖这三个错误值和 `CommitStatus`,不接触 DecisionKind。 @@ -913,7 +931,7 @@ canonical 编码和 digest 函数由 agent 提供;Memoh 只保存和比较结 CommandEnvelope 和 AgentEvent 的 `SchemaVersion` 和 `Type` 是持久化协议字段;Type 必须与 sealed AgentCommand/Fact 的具体变体一致,未知版本或类型直接拒绝。agent 必须提供正式 wire codec:decode 时先 canonicalize 整个 document,再读 `Type`,恢复具体 command/fact variant,校验 command/fact digest,并要求 decoded value 重新 canonical marshal 后与输入 canonical document 等价;不能依赖 `encoding/json` 自动反序列化 interface 字段,也不能接受 duplicate key、大小写模糊字段名或其他会被 Go decoder 合并/宽容的形态。`DigestCommand`/`DigestFact` 对 `SchemaVersion`、`Type` 和内容做 canonical digest,但不把 `Digest` 字段自身纳入摘要,保证 Memoh scanner、MemoryRuntime 和不同进程使用同一身份规则。`Revision` 只用于 authority 的 CAS,不进入任何 digest。 -Evolve 的折叠语义与事件编码同属永久兼容契约:已发布 SchemaVersion 的事件必须永远能被折叠出与写入当时相同的状态。conformance kit 为每个已发布 SchemaVersion 冻结 golden event stream 与对应的状态字节,任何 Evolve 实现变更都必须通过全部历史版本的 golden 校验。 +Evolve 的折叠语义与事件编码同属永久兼容契约:已发布 SchemaVersion 的事件必须永远能被折叠出与写入当时相同的状态。conformance kit 为每个已发布 SchemaVersion 冻结 golden transition stream 与对应的状态字节,任何 versioned Evolve 实现变更都必须通过全部历史版本的 golden 校验。 ### 5.6 Runtime 不拥有 Planner @@ -1288,7 +1306,7 @@ agent 提供 MCP schema/call adapter,把 MCP tool 转换为 `sdk.ToolDefinitio ### 8.1 MemoryRuntime -MemoryRuntime 使用 `mutex + MachineState + AgentEvent map`: +MemoryRuntime 使用 `mutex + MachineState + TransitionRecord log`: ```text Load @@ -1301,7 +1319,7 @@ Commit MemoryRuntime 不需要 owner、fence、lease、outbox 或 Attempt 表。进程退出后状态可以丢失;它是本地会话、测试和 conformance reference。它仍须在同一把锁内记录每个已接受 start 的执行占用,防止两个本地 worker 同时执行同一个 Call。 -MemoryRuntime 只保存 agent 的 MachineState、AgentEvent 和提交幂等记录,不自动保存产品 history。需要多轮上下文的 in-process +MemoryRuntime 只保存 agent 的 MachineState、TransitionRecord log 和提交幂等记录;`Events()` 只返回 flatten 后的事件流视图,不自动保存产品 history。需要多轮上下文的 in-process 宿主应在 Runtime 外维护一个内存 history projection,并让自己的 RequestPlanner 读取它;这只是一个轻量的应用层配套,不是 MemoryRuntime 为 durable 语义模拟数据库。 worker context 的取消不等同于业务取消。若已提交 `StartToolCall` 后 worker 返回 Unknown,Loop 使用不受 worker cancellation 影响的 commit context 提交 `SubmitToolFailure{Outcome: ToolOutcomeUnknown}`;MemoryRuntime 在同一把锁内记录 Unknown 并结束 Run。若当前进程直接退出,MemoryRuntime 不承诺跨进程恢复;仍处于 Executing 的 Call 随内存状态一起丢失。 @@ -1320,13 +1338,13 @@ Load Commit 判定内部 owner/fence/lease 的有效性 对 StartModelExecution/StartToolCall 在同一事务内建立 Attempt/lease - 调用共享 EvaluateCommit(command identity、Revision、Decide/Evolve) - 原子保存 MachineState、AgentEvent 组、history projection、queue action、response record 和 outbox + 调用共享 EvaluateCommit(command identity、Revision、Decide/EvolveVersion) + 原子保存 MachineState、TransitionRecord、history projection、queue action、response record 和 outbox ``` 并行 Call 的完成 transition 按提交先后获得递增的 Revision;这不改变模型上下文中的 Call 顺序。Memoh 先按 CallID 保存各自结果,ToolStep 关闭时再按 ModelResult 原始 Call 顺序写入 assistant tool-call 与 tool-result history。 -Attempt、owner、fence、lease 和数据库 row 不进入 agent public state。它们只保证多个 Loop attempt 不会同时取得同一个 Call 的执行权。持久化的 MachineState snapshot 必须带 adapter 自己的 snapshot schema version;跨版本升级时按该版本解码,或直接 truncate 后从事件流重建(§5.1),不复用事件的 `SchemaVersion` 字段。每 Run 的 revision 水位与 snapshot 分开存放(state 表中不随重建清空的列,或独立小表),每次 Commit 与日志同事务推进。 +Attempt、owner、fence、lease 和数据库 row 不进入 agent public state。它们只保证多个 Loop attempt 不会同时取得同一个 Call 的执行权。持久化的 MachineState snapshot 必须带 adapter 自己的 snapshot schema version;跨版本升级时按该版本解码,或直接 truncate 后从 TransitionRecord log 重建(§5.1),不复用事件的 `SchemaVersion` 字段。每 Run 的 revision 水位与 snapshot 分开存放(state 表中不随重建清空的列,或独立小表),每次 Commit 与 transition log 同事务推进。 MemohRuntime 的 worker 实例在构造时绑定当前 worker 的 owner identity;只有该实例可以提交自己接受的 model/tool start、completion 和主动 recovery。response 和 cancel 使用同一个 `Commit` 语义,但由 Memoh 创建不带 worker grant 的 ingress-scoped adapter;这些 command 不取得执行权,因此不需要伪造 Loop owner。新 Run admission 仍由 Memoh 的 admission 事务处理,不调用旧 Run 的 `Runtime.Commit`。 @@ -1368,12 +1386,12 @@ Tool effect ```text Loop / response ingress -> AgentCommand - -> Runtime.Commit (EvaluateCommit: 幂等/类别校验 + Machine.Decide + Machine.Evolve) - -> MachineState + AgentEvent 组 in one atomic boundary + -> Runtime.Commit (EvaluateCommit: 幂等/类别校验 + Machine.Decide + Machine.EvolveVersion) + -> MachineState + TransitionRecord in one atomic boundary -> EventSink / replay / projection / OTel ``` -`AgentCommand` 表示“希望发生的状态变化”;`AgentEvent` 表示“authority 已接受并持久化的事实”。一个接受的 command 构成一次 transition,产出一个或多个事实。AgentEvent 必须具备: +`AgentCommand` 表示“希望发生的状态变化”;`AgentEvent` 表示“authority 已接受的事实”,`TransitionRecord` 是持久化的 authority aggregate。一个接受的 command 构成一次 transition,产出一个或多个事实,并以完整 `TransitionRecord` 原子保存。AgentEvent 必须具备: ```text RunID + (Revision, Index) 全序身份;Revision 是 transition 计数,Index 是组内序 @@ -1383,9 +1401,9 @@ SchemaVersion + Type wire 兼容和 sealed fact discriminator Fact 已接受的事实内容 ``` -AgentEvent log 是 source of truth;MachineState 是必需的同事务 projection(§5.1)。Runtime 必须把两者、水位和需要一致的 Memoh projection/outbox 放在同一事务或锁边界。Durable adapter 必须保留 AgentEvent,使其可以按 RunID/(Revision, Index) replay;MemoryRuntime 可以只在进程内保留同样的记录。公共 `Runtime` 不增加 replay 方法,读取由实现或 application projection 提供。 +Initial MachineState + TransitionRecord log 是 source of truth;MachineState 是必需的同事务 projection(§5.1)。Runtime 必须把 transition log、snapshot、水位和需要一致的 Memoh projection/outbox 放在同一事务或锁边界。Durable adapter 必须保留完整 TransitionRecord,使其可以按 RunID/Revision replay;MemoryRuntime 可以只在进程内保留同样的记录。公共 `Runtime` 不增加 replay 方法,读取由实现或 application projection 提供。 -Replay 按 RunID/(Revision, Index) 取出 AgentEvent,从初始状态(Revision=0)开始依次调用同一份 `Machine.Evolve` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里,Machine 决策规则的演进不影响历史事件的折叠;折叠不产生任何外部 effect。仲裁按 §5.1 的规则:日志完整(maxRevision >= watermark)时日志为准,snapshot 分歧或缺失自动重建并记录重建事件;日志尾部低于水位时 halt。事件流内部的 RunID 不匹配、SchemaVersion/Type 不支持、同一 Revision 的 CommandID/CommandDigest 不一致、Revision/Index 缺洞或 digest 不匹配同样按日志损坏处理,halt 该 Run。 +Replay 按 RunID/Revision 取出 TransitionRecord,从初始状态(Revision=0)开始依次展开其中的 AgentEvent,并调用对应 `SchemaVersion` 的 `Machine.EvolveVersion` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里,Machine 决策规则的演进不影响历史事件的折叠;折叠不产生任何外部 effect。仲裁按 §5.1 的规则:transition log 完整(maxRevision >= watermark 且每条 transition digest 正确)时日志为准,snapshot 分歧或缺失自动重建并记录重建事件;日志尾部低于水位或尾部 transition 不完整时 halt。事件流内部的 RunID 不匹配、SchemaVersion/Type 不支持、同一 transition 的 CommandID/CommandDigest 不一致、Revision/Index 缺洞、fact digest 或 transition digest 不匹配同样按日志损坏处理,halt 该 Run。 Replay 的起点是 admission 已建立的初始 `MachineState`;`RunSeed` 的 admission 记录不作为任何 Run 的 AgentEvent 重放。需要重建 admission 链时,由 Memoh 的 session/queue 记录负责。 @@ -1524,7 +1542,7 @@ Memoh -> agent.Loop + MemohRuntime 1. 实现 `MachineState`、ModelStep、ToolStep、ToolCall 状态和 Decide/Evolve/Next 规则。 2. 实现 `EvaluateCommit`、`Runtime.Load/Commit`、command/fact digest 与幂等和 opaque grant。 3. 实现 Loop 的 model/tool/approval/response 路径和并行执行策略。 -4. 完成 MemoryRuntime conformance 测试(`agent/runtimetest`),提供 CommandEnvelope/AgentEvent wire codec,严格 replay 校验,并冻结 SchemaVersion 1 的 golden event stream。 +4. 完成 MemoryRuntime conformance 测试(`agent/runtimetest`),提供 CommandEnvelope/AgentEvent/TransitionRecord wire codec,严格 replay 校验,并冻结 SchemaVersion 1 的 golden transition stream。 ### 阶段 C:Memoh storage groundwork @@ -1605,8 +1623,8 @@ RunStopped 与 worker cancellation 区分 MachineState.Usage 逐字段累计 ModelStepCompleted 与 ModelStepRejected;terminal 时复制到 RunResult.Usage Decide 拒绝时不产出部分事实;接受时事实组与 MachineState 原子提交 Evolve 不读 RunConfig、无 policy 分支;对 Decide 产出的全部事实全定义 -AgentEvent 按 RunID/(Revision, Index) 可 replay,重复提交不产生第二组 -replay 只经 Evolve 折叠,不重新运行 Decide;golden event stream 折叠出冻结的状态字节 +TransitionRecord 按 RunID/Revision 可 replay,内部 AgentEvent 按 (Revision, Index) 保持事件流顺序,重复提交不产生第二组 +replay 只经 EvolveVersion 折叠,不重新运行 Decide;golden transition stream 折叠出冻结的状态字节 EventSink provisional/committed 发射点 并行 EventSink 事件包含 CallID,Waiting result 可路由到目标 Call Streaming=true 但 invoker 不支持 streaming -> Generate fallback @@ -1640,11 +1658,11 @@ RunStopped/RunFailed 保留最近已接受的 ModelResult Cancel 与 Unknown 的提交先后决定终态 CancelRun 在过期 BaseRevision 上对非 terminal Run 重新评估 RejectModelResult 必须带有效 Model grant;AlreadyApplied 重放不重复累计 usage -持久化状态与按 Evolve 折叠的事件流一致;snapshot 分歧或缺失且日志完整 -> 自动重建并报告,重建不改变健康状态 -日志尾部低于 revision 水位 -> ErrLogTruncated,halt 该 Run -事件流内部 Revision/Index 缺洞或事实 digest 不匹配 -> fold 拒绝,按日志损坏处理 +持久化状态与按 EvolveVersion 折叠的 TransitionRecord log 一致;snapshot 分歧或缺失且日志完整 -> 自动重建并报告,重建不改变健康状态 +transition log 尾部低于 revision 水位或尾部 transition 不完整 -> ErrLogTruncated/log damage,halt 该 Run +TransitionRecord 内部 Revision/Index 缺洞、事实 digest 或 transition digest 不匹配 -> fold 拒绝,按日志损坏处理 ModelStepPrepared/ToolStepOpened 自包含:携带的 digest 折叠后可重现 Step 身份 -golden event stream:固定 v1 命令序列折叠出冻结的状态字节 +golden transition stream:固定 v1 命令序列折叠出冻结的状态字节 ``` ### 14.4 Memoh integration @@ -1652,7 +1670,7 @@ golden event stream:固定 v1 命令序列折叠出冻结的状态字节 ```text queue FIFO、accepted-order reorder、typed ID isolation assigned follow-up 只由正确的 R1 admission claim -canonical history、AgentEvent 与 MachineState 同事务 +canonical history、TransitionRecord 与 MachineState 同事务 assistant tool-call 和 tool result 只写一次 多 response rows 与逐次 wake/idempotency lease expiry/recovery/unknown outcome @@ -1684,9 +1702,9 @@ Memoh queue/session/admission 的语义保持在 Memoh;现有 NativeAgentLoop 2. breaking release 版本和 Memoh protocol upgrade window。 3. EventSink payload schema,以及是否需要在 Memoh outbox 中加入跨进程 execution epoch。 -本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;agent `ModelRequest` 冻结完整的 generation options,streaming 只是 `ModelInvoker` 的可选执行路径,不改变 AgentCommand/AgentEvent 语义。Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、与事件编码同属永久兼容契约;AgentEvent log 为 source of truth,MachineState 为必需的同事务 projection,分歧仲裁按 §5.1(日志完整则自动重建,日志尾部低于水位则 halt)。结构性 malformed 的模型结果通过 `RejectModelResult` 在同一冻结 request 上有限重试;usage 在 MachineState 内逐字段累计;steer 由 MemohRuntime 的 Prepare gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 +本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;agent `ModelRequest` 冻结完整的 generation options,streaming 只是 `ModelInvoker` 的可选执行路径,不改变 AgentCommand/AgentEvent 语义。Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、与事件编码同属永久兼容契约;initial MachineState + TransitionRecord log 为 source of truth,MachineState 为必需的同事务 projection,分歧仲裁按 §5.1(transition log 完整则自动重建,日志尾部低于水位或 transition 不完整则 halt)。结构性 malformed 的模型结果通过 `RejectModelResult` 在同一冻结 request 上有限重试;usage 在 MachineState 内逐字段累计;steer 由 MemohRuntime 的 Prepare gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 -本规范采用 event log 为 source of truth(§5.1):三个稳定条件(ontology 冻结、Evolve 冻结、事实自包含)由 Decide/Evolve 拆分保障,revision 水位保护日志尾部完整性。MachineState 保持为必需的同事务 projection——提交验证要求当前状态在临界区内可得,这与日志权威并不冲突。 +本规范采用 initial MachineState + TransitionRecord log 为 source of truth(§5.1):三个稳定条件(ontology 冻结、versioned Evolve 冻结、事实自包含)由 Decide/Evolve 拆分保障,revision 水位保护 transition log 尾部完整性,TransitionDigest 保护单个 transition 内部的完整事件组。MachineState 保持为必需的同事务 projection——提交验证要求当前状态在临界区内可得,这与日志权威并不冲突。 ## 附录 A:最小 public API 草案 @@ -2211,6 +2229,16 @@ type AgentEvent struct { Fact Fact } +type TransitionRecord struct { + SchemaVersion uint16 + RunID RunID + Revision uint64 + CommandID CommandID + CommandDigest Digest + Events []AgentEvent + TransitionDigest Digest +} + type CommitRequest struct { BaseRevision uint64 Grant ExecutionGrant @@ -2242,16 +2270,17 @@ const ( ) type CommitDecision struct { - Kind DecisionKind - NewState MachineState - Events []AgentEvent + Kind DecisionKind + NewState MachineState + Events []AgentEvent + Transition TransitionRecord } // Shared, pure commit evaluation. Both runtimes call this single // implementation inside their own critical section / transaction. func EvaluateCommit( cur MachineState, curRevision uint64, - prior []AgentEvent, + prior *TransitionRecord, req CommitRequest, grantValid bool, recoveryValid bool, @@ -2261,6 +2290,10 @@ func EncodeCommand(CommandEnvelope) ([]byte, error) // 不包含 Digest 字段 func DigestCommand(schemaVersion uint16, typ string, command AgentCommand) (Digest, error) func EncodeFact(schemaVersion uint16, typ string, fact Fact) ([]byte, error) func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) +func BuildTransitionRecord([]AgentEvent) (TransitionRecord, error) +func ValidateTransitionRecord(*TransitionRecord) error +func DigestTransitionRecord(*TransitionRecord) (Digest, error) +func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineState, uint64, error) func EncodeRunSeed(RunSeed) ([]byte, error) func DigestRunSeed(schemaVersion uint16, seed RunSeed) (Digest, error) func DigestRequest(ModelRequest) (Digest, error) @@ -2403,10 +2436,10 @@ func (l *Loop) Run(context.Context, Runtime, EventSink) (LoopResult, error) 8. Waiting response 只推进对应 Call;approval approved 先变 Pending,随后由 Loop 执行工具。日志记录结果事实(`ToolCallFailed{permission_denied}`、`RunEnded{cancelled}`),不记录请求本身。 9. 幂等按 command 判定:相同 CommandID/digest 重放返回 CommitAlreadyApplied 与原事件组(不重新运行 Decide),不重复写入 projection、history、queue action 或 outbox;相同 CommandID 不同 digest 冲突。 10. 一次接受的 transition 使 Revision 恰好加一;其全部事实共享该 Revision,Index 组内连续,提交后 `Snapshot.Revision` 等于该 Revision。 -11. AgentEvent log 是 source of truth;MachineState 是必需的同事务 projection,可按 `Evolve` 从日志重建。对任意 Revision,状态必须等于初始状态经 `Evolve` 折叠事件流的结果;snapshot 分歧或缺失且日志完整时自动重建并记录,日志尾部低于 revision 水位时 halt 该 Run。 -12. Evolve 的折叠语义与事件编码同属永久兼容契约,按 SchemaVersion 冻结;Decide 的决策规则可随版本演进,因为决策结果已记录为事实。 +11. Admission-created initial MachineState + TransitionRecord log 是 source of truth;MachineState 是必需的同事务 projection,可按 `EvolveVersion` 从 transition log 重建。对任意 Revision,状态必须等于初始状态经 `flatten(TransitionRecord[].Events)` 折叠的结果;snapshot 分歧或缺失且日志完整时自动重建并记录,日志尾部低于 revision 水位或 transition digest/事件组不完整时 halt 该 Run。 +12. Evolve 的折叠语义与事件编码同属永久兼容契约,按 SchemaVersion 冻结;Replay 通过 `EvolveVersion` 选择历史语义;Decide 的决策规则可随版本演进,因为决策结果已记录为事实。 13. 已知工具失败交给下一次模型请求;Unknown 终止 Run,不自动重试、不查询外部系统。 14. worker cancellation 不等于 RunStopped;业务停止必须提交控制 command。宿主的业务停止先提交 `CancelRun`,再取消 Loop 的 ctx;ctx 取消本身只结束执行尝试,工具 worker 运行到自身结束。 -15. EventSink 只是实时观察;AgentEvent、durable snapshot 和 outbox 才是 replay/recovery 依据。 +15. EventSink 只是实时观察;TransitionRecord、durable snapshot 和 outbox 才是 replay/recovery 依据,AgentEvent 是 transition 内部和观察出口的事实流视图。 16. MemoryRuntime 用进程内同步;MemohRuntime 用事务、CAS 和内部 Attempt/owner/fence/lease;两者共享 `EvaluateCommit` 与 Machine 规则,但不共享存储实现。 17. 结构性 malformed 的模型结果以 `RejectModelResult` 回到 Prepared,在同一冻结 request 上重试并累计 usage;超过 `RunConfig.ModelRejectLimit` 才 RunFailed。单个 Call 的参数解析失败不是 malformed,按已知 `invalid_arguments` 进入下一次模型请求。 From 6c2754cffdd4bba04aa7a252ded5f47292c5ec51 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 00:30:16 +0800 Subject: [PATCH 11/81] refactor(agent): keep legacy run config out of core state --- agent/alias_test.go | 5 +- agent/codec.go | 2 + agent/codec_test.go | 1 + agent/command.go | 54 ++++++++--- agent/commit.go | 2 +- agent/contract.go | 16 +++- agent/decide.go | 43 +++++---- agent/loop.go | 51 ++++++++-- agent/loop_test.go | 62 +++++++++++- agent/machine_test.go | 71 +++++++------- agent/memory_runtime.go | 2 +- agent/next.go | 2 - agent/rebuild.go | 2 +- agent/rebuild_test.go | 2 +- agent/runtime_helpers_test.go | 9 +- agent/runtimetest/conformance.go | 24 ++--- agent/state.go | 62 +++++++----- docs/design/agent-runtime-refactor.md | 132 ++++++++++++++------------ 18 files changed, 353 insertions(+), 189 deletions(-) diff --git a/agent/alias_test.go b/agent/alias_test.go index a8952a7..5366216 100644 --- a/agent/alias_test.go +++ b/agent/alias_test.go @@ -101,7 +101,8 @@ func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { if err != nil { t.Fatal(err) } - binding, err := DigestModelStepBinding(snap.State.Config.Model, reqDigest, toolsDigest) + model := ModelRef(req.Model) + binding, err := DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -112,7 +113,7 @@ func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { } res := mustCommit(t, rt, cmdID, snap.Revision, "", PrepareModelRequest{ StepID: DeriveModelStepID(snap.State.RunID, cmdID, binding), - Model: snap.State.Config.Model, + Model: model, Request: req, RequestDigest: reqDigest, InputIDs: ids, diff --git a/agent/codec.go b/agent/codec.go index 9bc6efa..f873d12 100644 --- a/agent/codec.go +++ b/agent/codec.go @@ -337,6 +337,8 @@ func decodeCommandVariant(typ string, raw json.RawMessage) (AgentCommand, error) return decodeCommandAs[SubmitToolResponse](raw) case "cancel_run": return decodeCommandAs[CancelRun](raw) + case "stop_run": + return decodeCommandAs[StopRun](raw) case "accept_input": return decodeCommandAs[AcceptInput](raw) default: diff --git a/agent/codec_test.go b/agent/codec_test.go index 3c04dc2..7cecf28 100644 --- a/agent/codec_test.go +++ b/agent/codec_test.go @@ -23,6 +23,7 @@ func TestCommandEnvelopeJSONRoundTripRestoresVariants(t *testing.T) { 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{}, + StopRun{Reason: ReasonStepLimit}, AcceptInput{Input: AgentInput{ID: "in", Payload: cj(`{"q":"hi"}`)}}, } for _, cmd := range commands { diff --git a/agent/command.go b/agent/command.go index dd6df23..f26c2c4 100644 --- a/agent/command.go +++ b/agent/command.go @@ -2,7 +2,7 @@ package agent // AgentCommand is the intent submitted through Runtime.Commit for an existing // Run. Accepting one command constitutes one transition (spec §3.6). The -// interface is sealed: only the fourteen variants below exist. +// interface is sealed: only the fifteen variants below exist. type AgentCommand interface{ agentCommand() } // AgentInput is a queue-safe input: a stable ID plus an immutable payload. @@ -15,14 +15,19 @@ type AgentInput struct { // NextStep creates the command consumed by an active Run at a safe boundary. func NextStep(input AgentInput) AcceptInput { return AcceptInput{Input: input} } -// RunSeed is the admission seed for a new Run. It is not a command: it never -// goes through Runtime.Commit; application admission passes it to Initialize. +// RunSeed is the legacy admission seed for a new Run. It is not a command: it +// never goes through Runtime.Commit. New admission should use InitializeRun and +// submit initial input with AcceptInput. +// +// Deprecated: use InitializeRun and AcceptInput. type RunSeed struct { Input AgentInput `json:"input"` } -// NextRun creates the admission seed used by application admission. It does -// not allocate a RunID, claim a queue item, or mutate an existing Run. +// NextRun creates the legacy admission seed used by application admission. It +// does not allocate a RunID, claim a queue item, or mutate an existing Run. +// +// Deprecated: use InitializeRun and AcceptInput. func NextRun(input AgentInput) RunSeed { return RunSeed{Input: input} } // PrepareModelRequest freezes the next model request. Its CommandID is @@ -76,14 +81,26 @@ type SubmitModelFailure struct { 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 the step returns -// to Prepared until RunConfig.ModelRejectLimit is exceeded. Requires the -// model start grant. +// 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"` + StepID StepID `json:"stepId"` + Usage Usage `json:"usage"` + Failure StepFailure `json:"failure"` + Disposition ModelRejectDisposition `json:"disposition,omitempty"` } func (RejectModelResult) agentCommand() {} @@ -154,14 +171,23 @@ type SubmitToolResponse struct { func (SubmitToolResponse) agentCommand() {} -// CancelRun stops a non-terminal Run. Hosts must commit this before -// cancelling the Loop's context (spec §6.6). +// CancelRun stops a non-terminal Run as a business cancellation. Hosts must +// commit this before cancelling the Loop's context (spec §6.6). type CancelRun struct { Reason RunReason `json:"reason,omitempty"` } func (CancelRun) agentCommand() {} +// StopRun records a host-owned non-cancellation stop policy. It exists so +// compatibility wrappers can keep max-step behavior without storing limits in +// MachineState. +type StopRun struct { + Reason RunReason `json:"reason"` +} + +func (StopRun) agentCommand() {} + // AcceptInput appends one queue-safe input to PendingInputs. Idempotent per // (RunID, InputID) with identical payload. type AcceptInput struct { @@ -199,6 +225,8 @@ func commandType(c AgentCommand) string { return "submit_tool_response" case CancelRun: return "cancel_run" + case StopRun: + return "stop_run" case AcceptInput: return "accept_input" default: diff --git a/agent/commit.go b/agent/commit.go index f519af7..3b5dcae 100644 --- a/agent/commit.go +++ b/agent/commit.go @@ -93,7 +93,7 @@ func categorize(c AgentCommand) commandCategory { return catIngress // known failure on Pending uses empty grant; Executing path checks grant below case ApproveToolCall, RejectToolCall, SubmitToolResponse, AcceptInput: return catIngress - case CancelRun: + case CancelRun, StopRun: return catRunControl case RecoverModelExecution: return catRecovery diff --git a/agent/contract.go b/agent/contract.go index 4182c56..04e36b6 100644 --- a/agent/contract.go +++ b/agent/contract.go @@ -126,12 +126,20 @@ type Event struct { Canonical *AgentEvent } -// ExecutionPolicy bounds this Loop's tool-call parallelism. 1 is sequential; -// n>1 is bounded parallel; 0 normalizes to 1; negative is rejected at Loop -// construction. It limits only workers this Loop launches, never a global -// count (spec §4.3). +// ExecutionPolicy is host-owned loop policy. It is not persisted in +// MachineState or events. MaxParallel bounds only workers this Loop launches, +// never a global count (spec §4.3). type ExecutionPolicy struct { + // MaxParallel: 1 is sequential; n>1 is bounded parallel; 0 normalizes to 1; + // negative is rejected. MaxParallel int + // ModelStepLimit: 0 means unlimited; positive values make the Loop submit + // StopRun(step_limit) before planning another ModelStep. + ModelStepLimit int + // MalformedModelResultLimit: 0 normalizes to + // DefaultMalformedModelResultLimit; negative is rejected. The Loop chooses + // RejectModelResult disposition from the current ModelStep reject count. + MalformedModelResultLimit int } type LoopDisposition uint8 diff --git a/agent/decide.go b/agent/decide.go index b3b5054..3d10f0a 100644 --- a/agent/decide.go +++ b/agent/decide.go @@ -58,6 +58,8 @@ func Decide(s MachineState, c AgentCommand) ([]Fact, error) { return decideSubmitToolResponse(&s, &cmd) case CancelRun: return decideCancelRun(&s, cmd) + case StopRun: + return decideStopRun(&s, cmd) case AcceptInput: return decideAcceptInput(&s, cmd) default: @@ -74,17 +76,12 @@ func decidePrepareModelRequest(s *MachineState, cmd *PrepareModelRequest) ([]Fac if cmd.StepID == "" { return nil, rejectionf("prepare: empty StepID") } - if cmd.Model != s.Config.Model { - return nil, rejectionf("prepare: model %q does not match frozen RunConfig model %q", cmd.Model, s.Config.Model) + 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) } - if s.Config.ModelStepLimit > 0 && s.ModelSteps >= s.Config.ModelStepLimit { - // Unreachable when limits convert to terminal at the prior boundary, - // but the authority still refuses rather than over-running. - return nil, rejectionf("prepare: model-step limit reached") - } // 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)) @@ -341,15 +338,19 @@ func decideRejectModelResult(s *MachineState, cmd *RejectModelResult) ([]Fact, e if ms.Status != ModelExecuting { return nil, rejectionf("reject model result: step is not Executing") } - rejected := ModelStepRejected(*cmd) - if ms.Rejects+1 > s.Config.ModelRejectLimit { + 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{ Status: RunFailed, Reason: ReasonMalformedModel, Failure: &RunFailure{Class: FailureMalformedModel, Message: cmd.Failure.Message}, }}, nil + default: + return nil, rejectionf("reject model result: unknown disposition %d", cmd.Disposition) } - return []Fact{rejected}, nil } // --- rules 6-8: tool call lifecycle --- @@ -438,9 +439,8 @@ func decideSubmitToolFailure(s *MachineState, cmd SubmitToolFailure) ([]Fact, er } // appendCloseIfLast appends ToolStepClosed when call i reaching a closable -// terminal state closes the step, plus RunEnded when the frozen model-step -// limit was reached (spec §3.7.1 rule 11). -func appendCloseIfLast(s *MachineState, ts *ToolStep, i int, facts []Fact) ([]Fact, error) { +// terminal state closes the step. +func appendCloseIfLast(_ *MachineState, ts *ToolStep, i int, facts []Fact) ([]Fact, error) { for j := range ts.Calls { if j == i { continue @@ -453,9 +453,6 @@ func appendCloseIfLast(s *MachineState, ts *ToolStep, i int, facts []Fact) ([]Fa } } facts = append(facts, ToolStepClosed{StepID: ts.RefValue.ID}) - if s.Config.ModelStepLimit > 0 && s.ModelSteps >= s.Config.ModelStepLimit { - facts = append(facts, RunEnded{Status: RunStopped, Reason: ReasonStepLimit}) - } return facts, nil } @@ -550,18 +547,24 @@ func decideSubmitToolResponse(s *MachineState, cmd *SubmitToolResponse) ([]Fact, return appendCloseIfLast(s, ts, i, facts) } -// --- rules 12-13: cancel and input --- +// --- rules 12-14: cancel, stop and input --- func decideCancelRun(_ *MachineState, cmd CancelRun) ([]Fact, error) { - // Spec rule 12 fixes the mapping: CancelRun always records - // RunStopped(cancelled). A caller-chosen reason would let ingress forge - // step_limit or other system reasons into the log. + // CancelRun always records RunStopped(cancelled). Host/system stops use + // StopRun so cancellation cannot forge system reasons. if cmd.Reason != "" && cmd.Reason != ReasonCancelled { return nil, rejectionf("cancel: reason must be empty or %q", ReasonCancelled) } return []Fact{RunEnded{Status: RunStopped, Reason: ReasonCancelled}}, nil } +func decideStopRun(_ *MachineState, cmd StopRun) ([]Fact, error) { + if cmd.Reason != ReasonStepLimit { + return nil, rejectionf("stop: reason must be %q", ReasonStepLimit) + } + return []Fact{RunEnded{Status: RunStopped, Reason: cmd.Reason}}, nil +} + func decideAcceptInput(s *MachineState, cmd AcceptInput) ([]Fact, error) { if s.Current != nil { return nil, rejectionf("accept input: run has a current step") diff --git a/agent/loop.go b/agent/loop.go index df0f86a..a793d36 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -26,9 +26,18 @@ func NewLoop(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, pol if policy.MaxParallel < 0 { return nil, errors.New("agent: loop: negative MaxParallel") } + if policy.ModelStepLimit < 0 { + return nil, errors.New("agent: loop: negative ModelStepLimit") + } + if policy.MalformedModelResultLimit < 0 { + return nil, errors.New("agent: loop: negative MalformedModelResultLimit") + } if policy.MaxParallel == 0 { policy.MaxParallel = 1 } + if policy.MalformedModelResultLimit == 0 { + policy.MalformedModelResultLimit = DefaultMalformedModelResultLimit + } return &Loop{Models: models, Tools: tools, Planner: planner, Execution: policy, Streaming: streaming}, nil } @@ -66,6 +75,17 @@ func (l *Loop) Run(ctx context.Context, runtime Runtime, events EventSink) (Loop switch eff := effect.(type) { case NeedModelRequest: + if l.Execution.ModelStepLimit > 0 && snapshot.State.ModelSteps >= l.Execution.ModelStepLimit { + res, err := l.commit(controlCtx, runtime, snapshot.State.RunID, freshCommandID(), snapshot.Revision, "", StopRun{Reason: ReasonStepLimit}) + if err != nil { + if retriable(err) { + continue + } + return LoopResult{}, err + } + l.emitCommitted(controlCtx, events, snapshot.State.RunID, res.Events) + continue + } if err := l.planAndPrepare(ctx, controlCtx, runtime, &snapshot, eff.Hint); err != nil { return LoopResult{}, err } @@ -131,15 +151,19 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, if err != nil { return err } - if plan.Model != hint.Model { - return fmt.Errorf("agent: loop: plan model %q does not match hint model %q", plan.Model, hint.Model) - } frozenRequest, err := FreezeModelRequest(plan.Request) if err != nil { return err } - if ModelRef(frozenRequest.Model) != plan.Model { - return fmt.Errorf("agent: loop: request model %q does not match plan model %q", frozenRequest.Model, plan.Model) + model := plan.Model + if model == "" { + model = ModelRef(frozenRequest.Model) + } + if model == "" { + return fmt.Errorf("agent: loop: empty model") + } + if ModelRef(frozenRequest.Model) != model { + return fmt.Errorf("agent: loop: request model %q does not match plan model %q", frozenRequest.Model, model) } requestDigest, err := DigestRequest(frozenRequest) if err != nil { @@ -149,7 +173,7 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, if err != nil { return err } - binding, err := DigestModelStepBinding(plan.Model, requestDigest, toolsDigest) + binding, err := DigestModelStepBinding(model, requestDigest, toolsDigest) if err != nil { return err } @@ -157,7 +181,7 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, stepID := DeriveModelStepID(snapshot.State.RunID, cmdID, binding) _, err = l.commit(controlCtx, runtime, snapshot.State.RunID, cmdID, snapshot.Revision, "", PrepareModelRequest{ StepID: stepID, - Model: plan.Model, + Model: model, Request: frozenRequest, RequestDigest: requestDigest, InputIDs: plan.InputIDs, @@ -226,10 +250,12 @@ func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime Runtime, ev bindings, bindErr := l.bindToolCalls(&result, &modelStep) if bindErr != nil { completion = RejectModelResult{StepID: stepID, Usage: UsageFromSDK(result.Usage), - Failure: StepFailure{Class: FailureMalformedModel, Message: bindErr.Error()}} + Failure: StepFailure{Class: FailureMalformedModel, Message: bindErr.Error()}, + Disposition: l.modelRejectDisposition(modelStep.Rejects)} } else if frozenResult, freezeErr := FreezeModelResult(result); freezeErr != nil { completion = RejectModelResult{StepID: stepID, Usage: UsageFromSDK(result.Usage), - Failure: StepFailure{Class: FailureMalformedModel, Message: freezeErr.Error()}} + Failure: StepFailure{Class: FailureMalformedModel, Message: freezeErr.Error()}, + Disposition: l.modelRejectDisposition(modelStep.Rejects)} } else { completion = SubmitModelResult{StepID: stepID, Result: frozenResult, Calls: bindings} } @@ -248,6 +274,13 @@ func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime Runtime, ev return nil } +func (l *Loop) modelRejectDisposition(priorRejects int) ModelRejectDisposition { + if priorRejects+1 > l.Execution.MalformedModelResultLimit { + return ModelRejectFailRun + } + return ModelRejectRetry +} + func (l *Loop) invokeModel(ctx context.Context, invoker ModelInvoker, req *sdk.Request, run RunID, step StepID, events EventSink) (sdk.ModelResult, error) { if l.Streaming { if streamer, ok := invoker.(StreamingModelInvoker); ok { diff --git a/agent/loop_test.go b/agent/loop_test.go index 34e577c..f7955d9 100644 --- a/agent/loop_test.go +++ b/agent/loop_test.go @@ -65,11 +65,16 @@ func (c fakeToolCatalog) Resolve(ref ToolRef) (ExecutableTool, error) { // 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) { - req := sdk.Request{Model: string(hint.Model), Messages: []sdk.Message{sdk.UserMessage("go")}} + 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, s.Definition.SDK()) } @@ -77,7 +82,7 @@ func (p staticPlanner) Plan(_ context.Context, hint PlanningHint) (RequestPlan, for i, in := range hint.Inputs { ids[i] = in.ID } - return RequestPlan{Model: hint.Model, Request: req, InputIDs: ids, Tools: p.specs}, nil + return RequestPlan{Model: model, Request: req, InputIDs: ids, Tools: p.specs}, nil } func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { @@ -375,6 +380,59 @@ func TestLoopCtxCancelReturnsWithoutFailingRun(t *testing.T) { } } +func TestLoopModelStepLimitStopsBeforePlanning(t *testing.T) { + s, err := InitializeRun("run-1") + if err != nil { + t.Fatal(err) + } + s.ModelSteps = 1 + rt := NewMemoryRuntime(s) + loop, err := NewLoop(fakeCatalog{}, fakeToolCatalog{}, panicPlanner{}, ExecutionPolicy{ModelStepLimit: 1}, false) + if err != nil { + t.Fatal(err) + } + res, err := loop.Run(context.Background(), rt, nil) + if err != nil { + t.Fatal(err) + } + if res.Disposition != LoopFinished || res.Result == nil || res.Result.Reason != ReasonStepLimit { + t.Fatalf("loop result = %+v", res) + } +} + +func TestLoopMalformedModelResultLimitFailsRun(t *testing.T) { + rt := loopRuntime(t) + bad := sdk.ModelResult{ + FinishReason: sdk.FinishReasonToolCalls, + ToolCalls: []sdk.ToolCall{ + {ToolCallID: "dup", ToolName: "echo", Input: `{"x":1}`}, + {ToolCallID: "dup", ToolName: "echo", Input: `{"x":2}`}, + }, + Usage: sdk.Usage{TotalTokens: 1}, + } + invoker := &fakeInvoker{results: []sdk.ModelResult{bad, bad, bad}} + loop, err := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{MalformedModelResultLimit: 2}, false) + if err != nil { + t.Fatal(err) + } + res, err := loop.Run(context.Background(), rt, 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") +} + func TestLoopCancelRunViaCommand(t *testing.T) { // Host order: commit CancelRun first, then cancel ctx (spec §6.6). rt := loopRuntime(t) diff --git a/agent/machine_test.go b/agent/machine_test.go index cd6e59a..07f2c6b 100644 --- a/agent/machine_test.go +++ b/agent/machine_test.go @@ -10,19 +10,21 @@ import ( // --- helpers --- +const testModel ModelRef = "m-1" + func testConfig() RunConfig { - return RunConfig{Model: "m-1", ModelRejectLimit: DefaultModelRejectLimit} + return RunConfig{Model: testModel, ModelRejectLimit: DefaultModelRejectLimit} } func cj(raw string) CanonicalJSON { return MustParseCanonicalJSON(raw) } -func newRun(t *testing.T, cfg RunConfig) MachineState { +func newRun(t *testing.T, _ RunConfig) MachineState { t.Helper() - s, err := Initialize("run-1", cfg, NextRun(AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)})) + s, err := InitializeRun("run-1") if err != nil { t.Fatal(err) } - return s + 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 { @@ -72,7 +74,8 @@ func buildPrepare(t *testing.T, s MachineState, req sdk.Request, specs []ToolSpe if err != nil { t.Fatal(err) } - binding, err := DigestModelStepBinding(s.Config.Model, reqDigest, toolsDigest) + model := ModelRef(frozenReq.Model) + binding, err := DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -84,7 +87,7 @@ func buildPrepare(t *testing.T, s MachineState, req sdk.Request, specs []ToolSpe } return PrepareModelRequest{ StepID: stepID, - Model: s.Config.Model, + Model: model, Request: frozenReq, RequestDigest: reqDigest, InputIDs: ids, @@ -174,13 +177,20 @@ func advanceToExecuting(t *testing.T, s MachineState, req sdk.Request, specs []T // --- tests --- -func TestInitializeNormalizesRejectLimit(t *testing.T) { - s, err := Initialize("r", RunConfig{Model: "m"}, NextRun(AgentInput{ID: "i"})) +func TestInitializeRunIsMinimalAndLegacyConfigIsNotState(t *testing.T) { + s, err := InitializeRun("r") + if err != nil { + t.Fatal(err) + } + if s.RunID != "r" || s.Status != RunActive || len(s.PendingInputs) != 0 { + t.Fatalf("initial state = %+v", s) + } + legacy, err := Initialize("r", RunConfig{Model: "m", ModelStepLimit: 1, ModelRejectLimit: 2}, NextRun(AgentInput{ID: "i"})) if err != nil { t.Fatal(err) } - if s.Config.ModelRejectLimit != DefaultModelRejectLimit { - t.Fatalf("reject limit = %d, want %d", s.Config.ModelRejectLimit, DefaultModelRejectLimit) + if legacy.RunID != "r" || len(legacy.PendingInputs) != 1 { + t.Fatalf("legacy initial state = %+v", legacy) } if _, err := Initialize("r", RunConfig{Model: "m", ModelStepLimit: -1}, NextRun(AgentInput{ID: "i"})); err == nil { t.Fatal("negative step limit accepted") @@ -468,9 +478,8 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { } } -func TestRejectModelResultRetriesThenFails(t *testing.T) { - cfg := testConfig() // reject limit 2 - s := newRun(t, cfg) +func TestRejectModelResultDispositionRetriesThenFails(t *testing.T) { + s := newRun(t, testConfig()) s, stepID := advanceToExecuting(t, s, testRequest(), nil) usage := Usage{TotalTokens: 3} @@ -487,16 +496,16 @@ func TestRejectModelResultRetriesThenFails(t *testing.T) { t.Fatal("usage not accumulated on reject") } - // Start again, reject 2: still within limit. + // 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 exceeds limit 2: run fails. + // 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}}) + 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)) } @@ -525,27 +534,19 @@ func TestModelRecoveryKeepsFrozenRequestAndCounts(t *testing.T) { } } -func TestStepLimitEndsRunAtToolStepClose(t *testing.T) { - cfg := testConfig() - cfg.ModelStepLimit = 1 - def := testToolDef("t") - spec := makeSpec(t, def, DirectExecution) - s := newRun(t, cfg) - s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) - b := makeBinding(t, "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: "c1"})) - - facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: "c1", Result: ToolExecutionResult{Output: cj(`"ok"`)}}) - if len(facts) != 3 { - t.Fatalf("facts = %d, want [completed, closed, ended]", len(facts)) +func TestStopRunProducesStepLimit(t *testing.T) { + s := newRun(t, testConfig()) + facts := mustDecide(t, s, StopRun{Reason: ReasonStepLimit}) + if len(facts) != 1 { + t.Fatalf("facts = %d, want [ended]", len(facts)) } - ended := facts[2].(RunEnded) + ended := facts[0].(RunEnded) if ended.Status != RunStopped || ended.Reason != ReasonStepLimit { t.Fatalf("ended = %+v", ended) } + if _, err := Decide(s, StopRun{Reason: ReasonCancelled}); err == nil { + t.Fatal("StopRun accepted cancellation reason") + } } func TestCancelProducesRunStopped(t *testing.T) { @@ -596,8 +597,8 @@ func TestEvolveRejectsModelPrepareOverCurrentStep(t *testing.T) { s, _ = advanceToExecuting(t, s, testRequest(), nil) _, err := Evolve(s, ModelStepPrepared{ StepID: "other", - Model: s.Config.Model, - Request: ModelRequest{Model: string(s.Config.Model)}, + Model: testModel, + Request: ModelRequest{Model: string(testModel)}, RequestDigest: "sha256:req", ToolsDigest: "sha256:tools", BindingDigest: "sha256:binding", diff --git a/agent/memory_runtime.go b/agent/memory_runtime.go index c30f6a2..3f010e4 100644 --- a/agent/memory_runtime.go +++ b/agent/memory_runtime.go @@ -31,7 +31,7 @@ type MemoryRuntime struct { grants map[string]ExecutionGrant } -// NewMemoryRuntime starts from an Initialize-produced state at Revision 0. +// NewMemoryRuntime starts from an InitializeRun/Initialize-produced state at Revision 0. // //nolint:gocritic // hugeParam: constructor takes a value snapshot and clones it into runtime authority storage. func NewMemoryRuntime(initial MachineState) *MemoryRuntime { diff --git a/agent/next.go b/agent/next.go index ffb1c41..5b59cf7 100644 --- a/agent/next.go +++ b/agent/next.go @@ -37,7 +37,6 @@ func (WaitForExecutionRecovery) effect() {} // PlanningHint is what the Loop hands the application RequestPlanner. type PlanningHint struct { RunID RunID - Model ModelRef SourceStep StepID Inputs []AgentInput } @@ -54,7 +53,6 @@ func Next(s MachineState) (Effect, error) { case nil: return NeedModelRequest{Hint: PlanningHint{ RunID: s.RunID, - Model: s.Config.Model, SourceStep: s.LastClosedStep, Inputs: append([]AgentInput(nil), s.PendingInputs...), }}, nil diff --git a/agent/rebuild.go b/agent/rebuild.go index ed98864..5771d75 100644 --- a/agent/rebuild.go +++ b/agent/rebuild.go @@ -153,7 +153,7 @@ func statesEquivalent(a, b *MachineState) bool { // step, which encoding/json cannot round-trip on its own. func stateComparable(s *MachineState) map[string]any { m := map[string]any{ - "runId": s.RunID, "status": s.Status, "config": s.Config, + "runId": s.RunID, "status": s.Status, "modelSteps": s.ModelSteps, "lastClosedStep": s.LastClosedStep, "usage": s.Usage, "pendingInputs": s.PendingInputs, "lastModelResult": s.LastModelResult, "result": s.Result, diff --git a/agent/rebuild_test.go b/agent/rebuild_test.go index e1252a1..71e112d 100644 --- a/agent/rebuild_test.go +++ b/agent/rebuild_test.go @@ -282,7 +282,7 @@ func TestGoldenEventStreamV1(t *testing.T) { t.Fatal(err) } got := string(sha256Digest(stateBytes)) - const frozen = "sha256:004694c1e4ffc3d5ba55bbc833b4cc912943959115836cdc29e1620d063afccb" + const frozen = "sha256:7b95310e5132ee6490526c15804e2cb22efe14cd60e9f9320b1c14ecbead687a" if got != frozen { t.Fatalf("golden v1 state digest changed:\n got %s\nwant %s\nstate: %s", got, frozen, stateBytes) } diff --git a/agent/runtime_helpers_test.go b/agent/runtime_helpers_test.go index 85b4af4..9990942 100644 --- a/agent/runtime_helpers_test.go +++ b/agent/runtime_helpers_test.go @@ -67,7 +67,8 @@ func buildPrepareFromSnap(t *testing.T, snap RuntimeSnapshot, req sdk.Request, s if err != nil { t.Fatal(err) } - binding, err := DigestModelStepBinding(snap.State.Config.Model, reqDigest, toolsDigest) + model := ModelRef(frozenReq.Model) + binding, err := DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -78,7 +79,7 @@ func buildPrepareFromSnap(t *testing.T, snap RuntimeSnapshot, req sdk.Request, s ids[i] = in.ID } return PrepareModelRequest{ - StepID: stepID, Model: snap.State.Config.Model, Request: frozenReq, + StepID: stepID, Model: model, Request: frozenReq, RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, }, cmdID } @@ -102,6 +103,6 @@ func TestLoopPrepareRejectionDoesNotLivelock(t *testing.T) { // badPlanner never consumes pending inputs, so its Prepare is always rejected. type badPlanner struct{} -func (badPlanner) Plan(_ context.Context, hint PlanningHint) (RequestPlan, error) { - return RequestPlan{Model: hint.Model, Request: sdk.Request{Model: string(hint.Model)}}, nil +func (badPlanner) Plan(_ context.Context, _ PlanningHint) (RequestPlan, error) { + return RequestPlan{Model: testModel, Request: sdk.Request{Model: string(testModel)}}, nil } diff --git a/agent/runtimetest/conformance.go b/agent/runtimetest/conformance.go index 280c1df..3ef6ef2 100644 --- a/agent/runtimetest/conformance.go +++ b/agent/runtimetest/conformance.go @@ -41,12 +41,13 @@ type runtimeCase struct { events []agent.AgentEvent } -func newCase(t testing.TB, newRuntime Factory, cfg agent.RunConfig) *runtimeCase { +func newCase(t testing.TB, newRuntime Factory) *runtimeCase { t.Helper() - initial, err := agent.Initialize("run-1", cfg, agent.NextRun(agent.AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)})) + initial, err := agent.InitializeRun("run-1") if err != nil { t.Fatal(err) } + initial.PendingInputs = []agent.AgentInput{{ID: "seed", Payload: cj(`{"q":"hi"}`)}} return &runtimeCase{t: t, runID: initial.RunID, initial: initial, rt: newRuntime(t, initial)} } @@ -83,7 +84,7 @@ func (c *runtimeCase) mustCommit(id agent.CommandID, base uint64, grant agent.Ex func preparedCase(t testing.TB, newRuntime Factory, tools []sdk.ToolDefinition, specs []agent.ToolSpec) (*runtimeCase, agent.StepID, agent.ExecutionGrant) { t.Helper() - c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1", ModelRejectLimit: 2}) + c := newCase(t, newRuntime) snap := c.load() req := testRequest(tools...) prep, cmdID := buildPrepareFromSnap(t, &snap, &req, specs) @@ -109,7 +110,8 @@ func buildPrepareFromSnap(t testing.TB, snap *agent.RuntimeSnapshot, req *sdk.Re if err != nil { t.Fatal(err) } - binding, err := agent.DigestModelStepBinding(snap.State.Config.Model, reqDigest, toolsDigest) + model := agent.ModelRef(frozenReq.Model) + binding, err := agent.DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -120,7 +122,7 @@ func buildPrepareFromSnap(t testing.TB, snap *agent.RuntimeSnapshot, req *sdk.Re ids[i] = in.ID } return agent.PrepareModelRequest{ - StepID: stepID, Model: snap.State.Config.Model, Request: frozenReq, + StepID: stepID, Model: model, Request: frozenReq, RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, }, cmdID } @@ -197,7 +199,7 @@ func modelResultWithCalls(callIDs ...string) agent.ModelResult { } func testIdempotentReplay(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1"}) + c := newCase(t, newRuntime) res1 := c.mustCommit("cancel-1", 0, "", agent.CancelRun{}) if res1.Status != agent.CommitAccepted || len(res1.Events) != 1 { t.Fatalf("res1 = %+v", res1) @@ -305,7 +307,7 @@ func testCallLocalRebase(t *testing.T, newRuntime Factory) { } func testPrepareDerivedIdentity(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1"}) + c := newCase(t, newRuntime) snap := c.load() req := testRequest() prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) @@ -323,7 +325,7 @@ func testPrepareDerivedIdentity(t *testing.T, newRuntime Factory) { } func testPrepareIsHardCAS(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1"}) + c := newCase(t, newRuntime) snap := c.load() req := testRequest() prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) @@ -402,7 +404,7 @@ func testReplayFoldMatchesState(t *testing.T, newRuntime Factory) { } func testAcceptInputByInputID(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1"}) + c := newCase(t, newRuntime) in := agent.AgentInput{ID: "in-9", Payload: cj(`{"t":"x"}`)} id := agent.DeriveInputCommandID("run-1", in.ID) res1 := c.mustCommit(id, 0, "", agent.NextStep(in)) @@ -420,7 +422,7 @@ func testAcceptInputByInputID(t *testing.T, newRuntime Factory) { } func testDerivedCommandIDEnforced(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime, agent.RunConfig{Model: "m-1"}) + c := newCase(t, newRuntime) _, err := c.commit("random-id", 0, "", agent.NextStep(agent.AgentInput{ID: "in-1", Payload: cj(`1`)})) if err == nil { t.Fatal("AcceptInput with non-derived CommandID accepted") @@ -433,7 +435,7 @@ func testDerivedCommandIDEnforced(t *testing.T, newRuntime Factory) { func stateComparable(s *agent.MachineState) map[string]any { m := map[string]any{ - "runId": s.RunID, "status": s.Status, "config": s.Config, + "runId": s.RunID, "status": s.Status, "modelSteps": s.ModelSteps, "lastClosedStep": s.LastClosedStep, "usage": s.Usage, "pendingInputs": s.PendingInputs, "lastModelResult": s.LastModelResult, "result": s.Result, diff --git a/agent/state.go b/agent/state.go index f179bb7..2f8df88 100644 --- a/agent/state.go +++ b/agent/state.go @@ -40,21 +40,31 @@ type RunResult struct { Usage Usage `json:"usage"` } -// RunConfig is frozen at Run creation. +// RunConfig is retained only for the deprecated Initialize compatibility +// helper. It is not stored in MachineState, AgentEvent, or TransitionRecord. +// New code should use InitializeRun and keep fixed-model / limit policy in the +// host or Loop ExecutionPolicy. +// +// Deprecated: use InitializeRun and submit seed input with AcceptInput. type RunConfig struct { Model ModelRef `json:"model"` - // ModelStepLimit: zero means unlimited; a positive value caps ModelSteps. - // Negative values are invalid. + // ModelStepLimit is a legacy fixed-run policy. New code should use + // ExecutionPolicy.ModelStepLimit or host-owned policy. ModelStepLimit int `json:"modelStepLimit,omitempty"` - // ModelRejectLimit: max accepted RejectModelResult per ModelStep before - // the Run fails. Zero is normalized to DefaultModelRejectLimit by - // Initialize; negative values are invalid. There is no unlimited value. + // ModelRejectLimit is a legacy name for malformed result retry policy. New + // code should use ExecutionPolicy.MalformedModelResultLimit. ModelRejectLimit int `json:"modelRejectLimit,omitempty"` } -// DefaultModelRejectLimit is the Initialize-time normalization of -// RunConfig.ModelRejectLimit == 0. -const DefaultModelRejectLimit = 2 +// DefaultMalformedModelResultLimit is the Loop default for structurally +// malformed model results before the run fails. +const DefaultMalformedModelResultLimit = 2 + +// DefaultModelRejectLimit is retained for source compatibility with the old +// RunConfig field name. +// +// Deprecated: use DefaultMalformedModelResultLimit. +const DefaultModelRejectLimit = DefaultMalformedModelResultLimit type StepFailure struct { Class string `json:"class"` @@ -276,7 +286,6 @@ func (s *ToolStep) callIndex(id CallID) int { type MachineState struct { RunID RunID `json:"runId"` Status RunStatus `json:"status"` - Config RunConfig `json:"config"` Current Step `json:"-"` // serialized by adapters with their snapshot schema PendingInputs []AgentInput `json:"pendingInputs,omitempty"` ModelSteps int `json:"modelSteps"` @@ -288,13 +297,23 @@ type MachineState struct { Result *RunResult `json:"result,omitempty"` } -// Initialize builds the initial MachineState (Revision 0) for a new Run from -// an admission seed (spec §3.7.1 rule 14). RunSeed never goes through -// Runtime.Commit. -func Initialize(run RunID, cfg RunConfig, seed RunSeed) (MachineState, error) { +// 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) (MachineState, error) { if run == "" { return MachineState{}, errors.New("agent: initialize: empty RunID") } + return MachineState{RunID: run, Status: RunActive}, nil +} + +// Initialize builds a legacy initial MachineState with the admission seed +// already present in PendingInputs. RunConfig is validated for legacy callers +// but is intentionally not stored in MachineState. +// +// Deprecated: use InitializeRun and submit seed input with AcceptInput so the +// seed enters the transition log. +func Initialize(run RunID, cfg RunConfig, seed RunSeed) (MachineState, error) { if cfg.Model == "" { return MachineState{}, errors.New("agent: initialize: empty RunConfig.Model") } @@ -304,20 +323,17 @@ func Initialize(run RunID, cfg RunConfig, seed RunSeed) (MachineState, error) { if cfg.ModelRejectLimit < 0 { return MachineState{}, errors.New("agent: initialize: negative ModelRejectLimit") } - if cfg.ModelRejectLimit == 0 { - cfg.ModelRejectLimit = DefaultModelRejectLimit - } if seed.Input.ID == "" { return MachineState{}, errors.New("agent: initialize: seed input requires an InputID") } + s, err := InitializeRun(run) + if err != nil { + return MachineState{}, err + } input, err := snapshotJSONStable(seed.Input) if err != nil { return MachineState{}, err } - return MachineState{ - RunID: run, - Status: RunActive, - Config: cfg, - PendingInputs: []AgentInput{input}, - }, nil + s.PendingInputs = []AgentInput{input} + return s, nil } diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 61fea53..1f1ab1e 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -316,40 +316,39 @@ Machine.Evolve 逐个折叠(机械) -> new MachineState Machine.Next(state) -> Effect ``` -`Next(state)` 根据当前事实产生至多一个待执行的 `Effect`。`Decide(state, command)` 校验一个意图并产出这次 transition 的完整事实序列——所有决策(是否接受、派生哪些后果、是否进入终态)都发生在这里,且只在提交时运行一次,输出即冻结。`Evolve(state, event)` 把单个事实机械地折叠进状态:它不读 RunConfig,不含任何 policy 分支,对 Decide 产出的每种事实全定义;replay 只依赖 Evolve。Runtime 接受一个 command 后,把 Decide 的事实序列包装为同一 Revision 的 AgentEvent 组,与折叠后的新状态放在同一提交边界。两种 Runtime 不能各自复制规则;Loop 重新 `Load` 后再次调用 `Next`,不会依赖一次提交响应中的 effect。 +`Next(state)` 根据当前事实产生至多一个待执行的 `Effect`。`Decide(state, command)` 校验一个意图并产出这次 transition 的完整事实序列——所有决策(是否接受、派生哪些后果、是否进入终态)都发生在这里,且只在提交时运行一次,输出即冻结。`Evolve(state, event)` 把单个事实机械地折叠进状态:它不读 RunConfig/Loop policy,不含 IO,对 Decide 产出的每种事实全定义;replay 只依赖 versioned Evolve。Runtime 接受一个 command 后,把 Decide 的事实序列包装为同一 Revision 的 AgentEvent 组,与折叠后的新状态放在同一提交边界。两种 Runtime 不能各自复制规则;Loop 重新 `Load` 后再次调用 `Next`,不会依赖一次提交响应中的 effect。 -决策与折叠的分工是协议的兼容边界:Machine 的决策规则(limit 判断、自动关闭、终态转换)可以随版本演进,因为历史事件已把这些决策的结果记录在案;Evolve 与事件编码一起构成永久兼容契约,已发布 SchemaVersion 的折叠语义不再修改。 +决策与折叠的分工是协议的兼容边界:Machine 的决策规则(自动关闭、终态转换、command disposition)可以随版本演进,因为历史事件已把这些决策的结果记录在案;Evolve 与事件编码一起构成永久兼容契约,已发布 SchemaVersion 的折叠语义不再修改。 -Run terminal、model-step limit、Step successor、ToolStep 自动关闭和等待条件都由 Machine 的 Decide 决定,并显式产出对应事实(`ToolStepClosed`、`RunEnded`)。Runtime 不再维护另一套 Run 终态和等待判断;Memoh 只把已经接受的 AgentEvent 投影到自己的 history、queue 和 outbox,按事件驱动,不做提交前后的状态差分。 +Run terminal、Step successor、ToolStep 自动关闭和等待条件都由 Machine 的 Decide 决定,并显式产出对应事实(`ToolStepClosed`、`RunEnded`)。固定模型、max-step budget 和 malformed retry limit 是 host/Loop policy,不进入 MachineState;这些策略若要改变状态,必须提交显式 command(例如 `StopRun{step_limit}` 或带 fail-run disposition 的 `RejectModelResult`)。Runtime 不再维护另一套 Run 终态和等待判断;Memoh 只把已经接受的 AgentEvent 投影到自己的 history、queue 和 outbox,按事件驱动,不做提交前后的状态差分。 Machine 不知道 PostgreSQL、mutex、lease、fencing、provider client、queue 或产品 history。Runtime 可以在自己的临界区内调用这套规则,但不改变规则。 ### 3.2 输入语义 -Queue 不属于 Machine,但 Machine 需要知道“一个输入何时已经被接受,以及它对后续执行的影响”。因此 core 只定义两种输入边界: +Queue 不属于 Machine,但 Machine 需要知道“一个输入何时已经被接受,以及它对后续执行的影响”。因此 core 只定义 active Run 的输入提交边界;terminal follow-up 由 Memoh admission 创建新 Run 后再提交首个输入: ```text NextStep(input) 当前 Run 仍 active 且处于可接收边界;输入进入下一次 ModelStep 的规划上下文 -NextRun(input) - 当前 Run 已到 terminal;输入作为 continuation 的初始上下文,不修改旧 Run +terminal follow-up + Memoh admission -> InitializeRun(new RunID) -> AcceptInput(input) ``` `AgentInput` 只有稳定的 `InputID` 和不可变 payload,不包含 queue item、priority、order、claim 或 lease。`NextStep` 构造 `AcceptInput` command;被 Runtime 接受后产出 `InputAccepted` 事实,与新的 MachineState 一起提交,Memoh 可以在同一事务中把 queue claim 标记为 applied。 -`NextRun` 产生的是 admission seed(`RunSeed`),不是任何 Run 的 command:它不创建 RunID,也不负责 queue claim 或 session admission。Memoh 先完成 queue claim、session admission 和新 Run 的身份分配,再用该 seed 通过 `Initialize` 建立新 Run 的 MachineState。admission 可以复用 `AgentEvent` 的 identity/digest/canonical 编码规则,但其排序和记录归属由 Memoh admission 决定,不调用旧 Run 的 `Runtime.Commit`。 +`NextRun`/`RunSeed` 只保留为 legacy admission helper;新 core admission 使用 `InitializeRun(RunID)` 建立最小 Revision-0 state,然后把初始用户输入作为该 Run 的第一条 `AcceptInput` transition 提交。Memoh 仍负责 queue claim、session admission 和新 Run 的身份分配;admission 可以复用 digest/canonical 编码规则审计 seed,但普通 Run replay 不依赖重新计算 RunSeed。 -Machine 不在 ToolStep 执行中接受 `NextStep`,也不把 `NextRun` 解释成旧 Run 的状态变化。输入的具体文本如何进入 Planner 生成的 `sdk.Request` 仍由 Request Planner 决定;Loop 在提交前冻结为 agent `ModelRequest`,Machine 只保证输入边界和一次性接受语义。 +Machine 不在 ToolStep 执行中接受 `NextStep`,也不把 terminal follow-up 解释成旧 Run 的状态变化。输入的具体文本如何进入 Planner 生成的 `sdk.Request` 仍由 Request Planner 决定;Loop 在提交前冻结为 agent `ModelRequest`,Machine 只保证输入边界和一次性接受语义。 ### 3.3 MachineState MachineState 至少包括: ```text -RunID 和冻结的 RunConfig.Model +RunID Run status -其他冻结的 RunConfig current Step ToolStep 中每个 ToolCall 的 progress/result 等待中的 ResponseRequest @@ -360,6 +359,8 @@ model-step counter terminal RunResult(如果已结束) ``` +MachineState 不包含固定模型、prompt/history、step limit、malformed retry limit 或产品 RunConfig。模型选择由每个 `ModelStepPrepared` 冻结的 `ModelRequest.Model` 表示;limits 属于 host/Loop policy,并通过显式 command 形成事实。 + 权威语义状态不包括数据库 row、transaction、owner、fence、lease、Attempt 或 queue claim。Runtime 可以保存这些控制元数据,但它们不进入 MachineState。 ### 3.4 Run 状态 @@ -403,7 +404,7 @@ Step 只有 `ModelStep` 和 `ToolStep` 两种语义。ToolCall 是 ToolStep 内 协议使用两个 sealed 词表。`AgentCommand` 是 Loop 或受信任的外部入口针对**已有 Run**提出的意图;`AgentEvent` 承载 Runtime 接受一个 command 后产出的事实。一个被接受的 command 构成一次 transition,产出一个或多个事件;命令描述"请求发生什么",事件描述"已经发生什么",两个词表不共用类型。 -AgentCommand(14 种): +AgentCommand(15 种): ```text PrepareModelRequest 冻结下一次模型请求 @@ -419,6 +420,7 @@ ApproveToolCall 批准 Waiting(Approval) Call RejectToolCall 拒绝 Waiting(Approval) Call SubmitToolResponse 提交 ask-user 答案 CancelRun 业务取消 +StopRun host-owned 非取消停止策略,例如 step_limit AcceptInput 接受 queue-safe 输入(由 NextStep 构造) ``` @@ -443,18 +445,18 @@ RunEnded 终态:Status 为 RunCompleted、RunStopped 或 RunFai `AgentEvent` 与新的 `MachineState` 在同一个原子提交中写入,具有 authority 分配的 (Revision, Index) 身份、canonical digest 和产生它的 CommandID。`AgentEvent` 可供 replay、projection、审计和 OpenTelemetry 使用。 -`RunSeed` 属于新 Run 的 admission,不是 command,也不进入任何 Run 的事件流。如果 Memoh 需要审计这次 admission,可以复用相同的 identity/digest 编码规则,但该记录由 admission 事务保存,不经过 `Runtime.Commit`。 +`RunSeed` 属于 legacy 新 Run admission helper,不是 command。新 API 倾向将初始输入也作为 `AcceptInput` transition 记录;如果 Memoh 需要审计 legacy admission seed,可以复用相同的 identity/digest 编码规则,但该记录由 admission 事务保存,不经过旧 Run 的 `Runtime.Commit`。 二者的关系固定为: ```text Loop / response ingress -> AgentCommand (intent) -Runtime EvaluateCommit: 幂等/类别校验 + Machine.Decide + Machine.Evolve - -> MachineState + AgentEvent 组 (committed facts, one Revision) +Runtime EvaluateCommit: 幂等/类别校验 + Machine.Decide + Machine.EvolveVersion + -> MachineState + TransitionRecord (committed facts, one Revision) ``` -意图与事实由 Decide 显式转换,日志永远记录结果而非请求:`RejectToolCall` 记录为 `ToolCallFailed{permission_denied}`;`CancelRun` 记录为 `RunEnded{RunStopped, cancelled}`;触发 step limit 的最后一次 Call 完成记录为 `[ToolCallCompleted, ToolStepClosed, RunEnded{RunStopped, step_limit}]`。 +意图与事实由 Decide 显式转换,日志永远记录结果而非请求:`RejectToolCall` 记录为 `ToolCallFailed{permission_denied}`;`CancelRun` 记录为 `RunEnded{RunStopped, cancelled}`;host step limit 记录为 `StopRun{step_limit} -> RunEnded{RunStopped, step_limit}`。 `PrepareModelRequest.RequestDigest` 和 `ResponseDigest` 分别是请求/响应 payload 的内容摘要,不是提交身份。`ApproveToolCall`/`RejectToolCall` 使用 `DigestToolResponseDecision(kind, decision, reason)`,`SubmitToolResponse` 使用 `DigestToolResponsePayload(payload)`;Decide 必须校验 digest 与实际 decision/payload 匹配。一个 command 被重试时复用同一 CommandID 和 digest;Runtime 不会为重试生成第二组 AgentEvent。 @@ -476,26 +478,26 @@ WaitForExecutionRecovery `Decide(state, command)` 按下表校验前置条件并产出事实序列。任何前置条件不满足即拒绝整个 command,不产出部分事实: -1. `PrepareModelRequest` 只能在 Run active 且没有当前 Step 时接受;其 `InputIDs` 必须按 `PendingInputs` 的当前顺序完整匹配。`Request` 必须已经是 frozen agent `ModelRequest`,且 `Request.Model` 必须等于 command/RunConfig 的 `ModelRef`;`Tools` 必须与其中的 provider tool definitions 按 Ref、顺序和 definition digest 一一对应,`ToolsDigest` 覆盖 Ref、schema、顺序与 policy。产出 `[ModelStepPrepared]`,事实中携带冻结的请求、ToolSpec、被消费的 InputIDs 与 Decide 算好的 step binding digest(事实自包含条件:Evolve 不重算 digest)。 +1. `PrepareModelRequest` 只能在 Run active 且没有当前 Step 时接受;其 `InputIDs` 必须按 `PendingInputs` 的当前顺序完整匹配。`Request` 必须已经是 frozen agent `ModelRequest`,且 `Request.Model` 必须等于 command 的 `ModelRef`;`Tools` 必须与其中的 provider tool definitions 按 Ref、顺序和 definition digest 一一对应,`ToolsDigest` 覆盖 Ref、schema、顺序与 policy。产出 `[ModelStepPrepared]`,事实中携带冻结的请求、ToolSpec、被消费的 InputIDs 与 Decide 算好的 step binding digest(事实自包含条件:Evolve 不重算 digest)。 2. `StartModelExecution` 只能作用于 Prepared ModelStep,产出 `[ModelStepStarted]`。`RecoverModelExecution` 只能作用于没有已接受结果的 Executing ModelStep,产出 `[ModelStepRecovered]`;它只能由持有该 Model grant 的当前 Loop,或由 Runtime 自己确认 lease 失效后的 recovery 逻辑提交,普通 response ingress 不能提交。 3. `SubmitModelResult` 只能作用于对应的 Executing ModelStep。结果没有 tool calls 时产出 `[ModelStepCompleted, RunEnded{RunCompleted}]`;有 tool calls 时按冻结 `ToolSpec` 绑定 policy 和 binding digest,产出 `[ModelStepCompleted, ToolStepOpened]`——`ToolStepOpened` 携带完整 Call 集合:DirectExecution 的 Call 为 Pending,ApprovalRequired/ExternalResponse 的 Call 为带稳定 request 的 Waiting,每个 Waiting request 都包含目标 RunID、StepID、CallID、ResponseID、Kind 和 RequestDigest。 4. `SubmitModelFailure` 只能作用于对应的 Executing ModelStep,产出 `[RunEnded{RunFailed, provider_failure}]`,保留稳定失败原因。 -5. `RejectModelResult` 只能作用于对应的 Executing ModelStep,必须携带该 start 的 Grant。`Rejects+1` 不超过冻结的 `RunConfig.ModelRejectLimit` 时产出 `[ModelStepRejected]`(Step 回到 Prepared,同一冻结 request 可再次 start);超过时产出 `[ModelStepRejected, RunEnded{RunFailed, malformed_model_result}]`。被拒绝的结果不写入 `LastModelResult`。 +5. `RejectModelResult` 只能作用于对应的 Executing ModelStep,必须携带该 start 的 Grant。`Disposition=ModelRejectRetry` 时产出 `[ModelStepRejected]`(Step 回到 Prepared,同一冻结 request 可再次 start);`Disposition=ModelRejectFailRun` 时产出 `[ModelStepRejected, RunEnded{RunFailed, malformed_model_result}]`。Loop/host policy 可用当前 `ModelStep.Rejects` 和自己的 malformed limit 选择 disposition;limit 不进入 MachineState。被拒绝的结果不写入 `LastModelResult`。 6. `StartToolCall` 只能作用于 Pending Call,产出 `[ToolCallStarted]`。 7. `SubmitToolResult` 只能作用于 Executing Call,产出 `[ToolCallCompleted]`。`SubmitToolResponse` 只能作用于 Waiting(ExternalResponse) Call,产出 `[ToolCallAnswered]`。 8. `SubmitToolFailure`(known)可以作用于 Pending 或 Executing Call:Pending 的已知失败使用空 Grant,Executing 的必须使用对应 Grant,产出 `[ToolCallFailed{Known}]`。`SubmitToolFailure`(unknown)只能作用于 Executing Call,产出 `[ToolCallFailed{Unknown}, RunEnded{RunFailed, effect_unknown}]`,`RunResult.Failure` 记录 `effect_unknown` 和对应 CallID;scanner 提交它时必须先有实现内部的失效执行记录。 9. `ApproveToolCall` 必须匹配目标 Waiting(Approval) Call 保存的 ResponseID 和 kind,产出 `[ToolCallApproved]`(Call 变为 Pending,Loop 随后执行)。`RejectToolCall` 同样必须匹配,产出 `[ToolCallFailed{Known, permission_denied}]`。日志记录的是结果事实,不是请求本身。 10. 一次响应只推进对应 Call,不能修改其他 Call;ResponseID 和 kind 必须匹配该 Call 保存的请求。响应 payload 的 digest 用于内容冲突检测,不需要等于请求 payload 的 digest。 -11. 使 ToolStep 内最后一个 Call 到达可关闭终态的 command,其事实序列追加 `ToolStepClosed`;若此时 `ModelSteps` 已达冻结的 model-step limit,再追加 `RunEnded{RunStopped, step_limit}`。例如最后一个 Call 完成且触发 limit:`[ToolCallCompleted, ToolStepClosed, RunEnded{RunStopped, step_limit}]`。ToolStep 关闭前不能创建下一 ModelStep。 -12. `CancelRun` 只能作用于非 terminal Run,产出 `[RunEnded{RunStopped, cancelled}]`。 -13. `AcceptInput` 只能作用于 active 且没有当前 Step 的 Run,且 `InputID` 不得与当前 `PendingInputs` 中任何输入(包括 `RunSeed` 带入的 seed input)重复;重复 ID 是 identity conflict,不能记录一个 Evolve 会丢弃的 no-op fact。接受后产出 `[InputAccepted]`。Planner 由 `PlanningHint.Inputs` 收到这些输入并在 `RequestPlan.InputIDs` 中明确消费它们;遗漏或伪造 ID 的 `PrepareModelRequest` 被拒绝。 -14. `Initialize(RunID, RunConfig, RunSeed)` 只在 application admission 创建新 Run 时使用;它建立初始 `MachineState` 并把 seed 输入放入 `PendingInputs`。`RunSeed` 不能传给已有 Run 的 `Decide` 或 `Runtime.Commit`。 +11. 使 ToolStep 内最后一个 Call 到达可关闭终态的 command,其事实序列追加 `ToolStepClosed`。ToolStep 关闭前不能创建下一 ModelStep。 +12. `CancelRun` 只能作用于非 terminal Run,产出 `[RunEnded{RunStopped, cancelled}]`;`StopRun` 只能记录 host-owned 非取消停止原因,目前为 `[RunEnded{RunStopped, step_limit}]`。 +13. `AcceptInput` 只能作用于 active 且没有当前 Step 的 Run,且 `InputID` 不得与当前 `PendingInputs` 中任何输入重复;重复 ID 是 identity conflict,不能记录一个 Evolve 会丢弃的 no-op fact。接受后产出 `[InputAccepted]`。Planner 由 `PlanningHint.Inputs` 收到这些输入并在 `RequestPlan.InputIDs` 中明确消费它们;遗漏或伪造 ID 的 `PrepareModelRequest` 被拒绝。 +14. `InitializeRun(RunID)` 只在 application admission 创建新 Run 时使用;它建立最小初始 `MachineState`(Revision=0),不包含 seed input、fixed model 或 limits。兼容 helper `Initialize(RunID, RunConfig, RunSeed)` 仍可构造带 seed input 的 legacy initial state,但 `RunConfig` 不进入 MachineState。 `InputID` 在一个 Run 内唯一。相同 `InputID` 和相同 payload 的重复 `AcceptInput` 是语义 no-op,Runtime 返回原已接受的事件组;相同 ID 携带不同 payload 返回冲突。Memoh 的 queue claim 仍负责防止同一个 queue item 被多个输入入口同时消费。 #### 3.7.2 Evolve 折叠表(事实 -> 状态) -`Evolve(state, event)` 对每种事实执行固定的机械折叠,不读 RunConfig,不含 policy 分支: +`Evolve(state, event)` 对每种事实执行固定的机械折叠,不读 RunConfig/Loop policy,不含 policy 分支: | 事实 | 折叠 | | --- | --- | @@ -522,7 +524,7 @@ WaitForExecutionRecovery `RunStopped` 或 `RunFailed` 的 `RunResult` 保留最近一次已接受的模型结果(如有);`RunCompleted` 的 `RunResult.Model` 是产生正常终态的那次模型结果。取消或未知 effect 不会伪造新的模型结果。 -`Next` 的主要映射是:无当前 Step -> `NeedModelRequest(PlanningHint{Inputs: PendingInputs})`(model-step limit 在前一个 Step 的提交中已经转换为 terminal);Prepared ModelStep -> `StartModelCall`;ModelStep 正在 Executing -> `WaitForExecutionRecovery`;有 Pending Call 的 ToolStep -> `StartToolCalls`;没有 Pending 且存在 Waiting Call -> `WaitForResponse`(即使另有 Executing Call,也等待 response 或 execution wake);没有 Pending/Waiting 但存在 Executing Call -> `WaitForExecutionRecovery`。Runtime 接受 `StartModelExecution` 或 `StartToolCall` 后,在 CommitResult 中返回一次性 `ExecutionGrant`,Loop 使用该授权调用对应的 ModelInvoker 或 ExecutableTool。Model execution lease 失效后,Runtime 通过仅限 Runtime/recovery 使用的 `RecoverModelExecution` 把 ModelStep 恢复为 Prepared,Loop 才能再次 start。 +`Next` 的主要映射是:无当前 Step -> `NeedModelRequest(PlanningHint{Inputs: PendingInputs})`;Prepared ModelStep -> `StartModelCall`;ModelStep 正在 Executing -> `WaitForExecutionRecovery`;有 Pending Call 的 ToolStep -> `StartToolCalls`;没有 Pending 且存在 Waiting Call -> `WaitForResponse`(即使另有 Executing Call,也等待 response 或 execution wake);没有 Pending/Waiting 但存在 Executing Call -> `WaitForExecutionRecovery`。Loop/host 可以在处理 `NeedModelRequest` 前按自己的 step budget 提交 `StopRun{step_limit}`。Runtime 接受 `StartModelExecution` 或 `StartToolCall` 后,在 CommitResult 中返回一次性 `ExecutionGrant`,Loop 使用该授权调用对应的 ModelInvoker 或 ExecutableTool。Model execution lease 失效后,Runtime 通过仅限 Runtime/recovery 使用的 `RecoverModelExecution` 把 ModelStep 恢复为 Prepared,Loop 才能再次 start。 这里的 `WaitForExecutionRecovery` 是一个统一等待结果:它既表示已有 execution 仍可能由原 Loop 持有,也表示该 execution 已失效、等待 Runtime recovery。公共 `LoopResult.Reason` 不暴露 owner、lease 或 Attempt 的细节。 @@ -551,7 +553,7 @@ Request Planner 生成完整 `sdk.Request` 后,Loop 先调用 `FreezeModelRequ Loop 在 `SubmitModelResult` 时只校验 tool-call ID 与顺序,并从匹配的冻结 `ToolSpec` 生成 `ToolCallBinding`。这里不调用 ExecutableTool;未知工具保留为 `DirectExecution`、空 definition digest 的 unresolved binding,应用级参数错误留到 `StartToolCalls`,作为 Pending Call 的已知失败处理。Runtime 只校验 binding 与冻结请求、冻结模型结果和 Step 身份的一致性,不重复解析工具目录。 -模型响应的结构性 malformed(重复/错序 CallID、违反 provider 协议)使 Call 集合无法建立:Loop 不提交 `SubmitModelResult`,而以 start grant 提交 `RejectModelResult{Failure.Class: malformed_model_result}`。Decide 产出 `ModelStepRejected`(usage 累计、`Rejects` 加一、Step 回到 Prepared),同一冻结 request 由后续 start 重试;`Rejects` 超过冻结的 `RunConfig.ModelRejectLimit` 时追加 `RunEnded{RunFailed, malformed_model_result}`。被拒绝的结果不写入 `LastModelResult`,不创建 ToolStep。 +模型响应的结构性 malformed(重复/错序 CallID、违反 provider 协议)使 Call 集合无法建立:Loop 不提交 `SubmitModelResult`,而以 start grant 提交 `RejectModelResult{Failure.Class: malformed_model_result, Disposition: ...}`。Decide 产出 `ModelStepRejected`(usage 累计、`Rejects` 加一、Step 回到 Prepared)或再追加 `RunEnded{RunFailed, malformed_model_result}`;由 Loop/host policy 根据自己的 `MalformedModelResultLimit` 选择 retry/fail-run disposition。被拒绝的结果不写入 `LastModelResult`,不创建 ToolStep。 单个 Call 的参数无法解析不属于结构性 malformed:该 Call 以原始参数字节绑定为 Pending,`StartToolCalls` 的参数校验把它关闭为已知 `invalid_arguments`,失败结果进入下一次模型请求,由模型自行修正。未知 ToolRef 同样保留为待处理 Call,start 前记录 `tool_lookup_failed`。 @@ -681,7 +683,7 @@ Run 语义状态的 source of truth 是 admission-created immutable initial stat 这个权威声明成立的三个稳定条件(本规范的规范性条款): 1. Event ontology 稳定:Fact 词表 sealed,已发布 SchemaVersion 的事实结构永不修改,新增字段进入新版本。 -2. Evolve 语义稳定:折叠是机械的(不读 RunConfig、无 policy 分支),已发布版本的折叠语义与事件编码一起永久冻结;replay 通过 `EvolveVersion(SchemaVersion, state, fact)` 选择历史语义,会演进的决策语义全部在 Decide,其结果记录为事实。 +2. Evolve 语义稳定:折叠是机械的(不读 RunConfig/Loop policy、无 IO),已发布版本的折叠语义与事件编码一起永久冻结;replay 通过 `EvolveVersion(SchemaVersion, state, fact)` 选择历史语义,会演进的决策语义全部在 Decide,其结果记录为事实。 3. 事实自包含:折叠一条事实所需的全部信息在事实自身与折叠前状态之内,不访问外部系统,不重新计算依赖当前代码版本的派生值(digest 一律在 Decide 时算好并携带在事实中)。 4. 持久化值归属稳定:AgentEvent 和 MachineState 只保存 agent-owned frozen values;任何来自 SDK/provider/application 的引用在进入 Runtime 前必须被 canonicalize + detach,返回给 caller 的 snapshot/event 也必须是独立副本。 @@ -870,7 +872,7 @@ func EvaluateCommit( `EvaluateCommit` 用 `DecisionKind` 表达结果;`Runtime.Commit` 把非成功结果映射为对外错误:`DecisionConflict -> ErrCommandConflict`、`DecisionStale -> ErrStaleRuntime`、`DecisionTerminal -> ErrRunTerminal`。Loop 与 ingress 只依赖这三个错误值和 `CommitStatus`,不接触 DecisionKind。 -`RunSeed` 是新 Run admission 的初始化输入,不属于任何 Run 的 `AgentCommand`,也不通过旧 Run 的 `Runtime.Commit`。`Initialize` 使用与 Machine 相同的不变量以及统一的 identity/digest 编码规则建立初始 `MachineState`(Revision=0);普通 Runtime 只实现已有 Run 的 `Load/Commit`,admission 路径负责应用 `RunSeed`。 +新 Run admission 使用 `InitializeRun(RunID)` 建立最小 `MachineState`(Revision=0);初始用户输入应作为该 Run 的第一条 `AcceptInput` transition 进入日志。`RunSeed`/`Initialize(RunID, RunConfig, RunSeed)` 仅保留 legacy helper,`RunConfig` 不进入 MachineState、AgentEvent 或 TransitionRecord。普通 Runtime 只实现已有 Run 的 `Load/Commit`,admission 路径负责创建 RunID 与持久化 initial state。 Commit 的 effectively-once 只针对状态提交。事务已提交但响应丢失时,Loop 使用相同 CommandID 和 digest 重放;`CommitAlreadyApplied` 不能重新授予工具执行权,也不能重复 history、queue action、计数或 outbox。对于 start command,AlreadyApplied 的 `Grant` 必须为空;新 Loop 要等待原执行或 recovery,不能把重放当成新的 start。外部工具 effect 不因此变成 exactly-once。 @@ -967,7 +969,7 @@ Planner 所需的 history 必须由宿主提供:Memoh 从 durable history proj `PrepareModelRequest` 必须使用 Planner 开始前 Load 得到的 BaseRevision,并按当前顺序携带完整的 `PendingInputs` `InputIDs` 集合。相同 RunID 和 Revision 的并发 Planner 使用同一个 `DeriveModelRequestCommandID`:相同请求得到 `CommitAlreadyApplied`,不同请求得到 `ErrCommandConflict`;如果 Planner 在新的 Revision 上重试,则生成新的 CommandID。Memoh adapter 同时检查 `PlanningToken` 的 context revision,后到者不能覆盖已经冻结的请求。 -Loop 校验 `RequestPlan.Model` 与 `PlanningHint.Model`、RunConfig.Model 以及 frozen `ModelRequest.Model` 一致;Runtime 通过共享 Decide 规则再次校验 `PrepareModelRequest.Model`、`PrepareModelRequest.Request.Model` 与冻结的 RunConfig 一致。Planner 的 context 一致性由 Memoh 在自己的 queue-safe admission/planning 边界保证,不由 agent 解释。 +Loop 校验 `RequestPlan.Model`(若提供)与 frozen `ModelRequest.Model` 一致;Runtime 通过共享 Decide 规则再次校验 `PrepareModelRequest.Model` 与 `PrepareModelRequest.Request.Model` 一致。固定模型限制若存在,属于 Memoh/host policy,不进入 agent MachineState。Planner 的 context 一致性由 Memoh 在自己的 queue-safe admission/planning 边界保证,不由 agent 解释。 ### 5.7 外部 response @@ -1252,7 +1254,7 @@ Pending + Waiting | 情况 | 处理 | | --- | --- | | provider transport/rate limit | sdk 在一次模型调用内处理;最终失败由 Machine 记录。 | -| 结构性 malformed 模型结果 | 提交 `RejectModelResult`,Step 回到 Prepared 重试;超过 `ModelRejectLimit` 后 RunFailed。 | +| 结构性 malformed 模型结果 | 提交 `RejectModelResult`;Loop/host 通过 Disposition 选择回到 Prepared 重试或同 transition RunFailed。 | | tool lookup/参数错误 | 提交已知 `SubmitToolFailure`,交给下一次模型请求。 | | 工具明确失败 | 提交已知 `SubmitToolFailure`,模型决定是否在新 Step 重试。 | | 工具结果未知 | 提交 Unknown,RunFailed;不自动重试,不创建下一 ModelStep。 | @@ -1265,7 +1267,7 @@ Pending + Waiting 计划内停机(部署、滚动升级)不走崩溃路径:宿主收到停止信号后取消 Loop 的外层 ctx,按上表语义排空——不再 start 新 Call,已 start 的工具 worker 在停机 grace 内提交完成/失败,模型执行以 recovery 释放。grace 内未能结束的工具执行才留给 lease recovery 收束为 Unknown。 -Run 创建时冻结 `RunConfig.ModelStepLimit` 和 `RunConfig.ModelRejectLimit`;Loop 不持有第二份计数器。`ModelStepLimit` 的 `0` 表示无限;达到正数上限后不创建新的 ModelStep,已打开 ToolStep 仍完成或等待,随后由 Machine 结束 Run。`ModelRejectLimit` 的 `0` 在 `Initialize` 时归一化为默认值 `2`,负值无效;不提供无限值——结构性 malformed 的无限重试是无上界的成本。 +固定模型、model-step budget 和 malformed retry budget 不进入 MachineState。Loop/host 可以使用非持久化 `ExecutionPolicy`:`ModelStepLimit=0` 表示无限,达到正数上限时在下一次 planning 前提交 `StopRun{ReasonStepLimit}`;`MalformedModelResultLimit=0` 归一化为默认值 `2`,Loop 根据当前 `ModelStep.Rejects` 选择 `RejectModelResult` 的 retry/fail-run disposition。Memoh 也可以在自己的 host policy 中实现同样逻辑。 ## 7. Tool、approval、response 和 MCP @@ -1405,9 +1407,9 @@ Initial MachineState + TransitionRecord log 是 source of truth;MachineState Replay 按 RunID/Revision 取出 TransitionRecord,从初始状态(Revision=0)开始依次展开其中的 AgentEvent,并调用对应 `SchemaVersion` 的 `Machine.EvolveVersion` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里,Machine 决策规则的演进不影响历史事件的折叠;折叠不产生任何外部 effect。仲裁按 §5.1 的规则:transition log 完整(maxRevision >= watermark 且每条 transition digest 正确)时日志为准,snapshot 分歧或缺失自动重建并记录重建事件;日志尾部低于水位或尾部 transition 不完整时 halt。事件流内部的 RunID 不匹配、SchemaVersion/Type 不支持、同一 transition 的 CommandID/CommandDigest 不一致、Revision/Index 缺洞、fact digest 或 transition digest 不匹配同样按日志损坏处理,halt 该 Run。 -Replay 的起点是 admission 已建立的初始 `MachineState`;`RunSeed` 的 admission 记录不作为任何 Run 的 AgentEvent 重放。需要重建 admission 链时,由 Memoh 的 session/queue 记录负责。 +Replay 的起点是 admission 已建立并持久化的最小 `MachineState`;初始用户输入若存在,也应通过 `AcceptInput` transition 重放。`RunSeed` 的 legacy admission 记录不作为普通 Run 的 AgentEvent 重放。需要重建 admission 链时,由 Memoh 的 session/queue 记录负责。 -canonical event 只记录影响语义状态、恢复和审计的已接受事实:Step 的建立/启动/恢复/关闭、模型结果的接受与拒绝、工具结果、响应、active Run 的输入接受和 Run terminal(§3.6 的 Fact 词表)。新 Run 的 `RunSeed` 仍属于 admission record。模型文本 delta、工具 stdout、下载百分比和其他瞬时 progress 不进入 AgentEvent;它们仍可在提交前通过 EventSink 发送 provisional observation。 +canonical event 只记录影响语义状态、恢复和审计的已接受事实:Step 的建立/启动/恢复/关闭、模型结果的接受与拒绝、工具结果、响应、active Run 的输入接受和 Run terminal(§3.6 的 Fact 词表)。模型文本 delta、工具 stdout、下载百分比和其他瞬时 progress 不进入 AgentEvent;它们仍可在提交前通过 EventSink 发送 provisional observation。 ### 9.2 EventSink @@ -1453,7 +1455,7 @@ steer 优先进入当前 eligible boundary;若当前 Run 已 terminal, follow-up 当前 Run 自然结束后创建新的 Run ``` -active Run 的 steer 通过 `NextStep(input)` 生成 `AcceptInput` command;terminal Run 的 steer/follow-up 通过 `NextRun(input)` 生成 `RunSeed`,然后由 Memoh admission 创建新 Run。core 不接收 queue item、priority、order 或 claim。 +active Run 的 steer 通过 `NextStep(input)` 生成 `AcceptInput` command;terminal Run 的 steer/follow-up 由 Memoh admission 创建新 Run,再对新 Run 提交首个 `AcceptInput`。`NextRun`/`RunSeed` 仅作为 legacy helper 保留。core 不接收 queue item、priority、order 或 claim。 重排必须带 order version;过期版本、未知 item、重复 item 和越过已 claim item 的操作都拒绝。 @@ -1479,7 +1481,7 @@ R0 terminal 不等于 session settled admission-active R1 存在时 session 仍 busy ``` -follow-up 可以在 R1 的第一个 ModelStep 之前完成 durable admission claim;这是 Memoh session 操作,不是 Loop 中间读取 queue。R0 continuation 通过 Memoh outbox/scanner 唤醒;R1 admission 用 `NextRun(input)` 的 `RunSeed` 初始化新 Run,R1 identity 不进入 agent 的通用结果。 +follow-up 可以在 R1 的第一个 ModelStep 之前完成 durable admission claim;这是 Memoh session 操作,不是 Loop 中间读取 queue。R0 continuation 通过 Memoh outbox/scanner 唤醒;R1 admission 用 `InitializeRun` 创建最小 state,并把 follow-up 输入提交为首个 `AcceptInput` transition;R1 identity 不进入 agent 的通用结果。 ### 10.4 多 response 恢复 @@ -1523,7 +1525,7 @@ Memoh -> agent.Loop + MemohRuntime 1. provider adapter 的现有请求/响应字段优先复用;旧 `GenerateParams` 无法表达的新 `Request` 字段(例如 `ProviderOptions`)在 fallback 到旧 provider 时必须显式报错,不能 silent drop。 2. sdk 保留旧单次调用入口,直到调用方迁移完成。 3. 旧自动 loop 只在显式 legacy wrapper 中存在,不能由新 Loop 隐式调用。 -4. 旧 `WithMaxSteps(0)` 保持一次模型调用、不自动执行 tools;`n>0` 映射为 `RunConfig.ModelStepLimit=n`;旧值 `-1` 由 legacy wrapper 先规范化为 `RunConfig.ModelStepLimit=0`(无限)。agent 的 `RunConfig` 不接受负数。Memoh 当前使用 `-1`,迁移后保持无限模型步骤语义。 +4. 旧 `WithMaxSteps(0)` 保持一次模型调用、不自动执行 tools;`n>0` 在 legacy wrapper/Loop `ExecutionPolicy.ModelStepLimit` 中处理,到上限后提交 `StopRun{step_limit}`;旧值 `-1` 由 legacy wrapper 先规范化为 unlimited。`RunConfig` 仅保留为 deprecated helper 输入,不进入 MachineState。Memoh 当前使用 `-1`,迁移后保持无限模型步骤语义。 5. 旧 deferred/approval 记录不能在线猜测为新的多 Call ToolStep。切换前必须排空,或以 `runtime_upgrade_required` 终态保留审计后再切换。 6. 新协议写入生产后不回滚旧 loop;Memoh 的 queue accepted order、重排、claim、admission、R0/R1 和 settled 保证不变。 @@ -1597,13 +1599,13 @@ provider retry 不改变一次调用语义 ```text 无当前 Step -> NeedModelRequest NextStep(input) -> AcceptInput -> InputAccepted -> pending input appears in PlanningHint -NextRun(input) -> RunSeed,admission initializes new Run, without mutating old Run +terminal follow-up -> Memoh admission creates new Run with InitializeRun, then AcceptInput(input), without mutating old Run PrepareModelRequest -> [ModelStepPrepared] -> ModelStep ModelExecuting lease recovery -> same frozen ModelStep can start again SubmitModelResult 有 tools -> [ModelStepCompleted, ToolStepOpened],保存完整 Call set SubmitModelResult 无 tools -> [ModelStepCompleted, RunEnded{RunCompleted}],并返回该 ModelResult 结构性 malformed -> RejectModelResult -> [ModelStepRejected],Step 回到 Prepared,usage 已累计 -Rejects 超过 ModelRejectLimit -> [ModelStepRejected, RunEnded{RunFailed, malformed_model_result}] +RejectModelResult{Disposition: FailRun} -> [ModelStepRejected, RunEnded{RunFailed, malformed_model_result}] 参数无法解析的单个 Call -> Pending,start 前以 invalid_arguments 已知失败关闭 unknown ToolRef/invalid arguments -> Pending Call 的已知失败,不提交 start approval approved -> [ToolCallApproved] -> Pending -> start -> tool execute @@ -1613,7 +1615,7 @@ Waiting 与 Executing 并存时,response 和 execution wake 都有效 response 只推进对应 Waiting Call Waiting result carries RunID/StepID/CallID/ResponseID for response routing 最后一个 Call terminal -> 同一 transition 追加 ToolStepClosed -最后一个 Call terminal 且触发 step limit -> [..., ToolStepClosed, RunEnded{RunStopped, step_limit}] +最后一个 Call terminal -> [..., ToolStepClosed];host step limit 在下一 planning 边界提交 StopRun(step_limit) RunEnded 只能是事实序列的最后一个事实 已知失败进入下一次模型上下文 Unknown -> [ToolCallFailed{Unknown}, RunEnded{RunFailed, effect_unknown}],不创建下一 ModelStep @@ -1622,7 +1624,7 @@ RunStopped 与 worker cancellation 区分 外层 ctx 取消:模型执行以 ModelStepRecovered 释放,工具 worker 运行到结束后 Loop 返回 ctx.Err() MachineState.Usage 逐字段累计 ModelStepCompleted 与 ModelStepRejected;terminal 时复制到 RunResult.Usage Decide 拒绝时不产出部分事实;接受时事实组与 MachineState 原子提交 -Evolve 不读 RunConfig、无 policy 分支;对 Decide 产出的全部事实全定义 +Evolve 不读 RunConfig/Loop policy、无 IO;对 Decide 产出的全部事实全定义 TransitionRecord 按 RunID/Revision 可 replay,内部 AgentEvent 按 (Revision, Index) 保持事件流顺序,重复提交不产生第二组 replay 只经 EvolveVersion 折叠,不重新运行 Decide;golden transition stream 折叠出冻结的状态字节 EventSink provisional/committed 发射点 @@ -1653,7 +1655,7 @@ stale grant/Revision 被拒绝 一次 transition 的事件共享 Revision,Index 连续,提交后 State.Revision == transition Revision ToolCall progress 按 CallID 合并 已关闭 ToolStep 不重复关闭或创建新 Step -model-step limit 在 authority 内生效 +Loop/host model-step limit 通过 StopRun(step_limit) 形成事实,不进入 MachineState RunStopped/RunFailed 保留最近已接受的 ModelResult Cancel 与 Unknown 的提交先后决定终态 CancelRun 在过期 BaseRevision 上对非 terminal Run 重新评估 @@ -1702,7 +1704,7 @@ Memoh queue/session/admission 的语义保持在 Memoh;现有 NativeAgentLoop 2. breaking release 版本和 Memoh protocol upgrade window。 3. EventSink payload schema,以及是否需要在 Memoh outbox 中加入跨进程 execution epoch。 -本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;agent `ModelRequest` 冻结完整的 generation options,streaming 只是 `ModelInvoker` 的可选执行路径,不改变 AgentCommand/AgentEvent 语义。Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、与事件编码同属永久兼容契约;initial MachineState + TransitionRecord log 为 source of truth,MachineState 为必需的同事务 projection,分歧仲裁按 §5.1(transition log 完整则自动重建,日志尾部低于水位或 transition 不完整则 halt)。结构性 malformed 的模型结果通过 `RejectModelResult` 在同一冻结 request 上有限重试;usage 在 MachineState 内逐字段累计;steer 由 MemohRuntime 的 Prepare gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 +本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;agent `ModelRequest` 冻结完整的 generation options,streaming 只是 `ModelInvoker` 的可选执行路径,不改变 AgentCommand/AgentEvent 语义。Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、与事件编码同属永久兼容契约;initial MachineState + TransitionRecord log 为 source of truth,MachineState 为必需的同事务 projection,分歧仲裁按 §5.1(transition log 完整则自动重建,日志尾部低于水位或 transition 不完整则 halt)。结构性 malformed 的模型结果通过 `RejectModelResult` 的 disposition 在同一冻结 request 上重试或失败;fixed model/limits 不进入 MachineState;usage 在 MachineState 内逐字段累计;steer 由 MemohRuntime 的 Prepare gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 本规范采用 initial MachineState + TransitionRecord log 为 source of truth(§5.1):三个稳定条件(ontology 冻结、versioned Evolve 冻结、事实自包含)由 Decide/Evolve 拆分保障,revision 水位保护 transition log 尾部完整性,TransitionDigest 保护单个 transition 内部的完整事件组。MachineState 保持为必需的同事务 projection——提交验证要求当前状态在临界区内可得,这与日志权威并不冲突。 @@ -1732,14 +1734,12 @@ type Digest string // context revision from which a RequestPlan was built. type PlanningToken string +// Deprecated compatibility input only. RunConfig is not stored in MachineState, +// AgentEvent, or TransitionRecord. New code uses InitializeRun plus host/Loop +// policy and per-step ModelRequest.Model. type RunConfig struct { Model ModelRef - // Zero means unlimited. A positive value caps ModelSteps in this Run; - // negative values are invalid. ModelStepLimit int - // Max RejectModelResult per ModelStep before the Run fails. Zero is - // normalized to 2 by Initialize; negative values are invalid. There is no - // unlimited value. ModelRejectLimit int } @@ -1908,10 +1908,12 @@ const ( ExternalResponse ) -// MaxParallel: 1 means sequential; values greater than 1 enable bounded -// parallelism. Zero is normalized to 1; negative values are rejected by Loop -// construction. -type ExecutionPolicy struct { MaxParallel int } +// ExecutionPolicy is host-owned Loop policy and is not persisted in MachineState. +type ExecutionPolicy struct { + MaxParallel int + ModelStepLimit int // 0 unlimited; Loop submits StopRun(step_limit) + MalformedModelResultLimit int // 0 default; Loop chooses reject disposition +} type ResponseRequest struct { RunID RunID @@ -1933,7 +1935,6 @@ const ( type MachineState struct { RunID RunID Status RunStatus - Config RunConfig Current Step PendingInputs []AgentInput ModelSteps int @@ -1958,9 +1959,8 @@ type AgentInput struct { // NextStep creates the command consumed by an active Run at a safe boundary. func NextStep(input AgentInput) AcceptInput -// NextRun creates the admission seed used by application admission. It does -// not allocate a RunID, claim a queue item, or mutate an existing Run, and it -// is never submitted through Runtime.Commit. +// Deprecated legacy admission helper. New admission uses InitializeRun and +// submits the initial input with AcceptInput. type RunSeed struct { Input AgentInput } func NextRun(input AgentInput) RunSeed @@ -1996,13 +1996,19 @@ func (SubmitModelResult) agentCommand() {} type SubmitModelFailure struct { StepID StepID; Failure StepFailure } func (SubmitModelFailure) agentCommand() {} -// A structurally malformed model result: usage is accumulated, the step's -// reject counter is incremented, and the step returns to Prepared until -// RunConfig.ModelRejectLimit is exceeded. Requires the model start grant. +type ModelRejectDisposition uint8 +const ( + ModelRejectRetry ModelRejectDisposition = iota + ModelRejectFailRun +) + +// A structurally malformed model result. Disposition records the host/Loop +// policy decision: retry same frozen request or fail the Run. type RejectModelResult struct { - StepID StepID - Usage Usage - Failure StepFailure + StepID StepID + Usage Usage + Failure StepFailure + Disposition ModelRejectDisposition } func (RejectModelResult) agentCommand() {} @@ -2045,6 +2051,9 @@ func (SubmitToolResponse) agentCommand() {} type CancelRun struct { Reason RunReason } func (CancelRun) agentCommand() {} +type StopRun struct { Reason RunReason } // currently ReasonStepLimit +func (StopRun) agentCommand() {} + type AcceptInput struct { Input AgentInput } func (AcceptInput) agentCommand() {} @@ -2156,6 +2165,9 @@ type ToolSpec struct { func Next(MachineState) (Effect, error) func Decide(MachineState, AgentCommand) ([]Fact, error) func Evolve(MachineState, Fact) (MachineState, error) +func InitializeRun(RunID) (MachineState, error) + +// Deprecated: compatibility helper; RunConfig is not stored in MachineState. func Initialize(RunID, RunConfig, RunSeed) (MachineState, error) type Effect interface { effect() } @@ -2442,4 +2454,4 @@ func (l *Loop) Run(context.Context, Runtime, EventSink) (LoopResult, error) 14. worker cancellation 不等于 RunStopped;业务停止必须提交控制 command。宿主的业务停止先提交 `CancelRun`,再取消 Loop 的 ctx;ctx 取消本身只结束执行尝试,工具 worker 运行到自身结束。 15. EventSink 只是实时观察;TransitionRecord、durable snapshot 和 outbox 才是 replay/recovery 依据,AgentEvent 是 transition 内部和观察出口的事实流视图。 16. MemoryRuntime 用进程内同步;MemohRuntime 用事务、CAS 和内部 Attempt/owner/fence/lease;两者共享 `EvaluateCommit` 与 Machine 规则,但不共享存储实现。 -17. 结构性 malformed 的模型结果以 `RejectModelResult` 回到 Prepared,在同一冻结 request 上重试并累计 usage;超过 `RunConfig.ModelRejectLimit` 才 RunFailed。单个 Call 的参数解析失败不是 malformed,按已知 `invalid_arguments` 进入下一次模型请求。 +17. 结构性 malformed 的模型结果以 `RejectModelResult` 累计 usage;Disposition 决定回到 Prepared 重试或同 transition RunFailed。单个 Call 的参数解析失败不是 malformed,按已知 `invalid_arguments` 进入下一次模型请求。 From 61d0e0a915dd9ac2ccdbec9776eccb3e460313dc Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 04:02:23 +0800 Subject: [PATCH 12/81] docs(agent): define es run session queue domains --- docs/design/agent-runtime-refactor.md | 918 ++++++++++++++++++-------- 1 file changed, 631 insertions(+), 287 deletions(-) diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 1f1ab1e..437a913 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -2,9 +2,9 @@ 状态:重构方案 -本文定义 Twilight AI 的 `sdk/`、`agent/` 和 Memoh native runtime 边界,并规定从当前 SDK 多步 loop 迁移到唯一 `agent.Loop` 的方式。 +本文定义 Twilight AI 的目标 agent domain 边界:`sdk/` 负责一次模型调用,`agent/es` 提供通用 event-sourcing 机制,`agent/run` 保存单次 Run 的 execution ES,`agent/session` 保存长期会话语义 ES,`agent/queue` 提供通用调度/claim/dedup 机制。Application/Product 负责把这些 domain 编排起来。 -本文只定义 Agent Machine 的语义、Loop 的执行协议、Runtime 的 authority/commit 边界,以及已提交事实的 canonical event 语义。Memoh 的 queue、session、R0/R1、owner、fencing、lease 和数据库投影仍由 Memoh 负责;本文规定它们如何接入,而不把它们提升为 agent core 概念。 +本文不再把 Twilight 定义成一个“大 Agent Core”。当前代码中的 root `agent` 包语义上应收敛为目标 `agent/run` 包;它回答“一次 computation 执行到哪里、下一步是否合法、如何 replay/recover”。Session、Queue、Context、Artifact、Memory、Workspace、Scheduler 等是独立 domain 或 application policy;其中本规范先正式拆出 `run`、`session`、`queue` 和共享 `es` substrate。 正文中的 Go 片段用于说明协议;附录 A 是 public API 草案。正文各节为规范文本,附录是汇总视图;两者不一致时以正文为准,并修订附录。 @@ -12,88 +12,111 @@ ### 1.1 核心目标 -Twilight AI 同时支持两种运行形态: +Twilight AI 的 agent 侧被拆成几个可组合 domain,而不是一个单体 Agent: ```text -Memory agent - MachineState 在当前进程内,适合本地会话和测试 - -Durable agent - MachineState 在数据库中,进程可以退出,新的进程可以继续 +Application / Product + │ + ├── conversation/session semantics + ├── steer / follow-up policy + ├── artifact/context/memory/workspace policy + └── orchestration + │ + ┌─────────┼─────────┐ + │ │ │ +agent/ agent/ agent/ +session queue run +长 ES 调度机制 短 Run ES ``` -两种形态使用同一套 Machine 规则和同一个 Loop。差别只在 Runtime 如何保存权威状态、控制并发和提交状态变化。 +三个已经明确的 domain authority: + +| Domain | 回答的问题 | Authority 形态 | 生命周期 | +| --- | --- | --- | --- | +| `agent/session` | 这个会话长期发生过什么? | append-only Session ES | 跨多个 Run 长期存在 | +| `agent/run` | 这一次执行如何跑到当前位置? | RunHeader + TransitionRecord log | 单次 Run,完成后可归档/GC | +| `agent/queue` | 哪些工作待处理、谁 claim、如何 dedup? | transactional queue state | 调度生命周期 | + +`agent/es` 是机制库,不是第四个语义 domain。它只提供 canonical envelope、digest、record completeness、revision/index 校验和 fold runner 等可复用机制;它不认识 ModelStep、Session message、queue claim 或产品 policy。 -本版本固定五个层级: +本版本固定五个执行层级,但这些层级属于 `agent/run`: ```text -1. Agent Machine +1. Run Machine 定义 Run、Step、ToolCall 的语义状态和合法状态变化 -2. Agent Loop +2. Run Loop 解释 Machine 产生的 effect,执行模型/工具,再提交结果事件 -3. Runtime - 加载权威 MachineState,并原子提交 AgentCommand,产生 AgentEvent +3. Run Runtime + 加载权威 MachineState,并原子提交 AgentCommand,产生 TransitionRecord 4. Model / Tool 执行一次模型请求或一次工具调用 5. Request Planner - 把 application context 投影为下一次模型所需的 sdk.Request + 把 application/session/context projection 投影为下一次模型所需的 sdk.Request ``` -这里的 “Agent Machine” 是语义层名称,不引入名为 `Agent` 的核心对象;公共执行入口仍是 `Loop`。 +这里的 “Run Machine” 是语义层名称,不引入名为 `Agent` 的核心对象;公共执行入口仍是 `Loop`。 整体关系: ```text - Agent Machine (pure rules) - MachineState + Next/Decide/Evolve - ^ | - | state | Effect - | v -Request Planner --> sdk.Request --Freeze*--> Agent Loop --SDK()--> Model / Tool - application (boundary) | - context | AgentCommand carries - | agent.ModelRequest / ModelResult - | (JSON-stable persisted values) - v - Runtime - Load + atomic Commit - / \ - MemoryRuntime MemohRuntime - memory + mutex PostgreSQL + private lease/fence - \ / - \ / - MachineState + AgentEvents - (same commit boundary) - | - v - EventSink / replay / projection / OTel -``` - -Loop 与 Runtime 的职责相互独立:Loop 决定如何执行,Runtime 决定权威状态在哪里以及如何安全提交。Machine 决定什么状态变化合法;Planner 决定模型看到的 application context;Model/Tool 只执行一次外部 effect。 + Session ES / Context / Queue + (Application-owned planning inputs) + │ + v + Request Planner + │ sdk.Request + v + run.Loop --SDK()--> Model / Tool + │ + │ AgentCommand carries frozen run.ModelRequest / ModelResult + v + run.Runtime + Load + atomic Commit + / \ + run.MemoryRuntime Durable runtime adapter + memory + mutex DB + private lease/fence/outbox + \ / + \ / + RunHeader + TransitionRecord log + MachineState projection + │ + v + EventSink / Run replay / Session materializer / OTel +``` + +Loop 与 Runtime 的职责相互独立:Loop 决定如何执行,Runtime 决定权威状态在哪里以及如何安全提交。Machine 决定什么状态变化合法;Planner 决定模型看到的 application/session/context projection;Model/Tool 只执行一次外部 effect。 目标目录边界: ```text twilight-ai/ - sdk/ 一次 LLM request/response 的边界/transport 类型 - agent/ Machine、Loop、Step、工具 contract、Runtime contract、JSON-stable persisted model data - -Memoh native runtime - Request Planner、产品 context、durable history、queue、session 和数据库事务 + sdk/ 一次 LLM request/response 的边界/transport 类型 + agent/ + es/ 通用 ES envelope、digest、record、fold 机制 + run/ 单次 Run 的 execution ES:Machine、Loop、Runtime、Step/ToolCall + session/ 长期语义 Session ES substrate + queue/ 通用 transactional queue/claim/dedup 机制 + jsonstable/ canonical JSON value 与解析/编码 + harness/ 可选:in-process 组合器,不是新的 Runtime 语义 ``` 依赖方向固定为: ```text -agent ---> sdk -Memoh ---> agent + sdk -sdk 不依赖 agent 或 Memoh +agent/es ---> agent/jsonstable(可选) +agent/session ---> agent/es +agent/run ---> agent/es + agent/jsonstable + sdk +agent/queue ---> 不依赖 agent/run;默认不依赖 ES +Application ---> sdk + agent/run + agent/session + agent/queue +sdk 不依赖 agent 或 Application ``` +禁止出现 `agent/es -> agent/run` 或 `agent/session -> agent/run` 这类反向依赖;共享机制不能 import domain 语义。 + ### 1.2 核心执行模型 ```text @@ -112,7 +135,7 @@ MachineState + AgentCommand Machine.Evolve 逐个折叠(机械,无决策) | v - new MachineState + AgentEvents(同一 Revision) + TransitionRecord + new MachineState(同一 Revision) | Machine.Next -> Effect -> Loop ``` @@ -131,7 +154,7 @@ Step 不是最小的外部操作。ToolStep 保存每个 ToolCall 中会影响 | Loop | 当前进程执行 Run 的算法;它不保存权威状态。 | | MachineState | Machine 的完整语义状态。 | | Step | Run 中的 durable resume boundary。 | -| ModelStep | 一次冻结的 agent-owned `ModelRequest`,直到接受模型结果。 | +| ModelStep | 一次冻结的 run-owned `ModelRequest`,直到接受模型结果。 | | ToolStep | 一个模型结果产生的一组 ToolCall 及其 progress。 | | ToolCall | ToolStep 内的一个结构化工具调用。 | | AgentCommand | Loop 或外部入口希望 Machine 接受的意图;接受后构成一次 transition。 | @@ -140,7 +163,7 @@ Step 不是最小的外部操作。ToolStep 保存每个 ToolCall 中会影响 | Revision | 每 Run 单调递增的 transition 计数;第 N 次接受的 transition 产出 Revision=N 的 TransitionRecord 和 Revision=N 的状态。 | | Effect | Machine 根据当前状态返回的至多一个待执行动作;不表示一定有外部副作用。 | | Attempt | Runtime 为一次进程执行建立的内部执行租约。 | -| Runtime | MachineState 的 execution authority、AgentCommand 的原子提交和 AgentEvent 的产生者。 | +| Runtime | MachineState 的 execution authority、AgentCommand 的原子提交和 TransitionRecord 的产生者。 | | Request Planner | application context 到 `sdk.Request` 的投影器。 | ### 1.4 成功标准 @@ -148,8 +171,8 @@ Step 不是最小的外部操作。ToolStep 保存每个 ToolCall 中会影响 | 场景 | 期望结果 | | --- | --- | | 单次模型调用 | 直接使用 `sdk`,不自动执行工具。 | -| 本地会话 | `agent.Loop` 配合 `MemoryRuntime`。 | -| Memoh durable 会话 | 同一个 `agent.Loop` 配合 `MemohRuntime`。 | +| 本地 Run | `run.Loop` 配合 `run.MemoryRuntime`。 | +| durable Run | 同一个 `run.Loop` 配合 durable runtime adapter。 | | 模型返回 tools | ModelStep 完成后创建一个 ToolStep;ToolStep 完成前不创建下一 ModelStep。 | | 多个 approval/用户响应 | 每个等待请求有稳定身份;一个响应只推进对应 Call。 | | ToolStep 中途崩溃 | Completed Call 不再执行;Pending Call 可恢复;结果未知的 Executing Call 终止 Run。 | @@ -174,16 +197,16 @@ sdk 只负责 LLM API: `sdk.Request` 是一次模型调用的完整输入,不是 session、history 或 queue。`sdk.ModelResult` 是一次完整模型响应,保留旧 `GenerateResult` 的单次调用字段(文本、reasoning parts、tool calls、finish reason、usage、sources/files 和 provider metadata),但不包含自动 tool loop、approval 或多次调用累加。多步执行的 steps 和 messages 由 agent/application 另行保存。 -`sdk` 类型只允许存在于 Planner/ModelInvoker/provider 边界;不得作为 AgentEvent、MachineState、AgentCommand 或 digest input 的持久化形态。Loop 必须在提交前把 `sdk.Request`、`sdk.ModelResult` 和 `sdk.ToolDefinition` 分别转换成 agent-owned 的 `ModelRequest`、`ModelResult` 和 `ToolDefinition`;调用 provider/tool 前再由这些 frozen value 构造新的 `sdk` 值。 +`sdk` 类型只允许存在于 Planner/ModelInvoker/provider 边界;不得作为 Run event、MachineState、AgentCommand 或 digest input 的持久化形态。Loop 必须在提交前把 `sdk.Request`、`sdk.ModelResult` 和 `sdk.ToolDefinition` 分别转换成 run-owned 的 `ModelRequest`、`ModelResult` 和 `ToolDefinition`;调用 provider/tool 前再由这些 frozen value 构造新的 `sdk` 值。 -agent-owned 冻结形态遵守以下规则;`FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`、`DigestRequest`、StepID 派生和提交幂等都建立在这套规则上: +run-owned 冻结形态遵守以下规则;`FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`、`DigestRequest`、StepID 派生和提交幂等都建立在这套规则上: 1. 冻结值是纯数据。它不包含 provider client、接口值、回调或 `Execute` 句柄;模型以 provider 作用域内的字符串 ID 表示,provider 绑定发生在 `ModelCatalog`/`ModelInvoker` 解析时。 2. 工具以 provider-neutral 的 `ToolDefinition{Name, Description, Parameters}` 表示;`Parameters` 是解析完成并 canonicalized 的 JSON Schema 文档。由 Go struct 推导 schema 的工作在冻结前完成,冻结后的 Request 不依赖推导或反射。 3. `ToolChoice` 是封闭类型 `{Mode: auto|none|required|tool, Tool string}`,不使用 `any`。 4. 消息 part 是 sealed union 的 agent value(text/reasoning/image/file/tool-call/tool-result),不持久化 `sdk.MessagePart` interface。 5. 消息中的二进制内容有两种形式:inline bytes(canonical 编码为 base64),或稳定的内容寻址引用 `BlobRef{Digest, MediaType, ByteSize}`。`BlobRef` 的字节解析由组装 `ModelInvoker` 的一方负责;带时效的 URL 等不稳定引用不能进入冻结请求。两种形式产生不同的 digest,Planner 对同一内容必须确定性地选择一种形式。 -6. provider metadata、provider options、tool input/result、response format schema 等扩展字段的值必须是 JSON 值。进入 agent 前必须 parse/canonicalize 成 opaque `CanonicalJSON`(由 `agent/jsonstable.Value` 承载,内部 bytes 不可被 agent core 或 caller 直接构造/修改);不能保存 caller-owned map/slice/RawMessage 或 `any`。canonicalization 必须拒绝会把不同 payload 折叠成同一值的输入,包括重复 object key、trailing data、invalid UTF-8 和 escaped lone surrogate(`\ud800`..`\udfff`)。 +6. provider metadata、provider options、tool input/result、response format schema 等扩展字段的值必须是 JSON 值。进入 `agent/run` 前必须 parse/canonicalize 成 opaque `CanonicalJSON`(由 `agent/jsonstable.Value` 承载,内部 bytes 不可被 run core 或 caller 直接构造/修改);不能保存 caller-owned map/slice/RawMessage 或 `any`。canonicalization 必须拒绝会把不同 payload 折叠成同一值的输入,包括重复 object key、trailing data、invalid UTF-8 和 escaped lone surrogate(`\ud800`..`\udfff`)。 7. `DigestRequest` 覆盖 frozen `ModelRequest` 的全部字段,不设排除项。cache 配置等只影响成本的字段同样参与摘要;排除任何字段都会把不同请求判成同一事件,产生错误的 `CommitAlreadyApplied`。 边界的 SDK 类型可以保持以下形态;agent 的持久化 `ModelRequest`/`ToolDefinition` 是相同语义的 concrete mirror,字段中所有接口/any/JSON 原文先在 `Freeze*` 中 canonicalize 为 `CanonicalJSON`。 @@ -242,70 +265,317 @@ provider transport 的短暂失败可以由 sdk/provider client 在一次调用 旧的 `GenerateText`、`StreamText` 和自动 tool loop 在迁移期只能作为显式 legacy wrapper;新 Loop 不依赖它们。 -### 2.2 `agent/` +### 2.2 `agent/es` + +`agent/es` 是两个 ES domain 共用的机制层,只抽机制,不抽语义。它不能 import `agent/run`、`agent/session` 或 application 类型。 -agent 提供通用 core: +`agent/es` 负责: | 能力 | 内容 | | --- | --- | -| Machine | `MachineState`、`Step`、ToolCall 状态、AgentCommand、AgentEvent 和共享的 Decide/Evolve/Next 规则。 | -| Loop | 唯一的多步执行算法。 | -| Tool contract | ToolRef、ExecutableTool、参数校验、结果分类和 response policy。 | -| Runtime contract | `Load`/`Commit` authority 接口。 | -| Planner port | 供 Loop 注入 application Request Planner 的最小接口;规划实现不在 agent。 | -| Effect contract | 模型、工具、等待和观察动作。 | -| EventSink | canonical event 的实时观察出口,也承载不进入权威状态的 provisional delta;它不保存 canonical event。 | -| MCP adapter | MCP schema/call 到 agent tool contract 的适配。 | -| MemoryRuntime | 进程内参考实现;它不是 Machine 的一部分,生产应用也可以在 application 包中实现同一 Runtime contract。 | +| envelope | schema version、stream identity、revision、index、type、causation id、payload digest 的通用封装。 | +| canonical digest | 对 version/type/payload/record body 做稳定编码和 digest;拒绝 ambiguous JSON。 | +| record completeness | 校验一次 append 的完整 event group:revision、index、stream id、type、digest、record digest。 | +| replay runner | 给定 initial state、record log 和 domain-provided `EvolveVersion`,执行 `fold(initial, events)`。 | +| conformance helpers | 通用的 record truncation、digest mismatch、revision gap、duplicate/ambiguous JSON 测试工具。 | + +`agent/es` 不负责: -agent 不拥有 Memoh 的 session、queue schema、admission、owner、fencing、R0/R1 或数据库类型。它也不组装产品 prompt,不执行 scheduler,不保存产品 memory。 +```text +ModelStep / ToolStep / ToolCall / RunEnded +Session MessageAdded / Compact / Artifact +Queue claim / lease / visibility timeout +Runtime.Commit / ExecutionGrant +Planner / provider / tool execution +产品 policy +``` -#### 2.2.1 本轮修改的 rationale:持久化类型必须由 agent 拥有 +建议 API 形态是泛型和 hook,而不是把 domain 类型塞进 `es`: -Runtime 的 authority boundary 不能靠“调用者不要修改快照”这类规范约束来成立,必须由类型和提交路径保证。旧设计把 `sdk.Request`、`sdk.ModelResult`、`sdk.ToolDefinition`、`sdk.Usage` 及其内部的 `map`、`slice`、`json.RawMessage`、`any`、interface value 直接或浅拷贝地放进 command/fact/state,会产生三类破坏: +```go +package es -1. **aliasing/mutation**:Planner、provider 或测试代码在 `Commit` 后继续修改 SDK 对象,可能改变已返回 snapshot、已保存 transition 或后续 `EvolveVersion` 输入,使 `state = fold(initial, flatten(transitions))` 不再成立。 -2. **digest/replay nondeterminism**:digest 在提交时按一组字节计算,但持久化对象仍引用 caller-owned map/slice/RawMessage;之后对象变化会让相同 `CommandID` 的重放、`CommitAlreadyApplied` 判断、StepID 派生和 replay 校验失真。 -3. **unbounded SDK surface**:`sdk.MessagePart` interface、provider metadata 的 `map[string]any`、tool/schema 的原始 JSON 等属于 provider/transport 边界数据,不能成为 Machine 协议的永久 wire contract。 +type StreamID string +type EntryID string +type Revision uint64 +type Index uint16 +type Digest string +type CausationID string -因此 agent 必须定义自己的 JSON-stable persisted value:`ModelRequest`、`ModelResult`、`ToolDefinition`、`Usage`、`Message`、`MessagePart`、`ProviderMetadata` 等。Loop/provider 边界仍使用 `sdk.Request`/`sdk.ModelResult`;进入 Runtime 前必须调用 `FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`,把动态 JSON parse/canonicalize 成 opaque `CanonicalJSON`;离开 Runtime 调 provider 时通过 `.SDK()` 构造新的 SDK 值。`DigestRequest`/`DigestToolDefinition` 只接受 frozen agent value。`EvaluateCommit` 和 Runtime 实现保存 facts/state 前还必须 snapshot 自己拥有的 value,`MemoryRuntime.Commit` 以已保存事件重新 fold 出 authority state,而不是信任 caller-provided `decision.NewState`。 +type Event[T any] struct { + SchemaVersion uint16 + StreamID StreamID + Revision Revision + Index Index + Type string + // CausationID links this event to the operation/event that caused it. + // It is opaque to es; the domain/application defines its interpretation. + CausationID CausationID + Payload T + PayloadDigest Digest +} -这不是防御性 deep clone 的局部补丁,而是分层边界:`sdk` 是一次调用的 transport API,`agent` 是可重放、可审计、可长期兼容的事件协议。任何新增进入 AgentEvent/MachineState 的 provider 数据,必须先落到 agent-owned sealed/JSON-stable 类型或 `CanonicalJSON`;不得把新的 SDK interface、`any`、`json.RawMessage` 或可变引用跨过 Runtime authority boundary。外部 bytes 的检测只能发生在 codec/Freeze 边界;边界之后 agent core 只看已构造的 immutable value。 +type Record[T any] struct { + SchemaVersion uint16 + StreamID StreamID + Revision Revision + Events []Event[T] + RecordDigest Digest +} -### 2.3 Memoh native runtime +type Codec[T any] interface { + Type(T) string + DigestPayload(schemaVersion uint16, typ string, payload T) (Digest, error) +} -Memoh 负责产品和 durable 层: +type Folder[S any, E any] func(schemaVersion uint16, state S, event E) (S, error) + +func BuildRecord[T any](codec Codec[T], events []Event[T]) (Record[T], error) +func ValidateRecord[T any](codec Codec[T], record *Record[T]) error +func FoldRecords[S any, E any](initial S, records []Record[E], folder Folder[S, E]) (S, Revision, error) +``` + +具体字段名可以在实现时调整,但边界原则固定:`es` 提供 append-only/replay 的机械不变量;domain 提供 type discriminator、payload encoding、Evolve 语义和 store adapter。 + +### 2.3 `agent/run` + +`agent/run` 是当前 root `agent` 包的目标形态。它是单次 Run 的 execution ES,不是 Session ES。 + +`agent/run` 负责: | 能力 | 内容 | | --- | --- | -| context | system prompt、memory、compaction、attachments、workspace 和产品 metadata。 | -| Request Planner | 把 context、history 和 queue-safe 输入投影成边界 `sdk.Request`;Loop 冻结成 agent `ModelRequest`。 | -| durable history | messages、ModelStep record、ToolStep progress 和 tool result record。 | -| queue | steer/follow-up 的入队、accepted order、重排、claim、apply、取消和 admission。 | -| session/run | admission、R0/R1、settled 和后续 Run。 | -| ownership | owner、fencing、lease、liveness 和 takeover。 | -| MemohRuntime | agent.Runtime 的 PostgreSQL adapter。 | -| durable events | AgentEvent、outbox、审计事实和恢复所需的 projection。 | +| Run Machine | `MachineState`、`Step`、`ToolCallState`、`AgentCommand`、`Fact`、`AgentEvent` 和共享的 Decide/Evolve/Next 规则。 | +| Transition | 把一次 accepted command 产生的完整 fact/event group 封装为 `TransitionRecord`。实现上可直接使用 `agent/es.Record` 或在其上包 domain metadata。 | +| RunHeader | 正式持久协议的一部分:RunID、initial state/schema/digest、admission causation/provenance。完整 authority 是 `RunHeader + TransitionRecord log`。 | +| Runtime contract | `Load`/`Commit` authority 接口;所有语义状态变化只通过 Commit。 | +| Loop | 唯一的多步执行算法:解释 Effect,调用 Model/Tool,再提交结果 command。 | +| Tool contract | ToolRef、ExecutableTool、参数校验、结果分类和 response policy。 | +| Planner port | 供 Loop 注入 application Request Planner 的最小接口;规划实现不在 run。 | +| EventSink | canonical run event 的实时观察出口,也承载 provisional delta;它不是 authority。 | +| MemoryRuntime | 进程内 reference runtime;它实现同一 Run ES 语义,但不承诺跨进程 durable recovery。 | +| runtimetest | durable runtime 必须复用的 conformance suite。 | + +`agent/run` 不拥有: + +```text +Session history / compact / memory / artifacts +Queue admission / accepted order / claim policy +Product prompt/context/workspace construction +Provider clients / API keys +Durable DB schema / owner/fence/lease/outbox implementation +``` + +Run source of truth: + +```text +RunHeader(initial MachineState at Revision 0) ++ +TransitionRecord log (Revision 1..N) +``` + +状态不变量: + +```text +MachineState_N = Fold(initial, flatten(TransitionRecord[1..N].Events)) +``` + +Run ES 是短生命周期 authority。Run 完成后可以 archive/GC,但前提是需要跨 Run 保留的语义已经 materialize 到长期 domain。 + +### 2.3.1 run-owned persisted model data + +Runtime 的 authority boundary 不能靠“调用者不要修改快照”这类规范约束来成立,必须由类型和提交路径保证。`sdk.Request`、`sdk.ModelResult`、`sdk.ToolDefinition` 及其内部的 `map`、`slice`、`json.RawMessage`、`any`、interface value 属于 transport boundary,不能直接进入 `run` 的 command/fact/state/event。 + +因此 `agent/run` 必须定义自己的 JSON-stable persisted value:`ModelRequest`、`ModelResult`、`ToolDefinition`、`Usage`、`Message`、`MessagePart`、`ProviderMetadata` 等。Loop/provider 边界仍使用 `sdk.Request`/`sdk.ModelResult`;进入 Runtime 前必须调用 `FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`,把动态 JSON parse/canonicalize 成 opaque `CanonicalJSON`;离开 Runtime 调 provider 时通过 `.SDK()` 构造新的 SDK 值。 + +这不是防御性 deep clone 的局部补丁,而是分层边界:`sdk` 是一次调用的 transport API,`agent/run` 是可重放、可审计、可长期兼容的 execution event protocol。任何新增进入 Run event/MachineState 的 provider 数据,必须先落到 run-owned sealed/JSON-stable 类型或 `CanonicalJSON`。 + +### 2.4 `agent/session` + +`agent/session` 是长期语义历史的 append-only ES substrate。它不写死 Twilight/Memoh 的 message ontology,而是用泛型承载上层语义事件: + +```go +package session + +type SessionID string +type EntryID string +type BranchID string + +// Session is a typed view of one semantic stream. The package owns stream +// mechanics, not the meaning of E. +type Session[E any] struct { + ID SessionID + BranchID BranchID + Head EntryID + Revision es.Revision +} + +type Entry[E any] struct { + SessionID SessionID + EntryID EntryID + BranchID BranchID + Parent EntryID + Revision es.Revision + SchemaVersion uint16 + Type string + CausationID es.CausationID + Payload E + Digest es.Digest +} + +type Store[E any] interface { + Append(context.Context, AppendRequest[E]) (Entry[E], error) + Head(context.Context, SessionID, BranchID) (EntryID, es.Revision, error) + Replay(context.Context, SessionID, BranchID) ([]Entry[E], error) + Fork(context.Context, ForkRequest) (BranchID, error) +} +``` + +上层可以定义自己的 session event,例如: + +```text +UserMessageAdded +AssistantMessageAdded +ToolResultAdded +CompactCreated +ArtifactLinked +MemoryUpdated +Custom product event +``` + +`agent/session` 只保证: + +```text +append-only +revision/head CAS +parent/fork lineage +causation id / digest / schema version +replay order +``` + +它不决定: + +```text +什么算 assistant message +哪些 Run event 应该变成长期消息 +compact 策略 +memory extraction 策略 +artifact retention 策略 +``` + +这些属于 Application/Product。 + +### 2.5 `agent/queue` + +`agent/queue` 是通用 transactional queue/claim/dedup 机制,不默认使用 ES。Queue 回答“哪些工作待处理、谁拥有处理权、如何去重/过期/重排”,不是长期语义历史。 + +建议 API 形态: + +```go +package queue + +type QueueID string +type ItemID string +type ClaimID string +type DedupKey string + +type Item[T any] struct { + QueueID QueueID + ItemID ItemID + Payload T + PayloadDigest es.Digest + DedupKey DedupKey + CausationID es.CausationID +} + +type Store[T any] interface { + Enqueue(context.Context, EnqueueRequest[T]) (Item[T], error) + Claim(context.Context, ClaimRequest) (Claim[T], error) + Ack(context.Context, ClaimID) error + Release(context.Context, ClaimID, ReleaseReason) error +} +``` -### 2.4 类型归属 +`SteerItem`、`FollowUpItem`、`RunQueueItem` 等不是 queue core 语义,而是 application payload: + +```text +queue[T] 通用机制 + ↑ +SteerItem / FollowUpItem / RunQueueItem 上层类型 + policy +``` + +Queue 可以被 Memoh durable adapter 放进数据库事务,也可以有 in-memory implementation;但它不进入 Run Machine,不成为 `MachineState` 字段。 + +### 2.6 Cross-domain materialization and finalization + +Run ES 可以短命的前提,是其中所有需要跨 Run 保留的语义都已经 materialize 到长期 domain: + +```text +Run ES +ModelStepCompleted +ToolCallCompleted +RunEnded + │ + ├──→ Session ES + │ AssistantMessageAdded + │ ToolResultAdded + │ + ├──→ Artifact Store + │ files / large outputs / reasoning artifact + │ + └──→ Usage / Trace + cost / latency / debug +``` + +Run → Session/Artifact/Usage 的投影不能是 best-effort listener。Durable 场景必须有 transaction/outbox/inbox 或等价机制,并使用稳定 `causation_id` 做幂等: + +```text +Run source event ID = run_id + revision + index(或其 canonical digest) +Session source_event_id = same stable identity; unique/inbox key +Artifact source_event_id = same stable identity; unique/inbox key +Usage source_event_id = same stable identity; unique/inbox key + +Run event causation_id = inherited application/session/queue/Run cause +Session causation_id = preserved Run event causation_id +``` + +必须存在 finalization barrier: + +```text +RunEnded + ↓ +确保长期语义、artifact、usage/outbox 已提交或可幂等恢复 + ↓ +RunFinalized control-plane marker + ↓ +Run ES 才允许 archive / GC +``` + +`RunFinalized` 是 run storage/control-plane marker,不是 `agent/run.Fact`,不参与 `MachineState` fold。 + +### 2.7 类型归属 | 类型或能力 | 所属层 | | --- | --- | -| `sdk.Request`、`sdk.ModelResult`、`sdk.ToolDefinition`(provider-neutral 边界类型) | sdk | -| `ModelRequest`、`ModelResult`、`ToolDefinition`、`Usage`、`Message`、`MessagePart`(persisted frozen values) | agent | -| `Step`、`ToolCallState`、`AgentCommand`、`RunSeed`、`AgentEvent`、`Effect` | agent | -| `ExecutableTool`、参数校验、response policy | agent | -| `RequestPlanner` port、`PlanningHint`、`RequestPlan` | agent;只用于依赖注入 | -| Request Planner 实现和 context transformer | Memoh/application | -| queue、history、session、R0/R1、owner、fencing、outbox | Memoh | -| MCP server 连接和生命周期 | Memoh/application;schema/call adapter 在 agent | +| `sdk.Request`、`sdk.ModelResult`、`sdk.ToolDefinition`(provider-neutral 边界类型) | `sdk` | +| canonical JSON parser/value | `agent/jsonstable`(可被 `es`、`run`、`session` 使用) | +| generic event envelope/record/fold/digest mechanics | `agent/es` | +| `ModelRequest`、`ModelResult`、`ToolDefinition`、`Usage`、`Message`、`MessagePart`(run persisted frozen values) | `agent/run` | +| `Step`、`ToolCallState`、`AgentCommand`、`Fact`、`AgentEvent`、`TransitionRecord`、`Effect` | `agent/run` | +| `Runtime`、`MemoryRuntime`、`Loop`、`runtimetest` | `agent/run` | +| `SessionID`、`EntryID`、session entry envelope、fork/replay/store | `agent/session` | +| queue item/claim/dedup/visibility mechanism | `agent/queue` | +| Request Planner 实现和 context transformer | Application/Product | +| fixed model policy、step budget、malformed retry budget | Application/Product 或 run `ExecutionPolicy`;不进入 MachineState | +| owner、fencing、lease、outbox、DB schema | Durable adapter/Application | +| MCP server 连接和生命周期 | Application;schema/call adapter 可在 `agent/run` | | provider transport retry | sdk/provider client | -## 3. Agent Machine +## 3. Run Machine ### 3.1 Machine 的边界 -Machine 是 agent package 中的纯语义规则。它只读取完整 `MachineState`、待决策的 `AgentCommand` 和待折叠的 `AgentEvent`,不访问 IO: +Machine 是 `agent/run` package 中的纯 execution 语义规则。它只读取完整 `MachineState`、待决策的 `AgentCommand` 和待折叠的 `AgentEvent`,不访问 IO: ```text MachineState + AgentCommand @@ -316,7 +586,7 @@ Machine.Evolve 逐个折叠(机械) -> new MachineState Machine.Next(state) -> Effect ``` -`Next(state)` 根据当前事实产生至多一个待执行的 `Effect`。`Decide(state, command)` 校验一个意图并产出这次 transition 的完整事实序列——所有决策(是否接受、派生哪些后果、是否进入终态)都发生在这里,且只在提交时运行一次,输出即冻结。`Evolve(state, event)` 把单个事实机械地折叠进状态:它不读 RunConfig/Loop policy,不含 IO,对 Decide 产出的每种事实全定义;replay 只依赖 versioned Evolve。Runtime 接受一个 command 后,把 Decide 的事实序列包装为同一 Revision 的 AgentEvent 组,与折叠后的新状态放在同一提交边界。两种 Runtime 不能各自复制规则;Loop 重新 `Load` 后再次调用 `Next`,不会依赖一次提交响应中的 effect。 +`Next(state)` 根据当前事实产生至多一个待执行的 `Effect`。`Decide(state, command)` 校验一个意图并产出这次 transition 的完整事实序列——所有决策(是否接受、派生哪些后果、是否进入终态)都发生在这里,且只在提交时运行一次,输出即冻结。`Evolve(state, event)` 把单个事实机械地折叠进状态:它不读产品配置或 Loop policy,不含 IO,对 Decide 产出的每种事实全定义;replay 只依赖 versioned Evolve。Runtime 接受一个 command 后,把 Decide 的事实序列包装为同一 Revision 的 AgentEvent 组,与折叠后的新状态放在同一提交边界。两种 Runtime 不能各自复制规则;Loop 重新 `Load` 后再次调用 `Next`,不会依赖一次提交响应中的 effect。 决策与折叠的分工是协议的兼容边界:Machine 的决策规则(自动关闭、终态转换、command disposition)可以随版本演进,因为历史事件已把这些决策的结果记录在案;Evolve 与事件编码一起构成永久兼容契约,已发布 SchemaVersion 的折叠语义不再修改。 @@ -338,7 +608,7 @@ terminal follow-up `AgentInput` 只有稳定的 `InputID` 和不可变 payload,不包含 queue item、priority、order、claim 或 lease。`NextStep` 构造 `AcceptInput` command;被 Runtime 接受后产出 `InputAccepted` 事实,与新的 MachineState 一起提交,Memoh 可以在同一事务中把 queue claim 标记为 applied。 -`NextRun`/`RunSeed` 只保留为 legacy admission helper;新 core admission 使用 `InitializeRun(RunID)` 建立最小 Revision-0 state,然后把初始用户输入作为该 Run 的第一条 `AcceptInput` transition 提交。Memoh 仍负责 queue claim、session admission 和新 Run 的身份分配;admission 可以复用 digest/canonical 编码规则审计 seed,但普通 Run replay 不依赖重新计算 RunSeed。 +Run admission 使用 `InitializeRun(RunID)` 建立最小 Revision-0 state,然后把初始用户输入作为该 Run 的第一条 `AcceptInput` transition 提交。Application 负责 Session/Queue admission 和新 Run 的身份分配;Run replay 不依赖从当前产品配置重新计算 initial state。 Machine 不在 ToolStep 执行中接受 `NextStep`,也不把 terminal follow-up 解释成旧 Run 的状态变化。输入的具体文本如何进入 Planner 生成的 `sdk.Request` 仍由 Request Planner 决定;Loop 在提交前冻结为 agent `ModelRequest`,Machine 只保证输入边界和一次性接受语义。 @@ -359,7 +629,7 @@ model-step counter terminal RunResult(如果已结束) ``` -MachineState 不包含固定模型、prompt/history、step limit、malformed retry limit 或产品 RunConfig。模型选择由每个 `ModelStepPrepared` 冻结的 `ModelRequest.Model` 表示;limits 属于 host/Loop policy,并通过显式 command 形成事实。 +MachineState 不包含固定模型、prompt/history、step limit、malformed retry limit 或产品配置。模型选择由每个 `ModelStepPrepared` 冻结的 `ModelRequest.Model` 表示;limits 属于 host/Loop policy,并通过显式 command 形成事实。 权威语义状态不包括数据库 row、transaction、owner、fence、lease、Attempt 或 queue claim。Runtime 可以保存这些控制元数据,但它们不进入 MachineState。 @@ -445,7 +715,7 @@ RunEnded 终态:Status 为 RunCompleted、RunStopped 或 RunFai `AgentEvent` 与新的 `MachineState` 在同一个原子提交中写入,具有 authority 分配的 (Revision, Index) 身份、canonical digest 和产生它的 CommandID。`AgentEvent` 可供 replay、projection、审计和 OpenTelemetry 使用。 -`RunSeed` 属于 legacy 新 Run admission helper,不是 command。新 API 倾向将初始输入也作为 `AcceptInput` transition 记录;如果 Memoh 需要审计 legacy admission seed,可以复用相同的 identity/digest 编码规则,但该记录由 admission 事务保存,不经过旧 Run 的 `Runtime.Commit`。 +新 Run 的初始用户输入也必须作为 `AcceptInput` transition 记录,不存在绕过 Runtime.Commit 的 seed input。Run 创建本身由 `RunHeader` 表达;输入接受属于 Run ES。 二者的关系固定为: @@ -491,13 +761,13 @@ WaitForExecutionRecovery 11. 使 ToolStep 内最后一个 Call 到达可关闭终态的 command,其事实序列追加 `ToolStepClosed`。ToolStep 关闭前不能创建下一 ModelStep。 12. `CancelRun` 只能作用于非 terminal Run,产出 `[RunEnded{RunStopped, cancelled}]`;`StopRun` 只能记录 host-owned 非取消停止原因,目前为 `[RunEnded{RunStopped, step_limit}]`。 13. `AcceptInput` 只能作用于 active 且没有当前 Step 的 Run,且 `InputID` 不得与当前 `PendingInputs` 中任何输入重复;重复 ID 是 identity conflict,不能记录一个 Evolve 会丢弃的 no-op fact。接受后产出 `[InputAccepted]`。Planner 由 `PlanningHint.Inputs` 收到这些输入并在 `RequestPlan.InputIDs` 中明确消费它们;遗漏或伪造 ID 的 `PrepareModelRequest` 被拒绝。 -14. `InitializeRun(RunID)` 只在 application admission 创建新 Run 时使用;它建立最小初始 `MachineState`(Revision=0),不包含 seed input、fixed model 或 limits。兼容 helper `Initialize(RunID, RunConfig, RunSeed)` 仍可构造带 seed input 的 legacy initial state,但 `RunConfig` 不进入 MachineState。 +14. `InitializeRun(RunID)` 只在 application admission 创建新 Run 时使用;它建立最小初始 `MachineState`(Revision=0),不包含 initial input、fixed model、limits 或产品配置。Application 随后通过正常 `Runtime.Commit` 提交首个 `AcceptInput`。 `InputID` 在一个 Run 内唯一。相同 `InputID` 和相同 payload 的重复 `AcceptInput` 是语义 no-op,Runtime 返回原已接受的事件组;相同 ID 携带不同 payload 返回冲突。Memoh 的 queue claim 仍负责防止同一个 queue item 被多个输入入口同时消费。 #### 3.7.2 Evolve 折叠表(事实 -> 状态) -`Evolve(state, event)` 对每种事实执行固定的机械折叠,不读 RunConfig/Loop policy,不含 policy 分支: +`Evolve(state, event)` 对每种事实执行固定的机械折叠,不读产品配置或 Loop policy,不含 policy 分支: | 事实 | 折叠 | | --- | --- | @@ -678,14 +948,14 @@ Runtime 只回答两个问题: 因此 Runtime 的接口很小,但一次 `Commit` 的事务范围可以很大:它必须让一个 AgentCommand、产出的 `TransitionRecord` 及其必要的产品投影一起成功或一起失败;这不意味着 Runtime 获得了 history、queue 或 prompt 的所有权。`CommitResult.Events` 只是该 transition 的事件流视图,方便 Loop、UI 和 observability 消费;Runtime 的 authority storage 是完整 transition aggregate。 -Run 语义状态的 source of truth 是 admission-created immutable initial state(Revision=0)加 `TransitionRecord` log;MachineState 是必需的同事务 projection(execution cache):提交验证与 Loop 执行从它读取,因此它必须与 transition log 在同一原子提交内更新,但它可以从日志重建。对任意 Revision N,状态必须等于初始状态按 `flatten(TransitionRecord[].Events)` 调用 versioned `Evolve` fold 到 N 的结果——这是可自动恢复的不变量,不是 halt 条件。 +Run 语义状态的 source of truth 是 immutable `RunHeader` 加 `TransitionRecord` log;`RunHeader` 固化 Revision-0 initial state 及其 schema/digest/admission causation。MachineState 是必需的同事务 projection(execution cache):提交验证与 Loop 执行从它读取,因此它必须与 transition log 在同一原子提交内更新,但它可以从 RunHeader 和日志重建。对任意 Revision N,状态必须等于 `RunHeader.InitialState` 按 `flatten(TransitionRecord[].Events)` 调用 versioned `Evolve` fold 到 N 的结果——这是可自动恢复的不变量,不是 halt 条件。 这个权威声明成立的三个稳定条件(本规范的规范性条款): 1. Event ontology 稳定:Fact 词表 sealed,已发布 SchemaVersion 的事实结构永不修改,新增字段进入新版本。 -2. Evolve 语义稳定:折叠是机械的(不读 RunConfig/Loop policy、无 IO),已发布版本的折叠语义与事件编码一起永久冻结;replay 通过 `EvolveVersion(SchemaVersion, state, fact)` 选择历史语义,会演进的决策语义全部在 Decide,其结果记录为事实。 +2. Evolve 语义稳定:折叠是机械的(不读产品配置/Loop policy、无 IO),已发布版本的折叠语义与事件编码一起永久冻结;replay 通过 `EvolveVersion(SchemaVersion, state, fact)` 选择历史语义,会演进的决策语义全部在 Decide,其结果记录为事实。 3. 事实自包含:折叠一条事实所需的全部信息在事实自身与折叠前状态之内,不访问外部系统,不重新计算依赖当前代码版本的派生值(digest 一律在 Decide 时算好并携带在事实中)。 -4. 持久化值归属稳定:AgentEvent 和 MachineState 只保存 agent-owned frozen values;任何来自 SDK/provider/application 的引用在进入 Runtime 前必须被 canonicalize + detach,返回给 caller 的 snapshot/event 也必须是独立副本。 +4. 持久化值归属稳定:Run event 和 MachineState 只保存 run-owned frozen values;任何来自 SDK/provider/application 的引用在进入 Runtime 前必须被 canonicalize + detach,返回给 caller 的 snapshot/event 也必须是独立副本。 Runtime 实现还必须在代码层面维护这些条件:`EvaluateCommit` 对 Decide 产出的 facts 做 `snapshotFact` 后再 fold/persist,并构造带 transition digest 的 `TransitionRecord`;`Load`、`CommitResult` 和 AlreadyApplied replay 返回的 snapshot/event 不得共享 authority 内部引用;MemoryRuntime 这类参考实现保存 accepted transition 后,从 stored transition 的 events fold 出新的 authority state,而不是直接保存调用栈里算出的 `decision.NewState`。durable adapter 可以用数据库事务替代 mutex,但不能把未冻结 SDK 对象、浅拷贝 snapshot 或 caller-owned bytes 写入事件表/状态表。 @@ -706,9 +976,34 @@ transitionLog.maxRevision < watermark,或尾部 transition 的 digest/事件 主动 truncate snapshot 强制全量重建是合法运维操作(例如 MachineState 存储布局变更时替代迁移)。水位与日志同库整体回退(全量备份恢复)不在检测范围内:内部自洽的一致回退需要外部见证,v1 不做。 -一个 Runtime 实例服务一个 Run;多个 Run 由上层创建多个 Runtime 实例。Run 的创建、身份分配和初始 -`MachineState` 由 application/Memoh admission 完成,Runtime 从一个已经有效的初始状态开始。 -Loop 是对这个 Run 的一次进程执行。 +一个 Runtime 实例服务一个 Run;多个 Run 由上层创建多个 Runtime 实例。Run 的创建和身份分配由 Application admission 完成;admission 调用 `InitializeRun`,构造并原子保存 `RunHeader`。Runtime 从这个已存在的 header 开始,Loop 是对该 Run 的一次进程执行。 + +### 5.1.1 RunHeader + +`RunHeader` 是正式持久协议,不是可从当前代码、当前默认值或当前产品配置重新生成的临时参数: + +```go +type RunHeader struct { + SchemaVersion uint16 + RunID RunID + InitialStateVersion uint16 + InitialState MachineState + InitialStateDigest es.Digest + CausationID es.CausationID // creating session/queue/application operation + HeaderDigest es.Digest +} +``` + +规范性要求: + +1. `RunHeader` 创建后 immutable;普通 `Runtime.Commit` 不修改它。 +2. `InitialState` 必须是 `InitializeRun(RunID)` 产生的最小 Revision-0 state,不包含 initial input、fixed model、limits、session history 或产品配置。 +3. initial input 通过 Revision 1 的正常 `AcceptInput` transition 提交。 +4. `InitialStateDigest` 绑定 initial state 的 canonical wire bytes;`HeaderDigest` 绑定 header 中除自身外的全部协议字段。 +5. durable adapter 必须让 header 创建、Run identity admission 和必要的 application causation 记录原子或可幂等恢复。 +6. local Run 上传/迁移时传输 `RunHeader + TransitionRecord log + referenced artifacts`;目标 runtime 先验证 header/log,再重建 projection,不能信任上传的 MachineState snapshot。 + +`RunHeader.CausationID` 只提供跨 domain 关联,不让 `agent/run` import `agent/session` 或 `agent/queue` 的具体 ID 类型。Application 可以维护 SessionID/EntryID/QueueItemID 到 causation ID 的映射。 ### 5.2 最小接口 @@ -725,7 +1020,7 @@ type Runtime interface { 请求构造不在 Runtime 接口中。Loop 使用 `NeedModelRequest` 提示调用 Request Planner,再把完整请求作为 `PrepareModelRequest` 提交。 -Runtime 的语义范围仍然只有 authority 和 commit。MemohRuntime 为了保持 AgentEvent、MachineState、history projection 和 outbox 的一致性,可以在自己的数据库事务内一起写入这些投影;这不是 Runtime 对外暴露的通用业务 API,也不让 Runtime 获得 prompt、queue 或 history 的所有权。 +Runtime 的语义范围仍然只有 authority 和 commit。Durable application adapter 为了保持 Run transition、MachineState projection、Session materialization outbox 和 artifact/usage projection 的一致性,可以在自己的数据库事务内一起写入这些投影;这不是 Runtime 对外暴露的通用业务 API,也不让 Runtime 获得 prompt、queue 或 session history 的所有权。 ### 5.3 Snapshot、Commit 和执行授权 @@ -750,6 +1045,8 @@ type CommandEnvelope struct { Type string RunID RunID ID CommandID + // Opaque cross-domain lineage; Run Machine does not interpret it. + CausationID es.CausationID Digest Digest Command AgentCommand } @@ -767,6 +1064,7 @@ type AgentEvent struct { Index uint16 CommandID CommandID CommandDigest Digest // digest of the accepted command; idempotent replay compares against it + CausationID es.CausationID // inherited from accepted command Digest Digest // canonical digest of this fact Fact Fact } @@ -794,12 +1092,13 @@ func EncodeCommand(CommandEnvelope) ([]byte, error) // 不包含 Digest 字段 func DigestCommand(schemaVersion uint16, typ string, command AgentCommand) (Digest, error) func EncodeFact(schemaVersion uint16, typ string, fact Fact) ([]byte, error) func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) +func BuildRunHeader(RunID, es.CausationID) (RunHeader, error) +func ValidateRunHeader(*RunHeader) error +func DigestRunHeader(*RunHeader) (es.Digest, error) func BuildTransitionRecord([]AgentEvent) (TransitionRecord, error) func ValidateTransitionRecord(*TransitionRecord) error func DigestTransitionRecord(*TransitionRecord) (Digest, error) -func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineState, uint64, error) -func EncodeRunSeed(RunSeed) ([]byte, error) -func DigestRunSeed(schemaVersion uint16, seed RunSeed) (Digest, error) +func FoldTransitions(header RunHeader, records []TransitionRecord) (MachineState, uint64, error) func DigestRequest(ModelRequest) (Digest, error) func DigestToolDefinition(ToolDefinition) (Digest, error) func DigestToolSpec(ToolSpec) (Digest, error) @@ -813,9 +1112,9 @@ func DeriveResponseCommandID(RunID, StepID, CallID, ResponseID) CommandID func DeriveInputCommandID(RunID, InputID) CommandID ``` -`ExecutionGrant` 是 Runtime 返回的 opaque capability,只用于证明当前 Loop 获得了执行许可;调用方只保存并原样传回,不依赖其内容。它不是 Step 的业务字段,不暴露 AttemptID、FenceToken、lease 或数据库类型,也不是用户认证凭证。Runtime 必须生成不可预测且绑定到单个 Step/Call 和当前执行所有者的值,并在完成 command 中校验它。MemoryRuntime 可以用 mutex 加随机 generation 实现它;MemohRuntime 可以用 owner/fence/lease 实现它。 +`ExecutionGrant` 是 Runtime 返回的 opaque capability,只用于证明当前 Loop 获得了执行许可;调用方只保存并原样传回,不依赖其内容。它不是 Step 的业务字段,不暴露 AttemptID、FenceToken、lease 或数据库类型,也不是用户认证凭证。Runtime 必须生成不可预测且绑定到单个 Step/Call 和当前执行所有者的值,并在完成 command 中校验它。`run.MemoryRuntime` 可以用 mutex 加随机 generation 实现它;durable adapter 可以用 owner/fence/lease 实现它。 -`Attempt` 只表示一次进程对当前 ModelStep 或 ToolCall 的执行占用,不是 MachineState,也不是恢复边界。一个 Step 可以先后有多个 Attempt;旧 Attempt 失效后,新的 Loop 重新读取同一个 Step。MemoryRuntime 的 Attempt 是内存中的占用记录,MemohRuntime 的 Attempt 是私有 lease/fence 记录。 +`Attempt` 只表示一次进程对当前 ModelStep 或 ToolCall 的执行占用,不是 MachineState,也不是恢复边界。一个 Step 可以先后有多个 Attempt;旧 Attempt 失效后,新的 Loop 重新读取同一个 Step。MemoryRuntime 的 Attempt 是内存中的占用记录,durable adapter 的 Attempt 是私有 lease/fence 记录。 grant 规则固定为: @@ -872,7 +1171,7 @@ func EvaluateCommit( `EvaluateCommit` 用 `DecisionKind` 表达结果;`Runtime.Commit` 把非成功结果映射为对外错误:`DecisionConflict -> ErrCommandConflict`、`DecisionStale -> ErrStaleRuntime`、`DecisionTerminal -> ErrRunTerminal`。Loop 与 ingress 只依赖这三个错误值和 `CommitStatus`,不接触 DecisionKind。 -新 Run admission 使用 `InitializeRun(RunID)` 建立最小 `MachineState`(Revision=0);初始用户输入应作为该 Run 的第一条 `AcceptInput` transition 进入日志。`RunSeed`/`Initialize(RunID, RunConfig, RunSeed)` 仅保留 legacy helper,`RunConfig` 不进入 MachineState、AgentEvent 或 TransitionRecord。普通 Runtime 只实现已有 Run 的 `Load/Commit`,admission 路径负责创建 RunID 与持久化 initial state。 +新 Run admission 使用 `InitializeRun(RunID)` 建立最小 `MachineState`(Revision=0);初始用户输入作为该 Run 的第一条 `AcceptInput` transition 进入日志。不存在持久化 `RunConfig` 或绕过 Commit 的 `RunSeed`。普通 Runtime 只实现已有 Run 的 `Load/Commit`,admission 路径负责创建 RunID、构造并持久化 `RunHeader`。 Commit 的 effectively-once 只针对状态提交。事务已提交但响应丢失时,Loop 使用相同 CommandID 和 digest 重放;`CommitAlreadyApplied` 不能重新授予工具执行权,也不能重复 history、queue action、计数或 outbox。对于 start command,AlreadyApplied 的 `Grant` 必须为空;新 Loop 要等待原执行或 recovery,不能把重放当成新的 start。外部工具 effect 不因此变成 exactly-once。 @@ -931,7 +1230,7 @@ canonical 编码和 digest 函数由 agent 提供;Memoh 只保存和比较结 已发布 `SchemaVersion` 的 canonical 编码和 digest 规则永久冻结;字段增删只能进入新的 SchemaVersion,旧事件按其自带版本校验。同一个 Run 不允许由写入不同 SchemaVersion 的进程混跑:升级窗口内先全量部署可读写新版本的代码,再开始写入新版本;否则同一 command 的重放会因编码不同被误判为 `ErrCommandConflict`。 -CommandEnvelope 和 AgentEvent 的 `SchemaVersion` 和 `Type` 是持久化协议字段;Type 必须与 sealed AgentCommand/Fact 的具体变体一致,未知版本或类型直接拒绝。agent 必须提供正式 wire codec:decode 时先 canonicalize 整个 document,再读 `Type`,恢复具体 command/fact variant,校验 command/fact digest,并要求 decoded value 重新 canonical marshal 后与输入 canonical document 等价;不能依赖 `encoding/json` 自动反序列化 interface 字段,也不能接受 duplicate key、大小写模糊字段名或其他会被 Go decoder 合并/宽容的形态。`DigestCommand`/`DigestFact` 对 `SchemaVersion`、`Type` 和内容做 canonical digest,但不把 `Digest` 字段自身纳入摘要,保证 Memoh scanner、MemoryRuntime 和不同进程使用同一身份规则。`Revision` 只用于 authority 的 CAS,不进入任何 digest。 +CommandEnvelope 和 AgentEvent 的 `SchemaVersion`、`Type`、`CausationID` 是 Run 持久化协议字段;Type 必须与 sealed AgentCommand/Fact 的具体变体一致,未知版本或类型直接拒绝。`CausationID` 是跨 domain 的 opaque lineage:Application 可将 session entry、queue claim、前一 Run event 或 system recovery record 关联到 command;Run Machine 不解释其内容。接受 command 后,其产出的 AgentEvent 继承该 causation ID。`agent/run` 必须提供正式 wire codec:decode 时先 canonicalize 整个 document,再读 `Type`,恢复具体 command/fact variant,校验 command/fact digest,并要求 decoded value 重新 canonical marshal 后与输入 canonical document 等价;不能依赖 `encoding/json` 自动反序列化 interface 字段,也不能接受 duplicate key、大小写模糊字段名或其他会被 Go decoder 合并/宽容的形态。`DigestCommand`/`DigestFact` 对 `SchemaVersion`、`Type`、causation 和内容做 canonical digest,但不把 `Digest` 字段自身纳入摘要,保证 durable scanner、MemoryRuntime 和不同进程使用同一身份规则。`Revision` 只用于 authority 的 CAS,不进入任何 digest。 Evolve 的折叠语义与事件编码同属永久兼容契约:已发布 SchemaVersion 的事件必须永远能被折叠出与写入当时相同的状态。conformance kit 为每个已发布 SchemaVersion 冻结 golden transition stream 与对应的状态字节,任何 versioned Evolve 实现变更都必须通过全部历史版本的 golden 校验。 @@ -999,7 +1298,7 @@ ToolStep 自动关闭 只有在这些 boundary,Memoh 才能把 queue 输入绑定到下一次 Planner 请求或创建后续 Run。ToolStep 中间的新输入不能修改已经冻结的 ModelStep,也不能跳过 Pending/Waiting Call。 -steer 必须进入下一个 ModelStep 的规划上下文,而不是延迟到更晚的边界。Loop 在 boundary 处的 Plan/Prepare 提交与 Memoh 的 queue 仲裁存在竞态;MemohRuntime adapter 用以下 gate 消除它:处理 `PrepareModelRequest` 的同一事务内检查是否存在 eligible 的 steer item,存在时不接受该次 Prepare,先在事务内应用对应的 `AcceptInput`(Revision 递增,Prepare 按 `ErrStaleRuntime` 返回)。Loop 重新 Load 后由 `PlanningHint.Inputs` 携带该输入重新规划。这条 gate 是 adapter 行为,不进入 agent Machine 规则;in-process 宿主没有 queue,输入由宿主在 Loop 空闲边界提交。 +steer 必须进入下一个 ModelStep 的规划上下文,而不是延迟到更晚的边界。Loop 在 boundary 处的 Plan/Prepare 提交与 Application 的 queue 仲裁存在竞态;durable adapter 用以下 gate 消除它:处理 `PrepareModelRequest` 的同一事务内检查是否存在 eligible 的 steer item,存在时不接受该次 Prepare,先在事务内应用对应的 `AcceptInput`(Revision 递增,Prepare 按 `ErrStaleRuntime` 返回)。Loop 重新 Load 后由 `PlanningHint.Inputs` 携带该输入重新规划。这条 gate 是 Application orchestration 行为,不进入 Run Machine 规则;in-process harness 没有 queue 时由宿主在 Loop 空闲边界提交。 ## 6. Loop 算法 @@ -1273,7 +1572,7 @@ Pending + Waiting ### 7.1 Tool contract -`sdk.ToolDefinition` 只描述 provider 可发现的 schema,不依赖 agent,也不携带 `ResponsePolicy`。`agent.ExecutableTool.Definition()` 可以返回 SDK 边界类型,Loop 必须先 `FreezeToolDefinition` 再计算 `DigestToolDefinition` 或写入 `ToolSpec`;MachineState/AgentEvent 中保存的是 agent `ToolDefinition`。`agent.ExecutableTool` 描述应用如何执行工具并提供 response policy;模型返回后,agent 用 `ToolRef`、definition digest 和 policy 生成冻结的 `ToolCallBinding`。恢复时 schema、工具版本或 policy 不匹配都不能静默换版本。 +`sdk.ToolDefinition` 只描述 provider 可发现的 schema,不依赖 run,也不携带 `ResponsePolicy`。`run.ExecutableTool.Definition()` 可以返回 SDK 边界类型,Loop 必须先 `FreezeToolDefinition` 再计算 `DigestToolDefinition` 或写入 `ToolSpec`;MachineState/AgentEvent 中保存的是 run `ToolDefinition`。`run.ExecutableTool` 描述应用如何执行工具并提供 response policy;模型返回后,run 用 `ToolRef`、definition digest 和 policy 生成冻结的 `ToolCallBinding`。恢复时 schema、工具版本或 policy 不匹配都不能静默换版本。 ```go type ExecutableTool interface { @@ -1304,80 +1603,91 @@ ask_user -> Waiting(ExternalResponse) agent 提供 MCP schema/call adapter,把 MCP tool 转换为 `sdk.ToolDefinition` 和 `ExecutableTool`。MCP server 的连接、认证、生命周期和产品权限由 Memoh/application 管理。迁移期可以保留旧 `sdk.MCPClient` wrapper;新 Loop 不依赖 SDK 的 MCP session。 -## 8. MemoryRuntime 与 MemohRuntime +## 8. 同一 Run ES 的 Runtime implementations -### 8.1 MemoryRuntime +### 8.1 `run.MemoryRuntime` -MemoryRuntime 使用 `mutex + MachineState + TransitionRecord log`: +`run.MemoryRuntime` 是 in-process reference runtime: + +```text +RunHeader + mutex + MachineState projection + TransitionRecord log +``` ```text Load 在锁内返回当前 MachineState 和 Revision Commit - 在锁内判定 grant 有效性,调用共享 EvaluateCommit 并保存结果; + 在锁内判定本进程 grant 有效性,调用共享 EvaluateCommit, + 原子保存完整 TransitionRecord、projection、watermark 和幂等索引; 接受 start command 时返回本进程的 opaque ExecutionGrant ``` -MemoryRuntime 不需要 owner、fence、lease、outbox 或 Attempt 表。进程退出后状态可以丢失;它是本地会话、测试和 conformance reference。它仍须在同一把锁内记录每个已接受 start 的执行占用,防止两个本地 worker 同时执行同一个 Call。 - -MemoryRuntime 只保存 agent 的 MachineState、TransitionRecord log 和提交幂等记录;`Events()` 只返回 flatten 后的事件流视图,不自动保存产品 history。需要多轮上下文的 in-process -宿主应在 Runtime 外维护一个内存 history projection,并让自己的 RequestPlanner 读取它;这只是一个轻量的应用层配套,不是 MemoryRuntime 为 durable 语义模拟数据库。 +它可以省掉 durable **机制**: -worker context 的取消不等同于业务取消。若已提交 `StartToolCall` 后 worker 返回 Unknown,Loop 使用不受 worker cancellation 影响的 commit context 提交 `SubmitToolFailure{Outcome: ToolOutcomeUnknown}`;MemoryRuntime 在同一把锁内记录 Unknown 并结束 Run。若当前进程直接退出,MemoryRuntime 不承诺跨进程恢复;仍处于 Executing 的 Call 随内存状态一起丢失。 +```text +lease / fencing / distributed claim / worker heartbeat +DB transaction / durable outbox / crash 后跨机器 recovery +persistent execution queue +``` -ModelStep 的执行授权被取消时,Loop 使用仍有效的 model grant 提交 `RecoverModelExecution`;MemoryRuntime 在同一把锁下把它恢复为 Prepared,下次 Loop 仍使用同一冻结 request。若进程在提交前退出,MemoryRuntime 随内存状态丢失,不承诺跨进程恢复。 +但不能省掉 durable **语义**: -### 8.2 MemohRuntime +```text +RunHeader +TransitionRecord complete append +AgentCommand -> Decide -> Fact[] -> EvolveVersion +execution grant/start barrier +idempotency +replay/fold invariants +``` -MemohRuntime 用 PostgreSQL transaction/CAS 加上 Memoh 私有的 owner/fence/lease: +因此 in-process 和 durable 不是两种 Agent,而是同一 `run.Runtime` contract 的不同实现。Local Run 可以导出: ```text -Load - 只读取 canonical history、MachineState、ToolStep progress 和 session facts - 不创建 Attempt,不取得 lease;这些控制记录不出现在 snapshot - -Commit - 判定内部 owner/fence/lease 的有效性 - 对 StartModelExecution/StartToolCall 在同一事务内建立 Attempt/lease - 调用共享 EvaluateCommit(command identity、Revision、Decide/EvolveVersion) - 原子保存 MachineState、TransitionRecord、history projection、queue action、response record 和 outbox +RunHeader + TransitionRecord log + referenced artifacts ``` -并行 Call 的完成 transition 按提交先后获得递增的 Revision;这不改变模型上下文中的 Call 顺序。Memoh 先按 CallID 保存各自结果,ToolStep 关闭时再按 ModelResult 原始 Call 顺序写入 assistant tool-call 与 tool-result history。 +供 durable adapter 验证、replay 后继续执行。 -Attempt、owner、fence、lease 和数据库 row 不进入 agent public state。它们只保证多个 Loop attempt 不会同时取得同一个 Call 的执行权。持久化的 MachineState snapshot 必须带 adapter 自己的 snapshot schema version;跨版本升级时按该版本解码,或直接 truncate 后从 TransitionRecord log 重建(§5.1),不复用事件的 `SchemaVersion` 字段。每 Run 的 revision 水位与 snapshot 分开存放(state 表中不随重建清空的列,或独立小表),每次 Commit 与 transition log 同事务推进。 +MemoryRuntime 不保存 Session history、context、queue、long-term memory 或 product artifacts。需要多轮上下文的 in-process harness 在 Runtime 外组合 `session` memory store、可选 `queue` memory store、materializer 和 RequestPlanner;这不是把产品 memory 偷塞进 Runtime。 -MemohRuntime 的 worker 实例在构造时绑定当前 worker 的 owner identity;只有该实例可以提交自己接受的 model/tool start、completion 和主动 recovery。response 和 cancel 使用同一个 `Commit` 语义,但由 Memoh 创建不带 worker grant 的 ingress-scoped adapter;这些 command 不取得执行权,因此不需要伪造 Loop owner。新 Run admission 仍由 Memoh 的 admission 事务处理,不调用旧 Run 的 `Runtime.Commit`。 +### 8.2 Durable Run runtime adapter -续租失败不是立即的工具失败。Memoh 先使用 backend-loss/recovery grace 判断旧 owner 是否已经失效;确认 lease 和 grace 都失效后,scanner 在事务中检查该 Executing Call 是否已有已接受结果: +Durable adapter 位于 Application/Product(例如 Memoh),通过 PostgreSQL transaction/CAS 或等价存储实现同一个 `run.Runtime`: ```text -已有结果 -> 只释放旧 lease -没有结果 -> 生成 `ToolCallFailed{Outcome: ToolOutcomeUnknown, Failure: effect_unknown}` 并提交,RunFailed -``` +Load + 读取 RunHeader、MachineState projection、revision 和必要的私有 control-plane metadata + 不创建 Attempt,不取得 lease;control-plane 不出现在 RuntimeSnapshot -scanner 使用由 StepID、CallID 和 system namespace 稳定生成的 CommandID,并遵守与普通 Commit 相同的 digest/idempotency。它只在自己的 recovery record 证明 lease 已失效后通过共享 `EvaluateCommit` 提交 Unknown;普通 Commit 调用者不能伪造这条 system command。Model recovery 也必须通过共享 Decide/Evolve 规则,而不能直接改写 Step 状态。scanner 不查询外部系统,也不重新执行工具。Pending Call 不走 Unknown 路径,可以由新 Loop 继续。 +Commit + 判定私有 owner/fence/lease/recovery record 的有效性 + 对 StartModelExecution/StartToolCall 在同一事务内建立 Attempt/lease + 调用共享 EvaluateCommit + 原子保存 TransitionRecord、MachineState projection、watermark、idempotency index、 + Session/artifact/usage materialization outbox 和必要的 queue claim outcome +``` -ModelStep 的 recovery 规则不同:模型请求没有工具那样的外部业务 effect。ModelStep 的执行 lease 失效后,Memoh 可以在事务中把它从 Executing 重置为 Prepared;新的 Loop 使用同一冻结 agent `ModelRequest` 重试,不创建新的 Step。只有已经接受的 `SubmitModelResult` 才能关闭该 ModelStep。 +Attempt、owner、fence、lease、outbox row 和数据库 schema 不进入 `run.MachineState`。它们只保证多个 Loop attempt 不会同时取得同一个 Step/Call 的执行权。snapshot storage schema 与 Run event schema 独立版本化;snapshot 可被 truncate 后从 `RunHeader + TransitionRecord log` 重建,watermark 不得随 rebuild 清除。 -当 ToolStep 自动关闭且 Run 允许继续时,Memoh 先提交 history、queue action 和 context revision;下一次 Loop 通过 Planner 构造边界 `sdk.Request`,再冻结并提交下一份 agent `ModelRequest`。已经冻结的请求不会被后来的输入改变。 +Durable worker 实例可绑定 owner identity;只有当前 owner 能提交其取得的 start/completion/recovery。response、cancel、host StopRun 使用不带 worker grant 的 ingress/control adapter。lease/recovery scanner 必须通过正常 command + `EvaluateCommit` 提交 Unknown 或 Model recovery,不能直接改写 MachineState。 ### 8.3 外部 effect 的保证 ```text -AgentCommand commit -> AgentEvent 组 +AgentCommand commit -> TransitionRecord effectively-once(identity + digest) Model call - lease 失效后可使用同一冻结 request 重复;不同 attempt 可能返回不同结果,只有先被 - Runtime 接受的结果推进 Run + lease 失效后可使用同一冻结 request 重复;不同 attempt 可能返回不同结果, + 只有先被 Runtime 接受的结果推进 Run Tool effect start command 提交后才发生,但结果可能在提交前丢失 ``` -通用 core 无法判断 Unknown effect 是否已经发生,也不假设支付系统或其他外部系统提供查询接口。因此 Unknown 保守地终止当前 Run;如果产品需要继续,只能创建新的 Run。非幂等工具不能由 Runtime 获得 exactly-once 保证。 +Run core 无法判断 Unknown effect 是否已经发生,也不假设支付系统或其他外部系统提供查询接口。因此 Unknown 保守地终止当前 Run;如果产品需要继续,只能创建新的 Run。非幂等工具不能由 Runtime 获得 exactly-once 保证。 ## 9. Events 和 context @@ -1403,11 +1713,11 @@ SchemaVersion + Type wire 兼容和 sealed fact discriminator Fact 已接受的事实内容 ``` -Initial MachineState + TransitionRecord log 是 source of truth;MachineState 是必需的同事务 projection(§5.1)。Runtime 必须把 transition log、snapshot、水位和需要一致的 Memoh projection/outbox 放在同一事务或锁边界。Durable adapter 必须保留完整 TransitionRecord,使其可以按 RunID/Revision replay;MemoryRuntime 可以只在进程内保留同样的记录。公共 `Runtime` 不增加 replay 方法,读取由实现或 application projection 提供。 +RunHeader + TransitionRecord log 是 Run source of truth;MachineState 是必需的同事务 projection(§5.1)。Runtime 必须把 transition log、snapshot、水位和需要一致的 Application materialization outbox 放在同一事务或锁边界。Durable adapter 必须保留完整 TransitionRecord,使其可以按 RunID/Revision replay;MemoryRuntime 可以只在进程内保留同样的记录。公共 `Runtime` 不增加 replay 方法,读取由实现或 application projection 提供。 -Replay 按 RunID/Revision 取出 TransitionRecord,从初始状态(Revision=0)开始依次展开其中的 AgentEvent,并调用对应 `SchemaVersion` 的 `Machine.EvolveVersion` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里,Machine 决策规则的演进不影响历史事件的折叠;折叠不产生任何外部 effect。仲裁按 §5.1 的规则:transition log 完整(maxRevision >= watermark 且每条 transition digest 正确)时日志为准,snapshot 分歧或缺失自动重建并记录重建事件;日志尾部低于水位或尾部 transition 不完整时 halt。事件流内部的 RunID 不匹配、SchemaVersion/Type 不支持、同一 transition 的 CommandID/CommandDigest 不一致、Revision/Index 缺洞、fact digest 或 transition digest 不匹配同样按日志损坏处理,halt 该 Run。 +Replay 按 RunID/Revision 取出 TransitionRecord,从经 `ValidateRunHeader` 验证的 `RunHeader.InitialState`(Revision=0)开始依次展开其中的 AgentEvent,并调用对应 `SchemaVersion` 的 `Machine.EvolveVersion` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里,Machine 决策规则的演进不影响历史事件的折叠;折叠不产生任何外部 effect。仲裁按 §5.1 的规则:transition log 完整(maxRevision >= watermark 且每条 transition digest 正确)时日志为准,snapshot 分歧或缺失自动重建并记录重建事件;日志尾部低于水位或尾部 transition 不完整时 halt。事件流内部的 RunID 不匹配、SchemaVersion/Type 不支持、同一 transition 的 CommandID/CommandDigest 不一致、Revision/Index 缺洞、fact digest 或 transition digest 不匹配同样按日志损坏处理,halt 该 Run。 -Replay 的起点是 admission 已建立并持久化的最小 `MachineState`;初始用户输入若存在,也应通过 `AcceptInput` transition 重放。`RunSeed` 的 legacy admission 记录不作为普通 Run 的 AgentEvent 重放。需要重建 admission 链时,由 Memoh 的 session/queue 记录负责。 +Replay 的起点是 `RunHeader` 中已建立并持久化的最小 `MachineState`;初始用户输入通过 `AcceptInput` transition 重放。Session/Queue admission lineage 由各自 domain 记录,并通过 causation/provenance 与 RunHeader 关联。 canonical event 只记录影响语义状态、恢复和审计的已接受事实:Step 的建立/启动/恢复/关闭、模型结果的接受与拒绝、工具结果、响应、active Run 的输入接受和 Run terminal(§3.6 的 Fact 词表)。模型文本 delta、工具 stdout、下载百分比和其他瞬时 progress 不进入 AgentEvent;它们仍可在提交前通过 EventSink 发送 provisional observation。 @@ -1423,8 +1733,8 @@ ToolStarted 只能在 start command Accepted 后发送 ToolCompleted、Run terminal - 先由 Runtime Commit;Loop 随后发送观察事件,Memoh 的 durable projection/outbox - 由同一 Commit 事务保存对应事实 + 先由 Runtime Commit;Loop 随后发送观察事件,durable adapter 的 + Session/artifact/usage materialization outbox 由同一事务保存对应事实 ``` EventSink 丢失、重复或来自旧 Attempt 都不改变 MachineState 或 AgentEvent。客户端出现 gap 时从 durable AgentEvent、snapshot 或最终 `RunResult` 重建。Loop 默认忽略观察通道错误,不因此重试模型/工具。 @@ -1434,54 +1744,49 @@ EventSink 丢失、重复或来自旧 Attempt 都不改变 MachineState 或 Agen ### 9.3 context transform -Request Planner 属于 Memoh/application。它可以在事务外读取 context,但 MemohRuntime adapter +Request Planner 属于 Application。它可以在事务外读取 context,但 durable runtime adapter 必须以版本检查和 create-if-absent 冻结生成的 request。已冻结 ModelStep 不受后来输入影响。 -这里的版本检查由 MemohRuntime 的 adapter 实现:agent.Runtime 只看到带有 `BaseRevision` +这里的版本检查由 durable runtime adapter 实现:`run.Runtime` 只看到带有 `BaseRevision` 的 `PrepareModelRequest`,不理解 Memoh 的 context revision,也不读取 queue 或 history。 ### 9.4 provider transport -一次 `sdk.Generate` 或 `sdk.Stream` 对应一次逻辑 provider request。transport retry 在 sdk/provider client 内部发生;agent 不记录它,也不为它创建新的 Step。 +一次 `sdk.Generate` 或 `sdk.Stream` 对应一次逻辑 provider request。transport retry 在 sdk/provider client 内部发生;run 不记录它,也不为它创建新的 Step。 -## 10. Memoh queue、session 和恢复 +## 10. Application queue、session 和恢复 ### 10.1 queue 归属 -steer/follow-up 的 queue 数据结构、accepted order、重排、claim、apply、取消和 admission 全部属于 Memoh。两种 queue 可以共享稳定 item reference、accepted sequence、order version、取消状态和 claim provenance;消费策略不同。被选中的 item 在交给 core 前转换为只有 `InputID` 和 payload 的 `AgentInput`,Memoh 私下保留 item reference 与 claim provenance 的映射: +steer/follow-up 的 queue 数据结构、accepted order、重排、claim、apply、取消和 admission 全部属于 Application queue/session policy。两种 queue 可以共享稳定 item reference、accepted sequence、order version、取消状态和 claim provenance;消费策略不同。被选中的 item 在交给 Run core 前转换为只有 `InputID` 和 payload 的 `AgentInput`,Application 私下保留 item reference 与 claim provenance 的映射: ```text steer 优先进入当前 eligible boundary;若当前 Run 已 terminal,则按 session policy 创建 continuation Run follow-up 当前 Run 自然结束后创建新的 Run ``` -active Run 的 steer 通过 `NextStep(input)` 生成 `AcceptInput` command;terminal Run 的 steer/follow-up 由 Memoh admission 创建新 Run,再对新 Run 提交首个 `AcceptInput`。`NextRun`/`RunSeed` 仅作为 legacy helper 保留。core 不接收 queue item、priority、order 或 claim。 +active Run 的 steer 通过 `NextStep(input)` 生成 `AcceptInput` command;terminal Run 的 follow-up 由 Application admission 创建新 Run,再对新 Run 提交首个 `AcceptInput`。Run core 不接收 queue item、priority、order 或 claim。 重排必须带 order version;过期版本、未知 item、重复 item 和越过已 claim item 的操作都拒绝。 ### 10.2 queue-safe boundary -Memoh 只在以下 boundary 仲裁 queue: +Application 只在以下 boundary 仲裁 queue: ```text ModelStep 完成且没有 tool calls ToolStep 自动关闭 ``` -ModelStep 执行中、ToolStep 有 Pending/Executing/Waiting Call 时,不消费新的 queue 输入。queue action、对应 `InputAccepted` 事件以及 claim provenance 在 Memoh transaction 中一起提交,已 claim item 不能回退或越过。 - -没有 tool calls 的 ModelStep 会使当前 Run 到达 `RunCompleted`。Memoh 可以在这个 queue-safe boundary 消费 steer 并创建 continuation Run;这不改变已经完成的 Run,也不把 queue policy 放进 Machine。 +ModelStep 执行中、ToolStep 有 Pending/Executing/Waiting Call 时,不消费新的 queue 输入。queue action、对应 `InputAccepted` transition 以及 claim provenance 在 Application transaction 或可幂等 outbox 中一起提交,已 claim item 不能回退或越过。 -### 10.3 R0/R1 与 session settled +没有 tool calls 的 ModelStep 会使当前 Run 到达 `RunCompleted`。Application 可以在这个 queue-safe boundary 消费 steer 并创建 follow-up Run;这不改变已经完成的 Run,也不把 queue policy 放进 Machine。 -Memoh 的 session 规则保持: +### 10.3 Session settled 与 follow-up admission -```text -R0 terminal 不等于 session settled -admission-active R1 存在时 session 仍 busy -``` +具体产品可以定义 session settled/busy 规则,例如 terminal Run 不等于 session settled,或 admission-active follow-up Run 存在时 session 仍 busy。这些是 `agent/session` 上层 policy,不是 Run Machine 状态。 -follow-up 可以在 R1 的第一个 ModelStep 之前完成 durable admission claim;这是 Memoh session 操作,不是 Loop 中间读取 queue。R0 continuation 通过 Memoh outbox/scanner 唤醒;R1 admission 用 `InitializeRun` 创建最小 state,并把 follow-up 输入提交为首个 `AcceptInput` transition;R1 identity 不进入 agent 的通用结果。 +follow-up 可以在新 Run 的第一个 ModelStep 之前完成 durable admission claim;这是 Session/Queue operation,不是 Loop 中间读取 queue。Application outbox/scanner 可以唤醒新 Run;admission 用 `InitializeRun` 创建最小 state,并把 follow-up 输入提交为首个 `AcceptInput` transition。 ### 10.4 多 response 恢复 @@ -1495,93 +1800,107 @@ ToolStep T1 response 101 只完成 B;D 仍可执行,不必等待 C。response 102 再完成 C;D 完成后 Machine 自动关闭 T1,并允许下一 ModelStep。每个 response 有自己的 row、CommandID 和 wake,不再受旧协议“一个 deferred 只能保存一个 approval”的限制。 -## 11. 迁移现状与兼容策略 +## 11. Domain 拆分与实施顺序 -### 11.1 当前问题 +当前 root `agent` 包是 Run execution ES 的实现雏形;它不应继续吸收 Session、Queue、Artifact、Context 或产品 orchestration。拆包以 target package 为准,不为尚未合并的 API 保留 agent-level compatibility façade。 -当前 SDK 同时承载 provider API 和多步 loop: +### 阶段 A:抽取 `agent/es` -```text -GenerateParams / GenerateResult - 混合请求、自动 tool loop、approval、steps、callbacks 和 max steps -``` - -Memoh native runtime 已拥有产品 context、queue 和 durable session,但旧 loop 让它看不到 ToolStep 内逐 Call 的 progress。新方案把多步执行统一到 `agent.Loop`,并要求 Memoh 增加 ToolCall progress/response projection。 +1. 从现有 Run codec/transition/rebuild 中抽出不认识 run 语义的机制:canonical record encoding、payload/record digest hook、revision/index 校验、complete-record validation 和 generic fold runner。 +2. `es` 不 import `run`、`session`、`queue`。 +3. Run 的 `TransitionRecord` 先适配/包装 `es.Record`;保持 Run-specific `CommandID`、`CommandDigest`、sealed Fact codec 在 `run`。 +4. 为 `es` 添加独立 conformance:partial record tail、revision/index gap、digest mismatch、schema/type mismatch、canonical JSON ambiguity。 -本规范中的 `sdk.Request`、`sdk.ModelResult` 和 provider-neutral `sdk.ToolDefinition` 是一次调用的边界合同;agent 的 `ModelRequest`、`ModelResult`、`ToolDefinition` 是持久化闭合合同。阶段 A 负责实现边界转换与显式 legacy wrapper,不能把 SDK 边界类型误认为 Machine wire contract。 +### 阶段 B:移动现有 core 到 `agent/run` -### 11.2 迁移目标 +1. 将现有 root `agent` 中的 Machine、Loop、Runtime、MemoryRuntime、model data、tool contract、codec、runtimetest 移到 `agent/run`。 +2. 删除 `RunConfig`、`RunSeed`、`NextRun`、旧 `Initialize(run, config, seed)` 以及对应 codec/digest;目标 API 只有 `InitializeRun(runID)`,初始输入通过 `AcceptInput` transition。 +3. 把 `RunHeader` 实现为正式 authority record;rebuild/fold API 以 header 为起点。 +4. 让 `MemoryRuntime` 成为同一 Run ES 的最轻 reference runtime,而不是另一种 agent;它可以没有 lease/DB/heartbeat,但不能跳过 execution event semantics。 +5. 更新 import path、examples、conformance 和 golden streams;此时尚未合并,不保留 root `agent` compatibility wrapper。 -```text -旧调用者 -> legacy sdk wrapper(迁移期) -新调用者 -> agent.Loop + Runtime -Memoh -> agent.Loop + MemohRuntime -``` +### 阶段 C:实现 `agent/session` -生产环境最终只有一条多步执行路径:`agent.Loop`。 +1. 定义 generic append-only Session store、entry envelope、head CAS、parent/fork lineage、schema/digest/causation。 +2. 先提供 memory store 和 replay/fork conformance;不在 package 内写死 MessageAdded/Compact/Artifact ontology。 +3. Application 定义 session event,并实现 Run event -> Session event 的 materializer。 -### 11.3 兼容原则 +### 阶段 D:实现 `agent/queue` -1. provider adapter 的现有请求/响应字段优先复用;旧 `GenerateParams` 无法表达的新 `Request` 字段(例如 `ProviderOptions`)在 fallback 到旧 provider 时必须显式报错,不能 silent drop。 -2. sdk 保留旧单次调用入口,直到调用方迁移完成。 -3. 旧自动 loop 只在显式 legacy wrapper 中存在,不能由新 Loop 隐式调用。 -4. 旧 `WithMaxSteps(0)` 保持一次模型调用、不自动执行 tools;`n>0` 在 legacy wrapper/Loop `ExecutionPolicy.ModelStepLimit` 中处理,到上限后提交 `StopRun{step_limit}`;旧值 `-1` 由 legacy wrapper 先规范化为 unlimited。`RunConfig` 仅保留为 deprecated helper 输入,不进入 MachineState。Memoh 当前使用 `-1`,迁移后保持无限模型步骤语义。 -5. 旧 deferred/approval 记录不能在线猜测为新的多 Call ToolStep。切换前必须排空,或以 `runtime_upgrade_required` 终态保留审计后再切换。 -6. 新协议写入生产后不回滚旧 loop;Memoh 的 queue accepted order、重排、claim、admission、R0/R1 和 settled 保证不变。 +1. 定义 generic enqueue/claim/ack/release/dedup/visibility contract 与 memory implementation。 +2. `SteerItem`、`FollowUpItem`、`RunQueueItem` 保持在 Application payload/policy。 +3. 不将 queue state 或 claim identity 加入 `run.MachineState`。 -## 12. 分阶段实施 +### 阶段 E:simple in-process harness -### 阶段 A:sdk 单次调用边界 +`agent/harness`(或 application example package)组合: -1. 固定 provider-neutral `Request`、`ModelResult`、stream parts 和 snapshot。 -2. 让 Generate、Stream 各自对应一次 provider request;transport retry 留在 sdk。 -3. 将旧自动 loop 隔离为 legacy wrapper。 -4. 保留 blocking/streaming 等价测试。 -5. 提供 additive compatibility adapters:`RequestFromGenerateParams`、`GenerateParamsFromRequest`、`ToolDefinitionFromTool`、`ToolFromDefinition`、`ToolChoiceFromLegacy`、`ModelResultFromGenerateResult`、`GenerateResultFromModelResult`、`ModelStreamFromStreamResult`;并提供可选 `ModelInvoker` / `StreamingModelInvoker`,provider interface 未整体切换前先靠这些 helper 和 optional interface 桥接新旧边界;旧 provider fallback 无法表达的新字段必须报错而不是丢弃。 +```text +session memory store ++ queue memory store(可选) ++ run.MemoryRuntime ++ run.Loop ++ in-memory EventSink ++ application RequestPlanner/materializer +``` -### 阶段 B:Machine 和 MemoryRuntime +它不是第二个 Runtime interface,不复制 Run Machine,不把 chat history 塞进 `MemoryRuntime`。它用于 local example、test、prototype,以及证明 local/durable 是同一 Run ES semantics 的不同 runtime implementation。 -1. 实现 `MachineState`、ModelStep、ToolStep、ToolCall 状态和 Decide/Evolve/Next 规则。 -2. 实现 `EvaluateCommit`、`Runtime.Load/Commit`、command/fact digest 与幂等和 opaque grant。 -3. 实现 Loop 的 model/tool/approval/response 路径和并行执行策略。 -4. 完成 MemoryRuntime conformance 测试(`agent/runtimetest`),提供 CommandEnvelope/AgentEvent/TransitionRecord wire codec,严格 replay 校验,并冻结 SchemaVersion 1 的 golden transition stream。 +### 阶段 F:durable application adapter -### 阶段 C:Memoh storage groundwork +Durable adapter 在 Application/Memoh 实现: -1. 增加 ToolCall progress、response set、event idempotency、history 和 outbox projection。 -2. 保留 queue 的 accepted order、重排、claim、admission、R0/R1 和 settled 语义。 -3. 为旧 deferred 数据建立排空/审计迁移窗口。 +1. 持久化 `RunHeader`、TransitionRecord log、MachineState projection 和 watermark。 +2. 用 transaction/CAS 实现 `run.Runtime.Load/Commit`,私有实现 owner/fence/lease/Attempt/recovery。 +3. 将 Run transition、Session materialization outbox、artifact/usage projection 和 finalization state 放在同一事务,或使用可幂等 inbox/outbox 恢复。 +4. `RunFinalized` 后才 archive/GC Run log;Session/Artifact/Usage 的长期语义不依赖保留旧 Run log。 -### 阶段 D:MemohRuntime adapter +## 12. Cross-domain orchestration contract -1. 用 transaction/CAS 实现 `Load/Commit`。 -2. 在 adapter 内加入 owner/fence/lease/Attempt 和 recovery scanner。 -3. 实现逐 Call response、wake/outbox、unknown outcome 和 crash/fencing 测试。 -4. 让 Memoh Request Planner 以 revision 规划下一份 sdk.Request,并由 Loop 冻结成 agent ModelRequest。 +Application 是唯一允许同时依赖 `run`、`session`、`queue`、artifact/context/memory 的层。它负责: -### 阶段 E:production cutover +```text +Session semantic history + artifacts + memory + compact + -> ContextView + -> RequestPlanner + -> run.PrepareModelRequest + +run TransitionRecord + -> durable materializer/outbox + -> session entries / artifact refs / usage projection + +queue claim + -> queue-safe admission policy + -> run.AcceptInput or new RunHeader + first AcceptInput +``` -1. 先以 shadow projection 验证 MachineState、history 和 queue action。 -2. 排空或审计终结旧 deferred Run。 -3. 将现有 Memoh NativeAgentLoop 保留为薄宿主 wrapper:由它组装 Request Planner、`agent.Loop` 和 `MemohRuntime`,但不再保留独立的多步执行算法。 -4. 禁止旧 loop 写入新 Runtime projection,观察 recovery、duplicate commit 和多 response。 +每个 ModelStep 应记录足以审计输入来源的 `ContextManifest`(位置可为 `ModelStepPrepared` 中的 immutable reference 或 companion artifact): -### 阶段 F:删除兼容残留 +```text +ContextManifest { + session_revision + artifact_refs + memory_revision + compact_revision +} +``` -删除旧自动 loop、旧 approval/deferred 提交路径和只服务旧 loop 的 SDK 状态字段;保留仍有外部调用者使用的单次 provider API。 +它不是完整 prompt/history 的重复副本,而是 provenance。下一 Run 的 context 必须由 Session ES、Artifacts、Memory/Compact projection 构造,不能依赖读取已完成 Run 的 execution log。 ## 13. 并行工作边界 -| 工作 | 负责方 | 依赖 | +| 工作 | 目标包/层 | 依赖 | | --- | --- | --- | -| sdk Request/ModelResult/stream | twilight-ai/sdk | provider adapter | -| Machine 规则和 Step 类型 | twilight-ai/agent | sdk types | -| Loop、工具 contract、EventSink | twilight-ai/agent | Machine | -| MemoryRuntime | twilight-ai/agent | Machine/Loop | -| ToolCall projection、history/outbox | Memoh | event contract | -| queue、session、R0/R1、owner/fence | Memoh | existing session spec | -| Request Planner/context | Memoh/application | sdk.Request(边界),agent.ModelRequest(持久化) | -| MemohRuntime | Memoh | Runtime contract + projections | +| sdk Request/ModelResult/stream | `sdk` | provider adapter | +| generic record/digest/fold mechanism | `agent/es` | jsonstable | +| Run Machine、Loop、Runtime、MemoryRuntime | `agent/run` | es + sdk | +| RunHeader、run codec、runtimetest | `agent/run` | es | +| generic Session store/memory store/fork conformance | `agent/session` | es | +| generic Queue store/memory store/claim conformance | `agent/queue` | optional es digest types | +| Session event ontology/materializer/context planner | Application/Product | session + run + artifacts | +| Steer/follow-up/run queue policy | Application/Product | queue + session + run | +| durable runtime, DB schema, owner/fence/lease/outbox | Application/Memoh | run contract | +| simple in-process harness | `agent/harness` or examples | run + session + queue | ## 14. 测试矩阵 @@ -1599,7 +1918,7 @@ provider retry 不改变一次调用语义 ```text 无当前 Step -> NeedModelRequest NextStep(input) -> AcceptInput -> InputAccepted -> pending input appears in PlanningHint -terminal follow-up -> Memoh admission creates new Run with InitializeRun, then AcceptInput(input), without mutating old Run +terminal follow-up -> Application admission creates new Run with InitializeRun, then AcceptInput(input), without mutating old Run PrepareModelRequest -> [ModelStepPrepared] -> ModelStep ModelExecuting lease recovery -> same frozen ModelStep can start again SubmitModelResult 有 tools -> [ModelStepCompleted, ToolStepOpened],保存完整 Call set @@ -1614,7 +1933,6 @@ approval rejected -> [ToolCallFailed{Known, permission_denied}] Waiting 与 Executing 并存时,response 和 execution wake 都有效 response 只推进对应 Waiting Call Waiting result carries RunID/StepID/CallID/ResponseID for response routing -最后一个 Call terminal -> 同一 transition 追加 ToolStepClosed 最后一个 Call terminal -> [..., ToolStepClosed];host step limit 在下一 planning 边界提交 StopRun(step_limit) RunEnded 只能是事实序列的最后一个事实 已知失败进入下一次模型上下文 @@ -1624,7 +1942,7 @@ RunStopped 与 worker cancellation 区分 外层 ctx 取消:模型执行以 ModelStepRecovered 释放,工具 worker 运行到结束后 Loop 返回 ctx.Err() MachineState.Usage 逐字段累计 ModelStepCompleted 与 ModelStepRejected;terminal 时复制到 RunResult.Usage Decide 拒绝时不产出部分事实;接受时事实组与 MachineState 原子提交 -Evolve 不读 RunConfig/Loop policy、无 IO;对 Decide 产出的全部事实全定义 +Evolve 不读产品配置/Loop policy、无 IO;对 Decide 产出的全部事实全定义 TransitionRecord 按 RunID/Revision 可 replay,内部 AgentEvent 按 (Revision, Index) 保持事件流顺序,重复提交不产生第二组 replay 只经 EvolveVersion 折叠,不重新运行 Decide;golden transition stream 折叠出冻结的状态字节 EventSink provisional/committed 发射点 @@ -1634,7 +1952,7 @@ Streaming=true 但 invoker 不支持 streaming -> Generate fallback ### 14.3 Runtime conformance -conformance 测试由 agent 以可运行测试包(`agent/runtimetest`)交付;MemoryRuntime 与 MemohRuntime 直接运行同一套件,不各自转写矩阵: +conformance 测试由 `agent/run` 以可运行测试包(`agent/run/runtimetest`)交付;MemoryRuntime 与任意 durable runtime adapter 直接运行同一套件,不各自转写矩阵: ```text same CommandID + digest -> CommitAlreadyApplied + 原事件组 @@ -1667,19 +1985,42 @@ ModelStepPrepared/ToolStepOpened 自包含:携带的 digest 折叠后可重现 golden transition stream:固定 v1 命令序列折叠出冻结的状态字节 ``` -### 14.4 Memoh integration +### 14.4 `agent/es` + +```text +record 内 event 的 stream/revision/index/type/payload digest 一致性 +record digest 覆盖完整 event group,partial tail 必须拒绝 +revision gap、index gap、重复/冲突 identity、schema/type mismatch 拒绝 +canonical JSON duplicate key/trailing data/invalid UTF-8/lone surrogate 拒绝 +generic FoldRecords 不运行 domain Decide 或任何 IO +``` + +### 14.5 `agent/session` 与 `agent/queue` + +```text +session append/head CAS/replay order +branch/fork parent lineage +相同 causation/source event 不重复 materialize +queue enqueue/dedup/claim/ack/release/visibility +过期 claim 不允许 ack;重复 claim/ack 幂等或明确冲突 +SteerItem/FollowUpItem 只作为 application payload,不污染 queue core +``` + +### 14.6 Durable application integration ```text queue FIFO、accepted-order reorder、typed ID isolation -assigned follow-up 只由正确的 R1 admission claim -canonical history、TransitionRecord 与 MachineState 同事务 -assistant tool-call 和 tool result 只写一次 +assigned follow-up 只由正确的 admission claim +RunHeader、TransitionRecord、MachineState projection 与 watermark 一致 +Session materialization、artifact/usage outbox 与 Run transition 原子或可幂等恢复 +assistant tool-call 和 tool result 只 materialize 一次 多 response rows 与逐次 wake/idempotency lease expiry/recovery/unknown outcome eligible steer 存在时 Prepare 在同一事务内被拒绝,AcceptInput 先应用,重新规划携带该输入 并行 Call 中一个 Unknown 后撤销其他 grant,迟到结果不改变终态 -terminal 后迟到的工具结果投影到产品可见的审计视图 -R0 terminal 与 session settled 分离 +terminal Run 与 session settled 分离 +RunEnded 后未 finalization 不允许 archive/GC +RunFinalized 后 Session/Artifact/Usage 仍可完整构造下一 Run context EventSink gap 后可由 durable snapshot 对账 ``` @@ -1687,37 +2028,39 @@ EventSink gap 后可由 durable snapshot 对账 本次不编辑 `session-runtime-steer-followup.md`。后续应按以下边界修订: -Memoh queue/session/admission 的语义保持在 Memoh;现有 NativeAgentLoop 只保留为组装 Request Planner、`agent.Loop` 和 `MemohRuntime` 的薄宿主 wrapper,不包含第二套多步执行算法。queue 仲裁只发生在 queue-safe boundary,并与对应 AgentCommand/AgentEvent 在 Memoh transaction 中提交。旧的完整 Step 提交改为 ModelStep、ToolStep 以及逐 Call progress/response 记录;每次 response 只推进对应 Call。R0/R1、ownerless recovery、session settled 和 claim takeover 语义保持。 +Application 的 queue/session/admission 语义属于各自 domain;Application loop host 只组装 Request Planner、`run.Loop` 和 durable runtime adapter,不包含第二套多步执行算法。queue 仲裁只发生在 queue-safe boundary,并与对应 Run command/transition 在 application transaction 或可幂等 outbox 中提交。Step 提交使用 ModelStep、ToolStep 以及逐 Call progress/response 记录;每次 response 只推进对应 Call。具体产品的 branch/claim/recovery 语义保持在 Application,不进入 `agent/run`。 ## 16. 实施前置条件 -1. Memoh 增加 ToolCall progress、response set、event idempotency、history/outbox projection。 -2. Memoh 冻结内部 Attempt、owner、fence、lease 和 recovery grace 规则;这些不进入 agent public API。 -3. 工具失败不由 agent core 调度 retry timer;已知失败交给下一次模型,未知结果终止当前 Run。非幂等外部 effect 只能承诺 at-least-once。 -4. Request Planner 必须能从已提交的 application context 构造完整、可冻结的边界 `sdk.Request`,由 Loop freeze 为 agent `ModelRequest`。 +1. Durable Application adapter 增加 ToolCall progress、response set、event idempotency、Run→Session/artifact/usage outbox projection 和 finalization marker。 +2. Durable adapter 冻结内部 Attempt、owner、fence、lease 和 recovery grace 规则;这些不进入 `run` public API。 +3. 工具失败不由 Run core 调度 retry timer;已知失败交给下一次模型,未知结果终止当前 Run。非幂等外部 effect 只能承诺 at-least-once。 +4. Request Planner 必须能从已提交的 Session/context projection 构造完整、可冻结的边界 `sdk.Request`,由 Loop freeze 为 `run.ModelRequest`。 ## 17. 待确认决策 实现前仍需确认: -1. queue capacity、expiry 和产品授权是否进入 Memoh queue contract。 -2. breaking release 版本和 Memoh protocol upgrade window。 -3. EventSink payload schema,以及是否需要在 Memoh outbox 中加入跨进程 execution epoch。 +1. queue capacity、expiry 和产品授权是否进入 Application queue policy。 +2. breaking release 版本和 durable protocol upgrade window。 +3. EventSink payload schema,以及是否需要在 durable outbox 中加入跨进程 execution epoch。 -本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;agent `ModelRequest` 冻结完整的 generation options,streaming 只是 `ModelInvoker` 的可选执行路径,不改变 AgentCommand/AgentEvent 语义。Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、与事件编码同属永久兼容契约;initial MachineState + TransitionRecord log 为 source of truth,MachineState 为必需的同事务 projection,分歧仲裁按 §5.1(transition log 完整则自动重建,日志尾部低于水位或 transition 不完整则 halt)。结构性 malformed 的模型结果通过 `RejectModelResult` 的 disposition 在同一冻结 request 上重试或失败;fixed model/limits 不进入 MachineState;usage 在 MachineState 内逐字段累计;steer 由 MemohRuntime 的 Prepare gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 +本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;`run.ModelRequest` 冻结完整的 generation options,streaming 只是 `run.ModelInvoker` 的可选执行路径,不改变 Run command/event 语义。Run Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、与事件编码同属永久兼容契约;`RunHeader + TransitionRecord log` 为 Run source of truth,MachineState 为必需的同事务 projection,分歧仲裁按 §5.1(transition log 完整则自动重建,日志尾部低于水位或 transition 不完整则 halt)。结构性 malformed 的模型结果通过 `RejectModelResult` 的 disposition 在同一冻结 request 上重试或失败;fixed model/limits 不进入 MachineState;usage 在 MachineState 内逐字段累计;steer 由 Application 的 queue-safe admission gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 -本规范采用 initial MachineState + TransitionRecord log 为 source of truth(§5.1):三个稳定条件(ontology 冻结、versioned Evolve 冻结、事实自包含)由 Decide/Evolve 拆分保障,revision 水位保护 transition log 尾部完整性,TransitionDigest 保护单个 transition 内部的完整事件组。MachineState 保持为必需的同事务 projection——提交验证要求当前状态在临界区内可得,这与日志权威并不冲突。 +本规范采用 `RunHeader + TransitionRecord log` 为 Run source of truth(§5.1):三个稳定条件(ontology 冻结、versioned Evolve 冻结、事实自包含)由 Decide/Evolve 拆分保障,revision 水位保护 transition log 尾部完整性,TransitionDigest 保护单个 transition 内部的完整事件组。MachineState 保持为必需的同事务 projection——提交验证要求当前状态在临界区内可得,这与日志权威并不冲突。跨 Run 语义不从旧 Run log 读取,而由 Session ES、artifact、memory/context projection 构造。 ## 附录 A:最小 public API 草案 ```go -package agent +package run import ( "context" "encoding/json" "errors" + "github.com/memohai/twilight-ai/agent/es" + "github.com/memohai/twilight-ai/agent/jsonstable" "github.com/memohai/twilight-ai/sdk" ) @@ -1734,15 +2077,6 @@ type Digest string // context revision from which a RequestPlan was built. type PlanningToken string -// Deprecated compatibility input only. RunConfig is not stored in MachineState, -// AgentEvent, or TransitionRecord. New code uses InitializeRun plus host/Loop -// policy and per-step ModelRequest.Model. -type RunConfig struct { - Model ModelRef - ModelStepLimit int - ModelRejectLimit int -} - type RunStatus uint8 const ( @@ -1768,7 +2102,7 @@ type RunFailure struct { CallID CallID } -// Agent-owned JSON-stable persisted data. SDK request/result/tool values are +// Run-owned JSON-stable persisted data. SDK request/result/tool values are // converted at Loop/provider boundaries via Freeze* and SDK(); Runtime never // stores sdk.MessagePart interfaces, map[string]any provider metadata, or // caller-owned JSON bytes. CanonicalJSON is an opaque immutable value constructed @@ -1811,7 +2145,7 @@ type StepRef struct { Digest Digest // immutable step binding digest; progress is not included } -// Step is sealed by the agent package. Runtime implementations return values +// Step is sealed by the run package. Runtime implementations return values // created by the Machine rules; callers cannot add another Step variant. type Step interface { step() @@ -1959,11 +2293,6 @@ type AgentInput struct { // NextStep creates the command consumed by an active Run at a safe boundary. func NextStep(input AgentInput) AcceptInput -// Deprecated legacy admission helper. New admission uses InitializeRun and -// submits the initial input with AcceptInput. -type RunSeed struct { Input AgentInput } -func NextRun(input AgentInput) RunSeed - // --- Commands (intent) --- type PrepareModelRequest struct { @@ -2165,10 +2494,22 @@ type ToolSpec struct { func Next(MachineState) (Effect, error) func Decide(MachineState, AgentCommand) ([]Fact, error) func Evolve(MachineState, Fact) (MachineState, error) +func EvolveVersion(uint16, MachineState, Fact) (MachineState, error) func InitializeRun(RunID) (MachineState, error) -// Deprecated: compatibility helper; RunConfig is not stored in MachineState. -func Initialize(RunID, RunConfig, RunSeed) (MachineState, error) +type RunHeader struct { + SchemaVersion uint16 + RunID RunID + InitialStateVersion uint16 + InitialState MachineState + InitialStateDigest es.Digest + CausationID es.CausationID + HeaderDigest es.Digest +} + +func BuildRunHeader(RunID, es.CausationID) (RunHeader, error) +func ValidateRunHeader(*RunHeader) error +func DigestRunHeader(*RunHeader) (es.Digest, error) type Effect interface { effect() } @@ -2188,10 +2529,9 @@ type WaitForExecutionRecovery struct{} func (WaitForExecutionRecovery) effect() {} type PlanningHint struct { - RunID RunID - Model ModelRef - SourceStep StepID - Inputs []AgentInput + RunID RunID + SourceStep StepID + Inputs []AgentInput } type RequestPlanner interface { @@ -2225,6 +2565,8 @@ type CommandEnvelope struct { Type string RunID RunID ID CommandID + // Opaque cross-domain lineage; Run Machine does not interpret it. + CausationID es.CausationID Digest Digest Command AgentCommand } @@ -2237,6 +2579,7 @@ type AgentEvent struct { Index uint16 CommandID CommandID CommandDigest Digest + CausationID es.CausationID Digest Digest Fact Fact } @@ -2302,12 +2645,13 @@ func EncodeCommand(CommandEnvelope) ([]byte, error) // 不包含 Digest 字段 func DigestCommand(schemaVersion uint16, typ string, command AgentCommand) (Digest, error) func EncodeFact(schemaVersion uint16, typ string, fact Fact) ([]byte, error) func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) +func BuildRunHeader(RunID, es.CausationID) (RunHeader, error) +func ValidateRunHeader(*RunHeader) error +func DigestRunHeader(*RunHeader) (es.Digest, error) func BuildTransitionRecord([]AgentEvent) (TransitionRecord, error) func ValidateTransitionRecord(*TransitionRecord) error func DigestTransitionRecord(*TransitionRecord) (Digest, error) -func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineState, uint64, error) -func EncodeRunSeed(RunSeed) ([]byte, error) -func DigestRunSeed(schemaVersion uint16, seed RunSeed) (Digest, error) +func FoldTransitions(header RunHeader, records []TransitionRecord) (MachineState, uint64, error) func DigestRequest(ModelRequest) (Digest, error) func DigestToolDefinition(ToolDefinition) (Digest, error) func DigestToolSpec(ToolSpec) (Digest, error) @@ -2439,7 +2783,7 @@ func (l *Loop) Run(context.Context, Runtime, EventSink) (LoopResult, error) ## 附录 B:核心不变量 1. sdk 的一次 `Generate` 或 `Stream` 对应一次 provider request;transport retry 不创建新的 Step。 -2. `agent.Loop` 是唯一的多步执行算法;Run 的权威状态由 Runtime 持有,Loop 不保存第二份。 +2. `agent/run.Loop` 是唯一的 Run 多步执行算法;Run 的权威状态由 Runtime 持有,Loop 不保存第二份。 3. Runtime 对 Loop 只公开 `Load` 和 `Commit`;Planner、queue 和工具入口不是 Runtime 的隐藏第三、第四个方法。 4. Machine 是完整的 Run/Step/ToolCall 语义规则;决策只在 `Decide` 中、只在提交时运行一次,`Evolve` 是机械折叠。Runtime 通过共享 `EvaluateCommit` 调用它们,不复刻规则。 5. Step 是 durable resume boundary,只有 ModelStep 和 ToolStep;ToolCall 是 ToolStep 内的 progress。 @@ -2448,10 +2792,10 @@ func (l *Loop) Run(context.Context, Runtime, EventSink) (LoopResult, error) 8. Waiting response 只推进对应 Call;approval approved 先变 Pending,随后由 Loop 执行工具。日志记录结果事实(`ToolCallFailed{permission_denied}`、`RunEnded{cancelled}`),不记录请求本身。 9. 幂等按 command 判定:相同 CommandID/digest 重放返回 CommitAlreadyApplied 与原事件组(不重新运行 Decide),不重复写入 projection、history、queue action 或 outbox;相同 CommandID 不同 digest 冲突。 10. 一次接受的 transition 使 Revision 恰好加一;其全部事实共享该 Revision,Index 组内连续,提交后 `Snapshot.Revision` 等于该 Revision。 -11. Admission-created initial MachineState + TransitionRecord log 是 source of truth;MachineState 是必需的同事务 projection,可按 `EvolveVersion` 从 transition log 重建。对任意 Revision,状态必须等于初始状态经 `flatten(TransitionRecord[].Events)` 折叠的结果;snapshot 分歧或缺失且日志完整时自动重建并记录,日志尾部低于 revision 水位或 transition digest/事件组不完整时 halt 该 Run。 +11. `RunHeader + TransitionRecord log` 是 Run source of truth;MachineState 是必需的同事务 projection,可按 `EvolveVersion` 从经验证的 header 和 transition log 重建。对任意 Revision,状态必须等于 `RunHeader.InitialState` 经 `flatten(TransitionRecord[].Events)` 折叠的结果;snapshot 分歧或缺失且日志完整时自动重建并记录,日志尾部低于 revision 水位或 transition digest/事件组不完整时 halt 该 Run。 12. Evolve 的折叠语义与事件编码同属永久兼容契约,按 SchemaVersion 冻结;Replay 通过 `EvolveVersion` 选择历史语义;Decide 的决策规则可随版本演进,因为决策结果已记录为事实。 13. 已知工具失败交给下一次模型请求;Unknown 终止 Run,不自动重试、不查询外部系统。 14. worker cancellation 不等于 RunStopped;业务停止必须提交控制 command。宿主的业务停止先提交 `CancelRun`,再取消 Loop 的 ctx;ctx 取消本身只结束执行尝试,工具 worker 运行到自身结束。 15. EventSink 只是实时观察;TransitionRecord、durable snapshot 和 outbox 才是 replay/recovery 依据,AgentEvent 是 transition 内部和观察出口的事实流视图。 -16. MemoryRuntime 用进程内同步;MemohRuntime 用事务、CAS 和内部 Attempt/owner/fence/lease;两者共享 `EvaluateCommit` 与 Machine 规则,但不共享存储实现。 +16. `run.MemoryRuntime` 用进程内同步;durable runtime adapter 用事务、CAS 和内部 Attempt/owner/fence/lease;两者共享 `EvaluateCommit` 与 Run Machine 规则,但不共享存储实现。 17. 结构性 malformed 的模型结果以 `RejectModelResult` 累计 usage;Disposition 决定回到 Prepared 重试或同 transition RunFailed。单个 Call 的参数解析失败不是 malformed,按已知 `invalid_arguments` 进入下一次模型请求。 From 468d4d16525f3f67ffb00f64df4ff707f3effbff Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 04:16:27 +0800 Subject: [PATCH 13/81] refactor(agent): extract generic event sourcing primitives --- agent/canonical.go | 9 +- agent/es/canonical.go | 60 ++++++++++++ agent/es/es_test.go | 141 +++++++++++++++++++++++++++ agent/es/fold.go | 71 ++++++++++++++ agent/es/record.go | 216 ++++++++++++++++++++++++++++++++++++++++++ agent/ids.go | 12 +-- agent/protocol.go | 9 +- agent/rebuild.go | 42 ++++---- agent/transition.go | 69 +++++++------- 9 files changed, 560 insertions(+), 69 deletions(-) create mode 100644 agent/es/canonical.go create mode 100644 agent/es/es_test.go create mode 100644 agent/es/fold.go create mode 100644 agent/es/record.go diff --git a/agent/canonical.go b/agent/canonical.go index 9b9eddb..6f41530 100644 --- a/agent/canonical.go +++ b/agent/canonical.go @@ -6,6 +6,7 @@ package agent import ( + "github.com/memohai/twilight-ai/agent/es" "github.com/memohai/twilight-ai/agent/jsonstable" ) @@ -26,10 +27,6 @@ func MustParseCanonicalJSON(raw string) CanonicalJSON { return jsonstable.MustParse(raw) } -func canonicalJSON(raw []byte) ([]byte, error) { - return jsonstable.Canonicalize(raw) -} +func canonicalJSON(raw []byte) ([]byte, error) { return es.Canonicalize(raw) } -func marshalCanonical(v any) ([]byte, error) { - return jsonstable.MarshalCanonical(v) -} +func marshalCanonical(v any) ([]byte, error) { return es.MarshalCanonical(v) } diff --git a/agent/es/canonical.go b/agent/es/canonical.go new file mode 100644 index 0000000..752d93c --- /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/memohai/twilight-ai/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/ids.go b/agent/ids.go index 8abd55c..b173d13 100644 --- a/agent/ids.go +++ b/agent/ids.go @@ -4,6 +4,8 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + + "github.com/memohai/twilight-ai/agent/es" ) type RunID string @@ -15,8 +17,9 @@ type InputID string type ToolRef string type ModelRef string -// Digest is "sha256:<64 lowercase hex>" over agent canonical bytes (spec §5.5). -type Digest 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. @@ -27,10 +30,7 @@ type PlanningToken string // implementation-defined. type ExecutionGrant string -func sha256Digest(data []byte) Digest { - sum := sha256.Sum256(data) - return Digest("sha256:" + hex.EncodeToString(sum[:])) -} +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. diff --git a/agent/protocol.go b/agent/protocol.go index 6bf2047..2d1190d 100644 --- a/agent/protocol.go +++ b/agent/protocol.go @@ -3,6 +3,8 @@ package agent import ( "errors" "fmt" + + "github.com/memohai/twilight-ai/agent/es" ) // SchemaVersion1 is the first published wire schema. Canonical encoding and @@ -41,12 +43,7 @@ type AgentEvent struct { // discriminator and canonical command bytes. The Digest field itself, base // revisions and grants never enter the digest (spec §5.5). func encodeEnvelopeBody(schemaVersion uint16, typ string, body any) ([]byte, error) { - canonical, err := marshalCanonical(body) - if err != nil { - return nil, err - } - prefix := fmt.Sprintf("v%d:%d:%s:", schemaVersion, len(typ), typ) - return append([]byte(prefix), canonical...), nil + return es.EncodeTypedPayload(schemaVersion, typ, body) } // EncodeCommand renders the canonical bytes of a command envelope, excluding diff --git a/agent/rebuild.go b/agent/rebuild.go index 5771d75..4803869 100644 --- a/agent/rebuild.go +++ b/agent/rebuild.go @@ -4,6 +4,8 @@ import ( "bytes" "errors" "fmt" + + "github.com/memohai/twilight-ai/agent/es" ) // ErrLogTruncated reports that a Run's event log ends below its revision @@ -85,33 +87,35 @@ func FoldEvents(initial MachineState, events []AgentEvent) (MachineState, uint64 // //nolint:gocritic // hugeParam: public replay API folds from an initial value state without mutating caller-owned state. func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineState, uint64, error) { - state := initial - var revision uint64 + views := make([]es.RecordView[AgentEvent], len(records)) for i := range records { record := records[i] + // Run-specific metadata (command identity and aggregate digest) is + // validated by the Run adapter; es validates the generic complete + // record structure and folding order below. if err := ValidateTransitionRecord(&record); err != nil { return initial, 0, err } - if record.RunID != initial.RunID { - return initial, 0, fmt.Errorf("agent: fold: transition run %q does not match initial run %q", record.RunID, initial.RunID) - } - if record.Revision != revision+1 { - return initial, 0, fmt.Errorf("agent: fold: gap at transition revision %d (expected %d)", record.Revision, revision+1) - } - for j := range record.Events { - e := record.Events[j] - fact, err := snapshotFact(e.Fact) - if err != nil { - return initial, 0, err - } - state, err = EvolveVersion(e.SchemaVersion, state, fact) + views[i] = transitionRecordView(&record) + } + state, revision, err := es.FoldRecords( + initial, + es.StreamID(initial.RunID), + views, + supportsRunSchema, + inspectTransitionEvent, + func(schemaVersion uint16, state MachineState, event AgentEvent) (MachineState, error) { + fact, err := snapshotFact(event.Fact) if err != nil { - return initial, 0, err + return MachineState{}, err } - } - revision = record.Revision + return EvolveVersion(schemaVersion, state, fact) + }, + ) + if err != nil { + return initial, 0, err } - return state, revision, nil + return state, uint64(revision), nil } // Rebuild discards the in-memory snapshot and refolds it from the transition diff --git a/agent/transition.go b/agent/transition.go index 43744a6..8f0f94a 100644 --- a/agent/transition.go +++ b/agent/transition.go @@ -3,6 +3,8 @@ package agent import ( "errors" "fmt" + + "github.com/memohai/twilight-ai/agent/es" ) // TransitionRecord is the atomic authority record for one accepted @@ -39,6 +41,38 @@ func transitionRecordBody(record *TransitionRecord) transitionRecordDigestBody { } } +func transitionRecordView(record *TransitionRecord) es.RecordView[AgentEvent] { + return es.RecordView[AgentEvent]{ + SchemaVersion: record.SchemaVersion, + StreamID: es.StreamID(record.RunID), + Revision: es.Revision(record.Revision), + Events: record.Events, + } +} + +//nolint:gocritic // EventInspector is value-based so generic records do not retain mutable event pointers. +func inspectTransitionEvent(event AgentEvent) (es.EventMetadata, error) { + typ := factType(event.Fact) + if typ == "" || event.Type != typ { + return es.EventMetadata{}, fmt.Errorf("event type %q does not match fact variant %T", event.Type, event.Fact) + } + wantDigest, err := DigestFact(event.SchemaVersion, event.Type, event.Fact) + if err != nil { + return es.EventMetadata{}, err + } + if event.Digest != wantDigest { + return es.EventMetadata{}, fmt.Errorf("fact digest mismatch at revision %d index %d", event.Revision, event.Index) + } + return es.EventMetadata{ + SchemaVersion: event.SchemaVersion, + StreamID: es.StreamID(event.RunID), + Revision: es.Revision(event.Revision), + Index: es.Index(event.Index), + }, nil +} + +func supportsRunSchema(version uint16) bool { return isSupportedSchemaVersion(version) } + // DigestTransitionRecord computes the digest for one transition aggregate. The // digest binds the transition identity and the complete ordered event group; // TransitionDigest itself is excluded from the digest input. @@ -50,7 +84,7 @@ func DigestTransitionRecord(record *TransitionRecord) (Digest, error) { if err != nil { return "", err } - return sha256Digest(body), nil + return es.DigestBytes(body), nil } // BuildTransitionRecord freezes and validates the complete event group of one @@ -93,43 +127,14 @@ func ValidateTransitionRecord(record *TransitionRecord) error { if record.RunID == "" || record.Revision == 0 || record.CommandID == "" || record.CommandDigest == "" { return errors.New("agent: transition: missing identity") } - if !isSupportedSchemaVersion(record.SchemaVersion) { - return fmt.Errorf("agent: transition: unsupported schema version %d", record.SchemaVersion) - } - if len(record.Events) == 0 { - return fmt.Errorf("agent: transition: revision %d has no events", record.Revision) - } - if len(record.Events) > int(^uint16(0))+1 { - return fmt.Errorf("agent: transition: revision %d event group too large: %d", record.Revision, len(record.Events)) + if err := es.ValidateRecordView(transitionRecordView(record), supportsRunSchema, inspectTransitionEvent); err != nil { + return fmt.Errorf("agent: transition: %w", err) } for i := range record.Events { e := record.Events[i] - if e.SchemaVersion != record.SchemaVersion { - return fmt.Errorf("agent: transition: event %d schema version %d does not match transition %d", i, e.SchemaVersion, record.SchemaVersion) - } - if e.RunID != record.RunID { - return fmt.Errorf("agent: transition: event %d run %q does not match transition run %q", i, e.RunID, record.RunID) - } - if e.Revision != record.Revision { - return fmt.Errorf("agent: transition: event %d revision %d does not match transition revision %d", i, e.Revision, record.Revision) - } - if e.Index != uint16(i) { - return fmt.Errorf("agent: transition: revision %d index %d has event index %d", record.Revision, i, e.Index) - } if e.CommandID != record.CommandID || e.CommandDigest != record.CommandDigest { return fmt.Errorf("agent: transition: revision %d command identity changed within transition", record.Revision) } - typ := factType(e.Fact) - if typ == "" || e.Type != typ { - return fmt.Errorf("agent: transition: event type %q does not match fact variant %T", e.Type, e.Fact) - } - wantDigest, err := DigestFact(e.SchemaVersion, e.Type, e.Fact) - if err != nil { - return err - } - if e.Digest != wantDigest { - return fmt.Errorf("agent: transition: fact digest mismatch at revision %d index %d", e.Revision, e.Index) - } } if record.TransitionDigest == "" { return errors.New("agent: transition: missing digest") From 9ecaede279805d901d44c0f37b954dac9d1c00b1 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 12:00:29 +0800 Subject: [PATCH 14/81] docs(agent): make session the cross-run authority --- docs/design/agent-runtime-refactor.md | 59 ++++++++++++++++----------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 437a913..c7a5897 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -33,10 +33,12 @@ session queue run | Domain | 回答的问题 | Authority 形态 | 生命周期 | | --- | --- | --- | --- | -| `agent/session` | 这个会话长期发生过什么? | append-only Session ES | 跨多个 Run 长期存在 | -| `agent/run` | 这一次执行如何跑到当前位置? | RunHeader + TransitionRecord log | 单次 Run,完成后可归档/GC | +| `agent/session` | 这个会话长期发生过什么? | append-only Session ES;**产品跨 Run 的语义主 authority** | 跨多个 Run 长期存在 | +| `agent/run` | 这一次执行如何跑到当前位置? | RunHeader + TransitionRecord log;**该 Run 存活期间的 execution authority** | 单次 Run,materialize/finalize 后可归档/GC | | `agent/queue` | 哪些工作待处理、谁 claim、如何 dedup? | transactional queue state | 调度生命周期 | +Session 不需要保存每一个执行微步骤,但必须记录每一次 Run 的 admission、终态和继续理解会话所需的结果/引用。Run log 只服务执行、恢复和审计;它不是跨 Run 语义的最终来源。 + `agent/es` 是机制库,不是第四个语义 domain。它只提供 canonical envelope、digest、record completeness、revision/index 校验和 fold runner 等可复用机制;它不认识 ModelStep、Session message、queue claim 或产品 policy。 本版本固定五个执行层级,但这些层级属于 `agent/run`: @@ -347,7 +349,7 @@ func FoldRecords[S any, E any](initial S, records []Record[E], folder Folder[S, | --- | --- | | Run Machine | `MachineState`、`Step`、`ToolCallState`、`AgentCommand`、`Fact`、`AgentEvent` 和共享的 Decide/Evolve/Next 规则。 | | Transition | 把一次 accepted command 产生的完整 fact/event group 封装为 `TransitionRecord`。实现上可直接使用 `agent/es.Record` 或在其上包 domain metadata。 | -| RunHeader | 正式持久协议的一部分:RunID、initial state/schema/digest、admission causation/provenance。完整 authority 是 `RunHeader + TransitionRecord log`。 | +| RunHeader | 正式持久协议的一部分:RunID、initial state/schema/digest、admission causation/provenance。完整 **execution authority** 是 `RunHeader + TransitionRecord log`。 | | Runtime contract | `Load`/`Commit` authority 接口;所有语义状态变化只通过 Commit。 | | Loop | 唯一的多步执行算法:解释 Effect,调用 Model/Tool,再提交结果 command。 | | Tool contract | ToolRef、ExecutableTool、参数校验、结果分类和 response policy。 | @@ -366,7 +368,7 @@ Provider clients / API keys Durable DB schema / owner/fence/lease/outbox implementation ``` -Run source of truth: +Run execution source of truth(不替代 Session 的长期语义 authority): ```text RunHeader(initial MachineState at Revision 0) @@ -380,7 +382,7 @@ TransitionRecord log (Revision 1..N) MachineState_N = Fold(initial, flatten(TransitionRecord[1..N].Events)) ``` -Run ES 是短生命周期 authority。Run 完成后可以 archive/GC,但前提是需要跨 Run 保留的语义已经 materialize 到长期 domain。 +Run ES 是短生命周期的 execution authority。它完成后不能再作为下一 Run 的 context 来源;只有当 Session 已记录该 Run 的 lifecycle 与所需长期语义,并且 artifact/usage 等引用可恢复时,才可以 finalization 后 archive/GC。 ### 2.3.1 run-owned persisted model data @@ -437,12 +439,17 @@ type Store[E any] interface { UserMessageAdded AssistantMessageAdded ToolResultAdded +RunAdmitted // RunID + RunHeader digest + admission provenance +RunCompleted // RunID + terminal summary + durable output/artifact refs +RunFinalized // 此 Run 的长期语义已吸收,可回收 execution log CompactCreated ArtifactLinked MemoryUpdated Custom product event ``` +`RunAdmitted`、`RunCompleted` 和 `RunFinalized` 是 Application 定义的 Session ontology,不是 `agent/session` 预置的类型;但每一个 Run 至少必须以这种 lifecycle entry 被 Session 记录。 + `agent/session` 只保证: ```text @@ -508,7 +515,9 @@ Queue 可以被 Memoh durable adapter 放进数据库事务,也可以有 in-me ### 2.6 Cross-domain materialization and finalization -Run ES 可以短命的前提,是其中所有需要跨 Run 保留的语义都已经 materialize 到长期 domain: +Session ES 是跨 Run 的语义主 authority。Run ES 可以短命的前提,是 Session 已吸收该 Run 的 lifecycle 和下一 Run/用户可见语义所需的内容;artifact、usage、trace 等长期 store 由 Session entry 引用或以同一 provenance 关联。Run log 不是长期 conversation/history 的后备来源。 + +需要跨 Run 保留的 Run 语义 materialize 到长期 domain: ```text Run ES @@ -544,14 +553,14 @@ Session causation_id = preserved Run event causation_id ```text RunEnded ↓ -确保长期语义、artifact、usage/outbox 已提交或可幂等恢复 +Session 追加 RunCompleted;确保长期消息/结果、artifact、usage/outbox 已提交或可幂等恢复 ↓ -RunFinalized control-plane marker +Session 追加 RunFinalized(或事务耦合的 application control-plane marker) ↓ -Run ES 才允许 archive / GC +Run ES execution log 才允许 archive / GC ``` -`RunFinalized` 是 run storage/control-plane marker,不是 `agent/run.Fact`,不参与 `MachineState` fold。 +`RunFinalized` 的语义归属是 Session/Application control plane,并携带 RunID 与 materialization watermark;它不是 `agent/run.Fact`,不参与 `MachineState` fold。它证明 Session 已能在不读取该 Run execution log 的情况下继续会话和构造后续 Run。 ### 2.7 类型归属 @@ -948,7 +957,7 @@ Runtime 只回答两个问题: 因此 Runtime 的接口很小,但一次 `Commit` 的事务范围可以很大:它必须让一个 AgentCommand、产出的 `TransitionRecord` 及其必要的产品投影一起成功或一起失败;这不意味着 Runtime 获得了 history、queue 或 prompt 的所有权。`CommitResult.Events` 只是该 transition 的事件流视图,方便 Loop、UI 和 observability 消费;Runtime 的 authority storage 是完整 transition aggregate。 -Run 语义状态的 source of truth 是 immutable `RunHeader` 加 `TransitionRecord` log;`RunHeader` 固化 Revision-0 initial state 及其 schema/digest/admission causation。MachineState 是必需的同事务 projection(execution cache):提交验证与 Loop 执行从它读取,因此它必须与 transition log 在同一原子提交内更新,但它可以从 RunHeader 和日志重建。对任意 Revision N,状态必须等于 `RunHeader.InitialState` 按 `flatten(TransitionRecord[].Events)` 调用 versioned `Evolve` fold 到 N 的结果——这是可自动恢复的不变量,不是 halt 条件。 +Run execution 状态的 source of truth 是 immutable `RunHeader` 加 `TransitionRecord` log;它只负责该次执行的恢复与审计,不替代 Session ES 的长期语义 authority;`RunHeader` 固化 Revision-0 initial state 及其 schema/digest/admission causation。MachineState 是必需的同事务 projection(execution cache):提交验证与 Loop 执行从它读取,因此它必须与 transition log 在同一原子提交内更新,但它可以从 RunHeader 和日志重建。对任意 Revision N,状态必须等于 `RunHeader.InitialState` 按 `flatten(TransitionRecord[].Events)` 调用 versioned `Evolve` fold 到 N 的结果——这是可自动恢复的不变量,不是 halt 条件。 这个权威声明成立的三个稳定条件(本规范的规范性条款): @@ -1713,7 +1722,7 @@ SchemaVersion + Type wire 兼容和 sealed fact discriminator Fact 已接受的事实内容 ``` -RunHeader + TransitionRecord log 是 Run source of truth;MachineState 是必需的同事务 projection(§5.1)。Runtime 必须把 transition log、snapshot、水位和需要一致的 Application materialization outbox 放在同一事务或锁边界。Durable adapter 必须保留完整 TransitionRecord,使其可以按 RunID/Revision replay;MemoryRuntime 可以只在进程内保留同样的记录。公共 `Runtime` 不增加 replay 方法,读取由实现或 application projection 提供。 +RunHeader + TransitionRecord log 是 Run execution source of truth;MachineState 是必需的同事务 projection(§5.1)。Runtime 必须把 transition log、snapshot、水位和需要一致的 Application materialization outbox 放在同一事务或锁边界。Durable adapter 必须保留完整 TransitionRecord,使其可以按 RunID/Revision replay;MemoryRuntime 可以只在进程内保留同样的记录。公共 `Runtime` 不增加 replay 方法,读取由实现或 application projection 提供。 Replay 按 RunID/Revision 取出 TransitionRecord,从经 `ValidateRunHeader` 验证的 `RunHeader.InitialState`(Revision=0)开始依次展开其中的 AgentEvent,并调用对应 `SchemaVersion` 的 `Machine.EvolveVersion` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里,Machine 决策规则的演进不影响历史事件的折叠;折叠不产生任何外部 effect。仲裁按 §5.1 的规则:transition log 完整(maxRevision >= watermark 且每条 transition digest 正确)时日志为准,snapshot 分歧或缺失自动重建并记录重建事件;日志尾部低于水位或尾部 transition 不完整时 halt。事件流内部的 RunID 不匹配、SchemaVersion/Type 不支持、同一 transition 的 CommandID/CommandDigest 不一致、Revision/Index 缺洞、fact digest 或 transition digest 不匹配同样按日志损坏处理,halt 该 Run。 @@ -1815,7 +1824,7 @@ response 101 只完成 B;D 仍可执行,不必等待 C。response 102 再完 1. 将现有 root `agent` 中的 Machine、Loop、Runtime、MemoryRuntime、model data、tool contract、codec、runtimetest 移到 `agent/run`。 2. 删除 `RunConfig`、`RunSeed`、`NextRun`、旧 `Initialize(run, config, seed)` 以及对应 codec/digest;目标 API 只有 `InitializeRun(runID)`,初始输入通过 `AcceptInput` transition。 -3. 把 `RunHeader` 实现为正式 authority record;rebuild/fold API 以 header 为起点。 +3. 把 `RunHeader` 实现为正式 execution authority record;rebuild/fold API 以 header 为起点。 4. 让 `MemoryRuntime` 成为同一 Run ES 的最轻 reference runtime,而不是另一种 agent;它可以没有 lease/DB/heartbeat,但不能跳过 execution event semantics。 5. 更新 import path、examples、conformance 和 golden streams;此时尚未合并,不保留 root `agent` compatibility wrapper。 @@ -1852,8 +1861,8 @@ Durable adapter 在 Application/Memoh 实现: 1. 持久化 `RunHeader`、TransitionRecord log、MachineState projection 和 watermark。 2. 用 transaction/CAS 实现 `run.Runtime.Load/Commit`,私有实现 owner/fence/lease/Attempt/recovery。 -3. 将 Run transition、Session materialization outbox、artifact/usage projection 和 finalization state 放在同一事务,或使用可幂等 inbox/outbox 恢复。 -4. `RunFinalized` 后才 archive/GC Run log;Session/Artifact/Usage 的长期语义不依赖保留旧 Run log。 +3. 将 Run transition、Session materialization outbox、artifact/usage projection 和 finalization state 放在同一事务,或使用可幂等 inbox/outbox 恢复;Application 要把每一个 Run 的 lifecycle 记录为 Session event。 +4. Session 已追加 `RunFinalized`(或存在与之事务耦合的 marker)后才 archive/GC Run log;Session/Artifact/Usage 的长期语义不依赖保留旧 Run log。 ## 12. Cross-domain orchestration contract @@ -1865,13 +1874,17 @@ Session semantic history + artifacts + memory + compact -> RequestPlanner -> run.PrepareModelRequest -run TransitionRecord +Session RunAdmitted + -> new RunHeader + first AcceptInput + -> run TransitionRecord (short-lived execution trace) -> durable materializer/outbox - -> session entries / artifact refs / usage projection + -> Session RunCompleted + messages/results/artifact refs/usage projection + -> Session RunFinalized + -> Run log archive / GC is now permitted queue claim -> queue-safe admission policy - -> run.AcceptInput or new RunHeader + first AcceptInput + -> Session lifecycle entry + run.AcceptInput or new RunHeader ``` 每个 ModelStep 应记录足以审计输入来源的 `ContextManifest`(位置可为 `ModelStepPrepared` 中的 immutable reference 或 companion artifact): @@ -2019,8 +2032,8 @@ lease expiry/recovery/unknown outcome eligible steer 存在时 Prepare 在同一事务内被拒绝,AcceptInput 先应用,重新规划携带该输入 并行 Call 中一个 Unknown 后撤销其他 grant,迟到结果不改变终态 terminal Run 与 session settled 分离 -RunEnded 后未 finalization 不允许 archive/GC -RunFinalized 后 Session/Artifact/Usage 仍可完整构造下一 Run context +RunEnded 后未写入 Session RunFinalized 不允许 archive/GC +Session RunFinalized 后 Session/Artifact/Usage 仍可完整构造下一 Run context EventSink gap 后可由 durable snapshot 对账 ``` @@ -2045,9 +2058,9 @@ Application 的 queue/session/admission 语义属于各自 domain;Application 2. breaking release 版本和 durable protocol upgrade window。 3. EventSink payload schema,以及是否需要在 durable outbox 中加入跨进程 execution epoch。 -本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;`run.ModelRequest` 冻结完整的 generation options,streaming 只是 `run.ModelInvoker` 的可选执行路径,不改变 Run command/event 语义。Run Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、与事件编码同属永久兼容契约;`RunHeader + TransitionRecord log` 为 Run source of truth,MachineState 为必需的同事务 projection,分歧仲裁按 §5.1(transition log 完整则自动重建,日志尾部低于水位或 transition 不完整则 halt)。结构性 malformed 的模型结果通过 `RejectModelResult` 的 disposition 在同一冻结 request 上重试或失败;fixed model/limits 不进入 MachineState;usage 在 MachineState 内逐字段累计;steer 由 Application 的 queue-safe admission gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 +本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;`run.ModelRequest` 冻结完整的 generation options,streaming 只是 `run.ModelInvoker` 的可选执行路径,不改变 Run command/event 语义。Run Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、与事件编码同属永久兼容契约;`RunHeader + TransitionRecord log` 为 Run execution source of truth,MachineState 为必需的同事务 projection,分歧仲裁按 §5.1(transition log 完整则自动重建,日志尾部低于水位或 transition 不完整则 halt)。结构性 malformed 的模型结果通过 `RejectModelResult` 的 disposition 在同一冻结 request 上重试或失败;fixed model/limits 不进入 MachineState;usage 在 MachineState 内逐字段累计;steer 由 Application 的 queue-safe admission gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 -本规范采用 `RunHeader + TransitionRecord log` 为 Run source of truth(§5.1):三个稳定条件(ontology 冻结、versioned Evolve 冻结、事实自包含)由 Decide/Evolve 拆分保障,revision 水位保护 transition log 尾部完整性,TransitionDigest 保护单个 transition 内部的完整事件组。MachineState 保持为必需的同事务 projection——提交验证要求当前状态在临界区内可得,这与日志权威并不冲突。跨 Run 语义不从旧 Run log 读取,而由 Session ES、artifact、memory/context projection 构造。 +本规范采用 `RunHeader + TransitionRecord log` 为 Run execution source of truth(§5.1):三个稳定条件(ontology 冻结、versioned Evolve 冻结、事实自包含)由 Decide/Evolve 拆分保障,revision 水位保护 transition log 尾部完整性,TransitionDigest 保护单个 transition 内部的完整事件组。MachineState 保持为必需的同事务 projection——提交验证要求当前状态在临界区内可得,这与日志权威并不冲突。跨 Run 语义不从旧 Run log 读取,而由 Session ES、artifact、memory/context projection 构造。 ## 附录 A:最小 public API 草案 @@ -2792,7 +2805,7 @@ func (l *Loop) Run(context.Context, Runtime, EventSink) (LoopResult, error) 8. Waiting response 只推进对应 Call;approval approved 先变 Pending,随后由 Loop 执行工具。日志记录结果事实(`ToolCallFailed{permission_denied}`、`RunEnded{cancelled}`),不记录请求本身。 9. 幂等按 command 判定:相同 CommandID/digest 重放返回 CommitAlreadyApplied 与原事件组(不重新运行 Decide),不重复写入 projection、history、queue action 或 outbox;相同 CommandID 不同 digest 冲突。 10. 一次接受的 transition 使 Revision 恰好加一;其全部事实共享该 Revision,Index 组内连续,提交后 `Snapshot.Revision` 等于该 Revision。 -11. `RunHeader + TransitionRecord log` 是 Run source of truth;MachineState 是必需的同事务 projection,可按 `EvolveVersion` 从经验证的 header 和 transition log 重建。对任意 Revision,状态必须等于 `RunHeader.InitialState` 经 `flatten(TransitionRecord[].Events)` 折叠的结果;snapshot 分歧或缺失且日志完整时自动重建并记录,日志尾部低于 revision 水位或 transition digest/事件组不完整时 halt 该 Run。 +11. `RunHeader + TransitionRecord log` 是 Run execution source of truth;MachineState 是必需的同事务 projection,可按 `EvolveVersion` 从经验证的 header 和 transition log 重建。对任意 Revision,状态必须等于 `RunHeader.InitialState` 经 `flatten(TransitionRecord[].Events)` 折叠的结果;snapshot 分歧或缺失且日志完整时自动重建并记录,日志尾部低于 revision 水位或 transition digest/事件组不完整时 halt 该 Run。 12. Evolve 的折叠语义与事件编码同属永久兼容契约,按 SchemaVersion 冻结;Replay 通过 `EvolveVersion` 选择历史语义;Decide 的决策规则可随版本演进,因为决策结果已记录为事实。 13. 已知工具失败交给下一次模型请求;Unknown 终止 Run,不自动重试、不查询外部系统。 14. worker cancellation 不等于 RunStopped;业务停止必须提交控制 command。宿主的业务停止先提交 `CancelRun`,再取消 Loop 的 ctx;ctx 取消本身只结束执行尝试,工具 worker 运行到自身结束。 From e05ab89e06e0760ee1e7d1b0a62ea64a4b1680e1 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 12:19:08 +0800 Subject: [PATCH 15/81] fix(lint): avoid external test package typecheck --- .golangci.yml | 5 +++++ provider/edge/speech/speech.go | 5 ----- provider/edge/speech/speech_test.go | 4 ++++ provider/openai/completions/kimi_schema.go | 10 ++++++---- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index cfd63cb..0c47d7d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -60,6 +60,11 @@ linters: run: timeout: 5m + # Tests are compiled and executed by the dedicated CI test job. golangci-lint + # v2.11.3 fails to resolve the valid external-test graph + # agent_test -> agent/runtimetest -> agent on Linux, so lint production + # packages here instead of duplicating that typecheck. + tests: false issues: max-issues-per-linter: 0 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 From f2d215c16b823e7dc4061ccf352d757dad449b05 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 12:24:53 +0800 Subject: [PATCH 16/81] fix(lint): exclude runtime conformance test helper --- .golangci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index 0c47d7d..828b7ad 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -49,6 +49,11 @@ linters: - comments - common-false-positives - std-error-handling + paths: + # runtimetest is an importable test helper. Its dependency on agent is + # exercised by go test; golangci-lint v2.11.3 cannot typecheck that + # in-repo reverse dependency on Linux. + - agent/runtimetest/.* rules: - path: _test\.go linters: From 40f9eb7cf8302260eb171f28ca5c0e448eda795b Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 12:46:22 +0800 Subject: [PATCH 17/81] refactor(agent): move run execution core into run package --- agent/{ => run}/alias_test.go | 2 +- agent/{ => run}/arguments.go | 2 +- agent/{ => run}/canonical.go | 2 +- agent/{ => run}/canonical_test.go | 2 +- agent/{ => run}/clone.go | 2 +- agent/{ => run}/codec.go | 2 +- agent/{ => run}/codec_test.go | 2 +- agent/{ => run}/command.go | 2 +- agent/{ => run}/commit.go | 2 +- agent/{ => run}/contract.go | 2 +- agent/{ => run}/decide.go | 2 +- agent/{ => run}/evolve.go | 2 +- agent/{ => run}/fact.go | 2 +- agent/{ => run}/ids.go | 2 +- agent/{ => run}/loop.go | 2 +- agent/{ => run}/loop_test.go | 2 +- agent/{ => run}/machine_test.go | 2 +- agent/{ => run}/memory_conformance_test.go | 6 +++--- agent/{ => run}/memory_runtime.go | 2 +- agent/{ => run}/model_data.go | 2 +- agent/{ => run}/next.go | 2 +- agent/{ => run}/protocol.go | 2 +- agent/{ => run}/rebuild.go | 2 +- agent/{ => run}/rebuild_test.go | 2 +- agent/{ => run}/regression_test.go | 2 +- agent/{ => run}/runtime_helpers_test.go | 2 +- agent/{ => run}/runtimetest/conformance.go | 2 +- agent/{ => run}/state.go | 2 +- agent/{ => run}/transition.go | 2 +- 29 files changed, 31 insertions(+), 31 deletions(-) rename agent/{ => run}/alias_test.go (99%) rename agent/{ => run}/arguments.go (99%) rename agent/{ => run}/canonical.go (98%) rename agent/{ => run}/canonical_test.go (99%) rename agent/{ => run}/clone.go (99%) rename agent/{ => run}/codec.go (99%) rename agent/{ => run}/codec_test.go (99%) rename agent/{ => run}/command.go (99%) rename agent/{ => run}/commit.go (99%) rename agent/{ => run}/contract.go (99%) rename agent/{ => run}/decide.go (99%) rename agent/{ => run}/evolve.go (99%) rename agent/{ => run}/fact.go (99%) rename agent/{ => run}/ids.go (99%) rename agent/{ => run}/loop.go (99%) rename agent/{ => run}/loop_test.go (99%) rename agent/{ => run}/machine_test.go (99%) rename agent/{ => run}/memory_conformance_test.go (63%) rename agent/{ => run}/memory_runtime.go (99%) rename agent/{ => run}/model_data.go (99%) rename agent/{ => run}/next.go (99%) rename agent/{ => run}/protocol.go (99%) rename agent/{ => run}/rebuild.go (99%) rename agent/{ => run}/rebuild_test.go (99%) rename agent/{ => run}/regression_test.go (99%) rename agent/{ => run}/runtime_helpers_test.go (99%) rename agent/{ => run}/runtimetest/conformance.go (99%) rename agent/{ => run}/state.go (99%) rename agent/{ => run}/transition.go (99%) diff --git a/agent/alias_test.go b/agent/run/alias_test.go similarity index 99% rename from agent/alias_test.go rename to agent/run/alias_test.go index 5366216..2dfcdeb 100644 --- a/agent/alias_test.go +++ b/agent/run/alias_test.go @@ -1,4 +1,4 @@ -package agent +package run import ( "context" diff --git a/agent/arguments.go b/agent/run/arguments.go similarity index 99% rename from agent/arguments.go rename to agent/run/arguments.go index 373568b..63de699 100644 --- a/agent/arguments.go +++ b/agent/run/arguments.go @@ -1,4 +1,4 @@ -package agent +package run import "encoding/json" diff --git a/agent/canonical.go b/agent/run/canonical.go similarity index 98% rename from agent/canonical.go rename to agent/run/canonical.go index 6f41530..999845b 100644 --- a/agent/canonical.go +++ b/agent/run/canonical.go @@ -3,7 +3,7 @@ // and the canonical encoding that gives commands and facts stable identity. // // See docs/design/agent-runtime-refactor.md for the governing spec. -package agent +package run import ( "github.com/memohai/twilight-ai/agent/es" diff --git a/agent/canonical_test.go b/agent/run/canonical_test.go similarity index 99% rename from agent/canonical_test.go rename to agent/run/canonical_test.go index 9a75167..98372b2 100644 --- a/agent/canonical_test.go +++ b/agent/run/canonical_test.go @@ -1,4 +1,4 @@ -package agent +package run import ( "strings" diff --git a/agent/clone.go b/agent/run/clone.go similarity index 99% rename from agent/clone.go rename to agent/run/clone.go index 19fc990..49e961d 100644 --- a/agent/clone.go +++ b/agent/run/clone.go @@ -1,4 +1,4 @@ -package agent +package run import "encoding/json" diff --git a/agent/codec.go b/agent/run/codec.go similarity index 99% rename from agent/codec.go rename to agent/run/codec.go index f873d12..730b8fb 100644 --- a/agent/codec.go +++ b/agent/run/codec.go @@ -1,4 +1,4 @@ -package agent +package run import ( "bytes" diff --git a/agent/codec_test.go b/agent/run/codec_test.go similarity index 99% rename from agent/codec_test.go rename to agent/run/codec_test.go index 7cecf28..fc87d56 100644 --- a/agent/codec_test.go +++ b/agent/run/codec_test.go @@ -1,4 +1,4 @@ -package agent +package run import ( "encoding/json" diff --git a/agent/command.go b/agent/run/command.go similarity index 99% rename from agent/command.go rename to agent/run/command.go index f26c2c4..dfadabb 100644 --- a/agent/command.go +++ b/agent/run/command.go @@ -1,4 +1,4 @@ -package agent +package run // AgentCommand is the intent submitted through Runtime.Commit for an existing // Run. Accepting one command constitutes one transition (spec §3.6). The diff --git a/agent/commit.go b/agent/run/commit.go similarity index 99% rename from agent/commit.go rename to agent/run/commit.go index 3b5dcae..9df6b82 100644 --- a/agent/commit.go +++ b/agent/run/commit.go @@ -1,4 +1,4 @@ -package agent +package run import ( "context" diff --git a/agent/contract.go b/agent/run/contract.go similarity index 99% rename from agent/contract.go rename to agent/run/contract.go index 04e36b6..c90176b 100644 --- a/agent/contract.go +++ b/agent/run/contract.go @@ -1,4 +1,4 @@ -package agent +package run import ( "context" diff --git a/agent/decide.go b/agent/run/decide.go similarity index 99% rename from agent/decide.go rename to agent/run/decide.go index 3d10f0a..56d2b5b 100644 --- a/agent/decide.go +++ b/agent/run/decide.go @@ -1,4 +1,4 @@ -package agent +package run import ( "errors" diff --git a/agent/evolve.go b/agent/run/evolve.go similarity index 99% rename from agent/evolve.go rename to agent/run/evolve.go index 0cf583c..d2743d5 100644 --- a/agent/evolve.go +++ b/agent/run/evolve.go @@ -1,4 +1,4 @@ -package agent +package run import "fmt" diff --git a/agent/fact.go b/agent/run/fact.go similarity index 99% rename from agent/fact.go rename to agent/run/fact.go index 0b87108..833a21f 100644 --- a/agent/fact.go +++ b/agent/run/fact.go @@ -1,4 +1,4 @@ -package agent +package run // Fact is one committed outcome produced by Machine.Decide. Facts are wrapped // as AgentEvents; Machine.Evolve folds them mechanically (spec §3.6). The diff --git a/agent/ids.go b/agent/run/ids.go similarity index 99% rename from agent/ids.go rename to agent/run/ids.go index b173d13..1f80721 100644 --- a/agent/ids.go +++ b/agent/run/ids.go @@ -1,4 +1,4 @@ -package agent +package run import ( "crypto/sha256" diff --git a/agent/loop.go b/agent/run/loop.go similarity index 99% rename from agent/loop.go rename to agent/run/loop.go index a793d36..7ab7761 100644 --- a/agent/loop.go +++ b/agent/run/loop.go @@ -1,4 +1,4 @@ -package agent +package run import ( "context" diff --git a/agent/loop_test.go b/agent/run/loop_test.go similarity index 99% rename from agent/loop_test.go rename to agent/run/loop_test.go index f7955d9..65a990e 100644 --- a/agent/loop_test.go +++ b/agent/run/loop_test.go @@ -1,4 +1,4 @@ -package agent +package run import ( "context" diff --git a/agent/machine_test.go b/agent/run/machine_test.go similarity index 99% rename from agent/machine_test.go rename to agent/run/machine_test.go index 07f2c6b..098c2aa 100644 --- a/agent/machine_test.go +++ b/agent/run/machine_test.go @@ -1,4 +1,4 @@ -package agent +package run import ( "encoding/json" diff --git a/agent/memory_conformance_test.go b/agent/run/memory_conformance_test.go similarity index 63% rename from agent/memory_conformance_test.go rename to agent/run/memory_conformance_test.go index c7c5643..86fd2b6 100644 --- a/agent/memory_conformance_test.go +++ b/agent/run/memory_conformance_test.go @@ -1,10 +1,10 @@ -package agent_test +package run_test import ( "testing" - "github.com/memohai/twilight-ai/agent" - "github.com/memohai/twilight-ai/agent/runtimetest" + agent "github.com/memohai/twilight-ai/agent/run" + "github.com/memohai/twilight-ai/agent/run/runtimetest" ) func TestMemoryRuntimeConformance(t *testing.T) { diff --git a/agent/memory_runtime.go b/agent/run/memory_runtime.go similarity index 99% rename from agent/memory_runtime.go rename to agent/run/memory_runtime.go index 3f010e4..3889934 100644 --- a/agent/memory_runtime.go +++ b/agent/run/memory_runtime.go @@ -1,4 +1,4 @@ -package agent +package run import ( "context" diff --git a/agent/model_data.go b/agent/run/model_data.go similarity index 99% rename from agent/model_data.go rename to agent/run/model_data.go index 87e3dc6..7c2753f 100644 --- a/agent/model_data.go +++ b/agent/run/model_data.go @@ -1,4 +1,4 @@ -package agent +package run import ( "encoding/json" diff --git a/agent/next.go b/agent/run/next.go similarity index 99% rename from agent/next.go rename to agent/run/next.go index 5b59cf7..84e8082 100644 --- a/agent/next.go +++ b/agent/run/next.go @@ -1,4 +1,4 @@ -package agent +package run // Effect is the at-most-one pending action Machine.Next derives from the // current state (spec §3.6). Effects are never persisted; the Loop re-derives diff --git a/agent/protocol.go b/agent/run/protocol.go similarity index 99% rename from agent/protocol.go rename to agent/run/protocol.go index 2d1190d..637583e 100644 --- a/agent/protocol.go +++ b/agent/run/protocol.go @@ -1,4 +1,4 @@ -package agent +package run import ( "errors" diff --git a/agent/rebuild.go b/agent/run/rebuild.go similarity index 99% rename from agent/rebuild.go rename to agent/run/rebuild.go index 4803869..00c02c5 100644 --- a/agent/rebuild.go +++ b/agent/run/rebuild.go @@ -1,4 +1,4 @@ -package agent +package run import ( "bytes" diff --git a/agent/rebuild_test.go b/agent/run/rebuild_test.go similarity index 99% rename from agent/rebuild_test.go rename to agent/run/rebuild_test.go index 71e112d..e1bc430 100644 --- a/agent/rebuild_test.go +++ b/agent/run/rebuild_test.go @@ -1,4 +1,4 @@ -package agent +package run import ( "context" diff --git a/agent/regression_test.go b/agent/run/regression_test.go similarity index 99% rename from agent/regression_test.go rename to agent/run/regression_test.go index ebe01f4..9344f7a 100644 --- a/agent/regression_test.go +++ b/agent/run/regression_test.go @@ -1,4 +1,4 @@ -package agent +package run import ( "context" diff --git a/agent/runtime_helpers_test.go b/agent/run/runtime_helpers_test.go similarity index 99% rename from agent/runtime_helpers_test.go rename to agent/run/runtime_helpers_test.go index 9990942..685f343 100644 --- a/agent/runtime_helpers_test.go +++ b/agent/run/runtime_helpers_test.go @@ -1,4 +1,4 @@ -package agent +package run import ( "context" diff --git a/agent/runtimetest/conformance.go b/agent/run/runtimetest/conformance.go similarity index 99% rename from agent/runtimetest/conformance.go rename to agent/run/runtimetest/conformance.go index 3ef6ef2..d134aed 100644 --- a/agent/runtimetest/conformance.go +++ b/agent/run/runtimetest/conformance.go @@ -10,7 +10,7 @@ import ( "errors" "testing" - "github.com/memohai/twilight-ai/agent" + agent "github.com/memohai/twilight-ai/agent/run" "github.com/memohai/twilight-ai/sdk" ) diff --git a/agent/state.go b/agent/run/state.go similarity index 99% rename from agent/state.go rename to agent/run/state.go index 2f8df88..a7402bc 100644 --- a/agent/state.go +++ b/agent/run/state.go @@ -1,4 +1,4 @@ -package agent +package run import ( "errors" diff --git a/agent/transition.go b/agent/run/transition.go similarity index 99% rename from agent/transition.go rename to agent/run/transition.go index 8f0f94a..fd4ef9a 100644 --- a/agent/transition.go +++ b/agent/run/transition.go @@ -1,4 +1,4 @@ -package agent +package run import ( "errors" From ab57936dd80d0e666b4a597e49366e329f5ba6ca Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 12:48:10 +0800 Subject: [PATCH 18/81] refactor(run): keep conformance suite in test package --- .golangci.yml | 10 ---------- .../conformance.go => conformance_test.go} | 11 +++++++++-- agent/run/memory_conformance_test.go | 15 --------------- 3 files changed, 9 insertions(+), 27 deletions(-) rename agent/run/{runtimetest/conformance.go => conformance_test.go} (98%) delete mode 100644 agent/run/memory_conformance_test.go diff --git a/.golangci.yml b/.golangci.yml index 828b7ad..cfd63cb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -49,11 +49,6 @@ linters: - comments - common-false-positives - std-error-handling - paths: - # runtimetest is an importable test helper. Its dependency on agent is - # exercised by go test; golangci-lint v2.11.3 cannot typecheck that - # in-repo reverse dependency on Linux. - - agent/runtimetest/.* rules: - path: _test\.go linters: @@ -65,11 +60,6 @@ linters: run: timeout: 5m - # Tests are compiled and executed by the dedicated CI test job. golangci-lint - # v2.11.3 fails to resolve the valid external-test graph - # agent_test -> agent/runtimetest -> agent on Linux, so lint production - # packages here instead of duplicating that typecheck. - tests: false issues: max-issues-per-linter: 0 diff --git a/agent/run/runtimetest/conformance.go b/agent/run/conformance_test.go similarity index 98% rename from agent/run/runtimetest/conformance.go rename to agent/run/conformance_test.go index d134aed..0859857 100644 --- a/agent/run/runtimetest/conformance.go +++ b/agent/run/conformance_test.go @@ -1,7 +1,7 @@ // Package runtimetest contains the shared Runtime conformance suite. Durable // Runtime implementations should run this suite in their own tests instead of // copying MemoryRuntime-specific assertions. -package runtimetest +package run_test import ( "bytes" @@ -18,7 +18,7 @@ import ( type Factory func(testing.TB, agent.MachineState) agent.Runtime // Run executes the shared Runtime conformance suite. -func Run(t *testing.T, newRuntime Factory) { +func runConformance(t *testing.T, newRuntime Factory) { t.Helper() t.Run("IdempotentReplay", func(t *testing.T) { testIdempotentReplay(t, newRuntime) }) t.Run("RevisionAndIndex", func(t *testing.T) { testRevisionAndIndex(t, newRuntime) }) @@ -448,3 +448,10 @@ func stateComparable(s *agent.MachineState) map[string]any { } return m } + +func TestMemoryRuntimeConformance(t *testing.T) { + runConformance(t, func(t testing.TB, initial agent.MachineState) agent.Runtime { + t.Helper() + return agent.NewMemoryRuntime(initial) + }) +} diff --git a/agent/run/memory_conformance_test.go b/agent/run/memory_conformance_test.go deleted file mode 100644 index 86fd2b6..0000000 --- a/agent/run/memory_conformance_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package run_test - -import ( - "testing" - - agent "github.com/memohai/twilight-ai/agent/run" - "github.com/memohai/twilight-ai/agent/run/runtimetest" -) - -func TestMemoryRuntimeConformance(t *testing.T) { - runtimetest.Run(t, func(t testing.TB, initial agent.MachineState) agent.Runtime { - t.Helper() - return agent.NewMemoryRuntime(initial) - }) -} From f62ccfbc37056b91ebfbaa98d2c3f545bf70cce5 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 12:57:11 +0800 Subject: [PATCH 19/81] fix(run): use internal conformance test package --- agent/run/conformance_test.go | 303 +++++++++++++++++----------------- 1 file changed, 151 insertions(+), 152 deletions(-) diff --git a/agent/run/conformance_test.go b/agent/run/conformance_test.go index 0859857..e67e1e5 100644 --- a/agent/run/conformance_test.go +++ b/agent/run/conformance_test.go @@ -1,7 +1,7 @@ // Package runtimetest contains the shared Runtime conformance suite. Durable // Runtime implementations should run this suite in their own tests instead of // copying MemoryRuntime-specific assertions. -package run_test +package run import ( "bytes" @@ -10,15 +10,14 @@ import ( "errors" "testing" - agent "github.com/memohai/twilight-ai/agent/run" "github.com/memohai/twilight-ai/sdk" ) -// Factory constructs a Runtime from an already-initialized Revision-0 state. -type Factory func(testing.TB, agent.MachineState) agent.Runtime +// conformanceFactory constructs a Runtime from an already-initialized Revision-0 state. +type conformanceFactory func(testing.TB, MachineState) Runtime // Run executes the shared Runtime conformance suite. -func runConformance(t *testing.T, newRuntime Factory) { +func runConformance(t *testing.T, newRuntime conformanceFactory) { t.Helper() t.Run("IdempotentReplay", func(t *testing.T) { testIdempotentReplay(t, newRuntime) }) t.Run("RevisionAndIndex", func(t *testing.T) { testRevisionAndIndex(t, newRuntime) }) @@ -33,25 +32,25 @@ func runConformance(t *testing.T, newRuntime Factory) { t.Run("DerivedCommandIDEnforced", func(t *testing.T) { testDerivedCommandIDEnforced(t, newRuntime) }) } -type runtimeCase struct { +type conformanceCase struct { t testing.TB - runID agent.RunID - initial agent.MachineState - rt agent.Runtime - events []agent.AgentEvent + runID RunID + initial MachineState + rt Runtime + events []AgentEvent } -func newCase(t testing.TB, newRuntime Factory) *runtimeCase { +func newConformanceCase(t testing.TB, newRuntime conformanceFactory) *conformanceCase { t.Helper() - initial, err := agent.InitializeRun("run-1") + initial, err := InitializeRun("run-1") if err != nil { t.Fatal(err) } - initial.PendingInputs = []agent.AgentInput{{ID: "seed", Payload: cj(`{"q":"hi"}`)}} - return &runtimeCase{t: t, runID: initial.RunID, initial: initial, rt: newRuntime(t, initial)} + initial.PendingInputs = []AgentInput{{ID: "seed", Payload: conformanceJSON(`{"q":"hi"}`)}} + return &conformanceCase{t: t, runID: initial.RunID, initial: initial, rt: newRuntime(t, initial)} } -func (c *runtimeCase) load() agent.RuntimeSnapshot { +func (c *conformanceCase) load() RuntimeSnapshot { c.t.Helper() snap, err := c.rt.Load(context.Background()) if err != nil { @@ -60,20 +59,20 @@ func (c *runtimeCase) load() agent.RuntimeSnapshot { return snap } -func (c *runtimeCase) commit(id agent.CommandID, base uint64, grant agent.ExecutionGrant, cmd agent.AgentCommand) (agent.CommitResult, error) { +func (c *conformanceCase) commit(id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) (CommitResult, error) { c.t.Helper() - env, err := agent.BuildEnvelope(c.runID, id, cmd) + env, err := BuildEnvelope(c.runID, id, cmd) if err != nil { c.t.Fatal(err) } - res, err := c.rt.Commit(context.Background(), agent.CommitRequest{BaseRevision: base, Grant: grant, Command: env}) - if err == nil && res.Status == agent.CommitAccepted { + res, err := c.rt.Commit(context.Background(), CommitRequest{BaseRevision: base, Grant: grant, Command: env}) + if err == nil && res.Status == CommitAccepted { c.events = append(c.events, res.Events...) } return res, err } -func (c *runtimeCase) mustCommit(id agent.CommandID, base uint64, grant agent.ExecutionGrant, cmd agent.AgentCommand) agent.CommitResult { +func (c *conformanceCase) mustCommit(id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) CommitResult { c.t.Helper() res, err := c.commit(id, base, grant, cmd) if err != nil { @@ -82,52 +81,52 @@ func (c *runtimeCase) mustCommit(id agent.CommandID, base uint64, grant agent.Ex return res } -func preparedCase(t testing.TB, newRuntime Factory, tools []sdk.ToolDefinition, specs []agent.ToolSpec) (*runtimeCase, agent.StepID, agent.ExecutionGrant) { +func preparedConformanceCase(t testing.TB, newRuntime conformanceFactory, tools []sdk.ToolDefinition, specs []ToolSpec) (*conformanceCase, StepID, ExecutionGrant) { t.Helper() - c := newCase(t, newRuntime) + c := newConformanceCase(t, newRuntime) snap := c.load() - req := testRequest(tools...) - prep, cmdID := buildPrepareFromSnap(t, &snap, &req, specs) + req := conformanceRequest(tools...) + prep, cmdID := buildConformancePrepareFromSnap(t, &snap, &req, specs) c.mustCommit(cmdID, snap.Revision, "", prep) - start := c.mustCommit("start-1", 1, "", agent.StartModelExecution{StepID: prep.StepID}) + start := c.mustCommit("start-1", 1, "", StartModelExecution{StepID: prep.StepID}) if start.Grant == "" { t.Fatal("accepted start returned no grant") } return c, prep.StepID, start.Grant } -func buildPrepareFromSnap(t testing.TB, snap *agent.RuntimeSnapshot, req *sdk.Request, specs []agent.ToolSpec) (agent.PrepareModelRequest, agent.CommandID) { +func buildConformancePrepareFromSnap(t testing.TB, snap *RuntimeSnapshot, req *sdk.Request, specs []ToolSpec) (PrepareModelRequest, CommandID) { t.Helper() - frozenReq, err := agent.FreezeModelRequest(*req) + frozenReq, err := FreezeModelRequest(*req) if err != nil { t.Fatal(err) } - reqDigest, err := agent.DigestRequest(frozenReq) + reqDigest, err := DigestRequest(frozenReq) if err != nil { t.Fatal(err) } - toolsDigest, err := agent.DigestToolSpecs(specs) + toolsDigest, err := DigestToolSpecs(specs) if err != nil { t.Fatal(err) } - model := agent.ModelRef(frozenReq.Model) - binding, err := agent.DigestModelStepBinding(model, reqDigest, toolsDigest) + model := ModelRef(frozenReq.Model) + binding, err := DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } - cmdID := agent.DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) - stepID := agent.DeriveModelStepID(snap.State.RunID, cmdID, binding) - ids := make([]agent.InputID, len(snap.State.PendingInputs)) + cmdID := DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) + stepID := DeriveModelStepID(snap.State.RunID, cmdID, binding) + ids := make([]InputID, len(snap.State.PendingInputs)) for i, in := range snap.State.PendingInputs { ids[i] = in.ID } - return agent.PrepareModelRequest{ + return PrepareModelRequest{ StepID: stepID, Model: model, Request: frozenReq, RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, }, cmdID } -func testRequest(tools ...sdk.ToolDefinition) sdk.Request { +func conformanceRequest(tools ...sdk.ToolDefinition) sdk.Request { return sdk.Request{ Model: "m-1", Messages: []sdk.Message{sdk.UserMessage("hi")}, @@ -135,34 +134,34 @@ func testRequest(tools ...sdk.ToolDefinition) sdk.Request { } } -func testToolDef(name string) sdk.ToolDefinition { +func conformanceToolDef(name string) sdk.ToolDefinition { return sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} } -func cj(raw string) agent.CanonicalJSON { return agent.MustParseCanonicalJSON(raw) } +func conformanceJSON(raw string) CanonicalJSON { return MustParseCanonicalJSON(raw) } -func makeSpec(t testing.TB, def sdk.ToolDefinition) agent.ToolSpec { +func makeConformanceSpec(t testing.TB, def sdk.ToolDefinition) ToolSpec { t.Helper() - frozen, err := agent.FreezeToolDefinition(def) + frozen, err := FreezeToolDefinition(def) if err != nil { t.Fatal(err) } - d, err := agent.DigestToolDefinition(frozen) + d, err := DigestToolDefinition(frozen) if err != nil { t.Fatal(err) } - return agent.ToolSpec{Ref: agent.ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: agent.DirectExecution} + return ToolSpec{Ref: ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: DirectExecution} } -func makeBinding(t testing.TB, callID string, spec *agent.ToolSpec) agent.ToolCallBinding { +func makeConformanceBinding(t testing.TB, callID string, spec *ToolSpec) ToolCallBinding { t.Helper() - parsedArgs := cj(`{}`) - bd, err := agent.DigestToolCallBinding(agent.CallID(callID), spec.DefinitionDigest, spec.Policy, parsedArgs) + parsedArgs := conformanceJSON(`{}`) + bd, err := DigestToolCallBinding(CallID(callID), spec.DefinitionDigest, spec.Policy, parsedArgs) if err != nil { t.Fatal(err) } - return agent.ToolCallBinding{ - CallID: agent.CallID(callID), + return ToolCallBinding{ + CallID: CallID(callID), ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, BindingDigest: bd, @@ -171,19 +170,19 @@ func makeBinding(t testing.TB, callID string, spec *agent.ToolSpec) agent.ToolCa } } -func openedToolStepID(t testing.TB, res *agent.CommitResult) agent.StepID { +func openedConformanceToolStepID(t testing.TB, res *CommitResult) StepID { t.Helper() if len(res.Events) < 2 { t.Fatalf("events = %d, want ToolStepOpened at index 1", len(res.Events)) } - opened, ok := res.Events[1].Fact.(agent.ToolStepOpened) + opened, ok := res.Events[1].Fact.(ToolStepOpened) if !ok { - t.Fatalf("event[1] fact = %T, want agent.ToolStepOpened", res.Events[1].Fact) + t.Fatalf("event[1] fact = %T, want ToolStepOpened", res.Events[1].Fact) } return opened.StepID } -func modelResultWithCalls(callIDs ...string) agent.ModelResult { +func conformanceModelResultWithCalls(callIDs ...string) ModelResult { r := sdk.ModelResult{ FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{InputTokens: 10, OutputTokens: 5, TotalTokens: 15}, @@ -191,21 +190,21 @@ func modelResultWithCalls(callIDs ...string) agent.ModelResult { for _, id := range callIDs { r.ToolCalls = append(r.ToolCalls, sdk.ToolCall{ToolCallID: id, ToolName: "t", Input: `{}`}) } - frozen, err := agent.FreezeModelResult(r) + frozen, err := FreezeModelResult(r) if err != nil { panic(err) } return frozen } -func testIdempotentReplay(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - res1 := c.mustCommit("cancel-1", 0, "", agent.CancelRun{}) - if res1.Status != agent.CommitAccepted || len(res1.Events) != 1 { +func testIdempotentReplay(t *testing.T, newRuntime conformanceFactory) { + c := newConformanceCase(t, newRuntime) + res1 := c.mustCommit("cancel-1", 0, "", CancelRun{}) + if res1.Status != CommitAccepted || len(res1.Events) != 1 { t.Fatalf("res1 = %+v", res1) } - res2 := c.mustCommit("cancel-1", 0, "", agent.CancelRun{}) - if res2.Status != agent.CommitAlreadyApplied { + res2 := c.mustCommit("cancel-1", 0, "", CancelRun{}) + if res2.Status != CommitAlreadyApplied { t.Fatalf("status = %v", res2.Status) } if len(res2.Events) != 1 || res2.Events[0].Digest != res1.Events[0].Digest || @@ -215,20 +214,20 @@ func testIdempotentReplay(t *testing.T, newRuntime Factory) { if res2.Snapshot.Revision != res1.Snapshot.Revision { t.Fatal("replay advanced the revision") } - _, err := c.commit("cancel-1", 0, "", agent.CancelRun{Reason: "other"}) - if !errors.Is(err, agent.ErrCommandConflict) { + _, err := c.commit("cancel-1", 0, "", CancelRun{Reason: "other"}) + if !errors.Is(err, ErrCommandConflict) { t.Fatalf("err = %v, want ErrCommandConflict", err) } } -func testRevisionAndIndex(t *testing.T, newRuntime Factory) { - def := testToolDef("t") - spec := makeSpec(t, def) - c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []agent.ToolSpec{spec}) +func testRevisionAndIndex(t *testing.T, newRuntime conformanceFactory) { + def := conformanceToolDef("t") + spec := makeConformanceSpec(t, def) + c, stepID, grant := preparedConformanceCase(t, newRuntime, []sdk.ToolDefinition{def}, []ToolSpec{spec}) - b := makeBinding(t, "c1", &spec) + b := makeConformanceBinding(t, "c1", &spec) res := c.mustCommit("complete-1", 2, grant, - agent.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []agent.ToolCallBinding{b}}) + SubmitModelResult{StepID: stepID, Result: conformanceModelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) if len(res.Events) != 2 { t.Fatalf("events = %d", len(res.Events)) } @@ -245,36 +244,36 @@ func testRevisionAndIndex(t *testing.T, newRuntime Factory) { } } -func testStartGrantLifecycle(t *testing.T, newRuntime Factory) { - c, stepID, grant := preparedCase(t, newRuntime, nil, nil) +func testStartGrantLifecycle(t *testing.T, newRuntime conformanceFactory) { + c, stepID, grant := preparedConformanceCase(t, newRuntime, nil, nil) - _, err := c.commit("done-x", 2, "", agent.SubmitModelResult{StepID: stepID, Result: agent.ModelResult{}}) - if !errors.Is(err, agent.ErrStaleRuntime) { + _, err := c.commit("done-x", 2, "", SubmitModelResult{StepID: stepID, Result: ModelResult{}}) + if !errors.Is(err, ErrStaleRuntime) { t.Fatalf("grantless completion err = %v, want ErrStaleRuntime", err) } - res := c.mustCommit("start-1", 1, "", agent.StartModelExecution{StepID: stepID}) - if res.Status != agent.CommitAlreadyApplied || res.Grant != "" { + res := c.mustCommit("start-1", 1, "", StartModelExecution{StepID: stepID}) + if res.Status != CommitAlreadyApplied || res.Grant != "" { t.Fatalf("replayed start: %+v", res) } - ok, err := agent.FreezeModelResult(sdk.ModelResult{Text: "ok"}) + ok, err := FreezeModelResult(sdk.ModelResult{Text: "ok"}) if err != nil { t.Fatal(err) } - res = c.mustCommit("done-1", 2, grant, agent.SubmitModelResult{StepID: stepID, Result: ok}) - if res.Status != agent.CommitAccepted || res.Snapshot.State.Status != agent.RunCompleted { + res = c.mustCommit("done-1", 2, grant, SubmitModelResult{StepID: stepID, Result: ok}) + if res.Status != CommitAccepted || res.Snapshot.State.Status != RunCompleted { t.Fatalf("completion: %+v", res.Snapshot.State.Status) } } -func testCallLocalRebase(t *testing.T, newRuntime Factory) { - defA, defB := testToolDef("a"), testToolDef("b") - specA := makeSpec(t, defA) - specB := makeSpec(t, defB) - c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{defA, defB}, []agent.ToolSpec{specA, specB}) +func testCallLocalRebase(t *testing.T, newRuntime conformanceFactory) { + defA, defB := conformanceToolDef("a"), conformanceToolDef("b") + specA := makeConformanceSpec(t, defA) + specB := makeConformanceSpec(t, defB) + c, stepID, grant := preparedConformanceCase(t, newRuntime, []sdk.ToolDefinition{defA, defB}, []ToolSpec{specA, specB}) - bA := makeBinding(t, "cA", &specA) - bB := makeBinding(t, "cB", &specB) - r, err := agent.FreezeModelResult(sdk.ModelResult{ + bA := makeConformanceBinding(t, "cA", &specA) + bB := makeConformanceBinding(t, "cB", &specB) + r, err := FreezeModelResult(sdk.ModelResult{ FinishReason: sdk.FinishReasonToolCalls, ToolCalls: []sdk.ToolCall{ {ToolCallID: "cA", ToolName: "a", Input: `{}`}, @@ -285,32 +284,32 @@ func testCallLocalRebase(t *testing.T, newRuntime Factory) { t.Fatal(err) } res := c.mustCommit("complete-1", 2, grant, - agent.SubmitModelResult{StepID: stepID, Result: r, Calls: []agent.ToolCallBinding{bA, bB}}) - toolStep := openedToolStepID(t, &res) + SubmitModelResult{StepID: stepID, Result: r, Calls: []ToolCallBinding{bA, bB}}) + toolStep := openedConformanceToolStepID(t, &res) base := res.Snapshot.Revision - startA := c.mustCommit("start-A", base, "", agent.StartToolCall{StepID: toolStep, CallID: "cA"}) - startB := c.mustCommit("start-B", base, "", agent.StartToolCall{StepID: toolStep, CallID: "cB"}) - if startB.Status != agent.CommitAccepted || startB.Grant == "" { + startA := c.mustCommit("start-A", base, "", StartToolCall{StepID: toolStep, CallID: "cA"}) + startB := c.mustCommit("start-B", base, "", StartToolCall{StepID: toolStep, CallID: "cB"}) + if startB.Status != CommitAccepted || startB.Grant == "" { t.Fatal("stale-base start of an untouched Pending call must rebase") } doneA := c.mustCommit("done-A", base, startA.Grant, - agent.SubmitToolResult{StepID: toolStep, CallID: "cA", Result: agent.ToolExecutionResult{Output: cj(`1`)}}) - if doneA.Status != agent.CommitAccepted { + SubmitToolResult{StepID: toolStep, CallID: "cA", Result: ToolExecutionResult{Output: conformanceJSON(`1`)}}) + if doneA.Status != CommitAccepted { t.Fatal("owner completion on stale base must rebase") } - _, err = c.commit("start-A2", base, "", agent.StartToolCall{StepID: toolStep, CallID: "cA"}) - if !errors.Is(err, agent.ErrStaleRuntime) { + _, err = c.commit("start-A2", base, "", StartToolCall{StepID: toolStep, CallID: "cA"}) + if !errors.Is(err, ErrStaleRuntime) { t.Fatalf("restart of settled call err = %v, want ErrStaleRuntime", err) } _ = startB } -func testPrepareDerivedIdentity(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) +func testPrepareDerivedIdentity(t *testing.T, newRuntime conformanceFactory) { + c := newConformanceCase(t, newRuntime) snap := c.load() - req := testRequest() - prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) + req := conformanceRequest() + prep, cmdID := buildConformancePrepareFromSnap(t, &snap, &req, nil) if _, err := c.commit("wrong-prepare-id", snap.Revision, "", prep); err == nil { t.Fatal("PrepareModelRequest accepted a non-derived CommandID") @@ -319,82 +318,82 @@ func testPrepareDerivedIdentity(t *testing.T, newRuntime Factory) { bad := prep bad.StepID = "wrong-step" _, err := c.commit(cmdID, snap.Revision, "", bad) - if !errors.Is(err, agent.ErrStaleRuntime) { + if !errors.Is(err, ErrStaleRuntime) { t.Fatalf("bad prepare StepID err = %v, want ErrStaleRuntime", err) } } -func testPrepareIsHardCAS(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) +func testPrepareIsHardCAS(t *testing.T, newRuntime conformanceFactory) { + c := newConformanceCase(t, newRuntime) snap := c.load() - req := testRequest() - prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) + req := conformanceRequest() + prep, cmdID := buildConformancePrepareFromSnap(t, &snap, &req, nil) c.mustCommit(cmdID, snap.Revision, "", prep) - otherReq := testRequest() + otherReq := conformanceRequest() otherReq.System = "different" - prep2, cmdID2 := buildPrepareFromSnap(t, &snap, &otherReq, nil) + prep2, cmdID2 := buildConformancePrepareFromSnap(t, &snap, &otherReq, nil) if cmdID2 != cmdID { t.Fatal("same revision must derive the same command id") } _, err := c.commit(cmdID2, snap.Revision, "", prep2) - if !errors.Is(err, agent.ErrCommandConflict) { + if !errors.Is(err, ErrCommandConflict) { t.Fatalf("err = %v, want ErrCommandConflict", err) } res := c.mustCommit(cmdID, snap.Revision, "", prep) - if res.Status != agent.CommitAlreadyApplied { + if res.Status != CommitAlreadyApplied { t.Fatalf("status = %v", res.Status) } } -func testCancelRebasesAndUnknownWins(t *testing.T, newRuntime Factory) { - def := testToolDef("t") - spec := makeSpec(t, def) - c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []agent.ToolSpec{spec}) - b := makeBinding(t, "c1", &spec) +func testCancelRebasesAndUnknownWins(t *testing.T, newRuntime conformanceFactory) { + def := conformanceToolDef("t") + spec := makeConformanceSpec(t, def) + c, stepID, grant := preparedConformanceCase(t, newRuntime, []sdk.ToolDefinition{def}, []ToolSpec{spec}) + b := makeConformanceBinding(t, "c1", &spec) res := c.mustCommit("complete-1", 2, grant, - agent.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []agent.ToolCallBinding{b}}) - toolStep := openedToolStepID(t, &res) - startRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", agent.StartToolCall{StepID: toolStep, CallID: "c1"}) + SubmitModelResult{StepID: stepID, Result: conformanceModelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) + toolStep := openedConformanceToolStepID(t, &res) + startRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: "c1"}) unknown := c.mustCommit("unk-1", startRes.Snapshot.Revision, startRes.Grant, - agent.SubmitToolFailure{StepID: toolStep, CallID: "c1", Outcome: agent.ToolOutcomeUnknown}) - if unknown.Snapshot.State.Status != agent.RunFailed { + SubmitToolFailure{StepID: toolStep, CallID: "c1", Outcome: ToolOutcomeUnknown}) + if unknown.Snapshot.State.Status != RunFailed { t.Fatal("unknown did not fail the run") } - _, err := c.commit("cancel-late", 0, "", agent.CancelRun{}) - if !errors.Is(err, agent.ErrRunTerminal) { + _, err := c.commit("cancel-late", 0, "", CancelRun{}) + if !errors.Is(err, ErrRunTerminal) { t.Fatalf("late cancel err = %v, want ErrRunTerminal", err) } } -func testCancelOnStaleBase(t *testing.T, newRuntime Factory) { - c, _, _ := preparedCase(t, newRuntime, nil, nil) - res := c.mustCommit("cancel-1", 0, "", agent.CancelRun{}) - if res.Status != agent.CommitAccepted || res.Snapshot.State.Status != agent.RunStopped { +func testCancelOnStaleBase(t *testing.T, newRuntime conformanceFactory) { + c, _, _ := preparedConformanceCase(t, newRuntime, nil, nil) + res := c.mustCommit("cancel-1", 0, "", CancelRun{}) + if res.Status != CommitAccepted || res.Snapshot.State.Status != RunStopped { t.Fatalf("cancel: %+v", res.Snapshot.State.Status) } } -func testReplayFoldMatchesState(t *testing.T, newRuntime Factory) { - def := testToolDef("t") - spec := makeSpec(t, def) - c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []agent.ToolSpec{spec}) - b := makeBinding(t, "c1", &spec) +func testReplayFoldMatchesState(t *testing.T, newRuntime conformanceFactory) { + def := conformanceToolDef("t") + spec := makeConformanceSpec(t, def) + c, stepID, grant := preparedConformanceCase(t, newRuntime, []sdk.ToolDefinition{def}, []ToolSpec{spec}) + b := makeConformanceBinding(t, "c1", &spec) res := c.mustCommit("complete-1", 2, grant, - agent.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []agent.ToolCallBinding{b}}) - toolStep := openedToolStepID(t, &res) - sRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", agent.StartToolCall{StepID: toolStep, CallID: "c1"}) + SubmitModelResult{StepID: stepID, Result: conformanceModelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) + toolStep := openedConformanceToolStepID(t, &res) + sRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: "c1"}) c.mustCommit("done-c1", sRes.Snapshot.Revision, sRes.Grant, - agent.SubmitToolResult{StepID: toolStep, CallID: "c1", Result: agent.ToolExecutionResult{Output: cj(`"ok"`)}}) + SubmitToolResult{StepID: toolStep, CallID: "c1", Result: ToolExecutionResult{Output: conformanceJSON(`"ok"`)}}) - folded, lastRev, err := agent.FoldEvents(c.initial, c.events) + folded, lastRev, err := FoldEvents(c.initial, c.events) if err != nil { t.Fatalf("FoldEvents: %v", err) } live := c.load() - a, _ := json.Marshal(stateComparable(&live.State)) - bts, _ := json.Marshal(stateComparable(&folded)) + a, _ := json.Marshal(conformanceStateComparable(&live.State)) + bts, _ := json.Marshal(conformanceStateComparable(&folded)) if !bytes.Equal(a, bts) { t.Fatalf("replay diverged:\n live %s\n replay %s", a, bts) } @@ -403,37 +402,37 @@ func testReplayFoldMatchesState(t *testing.T, newRuntime Factory) { } } -func testAcceptInputByInputID(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - in := agent.AgentInput{ID: "in-9", Payload: cj(`{"t":"x"}`)} - id := agent.DeriveInputCommandID("run-1", in.ID) - res1 := c.mustCommit(id, 0, "", agent.NextStep(in)) - if res1.Status != agent.CommitAccepted { +func testAcceptInputByInputID(t *testing.T, newRuntime conformanceFactory) { + c := newConformanceCase(t, newRuntime) + in := AgentInput{ID: "in-9", Payload: conformanceJSON(`{"t":"x"}`)} + id := DeriveInputCommandID("run-1", in.ID) + res1 := c.mustCommit(id, 0, "", NextStep(in)) + if res1.Status != CommitAccepted { t.Fatal("first accept rejected") } - res2 := c.mustCommit(id, 0, "", agent.NextStep(in)) - if res2.Status != agent.CommitAlreadyApplied { + res2 := c.mustCommit(id, 0, "", NextStep(in)) + if res2.Status != CommitAlreadyApplied { t.Fatalf("status = %v", res2.Status) } - _, err := c.commit(id, 0, "", agent.NextStep(agent.AgentInput{ID: "in-9", Payload: cj(`{"t":"y"}`)})) - if !errors.Is(err, agent.ErrCommandConflict) { + _, err := c.commit(id, 0, "", NextStep(AgentInput{ID: "in-9", Payload: conformanceJSON(`{"t":"y"}`)})) + if !errors.Is(err, ErrCommandConflict) { t.Fatalf("err = %v, want ErrCommandConflict", err) } } -func testDerivedCommandIDEnforced(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - _, err := c.commit("random-id", 0, "", agent.NextStep(agent.AgentInput{ID: "in-1", Payload: cj(`1`)})) +func testDerivedCommandIDEnforced(t *testing.T, newRuntime conformanceFactory) { + c := newConformanceCase(t, newRuntime) + _, err := c.commit("random-id", 0, "", NextStep(AgentInput{ID: "in-1", Payload: conformanceJSON(`1`)})) if err == nil { t.Fatal("AcceptInput with non-derived CommandID accepted") } - _, err = c.commit("random-id-2", 0, "", agent.ApproveToolCall{StepID: "s", CallID: "c", ResponseID: "r"}) + _, err = c.commit("random-id-2", 0, "", ApproveToolCall{StepID: "s", CallID: "c", ResponseID: "r"}) if err == nil { t.Fatal("ApproveToolCall with non-derived CommandID accepted") } } -func stateComparable(s *agent.MachineState) map[string]any { +func conformanceStateComparable(s *MachineState) map[string]any { m := map[string]any{ "runId": s.RunID, "status": s.Status, "modelSteps": s.ModelSteps, "lastClosedStep": s.LastClosedStep, @@ -441,17 +440,17 @@ func stateComparable(s *agent.MachineState) map[string]any { "lastModelResult": s.LastModelResult, "result": s.Result, } switch cur := s.Current.(type) { - case agent.ModelStep: + case ModelStep: m["modelStep"] = cur - case agent.ToolStep: + case ToolStep: m["toolStep"] = cur } return m } func TestMemoryRuntimeConformance(t *testing.T) { - runConformance(t, func(t testing.TB, initial agent.MachineState) agent.Runtime { + runConformance(t, func(t testing.TB, initial MachineState) Runtime { t.Helper() - return agent.NewMemoryRuntime(initial) + return NewMemoryRuntime(initial) }) } From dc07e363acd19026e8e0fafaa36c225364f0f185 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 14:21:59 +0800 Subject: [PATCH 20/81] refactor(run): restore runtimetest as an importable conformance package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lint fix that inlined the conformance suite into package run made it _test.go-only, which no other package can import — but spec §2.3/§8.2 promise the suite to durable Runtime adapters (Memoh) as their acceptance gate. Restore the net/http/httptest layout: agent/run/runtimetest is a normal package exporting RunConformance(t, factory) and importing run; run's own conformance entry point moves to an external test package (package run_test), so the dependency chain run_test -> runtimetest -> run has no cycle and golangci-lint 2.11.3 (the CI version) reports zero issues with no .golangci.yml exclusions. The suite already used only the public run API; it moves verbatim modulo package qualification. Also drops the stale 'Package runtimetest' comment that sat on top of package run. --- agent/run/conformance_test.go | 456 -------------------------- agent/run/memory_conformance_test.go | 19 ++ agent/run/runtimetest/conformance.go | 461 +++++++++++++++++++++++++++ 3 files changed, 480 insertions(+), 456 deletions(-) delete mode 100644 agent/run/conformance_test.go create mode 100644 agent/run/memory_conformance_test.go create mode 100644 agent/run/runtimetest/conformance.go diff --git a/agent/run/conformance_test.go b/agent/run/conformance_test.go deleted file mode 100644 index e67e1e5..0000000 --- a/agent/run/conformance_test.go +++ /dev/null @@ -1,456 +0,0 @@ -// Package runtimetest contains the shared Runtime conformance suite. Durable -// Runtime implementations should run this suite in their own tests instead of -// copying MemoryRuntime-specific assertions. -package run - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "testing" - - "github.com/memohai/twilight-ai/sdk" -) - -// conformanceFactory constructs a Runtime from an already-initialized Revision-0 state. -type conformanceFactory func(testing.TB, MachineState) Runtime - -// Run executes the shared Runtime conformance suite. -func runConformance(t *testing.T, newRuntime conformanceFactory) { - t.Helper() - t.Run("IdempotentReplay", func(t *testing.T) { testIdempotentReplay(t, newRuntime) }) - t.Run("RevisionAndIndex", func(t *testing.T) { testRevisionAndIndex(t, newRuntime) }) - t.Run("StartGrantLifecycle", func(t *testing.T) { testStartGrantLifecycle(t, newRuntime) }) - t.Run("CallLocalRebase", func(t *testing.T) { testCallLocalRebase(t, newRuntime) }) - t.Run("PrepareDerivedIdentity", func(t *testing.T) { testPrepareDerivedIdentity(t, newRuntime) }) - t.Run("PrepareIsHardCAS", func(t *testing.T) { testPrepareIsHardCAS(t, newRuntime) }) - t.Run("CancelRebasesAndUnknownWins", func(t *testing.T) { testCancelRebasesAndUnknownWins(t, newRuntime) }) - t.Run("CancelOnStaleBase", func(t *testing.T) { testCancelOnStaleBase(t, newRuntime) }) - t.Run("ReplayFoldMatchesState", func(t *testing.T) { testReplayFoldMatchesState(t, newRuntime) }) - t.Run("AcceptInputByInputID", func(t *testing.T) { testAcceptInputByInputID(t, newRuntime) }) - t.Run("DerivedCommandIDEnforced", func(t *testing.T) { testDerivedCommandIDEnforced(t, newRuntime) }) -} - -type conformanceCase struct { - t testing.TB - runID RunID - initial MachineState - rt Runtime - events []AgentEvent -} - -func newConformanceCase(t testing.TB, newRuntime conformanceFactory) *conformanceCase { - t.Helper() - initial, err := InitializeRun("run-1") - if err != nil { - t.Fatal(err) - } - initial.PendingInputs = []AgentInput{{ID: "seed", Payload: conformanceJSON(`{"q":"hi"}`)}} - return &conformanceCase{t: t, runID: initial.RunID, initial: initial, rt: newRuntime(t, initial)} -} - -func (c *conformanceCase) load() RuntimeSnapshot { - c.t.Helper() - snap, err := c.rt.Load(context.Background()) - if err != nil { - c.t.Fatal(err) - } - return snap -} - -func (c *conformanceCase) commit(id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) (CommitResult, error) { - c.t.Helper() - env, err := BuildEnvelope(c.runID, id, cmd) - if err != nil { - c.t.Fatal(err) - } - res, err := c.rt.Commit(context.Background(), CommitRequest{BaseRevision: base, Grant: grant, Command: env}) - if err == nil && res.Status == CommitAccepted { - c.events = append(c.events, res.Events...) - } - return res, err -} - -func (c *conformanceCase) mustCommit(id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) CommitResult { - c.t.Helper() - res, err := c.commit(id, base, grant, cmd) - if err != nil { - c.t.Fatalf("commit %T: %v", cmd, err) - } - return res -} - -func preparedConformanceCase(t testing.TB, newRuntime conformanceFactory, tools []sdk.ToolDefinition, specs []ToolSpec) (*conformanceCase, StepID, ExecutionGrant) { - t.Helper() - c := newConformanceCase(t, newRuntime) - snap := c.load() - req := conformanceRequest(tools...) - prep, cmdID := buildConformancePrepareFromSnap(t, &snap, &req, specs) - c.mustCommit(cmdID, snap.Revision, "", prep) - start := c.mustCommit("start-1", 1, "", StartModelExecution{StepID: prep.StepID}) - if start.Grant == "" { - t.Fatal("accepted start returned no grant") - } - return c, prep.StepID, start.Grant -} - -func buildConformancePrepareFromSnap(t testing.TB, snap *RuntimeSnapshot, req *sdk.Request, specs []ToolSpec) (PrepareModelRequest, CommandID) { - t.Helper() - frozenReq, err := FreezeModelRequest(*req) - if err != nil { - t.Fatal(err) - } - reqDigest, err := DigestRequest(frozenReq) - if err != nil { - t.Fatal(err) - } - toolsDigest, err := DigestToolSpecs(specs) - if err != nil { - t.Fatal(err) - } - model := ModelRef(frozenReq.Model) - binding, err := DigestModelStepBinding(model, reqDigest, toolsDigest) - if err != nil { - t.Fatal(err) - } - cmdID := DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) - stepID := DeriveModelStepID(snap.State.RunID, cmdID, binding) - ids := make([]InputID, len(snap.State.PendingInputs)) - for i, in := range snap.State.PendingInputs { - ids[i] = in.ID - } - return PrepareModelRequest{ - StepID: stepID, Model: model, Request: frozenReq, - RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, - }, cmdID -} - -func conformanceRequest(tools ...sdk.ToolDefinition) sdk.Request { - return sdk.Request{ - Model: "m-1", - Messages: []sdk.Message{sdk.UserMessage("hi")}, - Tools: tools, - } -} - -func conformanceToolDef(name string) sdk.ToolDefinition { - return sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} -} - -func conformanceJSON(raw string) CanonicalJSON { return MustParseCanonicalJSON(raw) } - -func makeConformanceSpec(t testing.TB, def sdk.ToolDefinition) ToolSpec { - t.Helper() - frozen, err := FreezeToolDefinition(def) - if err != nil { - t.Fatal(err) - } - d, err := DigestToolDefinition(frozen) - if err != nil { - t.Fatal(err) - } - return ToolSpec{Ref: ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: DirectExecution} -} - -func makeConformanceBinding(t testing.TB, callID string, spec *ToolSpec) ToolCallBinding { - t.Helper() - parsedArgs := conformanceJSON(`{}`) - bd, err := DigestToolCallBinding(CallID(callID), spec.DefinitionDigest, spec.Policy, parsedArgs) - if err != nil { - t.Fatal(err) - } - return ToolCallBinding{ - CallID: CallID(callID), - ToolRef: spec.Ref, - DefinitionDigest: spec.DefinitionDigest, - BindingDigest: bd, - Arguments: parsedArgs, - Policy: spec.Policy, - } -} - -func openedConformanceToolStepID(t testing.TB, res *CommitResult) StepID { - t.Helper() - if len(res.Events) < 2 { - t.Fatalf("events = %d, want ToolStepOpened at index 1", len(res.Events)) - } - opened, ok := res.Events[1].Fact.(ToolStepOpened) - if !ok { - t.Fatalf("event[1] fact = %T, want ToolStepOpened", res.Events[1].Fact) - } - return opened.StepID -} - -func conformanceModelResultWithCalls(callIDs ...string) ModelResult { - r := sdk.ModelResult{ - 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: "t", Input: `{}`}) - } - frozen, err := FreezeModelResult(r) - if err != nil { - panic(err) - } - return frozen -} - -func testIdempotentReplay(t *testing.T, newRuntime conformanceFactory) { - c := newConformanceCase(t, newRuntime) - res1 := c.mustCommit("cancel-1", 0, "", CancelRun{}) - if res1.Status != CommitAccepted || len(res1.Events) != 1 { - t.Fatalf("res1 = %+v", res1) - } - res2 := c.mustCommit("cancel-1", 0, "", CancelRun{}) - if res2.Status != CommitAlreadyApplied { - t.Fatalf("status = %v", res2.Status) - } - if len(res2.Events) != 1 || res2.Events[0].Digest != res1.Events[0].Digest || - res2.Events[0].Revision != res1.Events[0].Revision { - t.Fatal("replay did not return the original event group") - } - if res2.Snapshot.Revision != res1.Snapshot.Revision { - t.Fatal("replay advanced the revision") - } - _, err := c.commit("cancel-1", 0, "", CancelRun{Reason: "other"}) - if !errors.Is(err, ErrCommandConflict) { - t.Fatalf("err = %v, want ErrCommandConflict", err) - } -} - -func testRevisionAndIndex(t *testing.T, newRuntime conformanceFactory) { - def := conformanceToolDef("t") - spec := makeConformanceSpec(t, def) - c, stepID, grant := preparedConformanceCase(t, newRuntime, []sdk.ToolDefinition{def}, []ToolSpec{spec}) - - b := makeConformanceBinding(t, "c1", &spec) - res := c.mustCommit("complete-1", 2, grant, - SubmitModelResult{StepID: stepID, Result: conformanceModelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) - if len(res.Events) != 2 { - t.Fatalf("events = %d", len(res.Events)) - } - for i, e := range res.Events { - if e.Revision != res.Snapshot.Revision { - t.Fatalf("event revision %d != snapshot %d", e.Revision, res.Snapshot.Revision) - } - if int(e.Index) != i { - t.Fatalf("index[%d] = %d", i, e.Index) - } - if e.CommandID != "complete-1" { - t.Fatal("command id not stamped") - } - } -} - -func testStartGrantLifecycle(t *testing.T, newRuntime conformanceFactory) { - c, stepID, grant := preparedConformanceCase(t, newRuntime, nil, nil) - - _, err := c.commit("done-x", 2, "", SubmitModelResult{StepID: stepID, Result: ModelResult{}}) - if !errors.Is(err, ErrStaleRuntime) { - t.Fatalf("grantless completion err = %v, want ErrStaleRuntime", err) - } - res := c.mustCommit("start-1", 1, "", StartModelExecution{StepID: stepID}) - if res.Status != CommitAlreadyApplied || res.Grant != "" { - t.Fatalf("replayed start: %+v", res) - } - ok, err := FreezeModelResult(sdk.ModelResult{Text: "ok"}) - if err != nil { - t.Fatal(err) - } - res = c.mustCommit("done-1", 2, grant, SubmitModelResult{StepID: stepID, Result: ok}) - if res.Status != CommitAccepted || res.Snapshot.State.Status != RunCompleted { - t.Fatalf("completion: %+v", res.Snapshot.State.Status) - } -} - -func testCallLocalRebase(t *testing.T, newRuntime conformanceFactory) { - defA, defB := conformanceToolDef("a"), conformanceToolDef("b") - specA := makeConformanceSpec(t, defA) - specB := makeConformanceSpec(t, defB) - c, stepID, grant := preparedConformanceCase(t, newRuntime, []sdk.ToolDefinition{defA, defB}, []ToolSpec{specA, specB}) - - bA := makeConformanceBinding(t, "cA", &specA) - bB := makeConformanceBinding(t, "cB", &specB) - r, err := FreezeModelResult(sdk.ModelResult{ - FinishReason: sdk.FinishReasonToolCalls, - ToolCalls: []sdk.ToolCall{ - {ToolCallID: "cA", ToolName: "a", Input: `{}`}, - {ToolCallID: "cB", ToolName: "b", Input: `{}`}, - }, - }) - if err != nil { - t.Fatal(err) - } - res := c.mustCommit("complete-1", 2, grant, - SubmitModelResult{StepID: stepID, Result: r, Calls: []ToolCallBinding{bA, bB}}) - toolStep := openedConformanceToolStepID(t, &res) - base := res.Snapshot.Revision - - startA := c.mustCommit("start-A", base, "", StartToolCall{StepID: toolStep, CallID: "cA"}) - startB := c.mustCommit("start-B", base, "", StartToolCall{StepID: toolStep, CallID: "cB"}) - if startB.Status != CommitAccepted || startB.Grant == "" { - t.Fatal("stale-base start of an untouched Pending call must rebase") - } - doneA := c.mustCommit("done-A", base, startA.Grant, - SubmitToolResult{StepID: toolStep, CallID: "cA", Result: ToolExecutionResult{Output: conformanceJSON(`1`)}}) - if doneA.Status != CommitAccepted { - t.Fatal("owner completion on stale base must rebase") - } - _, err = c.commit("start-A2", base, "", StartToolCall{StepID: toolStep, CallID: "cA"}) - if !errors.Is(err, ErrStaleRuntime) { - t.Fatalf("restart of settled call err = %v, want ErrStaleRuntime", err) - } - _ = startB -} - -func testPrepareDerivedIdentity(t *testing.T, newRuntime conformanceFactory) { - c := newConformanceCase(t, newRuntime) - snap := c.load() - req := conformanceRequest() - prep, cmdID := buildConformancePrepareFromSnap(t, &snap, &req, nil) - - if _, err := c.commit("wrong-prepare-id", snap.Revision, "", prep); err == nil { - t.Fatal("PrepareModelRequest accepted a non-derived CommandID") - } - - bad := prep - bad.StepID = "wrong-step" - _, err := c.commit(cmdID, snap.Revision, "", bad) - if !errors.Is(err, ErrStaleRuntime) { - t.Fatalf("bad prepare StepID err = %v, want ErrStaleRuntime", err) - } -} - -func testPrepareIsHardCAS(t *testing.T, newRuntime conformanceFactory) { - c := newConformanceCase(t, newRuntime) - snap := c.load() - req := conformanceRequest() - prep, cmdID := buildConformancePrepareFromSnap(t, &snap, &req, nil) - c.mustCommit(cmdID, snap.Revision, "", prep) - - otherReq := conformanceRequest() - otherReq.System = "different" - prep2, cmdID2 := buildConformancePrepareFromSnap(t, &snap, &otherReq, nil) - if cmdID2 != cmdID { - t.Fatal("same revision must derive the same command id") - } - _, err := c.commit(cmdID2, snap.Revision, "", prep2) - if !errors.Is(err, ErrCommandConflict) { - t.Fatalf("err = %v, want ErrCommandConflict", err) - } - res := c.mustCommit(cmdID, snap.Revision, "", prep) - if res.Status != CommitAlreadyApplied { - t.Fatalf("status = %v", res.Status) - } -} - -func testCancelRebasesAndUnknownWins(t *testing.T, newRuntime conformanceFactory) { - def := conformanceToolDef("t") - spec := makeConformanceSpec(t, def) - c, stepID, grant := preparedConformanceCase(t, newRuntime, []sdk.ToolDefinition{def}, []ToolSpec{spec}) - b := makeConformanceBinding(t, "c1", &spec) - res := c.mustCommit("complete-1", 2, grant, - SubmitModelResult{StepID: stepID, Result: conformanceModelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) - toolStep := openedConformanceToolStepID(t, &res) - startRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: "c1"}) - - unknown := c.mustCommit("unk-1", startRes.Snapshot.Revision, startRes.Grant, - SubmitToolFailure{StepID: toolStep, CallID: "c1", Outcome: ToolOutcomeUnknown}) - if unknown.Snapshot.State.Status != RunFailed { - t.Fatal("unknown did not fail the run") - } - _, err := c.commit("cancel-late", 0, "", CancelRun{}) - if !errors.Is(err, ErrRunTerminal) { - t.Fatalf("late cancel err = %v, want ErrRunTerminal", err) - } -} - -func testCancelOnStaleBase(t *testing.T, newRuntime conformanceFactory) { - c, _, _ := preparedConformanceCase(t, newRuntime, nil, nil) - res := c.mustCommit("cancel-1", 0, "", CancelRun{}) - if res.Status != CommitAccepted || res.Snapshot.State.Status != RunStopped { - t.Fatalf("cancel: %+v", res.Snapshot.State.Status) - } -} - -func testReplayFoldMatchesState(t *testing.T, newRuntime conformanceFactory) { - def := conformanceToolDef("t") - spec := makeConformanceSpec(t, def) - c, stepID, grant := preparedConformanceCase(t, newRuntime, []sdk.ToolDefinition{def}, []ToolSpec{spec}) - b := makeConformanceBinding(t, "c1", &spec) - res := c.mustCommit("complete-1", 2, grant, - SubmitModelResult{StepID: stepID, Result: conformanceModelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) - toolStep := openedConformanceToolStepID(t, &res) - sRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: "c1"}) - c.mustCommit("done-c1", sRes.Snapshot.Revision, sRes.Grant, - SubmitToolResult{StepID: toolStep, CallID: "c1", Result: ToolExecutionResult{Output: conformanceJSON(`"ok"`)}}) - - folded, lastRev, err := FoldEvents(c.initial, c.events) - if err != nil { - t.Fatalf("FoldEvents: %v", err) - } - live := c.load() - a, _ := json.Marshal(conformanceStateComparable(&live.State)) - bts, _ := json.Marshal(conformanceStateComparable(&folded)) - if !bytes.Equal(a, bts) { - t.Fatalf("replay diverged:\n live %s\n replay %s", a, bts) - } - if live.Revision != lastRev { - t.Fatalf("snapshot revision %d != last event revision %d", live.Revision, lastRev) - } -} - -func testAcceptInputByInputID(t *testing.T, newRuntime conformanceFactory) { - c := newConformanceCase(t, newRuntime) - in := AgentInput{ID: "in-9", Payload: conformanceJSON(`{"t":"x"}`)} - id := DeriveInputCommandID("run-1", in.ID) - res1 := c.mustCommit(id, 0, "", NextStep(in)) - if res1.Status != CommitAccepted { - t.Fatal("first accept rejected") - } - res2 := c.mustCommit(id, 0, "", NextStep(in)) - if res2.Status != CommitAlreadyApplied { - t.Fatalf("status = %v", res2.Status) - } - _, err := c.commit(id, 0, "", NextStep(AgentInput{ID: "in-9", Payload: conformanceJSON(`{"t":"y"}`)})) - if !errors.Is(err, ErrCommandConflict) { - t.Fatalf("err = %v, want ErrCommandConflict", err) - } -} - -func testDerivedCommandIDEnforced(t *testing.T, newRuntime conformanceFactory) { - c := newConformanceCase(t, newRuntime) - _, err := c.commit("random-id", 0, "", NextStep(AgentInput{ID: "in-1", Payload: conformanceJSON(`1`)})) - if err == nil { - t.Fatal("AcceptInput with non-derived CommandID accepted") - } - _, err = c.commit("random-id-2", 0, "", ApproveToolCall{StepID: "s", CallID: "c", ResponseID: "r"}) - if err == nil { - t.Fatal("ApproveToolCall with non-derived CommandID accepted") - } -} - -func conformanceStateComparable(s *MachineState) map[string]any { - m := map[string]any{ - "runId": s.RunID, "status": s.Status, - "modelSteps": s.ModelSteps, "lastClosedStep": s.LastClosedStep, - "usage": s.Usage, "pendingInputs": s.PendingInputs, - "lastModelResult": s.LastModelResult, "result": s.Result, - } - switch cur := s.Current.(type) { - case ModelStep: - m["modelStep"] = cur - case ToolStep: - m["toolStep"] = cur - } - return m -} - -func TestMemoryRuntimeConformance(t *testing.T) { - runConformance(t, func(t testing.TB, initial MachineState) Runtime { - t.Helper() - return NewMemoryRuntime(initial) - }) -} diff --git a/agent/run/memory_conformance_test.go b/agent/run/memory_conformance_test.go new file mode 100644 index 0000000..2ea2451 --- /dev/null +++ b/agent/run/memory_conformance_test.go @@ -0,0 +1,19 @@ +package run_test + +import ( + "testing" + + "github.com/memohai/twilight-ai/agent/run" + "github.com/memohai/twilight-ai/agent/run/runtimetest" +) + +// MemoryRuntime is the reference implementation of the Runtime contract; it +// runs the same shared suite durable adapters import (spec §2.3, §8.2). +// Dependency chain: run_test -> runtimetest -> run (the net/http/httptest +// layout), so the loop is broken by the external test package. +func TestMemoryRuntimeConformance(t *testing.T) { + runtimetest.RunConformance(t, func(t testing.TB, initial run.MachineState) run.Runtime { + t.Helper() + return run.NewMemoryRuntime(initial) + }) +} diff --git a/agent/run/runtimetest/conformance.go b/agent/run/runtimetest/conformance.go new file mode 100644 index 0000000..fab3375 --- /dev/null +++ b/agent/run/runtimetest/conformance.go @@ -0,0 +1,461 @@ +// Package runtimetest contains the shared run.Runtime conformance suite. +// Durable Runtime implementations run this suite in their own tests instead +// of copying MemoryRuntime-specific assertions: +// +// func TestMyRuntimeConformance(t *testing.T) { +// runtimetest.RunConformance(t, func(t testing.TB, initial run.MachineState) run.Runtime { +// return newMyRuntime(t, initial) +// }) +// } +// +// The suite exercises only the public run API, so it holds for any Runtime +// that honors the contract (spec §14.3): command idempotency, revision/index +// assignment, grant lifecycle, call-local rebase, prepare hard-CAS, terminal +// arbitration, and replay-fold equivalence. +package runtimetest + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/memohai/twilight-ai/agent/run" + "github.com/memohai/twilight-ai/sdk" +) + +// Factory constructs the Runtime under test from an already-initialized +// Revision-0 state. +type Factory func(testing.TB, run.MachineState) run.Runtime + +// RunConformance executes the shared Runtime conformance suite. +func RunConformance(t *testing.T, newRuntime Factory) { + t.Helper() + t.Run("IdempotentReplay", func(t *testing.T) { testIdempotentReplay(t, newRuntime) }) + t.Run("RevisionAndIndex", func(t *testing.T) { testRevisionAndIndex(t, newRuntime) }) + t.Run("StartGrantLifecycle", func(t *testing.T) { testStartGrantLifecycle(t, newRuntime) }) + t.Run("CallLocalRebase", func(t *testing.T) { testCallLocalRebase(t, newRuntime) }) + t.Run("PrepareDerivedIdentity", func(t *testing.T) { testPrepareDerivedIdentity(t, newRuntime) }) + t.Run("PrepareIsHardCAS", func(t *testing.T) { testPrepareIsHardCAS(t, newRuntime) }) + t.Run("CancelRebasesAndUnknownWins", func(t *testing.T) { testCancelRebasesAndUnknownWins(t, newRuntime) }) + t.Run("CancelOnStaleBase", func(t *testing.T) { testCancelOnStaleBase(t, newRuntime) }) + t.Run("ReplayFoldMatchesState", func(t *testing.T) { testReplayFoldMatchesState(t, newRuntime) }) + t.Run("AcceptInputByInputID", func(t *testing.T) { testAcceptInputByInputID(t, newRuntime) }) + t.Run("DerivedCommandIDEnforced", func(t *testing.T) { testDerivedCommandIDEnforced(t, newRuntime) }) +} + +type conformanceCase struct { + t testing.TB + runID run.RunID + initial run.MachineState + rt run.Runtime + events []run.AgentEvent +} + +func newCase(t testing.TB, newRuntime Factory) *conformanceCase { + t.Helper() + initial, err := run.InitializeRun("run-1") + if err != nil { + t.Fatal(err) + } + initial.PendingInputs = []run.AgentInput{{ID: "seed", Payload: mustJSON(`{"q":"hi"}`)}} + return &conformanceCase{t: t, runID: initial.RunID, initial: initial, rt: newRuntime(t, initial)} +} + +func (c *conformanceCase) load() run.RuntimeSnapshot { + c.t.Helper() + snap, err := c.rt.Load(context.Background()) + if err != nil { + c.t.Fatal(err) + } + return snap +} + +func (c *conformanceCase) commit(id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) (run.CommitResult, error) { + c.t.Helper() + env, err := run.BuildEnvelope(c.runID, id, cmd) + if err != nil { + c.t.Fatal(err) + } + res, err := c.rt.Commit(context.Background(), run.CommitRequest{BaseRevision: base, Grant: grant, Command: env}) + if err == nil && res.Status == run.CommitAccepted { + c.events = append(c.events, res.Events...) + } + return res, err +} + +func (c *conformanceCase) mustCommit(id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) run.CommitResult { + c.t.Helper() + res, err := c.commit(id, base, grant, cmd) + if err != nil { + c.t.Fatalf("commit %T: %v", cmd, err) + } + return res +} + +func preparedCase(t testing.TB, newRuntime Factory, tools []sdk.ToolDefinition, specs []run.ToolSpec) (*conformanceCase, run.StepID, run.ExecutionGrant) { + t.Helper() + c := newCase(t, newRuntime) + snap := c.load() + req := request(tools...) + prep, cmdID := buildPrepareFromSnap(t, &snap, &req, specs) + c.mustCommit(cmdID, snap.Revision, "", prep) + start := c.mustCommit("start-1", 1, "", run.StartModelExecution{StepID: prep.StepID}) + if start.Grant == "" { + t.Fatal("accepted start returned no grant") + } + return c, prep.StepID, start.Grant +} + +func buildPrepareFromSnap(t testing.TB, snap *run.RuntimeSnapshot, req *sdk.Request, specs []run.ToolSpec) (run.PrepareModelRequest, run.CommandID) { + t.Helper() + frozenReq, err := run.FreezeModelRequest(*req) + if err != nil { + t.Fatal(err) + } + reqDigest, err := run.DigestRequest(frozenReq) + if err != nil { + t.Fatal(err) + } + toolsDigest, err := run.DigestToolSpecs(specs) + if err != nil { + t.Fatal(err) + } + model := run.ModelRef(frozenReq.Model) + binding, err := run.DigestModelStepBinding(model, reqDigest, toolsDigest) + if err != nil { + t.Fatal(err) + } + cmdID := run.DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) + stepID := run.DeriveModelStepID(snap.State.RunID, cmdID, binding) + ids := make([]run.InputID, len(snap.State.PendingInputs)) + for i, in := range snap.State.PendingInputs { + ids[i] = in.ID + } + return run.PrepareModelRequest{ + StepID: stepID, Model: model, Request: frozenReq, + RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, + }, cmdID +} + +func request(tools ...sdk.ToolDefinition) sdk.Request { + return sdk.Request{ + Model: "m-1", + Messages: []sdk.Message{sdk.UserMessage("hi")}, + Tools: tools, + } +} + +func toolDef(name string) sdk.ToolDefinition { + return sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} +} + +func mustJSON(raw string) run.CanonicalJSON { return run.MustParseCanonicalJSON(raw) } + +func makeSpec(t testing.TB, def sdk.ToolDefinition) run.ToolSpec { + t.Helper() + frozen, err := run.FreezeToolDefinition(def) + if err != nil { + t.Fatal(err) + } + d, err := run.DigestToolDefinition(frozen) + if err != nil { + t.Fatal(err) + } + return run.ToolSpec{Ref: run.ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: run.DirectExecution} +} + +func makeBinding(t testing.TB, callID string, spec *run.ToolSpec) run.ToolCallBinding { + t.Helper() + parsedArgs := mustJSON(`{}`) + bd, err := run.DigestToolCallBinding(run.CallID(callID), spec.DefinitionDigest, spec.Policy, parsedArgs) + if err != nil { + t.Fatal(err) + } + return run.ToolCallBinding{ + CallID: run.CallID(callID), + ToolRef: spec.Ref, + DefinitionDigest: spec.DefinitionDigest, + BindingDigest: bd, + Arguments: parsedArgs, + Policy: spec.Policy, + } +} + +func openedToolStepID(t testing.TB, res *run.CommitResult) run.StepID { + t.Helper() + if len(res.Events) < 2 { + t.Fatalf("events = %d, want ToolStepOpened at index 1", len(res.Events)) + } + opened, ok := res.Events[1].Fact.(run.ToolStepOpened) + if !ok { + t.Fatalf("event[1] fact = %T, want ToolStepOpened", res.Events[1].Fact) + } + return opened.StepID +} + +func modelResultWithCalls(callIDs ...string) run.ModelResult { + r := sdk.ModelResult{ + 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: "t", Input: `{}`}) + } + frozen, err := run.FreezeModelResult(r) + if err != nil { + panic(err) + } + return frozen +} + +func testIdempotentReplay(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime) + res1 := c.mustCommit("cancel-1", 0, "", run.CancelRun{}) + if res1.Status != run.CommitAccepted || len(res1.Events) != 1 { + t.Fatalf("res1 = %+v", res1) + } + res2 := c.mustCommit("cancel-1", 0, "", run.CancelRun{}) + if res2.Status != run.CommitAlreadyApplied { + t.Fatalf("status = %v", res2.Status) + } + if len(res2.Events) != 1 || res2.Events[0].Digest != res1.Events[0].Digest || + res2.Events[0].Revision != res1.Events[0].Revision { + t.Fatal("replay did not return the original event group") + } + if res2.Snapshot.Revision != res1.Snapshot.Revision { + t.Fatal("replay advanced the revision") + } + _, err := c.commit("cancel-1", 0, "", run.CancelRun{Reason: "other"}) + if !errors.Is(err, run.ErrCommandConflict) { + t.Fatalf("err = %v, want ErrCommandConflict", err) + } +} + +func testRevisionAndIndex(t *testing.T, newRuntime Factory) { + def := toolDef("t") + spec := makeSpec(t, def) + c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []run.ToolSpec{spec}) + + b := makeBinding(t, "c1", &spec) + res := c.mustCommit("complete-1", 2, grant, + run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) + if len(res.Events) != 2 { + t.Fatalf("events = %d", len(res.Events)) + } + for i, e := range res.Events { + if e.Revision != res.Snapshot.Revision { + t.Fatalf("event revision %d != snapshot %d", e.Revision, res.Snapshot.Revision) + } + if int(e.Index) != i { + t.Fatalf("index[%d] = %d", i, e.Index) + } + if e.CommandID != "complete-1" { + t.Fatal("command id not stamped") + } + } +} + +func testStartGrantLifecycle(t *testing.T, newRuntime Factory) { + c, stepID, grant := preparedCase(t, newRuntime, nil, nil) + + _, err := c.commit("done-x", 2, "", run.SubmitModelResult{StepID: stepID, Result: run.ModelResult{}}) + if !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("grantless completion err = %v, want ErrStaleRuntime", err) + } + res := c.mustCommit("start-1", 1, "", run.StartModelExecution{StepID: stepID}) + if res.Status != run.CommitAlreadyApplied || res.Grant != "" { + t.Fatalf("replayed start: %+v", res) + } + ok, err := run.FreezeModelResult(sdk.ModelResult{Text: "ok"}) + if err != nil { + t.Fatal(err) + } + res = c.mustCommit("done-1", 2, grant, run.SubmitModelResult{StepID: stepID, Result: ok}) + if res.Status != run.CommitAccepted || res.Snapshot.State.Status != run.RunCompleted { + t.Fatalf("completion: %+v", res.Snapshot.State.Status) + } +} + +func testCallLocalRebase(t *testing.T, newRuntime Factory) { + defA, defB := toolDef("a"), toolDef("b") + specA := makeSpec(t, defA) + specB := makeSpec(t, defB) + c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{defA, defB}, []run.ToolSpec{specA, specB}) + + bA := makeBinding(t, "cA", &specA) + bB := makeBinding(t, "cB", &specB) + r, err := run.FreezeModelResult(sdk.ModelResult{ + FinishReason: sdk.FinishReasonToolCalls, + ToolCalls: []sdk.ToolCall{ + {ToolCallID: "cA", ToolName: "a", Input: `{}`}, + {ToolCallID: "cB", ToolName: "b", Input: `{}`}, + }, + }) + if err != nil { + t.Fatal(err) + } + res := c.mustCommit("complete-1", 2, grant, + run.SubmitModelResult{StepID: stepID, Result: r, Calls: []run.ToolCallBinding{bA, bB}}) + toolStep := openedToolStepID(t, &res) + base := res.Snapshot.Revision + + startA := c.mustCommit("start-A", base, "", run.StartToolCall{StepID: toolStep, CallID: "cA"}) + startB := c.mustCommit("start-B", base, "", run.StartToolCall{StepID: toolStep, CallID: "cB"}) + if startB.Status != run.CommitAccepted || startB.Grant == "" { + t.Fatal("stale-base start of an untouched Pending call must rebase") + } + doneA := c.mustCommit("done-A", base, startA.Grant, + run.SubmitToolResult{StepID: toolStep, CallID: "cA", Result: run.ToolExecutionResult{Output: mustJSON(`1`)}}) + if doneA.Status != run.CommitAccepted { + t.Fatal("owner completion on stale base must rebase") + } + _, err = c.commit("start-A2", base, "", run.StartToolCall{StepID: toolStep, CallID: "cA"}) + if !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("restart of settled call err = %v, want ErrStaleRuntime", err) + } +} + +func testPrepareDerivedIdentity(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime) + snap := c.load() + req := request() + prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) + + if _, err := c.commit("wrong-prepare-id", snap.Revision, "", prep); err == nil { + t.Fatal("PrepareModelRequest accepted a non-derived CommandID") + } + + bad := prep + bad.StepID = "wrong-step" + _, err := c.commit(cmdID, snap.Revision, "", bad) + if !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("bad prepare StepID err = %v, want ErrStaleRuntime", err) + } +} + +func testPrepareIsHardCAS(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime) + snap := c.load() + req := request() + prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) + c.mustCommit(cmdID, snap.Revision, "", prep) + + otherReq := request() + otherReq.System = "different" + prep2, cmdID2 := buildPrepareFromSnap(t, &snap, &otherReq, nil) + if cmdID2 != cmdID { + t.Fatal("same revision must derive the same command id") + } + _, err := c.commit(cmdID2, snap.Revision, "", prep2) + if !errors.Is(err, run.ErrCommandConflict) { + t.Fatalf("err = %v, want ErrCommandConflict", err) + } + res := c.mustCommit(cmdID, snap.Revision, "", prep) + if res.Status != run.CommitAlreadyApplied { + t.Fatalf("status = %v", res.Status) + } +} + +func testCancelRebasesAndUnknownWins(t *testing.T, newRuntime Factory) { + def := toolDef("t") + spec := makeSpec(t, def) + c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []run.ToolSpec{spec}) + b := makeBinding(t, "c1", &spec) + res := c.mustCommit("complete-1", 2, grant, + run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) + toolStep := openedToolStepID(t, &res) + startRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: "c1"}) + + unknown := c.mustCommit("unk-1", startRes.Snapshot.Revision, startRes.Grant, + run.SubmitToolFailure{StepID: toolStep, CallID: "c1", Outcome: run.ToolOutcomeUnknown}) + if unknown.Snapshot.State.Status != run.RunFailed { + t.Fatal("unknown did not fail the run") + } + _, err := c.commit("cancel-late", 0, "", run.CancelRun{}) + if !errors.Is(err, run.ErrRunTerminal) { + t.Fatalf("late cancel err = %v, want ErrRunTerminal", err) + } +} + +func testCancelOnStaleBase(t *testing.T, newRuntime Factory) { + c, _, _ := preparedCase(t, newRuntime, nil, nil) + res := c.mustCommit("cancel-1", 0, "", run.CancelRun{}) + if res.Status != run.CommitAccepted || res.Snapshot.State.Status != run.RunStopped { + t.Fatalf("cancel: %+v", res.Snapshot.State.Status) + } +} + +func testReplayFoldMatchesState(t *testing.T, newRuntime Factory) { + def := toolDef("t") + spec := makeSpec(t, def) + c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []run.ToolSpec{spec}) + b := makeBinding(t, "c1", &spec) + res := c.mustCommit("complete-1", 2, grant, + run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) + toolStep := openedToolStepID(t, &res) + sRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: "c1"}) + c.mustCommit("done-c1", sRes.Snapshot.Revision, sRes.Grant, + run.SubmitToolResult{StepID: toolStep, CallID: "c1", Result: run.ToolExecutionResult{Output: mustJSON(`"ok"`)}}) + + folded, lastRev, err := run.FoldEvents(c.initial, c.events) + if err != nil { + t.Fatalf("FoldEvents: %v", err) + } + live := c.load() + a, _ := json.Marshal(stateComparable(&live.State)) + bts, _ := json.Marshal(stateComparable(&folded)) + if !bytes.Equal(a, bts) { + t.Fatalf("replay diverged:\n live %s\n replay %s", a, bts) + } + if live.Revision != lastRev { + t.Fatalf("snapshot revision %d != last event revision %d", live.Revision, lastRev) + } +} + +func testAcceptInputByInputID(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime) + in := run.AgentInput{ID: "in-9", Payload: mustJSON(`{"t":"x"}`)} + id := run.DeriveInputCommandID("run-1", in.ID) + res1 := c.mustCommit(id, 0, "", run.NextStep(in)) + if res1.Status != run.CommitAccepted { + t.Fatal("first accept rejected") + } + res2 := c.mustCommit(id, 0, "", run.NextStep(in)) + if res2.Status != run.CommitAlreadyApplied { + t.Fatalf("status = %v", res2.Status) + } + _, err := c.commit(id, 0, "", run.NextStep(run.AgentInput{ID: "in-9", Payload: mustJSON(`{"t":"y"}`)})) + if !errors.Is(err, run.ErrCommandConflict) { + t.Fatalf("err = %v, want ErrCommandConflict", err) + } +} + +func testDerivedCommandIDEnforced(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime) + _, err := c.commit("random-id", 0, "", run.NextStep(run.AgentInput{ID: "in-1", Payload: mustJSON(`1`)})) + if err == nil { + t.Fatal("AcceptInput with non-derived CommandID accepted") + } + _, err = c.commit("random-id-2", 0, "", run.ApproveToolCall{StepID: "s", CallID: "c", ResponseID: "r"}) + if err == nil { + t.Fatal("ApproveToolCall with non-derived CommandID accepted") + } +} + +func stateComparable(s *run.MachineState) map[string]any { + m := map[string]any{ + "runId": s.RunID, "status": s.Status, + "modelSteps": s.ModelSteps, "lastClosedStep": s.LastClosedStep, + "usage": s.Usage, "pendingInputs": s.PendingInputs, + "lastModelResult": s.LastModelResult, "result": s.Result, + } + switch cur := s.Current.(type) { + case run.ModelStep: + m["modelStep"] = cur + case run.ToolStep: + m["toolStep"] = cur + } + return m +} From 6761f2c5125056398047ad353ea401330d54ec0c Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 14:35:49 +0800 Subject: [PATCH 21/81] fix(agent): adopt renamed module path in agent packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream b561c3d renamed the module github.com/memohai/twilight-ai -> github.com/memohai/twilight before this branch's packages landed, so every cross-package import inside agent/{es,run,runtimetest} and the sdk test helpers still pointed at the old module. This is what CI's typecheck reported as 'no required module provides package .../agent/jsonstable' — the path simply no longer names this module. Rebased onto upstream main and rewrote the import paths; the remaining twilight-ai strings are upstream's own User-Agent / MCP client display names, unchanged on main. --- agent/es/canonical.go | 2 +- agent/run/alias_test.go | 2 +- agent/run/canonical.go | 4 ++-- agent/run/contract.go | 2 +- agent/run/ids.go | 2 +- agent/run/loop.go | 2 +- agent/run/loop_test.go | 2 +- agent/run/machine_test.go | 2 +- agent/run/memory_conformance_test.go | 4 ++-- agent/run/model_data.go | 2 +- agent/run/protocol.go | 2 +- agent/run/rebuild.go | 2 +- agent/run/rebuild_test.go | 2 +- agent/run/regression_test.go | 2 +- agent/run/runtime_helpers_test.go | 2 +- agent/run/runtimetest/conformance.go | 4 ++-- agent/run/transition.go | 2 +- 17 files changed, 20 insertions(+), 20 deletions(-) diff --git a/agent/es/canonical.go b/agent/es/canonical.go index 752d93c..1581dd6 100644 --- a/agent/es/canonical.go +++ b/agent/es/canonical.go @@ -11,7 +11,7 @@ import ( "encoding/hex" "fmt" - "github.com/memohai/twilight-ai/agent/jsonstable" + "github.com/memohai/twilight/agent/jsonstable" ) // Digest is a SHA-256 digest over canonical protocol bytes. diff --git a/agent/run/alias_test.go b/agent/run/alias_test.go index 2dfcdeb..372e8fc 100644 --- a/agent/run/alias_test.go +++ b/agent/run/alias_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/memohai/twilight-ai/sdk" + "github.com/memohai/twilight/sdk" ) func TestMemoryRuntimeClonesInitialState(t *testing.T) { diff --git a/agent/run/canonical.go b/agent/run/canonical.go index 999845b..c56705c 100644 --- a/agent/run/canonical.go +++ b/agent/run/canonical.go @@ -6,8 +6,8 @@ package run import ( - "github.com/memohai/twilight-ai/agent/es" - "github.com/memohai/twilight-ai/agent/jsonstable" + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/jsonstable" ) // CanonicalJSON is an immutable, agent-owned canonical JSON value. It can only diff --git a/agent/run/contract.go b/agent/run/contract.go index c90176b..f18b2b0 100644 --- a/agent/run/contract.go +++ b/agent/run/contract.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" - "github.com/memohai/twilight-ai/sdk" + "github.com/memohai/twilight/sdk" ) // RequestPlanner is the port the application injects: it projects application diff --git a/agent/run/ids.go b/agent/run/ids.go index 1f80721..4e03b3d 100644 --- a/agent/run/ids.go +++ b/agent/run/ids.go @@ -5,7 +5,7 @@ import ( "encoding/hex" "fmt" - "github.com/memohai/twilight-ai/agent/es" + "github.com/memohai/twilight/agent/es" ) type RunID string diff --git a/agent/run/loop.go b/agent/run/loop.go index 7ab7761..3ef3d16 100644 --- a/agent/run/loop.go +++ b/agent/run/loop.go @@ -8,7 +8,7 @@ import ( "fmt" "sync" - "github.com/memohai/twilight-ai/sdk" + "github.com/memohai/twilight/sdk" ) // Loop is the in-process interpreter of one Run (spec §6). It holds no diff --git a/agent/run/loop_test.go b/agent/run/loop_test.go index 65a990e..060f253 100644 --- a/agent/run/loop_test.go +++ b/agent/run/loop_test.go @@ -8,7 +8,7 @@ import ( "sync/atomic" "testing" - "github.com/memohai/twilight-ai/sdk" + "github.com/memohai/twilight/sdk" ) // --- fakes --- diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go index 098c2aa..aadea27 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - "github.com/memohai/twilight-ai/sdk" + "github.com/memohai/twilight/sdk" ) // --- helpers --- diff --git a/agent/run/memory_conformance_test.go b/agent/run/memory_conformance_test.go index 2ea2451..29ab212 100644 --- a/agent/run/memory_conformance_test.go +++ b/agent/run/memory_conformance_test.go @@ -3,8 +3,8 @@ package run_test import ( "testing" - "github.com/memohai/twilight-ai/agent/run" - "github.com/memohai/twilight-ai/agent/run/runtimetest" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/runtimetest" ) // MemoryRuntime is the reference implementation of the Runtime contract; it diff --git a/agent/run/model_data.go b/agent/run/model_data.go index 7c2753f..ac098ca 100644 --- a/agent/run/model_data.go +++ b/agent/run/model_data.go @@ -6,7 +6,7 @@ import ( "time" "github.com/google/jsonschema-go/jsonschema" - "github.com/memohai/twilight-ai/sdk" + "github.com/memohai/twilight/sdk" ) // ProviderMetadata is the agent's persisted representation of provider-owned diff --git a/agent/run/protocol.go b/agent/run/protocol.go index 637583e..7863f5d 100644 --- a/agent/run/protocol.go +++ b/agent/run/protocol.go @@ -4,7 +4,7 @@ import ( "errors" "fmt" - "github.com/memohai/twilight-ai/agent/es" + "github.com/memohai/twilight/agent/es" ) // SchemaVersion1 is the first published wire schema. Canonical encoding and diff --git a/agent/run/rebuild.go b/agent/run/rebuild.go index 00c02c5..6007c31 100644 --- a/agent/run/rebuild.go +++ b/agent/run/rebuild.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "github.com/memohai/twilight-ai/agent/es" + "github.com/memohai/twilight/agent/es" ) // ErrLogTruncated reports that a Run's event log ends below its revision diff --git a/agent/run/rebuild_test.go b/agent/run/rebuild_test.go index e1bc430..629cd48 100644 --- a/agent/run/rebuild_test.go +++ b/agent/run/rebuild_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - "github.com/memohai/twilight-ai/sdk" + "github.com/memohai/twilight/sdk" ) // Event-sourcing arbitration tests (spec §5.1): the log is the source of diff --git a/agent/run/regression_test.go b/agent/run/regression_test.go index 9344f7a..61784c2 100644 --- a/agent/run/regression_test.go +++ b/agent/run/regression_test.go @@ -8,7 +8,7 @@ import ( "sync/atomic" "testing" - "github.com/memohai/twilight-ai/sdk" + "github.com/memohai/twilight/sdk" ) // Regression tests for the code-review findings on the phase A/B diff --git a/agent/run/runtime_helpers_test.go b/agent/run/runtime_helpers_test.go index 685f343..10cf0d6 100644 --- a/agent/run/runtime_helpers_test.go +++ b/agent/run/runtime_helpers_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/memohai/twilight-ai/sdk" + "github.com/memohai/twilight/sdk" ) // Shared helpers for package-local agent tests. Runtime conformance lives in diff --git a/agent/run/runtimetest/conformance.go b/agent/run/runtimetest/conformance.go index fab3375..e79ac5a 100644 --- a/agent/run/runtimetest/conformance.go +++ b/agent/run/runtimetest/conformance.go @@ -21,8 +21,8 @@ import ( "errors" "testing" - "github.com/memohai/twilight-ai/agent/run" - "github.com/memohai/twilight-ai/sdk" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/sdk" ) // Factory constructs the Runtime under test from an already-initialized diff --git a/agent/run/transition.go b/agent/run/transition.go index fd4ef9a..1136b11 100644 --- a/agent/run/transition.go +++ b/agent/run/transition.go @@ -4,7 +4,7 @@ import ( "errors" "fmt" - "github.com/memohai/twilight-ai/agent/es" + "github.com/memohai/twilight/agent/es" ) // TransitionRecord is the atomic authority record for one accepted From 9cb9201f7d181add013b37884dfb84894eafddf1 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 14:57:10 +0800 Subject: [PATCH 22/81] feat(run): add RunHeader; reorder rollout around the durable adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunHeader was specified (§5.1.1) but never implemented: BuildRunHeader creates the immutable Revision-0 record (minimal InitializeRun state, digest-bound, causation-linked); ValidateRunHeader rejects tampering and non-minimal initial states; FoldRun validates the header then folds the transition log — the entry point for durable adapters and run import, which must never trust an uploaded snapshot. Spec: mark agent/session as contract-first (the §2.6 materialization contract binds now, on the application's existing storage; the generic substrate waits for its first real consumer) and agent/queue as deferred until a second consumer exists. Rollout order flips: the durable application adapter is now phase C — the Runtime contract's first real external consumer — ahead of any new packages; session/queue become need-driven phases D/E and the harness drops to example status. docs: add the Memoh durable adapter work order (DDL sketch, commit transaction skeleton, materialization contract on existing tables, queue integration points, recovery scanner, acceptance checklist). --- agent/run/header.go | 139 +++++++++++ agent/run/header_test.go | 83 +++++++ docs/design/agent-runtime-refactor.md | 45 ++-- .../design/memoh-durable-adapter-workorder.md | 219 ++++++++++++++++++ 4 files changed, 467 insertions(+), 19 deletions(-) create mode 100644 agent/run/header.go create mode 100644 agent/run/header_test.go create mode 100644 docs/design/memoh-durable-adapter-workorder.md diff --git a/agent/run/header.go b/agent/run/header.go new file mode 100644 index 0000000..6e02f68 --- /dev/null +++ b/agent/run/header.go @@ -0,0 +1,139 @@ +package run + +import ( + "errors" + "fmt" + + "github.com/memohai/twilight/agent/es" +) + +// snapshotSchemaVersion versions the MachineState wire shape used inside +// RunHeader.InitialState. It is independent of the event SchemaVersion: event +// encoding is frozen forever, while the snapshot shape may evolve with a +// version bump (spec §8.2). +const snapshotSchemaVersion uint16 = 1 + +// encodeMachineStateWire renders the canonical bytes of a MachineState for +// header digests. The interface-typed Current step is flattened the same way +// state equivalence comparison does. +func encodeMachineStateWire(s *MachineState) ([]byte, error) { + return marshalCanonical(stateComparable(s)) +} + +// RunHeader is the formal persisted Revision-0 protocol record (spec §5.1.1). +// The complete execution authority is RunHeader + TransitionRecord log; every +// fold starts from the header's initial state. It is immutable after +// creation; ordinary Runtime.Commit never touches it. +type RunHeader struct { + SchemaVersion uint16 `json:"schemaVersion"` + RunID RunID `json:"runId"` + InitialStateVersion uint16 `json:"initialStateVersion"` + InitialState MachineState `json:"initialState"` + InitialStateDigest Digest `json:"initialStateDigest"` + // CausationID links the Run to the creating session/queue/application + // operation. Opaque to run; the application defines its interpretation. + CausationID es.CausationID `json:"causationId,omitempty"` + HeaderDigest Digest `json:"headerDigest"` +} + +type runHeaderDigestBody struct { + SchemaVersion uint16 `json:"schemaVersion"` + RunID RunID `json:"runId"` + InitialStateVersion uint16 `json:"initialStateVersion"` + InitialStateDigest Digest `json:"initialStateDigest"` + CausationID es.CausationID `json:"causationId,omitempty"` +} + +// BuildRunHeader creates the immutable header for a new Run. The initial +// state is the minimal InitializeRun state: no seed input, no model policy, +// no limits — the first input arrives as the Revision-1 AcceptInput +// transition (spec §5.1.1 rules 2-3). +func BuildRunHeader(runID RunID, causationID es.CausationID) (RunHeader, error) { + initial, err := InitializeRun(runID) + if err != nil { + return RunHeader{}, err + } + stateBytes, err := encodeMachineStateWire(&initial) + if err != nil { + return RunHeader{}, err + } + stateDigest := sha256Digest(stateBytes) + header := RunHeader{ + SchemaVersion: currentSchemaVersion, + RunID: runID, + InitialStateVersion: snapshotSchemaVersion, + InitialState: initial, + InitialStateDigest: stateDigest, + CausationID: causationID, + } + headerDigest, err := digestRunHeader(&header) + if err != nil { + return RunHeader{}, err + } + header.HeaderDigest = headerDigest + return header, nil +} + +func digestRunHeader(h *RunHeader) (Digest, error) { + body, err := encodeEnvelopeBody(h.SchemaVersion, "run_header", runHeaderDigestBody{ + SchemaVersion: h.SchemaVersion, + RunID: h.RunID, + InitialStateVersion: h.InitialStateVersion, + InitialStateDigest: h.InitialStateDigest, + CausationID: h.CausationID, + }) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +// ValidateRunHeader verifies header integrity: state digest, header digest, +// and that the initial state is a legal Revision-0 state for this RunID. +// Imported/uploaded runs must pass this before their log is folded +// (spec §5.1.1 rule 6). +func ValidateRunHeader(h *RunHeader) error { + if h.RunID == "" { + return errors.New("agent: run header: empty RunID") + } + if !isSupportedSchemaVersion(h.SchemaVersion) { + return fmt.Errorf("agent: run header: unsupported schema version %d", h.SchemaVersion) + } + if h.InitialStateVersion != snapshotSchemaVersion { + return fmt.Errorf("agent: run header: unsupported initial state version %d", h.InitialStateVersion) + } + if h.InitialState.RunID != h.RunID { + return errors.New("agent: run header: initial state RunID mismatch") + } + if h.InitialState.Status != RunActive || h.InitialState.Current != nil || + len(h.InitialState.PendingInputs) != 0 || h.InitialState.ModelSteps != 0 || + h.InitialState.Result != nil || h.InitialState.LastModelResult != nil { + return errors.New("agent: run header: initial state is not a minimal Revision-0 state") + } + stateBytes, err := encodeMachineStateWire(&h.InitialState) + if err != nil { + return err + } + if sha256Digest(stateBytes) != h.InitialStateDigest { + return errors.New("agent: run header: initial state digest mismatch") + } + want, err := digestRunHeader(h) + if err != nil { + return err + } + if h.HeaderDigest != want { + return errors.New("agent: run header: header digest mismatch") + } + return nil +} + +// FoldRun rebuilds the run state from its complete authority: header + +// transition log. It validates the header first, then folds the records +// (spec §9.1). This is the entry point durable adapters and import/migration +// paths use; trusting an uploaded MachineState snapshot is never legal. +func FoldRun(header *RunHeader, records []TransitionRecord) (MachineState, uint64, error) { + if err := ValidateRunHeader(header); err != nil { + return MachineState{}, 0, err + } + return FoldTransitions(header.InitialState, records) +} diff --git a/agent/run/header_test.go b/agent/run/header_test.go new file mode 100644 index 0000000..4dd2858 --- /dev/null +++ b/agent/run/header_test.go @@ -0,0 +1,83 @@ +package run + +import ( + "strings" + "testing" + + "github.com/memohai/twilight/agent/es" +) + +func TestBuildRunHeaderRoundTrip(t *testing.T) { + h, err := BuildRunHeader("run-1", es.CausationID("session:entry-9")) + if err != nil { + t.Fatal(err) + } + if err := ValidateRunHeader(&h); err != nil { + t.Fatalf("fresh header invalid: %v", err) + } + if h.InitialState.RunID != "run-1" || h.InitialState.Status != RunActive { + t.Fatalf("initial state = %+v", h.InitialState) + } + if h.CausationID != "session:entry-9" { + t.Fatal("causation id not carried") + } +} + +func TestValidateRunHeaderRejectsTampering(t *testing.T) { + h, err := BuildRunHeader("run-1", "") + if err != nil { + t.Fatal(err) + } + // Tampered causation changes the header digest preimage. + tampered := h + tampered.CausationID = "forged" + if err := ValidateRunHeader(&tampered); err == nil { + t.Fatal("tampered causation accepted") + } + // A non-minimal initial state is rejected even with recomputed digests. + fat := h + fat.InitialState.ModelSteps = 3 + if err := ValidateRunHeader(&fat); err == nil || !strings.Contains(err.Error(), "minimal") { + t.Fatalf("non-minimal initial state accepted: %v", err) + } + // RunID mismatch between header and state is rejected. + cross := h + cross.RunID = "run-2" + if err := ValidateRunHeader(&cross); err == nil { + t.Fatal("cross-run header accepted") + } +} + +func TestFoldRunFromHeaderMatchesRuntime(t *testing.T) { + h, err := BuildRunHeader("run-1", "") + if err != nil { + t.Fatal(err) + } + rt := NewMemoryRuntime(h.InitialState) + + // Drive one transition: accept the seed input at Revision 1 (spec §5.1.1 + // rule 3 — seed enters the log, not the header). + in := AgentInput{ID: "seed", Payload: MustParseCanonicalJSON(`{"q":"hi"}`)} + env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), NextStep(in)) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(t.Context(), CommitRequest{BaseRevision: 0, Command: env}); err != nil { + t.Fatal(err) + } + + folded, rev, err := FoldRun(&h, rt.Transitions()) + if err != nil { + t.Fatal(err) + } + if rev != 1 { + t.Fatalf("rev = %d", rev) + } + live, err := rt.Load(t.Context()) + if err != nil { + t.Fatal(err) + } + if !statesEquivalent(&live.State, &folded) { + t.Fatal("header fold diverged from live state") + } +} diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index c7a5897..548795c 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -394,6 +394,8 @@ Runtime 的 authority boundary 不能靠“调用者不要修改快照”这类 ### 2.4 `agent/session` +> 实施状态:**契约先行,substrate 按需**。本节的跨 domain 契约(source event identity、causation、finalization barrier,见 §2.6)立即生效,由 durable adapter 在 Application 现有存储上落实;下述泛型 substrate API 是参考草案,由第一个真实消费方(本地持久会话或 local/durable 会话同步)定形后再实现,不预先建包。第一版 durable adapter 不依赖本包。 + `agent/session` 是长期语义历史的 append-only ES substrate。它不写死 Twilight/Memoh 的 message ontology,而是用泛型承载上层语义事件: ```go @@ -474,6 +476,8 @@ artifact retention 策略 ### 2.5 `agent/queue` +> 实施状态:**无限期推迟**。Application(Memoh)已有生产级 steer/follow-up queue;durable adapter 只需要两处集成(boundary 处 item 转 `AcceptInput`、Prepare gate 事务内检查 eligible steer),均不要求重写为泛型包。抽取本包的触发条件是出现第二个真实消费方;下述 API 为届时的参考草案。 + `agent/queue` 是通用 transactional queue/claim/dedup 机制,不默认使用 ES。Queue 回答“哪些工作待处理、谁拥有处理权、如何去重/过期/重排”,不是长期语义历史。 建议 API 形态: @@ -1828,41 +1832,44 @@ response 101 只完成 B;D 仍可执行,不必等待 C。response 102 再完 4. 让 `MemoryRuntime` 成为同一 Run ES 的最轻 reference runtime,而不是另一种 agent;它可以没有 lease/DB/heartbeat,但不能跳过 execution event semantics。 5. 更新 import path、examples、conformance 和 golden streams;此时尚未合并,不保留 root `agent` compatibility wrapper。 -### 阶段 C:实现 `agent/session` +### 阶段 C:durable application adapter(前置) + +Durable adapter 在 Application/Memoh 实现,是 Runtime contract 的第一个真实外部消费方;它在 session/queue 包之前进行——contract 若与真实数据库事务模型不契合,反馈必须在建更多包之前回来。它不依赖 `agent/session`、`agent/queue` 包:materialization 契约(§2.6 的 source event identity、causation、finalization barrier)落在 Application 现有的 history/session 存储上;steer/follow-up 复用 Application 现有 queue。 + +1. 持久化 `RunHeader`、TransitionRecord log、MachineState projection 和 watermark。 +2. 用 transaction/CAS 实现 `run.Runtime.Load/Commit`,私有实现 owner/fence/lease/Attempt/recovery。 +3. 将 Run transition、Session materialization outbox、artifact/usage projection 和 finalization state 放在同一事务,或使用可幂等 inbox/outbox 恢复;Application 要把每一个 Run 的 lifecycle 记录为语义历史条目(现有存储加 source_event_id 幂等键即可,无需新包)。 +4. 语义历史已记录 `RunFinalized`(或存在与之事务耦合的 marker)后才 archive/GC Run log;长期语义不依赖保留旧 Run log。 +5. 验收门槛:`run/runtimetest.RunConformance` 全绿,加 §14.4 的集成矩阵。 + +### 阶段 D:实现 `agent/session`(按需,等待消费方) + +触发条件:本地持久会话或 local/durable 会话同步进入排期。届时 API 从阶段 C 已运行的 materializer 反推,不从草案照抄: 1. 定义 generic append-only Session store、entry envelope、head CAS、parent/fork lineage、schema/digest/causation。 2. 先提供 memory store 和 replay/fork conformance;不在 package 内写死 MessageAdded/Compact/Artifact ontology。 -3. Application 定义 session event,并实现 Run event -> Session event 的 materializer。 +3. Application 的 materializer 从现有存储迁移到本包时,语义(source_event_id、causation)保持不变。 + +### 阶段 E:实现 `agent/queue`(按需,等待第二个消费方) -### 阶段 D:实现 `agent/queue` +触发条件:出现 Application 现有 queue 之外的第二个真实消费方。届时: 1. 定义 generic enqueue/claim/ack/release/dedup/visibility contract 与 memory implementation。 2. `SteerItem`、`FollowUpItem`、`RunQueueItem` 保持在 Application payload/policy。 3. 不将 queue state 或 claim identity 加入 `run.MachineState`。 -### 阶段 E:simple in-process harness +### 阶段 F:simple in-process harness(example 级) -`agent/harness`(或 application example package)组合: +`agent/harness`(或 application example package)组合 in-process 各件;它是示例与测试工具,不是交付物,也不是第二个 Runtime interface: ```text -session memory store -+ queue memory store(可选) -+ run.MemoryRuntime +run.MemoryRuntime + run.Loop + in-memory EventSink -+ application RequestPlanner/materializer ++ application RequestPlanner(自持内存 history) ``` -它不是第二个 Runtime interface,不复制 Run Machine,不把 chat history 塞进 `MemoryRuntime`。它用于 local example、test、prototype,以及证明 local/durable 是同一 Run ES semantics 的不同 runtime implementation。 - -### 阶段 F:durable application adapter - -Durable adapter 在 Application/Memoh 实现: - -1. 持久化 `RunHeader`、TransitionRecord log、MachineState projection 和 watermark。 -2. 用 transaction/CAS 实现 `run.Runtime.Load/Commit`,私有实现 owner/fence/lease/Attempt/recovery。 -3. 将 Run transition、Session materialization outbox、artifact/usage projection 和 finalization state 放在同一事务,或使用可幂等 inbox/outbox 恢复;Application 要把每一个 Run 的 lifecycle 记录为 Session event。 -4. Session 已追加 `RunFinalized`(或存在与之事务耦合的 marker)后才 archive/GC Run log;Session/Artifact/Usage 的长期语义不依赖保留旧 Run log。 +它不复制 Run Machine,不把 chat history 塞进 `MemoryRuntime`。它用于 local example、test、prototype,以及证明 local/durable 是同一 Run ES semantics 的不同 runtime implementation。 ## 12. Cross-domain orchestration contract diff --git a/docs/design/memoh-durable-adapter-workorder.md b/docs/design/memoh-durable-adapter-workorder.md new file mode 100644 index 0000000..e5ebabb --- /dev/null +++ b/docs/design/memoh-durable-adapter-workorder.md @@ -0,0 +1,219 @@ +# Memoh Durable Run Runtime Adapter 工作单 + +状态:交接文档(twilight 侧 → Memoh 侧) +对应 spec:`docs/design/agent-runtime-refactor.md` §5、§8.2、§11 阶段 C +依赖:twilight 模块 `github.com/memohai/twilight`(PR #47 合并后的 tag) + +本文是 Memoh 实现 durable `run.Runtime` adapter 的工作单:表结构草案、Commit 事务伪代码、materialization 契约和验收清单。约定:本文中的 SQL 为形态说明,字段类型与索引命名以 Memoh 现有规范为准;`run.` 前缀指 `github.com/memohai/twilight/agent/run`。 + +## 0. 原则 + +1. adapter 只实现 `run.Runtime` 的 `Load/Commit` 两个方法;全部语义规则在 `run.EvaluateCommit`(单实现),adapter 不复刻任何准入判断。 +2. adapter 不依赖 `agent/session`、`agent/queue` 包。materialization 契约落在 Memoh 现有 history/session 表上;steer/follow-up 复用 Memoh 现有 queue。 +3. control-plane(owner/fence/lease/attempt)是 Memoh 私有实现,不进入 `run.MachineState`、不进入事件。 +4. 验收门槛是 `run/runtimetest.RunConformance` 全绿;这不是参考项,是合并前置条件。 + +## 1. 表结构草案 + +```sql +-- Revision-0 权威记录,创建后 immutable(spec §5.1.1) +CREATE TABLE agent_run_header ( + run_id TEXT PRIMARY KEY, + schema_version SMALLINT NOT NULL, + header_json JSONB NOT NULL, -- run.RunHeader 的完整 JSON(含 digest 字段) + causation_id TEXT, -- 冗余列,便于按来源查询 + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- TransitionRecord log:authority 的日志单元(spec §5.3) +CREATE TABLE agent_run_transition ( + run_id TEXT NOT NULL REFERENCES agent_run_header(run_id), + revision BIGINT NOT NULL, + command_id TEXT NOT NULL, + command_digest TEXT NOT NULL, + record_json JSONB NOT NULL, -- run.TransitionRecord 的完整 JSON + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (run_id, revision), + UNIQUE (run_id, command_id) -- 兼作幂等查询索引(prior lookup) +); + +-- MachineState projection + watermark。projection 可 truncate 后由 +-- header + transition log 重建;watermark 不随重建清除(spec §5.1、§8.2)。 +CREATE TABLE agent_run_state ( + run_id TEXT PRIMARY KEY REFERENCES agent_run_header(run_id), + revision BIGINT NOT NULL, + watermark BIGINT NOT NULL, -- 每次 Commit 与日志同步推进 + snapshot_schema_version SMALLINT NOT NULL, -- 与事件 SchemaVersion 独立版本化 + state_json JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (watermark >= 0) +); + +-- Memoh 私有 control-plane:执行占用。不进入 snapshot、不进入事件。 +CREATE TABLE agent_run_lease ( + run_id TEXT NOT NULL, + target_key TEXT NOT NULL, -- "model/" 或 "call//" + grant_token TEXT NOT NULL, -- 不可预测;即 run.ExecutionGrant 的载体 + owner TEXT NOT NULL, -- worker identity + fence BIGINT NOT NULL, -- 单调 fencing token + expires_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (run_id, target_key) +); + +-- 可选:recovery scanner 的失效执行记录(system command 的 CommandID 来源) +CREATE TABLE agent_run_recovery ( + run_id TEXT NOT NULL, + target_key TEXT NOT NULL, + record_ref TEXT NOT NULL, -- 稳定引用;同一记录重放复用同一 system CommandID + expired_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (run_id, target_key, record_ref) +); +``` + +Memoh 现有表的增量(materialization 契约,见 §4): + +```sql +ALTER TABLE ADD COLUMN source_event_id TEXT; -- run_id:revision:index +CREATE UNIQUE INDEX ... ON (source_event_id) WHERE source_event_id IS NOT NULL; +-- RunFinalized 标记:落在 Memoh 自己的 run/session control 表,加一列或一行皆可 +``` + +## 2. admission(创建 Run) + +```text +BEGIN; + header, _ := run.BuildRunHeader(runID, causationID) -- causationID 关联 Memoh 的 session/queue 来源 + INSERT agent_run_header(run_id, header_json, ...) + INSERT agent_run_state(run_id, revision=0, watermark=0, state_json=header.InitialState) + -- Memoh 自己的 admission 记录(session lifecycle:RunAdmitted)同事务写入 +COMMIT; +-- 首个输入不进 header:admission 后由正常 Commit 提交 Revision-1 的 AcceptInput +``` + +导入/迁移路径(local run 上传):先 `run.ValidateRunHeader`,再 `run.FoldRun(header, records)` 重建 projection;上传的 snapshot 一律不信任。 + +## 3. Commit 事务骨架 + +对应 `run.Runtime.Commit`。全部语义判断在 `run.EvaluateCommit` 内;adapter 只负责锁、查、control-plane 判定和落盘。 + +```text +Commit(ctx, req CommitRequest) (CommitResult, error): + BEGIN; + -- 1. 锁权威行 + state := SELECT ... FROM agent_run_state WHERE run_id = $1 FOR UPDATE + header 已在内存或按需读取(immutable,可缓存) + + -- 2. 幂等查询:按 (run_id, command_id) 查 prior transition + prior := SELECT record_json FROM agent_run_transition + WHERE run_id = $1 AND command_id = req.Command.ID + priorRecord := run.DecodeTransitionRecord(prior) -- 存在时 + + -- 3. control-plane 判定(Memoh 私有) + grantValid := lease 表中 target_key 的 grant_token == req.Grant 且未过期 + 且 owner == 本 worker(completion 类) + recoveryValid := grantless recovery/system command 时, + agent_run_recovery 存在匹配的失效记录 + + -- 4. 单实现评估 + decision, err := run.EvaluateCommit(state, revision, priorRecord, + req, grantValid, recoveryValid) + + -- 5. 按 DecisionKind 分派 + switch decision.Kind: + AlreadyApplied: + ROLLBACK(无写入); return {Status: AlreadyApplied, Events: prior.Events} + -- 注意:AlreadyApplied 不重发 grant、不重写任何投影/outbox + Conflict / Stale / Terminal: + ROLLBACK; return 对应 sentinel error + Apply: + INSERT agent_run_transition(run_id, revision+1, command_id, + command_digest, decision.Transition) + UPDATE agent_run_state SET revision = revision+1, + watermark = revision+1, state_json = decision.NewState + -- start command:同事务建 lease(铸 grant_token、fence+1) + -- completion/recovery:同事务删对应 lease + -- terminal:删该 run 全部 lease + -- materialization outbox(见 §4):同事务写入 + -- Prepare gate(见 §5):在此事务内先行检查 + COMMIT; + return {Status: Accepted, Snapshot, Events: decision.Events, Grant: minted} +``` + +要点: + +- `(run_id, revision)` 主键使 CAS 内建于 INSERT——并发提交同一 revision 的第二个事务在唯一约束上失败,重读后按 EvaluateCommit 的 rebase 规则重试或拒绝。 +- `AlreadyApplied` 分支在只读路径完成,绝不重复 outbox/history/计数(spec §5.4)。 +- lease 的建立/消费与状态提交同一事务(spec §5.4 step 4 末句)。 +- Load 是纯读取:`SELECT state + revision`,不触 lease,不返回 busy。 + +## 4. materialization 契约(不引入 session 包) + +Run 事件 → Memoh 长期语义的投影,走 outbox: + +```text +同一 Commit 事务写 outbox 行: + source_event_id = run_id + ":" + revision + ":" + index (或其 canonical digest) + causation_id = 事件继承的 CausationID + payload = 需要投影的事实(ModelStepCompleted / ToolCallCompleted / RunEnded ...) + +outbox consumer(幂等): + ToolCallCompleted / ModelStepCompleted -> Memoh history 表 + (INSERT ... ON CONFLICT (source_event_id) DO NOTHING) + RunEnded -> Memoh session lifecycle(RunCompleted 条目)+ 触发 queue 仲裁 + usage -> Memoh usage/trace 存储,同一 source_event_id 幂等 +``` + +finalization barrier(spec §2.6): + +```text +RunEnded 已投影 + 且 history/artifact/usage 的 outbox 均已消费或可幂等恢复 + -> Memoh control 表写 RunFinalized 标记(含 run_id + materialization watermark) + -> 此后才允许 archive/GC 该 run 的 agent_run_transition 日志 +``` + +ToolStep 关闭时按 ModelResult 原始 Call 顺序写 assistant tool-call/tool-result 历史(spec §8.2 已有要求);并行 Call 的 transition 提交顺序不影响历史顺序。 + +## 5. queue 集成(复用 Memoh 现有 queue) + +两处,均不改 queue 本身: + +1. **boundary 消费**:queue-safe boundary(ModelStep 无 tool calls 完成 / ToolStep 关闭,从 `RunEnded`/`ToolStepClosed` 事件得知)仲裁 queue,选中 item 转成 `run.AgentInput{InputID, payload}`,用 `run.DeriveInputCommandID` 提交 `AcceptInput`;queue claim 标记 applied 与该 Commit 同事务(或经幂等 outbox)。 +2. **Prepare gate**(spec §10.2):处理 `PrepareModelRequest` 的事务内检查是否存在 eligible steer item;存在则不接受该次 Prepare,先应用对应 `AcceptInput`(revision 前移,Prepare 因 hard-CAS 返回 stale),Loop 重新 Load 后由 `PlanningHint.Inputs` 带着输入重新规划。 + +## 6. recovery scanner + +lease 过期后(含 backend-loss grace): + +```text +扫描 agent_run_lease 中 expires_at 过期的行: + 写 agent_run_recovery 记录(record_ref 稳定) + target 是 model step -> 提交 RecoverModelExecution + target 是 tool call -> 检查该 call 是否已有结果 transition; + 无结果 -> 提交 SubmitToolFailure{Outcome: Unknown} + 两者都用 run.DeriveSystemCommandID(runID, stepID, callID, record_ref), + 经正常 Commit + EvaluateCommit(recoveryValid=true) 提交; + 同一 recovery 记录重放复用同一 CommandID(幂等) +``` + +scanner 不直接改写任何状态行。 + +## 7. 验收清单 + +| # | 项 | 判据 | +|---|---|---| +| 1 | conformance | `runtimetest.RunConformance(t, memohFactory)` 11 项全绿 | +| 2 | 幂等横切 | 同一 CommandID 重放:不重复 history 行、不重复 outbox、不重发 grant | +| 3 | 并发 CAS | 两 worker 并发提交同 revision:一个成功、一个经 rebase 或 stale | +| 4 | crash 恢复 | start 后 kill worker:lease 过期 → scanner 收束(model→Prepared 重试;tool→Unknown 终止)| +| 5 | rebuild | truncate agent_run_state 后从 header+log 重建,watermark 不变;尾部截断日志 → `ErrLogTruncated` halt | +| 6 | Prepare gate | eligible steer 存在时 Prepare 被拒、AcceptInput 先行、重新规划带输入 | +| 7 | materialization | RunEnded 后 history/usage 恰好一次;RunFinalized 前日志不可 GC | +| 8 | 迟到结果 | Run terminal 后的完成提交返回 terminal,结果进审计视图(产品可见)| +| 9 | 集成矩阵 | spec §14.4 全部条目 | + +## 8. 非目标 + +- 不实现 `agent/session`、`agent/queue` 包(spec §2.4/§2.5 实施状态)。 +- 不做工具效果分级(spec §17:Unknown 只覆盖崩溃与 lease 失效;计划内停机用排空)。 +- 不在线转换旧 deferred/approval 记录(排空或 `runtime_upgrade_required` 终态审计,spec §11 兼容原则 5)。 From d0575118f55dd9dbd441d4faa1645170b02bd481 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 15:21:21 +0800 Subject: [PATCH 23/81] docs: move the Memoh adapter work order to the Memoh repo The work order describes Memoh-side implementation (DDL, transaction skeleton, acceptance checklist); it lives with the code that will implement it, not in the library repo. Moved to Memoh/docs/design/durable-run-adapter-workorder.md. --- .../design/memoh-durable-adapter-workorder.md | 219 ------------------ 1 file changed, 219 deletions(-) delete mode 100644 docs/design/memoh-durable-adapter-workorder.md diff --git a/docs/design/memoh-durable-adapter-workorder.md b/docs/design/memoh-durable-adapter-workorder.md deleted file mode 100644 index e5ebabb..0000000 --- a/docs/design/memoh-durable-adapter-workorder.md +++ /dev/null @@ -1,219 +0,0 @@ -# Memoh Durable Run Runtime Adapter 工作单 - -状态:交接文档(twilight 侧 → Memoh 侧) -对应 spec:`docs/design/agent-runtime-refactor.md` §5、§8.2、§11 阶段 C -依赖:twilight 模块 `github.com/memohai/twilight`(PR #47 合并后的 tag) - -本文是 Memoh 实现 durable `run.Runtime` adapter 的工作单:表结构草案、Commit 事务伪代码、materialization 契约和验收清单。约定:本文中的 SQL 为形态说明,字段类型与索引命名以 Memoh 现有规范为准;`run.` 前缀指 `github.com/memohai/twilight/agent/run`。 - -## 0. 原则 - -1. adapter 只实现 `run.Runtime` 的 `Load/Commit` 两个方法;全部语义规则在 `run.EvaluateCommit`(单实现),adapter 不复刻任何准入判断。 -2. adapter 不依赖 `agent/session`、`agent/queue` 包。materialization 契约落在 Memoh 现有 history/session 表上;steer/follow-up 复用 Memoh 现有 queue。 -3. control-plane(owner/fence/lease/attempt)是 Memoh 私有实现,不进入 `run.MachineState`、不进入事件。 -4. 验收门槛是 `run/runtimetest.RunConformance` 全绿;这不是参考项,是合并前置条件。 - -## 1. 表结构草案 - -```sql --- Revision-0 权威记录,创建后 immutable(spec §5.1.1) -CREATE TABLE agent_run_header ( - run_id TEXT PRIMARY KEY, - schema_version SMALLINT NOT NULL, - header_json JSONB NOT NULL, -- run.RunHeader 的完整 JSON(含 digest 字段) - causation_id TEXT, -- 冗余列,便于按来源查询 - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- TransitionRecord log:authority 的日志单元(spec §5.3) -CREATE TABLE agent_run_transition ( - run_id TEXT NOT NULL REFERENCES agent_run_header(run_id), - revision BIGINT NOT NULL, - command_id TEXT NOT NULL, - command_digest TEXT NOT NULL, - record_json JSONB NOT NULL, -- run.TransitionRecord 的完整 JSON - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (run_id, revision), - UNIQUE (run_id, command_id) -- 兼作幂等查询索引(prior lookup) -); - --- MachineState projection + watermark。projection 可 truncate 后由 --- header + transition log 重建;watermark 不随重建清除(spec §5.1、§8.2)。 -CREATE TABLE agent_run_state ( - run_id TEXT PRIMARY KEY REFERENCES agent_run_header(run_id), - revision BIGINT NOT NULL, - watermark BIGINT NOT NULL, -- 每次 Commit 与日志同步推进 - snapshot_schema_version SMALLINT NOT NULL, -- 与事件 SchemaVersion 独立版本化 - state_json JSONB NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CHECK (watermark >= 0) -); - --- Memoh 私有 control-plane:执行占用。不进入 snapshot、不进入事件。 -CREATE TABLE agent_run_lease ( - run_id TEXT NOT NULL, - target_key TEXT NOT NULL, -- "model/" 或 "call//" - grant_token TEXT NOT NULL, -- 不可预测;即 run.ExecutionGrant 的载体 - owner TEXT NOT NULL, -- worker identity - fence BIGINT NOT NULL, -- 单调 fencing token - expires_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (run_id, target_key) -); - --- 可选:recovery scanner 的失效执行记录(system command 的 CommandID 来源) -CREATE TABLE agent_run_recovery ( - run_id TEXT NOT NULL, - target_key TEXT NOT NULL, - record_ref TEXT NOT NULL, -- 稳定引用;同一记录重放复用同一 system CommandID - expired_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (run_id, target_key, record_ref) -); -``` - -Memoh 现有表的增量(materialization 契约,见 §4): - -```sql -ALTER TABLE ADD COLUMN source_event_id TEXT; -- run_id:revision:index -CREATE UNIQUE INDEX ... ON (source_event_id) WHERE source_event_id IS NOT NULL; --- RunFinalized 标记:落在 Memoh 自己的 run/session control 表,加一列或一行皆可 -``` - -## 2. admission(创建 Run) - -```text -BEGIN; - header, _ := run.BuildRunHeader(runID, causationID) -- causationID 关联 Memoh 的 session/queue 来源 - INSERT agent_run_header(run_id, header_json, ...) - INSERT agent_run_state(run_id, revision=0, watermark=0, state_json=header.InitialState) - -- Memoh 自己的 admission 记录(session lifecycle:RunAdmitted)同事务写入 -COMMIT; --- 首个输入不进 header:admission 后由正常 Commit 提交 Revision-1 的 AcceptInput -``` - -导入/迁移路径(local run 上传):先 `run.ValidateRunHeader`,再 `run.FoldRun(header, records)` 重建 projection;上传的 snapshot 一律不信任。 - -## 3. Commit 事务骨架 - -对应 `run.Runtime.Commit`。全部语义判断在 `run.EvaluateCommit` 内;adapter 只负责锁、查、control-plane 判定和落盘。 - -```text -Commit(ctx, req CommitRequest) (CommitResult, error): - BEGIN; - -- 1. 锁权威行 - state := SELECT ... FROM agent_run_state WHERE run_id = $1 FOR UPDATE - header 已在内存或按需读取(immutable,可缓存) - - -- 2. 幂等查询:按 (run_id, command_id) 查 prior transition - prior := SELECT record_json FROM agent_run_transition - WHERE run_id = $1 AND command_id = req.Command.ID - priorRecord := run.DecodeTransitionRecord(prior) -- 存在时 - - -- 3. control-plane 判定(Memoh 私有) - grantValid := lease 表中 target_key 的 grant_token == req.Grant 且未过期 - 且 owner == 本 worker(completion 类) - recoveryValid := grantless recovery/system command 时, - agent_run_recovery 存在匹配的失效记录 - - -- 4. 单实现评估 - decision, err := run.EvaluateCommit(state, revision, priorRecord, - req, grantValid, recoveryValid) - - -- 5. 按 DecisionKind 分派 - switch decision.Kind: - AlreadyApplied: - ROLLBACK(无写入); return {Status: AlreadyApplied, Events: prior.Events} - -- 注意:AlreadyApplied 不重发 grant、不重写任何投影/outbox - Conflict / Stale / Terminal: - ROLLBACK; return 对应 sentinel error - Apply: - INSERT agent_run_transition(run_id, revision+1, command_id, - command_digest, decision.Transition) - UPDATE agent_run_state SET revision = revision+1, - watermark = revision+1, state_json = decision.NewState - -- start command:同事务建 lease(铸 grant_token、fence+1) - -- completion/recovery:同事务删对应 lease - -- terminal:删该 run 全部 lease - -- materialization outbox(见 §4):同事务写入 - -- Prepare gate(见 §5):在此事务内先行检查 - COMMIT; - return {Status: Accepted, Snapshot, Events: decision.Events, Grant: minted} -``` - -要点: - -- `(run_id, revision)` 主键使 CAS 内建于 INSERT——并发提交同一 revision 的第二个事务在唯一约束上失败,重读后按 EvaluateCommit 的 rebase 规则重试或拒绝。 -- `AlreadyApplied` 分支在只读路径完成,绝不重复 outbox/history/计数(spec §5.4)。 -- lease 的建立/消费与状态提交同一事务(spec §5.4 step 4 末句)。 -- Load 是纯读取:`SELECT state + revision`,不触 lease,不返回 busy。 - -## 4. materialization 契约(不引入 session 包) - -Run 事件 → Memoh 长期语义的投影,走 outbox: - -```text -同一 Commit 事务写 outbox 行: - source_event_id = run_id + ":" + revision + ":" + index (或其 canonical digest) - causation_id = 事件继承的 CausationID - payload = 需要投影的事实(ModelStepCompleted / ToolCallCompleted / RunEnded ...) - -outbox consumer(幂等): - ToolCallCompleted / ModelStepCompleted -> Memoh history 表 - (INSERT ... ON CONFLICT (source_event_id) DO NOTHING) - RunEnded -> Memoh session lifecycle(RunCompleted 条目)+ 触发 queue 仲裁 - usage -> Memoh usage/trace 存储,同一 source_event_id 幂等 -``` - -finalization barrier(spec §2.6): - -```text -RunEnded 已投影 - 且 history/artifact/usage 的 outbox 均已消费或可幂等恢复 - -> Memoh control 表写 RunFinalized 标记(含 run_id + materialization watermark) - -> 此后才允许 archive/GC 该 run 的 agent_run_transition 日志 -``` - -ToolStep 关闭时按 ModelResult 原始 Call 顺序写 assistant tool-call/tool-result 历史(spec §8.2 已有要求);并行 Call 的 transition 提交顺序不影响历史顺序。 - -## 5. queue 集成(复用 Memoh 现有 queue) - -两处,均不改 queue 本身: - -1. **boundary 消费**:queue-safe boundary(ModelStep 无 tool calls 完成 / ToolStep 关闭,从 `RunEnded`/`ToolStepClosed` 事件得知)仲裁 queue,选中 item 转成 `run.AgentInput{InputID, payload}`,用 `run.DeriveInputCommandID` 提交 `AcceptInput`;queue claim 标记 applied 与该 Commit 同事务(或经幂等 outbox)。 -2. **Prepare gate**(spec §10.2):处理 `PrepareModelRequest` 的事务内检查是否存在 eligible steer item;存在则不接受该次 Prepare,先应用对应 `AcceptInput`(revision 前移,Prepare 因 hard-CAS 返回 stale),Loop 重新 Load 后由 `PlanningHint.Inputs` 带着输入重新规划。 - -## 6. recovery scanner - -lease 过期后(含 backend-loss grace): - -```text -扫描 agent_run_lease 中 expires_at 过期的行: - 写 agent_run_recovery 记录(record_ref 稳定) - target 是 model step -> 提交 RecoverModelExecution - target 是 tool call -> 检查该 call 是否已有结果 transition; - 无结果 -> 提交 SubmitToolFailure{Outcome: Unknown} - 两者都用 run.DeriveSystemCommandID(runID, stepID, callID, record_ref), - 经正常 Commit + EvaluateCommit(recoveryValid=true) 提交; - 同一 recovery 记录重放复用同一 CommandID(幂等) -``` - -scanner 不直接改写任何状态行。 - -## 7. 验收清单 - -| # | 项 | 判据 | -|---|---|---| -| 1 | conformance | `runtimetest.RunConformance(t, memohFactory)` 11 项全绿 | -| 2 | 幂等横切 | 同一 CommandID 重放:不重复 history 行、不重复 outbox、不重发 grant | -| 3 | 并发 CAS | 两 worker 并发提交同 revision:一个成功、一个经 rebase 或 stale | -| 4 | crash 恢复 | start 后 kill worker:lease 过期 → scanner 收束(model→Prepared 重试;tool→Unknown 终止)| -| 5 | rebuild | truncate agent_run_state 后从 header+log 重建,watermark 不变;尾部截断日志 → `ErrLogTruncated` halt | -| 6 | Prepare gate | eligible steer 存在时 Prepare 被拒、AcceptInput 先行、重新规划带输入 | -| 7 | materialization | RunEnded 后 history/usage 恰好一次;RunFinalized 前日志不可 GC | -| 8 | 迟到结果 | Run terminal 后的完成提交返回 terminal,结果进审计视图(产品可见)| -| 9 | 集成矩阵 | spec §14.4 全部条目 | - -## 8. 非目标 - -- 不实现 `agent/session`、`agent/queue` 包(spec §2.4/§2.5 实施状态)。 -- 不做工具效果分级(spec §17:Unknown 只覆盖崩溃与 lease 失效;计划内停机用排空)。 -- 不在线转换旧 deferred/approval 记录(排空或 `runtime_upgrade_required` 终态审计,spec §11 兼容原则 5)。 From ecdaf3f685ab7e3b9e936a6918726a5c64f08396 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 15:59:18 +0800 Subject: [PATCH 24/81] docs: before/after architecture comparison diagram (draw.io) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pages: the legacy in-process SDK loop (authority in the library's stack frame, callback weaving, deferred-approval single slot, manual state capture — each red block a compensation Memoh had to build, grounded in internal/agent/runtime/native and migrations 0073/0121) and the Run ES design (authority in the runtime transaction as RunHeader + TransitionRecord log, single Decide/Evolve implementation, per-call response routing, watermark arbitration). The closing panels map each old compensation to the mechanism that removes it. --- docs/design/architecture-before-after.drawio | 195 +++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/design/architecture-before-after.drawio diff --git a/docs/design/architecture-before-after.drawio b/docs/design/architecture-before-after.drawio new file mode 100644 index 0000000..3e30e44 --- /dev/null +++ b/docs/design/architecture-before-after.drawio @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 6a636d8f0abebb808d8a41fcb6b5a7800bd0d3b9 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 16:13:21 +0800 Subject: [PATCH 25/81] docs: drop the flat drawio comparison Superseded by the progressive narrative page: the single-canvas layout buried the argument; the replacement introduces one concept per chapter. --- docs/design/architecture-before-after.drawio | 195 ------------------- 1 file changed, 195 deletions(-) delete mode 100644 docs/design/architecture-before-after.drawio diff --git a/docs/design/architecture-before-after.drawio b/docs/design/architecture-before-after.drawio deleted file mode 100644 index 3e30e44..0000000 --- a/docs/design/architecture-before-after.drawio +++ /dev/null @@ -1,195 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 917ee6c983362a30ec370844ff8a6f8826e16be9 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 18:09:58 +0800 Subject: [PATCH 26/81] fix(run,sdk): close four medium-review findings in the ES core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run/header: ValidateRunHeader's minimal-state check now also rejects pre-seeded Usage and a forged LastClosedStep — both fields passed digest verification as-is, letting an imported header inflate every RunResult's usage or steer the first PlanningHint.SourceStep (spec §5.1.1 rule 2). run/loop: planAndPrepare now emits the accepted ModelStepPrepared events; it was the only accepted transition invisible to EventSink observers, so projections keyed on committed events missed the frozen request. The spec §6.6 one-shot same-CommandID commit replay moves from settleWorkers into the shared commit helper, so model completion, start, StopRun and prepare submissions get the same protection — a transient commit failure on SubmitModelResult no longer aborts the Loop into a state that MemoryRuntime (no lease expiry) can never recover. sdk/request_adapter: the stream path materialized Response as a non-nil pointer to a zero ResponseMetadata where the generate path yields nil, so FreezeModelResult persisted different bytes per execution mode and ModelStepCompleted digests were not reproducible across Streaming on/off. Absent metadata now stays nil on both paths. --- agent/run/header.go | 3 ++- agent/run/loop.go | 29 +++++++++++++++++++---------- sdk/request_adapter.go | 11 ++++++++++- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/agent/run/header.go b/agent/run/header.go index 6e02f68..a4dad78 100644 --- a/agent/run/header.go +++ b/agent/run/header.go @@ -107,7 +107,8 @@ func ValidateRunHeader(h *RunHeader) error { } if h.InitialState.Status != RunActive || h.InitialState.Current != nil || len(h.InitialState.PendingInputs) != 0 || h.InitialState.ModelSteps != 0 || - h.InitialState.Result != nil || h.InitialState.LastModelResult != nil { + h.InitialState.Result != nil || h.InitialState.LastModelResult != nil || + h.InitialState.LastClosedStep != "" || h.InitialState.Usage != (Usage{}) { return errors.New("agent: run header: initial state is not a minimal Revision-0 state") } stateBytes, err := encodeMachineStateWire(&h.InitialState) diff --git a/agent/run/loop.go b/agent/run/loop.go index 3ef3d16..352d9ea 100644 --- a/agent/run/loop.go +++ b/agent/run/loop.go @@ -86,7 +86,7 @@ func (l *Loop) Run(ctx context.Context, runtime Runtime, events EventSink) (Loop l.emitCommitted(controlCtx, events, snapshot.State.RunID, res.Events) continue } - if err := l.planAndPrepare(ctx, controlCtx, runtime, &snapshot, eff.Hint); err != nil { + if err := l.planAndPrepare(ctx, controlCtx, runtime, events, &snapshot, eff.Hint); err != nil { return LoopResult{}, err } case StartModelCall: @@ -108,12 +108,21 @@ func (l *Loop) Run(ctx context.Context, runtime Runtime, events EventSink) (Loop } // 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 (spec §6.6 "commit response unknown"): if the first attempt actually +// committed and only the response was lost, the replay returns AlreadyApplied +// instead of abandoning a live grant or re-executing an expensive step. func (l *Loop) commit(ctx context.Context, runtime Runtime, run RunID, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) (CommitResult, error) { env, err := BuildEnvelope(run, id, cmd) if err != nil { return CommitResult{}, err } - return runtime.Commit(ctx, CommitRequest{BaseRevision: base, Grant: grant, Command: env}) + req := CommitRequest{BaseRevision: base, Grant: grant, Command: env} + res, err := runtime.Commit(ctx, req) + if err != nil && !retriable(err) { + res, err = runtime.Commit(ctx, req) + } + return res, err } // retriable reports the commit errors that mean "reload and rederive". @@ -146,7 +155,7 @@ func (l *Loop) emitCommitted(ctx context.Context, events EventSink, run RunID, c // --- NeedModelRequest --- -func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, snapshot *RuntimeSnapshot, hint PlanningHint) error { +func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, events EventSink, snapshot *RuntimeSnapshot, hint PlanningHint) error { plan, err := l.Planner.Plan(ctx, hint) if err != nil { return err @@ -179,7 +188,7 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, } cmdID := DeriveModelRequestCommandID(snapshot.State.RunID, snapshot.Revision) stepID := DeriveModelStepID(snapshot.State.RunID, cmdID, binding) - _, err = l.commit(controlCtx, runtime, snapshot.State.RunID, cmdID, snapshot.Revision, "", PrepareModelRequest{ + res, err := l.commit(controlCtx, runtime, snapshot.State.RunID, cmdID, snapshot.Revision, "", PrepareModelRequest{ StepID: stepID, Model: model, Request: frozenRequest, @@ -190,6 +199,10 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, ToolsDigest: toolsDigest, }) 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(controlCtx, events, snapshot.State.RunID, res.Events) return nil } if !retriable(err) { @@ -538,12 +551,8 @@ func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime Runtime, e // Commit with the worker's own grant on its start base; stale // bases rebase call-locally. Late results after terminal return // ErrRunTerminal and are dropped (audit is the adapter's job). - cmdID := freshCommandID() - res, err := l.commit(controlCtx, runtime, run, cmdID, w.base, w.grant, cmd) - if err != nil && !retriable(err) { - // One replay with the same CommandID and digest. - res, err = l.commit(controlCtx, runtime, run, cmdID, w.base, w.grant, cmd) - } + // The one-shot same-CommandID replay lives inside l.commit. + res, err := l.commit(controlCtx, runtime, run, freshCommandID(), w.base, w.grant, cmd) switch { case err == nil: l.emitCommitted(controlCtx, events, run, res.Events) diff --git a/sdk/request_adapter.go b/sdk/request_adapter.go index a1ea237..07354ea 100644 --- a/sdk/request_adapter.go +++ b/sdk/request_adapter.go @@ -58,7 +58,16 @@ func ModelStreamFromStreamResult(stream *StreamResult) ModelStream { result.FinishReason = p.FinishReason result.RawFinishReason = p.RawFinishReason result.Usage = p.Usage - result.Response = cloneResponseMetadataPtr(&p.Response) + // 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 From 89c70448a7426c3eb215b077f12fe1fd91c720f3 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 19:01:53 +0800 Subject: [PATCH 27/81] docs,run: state is the execution authority; the log is the canonical record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The durable-continuation requirement — a new process resumes the same run from the same step — needs durable MachineState plus lease/grant coordination; it does not need the state to be rebuilt from events. Event-log authority bought two capabilities on top of that (automatic refold after state corruption, historical execution fork): the first never fires on a correct implementation and has a manual FoldRun + backup-restore fallback, the second has no product need and is semantically unsound for executions with external side effects. Spec §5.1/§8/§9.1/§14.3/§17/appendix B: MachineState is the Run execution authority (recovery = Load); RunHeader + TransitionRecord log is the same-transaction canonical record serving audit, projections, and verified run import/migration via FoldRun. The replay-fold equivalence stays as the dual-write correctness test. Divergence is an implementation defect handled operationally; the watermark and the arbitration protocol leave the spec. Evolve relaxes from permanently frozen to stable-within-version; migration shipping re-tightens it via the golden-stream check (recorded as the trigger, alongside execution fork, for reconsidering log authority). Code: Rebuild/ErrLogTruncated demoted to optional diagnostics in comments; watermark kept as a diagnostic field only. Memoh work order: watermark column dropped, rebuild acceptance item replaced by a FoldRun consistency check. ES doc section 5 retitled to durable execution state + canonical record; recovery narrative now reads Load-not-fold, with the two layers' deliberately different answers to the ES litmus question. --- agent/run/memory_runtime.go | 3 +- agent/run/rebuild.go | 26 ++++++------ docs/design/agent-runtime-refactor.md | 58 ++++++++++----------------- 3 files changed, 38 insertions(+), 49 deletions(-) diff --git a/agent/run/memory_runtime.go b/agent/run/memory_runtime.go index 3889934..e430fed 100644 --- a/agent/run/memory_runtime.go +++ b/agent/run/memory_runtime.go @@ -20,7 +20,8 @@ type MemoryRuntime struct { revision uint64 // initial is the Revision-0 state; Rebuild folds the log from it. initial MachineState - // watermark witnesses log-tail completeness: it advances with every + // watermark records the last committed revision for the optional Rebuild + // diagnostic; it is not a protocol guard. It advances with every // commit and is never cleared by a rebuild (spec §5.1). watermark uint64 // transitions keyed by CommandID: the full transition record for idempotency. diff --git a/agent/run/rebuild.go b/agent/run/rebuild.go index 6007c31..782f242 100644 --- a/agent/run/rebuild.go +++ b/agent/run/rebuild.go @@ -8,11 +8,12 @@ import ( "github.com/memohai/twilight/agent/es" ) -// ErrLogTruncated reports that a Run's event log ends below its revision -// watermark: accepted facts are permanently gone. The Run halts — continuing -// on a truncated log would upgrade the loss into wrong repeated execution -// (spec §5.1). Recovery is a disaster-recovery matter, not a protocol one. -var ErrLogTruncated = errors.New("agent: event log ends below the revision watermark") +// ErrLogTruncated reports that the transition log ends below the revision the +// caller expected. It is a diagnostic signal, not a protocol halt: the +// MachineState is the execution authority (spec §5.1), so a shorter-than- +// expected log means an audit gap, and how to handle it is an operational +// decision. +var ErrLogTruncated = errors.New("agent: transition log ends below the expected revision") // FoldEvents rebuilds a MachineState by folding a complete flat event stream // from the initial (Revision 0) state with EvolveVersion only: no Decide, no @@ -118,13 +119,14 @@ func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineS return state, uint64(revision), nil } -// Rebuild discards the in-memory snapshot and refolds it from the transition -// log, arbitrating per spec §5.1: the log wins when it is complete -// (maxRevision >= watermark); a log tail below the watermark halts with -// ErrLogTruncated. It returns true when the refolded state differed from the -// stored snapshot — with a correct implementation this never happens, so a -// true return is an audit signal (Evolve bug, out-of-band write, or snapshot -// corruption occurred). +// Rebuild is an optional diagnostic (spec §5.1): it refolds the state from +// the transition log and replaces the in-memory state with the fold result. +// The MachineState is the execution authority, so the normal path never calls +// this; use it for consistency checks and storage-migration rebuilds. A log +// shorter than the last committed revision returns ErrLogTruncated (audit +// gap). It returns true when the refolded state differed from the stored +// state — with a correct implementation this never happens, so a true return +// is an audit signal (Evolve bug, out-of-band write, or storage corruption). func (m *MemoryRuntime) Rebuild() (rebuilt bool, err error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 548795c..668863f 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -961,33 +961,20 @@ Runtime 只回答两个问题: 因此 Runtime 的接口很小,但一次 `Commit` 的事务范围可以很大:它必须让一个 AgentCommand、产出的 `TransitionRecord` 及其必要的产品投影一起成功或一起失败;这不意味着 Runtime 获得了 history、queue 或 prompt 的所有权。`CommitResult.Events` 只是该 transition 的事件流视图,方便 Loop、UI 和 observability 消费;Runtime 的 authority storage 是完整 transition aggregate。 -Run execution 状态的 source of truth 是 immutable `RunHeader` 加 `TransitionRecord` log;它只负责该次执行的恢复与审计,不替代 Session ES 的长期语义 authority;`RunHeader` 固化 Revision-0 initial state 及其 schema/digest/admission causation。MachineState 是必需的同事务 projection(execution cache):提交验证与 Loop 执行从它读取,因此它必须与 transition log 在同一原子提交内更新,但它可以从 RunHeader 和日志重建。对任意 Revision N,状态必须等于 `RunHeader.InitialState` 按 `flatten(TransitionRecord[].Events)` 调用 versioned `Evolve` fold 到 N 的结果——这是可自动恢复的不变量,不是 halt 条件。 +Run execution 状态的 authority 是持久化的 **MachineState**:提交验证与 Loop 执行从它读取,崩溃恢复通过 `Load` 直接取回它——durable continuation 是"执行状态可恢复",不要求"执行状态由历史事件重建"。`RunHeader + TransitionRecord log` 是与状态同事务原子写入的 **canonical record**:它记录每次接受的 command 产出的完整事实组,服务审计、观察、事件投影,以及经验证的 run 导入/迁移(§5.1.1 规则 6);它不替代 Session ES 的长期语义 authority。对任意 Revision N,状态应当等于 `RunHeader.InitialState` 按 `flatten(TransitionRecord[].Events)` 调用 versioned `Evolve` fold 到 N 的结果——这是同事务双写的正确性性质,由 conformance 的 replay-fold 等价测试守护;两者出现分歧说明实现存在缺陷(Evolve bug、越权写入或存储损坏),处理方式(从备份恢复状态、或人工 fold 核对后修复)是运维决定,本规范不定义自动仲裁,也不定义 halt 协议。 -这个权威声明成立的三个稳定条件(本规范的规范性条款): +这个双写模型成立的稳定条件(本规范的规范性条款): -1. Event ontology 稳定:Fact 词表 sealed,已发布 SchemaVersion 的事实结构永不修改,新增字段进入新版本。 -2. Evolve 语义稳定:折叠是机械的(不读产品配置/Loop policy、无 IO),已发布版本的折叠语义与事件编码一起永久冻结;replay 通过 `EvolveVersion(SchemaVersion, state, fact)` 选择历史语义,会演进的决策语义全部在 Decide,其结果记录为事实。 -3. 事实自包含:折叠一条事实所需的全部信息在事实自身与折叠前状态之内,不访问外部系统,不重新计算依赖当前代码版本的派生值(digest 一律在 Decide 时算好并携带在事实中)。 -4. 持久化值归属稳定:Run event 和 MachineState 只保存 run-owned frozen values;任何来自 SDK/provider/application 的引用在进入 Runtime 前必须被 canonicalize + detach,返回给 caller 的 snapshot/event 也必须是独立副本。 +1. Event ontology 稳定:Fact 词表 sealed,已发布 SchemaVersion 的事实结构永不修改,新增字段进入新版本。 +2. Evolve 语义版本内稳定:折叠是机械的(不读产品配置/Loop policy、无 IO),replay 通过 `EvolveVersion(SchemaVersion, state, fact)` 选择语义;会演进的决策语义全部在 Decide,其结果记录为事实。同一 SchemaVersion 内的折叠语义不做行为变更;跨部署迁移(本地↔云)要求迁移双方对该版本的折叠语义一致——迁移功能排期时,golden event stream 校验从回归测试升级为版本冻结校验。 +3. 事实自包含:折叠一条事实所需的全部信息在事实自身与折叠前状态之内,不访问外部系统,不重新计算依赖当前代码版本的派生值(digest 一律在 Decide 时算好并携带在事实中)。已实现的自包含保持;它是 run 导入/迁移的前提。 +4. 持久化值归属稳定:Run event 和 MachineState 只保存 run-owned frozen values;任何来自 SDK/provider/application 的引用在进入 Runtime 前必须被 canonicalize + detach,返回给 caller 的 snapshot/event 也必须是独立副本。 -Runtime 实现还必须在代码层面维护这些条件:`EvaluateCommit` 对 Decide 产出的 facts 做 `snapshotFact` 后再 fold/persist,并构造带 transition digest 的 `TransitionRecord`;`Load`、`CommitResult` 和 AlreadyApplied replay 返回的 snapshot/event 不得共享 authority 内部引用;MemoryRuntime 这类参考实现保存 accepted transition 后,从 stored transition 的 events fold 出新的 authority state,而不是直接保存调用栈里算出的 `decision.NewState`。durable adapter 可以用数据库事务替代 mutex,但不能把未冻结 SDK 对象、浅拷贝 snapshot 或 caller-owned bytes 写入事件表/状态表。 +Runtime 实现还必须在代码层面维护这些条件:`EvaluateCommit` 对 Decide 产出的 facts 做 `snapshotFact` 后再 fold/persist,并构造带 transition digest 的 `TransitionRecord`;`Load`、`CommitResult` 和 AlreadyApplied replay 返回的 snapshot/event 不得共享 authority 内部引用;durable adapter 可以用数据库事务替代 mutex,但不能把未冻结 SDK 对象、浅拷贝 snapshot 或 caller-owned bytes 写入事件表/状态表。 -每 Run 维护一个不可丢弃的 revision 水位(watermark):每次提交与 transition log 同步推进的单调计数,语义为"transition log 至少完整到此"。它是日志尾部完整性的末端见证;单个 transition 内部完整性由 `TransitionRecord.Events` 和 `TransitionDigest` 绑定,transition 之间用 Revision 连续性检测缺洞。水位是控制平面数据,不进入 MachineState,重建不清除它。 +`FoldRun(header, records)` 是导入/迁移与诊断的入口:验证 header 与逐 transition 的 digest 链后折叠出状态。它用于三处——跨部署迁移时目标侧重建初始权威状态(不信任上传的 snapshot)、conformance 的等价测试、以及运维按需的一致性核对。日常执行路径不调用它。 -分歧仲裁规则固定为: - -```text -snapshot 与 fold(transitions) 不一致,或 snapshot 缺失,且 transitionLog.maxRevision >= watermark - -> transition log 为准,自动重建(纯 EvolveVersion 折叠,零副作用,不重放命令,不产生外部 effect), - 并记录一次重建事件供运维审计——实现正确时这条路径不触发,每次触发都意味着 - Evolve bug、越权写入或 snapshot 损坏真实发生过 - -transitionLog.maxRevision < watermark,或尾部 transition 的 digest/事件组不完整 - -> 日志尾部缺失/损坏,halt 该 Run——已接受的事实永久消失无法凭空恢复, - 继续推进会把丢失升级为错误的重复执行;恢复属于灾难恢复范畴(备份、复制) -``` - -主动 truncate snapshot 强制全量重建是合法运维操作(例如 MachineState 存储布局变更时替代迁移)。水位与日志同库整体回退(全量备份恢复)不在检测范围内:内部自洽的一致回退需要外部见证,v1 不做。 +重新收紧为日志权威(状态可丢弃、自动仲裁、水位守护)的触发条件:执行历史 fork 成为产品功能,或出现"状态存储不可信而日志存储可信"的实际部署形态。当前两者均无需求方;本规范此前的日志权威版本可在版本历史中查阅。 一个 Runtime 实例服务一个 Run;多个 Run 由上层创建多个 Runtime 实例。Run 的创建和身份分配由 Application admission 完成;admission 调用 `InitializeRun`,构造并原子保存 `RunHeader`。Runtime 从这个已存在的 header 开始,Loop 是对该 Run 的一次进程执行。 @@ -1632,7 +1619,7 @@ Load Commit 在锁内判定本进程 grant 有效性,调用共享 EvaluateCommit, - 原子保存完整 TransitionRecord、projection、watermark 和幂等索引; + 原子保存 MachineState、完整 TransitionRecord 和幂等索引; 接受 start command 时返回本进程的 opaque ExecutionGrant ``` @@ -1678,11 +1665,11 @@ Commit 判定私有 owner/fence/lease/recovery record 的有效性 对 StartModelExecution/StartToolCall 在同一事务内建立 Attempt/lease 调用共享 EvaluateCommit - 原子保存 TransitionRecord、MachineState projection、watermark、idempotency index、 + 原子保存 MachineState、TransitionRecord、idempotency index、 Session/artifact/usage materialization outbox 和必要的 queue claim outcome ``` -Attempt、owner、fence、lease、outbox row 和数据库 schema 不进入 `run.MachineState`。它们只保证多个 Loop attempt 不会同时取得同一个 Step/Call 的执行权。snapshot storage schema 与 Run event schema 独立版本化;snapshot 可被 truncate 后从 `RunHeader + TransitionRecord log` 重建,watermark 不得随 rebuild 清除。 +Attempt、owner、fence、lease、outbox row 和数据库 schema 不进入 `run.MachineState`。它们只保证多个 Loop attempt 不会同时取得同一个 Step/Call 的执行权。MachineState storage schema 与 Run event schema 独立版本化;需要重建状态时(存储布局迁移、一致性核对)用 `FoldRun` 从 header 和 transition log 折叠,这是运维操作而非日常路径。 Durable worker 实例可绑定 owner identity;只有当前 owner 能提交其取得的 start/completion/recovery。response、cancel、host StopRun 使用不带 worker grant 的 ingress/control adapter。lease/recovery scanner 必须通过正常 command + `EvaluateCommit` 提交 Unknown 或 Model recovery,不能直接改写 MachineState。 @@ -1726,9 +1713,9 @@ SchemaVersion + Type wire 兼容和 sealed fact discriminator Fact 已接受的事实内容 ``` -RunHeader + TransitionRecord log 是 Run execution source of truth;MachineState 是必需的同事务 projection(§5.1)。Runtime 必须把 transition log、snapshot、水位和需要一致的 Application materialization outbox 放在同一事务或锁边界。Durable adapter 必须保留完整 TransitionRecord,使其可以按 RunID/Revision replay;MemoryRuntime 可以只在进程内保留同样的记录。公共 `Runtime` 不增加 replay 方法,读取由实现或 application projection 提供。 +MachineState 是 Run execution authority;`RunHeader + TransitionRecord log` 是同事务原子写入的 canonical record(§5.1)。Runtime 必须把状态、transition log 和需要一致的 Application materialization outbox 放在同一事务或锁边界。Durable adapter 必须保留完整 TransitionRecord,使其可以按 RunID/Revision replay(审计、投影、导入/迁移);MemoryRuntime 可以只在进程内保留同样的记录。公共 `Runtime` 不增加 replay 方法,读取由实现或 application projection 提供。 -Replay 按 RunID/Revision 取出 TransitionRecord,从经 `ValidateRunHeader` 验证的 `RunHeader.InitialState`(Revision=0)开始依次展开其中的 AgentEvent,并调用对应 `SchemaVersion` 的 `Machine.EvolveVersion` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里,Machine 决策规则的演进不影响历史事件的折叠;折叠不产生任何外部 effect。仲裁按 §5.1 的规则:transition log 完整(maxRevision >= watermark 且每条 transition digest 正确)时日志为准,snapshot 分歧或缺失自动重建并记录重建事件;日志尾部低于水位或尾部 transition 不完整时 halt。事件流内部的 RunID 不匹配、SchemaVersion/Type 不支持、同一 transition 的 CommandID/CommandDigest 不一致、Revision/Index 缺洞、fact digest 或 transition digest 不匹配同样按日志损坏处理,halt 该 Run。 +Replay(`FoldRun`)按 RunID/Revision 取出 TransitionRecord,从经 `ValidateRunHeader` 验证的 `RunHeader.InitialState`(Revision=0)开始依次展开其中的 AgentEvent,并调用对应 `SchemaVersion` 的 `Machine.EvolveVersion` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里;折叠不产生任何外部 effect。它服务导入/迁移(目标侧以 fold 结果为初始权威状态,不信任上传的 snapshot)、conformance 等价测试与运维核对;日常恢复不经过它。fold 过程中 RunID 不匹配、SchemaVersion/Type 不支持、同一 transition 的 CommandID/CommandDigest 不一致、Revision/Index 缺洞、fact digest 或 transition digest 不匹配都拒绝折叠——对导入而言即拒绝该上传;对核对而言即报告损坏,后续处理是运维决定。 Replay 的起点是 `RunHeader` 中已建立并持久化的最小 `MachineState`;初始用户输入通过 `AcceptInput` transition 重放。Session/Queue admission lineage 由各自 domain 记录,并通过 causation/provenance 与 RunHeader 关联。 @@ -1836,7 +1823,7 @@ response 101 只完成 B;D 仍可执行,不必等待 C。response 102 再完 Durable adapter 在 Application/Memoh 实现,是 Runtime contract 的第一个真实外部消费方;它在 session/queue 包之前进行——contract 若与真实数据库事务模型不契合,反馈必须在建更多包之前回来。它不依赖 `agent/session`、`agent/queue` 包:materialization 契约(§2.6 的 source event identity、causation、finalization barrier)落在 Application 现有的 history/session 存储上;steer/follow-up 复用 Application 现有 queue。 -1. 持久化 `RunHeader`、TransitionRecord log、MachineState projection 和 watermark。 +1. 持久化 `RunHeader`、MachineState(execution authority)和 TransitionRecord log(canonical record)。 2. 用 transaction/CAS 实现 `run.Runtime.Load/Commit`,私有实现 owner/fence/lease/Attempt/recovery。 3. 将 Run transition、Session materialization outbox、artifact/usage projection 和 finalization state 放在同一事务,或使用可幂等 inbox/outbox 恢复;Application 要把每一个 Run 的 lifecycle 记录为语义历史条目(现有存储加 source_event_id 幂等键即可,无需新包)。 4. 语义历史已记录 `RunFinalized`(或存在与之事务耦合的 marker)后才 archive/GC Run log;长期语义不依赖保留旧 Run log。 @@ -1998,11 +1985,10 @@ RunStopped/RunFailed 保留最近已接受的 ModelResult Cancel 与 Unknown 的提交先后决定终态 CancelRun 在过期 BaseRevision 上对非 terminal Run 重新评估 RejectModelResult 必须带有效 Model grant;AlreadyApplied 重放不重复累计 usage -持久化状态与按 EvolveVersion 折叠的 TransitionRecord log 一致;snapshot 分歧或缺失且日志完整 -> 自动重建并报告,重建不改变健康状态 -transition log 尾部低于 revision 水位或尾部 transition 不完整 -> ErrLogTruncated/log damage,halt 该 Run -TransitionRecord 内部 Revision/Index 缺洞、事实 digest 或 transition digest 不匹配 -> fold 拒绝,按日志损坏处理 +持久化状态与按 EvolveVersion 折叠的 TransitionRecord log 一致(replay-fold 等价,双写正确性的回归测试) +FoldRun/FoldTransitions 对 Revision/Index 缺洞、事实 digest 或 transition digest 不匹配拒绝折叠(导入/迁移的验证入口) ModelStepPrepared/ToolStepOpened 自包含:携带的 digest 折叠后可重现 Step 身份 -golden transition stream:固定 v1 命令序列折叠出冻结的状态字节 +golden transition stream:固定 v1 命令序列折叠出冻结的状态字节(回归测试;迁移功能排期时升级为版本冻结校验) ``` ### 14.4 `agent/es` @@ -2031,7 +2017,7 @@ SteerItem/FollowUpItem 只作为 application payload,不污染 queue core ```text queue FIFO、accepted-order reorder、typed ID isolation assigned follow-up 只由正确的 admission claim -RunHeader、TransitionRecord、MachineState projection 与 watermark 一致 +RunHeader、TransitionRecord 与 MachineState 同事务一致 Session materialization、artifact/usage outbox 与 Run transition 原子或可幂等恢复 assistant tool-call 和 tool result 只 materialize 一次 多 response rows 与逐次 wake/idempotency @@ -2065,9 +2051,9 @@ Application 的 queue/session/admission 语义属于各自 domain;Application 2. breaking release 版本和 durable protocol upgrade window。 3. EventSink payload schema,以及是否需要在 durable outbox 中加入跨进程 execution epoch。 -本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;`run.ModelRequest` 冻结完整的 generation options,streaming 只是 `run.ModelInvoker` 的可选执行路径,不改变 Run command/event 语义。Run Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、与事件编码同属永久兼容契约;`RunHeader + TransitionRecord log` 为 Run execution source of truth,MachineState 为必需的同事务 projection,分歧仲裁按 §5.1(transition log 完整则自动重建,日志尾部低于水位或 transition 不完整则 halt)。结构性 malformed 的模型结果通过 `RejectModelResult` 的 disposition 在同一冻结 request 上重试或失败;fixed model/limits 不进入 MachineState;usage 在 MachineState 内逐字段累计;steer 由 Application 的 queue-safe admission gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 +本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;`run.ModelRequest` 冻结完整的 generation options,streaming 只是 `run.ModelInvoker` 的可选执行路径,不改变 Run command/event 语义。Run Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、版本内稳定;MachineState 为 Run execution authority(durable continuation 经 `Load` 直接恢复),`RunHeader + TransitionRecord log` 为同事务原子写入的 canonical record(审计、投影、经验证的导入/迁移),两者分歧属实现缺陷,处理为运维决定(§5.1)。结构性 malformed 的模型结果通过 `RejectModelResult` 的 disposition 在同一冻结 request 上重试或失败;fixed model/limits 不进入 MachineState;usage 在 MachineState 内逐字段累计;steer 由 Application 的 queue-safe admission gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 -本规范采用 `RunHeader + TransitionRecord log` 为 Run execution source of truth(§5.1):三个稳定条件(ontology 冻结、versioned Evolve 冻结、事实自包含)由 Decide/Evolve 拆分保障,revision 水位保护 transition log 尾部完整性,TransitionDigest 保护单个 transition 内部的完整事件组。MachineState 保持为必需的同事务 projection——提交验证要求当前状态在临界区内可得,这与日志权威并不冲突。跨 Run 语义不从旧 Run log 读取,而由 Session ES、artifact、memory/context projection 构造。 +本规范采用 MachineState 为 Run execution authority、`RunHeader + TransitionRecord log` 为 canonical record(§5.1):稳定条件(ontology 冻结、versioned Evolve 版本内稳定、事实自包含)由 Decide/Evolve 拆分保障,TransitionDigest 保护单个 transition 内部的完整事件组。恢复执行走 `Load(MachineState)`;`FoldRun` 服务导入/迁移与诊断。重新收紧为日志权威的触发条件(执行历史 fork 成为产品功能,或状态存储不可信而日志存储可信的部署形态)记录于 §5.1。跨 Run 语义不从旧 Run log 读取,而由 Session ES、artifact、memory/context projection 构造。 ## 附录 A:最小 public API 草案 @@ -2812,7 +2798,7 @@ func (l *Loop) Run(context.Context, Runtime, EventSink) (LoopResult, error) 8. Waiting response 只推进对应 Call;approval approved 先变 Pending,随后由 Loop 执行工具。日志记录结果事实(`ToolCallFailed{permission_denied}`、`RunEnded{cancelled}`),不记录请求本身。 9. 幂等按 command 判定:相同 CommandID/digest 重放返回 CommitAlreadyApplied 与原事件组(不重新运行 Decide),不重复写入 projection、history、queue action 或 outbox;相同 CommandID 不同 digest 冲突。 10. 一次接受的 transition 使 Revision 恰好加一;其全部事实共享该 Revision,Index 组内连续,提交后 `Snapshot.Revision` 等于该 Revision。 -11. `RunHeader + TransitionRecord log` 是 Run execution source of truth;MachineState 是必需的同事务 projection,可按 `EvolveVersion` 从经验证的 header 和 transition log 重建。对任意 Revision,状态必须等于 `RunHeader.InitialState` 经 `flatten(TransitionRecord[].Events)` 折叠的结果;snapshot 分歧或缺失且日志完整时自动重建并记录,日志尾部低于 revision 水位或 transition digest/事件组不完整时 halt 该 Run。 +11. MachineState 是 Run execution authority,durable continuation 经 `Load` 直接恢复;`RunHeader + TransitionRecord log` 是同事务原子写入的 canonical record,服务审计、投影与经验证的导入/迁移。对任意 Revision,状态应当等于 `RunHeader.InitialState` 经 `flatten(TransitionRecord[].Events)` 折叠的结果——由 replay-fold 等价测试守护;分歧属实现缺陷,处理为运维决定,协议不定义自动仲裁。 12. Evolve 的折叠语义与事件编码同属永久兼容契约,按 SchemaVersion 冻结;Replay 通过 `EvolveVersion` 选择历史语义;Decide 的决策规则可随版本演进,因为决策结果已记录为事实。 13. 已知工具失败交给下一次模型请求;Unknown 终止 Run,不自动重试、不查询外部系统。 14. worker cancellation 不等于 RunStopped;业务停止必须提交控制 command。宿主的业务停止先提交 `CancelRun`,再取消 Loop 的 ctx;ctx 取消本身只结束执行尝试,工具 worker 运行到自身结束。 From 72dcccb54d5f224263a5d5dea1fcd2fc2d19d04a Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 29 Aug 2026 19:14:20 +0800 Subject: [PATCH 28/81] refactor(run): drop the per-commit double fold; state is stored directly With MachineState as the execution authority, deriving the stored state by refolding the just-persisted transition was a leftover of the log-authority era. Commit now stores decision.NewState directly; the state/log equivalence stays guarded by the conformance replay-fold test. Stale 'log is the source of truth' and 'frozen forever' comments updated to the final authority wording. --- agent/run/memory_runtime.go | 34 ++++++---------------------------- agent/run/protocol.go | 2 +- 2 files changed, 7 insertions(+), 29 deletions(-) diff --git a/agent/run/memory_runtime.go b/agent/run/memory_runtime.go index e430fed..8d7dbc3 100644 --- a/agent/run/memory_runtime.go +++ b/agent/run/memory_runtime.go @@ -10,8 +10,9 @@ import ( ) // MemoryRuntime is the in-process reference Runtime: mutex + MachineState + -// TransitionRecord log (spec §8.1). The transition log is the source of truth; -// the state is the same-transaction projection. It is the conformance +// TransitionRecord log (spec §8.1). The MachineState is the execution +// authority; the transition log is the same-transaction canonical record +// (audit, projections, verified import). It is the conformance // reference; it does not survive the process and does not store product // history. type MemoryRuntime struct { @@ -87,24 +88,6 @@ func newGrant() ExecutionGrant { return ExecutionGrant(hex.EncodeToString(b[:])) } -func foldCommittedTransition(state *MachineState, record *TransitionRecord) (MachineState, error) { - if err := ValidateTransitionRecord(record); err != nil { - return *state, err - } - current := *state - for i := range record.Events { - e := record.Events[i] - fact, err := snapshotFact(e.Fact) - if err != nil { - return current, err - } - current, err = EvolveVersion(e.SchemaVersion, current, fact) - if err != nil { - return current, err - } - } - return current, nil -} //nolint:gocritic // hugeParam: CommitRequest is the value DTO of the Runtime authority boundary. func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitResult, error) { @@ -157,15 +140,10 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe return CommitResult{}, ErrRunTerminal } - // DecisionApply: persist the owned transition and derive the authoritative - // snapshot from that same stored transition. The transition log is the source - // of truth; the in-memory state is only its same-transaction projection. + // DecisionApply: persist the new authoritative state and the canonical + // transition record in the same critical section (spec §5.1). stored := cloneTransitionRecord(&decision.Transition) - newState, err := foldCommittedTransition(&m.state, &stored) - if err != nil { - return CommitResult{}, err - } - m.state = cloneMachineState(&newState) + m.state = cloneMachineState(&decision.NewState) m.revision++ m.watermark = m.revision m.transitions[req.Command.ID] = stored diff --git a/agent/run/protocol.go b/agent/run/protocol.go index 7863f5d..4f8c5c3 100644 --- a/agent/run/protocol.go +++ b/agent/run/protocol.go @@ -8,7 +8,7 @@ import ( ) // SchemaVersion1 is the first published wire schema. Canonical encoding and -// Evolve folding semantics for a published version are frozen forever. +// Evolve folding semantics stay stable within a published version. const SchemaVersion1 uint16 = 1 // currentSchemaVersion is what new commands and facts are written with. From ccd76cff3de0216c1131e43a7015dc81eb7a5374 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sun, 30 Aug 2026 01:34:34 +0800 Subject: [PATCH 29/81] refactor(jsonstable): use RFC 8785 JCS canonicalization --- agent/jsonstable/jsonstable.go | 313 ++++----------------------------- agent/run/canonical_test.go | 1 + agent/run/regression_test.go | 31 ++-- go.mod | 1 + go.sum | 12 ++ 5 files changed, 60 insertions(+), 298 deletions(-) diff --git a/agent/jsonstable/jsonstable.go b/agent/jsonstable/jsonstable.go index 31010e3..f5c22a1 100644 --- a/agent/jsonstable/jsonstable.go +++ b/agent/jsonstable/jsonstable.go @@ -1,6 +1,7 @@ -// Package jsonstable provides immutable canonical 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 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 ( @@ -8,13 +9,9 @@ import ( "encoding/json" "errors" "fmt" - "io" - "math" - "sort" - "strconv" - "strings" - "unicode/utf16" "unicode/utf8" + + "github.com/gowebpki/jcs" ) // Value is an immutable canonical JSON value. The zero value represents an @@ -23,8 +20,14 @@ type Value struct { raw []byte } -// Parse validates raw JSON and stores its canonical representation. A nil -// slice returns the zero Value; an empty but non-nil slice is invalid JSON. +// 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 @@ -108,44 +111,29 @@ func (v *Value) UnmarshalJSON(raw []byte) error { return nil } -// Canonicalize transforms JSON into the protocol's canonical form: RFC 8785 -// (JCS) object-key ordering and string escaping, with two deliberate -// deviations required for digest identity — integer tokens keep arbitrary -// precision instead of collapsing through float64, and inputs JCS tolerates -// but that would merge distinct payloads into one digest (duplicate object -// keys, trailing data, invalid UTF-8) are rejected outright. +// 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) { - // Reject invalid UTF-8 up front: encoding/json silently rewrites broken - // bytes to U+FFFD during decode, which would merge distinct payloads into - // one canonical identity before writeCanonicalString could see them. if !utf8.Valid(raw) { return nil, errors.New("agent: canonical: invalid UTF-8 input") } - // encoding/json also rewrites escaped lone surrogates (\ud800..\udfff) to - // U+FFFD before writeCanonicalString sees them. Reject them while the raw - // escape structure is still visible; otherwise distinct payloads collapse - // into the same digest. if err := rejectEscapedLoneSurrogates(raw); err != nil { return nil, err } - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.UseNumber() - v, err := parseCanonicalValue(dec) + canonical, err := jcs.Transform(raw) if err != nil { return nil, fmt.Errorf("agent: canonical: %w", err) } - // Strict end: any trailing token — including a stray ']' or '}' the - // decoder's More() would miss — rejects the input. - if _, err := dec.Token(); err != io.EOF { - return nil, errors.New("agent: canonical: trailing data after JSON value") - } - var b bytes.Buffer - if err := writeCanonical(&b, v); err != nil { - return nil, err - } - return b.Bytes(), nil + 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] != '"' { @@ -158,18 +146,18 @@ func rejectEscapedLoneSurrogates(raw []byte) error { goto nextToken case '\\': if i+1 >= len(raw) { - return nil // let the JSON decoder report syntax + 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 JSON decoder report syntax + return nil // let the JCS parser report syntax. } code, ok := parseHex4(raw[i+2 : i+6]) if !ok { - return nil // let the JSON decoder report syntax + return nil // let the JCS parser report syntax. } switch { case 0xd800 <= code && code <= 0xdbff: @@ -216,249 +204,8 @@ func parseHex4(raw []byte) (rune, bool) { return n, true } -// parseCanonicalValue decodes one JSON value from the token stream, rejecting -// duplicate object keys: last-wins collapsing would let two byte-distinct -// payloads share one digest. -func parseCanonicalValue(dec *json.Decoder) (any, error) { - tok, err := dec.Token() - if err != nil { - return nil, err - } - return parseFromToken(dec, tok) -} - -func parseFromToken(dec *json.Decoder, tok json.Token) (any, error) { - delim, ok := tok.(json.Delim) - if !ok { - return tok, nil // string, json.Number, bool, or nil - } - switch delim { - case '{': - m := make(map[string]any) - for dec.More() { - keyTok, err := dec.Token() - if err != nil { - return nil, err - } - key, ok := keyTok.(string) - if !ok { - return nil, fmt.Errorf("object key %v is not a string", keyTok) - } - if _, dup := m[key]; dup { - return nil, fmt.Errorf("duplicate object key %q", key) - } - val, err := parseCanonicalValue(dec) - if err != nil { - return nil, err - } - m[key] = val - } - if _, err := dec.Token(); err != nil { // consume '}' - return nil, err - } - return m, nil - case '[': - a := []any{} - for dec.More() { - val, err := parseCanonicalValue(dec) - if err != nil { - return nil, err - } - a = append(a, val) - } - if _, err := dec.Token(); err != nil { // consume ']' - return nil, err - } - return a, nil - default: - return nil, fmt.Errorf("unexpected delimiter %v", delim) - } -} - -func writeCanonical(b *bytes.Buffer, v any) error { - switch x := v.(type) { - case nil: - b.WriteString("null") - case bool: - if x { - b.WriteString("true") - } else { - b.WriteString("false") - } - case json.Number: - s, err := canonicalNumber(x) - if err != nil { - return err - } - b.WriteString(s) - case string: - if err := writeCanonicalString(b, x); err != nil { - return err - } - case []any: - b.WriteByte('[') - for i, e := range x { - if i > 0 { - b.WriteByte(',') - } - if err := writeCanonical(b, e); err != nil { - return err - } - } - b.WriteByte(']') - case map[string]any: - keys := make([]string, 0, len(x)) - for k := range x { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { return utf16Less(keys[i], keys[j]) }) - b.WriteByte('{') - for i, k := range keys { - if i > 0 { - b.WriteByte(',') - } - if err := writeCanonicalString(b, k); err != nil { - return err - } - b.WriteByte(':') - if err := writeCanonical(b, x[k]); err != nil { - return err - } - } - b.WriteByte('}') - default: - return fmt.Errorf("agent: canonical: unsupported value %T", v) - } - return nil -} - -// utf16Less orders strings by their UTF-16 code units (RFC 8785 §3.2.3). -func utf16Less(a, b string) bool { - ua := utf16.Encode([]rune(a)) - ub := utf16.Encode([]rune(b)) - for i := 0; i < len(ua) && i < len(ub); i++ { - if ua[i] != ub[i] { - return ua[i] < ub[i] - } - } - return len(ua) < len(ub) -} - -// writeCanonicalString emits a JSON string with JCS minimal escaping. Invalid -// UTF-8 is rejected: silently replacing broken bytes with U+FFFD would merge -// distinct payloads into one canonical identity. -func writeCanonicalString(b *bytes.Buffer, s string) error { - b.WriteByte('"') - for i, r := range s { - switch r { - case '"': - b.WriteString(`\"`) - case '\\': - b.WriteString(`\\`) - case '\b': - b.WriteString(`\b`) - case '\t': - b.WriteString(`\t`) - case '\n': - b.WriteString(`\n`) - case '\f': - b.WriteString(`\f`) - case '\r': - b.WriteString(`\r`) - default: - switch { - case r < 0x20: - fmt.Fprintf(b, `\u%04x`, r) - case r == utf8.RuneError: - if _, size := utf8.DecodeRuneInString(s[i:]); size == 1 { - return errors.New("agent: canonical: invalid UTF-8 in string") - } - b.WriteRune(r) // a genuine U+FFFD character - default: - b.WriteRune(r) - } - } - } - b.WriteByte('"') - return nil -} - -// canonicalNumber renders a JSON number. Integer tokens keep their exact -// digits (arbitrary precision): forcing them through float64 per strict JCS -// would corrupt 64-bit identifiers above 2^53 and collide near-adjacent -// values into one digest. Non-integer tokens use the ES6 double form. -func canonicalNumber(n json.Number) (string, error) { - s := n.String() - if !strings.ContainsAny(s, ".eE") { - neg := strings.HasPrefix(s, "-") - digits := strings.TrimPrefix(s, "-") - digits = strings.TrimLeft(digits, "0") - if digits == "" { - return "0", nil // 0 and -0 canonicalize to "0" - } - if neg { - return "-" + digits, nil - } - return digits, nil - } - f, err := strconv.ParseFloat(s, 64) - if err != nil { - return "", fmt.Errorf("agent: canonical: bad number %q: %w", s, err) - } - return formatES6Float(f) -} - -// formatES6Float implements ES6 Number::toString for finite doubles. -func formatES6Float(x float64) (string, error) { - if math.IsNaN(x) || math.IsInf(x, 0) { - return "", errors.New("agent: canonical: non-finite number") - } - if x == 0 { - return "0", nil // negative zero canonicalizes to "0" - } - neg := math.Signbit(x) - if neg { - x = -x - } - // Shortest round-trip digits in exponential form: d[.ddd]e±dd - s := strconv.FormatFloat(x, 'e', -1, 64) - ePos := strings.IndexByte(s, 'e') - mant := strings.Replace(s[:ePos], ".", "", 1) - exp, err := strconv.Atoi(s[ePos+1:]) - if err != nil { - return "", err - } - k := len(mant) - n := exp + 1 // value = 0.d1..dk × 10^n - - var out string - switch { - case k <= n && n <= 21: - out = mant + strings.Repeat("0", n-k) - case 0 < n && n <= 21: - out = mant[:n] + "." + mant[n:] - case -6 < n && n <= 0: - out = "0." + strings.Repeat("0", -n) + mant - default: - e := n - 1 - m := mant[:1] - if k > 1 { - m += "." + mant[1:] - } - if e < 0 { - out = m + "e-" + strconv.Itoa(-e) - } else { - out = m + "e+" + strconv.Itoa(e) - } - } - if neg { - out = "-" + out - } - return out, nil -} - -// MarshalCanonical marshals a Go value with encoding/json and canonicalizes -// the result. This is the single path from protocol values to digest input. +// 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 { diff --git a/agent/run/canonical_test.go b/agent/run/canonical_test.go index 98372b2..c08d46f 100644 --- a/agent/run/canonical_test.go +++ b/agent/run/canonical_test.go @@ -25,6 +25,7 @@ func TestCanonicalJSON(t *testing.T) { {"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\"}"}, diff --git a/agent/run/regression_test.go b/agent/run/regression_test.go index 61784c2..e279c0d 100644 --- a/agent/run/regression_test.go +++ b/agent/run/regression_test.go @@ -49,31 +49,32 @@ func TestRegressionBindingMustMatchModelResult(t *testing.T) { } } -// Finding 4: integers above 2^53 keep exact digits; near-adjacent big -// integers must not collide into one digest. -func TestRegressionBigIntegerPrecision(t *testing.T) { - got, err := canonicalJSON([]byte(`{"channel_id":1234567890123456789}`)) +// Finding 4: protocol JSON follows RFC 8785 / IEEE-754 semantics, so the +// PostgreSQL JSONB rendering of a number has the same digest as its wire +// spelling. Exact large identifiers must be JSON strings. +func TestRegressionJCSNumberSemantics(t *testing.T) { + d1, err := digestToolCallBinding("c", "", DirectExecution, cj(`{"n":1e+21}`)) if err != nil { t.Fatal(err) } - if string(got) != `{"channel_id":1234567890123456789}` { - t.Fatalf("big integer corrupted: %s", got) - } - d1, err := digestToolCallBinding("c", "", DirectExecution, cj(`{"n":9007199254740993}`)) + d2, err := digestToolCallBinding("c", "", DirectExecution, cj(`{"n":1000000000000000000000}`)) if err != nil { t.Fatal(err) } - d2, err := digestToolCallBinding("c", "", DirectExecution, cj(`{"n":9007199254740992}`)) + if d1 != d2 { + t.Fatal("equivalent JCS numbers produced different binding digests") + } + + id1, err := digestToolCallBinding("c", "", DirectExecution, cj(`{"channel_id":"9007199254740993"}`)) if err != nil { t.Fatal(err) } - if d1 == d2 { - t.Fatal("adjacent big integers collide into one binding digest") + id2, err := digestToolCallBinding("c", "", DirectExecution, cj(`{"channel_id":"9007199254740992"}`)) + if err != nil { + t.Fatal(err) } - // Non-integer numbers still use the ES6 double form. - got, _ = canonicalJSON([]byte(`{"a":1.0e3}`)) - if string(got) != `{"a":1000}` { - t.Fatalf("float form changed: %s", got) + if id1 == id2 { + t.Fatal("distinct string identifiers collided in a binding digest") } } diff --git a/go.mod b/go.mod index 3e7ef98..0c1a3b4 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ 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 ) diff --git a/go.sum b/go.sum index 0786bf1..8674c68 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ 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/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= @@ -36,12 +38,19 @@ 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/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= +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/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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -50,3 +59,6 @@ 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= +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= From ce2b1fd22dbccaff8c8b3dda64a296e8f531f9dc Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sun, 30 Aug 2026 01:34:46 +0800 Subject: [PATCH 30/81] docs(agent-runtime): specify JCS number semantics --- docs/design/agent-runtime-refactor.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 668863f..87b1552 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -208,7 +208,7 @@ run-owned 冻结形态遵守以下规则;`FreezeModelRequest`、`FreezeModelRe 3. `ToolChoice` 是封闭类型 `{Mode: auto|none|required|tool, Tool string}`,不使用 `any`。 4. 消息 part 是 sealed union 的 agent value(text/reasoning/image/file/tool-call/tool-result),不持久化 `sdk.MessagePart` interface。 5. 消息中的二进制内容有两种形式:inline bytes(canonical 编码为 base64),或稳定的内容寻址引用 `BlobRef{Digest, MediaType, ByteSize}`。`BlobRef` 的字节解析由组装 `ModelInvoker` 的一方负责;带时效的 URL 等不稳定引用不能进入冻结请求。两种形式产生不同的 digest,Planner 对同一内容必须确定性地选择一种形式。 -6. provider metadata、provider options、tool input/result、response format schema 等扩展字段的值必须是 JSON 值。进入 `agent/run` 前必须 parse/canonicalize 成 opaque `CanonicalJSON`(由 `agent/jsonstable.Value` 承载,内部 bytes 不可被 run core 或 caller 直接构造/修改);不能保存 caller-owned map/slice/RawMessage 或 `any`。canonicalization 必须拒绝会把不同 payload 折叠成同一值的输入,包括重复 object key、trailing data、invalid UTF-8 和 escaped lone surrogate(`\ud800`..`\udfff`)。 +6. provider metadata、provider options、tool input/result、response format schema 等扩展字段的值必须是 JSON 值。进入 `agent/run` 前必须按 RFC 8785/JCS parse/canonicalize 成 opaque `CanonicalJSON`(由 `agent/jsonstable.Value` 承载,内部 bytes 不可被 run core 或 caller 直接构造/修改);不能保存 caller-owned map/slice/RawMessage 或 `any`。JSON number 采用 IEEE-754 binary64 语义,精确 ID 或任意精度数值必须编码为 JSON string;canonicalization 必须拒绝会把不同 payload 折叠成同一值的输入,包括重复 object key、trailing data、invalid UTF-8 和 escaped lone surrogate(`\ud800`..`\udfff`)。 7. `DigestRequest` 覆盖 frozen `ModelRequest` 的全部字段,不设排除项。cache 配置等只影响成本的字段同样参与摘要;排除任何字段都会把不同请求判成同一事件,产生错误的 `CommitAlreadyApplied`。 边界的 SDK 类型可以保持以下形态;agent 的持久化 `ModelRequest`/`ToolDefinition` 是相同语义的 concrete mirror,字段中所有接口/any/JSON 原文先在 `Freeze*` 中 canonicalize 为 `CanonicalJSON`。 @@ -1226,7 +1226,7 @@ AgentEvent 身份 ``` ``` -canonical 编码和 digest 函数由 agent 提供;Memoh 只保存和比较结果,不重新实现排序或编码。编码必须包含 sealed command/fact discriminator、按声明顺序编码有序 slice、对 map key 排序,并对 `CanonicalJSON` 原样写入其 canonical bytes;重复 object key、trailing data、invalid UTF-8、escaped lone surrogate 一律在构造 `CanonicalJSON` 或 decode wire document 时拒绝,不能让 `encoding/json` 的 replacement behavior 把不同输入合并为同一 digest;不把 `Digest`、BaseRevision、Revision、Index 或 ExecutionGrant 编入 digest。 +canonical 编码和 digest 函数由 agent 提供;Memoh 只保存和比较结果,不重新实现排序或编码。`CanonicalJSON` 按 RFC 8785/JCS 编码,并采用 IEEE-754 binary64 number 语义;精确 ID 或任意精度数值必须是 JSON string,因此 PostgreSQL JSONB 的结构化重写在重新 canonicalize 后保持 digest 稳定。编码必须包含 sealed command/fact discriminator、按声明顺序编码有序 slice、对 map key 排序,并对 `CanonicalJSON` 原样写入其 canonical bytes;重复 object key、trailing data、invalid UTF-8、escaped lone surrogate 一律在构造 `CanonicalJSON` 或 decode wire document 时拒绝,不能让 `encoding/json` 的 replacement behavior 把不同输入合并为同一 digest;不把 `Digest`、BaseRevision、Revision、Index 或 ExecutionGrant 编入 digest。 已发布 `SchemaVersion` 的 canonical 编码和 digest 规则永久冻结;字段增删只能进入新的 SchemaVersion,旧事件按其自带版本校验。同一个 Run 不允许由写入不同 SchemaVersion 的进程混跑:升级窗口内先全量部署可读写新版本的代码,再开始写入新版本;否则同一 command 的重放会因编码不同被误判为 `ErrCommandConflict`。 From d4a67a01b47974a10d1f0f756f44c537864ec99e Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sun, 30 Aug 2026 01:36:13 +0800 Subject: [PATCH 31/81] docs(session): define three-ring substrate scope --- docs/design/agent-runtime-refactor.md | 30 +++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 87b1552..7a6be05 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -394,9 +394,35 @@ Runtime 的 authority boundary 不能靠“调用者不要修改快照”这类 ### 2.4 `agent/session` -> 实施状态:**契约先行,substrate 按需**。本节的跨 domain 契约(source event identity、causation、finalization barrier,见 §2.6)立即生效,由 durable adapter 在 Application 现有存储上落实;下述泛型 substrate API 是参考草案,由第一个真实消费方(本地持久会话或 local/durable 会话同步)定形后再实现,不预先建包。第一版 durable adapter 不依赖本包。 +> 实施状态:**契约先行,substrate 按 Ring 分批**。§2.6 的跨 domain 契约(source event identity、causation、finalization barrier)立即生效,由 durable adapter 在 Application 现有存储上落实;第一版 durable adapter 不依赖本包。Ring 1/Ring 2 在 durable adapter 的 materializer 跑通后开工,接口从运行中的消费方(materializer 与 Application 的 timeline 投影)反推定形,不从本节草案照抄。 -`agent/session` 是长期语义历史的 append-only ES substrate。它不写死 Twilight/Memoh 的 message ontology,而是用泛型承载上层语义事件: +`agent/session` 是长期语义历史的 append-only ES substrate。它的范围按三环划分;一个能力进入本包必须同时满足三条判别:主流 agent 实现中已收敛出同构形状(行业验证,非发明)、不含 policy(只回答"怎么记、怎么折叠",不回答"何时/是否/对谁")、agent 折叠上下文时必须理解它。 + +```text +Ring 1 substrate(机制)——agent/session + Entry 身份/digest/causation、Store 接口(Append/Replay/Head CAS/Fork)、 + parent/fork lineage、snapshot、SchemaVersion、memory 参考实现、conformance 套件 + +Ring 2 通用会话语义(标准词表)——agent/session/chatlog,可选子包 + 标准事件:MessageAdded 族、TurnSuperseded、CompactCreated、BranchForked、 + InputAccepted、RunAdmitted/RunEnded + SurfaceFold:折叠出 current / shadowed / log-only 三类视图(取代链完整可查) + ContextFold:entry 流 → 当前可见消息序列(agent 读路径) + 未知事件归入 log-only(保留、不进 surface、不进上下文)——substrate 层拒绝 + 未知类型,chatlog 层收容应用扩展,两个姿态都是显式规则 + +Ring 3 产品(policy 与身份)——Application,永不进入本包 + compact 触发策略/模型/阈值、可见性与权限、多租户(team_id)、retention/合规、 + 搜索后端与排名、分享、queue 本体(协调状态)、prompt 组装的产品部分 +``` + +Ring 1 对词表零知识(泛型 `Store[E]`);chatlog 是其上的可选词表包,应用可以不用它、用它、或在它之上扩展自定义事件。RunConfig 的教训适用于此:任何"何时/是否"的判断都属 Ring 3,不因实现方便而进入 Ring 1/2。 + +Store 接口的 SQL 可实现性约束(设计时生效,先于任何 SQL 实现存在):payload 是不透明的 canonical bytes,store 不理解也不索引其内容;revision 由 store 逻辑经 head CAS 分配,不依赖数据库序列;全部操作是单表点查/范围扫,无 JOIN、无数据库方言特性。满足这些约束的接口映射到 SQLite/PostgreSQL/JSONL 都是平凡的。 + +实现交付遵循 run 侧先例:本包只交付 memory 参考实现 + conformance 套件;SQL 实现随消费方——Application 形状的实现(租户、RLS、与 run transition 同事务)由 Application 自建并以 conformance 验收,本地会话/同步场景的 SQLite 实现在该功能排期时进入(届时我们自己就是消费方)。跨库能力由"接口约束 + conformance 套件对任意后端成立"保证,不由预制实现保证。 + +它不写死 Twilight/Memoh 的 message ontology,泛型承载上层语义事件: ```go package session From e3e9860bcf9357fed840cef0854cfb08891a049a Mon Sep 17 00:00:00 2001 From: Fodesu Date: Mon, 31 Aug 2026 00:36:12 +0800 Subject: [PATCH 32/81] refactor(run): make Runtime RunID-addressed --- agent/run/alias_test.go | 50 +- agent/run/command.go | 6 +- agent/run/commit.go | 11 +- agent/run/evolve.go | 31 +- agent/run/header.go | 30 +- agent/run/header_test.go | 17 +- agent/run/loop.go | 18 +- agent/run/loop_test.go | 65 +- agent/run/machine_test.go | 51 ++ agent/run/memory_conformance_test.go | 5 +- agent/run/memory_runtime.go | 249 +++++--- agent/run/rebuild.go | 28 +- agent/run/rebuild_test.go | 142 +++-- agent/run/regression_test.go | 20 +- agent/run/run_creation.go | 143 +++++ agent/run/runtime_creation_test.go | 215 +++++++ agent/run/runtime_helpers_test.go | 39 +- agent/run/runtimetest/conformance.go | 360 ++++++++++- docs/design/agent-runtime-refactor.md | 865 ++++++++++++-------------- 19 files changed, 1595 insertions(+), 750 deletions(-) create mode 100644 agent/run/run_creation.go create mode 100644 agent/run/runtime_creation_test.go diff --git a/agent/run/alias_test.go b/agent/run/alias_test.go index 372e8fc..b8454ec 100644 --- a/agent/run/alias_test.go +++ b/agent/run/alias_test.go @@ -13,23 +13,29 @@ func TestMemoryRuntimeClonesInitialState(t *testing.T) { if err != nil { t.Fatal(err) } - initial, err := Initialize("run-1", testConfig(), NextRun(AgentInput{ID: "seed", Payload: payload})) + rt := NewMemoryRuntime() + newRun, err := BuildNewRun("run-1", "") if err != nil { t.Fatal(err) } - rt := NewMemoryRuntime(initial) + if _, err := rt.Create(context.Background(), newRun); err != nil { + t.Fatal(err) + } + // Initial payload admission uses the actual AcceptInput transition, never a + // synthetic non-Revision-0 initial state. + acceptInput(t, rt, "run-1", AgentInput{ID: "seed", Payload: payload}) copy(raw, []byte(`{"q":"no"}`)) - initial.PendingInputs[0].Payload = cj(`{"q":"mutated"}`) + payload = cj(`{"q":"mutated"}`) - snap, err := rt.Load(context.Background()) + snap, err := rt.Load(context.Background(), "run-1") if err != nil { t.Fatal(err) } if got := snap.State.PendingInputs[0].Payload.String(); got != `{"q":"hi"}` { t.Fatalf("runtime initial payload aliased caller state: %s", got) } - diverged, err := rt.Rebuild() + diverged, err := rt.Rebuild("run-1") if err != nil { t.Fatal(err) } @@ -46,11 +52,15 @@ func TestCommitSnapshotsCommandPayloadBeforeFoldingState(t *testing.T) { t.Fatal(err) } cmdID := DeriveInputCommandID("run-1", "in-1") - mustCommit(t, rt, cmdID, 0, "", AcceptInput{Input: AgentInput{ID: "in-1", Payload: payload}}) + snapshot, err := rt.Load(context.Background(), "run-1") + if err != nil { + t.Fatal(err) + } + mustCommit(t, rt, cmdID, snapshot.Revision, "", AcceptInput{Input: AgentInput{ID: "in-1", Payload: payload}}) copy(raw, []byte(`{"v":"two"}`)) - snap, err := rt.Load(context.Background()) + snap, err := rt.Load(context.Background(), "run-1") if err != nil { t.Fatal(err) } @@ -67,7 +77,7 @@ func TestCommitSnapshotsCommandPayloadBeforeFoldingState(t *testing.T) { t.Fatal("accepted input not found") } - events := rt.Events() + events := recordEvents(t, rt, "run-1") for _, e := range events { if f, ok := e.Fact.(InputAccepted); ok && f.Input.ID == "in-1" { if got := f.Input.Payload.String(); got != `{"v":"one"}` { @@ -75,7 +85,7 @@ func TestCommitSnapshotsCommandPayloadBeforeFoldingState(t *testing.T) { } } } - diverged, err := rt.Rebuild() + diverged, err := rt.Rebuild("run-1") if err != nil { t.Fatal(err) } @@ -86,7 +96,7 @@ func TestCommitSnapshotsCommandPayloadBeforeFoldingState(t *testing.T) { func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { rt := newTestRuntime(t, RunConfig{Model: "m-1"}) - snap, _ := rt.Load(context.Background()) + snap, _ := rt.Load(context.Background(), "run-1") req := ModelRequest{ Model: "m-1", ProviderOptions: map[string]CanonicalJSON{ @@ -124,7 +134,7 @@ func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { if got := ms.Request.ProviderOptions["p"].String(); got != `{"a":1,"b":2}` { t.Fatalf("snapshot stored non-canonical provider option: %s", got) } - for _, e := range rt.Events() { + for _, e := range recordEvents(t, rt, "run-1") { if f, ok := e.Fact.(ModelStepPrepared); ok { if got := f.Request.ProviderOptions["p"].String(); got != `{"a":1,"b":2}` { t.Fatalf("event stored non-canonical provider option: %s", got) @@ -146,11 +156,11 @@ func TestLoadSnapshotDoesNotAliasFrozenRequest(t *testing.T) { }}, }}, } - snap, _ := rt.Load(context.Background()) + snap, _ := rt.Load(context.Background(), "run-1") prep, cmdID := buildPrepareFromSnap(t, snap, req, nil) mustCommit(t, rt, cmdID, snap.Revision, "", prep) - snap, err := rt.Load(context.Background()) + snap, err := rt.Load(context.Background(), "run-1") if err != nil { t.Fatal(err) } @@ -161,7 +171,7 @@ func TestLoadSnapshotDoesNotAliasFrozenRequest(t *testing.T) { part.ProviderMetadata["new"] = cj(`"bad"`) ms.Request.Messages[0].Content[0] = part - snap, err = rt.Load(context.Background()) + snap, err = rt.Load(context.Background(), "run-1") if err != nil { t.Fatal(err) } @@ -201,7 +211,11 @@ func TestCommitResultEventsDoNotAliasStateOrLog(t *testing.T) { if err != nil { t.Fatal(err) } - res := mustCommit(t, rt, "done-1", 2, grant, SubmitModelResult{StepID: stepID, Result: frozen}) + snapshot, err := rt.Load(context.Background(), "run-1") + if err != nil { + t.Fatal(err) + } + res := mustCommit(t, rt, "done-1", snapshot.Revision, grant, SubmitModelResult{StepID: stepID, Result: frozen}) fact := res.Events[0].Fact.(ModelStepCompleted) fact.Result.ReasoningParts[0].ProviderMetadata["anthropic"] = cj(`{"signature":"bad"}`) @@ -210,7 +224,7 @@ func TestCommitResultEventsDoNotAliasStateOrLog(t *testing.T) { fact.Result.Response.Headers["h"] = "bad" res.Events[0].Fact = fact - snap, err := rt.Load(context.Background()) + snap, err := rt.Load(context.Background(), "run-1") if err != nil { t.Fatal(err) } @@ -231,7 +245,7 @@ func TestCommitResultEventsDoNotAliasStateOrLog(t *testing.T) { t.Fatalf("state response headers aliased returned event: %v", h) } - for _, e := range rt.Events() { + for _, e := range recordEvents(t, rt, "run-1") { if f, ok := e.Fact.(ModelStepCompleted); ok { if sig := f.Result.ReasoningParts[0].ProviderMetadata["anthropic"].String(); sig != `{"signature":"s1"}` { t.Fatalf("log reasoning metadata aliased returned event: %v", sig) @@ -241,7 +255,7 @@ func TestCommitResultEventsDoNotAliasStateOrLog(t *testing.T) { } } } - diverged, err := rt.Rebuild() + diverged, err := rt.Rebuild("run-1") if err != nil { t.Fatal(err) } diff --git a/agent/run/command.go b/agent/run/command.go index dfadabb..24ab996 100644 --- a/agent/run/command.go +++ b/agent/run/command.go @@ -16,10 +16,10 @@ type AgentInput struct { func NextStep(input AgentInput) AcceptInput { return AcceptInput{Input: input} } // RunSeed is the legacy admission seed for a new Run. It is not a command: it -// never goes through Runtime.Commit. New admission should use InitializeRun and -// submit initial input with AcceptInput. +// never goes through Runtime.Commit. New admission uses NewRun with +// Runtime.Create, then submits every initial input with AcceptInput. // -// Deprecated: use InitializeRun and AcceptInput. +// Deprecated: use NewRun, Runtime.Create, and AcceptInput. type RunSeed struct { Input AgentInput `json:"input"` } diff --git a/agent/run/commit.go b/agent/run/commit.go index 9df6b82..f63ae41 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -6,12 +6,15 @@ import ( "fmt" ) -// Runtime is the authority boundary: Load returns the current state and -// Revision; Commit atomically applies one command (spec §5.2). Nothing else — -// planning, queues and tool entry points are not hidden methods. +// Runtime is the RunID-addressed access and atomic commit boundary. Create +// establishes immutable Revision-0 state; Load, Commit, and Record operate on +// exactly one Run. MachineState remains execution authority; planning, queues, +// and tool entry points are not hidden methods. type Runtime interface { - Load(context.Context) (RuntimeSnapshot, error) + Create(context.Context, NewRun) (CreateResult, error) + Load(context.Context, RunID) (RuntimeSnapshot, error) Commit(context.Context, CommitRequest) (CommitResult, error) + Record(context.Context, RunID) (RunRecord, error) } type RuntimeSnapshot struct { diff --git a/agent/run/evolve.go b/agent/run/evolve.go index d2743d5..597e0b8 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -33,6 +33,18 @@ func evolveV1(s MachineState, f Fact) (MachineState, error) { if s.Current != nil { return s, fmt.Errorf("agent: evolve: model step prepared while a step is current") } + // 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 s, 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 s, fmt.Errorf("agent: evolve: model step prepared input ID at position %d = %q, want pending input %q", i, fact.InputIDs[i], input.ID) + } + } s.Current = ModelStep{ RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: fact.BindingDigest}, Request: fact.Request, @@ -43,7 +55,7 @@ func evolveV1(s MachineState, f Fact) (MachineState, error) { Status: ModelPrepared, } s.ModelSteps++ - s.PendingInputs = removeInputs(s.PendingInputs, fact.InputIDs) + s.PendingInputs = nil return s, nil case ModelStepStarted: @@ -215,20 +227,3 @@ func evolveCall(s *MachineState, step StepID, call CallID, apply func(*ToolCallS s.Current = ts return *s, nil } - -func removeInputs(inputs []AgentInput, ids []InputID) []AgentInput { - if len(ids) == 0 { - return inputs - } - drop := make(map[InputID]bool, len(ids)) - for _, id := range ids { - drop[id] = true - } - var kept []AgentInput - for _, in := range inputs { - if !drop[in.ID] { - kept = append(kept, in) - } - } - return kept -} diff --git a/agent/run/header.go b/agent/run/header.go index a4dad78..db08b0e 100644 --- a/agent/run/header.go +++ b/agent/run/header.go @@ -21,17 +21,19 @@ func encodeMachineStateWire(s *MachineState) ([]byte, error) { } // RunHeader is the formal persisted Revision-0 protocol record (spec §5.1.1). -// The complete execution authority is RunHeader + TransitionRecord log; every -// fold starts from the header's initial state. It is immutable after -// creation; ordinary Runtime.Commit never touches it. +// MachineState is the execution authority. RunHeader plus the TransitionRecord +// log is the canonical record used for audit, projections, and verified +// import/replay: Create atomically stores the header and Revision-0 state, and +// each Commit atomically stores the next state and transition. Every fold starts +// from the header's initial state. The header is immutable after creation. type RunHeader struct { - SchemaVersion uint16 `json:"schemaVersion"` - RunID RunID `json:"runId"` - InitialStateVersion uint16 `json:"initialStateVersion"` - InitialState MachineState `json:"initialState"` - InitialStateDigest Digest `json:"initialStateDigest"` - // CausationID links the Run to the creating session/queue/application - // operation. Opaque to run; the application defines its interpretation. + SchemaVersion uint16 `json:"schemaVersion"` + RunID RunID `json:"runId"` + InitialStateVersion uint16 `json:"initialStateVersion"` + InitialState MachineState `json:"initialState"` + InitialStateDigest Digest `json:"initialStateDigest"` + // CausationID links the Run to its creating turn/application operation. + // Opaque to run; the application defines its interpretation. CausationID es.CausationID `json:"causationId,omitempty"` HeaderDigest Digest `json:"headerDigest"` } @@ -128,10 +130,10 @@ func ValidateRunHeader(h *RunHeader) error { return nil } -// FoldRun rebuilds the run state from its complete authority: header + -// transition log. It validates the header first, then folds the records -// (spec §9.1). This is the entry point durable adapters and import/migration -// paths use; trusting an uploaded MachineState snapshot is never legal. +// FoldRun rebuilds the run state from the canonical header + transition log. +// It validates the header first, then folds the records (spec §9.1). This is +// the entry point durable adapters and import/migration paths use; trusting an +// uploaded MachineState snapshot is never legal. func FoldRun(header *RunHeader, records []TransitionRecord) (MachineState, uint64, error) { if err := ValidateRunHeader(header); err != nil { return MachineState{}, 0, err diff --git a/agent/run/header_test.go b/agent/run/header_test.go index 4dd2858..69d586e 100644 --- a/agent/run/header_test.go +++ b/agent/run/header_test.go @@ -53,7 +53,14 @@ func TestFoldRunFromHeaderMatchesRuntime(t *testing.T) { if err != nil { t.Fatal(err) } - rt := NewMemoryRuntime(h.InitialState) + rt := NewMemoryRuntime() + newRun, err := BuildNewRun("run-1", "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(t.Context(), newRun); err != nil { + t.Fatal(err) + } // Drive one transition: accept the seed input at Revision 1 (spec §5.1.1 // rule 3 — seed enters the log, not the header). @@ -66,14 +73,18 @@ func TestFoldRunFromHeaderMatchesRuntime(t *testing.T) { t.Fatal(err) } - folded, rev, err := FoldRun(&h, rt.Transitions()) + record, err := rt.Record(t.Context(), "run-1") + if err != nil { + t.Fatal(err) + } + folded, rev, err := FoldRun(&h, record.Transitions) if err != nil { t.Fatal(err) } if rev != 1 { t.Fatalf("rev = %d", rev) } - live, err := rt.Load(t.Context()) + live, err := rt.Load(t.Context(), "run-1") if err != nil { t.Fatal(err) } diff --git a/agent/run/loop.go b/agent/run/loop.go index 352d9ea..5f54cca 100644 --- a/agent/run/loop.go +++ b/agent/run/loop.go @@ -44,7 +44,16 @@ func NewLoop(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, pol // Run drives the Run until it finishes, must wait, or the context is // cancelled (spec §6.2). controlCtx for reads/commits is derived from ctx via // WithoutCancel so worker cancellation never blocks result submission. -func (l *Loop) Run(ctx context.Context, runtime Runtime, events EventSink) (LoopResult, error) { +func (l *Loop) Run(ctx context.Context, runtime Runtime, runID RunID, events EventSink) (LoopResult, error) { + if ctx == nil { + return LoopResult{}, errors.New("agent: loop: nil context") + } + if runtime == nil { + return LoopResult{}, errors.New("agent: loop: nil runtime") + } + if runID == "" { + return LoopResult{}, errors.New("agent: loop: empty RunID") + } controlCtx := context.WithoutCancel(ctx) for { @@ -53,10 +62,13 @@ func (l *Loop) Run(ctx context.Context, runtime Runtime, events EventSink) (Loop // branches below before we reach this check. return LoopResult{}, err } - snapshot, err := runtime.Load(controlCtx) + snapshot, err := runtime.Load(controlCtx, 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() { if events != nil { _ = events.Emit(controlCtx, Event{ @@ -211,7 +223,7 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, // 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(controlCtx) + after, loadErr := runtime.Load(controlCtx, snapshot.State.RunID) if loadErr != nil { return loadErr } diff --git a/agent/run/loop_test.go b/agent/run/loop_test.go index 060f253..9bd0a19 100644 --- a/agent/run/loop_test.go +++ b/agent/run/loop_test.go @@ -101,11 +101,7 @@ func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { func loopRuntime(t *testing.T) *MemoryRuntime { t.Helper() - s, err := Initialize("run-1", RunConfig{Model: "m-1"}, NextRun(AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)})) - if err != nil { - t.Fatal(err) - } - return NewMemoryRuntime(s) + return newTestRuntime(t, RunConfig{Model: "m-1"}) } func textResult(text string) sdk.ModelResult { @@ -129,7 +125,7 @@ func TestLoopSingleModelCallCompletes(t *testing.T) { if err != nil { t.Fatal(err) } - res, err := loop.Run(context.Background(), rt, nil) + res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } @@ -152,7 +148,7 @@ func TestLoopToolRoundTrip(t *testing.T) { loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) - res, err := loop.Run(context.Background(), rt, nil) + res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } @@ -182,7 +178,7 @@ func TestLoopApprovalWaitsAndResumes(t *testing.T) { staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) // First run: reaches Waiting(Approval) and returns. - res, err := loop.Run(context.Background(), rt, nil) + res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } @@ -206,13 +202,13 @@ func TestLoopApprovalWaitsAndResumes(t *testing.T) { if err != nil { t.Fatal(err) } - snap, _ := rt.Load(context.Background()) + snap, _ := rt.Load(context.Background(), "run-1") if _, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { t.Fatal(err) } // Wake: a new Loop run executes the tool and finishes. - res, err = loop.Run(context.Background(), rt, nil) + res, err = loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } @@ -235,7 +231,7 @@ func TestLoopUnknownOutcomeFailsRun(t *testing.T) { loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) - res, err := loop.Run(context.Background(), rt, nil) + res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } @@ -258,7 +254,7 @@ func TestLoopKnownToolFailureContinues(t *testing.T) { loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) - res, err := loop.Run(context.Background(), rt, nil) + res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } @@ -282,7 +278,7 @@ func TestLoopUnknownToolRefClosesAsLookupFailure(t *testing.T) { loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{tools: map[ToolRef]ExecutableTool{}}, staticPlanner{}, ExecutionPolicy{}, false) - res, err := loop.Run(context.Background(), rt, nil) + res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } @@ -291,7 +287,7 @@ func TestLoopUnknownToolRefClosesAsLookupFailure(t *testing.T) { } // The failed call must be recorded as tool_lookup_failed in the log. found := false - for _, e := range rt.Events() { + for _, e := range recordEvents(t, rt, "run-1") { if f, ok := e.Fact.(ToolCallFailed); ok && f.Failure.Class == FailureToolLookup { found = true } @@ -329,7 +325,7 @@ func TestLoopParallelBounded(t *testing.T) { var res LoopResult var runErr error go func() { - res, runErr = loop.Run(context.Background(), rt, nil) + res, runErr = loop.Run(context.Background(), rt, "run-1", nil) close(done) }() @@ -359,19 +355,19 @@ func TestLoopCtxCancelReturnsWithoutFailingRun(t *testing.T) { loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) cancel() // cancelled before the model call - _, err := loop.Run(ctx, rt, nil) + _, err := loop.Run(ctx, rt, "run-1", nil) if !errors.Is(err, context.Canceled) { t.Fatalf("err = %v, want context.Canceled", err) } // Run must still be active (recovery released it), never failed. - snap, _ := rt.Load(context.Background()) + snap, _ := rt.Load(context.Background(), "run-1") if snap.State.Status != RunActive { t.Fatalf("status = %v, want RunActive", snap.State.Status) } // A fresh Loop with a working invoker resumes the same frozen request. invoker2 := &fakeInvoker{results: []sdk.ModelResult{textResult("resumed")}} loop2, _ := NewLoop(fakeCatalog{invoker2}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) - res, err := loop2.Run(context.Background(), rt, nil) + res, err := loop2.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } @@ -381,17 +377,26 @@ func TestLoopCtxCancelReturnsWithoutFailingRun(t *testing.T) { } func TestLoopModelStepLimitStopsBeforePlanning(t *testing.T) { - s, err := InitializeRun("run-1") + // Corruption fixture: the limit is a snapshot-only condition that cannot + // be reached without planning, so inject it locally after a real Revision-0 + // admission. Normal loop fixtures never mutate MemoryRuntime internals. + rt := NewMemoryRuntime() + newRun, err := BuildNewRun("run-1", "") if err != nil { t.Fatal(err) } - s.ModelSteps = 1 - rt := NewMemoryRuntime(s) + if _, err := rt.Create(context.Background(), newRun); err != nil { + t.Fatal(err) + } + entry := memoryEntry(t, rt) + entry.mu.Lock() + entry.state.ModelSteps = 1 + entry.mu.Unlock() loop, err := NewLoop(fakeCatalog{}, fakeToolCatalog{}, panicPlanner{}, ExecutionPolicy{ModelStepLimit: 1}, false) if err != nil { t.Fatal(err) } - res, err := loop.Run(context.Background(), rt, nil) + res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } @@ -415,7 +420,7 @@ func TestLoopMalformedModelResultLimitFailsRun(t *testing.T) { if err != nil { t.Fatal(err) } - res, err := loop.Run(context.Background(), rt, nil) + res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } @@ -436,13 +441,13 @@ func (panicPlanner) Plan(context.Context, PlanningHint) (RequestPlan, error) { func TestLoopCancelRunViaCommand(t *testing.T) { // Host order: commit CancelRun first, then cancel ctx (spec §6.6). rt := loopRuntime(t) - snap, _ := rt.Load(context.Background()) + snap, _ := rt.Load(context.Background(), "run-1") env, _ := BuildEnvelope("run-1", "cancel-1", CancelRun{}) if _, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { t.Fatal(err) } loop, _ := NewLoop(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) - res, err := loop.Run(context.Background(), rt, nil) + res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } @@ -467,13 +472,13 @@ func TestLoopMidExecutionCancelRecoversModelStep(t *testing.T) { rt := loopRuntime(t) loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) - _, err := loop.Run(ctx, rt, nil) + _, err := loop.Run(ctx, rt, "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()) + snap, _ := rt.Load(context.Background(), "run-1") ms, ok := snap.State.Current.(ModelStep) if !ok || ms.Status != ModelPrepared { t.Fatalf("current = %#v, want Prepared ModelStep", snap.State.Current) @@ -482,7 +487,7 @@ func TestLoopMidExecutionCancelRecoversModelStep(t *testing.T) { t.Fatalf("ModelSteps = %d", snap.State.ModelSteps) } recovered := false - for _, e := range rt.Events() { + for _, e := range recordEvents(t, rt, "run-1") { if _, ok := e.Fact.(ModelStepRecovered); ok { recovered = true } @@ -494,14 +499,14 @@ func TestLoopMidExecutionCancelRecoversModelStep(t *testing.T) { // A fresh Loop resumes the SAME frozen step without a new Prepare. invoker2 := &fakeInvoker{results: []sdk.ModelResult{textResult("resumed")}} loop2, _ := NewLoop(fakeCatalog{invoker2}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) - res, err := loop2.Run(context.Background(), rt, nil) + res, err := loop2.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } if res.Result.Model.Text != "resumed" { t.Fatalf("res = %+v", res) } - final, _ := rt.Load(context.Background()) + final, _ := rt.Load(context.Background(), "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/machine_test.go b/agent/run/machine_test.go index aadea27..00a4523 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -592,6 +592,57 @@ func TestSubmitModelResultRequiresCanonicalToolInput(t *testing.T) { } } +func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { + minimal, err := InitializeRun("run-1") + if err != nil { + t.Fatal(err) + } + withInputs := func(ids ...InputID) MachineState { + t.Helper() + s := minimal + for _, id := range ids { + var foldErr error + s, foldErr = Evolve(s, InputAccepted{Input: AgentInput{ID: id, Payload: cj(`null`)}}) + if foldErr != nil { + t.Fatal(foldErr) + } + } + return s + } + prepared := func(ids ...InputID) ModelStepPrepared { + return ModelStepPrepared{StepID: "step-1", Model: testModel, Request: ModelRequest{Model: string(testModel)}, RequestDigest: "sha256:req", ToolsDigest: "sha256:tools", BindingDigest: "sha256:binding", InputIDs: ids} + } + + t.Run("nonexistent input", func(t *testing.T) { + s := withInputs("in-1") + if _, err := 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 := 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 := 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 := Evolve(s, prepared("in-1", "in-2")) + if err != nil { + t.Fatal(err) + } + if next.Current == nil || len(next.PendingInputs) != 0 { + t.Fatalf("prepared state = %+v", next) + } + }) +} + func TestEvolveRejectsModelPrepareOverCurrentStep(t *testing.T) { s := newRun(t, testConfig()) s, _ = advanceToExecuting(t, s, testRequest(), nil) diff --git a/agent/run/memory_conformance_test.go b/agent/run/memory_conformance_test.go index 29ab212..1561244 100644 --- a/agent/run/memory_conformance_test.go +++ b/agent/run/memory_conformance_test.go @@ -12,8 +12,5 @@ import ( // Dependency chain: run_test -> runtimetest -> run (the net/http/httptest // layout), so the loop is broken by the external test package. func TestMemoryRuntimeConformance(t *testing.T) { - runtimetest.RunConformance(t, func(t testing.TB, initial run.MachineState) run.Runtime { - t.Helper() - return run.NewMemoryRuntime(initial) - }) + runtimetest.RunConformance(t, func() run.Runtime { return run.NewMemoryRuntime() }) } diff --git a/agent/run/memory_runtime.go b/agent/run/memory_runtime.go index 8d7dbc3..7eac264 100644 --- a/agent/run/memory_runtime.go +++ b/agent/run/memory_runtime.go @@ -9,52 +9,103 @@ import ( "sync" ) -// MemoryRuntime is the in-process reference Runtime: mutex + MachineState + -// TransitionRecord log (spec §8.1). The MachineState is the execution -// authority; the transition log is the same-transaction canonical record -// (audit, projections, verified import). It is the conformance -// reference; it does not survive the process and does not store product -// history. +// MemoryRuntime is the in-process reference Runtime. Its collection lock is +// used only to create or find stable per-Run entries; each entry has an +// independent lock for state, canonical commit records, revision, and grants. +// Runtime operations are context-aware before and after acquiring a lock so a +// request cancelled while waiting never reads or writes an entry. type MemoryRuntime struct { - mu sync.Mutex - state MachineState - revision uint64 - // initial is the Revision-0 state; Rebuild folds the log from it. - initial MachineState - // watermark records the last committed revision for the optional Rebuild - // diagnostic; it is not a protocol guard. It advances with every - // commit and is never cleared by a rebuild (spec §5.1). - watermark uint64 - // transitions keyed by CommandID: the full transition record for idempotency. + mu sync.RWMutex + runs map[RunID]*memoryRun +} + +type memoryRun struct { + mu sync.Mutex + header RunHeader + state MachineState + revision uint64 + initial MachineState + watermark uint64 transitions map[CommandID]TransitionRecord - // log holds every transition in Revision order for replay. - log []TransitionRecord - // occupancy: live grants per target (one model step or one call). - grants map[string]ExecutionGrant + log []TransitionRecord + grants map[string]ExecutionGrant } -// NewMemoryRuntime starts from an InitializeRun/Initialize-produced state at Revision 0. -// -//nolint:gocritic // hugeParam: constructor takes a value snapshot and clones it into runtime authority storage. -func NewMemoryRuntime(initial MachineState) *MemoryRuntime { - frozenInitial := cloneMachineState(&initial) - return &MemoryRuntime{ - state: cloneMachineState(&frozenInitial), - initial: frozenInitial, +// NewMemoryRuntime creates an empty, RunID-addressed in-process Runtime. +func NewMemoryRuntime() *MemoryRuntime { + return &MemoryRuntime{runs: make(map[RunID]*memoryRun)} +} + +func (m *MemoryRuntime) entry(runID RunID) (*memoryRun, error) { + if m == nil { + return nil, errors.New("agent: memory runtime: nil runtime") + } + m.mu.RLock() + entry := m.runs[runID] + m.mu.RUnlock() + if entry == nil { + return nil, ErrRunNotFound + } + return entry, nil +} + +func (m *MemoryRuntime) Create(ctx context.Context, run NewRun) (CreateResult, error) { + if err := checkContext(ctx); err != nil { + return CreateResult{}, err + } + header, err := BuildRunHeaderFromNewRun(run) + if err != nil { + return CreateResult{}, err + } + if m == nil { + return CreateResult{}, errors.New("agent: memory runtime: nil runtime") + } + + m.mu.Lock() + defer m.mu.Unlock() + if err := checkContext(ctx); err != nil { + return CreateResult{}, err + } + if existing := m.runs[run.RunID]; existing != nil { + // Header is immutable after admission, so the collection lock protects + // this lookup without taking the entry's execution lock. + existingHeader := cloneRunHeader(existing.header) + equal, err := canonicalHeadersEqual(existingHeader, header) + if err != nil { + return CreateResult{}, err + } + if !equal { + return CreateResult{}, ErrCreateConflict + } + return CreateResult{Header: existingHeader, Created: false}, nil + } + + stored := cloneRunHeader(header) + initial := cloneMachineState(&stored.InitialState) + m.runs[run.RunID] = &memoryRun{ + header: stored, + state: cloneMachineState(&initial), + initial: initial, transitions: make(map[CommandID]TransitionRecord), grants: make(map[string]ExecutionGrant), } + return CreateResult{Header: cloneRunHeader(stored), Created: true}, nil } -func (m *MemoryRuntime) Load(ctx context.Context) (RuntimeSnapshot, error) { - if err := ctx.Err(); err != nil { +func (m *MemoryRuntime) Load(ctx context.Context, runID RunID) (RuntimeSnapshot, error) { + if err := checkContext(ctx); err != nil { return RuntimeSnapshot{}, err } - m.mu.Lock() - defer m.mu.Unlock() - // Deep copy: returned snapshots are read-only views; caller mutation must - // never reach authoritative storage (spec appendix A). - return RuntimeSnapshot{State: cloneMachineState(&m.state), Revision: m.revision}, nil + entry, err := m.entry(runID) + if err != nil { + return RuntimeSnapshot{}, err + } + entry.mu.Lock() + defer entry.mu.Unlock() + if err := checkContext(ctx); err != nil { + return RuntimeSnapshot{}, err + } + return RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision}, nil } func grantKey(c AgentCommand) string { @@ -88,47 +139,45 @@ func newGrant() ExecutionGrant { return ExecutionGrant(hex.EncodeToString(b[:])) } - +// Commit atomically evaluates and writes the Run addressed by Command.RunID. +// //nolint:gocritic // hugeParam: CommitRequest is the value DTO of the Runtime authority boundary. func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitResult, error) { - if err := ctx.Err(); err != nil { + if err := checkContext(ctx); err != nil { + return CommitResult{}, err + } + entry, err := m.entry(req.Command.RunID) + if err != nil { + return CommitResult{}, err + } + entry.mu.Lock() + defer entry.mu.Unlock() + if err := checkContext(ctx); err != nil { return CommitResult{}, err } - m.mu.Lock() - defer m.mu.Unlock() key := grantKey(req.Command.Command) - grantValid := false - if key != "" && req.Grant != "" { - grantValid = m.grants[key] == req.Grant - } - // MemoryRuntime has no lease expiry: a grantless recovery command is only - // valid when no live occupancy exists for the target (the worker died with - // the process, which memory state does not survive; recoveryValid mainly - // serves conformance tests). + grantValid := key != "" && req.Grant != "" && entry.grants[key] == req.Grant recoveryValid := false if key != "" && req.Grant == "" { - _, occupied := m.grants[key] + _, occupied := entry.grants[key] recoveryValid = !occupied } var prior *TransitionRecord - if record, ok := m.transitions[req.Command.ID]; ok { - priorRecord := record - prior = &priorRecord + if record, ok := entry.transitions[req.Command.ID]; ok { + copy := cloneTransitionRecord(&record) + prior = © } - decision, err := EvaluateCommit(m.state, m.revision, prior, req, grantValid, recoveryValid) + decision, err := EvaluateCommit(entry.state, entry.revision, prior, req, grantValid, recoveryValid) if err != nil { return CommitResult{}, err } switch decision.Kind { case DecisionAlreadyApplied: - // Replay never re-grants execution (spec §5.4). - return CommitResult{ - Status: CommitAlreadyApplied, - Snapshot: RuntimeSnapshot{State: cloneMachineState(&m.state), Revision: m.revision}, - Events: cloneEvents(decision.Events), - }, nil + return CommitResult{Status: CommitAlreadyApplied, + Snapshot: RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision}, + Events: cloneEvents(decision.Events)}, nil case DecisionConflict: return CommitResult{}, ErrCommandConflict case DecisionStale: @@ -140,50 +189,66 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe return CommitResult{}, ErrRunTerminal } - // DecisionApply: persist the new authoritative state and the canonical - // transition record in the same critical section (spec §5.1). stored := cloneTransitionRecord(&decision.Transition) - m.state = cloneMachineState(&decision.NewState) - m.revision++ - m.watermark = m.revision - m.transitions[req.Command.ID] = stored - m.log = append(m.log, stored) + entry.state = cloneMachineState(&decision.NewState) + entry.revision++ + entry.watermark = entry.revision + entry.transitions[req.Command.ID] = stored + entry.log = append(entry.log, stored) var minted ExecutionGrant switch req.Command.Command.(type) { case StartModelExecution, StartToolCall: minted = newGrant() - m.grants[key] = minted + entry.grants[key] = minted case SubmitModelResult, SubmitModelFailure, RejectModelResult, RecoverModelExecution, SubmitToolResult, SubmitToolFailure: - delete(m.grants, key) + delete(entry.grants, key) } - if m.state.Status.Terminal() { - // Terminal invalidates every outstanding grant (spec §3.7.3). - m.grants = make(map[string]ExecutionGrant) + if entry.state.Status.Terminal() { + entry.grants = make(map[string]ExecutionGrant) } - - return CommitResult{ - Status: CommitAccepted, - Snapshot: RuntimeSnapshot{State: cloneMachineState(&m.state), Revision: m.revision}, - Events: cloneEvents(stored.Events), - Grant: minted, - }, nil -} - -// Events returns a deep copy of the flattened event stream in (Revision, Index) -// order. Test and replay helper; not part of the Runtime contract. -func (m *MemoryRuntime) Events() []AgentEvent { - m.mu.Lock() - defer m.mu.Unlock() - return flattenTransitionRecords(m.log) + return CommitResult{Status: CommitAccepted, + Snapshot: RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision}, + Events: cloneEvents(stored.Events), Grant: minted}, nil } -// Transitions returns a deep copy of the authoritative transition log in -// Revision order. Test and durable-runtime helper; not part of the Runtime -// contract. -func (m *MemoryRuntime) Transitions() []TransitionRecord { - m.mu.Lock() - defer m.mu.Unlock() - return cloneTransitionRecords(m.log) +// Record returns a detached consistent point-in-time snapshot and verifies it +// against the immutable header and complete transition records. +func (m *MemoryRuntime) Record(ctx context.Context, runID RunID) (RunRecord, error) { + if err := checkContext(ctx); err != nil { + return RunRecord{}, err + } + entry, err := m.entry(runID) + if err != nil { + return RunRecord{}, err + } + entry.mu.Lock() + if err := checkContext(ctx); err != nil { + entry.mu.Unlock() + return RunRecord{}, err + } + header := cloneRunHeader(entry.header) + snapshot := RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision} + transitions := cloneTransitionRecords(entry.log) + entry.mu.Unlock() + if err := checkContext(ctx); err != nil { + return RunRecord{}, err + } + if err := ValidateRunHeader(&header); err != nil { + return RunRecord{}, fmt.Errorf("agent: memory runtime: invalid header: %w", err) + } + for i := range transitions { + if err := ValidateTransitionRecord(&transitions[i]); err != nil { + return RunRecord{}, fmt.Errorf("agent: memory runtime: invalid transition %d: %w", i, err) + } + } + folded, revision, err := FoldRun(&header, transitions) + if err != nil { + return RunRecord{}, fmt.Errorf("agent: memory runtime: fold: %w", err) + } + if revision != snapshot.Revision || !statesEquivalent(&folded, &snapshot.State) { + return RunRecord{}, errors.New("agent: memory runtime: snapshot diverges from transition log") + } + return RunRecord{Header: cloneRunHeader(header), Snapshot: cloneRuntimeSnapshot(snapshot), Transitions: cloneTransitionRecords(transitions)}, nil } diff --git a/agent/run/rebuild.go b/agent/run/rebuild.go index 782f242..f1479df 100644 --- a/agent/run/rebuild.go +++ b/agent/run/rebuild.go @@ -82,9 +82,9 @@ func FoldEvents(initial MachineState, events []AgentEvent) (MachineState, uint64 return state, revision, nil } -// FoldTransitions rebuilds a MachineState by folding authoritative transition -// records from the immutable initial state. The source of truth is the -// admission-created initial state plus the complete TransitionRecord log. +// FoldTransitions rebuilds a MachineState from the immutable initial state +// and complete TransitionRecord sequence. Together they are the canonical +// diagnostic commit record for replay and consistency checks. // //nolint:gocritic // hugeParam: public replay API folds from an initial value state without mutating caller-owned state. func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineState, uint64, error) { @@ -127,20 +127,24 @@ func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineS // gap). It returns true when the refolded state differed from the stored // state — with a correct implementation this never happens, so a true return // is an audit signal (Evolve bug, out-of-band write, or storage corruption). -func (m *MemoryRuntime) Rebuild() (rebuilt bool, err error) { - m.mu.Lock() - defer m.mu.Unlock() +func (m *MemoryRuntime) Rebuild(runID RunID) (rebuilt bool, err error) { + entry, err := m.entry(runID) + if err != nil { + return false, err + } + entry.mu.Lock() + defer entry.mu.Unlock() - folded, maxRevision, err := FoldTransitions(cloneMachineState(&m.initial), m.log) + folded, maxRevision, err := FoldTransitions(cloneMachineState(&entry.initial), entry.log) if err != nil { return false, err } - if maxRevision < m.watermark { - return false, fmt.Errorf("%w: log ends at %d, watermark %d", ErrLogTruncated, maxRevision, m.watermark) + if maxRevision < entry.watermark { + return false, fmt.Errorf("%w: log ends at %d, watermark %d", ErrLogTruncated, maxRevision, entry.watermark) } - diverged := m.revision != maxRevision || !statesEquivalent(&m.state, &folded) - m.state = folded - m.revision = maxRevision + diverged := entry.revision != maxRevision || !statesEquivalent(&entry.state, &folded) + entry.state = folded + entry.revision = maxRevision return diverged, nil } diff --git a/agent/run/rebuild_test.go b/agent/run/rebuild_test.go index 629cd48..be4be4a 100644 --- a/agent/run/rebuild_test.go +++ b/agent/run/rebuild_test.go @@ -8,9 +8,10 @@ import ( "github.com/memohai/twilight/sdk" ) -// Event-sourcing arbitration tests (spec §5.1): the log is the source of -// truth; the snapshot is a rebuildable same-transaction projection; the -// revision watermark witnesses log-tail completeness. +// Event-sourcing arbitration tests (spec §5.1): the complete canonical +// TransitionRecord is the diagnostic commit record; the snapshot is a +// rebuildable same-transaction projection; the revision watermark witnesses +// log-tail completeness. // fullRunRuntime drives one complete run (prepare -> model -> tool -> done) // and returns the runtime. @@ -20,19 +21,23 @@ func fullRunRuntime(t *testing.T) *MemoryRuntime { spec := makeSpec(t, def, DirectExecution) rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) b := makeBinding(t, "c1", spec, `{}`) - res := mustCommit(t, rt, "complete-1", 2, grant, + snap, err := rt.Load(context.Background(), "run-1") + if err != nil { + t.Fatal(err) + } + res := mustCommit(t, rt, "complete-1", snap.Revision, grant, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) toolStep := res.Events[1].Fact.(ToolStepOpened).StepID sRes := mustCommit(t, rt, "start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: "c1"}) mustCommit(t, rt, "done-c1", sRes.Snapshot.Revision, sRes.Grant, SubmitToolResult{StepID: toolStep, CallID: "c1", Result: ToolExecutionResult{Output: cj(`"ok"`)}}) - mustCommit(t, rt, DeriveModelRequestCommandID("run-1", 5), 5, "", - func() PrepareModelRequest { - snap, _ := rt.Load(context.Background()) - prep, _ := buildPrepareFromSnap(t, snap, testRequest(), nil) - return prep - }()) - start := mustCommit(t, rt, "start-2", 6, "", StartModelExecution{StepID: currentStepID(t, rt)}) + snap, err = rt.Load(context.Background(), "run-1") + if err != nil { + t.Fatal(err) + } + prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) + prepared := mustCommit(t, rt, cmdID, snap.Revision, "", prep) + start := mustCommit(t, rt, "start-2", prepared.Snapshot.Revision, "", StartModelExecution{StepID: currentStepID(t, rt)}) final, err := FreezeModelResult(sdk.ModelResult{Text: "final", FinishReason: sdk.FinishReasonStop}) if err != nil { t.Fatal(err) @@ -44,7 +49,7 @@ func fullRunRuntime(t *testing.T) *MemoryRuntime { func currentStepID(t *testing.T, rt *MemoryRuntime) StepID { t.Helper() - snap, err := rt.Load(context.Background()) + snap, err := rt.Load(context.Background(), "run-1") if err != nil { t.Fatal(err) } @@ -58,15 +63,15 @@ func currentStepID(t *testing.T, rt *MemoryRuntime) StepID { // implementation the arbitration branch never fires. func TestRebuildHealthyIsNoop(t *testing.T) { rt := fullRunRuntime(t) - before, _ := rt.Load(context.Background()) - diverged, err := rt.Rebuild() + before, _ := rt.Load(context.Background(), "run-1") + diverged, err := rt.Rebuild("run-1") if err != nil { t.Fatal(err) } if diverged { t.Fatal("healthy runtime reported divergence on rebuild") } - after, _ := rt.Load(context.Background()) + after, _ := rt.Load(context.Background(), "run-1") if !statesEquivalent(&before.State, &after.State) || before.Revision != after.Revision { t.Fatal("rebuild changed a healthy state") } @@ -76,23 +81,24 @@ func TestRebuildHealthyIsNoop(t *testing.T) { // reported for audit. func TestRebuildRepairsCorruptedSnapshot(t *testing.T) { rt := fullRunRuntime(t) - want, _ := rt.Load(context.Background()) + want, _ := rt.Load(context.Background(), "run-1") // Out-of-band write: corrupt the authoritative snapshot directly. - rt.mu.Lock() - rt.state.ModelSteps = 99 - rt.state.Status = RunActive - rt.state.Result = nil - rt.mu.Unlock() + entry := memoryEntry(t, rt) + entry.mu.Lock() + entry.state.ModelSteps = 99 + entry.state.Status = RunActive + entry.state.Result = nil + entry.mu.Unlock() - diverged, err := rt.Rebuild() + diverged, err := rt.Rebuild("run-1") if err != nil { t.Fatal(err) } if !diverged { t.Fatal("rebuild did not report the repaired divergence") } - got, _ := rt.Load(context.Background()) + got, _ := rt.Load(context.Background(), "run-1") if !statesEquivalent(&want.State, &got.State) { t.Fatal("rebuild did not restore the log-derived state") } @@ -105,13 +111,14 @@ func TestRebuildRepairsCorruptedSnapshot(t *testing.T) { // are gone and continuing would repeat gated executions. func TestRebuildHaltsOnTruncatedTail(t *testing.T) { rt := fullRunRuntime(t) - rt.mu.Lock() + entry := memoryEntry(t, rt) + entry.mu.Lock() // Simulate selective damage: drop the last transition while the watermark // (separate storage in a durable adapter) survives. - rt.log = cloneTransitionRecords(rt.log[:len(rt.log)-1]) - rt.mu.Unlock() + entry.log = cloneTransitionRecords(entry.log[:len(entry.log)-1]) + entry.mu.Unlock() - _, err := rt.Rebuild() + _, err := rt.Rebuild("run-1") if !errors.Is(err, ErrLogTruncated) { t.Fatalf("err = %v, want ErrLogTruncated", err) } @@ -119,15 +126,16 @@ func TestRebuildHaltsOnTruncatedTail(t *testing.T) { func TestRebuildHaltsOnPartialTailTransition(t *testing.T) { rt := fullRunRuntime(t) - rt.mu.Lock() - last := &rt.log[len(rt.log)-1] + entry := memoryEntry(t, rt) + entry.mu.Lock() + last := &entry.log[len(entry.log)-1] if len(last.Events) < 2 { t.Fatal("test requires a multi-event tail transition") } last.Events = last.Events[:len(last.Events)-1] - rt.mu.Unlock() + entry.mu.Unlock() - _, err := rt.Rebuild() + _, err := rt.Rebuild("run-1") if err == nil { t.Fatal("partial tail transition folded silently") } @@ -136,17 +144,18 @@ func TestRebuildHaltsOnPartialTailTransition(t *testing.T) { // A gap in the middle of the transition log is log damage, not a rebuild input. func TestFoldTransitionsRejectsInteriorGap(t *testing.T) { rt := fullRunRuntime(t) - rt.mu.Lock() + entry := memoryEntry(t, rt) + entry.mu.Lock() var holed []TransitionRecord - for i := range rt.log { - if rt.log[i].Revision == 3 { // drop one interior transition + for i := range entry.log { + if entry.log[i].Revision == 3 { // drop one interior transition continue } - holed = append(holed, cloneTransitionRecord(&rt.log[i])) + holed = append(holed, cloneTransitionRecord(&entry.log[i])) } log := holed - initial := cloneMachineState(&rt.initial) - rt.mu.Unlock() + initial := cloneMachineState(&entry.initial) + entry.mu.Unlock() if _, _, err := FoldTransitions(initial, log); err == nil { t.Fatal("interior transition gap folded silently") @@ -156,10 +165,11 @@ func TestFoldTransitionsRejectsInteriorGap(t *testing.T) { // A gap in the middle of a complete flat event stream is still rejected. func TestFoldEventsRejectsInteriorGap(t *testing.T) { rt := fullRunRuntime(t) - rt.mu.Lock() - flat := flattenTransitionRecords(rt.log) - initial := cloneMachineState(&rt.initial) - rt.mu.Unlock() + entry := memoryEntry(t, rt) + entry.mu.Lock() + flat := flattenTransitionRecords(entry.log) + initial := cloneMachineState(&entry.initial) + entry.mu.Unlock() var holed []AgentEvent for i := range flat { @@ -175,10 +185,11 @@ func TestFoldEventsRejectsInteriorGap(t *testing.T) { func TestFoldRejectsRunIDMismatch(t *testing.T) { rt := fullRunRuntime(t) - rt.mu.Lock() - log := flattenTransitionRecords(rt.log) - initial := cloneMachineState(&rt.initial) - rt.mu.Unlock() + entry := memoryEntry(t, rt) + entry.mu.Lock() + log := flattenTransitionRecords(entry.log) + initial := cloneMachineState(&entry.initial) + entry.mu.Unlock() log[0].RunID = "other-run" if _, _, err := FoldEvents(initial, log); err == nil { @@ -188,13 +199,14 @@ func TestFoldRejectsRunIDMismatch(t *testing.T) { func TestFoldRejectsTransitionCommandIdentityChange(t *testing.T) { rt := fullRunRuntime(t) - rt.mu.Lock() - log := flattenTransitionRecords(rt.log) - initial := cloneMachineState(&rt.initial) - rt.mu.Unlock() + entry := memoryEntry(t, rt) + entry.mu.Lock() + log := flattenTransitionRecords(entry.log) + initial := cloneMachineState(&entry.initial) + entry.mu.Unlock() for i := range log { - if log[i].Revision == 3 && log[i].Index == 1 { + if log[i].Index == 1 { log[i].CommandID = "other-command" break } @@ -206,10 +218,11 @@ func TestFoldRejectsTransitionCommandIdentityChange(t *testing.T) { func TestFoldRejectsUnsupportedSchemaVersion(t *testing.T) { rt := fullRunRuntime(t) - rt.mu.Lock() - log := flattenTransitionRecords(rt.log) - initial := cloneMachineState(&rt.initial) - rt.mu.Unlock() + entry := memoryEntry(t, rt) + entry.mu.Lock() + log := flattenTransitionRecords(entry.log) + initial := cloneMachineState(&entry.initial) + entry.mu.Unlock() log[0].SchemaVersion = 99 digest, err := DigestFact(log[0].SchemaVersion, log[0].Type, log[0].Fact) @@ -225,10 +238,11 @@ func TestFoldRejectsUnsupportedSchemaVersion(t *testing.T) { // A tampered fact fails its digest check during fold. func TestFoldRejectsTamperedFact(t *testing.T) { rt := fullRunRuntime(t) - rt.mu.Lock() - log := flattenTransitionRecords(rt.log) - initial := cloneMachineState(&rt.initial) - rt.mu.Unlock() + entry := memoryEntry(t, rt) + entry.mu.Lock() + log := flattenTransitionRecords(entry.log) + initial := cloneMachineState(&entry.initial) + entry.mu.Unlock() for i := range log { if f, ok := log[i].Fact.(ToolCallCompleted); ok { @@ -270,19 +284,23 @@ func TestRegressionPreparedFactSelfContained(t *testing.T) { // the same commit that changes the protocol). func TestGoldenEventStreamV1(t *testing.T) { rt := fullRunRuntime(t) - folded, maxRev, err := FoldTransitions(cloneMachineState(&rt.initial), rt.Transitions()) + record, err := rt.Record(context.Background(), "run-1") + if err != nil { + t.Fatal(err) + } + folded, maxRev, err := FoldTransitions(cloneMachineState(&record.Header.InitialState), record.Transitions) if err != nil { t.Fatal(err) } - if maxRev != 8 { - t.Fatalf("golden stream has %d transitions, want 8", maxRev) + if maxRev != 9 { + t.Fatalf("golden stream has %d transitions, want 9", maxRev) } stateBytes, err := marshalCanonical(stateComparable(&folded)) if err != nil { t.Fatal(err) } got := string(sha256Digest(stateBytes)) - const frozen = "sha256:7b95310e5132ee6490526c15804e2cb22efe14cd60e9f9320b1c14ecbead687a" + const frozen = "sha256:5ab6b883663a76e3293bf5dded726ae4cfdcfc50cc957b5b72fcf7d677e648ed" if got != frozen { t.Fatalf("golden v1 state digest changed:\n got %s\nwant %s\nstate: %s", got, frozen, stateBytes) } diff --git a/agent/run/regression_test.go b/agent/run/regression_test.go index e279c0d..0e2b355 100644 --- a/agent/run/regression_test.go +++ b/agent/run/regression_test.go @@ -120,7 +120,7 @@ func TestRegressionToolPanicBecomesUnknown(t *testing.T) { loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) - res, err := loop.Run(context.Background(), rt, nil) + res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } @@ -186,7 +186,11 @@ func TestRegressionRuntimeReturnsAreIsolated(t *testing.T) { spec := makeSpec(t, def, ApprovalRequired) rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) b := makeBinding(t, "c1", spec, `{"k":"original"}`) - res := mustCommit(t, rt, "complete-1", 2, grant, + snap, err := rt.Load(context.Background(), "run-1") + if err != nil { + t.Fatal(err) + } + res := mustCommit(t, rt, "complete-1", snap.Revision, grant, SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("t", `{"k":"original"}`, "c1"), Calls: []ToolCallBinding{b}}) // Mutate byte views returned from immutable CanonicalJSON values. @@ -194,18 +198,18 @@ func TestRegressionRuntimeReturnsAreIsolated(t *testing.T) { argBytes := opened.Calls[0].Arguments.RawMessage() argBytes[2] = 'X' // Mutate the snapshot's waiting payload view. - snap, _ := rt.Load(context.Background()) + snap, _ = rt.Load(context.Background(), "run-1") ts := snap.State.Current.(ToolStep) payloadBytes := ts.Calls[0].Waiting.Payload.RawMessage() payloadBytes[2] = 'Y' // Authority must be unchanged: reload and verify the argument bytes. - fresh, _ := rt.Load(context.Background()) + fresh, _ := rt.Load(context.Background(), "run-1") got := fresh.State.Current.(ToolStep).Calls[0].Arguments if got.String() != `{"k":"original"}` { t.Fatalf("authoritative arguments mutated through a returned view: %s", got.String()) } - stored := rt.Events() + stored := recordEvents(t, rt, "run-1") for _, e := range stored { if f, ok := e.Fact.(ToolStepOpened); ok { if f.Calls[0].Arguments.String() != `{"k":"original"}` { @@ -249,7 +253,7 @@ func TestRegressionRunFinishedEmitted(t *testing.T) { }) loop, _ := NewLoop(fakeCatalog{&fakeInvoker{results: []sdk.ModelResult{textResult("done")}}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) - if _, err := loop.Run(context.Background(), rt, sink); err != nil { + if _, err := loop.Run(context.Background(), rt, "run-1", sink); err != nil { t.Fatal(err) } for _, k := range kinds { @@ -292,7 +296,7 @@ func TestRegressionAliasedToolRefExecutes(t *testing.T) { loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"fs.read": tool}}, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) - res, err := loop.Run(context.Background(), rt, nil) + res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } @@ -321,7 +325,7 @@ func TestRegressionCancelReasonFixed(t *testing.T) { func TestRegressionStreamNilResult(t *testing.T) { rt := loopRuntime(t) loop, _ := NewLoop(fakeCatalog{nilResultStreamer{}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, true) - res, err := loop.Run(context.Background(), rt, nil) + res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } diff --git a/agent/run/run_creation.go b/agent/run/run_creation.go new file mode 100644 index 0000000..ba44479 --- /dev/null +++ b/agent/run/run_creation.go @@ -0,0 +1,143 @@ +package run + +import ( + "bytes" + "context" + "errors" + "fmt" + "unicode/utf8" + + "github.com/memohai/twilight/agent/es" +) + +// NewRun is the immutable, versioned creation data for a Run. RunID is +// caller-supplied so retries retain a stable identity. +type NewRun struct { + SchemaVersion uint16 `json:"schemaVersion"` + RunID RunID `json:"runId"` + CausationID es.CausationID `json:"causationId,omitempty"` +} + +// BuildNewRun constructs a current-version Run creation value. +func BuildNewRun(runID RunID, causationID es.CausationID) (NewRun, error) { + run := NewRun{SchemaVersion: SchemaVersion1, RunID: runID, 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.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 +} + +// BuildRunHeaderFromNewRun constructs Revision 0 using the explicit v1 rule. +// This dispatch must not use currentSchemaVersion: later command schemas must +// never change the bytes admitted by a v1 NewRun. +func BuildRunHeaderFromNewRun(run NewRun) (RunHeader, error) { + if err := ValidateNewRun(run); err != nil { + return RunHeader{}, err + } + switch run.SchemaVersion { + case SchemaVersion1: + return buildRunHeaderV1(run) + default: + return RunHeader{}, fmt.Errorf("agent: new run: unsupported schema version %d", run.SchemaVersion) + } +} + +const newRunV1InitialStateVersion uint16 = 1 + +func buildRunHeaderV1(run NewRun) (RunHeader, error) { + initial := MachineState{RunID: run.RunID, Status: RunActive} + stateBytes, err := encodeMachineStateWire(&initial) + if err != nil { + return RunHeader{}, err + } + header := RunHeader{ + SchemaVersion: SchemaVersion1, + RunID: run.RunID, + InitialStateVersion: newRunV1InitialStateVersion, + InitialState: initial, + InitialStateDigest: sha256Digest(stateBytes), + CausationID: run.CausationID, + } + header.HeaderDigest, err = digestRunHeader(&header) + if err != nil { + return RunHeader{}, err + } + if err := ValidateRunHeader(&header); err != nil { + return RunHeader{}, err + } + return header, nil +} + +// CreateResult is the outcome of conditionally creating one Run. +type CreateResult struct { + Header RunHeader `json:"header"` + Created bool `json:"created"` +} + +// RunRecord is one consistent, verified read of a Run. +type RunRecord struct { + Header RunHeader `json:"header"` + Snapshot RuntimeSnapshot `json:"snapshot"` + Transitions []TransitionRecord `json:"transitions"` +} + +var ( + // ErrCreateConflict reports that a RunID is already associated with a + // different canonical Revision-0 header. + ErrCreateConflict = errors.New("agent: run create conflict") + // ErrRunNotFound reports an operation addressed a RunID not in this Runtime. + ErrRunNotFound = errors.New("agent: run not found") +) + +func cloneRuntimeSnapshot(snapshot RuntimeSnapshot) RuntimeSnapshot { + return RuntimeSnapshot{State: cloneMachineState(&snapshot.State), Revision: snapshot.Revision} +} + +func cloneRunHeader(header RunHeader) RunHeader { + header.InitialState = cloneMachineState(&header.InitialState) + return header +} + +func canonicalHeadersEqual(left, right RunHeader) (bool, error) { + if err := ValidateRunHeader(&left); err != nil { + return false, err + } + if err := ValidateRunHeader(&right); err != nil { + return false, err + } + leftBytes, err := marshalCanonical(left) + if err != nil { + return false, err + } + rightBytes, err := marshalCanonical(right) + if err != nil { + return false, err + } + return bytes.Equal(leftBytes, rightBytes), nil +} + +// 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() +} diff --git a/agent/run/runtime_creation_test.go b/agent/run/runtime_creation_test.go new file mode 100644 index 0000000..960a744 --- /dev/null +++ b/agent/run/runtime_creation_test.go @@ -0,0 +1,215 @@ +package run + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + + "github.com/memohai/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 mustCreateRun(t testing.TB, rt Runtime, id RunID) { + t.Helper() + if _, err := rt.Create(context.Background(), mustNewRun(t, id, "session-1")); err != nil { + t.Fatal(err) + } +} + +func acceptInput(t testing.TB, rt Runtime, id RunID, input AgentInput) CommitResult { + t.Helper() + snapshot, err := rt.Load(context.Background(), id) + if err != nil { + t.Fatal(err) + } + envelope, err := BuildEnvelope(id, DeriveInputCommandID(id, input.ID), AcceptInput{Input: input}) + if err != nil { + t.Fatal(err) + } + result, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: snapshot.Revision, Command: envelope}) + if err != nil { + t.Fatal(err) + } + return result +} + +func TestNewRunValidationAndV1HeaderGolden(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) + } + } + header, err := BuildRunHeaderFromNewRun(created) + if err != nil { + t.Fatal(err) + } + if header.InitialStateDigest != "sha256:6dd6e9f67d4b9e6c1d2c72d20a95276de2a8c18453a5cbea3f5d602087770468" || + header.HeaderDigest != "sha256:f51f1136690ef97ea4fef7385f76b48ec41933fc66f30283df673bafce64367d" { + t.Fatalf("v1 header changed: %+v", header) + } +} + +func TestRuntimeCreateIdempotentAndConflict(t *testing.T) { + rt := NewMemoryRuntime() + first, err := rt.Create(context.Background(), mustNewRun(t, "run-1", "session-1")) + if err != nil || !first.Created { + t.Fatalf("first Create = %+v, %v", first, err) + } + first.Header.InitialState.RunID = "mutated" + second, err := rt.Create(context.Background(), mustNewRun(t, "run-1", "session-1")) + if err != nil || second.Created || second.Header.InitialState.RunID != "run-1" { + t.Fatalf("idempotent Create = %+v, %v", second, err) + } + if _, err := rt.Create(context.Background(), mustNewRun(t, "run-1", "session-2")); !errors.Is(err, ErrCreateConflict) { + t.Fatalf("conflict error = %v", err) + } +} + +func TestRuntimeMissingRun(t *testing.T) { + rt := NewMemoryRuntime() + if _, err := rt.Load(context.Background(), "missing"); !errors.Is(err, ErrRunNotFound) { + t.Fatalf("Load error = %v", err) + } + if _, err := rt.Record(context.Background(), "missing"); !errors.Is(err, ErrRunNotFound) { + t.Fatalf("Record error = %v", err) + } + env, err := BuildEnvelope("missing", "command-1", CancelRun{}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(context.Background(), CommitRequest{Command: env}); !errors.Is(err, ErrRunNotFound) { + t.Fatalf("Commit error = %v", err) + } +} + +func TestRuntimeRecordFoldAndIsolation(t *testing.T) { + rt := NewMemoryRuntime() + mustCreateRun(t, rt, "run-1") + zero, err := rt.Record(context.Background(), "run-1") + if err != nil || zero.Snapshot.Revision != 0 || len(zero.Transitions) != 0 { + t.Fatalf("revision-zero record = %+v, %v", zero, err) + } + acceptInput(t, rt, "run-1", AgentInput{ID: "in-1", Payload: MustParseCanonicalJSON(`{"q":"hi"}`)}) + record, err := rt.Record(context.Background(), "run-1") + if err != nil { + t.Fatal(err) + } + folded, revision, err := FoldRun(&record.Header, record.Transitions) + if err != nil || revision != record.Snapshot.Revision || !statesEquivalent(&folded, &record.Snapshot.State) { + t.Fatalf("record fold = revision %d record %+v err %v", revision, record, err) + } + record.Header.InitialState.RunID = "mutated" + record.Snapshot.State.RunID = "mutated" + record.Transitions[0].Events[0].RunID = "mutated" + fresh, err := rt.Record(context.Background(), "run-1") + if err != nil || fresh.Header.InitialState.RunID != "run-1" || fresh.Snapshot.State.RunID != "run-1" || fresh.Transitions[0].Events[0].RunID != "run-1" { + t.Fatalf("record aliases authority: %+v, %v", fresh, err) + } +} + +func TestRuntimeConcurrentCreateAndRunIsolation(t *testing.T) { + rt := NewMemoryRuntime() + newRun := mustNewRun(t, "run-1", "session-1") + const callers = 32 + results := make(chan CreateResult, callers) + errs := make(chan error, callers) + var group sync.WaitGroup + for range callers { + group.Add(1) + go func() { + defer group.Done() + result, err := rt.Create(context.Background(), newRun) + if err != nil { + errs <- err + return + } + results <- result + }() + } + group.Wait() + close(results) + close(errs) + created := 0 + for err := range errs { + t.Fatal(err) + } + for result := range results { + if result.Created { + created++ + } + } + if created != 1 { + t.Fatalf("created = %d", created) + } + mustCreateRun(t, rt, "run-2") + acceptInput(t, rt, "run-1", AgentInput{ID: "one", Payload: MustParseCanonicalJSON(`1`)}) + acceptInput(t, rt, "run-2", AgentInput{ID: "two", Payload: MustParseCanonicalJSON(`2`)}) + one, _ := rt.Load(context.Background(), "run-1") + two, _ := rt.Load(context.Background(), "run-2") + if len(one.State.PendingInputs) != 1 || one.State.PendingInputs[0].ID != "one" || len(two.State.PendingInputs) != 1 || two.State.PendingInputs[0].ID != "two" { + t.Fatalf("cross-run state leaked: one=%+v two=%+v", one.State.PendingInputs, two.State.PendingInputs) + } +} + +func TestRuntimeRecordConcurrentWithCommit(t *testing.T) { + rt := NewMemoryRuntime() + mustCreateRun(t, rt, "run-1") + const commits = 64 + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < commits; i++ { + input := AgentInput{ID: InputID(fmt.Sprintf("in-%d", i)), Payload: MustParseCanonicalJSON(`null`)} + for { + snapshot, err := rt.Load(context.Background(), "run-1") + if err != nil { + t.Errorf("Load: %v", err) + return + } + env, _ := BuildEnvelope("run-1", DeriveInputCommandID("run-1", input.ID), AcceptInput{Input: input}) + _, err = rt.Commit(context.Background(), CommitRequest{BaseRevision: snapshot.Revision, Command: env}) + if errors.Is(err, ErrStaleRuntime) { + continue + } + if err != nil { + t.Errorf("Commit: %v", err) + } + break + } + } + }() + for { + record, err := rt.Record(context.Background(), "run-1") + if err != nil { + t.Fatal(err) + } + folded, revision, err := FoldRun(&record.Header, record.Transitions) + if err != nil || revision != record.Snapshot.Revision || !statesEquivalent(&folded, &record.Snapshot.State) { + t.Fatalf("inconsistent record: rev=%d snapshot=%d err=%v", revision, record.Snapshot.Revision, err) + } + select { + case <-done: + return + default: + } + } +} diff --git a/agent/run/runtime_helpers_test.go b/agent/run/runtime_helpers_test.go index 10cf0d6..edafb95 100644 --- a/agent/run/runtime_helpers_test.go +++ b/agent/run/runtime_helpers_test.go @@ -10,13 +10,38 @@ import ( // Shared helpers for package-local agent tests. Runtime conformance lives in // agent/runtimetest so durable Runtime implementations can reuse it. -func newTestRuntime(t *testing.T, cfg RunConfig) *MemoryRuntime { +func newTestRuntime(t *testing.T, _ RunConfig) *MemoryRuntime { t.Helper() - s, err := Initialize("run-1", cfg, NextRun(AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)})) + rt := NewMemoryRuntime() + newRun, err := BuildNewRun("run-1", "") if err != nil { t.Fatal(err) } - return NewMemoryRuntime(s) + if _, err := rt.Create(context.Background(), newRun); err != nil { + t.Fatal(err) + } + // Seed inputs must enter through the public Runtime boundary so the + // fixture has the same Revision-0 header and transition history as a Run. + acceptInput(t, rt, "run-1", AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)}) + return rt +} + +func recordEvents(t testing.TB, rt Runtime, runID RunID) []AgentEvent { + t.Helper() + record, err := rt.Record(context.Background(), runID) + if err != nil { + t.Fatal(err) + } + return flattenTransitionRecords(record.Transitions) +} + +func memoryEntry(t testing.TB, rt *MemoryRuntime) *memoryRun { + t.Helper() + entry, err := rt.entry("run-1") + if err != nil { + t.Fatal(err) + } + return entry } func commitCmd(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) (CommitResult, error) { @@ -42,11 +67,11 @@ func mustCommit(t *testing.T, rt Runtime, id CommandID, base uint64, grant Execu func preparedRuntime(t *testing.T, tools []sdk.ToolDefinition, specs []ToolSpec) (*MemoryRuntime, StepID, ExecutionGrant) { t.Helper() rt := newTestRuntime(t, RunConfig{Model: "m-1", ModelRejectLimit: 2}) - snap, _ := rt.Load(context.Background()) + snap, _ := rt.Load(context.Background(), "run-1") req := testRequest(tools...) prep, cmdID := buildPrepareFromSnap(t, snap, req, specs) - mustCommit(t, rt, cmdID, snap.Revision, "", prep) - start := mustCommit(t, rt, "start-1", 1, "", StartModelExecution{StepID: prep.StepID}) + prepared := mustCommit(t, rt, cmdID, snap.Revision, "", prep) + start := mustCommit(t, rt, "start-1", prepared.Snapshot.Revision, "", StartModelExecution{StepID: prep.StepID}) if start.Grant == "" { t.Fatal("accepted start returned no grant") } @@ -94,7 +119,7 @@ func TestLoopPrepareRejectionDoesNotLivelock(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = loop.Run(context.Background(), rt, nil) + _, err = loop.Run(context.Background(), rt, "run-1", nil) if err == nil { t.Fatal("prepare livelock not surfaced") } diff --git a/agent/run/runtimetest/conformance.go b/agent/run/runtimetest/conformance.go index e79ac5a..ed9739d 100644 --- a/agent/run/runtimetest/conformance.go +++ b/agent/run/runtimetest/conformance.go @@ -3,9 +3,7 @@ // of copying MemoryRuntime-specific assertions: // // func TestMyRuntimeConformance(t *testing.T) { -// runtimetest.RunConformance(t, func(t testing.TB, initial run.MachineState) run.Runtime { -// return newMyRuntime(t, initial) -// }) +// runtimetest.RunConformance(t, func() run.Runtime { return newMyRuntime() }) // } // // The suite exercises only the public run API, so it holds for any Runtime @@ -19,19 +17,30 @@ import ( "context" "encoding/json" "errors" + "fmt" + "sync" "testing" "github.com/memohai/twilight/agent/run" "github.com/memohai/twilight/sdk" ) -// Factory constructs the Runtime under test from an already-initialized -// Revision-0 state. -type Factory func(testing.TB, run.MachineState) run.Runtime +// Factory constructs an empty Runtime under test. +type Factory func() run.Runtime // RunConformance executes the shared Runtime conformance suite. func RunConformance(t *testing.T, newRuntime Factory) { t.Helper() + t.Run("CreateReturnsValidDetachedHeader", func(t *testing.T) { testCreateReturnsValidDetachedHeader(t, newRuntime) }) + t.Run("CreateRetryAndConflict", func(t *testing.T) { testCreateRetryAndConflict(t, newRuntime) }) + t.Run("MissingRunOperations", func(t *testing.T) { testMissingRunOperations(t, newRuntime) }) + t.Run("RecordRevisionZero", func(t *testing.T) { testRecordRevisionZero(t, newRuntime) }) + t.Run("RecordFoldsAcceptedTransition", func(t *testing.T) { testRecordFoldsAcceptedTransition(t, newRuntime) }) + t.Run("ReturnedValuesAreDetached", func(t *testing.T) { testReturnedValuesAreDetached(t, newRuntime) }) + t.Run("ConcurrentCreate", func(t *testing.T) { testConcurrentCreate(t, newRuntime) }) + t.Run("RunsAreIsolated", func(t *testing.T) { testRunsAreIsolated(t, newRuntime) }) + t.Run("CrossRunGrantIsRejected", func(t *testing.T) { testCrossRunGrantIsRejected(t, newRuntime) }) + t.Run("RecordConcurrentWithCommit", func(t *testing.T) { testRecordConcurrentWithCommit(t, newRuntime) }) t.Run("IdempotentReplay", func(t *testing.T) { testIdempotentReplay(t, newRuntime) }) t.Run("RevisionAndIndex", func(t *testing.T) { testRevisionAndIndex(t, newRuntime) }) t.Run("StartGrantLifecycle", func(t *testing.T) { testStartGrantLifecycle(t, newRuntime) }) @@ -45,6 +54,314 @@ func RunConformance(t *testing.T, newRuntime Factory) { t.Run("DerivedCommandIDEnforced", func(t *testing.T) { testDerivedCommandIDEnforced(t, newRuntime) }) } +func testCreateReturnsValidDetachedHeader(t *testing.T, newRuntime Factory) { + rt := newRuntime() + newRun, err := run.BuildNewRun("run-create", "cause-1") + if err != nil { + t.Fatal(err) + } + created, err := rt.Create(context.Background(), newRun) + if err != nil || !created.Created { + t.Fatalf("first Create = %+v, %v", created, err) + } + if err := run.ValidateRunHeader(&created.Header); err != nil { + t.Fatalf("Create returned invalid Header: %v", err) + } + created.Header.InitialState.RunID = "mutated" + record, err := rt.Record(context.Background(), "run-create") + if err != nil { + t.Fatal(err) + } + if record.Header.InitialState.RunID != "run-create" { + t.Fatal("Create Header aliases runtime state") + } +} + +func testCreateRetryAndConflict(t *testing.T, newRuntime Factory) { + rt := newRuntime() + first, err := run.BuildNewRun("run-create", "cause-1") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(context.Background(), first); err != nil { + t.Fatal(err) + } + retry, err := rt.Create(context.Background(), first) + if err != nil || retry.Created { + t.Fatalf("retry Create = %+v, %v", retry, err) + } + retry.Header.InitialState.RunID = "mutated" + record, err := rt.Record(context.Background(), first.RunID) + if err != nil { + t.Fatal(err) + } + if record.Header.InitialState.RunID != first.RunID { + t.Fatal("retry Create Header aliases runtime state") + } + conflict, err := run.BuildNewRun("run-create", "cause-2") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(context.Background(), conflict); !errors.Is(err, run.ErrCreateConflict) { + t.Fatalf("conflicting Create error = %v, want ErrCreateConflict", err) + } +} + +func testMissingRunOperations(t *testing.T, newRuntime Factory) { + rt := newRuntime() + if _, err := rt.Load(context.Background(), "missing"); !errors.Is(err, run.ErrRunNotFound) { + t.Fatalf("Load error = %v, want ErrRunNotFound", err) + } + if _, err := rt.Record(context.Background(), "missing"); !errors.Is(err, run.ErrRunNotFound) { + t.Fatalf("Record error = %v, want ErrRunNotFound", err) + } + env, err := run.BuildEnvelope("missing", "cancel", run.CancelRun{}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(context.Background(), run.CommitRequest{Command: env}); !errors.Is(err, run.ErrRunNotFound) { + t.Fatalf("Commit error = %v, want ErrRunNotFound", err) + } +} + +func testRecordRevisionZero(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime) + record, err := c.rt.Record(context.Background(), c.runID) + if err != nil { + t.Fatal(err) + } + if record.Snapshot.Revision != 0 || len(record.Transitions) != 0 { + t.Fatalf("revision-zero Record = %+v", record) + } +} + +func testRecordFoldsAcceptedTransition(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime) + input := run.AgentInput{ID: "in-1", Payload: mustJSON(`{"q":"hi"}`)} + res := c.mustCommit(run.DeriveInputCommandID(c.runID, input.ID), 0, "", run.AcceptInput{Input: input}) + if res.Status != run.CommitAccepted { + t.Fatalf("accept status = %v", res.Status) + } + record, err := c.rt.Record(context.Background(), c.runID) + if err != nil { + t.Fatal(err) + } + if len(record.Transitions) != 1 { + t.Fatalf("transitions = %d, want 1", len(record.Transitions)) + } + folded, revision, err := run.FoldRun(&record.Header, record.Transitions) + if err != nil { + t.Fatal(err) + } + foldedJSON, _ := json.Marshal(stateComparable(&folded)) + snapshotJSON, _ := json.Marshal(stateComparable(&record.Snapshot.State)) + if revision != record.Snapshot.Revision || !bytes.Equal(foldedJSON, snapshotJSON) { + t.Fatalf("FoldRun = revision %d state %s, Record = revision %d state %s", revision, foldedJSON, record.Snapshot.Revision, snapshotJSON) + } +} + +func testReturnedValuesAreDetached(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime) + newRun, err := run.BuildNewRun(c.runID, "") + if err != nil { + t.Fatal(err) + } + created, err := c.rt.Create(context.Background(), newRun) + if err != nil || created.Created { + t.Fatalf("retry Create = %+v, %v", created, err) + } + created.Header.InitialState.RunID = "mutated" + input := run.AgentInput{ID: "in-1", Payload: mustJSON(`1`)} + res := c.mustCommit(run.DeriveInputCommandID(c.runID, input.ID), 0, "", run.AcceptInput{Input: input}) + res.Snapshot.State.RunID = "mutated" + if len(res.Events) > 0 { + res.Events[0].RunID = "mutated" + } + record, err := c.rt.Record(context.Background(), c.runID) + if err != nil { + t.Fatal(err) + } + record.Header.InitialState.RunID = "mutated" + record.Snapshot.State.RunID = "mutated" + record.Transitions[0].Events[0].RunID = "mutated" + fresh, err := c.rt.Record(context.Background(), c.runID) + if err != nil { + t.Fatal(err) + } + if fresh.Header.InitialState.RunID != c.runID || fresh.Snapshot.State.RunID != c.runID || fresh.Transitions[0].Events[0].RunID != c.runID { + t.Fatal("returned Header, Snapshot, or Transitions alias runtime state") + } +} + +func testConcurrentCreate(t *testing.T, newRuntime Factory) { + rt := newRuntime() + newRun, err := run.BuildNewRun("run-concurrent-create", "cause-1") + if err != nil { + t.Fatal(err) + } + const callers = 16 + results := make(chan run.CreateResult, callers) + errs := make(chan error, callers) + var group sync.WaitGroup + for range callers { + group.Add(1) + go func() { + defer group.Done() + result, err := rt.Create(context.Background(), newRun) + if err != nil { + errs <- err + return + } + results <- result + }() + } + group.Wait() + close(results) + close(errs) + for err := range errs { + t.Fatal(err) + } + created := 0 + for result := range results { + if result.Created { + created++ + } + } + if created != 1 { + t.Fatalf("Created=true results = %d, want 1", created) + } +} + +func testRunsAreIsolated(t *testing.T, newRuntime Factory) { + rt := newRuntime() + for _, id := range []run.RunID{"run-one", "run-two"} { + newRun, err := run.BuildNewRun(id, "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(context.Background(), newRun); err != nil { + t.Fatal(err) + } + input := run.AgentInput{ID: "same-input", Payload: mustJSON(`{"run":"` + string(id) + `"}`)} + env, err := run.BuildEnvelope(id, run.DeriveInputCommandID(id, input.ID), run.AcceptInput{Input: input}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(context.Background(), run.CommitRequest{Command: env}); err != nil { + t.Fatal(err) + } + } + one, err := rt.Record(context.Background(), "run-one") + if err != nil { + t.Fatal(err) + } + two, err := rt.Record(context.Background(), "run-two") + if err != nil { + t.Fatal(err) + } + if len(one.Snapshot.State.PendingInputs) != 1 || len(two.Snapshot.State.PendingInputs) != 1 || + one.Snapshot.State.PendingInputs[0].Payload.String() == two.Snapshot.State.PendingInputs[0].Payload.String() { + t.Fatalf("runs leaked state or commands: one=%+v two=%+v", one.Snapshot.State.PendingInputs, two.Snapshot.State.PendingInputs) + } + for _, record := range []run.RunRecord{one, two} { + if len(record.Transitions) != 1 || record.Transitions[0].RunID != record.Header.RunID { + t.Fatalf("run %q has foreign transition log: %+v", record.Header.RunID, record.Transitions) + } + for _, event := range record.Transitions[0].Events { + if event.RunID != record.Header.RunID { + t.Fatalf("run %q has foreign event %+v", record.Header.RunID, event) + } + } + } +} + +func testCrossRunGrantIsRejected(t *testing.T, newRuntime Factory) { + rt := newRuntime() + one := newCaseOnRuntime(t, rt, "grant-run-one") + two := newCaseOnRuntime(t, rt, "grant-run-two") + stepOne, grantOne := prepareAndStart(t, one) + stepTwo, grantTwo := prepareAndStart(t, two) + result, err := run.FreezeModelResult(sdk.ModelResult{Text: "ok"}) + if err != nil { + t.Fatal(err) + } + if _, err := two.commit("foreign-grant", 2, grantOne, run.SubmitModelResult{StepID: stepTwo, Result: result}); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("cross-run grant error = %v, want ErrStaleRuntime", err) + } + one.mustCommit("complete-one", 2, grantOne, run.SubmitModelResult{StepID: stepOne, Result: result}) + two.mustCommit("complete-two", 2, grantTwo, run.SubmitModelResult{StepID: stepTwo, Result: result}) +} + +func testRecordConcurrentWithCommit(t *testing.T, newRuntime Factory) { + rt := newRuntime() + c := newCaseOnRuntime(t, rt, "record-concurrent") + const commits = 16 + for i := range commits { + snapshot, err := rt.Load(context.Background(), c.runID) + if err != nil { + t.Fatal(err) + } + input := run.AgentInput{ID: run.InputID(fmt.Sprintf("in-%d", i)), Payload: mustJSON(`null`)} + env, err := run.BuildEnvelope(c.runID, run.DeriveInputCommandID(c.runID, input.ID), run.AcceptInput{Input: input}) + if err != nil { + t.Fatal(err) + } + + start := make(chan struct{}) + var ready sync.WaitGroup + ready.Add(2) + commitCh := make(chan error, 1) + recordCh := make(chan struct { + record run.RunRecord + err error + }, 1) + go func() { + ready.Done() + <-start + _, err := rt.Commit(context.Background(), run.CommitRequest{BaseRevision: snapshot.Revision, Command: env}) + commitCh <- err + }() + go func() { + ready.Done() + <-start + record, err := rt.Record(context.Background(), c.runID) + recordCh <- struct { + record run.RunRecord + err error + }{record: record, err: err} + }() + ready.Wait() + close(start) + + if err := <-commitCh; err != nil { + t.Fatal(err) + } + observed := <-recordCh + if observed.err != nil { + t.Fatal(observed.err) + } + folded, revision, err := run.FoldRun(&observed.record.Header, observed.record.Transitions) + if err != nil { + t.Fatal(err) + } + foldedJSON, _ := json.Marshal(stateComparable(&folded)) + snapshotJSON, _ := json.Marshal(stateComparable(&observed.record.Snapshot.State)) + if revision != observed.record.Snapshot.Revision || !bytes.Equal(foldedJSON, snapshotJSON) { + t.Fatalf("inconsistent Record at revision %d", observed.record.Snapshot.Revision) + } + if wantBefore, wantAfter := snapshot.Revision, snapshot.Revision+1; revision != wantBefore && revision != wantAfter { + t.Fatalf("concurrent Record revision = %d, want %d or %d", revision, wantBefore, wantAfter) + } + } + final, err := rt.Record(context.Background(), c.runID) + if err != nil { + t.Fatal(err) + } + if final.Snapshot.Revision != commits || len(final.Transitions) != commits { + t.Fatalf("final Record = revision %d transitions %d, want %d", final.Snapshot.Revision, len(final.Transitions), commits) + } +} + type conformanceCase struct { t testing.TB runID run.RunID @@ -55,17 +372,27 @@ type conformanceCase struct { func newCase(t testing.TB, newRuntime Factory) *conformanceCase { t.Helper() - initial, err := run.InitializeRun("run-1") + return newCaseOnRuntime(t, newRuntime(), "run-1") +} + +func newCaseOnRuntime(t testing.TB, rt run.Runtime, runID run.RunID) *conformanceCase { + t.Helper() + newRun, err := run.BuildNewRun(runID, "") + if err != nil { + t.Fatal(err) + } + created, err := rt.Create(context.Background(), newRun) if err != nil { t.Fatal(err) } - initial.PendingInputs = []run.AgentInput{{ID: "seed", Payload: mustJSON(`{"q":"hi"}`)}} - return &conformanceCase{t: t, runID: initial.RunID, initial: initial, rt: newRuntime(t, initial)} + return &conformanceCase{ + t: t, runID: runID, initial: created.Header.InitialState, rt: rt, + } } func (c *conformanceCase) load() run.RuntimeSnapshot { c.t.Helper() - snap, err := c.rt.Load(context.Background()) + snap, err := c.rt.Load(context.Background(), c.runID) if err != nil { c.t.Fatal(err) } @@ -108,6 +435,19 @@ func preparedCase(t testing.TB, newRuntime Factory, tools []sdk.ToolDefinition, return c, prep.StepID, start.Grant } +func prepareAndStart(t testing.TB, c *conformanceCase) (run.StepID, run.ExecutionGrant) { + t.Helper() + snap := c.load() + req := request() + prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) + c.mustCommit(cmdID, snap.Revision, "", prep) + start := c.mustCommit(run.CommandID("start-"+string(c.runID)), snap.Revision+1, "", run.StartModelExecution{StepID: prep.StepID}) + if start.Grant == "" { + t.Fatal("accepted start returned no grant") + } + return prep.StepID, start.Grant +} + func buildPrepareFromSnap(t testing.TB, snap *run.RuntimeSnapshot, req *sdk.Request, specs []run.ToolSpec) (run.PrepareModelRequest, run.CommandID) { t.Helper() frozenReq, err := run.FreezeModelRequest(*req) diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 7a6be05..3aff08d 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -2,9 +2,9 @@ 状态:重构方案 -本文定义 Twilight AI 的目标 agent domain 边界:`sdk/` 负责一次模型调用,`agent/es` 提供通用 event-sourcing 机制,`agent/run` 保存单次 Run 的 execution ES,`agent/session` 保存长期会话语义 ES,`agent/queue` 提供通用调度/claim/dedup 机制。Application/Product 负责把这些 domain 编排起来。 +本文定义 Twilight AI 的目标 agent domain 边界:`sdk/` 负责一次模型调用,`agent/es` 提供通用 event-sourcing 机制,`agent/run` 定义单次 Run execution,`agent/session` 保存长期语义 Events,`agent/artifact` 管理不可变内容引用与 retention,`agent/session/extension` 管理静态 ontology 与语义写入,`agent/session/chatlog` 定义会话语义,`agent/turn` 协调 Turn 与 Run。Application/Product 组合这些 domain,并拥有模型、工具、权限、队列、渠道和产品 policy。 -本文不再把 Twilight 定义成一个“大 Agent Core”。当前代码中的 root `agent` 包语义上应收敛为目标 `agent/run` 包;它回答“一次 computation 执行到哪里、下一步是否合法、如何 replay/recover”。Session、Queue、Context、Artifact、Memory、Workspace、Scheduler 等是独立 domain 或 application policy;其中本规范先正式拆出 `run`、`session`、`queue` 和共享 `es` substrate。 +当前代码中的 root `agent` 包语义上收敛为目标 `agent/run` 包;它回答“一次 computation 执行到哪里、下一步是否合法、如何 replay/recover”。本文以 Run 重构为主,并只描述其他 domain 与 Run 相接的边界;Session、Artifact、Extension、Chatlog 与 Turn 的完整协议分别由对应设计规范唯一定义。 正文中的 Go 片段用于说明协议;附录 A 是 public API 草案。正文各节为规范文本,附录是汇总视图;两者不一致时以正文为准,并修订附录。 @@ -12,34 +12,33 @@ ### 1.1 核心目标 -Twilight AI 的 agent 侧被拆成几个可组合 domain,而不是一个单体 Agent: +Twilight AI 的 agent 侧由多个可组合 domain 构成: ```text Application / Product - │ - ├── conversation/session semantics - ├── steer / follow-up policy - ├── artifact/context/memory/workspace policy - └── orchestration - │ - ┌─────────┼─────────┐ - │ │ │ -agent/ agent/ agent/ -session queue run -长 ES 调度机制 短 Run ES + ├── model/tool/provider/permission/queue/channel policy + ├── Request Planner 与 Context materialization + └── composition + ├── agent/turn ───────────────┐ + ├── agent/session/chatlog │ + ├── agent/session/extension ├── agent/session + ├── agent/artifact │ + └── agent/run ───────────────┘ + └── sdk/model + sdk/tool boundaries ``` -三个已经明确的 domain authority: +已经明确的 authority: | Domain | 回答的问题 | Authority 形态 | 生命周期 | | --- | --- | --- | --- | -| `agent/session` | 这个会话长期发生过什么? | append-only Session ES;**产品跨 Run 的语义主 authority** | 跨多个 Run 长期存在 | -| `agent/run` | 这一次执行如何跑到当前位置? | RunHeader + TransitionRecord log;**该 Run 存活期间的 execution authority** | 单次 Run,materialize/finalize 后可归档/GC | -| `agent/queue` | 哪些工作待处理、谁 claim、如何 dedup? | transactional queue state | 调度生命周期 | +| `agent/session` | 这个会话长期发生过什么? | resolved committed `SessionEvent` stream | 跨多个 Run 长期存在 | +| `agent/run` | 这一次执行如何跑到当前位置? | `MachineState` 是 execution authority;`RunHeader + TransitionRecord log` 是 canonical record | 单次 Run,materialize/settle 后可按产品策略归档 | +| `agent/artifact` | 长期事件引用什么不可变内容,如何保留? | immutable `Binding` 与 `RetentionClaim` | 随 owner facts 保留 | +| `agent/turn` | 一个 Chatlog Turn 如何创建、恢复、投影和结算 Run? | Session 中的 turn facts + verified `RunRecord` | 一个 Turn 与其 primary Run | -Session 不需要保存每一个执行微步骤,但必须记录每一次 Run 的 admission、终态和继续理解会话所需的结果/引用。Run log 只服务执行、恢复和审计;它不是跨 Run 语义的最终来源。 +Session 保存跨 Run 的长期语义;Turn 协议将 Run facts materialize 为 Session Events,并在完整 coverage 后 settlement。Run records 服务执行、恢复、审计和 materialization,长期 context 从 Session projection 与 Artifact Binding 构造。 -`agent/es` 是机制库,不是第四个语义 domain。它只提供 canonical envelope、digest、record completeness、revision/index 校验和 fold runner 等可复用机制;它不认识 ModelStep、Session message、queue claim 或产品 policy。 +`agent/es` 是共享机制库,提供 canonical envelope、digest、record completeness、revision/index 校验和 fold runner;ModelStep、Session message、queue claim 与产品 policy 留在各自 owner。 本版本固定五个执行层级,但这些层级属于 `agent/run`: @@ -51,7 +50,7 @@ Session 不需要保存每一个执行微步骤,但必须记录每一次 Run 解释 Machine 产生的 effect,执行模型/工具,再提交结果事件 3. Run Runtime - 加载权威 MachineState,并原子提交 AgentCommand,产生 TransitionRecord + Create/Load/Commit/Record 按 RunID 管理 execution authority 与 canonical records 4. Model / Tool 执行一次模型请求或一次工具调用 @@ -65,29 +64,30 @@ Session 不需要保存每一个执行微步骤,但必须记录每一次 Run 整体关系: ```text - Session ES / Context / Queue - (Application-owned planning inputs) - │ - v - Request Planner - │ sdk.Request - v - run.Loop --SDK()--> Model / Tool - │ - │ AgentCommand carries frozen run.ModelRequest / ModelResult - v - run.Runtime - Load + atomic Commit - / \ - run.MemoryRuntime Durable runtime adapter - memory + mutex DB + private lease/fence/outbox - \ / - \ / - RunHeader + TransitionRecord log - MachineState projection - │ - v - EventSink / Run replay / Session materializer / OTel + Session Events + Artifact Bindings + Application queue/context + │ + v + Request Planner + │ sdk.Request + v + run.Loop ──SDK──> Model / Tool + │ AgentCommand + v + shared run.Runtime + Create / Load / Commit / Record + / \ + run.MemoryRuntime Durable adapter + memory + locks DB + lease/fence/outbox + \ / + \ / + MachineState (execution authority) + RunHeader + TransitionRecords (canonical record) + │ verified RunRecord + v + turn.MaterializeAll -> SemanticAppender + │ + v + Chatlog/Turn Session Events + OTel ``` Loop 与 Runtime 的职责相互独立:Loop 决定如何执行,Runtime 决定权威状态在哪里以及如何安全提交。Machine 决定什么状态变化合法;Planner 决定模型看到的 application/session/context projection;Model/Tool 只执行一次外部 effect。 @@ -96,28 +96,33 @@ Loop 与 Runtime 的职责相互独立:Loop 决定如何执行,Runtime 决 ```text twilight-ai/ - sdk/ 一次 LLM request/response 的边界/transport 类型 + sdk/ 一次 LLM request/response 的 transport 边界 agent/ - es/ 通用 ES envelope、digest、record、fold 机制 - run/ 单次 Run 的 execution ES:Machine、Loop、Runtime、Step/ToolCall - session/ 长期语义 Session ES substrate - queue/ 通用 transactional queue/claim/dedup 机制 - jsonstable/ canonical JSON value 与解析/编码 - harness/ 可选:in-process 组合器,不是新的 Runtime 语义 + es/ 通用 ES envelope、digest、record、fold 机制 + jsonstable/ canonical JSON value 与解析/编码 + run/ Run Machine、Loop、Runtime、Step/ToolCall + artifact/ Ref、Binding、BindingSet、RetentionClaim + session/ 长期语义 Session ES kernel + extension/ 静态 Module/Catalog、SemanticAppender、projection + chatlog/ first-party Message/Input/Turn/Item ontology + turn/ Turn→Run 协调、materialization 与 settlement ``` 依赖方向固定为: ```text -agent/es ---> agent/jsonstable(可选) -agent/session ---> agent/es -agent/run ---> agent/es + agent/jsonstable + sdk -agent/queue ---> 不依赖 agent/run;默认不依赖 ES -Application ---> sdk + agent/run + agent/session + agent/queue -sdk 不依赖 agent 或 Application +agent/es ---> agent/jsonstable(可选) +agent/run ---> agent/es + agent/jsonstable + sdk +agent/artifact ---> agent/es + agent/jsonstable +agent/session ---> agent/es + agent/jsonstable +agent/session/extension ---> agent/session + agent/artifact +agent/session/chatlog ---> agent/session/extension + agent/artifact +agent/turn ---> agent/run + agent/session + agent/session/extension + agent/session/chatlog +Application ---> sdk + 上述 domain;并组合 queue/provider/channel policy +sdk 不依赖 agent 或 Application ``` -禁止出现 `agent/es -> agent/run` 或 `agent/session -> agent/run` 这类反向依赖;共享机制不能 import domain 语义。 +`agent/es`、`agent/session` 与 `agent/artifact` 保持各自 kernel 边界;Application 通过公开 capability 组合 domain,不以反向 import 共享产品语义。 ### 1.2 核心执行模型 @@ -142,7 +147,7 @@ MachineState + AgentCommand Machine.Next -> Effect -> Loop ``` -Step 不是最小的外部操作。ToolStep 保存每个 ToolCall 中会影响恢复决策的状态。durable 实现可以为当前执行建立内部 Attempt;Attempt 可能因进程退出、超时或 lease 失效而消失,但新的 Loop 仍恢复同一个 Step。 +ToolCall 是 Step 内的外部操作单元。ToolStep 保存每个 ToolCall 中会影响恢复决策的状态。durable 实现可以为当前执行建立内部 Attempt;Attempt 可能因进程退出、超时或 lease 失效而消失,新的 Loop 仍恢复同一个 Step。 最重要的不变量是: @@ -165,7 +170,7 @@ Step 不是最小的外部操作。ToolStep 保存每个 ToolCall 中会影响 | Revision | 每 Run 单调递增的 transition 计数;第 N 次接受的 transition 产出 Revision=N 的 TransitionRecord 和 Revision=N 的状态。 | | Effect | Machine 根据当前状态返回的至多一个待执行动作;不表示一定有外部副作用。 | | Attempt | Runtime 为一次进程执行建立的内部执行租约。 | -| Runtime | MachineState 的 execution authority、AgentCommand 的原子提交和 TransitionRecord 的产生者。 | +| Runtime | RunID-addressed MachineState 的 execution authority、Run creation、AgentCommand 的原子提交和 TransitionRecord 的产生者。 | | Request Planner | application context 到 `sdk.Request` 的投影器。 | ### 1.4 成功标准 @@ -197,7 +202,7 @@ sdk 只负责 LLM API: | provider 错误 | transport、rate limit、malformed stream 和 provider response error。 | | request/result boundary | provider-neutral 的一次调用输入输出;可由 agent 在 Loop 边界冻结。 | -`sdk.Request` 是一次模型调用的完整输入,不是 session、history 或 queue。`sdk.ModelResult` 是一次完整模型响应,保留旧 `GenerateResult` 的单次调用字段(文本、reasoning parts、tool calls、finish reason、usage、sources/files 和 provider metadata),但不包含自动 tool loop、approval 或多次调用累加。多步执行的 steps 和 messages 由 agent/application 另行保存。 +`sdk.Request` 是一次模型调用的完整输入,其范围排除 session、history 与 queue。`sdk.ModelResult` 是一次完整模型响应,保留旧 `GenerateResult` 的单次调用字段(文本、reasoning parts、tool calls、finish reason、usage、sources/files 和 provider metadata);自动 tool loop、approval 与多次调用累加由 agent/application 另行保存。 `sdk` 类型只允许存在于 Planner/ModelInvoker/provider 边界;不得作为 Run event、MachineState、AgentCommand 或 digest input 的持久化形态。Loop 必须在提交前把 `sdk.Request`、`sdk.ModelResult` 和 `sdk.ToolDefinition` 分别转换成 run-owned 的 `ModelRequest`、`ModelResult` 和 `ToolDefinition`;调用 provider/tool 前再由这些 frozen value 构造新的 `sdk` 值。 @@ -292,56 +297,74 @@ Planner / provider / tool execution 产品 policy ``` -建议 API 形态是泛型和 hook,而不是把 domain 类型塞进 `es`: +当前实现通过泛型和 hook 提供 API,domain 类型保留在 owner package: ```go package es type StreamID string -type EntryID string type Revision uint64 type Index uint16 type Digest string type CausationID string -type Event[T any] struct { +type Event[P any] struct { SchemaVersion uint16 StreamID StreamID Revision Revision Index Index Type string - // CausationID links this event to the operation/event that caused it. - // It is opaque to es; the domain/application defines its interpretation. CausationID CausationID - Payload T + Payload P PayloadDigest Digest } -type Record[T any] struct { +type Record[E any] struct { SchemaVersion uint16 StreamID StreamID Revision Revision - Events []Event[T] + Events []E RecordDigest Digest } -type Codec[T any] interface { - Type(T) string - DigestPayload(schemaVersion uint16, typ string, payload T) (Digest, error) +type EventMetadata struct { + SchemaVersion uint16 + StreamID StreamID + Revision Revision + Index Index +} + +type RecordView[E any] struct { + SchemaVersion uint16 + StreamID StreamID + Revision Revision + Events []E } -type Folder[S any, E any] func(schemaVersion uint16, state S, event E) (S, error) +type EventInspector[E any] func(E) (EventMetadata, error) +type SchemaSupported func(uint16) bool -func BuildRecord[T any](codec Codec[T], events []Event[T]) (Record[T], error) -func ValidateRecord[T any](codec Codec[T], record *Record[T]) error -func FoldRecords[S any, E any](initial S, records []Record[E], folder Folder[S, E]) (S, Revision, error) +func BuildEvent[P any](schemaVersion uint16, streamID StreamID, revision Revision, + index Index, typ string, causationID CausationID, payload P) (Event[P], error) +func ValidateEvent[P any](event Event[P], supported SchemaSupported) error +func DigestRecord[E any](record *Record[E]) (Digest, error) +func ValidateRecord[E any](record *Record[E], supported SchemaSupported, + inspect EventInspector[E]) error +func ValidateRecordView[E any](record RecordView[E], supported SchemaSupported, + inspect EventInspector[E]) error +func FoldStandardRecords[S any, E any](initial S, expectedStream StreamID, + records []Record[E], supported SchemaSupported, inspect EventInspector[E], + evolve func(uint16, S, E) (S, error)) (S, Revision, error) +func FoldRecords[S any, E any](initial S, expectedStream StreamID, + records []RecordView[E], supported SchemaSupported, inspect EventInspector[E], + evolve func(uint16, S, E) (S, error)) (S, Revision, error) ``` -具体字段名可以在实现时调整,但边界原则固定:`es` 提供 append-only/replay 的机械不变量;domain 提供 type discriminator、payload encoding、Evolve 语义和 store adapter。 +`BuildEvent`、`Validate*` 和 `Fold*` 是机械 API;domain 仍负责 type discriminator、payload encoding、Evolve 语义和 store adapter。带有额外 domain metadata 的 record 通过 `RecordView` 接入,不要求 domain 放弃自己的 record 类型。 ### 2.3 `agent/run` -`agent/run` 是当前 root `agent` 包的目标形态。它是单次 Run 的 execution ES,不是 Session ES。 +`agent/run` 是当前 root `agent` 包的目标形态,只拥有单次 Run execution;长期 Session Events 由 `agent/session` 拥有。 `agent/run` 负责: @@ -349,13 +372,13 @@ func FoldRecords[S any, E any](initial S, records []Record[E], folder Folder[S, | --- | --- | | Run Machine | `MachineState`、`Step`、`ToolCallState`、`AgentCommand`、`Fact`、`AgentEvent` 和共享的 Decide/Evolve/Next 规则。 | | Transition | 把一次 accepted command 产生的完整 fact/event group 封装为 `TransitionRecord`。实现上可直接使用 `agent/es.Record` 或在其上包 domain metadata。 | -| RunHeader | 正式持久协议的一部分:RunID、initial state/schema/digest、admission causation/provenance。完整 **execution authority** 是 `RunHeader + TransitionRecord log`。 | -| Runtime contract | `Load`/`Commit` authority 接口;所有语义状态变化只通过 Commit。 | +| RunHeader | 正式持久协议的一部分:RunID、initial state/schema/digest、admission causation/provenance。它与 `TransitionRecord` 组成 canonical record;`MachineState` 才是日常 execution authority。 | +| Runtime contract | `Create`/`Load`/`Commit`/`Record` authority 接口;Run creation 由 Create 协议建立,所有后续语义状态变化只通过 Commit。 | | Loop | 唯一的多步执行算法:解释 Effect,调用 Model/Tool,再提交结果 command。 | | Tool contract | ToolRef、ExecutableTool、参数校验、结果分类和 response policy。 | | Planner port | 供 Loop 注入 application Request Planner 的最小接口;规划实现不在 run。 | -| EventSink | canonical run event 的实时观察出口,也承载 provisional delta;它不是 authority。 | -| MemoryRuntime | 进程内 reference runtime;它实现同一 Run ES 语义,但不承诺跨进程 durable recovery。 | +| EventSink | canonical run event 的实时观察出口,也承载 provisional delta;authority 保持在 MachineState 与 canonical records。 | +| MemoryRuntime | 进程内 reference runtime;它实现同一 Run execution semantics,但不承诺跨进程 durable recovery。 | | runtimetest | durable runtime 必须复用的 conformance suite。 | `agent/run` 不拥有: @@ -368,7 +391,7 @@ Provider clients / API keys Durable DB schema / owner/fence/lease/outbox implementation ``` -Run execution source of truth(不替代 Session 的长期语义 authority): +Run execution canonical record(不替代 Session 的长期语义 authority): ```text RunHeader(initial MachineState at Revision 0) @@ -382,7 +405,7 @@ TransitionRecord log (Revision 1..N) MachineState_N = Fold(initial, flatten(TransitionRecord[1..N].Events)) ``` -Run ES 是短生命周期的 execution authority。它完成后不能再作为下一 Run 的 context 来源;只有当 Session 已记录该 Run 的 lifecycle 与所需长期语义,并且 artifact/usage 等引用可恢复时,才可以 finalization 后 archive/GC。 +Run execution domain 是短生命周期的;其中的 `MachineState` 是执行 authority。它完成后不再作为下一 Run 的 context 来源。Application 在 Turn 完整 materialization、terminal settlement 与 Artifact retention 得到 durable evidence 后,才按 archive policy 回收 Run records。 ### 2.3.1 run-owned persisted model data @@ -390,220 +413,63 @@ Runtime 的 authority boundary 不能靠“调用者不要修改快照”这类 因此 `agent/run` 必须定义自己的 JSON-stable persisted value:`ModelRequest`、`ModelResult`、`ToolDefinition`、`Usage`、`Message`、`MessagePart`、`ProviderMetadata` 等。Loop/provider 边界仍使用 `sdk.Request`/`sdk.ModelResult`;进入 Runtime 前必须调用 `FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`,把动态 JSON parse/canonicalize 成 opaque `CanonicalJSON`;离开 Runtime 调 provider 时通过 `.SDK()` 构造新的 SDK 值。 -这不是防御性 deep clone 的局部补丁,而是分层边界:`sdk` 是一次调用的 transport API,`agent/run` 是可重放、可审计、可长期兼容的 execution event protocol。任何新增进入 Run event/MachineState 的 provider 数据,必须先落到 run-owned sealed/JSON-stable 类型或 `CanonicalJSON`。 +该规则建立分层 persisted-value 边界:`sdk` 是一次调用的 transport API,`agent/run` 是可重放、可审计、可长期兼容的 execution event protocol。任何新增进入 Run event/MachineState 的 provider 数据,必须先落到 run-owned sealed/JSON-stable 类型或 `CanonicalJSON`。 ### 2.4 `agent/session` -> 实施状态:**契约先行,substrate 按 Ring 分批**。§2.6 的跨 domain 契约(source event identity、causation、finalization barrier)立即生效,由 durable adapter 在 Application 现有存储上落实;第一版 durable adapter 不依赖本包。Ring 1/Ring 2 在 durable adapter 的 materializer 跑通后开工,接口从运行中的消费方(materializer 与 Application 的 timeline 投影)反推定形,不从本节草案照抄。 - -`agent/session` 是长期语义历史的 append-only ES substrate。它的范围按三环划分;一个能力进入本包必须同时满足三条判别:主流 agent 实现中已收敛出同构形状(行业验证,非发明)、不含 policy(只回答"怎么记、怎么折叠",不回答"何时/是否/对谁")、agent 折叠上下文时必须理解它。 +`docs/design/agent-session.md` 是 Session kernel 的唯一协议 authority。其 Event-first 模型为: ```text -Ring 1 substrate(机制)——agent/session - Entry 身份/digest/causation、Store 接口(Append/Replay/Head CAS/Fork)、 - parent/fork lineage、snapshot、SchemaVersion、memory 参考实现、conformance 套件 - -Ring 2 通用会话语义(标准词表)——agent/session/chatlog,可选子包 - 标准事件:MessageAdded 族、TurnSuperseded、CompactCreated、BranchForked、 - InputAccepted、RunAdmitted/RunEnded - SurfaceFold:折叠出 current / shadowed / log-only 三类视图(取代链完整可查) - ContextFold:entry 流 → 当前可见消息序列(agent 读路径) - 未知事件归入 log-only(保留、不进 surface、不进上下文)——substrate 层拒绝 - 未知类型,chatlog 层收容应用扩展,两个姿态都是显式规则 - -Ring 3 产品(policy 与身份)——Application,永不进入本包 - compact 触发策略/模型/阈值、可见性与权限、多租户(team_id)、retention/合规、 - 搜索后端与排名、分享、queue 本体(协调状态)、prompt 组装的产品部分 -``` - -Ring 1 对词表零知识(泛型 `Store[E]`);chatlog 是其上的可选词表包,应用可以不用它、用它、或在它之上扩展自定义事件。RunConfig 的教训适用于此:任何"何时/是否"的判断都属 Ring 3,不因实现方便而进入 Ring 1/2。 - -Store 接口的 SQL 可实现性约束(设计时生效,先于任何 SQL 实现存在):payload 是不透明的 canonical bytes,store 不理解也不索引其内容;revision 由 store 逻辑经 head CAS 分配,不依赖数据库序列;全部操作是单表点查/范围扫,无 JOIN、无数据库方言特性。满足这些约束的接口映射到 SQLite/PostgreSQL/JSONL 都是平凡的。 - -实现交付遵循 run 侧先例:本包只交付 memory 参考实现 + conformance 套件;SQL 实现随消费方——Application 形状的实现(租户、RLS、与 run transition 同事务)由 Application 自建并以 conformance 验收,本地会话/同步场景的 SQLite 实现在该功能排期时进入(届时我们自己就是消费方)。跨库能力由"接口约束 + conformance 套件对任意后端成立"保证,不由预制实现保证。 - -它不写死 Twilight/Memoh 的 message ontology,泛型承载上层语义事件: - -```go -package session - -type SessionID string -type EntryID string -type BranchID string - -// Session is a typed view of one semantic stream. The package owns stream -// mechanics, not the meaning of E. -type Session[E any] struct { - ID SessionID - BranchID BranchID - Head EntryID - Revision es.Revision -} - -type Entry[E any] struct { - SessionID SessionID - EntryID EntryID - BranchID BranchID - Parent EntryID - Revision es.Revision - SchemaVersion uint16 - Type string - CausationID es.CausationID - Payload E - Digest es.Digest -} - -type Store[E any] interface { - Append(context.Context, AppendRequest[E]) (Entry[E], error) - Head(context.Context, SessionID, BranchID) (EntryID, es.Revision, error) - Replay(context.Context, SessionID, BranchID) ([]Entry[E], error) - Fork(context.Context, ForkRequest) (BranchID, error) -} -``` - -上层可以定义自己的 session event,例如: - -```text -UserMessageAdded -AssistantMessageAdded -ToolResultAdded -RunAdmitted // RunID + RunHeader digest + admission provenance -RunCompleted // RunID + terminal summary + durable output/artifact refs -RunFinalized // 此 Run 的长期语义已吸收,可回收 execution log -CompactCreated -ArtifactLinked -MemoryUpdated -Custom product event -``` - -`RunAdmitted`、`RunCompleted` 和 `RunFinalized` 是 Application 定义的 Session ontology,不是 `agent/session` 预置的类型;但每一个 Run 至少必须以这种 lifecycle entry 被 Session 记录。 - -`agent/session` 只保证: - -```text -append-only -revision/head CAS -parent/fork lineage -causation id / digest / schema version -replay order -``` - -它不决定: - -```text -什么算 assistant message -哪些 Run event 应该变成长期消息 -compact 策略 -memory extraction 策略 -artifact retention 策略 -``` - -这些属于 Application/Product。 - -### 2.5 `agent/queue` - -> 实施状态:**无限期推迟**。Application(Memoh)已有生产级 steer/follow-up queue;durable adapter 只需要两处集成(boundary 处 item 转 `AcceptInput`、Prepare gate 事务内检查 eligible steer),均不要求重写为泛型包。抽取本包的触发条件是出现第二个真实消费方;下述 API 为届时的参考草案。 - -`agent/queue` 是通用 transactional queue/claim/dedup 机制,不默认使用 ES。Queue 回答“哪些工作待处理、谁拥有处理权、如何去重/过期/重排”,不是长期语义历史。 - -建议 API 形态: - -```go -package queue - -type QueueID string -type ItemID string -type ClaimID string -type DedupKey string - -type Item[T any] struct { - QueueID QueueID - ItemID ItemID - Payload T - PayloadDigest es.Digest - DedupKey DedupKey - CausationID es.CausationID -} +Events = resolved committed SessionEvent stream +State = Fold(Events) -type Store[T any] interface { - Enqueue(context.Context, EnqueueRequest[T]) (Item[T], error) - Claim(context.Context, ClaimRequest) (Claim[T], error) - Ack(context.Context, ClaimID) error - Release(context.Context, ClaimID, ReleaseReason) error -} +Persistent representation + = immutable SessionHeader + + ordered SessionCommit records containing ordered SessionEvents ``` -`SteerItem`、`FollowUpItem`、`RunQueueItem` 等不是 queue core 语义,而是 application payload: - -```text -queue[T] 通用机制 - ↑ -SteerItem / FollowUpItem / RunQueueItem 上层类型 + policy -``` +Kernel 负责 opaque canonical payload、atomic commit、head CAS、resolved ancestry replay、Fork、snapshot、canonical import 与 integrity。领域 event 的 codec、validation、Binding extraction 和 projection 由 `agent/session/extension` 负责;Chatlog 与 Turn ontology 分别由 `agent/session/chatlog` 和 `agent/turn` 负责。 -Queue 可以被 Memoh durable adapter 放进数据库事务,也可以有 in-memory implementation;但它不进入 Run Machine,不成为 `MachineState` 字段。 +Run 与 Session 通过稳定 causation 和 Turn materialization identity 关联。Session `SourceEvents` 只引用同一 resolved Session stream 中的 EventID;Run facts 的 `SourceFactID` 进入 `FactMap.SourceFacts` 以及 materialization CommitID preimage,用于逐 transition coverage 和 exactly-once 收敛,不写入 `Session.SourceEvents`,也不要求 Chatlog payload 增加 carrier。 -### 2.6 Cross-domain materialization and finalization +### 2.5 Queue boundary -Session ES 是跨 Run 的语义主 authority。Run ES 可以短命的前提,是 Session 已吸收该 Run 的 lifecycle 和下一 Run/用户可见语义所需的内容;artifact、usage、trace 等长期 store 由 Session entry 引用或以同一 provenance 关联。Run log 不是长期 conversation/history 的后备来源。 +Queue、steer、follow-up、claim、priority、visibility 和 admission policy 由 Application 管理。Application 只在 Run 的安全输入边界将已持久化输入转换为 `AcceptInput`;terminal follow-up 创建新的 Turn 与 Run。queue state、claim identity 和调度 policy 不进入 `MachineState`、`TransitionRecord`、Session kernel 或 Artifact Core。 -需要跨 Run 保留的 Run 语义 materialize 到长期 domain: +### 2.6 Cross-domain materialization 与 settlement -```text -Run ES -ModelStepCompleted -ToolCallCompleted -RunEnded - │ - ├──→ Session ES - │ AssistantMessageAdded - │ ToolResultAdded - │ - ├──→ Artifact Store - │ files / large outputs / reasoning artifact - │ - └──→ Usage / Trace - cost / latency / debug -``` - -Run → Session/Artifact/Usage 的投影不能是 best-effort listener。Durable 场景必须有 transaction/outbox/inbox 或等价机制,并使用稳定 `causation_id` 做幂等: +`docs/design/agent-turn.md` 是 Turn→Run 顺序、Run→Session materialization、settlement 和恢复的唯一协议 authority;`docs/design/agent-session-extension.md` 定义所有语义 Session 写入与 Binding admission;`docs/design/agent-artifact.md` 定义 Binding 和 RetentionClaim。 ```text -Run source event ID = run_id + revision + index(或其 canonical digest) -Session source_event_id = same stable identity; unique/inbox key -Artifact source_event_id = same stable identity; unique/inbox key -Usage source_event_id = same stable identity; unique/inbox key - -Run event causation_id = inherited application/session/queue/Run cause -Session causation_id = preserved Run event causation_id +verified RunRecord + -> turn.MaterializeAll: revision 1..head,逐 transition 完整 coverage + -> versioned pure FactMapper + stable SourceFactID/CommitID/EventID + -> extension.SemanticAppender + -> committed Chatlog/Turn Session Events + Active retention claims + -> exact terminal RunEnded + -> run_settled + TurnCompleted/TurnFailed in one semantic commit ``` -必须存在 finalization barrier: +每个 materialization revision 在 matched commit、applied commit 或 deterministic empty-map coverage 后前进。Transactional outbox 可优化 durable delivery;Session facts、stable identities 与 verified `RunRecord` 提供恢复依据。任何语义 Session 写入都通过 `SemanticAppender`,由它完成 codec validation、Binding extraction、claim journal 和 unknown-outcome recovery。 -```text -RunEnded - ↓ -Session 追加 RunCompleted;确保长期消息/结果、artifact、usage/outbox 已提交或可幂等恢复 - ↓ -Session 追加 RunFinalized(或事务耦合的 application control-plane marker) - ↓ -Run ES execution log 才允许 archive / GC -``` - -`RunFinalized` 的语义归属是 Session/Application control plane,并携带 RunID 与 materialization watermark;它不是 `agent/run.Fact`,不参与 `MachineState` fold。它证明 Session 已能在不读取该 Run execution log 的情况下继续会话和构造后续 Run。 +Application 只有在完整 materialization coverage、terminal settlement 及其所引用 Artifact 的有效 retention 均已得到 durable evidence 后,才可按产品 archive policy 回收 Run records。Turn 的 `run_settled` 与这些 evidence 已形成完整 archive gate,无需增加第二个 Session finalization event。 ### 2.7 类型归属 | 类型或能力 | 所属层 | | --- | --- | | `sdk.Request`、`sdk.ModelResult`、`sdk.ToolDefinition`(provider-neutral 边界类型) | `sdk` | -| canonical JSON parser/value | `agent/jsonstable`(可被 `es`、`run`、`session` 使用) | +| canonical JSON parser/value | `agent/jsonstable`(可被 `es`、`run`、`session`、`artifact` 使用) | | generic event envelope/record/fold/digest mechanics | `agent/es` | | `ModelRequest`、`ModelResult`、`ToolDefinition`、`Usage`、`Message`、`MessagePart`(run persisted frozen values) | `agent/run` | | `Step`、`ToolCallState`、`AgentCommand`、`Fact`、`AgentEvent`、`TransitionRecord`、`Effect` | `agent/run` | | `Runtime`、`MemoryRuntime`、`Loop`、`runtimetest` | `agent/run` | -| `SessionID`、`EntryID`、session entry envelope、fork/replay/store | `agent/session` | -| queue item/claim/dedup/visibility mechanism | `agent/queue` | +| `SessionHeader`、`SessionCommit`、`SessionEvent`、resolved replay、fork、snapshot、store | `agent/session` | +| `Ref`、`Binding`、`BindingSet`、`RetentionClaim` | `agent/artifact` | +| static Module/Catalog、codec/upcast、`SemanticAppender`、pure projection | `agent/session/extension` | +| semantic Message/Input/Turn/Item ontology 与 Surface/Context projection | `agent/session/chatlog` | +| Turn→Run coordination、`FactMapper`、`MaterializeAll`、settlement | `agent/turn` | +| queue item/claim/dedup/visibility 和 steer/follow-up policy | Application/Product | | Request Planner 实现和 context transformer | Application/Product | | fixed model policy、step budget、malformed retry budget | Application/Product 或 run `ExecutionPolicy`;不进入 MachineState | | owner、fencing、lease、outbox、DB schema | Durable adapter/Application | @@ -625,7 +491,7 @@ Machine.Evolve 逐个折叠(机械) -> new MachineState Machine.Next(state) -> Effect ``` -`Next(state)` 根据当前事实产生至多一个待执行的 `Effect`。`Decide(state, command)` 校验一个意图并产出这次 transition 的完整事实序列——所有决策(是否接受、派生哪些后果、是否进入终态)都发生在这里,且只在提交时运行一次,输出即冻结。`Evolve(state, event)` 把单个事实机械地折叠进状态:它不读产品配置或 Loop policy,不含 IO,对 Decide 产出的每种事实全定义;replay 只依赖 versioned Evolve。Runtime 接受一个 command 后,把 Decide 的事实序列包装为同一 Revision 的 AgentEvent 组,与折叠后的新状态放在同一提交边界。两种 Runtime 不能各自复制规则;Loop 重新 `Load` 后再次调用 `Next`,不会依赖一次提交响应中的 effect。 +`Next(state)` 根据当前事实产生至多一个待执行的 `Effect`。`Decide(state, command)` 校验一个意图并产出这次 transition 的完整事实序列——所有决策(是否接受、派生哪些后果、是否进入终态)都发生在这里,且只在提交时运行一次,输出即冻结。`Evolve(state, event)` 把单个事实机械地折叠进状态:它不读产品配置或 Loop policy,不含 IO,对 Decide 产出的每种事实全定义;replay 只依赖 versioned Evolve。Runtime 接受一个 command 后,把 Decide 的事实序列包装为同一 Revision 的 AgentEvent 组,与折叠后的新状态放在同一提交边界。两种 Runtime 不能各自复制规则;Loop 重新 `Load(ctx, runID)` 后再次调用 `Next`,不会依赖一次提交响应中的 effect。 决策与折叠的分工是协议的兼容边界:Machine 的决策规则(自动关闭、终态转换、command disposition)可以随版本演进,因为历史事件已把这些决策的结果记录在案;Evolve 与事件编码一起构成永久兼容契约,已发布 SchemaVersion 的折叠语义不再修改。 @@ -642,12 +508,12 @@ NextStep(input) 当前 Run 仍 active 且处于可接收边界;输入进入下一次 ModelStep 的规划上下文 terminal follow-up - Memoh admission -> InitializeRun(new RunID) -> AcceptInput(input) + Application 分配 stable RunID/causation -> Runtime.Create(NewRun) -> AcceptInput(input) ``` `AgentInput` 只有稳定的 `InputID` 和不可变 payload,不包含 queue item、priority、order、claim 或 lease。`NextStep` 构造 `AcceptInput` command;被 Runtime 接受后产出 `InputAccepted` 事实,与新的 MachineState 一起提交,Memoh 可以在同一事务中把 queue claim 标记为 applied。 -Run admission 使用 `InitializeRun(RunID)` 建立最小 Revision-0 state,然后把初始用户输入作为该 Run 的第一条 `AcceptInput` transition 提交。Application 负责 Session/Queue admission 和新 Run 的身份分配;Run replay 不依赖从当前产品配置重新计算 initial state。 +Application/turn 在 admission 中分配稳定的 RunID 和 causation,并调用 `Runtime.Create(NewRun)` 执行 run-owned creation protocol。Create 按 NewRun 的 SchemaVersion 构造 immutable Revision-0 `RunHeader`;初始用户输入随后作为该 Run 的第一条 `AcceptInput` transition 提交。Run replay 不依赖从当前产品配置重新计算 initial state。 Machine 不在 ToolStep 执行中接受 `NextStep`,也不把 terminal follow-up 解释成旧 Run 的状态变化。输入的具体文本如何进入 Planner 生成的 `sdk.Request` 仍由 Request Planner 决定;Loop 在提交前冻结为 agent `ModelRequest`,Machine 只保证输入边界和一次性接受语义。 @@ -681,7 +547,7 @@ RunStopped application 明确停止 RunFailed provider、step 或未知外部 effect 导致失败 ``` -等待是 Loop 的当前返回结果,不是单独的持久化 Run 状态。业务取消通过 Memoh/application 的控制事件提交为 `RunStopped`;取消 Loop 的 context 只结束当前执行尝试,不自动修改 Run 状态。 +等待是 Loop 的当前返回结果;持久化 Run 状态继续由 MachineState 表达。业务取消通过 Memoh/application 的控制事件提交为 `RunStopped`;取消 Loop 的 context 只结束当前执行尝试,不自动修改 Run 状态。 所有 terminal 状态都必须带 `MachineState.Result`;`RunActive` 的 Result 为空。`RunFailed` 的 Failure 至少包含稳定的 Class,未知外部 effect 还包含对应 CallID。 @@ -754,7 +620,7 @@ RunEnded 终态:Status 为 RunCompleted、RunStopped 或 RunFai `AgentEvent` 与新的 `MachineState` 在同一个原子提交中写入,具有 authority 分配的 (Revision, Index) 身份、canonical digest 和产生它的 CommandID。`AgentEvent` 可供 replay、projection、审计和 OpenTelemetry 使用。 -新 Run 的初始用户输入也必须作为 `AcceptInput` transition 记录,不存在绕过 Runtime.Commit 的 seed input。Run 创建本身由 `RunHeader` 表达;输入接受属于 Run ES。 +新 Run 的初始用户输入也必须作为 `AcceptInput` transition 记录,不存在绕过 Runtime.Commit 的 seed input。`NewRun` 经 `Runtime.Create` 建立 `RunHeader`;输入接受属于 Run domain。 二者的关系固定为: @@ -767,7 +633,7 @@ Runtime EvaluateCommit: 幂等/类别校验 + Machine.Decide + Machine.EvolveVer 意图与事实由 Decide 显式转换,日志永远记录结果而非请求:`RejectToolCall` 记录为 `ToolCallFailed{permission_denied}`;`CancelRun` 记录为 `RunEnded{RunStopped, cancelled}`;host step limit 记录为 `StopRun{step_limit} -> RunEnded{RunStopped, step_limit}`。 -`PrepareModelRequest.RequestDigest` 和 `ResponseDigest` 分别是请求/响应 payload 的内容摘要,不是提交身份。`ApproveToolCall`/`RejectToolCall` 使用 `DigestToolResponseDecision(kind, decision, reason)`,`SubmitToolResponse` 使用 `DigestToolResponsePayload(payload)`;Decide 必须校验 digest 与实际 decision/payload 匹配。一个 command 被重试时复用同一 CommandID 和 digest;Runtime 不会为重试生成第二组 AgentEvent。 +`PrepareModelRequest.RequestDigest` 和 `ResponseDigest` 分别是请求/响应 payload 的内容摘要;CommandID 与 command digest 提供提交身份。`ApproveToolCall`/`RejectToolCall` 使用 `DigestToolResponseDecision(kind, decision, reason)`,`SubmitToolResponse` 使用 `DigestToolResponsePayload(payload)`;Decide 必须校验 digest 与实际 decision/payload 匹配。一个 command 被重试时复用同一 CommandID 和 digest;Runtime 不会为重试生成第二组 AgentEvent。 Effect 是 Loop 动作: @@ -779,7 +645,7 @@ WaitForResponse(ResponseRequests) WaitForExecutionRecovery ``` -终态由 `MachineState.Status` 表示;Effect 只是待执行动作,既可以是模型调用,也可以是工具调用或等待,该名称不表示一定产生外部副作用。Effect 不写入权威状态;Loop 在每次 `Load` 后由 MachineState 重新得到它们。 +终态由 `MachineState.Status` 表示;Effect 只是待执行动作,既可以是模型调用,也可以是工具调用或等待,该名称不表示一定产生外部副作用。Effect 不写入权威状态;Loop 在每次 `Load(ctx, runID)` 后由 MachineState 重新得到它们。 ### 3.7 共享规则 @@ -795,12 +661,12 @@ WaitForExecutionRecovery 6. `StartToolCall` 只能作用于 Pending Call,产出 `[ToolCallStarted]`。 7. `SubmitToolResult` 只能作用于 Executing Call,产出 `[ToolCallCompleted]`。`SubmitToolResponse` 只能作用于 Waiting(ExternalResponse) Call,产出 `[ToolCallAnswered]`。 8. `SubmitToolFailure`(known)可以作用于 Pending 或 Executing Call:Pending 的已知失败使用空 Grant,Executing 的必须使用对应 Grant,产出 `[ToolCallFailed{Known}]`。`SubmitToolFailure`(unknown)只能作用于 Executing Call,产出 `[ToolCallFailed{Unknown}, RunEnded{RunFailed, effect_unknown}]`,`RunResult.Failure` 记录 `effect_unknown` 和对应 CallID;scanner 提交它时必须先有实现内部的失效执行记录。 -9. `ApproveToolCall` 必须匹配目标 Waiting(Approval) Call 保存的 ResponseID 和 kind,产出 `[ToolCallApproved]`(Call 变为 Pending,Loop 随后执行)。`RejectToolCall` 同样必须匹配,产出 `[ToolCallFailed{Known, permission_denied}]`。日志记录的是结果事实,不是请求本身。 +9. `ApproveToolCall` 必须匹配目标 Waiting(Approval) Call 保存的 ResponseID 和 kind,产出 `[ToolCallApproved]`(Call 变为 Pending,Loop 随后执行)。`RejectToolCall` 同样必须匹配,产出 `[ToolCallFailed{Known, permission_denied}]`。该响应 transition 记录已接受的结果事实;原请求保留在 Waiting Call state。 10. 一次响应只推进对应 Call,不能修改其他 Call;ResponseID 和 kind 必须匹配该 Call 保存的请求。响应 payload 的 digest 用于内容冲突检测,不需要等于请求 payload 的 digest。 11. 使 ToolStep 内最后一个 Call 到达可关闭终态的 command,其事实序列追加 `ToolStepClosed`。ToolStep 关闭前不能创建下一 ModelStep。 12. `CancelRun` 只能作用于非 terminal Run,产出 `[RunEnded{RunStopped, cancelled}]`;`StopRun` 只能记录 host-owned 非取消停止原因,目前为 `[RunEnded{RunStopped, step_limit}]`。 13. `AcceptInput` 只能作用于 active 且没有当前 Step 的 Run,且 `InputID` 不得与当前 `PendingInputs` 中任何输入重复;重复 ID 是 identity conflict,不能记录一个 Evolve 会丢弃的 no-op fact。接受后产出 `[InputAccepted]`。Planner 由 `PlanningHint.Inputs` 收到这些输入并在 `RequestPlan.InputIDs` 中明确消费它们;遗漏或伪造 ID 的 `PrepareModelRequest` 被拒绝。 -14. `InitializeRun(RunID)` 只在 application admission 创建新 Run 时使用;它建立最小初始 `MachineState`(Revision=0),不包含 initial input、fixed model、limits 或产品配置。Application 随后通过正常 `Runtime.Commit` 提交首个 `AcceptInput`。 +14. `Create(NewRun)` 是 Run 的 creation protocol:它按 `NewRun.SchemaVersion` 建立最小 Revision-0 `MachineState` 和 immutable `RunHeader`,不接收 initial input、binding、fixed model、limits 或产品配置。Application 随后通过正常 `Runtime.Commit` 提交首个 `AcceptInput`。 `InputID` 在一个 Run 内唯一。相同 `InputID` 和相同 payload 的重复 `AcceptInput` 是语义 no-op,Runtime 返回原已接受的事件组;相同 ID 携带不同 payload 返回冲突。Memoh 的 queue claim 仍负责防止同一个 queue item 被多个输入入口同时消费。 @@ -829,7 +695,7 @@ WaitForExecutionRecovery #### 3.7.3 终态与竞态 -终态按 Runtime 的线性化顺序确定:Cancel 先提交则 RunStopped;Unknown 先提交则 RunFailed,之后的 Cancel 不改变终态。Run 进入 terminal 后,其他并行 Call 的 grant 立即失效,Loop 取消仍在运行的 worker;迟到的完成提交返回 terminal/stale 错误,只能写实现级审计,不能再改变 MachineState;Memoh 必须把这类审计投影到产品可见的 history/审计视图——该工具的外部 effect 已经发生,只落内部日志会让会话记录与外部世界不一致。并行 Call 不引入额外的 settling 状态。 +终态按 Runtime 的线性化顺序确定:Cancel 先提交则 RunStopped;Unknown 先提交则 RunFailed,之后的 Cancel 不改变终态。Run 进入 terminal 后,其他并行 Call 的 grant 立即失效,Loop 取消仍在运行的 worker;迟到的完成提交返回 terminal/stale 错误,不能再改变 MachineState。Application 可将已证实的迟到 external effect 作为独立审计 ontology 经 `SemanticAppender` 记录;它不伪造 Run fact。并行 Call 不引入额外的 settling 状态。 `RunStopped` 或 `RunFailed` 的 `RunResult` 保留最近一次已接受的模型结果(如有);`RunCompleted` 的 `RunResult.Model` 是产生正常终态的那次模型结果。取消或未知 effect 不会伪造新的模型结果。 @@ -895,7 +761,7 @@ Failed(Unknown, failure) 这些状态只保存会影响恢复决策的事实。工具内部 stdout、下载百分比、HTTP 字节数等观察信息只走 EventSink,不写 MachineState。 -状态字段必须保持互斥且可校验:Pending/Executing 的 Result、Failure、Waiting 都为空;Waiting 必须带 ResponseRequest 且 policy 为 Approval 或 ExternalResponse;Completed 必须带 Result;Failed 必须带 Failure,Unknown outcome 必须使用 `effect_unknown` 且 Run 必须同时为 RunFailed。`Machine.Evolve` 和 Runtime.Load 都必须拒绝非法组合;approval accepted 清除 Waiting 并变为 Pending,answer 清除 Waiting 并变为 Completed,reject 清除 Waiting 并变为已知 Failed。 +状态字段必须保持互斥且可校验:Pending/Executing 的 Result、Failure、Waiting 都为空;Waiting 必须带 ResponseRequest 且 policy 为 Approval 或 ExternalResponse;Completed 必须带 Result;Failed 必须带 Failure,Unknown outcome 必须使用 `effect_unknown` 且 Run 必须同时为 RunFailed。`Machine.Evolve` 和 `Runtime.Load(ctx, runID)` 都必须拒绝非法组合;approval accepted 清除 Waiting 并变为 Pending,answer 清除 Waiting 并变为 Completed,reject 清除 Waiting 并变为已知 Failed。 ### 4.3 ToolCall 执行规则 @@ -910,7 +776,7 @@ Failed(Unknown, failure) 7. 所有 Call 到达 Completed 或已知 Failed 时,Decide 在该次提交的事实序列中追加 `ToolStepClosed`。 8. Executing Call 在执行权失效且结果未提交时不能自动重做;Runtime 记录 Unknown 并终止 Run。 -并行是 Loop 的执行策略,不是 ToolStep 固有语义。Machine 只返回可执行的 Pending Call,不推断工具之间是否存在 effect ordering;application 只有在确认一组工具允许并行时才配置大于 1 的并行度。一个 Loop 执行组使用同一份策略: +并行度由 Loop execution policy 决定,ToolStep 语义保持逐 Call 状态。Machine 只返回可执行的 Pending Call,不推断工具之间是否存在 effect ordering;application 只有在确认一组工具允许并行时才配置大于 1 的并行度。一个 Loop 执行组使用同一份策略: ```text Sequential @@ -920,7 +786,7 @@ BoundedParallel(n) 在 API 中统一表示为 `ExecutionPolicy{MaxParallel}`:`1` 表示 Sequential,`n>1` 表示 BoundedParallel(n),`0` 在 Loop 创建时归一化为 `1`,负值在 Loop 创建时拒绝。`Parallel`(不设上限)不作为默认行为;若未来需要,必须另行规定资源上限。 -`MaxParallel` 只限制一个 Loop 本次实际启动的 worker 数量,不是跨进程的全局并发计数。Runtime 仍以每个 Call 的 start grant 防止重复执行;Memoh 如果需要全局资源限额,必须在自己的调度层另行实现。 +`MaxParallel` 只限制一个 Loop 本次实际启动的 worker 数量。跨进程全局资源限额由 Memoh 调度层实现;Runtime 仍以每个 Call 的 start grant 防止重复执行。 同一 ToolStep 的结果投影仍按原始 Call 顺序;本版本不定义 Call 之间的依赖边,需要前置结果的调用由后续 ModelStep 产生。 @@ -974,20 +840,22 @@ effect_unknown ### 5.1 Runtime 的职责 -Runtime 只回答两个问题: +Runtime 管理以 RunID 定址的 Run collection,并回答四个问题: ```text -当前权威 MachineState 是什么? +如何以 versioned NewRun 创建 immutable Revision-0 RunHeader? +指定 RunID 的当前权威 MachineState 是什么? 一个 AgentCommand 如何安全地提交成一个 TransitionRecord,并对外产生 AgentEvent 流? +如何取得指定 Run 的一致、已验证 RunRecord(header、snapshot 与完整 transition log)? ``` -它不组装 prompt,不调用模型或工具,不定义 Machine 规则,不实现 queue policy。Runtime 可以在同一事务中更新 Memoh 的 history、queue projection、response record 和 outbox,但这些是 adapter 的原子投影,不是 Runtime 的语义职责。 +Runtime 的语义范围是 Run access 与 atomic commit。它不组装 prompt、不调用模型或工具、不定义 Machine 规则,也不实现 queue 或 Session policy。Durable adapter 可以在保存 Run transition 的同一事务中写入 delivery outbox、queue claim outcome 或其他私有协调记录;这些记录触发 `agent/turn` 的恢复/materialization,语义 Session commit 仍经 `extension.SemanticAppender`。 -`Commit` 必须在 authority 的临界区内调用共享的 `EvaluateCommit`(内部执行幂等/类别校验、`Machine.Decide` 和 versioned `Machine.Evolve`),因为“读取状态、验证 command、计算事实与新状态、保存完整 transition、更新 projection”不能在 durable 实现中拆成几个由 Loop 拼接的公开操作。这个必要的原子边界不等于 Runtime 拥有 Machine 规则;规则仍只有 agent 一份,也不等于 Runtime 拥有 Memoh 的产品数据。 +`Commit` 必须在 authority 的临界区内调用共享的 `EvaluateCommit`(内部执行幂等/类别校验、`Machine.Decide` 和 versioned `Machine.Evolve`),使“读取状态、验证 command、计算事实与新状态、保存完整 transition”形成一个原子操作。Machine 规则由 `agent/run` 唯一定义,adapter 只实现持久化和私有 control plane。 -因此 Runtime 的接口很小,但一次 `Commit` 的事务范围可以很大:它必须让一个 AgentCommand、产出的 `TransitionRecord` 及其必要的产品投影一起成功或一起失败;这不意味着 Runtime 获得了 history、queue 或 prompt 的所有权。`CommitResult.Events` 只是该 transition 的事件流视图,方便 Loop、UI 和 observability 消费;Runtime 的 authority storage 是完整 transition aggregate。 +`CommitResult.Events` 是该 transition 的事件流视图,供 Loop、UI 和 observability 消费;Runtime 的 canonical commit storage 是完整 `TransitionRecord`。Session materialization 读取 verified `RunRecord`,按 `agent/turn` 的 stable identity 和 coverage 协议独立收敛。 -Run execution 状态的 authority 是持久化的 **MachineState**:提交验证与 Loop 执行从它读取,崩溃恢复通过 `Load` 直接取回它——durable continuation 是"执行状态可恢复",不要求"执行状态由历史事件重建"。`RunHeader + TransitionRecord log` 是与状态同事务原子写入的 **canonical record**:它记录每次接受的 command 产出的完整事实组,服务审计、观察、事件投影,以及经验证的 run 导入/迁移(§5.1.1 规则 6);它不替代 Session ES 的长期语义 authority。对任意 Revision N,状态应当等于 `RunHeader.InitialState` 按 `flatten(TransitionRecord[].Events)` 调用 versioned `Evolve` fold 到 N 的结果——这是同事务双写的正确性性质,由 conformance 的 replay-fold 等价测试守护;两者出现分歧说明实现存在缺陷(Evolve bug、越权写入或存储损坏),处理方式(从备份恢复状态、或人工 fold 核对后修复)是运维决定,本规范不定义自动仲裁,也不定义 halt 协议。 +持久化 **MachineState** 是 Run execution authority:提交验证与 Loop 从它读取,崩溃恢复通过 `Load(ctx, runID)` 直接取回。`RunHeader + TransitionRecord log` 是 **canonical record**:Create 原子保存 header 与 Revision-0 state;每次 Commit 原子保存下一个 MachineState 与对应完整 TransitionRecord。它服务审计、观察、materialization 和经验证的 Run 导入/迁移(§5.1.1 规则 6)。对任意 Revision N,状态必须等于从 `RunHeader.InitialState` 对 `flatten(TransitionRecord[].Events)` 执行 versioned `Evolve` fold 的结果。conformance 的 replay-fold 等价测试守护此性质;分歧表示 Evolve bug、越权写入或存储损坏,修复和恢复由运维流程处理。 这个双写模型成立的稳定条件(本规范的规范性条款): @@ -996,17 +864,17 @@ Run execution 状态的 authority 是持久化的 **MachineState**:提交验证 3. 事实自包含:折叠一条事实所需的全部信息在事实自身与折叠前状态之内,不访问外部系统,不重新计算依赖当前代码版本的派生值(digest 一律在 Decide 时算好并携带在事实中)。已实现的自包含保持;它是 run 导入/迁移的前提。 4. 持久化值归属稳定:Run event 和 MachineState 只保存 run-owned frozen values;任何来自 SDK/provider/application 的引用在进入 Runtime 前必须被 canonicalize + detach,返回给 caller 的 snapshot/event 也必须是独立副本。 -Runtime 实现还必须在代码层面维护这些条件:`EvaluateCommit` 对 Decide 产出的 facts 做 `snapshotFact` 后再 fold/persist,并构造带 transition digest 的 `TransitionRecord`;`Load`、`CommitResult` 和 AlreadyApplied replay 返回的 snapshot/event 不得共享 authority 内部引用;durable adapter 可以用数据库事务替代 mutex,但不能把未冻结 SDK 对象、浅拷贝 snapshot 或 caller-owned bytes 写入事件表/状态表。 +Runtime 实现还必须在代码层面维护这些条件:`EvaluateCommit` 对 Decide 产出的 facts 做 `snapshotFact` 后再 fold/persist,并构造带 transition digest 的 `TransitionRecord`;`Load(ctx, runID)`、`CommitResult`、AlreadyApplied replay 和 `Record(ctx, runID)` 返回的 snapshot/event/record 均为 detached value;durable adapter 可以用数据库事务替代 mutex,但不能把未冻结 SDK 对象、浅拷贝 snapshot 或 caller-owned bytes 写入事件表/状态表。 `FoldRun(header, records)` 是导入/迁移与诊断的入口:验证 header 与逐 transition 的 digest 链后折叠出状态。它用于三处——跨部署迁移时目标侧重建初始权威状态(不信任上传的 snapshot)、conformance 的等价测试、以及运维按需的一致性核对。日常执行路径不调用它。 重新收紧为日志权威(状态可丢弃、自动仲裁、水位守护)的触发条件:执行历史 fork 成为产品功能,或出现"状态存储不可信而日志存储可信"的实际部署形态。当前两者均无需求方;本规范此前的日志权威版本可在版本历史中查阅。 -一个 Runtime 实例服务一个 Run;多个 Run 由上层创建多个 Runtime 实例。Run 的创建和身份分配由 Application admission 完成;admission 调用 `InitializeRun`,构造并原子保存 `RunHeader`。Runtime 从这个已存在的 header 开始,Loop 是对该 Run 的一次进程执行。 +一个 Runtime 实现管理多个按 RunID 定址的 Run。Application/turn 在 admission 中分配稳定 RunID 和 causation;`Runtime.Create(NewRun)` 运行 run-owned creation protocol 并持久化 Revision-0 header。Loop 是指定 RunID 的一次进程执行。 ### 5.1.1 RunHeader -`RunHeader` 是正式持久协议,不是可从当前代码、当前默认值或当前产品配置重新生成的临时参数: +`RunHeader` 是由 versioned Create protocol 产生的正式持久协议,其 bytes 永久绑定创建时输入: ```go type RunHeader struct { @@ -1015,7 +883,7 @@ type RunHeader struct { InitialStateVersion uint16 InitialState MachineState InitialStateDigest es.Digest - CausationID es.CausationID // creating session/queue/application operation + CausationID es.CausationID // creating turn/application operation HeaderDigest es.Digest } ``` @@ -1023,30 +891,32 @@ type RunHeader struct { 规范性要求: 1. `RunHeader` 创建后 immutable;普通 `Runtime.Commit` 不修改它。 -2. `InitialState` 必须是 `InitializeRun(RunID)` 产生的最小 Revision-0 state,不包含 initial input、fixed model、limits、session history 或产品配置。 +2. `InitialState` 由 `Runtime.Create(NewRun)` 按 NewRun 的 SchemaVersion 产生最小 Revision-0 state,不包含 initial input、binding、policy、fixed model、limits、session history 或产品配置。 3. initial input 通过 Revision 1 的正常 `AcceptInput` transition 提交。 4. `InitialStateDigest` 绑定 initial state 的 canonical wire bytes;`HeaderDigest` 绑定 header 中除自身外的全部协议字段。 -5. durable adapter 必须让 header 创建、Run identity admission 和必要的 application causation 记录原子或可幂等恢复。 -6. local Run 上传/迁移时传输 `RunHeader + TransitionRecord log + referenced artifacts`;目标 runtime 先验证 header/log,再重建 projection,不能信任上传的 MachineState snapshot。 +5. durable adapter 必须让 Create 的 header 写入与必要的 application causation 记录原子或可幂等恢复。 +6. local Run 上传/迁移时,Run package 只传输并验证 `RunHeader + TransitionRecord log`;目标 runtime 重建 projection,不信任上传的 MachineState snapshot。跨 package archive 由 Application coordinator 另行组合 Session canonical archive 与经 `agent/artifact` 验证的 `BindingManifest`/active claims。 -`RunHeader.CausationID` 只提供跨 domain 关联,不让 `agent/run` import `agent/session` 或 `agent/queue` 的具体 ID 类型。Application 可以维护 SessionID/EntryID/QueueItemID 到 causation ID 的映射。 +`NewRun` 是 Create 的唯一 caller-supplied creation value:`NewRun{SchemaVersion, RunID, CausationID}`。`BuildNewRun` 产生当前支持版本的值,`ValidateNewRun` 校验 version 与稳定文本身份;Runtime 按 version 派发构造 immutable header。`RunHeader.CausationID` 只提供跨 domain 关联,`agent/run` 不 import Session、Turn 或 Application 的具体 ID 类型;`agent/turn` 将 persisted `NewRun.CausationID` 与 start semantic commit 关联。 ### 5.2 最小接口 ```go type Runtime interface { - Load(context.Context) (RuntimeSnapshot, error) - Commit(context.Context, CommitRequest) (CommitResult, error) + Create(context.Context, NewRun) (CreateResult, error) + Load(context.Context, RunID) (RuntimeSnapshot, error) + Commit(context.Context, CommitRequest) (CommitResult, error) // CommitRequest.Command carries RunID + Record(context.Context, RunID) (RunRecord, error) } ``` -`Load` 是纯读取,只返回权威状态和 Revision;它不创建 Attempt、不取得 lease,也不因为另一个 execution 正在运行而返回 busy。`Commit` 接受一个 AgentCommand;Runtime 在自己的同步/事务边界内调用共享 `EvaluateCommit`、保存结果并写入 AgentEvent 组。只有 start command 的 `Commit` 可以建立执行占用并返回授权,不增加第三个“执行”方法。 +`Create` 验证 `NewRun`,按其 SchemaVersion 构造 immutable Revision-0 header,并将其作为指定 RunID 的唯一创建记录写入 collection。相同 header 的重试返回 `CreateResult{Created:false}`;同一 RunID 的不同 header 返回 `ErrCreateConflict`。`Load` 是指定 RunID 的纯读取,只返回权威状态和 Revision;它不创建 Attempt、不取得 lease,也不因为另一个 execution 正在运行而返回 busy。`Commit` 由 envelope 中的 RunID 定址,接受一个 AgentCommand;Runtime 在该 Run 的同步/事务边界内调用共享 `EvaluateCommit`、保存结果并写入 AgentEvent 组。`Record` 返回同一一致性边界内读取并验证的 header、snapshot 和完整 transition records。缺失的 RunID 上的 `Load`、`Commit` 与 `Record` 返回 `ErrRunNotFound`。只有 start command 的 `Commit` 可以建立执行占用并返回授权,不增加独立“执行”方法。 -因此 Executing 不是 Runtime error。Loop 从 snapshot 调用 `Machine.Next` 后得到 `WaitForExecutionRecovery`;ToolStep 同时存在 Executing 与 Pending Call 时,Machine 仍返回可执行的 Pending Call;同时存在 Executing 与 Waiting Call 时,Machine 仍返回等待请求。不能用一个 run-level busy 锁住整个 ToolStep。 +Executing 是合法 MachineState。Loop 从 snapshot 调用 `Machine.Next` 后得到 `WaitForExecutionRecovery`;ToolStep 同时存在 Executing 与 Pending Call 时,Machine 仍返回可执行的 Pending Call;同时存在 Executing 与 Waiting Call 时,Machine 仍返回等待请求。Runtime 保留逐 Call progress,不使用 run-level busy 锁覆盖整个 ToolStep。 请求构造不在 Runtime 接口中。Loop 使用 `NeedModelRequest` 提示调用 Request Planner,再把完整请求作为 `PrepareModelRequest` 提交。 -Runtime 的语义范围仍然只有 authority 和 commit。Durable application adapter 为了保持 Run transition、MachineState projection、Session materialization outbox 和 artifact/usage projection 的一致性,可以在自己的数据库事务内一起写入这些投影;这不是 Runtime 对外暴露的通用业务 API,也不让 Runtime 获得 prompt、queue 或 session history 的所有权。 +Durable application adapter 可以在自己的数据库事务内把 Run transition、MachineState projection 与 materialization delivery outbox 一起持久化。outbox 只优化投递;`agent/turn.MaterializeAll` 仍从 verified `RunRecord` 重建 coverage,并通过 `SemanticAppender` 写入 Session。Runtime 不暴露通用业务写入 API,也不拥有 prompt、queue、Session history 或 Artifact retention。 ### 5.3 Snapshot、Commit 和执行授权 @@ -1071,8 +941,6 @@ type CommandEnvelope struct { Type string RunID RunID ID CommandID - // Opaque cross-domain lineage; Run Machine does not interpret it. - CausationID es.CausationID Digest Digest Command AgentCommand } @@ -1080,8 +948,8 @@ type CommandEnvelope struct { // AgentEvent carries one fact produced by an accepted command. All events of // one transition share the same Revision, CommandID and CommandDigest; Index // orders them within the transition. Identity and ordering are assigned by the -// authority and are not supplied by callers. Run admission has a separate -// record because it initializes a new Run rather than advancing an existing one. +// authority and are not supplied by callers. Revision-0 creation is represented +// by NewRun/CreateResult rather than by a transition. type AgentEvent struct { SchemaVersion uint16 Type string @@ -1090,7 +958,6 @@ type AgentEvent struct { Index uint16 CommandID CommandID CommandDigest Digest // digest of the accepted command; idempotent replay compares against it - CausationID es.CausationID // inherited from accepted command Digest Digest // canonical digest of this fact Fact Fact } @@ -1114,17 +981,37 @@ type CommitResult struct { Grant ExecutionGrant // 仅 Accepted 的 start command 会返回;AlreadyApplied 为空 } +type NewRun struct { + SchemaVersion uint16 + RunID RunID + CausationID es.CausationID +} + +type CreateResult struct { + Header RunHeader + Created bool +} + +// RunRecord is a consistent verified read of one Run. +type RunRecord struct { + Header RunHeader + Snapshot RuntimeSnapshot + Transitions []TransitionRecord +} + func EncodeCommand(CommandEnvelope) ([]byte, error) // 不包含 Digest 字段 func DigestCommand(schemaVersion uint16, typ string, command AgentCommand) (Digest, error) func EncodeFact(schemaVersion uint16, typ string, fact Fact) ([]byte, error) func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) -func BuildRunHeader(RunID, es.CausationID) (RunHeader, error) +func BuildNewRun(RunID, es.CausationID) (NewRun, error) +func ValidateNewRun(NewRun) error +func BuildRunHeaderFromNewRun(NewRun) (RunHeader, error) func ValidateRunHeader(*RunHeader) error -func DigestRunHeader(*RunHeader) (es.Digest, error) func BuildTransitionRecord([]AgentEvent) (TransitionRecord, error) func ValidateTransitionRecord(*TransitionRecord) error func DigestTransitionRecord(*TransitionRecord) (Digest, error) -func FoldTransitions(header RunHeader, records []TransitionRecord) (MachineState, uint64, error) +func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineState, uint64, error) +func FoldRun(header *RunHeader, records []TransitionRecord) (MachineState, uint64, error) func DigestRequest(ModelRequest) (Digest, error) func DigestToolDefinition(ToolDefinition) (Digest, error) func DigestToolSpec(ToolSpec) (Digest, error) @@ -1138,9 +1025,9 @@ func DeriveResponseCommandID(RunID, StepID, CallID, ResponseID) CommandID func DeriveInputCommandID(RunID, InputID) CommandID ``` -`ExecutionGrant` 是 Runtime 返回的 opaque capability,只用于证明当前 Loop 获得了执行许可;调用方只保存并原样传回,不依赖其内容。它不是 Step 的业务字段,不暴露 AttemptID、FenceToken、lease 或数据库类型,也不是用户认证凭证。Runtime 必须生成不可预测且绑定到单个 Step/Call 和当前执行所有者的值,并在完成 command 中校验它。`run.MemoryRuntime` 可以用 mutex 加随机 generation 实现它;durable adapter 可以用 owner/fence/lease 实现它。 +`ExecutionGrant` 是 Runtime 返回的 opaque capability,只用于证明当前 Loop 获得了执行许可;调用方只保存并原样传回,不依赖其内容。Step 业务字段、AttemptID、FenceToken、lease、数据库类型和用户认证凭证均不使用该值表达。Runtime 必须生成不可预测且绑定到单个 Step/Call 和当前执行所有者的值,并在完成 command 中校验它。`run.MemoryRuntime` 可以用 mutex 加随机 generation 实现它;durable adapter 可以用 owner/fence/lease 实现它。 -`Attempt` 只表示一次进程对当前 ModelStep 或 ToolCall 的执行占用,不是 MachineState,也不是恢复边界。一个 Step 可以先后有多个 Attempt;旧 Attempt 失效后,新的 Loop 重新读取同一个 Step。MemoryRuntime 的 Attempt 是内存中的占用记录,durable adapter 的 Attempt 是私有 lease/fence 记录。 +`Attempt` 表示一次进程对当前 ModelStep 或 ToolCall 的执行占用,属于 Runtime 私有 control plane。MachineState 与 Step 提供语义状态和恢复边界。一个 Step 可以先后有多个 Attempt;旧 Attempt 失效后,新的 Loop 重新读取同一个 Step。MemoryRuntime 的 Attempt 是内存中的占用记录,durable adapter 的 Attempt 是私有 lease/fence 记录。 grant 规则固定为: @@ -1197,7 +1084,7 @@ func EvaluateCommit( `EvaluateCommit` 用 `DecisionKind` 表达结果;`Runtime.Commit` 把非成功结果映射为对外错误:`DecisionConflict -> ErrCommandConflict`、`DecisionStale -> ErrStaleRuntime`、`DecisionTerminal -> ErrRunTerminal`。Loop 与 ingress 只依赖这三个错误值和 `CommitStatus`,不接触 DecisionKind。 -新 Run admission 使用 `InitializeRun(RunID)` 建立最小 `MachineState`(Revision=0);初始用户输入作为该 Run 的第一条 `AcceptInput` transition 进入日志。不存在持久化 `RunConfig` 或绕过 Commit 的 `RunSeed`。普通 Runtime 只实现已有 Run 的 `Load/Commit`,admission 路径负责创建 RunID、构造并持久化 `RunHeader`。 +新 Run admission 由 Application/turn 分配 stable RunID/causation,并由 `Runtime.Create(NewRun)` 建立最小 `MachineState`(Revision=0)和 immutable header;初始用户输入作为该 Run 的第一条 `AcceptInput` transition 进入日志。不存在持久化 `RunConfig` 或绕过 Commit 的 `RunSeed`。Create 不接收 input、binding 或 policy。 Commit 的 effectively-once 只针对状态提交。事务已提交但响应丢失时,Loop 使用相同 CommandID 和 digest 重放;`CommitAlreadyApplied` 不能重新授予工具执行权,也不能重复 history、queue action、计数或 outbox。对于 start command,AlreadyApplied 的 `Grant` 必须为空;新 Loop 要等待原执行或 recovery,不能把重放当成新的 start。外部工具 effect 不因此变成 exactly-once。 @@ -1256,7 +1143,7 @@ canonical 编码和 digest 函数由 agent 提供;Memoh 只保存和比较结 已发布 `SchemaVersion` 的 canonical 编码和 digest 规则永久冻结;字段增删只能进入新的 SchemaVersion,旧事件按其自带版本校验。同一个 Run 不允许由写入不同 SchemaVersion 的进程混跑:升级窗口内先全量部署可读写新版本的代码,再开始写入新版本;否则同一 command 的重放会因编码不同被误判为 `ErrCommandConflict`。 -CommandEnvelope 和 AgentEvent 的 `SchemaVersion`、`Type`、`CausationID` 是 Run 持久化协议字段;Type 必须与 sealed AgentCommand/Fact 的具体变体一致,未知版本或类型直接拒绝。`CausationID` 是跨 domain 的 opaque lineage:Application 可将 session entry、queue claim、前一 Run event 或 system recovery record 关联到 command;Run Machine 不解释其内容。接受 command 后,其产出的 AgentEvent 继承该 causation ID。`agent/run` 必须提供正式 wire codec:decode 时先 canonicalize 整个 document,再读 `Type`,恢复具体 command/fact variant,校验 command/fact digest,并要求 decoded value 重新 canonical marshal 后与输入 canonical document 等价;不能依赖 `encoding/json` 自动反序列化 interface 字段,也不能接受 duplicate key、大小写模糊字段名或其他会被 Go decoder 合并/宽容的形态。`DigestCommand`/`DigestFact` 对 `SchemaVersion`、`Type`、causation 和内容做 canonical digest,但不把 `Digest` 字段自身纳入摘要,保证 durable scanner、MemoryRuntime 和不同进程使用同一身份规则。`Revision` 只用于 authority 的 CAS,不进入任何 digest。 +CommandEnvelope 和 AgentEvent 的 `SchemaVersion`、`Type` 是 Run 持久化协议字段;Type 必须与 sealed AgentCommand/Fact 的具体变体一致,未知版本或类型直接拒绝。跨 domain causation 固定在 immutable `RunHeader.CausationID`,command/event wire 不增加第二级 lineage 字段。`agent/run` 必须提供正式 wire codec:decode 时先 canonicalize 整个 document,再读 `Type`,恢复具体 command/fact variant,校验 command/fact digest,并要求 decoded value 重新 canonical marshal 后与输入 canonical document 等价;不能依赖 `encoding/json` 自动反序列化 interface 字段,也不能接受 duplicate key、大小写模糊字段名或其他会被 Go decoder 合并/宽容的形态。`DigestCommand`/`DigestFact` 对 `SchemaVersion`、`Type` 和内容做 canonical digest,并排除 `Digest` 字段自身;`Revision` 只用于 authority 的 CAS,不进入 digest。 Evolve 的折叠语义与事件编码同属永久兼容契约:已发布 SchemaVersion 的事件必须永远能被折叠出与写入当时相同的状态。conformance kit 为每个已发布 SchemaVersion 冻结 golden transition stream 与对应的状态字节,任何 versioned Evolve 实现变更都必须通过全部历史版本的 golden 校验。 @@ -1292,7 +1179,7 @@ Planner 所需的 history 必须由宿主提供:Memoh 从 durable history proj 调用者可以用一个简单的内存 history projection 或 planner 自己持有的会话上下文。Runtime 不负责把 AgentEvent 推送给 Planner,也不因此新增 history/store 方法。 -`PrepareModelRequest` 必须使用 Planner 开始前 Load 得到的 BaseRevision,并按当前顺序携带完整的 `PendingInputs` `InputIDs` 集合。相同 RunID 和 Revision 的并发 Planner 使用同一个 `DeriveModelRequestCommandID`:相同请求得到 `CommitAlreadyApplied`,不同请求得到 `ErrCommandConflict`;如果 Planner 在新的 Revision 上重试,则生成新的 CommandID。Memoh adapter 同时检查 `PlanningToken` 的 context revision,后到者不能覆盖已经冻结的请求。 +`PrepareModelRequest` 必须使用 Planner 开始前 `Load(ctx, runID)` 得到的 BaseRevision,并按当前顺序携带完整的 `PendingInputs` `InputIDs` 集合。相同 RunID 和 Revision 的并发 Planner 使用同一个 `DeriveModelRequestCommandID`:相同请求得到 `CommitAlreadyApplied`,不同请求得到 `ErrCommandConflict`;如果 Planner 在新的 Revision 上重试,则生成新的 CommandID。Memoh adapter 同时检查 `PlanningToken` 的 context revision,后到者不能覆盖已经冻结的请求。 Loop 校验 `RequestPlan.Model`(若提供)与 frozen `ModelRequest.Model` 一致;Runtime 通过共享 Decide 规则再次校验 `PrepareModelRequest.Model` 与 `PrepareModelRequest.Request.Model` 一致。固定模型限制若存在,属于 Memoh/host policy,不进入 agent MachineState。Planner 的 context 一致性由 Memoh 在自己的 queue-safe admission/planning 边界保证,不由 agent 解释。 @@ -1301,7 +1188,7 @@ Loop 校验 `RequestPlan.Model`(若提供)与 frozen `ModelRequest.Model` agent 不提供第三个 Loop 操作。Memoh/application 的 response ingress: 1. 验证用户权限、Run/Step/Call 身份和 payload。 -2. 读取 authority snapshot,确认目标 Call 仍为 Waiting;这个 ingress 读取不取得 Loop 的执行租约。 +2. 用 `Load(ctx, runID)` 读取 authority snapshot,确认目标 Call 仍为 Waiting;这个 ingress 读取不取得 Loop 的执行租约。 3. 用 `ResponseRequest.RunID/StepID/CallID/ID` 路由响应。将 approval 转成 `ApproveToolCall`/`RejectToolCall`,将外部结果转成 `SubmitToolResponse`; `ResponseRequest.RequestDigest` 是原请求的摘要,用户决定或答案的摘要单独放在 @@ -1311,7 +1198,7 @@ agent 不提供第三个 Loop 操作。Memoh/application 的 response ingress: 响应提交后,若有 Pending Call 或 ToolStep 已可自动关闭,Memoh 写入幂等 wake/outbox;其他 Waiting Call 保持原状态。响应入口不执行工具,也不调用模型。 -如果同一 ToolStep 还有 Executing Call,响应提交仍写入自己的 wake;执行 worker 的完成提交或 recovery 也必须写 wake。下一次 Loop 从 `Load` 重新判断两类事实,不要求把它们合并成一个等待状态。 +如果同一 ToolStep 还有 Executing Call,响应提交仍写入自己的 wake;执行 worker 的完成提交或 recovery 也必须写 wake。下一次 Loop 从 `Load(ctx, runID)` 重新判断两类事实,不要求把它们合并成一个等待状态。 ### 5.8 queue 边界 @@ -1324,7 +1211,7 @@ ToolStep 自动关闭 只有在这些 boundary,Memoh 才能把 queue 输入绑定到下一次 Planner 请求或创建后续 Run。ToolStep 中间的新输入不能修改已经冻结的 ModelStep,也不能跳过 Pending/Waiting Call。 -steer 必须进入下一个 ModelStep 的规划上下文,而不是延迟到更晚的边界。Loop 在 boundary 处的 Plan/Prepare 提交与 Application 的 queue 仲裁存在竞态;durable adapter 用以下 gate 消除它:处理 `PrepareModelRequest` 的同一事务内检查是否存在 eligible 的 steer item,存在时不接受该次 Prepare,先在事务内应用对应的 `AcceptInput`(Revision 递增,Prepare 按 `ErrStaleRuntime` 返回)。Loop 重新 Load 后由 `PlanningHint.Inputs` 携带该输入重新规划。这条 gate 是 Application orchestration 行为,不进入 Run Machine 规则;in-process harness 没有 queue 时由宿主在 Loop 空闲边界提交。 +steer 必须进入紧接着的 eligible ModelStep 规划上下文。Application coordinator 在 queue admission 与 Plan/Prepare 之间建立线性化顺序:steer 获胜时,先将 durable admission 与 `Runtime.Commit(AcceptInput)` 原子或可幂等协调,再允许规划;Prepare 已建立 frozen ModelStep 时,新输入等待下一 eligible boundary。AcceptInput 推进 Revision 后,基于旧 Revision 的 Prepare 自然返回 `ErrStaleRuntime`,Loop 重新 `Load(ctx, runID)` 并从 `PlanningHint.Inputs` 重新规划。共享 `run.Runtime` 不读取 queue;同库事务、private coordination capability 或 inbox/outbox 均属于 Application adapter。in-process harness 由宿主执行相同顺序。 ## 6. Loop 算法 @@ -1339,7 +1226,7 @@ type Loop struct { Streaming bool } -func (l *Loop) Run(context.Context, Runtime, EventSink) (LoopResult, error) +func (l *Loop) Run(context.Context, Runtime, RunID, EventSink) (LoopResult, error) ``` Loop 是当前进程的解释器。它不复制权威状态,不直接访问数据库或 Memoh queue。 @@ -1351,12 +1238,12 @@ Loop 使用一个用于读取/提交的 `controlCtx`,并为每个模型或工 ### 6.2 主算法 ```text -Loop.Run(ctx, runtime, events): +Loop.Run(ctx, runtime, runID, events): outer: for: if ctx is cancelled: return LoopResult{}, ctx.Err() // 已 start 的本地 worker 先按 6.1 的派生规则收束 - snapshot, err := runtime.Load(controlCtx) + snapshot, err := runtime.Load(controlCtx, runID) if err != nil: return err if snapshot.State.Status is terminal: @@ -1491,7 +1378,7 @@ Loop.Run(ctx, runtime, events): using that Call's start grant; a stale BaseRevision is eligible for Call-local rebase. If one worker reports Unknown, cancel the other workers and do not commit their late results. After all workers started by this branch have settled or been cancelled, - continue outer loop; a subsequent Load decides whether the remaining + continue outer loop; a subsequent Load(controlCtx, runID) decides whether the remaining Executing calls require recovery or whether more Pending calls can start. case WaitForResponse(requests): @@ -1500,13 +1387,13 @@ Loop.Run(ctx, runtime, events): case WaitForExecutionRecovery: return LoopWaiting(ExecutionRecovery) - // Every accepted event is followed by Load; Loop does not keep a + // Every accepted event is followed by Load(controlCtx, runID); Loop does not keep a // second authoritative MachineState. ``` 外层 ctx 取消不会让 Loop 立即返回:模型 worker 随之取消并以 `RecoverModelExecution` 释放,工具 worker 按 6.1 的规则运行到自身结束并提交结果,随后循环顶部的 ctx 检查返回 `ctx.Err()`。取消只结束本次 Loop 执行,不改变 Run 状态。 -`commit` 为每个 AgentCommand 生成一次 CommandID;如果提交响应丢失,使用同一个 CommandID 和 digest 重放。`CommitAlreadyApplied` 表示对应事件组已经落地,不能再次执行相应外部 effect;Loop 重新 Load 后根据权威状态决定下一动作。 +`commit` 为每个 AgentCommand 生成一次 CommandID;如果提交响应丢失,使用同一个 CommandID 和 digest 重放。`CommitAlreadyApplied` 表示对应事件组已经落地,不能再次执行相应外部 effect;Loop 重新 `Load(ctx, runID)` 后根据权威状态决定下一动作。 伪代码中的 `commit(command, commandID, baseRevision, grant)` 是一个小型 Loop helper:它补齐 CommandEnvelope 的 Type、SchemaVersion 和 `DigestCommand`,然后调用唯一的 `Runtime.Commit`。构造 CommandEnvelope 与派生 ID 只能通过 agent 提供的 typed 构造函数与 helper;任何代码不得手工拼装信封字段。 @@ -1521,7 +1408,7 @@ Loop.Run(ctx, runtime, events): `invokeModel` 是说明性 Loop helper:开启 streaming 且 invoker 支持 `StreamingModelInvoker` 时消费 stream 并向 EventSink 发送 delta,否则调用 `Generate`;两条路径都只返回一个完整边界 `sdk.ModelResult`,Loop 在提交前用 `FreezeModelResult` 转为 agent `ModelResult`。 -所有 completion commit 都遵循同一错误处理:`ErrStaleRuntime`/`ErrRunTerminal` 只触发重新 `Load` 并丢弃迟到结果,其他错误返回给调用方;只有 `CommitAccepted` 或相同 command 的 `CommitAlreadyApplied` 才表示该事实已被 authority 接受。接受后 Loop 可以把 `CommitResult.Events` 逐个包装为 `Event{Kind: EventAgentCommitted, Durability: EventCommitted, Canonical: &e}` 发送给 EventSink;发送失败不回滚提交。 +所有 completion commit 都遵循同一错误处理:`ErrStaleRuntime`/`ErrRunTerminal` 只触发重新 `Load(ctx, runID)` 并丢弃迟到结果,其他错误返回给调用方;只有 `CommitAccepted` 或相同 command 的 `CommitAlreadyApplied` 才表示该事实已被 authority 接受。接受后 Loop 可以把 `CommitResult.Events` 逐个包装为 `Event{Kind: EventAgentCommitted, Durability: EventCommitted, Canonical: &e}` 发送给 EventSink;发送失败不回滚提交。 ### 6.3 模型调用 @@ -1547,7 +1434,7 @@ ToolStep.Pending Loop 先解析 ToolRef、校验参数、工具定义 digest、response policy 和 binding digest,再提交 start command。多个独立 Pending Call 可以按 Loop 的 `ExecutionPolicy` 并行;每个 execution attempt 有自己的 CommandID 和 ExecutionGrant。如果解析或参数校验已经失败,直接提交 Pending Call 的已知 `SubmitToolFailure`,不经过 start barrier,也不调用外部工具。 -如果 start command 已经成功但响应丢失,Loop 不能把 `AlreadyApplied` 当作新的执行授权;它必须重新 Load。旧执行仍被确认拥有时,新的 Loop 返回 `LoopWaiting(ExecutionRecovery)`;执行权失效后由 Memoh recovery 处理为 Unknown。这样即使崩溃发生在 start barrier 与真正调用之间,也不会无凭据地重复执行工具;代价是该 Call 按 Unknown 终止当前 Run。 +如果 start command 已经成功但响应丢失,Loop 不能把 `AlreadyApplied` 当作新的执行授权;它必须重新 `Load(ctx, runID)`。旧执行仍被确认拥有时,新的 Loop 返回 `LoopWaiting(ExecutionRecovery)`;执行权失效后由 Memoh recovery 处理为 Unknown。这样即使崩溃发生在 start barrier 与真正调用之间,也不会无凭据地重复执行工具;代价是该 Call 按 Unknown 终止当前 Run。 ### 6.5 等待与并行 @@ -1566,7 +1453,7 @@ Pending + Waiting -> Machine 自动关闭 ToolStep ``` -不同 Call 的 response 可以并行到达。一个 response 不会取消或轮换其他 Call 的执行授权。每次重新唤醒都从 Runtime.Load 开始。 +不同 Call 的 response 可以并行到达。一个 response 不会取消或轮换其他 Call 的执行授权。每次重新唤醒都从 `Runtime.Load(ctx, runID)` 开始。 一个 Loop 在自己成功 start 的 worker 尚未结束时不会返回 `LoopWaiting`;它会等待该 worker 提交完成/失败,或提交 recovery。只有加载同一 Run 的后续 Loop,才可能看到 @@ -1587,8 +1474,8 @@ Pending + Waiting | 外层 ctx 在模型执行中取消 | 模型 worker 取消,提交 `RecoverModelExecution`,Loop 返回 `ctx.Err()`。 | | 进程崩溃留下 Executing Call | Runtime recovery 按 §8.2 收束为 Unknown。 | | application CancelRun | 宿主必须先提交 `CancelRun`(Run 置为 RunStopped),再取消 Loop 的 ctx;顺序颠倒会让执行中的工具收束为 Unknown,把用户停止错记为 `RunFailed(effect_unknown)`。 | -| stale Revision/grant | 丢弃本地结果,重新 Load;不重放旧 Step 的外部 effect。 | -| Commit 响应未知 | 同一 CommandID/digest 重放一次;仍未知则结束当前 Loop,后续 Load 读取 authority。 | +| stale Revision/grant | 丢弃本地结果,重新 `Load(ctx, runID)`;不重放旧 Step 的外部 effect。 | +| Commit 响应未知 | 同一 CommandID/digest 重放一次;仍未知则结束当前 Loop,后续 `Load(ctx, runID)` 读取 authority。 | 计划内停机(部署、滚动升级)不走崩溃路径:宿主收到停止信号后取消 Loop 的外层 ctx,按上表语义排空——不再 start 新 Call,已 start 的工具 worker 在停机 grace 内提交完成/失败,模型执行以 recovery 释放。grace 内未能结束的工具执行才留给 lease recovery 收束为 Unknown。 @@ -1616,7 +1503,7 @@ Loop 为每个执行 worker 创建绑定了 RunID/StepID/CallID 的 `ToolProgres ### 7.2 approval 和 ask-user -两者都是 ToolCall 的 response policy,不是新的 Step 类型: +approval 和 ask-user 都是 ToolCall response policy,Step 类型保持 ModelStep/ToolStep: ```text approval required -> Waiting(Approval) @@ -1629,26 +1516,35 @@ ask_user -> Waiting(ExternalResponse) agent 提供 MCP schema/call adapter,把 MCP tool 转换为 `sdk.ToolDefinition` 和 `ExecutableTool`。MCP server 的连接、认证、生命周期和产品权限由 Memoh/application 管理。迁移期可以保留旧 `sdk.MCPClient` wrapper;新 Loop 不依赖 SDK 的 MCP session。 -## 8. 同一 Run ES 的 Runtime implementations +## 8. 同一 Run execution semantics 的 Runtime implementations ### 8.1 `run.MemoryRuntime` -`run.MemoryRuntime` 是 in-process reference runtime: +`run.MemoryRuntime` 是 RunID-addressed、多 Run 的 in-process reference runtime: ```text -RunHeader + mutex + MachineState projection + TransitionRecord log +empty Run collection + RunID -> (immutable RunHeader + MachineState projection + TransitionRecord log + per-Run mutex) ``` +`NewMemoryRuntime()` 创建空 collection,不接收 initial state。`Create` 仅持有 collection map lock 以建立或查找 RunID entry;相同 canonical header 返回 `Created=false`,不同 header 返回 `ErrCreateConflict`。`Load`、`Commit` 和 `Record` 先按 RunID 查找 entry,再使用该 Run 的独立锁: + ```text -Load - 在锁内返回当前 MachineState 和 Revision +Load(runID) + 在该 Run 的锁内返回当前 MachineState 和 Revision -Commit - 在锁内判定本进程 grant 有效性,调用共享 EvaluateCommit, +Commit(envelope.RunID) + 在该 Run 的锁内判定本进程 grant 有效性,调用共享 EvaluateCommit, 原子保存 MachineState、完整 TransitionRecord 和幂等索引; 接受 start command 时返回本进程的 opaque ExecutionGrant + +Record(runID) + 在同一 Run 锁边界读取 detached header、snapshot 和完整 log, + 验证 header/每个 record,并 fold 验证 snapshot ``` +collection map lock 只保护 create/lookup;不同 Run 的 Load、Commit 和 Record 能以各自 per-Run lock 并行。 + 它可以省掉 durable **机制**: ```text @@ -1668,36 +1564,41 @@ idempotency replay/fold invariants ``` -因此 in-process 和 durable 不是两种 Agent,而是同一 `run.Runtime` contract 的不同实现。Local Run 可以导出: +因此 in-process 和 durable 是同一 `run.Runtime` contract 的不同实现。`Record(ctx, runID)` 产出 `RunRecord{RunHeader, RuntimeSnapshot, TransitionRecord log}` 这一致 verified read,供恢复、materialization、导入和诊断。跨 package export 由 Application 另行组合 Session archive 与 Artifact `BindingManifest`,并分别通过各 domain 的 verified import boundary。 -```text -RunHeader + TransitionRecord log + referenced artifacts -``` - -供 durable adapter 验证、replay 后继续执行。 - -MemoryRuntime 不保存 Session history、context、queue、long-term memory 或 product artifacts。需要多轮上下文的 in-process harness 在 Runtime 外组合 `session` memory store、可选 `queue` memory store、materializer 和 RequestPlanner;这不是把产品 memory 偷塞进 Runtime。 +MemoryRuntime 不保存 Session history、context、queue、long-term memory 或 product artifacts。需要多轮上下文的 in-process harness 在 Runtime 外组合 Session/Extension/Chatlog memory implementations、Artifact capabilities、Turn Coordinator 和 RequestPlanner。 ### 8.2 Durable Run runtime adapter Durable adapter 位于 Application/Product(例如 Memoh),通过 PostgreSQL transaction/CAS 或等价存储实现同一个 `run.Runtime`: ```text -Load - 读取 RunHeader、MachineState projection、revision 和必要的私有 control-plane metadata +Create(NewRun) + 验证 NewRun;按其 SchemaVersion 构造 immutable Revision-0 RunHeader + 对相同 RunID 做 header collision 判定:相同 header 返回 Created=false, + 不同 header 返回 ErrCreateConflict + +Load(runID) + 读取该 Run 的 MachineState projection、revision 和必要的私有 control-plane metadata 不创建 Attempt,不取得 lease;control-plane 不出现在 RuntimeSnapshot -Commit +Commit(envelope.RunID) 判定私有 owner/fence/lease/recovery record 的有效性 对 StartModelExecution/StartToolCall 在同一事务内建立 Attempt/lease 调用共享 EvaluateCommit - 原子保存 MachineState、TransitionRecord、idempotency index、 - Session/artifact/usage materialization outbox 和必要的 queue claim outcome + 原子保存 MachineState、TransitionRecord、idempotency index, + 并可保存 materialization delivery outbox 与必要的 queue claim outcome + +Record(runID) + 在同一 DB snapshot 中读取 immutable header、MachineState 和完整 TransitionRecords, + 验证 header/records 并 fold 验证 snapshot,再返回 detached RunRecord ``` +`Load`、`Commit` 和 `Record` 对缺失 RunID 返回 `ErrRunNotFound`。 + Attempt、owner、fence、lease、outbox row 和数据库 schema 不进入 `run.MachineState`。它们只保证多个 Loop attempt 不会同时取得同一个 Step/Call 的执行权。MachineState storage schema 与 Run event schema 独立版本化;需要重建状态时(存储布局迁移、一致性核对)用 `FoldRun` 从 header 和 transition log 折叠,这是运维操作而非日常路径。 -Durable worker 实例可绑定 owner identity;只有当前 owner 能提交其取得的 start/completion/recovery。response、cancel、host StopRun 使用不带 worker grant 的 ingress/control adapter。lease/recovery scanner 必须通过正常 command + `EvaluateCommit` 提交 Unknown 或 Model recovery,不能直接改写 MachineState。 +Durable worker 实例可绑定 owner identity;只有当前 owner 能提交其取得的 start/completion/recovery。response、cancel、host StopRun 使用不带 worker grant 的 ingress/control adapter。lease/recovery scanner 必须通过正常 command + `EvaluateCommit` 提交 Unknown 或 Model recovery,不能直接改写 MachineState。Create、Load、Commit 和 Record 共同以 RunID 定址同一 collection;Record 的 verified read 为导入、迁移、诊断和 adapter conformance 提供一致输入。 ### 8.3 外部 effect 的保证 @@ -1739,17 +1640,17 @@ SchemaVersion + Type wire 兼容和 sealed fact discriminator Fact 已接受的事实内容 ``` -MachineState 是 Run execution authority;`RunHeader + TransitionRecord log` 是同事务原子写入的 canonical record(§5.1)。Runtime 必须把状态、transition log 和需要一致的 Application materialization outbox 放在同一事务或锁边界。Durable adapter 必须保留完整 TransitionRecord,使其可以按 RunID/Revision replay(审计、投影、导入/迁移);MemoryRuntime 可以只在进程内保留同样的记录。公共 `Runtime` 不增加 replay 方法,读取由实现或 application projection 提供。 +MachineState 是 Run execution authority;`RunHeader + TransitionRecord log` 是 canonical record(§5.1)。Create 原子保存 header 与 Revision-0 state;Commit 原子保存新 state 与完整 TransitionRecord。Durable adapter 按 RunID/Revision 保留 records,并可在 Commit 事务附带写入 delivery outbox;MemoryRuntime 在进程内保留相同 records。公共读取统一通过 `Record(ctx, runID)` 返回一致 verified record。 Replay(`FoldRun`)按 RunID/Revision 取出 TransitionRecord,从经 `ValidateRunHeader` 验证的 `RunHeader.InitialState`(Revision=0)开始依次展开其中的 AgentEvent,并调用对应 `SchemaVersion` 的 `Machine.EvolveVersion` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里;折叠不产生任何外部 effect。它服务导入/迁移(目标侧以 fold 结果为初始权威状态,不信任上传的 snapshot)、conformance 等价测试与运维核对;日常恢复不经过它。fold 过程中 RunID 不匹配、SchemaVersion/Type 不支持、同一 transition 的 CommandID/CommandDigest 不一致、Revision/Index 缺洞、fact digest 或 transition digest 不匹配都拒绝折叠——对导入而言即拒绝该上传;对核对而言即报告损坏,后续处理是运维决定。 -Replay 的起点是 `RunHeader` 中已建立并持久化的最小 `MachineState`;初始用户输入通过 `AcceptInput` transition 重放。Session/Queue admission lineage 由各自 domain 记录,并通过 causation/provenance 与 RunHeader 关联。 +Replay 的起点是 `RunHeader` 中已建立并持久化的最小 `MachineState`;初始用户输入通过 `AcceptInput` transition 重放。Turn linkage 保存在 Session Events,queue admission lineage 由 Application 记录;二者通过稳定 causation/provenance 与 RunHeader 关联。 canonical event 只记录影响语义状态、恢复和审计的已接受事实:Step 的建立/启动/恢复/关闭、模型结果的接受与拒绝、工具结果、响应、active Run 的输入接受和 Run terminal(§3.6 的 Fact 词表)。模型文本 delta、工具 stdout、下载百分比和其他瞬时 progress 不进入 AgentEvent;它们仍可在提交前通过 EventSink 发送 provisional observation。 ### 9.2 EventSink -EventSink 是实时观察出口,不是 canonical source。Loop 在收到 `CommitResult.Events` 后可以发送对应的 committed observation,也可以发送不改变权威状态的 provisional observation: +EventSink 是实时观察出口;MachineState 与 canonical records 保持 authority。Loop 在收到 `CommitResult.Events` 后可以发送对应的 committed observation,也可以发送不改变权威状态的 provisional observation: ```text ModelTextDelta、ToolProgress @@ -1759,14 +1660,15 @@ ToolStarted 只能在 start command Accepted 后发送 ToolCompleted、Run terminal - 先由 Runtime Commit;Loop 随后发送观察事件,durable adapter 的 - Session/artifact/usage materialization outbox 由同一事务保存对应事实 + 先由 Runtime Commit;Loop 随后发送观察事件 + durable adapter 可在同一事务保存指向该 transition 的 delivery outbox + Turn Coordinator 仍按 RunRecord coverage 经 SemanticAppender materialize ``` EventSink 丢失、重复或来自旧 Attempt 都不改变 MachineState 或 AgentEvent。客户端出现 gap 时从 durable AgentEvent、snapshot 或最终 `RunResult` 重建。Loop 默认忽略观察通道错误,不因此重试模型/工具。 并行工具的观察事件通过 `CallID` 关联到具体 ToolCall;模型事件的 `CallID` 为空。 -`Event.Sequence` 只在一次观察流内单调递增,不是 `AgentEvent.Revision`,也不参加 canonical digest/idempotency。`Durability` 仅说明这条观察是否对应已提交事实;它不能替代 AgentEvent 的 identity/digest。 +`Event.Sequence` 只在一次观察流内单调递增,与 `AgentEvent.Revision` 分属不同 identity,并排除在 canonical digest/idempotency 之外。`Durability` 仅说明这条观察是否对应已提交事实;AgentEvent identity/digest 继续提供 canonical identity。 ### 9.3 context transform @@ -1780,15 +1682,15 @@ Request Planner 属于 Application。它可以在事务外读取 context,但 d 一次 `sdk.Generate` 或 `sdk.Stream` 对应一次逻辑 provider request。transport retry 在 sdk/provider client 内部发生;run 不记录它,也不为它创建新的 Step。 -## 10. Application queue、session 和恢复 +## 10. Application queue integration、session 和恢复 ### 10.1 queue 归属 -steer/follow-up 的 queue 数据结构、accepted order、重排、claim、apply、取消和 admission 全部属于 Application queue/session policy。两种 queue 可以共享稳定 item reference、accepted sequence、order version、取消状态和 claim provenance;消费策略不同。被选中的 item 在交给 Run core 前转换为只有 `InputID` 和 payload 的 `AgentInput`,Application 私下保留 item reference 与 claim provenance 的映射: +steer/follow-up 的 queue 数据结构、accepted order、重排、claim、apply 和取消属于 Application policy;Session 只保存 owner module 提交的长期语义 Events。两种 queue 可以共享稳定 item reference、accepted sequence、order version、取消状态和 claim provenance;消费策略不同。被选中的 item 在交给 Run core 前转换为只有 `InputID` 和 payload 的 `AgentInput`,Application 保留 item reference 与 claim provenance 的映射: ```text -steer 优先进入当前 eligible boundary;若当前 Run 已 terminal,则按 session policy 创建 continuation Run -follow-up 当前 Run 自然结束后创建新的 Run +steer 优先进入当前 active Run 的 eligible boundary +follow-up 为新的 Chatlog Turn 分配新的 RunID,并通过 turn.Start 建立 linkage ``` active Run 的 steer 通过 `NextStep(input)` 生成 `AcceptInput` command;terminal Run 的 follow-up 由 Application admission 创建新 Run,再对新 Run 提交首个 `AcceptInput`。Run core 不接收 queue item、priority、order 或 claim。 @@ -1808,11 +1710,11 @@ ModelStep 执行中、ToolStep 有 Pending/Executing/Waiting Call 时,不消 没有 tool calls 的 ModelStep 会使当前 Run 到达 `RunCompleted`。Application 可以在这个 queue-safe boundary 消费 steer 并创建 follow-up Run;这不改变已经完成的 Run,也不把 queue policy 放进 Machine。 -### 10.3 Session settled 与 follow-up admission +### 10.3 Turn settlement 与 follow-up admission -具体产品可以定义 session settled/busy 规则,例如 terminal Run 不等于 session settled,或 admission-active follow-up Run 存在时 session 仍 busy。这些是 `agent/session` 上层 policy,不是 Run Machine 状态。 +terminal Run 与 settled Turn 是不同协议状态。`agent/turn` 先以 verified `RunRecord` 完成 `MaterializeAll`,再将 `twilight.turn/run_settled` 与 `TurnCompleted`/`TurnFailed` 写入同一 semantic Session commit。Session busy、渠道呈现和后续 queue admission 由 Application projection/policy 决定。 -follow-up 可以在新 Run 的第一个 ModelStep 之前完成 durable admission claim;这是 Session/Queue operation,不是 Loop 中间读取 queue。Application outbox/scanner 可以唤醒新 Run;admission 用 `InitializeRun` 创建最小 state,并把 follow-up 输入提交为首个 `AcceptInput` transition。 +follow-up 在新 Run 的第一个 ModelStep 前完成 durable input 与 Turn intent admission。Application 分配新的 TurnID、RunID、causation 和 execution binding,再调用 `turn.Start`;Coordinator 先提交 `run_requested` linkage,随后 `Runtime.Create(NewRun)` 并按序提交首批 `AcceptInput`。Application scanner 从未 settled linkage 恢复。 ### 10.4 多 response 恢复 @@ -1828,97 +1730,99 @@ response 101 只完成 B;D 仍可执行,不必等待 C。response 102 再完 ## 11. Domain 拆分与实施顺序 -当前 root `agent` 包是 Run execution ES 的实现雏形;它不应继续吸收 Session、Queue、Artifact、Context 或产品 orchestration。拆包以 target package 为准,不为尚未合并的 API 保留 agent-level compatibility façade。 +当前 root `agent` 包是 Run execution domain 的实现雏形;它不应继续吸收 Session、Queue、Artifact、Context 或产品 orchestration。拆包以 target package 为准,不为尚未合并的 API 保留 agent-level compatibility façade。 ### 阶段 A:抽取 `agent/es` 1. 从现有 Run codec/transition/rebuild 中抽出不认识 run 语义的机制:canonical record encoding、payload/record digest hook、revision/index 校验、complete-record validation 和 generic fold runner。 -2. `es` 不 import `run`、`session`、`queue`。 +2. `es` 不 import `run`、`session`、`artifact`、`turn` 或 Application domain。 3. Run 的 `TransitionRecord` 先适配/包装 `es.Record`;保持 Run-specific `CommandID`、`CommandDigest`、sealed Fact codec 在 `run`。 4. 为 `es` 添加独立 conformance:partial record tail、revision/index gap、digest mismatch、schema/type mismatch、canonical JSON ambiguity。 ### 阶段 B:移动现有 core 到 `agent/run` 1. 将现有 root `agent` 中的 Machine、Loop、Runtime、MemoryRuntime、model data、tool contract、codec、runtimetest 移到 `agent/run`。 -2. 删除 `RunConfig`、`RunSeed`、`NextRun`、旧 `Initialize(run, config, seed)` 以及对应 codec/digest;目标 API 只有 `InitializeRun(runID)`,初始输入通过 `AcceptInput` transition。 -3. 把 `RunHeader` 实现为正式 execution authority record;rebuild/fold API 以 header 为起点。 -4. 让 `MemoryRuntime` 成为同一 Run ES 的最轻 reference runtime,而不是另一种 agent;它可以没有 lease/DB/heartbeat,但不能跳过 execution event semantics。 +2. 新 admission 使用 `BuildNewRun`/`Runtime.Create`,初始输入通过 `AcceptInput` transition。当前导出的 `InitializeRun` 与 `BuildRunHeader` 只服务迁移和低层测试,新 admission 不调用它们;删除或正式弃用另行处理。`BuildRunHeaderFromNewRun` 是 Runtime adapter 按 version 建立同一 Revision-0 header 的共享构造入口。 +3. 实现 `NewRun`、`BuildNewRun`/`ValidateNewRun`、`BuildRunHeaderFromNewRun` 与 `Runtime.Create`:Create 按 version 建立正式 immutable Revision-0 `RunHeader`;`MachineState` 是日常 execution authority,rebuild/fold API 以 header 为 canonical 起点。 +4. 让 `MemoryRuntime` 成为 RunID-addressed multi-Run collection 的最轻 reference runtime:`NewMemoryRuntime()` 返回空 collection,collection map lock 仅用于 create/lookup,每个 Run 使用独立锁;它可以没有 lease/DB/heartbeat,但不能跳过 execution event semantics。 5. 更新 import path、examples、conformance 和 golden streams;此时尚未合并,不保留 root `agent` compatibility wrapper。 -### 阶段 C:durable application adapter(前置) - -Durable adapter 在 Application/Memoh 实现,是 Runtime contract 的第一个真实外部消费方;它在 session/queue 包之前进行——contract 若与真实数据库事务模型不契合,反馈必须在建更多包之前回来。它不依赖 `agent/session`、`agent/queue` 包:materialization 契约(§2.6 的 source event identity、causation、finalization barrier)落在 Application 现有的 history/session 存储上;steer/follow-up 复用 Application 现有 queue。 +### 阶段 C:durable application adapter -1. 持久化 `RunHeader`、MachineState(execution authority)和 TransitionRecord log(canonical record)。 -2. 用 transaction/CAS 实现 `run.Runtime.Load/Commit`,私有实现 owner/fence/lease/Attempt/recovery。 -3. 将 Run transition、Session materialization outbox、artifact/usage projection 和 finalization state 放在同一事务,或使用可幂等 inbox/outbox 恢复;Application 要把每一个 Run 的 lifecycle 记录为语义历史条目(现有存储加 source_event_id 幂等键即可,无需新包)。 -4. 语义历史已记录 `RunFinalized`(或存在与之事务耦合的 marker)后才 archive/GC Run log;长期语义不依赖保留旧 Run log。 -5. 验收门槛:`run/runtimetest.RunConformance` 全绿,加 §14.4 的集成矩阵。 +Memoh 的既有第一批 durable adapter 只对应此前的单 Run `Load/Commit` contract:它验证了 `MachineState` 与 `TransitionRecord` 的持久化、旧接口的 transaction/CAS、command 幂等和执行租约/授权。该 adapter 尚未实现本规范的 collection `Create/Load/Commit/Record` contract,必须完成以下迁移后才能作为最新 contract 的实现;authority JSON 继续在 PostgreSQL `JSONB` 读写边界按 RFC 8785/JCS 重新 canonicalize 并校验 digest。 -### 阶段 D:实现 `agent/session`(按需,等待消费方) +1. **迁移 Create**:实现 `NewRun` 验证、按 version 构造 immutable Revision-0 header、同 RunID header collision 判定,以及与 application causation 的原子或可幂等写入。 +2. **迁移 Load/Commit**:按 RunID 定址 collection;Load 显式接收 RunID,Commit 使用 envelope 的 RunID,缺失 Run 返回 `ErrRunNotFound`。 +3. **迁移 Record**:在同一 DB snapshot 读取 header、MachineState 与完整 TransitionRecords,验证 records 并 fold 验证 snapshot,返回 detached `RunRecord`。 +4. **修复 durable authority**:保证 immutable table policy、Record 单一 read snapshot,以及 recovery lease/fence 的原子绑定和消费。 +5. **迁移验收**:执行共享 `run/runtimetest.RunConformance` 与 §14.6 的 durable integration matrix。 -触发条件:本地持久会话或 local/durable 会话同步进入排期。届时 API 从阶段 C 已运行的 materializer 反推,不从草案照抄: +### 阶段 D:实现 Session、Artifact、Extension、Chatlog 与 Turn -1. 定义 generic append-only Session store、entry envelope、head CAS、parent/fork lineage、schema/digest/causation。 -2. 先提供 memory store 和 replay/fork conformance;不在 package 内写死 MessageAdded/Compact/Artifact ontology。 -3. Application 的 materializer 从现有存储迁移到本包时,语义(source_event_id、causation)保持不变。 +协议 authority 已分别冻结在: -### 阶段 E:实现 `agent/queue`(按需,等待第二个消费方) - -触发条件:出现 Application 现有 queue 之外的第二个真实消费方。届时: +```text +docs/design/agent-session.md +docs/design/agent-artifact.md +docs/design/agent-session-extension.md +docs/design/agent-session-chatlog.md +docs/design/agent-turn.md +``` -1. 定义 generic enqueue/claim/ack/release/dedup/visibility contract 与 memory implementation。 -2. `SteerItem`、`FollowUpItem`、`RunQueueItem` 保持在 Application payload/policy。 -3. 不将 queue state 或 claim identity 加入 `run.MachineState`。 +实现顺序以 Memory reference vertical slice 为先:Session Store 与 conformance、Artifact Binding/Ledger、Extension Catalog/SemanticAppender、Chatlog projections、Turn Coordinator/FactMapper/MaterializeAll。Durable adapter 随后实现 PostgreSQL Session/Artifact tables、semantic append recovery 与 Runtime→Session delivery outbox。Queue 继续由 Application 管理。 -### 阶段 F:simple in-process harness(example 级) +### 阶段 E:simple in-process harness(example 级) -`agent/harness`(或 application example package)组合 in-process 各件;它是示例与测试工具,不是交付物,也不是第二个 Runtime interface: +`agent/harness`(或 application example package)组合 in-process 各件,定位为示例与测试工具;正式交付和 Runtime interface 仍由各 domain 提供: ```text -run.MemoryRuntime -+ run.Loop +run.NewMemoryRuntime() // empty multi-Run collection ++ session/artifact/extension/chatlog memory implementations ++ turn.Coordinator + BuildNewRun/Create/AcceptInput ++ run.Loop.Run(ctx, runtime, runID, sink) + in-memory EventSink -+ application RequestPlanner(自持内存 history) ++ application RequestPlanner(读取 Context projection) ``` -它不复制 Run Machine,不把 chat history 塞进 `MemoryRuntime`。它用于 local example、test、prototype,以及证明 local/durable 是同一 Run ES semantics 的不同 runtime implementation。 +它不复制 Run Machine,不把 chat history 塞进 `MemoryRuntime`。它用于 local example、test、prototype,以及证明 local/durable 是同一 Run execution semantics 的不同 runtime implementation。 ## 12. Cross-domain orchestration contract -Application 是唯一允许同时依赖 `run`、`session`、`queue`、artifact/context/memory 的层。它负责: +Application 组合 `run`、`session`、`artifact`、Extension、Chatlog、Turn 与产品 queue/context policy: ```text -Session semantic history + artifacts + memory + compact - -> ContextView +resolved Session Events + Artifact Bindings + -> chatlog Context projection -> RequestPlanner -> run.PrepareModelRequest -Session RunAdmitted - -> new RunHeader + first AcceptInput - -> run TransitionRecord (short-lived execution trace) - -> durable materializer/outbox - -> Session RunCompleted + messages/results/artifact refs/usage projection - -> Session RunFinalized - -> Run log archive / GC is now permitted +Turn Start semantic commit + -> TurnOpened + InputDelivered* + user MessageCommitted + twilight.turn/run_requested + -> Runtime.Create(persisted NewRun) + ordered AcceptInput commits + -> Run TransitionRecords + -> turn.MaterializeAll(verified RunRecord) + -> Chatlog Events through SemanticAppender + -> exact terminal RunEnded + -> twilight.turn/run_settled + TurnCompleted/TurnFailed in one semantic commit queue claim - -> queue-safe admission policy - -> Session lifecycle entry + run.AcceptInput or new RunHeader + -> Application admission policy + -> persisted input/Turn intent + -> turn.Start/Resume or safe-boundary AcceptInput ``` 每个 ModelStep 应记录足以审计输入来源的 `ContextManifest`(位置可为 `ModelStepPrepared` 中的 immutable reference 或 companion artifact): ```text ContextManifest { - session_revision - artifact_refs - memory_revision - compact_revision + resolved_session_head + binding_ids + memory_projection_version + compact_projection_version } ``` -它不是完整 prompt/history 的重复副本,而是 provenance。下一 Run 的 context 必须由 Session ES、Artifacts、Memory/Compact projection 构造,不能依赖读取已完成 Run 的 execution log。 +它只记录 provenance,不复制完整 prompt/history。下一 Run 的 context 必须由 Session ES、Artifacts、Memory/Compact projection 构造,不能依赖读取已完成 Run 的 execution log。 ## 13. 并行工作边界 @@ -1928,12 +1832,14 @@ ContextManifest { | generic record/digest/fold mechanism | `agent/es` | jsonstable | | Run Machine、Loop、Runtime、MemoryRuntime | `agent/run` | es + sdk | | RunHeader、run codec、runtimetest | `agent/run` | es | -| generic Session store/memory store/fork conformance | `agent/session` | es | -| generic Queue store/memory store/claim conformance | `agent/queue` | optional es digest types | -| Session event ontology/materializer/context planner | Application/Product | session + run + artifacts | -| Steer/follow-up/run queue policy | Application/Product | queue + session + run | -| durable runtime, DB schema, owner/fence/lease/outbox | Application/Memoh | run contract | -| simple in-process harness | `agent/harness` or examples | run + session + queue | +| Session Store/MemoryStore/replay/fork conformance | `agent/session` | es + jsonstable | +| Ref/Binding/RetentionClaim 与 memory conformance | `agent/artifact` | es + jsonstable | +| static Catalog、SemanticAppender、projection | `agent/session/extension` | session + artifact | +| Chatlog ontology、Surface/Context projections | `agent/session/chatlog` | session extension + artifact | +| Turn Coordinator、FactMapper、MaterializeAll | `agent/turn` | run + session extension + chatlog | +| Steer/follow-up/run queue policy | Application/Product | turn + session + run | +| durable runtime, DB schema, owner/fence/lease/outbox | Application/Memoh | run + session + artifact contracts | +| simple in-process harness | `agent/harness` or examples | run + session + artifact + extension + chatlog + turn | ## 14. 测试矩阵 @@ -1951,7 +1857,7 @@ provider retry 不改变一次调用语义 ```text 无当前 Step -> NeedModelRequest NextStep(input) -> AcceptInput -> InputAccepted -> pending input appears in PlanningHint -terminal follow-up -> Application admission creates new Run with InitializeRun, then AcceptInput(input), without mutating old Run +terminal follow-up -> Application allocates stable RunID/causation, calls Runtime.Create(NewRun), then AcceptInput(input), without mutating old Run PrepareModelRequest -> [ModelStepPrepared] -> ModelStep ModelExecuting lease recovery -> same frozen ModelStep can start again SubmitModelResult 有 tools -> [ModelStepCompleted, ToolStepOpened],保存完整 Call set @@ -1985,9 +1891,17 @@ Streaming=true 但 invoker 不支持 streaming -> Generate fallback ### 14.3 Runtime conformance -conformance 测试由 `agent/run` 以可运行测试包(`agent/run/runtimetest`)交付;MemoryRuntime 与任意 durable runtime adapter 直接运行同一套件,不各自转写矩阵: +conformance 测试由 `agent/run` 以可运行测试包(`agent/run/runtimetest`)交付;Factory 返回空 Runtime collection,suite 通过 `BuildNewRun`/`Create` 建立每个 case 的 Run。MemoryRuntime 与任意 durable runtime adapter 直接运行同一套件,不各自转写矩阵: ```text +Create(valid NewRun) -> immutable Revision-0 header + Created=true +Create(same RunID, same canonical header) -> Created=false;different header -> ErrCreateConflict +Load/Commit/Record(missing RunID) -> ErrRunNotFound +Record -> same consistency boundary 的 Header + Snapshot + complete TransitionRecords,并完成 fold verification +同一 Run 的并发 Create -> 恰一个 Created=true,其余 Created=false +Record 与 Commit 并发 -> 每次 Record 都是可独立 fold 的一致点 +多个 Run Create/Commit/Record -> RunID isolation,互不串状态或完整 log +一个 Run 的 ExecutionGrant 用于另一 Run -> ErrStaleRuntime,两个 Run 均可继续完成 same CommandID + digest -> CommitAlreadyApplied + 原事件组 same CommandID + different digest -> ErrCommandConflict AlreadyApplied 不重新运行 Decide;事件组逐字节等于首次提交 @@ -1997,7 +1911,7 @@ Planner InputIDs 必须完整匹配 PendingInputs;Tools/ToolsDigest 与 frozen 并行 Call 的 start/response 在旧 Revision 上按目标 Call rebase Pending Call 的 lookup/argument failure 在旧 Revision 上按目标 Call rebase 工具 ref/definition digest 变化 -> tool_definition_mismatch,且不调用工具 -ToolCallState 非法字段组合 -> Runtime.Load/Evolve 拒绝 +ToolCallState 非法字段组合 -> Runtime.Load(ctx, runID)/Evolve 拒绝 ToolCallState 的 BindingDigest 与 frozen ToolSpec 不匹配 -> tool_definition_mismatch 相同 ResponseID 不同 payload -> ErrCommandConflict start Accepted 后才授予外部执行权 @@ -2027,47 +1941,51 @@ canonical JSON duplicate key/trailing data/invalid UTF-8/lone surrogate 拒绝 generic FoldRecords 不运行 domain Decide 或任何 IO ``` -### 14.5 `agent/session` 与 `agent/queue` +### 14.5 Session、Artifact、Extension、Chatlog 与 Turn + +各 domain 的完整 conformance 由对应 authority 文档定义: ```text -session append/head CAS/replay order -branch/fork parent lineage -相同 causation/source event 不重复 materialize -queue enqueue/dedup/claim/ack/release/visibility -过期 claim 不允许 ack;重复 claim/ack 幂等或明确冲突 -SteerItem/FollowUpItem 只作为 application payload,不污染 queue core +agent-session.md Header/Commit/Event、CAS、resolved replay、fork、snapshot、import +agent-artifact.md Ref/Binding/BindingSet/RetentionClaim、provider boundary、archive +agent-session-extension.md Catalog、codec/upcast、Binding admission、SemanticAppender、projection +agent-session-chatlog.md Message/Input/Turn/Item ontology、Surface/Context projection +agent-turn.md Start/Resume/Stop、MaterializeAll、settlement、crash recovery ``` +Application queue tests 覆盖 admission durability、claim provenance、steer safe-boundary 与 follow-up 新 Turn/new Run linkage;queue policy 不进入 Core conformance。 + ### 14.6 Durable application integration ```text queue FIFO、accepted-order reorder、typed ID isolation -assigned follow-up 只由正确的 admission claim -RunHeader、TransitionRecord 与 MachineState 同事务一致 -Session materialization、artifact/usage outbox 与 Run transition 原子或可幂等恢复 -assistant tool-call 和 tool result 只 materialize 一次 +assigned follow-up 只由正确的 durable admission claim +Create 的 RunHeader 与 Revision-0 MachineState 原子一致;每次 Commit 的 TransitionRecord 与新 MachineState 原子一致 +Record 在同一 DB snapshot 读取 header/state/log 并完成 fold verification +Run transition delivery outbox 丢失或重复时,Turn 从 RunRecord 按 revision 完成 coverage +所有 Session semantic writes 经 SemanticAppender;Binding claim 的 crash points 可恢复 +assistant/tool-result/settlement 依 stable CommitID/EventID exactly-once materialize 多 response rows 与逐次 wake/idempotency lease expiry/recovery/unknown outcome -eligible steer 存在时 Prepare 在同一事务内被拒绝,AcceptInput 先应用,重新规划携带该输入 -并行 Call 中一个 Unknown 后撤销其他 grant,迟到结果不改变终态 -terminal Run 与 session settled 分离 -RunEnded 后未写入 Session RunFinalized 不允许 archive/GC -Session RunFinalized 后 Session/Artifact/Usage 仍可完整构造下一 Run context -EventSink gap 后可由 durable snapshot 对账 +queue admission 与 Prepare 竞态由 Application 线性化:AcceptInput 先提交则旧 Revision Prepare stale;Prepare 先提交则 steer 等待下一 eligible boundary +并行 Call 中一个 Unknown 后撤销其他 grant,迟到结果不改变 Run 终态 +terminal Run 与 settled Turn 分离;settlement 只发生在完整 materialization 后 +archive/GC 前验证 materialization coverage、run_settled terminal reference 与 Artifact retention +EventSink gap 后由 verified RunRecord 与 Session projections 对账 ``` -## 15. Memoh queue spec 的后续改写 - -本次不编辑 `session-runtime-steer-followup.md`。后续应按以下边界修订: +## 15. Memoh queue integration boundary -Application 的 queue/session/admission 语义属于各自 domain;Application loop host 只组装 Request Planner、`run.Loop` 和 durable runtime adapter,不包含第二套多步执行算法。queue 仲裁只发生在 queue-safe boundary,并与对应 Run command/transition 在 application transaction 或可幂等 outbox 中提交。Step 提交使用 ModelStep、ToolStep 以及逐 Call progress/response 记录;每次 response 只推进对应 Call。具体产品的 branch/claim/recovery 语义保持在 Application,不进入 `agent/run`。 +Application 的 queue/admission policy 负责 claim、priority、steer/follow-up 和 wake。Application host 组合 Turn Coordinator、Request Planner、`run.Loop` 与 durable Runtime adapter;queue 仲裁只发生在 queue-safe boundary,并将输入 durable admission 与对应 `AcceptInput` 通过事务或可幂等恢复协调。每次 response 只推进目标 Call。branch/claim/recovery policy 保持在 Application,不进入 `agent/run`。 ## 16. 实施前置条件 -1. Durable Application adapter 增加 ToolCall progress、response set、event idempotency、Run→Session/artifact/usage outbox projection 和 finalization marker。 -2. Durable adapter 冻结内部 Attempt、owner、fence、lease 和 recovery grace 规则;这些不进入 `run` public API。 -3. 工具失败不由 Run core 调度 retry timer;已知失败交给下一次模型,未知结果终止当前 Run。非幂等外部 effect 只能承诺 at-least-once。 -4. Request Planner 必须能从已提交的 Session/context projection 构造完整、可冻结的边界 `sdk.Request`,由 Loop freeze 为 `run.ModelRequest`。 +1. Durable Application adapter 迁移到 RunID-addressed `Create/Load/Commit/Record`:Create collision、missing-run errors 与 `Record` 的同一 DB snapshot fold verification 成为基础能力。 +2. 实现 Session、Artifact、Extension、Chatlog 与 Turn 的 Memory reference vertical slice 及其 conformance。 +3. Durable adapter 冻结内部 Attempt、owner、fence、lease 和 recovery grace 规则,并实现可恢复的 transition delivery outbox;这些不进入 `run` public API。 +4. Durable Session/Artifact adapter 实现 `SemanticAppender` intent/claim recovery、stable materialization identities 与 terminal settlement。 +5. 工具失败不由 Run core 调度 retry timer;已知失败交给下一次模型,未知结果终止当前 Run。非幂等外部 effect 只能承诺 at-least-once。 +6. Request Planner 从已提交的 Context projection 构造完整、可冻结的边界 `sdk.Request`,由 Loop freeze 为 `run.ModelRequest`。 ## 17. 待确认决策 @@ -2077,9 +1995,9 @@ Application 的 queue/session/admission 语义属于各自 domain;Application 2. breaking release 版本和 durable protocol upgrade window。 3. EventSink payload schema,以及是否需要在 durable outbox 中加入跨进程 execution epoch。 -本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;`run.ModelRequest` 冻结完整的 generation options,streaming 只是 `run.ModelInvoker` 的可选执行路径,不改变 Run command/event 语义。Run Machine 采用 Decide/Evolve 拆分:Decide 承载全部决策并在提交时产出结果事实,Evolve 是机械折叠、版本内稳定;MachineState 为 Run execution authority(durable continuation 经 `Load` 直接恢复),`RunHeader + TransitionRecord log` 为同事务原子写入的 canonical record(审计、投影、经验证的导入/迁移),两者分歧属实现缺陷,处理为运维决定(§5.1)。结构性 malformed 的模型结果通过 `RejectModelResult` 的 disposition 在同一冻结 request 上重试或失败;fixed model/limits 不进入 MachineState;usage 在 MachineState 内逐字段累计;steer 由 Application 的 queue-safe admission gate 保证进入下一个 ModelStep;工具不做效果分级,计划内停机以排空代替,Unknown 语义只覆盖崩溃和 lease 失效。 +本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;`run.ModelRequest` 冻结完整 generation options,streaming 是 `run.ModelInvoker` 的可选执行路径且保持 Run command/event 语义。Run Machine 采用 Decide/Evolve 拆分:Decide 承载决策并在提交时产出事实,Evolve 是机械、版本内稳定的折叠;MachineState 为 Run execution authority,`RunHeader + TransitionRecord log` 为 canonical record。Create 原子保存 header 与 Revision-0 state;Commit 原子保存新 state 与 transition;分歧属实现缺陷并由运维处理(§5.1)。结构性 malformed 模型结果通过 `RejectModelResult` disposition 在同一冻结 request 上重试或失败;fixed model/limits 留在 Application;usage 在 MachineState 内逐字段累计;steer 由 Application queue-safe admission gate 保证进入下一个 ModelStep;计划内停机使用排空,Unknown 语义覆盖崩溃和 lease 失效。 -本规范采用 MachineState 为 Run execution authority、`RunHeader + TransitionRecord log` 为 canonical record(§5.1):稳定条件(ontology 冻结、versioned Evolve 版本内稳定、事实自包含)由 Decide/Evolve 拆分保障,TransitionDigest 保护单个 transition 内部的完整事件组。恢复执行走 `Load(MachineState)`;`FoldRun` 服务导入/迁移与诊断。重新收紧为日志权威的触发条件(执行历史 fork 成为产品功能,或状态存储不可信而日志存储可信的部署形态)记录于 §5.1。跨 Run 语义不从旧 Run log 读取,而由 Session ES、artifact、memory/context projection 构造。 +本规范采用 MachineState 为 Run execution authority、`RunHeader + TransitionRecord log` 为 canonical record(§5.1):稳定条件(ontology 冻结、versioned Evolve 版本内稳定、事实自包含)由 Decide/Evolve 拆分保障,TransitionDigest 保护单个 transition 内部的完整事件组。恢复执行走 `Load(ctx, runID)`;`FoldRun` 服务导入/迁移与诊断。重新收紧为日志权威的触发条件(执行历史 fork 成为产品功能,或状态存储不可信而日志存储可信的部署形态)记录于 §5.1。跨 Run 语义不从旧 Run log 读取,而由 Session ES、artifact、memory/context projection 构造。 ## 附录 A:最小 public API 草案 @@ -2527,7 +2445,6 @@ func Next(MachineState) (Effect, error) func Decide(MachineState, AgentCommand) ([]Fact, error) func Evolve(MachineState, Fact) (MachineState, error) func EvolveVersion(uint16, MachineState, Fact) (MachineState, error) -func InitializeRun(RunID) (MachineState, error) type RunHeader struct { SchemaVersion uint16 @@ -2539,9 +2456,30 @@ type RunHeader struct { HeaderDigest es.Digest } -func BuildRunHeader(RunID, es.CausationID) (RunHeader, error) +// NewRun contains only immutable, versioned run-creation data. Create builds +// Revision 0 from it; initial inputs always use AcceptInput after Create. +type NewRun struct { + SchemaVersion uint16 + RunID RunID + CausationID es.CausationID +} + +type CreateResult struct { + Header RunHeader + Created bool // false when the same canonical header was already created +} + +// RunRecord is a detached, consistent verified read of one Run. +type RunRecord struct { + Header RunHeader + Snapshot RuntimeSnapshot + Transitions []TransitionRecord +} + +func BuildNewRun(RunID, es.CausationID) (NewRun, error) +func ValidateNewRun(NewRun) error +func BuildRunHeaderFromNewRun(NewRun) (RunHeader, error) func ValidateRunHeader(*RunHeader) error -func DigestRunHeader(*RunHeader) (es.Digest, error) type Effect interface { effect() } @@ -2579,8 +2517,10 @@ type RequestPlan struct { } type Runtime interface { - Load(context.Context) (RuntimeSnapshot, error) - Commit(context.Context, CommitRequest) (CommitResult, error) + Create(context.Context, NewRun) (CreateResult, error) + Load(context.Context, RunID) (RuntimeSnapshot, error) + Commit(context.Context, CommitRequest) (CommitResult, error) // Command envelope carries RunID + Record(context.Context, RunID) (RunRecord, error) } // The representation is implementation-defined. Callers only pass it back; @@ -2597,8 +2537,6 @@ type CommandEnvelope struct { Type string RunID RunID ID CommandID - // Opaque cross-domain lineage; Run Machine does not interpret it. - CausationID es.CausationID Digest Digest Command AgentCommand } @@ -2611,7 +2549,6 @@ type AgentEvent struct { Index uint16 CommandID CommandID CommandDigest Digest - CausationID es.CausationID Digest Digest Fact Fact } @@ -2677,13 +2614,15 @@ func EncodeCommand(CommandEnvelope) ([]byte, error) // 不包含 Digest 字段 func DigestCommand(schemaVersion uint16, typ string, command AgentCommand) (Digest, error) func EncodeFact(schemaVersion uint16, typ string, fact Fact) ([]byte, error) func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) -func BuildRunHeader(RunID, es.CausationID) (RunHeader, error) +func BuildNewRun(RunID, es.CausationID) (NewRun, error) +func ValidateNewRun(NewRun) error +func BuildRunHeaderFromNewRun(NewRun) (RunHeader, error) func ValidateRunHeader(*RunHeader) error -func DigestRunHeader(*RunHeader) (es.Digest, error) func BuildTransitionRecord([]AgentEvent) (TransitionRecord, error) func ValidateTransitionRecord(*TransitionRecord) error func DigestTransitionRecord(*TransitionRecord) (Digest, error) -func FoldTransitions(header RunHeader, records []TransitionRecord) (MachineState, uint64, error) +func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineState, uint64, error) +func FoldRun(header *RunHeader, records []TransitionRecord) (MachineState, uint64, error) func DigestRequest(ModelRequest) (Digest, error) func DigestToolDefinition(ToolDefinition) (Digest, error) func DigestToolSpec(ToolSpec) (Digest, error) @@ -2696,6 +2635,8 @@ func DeriveResponseID(RunID, StepID, CallID, ResponseKind) ResponseID func DeriveResponseCommandID(RunID, StepID, CallID, ResponseID) CommandID func DeriveInputCommandID(RunID, InputID) CommandID +var ErrCreateConflict = errors.New("agent: run create conflict") +var ErrRunNotFound = errors.New("agent: run not found") var ErrCommandConflict = errors.New("agent: command identity conflict") var ErrStaleRuntime = errors.New("agent: stale runtime version or grant") var ErrRunTerminal = errors.New("agent: run is terminal") @@ -2807,7 +2748,7 @@ type Loop struct { Streaming bool } -func (l *Loop) Run(context.Context, Runtime, EventSink) (LoopResult, error) +func (l *Loop) Run(context.Context, Runtime, RunID, EventSink) (LoopResult, error) ``` 实现必须保证所有返回的 Step、Call、Request、Result 和等待 payload 具有只读快照语义;调用方不能通过修改 slice、map 或 JSON bytes view 改变 Runtime 状态。`AgentCommand`、`Fact`、`Effect` 和 ToolExecutionOutcome 使用 agent 的 sealed interface,外部实现不能添加未定义变体。构造 CommandEnvelope 与派生 CommandID/ResponseID 只能通过 agent 提供的 typed 构造函数;手工拼装信封字段属于实现错误。 @@ -2816,7 +2757,7 @@ func (l *Loop) Run(context.Context, Runtime, EventSink) (LoopResult, error) 1. sdk 的一次 `Generate` 或 `Stream` 对应一次 provider request;transport retry 不创建新的 Step。 2. `agent/run.Loop` 是唯一的 Run 多步执行算法;Run 的权威状态由 Runtime 持有,Loop 不保存第二份。 -3. Runtime 对 Loop 只公开 `Load` 和 `Commit`;Planner、queue 和工具入口不是 Runtime 的隐藏第三、第四个方法。 +3. Runtime 公开 `Create`、`Load`、`Commit` 和 `Record`;Planner、queue 和工具入口不进入 Runtime contract。 4. Machine 是完整的 Run/Step/ToolCall 语义规则;决策只在 `Decide` 中、只在提交时运行一次,`Evolve` 是机械折叠。Runtime 通过共享 `EvaluateCommit` 调用它们,不复刻规则。 5. Step 是 durable resume boundary,只有 ModelStep 和 ToolStep;ToolCall 是 ToolStep 内的 progress。 6. ModelStep 完成有 tool calls 时产出 `ToolStepOpened`;全部 Call 到达可关闭终态时,同一 transition 产出 `ToolStepClosed`。终态一律以 `RunEnded` 显式产出,且它是其 transition 的最后一个事实。 @@ -2824,10 +2765,10 @@ func (l *Loop) Run(context.Context, Runtime, EventSink) (LoopResult, error) 8. Waiting response 只推进对应 Call;approval approved 先变 Pending,随后由 Loop 执行工具。日志记录结果事实(`ToolCallFailed{permission_denied}`、`RunEnded{cancelled}`),不记录请求本身。 9. 幂等按 command 判定:相同 CommandID/digest 重放返回 CommitAlreadyApplied 与原事件组(不重新运行 Decide),不重复写入 projection、history、queue action 或 outbox;相同 CommandID 不同 digest 冲突。 10. 一次接受的 transition 使 Revision 恰好加一;其全部事实共享该 Revision,Index 组内连续,提交后 `Snapshot.Revision` 等于该 Revision。 -11. MachineState 是 Run execution authority,durable continuation 经 `Load` 直接恢复;`RunHeader + TransitionRecord log` 是同事务原子写入的 canonical record,服务审计、投影与经验证的导入/迁移。对任意 Revision,状态应当等于 `RunHeader.InitialState` 经 `flatten(TransitionRecord[].Events)` 折叠的结果——由 replay-fold 等价测试守护;分歧属实现缺陷,处理为运维决定,协议不定义自动仲裁。 +11. MachineState 是 Run execution authority,durable continuation 经 `Load(ctx, runID)` 直接恢复;`RunHeader + TransitionRecord log` 是 canonical record。Create 原子保存 header 与 Revision-0 state,Commit 原子保存新 state 与 transition。对任意 Revision,状态必须等于 `RunHeader.InitialState` 经 `flatten(TransitionRecord[].Events)` 折叠的结果;replay-fold 等价测试守护该性质,分歧由运维流程处理。 12. Evolve 的折叠语义与事件编码同属永久兼容契约,按 SchemaVersion 冻结;Replay 通过 `EvolveVersion` 选择历史语义;Decide 的决策规则可随版本演进,因为决策结果已记录为事实。 13. 已知工具失败交给下一次模型请求;Unknown 终止 Run,不自动重试、不查询外部系统。 14. worker cancellation 不等于 RunStopped;业务停止必须提交控制 command。宿主的业务停止先提交 `CancelRun`,再取消 Loop 的 ctx;ctx 取消本身只结束执行尝试,工具 worker 运行到自身结束。 15. EventSink 只是实时观察;TransitionRecord、durable snapshot 和 outbox 才是 replay/recovery 依据,AgentEvent 是 transition 内部和观察出口的事实流视图。 16. `run.MemoryRuntime` 用进程内同步;durable runtime adapter 用事务、CAS 和内部 Attempt/owner/fence/lease;两者共享 `EvaluateCommit` 与 Run Machine 规则,但不共享存储实现。 -17. 结构性 malformed 的模型结果以 `RejectModelResult` 累计 usage;Disposition 决定回到 Prepared 重试或同 transition RunFailed。单个 Call 的参数解析失败不是 malformed,按已知 `invalid_arguments` 进入下一次模型请求。 +17. 结构性 malformed 的模型结果以 `RejectModelResult` 累计 usage;Disposition 决定回到 Prepared 重试或同 transition RunFailed。单个 Call 的参数解析失败归类为已知 `invalid_arguments`,并进入下一次模型请求。 From 614ac6b630b5a7b035008db075da3f5ada84735f Mon Sep 17 00:00:00 2001 From: Fodesu Date: Mon, 31 Aug 2026 00:36:20 +0800 Subject: [PATCH 33/81] docs(spec): define session, artifact, chatlog and turn protocols --- docs/design/agent-artifact.md | 226 +++++++++++++++++ docs/design/agent-session-chatlog.md | 224 +++++++++++++++++ docs/design/agent-session-extension.md | 255 ++++++++++++++++++++ docs/design/agent-session.md | 271 +++++++++++++++++++++ docs/design/agent-turn.md | 321 +++++++++++++++++++++++++ 5 files changed, 1297 insertions(+) create mode 100644 docs/design/agent-artifact.md create mode 100644 docs/design/agent-session-chatlog.md create mode 100644 docs/design/agent-session-extension.md create mode 100644 docs/design/agent-session.md create mode 100644 docs/design/agent-turn.md diff --git a/docs/design/agent-artifact.md b/docs/design/agent-artifact.md new file mode 100644 index 0000000..cba0466 --- /dev/null +++ b/docs/design/agent-artifact.md @@ -0,0 +1,226 @@ +# Twilight Agent Artifact Core + +状态:设计规范 + +本文定义 `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 的内容集合。`Prepared` claim 为 in-flight owner operation 提供 GC 保护;`Active` claim 是已确认 owner fact 的 retention root;`Released` claim 不再保留任何内容。Core 不依赖 Session、Event、Chatlog 或 Application,且不解释 owner 的领域语义。Attachment 等 owner module 可以关联 `AttachmentID`、subject 与 `BindingID`,但该边界只使用 BindingID,不引入 Event 依赖。 + +**ART-SCP-1** Core 不得解释 `ClaimOwner`,不得要求某种数据库、文件系统或 provider 实现。第一版只要求 Memory reference implementation 和 conformance suite。 + +## 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) + 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 ( + ClaimPrepared ClaimState = "prepared" + ClaimActive ClaimState = "active" + ClaimReleased ClaimState = "released" +) + +// 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 } + +type RetentionLedger interface { + Prepare(context.Context, ClaimID, ClaimOwner, BindingSet) (RetentionClaim, error) + LookupClaim(context.Context, ClaimID) (RetentionClaim, bool, error) + Activate(context.Context, ClaimID) (RetentionClaim, error) + AbortPrepared(context.Context, ClaimID) error + ReleaseActive(context.Context, ClaimID) error + PreparedClaims(context.Context, ClaimCursor) (ClaimPage, error) + ClaimsByOwner(context.Context, ClaimOwnerQuery, ClaimCursor) (ClaimPage, error) + ImportActiveClaims(context.Context, []RetentionClaim) 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:`Prepare` 对同 ID、同 owner、同 set 幂等,对任何其他组合 conflict。`Prepared` 与 `Active` 都是 GC roots;GC 只忽略 `Released` claim。未知 scheme 必须保守保留。 + +| 操作 | 前置状态 | 结果 | +|---|---|---| +| Prepare(new ID, owner, set) | 不存在 | Prepared | +| Prepare(existing ID, exact owner/set) | Prepared/Active | 返回既有 claim | +| Prepare(existing ID, exact owner/set) | Released | conflict | +| Prepare(existing ID, other owner/set) | 任意 | conflict | +| Activate | Prepared | Active | +| Activate | Active | 幂等成功 | +| Activate | Released/不存在 | conflict/not found | +| AbortPrepared | Prepared,且有 owner operation terminally aborted 证据 | Released | +| AbortPrepared | Released | 幂等成功 | +| AbortPrepared | Active | conflict | +| ReleaseActive | Active,且 owner retention 已结束 | Released | +| ReleaseActive | Released | 幂等成功 | +| ReleaseActive | Prepared | conflict | + +**ART-RET-3** `AbortPrepared` 的调用方必须以证明 owner operation 已 terminally aborted 的 durable evidence 授权。owner operation 查询为 NotFound、unknown 或 transient failure 时,reconciler 必须保留原 `Prepared` claim 并留待后续 reconciliation。对同一 owner fact 的 retry 必须保留原 `Prepared` claim,绝不得先 abort 再复用 ID。`PreparedClaims` 与 `ClaimsByOwner` 使用 watermark cursor,按 ClaimID 稳定排序;空 owner identities 不匹配。reconciler 将已证实 owner fact 映射为 Activate、已证实 terminal abort 映射为 AbortPrepared、已证实 owner retention 结束映射为 ReleaseActive。 + +## 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。 + +**ART-PRO-2** adapter 改变物理实现时必须保持 locator resolution 不变,并以 generation/fence 防止旧位置在新位置验证可恢复前回收。具体 filesystem、DB 与迁移步骤由 adapter/Application 负责。 + +## 7. archive 与 import/export + +```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。 + +多 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。 + +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、全状态表、cursor pagination、Prepared/Active GC protection 与保守 reconcile; +- **ART-PRO-1、ART-PRO-2**:immutable registry、provider-instance isolation 与迁移 fence; +- **ART-ARC-1、ART-ARC-2**:manifest wire、inspection、verified all-or-nothing active-claim import 和 exact idempotency。 diff --git a/docs/design/agent-session-chatlog.md b/docs/design/agent-session-chatlog.md new file mode 100644 index 0000000..6207a23 --- /dev/null +++ b/docs/design/agent-session-chatlog.md @@ -0,0 +1,224 @@ +# Twilight Agent Session Chatlog Extension + +状态:设计草案,wire schema/version 尚未冻结。 + +本文定义 `agent/session/chatlog` first-party Module,依赖 [Session](agent-session.md) 与 [Extension Framework](agent-session-extension.md)。文中的“必须”“不得”“应该”是草案冻结时应保留的协议约束;canonical JSON 与 digest 遵循 `agent/jsonstable`、`agent/es`。 + +## 1. module 与 ontology + +```text +ModuleID = twilight.chatlog +Event prefix = twilight.chatlog/ +Projections = twilight.chatlog/surface, twilight.chatlog/context +``` + +committed chatlog Events 是 persisted chatlog facts;Surface 和 Context 都是从这些 Events fold 的 pure projections。Attachment 是独立 domain;外部内容只能经 Message 的 `ReferencePart` 关联 BindingID。 + +**CHT-SCP-1** Module 的 authority 是会话语义;Application/provider adapter 负责模型调用、provider transport、发送策略、外部内容读取与审计 policy;`twilight.turn` 负责 Turn execution linkage,chatlog payload 必须限于会话语义。 + +## 2. stable entity 与生命周期 + +```go +type InputID string +type TurnID string +type ItemID string +type MessageID string +type CallID string +type CheckpointID string +type ItemKind string +type EntityStatus string +``` + +InputID、TurnID、ItemID、MessageID 在 resolved ancestry 内唯一;CallID 在 Turn 内唯一。replacement graph 无环,且一个实体至多一个直接 replacement。 + +| 实体 | 创建 | 可变过程 | 终态/替换 | 不变量 | +|---|---|---|---|---| +| Input | InputSubmitted | 无 | Delivered / Withdrawn / Rejected | 只终结一次 | +| Turn | TurnOpened | 无 | Completed / Failed / Superseded | replacement 指向新 Turn | +| Item | ItemOpened | ItemUpdated* | Completed / Failed / Superseded | `(TurnID, Sequence)` 唯一;version 连续 | +| Message | MessageCommitted | 无 | 可被 MessageSuperseded | immutable;ID 单次创建 | +| Checkpoint | ContextCheckpointCreated | 无 | Invalidated | 仅指向已有 EventPosition | + +**CHT-LIF-1** reducer 必须拒绝 identity mutation、非法状态迁移、version gap、sequence collision、replacement conflict 与重复 MessageID(即使 canonical value 相同)。一个 item 的 Completed 必须携带 final version 与 final content digest。 + +## 3. semantic Message interfaces + +```go +type MessagePartKind string +type ContentRefKind string +type MessageRole string +const ( + RoleUser MessageRole = "user" + RoleAssistant MessageRole = "assistant" + RoleToolResult MessageRole = "tool_result" + PartText MessagePartKind = "twilight.chatlog/text" + PartReasoning MessagePartKind = "twilight.chatlog/reasoning" + PartToolCall MessagePartKind = "twilight.chatlog/tool_call" + PartToolResult MessagePartKind = "twilight.chatlog/tool_result" + PartReference MessagePartKind = "twilight.chatlog/reference" + RefArtifactBinding ContentRefKind = "twilight.chatlog/artifact_binding_ref" +) +type MessagePart interface { PartKind() MessagePartKind } +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() MessagePartKind +type TextPart struct { Text string } +func (TextPart) PartKind() MessagePartKind +type ReasoningPart struct { Text string } +func (ReasoningPart) PartKind() MessagePartKind +type ToolCallPart struct { CallID CallID; Name string; Input jsonstable.Value } +func (ToolCallPart) PartKind() MessagePartKind +type ToolResultStatus string +const ( + ToolSuccess ToolResultStatus = "success"; ToolError ToolResultStatus = "error" + ToolUnknown ToolResultStatus = "unknown"; ToolIndeterminate ToolResultStatus = "indeterminate" +) +type ToolResultPart struct { CallID CallID; Status ToolResultStatus; Parts []MessagePart } +func (ToolResultPart) PartKind() MessagePartKind +type Message struct { + ID MessageID; TurnID TurnID; Role MessageRole + InputIDs []InputID; ItemIDs []ItemID; Parts []MessagePart; Digest es.Digest +} +``` + +**CHT-MSG-1** Parts、InputIDs、ItemIDs 均有序。`ArtifactBindingRef` 的 identity 为 discriminator 与 exact BindingID。所有 interface value 必须非 nil 且非 typed nil;part kind 必须匹配 concrete value。ReferencePart 不重复声明 MediaType;consumer 从 Binding 的 identity-bound Ref 得到它,并仍将其作为 untrusted metadata。 + +**CHT-MSG-2** assistant message 可有 Text、Reasoning、Reference、ToolCall;每个 `(TurnID,CallID)` 只有一个 ToolCall。user message 的每个 InputID 必须属于该 Turn 的 `TurnOpened.InputIDs`,已被 `InputDelivered` 指向该 Turn,且尚未被其他 user message 消费;不得消费未声明、未 delivered 或已消费 input。 + +`tool_result` role 只可有对应既有 call 的一个 ToolResult。`unknown` 与 `indeterminate` 是 terminal historical result,但 active Context 视其 call unresolved。唯一处置是 `MessageSuperseded` 将该结果 Message 指向同一 Turn、同一 CallID 的新 replacement tool-result Message;replacement 必须为 `success` 或 `error`,且成功/错误后该 active call resolved。每个 unresolved historical result 最多一个 replacement,replacement chain 中同一 call 只有一个 active Message;不得以新 call、另一 event 或删除历史结果处置。 + +**CHT-MSG-3** ToolResult 的 v1 nested Parts 只允许单层 TextPart 或 ReferencePart;不得嵌套 Reasoning、ToolCall、ToolResult 或另一层 child。ItemIDs 必须指向同一 Turn 中已 completed 且 final digest 匹配的 item;message role、TurnID、item sequence 必须符合已折叠状态。`ItemOpened.Content` 与 `ItemUpdated.Content` 在 v1 只承载 inline canonical content,不得承载 durable external BindingID 或 artifact reference;外部内容必须使用 Message `ReferencePart`。 + +## 4. canonical wire codec + +运行时 interface 是 typed model;wire 是 static registry 的 discriminated union。 + +```go +type PartCodec interface { + Kind() MessagePartKind + EncodePart(MessagePart) (jsonstable.Value, error) + DecodePart(jsonstable.Value) (MessagePart, error) +} +type ContentRefCodec interface { + Kind() ContentRefKind + EncodeRef(ContentRef) (jsonstable.Value, error) + DecodeRef(jsonstable.Value) (ContentRef, error) +} +type MessageBindingExtractor struct { /* immutable Message registry implementation */ } +func (MessageBindingExtractor) BindingIDs(value any) ([]artifact.BindingID, error) + +type MessageRegistry interface { + extension.RuntimeRegistry + RegistryID() extension.RegistryID + Version() uint16 + EncodeMessagePreimage(Message) (jsonstable.Value, error) + EncodeMessage(Message) (jsonstable.Value, error) + DecodeMessage(jsonstable.Value) (Message, error) +} +``` + +**CHT-COD-1** Decode 必须 wire-first:先检查 object、explicit discriminator、schema、unknown fields 和 limits,再构造 typed value。Encode/Decode 均拒绝 nil/typed nil、kind mismatch、未知 discriminator、cycle 和超限输入。有效值必须 `Encode → Decode → Encode` canonical-equivalent。 + +**CHT-COD-2** registry 是 startup static、immutable,kind 有唯一 codec,不依赖 Go type name。Message registry 实现 `extension.RuntimeRegistry.BindingExtractor()`,且必须返回 `(MessageBindingExtractor, true)`,不得以 typed nil 表示缺失。该 extractor 只接受 typed `MessageCommittedPayload`,并按 appearance order 返回其 `Message` 中顶层 ReferencePart 与 ToolResult 单层 child 的全部 ArtifactBindingRef;错误的 typed value 必须拒绝。它是 Extension Binding extraction 的唯一 Chatlog 入口。v1 draft 的 discriminator 为本节定义的 `Part*` 和 `RefArtifactBinding` 值;后续 extension kind 使用其 owner namespace。 + +**CHT-COD-3** `EncodeMessagePreimage` 编码 ID、TurnID、role、有序 InputIDs/ItemIDs、parts、每个 ref identity 和允许 extension fields,排除 Digest。`MessageDigest=Digest("twilight.chatlog/message/v1", registry ID, registry version, preimage)`;`EncodeMessage` 编码同一 preimage 加 Digest,Decode 必须重算。MessageCommitted payload 的 typed shape 是 `MessageCommittedPayload{Message Message}`;它的 wire codec 必须将 `message` 编码为 `EncodeMessage(Message)` 得到的 canonical message value,不得 generic re-marshal 或重复其中任一字段。 + +Note:本规范保持 draft;v1 freeze 前准确开放项仅为 wire field names、输入 limits、registry version 与 golden fixtures。 + +## 5. event schemas + +下列 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 TurnOpenedPayload struct { TurnID TurnID; InputIDs []InputID } +type TurnCompletedPayload struct { TurnID TurnID } +type TurnFailedPayload struct { TurnID TurnID; FailureClass string } +type TurnSupersededPayload struct { TurnID TurnID; ReplacementTurnID TurnID } +type ItemOpenedPayload struct { ItemID ItemID; TurnID TurnID; Sequence uint32; Kind ItemKind; Version uint32; Content jsonstable.Value; ContentDigest es.Digest } +type ItemUpdatedPayload struct { ItemID ItemID; Version uint32; Content jsonstable.Value; ContentDigest es.Digest } +type ItemCompletedPayload struct { ItemID ItemID; FinalVersion uint32; FinalDigest es.Digest } +type ItemFailedPayload struct { ItemID ItemID; FailureClass string } +type ItemSupersededPayload struct { ItemID ItemID; ReplacementItemID ItemID } +type MessageCommittedPayload struct { Message Message } +type MessageSupersededPayload struct { MessageID MessageID; ReplacementMessageID MessageID } +type ContextCheckpointCreatedPayload struct { + CheckpointID CheckpointID; CoveredThrough session.EventPosition + BaseContextDigest es.Digest; SummaryMessageID MessageID; SummaryMessageDigest es.Digest + Retained []MessageDigestPair; Digest es.Digest +} +type MessageDigestPair struct { MessageID MessageID; Digest es.Digest } +type ContextCheckpointInvalidatedPayload struct { CheckpointID CheckpointID; Reason string } +``` + +`ModuleDescriptor` 中 `twilight.chatlog/message_committed` 的每个有效 EventSchema 必须注册 Message registry requirement,并声明唯一 Binding reference: + +```go +extension.BindingReferenceDefinition{ + RegistryID: "twilight.chatlog/message", + Cardinality: extension.Cardinality{Min: 0}, + AllowedSchemes: nil, + RequiredDurability: artifact.EventBound, +} +``` + +`AllowedSchemes:nil`(或 empty)明确表示任意 Catalog-registered 且支持 `EventBound` 的 scheme;非空 slice 才是显式允许列表。它不表示允许 ephemeral。该 declaration 使 Message extractor 覆盖的每一个 Binding 都接受 Extension admission。 + +**CHT-EVT-1** Event names依次为 `input_submitted`、`input_delivered`、`input_withdrawn`、`input_rejected`、`turn_opened`、`turn_completed`、`turn_failed`、`turn_superseded`、`item_opened`、`item_updated`、`item_completed`、`item_failed`、`item_superseded`、`message_committed`、`message_superseded`、`context_checkpoint_created`、`context_checkpoint_invalidated`,均以 `twilight.chatlog/` 为前缀。 + +**CHT-EVT-2** InputSubmitted 创建 input;Delivered/Withdrawn/Rejected 只能一次终结它。`InputDelivered` 必须指向已存在、尚未终结的 TurnOpened,且该 TurnOpened.InputIDs 明确声明该 InputID。TurnOpened 的 InputIDs 必须是 submitted、distinct 且未终结 inputs;Turn 终结后不得新增 Item 或 Message。ItemOpened 从 version 1 起,ItemUpdated 每次加一。Item ContentDigest 的唯一算法为 `Digest("twilight.chatlog/item-content/v1", ItemID, TurnID, Kind, Version, canonical Content)`:ItemOpened 用 payload 的 ItemID/TurnID/Kind/Version/Content 重算并匹配其 ContentDigest;ItemUpdated 用当前 item 的 ItemID/TurnID/Kind 和该 update 的 Version/Content 重算并替换当前 digest;ItemCompleted 的 FinalVersion 和 FinalDigest 必须分别匹配当前/latest version 与该 latest digest。MessageCommitted payload codec 必须 decode/validate typed `MessageCommittedPayload.Message`,再创建其 MessageID;同一 resolved ancestry 中只能创建一次。 + +**CHT-EVT-3** `BaseContextDigest` 覆盖截至 `CoveredThrough`(必须早于该 checkpoint event)的 canonical ordered active Message sequence,即有序 `(MessageID, MessageDigest)`,而非仅 ID;该 base sequence 不包含 summary。`SummaryMessageID` 必须在 `CoveredThrough` 之后、checkpoint event 之前已由 `MessageCommitted` 创建且仍 active,其 digest 必须等于 `SummaryMessageDigest`。在 `CoveredThrough` 与 checkpoint event 之间,除该指定 summary 外不得有其他 context-contributing `MessageCommitted` 或 checkpoint;surface/log-only facts 可以出现。`Retained` 必须是 base sequence 的有序子集,digest 均匹配且不得重复。checkpoint digest 覆盖 registry identity、CoveredThrough、BaseContextDigest、summary pair 和 ordered retained pairs。应用有效 checkpoint 的结果固定为 `[SummaryMessage] + Retained`;tail 仅从 checkpoint event 之后开始 fold,绝不再次折叠 summary。显式 invalidation,或 checkpoint 之后 summary、任一 retained 或任一 base source message 的 supersession,都会使该 checkpoint incompatible;projection 必须回退到更早 compatible checkpoint 或完整 fold,不得对 checkpoint 内部结果局部应用 supersession。 + +## 6. Surface projection + +```go +type SurfaceItem struct { ItemID ItemID; TurnID TurnID; Sequence uint32; Status EntityStatus; Version uint32; Digest es.Digest } +type SurfaceMessage struct { MessageID MessageID; Position session.EventPosition } +type ConversationTurn struct { TurnID TurnID; Status EntityStatus; ItemOrder []ItemID; SupersededBy TurnID } +type Surface struct { + Turns []ConversationTurn; Items map[ItemID]SurfaceItem; Messages map[MessageID]Message + MessageOrder []SurfaceMessage +} +``` + +**CHT-SUR-1** SurfaceFold 是 pure deterministic fold,只消费 chatlog decoded events。它按 stable IDs 聚合 active、terminal 与 superseded 状态;`MessageOrder` 是 resolved replay order 的稳定完整 MessageCommitted sequence,并以 Position 保留 source event identity,调用方不得从 map iteration 推导顺序。展示 grouping 由调用方从 ItemOrder 和 MessageOrder 派生。 + +## 7. Context projection + +```go +func ContextFold(events []extension.DecodedEvent) ([]Message, error) +``` + +**CHT-CTX-1** 输入必须是已验证、decode、upcast 后、按 resolved ancestry segment/event order 的 events。ContextFold 只输出有效 MessageCommitted 经 supersession/checkpoint 处理后的有序 `[]Message`;它不得接收 raw commits、resolve content、访问 IO 或 provider transport。 + +**CHT-CTX-2** fold 必须再次执行 ancestry-wide MessageID single-creation、role/call pairing、active unresolved-call 检查以及 unknown/indeterminate replacement 规则。checkpoint 只在 CHT-EVT-3 的 coverage gap、base/summary/retained digest 与 retained order 均匹配且仍 compatible 时使用;应用后按 `[SummaryMessage] + Retained` 继续仅 fold checkpoint event 之后的 tail。 + +## 8. materializer port + +```go +type MaterializationTarget struct { Name string; Capabilities []string } +type MaterializationDecision struct { MessageID MessageID; BindingID artifact.BindingID; Operation string; Result string; Detail string } +type MaterializedMessage struct { Role MessageRole; Parts []jsonstable.Value } +type MaterializationResult struct { Messages []MaterializedMessage; Decisions []MaterializationDecision } +type ContextMaterializer interface { + Materialize(context.Context, []Message, MaterializationTarget) (MaterializationResult, error) +} +``` + +**CHT-MAT-1** materializer 是 IO boundary,可把 semantic message 转为 target 表示并报告 decisions;它不得改写 Message 或 committed chatlog Events。provider capability、fallback、audit 以及何时持久化 decision 都是 Application policy,本规范不要求发送前追加任何事实。 + +## 9. conformance + +Conformance 必须验证: + +- **CHT-LIF-1、CHT-EVT-1、CHT-EVT-2**:entity lifecycle、InputDelivered 的 opened/declaration 约束、sequence/version、Item ContentDigest 的 opened/update 重算与 latest completion matching、replacement 与 ancestry-wide MessageID; +- **CHT-MSG-1、CHT-MSG-2、CHT-MSG-3**:role/turn/order、declared-delivered-unconsumed input、CallID pairing、unknown/indeterminate 的唯一 replacement resolution、nested v1 grammar 与无 Item external BindingID; +- **CHT-COD-1、CHT-COD-2、CHT-COD-3**:wire-first codec、nil/kind/limit rejection、Message registry RuntimeRegistry extractor、`message_committed` 的 EventBound registry declaration、typed MessageCommittedPayload canonical message wire、digest closure 与完整 BindingIDs; +- **CHT-SUR-1、CHT-CTX-1、CHT-CTX-2**:pure Surface/Context、稳定 MessageOrder、decoded input、checkpoint 的 base 不含 summary、summary placement/coverage gap、retained ordered subset、`[Summary] + Retained` replacement、checkpoint 后 tail,以及 invalidation 或 source/summary/retained supersession 的回退; +- **CHT-MAT-1**:materializer port 不越过 Application policy boundary。 diff --git a/docs/design/agent-session-extension.md b/docs/design/agent-session-extension.md new file mode 100644 index 0000000..5a338b0 --- /dev/null +++ b/docs/design/agent-session-extension.md @@ -0,0 +1,255 @@ +# Twilight Agent Session Extension Framework + +状态:设计规范 + +本文定义建立在 `agent/session` 与 `agent/artifact` 之上的静态 `agent/session/extension`。文中的“必须”“不得”“应该”是协议约束;JSON canonicalization 与 digest 遵循 `agent/jsonstable`、`agent/es`。 + +## 1. 范围与依赖 + +```text +agent/artifact ← agent/session/extension → agent/session + ↑ + first-party 与 Application modules +``` + +Framework 负责 Module ownership、static startup composition、immutable Catalog、event codec/schema/upcast、Binding declaration、SemanticAppender 和 pure projection。first-party modules 可包括 `twilight.chatlog` 与 `twilight.turn`。它不支持运行时动态代码加载;新组合必须构建新 immutable Catalog。 + +**EXT-SCP-1** Session Store protocol 由 Session kernel 负责;多 Session transaction、package import、saga、operation log 与 provider policy 由 Application/adapter 负责。未知 event 的 archive Binding manifest 由 Application archive coordinator 处理。 + +## 2. Module、Catalog 与版本 + +```go +type ModuleID string +type ModuleVersion uint16 +type ProjectionID string +type ProjectionVersion uint16 +type RegistryID string + +type ModuleDescriptor struct { + ID ModuleID; Version ModuleVersion + Events []EventDefinition + Projections []ProjectionDefinition + Registries []CodecRegistryDescriptor + Schemes []artifact.SchemeDefinition +} +type Catalog struct { /* immutable, built only by BuildCatalog */ } +type CatalogBuildRequest struct { Modules []ModuleDescriptor; Registries []RuntimeRegistry } +type RuntimeRegistry interface { + Descriptor() CodecRegistryDescriptor + BindingExtractor() (BindingExtractor, bool) +} +func BuildCatalog(CatalogBuildRequest) (*Catalog, error) +``` + +**EXT-CAT-1** ModuleID 使用小写 ASCII namespace;`twilight.*` 保留 first-party。一个 Catalog 中 ModuleID、EventType、ProjectionID、Scheme、RegistryID 均唯一。Build defensive-copy 所有 descriptor 和 runtime registry,成功后只读且与注册顺序无关。 + +| 变化 | ModuleVersion | Event SchemaVersion | ProjectionVersion | +|---|---:|---:|---:| +| 新 EventType | 增加 | 从 1 开始 | 受影响时增加 | +| 修改 payload 或 Binding 声明 | 增加 | 增加 | fold 受影响时增加 | +| 修改 reducer/state codec | 增加 | 不变 | 增加 | +| 增加无关 Module | Catalog composition 改变 | 不变 | 不变 | + +**EXT-CAT-2** Build 必须拒绝 duplicate owner、namespace mismatch、schema gap、重复版本、registry requirement mismatch、upcast gap/cycle/ambiguity、非法 Binding declaration、以及任何 Session event declaration 的 `RequiredDurability < EventBound`。Binding declaration 引用 RegistryID 时,Catalog 必须找到匹配 runtime registry,并要求其 `BindingExtractor()` 返回 `(extractor, true)` 且 extractor 非 nil/非 typed nil;`false` 明确表示该 registry 不支持 Binding extraction,不能被该 declaration 引用。 + +## 3. event schema、codec 与 upcast + +```go +type EventDefinition struct { Type session.EventType; Schemas []EventSchema; Upcasters []Upcaster } +type EventSchema struct { + Version uint16 + Codec PayloadCodec + Bindings []BindingReferenceDefinition + RegistryRequirements []CodecRegistryRequirement +} +type PayloadCodec interface { + Encode(value any) (jsonstable.Value, error) + Decode(wire jsonstable.Value) (any, error) + Validate(value any) error +} +type CodecRegistryDescriptor struct { + ID RegistryID; Version uint16; WireManifest jsonstable.Value; WireProfile string +} +type CodecRegistryRequirement struct { ID RegistryID; Version uint16; WireManifest jsonstable.Value } +type Upcaster interface { + FromVersion() uint16; ToVersion() uint16 + Upcast(jsonstable.Value) (jsonstable.Value, error) +} +type DecodedEvent struct { + Event session.SessionEvent; ModuleID ModuleID + PersistedVersion uint16; TargetVersion uint16; Value any +} +``` + +**EXT-COD-1** descriptor 是 wire manifest/profile;runtime registry 是显式提供的 immutable implementation。descriptor 不声称 hash 或证明代码。Catalog 必须逐字段匹配 requirement 和 runtime descriptor。 + +**EXT-COD-2** codec、Validate、Upcast、Binding extraction 必须纯、确定、无 IO,不读 clock/random/environment/mutable global。Decode wire-first:先验证 object、discriminator(如适用)、kind、schema、limits 和 unknown policy,再构造 value。Encode/Decode 必须拒绝 nil、typed nil、kind mismatch、未知 kind 和非 canonical value;有效值须满足 `Encode → Decode → Encode` 的 canonical round-trip。 + +**EXT-COD-3** 一个 EventType 的 upcast 路径只能线性向前;upcast 仅为 projection 生成 typed view,不改写 committed Session events。Catalog 对未知 EventType 不作 decode。 + +## 4. Binding reference declaration + +```go +type Cardinality struct { Min uint32; Max *uint32 } +type BindingReferenceDefinition struct { + JSONPointer string // 仅普通 JSON payload 路径 + RegistryID RegistryID // 仅 custom runtime-registry 路径 + Cardinality Cardinality + AllowedSchemes []artifact.Scheme + RequiredDurability artifact.Durability +} +type BindingOccurrence struct { BindingID artifact.BindingID; Location string } +type BindingExtractor interface { BindingIDs(value any) ([]artifact.BindingID, error) } +``` + +**EXT-REF-1** declaration 恰选一种:非空 JSONPointer,或非空 RegistryID。JSONPointer 路径仅在普通 canonical JSON payload 上执行;RegistryID 路径把该 event 的 decoded typed value 交给对应 RuntimeRegistry 的 BindingExtractor。custom registry(包括 Message registry)必须以 `BindingIDs(value)` 返回内部全部引用,不能回退为 JSONPointer 猜测。提取保留 occurrence appearance order,随后 append group 才 sorted-unique。 + +**EXT-REF-2** Catalog 验证 cardinality、pointer grammar、RegistryID extractor availability 与 scheme/durability declaration。admission 解析每个 Binding,验证 Scheme、最低 durability、resolvability 与 host access policy;任何遗漏或违反均拒绝整个 group。所有 Session event declaration 的最低 durability 至少为 `EventBound`。 + +## 5. SemanticAppender + +普通 producer 只持有 SemanticAppender;raw `session.Store` 只能注入它或由 trusted adapter/recovery 使用。 + +```go +type TypedEvent struct { + EventID session.EventID; Type session.EventType; SchemaVersion uint16 + RecordedAtUnixMilli int64; SourceEvents []session.EventID; Value any +} +type SemanticAppendRequest struct { + SessionID session.SessionID; ExpectedHead session.Head; CommitID session.CommitID + CausationID es.CausationID; CorrelationID string; Events []TypedEvent +} +type SemanticAppendOutcome string +const ( + SemanticApplied SemanticAppendOutcome = "applied" + SemanticAlreadyApplied SemanticAppendOutcome = "already_applied" + SemanticHeadConflict SemanticAppendOutcome = "head_conflict" + SemanticCommitConflict SemanticAppendOutcome = "commit_conflict" + SemanticInvalid SemanticAppendOutcome = "invalid" + SemanticIndeterminate SemanticAppendOutcome = "indeterminate" +) +type SemanticAppendResult struct { + Outcome SemanticAppendOutcome; Commit *session.SessionCommit + BindingSet *artifact.BindingSet; Reconciled bool +} +type SemanticAppender interface { + AppendSemantic(context.Context, SemanticAppendRequest) (SemanticAppendResult, error) +} +``` + +为闭合进程崩溃后只有 claim、却没有完整 append request 的状态,单次 semantic append 使用下列最小 durable journal;multi-Session package saga 由 Application coordination 负责。 + +```go +type SemanticAppendIntentState string +const ( + IntentPending SemanticAppendIntentState = "pending" + IntentCompleted SemanticAppendIntentState = "completed" + IntentAborted SemanticAppendIntentState = "aborted" +) +type SemanticAppendIntent struct { + ClaimID artifact.ClaimID + ClaimOwner artifact.ClaimOwner + BindingSet artifact.BindingSet + Fingerprint es.Digest + CanonicalRequest session.AppendRequest + State SemanticAppendIntentState +} +type SemanticAppendJournal interface { + Prepare(context.Context, SemanticAppendIntent) (SemanticAppendIntent, error) + Lookup(context.Context, artifact.ClaimID) (SemanticAppendIntent, bool, error) + Pending(context.Context, artifact.ClaimCursor) ([]SemanticAppendIntent, *artifact.ClaimCursor, error) + MarkTerminal(context.Context, artifact.ClaimID, SemanticAppendIntentState) error +} +// Before an Event is visible, this capability atomically persists either the +// exact full intent and its matching Prepared claim, or the commit and matching +// Active claim; each outcome leaves a recoverable claim plan. +type AtomicSemanticCommitter interface { + CommitSemanticAtomic(context.Context, SemanticAppendIntent) (session.AppendResult, error) +} +``` + +**EXT-APP-1** request 的 Events 是完整 group,不能为空。Appender 对每个 TypedEvent lookup schema、validate、canonical encode、decode-round-trip 和 extract;任一失败不作 IO。它将全部 occurrence 组成 sorted-unique union,并通过 `artifact.BindingSetBuilder.Build(ctx, union)` 构造完整 BindingSet;Extension 只对 Build 已 resolve 的 Binding 按 declaration 执行 Scheme、最低 durability 与 host access admission。Appender 必须将 TypedEvent 的 `EventID`、`RecordedAtUnixMilli`、`SourceEvents` 与 canonical Payload、Type、SchemaVersion 逐字段映射为 `session.UncommittedEvent`,不得生成或替换其中任一值。`SourceEvents` 仍由 Session 的 bytewise sorted-unique、same-resolved-stream 规则验证。 + +**EXT-APP-2** 对 nonempty BindingSet,先经 `Store.Header(ctx, SessionID)` 读取并验证该 Session 的 Header,唯一派生为: + +```text +ClaimID = Digest("twilight.session-extension/claim/v1", + claim-profile-version "1", + extension-canonical-string(Header.ProtocolVersion), + SessionID, CommitID, BindingSet.RefSetDigest) +ClaimOwner = {Kind:"twilight.session/commit", + Authority:string(SessionID), Identity:string(CommitID)} +``` + +claim profile version 固定为 `1`;整数使用 Extension canonical string profile(无前导零的十进制),其中 Session `ProtocolVersion` 只能取自已验证 Header。Artifact `WireVersion` 已由 `BindingSet.RefSetDigest` 覆盖,绝不另入 preimage。ClaimID 不含 ExpectedHead。先 Build set,再派生 claim;调用者不得提供或覆盖它们。相同 CommitID retry 的 typed event identity/time/source/payload 或 BindingSet 不同均为 conflict;完全相同 immutable request 的 append fingerprint 稳定。`ExpectedHead` 不入该 fingerprint,且是同一 pending intent 唯一可更新的 CanonicalRequest 字段。retry、recovery 及导入 `twilight.session/commit` active claim 时都必须以该 Header 和同一 preimage 重算并验证 ClaimID。 + +**EXT-APP-3** 没有 Binding 时不创建 BindingSet、intent 或 claim。否则先构造含 ClaimID、ClaimOwner、BindingSet、Fingerprint、完整 CanonicalRequest 和 Pending State 的 intent,且非原子流程严格为 `journal.Prepare → ledger.Prepare → Store.Commit`。journal `Prepare` 对既有同一 ClaimID 的记录,必须逐字段验证 Fingerprint、ClaimOwner、BindingSet 及 CanonicalRequest 的所有 immutable 字段;只有全部相同才幂等,并且仅可更新 Pending intent 的 `CanonicalRequest.ExpectedHead`,其他差异均为 conflict。`ledger.Prepare` 必须使用 intent 内的 ClaimID、ClaimOwner、BindingSet。支持 `AtomicSemanticCommitter` 的 adapter 可跳过单独 choreography,但必须原子持久化同一完整 intent 与匹配 Prepared claim,或直接原子 commit 并建立匹配 Active claim;两种路径均不得在 Event 可见前缺少可恢复 claim plan,并使用等价 terminal handling。 + +```text +AppendSemantic(request): + validate/encode group → extract → build BindingSet → map canonical AppendRequest + derive ClaimID/ClaimOwner and append fingerprint + if AtomicSemanticCommitter exists: + result = capability.CommitSemanticAtomic(intent) + else: + journal.Prepare(intent); ledger.Prepare(intent.ClaimID, intent.ClaimOwner, intent.BindingSet) + result = Store.Commit(intent.CanonicalRequest) + Applied/AlreadyApplied: ledger.Activate(ClaimID); journal.MarkTerminal(Completed) + HeadConflict: retain Prepared claim and pending intent; return actual head + Invalid/CommitConflict: journal.MarkTerminal(Aborted); ledger.AbortPrepared(ClaimID) + unknown result: retain Prepared claim and pending intent; return Indeterminate +``` + +只有 claim 已 Activate,`SemanticApplied` 或 `SemanticAlreadyApplied` 才能返回;Activate 或 terminal marking 的未知/失败必须保留 Prepared/pending state 并返回 `SemanticIndeterminate`。`HeadConflict`、`CommitConflict`、`Invalid` 分别映射为同名 Semantic outcome,后两者仅在 journal 已标记 Aborted 且 AbortPrepared 成功后返回。A `HeadConflict` retry 保持相同 CommitID、fingerprint、ClaimID 和所有 event identity/time/source/payload/BindingSet,仅更新 ExpectedHead。`LookupCommit` 找到 canonical commit 时 Activate;返回 NotFound 时保留 Prepared/pending state,留待 retry 或 recovery。明确的 terminal `Invalid` 或 `CommitConflict` 才可先 journal 标记 `Aborted`,再 AbortPrepared。 + +**EXT-APP-4** recovery 扫描 journal Pending 与 ledger PreparedClaims 的并集。 + +对每个 journal Pending intent,第一步总是以 `intent.ClaimOwner` 和 `intent.BindingSet` 幂等调用 `ledger.Prepare(intent.ClaimID, ...)`,并逐字段验证返回 claim 的 ID、owner、set;只有其 state 为 Prepared 或 Active 且完全匹配,才可 `LookupCommit` 或 retry `Store.Commit(intent.CanonicalRequest)`。 + +journal 已存在而 claim 尚不存在是正常 crash point:NotFound 只表示该 Prepare 之前尚无 claim,且上述 Prepare 必须重建 Prepared claim。找到且与 intent fingerprint 匹配的 canonical commit 时 Activate 并 MarkTerminal(Completed);只有 journal 已 durable 标记 Aborted 才可 AbortPrepared。 + +对 ledger 的每个 Prepared claim,journal 缺失或无法提供同 ID、owner、set 的 intent 时,必须保持 Prepared 并返回 `SemanticIndeterminate`/运维错误,绝不得 Commit 或 Abort。claim/journal 不匹配、非 Prepared/Active state、LookupCommit NotFound、unknown 或查询失败时,同样保留可恢复 state 并返回 indeterminate。journal 的 durable `Aborted` 标记授权 `AbortPrepared`。因此每个 crash point 都保有可恢复状态,直到完整 request 的 terminal 状态得到 durable confirmation。 + +## 6. pure projection 与 snapshot + +```go +type Consumption struct { Type session.EventType; Versions []uint16 } +type ProjectionDefinition struct { + ID ProjectionID; Version ProjectionVersion + Consumes []Consumption; Ignores []session.EventType; RequireComplete []ModuleID + Initial func() (any, error) + Apply func(any, DecodedEvent) (any, error) + StateCodec PayloadCodec +} +type ProjectionRunRequest struct { Definition ProjectionDefinition; Events []session.SessionEvent; InitialState any } +type ProjectionRunResult struct { State any; Applied uint64; Ignored uint64 } +type ProjectionRunner interface { Run(ProjectionRunRequest) (ProjectionRunResult, error) } +``` + +**EXT-PRJ-1** Initial、Apply、StateCodec、runner decode/upcast 都必须 pure。runner 只接受已验证的 complete commit sequence;一个 commit 内任一 event 失败,不得发布该 commit 的 partial state。 + +**EXT-PRJ-2** `Consumes` 表示必须 decode/upcast/handle 的类型版本,`Ignores` 是显式已知跳过,二者不得重叠。出现属于 `RequireComplete` module 的 unknown 或未分类 event 必须失败。其他 module event 可忽略。 + +**EXT-PRJ-3** snapshot 使用 Session snapshot envelope。只有 ProjectionID、ProjectionVersion、StateCodec canonical validation 和 Session coverage 都匹配时可复用;否则从 log 重建。 + +## 7. errors 与 conformance + +```go +type ErrorCode string +const ( + ErrInvalid ErrorCode = "invalid"; ErrUnknownEvent ErrorCode = "unknown_event" + ErrSchema ErrorCode = "schema"; ErrCodec ErrorCode = "codec" + ErrBinding ErrorCode = "binding"; ErrConflict ErrorCode = "conflict" + ErrIndeterminate ErrorCode = "indeterminate" +) +type Error struct { Code ErrorCode; Type session.EventType; Detail string } +func (Error) Error() string +``` + +Conformance 必须验证: + +- **EXT-CAT-1、EXT-CAT-2**:static immutable Catalog、所有 ownership/version conflict、拒绝低于 EventBound 的 Session declaration、optional BindingExtractor availability/typed-nil rejection; +- **EXT-COD-1、EXT-COD-2、EXT-COD-3**:wire-first、安全 codec、canonical round-trip 和 linear upcast; +- **EXT-REF-1、EXT-REF-2**:pointer/custom registry 全量提取、cardinality、extractor requirement、scheme/durability admission; +- **EXT-APP-1、EXT-APP-2、EXT-APP-3、EXT-APP-4**:TypedEvent→UncommittedEvent 全字段映射、由 Header ProtocolVersion/claim profile v1/RefSetDigest 派生的 stable ClaimID、完整 intent 的逐字段 journal idempotency(仅 ExpectedHead 可更新)、durable intent-before-claim、claim-before-commit、journal-only claim rebuild、claim-only journal-missing indeterminate、terminal abort evidence、active-claim import revalidation、atomic capability 等价性与 crash recovery; +- **EXT-PRJ-1、EXT-PRJ-2、EXT-PRJ-3**:pure fold、commit boundary、Consumes/Ignores/RequireComplete 和 snapshot equivalence。 diff --git a/docs/design/agent-session.md b/docs/design/agent-session.md new file mode 100644 index 0000000..0ecd067 --- /dev/null +++ b/docs/design/agent-session.md @@ -0,0 +1,271 @@ +# Twilight Agent Session Protocol + +状态:设计规范 + +本文定义 Twilight Session 的 Event Sourcing kernel。文中的“必须”“不得”“应该”是协议约束。 + +本文冻结 Twilight 的 Session stream、并发、Fork ancestry 与 integrity 语义。 + +## 1. Events 与范围 + +```text +Events = resolved committed SessionEvent stream +State = Fold(Events) + +Persistent representation + = immutable SessionHeader + + ordered atomic SessionCommit records containing ordered SessionEvents +``` + +Committed Events 构成 Session 的长期语义事实。进入 Fold 前,Session kernel 必须验证: + +- Event 位于完整的 SessionCommit 中; +- Header、commit boundary、CAS 与 digest chain 有效; +- Fork events 已按 root 到 target 解析为确定序列。 + +Header 与 Commit 是 Events 的 canonical persistent representation,提供原子提交、完整性验证与 fork/replay framing。职责边界为: + +```text +Session kernel envelope、顺序、CAS、fork、snapshot、canonical import、integrity +Extension event ontology、typed codec、payload validation、projection +``` + +Payload 对 Session kernel 是 opaque canonical JSON。缓存和 snapshot 是可重建派生数据。 + +**SES-SCP-1** kernel 不依赖领域 payload,也不对其执行 schema validation 或解释。resolved replay 的唯一顺序是 root-to-target ancestry segment order,再在每个 segment 内按 `(revision, index)`;`EventPosition` 只标识 event/cursor,不定义跨 segment 的排序;时钟只作 metadata。 + +## 2. wire types 与 profile + +```go +type SessionID string +type CommitID string +type EventID string +type EventType string +type ForkID string +type ProjectionKey string +type CursorToken string + +type SessionHeader struct { + ProtocolVersion uint16 + SessionID SessionID + ParentFork *ForkPoint + CausationID es.CausationID + Metadata jsonstable.Value + HeaderDigest es.Digest +} +type ForkPoint struct { + ParentSessionID SessionID + Revision es.Revision + HeadDigest es.Digest +} +type SessionEvent struct { + EventID EventID + Index uint16 + Type EventType + SchemaVersion uint16 + RecordedAtUnixMilli int64 + SourceEvents []EventID + Payload jsonstable.Value + EventDigest es.Digest +} +type UncommittedEvent struct { + EventID EventID + Type EventType + SchemaVersion uint16 + RecordedAtUnixMilli int64 + SourceEvents []EventID + Payload jsonstable.Value +} +type SessionCommit struct { + ProtocolVersion uint16 + SessionID SessionID + Revision es.Revision + PreviousDigest es.Digest + CommitID CommitID + CausationID es.CausationID + CorrelationID string + Events []SessionEvent + CommitDigest es.Digest +} +type Head struct { Revision es.Revision; Digest es.Digest } +``` + +**SES-WIR-1** identity 非空且稳定;EventID 在 resolved ancestry 内唯一。header 不可变,revision 从 1 连续递增,commit 至少有一个 event,event Index 从 0 连续递增。revision 1 的 `PreviousDigest=HeaderDigest`,其后为前一 CommitDigest。 + +**SES-WIR-2** `ProtocolProfile` 冻结 envelope field、null/omission、精确 integer encoding、unknown-field policy、array order 与下列 digest preimage;所有 digest 依 `agent/es` 的 versioned domain separator。 + +```go +type ProtocolProfile interface { + Version() uint16 + EncodeHeader(SessionHeader) (jsonstable.Value, error) + DecodeHeader(jsonstable.Value) (SessionHeader, error) + EncodeEvent(SessionID, es.Revision, SessionEvent) (jsonstable.Value, error) + DecodeEvent(SessionID, es.Revision, jsonstable.Value) (SessionEvent, error) + EncodeCommit(SessionCommit) (jsonstable.Value, error) + DecodeCommit(jsonstable.Value) (SessionCommit, error) + EncodeSnapshot(Snapshot) (jsonstable.Value, error) + DecodeSnapshot(jsonstable.Value) (Snapshot, error) + EncodeAncestryArchive(ImmutableAncestryArchive) (jsonstable.Value, error) + DecodeAncestryArchive(jsonstable.Value) (ImmutableAncestryArchive, error) + ValidateCanonicalHeader(jsonstable.Value) (SessionHeader, error) + ValidateCanonicalEvent(SessionID, es.Revision, jsonstable.Value) (SessionEvent, error) + ValidateCanonicalCommit(jsonstable.Value) (SessionCommit, error) + ValidateCanonicalSnapshot(jsonstable.Value) (Snapshot, error) + ValidateCanonicalAncestryArchive(jsonstable.Value) (ImmutableAncestryArchive, error) + FingerprintAppend(AppendRequest) (es.Digest, error) +} +``` + +Header、event、commit 分别覆盖自身以外的全部持久字段。event digest 额外覆盖 origin SessionID、revision 和 index。每个 `ValidateCanonical*` 必须执行 decode → encode 并要求 canonical-equivalent wire,同时验证对应 digest;它是 adapter/import 的唯一 canonical round-trip validation 入口。`SourceEvents` 是 bytewise sorted-unique set,且只可引用同一 resolved Session stream 内已存在的 EventID;跨 stream provenance 必须编码在 payload 的 owner-defined `SourceRef` 中。opaque Payload 必须已经 canonical,完整 value 进入 event digest 和 append fingerprint。空 stream head 是 `{0, HeaderDigest}`。 + +## 3. Store API 与 errors + +```go +type CreateRequest struct { + ProtocolVersion uint16; SessionID SessionID; CausationID es.CausationID; Metadata jsonstable.Value +} +type AppendRequest struct { + SessionID SessionID; ExpectedHead Head; CommitID CommitID + CausationID es.CausationID; CorrelationID string; Events []UncommittedEvent +} +type AppendDisposition string +const ( + AppendApplied AppendDisposition = "applied" + AppendAlreadyApplied AppendDisposition = "already_applied" + AppendHeadConflict AppendDisposition = "head_conflict" + AppendCommitConflict AppendDisposition = "commit_conflict" + AppendInvalid AppendDisposition = "invalid" +) +type AppendResult struct { Disposition AppendDisposition; Commit *SessionCommit; ActualHead Head } + +type ReplayMode string +const ( ReplayLocal ReplayMode = "local"; ReplayResolved ReplayMode = "resolved" ) +// EventPosition is a verified event identity and is safe to expose in APIs. +type EventPosition struct { OriginSessionID SessionID; Revision es.Revision; Index uint16; EventDigest es.Digest } +type ReplayCursor struct { Mode ReplayMode; AncestryDigest es.Digest; After *EventPosition; Token CursorToken } +type ReplayRequest struct { SessionID SessionID; Mode ReplayMode; Cursor *ReplayCursor; Limit uint32 } +type ReplayPage struct { Header SessionHeader; Commits []SessionCommit; Next *ReplayCursor; Head ResolvedHead } + +type ForkRequest struct { + ForkID ForkID; ChildSessionID SessionID; ParentSessionID SessionID + ExpectedParentBoundary ForkPoint; CausationID es.CausationID; Metadata jsonstable.Value +} +type ForkResult struct { Header SessionHeader; Created bool } + +type CanonicalImportRequest struct { Header SessionHeader; Commits []SessionCommit; AncestryArchive *ImmutableAncestryArchive } +type CanonicalImportResult struct { Header SessionHeader; Imported uint64; Head Head; AlreadyPresent bool } +type ImmutableAncestryArchive struct { ProfileVersion uint16; Headers []SessionHeader; Commits []SessionCommit; ArchiveDigest es.Digest } + +type SnapshotRequest struct { SessionID SessionID; ProjectionKey ProjectionKey; ProjectionVersion uint16; AtOrBefore ResolvedHead } +type SnapshotResult struct { Snapshot *Snapshot; Found bool; Covers bool } +type SaveSnapshotRequest struct { Snapshot Snapshot } +type SaveSnapshotResult struct { Snapshot Snapshot; Replaced bool } + +type Store interface { + Create(context.Context, CreateRequest) (SessionHeader, error) + Header(context.Context, SessionID) (SessionHeader, error) + Head(context.Context, SessionID) (Head, error) + LookupCommit(context.Context, SessionID, CommitID) (SessionCommit, bool, error) + Commit(context.Context, AppendRequest) (AppendResult, error) + Replay(context.Context, ReplayRequest) (ReplayPage, error) + Fork(context.Context, ForkRequest) (ForkResult, error) + ImportCanonical(context.Context, CanonicalImportRequest) (CanonicalImportResult, error) + LoadSnapshot(context.Context, SnapshotRequest) (SnapshotResult, error) + SaveSnapshot(context.Context, SaveSnapshotRequest) (SaveSnapshotResult, error) +} +``` + +```go +type ErrorCode string +const ( + ErrInvalid ErrorCode = "invalid"; ErrNotFound ErrorCode = "not_found" + ErrConflict ErrorCode = "conflict"; ErrCorrupt ErrorCode = "corrupt" + ErrUnsupportedProfile ErrorCode = "unsupported_profile"; ErrUnavailable ErrorCode = "unavailable" +) +type Error struct { Code ErrorCode; Operation string; SessionID SessionID; CommitID CommitID; Detail string } +func (Error) Error() string +``` + +**SES-API-1** Store.Commit 是唯一 append Store protocol port。它必须原子持久化完整 commit 与 head;普通 producer 的 capability exposure 是 Extension 的责任。`ImportCanonical` 只给 trusted adapter、recovery/import coordinator 或 test。 + +## 4. append 与 idempotency + +**SES-APP-1** CommitID 的幂等键为 `(SessionID, CommitID)`。append fingerprint 覆盖 SessionID、CommitID、causation、correlation 和完整有序 UncommittedEvent group,**不**覆盖 ExpectedHead。 + +```text +append(request): + if existing := lookup(SessionID, CommitID): + if fingerprint(existing) == fingerprint(request): return AlreadyApplied(existing) + return CommitConflict + validate profile, identities, payloads and ExpectedHead + assign next revision and contiguous indexes; calculate event/commit digests + atomically write complete commit and new head + return Applied(commit) +``` + +**SES-APP-2** CAS 不匹配返回 `HeadConflict` 和 actual head,不得 last-write-wins。任何 invalid envelope、重复 EventID、非 canonical payload 或 digest-chain violation 返回 `Invalid` 或 `Corrupt`;不得部分写入。Store 至少拒绝 resolved ancestry 内的 EventID duplicate。 + +## 5. replay + +```go +type ResolvedHead struct { SessionID SessionID; LocalHead Head; AncestryDigest es.Digest } +type RetainClosure struct { Headers []es.Digest; Boundaries []ForkPoint; Commits []es.Digest } +``` + +**SES-REP-1** Local replay 只返回请求 `SessionID` 自己的 commits;其每个 `EventPosition.OriginSessionID` 必须等于该 SessionID,并按 `(Revision, Index)` 递增。Resolved replay 从 root 的可见 prefix 到 target 的 local commits,严格按 root-to-target ancestry segment order,再按每个 segment local `(Revision, Index)` 返回;每个 event 保留其实际 origin position。`EventPosition` 是 verified identity/cursor,不能替代该 segment order。两种模式的 page 都只返回完整 commit;`After=nil` 表示起点,非 nil `After` 表示该精确 digest 的最后已返回 event,续页严格从它之后开始。 + +**SES-REP-2** `AncestryDigest` 的 preimage 是 profile、target SessionID、root 到 target 的有序 HeaderDigest、每条完整 ForkPoint、每级可见 CommitDigest 和 target local Head;不含 cursor、snapshot、load time 或 projection state。Local 与 Resolved cursor 都绑定同一 target 的该 digest:Local 虽不返回 ancestor event,仍以它验证 fork closure;Resolved 则以它确定可见 ancestry。cursor 必须携带并在续页验证它,变更则 conflict。 + +**SES-REP-3** replay 必须验证 header、revision gap、previous/commit/event digest、index 和 profile。损坏、缺口或不支持版本必须 fail loudly。cursor 的 `After` 必须是当前 mode/ancestry 中存在且 digest 匹配的完整 `EventPosition`;Local mode 还必须满足其 origin 为请求 Session。Token 是 adapter 的 opaque continuation,必须绑定 `SessionID`、Mode、AncestryDigest、完整 After 和 Limit,且不得替代这些可验证字段。 + +## 6. fork + +**SES-FRK-1** Fork 创建 immutable child header,ParentFork 精确绑定 parent 已验证 boundary;revision 0 的 boundary digest 为 parent HeaderDigest。child local revision 从 1 开始。 + +| 请求情况 | 结果 | +|---|---| +| 新 ForkID、未使用 ChildSessionID、boundary 已验证 | 创建 child | +| 同 ForkID、逐字段相同 request | 返回原 child,`Created=false` | +| 同 ForkID、请求不同 | conflict | +| ChildSessionID 已被另一 ForkID 使用 | conflict | +| parent/boundary 不存在、不匹配或损坏 | conflict/corrupt | + +**SES-FRK-2** 保留 child 时必须保留 `RetainClosure`:所有 ancestor header 与每个 fork boundary 内 commit。实现可留存 ancestor prefix,或保存经验证 immutable ancestry archive;删除、归档或 compact parent 前必须证明 child 仍可 resolved replay。 + +## 7. snapshot + +```go +type Snapshot struct { + ProtocolVersion uint16; SessionID SessionID + ProjectionKey ProjectionKey; ProjectionVersion uint16 + ThroughHead ResolvedHead; CoverageDigest es.Digest + State jsonstable.Value; SnapshotDigest es.Digest +} +``` + +**SES-SNP-1** snapshot 是派生 cache。CoverageDigest 覆盖 `ThroughHead` 所解析的 root-to-target HeaderDigest/CommitDigest sequence;SnapshotDigest 覆盖全部 snapshot field(自身除外)。同 local head 而 ancestry 不同绝不等价。 + +**SES-SNP-2** Save 和 Load 必须重算 ThroughHead、AncestryDigest、coverage 和 snapshot digest。coverage 是当前 sequence 的完整前缀时才可从 snapshot tail replay;缺失、过旧、损坏或 projection 不兼容时全量 replay。 + +## 8. canonical import + +**SES-IMP-1** canonical import 仅处理 Session records,不解释 payload。它必须经 ProtocolProfile 的 `ValidateCanonical*` 入口验证 profile、header、完整 chain、event uniqueness 和 supplied positions/digests;只接受完整 stream 或已有可验证 predecessor 的 contiguous tail。`ImmutableAncestryArchive` 的 canonical preimage 是 profile version、按 root-to-target segment order 的 Headers、每个 segment 按 local `(revision,index)` 的完整 Commits,排除 `ArchiveDigest`;ArchiveDigest 覆盖该 preimage。archive codec 同样必须 decode→encode canonical-equivalent 并验证 digest。 + +**SES-IMP-2** 同 `(SessionID,CommitID)` 仅在 canonical commit 逐字段相同才幂等;不同 header、revision、group 或 digest 为 conflict。fork child import 必须有本地 existing verified parent boundary,或随 request 提供经过验证 immutable ancestry archive;不得只信任 ParentFork 声明。 + +Application/import coordinator 负责跨 Session package、业务事务与恢复编排。 + +## 9. boundaries 与 conformance + +Extension 可对 opaque payload 作 typed encode/decode;它不得改变 Store 的 CAS、CommitID、digest 或 replay 语义。unknown event 必须 raw-preserve 和 raw-replay。`agent/turn` 消费/追加 Events 并协调 Run;Session kernel 仍只处理纯 envelope 与 stream 机制。Run、queue、provider 与 Application policy 均在 kernel 外。 + +Conformance 必须验证: + +- **SES-WIR-1、SES-WIR-2**:wire/profile freeze、所有 Encode/Decode 与 `ValidateCanonical*` round-trip、digest preimage、同-stream SourceEvents、EventID uniqueness、complete commit; +- **SES-API-1、SES-APP-1、SES-APP-2**:atomic CAS、concurrent writer、CommitID exact idempotency 和 failure classification; +- **SES-REP-1、SES-REP-2、SES-REP-3**:local/resolved replay 的 segment-first 顺序、完整 EventPosition After 语义、cursor/token binding、tamper/gap failure; +- **SES-FRK-1、SES-FRK-2**:ForkID idempotency、parent boundary 和 RetainClosure; +- **SES-SNP-1、SES-SNP-2**:coverage、ancestry validation 与 snapshot+tail 等价; +- **SES-IMP-1、SES-IMP-2**:complete/tail import、exact conflict 和 verified ancestry archive。 + +第一版包含 MemoryStore 与上述 Store conformance;不承诺特定 durable adapter。 diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md new file mode 100644 index 0000000..222c752 --- /dev/null +++ b/docs/design/agent-turn.md @@ -0,0 +1,321 @@ +# Twilight Agent Turn 协议 + +状态:设计规范。 + +本文定义 `agent/turn` 对一个 Chatlog Turn 的协调协议;“必须”“应该”均为协议约束。 + +## 1. 模型与范围 + +| Concern | Canonical owner / record | Coordinator action | +|---|---|---| +| `MachineState` | execution authority | 经 shared Runtime 驱动、读取和验证 | +| `TransitionRecord` | canonical Run record | Record、fold、materialize 完整前缀 | +| Session events | verified resolved stream | SemanticAppender 提交、replay linkage 与 settlement | +| Runtime | 唯一 Run access 与 atomic Commit boundary | Create、Load、Commit、Record | +| Application policy | Application | 建立 binding,提供 driver 与业务 policy | + +**TRN-SCP-1** `agent/turn.Coordinator` 为 concrete Coordinator,`twilight.turn` 为唯一 first-party `ModuleID`。一个 Turn 恰有一个 primary Run;retry 重用该 RunID。replacement Turn 通过 chatlog replacement graph 建立其 primary Run;同 Turn 新建 primary Run 的请求触发 conflict。 + +**TRN-SCP-2** primary Run 保持既有 identity。放弃或替换对话时,Application 创建 replacement Turn 与新 RunID,并使用 chatlog replacement graph。实现拒绝旧 Turn 或旧 RunID 的 replacement reuse。subagent 运行于独立 Session、独立 Turn。 + +**TRN-SCP-3** Coordinator 从 Session facts 和 `Runtime.Record` 重建;`MachineState` 提供 execution authority,`TransitionRecord` 提供 canonical record。Coordinator 编排持久化事实,以 injected capability 执行协调动作;Session、Run、driver、mapper cursor、terminal verdict、队列 lease、provider client、per-Run handle 与额外权限均由各自 canonical owner 提供。 + +**TRN-SCP-4** Coordinator 通过 `extension.SemanticAppender` 完成所有 Session 写入;Extension 负责 Binding admission、claim 及其恢复。`artifact` 由其 canonical owner 管理。 + +**TRN-SCP-5** Application 管理 model、provider、tool、prompt、token、approval、queue、channel、并发、重试时机及其他业务 policy。Coordinator 按 persisted binding 解析 driver 并协调协议步骤,历史计划仅按其 persisted choices 执行。 + +**TRN-SCP-6** Application 在开始前建立 immutable execution binding,并持久化公开 `ID`、`Digest` 与 mapper version。serialized binding 仅含这些公开值;运行时对象、credential、secret、可变 policy 由 Application 管理。binding registry 精确解析持久化 identity;缺失 binding 返回 `binding_unavailable`。 + +## 2. identity、计划与事件 + +```go +type TurnRef struct { SessionID session.SessionID; TurnID chatlog.TurnID } +type ExecutionBindingRef struct { ID ExecutionBindingID; Digest es.Digest } +type MapperVersion string +type SourceFactID string + +type Settlement string +const ( + SettlementCompleted Settlement = "completed" + SettlementFailed Settlement = "failed" + SettlementStopped Settlement = "stopped" +) + +type RunHeadRef struct { RunID run.RunID; Revision uint64; TransitionDigest es.Digest } +type ResultReference struct { + RunID run.RunID; Revision uint64; EventIndex uint16 + EventType string; EventDigest es.Digest +} + +type RunCreateSpec struct { + Run run.NewRun + InitialInputs []run.AgentInput + ExecutionBinding ExecutionBindingRef + MapperVersion MapperVersion +} +type RunRequestedPayload struct { + TurnID chatlog.TurnID; RunID run.RunID + Create RunCreateSpec; PlanDigest es.Digest +} +type RunSettledPayload struct { + TurnID chatlog.TurnID; RunID run.RunID; Settlement Settlement + Head RunHeadRef + Result ResultReference +} +``` + +**TRN-ID-1** `TurnRef`、RunID、binding ID、MapperVersion、chatlog identity 与 digest 必须非空稳定。`ExecutionBindingRef` 仅编码公开 identity 与 digest;secret、token、provider handle、policy 分别由其 owner 管理。 + +**TRN-ID-2** `RunHeadRef` 指向经验证的 transition head;结果查询使用 `ResultReference`。`RunSettledPayload.Head` 精确等于 terminal `RunRecord` 的 head(RunID、revision、transition digest),该 head transition 包含其 `Result` 所指向的最后一个 terminal `RunEnded`。`ResultReference` 精确指向 terminal `RunEnded` AgentEvent;其 RunID、revision、index、type、digest 与 `RunRecord` 的该 event 逐字段相同。 + +**TRN-ID-3** `RunCreateSpec.Run.RunID` 等于 requested payload 的 RunID,完整 `NewRun` 通过 `run.ValidateNewRun`。InitialInputs 为有序 immutable admission data,随后以 `AcceptInput` transition 进入 Run;revision-0 header 与 Run collision identity 保持稳定。 + +**TRN-ID-4** `PlanDigest` 使用 `twilight.turn/plan/v1` domain,覆盖完整 versioned `RunCreateSpec`:完整 NewRun、有序每个 initial input 的 ID/canonical payload、ExecutionBindingRef、MapperVersion。字段、presence、顺序或版本变化均触发 digest 变化。 + +```go +type StartOperationDigest es.Digest +func DigestStartOperation( + sessionID session.SessionID, turnID chatlog.TurnID, runID run.RunID, + planDigest es.Digest, userMessageDigest es.Digest, inputIDs []chatlog.InputID, +) (StartOperationDigest, error) +``` + +**TRN-ID-5** StartOperationDigest 使用 `twilight.turn/start-operation/v1` domain,按序覆盖 SessionID、TurnID、RunID、PlanDigest、UserMessage.Digest、ordered InputIDs。它排除 head、clock、registry state、进程 identity,并作为 Start commit、event identity、response 的稳定 identity。 + +**TRN-EVT-1** 最小 committed ontology 为 `twilight.turn/run_requested` 与 `twilight.turn/run_settled`,payload 分别为 `RunRequestedPayload`、`RunSettledPayload`。transient UI delta、driver return、outbox record、观察日志提供辅助信息;ontology 查询仅接受这两个 committed event。 + +**TRN-EVT-2** Start 的 EventID、CommitID 由 StartOperationDigest、固定 event kind、group ordinal 在版本化 domain 中唯一派生;retry 重用该 identity。materialization、settlement 使用本规范所列 stable identity,恢复性 commit ID 使用确定性派生。 + +**TRN-EVT-3** resolved stream 内 `(TurnID,RunID)` 的 `run_requested` 唯一;一个 unsettled Turn 持有一个 linkage。相同 canonical payload 幂等;payload、PlanDigest、event identity 或 linkage 差异触发 conflict。 + +## 3. API 与 authority boundary + +```go +type Coordinator struct { + Sessions session.Store + Appender extension.SemanticAppender + Runtime run.Runtime + Bindings ExecutionBindingRegistry + Mapper FactMapper +} +type DriveRequest struct { Ref TurnRef; RunID run.RunID } +type RunDriver interface { Drive(context.Context, DriveRequest) (run.LoopResult, error) } +type ExecutionBindingRegistry interface { Resolve(ExecutionBindingRef) (RunDriver, error) } +``` + +**TRN-API-1** 上列字段为 injected capability。每个 Coordinator 实例依据 Session facts 与 `Runtime.Record` 接续;Session、Run、driver、mapper cursor、terminal verdict 均由其 canonical record 提供,跨调用状态通过这些 records 重建。 + +**TRN-API-2** binding registry 以同一个 shared `run.Runtime` 组合 driver。driver 接收 `DriveRequest{Ref,RunID}`,并经 shared Runtime 读写;其 Run access 以该 Runtime contract 完成,persisted binding 含义保持稳定。 + +**TRN-API-3** 正式 Run contract 为: + +```go +Runtime.Create(context.Context, run.NewRun) (run.CreateResult, error) +Runtime.Load(context.Context, run.RunID) (run.RuntimeSnapshot, error) +Runtime.Commit(context.Context, run.CommitRequest) (run.CommitResult, error) +Runtime.Record(context.Context, run.RunID) (run.RunRecord, error) +``` + +`CommitRequest.Command` 为含 RunID 的 envelope。Coordinator、driver、mapper、settlement 通过该 contract 协调 Run,input/transition 经 Commit 持久化;实现对绕过 Commit 的 input/transition 写入返回拒绝。 + +**TRN-API-4** 相同 canonical header 的 Create 返回 `Created=false`;同 RunID 的 header 差异返回 `run.ErrCreateConflict`;缺少 Run 的 Load、Commit、Record 返回 `run.ErrRunNotFound`。Coordinator 分别处理 conflict、corrupt、unavailable,且仅从 persisted linkage 取得计划。 + +```go +type Service interface { + Start(context.Context, StartRequest) (StartResponse, error) + Resume(context.Context, ResumeRequest) (ResumeResponse, error) + Stop(context.Context, StopRequest) (StopResponse, error) +} +type StartRequest struct { + Ref TurnRef; Run run.NewRun; InputIDs []chatlog.InputID + UserMessage chatlog.Message; InitialInputs []run.AgentInput + ExecutionBinding ExecutionBindingRef; MapperVersion MapperVersion +} +type StartResponse struct { + Ref TurnRef; RunID run.RunID; PlanDigest es.Digest; Operation StartOperationDigest + Created bool; Result *ResultReference; Waiting []run.ResponseRequest +} +type ResumeRequest struct { Ref TurnRef } +type ResumeResponse struct { Ref TurnRef; RunID run.RunID; Result *ResultReference; Waiting []run.ResponseRequest } +type StopRequest struct { Ref TurnRef; Reason string } +type StopResponse struct { Ref TurnRef; RunID run.RunID; Result *ResultReference } +``` + +**TRN-API-5** DTO 均为值语义。Start 对既有 linkage 的新 RunID 或计划改写返回 conflict;Resume/Stop 按 Ref 解析唯一 unsettled linkage。Waiting 表示当前 snapshot 的 response request;terminal fact 与后续计划授权由 RunRecord 和 persisted linkage 提供。 + +## 4. Start admission + +**TRN-STR-1** Start 依次验证、写入 Session intent/linkage、调用 Runtime.Create。Start 的 Session append causation 等于 persisted `NewRun.CausationID`;恢复沿用该 causation。 + +**TRN-STR-2** StartRequest 满足: + +1. Ref、RunID、binding ref、mapper version 非空; +2. InputIDs 与 InitialInputs 等长、无重复、顺序完全相同; +3. `InitialInputs[i].ID=InputIDs[i]`,其 payload 与对应 `InputSubmitted` admission payload 完全对应; +4. InitialInputs 为 Run ingress payload,UserMessage 按其 chatlog wire 规则持久化; +5. `UserMessage.Role=RoleUser`、`TurnID=Ref.TurnID`,并经 Message registry 重算 digest; +6. UserMessage.InputIDs 精确等于 ordered InputIDs,ItemIDs 为空; +7. input 已 submitted、未终结、可 delivered;message 的其余 role、part、binding、canonical 约束由 chatlog/Extension admission 执行。 + +**TRN-STR-3** v1 Start 写入一个原子 semantic Session commit,顺序严格为: + +```text +TurnOpened{TurnID, InputIDs} +InputDelivered{InputIDs[0], TurnID} +... +InputDelivered{InputIDs[n-1], TurnID} +MessageCommitted{UserMessage} +run_requested{RunRequestedPayload} +``` + +恰有一个 MessageCommitted,且为该 user message;该 group 的 chatlog/turn event 集合即如上。空 input group 为 `TurnOpened → MessageCommitted → run_requested`。 + +**TRN-STR-4** Start 构造 RunCreateSpec、PlanDigest、StartOperationDigest、stable CommitID/EventID group,再调用 `SemanticAppender.AppendSemantic`。applied/already-applied 表示同一成功事实;head conflict 进行 CAS rebase,并保留完整 group 的 identity、payload、输入顺序、causation。 + +**TRN-STR-5** semantic append 确认 intent 后,Start 重新 resolved replay 精确解析 linkage,再调用 `Runtime.Create(ctx, linkage.Create.Run)`。Created true/false 均进入 Resume;Create conflict、linkage 不唯一、payload 不符、header 非法返回失败并保留诊断事实。 + +**TRN-STR-6** Start 在 Create 后调用 Resume;inputs 投递、model 驱动、Session settlement 均收敛到恢复算法。 + +## 5. Resume、Drive 与 Stop + +**TRN-RSM-1** Resume 在 verified resolved stream 查询 Ref 的唯一 unsettled `run_requested`。无 linkage、多个 linkage、已 settled linkage、payload identity 冲突分别返回 not-found、corrupt 或 conflict;计划取自 persisted linkage。 + +**TRN-RSM-2** Resume 对 persisted `linkage.Create.Run` 再调 Create,验证 RunID collision identity;成功后执行 `EnsureInitialInputs`。 + +**TRN-RSM-3** EnsureInitialInputs 对 persisted InitialInputs 按序逐项: + +1. `Runtime.Load(RunID)` 取得 revision; +2. 用 `run.DeriveInputCommandID(RunID, Input.ID)` 与 `run.BuildEnvelope(RunID, commandID, run.AcceptInput{Input})` 建 envelope; +3. 以该 revision 提交 CommitRequest; +4. CommitAccepted 或同 command 的 CommitAlreadyApplied 均前进; +5. stale 时 Load 后以同 identity 重试,terminal 时停止投递并读 record; +6. 调用结果 unknown 时先 Record;完整 transition 出现该 command 后前进,否则返回 indeterminate。 + +**TRN-RSM-4** input payload/顺序差异触发 conflict。partial crash 后,已接受前缀和未接受后缀以同 command identity 收敛;进度由 snapshot 与 Record 共同验证,本地 cursor 不构成进度证据。 + +**TRN-RSM-5** inputs 后先 Record,并调用 `MaterializeAll(record)` 按 revision 递增补齐 revision 1 至 record head 的全部 transition;terminal record 随后进入 settlement。active Run 解析 persisted binding 并调用: + +```go +driver.Drive(ctx, DriveRequest{Ref: ref, RunID: linkage.RunID}) +``` + +无论 driver 返回完成、等待、error,Coordinator 均再次 Record、调用 `MaterializeAll(record)` 补齐完整新前缀,并据 record snapshot/terminal fact 返回 Waiting 或 Result。 + +**TRN-RSM-6** driver 的 provisional stream、LoopResult、网络响应丢失、context cancel 作为观察结果处理。driver 以 shared Runtime 的 Load/Commit 推进 MachineState;并发 Resume 依 Runtime command idempotency、revision、grant、record 一致性收敛。 + +**TRN-STP-1** Stop 先解析 linkage、Load。terminal Run 进入 record/materialize/settlement;active Run 以稳定 domain-separated CancelRun CommandID 构造 `CancelRun{Reason:ReasonCancelled}`,并经 shared Runtime Commit。 + +**TRN-STP-2** Cancel command identity 使用 `twilight.turn/cancel-run/v1` domain,至少覆盖 SessionID、TurnID、RunID、固定 cancel reason;Stop retry 重用它。StopRequest.Reason 供 Application audit/presentation,Run 采用固定 cancellation reason。 + +**TRN-STP-3** Stop Commit outcome unknown 时先 Record 查询完整 transition 是否含该 command;证明已应用后继续,未能证明时返回 indeterminate。CommitAccepted、CommitAlreadyApplied 或 Record 已证明应用时,Stop 重新 Record,调用 `MaterializeAll(record)` 补齐全部 transition,再从 exact terminal `RunEnded` 构造 ResultReference 并提交 settlement。StopResponse 在 settlement 成功或 already-applied 后返回 Result;indeterminate 路径保留 Session Turn 当前状态。 + +## 6. record、结果与 settlement + +**TRN-SET-1** `RunRecord` 为 materialization、unknown resolution、recovery、settlement 的唯一一致 verified read。它含 detached 且相互一致的 Header、Snapshot、完整 TransitionRecord sequence;消费者验证 header、records、fold、snapshot,并拒绝拼接独立 reads。 + +**TRN-SET-2** terminal 判断读取匹配 terminal status 的最后 `RunEnded` AgentEvent。Coordinator 仅从该 exact event 构建 ResultReference,RunResult、snapshot revision、driver return 与其他 event 不参与该构建。terminal fact 缺失、多个或未处于末尾时触发 corruption。 + +**TRN-SET-3** settlement mapping 固定如下: + +| RunEnded status | run_settled settlement | 同一 Session commit 的 chatlog event | +|---|---|---| +| `RunCompleted` | `completed` | `TurnCompleted{TurnID}` | +| `RunFailed` | `failed` | `TurnFailed{TurnID, FailureClass}` | +| `RunStopped` | `stopped` | `TurnFailed{TurnID, FailureClass:"stopped"}` | + +RunFailed 的 FailureClass 取 terminal failure class;没有 failure 时采用稳定非空 class。`RunStopped` 映射 `TurnFailed{..., FailureClass:"stopped"}`。 + +**TRN-SET-4** run_settled 与对应 TurnCompleted/TurnFailed 位于同一 semantic Session commit,CommitID/EventID 由 stable terminal ResultReference 派生。相同 reference retry 幂等;settlement/result reference 差异触发 conflict。 + +**TRN-SET-5** unknown external effect 的 persisted MachineState/terminal record 已包含 `ToolCallFailed{Outcome:Unknown, FailureEffectUnknown}` 加 terminal `RunEnded{RunFailed,...}`。Coordinator materialize unknown tool result 并 failed settle。 + +## 7. materialization + +```go +type FactMapRequest struct { + TurnID chatlog.TurnID; RunID run.RunID; MapperVersion MapperVersion + Prefix []run.TransitionRecord // complete through target + TargetRevision uint64; TargetTransitionDigest es.Digest +} +type FactMap struct { SourceFacts []SourceFactID; Events []extension.TypedEvent } +type FactMapper interface { Map(FactMapRequest) (FactMap, error) } +``` + +**TRN-MAT-1** `MaterializeAll(record)` 按 revision 1 至 `record.Snapshot.Revision` 递增枚举每条 transition,并为 revision `r` 构造一个 FactMapRequest。Prefix 从 revision 1 连续至 TargetRevision,且含 target 的完整 transition group;每条 record/event 均经验证。TargetRevision/TargetTransitionDigest 等于 prefix 尾。每次 Map 处理 TargetRevision 的完整 target transition;`Prefix[1..target]` 提供 pure context。FactMapRequest 覆盖完整 prefix 与 target;mapper 输入排除 partial transition、provisional delta、current binding、Session projection cache、hidden state。 + +**TRN-MAT-2** SourceFactID 唯一规则为: + +```text +Digest("twilight.turn/source-fact/v1", + RunID, Transition.Revision, Transition.TransitionDigest, + AgentEvent.Index, AgentEvent.Type, AgentEvent.Digest) +``` + +同一 event 的 source ID 随 retry、Session head、mapper process 保持稳定。`FactMap.SourceFacts` 按 target transition event 顺序,精确列出实际产生本 batch `Events` 的 AgentEvents;每个输出 event 可追溯到其中 source fact。`Events` 为空时 `SourceFacts` 为空。后续 revision 仅处理其 target transition 的输出与 source fact。 + +**TRN-MAT-3** materialization CommitID 使用 `twilight.turn/materialize/v1` domain,覆盖 SessionID、TurnID、RunID、MapperVersion、TargetRevision、TargetTransitionDigest、ordered target-source SourceFactIDs。输出 EventID 使用 `twilight.turn/materialized-event/v1` domain,覆盖 CommitID、ordinal、event type、schema version、canonical payload digest;CAS rebase 后保持字节稳定。 + +**TRN-MAT-4** mapper 为 pure deterministic total-prefix function:相同 FactMapRequest 产生同一 ordered FactMap,输入为请求中的持久化 facts。IO、clock、random、Application defaults、前次调用状态不参与 Map。MapperVersion 是协议输入;升级使用显式新 version,既有事实保持原 version。 + +**TRN-MAT-5** Map 空 Events 时 SourceFacts 为空,该 target transition 以可重试 deterministic no-op 完成 coverage。非空 batch 先以 stable CommitID `LookupCommit`;找到逐字段相同 commit 即完成该 target transition,未找到则按当前 head append,head conflict 后重新 replay/fold,以同一完整 target transition Map,直至 append 或 conflict。`MaterializeAll` 仅在每个 revision 分别达到 matched commit、applied commit 或 empty-map coverage 后前进;空 map 保持无 commit。 + +**TRN-MAT-6** transactional outbox 服务于 Session adapter 投递优化。source coverage、EventID、CommitID、materialized state、settlement 由 Session facts 和 RunRecord 重建。 + +### 7.1 v1 映射表 + +**TRN-MAP-1** v1 映射下表所列 AgentEvent;未列 AgentEvent 产生零个 chatlog event,未来扩展使用新 MapperVersion。`ItemID=DeriveItemID(TurnID,StepID,MapperVersion)`;UI delta 用于呈现。 + +| AgentEvent | 必须产生的 chatlog facts | +|---|---| +| `ModelStepPrepared` | `ItemOpened`;sequence 依完整 Run prefix 的稳定 model-step 顺序 | +| `ModelStepCompleted` | 该 item 的 `ItemUpdated`、`ItemCompleted`,一个 RoleAssistant MessageCommitted,含 text/reasoning/reference 与全部 tool calls | +| `ToolCallCompleted` / `ToolCallAnswered` | 对应 CallID 的 RoleToolResult MessageCommitted,status=`success` | +| `ToolCallFailed`,Outcome=`Known` | 对应 CallID 的 RoleToolResult MessageCommitted,status=`error` | +| `ToolCallFailed`,Outcome=`Unknown` 或 failure class=`effect_unknown` | 对应 CallID 的 RoleToolResult MessageCommitted,status=`unknown` | +| terminal failed/stopped `RunEnded` | 该 Turn 每个尚未 completed item 的 `ItemFailed` | + +**TRN-MAP-2** assistant Message.ItemIDs 仅指向同 Turn 已 completed 且 final digest 匹配 item;tool-call part 的 CallID/顺序与 ModelStepCompleted result 一致。tool-result message 遵守 chatlog 的既有 call、同 Turn、单 active result/replacement 规则。 + +**TRN-MAP-3** `ToolCallFailed.Outcome=Known` 映射 error;`Outcome=Unknown`、`FailureEffectUnknown` 或持久事实无法区分的结果映射 unknown。mapper 以持久事实完成该判定。 + +**TRN-MAP-4** ItemUpdated/ItemCompleted 的 content、version、final digest 从完整 persisted model result canonical projection 导出;流式 text delta 可丢弃且不持久化为映射事实,replay 使用 canonical projection。 + +## 8. recovery + +**TRN-REC-1** recovery 扫描 resolved stream 的 unsettled linkages,按稳定 `(SessionID,TurnID,RunID)` 顺序 Resume。实现验证 linkage、RunRecord、mapper input;header、transition、digest、fold、source ID、settlement 差异触发 fail loudly,并保留历史事实,不修补记录。 + +**TRN-REC-2** 最少覆盖如下 crash/concurrency matrix: + +| 情形 | 必须恢复动作 | +|---|---| +| Start 已提交、Run 缺失 | 以 persisted NewRun Create 后 Resume | +| Create retry/响应丢失 | 同 NewRun Create,Created=false 为同一 Run | +| initial inputs 仅前缀 | 逐项 Load、相同 derived command Commit,已应用跳过 | +| transition 已提交、materialization 缺失 | Record,按 revision 递增执行 MaterializeAll,以每个完整 target prefix Map/append | +| mapper 空 batch | 保持无 append;后续 Record 可再次 Map | +| materialization 响应丢失 | stable CommitID LookupCommit,匹配即成功 | +| Run terminal、settlement 缺失 | Record/materialize 后从 exact RunEnded 同 commit settle | +| model result unknown | 查询 `Runtime.Record` 的 MachineState/transition record;accepted result 未获证明时执行恢复,不派生猜测结果 | +| tool effect unknown | 查询 authority;已有 unknown terminal facts 时 materialize/failed settle,不重执行 effect | +| Record 与并发 Commit | 丢弃过时 record、重读;每次 Map 使用自洽完整 prefix | + +**TRN-REC-3** Commit、Create、append、query 的 unknown outcome 一律先查 authority:Run 使用 Record,Session 使用 LookupCommit 与 verified replay。terminal abort evidence 集合为 verified terminal RunRecord、匹配 ResultReference 的 `RunEnded` 与同 commit settlement;查询失败、瞬时 NotFound、本地超时保持 indeterminate。indeterminate 保留 effect、replacement、Turn terminalization 的后续决策。 + +**TRN-REC-4** binding 缺失返回可恢复 `binding_unavailable` 并保留 linkage;registry 仅解析 persisted binding,已 terminal Run 无需 binding,继续 materialize/settle。 + +**TRN-REC-5** recovery 在 start barrier 后遵循 MachineState、grant/recovery 规则、完整 transition record 所允许的 command 推进 Run。Coordinator 重驱动、读取、投影,并以 record 重建执行历史;重复越过 start barrier 的 model/tool effect 触发协议拒绝。 + +## 9. conformance + +实现按下列条款映射验证: + +- **TRN-SCP-1 至 TRN-SCP-6**:一 Turn/primary Run、retry/replacement/subagent 边界、Coordinator 重建与 MachineState authority、SemanticAppender、Application policy ownership、immutable binding/no defaults; +- **TRN-ID-1 至 TRN-ID-5、TRN-EVT-1 至 TRN-EVT-3**:完整 types、PlanDigest、start-operation domain、stable EventID/CommitID、最小 events、unique linkage; +- **TRN-API-1 至 TRN-API-5**:Session/Runtime 重建、shared Runtime driver、四方法 contract、Create/missing errors、Service/Start/Resume/Stop DTO; +- **TRN-STR-1 至 TRN-STR-6**:intent-before-Create、causation、InputIDs/InitialInputs exact group、合法 user message、原子严格顺序、retry; +- **TRN-RSM-1 至 TRN-RSM-6、TRN-STP-1 至 TRN-STP-3**:unique linkage、Create retry、partial inputs、DriveRequest、record-after-drive、stable CancelRun、unknown query-first、Stop 的 MaterializeAll 与 settlement、Turn settlement 时机; +- **TRN-SET-1 至 TRN-SET-5**:verified RunRecord、terminal RunEnded ResultReference、completed/failed/stopped、同 commit settlement、effect unknown failed history; +- **TRN-MAT-1 至 TRN-MAT-6、TRN-MAP-1 至 TRN-MAP-4**:MaterializeAll 的 revision 1..head 完整 coverage、complete prefix 与 target-only FactMap source coverage、SourceFactID domain、materialization identity、pure versioned mapper、empty Events/SourceFacts no-op、LookupCommit/CAS rebase、outbox、v1 table; +- **TRN-REC-1 至 TRN-REC-5**:crash matrix、unknown query-first、missing binding、concurrent Record、effect 与执行历史恢复。 + +第一版 conformance 对 memory 与 durable Runtime adapter 使用同一断言;Application、provider、transport 行为以本协议为准。 From 060b7c96d64ebaf9f5eea2bc5df46b69ef45c15c Mon Sep 17 00:00:00 2001 From: Fodesu Date: Mon, 31 Aug 2026 01:14:36 +0800 Subject: [PATCH 34/81] refactor(run): isolate loop interpreter --- agent/run/canonical_test.go | 19 ++ agent/run/commit.go | 41 --- agent/run/{run_creation.go => creation.go} | 0 ...time_creation_test.go => creation_test.go} | 0 agent/run/{rebuild.go => fold.go} | 0 agent/run/{rebuild_test.go => fold_test.go} | 0 agent/run/{ => loop}/contract.go | 56 +++-- agent/run/loop/helpers_test.go | 70 ++++++ agent/run/{ => loop}/loop.go | 235 +++++++++--------- agent/run/{ => loop}/loop_test.go | 43 ++-- agent/run/loop/regression_test.go | 125 ++++++++++ agent/run/loop/runtime_helpers_test.go | 29 +++ agent/run/model_data.go | 26 +- agent/run/regression_test.go | 128 ---------- agent/run/runtime.go | 43 ++++ agent/run/runtime_helpers_test.go | 23 -- docs/design/agent-runtime-refactor.md | 213 ++++++++-------- docs/design/agent-turn.md | 5 +- 18 files changed, 588 insertions(+), 468 deletions(-) rename agent/run/{run_creation.go => creation.go} (100%) rename agent/run/{runtime_creation_test.go => creation_test.go} (100%) rename agent/run/{rebuild.go => fold.go} (100%) rename agent/run/{rebuild_test.go => fold_test.go} (100%) rename agent/run/{ => loop}/contract.go (78%) create mode 100644 agent/run/loop/helpers_test.go rename agent/run/{ => loop}/loop.go (65%) rename agent/run/{ => loop}/loop_test.go (89%) create mode 100644 agent/run/loop/regression_test.go create mode 100644 agent/run/loop/runtime_helpers_test.go create mode 100644 agent/run/runtime.go diff --git a/agent/run/canonical_test.go b/agent/run/canonical_test.go index c08d46f..0ed266e 100644 --- a/agent/run/canonical_test.go +++ b/agent/run/canonical_test.go @@ -1,10 +1,29 @@ package run import ( + "encoding/json" "strings" "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 { diff --git a/agent/run/commit.go b/agent/run/commit.go index f63ae41..fa03795 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -1,51 +1,10 @@ package run import ( - "context" "errors" "fmt" ) -// Runtime is the RunID-addressed access and atomic commit boundary. Create -// establishes immutable Revision-0 state; Load, Commit, and Record operate on -// exactly one Run. MachineState remains execution authority; planning, queues, -// and tool entry points are not hidden methods. -type Runtime interface { - Create(context.Context, NewRun) (CreateResult, error) - Load(context.Context, RunID) (RuntimeSnapshot, error) - Commit(context.Context, CommitRequest) (CommitResult, error) - Record(context.Context, RunID) (RunRecord, error) -} - -type RuntimeSnapshot struct { - State MachineState - // Revision counts accepted transitions; the initial state is 0. - Revision uint64 -} - -type CommitRequest struct { - BaseRevision uint64 - Grant ExecutionGrant - Command CommandEnvelope -} - -type CommitStatus uint8 - -const ( - CommitAccepted CommitStatus = iota - CommitAlreadyApplied -) - -type CommitResult struct { - Status CommitStatus - Snapshot RuntimeSnapshot - // Events is the complete event group of the transition, for Accepted and - // AlreadyApplied alike. - Events []AgentEvent - // Grant is returned only for an Accepted start command; empty otherwise. - Grant ExecutionGrant -} - type DecisionKind uint8 const ( diff --git a/agent/run/run_creation.go b/agent/run/creation.go similarity index 100% rename from agent/run/run_creation.go rename to agent/run/creation.go diff --git a/agent/run/runtime_creation_test.go b/agent/run/creation_test.go similarity index 100% rename from agent/run/runtime_creation_test.go rename to agent/run/creation_test.go diff --git a/agent/run/rebuild.go b/agent/run/fold.go similarity index 100% rename from agent/run/rebuild.go rename to agent/run/fold.go diff --git a/agent/run/rebuild_test.go b/agent/run/fold_test.go similarity index 100% rename from agent/run/rebuild_test.go rename to agent/run/fold_test.go diff --git a/agent/run/contract.go b/agent/run/loop/contract.go similarity index 78% rename from agent/run/contract.go rename to agent/run/loop/contract.go index f18b2b0..9f3b755 100644 --- a/agent/run/contract.go +++ b/agent/run/loop/contract.go @@ -1,9 +1,11 @@ -package run +package loop import ( "context" "encoding/json" + run "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/sdk" ) @@ -12,21 +14,21 @@ import ( // an agent-owned ModelRequest before crossing the Runtime boundary. Planning // implementations never live in agent. type RequestPlanner interface { - Plan(context.Context, PlanningHint) (RequestPlan, error) + Plan(context.Context, run.PlanningHint) (RequestPlan, error) } type RequestPlan struct { - Model ModelRef + Model run.ModelRef Request sdk.Request - InputIDs []InputID - PlanningToken PlanningToken - Tools []ToolSpec + InputIDs []run.InputID + PlanningToken run.PlanningToken + Tools []run.ToolSpec } -// ModelCatalog resolves a frozen ModelRef into an invoker at execution time; +// ModelCatalog resolves a frozen run.ModelRef into an invoker at execution time; // provider binding never enters the frozen request. type ModelCatalog interface { - Resolve(ModelRef) (ModelInvoker, error) + Resolve(run.ModelRef) (ModelInvoker, error) } type ModelInvoker interface { @@ -40,44 +42,44 @@ type StreamingModelInvoker interface { } type ToolCatalog interface { - Resolve(ToolRef) (ExecutableTool, error) + Resolve(run.ToolRef) (ExecutableTool, error) } type ToolExecutionRequest struct { - RunID RunID - StepID StepID - CallID CallID - ToolRef ToolRef - DefinitionDigest Digest - Arguments CanonicalJSON + 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 (spec §7.1). type ExecutableTool interface { - Ref() ToolRef + Ref() run.ToolRef Definition() sdk.ToolDefinition - ResponsePolicy() ResponsePolicy + ResponsePolicy() run.ResponsePolicy // ValidateArguments runs before the start barrier and must not produce // external effects. - ValidateArguments(CanonicalJSON) error + 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 ToolExecutionResult } +type ToolExecutionSucceeded struct{ Result run.ToolExecutionResult } func (ToolExecutionSucceeded) toolExecutionOutcome() {} // ToolExecutionFailed asserts the external effect did NOT complete. -type ToolExecutionFailed struct{ Failure ToolFailure } +type ToolExecutionFailed struct{ Failure run.ToolFailure } func (ToolExecutionFailed) toolExecutionOutcome() {} // ToolExecutionUnknown means the effect may or may not have happened. -type ToolExecutionUnknown struct{ Failure ToolFailure } +type ToolExecutionUnknown struct{ Failure run.ToolFailure } func (ToolExecutionUnknown) toolExecutionOutcome() {} @@ -115,15 +117,15 @@ const ( ) type Event struct { - RunID RunID - StepID StepID - CallID CallID + RunID run.RunID + StepID run.StepID + CallID run.CallID Sequence uint64 Kind EventKind Durability EventDurability Payload json.RawMessage // Canonical is set for a committed observation; nil for provisional. - Canonical *AgentEvent + Canonical *run.AgentEvent } // ExecutionPolicy is host-owned loop policy. It is not persisted in @@ -159,6 +161,6 @@ const ( type LoopResult struct { Disposition LoopDisposition Reason WaitReason - Waiting []ResponseRequest - Result *RunResult + Waiting []run.ResponseRequest + Result *run.RunResult } diff --git a/agent/run/loop/helpers_test.go b/agent/run/loop/helpers_test.go new file mode 100644 index 0000000..c8cbf0a --- /dev/null +++ b/agent/run/loop/helpers_test.go @@ -0,0 +1,70 @@ +package loop + +import ( + "context" + "testing" + + . "github.com/memohai/twilight/agent/run" +) + +const testModel ModelRef = "m-1" + +func cj(raw string) CanonicalJSON { return MustParseCanonicalJSON(raw) } + +func newTestRuntime(t *testing.T, _ RunConfig) *MemoryRuntime { + t.Helper() + rt := NewMemoryRuntime() + newRun, err := BuildNewRun("run-1", "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(context.Background(), newRun); err != nil { + t.Fatal(err) + } + env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", "seed"), AcceptInput{ + Input: AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)}, + }) + if err != nil { + t.Fatal(err) + } + snap, err := rt.Load(context.Background(), "run-1") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { + t.Fatal(err) + } + return rt +} + +func recordEvents(t testing.TB, rt Runtime, runID RunID) []AgentEvent { + t.Helper() + record, err := rt.Record(context.Background(), runID) + if err != nil { + t.Fatal(err) + } + var events []AgentEvent + for _, transition := range record.Transitions { + events = append(events, transition.Events...) + } + return events +} + +type modelStepLimitRuntime struct{ Runtime } + +func (r modelStepLimitRuntime) Load(ctx context.Context, runID RunID) (RuntimeSnapshot, error) { + snapshot, err := r.Runtime.Load(ctx, runID) + if err == nil { + snapshot.State.ModelSteps = 1 + } + return snapshot, err +} + +func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDecision, reason string) Digest { + t.Helper() + digest, err := DigestToolResponseDecision(kind, decision, reason) + if err != nil { + t.Fatal(err) + } + return digest +} diff --git a/agent/run/loop.go b/agent/run/loop/loop.go similarity index 65% rename from agent/run/loop.go rename to agent/run/loop/loop.go index 5f54cca..02f45b8 100644 --- a/agent/run/loop.go +++ b/agent/run/loop/loop.go @@ -1,13 +1,16 @@ -package run +package loop import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "errors" "fmt" "sync" + run "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/sdk" ) @@ -21,8 +24,8 @@ type Loop struct { Streaming bool } -// NewLoop validates and normalizes the execution policy (spec §4.3). -func NewLoop(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy ExecutionPolicy, streaming bool) (*Loop, error) { +// New validates and normalizes the execution policy (spec §4.3). +func New(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy ExecutionPolicy, streaming bool) (*Loop, error) { if policy.MaxParallel < 0 { return nil, errors.New("agent: loop: negative MaxParallel") } @@ -36,7 +39,7 @@ func NewLoop(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, pol policy.MaxParallel = 1 } if policy.MalformedModelResultLimit == 0 { - policy.MalformedModelResultLimit = DefaultMalformedModelResultLimit + policy.MalformedModelResultLimit = run.DefaultMalformedModelResultLimit } return &Loop{Models: models, Tools: tools, Planner: planner, Execution: policy, Streaming: streaming}, nil } @@ -44,7 +47,7 @@ func NewLoop(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, pol // Run drives the Run until it finishes, must wait, or the context is // cancelled (spec §6.2). controlCtx for reads/commits is derived from ctx via // WithoutCancel so worker cancellation never blocks result submission. -func (l *Loop) Run(ctx context.Context, runtime Runtime, runID RunID, events EventSink) (LoopResult, error) { +func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, events EventSink) (LoopResult, error) { if ctx == nil { return LoopResult{}, errors.New("agent: loop: nil context") } @@ -80,15 +83,15 @@ func (l *Loop) Run(ctx context.Context, runtime Runtime, runID RunID, events Eve return LoopResult{Disposition: LoopFinished, Result: snapshot.State.Result}, nil } - effect, err := Next(snapshot.State) + effect, err := run.Next(snapshot.State) if err != nil { return LoopResult{}, err } switch eff := effect.(type) { - case NeedModelRequest: + case run.NeedModelRequest: if l.Execution.ModelStepLimit > 0 && snapshot.State.ModelSteps >= l.Execution.ModelStepLimit { - res, err := l.commit(controlCtx, runtime, snapshot.State.RunID, freshCommandID(), snapshot.Revision, "", StopRun{Reason: ReasonStepLimit}) + res, err := l.commit(controlCtx, runtime, snapshot.State.RunID, freshCommandID(), snapshot.Revision, "", run.StopRun{Reason: run.ReasonStepLimit}) if err != nil { if retriable(err) { continue @@ -101,17 +104,17 @@ func (l *Loop) Run(ctx context.Context, runtime Runtime, runID RunID, events Eve if err := l.planAndPrepare(ctx, controlCtx, runtime, events, &snapshot, eff.Hint); err != nil { return LoopResult{}, err } - case StartModelCall: + case run.StartModelCall: if err := l.runModelStep(ctx, controlCtx, runtime, events, &snapshot, eff.StepID); err != nil { return LoopResult{}, err } - case StartToolCalls: + case run.StartToolCalls: if err := l.runToolCalls(ctx, controlCtx, runtime, events, &snapshot, eff); err != nil { return LoopResult{}, err } - case WaitForResponse: + case run.WaitForResponse: return LoopResult{Disposition: LoopWaiting, Reason: WaitingForResponse, Waiting: eff.Requests}, nil - case WaitForExecutionRecovery: + case run.WaitForExecutionRecovery: return LoopResult{Disposition: LoopWaiting, Reason: ExecutionRecovery}, nil default: return LoopResult{}, fmt.Errorf("agent: loop: unknown effect %T", effect) @@ -124,12 +127,12 @@ func (l *Loop) Run(ctx context.Context, runtime Runtime, runID RunID, events Eve // digest (spec §6.6 "commit response unknown"): if the first attempt actually // committed and only the response was lost, the replay returns AlreadyApplied // instead of abandoning a live grant or re-executing an expensive step. -func (l *Loop) commit(ctx context.Context, runtime Runtime, run RunID, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) (CommitResult, error) { - env, err := BuildEnvelope(run, id, cmd) +func (l *Loop) commit(ctx context.Context, runtime run.Runtime, runID run.RunID, id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) (run.CommitResult, error) { + env, err := run.BuildEnvelope(runID, id, cmd) if err != nil { - return CommitResult{}, err + return run.CommitResult{}, err } - req := CommitRequest{BaseRevision: base, Grant: grant, Command: env} + req := run.CommitRequest{BaseRevision: base, Grant: grant, Command: env} res, err := runtime.Commit(ctx, req) if err != nil && !retriable(err) { res, err = runtime.Commit(ctx, req) @@ -139,25 +142,25 @@ func (l *Loop) commit(ctx context.Context, runtime Runtime, run RunID, id Comman // retriable reports the commit errors that mean "reload and rederive". func retriable(err error) bool { - return errors.Is(err, ErrStaleRuntime) || errors.Is(err, ErrRunTerminal) || errors.Is(err, ErrCommandConflict) + return errors.Is(err, run.ErrStaleRuntime) || errors.Is(err, run.ErrRunTerminal) || errors.Is(err, run.ErrCommandConflict) } -func freshCommandID() CommandID { +func freshCommandID() run.CommandID { var b [16]byte if _, err := rand.Read(b[:]); err != nil { panic(fmt.Sprintf("agent: loop: %v", err)) } - return CommandID(hex.EncodeToString(b[:])) + return run.CommandID(hex.EncodeToString(b[:])) } -func (l *Loop) emitCommitted(ctx context.Context, events EventSink, run RunID, committed []AgentEvent) { +func (l *Loop) emitCommitted(ctx context.Context, events EventSink, runID run.RunID, committed []run.AgentEvent) { if events == nil { return } for i := range committed { e := committed[i] _ = events.Emit(ctx, Event{ - RunID: run, + RunID: runID, Kind: EventAgentCommitted, Durability: EventCommitted, Canonical: &e, @@ -167,40 +170,40 @@ func (l *Loop) emitCommitted(ctx context.Context, events EventSink, run RunID, c // --- NeedModelRequest --- -func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, events EventSink, snapshot *RuntimeSnapshot, hint PlanningHint) error { +func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, hint run.PlanningHint) error { plan, err := l.Planner.Plan(ctx, hint) if err != nil { return err } - frozenRequest, err := FreezeModelRequest(plan.Request) + frozenRequest, err := run.FreezeModelRequest(plan.Request) if err != nil { return err } model := plan.Model if model == "" { - model = ModelRef(frozenRequest.Model) + model = run.ModelRef(frozenRequest.Model) } if model == "" { return fmt.Errorf("agent: loop: empty model") } - if ModelRef(frozenRequest.Model) != model { + if run.ModelRef(frozenRequest.Model) != model { return fmt.Errorf("agent: loop: request model %q does not match plan model %q", frozenRequest.Model, model) } - requestDigest, err := DigestRequest(frozenRequest) + requestDigest, err := run.DigestRequest(frozenRequest) if err != nil { return err } - toolsDigest, err := DigestToolSpecs(plan.Tools) + toolsDigest, err := run.DigestToolSpecs(plan.Tools) if err != nil { return err } - binding, err := DigestModelStepBinding(model, requestDigest, toolsDigest) + binding, err := run.DigestModelStepBinding(model, requestDigest, toolsDigest) if err != nil { return err } - cmdID := DeriveModelRequestCommandID(snapshot.State.RunID, snapshot.Revision) - stepID := DeriveModelStepID(snapshot.State.RunID, cmdID, binding) - res, err := l.commit(controlCtx, runtime, snapshot.State.RunID, cmdID, snapshot.Revision, "", PrepareModelRequest{ + cmdID := run.DeriveModelRequestCommandID(snapshot.State.RunID, snapshot.Revision) + stepID := run.DeriveModelStepID(snapshot.State.RunID, cmdID, binding) + res, err := l.commit(controlCtx, runtime, snapshot.State.RunID, cmdID, snapshot.Revision, "", run.PrepareModelRequest{ StepID: stepID, Model: model, Request: frozenRequest, @@ -235,78 +238,78 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime Runtime, // --- StartModelCall --- -func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime Runtime, events EventSink, snapshot *RuntimeSnapshot, stepID StepID) error { - run := snapshot.State.RunID - start, err := l.commit(controlCtx, runtime, run, freshCommandID(), snapshot.Revision, "", StartModelExecution{StepID: stepID}) +func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, stepID run.StepID) error { + runID := snapshot.State.RunID + start, err := l.commit(controlCtx, runtime, runID, freshCommandID(), snapshot.Revision, "", run.StartModelExecution{StepID: stepID}) if err != nil { if retriable(err) { return nil } return err } - if start.Status == CommitAlreadyApplied { + if start.Status == run.CommitAlreadyApplied { return nil // another attempt owns it; reload } - l.emitCommitted(controlCtx, events, run, start.Events) + l.emitCommitted(controlCtx, events, runID, start.Events) - modelStep, ok := start.Snapshot.State.Current.(ModelStep) + modelStep, ok := start.Snapshot.State.Current.(run.ModelStep) if !ok || modelStep.RefValue.ID != stepID { return fmt.Errorf("agent: loop: started step %q is not current", stepID) } - var completion AgentCommand + var completion run.AgentCommand invoker, resolveErr := l.Models.Resolve(modelStep.Model) if resolveErr != nil { - completion = SubmitModelFailure{StepID: stepID, Failure: StepFailure{Class: FailureProvider, Message: resolveErr.Error()}} + completion = run.SubmitModelFailure{StepID: stepID, Failure: run.StepFailure{Class: run.FailureProvider, Message: resolveErr.Error()}} } else { // Model workers derive from the outer ctx: cancelling a model call is // safe, the frozen request retries after recovery (spec §6.1). sdkRequest, err := modelStep.Request.SDK() if err != nil { - completion = SubmitModelFailure{StepID: stepID, Failure: StepFailure{Class: FailureProvider, Message: err.Error()}} + completion = run.SubmitModelFailure{StepID: stepID, Failure: run.StepFailure{Class: run.FailureProvider, Message: err.Error()}} } else { - result, invokeErr := l.invokeModel(ctx, invoker, &sdkRequest, run, stepID, events) + result, invokeErr := l.invokeModel(ctx, invoker, &sdkRequest, runID, stepID, events) switch { case invokeErr != nil && ctx.Err() != nil: - completion = RecoverModelExecution{StepID: stepID} + completion = run.RecoverModelExecution{StepID: stepID} case invokeErr != nil: - completion = SubmitModelFailure{StepID: stepID, Failure: StepFailure{Class: FailureProvider, Message: invokeErr.Error()}} + 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 = RejectModelResult{StepID: stepID, Usage: UsageFromSDK(result.Usage), - Failure: StepFailure{Class: FailureMalformedModel, Message: bindErr.Error()}, + completion = run.RejectModelResult{StepID: stepID, Usage: run.UsageFromSDK(result.Usage), + Failure: run.StepFailure{Class: run.FailureMalformedModel, Message: bindErr.Error()}, Disposition: l.modelRejectDisposition(modelStep.Rejects)} - } else if frozenResult, freezeErr := FreezeModelResult(result); freezeErr != nil { - completion = RejectModelResult{StepID: stepID, Usage: UsageFromSDK(result.Usage), - Failure: StepFailure{Class: FailureMalformedModel, Message: freezeErr.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.Rejects)} } else { - completion = SubmitModelResult{StepID: stepID, Result: frozenResult, Calls: bindings} + completion = run.SubmitModelResult{StepID: stepID, Result: frozenResult, Calls: bindings} } } } } - res, err := l.commit(controlCtx, runtime, run, freshCommandID(), start.Snapshot.Revision, start.Grant, completion) + res, err := l.commit(controlCtx, runtime, runID, freshCommandID(), start.Snapshot.Revision, start.Grant, completion) if err != nil { if retriable(err) { return nil } return err } - l.emitCommitted(controlCtx, events, run, res.Events) + l.emitCommitted(controlCtx, events, runID, res.Events) return nil } -func (l *Loop) modelRejectDisposition(priorRejects int) ModelRejectDisposition { +func (l *Loop) modelRejectDisposition(priorRejects int) run.ModelRejectDisposition { if priorRejects+1 > l.Execution.MalformedModelResultLimit { - return ModelRejectFailRun + return run.ModelRejectFailRun } - return ModelRejectRetry + return run.ModelRejectRetry } -func (l *Loop) invokeModel(ctx context.Context, invoker ModelInvoker, req *sdk.Request, run RunID, step StepID, events EventSink) (sdk.ModelResult, error) { +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) @@ -329,13 +332,13 @@ func (l *Loop) invokeModel(ctx context.Context, invoker ModelInvoker, req *sdk.R switch p := part.(type) { case *sdk.TextDeltaPart: _ = events.Emit(ctx, Event{ - RunID: run, StepID: step, + RunID: runID, StepID: step, Kind: EventModelTextDelta, Durability: EventProvisional, Payload: mustJSON(p.Text), }) case *sdk.ReasoningDeltaPart: _ = events.Emit(ctx, Event{ - RunID: run, StepID: step, + RunID: runID, StepID: step, Kind: EventModelReasoningDelta, Durability: EventProvisional, Payload: mustJSON(p.Text), }) @@ -359,16 +362,16 @@ func (l *Loop) invokeModel(ctx context.Context, invoker ModelInvoker, req *sdk.R // bindToolCalls validates tool-call IDs/order/shape and produces bindings // from the frozen ToolSpecs (spec §4.1). It never calls ExecutableTool. -func (l *Loop) bindToolCalls(result *sdk.ModelResult, step *ModelStep) ([]ToolCallBinding, error) { +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]ToolSpec, len(step.Tools)) + specByName := make(map[string]run.ToolSpec, len(step.Tools)) for _, s := range step.Tools { specByName[s.Definition.Name] = s } seen := make(map[string]bool, len(result.ToolCalls)) - bindings := make([]ToolCallBinding, len(result.ToolCalls)) + bindings := make([]run.ToolCallBinding, len(result.ToolCalls)) for i, tc := range result.ToolCalls { if tc.ToolCallID == "" { return nil, fmt.Errorf("tool call %d has an empty id", i) @@ -377,18 +380,15 @@ func (l *Loop) bindToolCalls(result *sdk.ModelResult, step *ModelStep) ([]ToolCa return nil, fmt.Errorf("duplicate tool call id %q", tc.ToolCallID) } seen[tc.ToolCallID] = true - args, err := canonicalToolArguments(tc.Input) + args, err := run.FreezeToolCallInput(tc.Input) if err != nil { - // A single call with unparsable arguments is NOT structurally - // malformed: bind it raw and let StartToolCalls close it as a - // known invalid_arguments failure (spec §4.1). - args = rawToolArguments(tc.Input) + return nil, fmt.Errorf("tool call %q input: %w", tc.ToolCallID, err) } - b := ToolCallBinding{ - CallID: CallID(tc.ToolCallID), - ToolRef: ToolRef(tc.ToolName), + b := run.ToolCallBinding{ + CallID: run.CallID(tc.ToolCallID), + ToolRef: run.ToolRef(tc.ToolName), Arguments: args, - Policy: DirectExecution, + Policy: run.DirectExecution, } if spec, known := specByName[tc.ToolName]; known { // The binding's ToolRef is the frozen spec's Ref — the catalog @@ -398,7 +398,7 @@ func (l *Loop) bindToolCalls(result *sdk.ModelResult, step *ModelStep) ([]ToolCa b.DefinitionDigest = spec.DefinitionDigest b.Policy = spec.Policy } - bd, err := digestToolCallBinding(b.CallID, b.DefinitionDigest, b.Policy, b.Arguments) + bd, err := run.DigestToolCallBinding(b.CallID, b.DefinitionDigest, b.Policy, b.Arguments) if err != nil { return nil, err } @@ -411,15 +411,24 @@ func (l *Loop) bindToolCalls(result *sdk.ModelResult, step *ModelStep) ([]ToolCa // --- StartToolCalls --- type startedWorker struct { - call ToolCallState - grant ExecutionGrant + call run.ToolCallState + grant run.ExecutionGrant base uint64 tool ExecutableTool } -func (l *Loop) runToolCalls(ctx, controlCtx context.Context, runtime Runtime, events EventSink, snapshot *RuntimeSnapshot, eff StartToolCalls) error { - run := snapshot.State.RunID - ts, ok := snapshot.State.Current.(ToolStep) +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) runToolCalls(ctx, controlCtx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, eff run.StartToolCalls) error { + runID := snapshot.State.RunID + 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) } @@ -437,73 +446,73 @@ func (l *Loop) runToolCalls(ctx, controlCtx context.Context, runtime Runtime, ev if ctx.Err() != nil { break } - i := ts.callIndex(callID) + i := toolCallIndex(ts, callID) if i < 0 { continue } call := ts.Calls[i] tool, resolveErr := l.Tools.Resolve(call.ToolRef) - var known *ToolFailure + var known *run.ToolFailure switch { case resolveErr != nil: - known = &ToolFailure{Class: FailureToolLookup, Message: resolveErr.Error()} + known = &run.ToolFailure{Class: run.FailureToolLookup, Message: resolveErr.Error()} default: - toolDef, err := FreezeToolDefinition(tool.Definition()) + toolDef, err := run.FreezeToolDefinition(tool.Definition()) if err != nil { return err } - defDigest, err := DigestToolDefinition(toolDef) + defDigest, err := run.DigestToolDefinition(toolDef) if err != nil { return err } switch { case tool.Ref() != call.ToolRef || defDigest != call.DefinitionDigest: - known = &ToolFailure{Class: FailureDefinitionMismatch, Message: "tool definition digest mismatch"} + known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: "tool definition digest mismatch"} case tool.ResponsePolicy() != call.Policy: - known = &ToolFailure{Class: FailureDefinitionMismatch, Message: "response policy mismatch"} + known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: "response policy mismatch"} default: if argErr := tool.ValidateArguments(call.Arguments); argErr != nil { - known = &ToolFailure{Class: FailureInvalidArguments, Message: argErr.Error()} + known = &run.ToolFailure{Class: run.FailureInvalidArguments, Message: argErr.Error()} } } } if known != nil { // Known failure of a Pending call: no start barrier, no tool call. - res, err := l.commit(controlCtx, runtime, run, freshCommandID(), snapshot.Revision, "", - SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: *known, Outcome: ToolOutcomeKnown}) + res, err := l.commit(controlCtx, runtime, runID, freshCommandID(), snapshot.Revision, "", + run.SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: *known, Outcome: run.ToolOutcomeKnown}) if err != nil { - settleErr := l.settleWorkers(ctx, controlCtx, runtime, events, run, eff.StepID, started) + settleErr := l.settleWorkers(ctx, controlCtx, runtime, events, runID, eff.StepID, started) if !retriable(err) { return err } return settleErr } - l.emitCommitted(controlCtx, events, run, res.Events) + l.emitCommitted(controlCtx, events, runID, res.Events) continue } - start, err := l.commit(controlCtx, runtime, run, freshCommandID(), snapshot.Revision, "", - StartToolCall{StepID: eff.StepID, CallID: callID}) + start, err := l.commit(controlCtx, runtime, runID, freshCommandID(), snapshot.Revision, "", + run.StartToolCall{StepID: eff.StepID, CallID: callID}) if err != nil { - settleErr := l.settleWorkers(ctx, controlCtx, runtime, events, run, eff.StepID, started) + settleErr := l.settleWorkers(ctx, controlCtx, runtime, events, runID, eff.StepID, started) if retriable(err) { return settleErr } return err } - if start.Status == CommitAlreadyApplied { + if start.Status == run.CommitAlreadyApplied { continue // another attempt owns this call } - l.emitCommitted(controlCtx, events, run, start.Events) + l.emitCommitted(controlCtx, events, runID, start.Events) if events != nil { - _ = events.Emit(controlCtx, Event{RunID: run, StepID: eff.StepID, CallID: callID, + _ = events.Emit(controlCtx, Event{RunID: runID, StepID: eff.StepID, CallID: callID, Kind: EventToolStarted, Durability: EventCommitted}) } started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Revision, tool: tool}) } - return l.settleWorkers(ctx, controlCtx, runtime, events, run, eff.StepID, started) + return l.settleWorkers(ctx, controlCtx, runtime, events, runID, eff.StepID, started) } // settleWorkers executes every started worker and commits its outcome. An @@ -512,7 +521,7 @@ func (l *Loop) runToolCalls(ctx, controlCtx context.Context, runtime Runtime, ev // that fails with a non-sentinel error is replayed once with the same // CommandID (spec §6.6 "commit response unknown"); a still-failing commit is // reported so the host does not mistake a wedged call for progress. -func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime Runtime, events EventSink, run RunID, stepID StepID, started []startedWorker) error { +func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime run.Runtime, events EventSink, runID run.RunID, stepID run.StepID, started []startedWorker) error { if len(started) == 0 { return nil } @@ -528,33 +537,33 @@ func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime Runtime, e go func(w startedWorker) { defer wg.Done() req := ToolExecutionRequest{ - RunID: run, + 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: run, step: stepID, call: w.call.CallID}, + Progress: &progressSink{events: events, run: runID, step: stepID, call: w.call.CallID}, } outcome := executeToolSafely(execCtx, w.tool, &req) - var cmd AgentCommand + var cmd run.AgentCommand unknown := false switch o := outcome.(type) { case ToolExecutionSucceeded: - cmd = SubmitToolResult{StepID: stepID, CallID: w.call.CallID, Result: o.Result} + cmd = run.SubmitToolResult{StepID: stepID, CallID: w.call.CallID, Result: o.Result} case ToolExecutionFailed: - cmd = SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, Failure: o.Failure, Outcome: ToolOutcomeKnown} + cmd = run.SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, Failure: o.Failure, Outcome: run.ToolOutcomeKnown} case ToolExecutionUnknown: failure := o.Failure if failure.Class == "" { - failure.Class = FailureEffectUnknown + failure.Class = run.FailureEffectUnknown } - cmd = SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, Failure: failure, Outcome: ToolOutcomeUnknown} + cmd = run.SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, Failure: failure, Outcome: run.ToolOutcomeUnknown} unknown = true default: - cmd = SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, - Failure: ToolFailure{Class: FailureEffectUnknown, Message: "tool returned no outcome"}, Outcome: ToolOutcomeUnknown} + cmd = run.SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, + Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: "tool returned no outcome"}, Outcome: run.ToolOutcomeUnknown} unknown = true } @@ -564,12 +573,12 @@ func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime Runtime, e // 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. - res, err := l.commit(controlCtx, runtime, run, freshCommandID(), w.base, w.grant, cmd) + res, err := l.commit(controlCtx, runtime, runID, freshCommandID(), w.base, w.grant, cmd) switch { case err == nil: - l.emitCommitted(controlCtx, events, run, res.Events) + l.emitCommitted(controlCtx, events, runID, res.Events) if events != nil { - _ = events.Emit(controlCtx, Event{RunID: run, StepID: stepID, CallID: w.call.CallID, + _ = events.Emit(controlCtx, Event{RunID: runID, StepID: stepID, CallID: w.call.CallID, Kind: EventToolCompleted, Durability: EventCommitted}) } case retriable(err): @@ -595,8 +604,8 @@ func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime Runtime, e func executeToolSafely(ctx context.Context, tool ExecutableTool, req *ToolExecutionRequest) (outcome ToolExecutionOutcome) { defer func() { if r := recover(); r != nil { - outcome = ToolExecutionUnknown{Failure: ToolFailure{ - Class: FailureEffectUnknown, + outcome = ToolExecutionUnknown{Failure: run.ToolFailure{ + Class: run.FailureEffectUnknown, Message: fmt.Sprintf("tool panic: %v", r), }} } @@ -606,9 +615,9 @@ func executeToolSafely(ctx context.Context, tool ExecutableTool, req *ToolExecut type progressSink struct { events EventSink - run RunID - step StepID - call CallID + run run.RunID + step run.StepID + call run.CallID seq uint64 mu sync.Mutex } @@ -629,7 +638,7 @@ func (p *progressSink) Publish(ctx context.Context, progress ToolProgress) { } func mustJSON(v any) []byte { - b, err := jsonMarshal(v) + b, err := json.Marshal(v) if err != nil { return []byte("null") } diff --git a/agent/run/loop_test.go b/agent/run/loop/loop_test.go similarity index 89% rename from agent/run/loop_test.go rename to agent/run/loop/loop_test.go index 9bd0a19..1901ef3 100644 --- a/agent/run/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -1,4 +1,4 @@ -package run +package loop import ( "context" @@ -8,6 +8,8 @@ import ( "sync/atomic" "testing" + . "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/sdk" ) @@ -120,7 +122,7 @@ func toolCallResult(ids ...string) sdk.ModelResult { func TestLoopSingleModelCallCompletes(t *testing.T) { rt := loopRuntime(t) - loop, err := NewLoop(fakeCatalog{&fakeInvoker{results: []sdk.ModelResult{textResult("hello")}}}, + loop, err := New(fakeCatalog{&fakeInvoker{results: []sdk.ModelResult{textResult("hello")}}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) if err != nil { t.Fatal(err) @@ -145,7 +147,7 @@ func TestLoopToolRoundTrip(t *testing.T) { }} invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("done")}} rt := loopRuntime(t) - loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) res, err := loop.Run(context.Background(), rt, "run-1", nil) @@ -174,7 +176,7 @@ func TestLoopApprovalWaitsAndResumes(t *testing.T) { }} invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("after")}} rt := loopRuntime(t) - loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) // First run: reaches Waiting(Approval) and returns. @@ -228,7 +230,7 @@ func TestLoopUnknownOutcomeFailsRun(t *testing.T) { }} invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1")}} rt := loopRuntime(t) - loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) res, err := loop.Run(context.Background(), rt, "run-1", nil) @@ -251,7 +253,7 @@ func TestLoopKnownToolFailureContinues(t *testing.T) { }} invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("recovered")}} rt := loopRuntime(t) - loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) res, err := loop.Run(context.Background(), rt, "run-1", nil) @@ -275,7 +277,7 @@ func TestLoopUnknownToolRefClosesAsLookupFailure(t *testing.T) { textResult("moved on"), }} rt := loopRuntime(t) - loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{tools: map[ToolRef]ExecutableTool{}}, + loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{tools: map[ToolRef]ExecutableTool{}}, staticPlanner{}, ExecutionPolicy{}, false) res, err := loop.Run(context.Background(), rt, "run-1", nil) @@ -318,7 +320,7 @@ func TestLoopParallelBounded(t *testing.T) { }} invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1", "c2", "c3"), textResult("done")}} rt := loopRuntime(t) - loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{MaxParallel: 2}, false) done := make(chan struct{}) @@ -352,7 +354,7 @@ func TestLoopCtxCancelReturnsWithoutFailingRun(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) invoker := &fakeInvoker{errs: []error{context.Canceled}} rt := loopRuntime(t) - loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) cancel() // cancelled before the model call _, err := loop.Run(ctx, rt, "run-1", nil) @@ -366,7 +368,7 @@ func TestLoopCtxCancelReturnsWithoutFailingRun(t *testing.T) { } // A fresh Loop with a working invoker resumes the same frozen request. invoker2 := &fakeInvoker{results: []sdk.ModelResult{textResult("resumed")}} - loop2, _ := NewLoop(fakeCatalog{invoker2}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + loop2, _ := New(fakeCatalog{invoker2}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) res, err := loop2.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) @@ -377,9 +379,8 @@ func TestLoopCtxCancelReturnsWithoutFailingRun(t *testing.T) { } func TestLoopModelStepLimitStopsBeforePlanning(t *testing.T) { - // Corruption fixture: the limit is a snapshot-only condition that cannot - // be reached without planning, so inject it locally after a real Revision-0 - // admission. Normal loop fixtures never mutate MemoryRuntime internals. + // Simulate the snapshot-only limit condition without reaching into + // MemoryRuntime internals from this package. rt := NewMemoryRuntime() newRun, err := BuildNewRun("run-1", "") if err != nil { @@ -388,15 +389,11 @@ func TestLoopModelStepLimitStopsBeforePlanning(t *testing.T) { if _, err := rt.Create(context.Background(), newRun); err != nil { t.Fatal(err) } - entry := memoryEntry(t, rt) - entry.mu.Lock() - entry.state.ModelSteps = 1 - entry.mu.Unlock() - loop, err := NewLoop(fakeCatalog{}, fakeToolCatalog{}, panicPlanner{}, ExecutionPolicy{ModelStepLimit: 1}, false) + loop, err := New(fakeCatalog{}, fakeToolCatalog{}, panicPlanner{}, ExecutionPolicy{ModelStepLimit: 1}, false) if err != nil { t.Fatal(err) } - res, err := loop.Run(context.Background(), rt, "run-1", nil) + res, err := loop.Run(context.Background(), modelStepLimitRuntime{Runtime: rt}, "run-1", nil) if err != nil { t.Fatal(err) } @@ -416,7 +413,7 @@ func TestLoopMalformedModelResultLimitFailsRun(t *testing.T) { Usage: sdk.Usage{TotalTokens: 1}, } invoker := &fakeInvoker{results: []sdk.ModelResult{bad, bad, bad}} - loop, err := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{MalformedModelResultLimit: 2}, false) + loop, err := New(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{MalformedModelResultLimit: 2}, false) if err != nil { t.Fatal(err) } @@ -446,7 +443,7 @@ func TestLoopCancelRunViaCommand(t *testing.T) { if _, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { t.Fatal(err) } - loop, _ := NewLoop(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + loop, _ := New(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) @@ -470,7 +467,7 @@ func TestLoopMidExecutionCancelRecoversModelStep(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) invoker := &cancellingInvoker{cancel: cancel} rt := loopRuntime(t) - loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) _, err := loop.Run(ctx, rt, "run-1", nil) if !errors.Is(err, context.Canceled) { @@ -498,7 +495,7 @@ func TestLoopMidExecutionCancelRecoversModelStep(t *testing.T) { // A fresh Loop resumes the SAME frozen step without a new Prepare. invoker2 := &fakeInvoker{results: []sdk.ModelResult{textResult("resumed")}} - loop2, _ := NewLoop(fakeCatalog{invoker2}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + loop2, _ := New(fakeCatalog{invoker2}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) res, err := loop2.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) diff --git a/agent/run/loop/regression_test.go b/agent/run/loop/regression_test.go new file mode 100644 index 0000000..c60554f --- /dev/null +++ b/agent/run/loop/regression_test.go @@ -0,0 +1,125 @@ +package loop + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync/atomic" + "testing" + + . "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/sdk" +) + +func TestRegressionToolPanicBecomesUnknown(t *testing.T) { + spec := toolSpec(t, "echo", DirectExecution) + echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, + execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { + panic("nil map write") + }} + invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1")}} + 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, "run-1", nil) + if err != nil { + t.Fatal(err) + } + if res.Result.Status != RunFailed || res.Result.Reason != ReasonEffectUnknown { + t.Fatalf("res = %+v", res.Result) + } + if !strings.Contains(res.Result.Failure.Message, "panic") { + t.Fatalf("failure = %+v", res.Result.Failure) + } +} + +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, "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 := DigestToolDefinition(frozenDef) + if err != nil { + t.Fatal(err) + } + spec := ToolSpec{Ref: "fs.read", Definition: frozenDef, 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, "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, "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/runtime_helpers_test.go b/agent/run/loop/runtime_helpers_test.go new file mode 100644 index 0000000..40e1541 --- /dev/null +++ b/agent/run/loop/runtime_helpers_test.go @@ -0,0 +1,29 @@ +package loop + +import ( + "context" + "testing" + + . "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/sdk" +) + +func TestLoopPrepareRejectionDoesNotLivelock(t *testing.T) { + rt := loopRuntime(t) + interpreter, err := New(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{}, + badPlanner{}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + _, err = interpreter.Run(context.Background(), rt, "run-1", nil) + if err == nil { + t.Fatal("prepare livelock not surfaced") + } +} + +// badPlanner never consumes pending inputs, so its Prepare is always rejected. +type badPlanner struct{} + +func (badPlanner) Plan(_ context.Context, _ PlanningHint) (RequestPlan, error) { + return RequestPlan{Model: testModel, Request: sdk.Request{Model: string(testModel)}}, nil +} diff --git a/agent/run/model_data.go b/agent/run/model_data.go index ac098ca..ed1f243 100644 --- a/agent/run/model_data.go +++ b/agent/run/model_data.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "time" + "unicode/utf8" "github.com/google/jsonschema-go/jsonschema" "github.com/memohai/twilight/sdk" @@ -411,7 +412,7 @@ func FreezeMessagePart(p sdk.MessagePart) (MessagePart, error) { } return FreezeMessagePart(*part) case sdk.ToolCallPart: - input, err := freezeToolCallInput(part.Input) + input, err := FreezeToolCallInput(part.Input) if err != nil { return MessagePart{}, err } @@ -693,21 +694,34 @@ func (f GeneratedFile) SDK() sdk.GeneratedFile { return sdk.GeneratedFile{Data: f.Data, MediaType: f.MediaType} } -func freezeToolCallInput(input any) (CanonicalJSON, error) { +// 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 input.(type) { - case string, json.RawMessage: - return rawToolArguments(input), 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) + input, err := FreezeToolCallInput(c.Input) if err != nil { return ModelToolCall{}, fmt.Errorf("tool call input: %w", err) } diff --git a/agent/run/regression_test.go b/agent/run/regression_test.go index 0e2b355..8c2079c 100644 --- a/agent/run/regression_test.go +++ b/agent/run/regression_test.go @@ -2,10 +2,6 @@ package run import ( "context" - "encoding/json" - "errors" - "strings" - "sync/atomic" "testing" "github.com/memohai/twilight/sdk" @@ -107,31 +103,6 @@ func TestRegressionInvalidToolOutputCannotBeConstructed(t *testing.T) { } } -// Finding 6: a panicking tool settles as Unknown instead of crashing the -// process. -func TestRegressionToolPanicBecomesUnknown(t *testing.T) { - spec := toolSpec(t, "echo", DirectExecution) - echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, - execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { - panic("nil map write") - }} - invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1")}} - rt := loopRuntime(t) - loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, - staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) - - res, err := loop.Run(context.Background(), rt, "run-1", nil) - if err != nil { - t.Fatal(err) - } - if res.Result.Status != RunFailed || res.Result.Reason != ReasonEffectUnknown { - t.Fatalf("res = %+v", res.Result) - } - if !strings.Contains(res.Result.Failure.Message, "panic") { - t.Fatalf("failure = %+v", res.Result.Failure) - } -} - // Finding 7: an abandoned ask-user call has a failure exit via RejectToolCall. func TestRegressionExternalResponseCanBeRejected(t *testing.T) { def := testToolDef("ask") @@ -243,71 +214,6 @@ func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { } } -// Finding 14: EventRunFinished fires on terminal. -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 - }) - loop, _ := NewLoop(fakeCatalog{&fakeInvoker{results: []sdk.ModelResult{textResult("done")}}}, - fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) - if _, err := loop.Run(context.Background(), rt, "run-1", sink); err != nil { - t.Fatal(err) - } - for _, k := range kinds { - if k == EventRunFinished { - return - } - } - t.Fatalf("EventRunFinished never emitted; kinds = %v", kinds) -} - -// Finding 15: a ToolSpec whose Ref differs from the definition name routes -// through the catalog by Ref end to end. -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 := DigestToolDefinition(frozenDef) - if err != nil { - t.Fatal(err) - } - spec := ToolSpec{Ref: "fs.read", Definition: frozenDef, 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"`)}} - }} - // Model calls the definition name "read"; catalog keys by Ref "fs.read". - 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) - loop, _ := NewLoop(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"fs.read": tool}}, - staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) - - res, err := loop.Run(context.Background(), rt, "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) - } -} - // Low-severity finding: CancelRun cannot forge a system reason. func TestRegressionCancelReasonFixed(t *testing.T) { s := newRun(t, testConfig()) @@ -319,37 +225,3 @@ func TestRegressionCancelReasonFixed(t *testing.T) { t.Fatal("cancel reason not fixed to cancelled") } } - -// Finding 13: a stream that closes Parts but returns (nil, nil) from Result -// fails the call instead of panicking the Loop. -func TestRegressionStreamNilResult(t *testing.T) { - rt := loopRuntime(t) - loop, _ := NewLoop(fakeCatalog{nilResultStreamer{}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, true) - res, err := loop.Run(context.Background(), rt, "run-1", nil) - if err != nil { - t.Fatal(err) - } - // SubmitModelFailure path: run fails with provider_failure, no panic. - 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/runtime.go b/agent/run/runtime.go new file mode 100644 index 0000000..7b4900a --- /dev/null +++ b/agent/run/runtime.go @@ -0,0 +1,43 @@ +package run + +import "context" + +// Runtime is the RunID-addressed access and atomic commit boundary. Create +// establishes immutable Revision-0 state; Load, Commit, and Record operate on +// exactly one Run. MachineState remains execution authority; planning, queues, +// and tool entry points are not hidden methods. +type Runtime interface { + Create(context.Context, NewRun) (CreateResult, error) + Load(context.Context, RunID) (RuntimeSnapshot, error) + Commit(context.Context, CommitRequest) (CommitResult, error) + Record(context.Context, RunID) (RunRecord, error) +} + +type RuntimeSnapshot struct { + State MachineState + // Revision counts accepted transitions; the initial state is 0. + Revision uint64 +} + +type CommitRequest struct { + BaseRevision uint64 + Grant ExecutionGrant + Command CommandEnvelope +} + +type CommitStatus uint8 + +const ( + CommitAccepted CommitStatus = iota + CommitAlreadyApplied +) + +type CommitResult struct { + Status CommitStatus + Snapshot RuntimeSnapshot + // Events is the complete event group of the transition, for Accepted and + // AlreadyApplied alike. + Events []AgentEvent + // Grant is returned only for an Accepted start command; empty otherwise. + Grant ExecutionGrant +} diff --git a/agent/run/runtime_helpers_test.go b/agent/run/runtime_helpers_test.go index edafb95..d84e9df 100644 --- a/agent/run/runtime_helpers_test.go +++ b/agent/run/runtime_helpers_test.go @@ -108,26 +108,3 @@ func buildPrepareFromSnap(t *testing.T, snap RuntimeSnapshot, req sdk.Request, s RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, }, cmdID } - -func TestLoopPrepareRejectionDoesNotLivelock(t *testing.T) { - // A planner that omits pending InputIDs produces a Prepare the authority - // rejects at the same revision forever; the Loop must surface an error - // instead of spinning (loop.go planAndPrepare guard). - rt := newTestRuntime(t, RunConfig{Model: "m-1"}) - loop, err := NewLoop(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{}, - badPlanner{}, ExecutionPolicy{}, false) - if err != nil { - t.Fatal(err) - } - _, err = loop.Run(context.Background(), rt, "run-1", nil) - if err == nil { - t.Fatal("prepare livelock not surfaced") - } -} - -// badPlanner never consumes pending inputs, so its Prepare is always rejected. -type badPlanner struct{} - -func (badPlanner) Plan(_ context.Context, _ PlanningHint) (RequestPlan, error) { - return RequestPlan{Model: testModel, Request: sdk.Request{Model: string(testModel)}}, nil -} diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 3aff08d..a831d6f 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -70,7 +70,7 @@ Session 保存跨 Run 的长期语义;Turn 协议将 Run facts materialize 为 Request Planner │ sdk.Request v - run.Loop ──SDK──> Model / Tool + run/loop.Loop ──SDK──> Model / Tool │ AgentCommand v shared run.Runtime @@ -100,7 +100,8 @@ twilight-ai/ agent/ es/ 通用 ES envelope、digest、record、fold 机制 jsonstable/ canonical JSON value 与解析/编码 - run/ Run Machine、Loop、Runtime、Step/ToolCall + run/ Run Machine、Runtime、Step/ToolCall + loop/ in-process Loop interpreter、planner/model/tool/event ports artifact/ Ref、Binding、BindingSet、RetentionClaim session/ 长期语义 Session ES kernel extension/ 静态 Module/Catalog、SemanticAppender、projection @@ -113,6 +114,7 @@ twilight-ai/ ```text agent/es ---> agent/jsonstable(可选) agent/run ---> agent/es + agent/jsonstable + sdk +agent/run/loop ---> agent/run + sdk agent/artifact ---> agent/es + agent/jsonstable agent/session ---> agent/es + agent/jsonstable agent/session/extension ---> agent/session + agent/artifact @@ -178,8 +180,8 @@ ToolCall 是 Step 内的外部操作单元。ToolStep 保存每个 ToolCall 中 | 场景 | 期望结果 | | --- | --- | | 单次模型调用 | 直接使用 `sdk`,不自动执行工具。 | -| 本地 Run | `run.Loop` 配合 `run.MemoryRuntime`。 | -| durable Run | 同一个 `run.Loop` 配合 durable runtime adapter。 | +| 本地 Run | `loop.Loop` 配合 `run.MemoryRuntime`。 | +| durable Run | 同一个 `loop.Loop` 配合 durable runtime adapter。 | | 模型返回 tools | ModelStep 完成后创建一个 ToolStep;ToolStep 完成前不创建下一 ModelStep。 | | 多个 approval/用户响应 | 每个等待请求有稳定身份;一个响应只推进对应 Call。 | | ToolStep 中途崩溃 | Completed Call 不再执行;Pending Call 可恢复;结果未知的 Executing Call 终止 Run。 | @@ -463,7 +465,8 @@ Application 只有在完整 materialization coverage、terminal settlement 及 | generic event envelope/record/fold/digest mechanics | `agent/es` | | `ModelRequest`、`ModelResult`、`ToolDefinition`、`Usage`、`Message`、`MessagePart`(run persisted frozen values) | `agent/run` | | `Step`、`ToolCallState`、`AgentCommand`、`Fact`、`AgentEvent`、`TransitionRecord`、`Effect` | `agent/run` | -| `Runtime`、`MemoryRuntime`、`Loop`、`runtimetest` | `agent/run` | +| `Runtime`、`MemoryRuntime`、`runtimetest` | `agent/run` | +| `Loop`、RequestPlanner/model/tool/event contracts、`ExecutionPolicy`、`LoopResult` | `agent/run/loop` | | `SessionHeader`、`SessionCommit`、`SessionEvent`、resolved replay、fork、snapshot、store | `agent/session` | | `Ref`、`Binding`、`BindingSet`、`RetentionClaim` | `agent/artifact` | | static Module/Catalog、codec/upcast、`SemanticAppender`、pure projection | `agent/session/extension` | @@ -471,9 +474,9 @@ Application 只有在完整 materialization coverage、terminal settlement 及 | Turn→Run coordination、`FactMapper`、`MaterializeAll`、settlement | `agent/turn` | | queue item/claim/dedup/visibility 和 steer/follow-up policy | Application/Product | | Request Planner 实现和 context transformer | Application/Product | -| fixed model policy、step budget、malformed retry budget | Application/Product 或 run `ExecutionPolicy`;不进入 MachineState | +| fixed model policy、step budget、malformed retry budget | Application/Product 或 `loop.ExecutionPolicy`;不进入 MachineState | | owner、fencing、lease、outbox、DB schema | Durable adapter/Application | -| MCP server 连接和生命周期 | Application;schema/call adapter 可在 `agent/run` | +| MCP server 连接、生命周期和 schema/call adapter | Application/provider adapter | | provider transport retry | sdk/provider client | ## 3. Run Machine @@ -1137,7 +1140,6 @@ AgentEvent 身份 CommandDigest,Runtime 不需要独立的 command 索引表。事件身份不由调用方提供, 也不参与 command 幂等判定。 ``` -``` canonical 编码和 digest 函数由 agent 提供;Memoh 只保存和比较结果,不重新实现排序或编码。`CanonicalJSON` 按 RFC 8785/JCS 编码,并采用 IEEE-754 binary64 number 语义;精确 ID 或任意精度数值必须是 JSON string,因此 PostgreSQL JSONB 的结构化重写在重新 canonicalize 后保持 digest 稳定。编码必须包含 sealed command/fact discriminator、按声明顺序编码有序 slice、对 map key 排序,并对 `CanonicalJSON` 原样写入其 canonical bytes;重复 object key、trailing data、invalid UTF-8、escaped lone surrogate 一律在构造 `CanonicalJSON` 或 decode wire document 时拒绝,不能让 `encoding/json` 的 replacement behavior 把不同输入合并为同一 digest;不把 `Digest`、BaseRevision、Revision、Index 或 ExecutionGrant 编入 digest。 @@ -1153,23 +1155,24 @@ agent 只声明供 Loop 依赖注入的 `RequestPlanner` port;Planner 的实 application/Memoh。Runtime 不调用这个 port,也不读取 Planner 的 context: ```go +// package loop; run is "github.com/memohai/twilight/agent/run" type RequestPlanner interface { - Plan(context.Context, PlanningHint) (RequestPlan, error) + Plan(context.Context, run.PlanningHint) (RequestPlan, error) } type RequestPlan struct { - Model ModelRef - Request sdk.Request // boundary value; Loop freezes to ModelRequest before PrepareModelRequest - InputIDs []InputID - Tools []ToolSpec // agent frozen ToolSpec/ToolDefinition sidecars - PlanningToken PlanningToken // application-owned freshness token + Model run.ModelRef + Request sdk.Request // boundary value; Loop freezes to ModelRequest before PrepareModelRequest + InputIDs []run.InputID + Tools []run.ToolSpec // frozen ToolSpec/ToolDefinition sidecars + PlanningToken run.PlanningToken // application-owned freshness token } +// package run type PlanningHint struct { - RunID RunID - Model ModelRef - SourceStep StepID - Inputs []AgentInput + RunID RunID + SourceStep StepID + Inputs []AgentInput } ``` @@ -1218,6 +1221,7 @@ steer 必须进入紧接着的 eligible ModelStep 规划上下文。Application ### 6.1 Loop 结构 ```go +// package loop; run is "github.com/memohai/twilight/agent/run" type Loop struct { Models ModelCatalog Tools ToolCatalog @@ -1226,7 +1230,8 @@ type Loop struct { Streaming bool } -func (l *Loop) Run(context.Context, Runtime, RunID, EventSink) (LoopResult, error) +func New(ModelCatalog, ToolCatalog, RequestPlanner, ExecutionPolicy, bool) (*Loop, error) +func (l *Loop) Run(context.Context, run.Runtime, run.RunID, EventSink) (LoopResult, error) ``` Loop 是当前进程的解释器。它不复制权威状态,不直接访问数据库或 Memoh queue。 @@ -1485,14 +1490,15 @@ Pending + Waiting ### 7.1 Tool contract -`sdk.ToolDefinition` 只描述 provider 可发现的 schema,不依赖 run,也不携带 `ResponsePolicy`。`run.ExecutableTool.Definition()` 可以返回 SDK 边界类型,Loop 必须先 `FreezeToolDefinition` 再计算 `DigestToolDefinition` 或写入 `ToolSpec`;MachineState/AgentEvent 中保存的是 run `ToolDefinition`。`run.ExecutableTool` 描述应用如何执行工具并提供 response policy;模型返回后,run 用 `ToolRef`、definition digest 和 policy 生成冻结的 `ToolCallBinding`。恢复时 schema、工具版本或 policy 不匹配都不能静默换版本。 +`sdk.ToolDefinition` 只描述 provider 可发现的 schema,不依赖 run,也不携带 `ResponsePolicy`。`loop.ExecutableTool.Definition()` 返回 SDK 边界类型,Loop 必须先经 `run.FreezeToolDefinition` 冻结再计算 `run.DigestToolDefinition` 或写入 `run.ToolSpec`;MachineState/AgentEvent 中保存的是 `run.ToolDefinition`。`loop.ExecutableTool` 描述应用如何执行工具并提供 response policy;模型返回后,Loop 用 `run.ToolRef`、definition digest 和 policy 生成冻结的 `run.ToolCallBinding`。恢复时 schema、工具版本或 policy 不匹配都不能静默换版本。 ```go +// package loop; run is github.com/memohai/twilight/agent/run type ExecutableTool interface { - Ref() ToolRef + Ref() run.ToolRef Definition() sdk.ToolDefinition - ResponsePolicy() ResponsePolicy - ValidateArguments(CanonicalJSON) error + ResponsePolicy() run.ResponsePolicy + ValidateArguments(run.CanonicalJSON) error Execute(context.Context, ToolExecutionRequest) ToolExecutionOutcome } ``` @@ -1514,7 +1520,7 @@ ask_user -> Waiting(ExternalResponse) ### 7.3 MCP -agent 提供 MCP schema/call adapter,把 MCP tool 转换为 `sdk.ToolDefinition` 和 `ExecutableTool`。MCP server 的连接、认证、生命周期和产品权限由 Memoh/application 管理。迁移期可以保留旧 `sdk.MCPClient` wrapper;新 Loop 不依赖 SDK 的 MCP session。 +Application adapter 把 MCP tool 转换为 `sdk.ToolDefinition` 和 `loop.ExecutableTool`。MCP server 的连接、认证、生命周期和产品权限由 Memoh/application 管理;Loop 不依赖 SDK 的 MCP session。 ## 8. 同一 Run execution semantics 的 Runtime implementations @@ -1741,7 +1747,7 @@ response 101 只完成 B;D 仍可执行,不必等待 C。response 102 再完 ### 阶段 B:移动现有 core 到 `agent/run` -1. 将现有 root `agent` 中的 Machine、Loop、Runtime、MemoryRuntime、model data、tool contract、codec、runtimetest 移到 `agent/run`。 +1. 将现有 root `agent` 中的 Machine、Runtime、MemoryRuntime、model data、codec、runtimetest 移到 `agent/run`;将 Loop、planner/model/tool/event contract 移到 `agent/run/loop`。 2. 新 admission 使用 `BuildNewRun`/`Runtime.Create`,初始输入通过 `AcceptInput` transition。当前导出的 `InitializeRun` 与 `BuildRunHeader` 只服务迁移和低层测试,新 admission 不调用它们;删除或正式弃用另行处理。`BuildRunHeaderFromNewRun` 是 Runtime adapter 按 version 建立同一 Revision-0 header 的共享构造入口。 3. 实现 `NewRun`、`BuildNewRun`/`ValidateNewRun`、`BuildRunHeaderFromNewRun` 与 `Runtime.Create`:Create 按 version 建立正式 immutable Revision-0 `RunHeader`;`MachineState` 是日常 execution authority,rebuild/fold API 以 header 为 canonical 起点。 4. 让 `MemoryRuntime` 成为 RunID-addressed multi-Run collection 的最轻 reference runtime:`NewMemoryRuntime()` 返回空 collection,collection map lock 仅用于 create/lookup,每个 Run 使用独立锁;它可以没有 lease/DB/heartbeat,但不能跳过 execution event semantics。 @@ -1779,9 +1785,9 @@ docs/design/agent-turn.md run.NewMemoryRuntime() // empty multi-Run collection + session/artifact/extension/chatlog memory implementations + turn.Coordinator + BuildNewRun/Create/AcceptInput -+ run.Loop.Run(ctx, runtime, runID, sink) -+ in-memory EventSink -+ application RequestPlanner(读取 Context projection) ++ loop.New(...).Run(ctx, runtime, runID, sink) ++ in-memory loop.EventSink ++ application loop.RequestPlanner(读取 Context projection) ``` 它不复制 Run Machine,不把 chat history 塞进 `MemoryRuntime`。它用于 local example、test、prototype,以及证明 local/durable 是同一 Run execution semantics 的不同 runtime implementation。 @@ -1830,7 +1836,8 @@ ContextManifest { | --- | --- | --- | | sdk Request/ModelResult/stream | `sdk` | provider adapter | | generic record/digest/fold mechanism | `agent/es` | jsonstable | -| Run Machine、Loop、Runtime、MemoryRuntime | `agent/run` | es + sdk | +| Run Machine、Runtime、MemoryRuntime | `agent/run` | es + sdk | +| Loop 与 planner/model/tool/event ports | `agent/run/loop` | run + sdk | | RunHeader、run codec、runtimetest | `agent/run` | es | | Session Store/MemoryStore/replay/fork conformance | `agent/session` | es + jsonstable | | Ref/Binding/RetentionClaim 与 memory conformance | `agent/artifact` | es + jsonstable | @@ -1976,7 +1983,7 @@ EventSink gap 后由 verified RunRecord 与 Session projections 对账 ## 15. Memoh queue integration boundary -Application 的 queue/admission policy 负责 claim、priority、steer/follow-up 和 wake。Application host 组合 Turn Coordinator、Request Planner、`run.Loop` 与 durable Runtime adapter;queue 仲裁只发生在 queue-safe boundary,并将输入 durable admission 与对应 `AcceptInput` 通过事务或可幂等恢复协调。每次 response 只推进目标 Call。branch/claim/recovery policy 保持在 Application,不进入 `agent/run`。 +Application 的 queue/admission policy 负责 claim、priority、steer/follow-up 和 wake。Application host 组合 Turn Coordinator、`loop.RequestPlanner`、`loop.Loop` 与 durable Runtime adapter;queue 仲裁只发生在 queue-safe boundary,并将输入 durable admission 与对应 `AcceptInput` 通过事务或可幂等恢复协调。每次 response 只推进目标 Call。branch/claim/recovery policy 保持在 Application,不进入 `agent/run`。 ## 16. 实施前置条件 @@ -1995,7 +2002,7 @@ Application 的 queue/admission policy 负责 claim、priority、steer/follow-up 2. breaking release 版本和 durable protocol upgrade window。 3. EventSink payload schema,以及是否需要在 durable outbox 中加入跨进程 execution epoch。 -本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;`run.ModelRequest` 冻结完整 generation options,streaming 是 `run.ModelInvoker` 的可选执行路径且保持 Run command/event 语义。Run Machine 采用 Decide/Evolve 拆分:Decide 承载决策并在提交时产出事实,Evolve 是机械、版本内稳定的折叠;MachineState 为 Run execution authority,`RunHeader + TransitionRecord log` 为 canonical record。Create 原子保存 header 与 Revision-0 state;Commit 原子保存新 state 与 transition;分歧属实现缺陷并由运维处理(§5.1)。结构性 malformed 模型结果通过 `RejectModelResult` disposition 在同一冻结 request 上重试或失败;fixed model/limits 留在 Application;usage 在 MachineState 内逐字段累计;steer 由 Application queue-safe admission gate 保证进入下一个 ModelStep;计划内停机使用排空,Unknown 语义覆盖崩溃和 lease 失效。 +本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;`run.ModelRequest` 冻结完整 generation options,streaming 是 `loop.StreamingModelInvoker` 的可选执行路径且保持 Run command/event 语义。Run Machine 采用 Decide/Evolve 拆分:Decide 承载决策并在提交时产出事实,Evolve 是机械、版本内稳定的折叠;MachineState 为 Run execution authority,`RunHeader + TransitionRecord log` 为 canonical record。Create 原子保存 header 与 Revision-0 state;Commit 原子保存新 state 与 transition;分歧属实现缺陷并由运维处理(§5.1)。结构性 malformed 模型结果通过 `RejectModelResult` disposition 在同一冻结 request 上重试或失败;fixed model/limits 留在 Application;usage 在 MachineState 内逐字段累计;steer 由 Application queue-safe admission gate 保证进入下一个 ModelStep;计划内停机使用排空,Unknown 语义覆盖崩溃和 lease 失效。 本规范采用 MachineState 为 Run execution authority、`RunHeader + TransitionRecord log` 为 canonical record(§5.1):稳定条件(ontology 冻结、versioned Evolve 版本内稳定、事实自包含)由 Decide/Evolve 拆分保障,TransitionDigest 保护单个 transition 内部的完整事件组。恢复执行走 `Load(ctx, runID)`;`FoldRun` 服务导入/迁移与诊断。重新收紧为日志权威的触发条件(执行历史 fork 成为产品功能,或状态存储不可信而日志存储可信的部署形态)记录于 §5.1。跨 Run 语义不从旧 Run log 读取,而由 Session ES、artifact、memory/context projection 构造。 @@ -2078,6 +2085,7 @@ func FreezeModelRequest(sdk.Request) (ModelRequest, error) func (ModelRequest) SDK() (sdk.Request, error) func FreezeModelResult(sdk.ModelResult) (ModelResult, error) func (ModelResult) SDK() (sdk.ModelResult, error) +func FreezeToolCallInput(any) (CanonicalJSON, error) func FreezeToolDefinition(sdk.ToolDefinition) (ToolDefinition, error) func (ToolDefinition) SDK() sdk.ToolDefinition @@ -2167,6 +2175,8 @@ type ToolFailure struct { Message string } +type ToolExecutionResult struct { Output CanonicalJSON } + type ToolFailureOutcome uint8 const ( @@ -2192,13 +2202,6 @@ const ( ExternalResponse ) -// ExecutionPolicy is host-owned Loop policy and is not persisted in MachineState. -type ExecutionPolicy struct { - MaxParallel int - ModelStepLimit int // 0 unlimited; Loop submits StopRun(step_limit) - MalformedModelResultLimit int // 0 default; Loop chooses reject disposition -} - type ResponseRequest struct { RunID RunID StepID StepID @@ -2504,18 +2507,6 @@ type PlanningHint struct { Inputs []AgentInput } -type RequestPlanner interface { - Plan(context.Context, PlanningHint) (RequestPlan, error) -} - -type RequestPlan struct { - Model ModelRef - Request sdk.Request // boundary value; Loop freezes before PrepareModelRequest - InputIDs []InputID - PlanningToken PlanningToken - Tools []ToolSpec -} - type Runtime interface { Create(context.Context, NewRun) (CreateResult, error) Load(context.Context, RunID) (RuntimeSnapshot, error) @@ -2641,122 +2632,134 @@ var ErrCommandConflict = errors.New("agent: command identity conflict") var ErrStaleRuntime = errors.New("agent: stale runtime version or grant") var ErrRunTerminal = errors.New("agent: run is terminal") -type ModelCatalog interface { Resolve(ModelRef) (ModelInvoker, error) } +``` + +`agent/run/loop` 的 public execution API 为: + +```go +package loop + +import ( + "context" + "encoding/json" + + run "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/sdk" +) -type ModelInvoker interface { - Generate(context.Context, sdk.Request) (sdk.ModelResult, error) +type ExecutionPolicy struct { + MaxParallel int + ModelStepLimit int + MalformedModelResultLimit int +} + +type RequestPlanner interface { + Plan(context.Context, run.PlanningHint) (RequestPlan, error) } -// Optional optimization. It must produce the same final ModelResult as Generate. -type StreamingModelInvoker interface { - Stream(context.Context, sdk.Request) (sdk.ModelStream, error) +type RequestPlan struct { + Model run.ModelRef + Request sdk.Request + InputIDs []run.InputID + PlanningToken run.PlanningToken + Tools []run.ToolSpec } -type ToolCatalog interface { Resolve(ToolRef) (ExecutableTool, error) } +type ModelCatalog interface { Resolve(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 { Resolve(run.ToolRef) (ExecutableTool, error) } type ToolExecutionRequest struct { - RunID RunID - StepID StepID - CallID CallID - ToolRef ToolRef - DefinitionDigest Digest - Arguments CanonicalJSON + RunID run.RunID + StepID run.StepID + CallID run.CallID + ToolRef run.ToolRef + DefinitionDigest run.Digest + Arguments run.CanonicalJSON Progress ToolProgressSink } type ExecutableTool interface { - Ref() ToolRef + Ref() run.ToolRef Definition() sdk.ToolDefinition - ResponsePolicy() ResponsePolicy - ValidateArguments(CanonicalJSON) error + ResponsePolicy() run.ResponsePolicy + ValidateArguments(run.CanonicalJSON) error Execute(context.Context, ToolExecutionRequest) ToolExecutionOutcome } -type ToolExecutionResult struct { Output CanonicalJSON } - type ToolExecutionOutcome interface { toolExecutionOutcome() } - -type ToolExecutionSucceeded struct { Result ToolExecutionResult } +type ToolExecutionSucceeded struct { Result run.ToolExecutionResult } func (ToolExecutionSucceeded) toolExecutionOutcome() {} - -type ToolExecutionFailed struct { Failure ToolFailure } +type ToolExecutionFailed struct { Failure run.ToolFailure } func (ToolExecutionFailed) toolExecutionOutcome() {} - -type ToolExecutionUnknown struct { Failure ToolFailure } +type ToolExecutionUnknown struct { Failure run.ToolFailure } func (ToolExecutionUnknown) toolExecutionOutcome() {} type ToolProgressSink interface { Publish(context.Context, ToolProgress) } type ToolProgress struct { Payload json.RawMessage } type EventSink interface { Emit(context.Context, Event) error } - type Event struct { - RunID RunID - StepID StepID - CallID CallID + RunID run.RunID + StepID run.StepID + CallID run.CallID Sequence uint64 Kind EventKind Durability EventDurability Payload json.RawMessage - Canonical *AgentEvent // set for a committed observation; nil for provisional + Canonical *run.AgentEvent } type EventDurability uint8 - -const ( - EventProvisional EventDurability = iota - EventCommitted -) +const ( EventProvisional EventDurability = iota; EventCommitted ) type EventKind string - const ( EventAgentCommitted EventKind = "agent_committed" EventModelTextDelta EventKind = "model_text_delta" - EventToolProgress EventKind = "tool_progress" - EventToolStarted EventKind = "tool_started" - EventToolCompleted EventKind = "tool_completed" - EventRunFinished EventKind = "run_finished" + EventModelReasoningDelta EventKind = "model_reasoning_delta" + EventToolProgress EventKind = "tool_progress" + EventToolStarted EventKind = "tool_started" + EventToolCompleted EventKind = "tool_completed" + EventRunFinished EventKind = "run_finished" ) type LoopDisposition uint8 - -const ( - LoopWaiting LoopDisposition = iota - LoopFinished -) +const ( LoopWaiting LoopDisposition = iota; LoopFinished ) type WaitReason string - const ( - WaitingForResponse WaitReason = "waiting_for_response" - ExecutionRecovery WaitReason = "execution_recovery" // 当前执行仍在运行或等待失效恢复 + WaitingForResponse WaitReason = "waiting_for_response" + ExecutionRecovery WaitReason = "execution_recovery" ) type LoopResult struct { Disposition LoopDisposition Reason WaitReason - Waiting []ResponseRequest - Result *RunResult + Waiting []run.ResponseRequest + Result *run.RunResult } type Loop struct { - Models ModelCatalog - Tools ToolCatalog - Planner RequestPlanner + Models ModelCatalog + Tools ToolCatalog + Planner RequestPlanner Execution ExecutionPolicy Streaming bool } -func (l *Loop) Run(context.Context, Runtime, RunID, EventSink) (LoopResult, error) +func New(ModelCatalog, ToolCatalog, RequestPlanner, ExecutionPolicy, bool) (*Loop, error) +func (l *Loop) Run(context.Context, run.Runtime, run.RunID, EventSink) (LoopResult, error) ``` -实现必须保证所有返回的 Step、Call、Request、Result 和等待 payload 具有只读快照语义;调用方不能通过修改 slice、map 或 JSON bytes view 改变 Runtime 状态。`AgentCommand`、`Fact`、`Effect` 和 ToolExecutionOutcome 使用 agent 的 sealed interface,外部实现不能添加未定义变体。构造 CommandEnvelope 与派生 CommandID/ResponseID 只能通过 agent 提供的 typed 构造函数;手工拼装信封字段属于实现错误。 +实现必须保证所有返回的 Step、Call、Request、Result 和等待 payload 具有只读快照语义;调用方不能通过修改 slice、map 或 JSON bytes view 改变 Runtime 状态。`AgentCommand`、`Fact`、`Effect` 使用 `agent/run` 的 sealed interface;ToolExecutionOutcome 使用 `agent/run/loop` 的 sealed interface,外部实现不能添加未定义变体。构造 CommandEnvelope 与派生 CommandID/ResponseID 只能通过 agent 提供的 typed 构造函数;手工拼装信封字段属于实现错误。 ## 附录 B:核心不变量 1. sdk 的一次 `Generate` 或 `Stream` 对应一次 provider request;transport retry 不创建新的 Step。 -2. `agent/run.Loop` 是唯一的 Run 多步执行算法;Run 的权威状态由 Runtime 持有,Loop 不保存第二份。 +2. `agent/run/loop.Loop` 是唯一的 Run 多步执行算法;Run 的权威状态由 Runtime 持有,Loop 不保存第二份。 3. Runtime 公开 `Create`、`Load`、`Commit` 和 `Record`;Planner、queue 和工具入口不进入 Runtime contract。 4. Machine 是完整的 Run/Step/ToolCall 语义规则;决策只在 `Decide` 中、只在提交时运行一次,`Evolve` 是机械折叠。Runtime 通过共享 `EvaluateCommit` 调用它们,不复刻规则。 5. Step 是 durable resume boundary,只有 ModelStep 和 ToolStep;ToolCall 是 ToolStep 内的 progress。 diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index 222c752..782f4bb 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -91,6 +91,7 @@ func DigestStartOperation( ## 3. API 与 authority boundary ```go +// run is github.com/memohai/twilight/agent/run. type Coordinator struct { Sessions session.Store Appender extension.SemanticAppender @@ -99,7 +100,7 @@ type Coordinator struct { Mapper FactMapper } type DriveRequest struct { Ref TurnRef; RunID run.RunID } -type RunDriver interface { Drive(context.Context, DriveRequest) (run.LoopResult, error) } +type RunDriver interface { Drive(context.Context, DriveRequest) error } type ExecutionBindingRegistry interface { Resolve(ExecutionBindingRef) (RunDriver, error) } ``` @@ -201,7 +202,7 @@ driver.Drive(ctx, DriveRequest{Ref: ref, RunID: linkage.RunID}) 无论 driver 返回完成、等待、error,Coordinator 均再次 Record、调用 `MaterializeAll(record)` 补齐完整新前缀,并据 record snapshot/terminal fact 返回 Waiting 或 Result。 -**TRN-RSM-6** driver 的 provisional stream、LoopResult、网络响应丢失、context cancel 作为观察结果处理。driver 以 shared Runtime 的 Load/Commit 推进 MachineState;并发 Resume 依 Runtime command idempotency、revision、grant、record 一致性收敛。 +**TRN-RSM-6** driver 的 provisional stream、返回错误、网络响应丢失、context cancel 作为观察结果处理。driver 以 shared Runtime 的 Load/Commit 推进 MachineState;并发 Resume 依 Runtime command idempotency、revision、grant、record 一致性收敛。具体执行器可以使用 `agent/run/loop.Loop`,其 `LoopResult` 保持在 Application driver 内;Coordinator 始终以随后读取的 `RunRecord` 判断 waiting、terminal 与 settlement。 **TRN-STP-1** Stop 先解析 linkage、Load。terminal Run 进入 record/materialize/settlement;active Run 以稳定 domain-separated CancelRun CommandID 构造 `CancelRun{Reason:ReasonCancelled}`,并经 shared Runtime Commit。 From 1db685d4409dad7e35ada47bff7aa6b1c89262de Mon Sep 17 00:00:00 2001 From: Fodesu Date: Mon, 31 Aug 2026 18:24:57 +0800 Subject: [PATCH 35/81] refactor agent run machine and runtime --- agent/run/canonical.go | 8 +- agent/run/canonical_test.go | 16 +- agent/run/clone.go | 13 +- agent/run/codec_test.go | 32 +- agent/run/command.go | 17 +- agent/run/commit.go | 50 +- agent/run/decide.go | 119 +- agent/run/evolve.go | 263 ++- agent/run/fact.go | 157 +- agent/run/fold.go | 23 +- agent/run/fold_test.go | 14 +- agent/run/header.go | 27 +- agent/run/header_test.go | 4 +- agent/run/ids.go | 15 +- agent/run/loop/contract.go | 73 +- agent/run/loop/loop.go | 538 ++++- agent/run/loop/loop_test.go | 227 +- agent/run/machine_test.go | 63 +- agent/run/memory_conformance_test.go | 2 +- agent/run/memory_runtime.go | 113 +- agent/run/next.go | 24 +- agent/run/protocol.go | 13 +- agent/run/regression_test.go | 5 +- agent/run/runtime.go | 13 +- agent/run/runtime_helpers_test.go | 19 + agent/run/runtimetest/conformance.go | 74 +- agent/run/state.go | 150 +- agent/run/transition.go | 3 + docs/design/agent-run.md | 455 ++++ docs/design/agent-runtime-refactor.md | 2831 +------------------------ docs/design/agent-turn.md | 47 +- sdk/generate_text.go | 3 +- sdk/model_result.go | 7 +- sdk/model_stream.go | 4 +- sdk/request.go | 2 +- sdk/stream_text.go | 4 +- 36 files changed, 2299 insertions(+), 3129 deletions(-) create mode 100644 docs/design/agent-run.md diff --git a/agent/run/canonical.go b/agent/run/canonical.go index c56705c..8ad5c8d 100644 --- a/agent/run/canonical.go +++ b/agent/run/canonical.go @@ -1,8 +1,8 @@ -// Package agent implements the Twilight AI agent runtime core: the Machine -// (Decide/Evolve/Next), the Loop, the Runtime contract with EvaluateCommit, -// and the canonical encoding that gives commands and facts stable identity. +// Package run implements the Twilight Run Machine, persisted protocol, +// Runtime authority boundary, verified fold, and canonical identities. // -// See docs/design/agent-runtime-refactor.md for the governing spec. +// 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 ( diff --git a/agent/run/canonical_test.go b/agent/run/canonical_test.go index 0ed266e..e704874 100644 --- a/agent/run/canonical_test.go +++ b/agent/run/canonical_test.go @@ -95,7 +95,7 @@ func TestCanonicalDeterminism(t *testing.T) { } func TestDigestCommandIdentity(t *testing.T) { - cmd := StartToolCall{StepID: "s1", CallID: "c1"} + cmd := StartToolCall{StepID: "s1", CallID: "c1", Claim: "claim-1"} d1, err := DigestCommand(SchemaVersion1, "start_tool_call", cmd) if err != nil { t.Fatal(err) @@ -104,12 +104,12 @@ func TestDigestCommandIdentity(t *testing.T) { t.Fatalf("bad digest wire form: %s", d1) } // Same content, same digest. - d2, _ := DigestCommand(SchemaVersion1, "start_tool_call", StartToolCall{StepID: "s1", CallID: "c1"}) + d2, _ := DigestCommand(SchemaVersion1, "start_tool_call", StartToolCall{StepID: "s1", CallID: "c1", Claim: "claim-1"}) if d1 != d2 { t.Fatal("same command produced different digests") } // Different content differs. - d3, _ := DigestCommand(SchemaVersion1, "start_tool_call", StartToolCall{StepID: "s1", CallID: "c2"}) + d3, _ := DigestCommand(SchemaVersion1, "start_tool_call", StartToolCall{StepID: "s1", CallID: "c2", Claim: "claim-1"}) if d1 == d3 { t.Fatal("different commands produced the same digest") } @@ -187,9 +187,9 @@ func TestDigestBindingCanonicalizesArguments(t *testing.T) { } } -// Golden vectors: these bytes are frozen for SchemaVersion 1. If this test -// fails, the canonical encoding changed — that is a protocol break, not a -// test to update. +// 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) @@ -204,8 +204,8 @@ func TestSchemaVersion1Golden(t *testing.T) { if err != nil { t.Fatal(err) } - // Frozen for SchemaVersion 1. A mismatch here is a protocol break that - // invalidates every persisted digest — fix the code, not this constant. + // Current pre-release fixture. After publication, a mismatch is a protocol + // break that invalidates persisted digests and must not update this value. const wantDigest = "sha256:a7770a5443f180ec1935bfa4498af75375b8d5f182f239f587917b28b78ee80c" if string(d) != wantDigest { t.Fatalf("golden digest changed:\n got %s\nwant %s", d, wantDigest) diff --git a/agent/run/clone.go b/agent/run/clone.go index 49e961d..f27eb8d 100644 --- a/agent/run/clone.go +++ b/agent/run/clone.go @@ -306,11 +306,20 @@ func cloneStep(s Step) Step { } } +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 = cloneStep(out.Current) } + out.LastToolStep = cloneToolStepPtr(out.LastToolStep) out.PendingInputs = cloneAgentInputs(out.PendingInputs) out.LastModelResult = cloneModelResult(out.LastModelResult) out.Result = cloneRunResult(out.Result) @@ -373,10 +382,6 @@ func cloneFact(f Fact) Fact { fact.Input = cloneAgentInput(fact.Input) return fact case RunEnded: - if fact.Failure != nil { - f := *fact.Failure - fact.Failure = &f - } return fact default: return f diff --git a/agent/run/codec_test.go b/agent/run/codec_test.go index fc87d56..43671b0 100644 --- a/agent/run/codec_test.go +++ b/agent/run/codec_test.go @@ -11,19 +11,18 @@ import ( func TestCommandEnvelopeJSONRoundTripRestoresVariants(t *testing.T) { commands := []AgentCommand{ PrepareModelRequest{StepID: "s", Model: "m", Request: ModelRequest{Model: "m"}, RequestDigest: "sha256:req", ToolsDigest: "sha256:tools"}, - StartModelExecution{StepID: "s"}, - RecoverModelExecution{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"}, + 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{}, - StopRun{Reason: ReasonStepLimit}, AcceptInput{Input: AgentInput{ID: "in", Payload: cj(`{"q":"hi"}`)}}, } for _, cmd := range commands { @@ -63,7 +62,7 @@ func TestAgentEventJSONRoundTripRestoresVariants(t *testing.T) { ToolCallFailed{StepID: "ts", CallID: "c", Failure: ToolFailure{Class: FailureExecution}, Outcome: ToolOutcomeKnown}, ToolStepClosed{StepID: "ts"}, InputAccepted{Input: AgentInput{ID: "in", Payload: cj(`{"q":"hi"}`)}}, - RunEnded{Status: RunCompleted}, + RunEnded{End: RunCompletedEnd{}}, } for i, fact := range facts { typ := factType(fact) @@ -102,7 +101,7 @@ func TestAgentEventJSONRoundTripRestoresVariants(t *testing.T) { func TestTransitionRecordJSONRoundTripRestoresVariants(t *testing.T) { facts := []Fact{ ModelStepCompleted{StepID: "s", Result: ModelResult{Text: "ok"}}, - RunEnded{Status: RunCompleted}, + RunEnded{End: RunCompletedEnd{}}, } events := make([]AgentEvent, len(facts)) for i, fact := range facts { @@ -190,7 +189,7 @@ func TestWireCodecRejectsUnknownTypeAndDigestMismatch(t *testing.T) { t.Fatal("bad command digest decoded") } - fact := RunEnded{Status: RunCompleted} + fact := RunEnded{End: RunCompletedEnd{}} digest, err := DigestFact(currentSchemaVersion, factType(fact), fact) if err != nil { t.Fatal(err) @@ -205,3 +204,22 @@ func TestWireCodecRejectsUnknownTypeAndDigestMismatch(t *testing.T) { t.Fatal("bad fact digest 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 := DigestFact(currentSchemaVersion, "run_ended", fact); err == nil { + t.Fatal("invalid tagged terminal value was accepted") + } + }) + } +} + +type fakeRunEnd struct{} + +func (fakeRunEnd) runEnd() {} diff --git a/agent/run/command.go b/agent/run/command.go index 24ab996..174bca2 100644 --- a/agent/run/command.go +++ b/agent/run/command.go @@ -1,7 +1,7 @@ package run // AgentCommand is the intent submitted through Runtime.Commit for an existing -// Run. Accepting one command constitutes one transition (spec §3.6). The +// Run. Accepting one command constitutes one transition (RUN-MCH-3). The // interface is sealed: only the fifteen variants below exist. type AgentCommand interface{ agentCommand() } @@ -48,6 +48,9 @@ func (PrepareModelRequest) 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() {} @@ -58,6 +61,9 @@ func (StartModelExecution) agentCommand() {} // 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() {} @@ -109,6 +115,8 @@ func (RejectModelResult) agentCommand() {} 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() {} @@ -172,16 +180,15 @@ type SubmitToolResponse struct { func (SubmitToolResponse) agentCommand() {} // CancelRun stops a non-terminal Run as a business cancellation. Hosts must -// commit this before cancelling the Loop's context (spec §6.6). +// commit this before cancelling the Loop's context (RUN-LOP-5). type CancelRun struct { Reason RunReason `json:"reason,omitempty"` } func (CancelRun) agentCommand() {} -// StopRun records a host-owned non-cancellation stop policy. It exists so -// compatibility wrappers can keep max-step behavior without storing limits in -// MachineState. +// StopRun is retained for compatibility with hosts that explicitly configure +// a non-cancellation stop policy. New code normally uses CancelRun. type StopRun struct { Reason RunReason `json:"reason"` } diff --git a/agent/run/commit.go b/agent/run/commit.go index fa03795..70490f4 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -17,7 +17,7 @@ const ( // CommitDecision is EvaluateCommit's verdict. Runtime.Commit maps rejections // onto the sentinel errors: Conflict -> ErrCommandConflict, Stale -> -// ErrStaleRuntime, Terminal -> ErrRunTerminal (spec §5.4). +// ErrStaleRuntime, Terminal -> ErrRunTerminal (RUN-CMT-6). type CommitDecision struct { Kind DecisionKind NewState MachineState @@ -27,8 +27,8 @@ type CommitDecision struct { Reject error } -// commandCategory classifies a command for BaseRevision handling (spec §5.4 -// step 4). PrepareModelRequest is the only hard-CAS command. +// commandCategory classifies a command for BaseRevision handling (RUN-CMT-3). +// PrepareModelRequest is the only hard-CAS command. type commandCategory uint8 const ( @@ -65,7 +65,7 @@ func categorize(c AgentCommand) commandCategory { } // requiresGrant reports whether this command must carry the start grant of -// its target, given the current state (spec §5.3 grant rules). +// its target, given the current state (RUN-CMT-5). func requiresGrant(s *MachineState, c AgentCommand) bool { switch cmd := c.(type) { case SubmitModelResult, SubmitModelFailure, RejectModelResult: @@ -91,7 +91,7 @@ func requiresGrant(s *MachineState, c AgentCommand) bool { } // EvaluateCommit is the single, pure commit evaluation both runtimes call -// inside their own critical section (spec §5.4). grantValid and recoveryValid +// inside their own critical section (RUN-CMT-3). grantValid and recoveryValid // are the control-plane verdicts the Runtime supplies: whether req.Grant is // the live grant for the command's target, and whether a grantless recovery // command matches the Runtime's own lease-expiry record. @@ -123,13 +123,22 @@ func EvaluateCommit( if env.Digest != wantDigest { return CommitDecision{Kind: DecisionConflict, Reject: fmt.Errorf("agent: commit: envelope digest mismatch")}, nil } - // Derived-identity families must use their derived CommandID (spec §5.5): - // the derivation IS the idempotency index for inputs/responses/planning, so - // a caller-minted random ID would silently bypass duplicate detection. - if err := checkDerivedCommandID(&env, req.BaseRevision); err != nil { - return CommitDecision{}, err + // A start claim is part of the command identity. Rejecting an empty claim + // here prevents an unbound worker from acquiring execution ownership. + switch cmd := env.Command.(type) { + case StartModelExecution: + if cmd.Claim == "" { + return CommitDecision{Kind: DecisionConflict, Reject: fmt.Errorf("agent: commit: model start requires an execution claim")}, nil + } + case StartToolCall: + if cmd.Claim == "" { + return CommitDecision{Kind: DecisionConflict, Reject: fmt.Errorf("agent: commit: tool start requires an execution claim")}, nil + } + case RecoverModelExecution: + if cmd.Claim == "" { + return CommitDecision{Kind: DecisionConflict, Reject: fmt.Errorf("agent: commit: model recovery requires an execution claim")}, nil + } } - // Steps 2-3: idempotent replay and identity conflict. if prior != nil { if prior.CommandDigest == env.Digest { @@ -137,6 +146,15 @@ func EvaluateCommit( } return CommitDecision{Kind: DecisionConflict, Reject: ErrCommandConflict}, nil } + // Derived-identity families must use their derived CommandID (RUN-WIR-3): + // the derivation is the idempotency index for inputs/responses/planning, so + // a caller-minted random ID cannot bypass duplicate detection. This check is + // deliberately after exact replay: BaseRevision is not part of the command + // digest, and a retry may have reloaded a newer snapshot before replaying an + // already accepted prepare command. + if err := checkDerivedCommandID(&env, req.BaseRevision); err != nil { + return CommitDecision{Kind: DecisionConflict, Reject: err}, nil + } // Step 7 precheck: terminal absorbs non-duplicate commands. if cur.Status.Terminal() { @@ -227,7 +245,7 @@ func EvaluateCommit( // BuildEnvelope assembles a CommandEnvelope with its type discriminator and // canonical digest. This is the only sanctioned construction path; callers -// never hand-assemble envelope fields (spec §6.2). +// never hand-assemble envelope fields (RUN-WIR-3). func BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) { typ := commandType(cmd) if typ == "" { @@ -247,7 +265,7 @@ func BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, }, nil } -// checkDerivedCommandID enforces the derived-identity rules of spec §5.5. +// checkDerivedCommandID enforces the derived-identity rules of RUN-WIR-3. // AcceptInput derives from (RunID, InputID); approval/rejection/answer derive // from (RunID, StepID, CallID, ResponseID). Approve and reject of the same // response share one identity by design, so a decision change surfaces as @@ -265,10 +283,14 @@ func checkDerivedCommandID(env *CommandEnvelope, baseRevision uint64) error { want = DeriveResponseCommandID(env.RunID, cmd.StepID, cmd.CallID, cmd.ResponseID) case SubmitToolResponse: want = DeriveResponseCommandID(env.RunID, cmd.StepID, cmd.CallID, cmd.ResponseID) + case RecoverModelExecution: + if cmd.Claim != "" { + want = DeriveModelRecoveryCommandID(env.RunID, cmd.StepID, cmd.Claim) + } default: return nil } - if env.ID != want { + if want != "" && env.ID != want { return fmt.Errorf("agent: commit: %s requires its derived CommandID", env.Type) } return nil diff --git a/agent/run/decide.go b/agent/run/decide.go index 56d2b5b..4391c58 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -21,7 +21,7 @@ func rejectionf(format string, args ...any) error { } // Decide validates one command against the current state and produces the -// complete fact sequence of its transition (spec §3.7.1). All decisions — +// complete fact sequence of its transition (RUN-MCH-3). All decisions — // acceptance, derived consequences, terminal transitions — happen here, run // exactly once per accepted command; the output is frozen. Any precondition // failure rejects the whole command with no partial facts. @@ -159,7 +159,7 @@ func decideStartModelExecution(s *MachineState, cmd StartModelExecution) ([]Fact if ms.Status != ModelPrepared { return nil, rejectionf("start model: step is not Prepared") } - return []Fact{ModelStepStarted(cmd)}, nil + return []Fact{ModelStepStarted{StepID: cmd.StepID}}, nil } func decideRecoverModelExecution(s *MachineState, cmd RecoverModelExecution) ([]Fact, error) { @@ -170,7 +170,7 @@ func decideRecoverModelExecution(s *MachineState, cmd RecoverModelExecution) ([] if ms.Status != ModelExecuting { return nil, rejectionf("recover model: step is not Executing") } - return []Fact{ModelStepRecovered(cmd)}, nil + return []Fact{ModelStepRecovered{StepID: cmd.StepID}}, nil } // --- rule 3: SubmitModelResult --- @@ -192,7 +192,7 @@ func decideSubmitModelResult(s *MachineState, cmd *SubmitModelResult) ([]Fact, e if len(cmd.Calls) != 0 { return nil, rejectionf("model result: %d bindings for a result with no tool calls", len(cmd.Calls)) } - return []Fact{completed, RunEnded{Status: RunCompleted}}, nil + return []Fact{completed, RunEnded{End: RunCompletedEnd{}}}, nil } // Validate bindings against the frozen ToolSpecs and the model result, @@ -222,7 +222,7 @@ func decideSubmitModelResult(s *MachineState, cmd *SubmitModelResult) ([]Fact, e // Cross-check the binding against the model result: the authority // accepts only bindings for the tool the model actually named, with - // the arguments the model actually produced (spec §4.1). + // the arguments the model actually produced (RUN-MCH-2). if spec, known := specByName[rc.ToolName]; known { if b.ToolRef != spec.Ref { return nil, 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) @@ -236,7 +236,7 @@ func decideSubmitModelResult(s *MachineState, cmd *SubmitModelResult) ([]Fact, e } else { // Unknown ToolRef stays an unresolved DirectExecution binding with // an empty definition digest; StartToolCalls records the lookup - // failure (spec §4.1). + // failure (RUN-MCH-2). if string(b.ToolRef) != rc.ToolName { return nil, rejectionf("model result: binding %q ToolRef %q does not match result tool %q", b.CallID, b.ToolRef, rc.ToolName) } @@ -321,11 +321,10 @@ func decideSubmitModelFailure(s *MachineState, cmd SubmitModelFailure) ([]Fact, if cmd.Failure.Class == "" { return nil, rejectionf("model failure: empty failure class") } - return []Fact{RunEnded{ - Status: RunFailed, + return []Fact{RunEnded{End: RunFailedEnd{ Reason: ReasonProviderFailure, - Failure: &RunFailure{Class: cmd.Failure.Class, Message: cmd.Failure.Message}, - }}, nil + Failure: RunFailure{Class: cmd.Failure.Class, Message: cmd.Failure.Message}, + }}}, nil } // --- rule 5: RejectModelResult --- @@ -343,11 +342,10 @@ func decideRejectModelResult(s *MachineState, cmd *RejectModelResult) ([]Fact, e case ModelRejectRetry: return []Fact{rejected}, nil case ModelRejectFailRun: - return []Fact{rejected, RunEnded{ - Status: RunFailed, + return []Fact{rejected, RunEnded{End: RunFailedEnd{ Reason: ReasonMalformedModel, - Failure: &RunFailure{Class: FailureMalformedModel, Message: cmd.Failure.Message}, - }}, nil + Failure: RunFailure{Class: FailureMalformedModel, Message: cmd.Failure.Message}, + }}}, nil default: return nil, rejectionf("reject model result: unknown disposition %d", cmd.Disposition) } @@ -378,7 +376,7 @@ func decideStartToolCall(s *MachineState, cmd StartToolCall) ([]Fact, error) { if ts.Calls[i].Status != ToolPending { return nil, rejectionf("start tool: call %q is not Pending", cmd.CallID) } - return []Fact{ToolCallStarted(cmd)}, nil + return []Fact{ToolCallStarted{StepID: cmd.StepID, CallID: cmd.CallID}}, nil } func decideSubmitToolResult(s *MachineState, cmd SubmitToolResult) ([]Fact, error) { @@ -394,7 +392,7 @@ func decideSubmitToolResult(s *MachineState, cmd SubmitToolResult) ([]Fact, erro return nil, rejectionf("tool result: call %q is not Executing", cmd.CallID) } facts := []Fact{ToolCallCompleted(cmd)} - return appendCloseIfLast(s, ts, i, facts) + return facts, nil } func decideSubmitToolFailure(s *MachineState, cmd SubmitToolFailure) ([]Fact, error) { @@ -407,13 +405,20 @@ func decideSubmitToolFailure(s *MachineState, cmd SubmitToolFailure) ([]Fact, er 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 appendCloseIfLast(s, ts, i, facts) + return facts, nil case ToolOutcomeUnknown: if call.Status != ToolExecuting { return nil, rejectionf("tool failure: unknown outcome requires Executing call") @@ -425,37 +430,17 @@ func decideSubmitToolFailure(s *MachineState, cmd SubmitToolFailure) ([]Fact, er 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}, - RunEnded{ - Status: RunFailed, - Reason: ReasonEffectUnknown, - Failure: &RunFailure{Class: FailureEffectUnknown, Message: failure.Message, CallID: cmd.CallID}, - }, - }, nil + facts := unknownExecutingCalls(s, cmd.StepID, cmd.CallID, failure) + facts = append(facts, RunEnded{End: RunFailedEnd{ + Reason: ReasonEffectUnknown, + Failure: RunFailure{Class: FailureEffectUnknown, Message: failure.Message, CallID: cmd.CallID}, + }}) + return facts, nil default: return nil, rejectionf("tool failure: unknown outcome value %d", cmd.Outcome) } } -// appendCloseIfLast appends ToolStepClosed when call i reaching a closable -// terminal state closes the step. -func appendCloseIfLast(_ *MachineState, ts *ToolStep, i int, facts []Fact) ([]Fact, error) { - for j := range ts.Calls { - if j == i { - continue - } - switch ts.Calls[j].Status { - case ToolCompleted, ToolFailed: - // closable terminal - default: - return facts, nil - } - } - facts = append(facts, ToolStepClosed{StepID: ts.RefValue.ID}) - return facts, nil -} - // --- rules 9-10: responses --- func waitingCall(s *MachineState, step StepID, call CallID, kind ResponseKind, resp ResponseID) (*ToolStep, int, error) { @@ -528,11 +513,11 @@ func decideRejectToolCall(s *MachineState, cmd *RejectToolCall) ([]Fact, error) Failure: ToolFailure{Class: FailurePermissionDenied, Message: cmd.Reason}, Outcome: ToolOutcomeKnown, }} - return appendCloseIfLast(s, ts, i, facts) + return facts, nil } func decideSubmitToolResponse(s *MachineState, cmd *SubmitToolResponse) ([]Fact, error) { - ts, i, err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseExternal, cmd.ResponseID) + _, _, err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseExternal, cmd.ResponseID) if err != nil { return nil, err } @@ -544,25 +529,55 @@ func decideSubmitToolResponse(s *MachineState, cmd *SubmitToolResponse) ([]Fact, return nil, rejectionf("response: answer payload digest mismatch") } facts := []Fact{ToolCallAnswered(*cmd)} - return appendCloseIfLast(s, ts, i, facts) + return facts, nil } -// --- rules 12-14: cancel, stop and input --- +// --- rules 12-14: cancel, compatibility stop and input --- -func decideCancelRun(_ *MachineState, cmd CancelRun) ([]Fact, error) { +func decideCancelRun(s *MachineState, cmd CancelRun) ([]Fact, error) { // CancelRun always records RunStopped(cancelled). Host/system stops use // StopRun so cancellation cannot forge system reasons. if cmd.Reason != "" && cmd.Reason != ReasonCancelled { return nil, rejectionf("cancel: reason must be empty or %q", ReasonCancelled) } - return []Fact{RunEnded{Status: RunStopped, Reason: ReasonCancelled}}, nil + facts := unknownExecutingCalls(s, "", "", ToolFailure{Class: FailureEffectUnknown, Message: "execution cancelled before settlement"}) + facts = append(facts, RunEnded{End: RunStoppedEnd{Reason: ReasonCancelled}}) + return facts, nil } -func decideStopRun(_ *MachineState, cmd StopRun) ([]Fact, error) { +// unknownExecutingCalls records every started effect that will be abandoned by +// a terminal transition. The target is optional for cancellation; all current +// Executing calls are included exactly once in call order. +func unknownExecutingCalls(s *MachineState, stepID StepID, target CallID, failure ToolFailure) []Fact { + ts, ok := s.Current.(ToolStep) + if !ok || (stepID != "" && ts.RefValue.ID != stepID) { + return nil + } + facts := make([]Fact, 0, len(ts.Calls)) + for _, call := range ts.Calls { + if call.Status != ToolExecuting { + continue + } + f := failure + if target != "" && call.CallID != target { + f.Message = "sibling execution result unknown after terminal transition" + } + facts = append(facts, ToolCallFailed{StepID: ts.RefValue.ID, CallID: call.CallID, Failure: f, Outcome: ToolOutcomeUnknown}) + } + return facts +} + +func decideStopRun(s *MachineState, cmd StopRun) ([]Fact, error) { if cmd.Reason != ReasonStepLimit { - return nil, rejectionf("stop: reason must be %q", ReasonStepLimit) + return nil, rejectionf("stop: compatibility reason must be %q", ReasonStepLimit) + } + // StopRun is the legacy host-policy command. It is accepted at a safe + // boundary after the current Step has settled; an executing effect keeps its + // own settlement/recovery path and cannot be silently discarded here. + if s.Current != nil { + return nil, rejectionf("stop: run has a current step") } - return []Fact{RunEnded{Status: RunStopped, Reason: cmd.Reason}}, nil + return []Fact{RunEnded{End: RunStoppedEnd{Reason: cmd.Reason}}}, nil } func decideAcceptInput(s *MachineState, cmd AcceptInput) ([]Fact, error) { diff --git a/agent/run/evolve.go b/agent/run/evolve.go index 597e0b8..28e18e9 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -1,6 +1,9 @@ package run -import "fmt" +import ( + "errors" + "fmt" +) // Evolve folds one fact with the current schema version. Persisted replay uses // EvolveVersion so future schema versions can keep their historical folding @@ -24,10 +27,13 @@ func EvolveVersion(schemaVersion uint16, s MachineState, f Fact) (MachineState, } } -// evolveV1 is the frozen fold semantics for SchemaVersion1. +// evolveV1 is the current fold semantics for the pre-release SchemaVersion1. // //nolint:gocritic // hugeParam: v1 fold body intentionally preserves value-state semantics. func evolveV1(s MachineState, f Fact) (MachineState, error) { + if err := validateFactTransition(s, f); err != nil { + return s, err + } switch fact := f.(type) { case ModelStepPrepared: if s.Current != nil { @@ -167,8 +173,16 @@ func evolveV1(s MachineState, f Fact) (MachineState, error) { case ToolStepClosed: ts, ok := s.Current.(ToolStep) if !ok || ts.RefValue.ID != fact.StepID { + // v1 logs written before implicit closure may contain this + // redundant fact after the final call update. It is a no-op when + // the step was already closed by the mechanical fold. + if s.Current == nil && s.LastClosedStep == fact.StepID { + return s, nil + } return s, fmt.Errorf("agent: evolve: tool step %q is not current", fact.StepID) } + closed := cloneStep(ts).(ToolStep) + s.LastToolStep = &closed s.Current = nil s.LastClosedStep = fact.StepID return s, nil @@ -183,12 +197,17 @@ func evolveV1(s MachineState, f Fact) (MachineState, error) { return s, nil case RunEnded: - s.Status = fact.Status + normalized, err := fact.normalized() + if err != nil { + return s, err + } + status, reason, failure := endProjection(normalized.End) + s.Status = status s.Current = nil s.Result = &RunResult{ - Status: fact.Status, - Reason: fact.Reason, - Failure: fact.Failure, + Status: status, + Reason: reason, + Failure: failure, Model: s.LastModelResult, Usage: s.Usage, } @@ -199,6 +218,216 @@ func evolveV1(s MachineState, f Fact) (MachineState, error) { } } +// validateFactTransition protects replay from a structurally valid event that +// was not a legal Machine transition. Decide performs the same checks before +// commit, while fold/recovery must defend itself without access to commands. +func validateFactTransition(s MachineState, f Fact) error { + switch fact := f.(type) { + case ModelStepPrepared: + if s.Status.Terminal() || s.Current != nil { + return fmt.Errorf("agent: evolve: model step prepared while run is not at a model boundary") + } + if fact.StepID == "" || fact.Model == "" || fact.RequestDigest == "" || fact.ToolsDigest == "" || fact.BindingDigest == "" { + return errors.New("agent: evolve: model step prepared is missing identity or digest") + } + if ModelRef(fact.Request.Model) != fact.Model { + return errors.New("agent: evolve: model step prepared model mismatch") + } + requestDigest, err := DigestRequest(fact.Request) + if err != nil || requestDigest != fact.RequestDigest { + return errors.New("agent: evolve: model step prepared request digest mismatch") + } + toolsDigest, err := DigestToolSpecs(fact.Tools) + if err != nil || toolsDigest != fact.ToolsDigest { + return errors.New("agent: evolve: model step prepared tools digest mismatch") + } + binding, err := DigestModelStepBinding(fact.Model, fact.RequestDigest, fact.ToolsDigest) + if err != nil || binding != fact.BindingDigest { + return errors.New("agent: evolve: model step prepared binding digest mismatch") + } + case ModelStepStarted: + ms, ok := s.Current.(ModelStep) + if !ok || ms.RefValue.ID != fact.StepID || ms.Status != ModelPrepared { + return fmt.Errorf("agent: evolve: model step %q is not Prepared", fact.StepID) + } + case ModelStepRecovered: + ms, ok := s.Current.(ModelStep) + if !ok || ms.RefValue.ID != fact.StepID || ms.Status != ModelExecuting { + return fmt.Errorf("agent: evolve: model step %q is not Executing", fact.StepID) + } + case ModelStepRejected: + ms, ok := s.Current.(ModelStep) + if !ok || ms.RefValue.ID != fact.StepID || ms.Status != ModelExecuting { + return fmt.Errorf("agent: evolve: model step %q is not Executing", fact.StepID) + } + case ModelStepCompleted: + ms, ok := s.Current.(ModelStep) + if !ok || ms.RefValue.ID != fact.StepID || ms.Status != ModelExecuting { + return fmt.Errorf("agent: evolve: model step %q is not Executing", fact.StepID) + } + case ToolStepOpened: + if s.Status.Terminal() || s.Current != nil || len(fact.Calls) == 0 { + return fmt.Errorf("agent: evolve: tool step opened outside an empty active boundary") + } + if fact.StepID == "" || fact.Source == "" || fact.BindingSetDigest == "" { + return errors.New("agent: evolve: tool step is missing identity or digest") + } + // BindingSetDigest is defined over the ordered call bindings before + // derived response requests are attached. Recompute that exact input. + baseBindings := make([]ToolCallBinding, len(fact.Calls)) + for i := range fact.Calls { + baseBindings[i] = fact.Calls[i] + baseBindings[i].Response = nil + } + setDigest, err := digestBindingSet(baseBindings) + if err != nil || setDigest != 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 _, call := range fact.Calls { + if call.CallID == "" { + return errors.New("agent: evolve: tool step contains empty CallID") + } + if _, exists := seen[call.CallID]; exists { + 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) + } + wantBinding, err := DigestToolCallBinding(call.CallID, call.DefinitionDigest, call.Policy, call.Arguments) + if err != nil || wantBinding != call.BindingDigest { + return fmt.Errorf("agent: evolve: tool call %q binding digest mismatch", call.CallID) + } + if call.Response != nil { + kind := ResponseApproval + if call.Policy == ExternalResponse { + kind = ResponseExternal + } + if call.Policy != ApprovalRequired && call.Policy != ExternalResponse { + return fmt.Errorf("agent: evolve: direct call %q cannot carry a response request", call.CallID) + } + requestDigest, err := DigestToolCallBinding(call.CallID, call.DefinitionDigest, call.Policy, call.Arguments) + if err != nil { + return err + } + if err := validateResponseRequest(*call.Response, s.RunID, fact.StepID, call.CallID, kind, call.Arguments, requestDigest); err != nil { + return err + } + } + } + case ToolCallStarted: + call, err := currentCallState(s, fact.StepID, fact.CallID) + if err != nil || call.Status != ToolPending { + return fmt.Errorf("agent: evolve: tool call %q is not Pending", fact.CallID) + } + case ToolCallApproved: + call, err := currentCallState(s, fact.StepID, fact.CallID) + if err != nil || call.Status != ToolWaiting || call.Waiting == nil || call.Waiting.Kind != ResponseApproval { + return fmt.Errorf("agent: evolve: tool call %q is not waiting for approval", fact.CallID) + } + if call.Waiting.ID != fact.ResponseID { + return fmt.Errorf("agent: evolve: tool call %q response ID mismatch", fact.CallID) + } + want, err := DigestToolResponseDecision(ResponseApproval, ResponseDecisionApproved, "") + if err != nil || want != fact.ResponseDigest { + return fmt.Errorf("agent: evolve: tool call %q approval digest mismatch", fact.CallID) + } + case ToolCallCompleted: + call, err := currentCallState(s, fact.StepID, fact.CallID) + if err != nil || call.Status != ToolExecuting { + return fmt.Errorf("agent: evolve: tool call %q is not Executing", fact.CallID) + } + case ToolCallAnswered: + call, err := currentCallState(s, fact.StepID, fact.CallID) + if err != nil || call.Status != ToolWaiting || call.Waiting == nil || call.Waiting.Kind != ResponseExternal { + return fmt.Errorf("agent: evolve: tool call %q is not waiting for an external response", fact.CallID) + } + if call.Waiting.ID != fact.ResponseID { + return fmt.Errorf("agent: evolve: tool call %q response ID mismatch", fact.CallID) + } + want, err := DigestToolResponsePayload(fact.Payload) + if err != nil || want != fact.ResponseDigest { + return fmt.Errorf("agent: evolve: tool call %q response digest mismatch", fact.CallID) + } + case ToolCallFailed: + call, err := currentCallState(s, fact.StepID, fact.CallID) + if err != nil || (call.Status != ToolPending && call.Status != ToolExecuting && call.Status != ToolWaiting) { + return fmt.Errorf("agent: evolve: tool call %q cannot fail from its current state", fact.CallID) + } + if fact.Failure.Class == "" { + return fmt.Errorf("agent: evolve: tool call %q failure has no class", fact.CallID) + } + switch fact.Outcome { + case ToolOutcomeKnown: + if fact.Failure.Class == FailureEffectUnknown { + return fmt.Errorf("agent: evolve: known outcome cannot use %s", FailureEffectUnknown) + } + case ToolOutcomeUnknown: + if call.Status != ToolExecuting { + return fmt.Errorf("agent: evolve: unknown outcome requires Executing call") + } + if fact.Failure.Class != FailureEffectUnknown { + return fmt.Errorf("agent: evolve: unknown outcome must use %s", FailureEffectUnknown) + } + default: + return fmt.Errorf("agent: evolve: unknown failure outcome %d", fact.Outcome) + } + case ToolStepClosed: + ts, ok := s.Current.(ToolStep) + if !ok || ts.RefValue.ID != fact.StepID || len(ts.Calls) == 0 { + if s.Status == RunActive && s.Current == nil && s.LastClosedStep == fact.StepID { + return nil + } + return fmt.Errorf("agent: evolve: tool step %q is not closable", fact.StepID) + } + for _, call := range ts.Calls { + if call.Status != ToolCompleted && call.Status != ToolFailed { + return fmt.Errorf("agent: evolve: tool step %q still has non-terminal calls", fact.StepID) + } + } + case InputAccepted: + if s.Status.Terminal() || s.Current != nil { + return errors.New("agent: evolve: input accepted outside an empty active boundary") + } + case RunEnded: + if s.Status.Terminal() { + return errors.New("agent: evolve: duplicate terminal fact") + } + if _, err := fact.normalized(); err != nil { + return err + } + default: + return fmt.Errorf("agent: evolve: unknown fact variant %T", f) + } + return nil +} + +func currentCallState(s MachineState, step StepID, call CallID) (ToolCallState, error) { + ts, ok := s.Current.(ToolStep) + if !ok || ts.RefValue.ID != step { + return ToolCallState{}, errors.New("not current ToolStep") + } + i := ts.callIndex(call) + if i < 0 { + return ToolCallState{}, errors.New("unknown CallID") + } + return ts.Calls[i], nil +} + +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 evolveModelStep(s *MachineState, step StepID) (*ModelStep, error) { ms, ok := s.Current.(ModelStep) if !ok || ms.RefValue.ID != step { @@ -224,6 +453,26 @@ func evolveCall(s *MachineState, step StepID, call CallID, apply func(*ToolCallS return *s, err } ts.Calls = calls - s.Current = ts + if allToolCallsTerminal(calls) { + closed := ts + closed.Calls = calls + s.LastToolStep = &closed + s.Current = nil + s.LastClosedStep = step + } else { + s.Current = ts + } return *s, nil } + +func allToolCallsTerminal(calls []ToolCallState) bool { + if len(calls) == 0 { + return false + } + for _, call := range calls { + if call.Status != ToolCompleted && call.Status != ToolFailed { + return false + } + } + return true +} diff --git a/agent/run/fact.go b/agent/run/fact.go index 833a21f..108f735 100644 --- a/agent/run/fact.go +++ b/agent/run/fact.go @@ -1,14 +1,22 @@ package run +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" +) + // Fact is one committed outcome produced by Machine.Decide. Facts are wrapped -// as AgentEvents; Machine.Evolve folds them mechanically (spec §3.6). The +// as AgentEvents; Machine.Evolve folds them mechanically (RUN-MCH-3). The // interface is sealed: only the fourteen variants below exist. type Fact interface{ fact() } // ModelStepPrepared establishes the frozen ModelStep and consumes the listed // pending inputs. BindingDigest (model + request + tools) is computed by // Decide and carried in the fact: Evolve folds it verbatim, never recomputes -// (fact self-containment, spec §5.1). +// (fact self-containment, RUN-MCH-3). type ModelStepPrepared struct { StepID StepID `json:"stepId"` Model ModelRef `json:"model"` @@ -117,8 +125,8 @@ type ToolCallFailed struct { func (ToolCallFailed) fact() {} -// ToolStepClosed: every call reached a closable terminal state; the current -// step is cleared. +// ToolStepClosed is retained only for folding legacy v1 logs. New transitions +// close a ToolStep implicitly when its final ToolCall reaches a terminal state. type ToolStepClosed struct { StepID StepID `json:"stepId"` } @@ -132,15 +140,150 @@ type InputAccepted struct { func (InputAccepted) fact() {} +// RunEnd is the closed set of terminal outcomes. +type RunEnd interface{ runEnd() } + +type RunCompletedEnd struct{} +type RunStoppedEnd struct{ Reason RunReason } +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 { - Status RunStatus `json:"status"` // RunCompleted, RunStopped or RunFailed - Reason RunReason `json:"reason,omitempty"` - Failure *RunFailure `json:"failure,omitempty"` + End RunEnd `json:"-"` } func (RunEnded) fact() {} +func (r RunEnded) effectiveEnd() (RunEnd, error) { + if err := validateRunEnd(r.End); err != nil { + return nil, err + } + return r.End, nil +} + +func legacyEnd(status RunStatus, reason RunReason, failure *RunFailure) (RunEnd, error) { + switch status { + case RunCompleted: + if reason != "" || failure != nil { + return nil, errors.New("agent: run ended: completed outcome cannot carry reason or failure") + } + return RunCompletedEnd{}, nil + case RunStopped: + if reason == "" { + return nil, errors.New("agent: run ended: stopped outcome requires a reason") + } + if failure != nil { + return nil, errors.New("agent: run ended: stopped outcome cannot carry failure") + } + return RunStoppedEnd{Reason: reason}, nil + case RunFailed: + if failure == nil { + return nil, errors.New("agent: run ended: failed outcome requires failure") + } + if reason == "" { + return nil, errors.New("agent: run ended: failed outcome requires a reason") + } + if failure.Class == "" { + return nil, errors.New("agent: run ended: failed outcome requires a failure class") + } + return RunFailedEnd{Reason: reason, Failure: *failure}, nil + default: + return nil, fmt.Errorf("agent: run ended: invalid status %d", status) + } +} + +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 (r RunEnded) normalized() (RunEnded, error) { + end, err := r.effectiveEnd() + if err != nil { + return RunEnded{}, err + } + return RunEnded{End: end}, nil +} + +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 + } +} + +// MarshalJSON preserves the current v1 wire shape while deriving it from the +// sealed terminal union. +func (r RunEnded) MarshalJSON() ([]byte, error) { + n, err := r.normalized() + if err != nil { + return nil, err + } + type wire struct { + Status RunStatus `json:"status"` + Reason RunReason `json:"reason,omitempty"` + Failure *RunFailure `json:"failure,omitempty"` + } + status, reason, failure := endProjection(n.End) + return json.Marshal(wire{Status: status, Reason: reason, Failure: failure}) +} + +func (r *RunEnded) UnmarshalJSON(raw []byte) error { + var wire struct { + Status RunStatus `json:"status"` + Reason RunReason `json:"reason,omitempty"` + Failure *RunFailure `json:"failure,omitempty"` + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&wire); 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 + } + end, err := legacyEnd(wire.Status, wire.Reason, wire.Failure) + if 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) { diff --git a/agent/run/fold.go b/agent/run/fold.go index f1479df..53b1a62 100644 --- a/agent/run/fold.go +++ b/agent/run/fold.go @@ -9,15 +9,13 @@ import ( ) // ErrLogTruncated reports that the transition log ends below the revision the -// caller expected. It is a diagnostic signal, not a protocol halt: the -// MachineState is the execution authority (spec §5.1), so a shorter-than- -// expected log means an audit gap, and how to handle it is an operational -// decision. +// caller expected. It identifies an audit gap for the Runtime authority; the +// adapter decides how to surface or repair that gap. var ErrLogTruncated = errors.New("agent: transition log ends below the expected revision") // FoldEvents rebuilds a MachineState by folding a complete flat event stream // from the initial (Revision 0) state with EvolveVersion only: no Decide, no -// external effects, no command replay (spec §9.1). It verifies (Revision, +// external effects, no command replay (RUN-NEW-2). It verifies (Revision, // Index) ordering and per-fact digests as it goes. Authority runtimes should // prefer FoldTransitions because only TransitionRecord can prove the last // transition's event group is complete. @@ -119,14 +117,12 @@ func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineS return state, uint64(revision), nil } -// Rebuild is an optional diagnostic (spec §5.1): it refolds the state from +// Rebuild is an optional diagnostic (RUN-CMT-2): it refolds the state from // the transition log and replaces the in-memory state with the fold result. -// The MachineState is the execution authority, so the normal path never calls -// this; use it for consistency checks and storage-migration rebuilds. A log -// shorter than the last committed revision returns ErrLogTruncated (audit -// gap). It returns true when the refolded state differed from the stored -// state — with a correct implementation this never happens, so a true return -// is an audit signal (Evolve bug, out-of-band write, or storage corruption). +// Runtime.Commit remains the normal state transition path. A log shorter than +// the last committed revision returns ErrLogTruncated (audit gap). It returns +// true when the refolded state differs from the stored state, which identifies +// an Evolve bug, out-of-band write, or storage corruption. func (m *MemoryRuntime) Rebuild(runID RunID) (rebuilt bool, err error) { entry, err := m.entry(runID) if err != nil { @@ -168,6 +164,9 @@ func stateComparable(s *MachineState) map[string]any { "usage": s.Usage, "pendingInputs": s.PendingInputs, "lastModelResult": s.LastModelResult, "result": s.Result, } + if s.LastToolStep != nil { + m["lastToolStep"] = s.LastToolStep + } switch cur := s.Current.(type) { case ModelStep: m["modelStep"] = cur diff --git a/agent/run/fold_test.go b/agent/run/fold_test.go index be4be4a..ac6df81 100644 --- a/agent/run/fold_test.go +++ b/agent/run/fold_test.go @@ -8,7 +8,7 @@ import ( "github.com/memohai/twilight/sdk" ) -// Event-sourcing arbitration tests (spec §5.1): the complete canonical +// Event-sourcing arbitration tests (RUN-SCP-1, RUN-CMT-2): the complete canonical // TransitionRecord is the diagnostic commit record; the snapshot is a // rebuildable same-transaction projection; the revision watermark witnesses // log-tail completeness. @@ -276,12 +276,10 @@ func TestRegressionPreparedFactSelfContained(t *testing.T) { } } -// Golden event stream: a fixed v1 command sequence folds to frozen state -// bytes. If this test fails, either the canonical encoding or Evolve's -// folding semantics changed — both are permanent contracts of SchemaVersion 1 -// (fix the code, not the constant), unless the protocol itself is still -// pre-release and the change is deliberate (then re-freeze the constant in -// the same commit that changes the protocol). +// Golden event stream for the current pre-release v1 command sequence. It +// protects the current fold result; deliberately changing the pre-release +// protocol requires re-freezing the fixture in the same change. After v1 is +// published, this becomes a permanent compatibility fixture. func TestGoldenEventStreamV1(t *testing.T) { rt := fullRunRuntime(t) record, err := rt.Record(context.Background(), "run-1") @@ -300,7 +298,7 @@ func TestGoldenEventStreamV1(t *testing.T) { t.Fatal(err) } got := string(sha256Digest(stateBytes)) - const frozen = "sha256:5ab6b883663a76e3293bf5dded726ae4cfdcfc50cc957b5b72fcf7d677e648ed" + const frozen = "sha256:f306b9499352398094ccbba585c4c60b6f474363cd0e9cac7b4df2fcf01d7d18" if got != frozen { t.Fatalf("golden v1 state digest changed:\n got %s\nwant %s\nstate: %s", got, frozen, stateBytes) } diff --git a/agent/run/header.go b/agent/run/header.go index db08b0e..7aaa531 100644 --- a/agent/run/header.go +++ b/agent/run/header.go @@ -8,9 +8,9 @@ import ( ) // snapshotSchemaVersion versions the MachineState wire shape used inside -// RunHeader.InitialState. It is independent of the event SchemaVersion: event -// encoding is frozen forever, while the snapshot shape may evolve with a -// version bump (spec §8.2). +// RunHeader.InitialState. It is independent of the event SchemaVersion. Both +// shapes remain pre-release until the protocol is published; after publication +// each can evolve only through its documented versioning rules (RUN-CMP-1). const snapshotSchemaVersion uint16 = 1 // encodeMachineStateWire renders the canonical bytes of a MachineState for @@ -20,12 +20,12 @@ func encodeMachineStateWire(s *MachineState) ([]byte, error) { return marshalCanonical(stateComparable(s)) } -// RunHeader is the formal persisted Revision-0 protocol record (spec §5.1.1). -// MachineState is the execution authority. RunHeader plus the TransitionRecord -// log is the canonical record used for audit, projections, and verified -// import/replay: Create atomically stores the header and Revision-0 state, and -// each Commit atomically stores the next state and transition. Every fold starts -// from the header's initial state. The header is immutable after creation. +// RunHeader is the formal persisted Revision-0 protocol record (RUN-NEW-1). +// RunHeader plus the TransitionRecord log is the canonical record used for +// audit, projections, and verified import/replay. Runtime atomically stores the +// header with Revision-0 state during Create and stores each next state with +// its transition during Commit. Every fold starts from the header's initial +// state. The header is immutable after creation. type RunHeader struct { SchemaVersion uint16 `json:"schemaVersion"` RunID RunID `json:"runId"` @@ -49,7 +49,7 @@ type runHeaderDigestBody struct { // BuildRunHeader creates the immutable header for a new Run. The initial // state is the minimal InitializeRun state: no seed input, no model policy, // no limits — the first input arrives as the Revision-1 AcceptInput -// transition (spec §5.1.1 rules 2-3). +// transition (RUN-NEW-1). func BuildRunHeader(runID RunID, causationID es.CausationID) (RunHeader, error) { initial, err := InitializeRun(runID) if err != nil { @@ -93,7 +93,7 @@ func digestRunHeader(h *RunHeader) (Digest, error) { // ValidateRunHeader verifies header integrity: state digest, header digest, // and that the initial state is a legal Revision-0 state for this RunID. // Imported/uploaded runs must pass this before their log is folded -// (spec §5.1.1 rule 6). +// (RUN-NEW-2). func ValidateRunHeader(h *RunHeader) error { if h.RunID == "" { return errors.New("agent: run header: empty RunID") @@ -110,7 +110,8 @@ func ValidateRunHeader(h *RunHeader) error { if h.InitialState.Status != RunActive || h.InitialState.Current != nil || len(h.InitialState.PendingInputs) != 0 || h.InitialState.ModelSteps != 0 || h.InitialState.Result != nil || h.InitialState.LastModelResult != nil || - h.InitialState.LastClosedStep != "" || h.InitialState.Usage != (Usage{}) { + h.InitialState.LastClosedStep != "" || h.InitialState.LastToolStep != nil || + h.InitialState.Usage != (Usage{}) { return errors.New("agent: run header: initial state is not a minimal Revision-0 state") } stateBytes, err := encodeMachineStateWire(&h.InitialState) @@ -131,7 +132,7 @@ func ValidateRunHeader(h *RunHeader) error { } // FoldRun rebuilds the run state from the canonical header + transition log. -// It validates the header first, then folds the records (spec §9.1). This is +// It validates the header first, then folds the records (RUN-NEW-2). This is // the entry point durable adapters and import/migration paths use; trusting an // uploaded MachineState snapshot is never legal. func FoldRun(header *RunHeader, records []TransitionRecord) (MachineState, uint64, error) { diff --git a/agent/run/header_test.go b/agent/run/header_test.go index 69d586e..0db973c 100644 --- a/agent/run/header_test.go +++ b/agent/run/header_test.go @@ -62,8 +62,8 @@ func TestFoldRunFromHeaderMatchesRuntime(t *testing.T) { t.Fatal(err) } - // Drive one transition: accept the seed input at Revision 1 (spec §5.1.1 - // rule 3 — seed enters the log, not the header). + // Drive one transition: accept the seed input at Revision 1 (RUN-NEW-1 — + // seed enters the log, not the header). in := AgentInput{ID: "seed", Payload: MustParseCanonicalJSON(`{"q":"hi"}`)} env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), NextStep(in)) if err != nil { diff --git a/agent/run/ids.go b/agent/run/ids.go index 4e03b3d..e9ba20c 100644 --- a/agent/run/ids.go +++ b/agent/run/ids.go @@ -25,6 +25,11 @@ type Digest = es.Digest // 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 + // ExecutionGrant is an opaque capability minted by the Runtime for one // accepted start command. Callers only pass it back; its representation is // implementation-defined. @@ -44,7 +49,7 @@ func namespacedHash(namespace string, parts ...string) string { } // DeriveModelRequestCommandID derives the CommandID for PrepareModelRequest -// from the Run and the Revision the planner loaded (spec §5.6): concurrent +// from the Run and the Revision the planner loaded (RUN-WIR-3): concurrent // planners on the same Revision converge on one command identity. func DeriveModelRequestCommandID(run RunID, revision uint64) CommandID { return CommandID(namespacedHash("twilight/model-request", string(run), fmt.Sprintf("%d", revision))) @@ -89,3 +94,11 @@ func DeriveInputCommandID(run RunID, input InputID) CommandID { func DeriveSystemCommandID(run RunID, step StepID, call CallID, recoveryRecord string) CommandID { return CommandID(namespacedHash("twilight/system-command", string(run), string(step), string(call), recoveryRecord)) } + +// 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))) +} diff --git a/agent/run/loop/contract.go b/agent/run/loop/contract.go index 9f3b755..5e2054d 100644 --- a/agent/run/loop/contract.go +++ b/agent/run/loop/contract.go @@ -3,14 +3,20 @@ package loop import ( "context" "encoding/json" + "errors" run "github.com/memohai/twilight/agent/run" "github.com/memohai/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 one execution grant has one in-process consumer. +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 (spec §5.6). Loop freezes it into +// 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 { @@ -55,7 +61,7 @@ type ToolExecutionRequest struct { Progress ToolProgressSink } -// ExecutableTool is the application-side execution contract (spec §7.1). +// ExecutableTool is the application-side execution contract (RUN-LOP-1). type ExecutableTool interface { Ref() run.ToolRef Definition() sdk.ToolDefinition @@ -91,7 +97,16 @@ type ToolProgress struct { Payload json.RawMessage } -// --- EventSink: realtime observation, never authority (spec §9.2) --- +// ToolExecutionMode controls how independent Pending calls are dispatched by +// a Loop. The mode is an execution concern; it is not part of MachineState. +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 @@ -117,9 +132,11 @@ const ( ) type Event struct { - RunID run.RunID - StepID run.StepID - CallID run.CallID + RunID run.RunID + StepID run.StepID + CallID run.CallID + // Sequence orders provisional observations within one stream. Canonical + // observations use AgentEvent.Revision/Index for authority ordering. Sequence uint64 Kind EventKind Durability EventDurability @@ -129,18 +146,22 @@ type Event struct { } // ExecutionPolicy is host-owned loop policy. It is not persisted in -// MachineState or events. MaxParallel bounds only workers this Loop launches, -// never a global count (spec §4.3). +// MachineState or events. ToolExecution controls dispatch mode and +// MaxParallel bounds workers launched by this Loop. The model-step and +// malformed-result limit fields remain source-compatible fields; malformed +// result disposition is selected by OnMalformedModelResult. type ExecutionPolicy struct { - // MaxParallel: 1 is sequential; n>1 is bounded parallel; 0 normalizes to 1; - // negative is rejected. + 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 - // ModelStepLimit: 0 means unlimited; positive values make the Loop submit - // StopRun(step_limit) before planning another ModelStep. - ModelStepLimit int - // MalformedModelResultLimit: 0 normalizes to - // DefaultMalformedModelResultLimit; negative is rejected. The Loop chooses - // RejectModelResult disposition from the current ModelStep reject count. + // Deprecated compatibility fields retained for older callers. They do not + // change Machine transitions; hosts select any step/retry policy outside + // the core protocol. + ModelStepLimit int MalformedModelResultLimit int } @@ -151,16 +172,22 @@ const ( LoopFinished ) +type LoopResult struct { + Disposition LoopDisposition + // Reason is retained for source compatibility. Waiting and + // ExecutionRecovery are the authoritative waiting signals. + Reason WaitReason + Waiting []run.ResponseRequest + // ExecutionRecovery is true when at least one ToolCall remains Executing. + // When Waiting is also non-empty, the host must wake on either response or + // execution recovery. + ExecutionRecovery bool + Result *run.RunResult +} + type WaitReason string const ( WaitingForResponse WaitReason = "waiting_for_response" ExecutionRecovery WaitReason = "execution_recovery" ) - -type LoopResult struct { - Disposition LoopDisposition - Reason WaitReason - Waiting []run.ResponseRequest - Result *run.RunResult -} diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index 02f45b8..74dfb86 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -14,7 +14,7 @@ import ( "github.com/memohai/twilight/sdk" ) -// Loop is the in-process interpreter of one Run (spec §6). It holds no +// 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 @@ -22,31 +22,253 @@ type Loop struct { Planner RequestPlanner Execution ExecutionPolicy Streaming bool + + // starts retains the identity of an in-flight start command across a + // transient Run return. A commit may have succeeded while its response was + // lost; reusing the same command and claim lets the next Run recover the + // original grant without issuing a second start. + startsMu sync.Mutex + starts map[startKey]startAttempt + + settlementsMu sync.Mutex + settlements map[startKey]settlementAttempt + + runsMu sync.Mutex + runs map[run.RunID]struct{} + eventsMu sync.Mutex +} + +type startKey struct { + runID run.RunID + stepID run.StepID + callID run.CallID +} + +type startAttempt struct { + commandID run.CommandID + claim run.ExecutionClaim +} + +type settlementAttempt struct { + commandID run.CommandID + base uint64 + grant run.ExecutionGrant + command run.AgentCommand } -// New validates and normalizes the execution policy (spec §4.3). +// 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 policy.MaxParallel < 0 { - return nil, errors.New("agent: loop: negative MaxParallel") + 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 || policy.ModelStepLimit < 0 || policy.MalformedModelResultLimit < 0 { + return nil, errors.New("agent: loop: negative compatibility policy") + } + if policy.ToolExecution == "" { + policy.ToolExecution = ToolExecutionParallel + } + return &Loop{Models: models, Tools: tools, Planner: planner, Execution: policy, Streaming: streaming, + starts: make(map[startKey]startAttempt), settlements: make(map[startKey]settlementAttempt), runs: make(map[run.RunID]struct{})}, nil +} + +func (l *Loop) startFor(key startKey) startAttempt { + l.startsMu.Lock() + defer l.startsMu.Unlock() + if attempt, ok := l.starts[key]; ok { + return attempt + } + attempt := startAttempt{commandID: freshCommandID(), claim: freshExecutionClaim()} + l.starts[key] = attempt + return attempt +} + +func (l *Loop) forgetStart(key startKey) { + l.startsMu.Lock() + delete(l.starts, key) + l.startsMu.Unlock() +} + +func (l *Loop) lookupStart(key startKey) (startAttempt, bool) { + l.startsMu.Lock() + defer l.startsMu.Unlock() + attempt, ok := l.starts[key] + return attempt, ok +} + +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() +} + +func (l *Loop) settlementFor(key startKey, commandID run.CommandID, base uint64, grant run.ExecutionGrant, command run.AgentCommand) settlementAttempt { + l.settlementsMu.Lock() + defer l.settlementsMu.Unlock() + if attempt, ok := l.settlements[key]; ok { + return attempt + } + attempt := settlementAttempt{commandID: commandID, base: base, grant: grant, command: command} + l.settlements[key] = attempt + return attempt +} + +func (l *Loop) lookupSettlement(key startKey) (settlementAttempt, bool) { + l.settlementsMu.Lock() + defer l.settlementsMu.Unlock() + attempt, ok := l.settlements[key] + return attempt, ok +} + +func (l *Loop) forgetSettlement(key startKey) { + l.settlementsMu.Lock() + delete(l.settlements, key) + l.settlementsMu.Unlock() +} + +func (l *Loop) forgetRunCaches(runID run.RunID) { + l.startsMu.Lock() + for key := range l.starts { + if key.runID == runID { + delete(l.starts, key) + } } - if policy.ModelStepLimit < 0 { - return nil, errors.New("agent: loop: negative ModelStepLimit") + l.startsMu.Unlock() + l.settlementsMu.Lock() + for key := range l.settlements { + if key.runID == runID { + delete(l.settlements, key) + } } - if policy.MalformedModelResultLimit < 0 { - return nil, errors.New("agent: loop: negative MalformedModelResultLimit") + l.settlementsMu.Unlock() +} + +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 } - if policy.MaxParallel == 0 { - policy.MaxParallel = 1 + s.mu.Lock() + defer s.mu.Unlock() + return s.sink.Emit(ctx, event) +} + +// resumeSettlement replays a result whose first commit may have succeeded +// while its response was lost. The same command identity makes the retry +// idempotent and avoids re-running the external effect. +func (l *Loop) resumeSettlement(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot) (bool, error) { + runID := snapshot.State.RunID + l.settlementsMu.Lock() + keys := make([]startKey, 0) + for key := range l.settlements { + if key.runID == runID { + keys = append(keys, key) + } } - if policy.MalformedModelResultLimit == 0 { - policy.MalformedModelResultLimit = run.DefaultMalformedModelResultLimit + l.settlementsMu.Unlock() + for _, key := range keys { + attempt, ok := l.lookupSettlement(key) + if !ok { + continue + } + if !settlementTargetActive(snapshot.State, key) { + l.forgetSettlement(key) + l.forgetStart(key) + continue + } + res, err := l.commit(context.WithoutCancel(ctx), runtime, runID, attempt.commandID, attempt.base, attempt.grant, attempt.command) + if err != nil { + if retriable(err) { + l.forgetSettlement(key) + l.forgetStart(key) + return true, nil + } + return false, err + } + l.forgetSettlement(key) + l.forgetStart(key) + l.emitCommitted(ctx, events, runID, res.Events) + return true, nil + } + return false, nil +} + +func settlementTargetActive(state run.MachineState, key startKey) bool { + switch current := state.Current.(type) { + case run.ModelStep: + return key.callID == "" && current.RefValue.ID == key.stepID && current.Status == run.ModelExecuting + case run.ToolStep: + if current.RefValue.ID != key.stepID { + return false + } + for _, call := range current.Calls { + if call.CallID == key.callID { + return call.Status == run.ToolExecuting + } + } + } + return false +} + +// resumeCachedStart re-enters an accepted start after a Loop.Run returned +// before receiving its grant. The authority state is Executing, so Next alone +// would otherwise wait for recovery instead of replaying the known start. +func (l *Loop) resumeCachedStart(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot) (bool, error) { + runID := snapshot.State.RunID + switch current := snapshot.State.Current.(type) { + case run.ModelStep: + if current.Status != run.ModelExecuting { + return false, nil + } + if _, ok := l.lookupStart(startKey{runID: runID, stepID: current.RefValue.ID}); !ok { + return false, nil + } + return true, l.runModelStep(ctx, runtime, events, snapshot, current.RefValue.ID) + case run.ToolStep: + var ids []run.CallID + for _, call := range current.Calls { + if call.Status != run.ToolExecuting { + continue + } + if _, ok := l.lookupStart(startKey{runID: runID, stepID: current.RefValue.ID, callID: call.CallID}); ok { + ids = append(ids, call.CallID) + } + } + if len(ids) == 0 { + return false, nil + } + return true, l.runToolCalls(ctx, runtime, events, snapshot, run.StartToolCalls{StepID: current.RefValue.ID, CallIDs: ids}) + default: + return false, nil } - return &Loop{Models: models, Tools: tools, Planner: planner, Execution: policy, Streaming: streaming}, nil } // Run drives the Run until it finishes, must wait, or the context is -// cancelled (spec §6.2). controlCtx for reads/commits is derived from ctx via -// WithoutCancel so worker cancellation never blocks result submission. +// 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, runtime run.Runtime, runID run.RunID, events EventSink) (LoopResult, error) { if ctx == nil { return LoopResult{}, errors.New("agent: loop: nil context") @@ -57,7 +279,13 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev if runID == "" { return LoopResult{}, errors.New("agent: loop: empty RunID") } - controlCtx := context.WithoutCancel(ctx) + if err := l.acquireRun(runID); err != nil { + return LoopResult{}, err + } + defer l.releaseRun(runID) + if events != nil { + events = &serializedEventSink{sink: events, mu: &l.eventsMu} + } for { if err := ctx.Err(); err != nil { @@ -65,7 +293,7 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev // branches below before we reach this check. return LoopResult{}, err } - snapshot, err := runtime.Load(controlCtx, runID) + snapshot, err := runtime.Load(ctx, runID) if err != nil { return LoopResult{}, err } @@ -73,8 +301,9 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev return LoopResult{}, fmt.Errorf("agent: loop: runtime returned RunID %q for %q", snapshot.State.RunID, runID) } if snapshot.State.Status.Terminal() { + l.forgetRunCaches(runID) if events != nil { - _ = events.Emit(controlCtx, Event{ + _ = events.Emit(ctx, Event{ RunID: snapshot.State.RunID, Kind: EventRunFinished, Durability: EventCommitted, @@ -83,6 +312,17 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev return LoopResult{Disposition: LoopFinished, Result: snapshot.State.Result}, nil } + if handled, err := l.resumeSettlement(ctx, runtime, events, &snapshot); err != nil { + return LoopResult{}, err + } else if handled { + continue + } + if handled, err := l.resumeCachedStart(ctx, runtime, events, &snapshot); err != nil { + return LoopResult{}, err + } else if handled { + continue + } + effect, err := run.Next(snapshot.State) if err != nil { return LoopResult{}, err @@ -90,32 +330,25 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev switch eff := effect.(type) { case run.NeedModelRequest: - if l.Execution.ModelStepLimit > 0 && snapshot.State.ModelSteps >= l.Execution.ModelStepLimit { - res, err := l.commit(controlCtx, runtime, snapshot.State.RunID, freshCommandID(), snapshot.Revision, "", run.StopRun{Reason: run.ReasonStepLimit}) - if err != nil { - if retriable(err) { - continue - } - return LoopResult{}, err - } - l.emitCommitted(controlCtx, events, snapshot.State.RunID, res.Events) - continue - } - if err := l.planAndPrepare(ctx, controlCtx, runtime, events, &snapshot, eff.Hint); err != nil { + if err := l.planAndPrepare(ctx, runtime, events, &snapshot, eff.Hint); err != nil { return LoopResult{}, err } case run.StartModelCall: - if err := l.runModelStep(ctx, controlCtx, runtime, events, &snapshot, eff.StepID); err != nil { + if err := l.runModelStep(ctx, runtime, events, &snapshot, eff.StepID); err != nil { return LoopResult{}, err } case run.StartToolCalls: - if err := l.runToolCalls(ctx, controlCtx, runtime, events, &snapshot, eff); err != nil { + if err := l.runToolCalls(ctx, runtime, events, &snapshot, eff); err != nil { return LoopResult{}, err } case run.WaitForResponse: - return LoopResult{Disposition: LoopWaiting, Reason: WaitingForResponse, Waiting: eff.Requests}, nil + reason := WaitingForResponse + if eff.ExecutionRecovery { + reason = ExecutionRecovery + } + return LoopResult{Disposition: LoopWaiting, Reason: reason, Waiting: eff.Requests, ExecutionRecovery: eff.ExecutionRecovery}, nil case run.WaitForExecutionRecovery: - return LoopResult{Disposition: LoopWaiting, Reason: ExecutionRecovery}, nil + return LoopResult{Disposition: LoopWaiting, Reason: ExecutionRecovery, ExecutionRecovery: true}, nil default: return LoopResult{}, fmt.Errorf("agent: loop: unknown effect %T", effect) } @@ -124,7 +357,7 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev // 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 (spec §6.6 "commit response unknown"): if the first attempt actually +// digest (RUN-LOP-5): if the first attempt actually // committed and only the response was lost, the replay returns AlreadyApplied // instead of abandoning a live grant or re-executing an expensive step. func (l *Loop) commit(ctx context.Context, runtime run.Runtime, runID run.RunID, id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) (run.CommitResult, error) { @@ -153,6 +386,14 @@ func freshCommandID() run.CommandID { return run.CommandID(hex.EncodeToString(b[:])) } +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[:])) +} + func (l *Loop) emitCommitted(ctx context.Context, events EventSink, runID run.RunID, committed []run.AgentEvent) { if events == nil { return @@ -170,7 +411,7 @@ func (l *Loop) emitCommitted(ctx context.Context, events EventSink, runID run.Ru // --- NeedModelRequest --- -func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, hint run.PlanningHint) error { +func (l *Loop) planAndPrepare(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, hint run.PlanningHint) error { plan, err := l.Planner.Plan(ctx, hint) if err != nil { return err @@ -203,7 +444,7 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime run.Runti } cmdID := run.DeriveModelRequestCommandID(snapshot.State.RunID, snapshot.Revision) stepID := run.DeriveModelStepID(snapshot.State.RunID, cmdID, binding) - res, err := l.commit(controlCtx, runtime, snapshot.State.RunID, cmdID, snapshot.Revision, "", run.PrepareModelRequest{ + res, err := l.commit(ctx, runtime, snapshot.State.RunID, cmdID, snapshot.Revision, "", run.PrepareModelRequest{ StepID: stepID, Model: model, Request: frozenRequest, @@ -217,7 +458,7 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime run.Runti // ModelStepPrepared carries the frozen request — the most informative // fact of the run; observers must see it like every other accepted // transition. - l.emitCommitted(controlCtx, events, snapshot.State.RunID, res.Events) + l.emitCommitted(ctx, events, snapshot.State.RunID, res.Events) return nil } if !retriable(err) { @@ -226,7 +467,7 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime run.Runti // 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(controlCtx, snapshot.State.RunID) + after, loadErr := runtime.Load(ctx, snapshot.State.RunID) if loadErr != nil { return loadErr } @@ -238,22 +479,39 @@ func (l *Loop) planAndPrepare(ctx, controlCtx context.Context, runtime run.Runti // --- StartModelCall --- -func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, stepID run.StepID) error { +func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, stepID run.StepID) error { runID := snapshot.State.RunID - start, err := l.commit(controlCtx, runtime, runID, freshCommandID(), snapshot.Revision, "", run.StartModelExecution{StepID: stepID}) + key := startKey{runID: runID, stepID: stepID} + attempt := l.startFor(key) + start, err := l.commit(ctx, runtime, runID, attempt.commandID, snapshot.Revision, "", run.StartModelExecution{StepID: stepID, Claim: attempt.claim}) if err != nil { if retriable(err) { + l.forgetStart(key) return nil } + // The start may have committed while its response was lost. Keep the + // command identity so a later Run can replay it and recover the grant. return err } - if start.Status == run.CommitAlreadyApplied { + if start.Status == run.CommitAlreadyApplied && start.Grant == "" { + l.forgetStart(key) return nil // another attempt owns it; reload } - l.emitCommitted(controlCtx, events, runID, start.Events) + if start.Grant == "" { + return errors.New("agent: loop: start model returned no execution grant") + } + l.emitCommitted(ctx, events, runID, start.Events) modelStep, ok := start.Snapshot.State.Current.(run.ModelStep) - if !ok || modelStep.RefValue.ID != stepID { + if !ok || modelStep.RefValue.ID != stepID || modelStep.Status != run.ModelExecuting { + // An exact start replay can race with another owner that already + // settled the step. The Runtime returns the original grant for replay, + // but executing again would duplicate the provider effect; reload and + // let the next machine state decide what to do. + if start.Status == run.CommitAlreadyApplied { + l.forgetStart(key) + return nil + } return fmt.Errorf("agent: loop: started step %q is not current", stepID) } @@ -263,7 +521,7 @@ func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime run.Runtime completion = run.SubmitModelFailure{StepID: stepID, Failure: run.StepFailure{Class: run.FailureProvider, Message: resolveErr.Error()}} } else { // Model workers derive from the outer ctx: cancelling a model call is - // safe, the frozen request retries after recovery (spec §6.1). + // safe, the frozen request retries after recovery (RUN-LOP-3). sdkRequest, err := modelStep.Request.SDK() if err != nil { completion = run.SubmitModelFailure{StepID: stepID, Failure: run.StepFailure{Class: run.FailureProvider, Message: err.Error()}} @@ -271,7 +529,7 @@ func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime run.Runtime result, invokeErr := l.invokeModel(ctx, invoker, &sdkRequest, runID, stepID, events) switch { case invokeErr != nil && ctx.Err() != nil: - completion = run.RecoverModelExecution{StepID: stepID} + completion = run.RecoverModelExecution{StepID: stepID, Claim: attempt.claim} case invokeErr != nil: completion = run.SubmitModelFailure{StepID: stepID, Failure: run.StepFailure{Class: run.FailureProvider, Message: invokeErr.Error()}} default: @@ -279,11 +537,11 @@ func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime run.Runtime 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.Rejects)} + 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.Rejects)} + Disposition: l.modelRejectDisposition(modelStep, run.StepFailure{Class: run.FailureMalformedModel, Message: freezeErr.Error()})} } else { completion = run.SubmitModelResult{StepID: stepID, Result: frozenResult, Calls: bindings} } @@ -291,22 +549,40 @@ func (l *Loop) runModelStep(ctx, controlCtx context.Context, runtime run.Runtime } } - res, err := l.commit(controlCtx, runtime, runID, freshCommandID(), start.Snapshot.Revision, start.Grant, completion) + completionID := freshCommandID() + if _, recovering := completion.(run.RecoverModelExecution); recovering { + completionID = run.DeriveModelRecoveryCommandID(runID, stepID, attempt.claim) + } + settlement := l.settlementFor(key, completionID, start.Snapshot.Revision, start.Grant, completion) + settlementCtx := context.WithoutCancel(ctx) + res, err := l.commit(settlementCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command) if err != nil { if retriable(err) { + l.forgetSettlement(key) + l.forgetStart(key) return nil } return err } - l.emitCommitted(controlCtx, events, runID, res.Events) + l.forgetSettlement(key) + l.forgetStart(key) + l.emitCommitted(ctx, events, runID, res.Events) return nil } -func (l *Loop) modelRejectDisposition(priorRejects int) run.ModelRejectDisposition { - if priorRejects+1 > l.Execution.MalformedModelResultLimit { +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 } - return run.ModelRejectRetry + // 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) { @@ -319,6 +595,16 @@ func (l *Loop) invokeModel(ctx context.Context, invoker ModelInvoker, req *sdk.R // 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 { @@ -331,17 +617,9 @@ func (l *Loop) invokeModel(ctx context.Context, invoker ModelInvoker, req *sdk.R } switch p := part.(type) { case *sdk.TextDeltaPart: - _ = events.Emit(ctx, Event{ - RunID: runID, StepID: step, - Kind: EventModelTextDelta, Durability: EventProvisional, - Payload: mustJSON(p.Text), - }) + emitDelta(EventModelTextDelta, p.Text) case *sdk.ReasoningDeltaPart: - _ = events.Emit(ctx, Event{ - RunID: runID, StepID: step, - Kind: EventModelReasoningDelta, Durability: EventProvisional, - Payload: mustJSON(p.Text), - }) + emitDelta(EventModelReasoningDelta, p.Text) } case <-ctx.Done(): return sdk.ModelResult{}, ctx.Err() @@ -361,7 +639,7 @@ func (l *Loop) invokeModel(ctx context.Context, invoker ModelInvoker, req *sdk.R } // bindToolCalls validates tool-call IDs/order/shape and produces bindings -// from the frozen ToolSpecs (spec §4.1). It never calls ExecutableTool. +// 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 @@ -415,6 +693,7 @@ type startedWorker struct { grant run.ExecutionGrant base uint64 tool ExecutableTool + key startKey } func toolCallIndex(step run.ToolStep, callID run.CallID) int { @@ -426,17 +705,20 @@ func toolCallIndex(step run.ToolStep, callID run.CallID) int { return -1 } -func (l *Loop) runToolCalls(ctx, controlCtx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, eff run.StartToolCalls) error { +func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, eff run.StartToolCalls) error { runID := snapshot.State.RunID 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 := l.Execution.MaxParallel - if limit <= 0 { + limit := len(eff.CallIDs) + if l.Execution.ToolExecution == ToolExecutionSequential { limit = 1 } + if l.Execution.MaxParallel > 0 && l.Execution.MaxParallel < limit { + limit = l.Execution.MaxParallel + } var started []startedWorker for _, callID := range eff.CallIDs { if len(started) >= limit { @@ -451,6 +733,16 @@ func (l *Loop) runToolCalls(ctx, controlCtx context.Context, runtime run.Runtime continue } call := ts.Calls[i] + key := startKey{runID: runID, stepID: eff.StepID, callID: callID} + if call.Status != run.ToolPending { + if call.Status == run.ToolExecuting { + if _, ok := l.lookupStart(key); !ok { + continue + } + } else { + continue + } + } tool, resolveErr := l.Tools.Resolve(call.ToolRef) var known *run.ToolFailure @@ -458,13 +750,19 @@ func (l *Loop) runToolCalls(ctx, controlCtx context.Context, runtime run.Runtime case resolveErr != nil: known = &run.ToolFailure{Class: run.FailureToolLookup, Message: resolveErr.Error()} default: - toolDef, err := run.FreezeToolDefinition(tool.Definition()) - if err != nil { - return err + if tool == nil { + known = &run.ToolFailure{Class: run.FailureToolLookup, Message: "tool catalog returned a nil tool"} + break } - defDigest, err := run.DigestToolDefinition(toolDef) - if err != nil { - return err + toolDef, freezeErr := run.FreezeToolDefinition(tool.Definition()) + if freezeErr != nil { + known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: freezeErr.Error()} + break + } + defDigest, digestErr := run.DigestToolDefinition(toolDef) + if digestErr != nil { + known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: digestErr.Error()} + break } switch { case tool.Ref() != call.ToolRef || defDigest != call.DefinitionDigest: @@ -479,54 +777,88 @@ func (l *Loop) runToolCalls(ctx, controlCtx context.Context, runtime run.Runtime } if known != nil { // Known failure of a Pending call: no start barrier, no tool call. - res, err := l.commit(controlCtx, runtime, runID, freshCommandID(), snapshot.Revision, "", + res, err := l.commit(ctx, runtime, runID, freshCommandID(), snapshot.Revision, "", run.SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: *known, Outcome: run.ToolOutcomeKnown}) if err != nil { - settleErr := l.settleWorkers(ctx, controlCtx, runtime, events, runID, eff.StepID, started) + settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started) if !retriable(err) { return err } return settleErr } - l.emitCommitted(controlCtx, events, runID, res.Events) + l.emitCommitted(ctx, events, runID, res.Events) continue } - start, err := l.commit(controlCtx, runtime, runID, freshCommandID(), snapshot.Revision, "", - run.StartToolCall{StepID: eff.StepID, CallID: callID}) + attempt := l.startFor(key) + start, err := l.commit(ctx, runtime, runID, attempt.commandID, snapshot.Revision, "", + run.StartToolCall{StepID: eff.StepID, CallID: callID, Claim: attempt.claim}) if err != nil { - settleErr := l.settleWorkers(ctx, controlCtx, runtime, events, runID, eff.StepID, started) + settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started) if retriable(err) { + // A sentinel rejection proves this start did not acquire the + // call. Drop the local claim so a later snapshot can create a + // fresh start attempt or observe the other owner. + l.forgetStart(key) return settleErr } return err } - if start.Status == run.CommitAlreadyApplied { + if start.Status == run.CommitAlreadyApplied && start.Grant == "" { + l.forgetStart(key) continue // another attempt owns this call } - l.emitCommitted(controlCtx, events, runID, start.Events) + if start.Grant == "" { + l.forgetStart(key) + return errors.New("agent: loop: start tool returned no execution grant") + } + if startedCall, ok := toolCallFromSnapshot(start.Snapshot.State, eff.StepID, callID); !ok || startedCall.Status != run.ToolExecuting { + // A replay may arrive after another worker has settled this call. + // Keep the original grant in the Runtime's replay record, but never + // invoke an effect for a call that is no longer Executing. + l.forgetStart(key) + continue + } + l.emitCommitted(ctx, events, runID, start.Events) if events != nil { - _ = events.Emit(controlCtx, Event{RunID: runID, StepID: eff.StepID, CallID: callID, + _ = events.Emit(ctx, Event{RunID: runID, StepID: eff.StepID, CallID: callID, Kind: EventToolStarted, Durability: EventCommitted}) } - started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Revision, tool: tool}) + started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Revision, tool: tool, key: key}) } - return l.settleWorkers(ctx, controlCtx, runtime, events, runID, eff.StepID, started) + return l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started) +} + +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 (spec §6.2). Tool workers do not inherit -// outer-ctx cancellation (spec §6.1); one Unknown cancels the rest. A commit -// that fails with a non-sentinel error is replayed once with the same -// CommandID (spec §6.6 "commit response unknown"); a still-failing commit is -// reported so the host does not mistake a wedged call for progress. -func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime run.Runtime, events EventSink, runID run.RunID, stepID run.StepID, started []startedWorker) error { +// 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). One Unknown cancels +// sibling workers. 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 run.Runtime, events EventSink, runID run.RunID, stepID run.StepID, started []startedWorker) error { if len(started) == 0 { return nil } - execCtx, cancelAll := context.WithCancel(context.WithoutCancel(ctx)) + // The tool sees cancellation so cooperative implementations can stop. The + // settlement path uses controlCtx, which is independent of worker + // cancellation and can record the resulting known/unknown outcome. + execCtx, cancelAll := context.WithCancel(ctx) defer cancelAll() + controlCtx := context.WithoutCancel(ctx) var mu sync.Mutex var wg sync.WaitGroup @@ -553,12 +885,17 @@ func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime run.Runtim case ToolExecutionSucceeded: cmd = run.SubmitToolResult{StepID: stepID, CallID: w.call.CallID, Result: o.Result} case ToolExecutionFailed: - cmd = run.SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, Failure: o.Failure, Outcome: run.ToolOutcomeKnown} + 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 + 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} unknown = true default: @@ -573,15 +910,20 @@ func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime run.Runtim // 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. - res, err := l.commit(controlCtx, runtime, runID, freshCommandID(), w.base, w.grant, cmd) + settlement := l.settlementFor(w.key, freshCommandID(), w.base, w.grant, cmd) + res, err := l.commit(controlCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command) switch { case err == nil: - l.emitCommitted(controlCtx, events, runID, res.Events) + l.forgetSettlement(w.key) + l.forgetStart(w.key) + l.emitCommitted(ctx, events, runID, res.Events) if events != nil { - _ = events.Emit(controlCtx, Event{RunID: runID, StepID: stepID, CallID: w.call.CallID, + _ = events.Emit(ctx, Event{RunID: runID, StepID: stepID, CallID: w.call.CallID, Kind: EventToolCompleted, Durability: EventCommitted}) } case retriable(err): + l.forgetSettlement(w.key) + l.forgetStart(w.key) // Terminal/stale: the authority already settled this call or // the run; the result is intentionally dropped. default: @@ -590,7 +932,7 @@ func (l *Loop) settleWorkers(ctx, controlCtx context.Context, runtime run.Runtim } } if unknown { - cancelAll() // one Unknown cancels sibling workers (spec §6.2) + cancelAll() // one Unknown cancels sibling workers (RUN-LOP-4) } }(w) } diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go index 1901ef3..058fcd2 100644 --- a/agent/run/loop/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "sync" "sync/atomic" "testing" @@ -35,6 +36,17 @@ func (f *fakeInvoker) Generate(ctx context.Context, req sdk.Request) (sdk.ModelR 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) Resolve(ModelRef) (ModelInvoker, error) { return c.invoker, nil } @@ -87,6 +99,22 @@ func (p staticPlanner) Plan(_ context.Context, hint PlanningHint) (RequestPlan, return RequestPlan{Model: model, Request: req, InputIDs: ids, Tools: p.specs}, nil } +type resultCheckingPlanner struct { + specs []ToolSpec + saw atomic.Bool +} + +func (p *resultCheckingPlanner) Plan(ctx context.Context, hint PlanningHint) (RequestPlan, error) { + if hint.LastToolStep != nil { + for _, call := range hint.LastToolStep.Calls { + if call.CallID == "c1" && call.Status == ToolCompleted && call.Result != nil && call.Result.Output.String() == `{"x":1}` { + p.saw.Store(true) + } + } + } + return staticPlanner{specs: p.specs}.Plan(ctx, hint) +} + func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { t.Helper() def := sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} @@ -139,6 +167,29 @@ func TestLoopSingleModelCallCompletes(t *testing.T) { } } +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, "run-1", nil) + done <- runErr + }() + <-invoker.started + if _, err := loop.Run(context.Background(), rt, "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) + } +} + func TestLoopToolRoundTrip(t *testing.T) { spec := toolSpec(t, "echo", DirectExecution) echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, @@ -147,8 +198,9 @@ func TestLoopToolRoundTrip(t *testing.T) { }} invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("done")}} rt := loopRuntime(t) + planner := &resultCheckingPlanner{specs: []ToolSpec{spec}} loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, - staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) + planner, ExecutionPolicy{}, false) res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { @@ -160,6 +212,9 @@ func TestLoopToolRoundTrip(t *testing.T) { if invoker.calls.Load() != 2 { t.Fatalf("model calls = %d, want 2", invoker.calls.Load()) } + if !planner.saw.Load() { + t.Fatal("second planning call did not receive the completed tool result") + } // Usage accumulated across both model steps. if res.Result.Usage.TotalTokens != 3 { t.Fatalf("usage = %d, want 3", res.Result.Usage.TotalTokens) @@ -195,7 +250,7 @@ func TestLoopApprovalWaitsAndResumes(t *testing.T) { t.Fatalf("kind = %v", wait.Kind) } - // Ingress approves via the derived response command id (spec §5.7). + // Ingress approves via the derived response command ID (RUN-WIR-3). cmdID := DeriveResponseCommandID(wait.RunID, wait.StepID, wait.CallID, wait.ID) env, err := BuildEnvelope(wait.RunID, cmdID, ApproveToolCall{ StepID: wait.StepID, CallID: wait.CallID, ResponseID: wait.ID, @@ -350,6 +405,144 @@ func TestLoopParallelBounded(t *testing.T) { } } +type staleCommitRuntime struct{ Runtime } + +func (staleCommitRuntime) Commit(context.Context, CommitRequest) (CommitResult, error) { + return CommitResult{}, ErrStaleRuntime +} + +func TestToolStartStaleDropsLocalClaim(t *testing.T) { + spec := toolSpec(t, "echo", DirectExecution) + args := cj(`{}`) + bindingDigest, err := DigestToolCallBinding("c1", spec.DefinitionDigest, spec.Policy, args) + if err != nil { + t.Fatal(err) + } + echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), 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: "c1", ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, + BindingDigest: bindingDigest, Arguments: args, Policy: DirectExecution, Status: ToolPending, + }}, + }, + }, Revision: 1} + + if err := loop.runToolCalls(context.Background(), staleCommitRuntime{}, nil, snapshot, + StartToolCalls{StepID: stepID, CallIDs: []CallID{"c1"}}); err != nil { + t.Fatal(err) + } + if _, ok := loop.lookupStart(startKey{runID: "run-1", stepID: stepID, callID: "c1"}); ok { + t.Fatal("stale tool start retained a local execution claim") + } +} + +type responseLossRuntime struct { + *MemoryRuntime + mu sync.Mutex + count map[CommandID]int + loseModelStart bool +} + +func newResponseLossRuntime(t *testing.T) *responseLossRuntime { + t.Helper() + base := loopRuntime(t) + return &responseLossRuntime{MemoryRuntime: base, count: make(map[CommandID]int)} +} + +func (r *responseLossRuntime) Commit(ctx context.Context, req CommitRequest) (CommitResult, error) { + result, err := r.MemoryRuntime.Commit(ctx, 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, "run-1", nil); err == nil { + t.Fatal("first run unexpectedly completed after lost start responses") + } + snapshot, err := rt.Load(context.Background(), "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 local claim is retained. + if current, ok := snapshot.State.Current.(ModelStep); !ok || current.Status != ModelExecuting { + t.Fatalf("current = %#v, want Executing ModelStep", snapshot.State.Current) + } + if _, err := loop.Run(context.Background(), rt, "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: spec.Definition.SDK(), 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, "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, "run-1", nil) + if err != nil { + t.Fatal(err) + } + if res.Disposition != LoopFinished || res.Result == nil || res.Result.Model.Text != "done" { + t.Fatalf("result = %+v", res) + } + if executions.Load() != 1 { + t.Fatalf("tool executions after replay = %d, want 1", executions.Load()) + } +} + func TestLoopCtxCancelReturnsWithoutFailingRun(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) invoker := &fakeInvoker{errs: []error{context.Canceled}} @@ -378,9 +571,7 @@ func TestLoopCtxCancelReturnsWithoutFailingRun(t *testing.T) { } } -func TestLoopModelStepLimitStopsBeforePlanning(t *testing.T) { - // Simulate the snapshot-only limit condition without reaching into - // MemoryRuntime internals from this package. +func TestLoopModelStepLimitCompatibilityFieldDoesNotAffectRun(t *testing.T) { rt := NewMemoryRuntime() newRun, err := BuildNewRun("run-1", "") if err != nil { @@ -389,20 +580,27 @@ func TestLoopModelStepLimitStopsBeforePlanning(t *testing.T) { if _, err := rt.Create(context.Background(), newRun); err != nil { t.Fatal(err) } - loop, err := New(fakeCatalog{}, fakeToolCatalog{}, panicPlanner{}, ExecutionPolicy{ModelStepLimit: 1}, false) + env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", "input-1"), AcceptInput{Input: AgentInput{ID: "input-1", Payload: cj(`{"q":"hi"}`)}}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: 0, Command: env}); err != nil { + t.Fatal(err) + } + loop, err := New(fakeCatalog{&fakeInvoker{results: []sdk.ModelResult{textResult("completed")}}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{ModelStepLimit: 1}, false) if err != nil { t.Fatal(err) } - res, err := loop.Run(context.Background(), modelStepLimitRuntime{Runtime: rt}, "run-1", nil) + res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } - if res.Disposition != LoopFinished || res.Result == nil || res.Result.Reason != ReasonStepLimit { + if res.Disposition != LoopFinished || res.Result == nil || res.Result.Model.Text != "completed" { t.Fatalf("loop result = %+v", res) } } -func TestLoopMalformedModelResultLimitFailsRun(t *testing.T) { +func TestLoopMalformedModelResultDispositionFailsRun(t *testing.T) { rt := loopRuntime(t) bad := sdk.ModelResult{ FinishReason: sdk.FinishReasonToolCalls, @@ -413,7 +611,14 @@ func TestLoopMalformedModelResultLimitFailsRun(t *testing.T) { Usage: sdk.Usage{TotalTokens: 1}, } invoker := &fakeInvoker{results: []sdk.ModelResult{bad, bad, bad}} - loop, err := New(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{MalformedModelResultLimit: 2}, false) + 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) } @@ -436,7 +641,7 @@ func (panicPlanner) Plan(context.Context, PlanningHint) (RequestPlan, error) { } func TestLoopCancelRunViaCommand(t *testing.T) { - // Host order: commit CancelRun first, then cancel ctx (spec §6.6). + // Host order: commit CancelRun first, then cancel ctx (RUN-LOP-5). rt := loopRuntime(t) snap, _ := rt.Load(context.Background(), "run-1") env, _ := BuildEnvelope("run-1", "cancel-1", CancelRun{}) diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go index 00a4523..f32ddc8 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -13,7 +13,7 @@ import ( const testModel ModelRef = "m-1" func testConfig() RunConfig { - return RunConfig{Model: testModel, ModelRejectLimit: DefaultModelRejectLimit} + return RunConfig{Model: testModel} } func cj(raw string) CanonicalJSON { return MustParseCanonicalJSON(raw) } @@ -185,7 +185,7 @@ func TestInitializeRunIsMinimalAndLegacyConfigIsNotState(t *testing.T) { if s.RunID != "r" || s.Status != RunActive || len(s.PendingInputs) != 0 { t.Fatalf("initial state = %+v", s) } - legacy, err := Initialize("r", RunConfig{Model: "m", ModelStepLimit: 1, ModelRejectLimit: 2}, NextRun(AgentInput{ID: "i"})) + legacy, err := Initialize("r", RunConfig{Model: "m"}, NextRun(AgentInput{ID: "i"})) if err != nil { t.Fatal(err) } @@ -354,17 +354,14 @@ func TestRejectRecordsPermissionDenied(t *testing.T) { facts = mustDecide(t, s, RejectToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: respID, ResponseDigest: responseDecisionDigest(t, ResponseApproval, ResponseDecisionRejected, "no"), Reason: "no"}) - // Single call: reject closes it as Known failed and closes the step. - if len(facts) != 2 { - t.Fatalf("facts = %d, want [failed, closed]", len(facts)) + // Single call: the Known failure implicitly closes the ToolStep. + if len(facts) != 1 { + t.Fatalf("facts = %d, want [failed]", len(facts)) } failed := facts[0].(ToolCallFailed) if failed.Failure.Class != FailurePermissionDenied || failed.Outcome != ToolOutcomeKnown { t.Fatalf("failed = %+v", failed) } - if _, ok := facts[1].(ToolStepClosed); !ok { - t.Fatalf("facts[1] = %T", facts[1]) - } s = fold(t, s, facts) if s.Current != nil || s.Status != RunActive { t.Fatal("run should continue with no current step") @@ -387,8 +384,8 @@ func TestExternalResponseRequiresPayloadDigest(t *testing.T) { } facts = mustDecide(t, s, SubmitToolResponse{StepID: opened.StepID, CallID: "c1", ResponseID: respID, ResponseDigest: responsePayloadDigest(t, payload), Payload: payload}) - if len(facts) != 2 { - t.Fatalf("facts = %d, want answered + closed", len(facts)) + if len(facts) != 1 { + t.Fatalf("facts = %d, want [answered]", len(facts)) } } @@ -408,8 +405,9 @@ func TestUnknownFailureEndsRun(t *testing.T) { t.Fatalf("facts = %d, want [failed, ended]", len(facts)) } ended := facts[1].(RunEnded) - if ended.Status != RunFailed || ended.Reason != ReasonEffectUnknown || ended.Failure.CallID != "c1" { - t.Fatalf("ended = %+v", ended) + failed, ok := ended.End.(RunFailedEnd) + if !ok || failed.Reason != ReasonEffectUnknown || failed.Failure.CallID != "c1" { + t.Fatalf("ended = %+v", ended.End) } s = fold(t, s, facts) if s.Status != RunFailed { @@ -462,15 +460,15 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { } s = fold(t, s, facts) - // Answer A via approval; approving moves to Pending, then failing known - // closes the step. + // 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: "cA", ResponseID: respID, ResponseDigest: responseDecisionDigest(t, ResponseApproval, ResponseDecisionApproved, "")})) s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: "cA"})) facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: "cA", Result: ToolExecutionResult{Output: cj(`"done"`)}}) - if len(facts) != 2 { - t.Fatalf("facts = %d, want [completed, closed]", len(facts)) + if len(facts) != 1 { + t.Fatalf("facts = %d, want [completed]", len(facts)) } s = fold(t, s, facts) if s.Current != nil { @@ -541,20 +539,30 @@ func TestStopRunProducesStepLimit(t *testing.T) { t.Fatalf("facts = %d, want [ended]", len(facts)) } ended := facts[0].(RunEnded) - if ended.Status != RunStopped || ended.Reason != ReasonStepLimit { - t.Fatalf("ended = %+v", ended) + stopped, ok := ended.End.(RunStoppedEnd) + if !ok || stopped.Reason != ReasonStepLimit { + t.Fatalf("ended = %+v", ended.End) } if _, err := Decide(s, StopRun{Reason: ReasonCancelled}); err == nil { t.Fatal("StopRun accepted cancellation reason") } } +func TestStopRunRequiresSafeBoundary(t *testing.T) { + s := newRun(t, testConfig()) + s, stepID := advanceToExecuting(t, s, testRequest(), nil) + if _, err := Decide(s, StopRun{Reason: ReasonStepLimit}); err == nil { + t.Fatalf("StopRun accepted while ModelStep %q was executing", stepID) + } +} + func TestCancelProducesRunStopped(t *testing.T) { s := newRun(t, testConfig()) facts := mustDecide(t, s, CancelRun{}) ended := facts[0].(RunEnded) - if ended.Status != RunStopped || ended.Reason != ReasonCancelled { - t.Fatalf("ended = %+v", ended) + stopped, ok := ended.End.(RunStoppedEnd) + if !ok || stopped.Reason != ReasonCancelled { + t.Fatalf("ended = %+v", ended.End) } } @@ -610,7 +618,20 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { return s } prepared := func(ids ...InputID) ModelStepPrepared { - return ModelStepPrepared{StepID: "step-1", Model: testModel, Request: ModelRequest{Model: string(testModel)}, RequestDigest: "sha256:req", ToolsDigest: "sha256:tools", BindingDigest: "sha256:binding", InputIDs: ids} + request := ModelRequest{Model: string(testModel)} + requestDigest, err := DigestRequest(request) + if err != nil { + t.Fatal(err) + } + toolsDigest, err := DigestToolSpecs(nil) + if err != nil { + t.Fatal(err) + } + binding, err := DigestModelStepBinding(testModel, requestDigest, toolsDigest) + if err != nil { + t.Fatal(err) + } + return ModelStepPrepared{StepID: "step-1", Model: testModel, Request: request, RequestDigest: requestDigest, ToolsDigest: toolsDigest, BindingDigest: binding, InputIDs: ids} } t.Run("nonexistent input", func(t *testing.T) { diff --git a/agent/run/memory_conformance_test.go b/agent/run/memory_conformance_test.go index 1561244..a2539cb 100644 --- a/agent/run/memory_conformance_test.go +++ b/agent/run/memory_conformance_test.go @@ -8,7 +8,7 @@ import ( ) // MemoryRuntime is the reference implementation of the Runtime contract; it -// runs the same shared suite durable adapters import (spec §2.3, §8.2). +// runs the same shared suite durable adapters import (RUN-CMP-2). // Dependency chain: run_test -> runtimetest -> run (the net/http/httptest // layout), so the loop is broken by the external test package. func TestMemoryRuntimeConformance(t *testing.T) { diff --git a/agent/run/memory_runtime.go b/agent/run/memory_runtime.go index 7eac264..f02e3bf 100644 --- a/agent/run/memory_runtime.go +++ b/agent/run/memory_runtime.go @@ -7,13 +7,14 @@ import ( "errors" "fmt" "sync" + "time" ) // MemoryRuntime is the in-process reference Runtime. Its collection lock is // used only to create or find stable per-Run entries; each entry has an // independent lock for state, canonical commit records, revision, and grants. -// Runtime operations are context-aware before and after acquiring a lock so a -// request cancelled while waiting never reads or writes an entry. +// Runtime operations check context before and after entry access and before +// mutating the protected state. type MemoryRuntime struct { mu sync.RWMutex runs map[RunID]*memoryRun @@ -29,6 +30,27 @@ type memoryRun struct { transitions map[CommandID]TransitionRecord log []TransitionRecord grants map[string]ExecutionGrant + claims map[string]ExecutionClaim + // startGrants lets an exact start-command replay recover the capability + // that was issued before its response was lost. + startGrants map[CommandID]ExecutionGrant +} + +func lockMemory(ctx context.Context, mu *sync.Mutex) error { + for { + if mu.TryLock() { + return nil + } + timer := time.NewTimer(time.Millisecond) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return ctx.Err() + case <-timer.C: + } + } } // NewMemoryRuntime creates an empty, RunID-addressed in-process Runtime. @@ -88,6 +110,8 @@ func (m *MemoryRuntime) Create(ctx context.Context, run NewRun) (CreateResult, e initial: initial, transitions: make(map[CommandID]TransitionRecord), grants: make(map[string]ExecutionGrant), + claims: make(map[string]ExecutionClaim), + startGrants: make(map[CommandID]ExecutionGrant), } return CreateResult{Header: cloneRunHeader(stored), Created: true}, nil } @@ -100,12 +124,37 @@ func (m *MemoryRuntime) Load(ctx context.Context, runID RunID) (RuntimeSnapshot, if err != nil { return RuntimeSnapshot{}, err } - entry.mu.Lock() - defer entry.mu.Unlock() + if err := lockMemory(ctx, &entry.mu); err != nil { + return RuntimeSnapshot{}, err + } if err := checkContext(ctx); err != nil { + entry.mu.Unlock() return RuntimeSnapshot{}, err } - return RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision}, nil + snapshot := RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision} + header := cloneRunHeader(entry.header) + entry.mu.Unlock() + if err := ValidateRunHeader(&header); err != nil { + return RuntimeSnapshot{}, fmt.Errorf("agent: memory runtime: invalid header: %w", err) + } + if err := ValidateMachineState(&snapshot.State); err != nil { + return RuntimeSnapshot{}, fmt.Errorf("agent: memory runtime: invalid snapshot: %w", err) + } + return snapshot, nil +} + +func verifyRuntimeSnapshot(header *RunHeader, initial *MachineState, snapshot RuntimeSnapshot, transitions []TransitionRecord) error { + if err := ValidateRunHeader(header); err != nil { + return err + } + folded, revision, err := FoldTransitions(*initial, transitions) + if err != nil { + return err + } + if revision != snapshot.Revision || !statesEquivalent(&folded, &snapshot.State) { + return errors.New("snapshot diverges from transition log") + } + return nil } func grantKey(c AgentCommand) string { @@ -139,6 +188,17 @@ func newGrant() ExecutionGrant { return ExecutionGrant(hex.EncodeToString(b[:])) } +func (entry *memoryRun) forgetStartGrant(grant ExecutionGrant) { + if grant == "" { + return + } + for commandID, candidate := range entry.startGrants { + if candidate == grant { + delete(entry.startGrants, commandID) + } + } +} + // Commit atomically evaluates and writes the Run addressed by Command.RunID. // //nolint:gocritic // hugeParam: CommitRequest is the value DTO of the Runtime authority boundary. @@ -150,7 +210,9 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe if err != nil { return CommitResult{}, err } - entry.mu.Lock() + if err := lockMemory(ctx, &entry.mu); err != nil { + return CommitResult{}, err + } defer entry.mu.Unlock() if err := checkContext(ctx); err != nil { return CommitResult{}, err @@ -158,11 +220,10 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe key := grantKey(req.Command.Command) grantValid := key != "" && req.Grant != "" && entry.grants[key] == req.Grant - recoveryValid := false - if key != "" && req.Grant == "" { - _, occupied := entry.grants[key] - recoveryValid = !occupied + if cmd, ok := req.Command.Command.(RecoverModelExecution); ok && req.Grant != "" { + grantValid = grantValid && entry.claims[key] == cmd.Claim } + recoveryValid := false var prior *TransitionRecord if record, ok := entry.transitions[req.Command.ID]; ok { @@ -175,9 +236,21 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe } switch decision.Kind { case DecisionAlreadyApplied: + var grant ExecutionGrant + if _, ok := req.Command.Command.(StartModelExecution); ok { + candidate := entry.startGrants[req.Command.ID] + if candidate != "" && entry.grants[key] == candidate { + grant = candidate + } + } else if _, ok := req.Command.Command.(StartToolCall); ok { + candidate := entry.startGrants[req.Command.ID] + if candidate != "" && entry.grants[key] == candidate { + grant = candidate + } + } return CommitResult{Status: CommitAlreadyApplied, Snapshot: RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision}, - Events: cloneEvents(decision.Events)}, nil + Events: cloneEvents(decision.Events), Grant: grant}, nil case DecisionConflict: return CommitResult{}, ErrCommandConflict case DecisionStale: @@ -197,16 +270,28 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe entry.log = append(entry.log, stored) var minted ExecutionGrant - switch req.Command.Command.(type) { + switch cmd := req.Command.Command.(type) { case StartModelExecution, StartToolCall: minted = newGrant() entry.grants[key] = minted + switch c := any(cmd).(type) { + case StartModelExecution: + entry.claims[key] = c.Claim + case StartToolCall: + entry.claims[key] = c.Claim + } + entry.startGrants[req.Command.ID] = minted case SubmitModelResult, SubmitModelFailure, RejectModelResult, RecoverModelExecution, SubmitToolResult, SubmitToolFailure: + activeGrant := entry.grants[key] delete(entry.grants, key) + delete(entry.claims, key) + entry.forgetStartGrant(activeGrant) } if entry.state.Status.Terminal() { entry.grants = make(map[string]ExecutionGrant) + entry.claims = make(map[string]ExecutionClaim) + entry.startGrants = make(map[CommandID]ExecutionGrant) } return CommitResult{Status: CommitAccepted, Snapshot: RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision}, @@ -223,7 +308,9 @@ func (m *MemoryRuntime) Record(ctx context.Context, runID RunID) (RunRecord, err if err != nil { return RunRecord{}, err } - entry.mu.Lock() + if err := lockMemory(ctx, &entry.mu); err != nil { + return RunRecord{}, err + } if err := checkContext(ctx); err != nil { entry.mu.Unlock() return RunRecord{}, err diff --git a/agent/run/next.go b/agent/run/next.go index 84e8082..e716cda 100644 --- a/agent/run/next.go +++ b/agent/run/next.go @@ -1,7 +1,7 @@ package run // Effect is the at-most-one pending action Machine.Next derives from the -// current state (spec §3.6). Effects are never persisted; the Loop re-derives +// current state (RUN-MCH-4). Effects are never persisted; the Loop re-derives // them after every Load. type Effect interface{ effect() } @@ -26,6 +26,10 @@ func (StartToolCalls) effect() {} type WaitForResponse struct { Requests []ResponseRequest + // ExecutionRecovery is true when another call in the same ToolStep is + // still Executing. The caller must wake on either a response or recovery; + // waiting for a response does not hide the in-flight execution. + ExecutionRecovery bool } func (WaitForResponse) effect() {} @@ -39,9 +43,13 @@ type PlanningHint struct { RunID RunID SourceStep StepID Inputs []AgentInput + // LastToolStep contains the committed results of the preceding tool + // boundary, allowing the planner to construct the next model request. + LastToolStep *ToolStep + LastModelResult *ModelResult } -// Next derives the pending effect from the current state (spec §3.7.3). +// Next derives the pending effect from the current state (RUN-MCH-4). // Terminal states yield no effect; callers check Status first. // //nolint:gocritic // hugeParam: Next is a pure value-state interpreter and must not mutate MachineState. @@ -52,9 +60,11 @@ func Next(s MachineState) (Effect, error) { switch cur := s.Current.(type) { case nil: return NeedModelRequest{Hint: PlanningHint{ - RunID: s.RunID, - SourceStep: s.LastClosedStep, - Inputs: append([]AgentInput(nil), s.PendingInputs...), + RunID: s.RunID, + SourceStep: s.LastClosedStep, + Inputs: append([]AgentInput(nil), s.PendingInputs...), + LastToolStep: cloneToolStepPtr(s.LastToolStep), + LastModelResult: cloneModelResult(s.LastModelResult), }}, nil case ModelStep: if cur.Status == ModelPrepared { @@ -81,9 +91,7 @@ func Next(s MachineState) (Effect, error) { return StartToolCalls{StepID: cur.RefValue.ID, CallIDs: pending}, nil } if len(waiting) > 0 { - // Even with an Executing call alongside, the Loop waits for a - // response or an execution wake (spec §3.7.3). - return WaitForResponse{Requests: waiting}, nil + return WaitForResponse{Requests: waiting, ExecutionRecovery: executing}, nil } if executing { return WaitForExecutionRecovery{}, nil diff --git a/agent/run/protocol.go b/agent/run/protocol.go index 4f8c5c3..4cd698a 100644 --- a/agent/run/protocol.go +++ b/agent/run/protocol.go @@ -7,8 +7,9 @@ import ( "github.com/memohai/twilight/agent/es" ) -// SchemaVersion1 is the first published wire schema. Canonical encoding and -// Evolve folding semantics stay stable within a published version. +// 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 // currentSchemaVersion is what new commands and facts are written with. @@ -41,7 +42,7 @@ type AgentEvent struct { // encodeEnvelopeBody is the digest input for a command: schema version, type // discriminator and canonical command bytes. The Digest field itself, base -// revisions and grants never enter the digest (spec §5.5). +// revisions and grants never enter the digest (RUN-WIR-2). func encodeEnvelopeBody(schemaVersion uint16, typ string, body any) ([]byte, error) { return es.EncodeTypedPayload(schemaVersion, typ, body) } @@ -146,7 +147,7 @@ func DigestToolResponsePayload(payload CanonicalJSON) (Digest, error) { } // DigestRequest covers every field of a frozen ModelRequest with no exclusions -// (spec §2.1 rule 7). +// (RUN-WIR-2). // //nolint:gocritic // hugeParam: digest covers the complete immutable ModelRequest value. func DigestRequest(req ModelRequest) (Digest, error) { @@ -178,7 +179,7 @@ func DigestToolSpec(spec ToolSpec) (Digest, error) { } // DigestToolSpecs covers an ordered ToolSpec list: ref, schema, order and -// policy all participate (spec §3.7.1 rule 1). +// policy all participate (RUN-MCH-2). func DigestToolSpecs(specs []ToolSpec) (Digest, error) { body, err := encodeEnvelopeBody(currentSchemaVersion, "tool_specs", specs) if err != nil { @@ -210,7 +211,7 @@ func digestBindingSet(bindings []ToolCallBinding) (Digest, error) { } // DigestToolCallBinding covers one binding: definition, policy and canonical -// arguments plus the CallID (spec §4.2). Runtime conformance suites use this +// 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", diff --git a/agent/run/regression_test.go b/agent/run/regression_test.go index 8c2079c..cc613af 100644 --- a/agent/run/regression_test.go +++ b/agent/run/regression_test.go @@ -202,7 +202,7 @@ func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { s = fold(t, s, facts) s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: "c1"})) - // Unknown outcome with a non-effect_unknown class is illegal (spec §4.2). + // Unknown outcome with a non-effect_unknown class is illegal (RUN-MCH-2). _, err := Evolve(s, ToolCallFailed{ StepID: opened.StepID, CallID: "c1", @@ -221,7 +221,8 @@ func TestRegressionCancelReasonFixed(t *testing.T) { t.Fatal("CancelRun forged step_limit into the log") } facts := mustDecide(t, s, CancelRun{}) - if facts[0].(RunEnded).Reason != ReasonCancelled { + 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/runtime.go b/agent/run/runtime.go index 7b4900a..f9cb339 100644 --- a/agent/run/runtime.go +++ b/agent/run/runtime.go @@ -2,10 +2,10 @@ package run import "context" -// Runtime is the RunID-addressed access and atomic commit boundary. Create +// Runtime is the RunID-addressed authority and atomic commit boundary. Create // establishes immutable Revision-0 state; Load, Commit, and Record operate on -// exactly one Run. MachineState remains execution authority; planning, queues, -// and tool entry points are not hidden methods. +// exactly one Run. MachineState is the semantic state view; planning, queues, +// and tool entry points stay outside this interface. type Runtime interface { Create(context.Context, NewRun) (CreateResult, error) Load(context.Context, RunID) (RuntimeSnapshot, error) @@ -14,6 +14,9 @@ type Runtime interface { } type RuntimeSnapshot struct { + // State is a detached in-process view, not a portable persistence format. + // Durable implementations rebuild through RunHeader + TransitionRecords; + // any optimized stored snapshot uses an implementation-private codec. State MachineState // Revision counts accepted transitions; the initial state is 0. Revision uint64 @@ -38,6 +41,8 @@ type CommitResult struct { // Events is the complete event group of the transition, for Accepted and // AlreadyApplied alike. Events []AgentEvent - // Grant is returned only for an Accepted start command; empty otherwise. + // Grant is returned for an Accepted start command and for an exact replay + // while that start is still live; after settlement or terminalization the + // replay remains idempotent but Grant is empty. Grant ExecutionGrant } diff --git a/agent/run/runtime_helpers_test.go b/agent/run/runtime_helpers_test.go index d84e9df..f37c673 100644 --- a/agent/run/runtime_helpers_test.go +++ b/agent/run/runtime_helpers_test.go @@ -46,6 +46,7 @@ func memoryEntry(t testing.TB, rt *MemoryRuntime) *memoryRun { func commitCmd(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) (CommitResult, error) { t.Helper() + cmd = withTestExecutionClaim(id, cmd) env, err := BuildEnvelope("run-1", id, cmd) if err != nil { t.Fatal(err) @@ -53,6 +54,24 @@ func commitCmd(t *testing.T, rt Runtime, id CommandID, base uint64, grant Execut return rt.Commit(context.Background(), CommitRequest{BaseRevision: base, Grant: grant, Command: env}) } +func withTestExecutionClaim(id CommandID, cmd AgentCommand) AgentCommand { + claim := ExecutionClaim("test-claim/" + string(id)) + switch c := cmd.(type) { + case StartModelExecution: + if c.Claim == "" { + c.Claim = claim + } + return c + case StartToolCall: + if c.Claim == "" { + c.Claim = claim + } + return c + default: + return cmd + } +} + func mustCommit(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) CommitResult { t.Helper() res, err := commitCmd(t, rt, id, base, grant, cmd) diff --git a/agent/run/runtimetest/conformance.go b/agent/run/runtimetest/conformance.go index ed9739d..0a070d4 100644 --- a/agent/run/runtimetest/conformance.go +++ b/agent/run/runtimetest/conformance.go @@ -7,7 +7,7 @@ // } // // The suite exercises only the public run API, so it holds for any Runtime -// that honors the contract (spec §14.3): command idempotency, revision/index +// that honors the contract (RUN-CMP-2): command idempotency, revision/index // assignment, grant lifecycle, call-local rebase, prepare hard-CAS, terminal // arbitration, and replay-fold equivalence. package runtimetest @@ -44,6 +44,7 @@ func RunConformance(t *testing.T, newRuntime Factory) { t.Run("IdempotentReplay", func(t *testing.T) { testIdempotentReplay(t, newRuntime) }) t.Run("RevisionAndIndex", func(t *testing.T) { testRevisionAndIndex(t, newRuntime) }) t.Run("StartGrantLifecycle", func(t *testing.T) { testStartGrantLifecycle(t, newRuntime) }) + t.Run("DerivedReplayIgnoresNewBaseRevision", func(t *testing.T) { testDerivedReplayIgnoresNewBaseRevision(t, newRuntime) }) t.Run("CallLocalRebase", func(t *testing.T) { testCallLocalRebase(t, newRuntime) }) t.Run("PrepareDerivedIdentity", func(t *testing.T) { testPrepareDerivedIdentity(t, newRuntime) }) t.Run("PrepareIsHardCAS", func(t *testing.T) { testPrepareIsHardCAS(t, newRuntime) }) @@ -401,6 +402,7 @@ func (c *conformanceCase) load() run.RuntimeSnapshot { func (c *conformanceCase) commit(id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) (run.CommitResult, error) { c.t.Helper() + cmd = withTestExecutionClaim(id, cmd) env, err := run.BuildEnvelope(c.runID, id, cmd) if err != nil { c.t.Fatal(err) @@ -412,6 +414,24 @@ func (c *conformanceCase) commit(id run.CommandID, base uint64, grant run.Execut return res, err } +func withTestExecutionClaim(id run.CommandID, cmd run.AgentCommand) run.AgentCommand { + claim := run.ExecutionClaim("test-claim/" + 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 + } +} + func (c *conformanceCase) mustCommit(id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) run.CommitResult { c.t.Helper() res, err := c.commit(id, base, grant, cmd) @@ -599,15 +619,26 @@ func testRevisionAndIndex(t *testing.T, newRuntime Factory) { func testStartGrantLifecycle(t *testing.T, newRuntime Factory) { c, stepID, grant := preparedCase(t, newRuntime, nil, nil) + // The authority rejects an unbound start before it can mint ownership. + empty, err := run.BuildEnvelope(c.runID, "empty-claim", run.StartModelExecution{StepID: stepID}) + if err != nil { + t.Fatal(err) + } + if _, err := c.rt.Commit(context.Background(), run.CommitRequest{BaseRevision: 1, Command: empty}); !errors.Is(err, run.ErrCommandConflict) { + t.Fatalf("empty start claim err = %v, want ErrCommandConflict", err) + } - _, err := c.commit("done-x", 2, "", run.SubmitModelResult{StepID: stepID, Result: run.ModelResult{}}) + _, err = c.commit("done-x", 2, "", run.SubmitModelResult{StepID: stepID, Result: run.ModelResult{}}) if !errors.Is(err, run.ErrStaleRuntime) { t.Fatalf("grantless completion err = %v, want ErrStaleRuntime", err) } res := c.mustCommit("start-1", 1, "", run.StartModelExecution{StepID: stepID}) - if res.Status != run.CommitAlreadyApplied || res.Grant != "" { + if res.Status != run.CommitAlreadyApplied || res.Grant != grant { t.Fatalf("replayed start: %+v", res) } + if _, err := c.commit("start-1", 1, "", run.StartModelExecution{StepID: stepID, Claim: "different-claim"}); !errors.Is(err, run.ErrCommandConflict) { + t.Fatalf("different start claim err = %v, want ErrCommandConflict", err) + } ok, err := run.FreezeModelResult(sdk.ModelResult{Text: "ok"}) if err != nil { t.Fatal(err) @@ -616,6 +647,36 @@ func testStartGrantLifecycle(t *testing.T, newRuntime Factory) { if res.Status != run.CommitAccepted || res.Snapshot.State.Status != run.RunCompleted { t.Fatalf("completion: %+v", res.Snapshot.State.Status) } + // The command remains idempotently replayable, but its consumed grant is + // not returned after settlement. + res = c.mustCommit("start-1", 1, "", run.StartModelExecution{StepID: stepID}) + if res.Status != run.CommitAlreadyApplied || res.Grant != "" { + t.Fatalf("settled start replay: %+v", res) + } +} + +func testDerivedReplayIgnoresNewBaseRevision(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime) + snap := c.load() + input := run.AgentInput{ID: "input-1", Payload: mustJSON(`{"text":"hi"}`)} + accepted := c.mustCommit(run.DeriveInputCommandID(c.runID, input.ID), snap.Revision, "", run.AcceptInput{Input: input}) + req := request() + prep, cmdID := buildPrepareFromSnap(t, &accepted.Snapshot, &req, nil) + prepared := c.mustCommit(cmdID, accepted.Snapshot.Revision, "", prep) + start := c.mustCommit("start-derived-replay", prepared.Snapshot.Revision, "", run.StartModelExecution{StepID: prep.StepID}) + ok, err := run.FreezeModelResult(sdk.ModelResult{Text: "done"}) + if err != nil { + t.Fatal(err) + } + c.mustCommit("finish-derived-replay", start.Snapshot.Revision, start.Grant, run.SubmitModelResult{StepID: prep.StepID, Result: ok}) + current := c.load() + // The original prepare command was derived from accepted.Snapshot.Revision. + // A retry after later progress must still replay by command identity rather + // than fail because it was sent with the current base revision. + replayed, err := c.commit(cmdID, current.Revision, "", prep) + if err != nil || replayed.Status != run.CommitAlreadyApplied { + t.Fatalf("prepare replay after progress = %+v, %v", replayed, err) + } } func testCallLocalRebase(t *testing.T, newRuntime Factory) { @@ -663,8 +724,8 @@ func testPrepareDerivedIdentity(t *testing.T, newRuntime Factory) { req := request() prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) - if _, err := c.commit("wrong-prepare-id", snap.Revision, "", prep); err == nil { - t.Fatal("PrepareModelRequest accepted a non-derived CommandID") + if _, err := c.commit("wrong-prepare-id", snap.Revision, "", prep); !errors.Is(err, run.ErrCommandConflict) { + t.Fatalf("wrong prepare id err = %v, want ErrCommandConflict", err) } bad := prep @@ -791,6 +852,9 @@ func stateComparable(s *run.MachineState) map[string]any { "usage": s.Usage, "pendingInputs": s.PendingInputs, "lastModelResult": s.LastModelResult, "result": s.Result, } + if s.LastToolStep != nil { + m["lastToolStep"] = s.LastToolStep + } switch cur := s.Current.(type) { case run.ModelStep: m["modelStep"] = cur diff --git a/agent/run/state.go b/agent/run/state.go index a7402bc..44428a9 100644 --- a/agent/run/state.go +++ b/agent/run/state.go @@ -48,22 +48,13 @@ type RunResult struct { // Deprecated: use InitializeRun and submit seed input with AcceptInput. type RunConfig struct { Model ModelRef `json:"model"` - // ModelStepLimit is a legacy fixed-run policy. New code should use - // ExecutionPolicy.ModelStepLimit or host-owned policy. - ModelStepLimit int `json:"modelStepLimit,omitempty"` - // ModelRejectLimit is a legacy name for malformed result retry policy. New - // code should use ExecutionPolicy.MalformedModelResultLimit. + // Deprecated compatibility policy. New code leaves limits at zero and + // applies any host policy outside the core state machine. + ModelStepLimit int `json:"modelStepLimit,omitempty"` ModelRejectLimit int `json:"modelRejectLimit,omitempty"` } -// DefaultMalformedModelResultLimit is the Loop default for structurally -// malformed model results before the run fails. const DefaultMalformedModelResultLimit = 2 - -// DefaultModelRejectLimit is retained for source compatibility with the old -// RunConfig field name. -// -// Deprecated: use DefaultMalformedModelResultLimit. const DefaultModelRejectLimit = DefaultMalformedModelResultLimit type StepFailure struct { @@ -218,7 +209,7 @@ type ToolCallState struct { Waiting *ResponseRequest `json:"waiting,omitempty"` } -// ValidateToolCallState rejects illegal field combinations (spec §4.2). +// 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 { @@ -251,8 +242,20 @@ func ValidateToolCallState(c ToolCallState) error { if c.Result != nil || c.Waiting != nil { return fmt.Errorf("agent: call %s: failed must have no result/waiting", c.CallID) } - if c.Failure.Outcome == ToolOutcomeUnknown && c.Failure.Failure.Class != FailureEffectUnknown { - return fmt.Errorf("agent: call %s: unknown outcome must use %s", c.CallID, FailureEffectUnknown) + 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) @@ -280,7 +283,7 @@ func (s *ToolStep) callIndex(id CallID) int { return -1 } -// MachineState is the complete semantic state of one Run (spec §3.3). +// MachineState is the complete semantic state of one Run (RUN-MCH-1). // Control metadata (owner, fence, lease, attempts, queue claims) never // appears here. type MachineState struct { @@ -291,12 +294,113 @@ type MachineState struct { ModelSteps int `json:"modelSteps"` // LastClosedStep is the most recently closed ToolStep; PlanningHint's // SourceStep is read from it at the next boundary. - LastClosedStep StepID `json:"lastClosedStep,omitempty"` + LastClosedStep StepID `json:"lastClosedStep,omitempty"` + // LastToolStep retains the most recently closed ToolStep so the planner can + // include committed tool results in the next model request. + LastToolStep *ToolStep `json:"lastToolStep,omitempty"` Usage Usage `json:"usage"` LastModelResult *ModelResult `json:"lastModelResult,omitempty"` 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") + } + seenInputs := make(map[InputID]struct{}, len(s.PendingInputs)) + for _, input := range s.PendingInputs { + if input.ID == "" { + return errors.New("agent: state: pending input has empty InputID") + } + if _, exists := seenInputs[input.ID]; exists { + return fmt.Errorf("agent: state: duplicate pending InputID %q", input.ID) + } + seenInputs[input.ID] = struct{}{} + } + if s.LastToolStep != nil { + last := s.LastToolStep + if last.RefValue.RunID != s.RunID || last.RefValue.ID == "" || last.RefValue.Digest == "" || last.Source == "" || s.LastClosedStep != last.RefValue.ID { + return errors.New("agent: state: invalid LastToolStep projection") + } + if len(last.Calls) == 0 { + return errors.New("agent: state: LastToolStep has no calls") + } + for _, call := range last.Calls { + if err := ValidateToolCallState(call); err != nil { + return err + } + if call.Status != ToolCompleted && call.Status != ToolFailed { + return errors.New("agent: state: LastToolStep contains a live call") + } + } + } else if s.LastClosedStep != "" { + return errors.New("agent: state: LastClosedStep has no LastToolStep") + } + + 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") + } + } else if s.Result != nil { + return errors.New("agent: state: active state has a result") + } + + switch current := s.Current.(type) { + case nil: + 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) + } + case ToolStep: + if current.RefValue.RunID != s.RunID || current.RefValue.ID == "" || current.RefValue.Digest == "" || current.Source == "" || len(current.Calls) == 0 { + return errors.New("agent: state: invalid current ToolStep identity") + } + seenCalls := make(map[CallID]struct{}, len(current.Calls)) + live := false + for _, call := range current.Calls { + if call.CallID == "" { + return errors.New("agent: state: current ToolStep has empty CallID") + } + if _, exists := seenCalls[call.CallID]; exists { + return fmt.Errorf("agent: state: duplicate CallID %q", call.CallID) + } + seenCalls[call.CallID] = struct{}{} + if err := ValidateToolCallState(call); err != nil { + return err + } + if call.Status == ToolPending || call.Status == ToolExecuting || call.Status == ToolWaiting { + live = true + } + } + if !live { + return errors.New("agent: state: current ToolStep has no live calls") + } + default: + return fmt.Errorf("agent: state: unknown current step %T", s.Current) + } + 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. @@ -317,12 +421,6 @@ func Initialize(run RunID, cfg RunConfig, seed RunSeed) (MachineState, error) { if cfg.Model == "" { return MachineState{}, errors.New("agent: initialize: empty RunConfig.Model") } - if cfg.ModelStepLimit < 0 { - return MachineState{}, errors.New("agent: initialize: negative ModelStepLimit") - } - if cfg.ModelRejectLimit < 0 { - return MachineState{}, errors.New("agent: initialize: negative ModelRejectLimit") - } if seed.Input.ID == "" { return MachineState{}, errors.New("agent: initialize: seed input requires an InputID") } @@ -330,6 +428,12 @@ func Initialize(run RunID, cfg RunConfig, seed RunSeed) (MachineState, error) { if err != nil { return MachineState{}, err } + if cfg.ModelStepLimit < 0 { + return MachineState{}, errors.New("agent: initialize: negative ModelStepLimit") + } + if cfg.ModelRejectLimit < 0 { + return MachineState{}, errors.New("agent: initialize: negative ModelRejectLimit") + } input, err := snapshotJSONStable(seed.Input) if err != nil { return MachineState{}, err diff --git a/agent/run/transition.go b/agent/run/transition.go index 1136b11..ec214b3 100644 --- a/agent/run/transition.go +++ b/agent/run/transition.go @@ -135,6 +135,9 @@ func ValidateTransitionRecord(record *TransitionRecord) error { if e.CommandID != record.CommandID || e.CommandDigest != record.CommandDigest { return fmt.Errorf("agent: transition: revision %d command identity changed within transition", record.Revision) } + if _, terminal := e.Fact.(RunEnded); terminal && i != len(record.Events)-1 { + return fmt.Errorf("agent: transition: terminal fact must be the final event") + } } if record.TransitionDigest == "" { return errors.New("agent: transition: missing digest") diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md new file mode 100644 index 0000000..88f94ac --- /dev/null +++ b/docs/design/agent-run.md @@ -0,0 +1,455 @@ +# Twilight Agent Run Protocol + +状态:设计规范 + +本文定义 `agent/run` 与 `agent/run/loop`。文中的“必须”“不得”“应该”是协议约束;canonical JSON、JCS 与 domain-separated digest 使用 `agent/jsonstable` 和 `agent/es` 的通则。 + +## 1. 范围与 authority + +```text +MachineState Run 的语义状态投影 +RunHeader + TransitionRecord[] canonical verified Run record +Runtime Run authority 与 atomic Commit boundary +loop.Loop 当前进程的 execution interpreter +``` + +`MachineState` 决定 Run 当前可执行动作。每次接受的 command 产生一个完整 `TransitionRecord`,其 facts 经 `EvolveVersion` 从 `RunHeader.InitialState` 重放后必须得到同 revision 的 `MachineState`。 + +Run 的职责分成五个相互独立的层面: + +```text +Agent Machine = Run/Step 状态与合法转移(Decide、Evolve、Next) +Agent Loop = Machine effect 的进程内解释器 +Runtime = 一个 Run 的 authority、并发校验与原子提交 +Model / Tool = 一次模型请求或一次工具调用的 effect 执行器 +Request Planner = application context 到 sdk.Request 的投影器 +``` + +Machine 处理已冻结的值和已提交的事实;Loop 解释 `Next` 产生的 transient effect;Runtime 保存并验证 Machine 的推进;Model/Tool 执行一次外部 effect;Request Planner 组装下一次模型请求。MemoryRuntime 和 durable adapter 实现同一个 Runtime contract,分别使用内存锁和数据库事务保存 authority。 + +`Step` 是 Run 的持久化恢复边界;`execution attempt` 表示某个 Loop 进程对该 Step 或 ToolCall 的一次易失执行。一个 Step 可以有多个 attempt,Machine 只接受带有效 grant 的 settlement。Attempt 的执行控制信息由 start command 的 `ExecutionClaim` 和 Runtime 返回的 opaque `ExecutionGrant` 表达。 + +每次 accepted command 产生的 `TransitionRecord.Events` 构成 canonical event plane:它与新的 MachineState 在同一 Runtime commit 中写入,按 revision/index 有序,可用于 replay、materialization 和审计。`EventSink` 转发这些已提交事件及临时 delta;canonical authority 由 Runtime.Record 提供。 + +**RUN-SCP-1** `agent/run` 拥有 Run identity、persisted frozen values、Machine、command/fact protocol、wire codec、fold、Runtime contract 与 MemoryRuntime。`agent/run/loop` 拥有 planner/model/tool ports、streaming、并发执行、EventSink 与 Loop policy;它单向依赖 `agent/run`,根 package 不依赖 loop。 + +**RUN-SCP-2** Session 长期语义、Turn→Run 协调、Run→Session materialization、Artifact、queue/claim、provider registry、权限、durable lease/fence/outbox 与产品 policy 分别由其 package 或 Application 拥有。对应 authority 为 [agent-session.md](agent-session.md)、[agent-turn.md](agent-turn.md) 与 [agent-artifact.md](agent-artifact.md)。 + +## 2. identity、persisted values 与 wire + +```go +type RunID string +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 ExecutionGrant string +type Digest = es.Digest +``` + +**RUN-WIR-1** identity 必须非空、稳定且为有效 UTF-8。`ExecutionClaim` 由 Loop 为一次 start command 生成并在该 command 的重试中保持不变,用于绑定 start command 与执行尝试。`ExecutionGrant` 是 Runtime 签发的 opaque capability,交还给签发它的 Runtime 完成 settlement。两者服务于执行授权;Run 跨 domain causation 记录在 immutable `RunHeader.CausationID`。 + +Run 持久化协议保存 run-owned frozen values。模型请求、模型结果、消息、工具定义、usage、provider metadata 与所有动态 JSON 在进入 command/fact 前,分别经 `FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`、`FreezeToolCallInput` 等入口转为纯数据和 immutable `CanonicalJSON`。Runtime 接收 agent-owned value;调用方负责在边界前完成冻结。 + +**RUN-WIR-2** 当前 pre-release schema v1 使用 RFC 8785/JCS canonical bytes。字段、omission、array order、type discriminator、digest preimage 与 `EvolveVersion` 语义在发布前继续演进,发布后永久冻结。command、fact 和 transition decoder 必须拒绝 unknown version/type、duplicate key、unknown field、trailing data、非法 UTF-8、非 canonical-equivalent wire 与 digest mismatch。精确 identity 和 digest 使用 JSON string;当前 v1 revision/index/schema fields 使用整数 wire shape。 + +```go +type CommandEnvelope struct { + SchemaVersion uint16 + Type string + RunID RunID + ID CommandID + Digest Digest + Command AgentCommand +} +type AgentEvent struct { + SchemaVersion uint16 + Type string + RunID RunID + Revision uint64 + Index uint16 + CommandID CommandID + CommandDigest Digest + Digest Digest + Fact Fact +} +type TransitionRecord struct { + SchemaVersion uint16 + RunID RunID + Revision uint64 + CommandID CommandID + CommandDigest Digest + Events []AgentEvent + TransitionDigest Digest +} +``` + +`CommandEnvelope.Digest` 覆盖 schema、type 与完整 command;RunID、CommandID、BaseRevision 和 grant 属于 envelope/commit metadata。`AgentEvent.Digest` 覆盖 schema、type 与完整 fact。`TransitionDigest` 覆盖自身以外的完整 transition,包括有序 event group。 + +**RUN-WIR-3** 一个 transition 的 event 数至少为 1;其 RunID、Revision、CommandID、CommandDigest 相同,Index 从 0 连续递增。revision 从 1 连续递增。构造 command 必须使用 `BuildEnvelope`;构造与验证 transition 必须使用 `BuildTransitionRecord`、`ValidateTransitionRecord`。所有公开返回值具有 detached snapshot 语义。 + +下列 identity 稳定派生并由 Commit 验证: + +| identity | preimage | +|---|---| +| PrepareModelRequest CommandID | RunID、loaded BaseRevision | +| ModelStep StepID | RunID、prepare CommandID、model/request/tools binding digest | +| ToolStep StepID | source ModelStepID、ordered binding-set digest | +| ResponseID | RunID、ToolStepID、CallID、ResponseKind | +| response CommandID | RunID、StepID、CallID、ResponseID | +| input CommandID | RunID、InputID | +| recovery CommandID | RunID、StepID、Claim、CallID(如适用)、recovery kind | + +同一派生 identity 的内容变化通过 command digest 触发 conflict。`PlanningToken` 是 Application-owned opaque freshness token,属于 prepare command identity 内容;Planner 负责解释它的 freshness,`ModelStepPrepared` 保存冻结请求及其 digest。 + +## 3. 创建与 canonical record + +```go +type NewRun struct { + SchemaVersion uint16 + RunID RunID + CausationID es.CausationID +} +type RunHeader struct { + SchemaVersion uint16 + RunID RunID + InitialStateVersion uint16 + InitialState MachineState + InitialStateDigest Digest + CausationID es.CausationID + HeaderDigest Digest +} +type CreateResult struct { Header RunHeader; Created bool } +type RunRecord struct { + Header RunHeader + Snapshot RuntimeSnapshot + Transitions []TransitionRecord +} +``` + +**RUN-NEW-1** `BuildNewRun` 建立当前 version 的 creation value;`BuildRunHeaderFromNewRun` 按 `NewRun.SchemaVersion` 的冻结规则建立 header。v1 Revision-0 state 恰为:相同 RunID、`RunActive`、无 Current、无 pending input、零 model step、零 usage、无 result。初始输入必须随后通过 `AcceptInput` transition 进入 log。 + +Header 创建后 immutable。`InitialStateDigest` 覆盖 frozen initial state;`HeaderDigest` 覆盖 schema、RunID、initial-state version/digest 与 causation。`ValidateRunHeader` 验证这些约束。 + +**RUN-NEW-2** `FoldRun(header, transitions)` 先验证 header,再按 revision/index 使用 `EvolveVersion` 折叠完整 transition sequence。Fold 过程执行纯状态重建。import、diagnostic 与 `Runtime.Record` integrity verification 从 header 开始;外部 snapshot 通过 FoldRun 结果校验。 + +## 4. Machine + +```go +type MachineState struct { + RunID RunID + Status RunStatus + Current Step + PendingInputs []AgentInput + ModelSteps int + LastClosedStep StepID + LastToolStep *ToolStep + Usage Usage + LastModelResult *ModelResult + Result *RunResult +} + +type RunResult struct { + Status RunStatus + Reason RunReason + Failure *RunFailure + Model *ModelResult + Usage Usage +} +``` + +Run status 为 `RunActive | RunCompleted | RunStopped | RunFailed`。`RunCompleted`、`RunStopped` 与 `RunFailed` 是 terminal status。`RunStatus` 表示当前 MachineState 的投影;终态 fact 使用 RunEnd union 表达具体结果。`Step` 是 sealed interface,只有 `ModelStep` 与 `ToolStep`。 + +终态 fact 使用 Go 的 sealed-union 形式,终态结构由合法的 RunEnd variant 构成: + +```go +type RunEnd interface{ runEnd() } + +type RunCompletedEnd struct{} +type RunStoppedEnd struct { Reason RunReason } +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 transition 的最后一个 fact。RunStatus、RunResult 等读取模型从该 union 派生。当前 v1 wire body 使用 `status/reason/failure` 字段;codec 负责在 wire 与 union 之间做严格映射,并拒绝 `RunActive`、缺失字段或多余字段。 + +```text +ModelStep: Prepared -> Executing -> Completed + | | + +-> Recovered-+ (回到同一 frozen request 的 Prepared) + +-> Rejected (retry 回到 Prepared,或同 transition 失败 Run) + +ToolCall: + Pending -> Executing -> Completed + | | + | +-> Failed(Known|Unknown) + +-> Failed(Known) + Waiting(Approval) -> Pending | Failed(Known) + Waiting(ExternalResponse) -> Completed | Failed(Known) +``` + +**RUN-MCH-1** MachineState 保存 Run 的 execution semantics。`LastToolStep` 保存最近一个已关闭 ToolStep 的只读投影,必须与 transition log 折叠出的最后关闭 step 一致,供下一次 planner 构造模型请求。terminal state 吸收所有未幂等命令;`RunEnded` 建立唯一 terminal result。RunResult 在该 terminal transition 中建立,并由后续 snapshot/record 读取。 + +**RUN-MCH-2** `ToolCallBinding` 冻结 CallID、ToolRef、definition digest、canonical arguments、response policy 与 binding digest。已知工具使用匹配 frozen ToolSpec 的 ref/digest/policy;未知工具保留为同名 unresolved DirectExecution binding,并在执行前收束为已知 lookup failure。approval/external response 的 `ResponseRequest` 由 Decide 稳定派生。Unknown outcome 使用 `effect_unknown`,并使 Run 进入 `RunFailed(effect_unknown)`;后续处理由新的 Run 决定。 + +`AgentCommand` 与 `Fact` 都是 sealed interface。v1 的 command→fact 规则为: + +| command | precondition / facts | +|---|---| +| `AcceptInput` | 无 Current;`InputAccepted` | +| `PrepareModelRequest` | 无 Current,完整有序消费 PendingInputs,request/tools digests 有效;`ModelStepPrepared` | +| `StartModelExecution` | Model Prepared;`ModelStepStarted`。command 必须携带本次 start 的 `ExecutionClaim` | +| `RecoverModelExecution` | Model Executing;`ModelStepRecovered`。恢复 durable attempt 时携带该 attempt 的 `Claim` | +| `SubmitModelResult` | Model Executing;`ModelStepCompleted`,随后无 calls 时 `RunEnded(completed)`,有 calls 时 `ToolStepOpened` | +| `SubmitModelFailure` | Model Executing;`RunEnded(failed/provider_failure)` | +| `RejectModelResult` | Model Executing;`ModelStepRejected`,由调用方显式选择回到 Prepared 或在同一 transition 追加 `RunEnded(failed/malformed_model_result)` | +| `StartToolCall` | Tool Pending;`ToolCallStarted`。command 必须携带本次 start 的 `ExecutionClaim` | +| `SubmitToolResult` | Tool Executing;`ToolCallCompleted`,最后一个 call 进入 terminal 时隐式关闭 ToolStep | +| `SubmitToolFailure(Known)` | Tool Pending/Executing;`ToolCallFailed(Known)`,最后一个 call 进入 terminal 时隐式关闭 ToolStep | +| `SubmitToolFailure(Unknown)` | Tool Executing;`ToolCallFailed(Unknown)`、`RunEnded(failed/effect_unknown)` | +| `ApproveToolCall` | Waiting(Approval);`ToolCallApproved` | +| `RejectToolCall` | Waiting(Approval/ExternalResponse);`ToolCallFailed(Known/permission_denied)`,最后一个 call 进入 terminal 时隐式关闭 ToolStep | +| `SubmitToolResponse` | Waiting(ExternalResponse);`ToolCallAnswered`,最后一个 call 进入 terminal 时隐式关闭 ToolStep | +| `CancelRun` | active;`RunEnded(stopped/cancelled)` | + +`ToolStepClosed` 作为旧 v1 transition 的兼容 fact 保留;新 command 在最终 ToolCall fact 中完成 ToolStep 的关闭。 + +**RUN-MCH-3** `Decide(state, command)` 执行全部验证与 derived consequence,一次返回该 transition 的完整 ordered fact group;验证成功后返回完整 facts。`EvolveVersion(version,state,fact)` 机械折叠 fact,依赖 fact 携带的完整数据。accepted facts 必须 self-contained;若 transition 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 +} +``` + +Loop 在提交 start 前生成并保留 `Claim`、`CommandID` 与 command digest。提交响应丢失时,Loop 以完全相同的三者重放;Runtime 对精确重放返回原 `ExecutionGrant`。恢复同一模型 attempt 时,`RecoverModelExecution` 携带原 `Claim`,其 CommandID 由 `(RunID, StepID, Claim)` 派生。若 Run 已记录 Executing 而 Loop 已丢失这些值,Runtime 的 recovery authority 处理该 execution,Loop 根据 recovery 结果继续。 + +`Next(state)` 最多返回一个 transient `Effect`: + +| state | effect | +|---|---| +| terminal | 无 | +| Current=nil | `NeedModelRequest{PlanningHint}` | +| Model Prepared | `StartModelCall` | +| Model Executing | `WaitForExecutionRecovery` | +| ToolStep 有 Pending calls | `StartToolCalls` | +| ToolStep 无 Pending、含 Waiting 且无 Executing | `WaitForResponse` | +| ToolStep 含 Waiting 且含 Executing | `WaitForResponse{ExecutionRecovery:true}` | +| ToolStep 含 Executing 且无 Waiting | `WaitForExecutionRecovery` | + +**RUN-MCH-4** Effect 由调用方每次 Load 后重新派生。`PrepareModelRequest.InputIDs` 必须与当前 PendingInputs 等长、同顺序、逐项相同;prepare 接受后一次消费全部 pending input。ToolStep 的 Waiting call 允许同一 step 中仍可执行的 Pending call 继续运行。Waiting 与 Executing 同时存在时返回 `WaitForResponse{ExecutionRecovery:true}`,让宿主同时响应用户输入和执行恢复;仅存在 Executing 时返回 `WaitForExecutionRecovery`。恢复唤醒由 Runtime/application 的 recovery authority 提供。 + +## 5. Runtime 与 Commit + +```go +type Runtime interface { + Create(context.Context, NewRun) (CreateResult, error) + Load(context.Context, RunID) (RuntimeSnapshot, error) + Commit(context.Context, CommitRequest) (CommitResult, error) + Record(context.Context, RunID) (RunRecord, error) +} +type RuntimeSnapshot struct { + State MachineState // detached in-process view + Revision uint64 +} +type CommitRequest struct { + BaseRevision uint64 + Grant ExecutionGrant + Command CommandEnvelope +} +type CommitResult struct { + Status CommitStatus // CommitAccepted | CommitAlreadyApplied + Snapshot RuntimeSnapshot + Events []AgentEvent + Grant ExecutionGrant +} +``` + +**RUN-CMT-1** Runtime 是 RunID-addressed collection。`Create` 原子保存 immutable header 与 Revision-0 state;相同 canonical header 幂等返回 `Created=false`,同 RunID 的不同 header 返回 `ErrCreateConflict`。缺失 Run 的 Load、Commit、Record 返回 `ErrRunNotFound`。 + +**RUN-CMT-2** `Load` 返回当前 detached execution snapshot,并验证 `RunID`、revision 与 MachineState 的基本语义不变量;snapshot 与 revision 必须来自同一 authority 版本。`RuntimeSnapshot` 提供 Go 读取视图;跨实现持久化使用 Header 与 TransitionRecord,优化 snapshot 由实现管理。`Record` 在一个一致点读取 detached Header、Snapshot 和完整 TransitionRecord sequence,验证 header、每个 transition、连续 fold 与 snapshot 等价后返回;corrupt、gap 或 divergence 必须失败。普通消费者使用 Record 获取一致的完整记录。`FoldRun` 从 Header 和完整 transition sequence 重建状态;任何导入、诊断或 Record 校验先完成 FoldRun,再使用重建状态。 + +所有 Runtime implementation 在自己的 critical section/transaction 内调用同一个 pure `EvaluateCommit`。顺序固定为: + +```text +1 validate envelope RunID/schema/type/digest and derived CommandID +2 lookup prior transition by CommandID +3 exact digest replay -> AlreadyApplied + original complete event group +4 same CommandID/different digest -> conflict +5 terminal check +6 validate hard CAS / target state / execution grant / recovery authority +7 facts = Decide(current, command) exactly once +8 snapshot each fact; EvolveVersion in order; assign next Revision and Index +9 build and validate one complete TransitionRecord +10 atomically persist transition and new MachineState +``` + +**RUN-CMT-3** `PrepareModelRequest` 是 hard-CAS command:BaseRevision 必须等于 current revision。其他 command 通过当前 target state 和 grant 做 call-local rebase;stale BaseRevision 本身不阻止无冲突的 ingress/control/settlement。相同 command 的 replay 判定先于 terminal check,因此 terminal Run 仍能返回原 transition。 + +**RUN-CMT-4** 幂等键为 `(RunID,CommandID)`。相同 digest 返回 `CommitAlreadyApplied`、当前 snapshot 与原完整 event group,且不得再次 Decide、分配 revision 或产生外部 effect。对于 `StartModelExecution` 和 `StartToolCall`,Runtime 还必须验证 command 中的 `ExecutionClaim`:相同 command ID、相同 digest、相同 claim 的精确重放在 grant 仍 live 时返回原 start grant;不同 claim 触发 `ErrCommandConflict`,并保持现有执行授权。非 start command 的 replay 不返回 grant。revision 每个 accepted command 恰加 1。 + +**RUN-CMT-5** accepted `StartModelExecution`/`StartToolCall` 为目标签发新 grant;该 start 的 `CommitAccepted` 和在 grant 仍 live 时满足精确 replay 条件的 `CommitAlreadyApplied` 返回同一个 grant。若该 start 已 settlement 或 Run 已 terminal,精确 replay 仍返回 `CommitAlreadyApplied`,并返回空 grant。model result/failure/reject 与 executing tool result/known failure 必须携带 live target grant。settlement 接受后 grant 失效;terminal transition 撤销全部 grant。公共 Runtime 的 `RecoverModelExecution` 由 live grant holder 提交;durable 实现内部可以在自己验证 execution recovery record 后无 grant 提交。Executing tool 的 recovery 使用同一条 `SubmitToolFailure{Outcome:Unknown}` command:工具 owner 必须携带 live grant;recovery scanner 仅在 Runtime 验证其 lease/claim 已失效且没有已接受 settlement 时无 grant 提交。scanner 使用上表的 deterministic recovery CommandID,因而 recovery update 也遵守同一 `(RunID, CommandID)` 幂等规则。durable adapter 保存 `(command ID, claim) -> grant` 精确 replay 所需的私有 start record;attempt、owner、fence、lease 与 recovery record 由 adapter 内部管理。 + +**RUN-CMT-6** Commit 必须原子保存新 MachineState 与完整 TransitionRecord,保证 event group 完整写入。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 + +`MemoryRuntime` 提供 multi-Run in-process reference implementation:collection lock 保护 Run map,每个 Run 使用独立锁。跨进程 durable recovery 由其他 Runtime adapter 提供。 + +## 6. Loop ports 与 policy + +```go +// package agent/run/loop +type RequestPlanner interface { + Plan(context.Context, run.PlanningHint) (RequestPlan, error) +} +type PlanningHint struct { + RunID RunID + SourceStep StepID + Inputs []AgentInput + LastToolStep *ToolStep + LastModelResult *ModelResult +} +type RequestPlan struct { + Model run.ModelRef + Request sdk.Request + InputIDs []run.InputID + PlanningToken run.PlanningToken + Tools []run.ToolSpec +} +type ModelCatalog interface { Resolve(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 { Resolve(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 // waiting_for_response | execution_recovery + Waiting []run.ResponseRequest + 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, run.RunID, EventSink) (LoopResult, error) +``` + +**RUN-LOP-1** `ExecutionPolicy` 是 Loop 的本地执行策略,包含 `ToolExecution`、`MaxParallel` 和可选的 malformed-result handler;未指定 `ToolExecution` 时使用 `parallel`,正数 `MaxParallel` 限制当前 ToolStep 的本地 worker 数量,零值允许当前批次的所有可执行 call 并行。nil handler 时结构错误的模型结果选择 `ModelRejectFailRun`;重试由 handler 明确返回 `ModelRejectRetry`。`streaming` 表示是否请求可用的流式模型端口;两种模式都产生同一完整 `sdk.ModelResult`。 + +`LoopResult` 的语义固定为:`LoopWaiting` 时 `Result` 为 nil;`Waiting` 列出当前 snapshot 中的 response requests,`ExecutionRecovery` 表示还存在需要 recovery authority 处理的 executing call。`Reason` 提供兼容读取;两者同时存在时使用 `ExecutionRecovery` 唤醒优先级并保留 `Waiting`。`LoopFinished` 时 `Result` 非 nil,并等于 terminal RunRecord 派生的 `RunResult`。 + +`RequestPlanner` 从 `PlanningHint` 接收 Run 边界事实;它使用自己注入的 history、session context、memory、attachments 与 product policy 组装 `sdk.Request`。Runtime 验证并冻结 planner 返回的 request,Planner 管理 application context。 + +## 7. Loop execution + +```text +Loop.Run(ctx, runtime, runID, sink): + repeat: + snapshot = Runtime.Load(runID) + validate snapshot.State.RunID == runID + if terminal: emit observational run_finished; return Finished(snapshot.Result) + effect = run.Next(snapshot.State) + dispatch effect +``` + +每个 `Loop` 实例为每个 `RunID` 分配一个本地 driver slot。同一实例对同一 `RunID` 的并发 `Run` 调用返回 `ErrRunAlreadyRunning`;不同 `RunID` 可以并行驱动。 + +**RUN-LOP-2** `NeedModelRequest` 调用 Planner,冻结 sdk.Request,验证 model、ordered InputIDs 与 ToolSpecs,计算 request/tools/binding digests 和 derived CommandID/StepID,再提交 Prepare。prepare stale 后重新 Load;同 revision 的内容拒绝不得 livelock 重试。业务停止统一使用 `CancelRun`。 + +**RUN-LOP-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须保留 start command 的 ID、digest 和 claim,直到完成 settlement;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。streaming 与 non-streaming 必须产生同一种完整 `sdk.ModelResult`;delta 只发 EventSink。provider failure 提交 `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`;只有 Accepted owner 可执行。`parallel` 模式并发执行同一 ToolStep 中所有可执行的 Pending call,`sequential` 模式按 call 顺序逐个执行;每个结果以自己的 grant 提交。tool panic 或 effect 状态无法确定的错误转为 Unknown;一个 Unknown 取消同批 sibling workers,并由 Machine 在同一 terminal transition 中记录目标及仍 Executing sibling 的 `ToolCallFailed(Unknown)`,最后追加 `RunEnded(failed/effect_unknown)`。`CancelRun` 也在 `RunEnded(stopped/cancelled)` 前记录所有仍 Executing call 的 Unknown。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 + +**RUN-LOP-5** model 与 tool worker 都接收外层 ctx;Loop 对已接受 effect 使用独立 control context 完成 known/unknown outcome settlement。Application 的业务停止顺序为先 Commit `CancelRun`,再取消 Loop ctx。非 sentinel Commit error 以同 CommandID/digest 重放一次;仍未知时返回错误,由后续 Load/Record 查询 authority。stale/terminal/conflict 触发 reload/drop,旧 external effect 保持单次执行尝试。工具实现配合 context 返回;永久阻塞由 application/durable recovery 处理。 + +Waiting response 不由 Loop 自行制造业务输入。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 { + RunID run.RunID + StepID run.StepID + CallID run.CallID + Sequence uint64 + Kind EventKind + Durability EventDurability + Payload json.RawMessage + Canonical *run.AgentEvent +} +``` + +`Sequence` 仅用于同一临时观察流内的顺序(例如 ToolProgress),从 1 开始;committed observation 的权威顺序由 `AgentEvent.Revision` 与 `Index` 表达,未提供临时序号时保持 0。 + +**RUN-LOP-6** EventSink 提供 realtime observation,Loop 通过序列化调用向 sink 发送事件。`EventAgentCommitted` 携带 accepted transition 中的 canonical AgentEvent;text/reasoning delta、tool progress、tool lifecycle 与 run-finished observation 可丢失、重复或断流。sink failure 保持 Commit 结果;恢复、materialization 与审计读取 `Runtime.Record`,EventSink gap 通过 canonical record 对账。 + +Queue 只能在 `Current=nil` 的 safe boundary 提交 `AcceptInput`;Application 负责 admission 与 planning 之间的线性化。Run→Session materialization、terminal settlement、stable cross-domain IDs 与 crash recovery 由 [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 和 `EvolveVersion(1)` 由 golden fixtures 保护;发布前有意修改协议时必须同步更新 fixture。v1 发布后,新增 variant、字段或折叠语义必须进入新 schema version,并继续 decode/fold 全部已发布版本。同一 Run 的 writer 不得混写不兼容 schema。 + +**RUN-CMP-2** Runtime conformance 必须覆盖: + +- Create 首次/幂等/conflict、并发 Create、missing Run; +- command exact replay/conflict、prepare hard CAS、call-local rebase、terminal replay; +- grant 签发、隔离、精确 start replay、消费、跨 Run 拒绝与 recovery authorization; +- revision/index、atomic complete TransitionRecord、fact/transition digest; +- Load/Commit/Record alias isolation、多 Run 隔离; +- Record 单一一致点、FoldRun 等价、gap/tamper/corrupt failure。 + +Loop conformance 必须覆盖: + +- 单模型完成、tool round trip、approval/external response wait/resume; +- known failure 继续、Unknown terminal、tool panic、aliased ToolRef 与 validation; +- parallel/sequential tool execution、ctx cancellation、model recovery、explicit malformed-result disposition; +- streaming delta 与 nil result、EventSink committed observation; +- stale/unknown commit response、prepare no-progress rejection 与无 livelock。 + +历史 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 index a831d6f..776a0f4 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -1,2777 +1,134 @@ -# Twilight AI Agent Runtime 重构设计规范 +# Twilight Agent Runtime 重构记录 -状态:重构方案 +状态:迁移记录,非协议规范 -本文定义 Twilight AI 的目标 agent domain 边界:`sdk/` 负责一次模型调用,`agent/es` 提供通用 event-sourcing 机制,`agent/run` 定义单次 Run execution,`agent/session` 保存长期语义 Events,`agent/artifact` 管理不可变内容引用与 retention,`agent/session/extension` 管理静态 ontology 与语义写入,`agent/session/chatlog` 定义会话语义,`agent/turn` 协调 Turn 与 Run。Application/Product 组合这些 domain,并拥有模型、工具、权限、队列、渠道和产品 policy。 +当前 Run/Loop 协议的唯一 authority 是 [agent-run.md](agent-run.md)。本文只保留重构背景、已接受的 package 决策、完成状态与后续迁移工作;实现与本文冲突时,以各领域正式规范为准。 -当前代码中的 root `agent` 包语义上收敛为目标 `agent/run` 包;它回答“一次 computation 执行到哪里、下一步是否合法、如何 replay/recover”。本文以 Run 重构为主,并只描述其他 domain 与 Run 相接的边界;Session、Artifact、Extension、Chatlog 与 Turn 的完整协议分别由对应设计规范唯一定义。 +正式规范: -正文中的 Go 片段用于说明协议;附录 A 是 public API 草案。正文各节为规范文本,附录是汇总视图;两者不一致时以正文为准,并修订附录。 +| 领域 | 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) | +| Extension 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) | -## 1. 目标 +## 1. 背景 -### 1.1 核心目标 +重构前的 agent execution 代码混合了 SDK transport、Run state、Loop、history、queue 与 application policy,导致: -Twilight AI 的 agent 侧由多个可组合 domain 构成: +- 单次模型调用和多步 Agent execution 边界不清; +- state mutation、event persistence 和恢复路径缺少统一 authority; +- local 与 durable execution 使用不同抽象; +- queue、Session history 和 Run progress 容易形成多份长期事实; +- package 边界无法表达不同变化周期。 -```text -Application / Product - ├── model/tool/provider/permission/queue/channel policy - ├── Request Planner 与 Context materialization - └── composition - ├── agent/turn ───────────────┐ - ├── agent/session/chatlog │ - ├── agent/session/extension ├── agent/session - ├── agent/artifact │ - └── agent/run ───────────────┘ - └── sdk/model + sdk/tool boundaries -``` - -已经明确的 authority: - -| Domain | 回答的问题 | Authority 形态 | 生命周期 | -| --- | --- | --- | --- | -| `agent/session` | 这个会话长期发生过什么? | resolved committed `SessionEvent` stream | 跨多个 Run 长期存在 | -| `agent/run` | 这一次执行如何跑到当前位置? | `MachineState` 是 execution authority;`RunHeader + TransitionRecord log` 是 canonical record | 单次 Run,materialize/settle 后可按产品策略归档 | -| `agent/artifact` | 长期事件引用什么不可变内容,如何保留? | immutable `Binding` 与 `RetentionClaim` | 随 owner facts 保留 | -| `agent/turn` | 一个 Chatlog Turn 如何创建、恢复、投影和结算 Run? | Session 中的 turn facts + verified `RunRecord` | 一个 Turn 与其 primary Run | - -Session 保存跨 Run 的长期语义;Turn 协议将 Run facts materialize 为 Session Events,并在完整 coverage 后 settlement。Run records 服务执行、恢复、审计和 materialization,长期 context 从 Session projection 与 Artifact Binding 构造。 - -`agent/es` 是共享机制库,提供 canonical envelope、digest、record completeness、revision/index 校验和 fold runner;ModelStep、Session message、queue claim 与产品 policy 留在各自 owner。 - -本版本固定五个执行层级,但这些层级属于 `agent/run`: - -```text -1. Run Machine - 定义 Run、Step、ToolCall 的语义状态和合法状态变化 - -2. Run Loop - 解释 Machine 产生的 effect,执行模型/工具,再提交结果事件 - -3. Run Runtime - Create/Load/Commit/Record 按 RunID 管理 execution authority 与 canonical records - -4. Model / Tool - 执行一次模型请求或一次工具调用 - -5. Request Planner - 把 application/session/context projection 投影为下一次模型所需的 sdk.Request -``` - -这里的 “Run Machine” 是语义层名称,不引入名为 `Agent` 的核心对象;公共执行入口仍是 `Loop`。 - -整体关系: - -```text - Session Events + Artifact Bindings + Application queue/context - │ - v - Request Planner - │ sdk.Request - v - run/loop.Loop ──SDK──> Model / Tool - │ AgentCommand - v - shared run.Runtime - Create / Load / Commit / Record - / \ - run.MemoryRuntime Durable adapter - memory + locks DB + lease/fence/outbox - \ / - \ / - MachineState (execution authority) - RunHeader + TransitionRecords (canonical record) - │ verified RunRecord - v - turn.MaterializeAll -> SemanticAppender - │ - v - Chatlog/Turn Session Events + OTel -``` - -Loop 与 Runtime 的职责相互独立:Loop 决定如何执行,Runtime 决定权威状态在哪里以及如何安全提交。Machine 决定什么状态变化合法;Planner 决定模型看到的 application/session/context projection;Model/Tool 只执行一次外部 effect。 - -目标目录边界: - -```text -twilight-ai/ - sdk/ 一次 LLM request/response 的 transport 边界 - agent/ - es/ 通用 ES envelope、digest、record、fold 机制 - jsonstable/ canonical JSON value 与解析/编码 - run/ Run Machine、Runtime、Step/ToolCall - loop/ in-process Loop interpreter、planner/model/tool/event ports - artifact/ Ref、Binding、BindingSet、RetentionClaim - session/ 长期语义 Session ES kernel - extension/ 静态 Module/Catalog、SemanticAppender、projection - chatlog/ first-party Message/Input/Turn/Item ontology - turn/ Turn→Run 协调、materialization 与 settlement -``` - -依赖方向固定为: - -```text -agent/es ---> agent/jsonstable(可选) -agent/run ---> agent/es + agent/jsonstable + sdk -agent/run/loop ---> agent/run + sdk -agent/artifact ---> agent/es + agent/jsonstable -agent/session ---> agent/es + agent/jsonstable -agent/session/extension ---> agent/session + agent/artifact -agent/session/chatlog ---> agent/session/extension + agent/artifact -agent/turn ---> agent/run + agent/session + agent/session/extension + agent/session/chatlog -Application ---> sdk + 上述 domain;并组合 queue/provider/channel policy -sdk 不依赖 agent 或 Application -``` - -`agent/es`、`agent/session` 与 `agent/artifact` 保持各自 kernel 边界;Application 通过公开 capability 组合 domain,不以反向 import 共享产品语义。 - -### 1.2 核心执行模型 - -```text -Run - └── Step durable resume boundary - ├── ModelStep 一次冻结的模型请求 - └── ToolStep 一组 ToolCall 及其可恢复进度 - ├── Call A Completed - ├── Call B Waiting - └── Call C Pending - -MachineState + AgentCommand - | - Machine.Decide -> 事实序列(一次 transition 的全部决策) - | - Machine.Evolve 逐个折叠(机械,无决策) - | - v - TransitionRecord + new MachineState(同一 Revision) - | - Machine.Next -> Effect -> Loop -``` - -ToolCall 是 Step 内的外部操作单元。ToolStep 保存每个 ToolCall 中会影响恢复决策的状态。durable 实现可以为当前执行建立内部 Attempt;Attempt 可能因进程退出、超时或 lease 失效而消失,新的 Loop 仍恢复同一个 Step。 - -最重要的不变量是: - -> 一个 Step 可以有多个执行尝试,但只有被 Runtime 接受的一次状态变化能够推进权威 Run;外部工具 effect 仍可能是 at-least-once。 - -### 1.3 术语 - -| 名称 | 含义 | -| --- | --- | -| Run | 一个有身份、有权威状态和最终结果的业务执行。 | -| Loop | 当前进程执行 Run 的算法;它不保存权威状态。 | -| MachineState | Machine 的完整语义状态。 | -| Step | Run 中的 durable resume boundary。 | -| ModelStep | 一次冻结的 run-owned `ModelRequest`,直到接受模型结果。 | -| ToolStep | 一个模型结果产生的一组 ToolCall 及其 progress。 | -| ToolCall | ToolStep 内的一个结构化工具调用。 | -| AgentCommand | Loop 或外部入口希望 Machine 接受的意图;接受后构成一次 transition。 | -| AgentEvent | Runtime 已接受的单个事实;一次 transition 产出一个或多个,带 (Revision, Index) 身份,可作为外部事件流消费。 | -| TransitionRecord | Runtime 持久化的原子 authority record;封装一次 transition 的完整 AgentEvent 组和 transition digest。 | -| Revision | 每 Run 单调递增的 transition 计数;第 N 次接受的 transition 产出 Revision=N 的 TransitionRecord 和 Revision=N 的状态。 | -| Effect | Machine 根据当前状态返回的至多一个待执行动作;不表示一定有外部副作用。 | -| Attempt | Runtime 为一次进程执行建立的内部执行租约。 | -| Runtime | RunID-addressed MachineState 的 execution authority、Run creation、AgentCommand 的原子提交和 TransitionRecord 的产生者。 | -| Request Planner | application context 到 `sdk.Request` 的投影器。 | - -### 1.4 成功标准 - -| 场景 | 期望结果 | -| --- | --- | -| 单次模型调用 | 直接使用 `sdk`,不自动执行工具。 | -| 本地 Run | `loop.Loop` 配合 `run.MemoryRuntime`。 | -| durable Run | 同一个 `loop.Loop` 配合 durable runtime adapter。 | -| 模型返回 tools | ModelStep 完成后创建一个 ToolStep;ToolStep 完成前不创建下一 ModelStep。 | -| 多个 approval/用户响应 | 每个等待请求有稳定身份;一个响应只推进对应 Call。 | -| ToolStep 中途崩溃 | Completed Call 不再执行;Pending Call 可恢复;结果未知的 Executing Call 终止 Run。 | -| steer/follow-up | 只在 Memoh 的 queue-safe boundary 仲裁。 | -| commit 响应丢失 | 用同一 command identity 和 digest 重放,不重复业务副作用。 | -| worker 取消 | 只结束当前 Loop attempt;不等同于业务取消 Run。 | - -## 2. Package 与职责 - -### 2.1 `sdk/` - -sdk 只负责 LLM API: - -| 能力 | 内容 | -| --- | --- | -| provider client | 认证、provider dispatch、transport。 | -| 一次调用 | `Generate` 或 `Stream`,每次对应一个 provider request。 | -| 协议类型 | message、provider-neutral tool definition、tool call、finish reason、usage 和 metadata。 | -| stream 归一化 | 将 parts 组装成一次完整 `ModelResult`。 | -| provider 错误 | transport、rate limit、malformed stream 和 provider response error。 | -| request/result boundary | provider-neutral 的一次调用输入输出;可由 agent 在 Loop 边界冻结。 | - -`sdk.Request` 是一次模型调用的完整输入,其范围排除 session、history 与 queue。`sdk.ModelResult` 是一次完整模型响应,保留旧 `GenerateResult` 的单次调用字段(文本、reasoning parts、tool calls、finish reason、usage、sources/files 和 provider metadata);自动 tool loop、approval 与多次调用累加由 agent/application 另行保存。 - -`sdk` 类型只允许存在于 Planner/ModelInvoker/provider 边界;不得作为 Run event、MachineState、AgentCommand 或 digest input 的持久化形态。Loop 必须在提交前把 `sdk.Request`、`sdk.ModelResult` 和 `sdk.ToolDefinition` 分别转换成 run-owned 的 `ModelRequest`、`ModelResult` 和 `ToolDefinition`;调用 provider/tool 前再由这些 frozen value 构造新的 `sdk` 值。 - -run-owned 冻结形态遵守以下规则;`FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`、`DigestRequest`、StepID 派生和提交幂等都建立在这套规则上: - -1. 冻结值是纯数据。它不包含 provider client、接口值、回调或 `Execute` 句柄;模型以 provider 作用域内的字符串 ID 表示,provider 绑定发生在 `ModelCatalog`/`ModelInvoker` 解析时。 -2. 工具以 provider-neutral 的 `ToolDefinition{Name, Description, Parameters}` 表示;`Parameters` 是解析完成并 canonicalized 的 JSON Schema 文档。由 Go struct 推导 schema 的工作在冻结前完成,冻结后的 Request 不依赖推导或反射。 -3. `ToolChoice` 是封闭类型 `{Mode: auto|none|required|tool, Tool string}`,不使用 `any`。 -4. 消息 part 是 sealed union 的 agent value(text/reasoning/image/file/tool-call/tool-result),不持久化 `sdk.MessagePart` interface。 -5. 消息中的二进制内容有两种形式:inline bytes(canonical 编码为 base64),或稳定的内容寻址引用 `BlobRef{Digest, MediaType, ByteSize}`。`BlobRef` 的字节解析由组装 `ModelInvoker` 的一方负责;带时效的 URL 等不稳定引用不能进入冻结请求。两种形式产生不同的 digest,Planner 对同一内容必须确定性地选择一种形式。 -6. provider metadata、provider options、tool input/result、response format schema 等扩展字段的值必须是 JSON 值。进入 `agent/run` 前必须按 RFC 8785/JCS parse/canonicalize 成 opaque `CanonicalJSON`(由 `agent/jsonstable.Value` 承载,内部 bytes 不可被 run core 或 caller 直接构造/修改);不能保存 caller-owned map/slice/RawMessage 或 `any`。JSON number 采用 IEEE-754 binary64 语义,精确 ID 或任意精度数值必须编码为 JSON string;canonicalization 必须拒绝会把不同 payload 折叠成同一值的输入,包括重复 object key、trailing data、invalid UTF-8 和 escaped lone surrogate(`\ud800`..`\udfff`)。 -7. `DigestRequest` 覆盖 frozen `ModelRequest` 的全部字段,不设排除项。cache 配置等只影响成本的字段同样参与摘要;排除任何字段都会把不同请求判成同一事件,产生错误的 `CommitAlreadyApplied`。 - -边界的 SDK 类型可以保持以下形态;agent 的持久化 `ModelRequest`/`ToolDefinition` 是相同语义的 concrete mirror,字段中所有接口/any/JSON 原文先在 `Freeze*` 中 canonicalize 为 `CanonicalJSON`。 - -```go -package sdk - -type Request struct { - Model string // provider 作用域内的模型 ID - System string - Messages []Message // parts 为纯数据;二进制为 inline bytes 或 BlobRef - Tools []ToolDefinition - ToolChoice ToolChoice - ResponseFormat *ResponseFormat - Temperature *float64 - TopP *float64 - MaxTokens *int - StopSequences []string - FrequencyPenalty *float64 - PresencePenalty *float64 - Seed *int - ReasoningEffort *string - ReasoningSummary *string - PromptCacheKey *string - ProviderOptions map[string]json.RawMessage // 按 provider namespace 存放 -} - -type ToolDefinition struct { - Name string - Description string - Parameters json.RawMessage // 解析完成的 JSON Schema - CacheControl *CacheControl -} - -type ToolChoiceMode string // "auto" | "none" | "required" | "tool" - -type ToolChoice struct { - Mode ToolChoiceMode - Tool string // Mode == "tool" 时的目标工具名 -} - -type BlobRef struct { - Digest string // sha256:,内容寻址 - MediaType string - ByteSize int64 -} -``` - -当前 `GenerateParams` 的 `Model *Model`、`Tool.Execute`、`Tool.Parameters any` 和 `ToolChoice any` 都不满足这些规则;阶段 A 实现上述新类型,legacy wrapper 在边界处完成新旧转换。 - -`MachineState.LastModelResult` 保留最近一次已接受的模型响应;终态时复制到 `RunResult.Model`,其中的 `Text` 对应旧 SDK 调用者看到的 final message。完整的 assistant/tool history 由 application 的 history projection 保存,不重复塞入 `RunResult`。 - -provider transport 的短暂失败可以由 sdk/provider client 在一次调用内部重试;agent 只看到最终的 `ModelResult` 或 provider error。重试次数和退避属于 sdk/provider 配置。 - -新的单次调用入口是 `sdk.Generate(ctx, model, Request)` / `(*Model).Generate(ctx, Request)` 和对应的 `Stream` / `(*Model).Stream`,返回 `ModelResult` / `ModelStream`,不执行工具、不累计多步状态。provider 可以选择实现 additive `ModelInvoker` / `StreamingModelInvoker`;未实现时 SDK 通过兼容 adapter 调用旧 `Provider.DoGenerate` / `DoStream`。 - -旧的 `GenerateText`、`StreamText` 和自动 tool loop 在迁移期只能作为显式 legacy wrapper;新 Loop 不依赖它们。 - -### 2.2 `agent/es` - -`agent/es` 是两个 ES domain 共用的机制层,只抽机制,不抽语义。它不能 import `agent/run`、`agent/session` 或 application 类型。 - -`agent/es` 负责: - -| 能力 | 内容 | -| --- | --- | -| envelope | schema version、stream identity、revision、index、type、causation id、payload digest 的通用封装。 | -| canonical digest | 对 version/type/payload/record body 做稳定编码和 digest;拒绝 ambiguous JSON。 | -| record completeness | 校验一次 append 的完整 event group:revision、index、stream id、type、digest、record digest。 | -| replay runner | 给定 initial state、record log 和 domain-provided `EvolveVersion`,执行 `fold(initial, events)`。 | -| conformance helpers | 通用的 record truncation、digest mismatch、revision gap、duplicate/ambiguous JSON 测试工具。 | - -`agent/es` 不负责: - -```text -ModelStep / ToolStep / ToolCall / RunEnded -Session MessageAdded / Compact / Artifact -Queue claim / lease / visibility timeout -Runtime.Commit / ExecutionGrant -Planner / provider / tool execution -产品 policy -``` - -当前实现通过泛型和 hook 提供 API,domain 类型保留在 owner package: - -```go -package es - -type StreamID string -type Revision uint64 -type Index uint16 -type Digest string -type CausationID string - -type Event[P any] struct { - SchemaVersion uint16 - StreamID StreamID - Revision Revision - Index Index - Type string - CausationID CausationID - Payload P - PayloadDigest Digest -} - -type Record[E any] struct { - SchemaVersion uint16 - StreamID StreamID - Revision Revision - Events []E - RecordDigest Digest -} - -type EventMetadata struct { - SchemaVersion uint16 - StreamID StreamID - Revision Revision - Index Index -} - -type RecordView[E any] struct { - SchemaVersion uint16 - StreamID StreamID - Revision Revision - Events []E -} - -type EventInspector[E any] func(E) (EventMetadata, error) -type SchemaSupported func(uint16) bool - -func BuildEvent[P any](schemaVersion uint16, streamID StreamID, revision Revision, - index Index, typ string, causationID CausationID, payload P) (Event[P], error) -func ValidateEvent[P any](event Event[P], supported SchemaSupported) error -func DigestRecord[E any](record *Record[E]) (Digest, error) -func ValidateRecord[E any](record *Record[E], supported SchemaSupported, - inspect EventInspector[E]) error -func ValidateRecordView[E any](record RecordView[E], supported SchemaSupported, - inspect EventInspector[E]) error -func FoldStandardRecords[S any, E any](initial S, expectedStream StreamID, - records []Record[E], supported SchemaSupported, inspect EventInspector[E], - evolve func(uint16, S, E) (S, error)) (S, Revision, error) -func FoldRecords[S any, E any](initial S, expectedStream StreamID, - records []RecordView[E], supported SchemaSupported, inspect EventInspector[E], - evolve func(uint16, S, E) (S, error)) (S, Revision, error) -``` - -`BuildEvent`、`Validate*` 和 `Fold*` 是机械 API;domain 仍负责 type discriminator、payload encoding、Evolve 语义和 store adapter。带有额外 domain metadata 的 record 通过 `RecordView` 接入,不要求 domain 放弃自己的 record 类型。 - -### 2.3 `agent/run` - -`agent/run` 是当前 root `agent` 包的目标形态,只拥有单次 Run execution;长期 Session Events 由 `agent/session` 拥有。 - -`agent/run` 负责: - -| 能力 | 内容 | -| --- | --- | -| Run Machine | `MachineState`、`Step`、`ToolCallState`、`AgentCommand`、`Fact`、`AgentEvent` 和共享的 Decide/Evolve/Next 规则。 | -| Transition | 把一次 accepted command 产生的完整 fact/event group 封装为 `TransitionRecord`。实现上可直接使用 `agent/es.Record` 或在其上包 domain metadata。 | -| RunHeader | 正式持久协议的一部分:RunID、initial state/schema/digest、admission causation/provenance。它与 `TransitionRecord` 组成 canonical record;`MachineState` 才是日常 execution authority。 | -| Runtime contract | `Create`/`Load`/`Commit`/`Record` authority 接口;Run creation 由 Create 协议建立,所有后续语义状态变化只通过 Commit。 | -| Loop | 唯一的多步执行算法:解释 Effect,调用 Model/Tool,再提交结果 command。 | -| Tool contract | ToolRef、ExecutableTool、参数校验、结果分类和 response policy。 | -| Planner port | 供 Loop 注入 application Request Planner 的最小接口;规划实现不在 run。 | -| EventSink | canonical run event 的实时观察出口,也承载 provisional delta;authority 保持在 MachineState 与 canonical records。 | -| MemoryRuntime | 进程内 reference runtime;它实现同一 Run execution semantics,但不承诺跨进程 durable recovery。 | -| runtimetest | durable runtime 必须复用的 conformance suite。 | - -`agent/run` 不拥有: - -```text -Session history / compact / memory / artifacts -Queue admission / accepted order / claim policy -Product prompt/context/workspace construction -Provider clients / API keys -Durable DB schema / owner/fence/lease/outbox implementation -``` - -Run execution canonical record(不替代 Session 的长期语义 authority): - -```text -RunHeader(initial MachineState at Revision 0) -+ -TransitionRecord log (Revision 1..N) -``` - -状态不变量: - -```text -MachineState_N = Fold(initial, flatten(TransitionRecord[1..N].Events)) -``` - -Run execution domain 是短生命周期的;其中的 `MachineState` 是执行 authority。它完成后不再作为下一 Run 的 context 来源。Application 在 Turn 完整 materialization、terminal settlement 与 Artifact retention 得到 durable evidence 后,才按 archive policy 回收 Run records。 - -### 2.3.1 run-owned persisted model data - -Runtime 的 authority boundary 不能靠“调用者不要修改快照”这类规范约束来成立,必须由类型和提交路径保证。`sdk.Request`、`sdk.ModelResult`、`sdk.ToolDefinition` 及其内部的 `map`、`slice`、`json.RawMessage`、`any`、interface value 属于 transport boundary,不能直接进入 `run` 的 command/fact/state/event。 - -因此 `agent/run` 必须定义自己的 JSON-stable persisted value:`ModelRequest`、`ModelResult`、`ToolDefinition`、`Usage`、`Message`、`MessagePart`、`ProviderMetadata` 等。Loop/provider 边界仍使用 `sdk.Request`/`sdk.ModelResult`;进入 Runtime 前必须调用 `FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`,把动态 JSON parse/canonicalize 成 opaque `CanonicalJSON`;离开 Runtime 调 provider 时通过 `.SDK()` 构造新的 SDK 值。 - -该规则建立分层 persisted-value 边界:`sdk` 是一次调用的 transport API,`agent/run` 是可重放、可审计、可长期兼容的 execution event protocol。任何新增进入 Run event/MachineState 的 provider 数据,必须先落到 run-owned sealed/JSON-stable 类型或 `CanonicalJSON`。 - -### 2.4 `agent/session` - -`docs/design/agent-session.md` 是 Session kernel 的唯一协议 authority。其 Event-first 模型为: - -```text -Events = resolved committed SessionEvent stream -State = Fold(Events) - -Persistent representation - = immutable SessionHeader - + ordered SessionCommit records containing ordered SessionEvents -``` - -Kernel 负责 opaque canonical payload、atomic commit、head CAS、resolved ancestry replay、Fork、snapshot、canonical import 与 integrity。领域 event 的 codec、validation、Binding extraction 和 projection 由 `agent/session/extension` 负责;Chatlog 与 Turn ontology 分别由 `agent/session/chatlog` 和 `agent/turn` 负责。 - -Run 与 Session 通过稳定 causation 和 Turn materialization identity 关联。Session `SourceEvents` 只引用同一 resolved Session stream 中的 EventID;Run facts 的 `SourceFactID` 进入 `FactMap.SourceFacts` 以及 materialization CommitID preimage,用于逐 transition coverage 和 exactly-once 收敛,不写入 `Session.SourceEvents`,也不要求 Chatlog payload 增加 carrier。 - -### 2.5 Queue boundary - -Queue、steer、follow-up、claim、priority、visibility 和 admission policy 由 Application 管理。Application 只在 Run 的安全输入边界将已持久化输入转换为 `AcceptInput`;terminal follow-up 创建新的 Turn 与 Run。queue state、claim identity 和调度 policy 不进入 `MachineState`、`TransitionRecord`、Session kernel 或 Artifact Core。 - -### 2.6 Cross-domain materialization 与 settlement - -`docs/design/agent-turn.md` 是 Turn→Run 顺序、Run→Session materialization、settlement 和恢复的唯一协议 authority;`docs/design/agent-session-extension.md` 定义所有语义 Session 写入与 Binding admission;`docs/design/agent-artifact.md` 定义 Binding 和 RetentionClaim。 - -```text -verified RunRecord - -> turn.MaterializeAll: revision 1..head,逐 transition 完整 coverage - -> versioned pure FactMapper + stable SourceFactID/CommitID/EventID - -> extension.SemanticAppender - -> committed Chatlog/Turn Session Events + Active retention claims - -> exact terminal RunEnded - -> run_settled + TurnCompleted/TurnFailed in one semantic commit -``` - -每个 materialization revision 在 matched commit、applied commit 或 deterministic empty-map coverage 后前进。Transactional outbox 可优化 durable delivery;Session facts、stable identities 与 verified `RunRecord` 提供恢复依据。任何语义 Session 写入都通过 `SemanticAppender`,由它完成 codec validation、Binding extraction、claim journal 和 unknown-outcome recovery。 - -Application 只有在完整 materialization coverage、terminal settlement 及其所引用 Artifact 的有效 retention 均已得到 durable evidence 后,才可按产品 archive policy 回收 Run records。Turn 的 `run_settled` 与这些 evidence 已形成完整 archive gate,无需增加第二个 Session finalization event。 - -### 2.7 类型归属 - -| 类型或能力 | 所属层 | -| --- | --- | -| `sdk.Request`、`sdk.ModelResult`、`sdk.ToolDefinition`(provider-neutral 边界类型) | `sdk` | -| canonical JSON parser/value | `agent/jsonstable`(可被 `es`、`run`、`session`、`artifact` 使用) | -| generic event envelope/record/fold/digest mechanics | `agent/es` | -| `ModelRequest`、`ModelResult`、`ToolDefinition`、`Usage`、`Message`、`MessagePart`(run persisted frozen values) | `agent/run` | -| `Step`、`ToolCallState`、`AgentCommand`、`Fact`、`AgentEvent`、`TransitionRecord`、`Effect` | `agent/run` | -| `Runtime`、`MemoryRuntime`、`runtimetest` | `agent/run` | -| `Loop`、RequestPlanner/model/tool/event contracts、`ExecutionPolicy`、`LoopResult` | `agent/run/loop` | -| `SessionHeader`、`SessionCommit`、`SessionEvent`、resolved replay、fork、snapshot、store | `agent/session` | -| `Ref`、`Binding`、`BindingSet`、`RetentionClaim` | `agent/artifact` | -| static Module/Catalog、codec/upcast、`SemanticAppender`、pure projection | `agent/session/extension` | -| semantic Message/Input/Turn/Item ontology 与 Surface/Context projection | `agent/session/chatlog` | -| Turn→Run coordination、`FactMapper`、`MaterializeAll`、settlement | `agent/turn` | -| queue item/claim/dedup/visibility 和 steer/follow-up policy | Application/Product | -| Request Planner 实现和 context transformer | Application/Product | -| fixed model policy、step budget、malformed retry budget | Application/Product 或 `loop.ExecutionPolicy`;不进入 MachineState | -| owner、fencing、lease、outbox、DB schema | Durable adapter/Application | -| MCP server 连接、生命周期和 schema/call adapter | Application/provider adapter | -| provider transport retry | sdk/provider client | - -## 3. Run Machine - -### 3.1 Machine 的边界 - -Machine 是 `agent/run` package 中的纯 execution 语义规则。它只读取完整 `MachineState`、待决策的 `AgentCommand` 和待折叠的 `AgentEvent`,不访问 IO: - -```text -MachineState + AgentCommand - | -Machine.Decide -> 事实序列(决策,只在提交时运行一次) - | -Machine.Evolve 逐个折叠(机械) -> new MachineState -Machine.Next(state) -> Effect -``` - -`Next(state)` 根据当前事实产生至多一个待执行的 `Effect`。`Decide(state, command)` 校验一个意图并产出这次 transition 的完整事实序列——所有决策(是否接受、派生哪些后果、是否进入终态)都发生在这里,且只在提交时运行一次,输出即冻结。`Evolve(state, event)` 把单个事实机械地折叠进状态:它不读产品配置或 Loop policy,不含 IO,对 Decide 产出的每种事实全定义;replay 只依赖 versioned Evolve。Runtime 接受一个 command 后,把 Decide 的事实序列包装为同一 Revision 的 AgentEvent 组,与折叠后的新状态放在同一提交边界。两种 Runtime 不能各自复制规则;Loop 重新 `Load(ctx, runID)` 后再次调用 `Next`,不会依赖一次提交响应中的 effect。 - -决策与折叠的分工是协议的兼容边界:Machine 的决策规则(自动关闭、终态转换、command disposition)可以随版本演进,因为历史事件已把这些决策的结果记录在案;Evolve 与事件编码一起构成永久兼容契约,已发布 SchemaVersion 的折叠语义不再修改。 - -Run terminal、Step successor、ToolStep 自动关闭和等待条件都由 Machine 的 Decide 决定,并显式产出对应事实(`ToolStepClosed`、`RunEnded`)。固定模型、max-step budget 和 malformed retry limit 是 host/Loop policy,不进入 MachineState;这些策略若要改变状态,必须提交显式 command(例如 `StopRun{step_limit}` 或带 fail-run disposition 的 `RejectModelResult`)。Runtime 不再维护另一套 Run 终态和等待判断;Memoh 只把已经接受的 AgentEvent 投影到自己的 history、queue 和 outbox,按事件驱动,不做提交前后的状态差分。 - -Machine 不知道 PostgreSQL、mutex、lease、fencing、provider client、queue 或产品 history。Runtime 可以在自己的临界区内调用这套规则,但不改变规则。 - -### 3.2 输入语义 - -Queue 不属于 Machine,但 Machine 需要知道“一个输入何时已经被接受,以及它对后续执行的影响”。因此 core 只定义 active Run 的输入提交边界;terminal follow-up 由 Memoh admission 创建新 Run 后再提交首个输入: - -```text -NextStep(input) - 当前 Run 仍 active 且处于可接收边界;输入进入下一次 ModelStep 的规划上下文 - -terminal follow-up - Application 分配 stable RunID/causation -> Runtime.Create(NewRun) -> AcceptInput(input) -``` - -`AgentInput` 只有稳定的 `InputID` 和不可变 payload,不包含 queue item、priority、order、claim 或 lease。`NextStep` 构造 `AcceptInput` command;被 Runtime 接受后产出 `InputAccepted` 事实,与新的 MachineState 一起提交,Memoh 可以在同一事务中把 queue claim 标记为 applied。 - -Application/turn 在 admission 中分配稳定的 RunID 和 causation,并调用 `Runtime.Create(NewRun)` 执行 run-owned creation protocol。Create 按 NewRun 的 SchemaVersion 构造 immutable Revision-0 `RunHeader`;初始用户输入随后作为该 Run 的第一条 `AcceptInput` transition 提交。Run replay 不依赖从当前产品配置重新计算 initial state。 - -Machine 不在 ToolStep 执行中接受 `NextStep`,也不把 terminal follow-up 解释成旧 Run 的状态变化。输入的具体文本如何进入 Planner 生成的 `sdk.Request` 仍由 Request Planner 决定;Loop 在提交前冻结为 agent `ModelRequest`,Machine 只保证输入边界和一次性接受语义。 - -### 3.3 MachineState - -MachineState 至少包括: - -```text -RunID -Run status -current Step -ToolStep 中每个 ToolCall 的 progress/result -等待中的 ResponseRequest -已接受但尚未用于冻结下一请求的 AgentInput -model-step counter -累计 usage(对已接受 ModelStepCompleted 和 ModelStepRejected 的 agent Usage 逐字段求和) -最近一次已接受的 agent ModelResult -terminal RunResult(如果已结束) -``` - -MachineState 不包含固定模型、prompt/history、step limit、malformed retry limit 或产品配置。模型选择由每个 `ModelStepPrepared` 冻结的 `ModelRequest.Model` 表示;limits 属于 host/Loop policy,并通过显式 command 形成事实。 - -权威语义状态不包括数据库 row、transaction、owner、fence、lease、Attempt 或 queue claim。Runtime 可以保存这些控制元数据,但它们不进入 MachineState。 - -### 3.4 Run 状态 - -```text -RunActive -RunCompleted 已得到正常终态模型结果 -RunStopped application 明确停止 -RunFailed provider、step 或未知外部 effect 导致失败 -``` - -等待是 Loop 的当前返回结果;持久化 Run 状态继续由 MachineState 表达。业务取消通过 Memoh/application 的控制事件提交为 `RunStopped`;取消 Loop 的 context 只结束当前执行尝试,不自动修改 Run 状态。 -所有 terminal 状态都必须带 `MachineState.Result`;`RunActive` 的 Result 为空。`RunFailed` 的 -Failure 至少包含稳定的 Class,未知外部 effect 还包含对应 CallID。 - -### 3.5 Step 层级 - -```text -Run R - └── Step S1: ModelStep - request frozen - result pending/completed - -ModelResult contains tool calls - | - v - Step S2: ToolStep - ├── Call A: Completed - ├── Call B: Waiting(response=101) - └── Call C: Pending - -S2 closes automatically after all Calls are terminal - | - v - Step S3: ModelStep -``` - -Step 只有 `ModelStep` 和 `ToolStep` 两种语义。ToolCall 是 ToolStep 内的 progress 项,不拥有独立的公共 Step identity。一个 ModelStep 最多产生一个后继 ToolStep;其 Call 集合、顺序和 provider-neutral tool definition digest 一次冻结。模型结果绑定到可执行工具时,`ToolCallBinding` 再冻结 policy 和 binding digest。ModelStep 的 ID 由 `DeriveModelStepID` 生成,并覆盖 ModelRef、request 与 tool-spec digest;ToolStep 的 ID 由原始 Call 顺序的完整 binding-set digest 通过 `DeriveToolStepID(source StepID, binding digest)` 生成,两个 Runtime 必须得到相同结果。 - -### 3.6 AgentCommand、AgentEvent 与 Effect - -协议使用两个 sealed 词表。`AgentCommand` 是 Loop 或受信任的外部入口针对**已有 Run**提出的意图;`AgentEvent` 承载 Runtime 接受一个 command 后产出的事实。一个被接受的 command 构成一次 transition,产出一个或多个事件;命令描述"请求发生什么",事件描述"已经发生什么",两个词表不共用类型。 - -AgentCommand(15 种): - -```text -PrepareModelRequest 冻结下一次模型请求 -StartModelExecution 取得 ModelStep 执行权 -SubmitModelResult 提交完整模型结果与 tool-call bindings -SubmitModelFailure 提交模型调用的最终失败 -RejectModelResult 提交结构性 malformed 的模型结果 -RecoverModelExecution 释放或回收 ModelStep 执行权 -StartToolCall 取得单个 ToolCall 执行权 -SubmitToolResult 提交工具执行成功结果 -SubmitToolFailure 提交工具已知/未知失败 -ApproveToolCall 批准 Waiting(Approval) Call -RejectToolCall 拒绝 Waiting(Approval) Call -SubmitToolResponse 提交 ask-user 答案 -CancelRun 业务取消 -StopRun host-owned 非取消停止策略,例如 step_limit -AcceptInput 接受 queue-safe 输入(由 NextStep 构造) -``` - -事实词表 `Fact`(14 种),由 `Machine.Decide` 产出、包装为 AgentEvent: - -```text -ModelStepPrepared 冻结的 ModelStep 建立,消费 pending inputs -ModelStepStarted Prepared -> Executing -ModelStepRecovered Executing -> Prepared(无已接受结果) -ModelStepRejected 记录一次 malformed 结果:usage 累计,Rejects 加一,回到 Prepared -ModelStepCompleted 模型结果被接受:usage 累计,写 LastModelResult,清空当前 Step -ToolStepOpened 按 bindings 建立 ToolStep 的完整 Call 集合 -ToolCallStarted Pending -> Executing -ToolCallApproved Waiting(Approval) -> Pending -ToolCallCompleted Executing -> Completed -ToolCallAnswered Waiting(ExternalResponse) -> Completed(答案) -ToolCallFailed Pending/Executing/Waiting -> Failed(Known/Unknown) -ToolStepClosed 全部 Call 到达可关闭终态,清空当前 Step -InputAccepted 输入进入 PendingInputs -RunEnded 终态:Status 为 RunCompleted、RunStopped 或 RunFailed -``` - -`AgentEvent` 与新的 `MachineState` 在同一个原子提交中写入,具有 authority 分配的 (Revision, Index) 身份、canonical digest 和产生它的 CommandID。`AgentEvent` 可供 replay、projection、审计和 OpenTelemetry 使用。 - -新 Run 的初始用户输入也必须作为 `AcceptInput` transition 记录,不存在绕过 Runtime.Commit 的 seed input。`NewRun` 经 `Runtime.Create` 建立 `RunHeader`;输入接受属于 Run domain。 - -二者的关系固定为: - -```text -Loop / response ingress - -> AgentCommand (intent) -Runtime EvaluateCommit: 幂等/类别校验 + Machine.Decide + Machine.EvolveVersion - -> MachineState + TransitionRecord (committed facts, one Revision) -``` - -意图与事实由 Decide 显式转换,日志永远记录结果而非请求:`RejectToolCall` 记录为 `ToolCallFailed{permission_denied}`;`CancelRun` 记录为 `RunEnded{RunStopped, cancelled}`;host step limit 记录为 `StopRun{step_limit} -> RunEnded{RunStopped, step_limit}`。 - -`PrepareModelRequest.RequestDigest` 和 `ResponseDigest` 分别是请求/响应 payload 的内容摘要;CommandID 与 command digest 提供提交身份。`ApproveToolCall`/`RejectToolCall` 使用 `DigestToolResponseDecision(kind, decision, reason)`,`SubmitToolResponse` 使用 `DigestToolResponsePayload(payload)`;Decide 必须校验 digest 与实际 decision/payload 匹配。一个 command 被重试时复用同一 CommandID 和 digest;Runtime 不会为重试生成第二组 AgentEvent。 - -Effect 是 Loop 动作: - -```text -NeedModelRequest(PlanningHint) -StartModelCall(StepID) -StartToolCalls(StepID, CallIDs) -WaitForResponse(ResponseRequests) -WaitForExecutionRecovery -``` - -终态由 `MachineState.Status` 表示;Effect 只是待执行动作,既可以是模型调用,也可以是工具调用或等待,该名称不表示一定产生外部副作用。Effect 不写入权威状态;Loop 在每次 `Load(ctx, runID)` 后由 MachineState 重新得到它们。 - -### 3.7 共享规则 - -#### 3.7.1 Decide 规则(命令 -> 事实序列) - -`Decide(state, command)` 按下表校验前置条件并产出事实序列。任何前置条件不满足即拒绝整个 command,不产出部分事实: - -1. `PrepareModelRequest` 只能在 Run active 且没有当前 Step 时接受;其 `InputIDs` 必须按 `PendingInputs` 的当前顺序完整匹配。`Request` 必须已经是 frozen agent `ModelRequest`,且 `Request.Model` 必须等于 command 的 `ModelRef`;`Tools` 必须与其中的 provider tool definitions 按 Ref、顺序和 definition digest 一一对应,`ToolsDigest` 覆盖 Ref、schema、顺序与 policy。产出 `[ModelStepPrepared]`,事实中携带冻结的请求、ToolSpec、被消费的 InputIDs 与 Decide 算好的 step binding digest(事实自包含条件:Evolve 不重算 digest)。 -2. `StartModelExecution` 只能作用于 Prepared ModelStep,产出 `[ModelStepStarted]`。`RecoverModelExecution` 只能作用于没有已接受结果的 Executing ModelStep,产出 `[ModelStepRecovered]`;它只能由持有该 Model grant 的当前 Loop,或由 Runtime 自己确认 lease 失效后的 recovery 逻辑提交,普通 response ingress 不能提交。 -3. `SubmitModelResult` 只能作用于对应的 Executing ModelStep。结果没有 tool calls 时产出 `[ModelStepCompleted, RunEnded{RunCompleted}]`;有 tool calls 时按冻结 `ToolSpec` 绑定 policy 和 binding digest,产出 `[ModelStepCompleted, ToolStepOpened]`——`ToolStepOpened` 携带完整 Call 集合:DirectExecution 的 Call 为 Pending,ApprovalRequired/ExternalResponse 的 Call 为带稳定 request 的 Waiting,每个 Waiting request 都包含目标 RunID、StepID、CallID、ResponseID、Kind 和 RequestDigest。 -4. `SubmitModelFailure` 只能作用于对应的 Executing ModelStep,产出 `[RunEnded{RunFailed, provider_failure}]`,保留稳定失败原因。 -5. `RejectModelResult` 只能作用于对应的 Executing ModelStep,必须携带该 start 的 Grant。`Disposition=ModelRejectRetry` 时产出 `[ModelStepRejected]`(Step 回到 Prepared,同一冻结 request 可再次 start);`Disposition=ModelRejectFailRun` 时产出 `[ModelStepRejected, RunEnded{RunFailed, malformed_model_result}]`。Loop/host policy 可用当前 `ModelStep.Rejects` 和自己的 malformed limit 选择 disposition;limit 不进入 MachineState。被拒绝的结果不写入 `LastModelResult`。 -6. `StartToolCall` 只能作用于 Pending Call,产出 `[ToolCallStarted]`。 -7. `SubmitToolResult` 只能作用于 Executing Call,产出 `[ToolCallCompleted]`。`SubmitToolResponse` 只能作用于 Waiting(ExternalResponse) Call,产出 `[ToolCallAnswered]`。 -8. `SubmitToolFailure`(known)可以作用于 Pending 或 Executing Call:Pending 的已知失败使用空 Grant,Executing 的必须使用对应 Grant,产出 `[ToolCallFailed{Known}]`。`SubmitToolFailure`(unknown)只能作用于 Executing Call,产出 `[ToolCallFailed{Unknown}, RunEnded{RunFailed, effect_unknown}]`,`RunResult.Failure` 记录 `effect_unknown` 和对应 CallID;scanner 提交它时必须先有实现内部的失效执行记录。 -9. `ApproveToolCall` 必须匹配目标 Waiting(Approval) Call 保存的 ResponseID 和 kind,产出 `[ToolCallApproved]`(Call 变为 Pending,Loop 随后执行)。`RejectToolCall` 同样必须匹配,产出 `[ToolCallFailed{Known, permission_denied}]`。该响应 transition 记录已接受的结果事实;原请求保留在 Waiting Call state。 -10. 一次响应只推进对应 Call,不能修改其他 Call;ResponseID 和 kind 必须匹配该 Call 保存的请求。响应 payload 的 digest 用于内容冲突检测,不需要等于请求 payload 的 digest。 -11. 使 ToolStep 内最后一个 Call 到达可关闭终态的 command,其事实序列追加 `ToolStepClosed`。ToolStep 关闭前不能创建下一 ModelStep。 -12. `CancelRun` 只能作用于非 terminal Run,产出 `[RunEnded{RunStopped, cancelled}]`;`StopRun` 只能记录 host-owned 非取消停止原因,目前为 `[RunEnded{RunStopped, step_limit}]`。 -13. `AcceptInput` 只能作用于 active 且没有当前 Step 的 Run,且 `InputID` 不得与当前 `PendingInputs` 中任何输入重复;重复 ID 是 identity conflict,不能记录一个 Evolve 会丢弃的 no-op fact。接受后产出 `[InputAccepted]`。Planner 由 `PlanningHint.Inputs` 收到这些输入并在 `RequestPlan.InputIDs` 中明确消费它们;遗漏或伪造 ID 的 `PrepareModelRequest` 被拒绝。 -14. `Create(NewRun)` 是 Run 的 creation protocol:它按 `NewRun.SchemaVersion` 建立最小 Revision-0 `MachineState` 和 immutable `RunHeader`,不接收 initial input、binding、fixed model、limits 或产品配置。Application 随后通过正常 `Runtime.Commit` 提交首个 `AcceptInput`。 - -`InputID` 在一个 Run 内唯一。相同 `InputID` 和相同 payload 的重复 `AcceptInput` 是语义 no-op,Runtime 返回原已接受的事件组;相同 ID 携带不同 payload 返回冲突。Memoh 的 queue claim 仍负责防止同一个 queue item 被多个输入入口同时消费。 - -#### 3.7.2 Evolve 折叠表(事实 -> 状态) - -`Evolve(state, event)` 对每种事实执行固定的机械折叠,不读产品配置或 Loop policy,不含 policy 分支: - -| 事实 | 折叠 | -| --- | --- | -| ModelStepPrepared | 若 Current 非空则拒绝折叠(损坏日志不能覆盖活跃 Step);否则设置 Current 为 Prepared ModelStep(StepRef.Digest 取事实携带的 BindingDigest);`ModelSteps+1`;按事实中的 InputIDs 从 `PendingInputs` 移除 | -| ModelStepStarted | Current.Status = Executing | -| ModelStepRecovered | Current.Status = Prepared | -| ModelStepRejected | `Rejects+1`;Current.Status = Prepared;Usage 逐字段累加事实携带的 usage | -| ModelStepCompleted | 写 `LastModelResult`;Usage 逐字段累加;清空 Current | -| ToolStepOpened | 设置 Current 为携带完整 Call 集合的 ToolStep | -| ToolCallStarted | 目标 Call: Pending -> Executing | -| ToolCallApproved | 目标 Call: Waiting -> Pending | -| ToolCallCompleted | 目标 Call: Executing -> Completed(result) | -| ToolCallAnswered | 目标 Call: Waiting -> Completed(answer) | -| ToolCallFailed | 目标 Call -> Failed(outcome, failure) | -| ToolStepClosed | 清空 Current | -| InputAccepted | 按 InputID 幂等追加到 `PendingInputs` | -| RunEnded | 设置 Status 与 `Result`(含 Usage 副本与最近已接受的模型结果);清空 Current | - -事实序列内的折叠按 Index 顺序进行;`RunEnded` 若出现必须是序列的最后一个事实。`ModelStepRecovered` 不改变 Usage 与 `Rejects`。 - -#### 3.7.3 终态与竞态 - -终态按 Runtime 的线性化顺序确定:Cancel 先提交则 RunStopped;Unknown 先提交则 RunFailed,之后的 Cancel 不改变终态。Run 进入 terminal 后,其他并行 Call 的 grant 立即失效,Loop 取消仍在运行的 worker;迟到的完成提交返回 terminal/stale 错误,不能再改变 MachineState。Application 可将已证实的迟到 external effect 作为独立审计 ontology 经 `SemanticAppender` 记录;它不伪造 Run fact。并行 Call 不引入额外的 settling 状态。 - -`RunStopped` 或 `RunFailed` 的 `RunResult` 保留最近一次已接受的模型结果(如有);`RunCompleted` 的 `RunResult.Model` 是产生正常终态的那次模型结果。取消或未知 effect 不会伪造新的模型结果。 - -`Next` 的主要映射是:无当前 Step -> `NeedModelRequest(PlanningHint{Inputs: PendingInputs})`;Prepared ModelStep -> `StartModelCall`;ModelStep 正在 Executing -> `WaitForExecutionRecovery`;有 Pending Call 的 ToolStep -> `StartToolCalls`;没有 Pending 且存在 Waiting Call -> `WaitForResponse`(即使另有 Executing Call,也等待 response 或 execution wake);没有 Pending/Waiting 但存在 Executing Call -> `WaitForExecutionRecovery`。Loop/host 可以在处理 `NeedModelRequest` 前按自己的 step budget 提交 `StopRun{step_limit}`。Runtime 接受 `StartModelExecution` 或 `StartToolCall` 后,在 CommitResult 中返回一次性 `ExecutionGrant`,Loop 使用该授权调用对应的 ModelInvoker 或 ExecutableTool。Model execution lease 失效后,Runtime 通过仅限 Runtime/recovery 使用的 `RecoverModelExecution` 把 ModelStep 恢复为 Prepared,Loop 才能再次 start。 - -这里的 `WaitForExecutionRecovery` 是一个统一等待结果:它既表示已有 execution 仍可能由原 Loop 持有,也表示该 execution 已失效、等待 Runtime recovery。公共 `LoopResult.Reason` 不暴露 owner、lease 或 Attempt 的细节。 - -## 4. Step 与 ToolCall progress - -### 4.1 ModelStep - -ModelStep 冻结: - -```text -StepID -agent.ModelRequest 及其 digest -ModelRef -本次请求使用的 provider-neutral tool definitions 及 digest -与这些 definition 对应的 agent `ToolSpec`(包含 response policy) -`ToolsDigest`(按 provider definition 顺序覆盖 schema、Ref 和 policy) -执行状态:Prepared / Executing -reject counter(progress,不参与冻结 digest) -``` - -`SubmitModelResult` 被接受后,当前 ModelStep 立即被 ToolStep 替换(`ToolStepOpened`),或因没有 tool calls 而关闭 Run(`RunEnded`);因此模型完成状态不作为当前 Step 状态保存。接受的结果保存在 `LastModelResult`、RunResult 和 application history 中。 - -一个 ModelStep 代表一次模型调用。Loop 默认调用 `ModelInvoker.Generate`;如果实现提供可选的 `StreamingModelInvoker`,Loop 可以用 `Stream` 发送实时 delta,但两条路径必须得到同一种边界 `sdk.ModelResult`,并在提交前冻结为 agent `ModelResult`;transport retry 不创建新的 Step。 - -Request Planner 生成完整 `sdk.Request` 后,Loop 先调用 `FreezeModelRequest` 得到 agent `ModelRequest`,再提交 `PrepareModelRequest`。Runtime 以 revision/CAS 或事务保证只接受一份冻结请求;新 ModelStep 的 StepID 由 RunID、command identity 和 frozen request digest 稳定派生。已经冻结的请求不受后来 queue、history 输入或 Planner 持有的 SDK 对象 mutation 影响。 - -Loop 在 `SubmitModelResult` 时只校验 tool-call ID 与顺序,并从匹配的冻结 `ToolSpec` 生成 `ToolCallBinding`。这里不调用 ExecutableTool;未知工具保留为 `DirectExecution`、空 definition digest 的 unresolved binding,应用级参数错误留到 `StartToolCalls`,作为 Pending Call 的已知失败处理。Runtime 只校验 binding 与冻结请求、冻结模型结果和 Step 身份的一致性,不重复解析工具目录。 - -模型响应的结构性 malformed(重复/错序 CallID、违反 provider 协议)使 Call 集合无法建立:Loop 不提交 `SubmitModelResult`,而以 start grant 提交 `RejectModelResult{Failure.Class: malformed_model_result, Disposition: ...}`。Decide 产出 `ModelStepRejected`(usage 累计、`Rejects` 加一、Step 回到 Prepared)或再追加 `RunEnded{RunFailed, malformed_model_result}`;由 Loop/host policy 根据自己的 `MalformedModelResultLimit` 选择 retry/fail-run disposition。被拒绝的结果不写入 `LastModelResult`,不创建 ToolStep。 - -单个 Call 的参数无法解析不属于结构性 malformed:该 Call 以原始参数字节绑定为 Pending,`StartToolCalls` 的参数校验把它关闭为已知 `invalid_arguments`,失败结果进入下一次模型请求,由模型自行修正。未知 ToolRef 同样保留为待处理 Call,start 前记录 `tool_lookup_failed`。 - -### 4.2 ToolStep - -ToolStep 保存: - -```text -source ModelStep ID -原始 ToolCall 顺序 -CallID、ToolRef、canonical arguments -response policy -工具定义 digest -每个 Call 的 binding digest(definition、policy 和 arguments 的摘要) -每个 Call 的 durable progress -``` - -`BindingDigest` 必须覆盖匹配的 `ToolSpec`、canonical arguments 和 CallID;`ToolStepOpened` 创建 ToolStep 时同时保存到 `ToolCallState`,执行前再次校验。 - -每个 Call 的状态为: - -```text -Pending -Executing -Waiting(request) -Completed(result) -Failed(Known, failure) -Failed(Unknown, failure) -``` - -这些状态只保存会影响恢复决策的事实。工具内部 stdout、下载百分比、HTTP 字节数等观察信息只走 EventSink,不写 MachineState。 - -状态字段必须保持互斥且可校验:Pending/Executing 的 Result、Failure、Waiting 都为空;Waiting 必须带 ResponseRequest 且 policy 为 Approval 或 ExternalResponse;Completed 必须带 Result;Failed 必须带 Failure,Unknown outcome 必须使用 `effect_unknown` 且 Run 必须同时为 RunFailed。`Machine.Evolve` 和 `Runtime.Load(ctx, runID)` 都必须拒绝非法组合;approval accepted 清除 Waiting 并变为 Pending,answer 清除 Waiting 并变为 Completed,reject 清除 Waiting 并变为已知 Failed。 - -### 4.3 ToolCall 执行规则 - -`StartToolCall` 是外部调用前的 durable barrier。它把 Pending 固定为某个 Loop attempt 正在负责;提交成功后才允许调用工具。这个顺序不能消除“提交后、调用前崩溃”的窗口,因此失去执行权且没有结果时仍按 Unknown 终止 Run,但它能阻止多个 Loop 同时执行同一 Call。 - -1. ModelStep 完成时一次性保存完整 Call 集合(`ToolStepOpened`)。 -2. Completed 和 Failed Call 永远跳过。 -3. Pending Call 必须先提交 `StartToolCall`;Runtime 接受后才允许调用外部工具。 -4. Waiting Call 不阻止其他 Pending Call。Pending Call 是否并行由 Loop 的 ExecutionPolicy 决定。 -5. 每个 Call 完成后立即提交自己的 `SubmitToolResult` 或 `SubmitToolFailure`。 -6. 没有 Pending 或 Executing、仍有 Waiting 时,Loop 返回等待。 -7. 所有 Call 到达 Completed 或已知 Failed 时,Decide 在该次提交的事实序列中追加 `ToolStepClosed`。 -8. Executing Call 在执行权失效且结果未提交时不能自动重做;Runtime 记录 Unknown 并终止 Run。 - -并行度由 Loop execution policy 决定,ToolStep 语义保持逐 Call 状态。Machine 只返回可执行的 Pending Call,不推断工具之间是否存在 effect ordering;application 只有在确认一组工具允许并行时才配置大于 1 的并行度。一个 Loop 执行组使用同一份策略: - -```text -Sequential -Parallel -BoundedParallel(n) -``` - -在 API 中统一表示为 `ExecutionPolicy{MaxParallel}`:`1` 表示 Sequential,`n>1` 表示 BoundedParallel(n),`0` 在 Loop 创建时归一化为 `1`,负值在 Loop 创建时拒绝。`Parallel`(不设上限)不作为默认行为;若未来需要,必须另行规定资源上限。 - -`MaxParallel` 只限制一个 Loop 本次实际启动的 worker 数量。跨进程全局资源限额由 Memoh 调度层实现;Runtime 仍以每个 Call 的 start grant 防止重复执行。 - -同一 ToolStep 的结果投影仍按原始 Call 顺序;本版本不定义 Call 之间的依赖边,需要前置结果的调用由后续 ModelStep 产生。 - -### 4.4 approval、ask-user 和外部响应 - -approval 和 ask-user 都是 ToolCall 的 response policy: - -```text -ModelStep - -> ToolStep - approval: Waiting(Approval) - ask-user: Waiting(ExternalResponse) -``` - -外部入口按 response policy 转换成对应的 AgentCommand,日志记录 Decide 产出的结果事实: - -```text -approve -> ApproveToolCall -> ToolCallApproved -> Pending -> 工具执行 -reject -> RejectToolCall -> ToolCallFailed(Known, permission_denied) -answer -> SubmitToolResponse -> ToolCallAnswered -> Completed(answer) -``` - -每个 response 有稳定的 `ResponseID`,由 Machine 在创建 Waiting request 时从 RunID、StepID、CallID 和 response kind 稳定派生;一个 Call 至多有一个未决请求,两个 Runtime 派生结果一致。一次响应只作用于对应 `RunID/StepID/CallID`,不需要旧 Loop 的执行租约,也不消费其他 Call 的执行权。 - -### 4.5 工具结果和失败 - -工具执行返回封闭的三种结果: - -```text -ToolExecutionSucceeded{Result} - ToolExecutionFailed{Failure} // 已知没有完成外部 effect - ToolExecutionUnknown{Failure} // 无法判断外部 effect -``` - -`ToolFailure` 使用稳定的 `Class` 和可选的 `Message`: - -```text -permission_denied -tool_lookup_failed -invalid_arguments -tool_definition_mismatch -execution_failed -effect_unknown -``` - -工具只有在能够确定外部 effect 没有完成时才能返回 `ToolExecutionFailed`;只要结果可能已经产生 effect,就必须返回 `ToolExecutionUnknown`。 - -已知失败作为下一次模型请求中的 `sdk.ToolResultPart{IsError:true}`,保留原始 CallID、工具名、Class 和 Message。模型可以在新的 ModelStep 中再次发起工具调用。Unknown 不投影给模型,不自动重试,Run 直接进入 RunFailed;通用 core 不假设外部系统支持查询或撤销。 - -## 5. Runtime contract - -### 5.1 Runtime 的职责 - -Runtime 管理以 RunID 定址的 Run collection,并回答四个问题: - -```text -如何以 versioned NewRun 创建 immutable Revision-0 RunHeader? -指定 RunID 的当前权威 MachineState 是什么? -一个 AgentCommand 如何安全地提交成一个 TransitionRecord,并对外产生 AgentEvent 流? -如何取得指定 Run 的一致、已验证 RunRecord(header、snapshot 与完整 transition log)? -``` - -Runtime 的语义范围是 Run access 与 atomic commit。它不组装 prompt、不调用模型或工具、不定义 Machine 规则,也不实现 queue 或 Session policy。Durable adapter 可以在保存 Run transition 的同一事务中写入 delivery outbox、queue claim outcome 或其他私有协调记录;这些记录触发 `agent/turn` 的恢复/materialization,语义 Session commit 仍经 `extension.SemanticAppender`。 - -`Commit` 必须在 authority 的临界区内调用共享的 `EvaluateCommit`(内部执行幂等/类别校验、`Machine.Decide` 和 versioned `Machine.Evolve`),使“读取状态、验证 command、计算事实与新状态、保存完整 transition”形成一个原子操作。Machine 规则由 `agent/run` 唯一定义,adapter 只实现持久化和私有 control plane。 - -`CommitResult.Events` 是该 transition 的事件流视图,供 Loop、UI 和 observability 消费;Runtime 的 canonical commit storage 是完整 `TransitionRecord`。Session materialization 读取 verified `RunRecord`,按 `agent/turn` 的 stable identity 和 coverage 协议独立收敛。 - -持久化 **MachineState** 是 Run execution authority:提交验证与 Loop 从它读取,崩溃恢复通过 `Load(ctx, runID)` 直接取回。`RunHeader + TransitionRecord log` 是 **canonical record**:Create 原子保存 header 与 Revision-0 state;每次 Commit 原子保存下一个 MachineState 与对应完整 TransitionRecord。它服务审计、观察、materialization 和经验证的 Run 导入/迁移(§5.1.1 规则 6)。对任意 Revision N,状态必须等于从 `RunHeader.InitialState` 对 `flatten(TransitionRecord[].Events)` 执行 versioned `Evolve` fold 的结果。conformance 的 replay-fold 等价测试守护此性质;分歧表示 Evolve bug、越权写入或存储损坏,修复和恢复由运维流程处理。 - -这个双写模型成立的稳定条件(本规范的规范性条款): - -1. Event ontology 稳定:Fact 词表 sealed,已发布 SchemaVersion 的事实结构永不修改,新增字段进入新版本。 -2. Evolve 语义版本内稳定:折叠是机械的(不读产品配置/Loop policy、无 IO),replay 通过 `EvolveVersion(SchemaVersion, state, fact)` 选择语义;会演进的决策语义全部在 Decide,其结果记录为事实。同一 SchemaVersion 内的折叠语义不做行为变更;跨部署迁移(本地↔云)要求迁移双方对该版本的折叠语义一致——迁移功能排期时,golden event stream 校验从回归测试升级为版本冻结校验。 -3. 事实自包含:折叠一条事实所需的全部信息在事实自身与折叠前状态之内,不访问外部系统,不重新计算依赖当前代码版本的派生值(digest 一律在 Decide 时算好并携带在事实中)。已实现的自包含保持;它是 run 导入/迁移的前提。 -4. 持久化值归属稳定:Run event 和 MachineState 只保存 run-owned frozen values;任何来自 SDK/provider/application 的引用在进入 Runtime 前必须被 canonicalize + detach,返回给 caller 的 snapshot/event 也必须是独立副本。 - -Runtime 实现还必须在代码层面维护这些条件:`EvaluateCommit` 对 Decide 产出的 facts 做 `snapshotFact` 后再 fold/persist,并构造带 transition digest 的 `TransitionRecord`;`Load(ctx, runID)`、`CommitResult`、AlreadyApplied replay 和 `Record(ctx, runID)` 返回的 snapshot/event/record 均为 detached value;durable adapter 可以用数据库事务替代 mutex,但不能把未冻结 SDK 对象、浅拷贝 snapshot 或 caller-owned bytes 写入事件表/状态表。 - -`FoldRun(header, records)` 是导入/迁移与诊断的入口:验证 header 与逐 transition 的 digest 链后折叠出状态。它用于三处——跨部署迁移时目标侧重建初始权威状态(不信任上传的 snapshot)、conformance 的等价测试、以及运维按需的一致性核对。日常执行路径不调用它。 - -重新收紧为日志权威(状态可丢弃、自动仲裁、水位守护)的触发条件:执行历史 fork 成为产品功能,或出现"状态存储不可信而日志存储可信"的实际部署形态。当前两者均无需求方;本规范此前的日志权威版本可在版本历史中查阅。 - -一个 Runtime 实现管理多个按 RunID 定址的 Run。Application/turn 在 admission 中分配稳定 RunID 和 causation;`Runtime.Create(NewRun)` 运行 run-owned creation protocol 并持久化 Revision-0 header。Loop 是指定 RunID 的一次进程执行。 - -### 5.1.1 RunHeader - -`RunHeader` 是由 versioned Create protocol 产生的正式持久协议,其 bytes 永久绑定创建时输入: - -```go -type RunHeader struct { - SchemaVersion uint16 - RunID RunID - InitialStateVersion uint16 - InitialState MachineState - InitialStateDigest es.Digest - CausationID es.CausationID // creating turn/application operation - HeaderDigest es.Digest -} -``` - -规范性要求: - -1. `RunHeader` 创建后 immutable;普通 `Runtime.Commit` 不修改它。 -2. `InitialState` 由 `Runtime.Create(NewRun)` 按 NewRun 的 SchemaVersion 产生最小 Revision-0 state,不包含 initial input、binding、policy、fixed model、limits、session history 或产品配置。 -3. initial input 通过 Revision 1 的正常 `AcceptInput` transition 提交。 -4. `InitialStateDigest` 绑定 initial state 的 canonical wire bytes;`HeaderDigest` 绑定 header 中除自身外的全部协议字段。 -5. durable adapter 必须让 Create 的 header 写入与必要的 application causation 记录原子或可幂等恢复。 -6. local Run 上传/迁移时,Run package 只传输并验证 `RunHeader + TransitionRecord log`;目标 runtime 重建 projection,不信任上传的 MachineState snapshot。跨 package archive 由 Application coordinator 另行组合 Session canonical archive 与经 `agent/artifact` 验证的 `BindingManifest`/active claims。 - -`NewRun` 是 Create 的唯一 caller-supplied creation value:`NewRun{SchemaVersion, RunID, CausationID}`。`BuildNewRun` 产生当前支持版本的值,`ValidateNewRun` 校验 version 与稳定文本身份;Runtime 按 version 派发构造 immutable header。`RunHeader.CausationID` 只提供跨 domain 关联,`agent/run` 不 import Session、Turn 或 Application 的具体 ID 类型;`agent/turn` 将 persisted `NewRun.CausationID` 与 start semantic commit 关联。 - -### 5.2 最小接口 - -```go -type Runtime interface { - Create(context.Context, NewRun) (CreateResult, error) - Load(context.Context, RunID) (RuntimeSnapshot, error) - Commit(context.Context, CommitRequest) (CommitResult, error) // CommitRequest.Command carries RunID - Record(context.Context, RunID) (RunRecord, error) -} -``` - -`Create` 验证 `NewRun`,按其 SchemaVersion 构造 immutable Revision-0 header,并将其作为指定 RunID 的唯一创建记录写入 collection。相同 header 的重试返回 `CreateResult{Created:false}`;同一 RunID 的不同 header 返回 `ErrCreateConflict`。`Load` 是指定 RunID 的纯读取,只返回权威状态和 Revision;它不创建 Attempt、不取得 lease,也不因为另一个 execution 正在运行而返回 busy。`Commit` 由 envelope 中的 RunID 定址,接受一个 AgentCommand;Runtime 在该 Run 的同步/事务边界内调用共享 `EvaluateCommit`、保存结果并写入 AgentEvent 组。`Record` 返回同一一致性边界内读取并验证的 header、snapshot 和完整 transition records。缺失的 RunID 上的 `Load`、`Commit` 与 `Record` 返回 `ErrRunNotFound`。只有 start command 的 `Commit` 可以建立执行占用并返回授权,不增加独立“执行”方法。 - -Executing 是合法 MachineState。Loop 从 snapshot 调用 `Machine.Next` 后得到 `WaitForExecutionRecovery`;ToolStep 同时存在 Executing 与 Pending Call 时,Machine 仍返回可执行的 Pending Call;同时存在 Executing 与 Waiting Call 时,Machine 仍返回等待请求。Runtime 保留逐 Call progress,不使用 run-level busy 锁覆盖整个 ToolStep。 - -请求构造不在 Runtime 接口中。Loop 使用 `NeedModelRequest` 提示调用 Request Planner,再把完整请求作为 `PrepareModelRequest` 提交。 - -Durable application adapter 可以在自己的数据库事务内把 Run transition、MachineState projection 与 materialization delivery outbox 一起持久化。outbox 只优化投递;`agent/turn.MaterializeAll` 仍从 verified `RunRecord` 重建 coverage,并通过 `SemanticAppender` 写入 Session。Runtime 不暴露通用业务写入 API,也不拥有 prompt、queue、Session history 或 Artifact retention。 - -### 5.3 Snapshot、Commit 和执行授权 - -```go -type RuntimeSnapshot struct { - State MachineState - Revision uint64 // 已接受的 transition 数;初始状态为 0 -} - -// The representation is implementation-defined. Callers only pass it back; -// it is not a Step identity, a durable domain value, or a user credential. -type ExecutionGrant string - -type CommitRequest struct { - BaseRevision uint64 - Grant ExecutionGrant - Command CommandEnvelope -} - -type CommandEnvelope struct { - SchemaVersion uint16 - Type string - RunID RunID - ID CommandID - Digest Digest - Command AgentCommand -} - -// AgentEvent carries one fact produced by an accepted command. All events of -// one transition share the same Revision, CommandID and CommandDigest; Index -// orders them within the transition. Identity and ordering are assigned by the -// authority and are not supplied by callers. Revision-0 creation is represented -// by NewRun/CreateResult rather than by a transition. -type AgentEvent struct { - SchemaVersion uint16 - Type string - RunID RunID - Revision uint64 - Index uint16 - CommandID CommandID - CommandDigest Digest // digest of the accepted command; idempotent replay compares against it - Digest Digest // canonical digest of this fact - Fact Fact -} - -// TransitionRecord is the atomic authority record for one accepted command. -// It binds the complete ordered event group for one Revision. -type TransitionRecord struct { - SchemaVersion uint16 - RunID RunID - Revision uint64 - CommandID CommandID - CommandDigest Digest - Events []AgentEvent // complete ordered event group for this revision - TransitionDigest Digest // digest of transition identity + complete event group -} - -type CommitResult struct { - Status CommitStatus - Snapshot RuntimeSnapshot - Events []AgentEvent // Accepted 或 AlreadyApplied 时为该 transition 的完整事件组 - Grant ExecutionGrant // 仅 Accepted 的 start command 会返回;AlreadyApplied 为空 -} - -type NewRun struct { - SchemaVersion uint16 - RunID RunID - CausationID es.CausationID -} - -type CreateResult struct { - Header RunHeader - Created bool -} - -// RunRecord is a consistent verified read of one Run. -type RunRecord struct { - Header RunHeader - Snapshot RuntimeSnapshot - Transitions []TransitionRecord -} - -func EncodeCommand(CommandEnvelope) ([]byte, error) // 不包含 Digest 字段 -func DigestCommand(schemaVersion uint16, typ string, command AgentCommand) (Digest, error) -func EncodeFact(schemaVersion uint16, typ string, fact Fact) ([]byte, error) -func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) -func BuildNewRun(RunID, es.CausationID) (NewRun, error) -func ValidateNewRun(NewRun) error -func BuildRunHeaderFromNewRun(NewRun) (RunHeader, error) -func ValidateRunHeader(*RunHeader) error -func BuildTransitionRecord([]AgentEvent) (TransitionRecord, error) -func ValidateTransitionRecord(*TransitionRecord) error -func DigestTransitionRecord(*TransitionRecord) (Digest, error) -func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineState, uint64, error) -func FoldRun(header *RunHeader, records []TransitionRecord) (MachineState, uint64, error) -func DigestRequest(ModelRequest) (Digest, error) -func DigestToolDefinition(ToolDefinition) (Digest, error) -func DigestToolSpec(ToolSpec) (Digest, error) -func DigestToolSpecs([]ToolSpec) (Digest, error) -func DigestModelStepBinding(model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) -func DeriveModelRequestCommandID(RunID, uint64) CommandID -func DeriveModelStepID(RunID, CommandID, Digest) StepID -func DeriveToolStepID(StepID, Digest) StepID -func DeriveResponseID(RunID, StepID, CallID, ResponseKind) ResponseID -func DeriveResponseCommandID(RunID, StepID, CallID, ResponseID) CommandID -func DeriveInputCommandID(RunID, InputID) CommandID -``` - -`ExecutionGrant` 是 Runtime 返回的 opaque capability,只用于证明当前 Loop 获得了执行许可;调用方只保存并原样传回,不依赖其内容。Step 业务字段、AttemptID、FenceToken、lease、数据库类型和用户认证凭证均不使用该值表达。Runtime 必须生成不可预测且绑定到单个 Step/Call 和当前执行所有者的值,并在完成 command 中校验它。`run.MemoryRuntime` 可以用 mutex 加随机 generation 实现它;durable adapter 可以用 owner/fence/lease 实现它。 - -`Attempt` 表示一次进程对当前 ModelStep 或 ToolCall 的执行占用,属于 Runtime 私有 control plane。MachineState 与 Step 提供语义状态和恢复边界。一个 Step 可以先后有多个 Attempt;旧 Attempt 失效后,新的 Loop 重新读取同一个 Step。MemoryRuntime 的 Attempt 是内存中的占用记录,durable adapter 的 Attempt 是私有 lease/fence 记录。 - -grant 规则固定为: - -```text -PrepareModelRequest、StartModelExecution、StartToolCall - 使用空 Grant;start command 只有 CommitAccepted 才返回新的 Grant。 - -SubmitModelResult、SubmitModelFailure、RejectModelResult、 -Executing Call 的 SubmitToolResult/SubmitToolFailure - 必须带回对应 start command 返回的非空 Grant;Pending Call 的已知失败使用空 Grant。 - -ApproveToolCall、RejectToolCall、SubmitToolResponse、CancelRun、AcceptInput - 使用空 Grant;Runtime 依据状态、Revision 和 command 身份校验。scanner 的 Unknown 和 - 无 grant 的 RecoverModelExecution 还必须匹配 Runtime 自己记录的 lease-expired/recovery - 事实,不能仅凭调用者构造同名 command。当前 Loop 主动释放自己持有的 Model grant 时, - RecoverModelExecution 必须带对应非空 Grant。 -``` - -Grant 只绑定一个 ModelStep 或一个 ToolCall,不能转用于另一个 Call。外部 response 入口不需要持有 Loop attempt 的执行凭据。 - -### 5.4 Commit 语义 - -提交决策是一个由 agent 导出的纯函数;两个 Runtime 在各自的临界区/事务内调用同一份实现,不各自复刻规则: - -```go -type CommitDecision struct { - Kind DecisionKind // Apply | AlreadyApplied | Conflict | Stale | Terminal - NewState MachineState - Events []AgentEvent // event-stream view of Transition.Events - Transition TransitionRecord // authority aggregate for this commit -} - -// grantValid/recoveryValid 由 Runtime 依据自己的 lease/occupancy 记录判定后传入; -// prior 是相同 (RunID, CommandID) 的已有 transition(若有)。 -func EvaluateCommit( - cur MachineState, curRevision uint64, - prior *TransitionRecord, - req CommitRequest, - grantValid bool, - recoveryValid bool, -) (CommitDecision, error) -``` - -`EvaluateCommit` 的固定顺序: - -1. 校验 CommandEnvelope 的 canonical digest 和 command identity。 -2. 已有相同 `(RunID, CommandID)` 且 digest 相同,返回 `AlreadyApplied` 与原事件组,不重复写入任何 projection 或 outbox,不重新运行 Decide(决策在首次接受时冻结)。 -3. 相同 identity 携带不同 digest,返回 `ErrCommandConflict`。 - 对 `AcceptInput`,还按 RunID/InputID 检查已接受索引:相同 payload 返回原事件组和 `CommitAlreadyApplied`,不同 payload 返回冲突,不产生第二条输入事实。 -4. 校验 BaseRevision、当前 Step、CallID 和 Grant。BaseRevision 只对 `PrepareModelRequest` 是硬校验——其 CommandID 由 Revision 派生,StepID 由该 CommandID 与 Decide 得到的 binding digest 派生,Revision 即它的并发控制,过期即返回 `ErrStaleRuntime`。其余 command 在 BaseRevision 过期时按类别前置条件基于当前状态重新评估:start(`StartModelExecution` 目标仍须为同一 Prepared ModelStep;`StartToolCall` 与 Pending 的已知失败目标 Call 仍须为 Pending,均空 Grant);owner 完成(Executing Call 的完成/失败、`SubmitModelResult`/`SubmitModelFailure`/`RejectModelResult`,以及持有效 Model grant 的 `RecoverModelExecution`,以提交者仍持有对应有效 Grant 为条件);system recovery(无 grant 的 `RecoverModelExecution` 和 scanner 的 Unknown,以 Runtime 自己的 recovery record 为条件);ingress(approval/external response 目标 Call 仍须为对应 Waiting 且 ResponseID/kind 匹配;`AcceptInput` 要求 Run 仍 active 且没有当前 Step,连续多条输入互不拒绝;均空 Grant);run-control(`CancelRun` 只要求 Run 非 terminal)。前置条件不满足时按具体原因返回 stale/terminal/冲突。start command 建立 grant/lease 的动作与状态提交属于同一个原子操作。 -5. 调用 `Machine.Decide` 产出事实序列,逐个 `Machine.EvolveVersion` 折叠出新状态;为这次 transition 分配 `Revision = curRevision + 1`,事实按序获得 `Index = 0..k-1`,全部携带产生它们的 CommandID,再封装为带 `TransitionDigest` 的 `TransitionRecord`。 -6. Runtime 在自己的原子边界内保存 MachineState、TransitionRecord 及需要一致的 Memoh projection;`CommitResult.Events` 返回该 transition 的 `Events` 视图。 -7. 非重复 command 若目标 Run 已经 terminal,返回 `ErrRunTerminal`;迟到的 worker 结果不会重新打开 Run。其他提交成功后返回新 snapshot。 - -`EvaluateCommit` 用 `DecisionKind` 表达结果;`Runtime.Commit` 把非成功结果映射为对外错误:`DecisionConflict -> ErrCommandConflict`、`DecisionStale -> ErrStaleRuntime`、`DecisionTerminal -> ErrRunTerminal`。Loop 与 ingress 只依赖这三个错误值和 `CommitStatus`,不接触 DecisionKind。 - -新 Run admission 由 Application/turn 分配 stable RunID/causation,并由 `Runtime.Create(NewRun)` 建立最小 `MachineState`(Revision=0)和 immutable header;初始用户输入作为该 Run 的第一条 `AcceptInput` transition 进入日志。不存在持久化 `RunConfig` 或绕过 Commit 的 `RunSeed`。Create 不接收 input、binding 或 policy。 - -Commit 的 effectively-once 只针对状态提交。事务已提交但响应丢失时,Loop 使用相同 CommandID 和 digest 重放;`CommitAlreadyApplied` 不能重新授予工具执行权,也不能重复 history、queue action、计数或 outbox。对于 start command,AlreadyApplied 的 `Grant` 必须为空;新 Loop 要等待原执行或 recovery,不能把重放当成新的 start。外部工具 effect 不因此变成 exactly-once。 - -这意味着 start 提交成功但响应丢失时,无法证明重试者仍是原 execution owner;Runtime 不安全地重新发放旧 grant。重试者只能等待原 owner 完成,或等待 lease recovery 将该 Call 收束为 Unknown。这是为了避免两个进程同时执行同一个外部工具的保守可用性取舍。 - -这条 call-local rebase 是并行执行成立的条件:A 的 start 使 Run 的 Revision 前移后,仍基于旧 Revision 提交的 B start 可以在 B 仍为 Pending 时被接受;A 的变化不能使 B 的 start 条件失效。B 的完成 command 也可以在 A 的无关 transition 之后提交,但必须带 B 自己的有效 Grant。重新评估不能跨越使前置条件失效的变化:同一 Call 已被推进、当前 Step 已关闭、Run 已因 Cancel 或 Unknown 进入 terminal 时,迟到的 command 按具体原因返回 stale/terminal,不静默改写。一个 Call 进入 Unknown 并使 Run terminal 后,其他 Call 的完成 command 不再改变 MachineState。 - -### 5.5 Command identity 和幂等 - -幂等是为了处理“提交已经成功,但 Loop 没收到响应”的情况: - -```text -Loop -> Commit(command C) -Runtime 已提交 C(事实组落地) -进程在收到响应前崩溃 -新 Loop -> 重放同一个 C -Runtime -> CommitAlreadyApplied + 原事件组 -``` - -command 身份规则: - -```text -RunID 和 CommandID 始终非空,且是区分大小写的 UTF-8 字符串。包含 Step、Call、Response 或 Input 的 command 必须提供相应的非空 ID;CancelRun 等 run-level command 不伪造 StepID/CallID。 - -Digest - 是 agent canonical bytes 的 SHA-256,wire 形式为 sha256:<64 位小写十六进制>。 - CommandEnvelope.Digest 摘要 command 内容;AgentEvent.Digest 摘要事实内容。 - -普通 CommandID - 由产生意图的执行者生成,重试时必须复用。 - -model request CommandID - `PrepareModelRequest` 的 CommandID 由 RunID 和 BaseRevision 稳定派生;StepID 由 RunID、该 CommandID 和 frozen request/tool binding digest 派生。相同 revision 的并发 planner 因此竞争同一个 identity:内容相同 replay,内容不同 conflict。 - -每个 AgentCommand 都有自己的 CommandID;同一个 execution attempt 的 start、completion 和 recovery 是不同 command,因此各自使用不同 ID。某个 command 因响应丢失而重放时必须复用原 ID。Runtime 以当前 Step/Call 状态和 CAS 防止第二个 attempt 获得执行权;ModelStep 经 recovery 回到 Prepared 后,下一次 start 使用新的 ID。 - -`RecoverModelExecution` 有两条合法来源:当前 Loop 持有效 Model grant 且模型调用未产生可接受结果时,使用该 grant 主动释放 execution;或者 Runtime 在 lease 失效后依据自己的 recovery record 提交。后者的 CommandID 由 Runtime 根据 recovery record 生成;同一个 recovery record 重放使用同一个 ID,不同的失效执行使用不同 ID。新的 Loop 随后重新提交同一冻结 request 的 start command。 - -response CommandID - 只由 RunID、StepID、CallID 和 ResponseID 稳定派生;payload/decision digest - 只放在 CommandEnvelope.Digest,用于检测同一响应身份的内容冲突。 - -input CommandID - 由 RunID 和 InputID 稳定派生;queue claim 的私有 item reference - 不进入 agent command/event payload。 - -AgentEvent 身份 - 由 authority 分配:(RunID, Revision, Index) 全序唯一,CommandID/CommandDigest 关联到 - 产生它的 transition——重放判定即比较传入 command 的 digest 与已存事件组的 - CommandDigest,Runtime 不需要独立的 command 索引表。事件身份不由调用方提供, - 也不参与 command 幂等判定。 -``` - -canonical 编码和 digest 函数由 agent 提供;Memoh 只保存和比较结果,不重新实现排序或编码。`CanonicalJSON` 按 RFC 8785/JCS 编码,并采用 IEEE-754 binary64 number 语义;精确 ID 或任意精度数值必须是 JSON string,因此 PostgreSQL JSONB 的结构化重写在重新 canonicalize 后保持 digest 稳定。编码必须包含 sealed command/fact discriminator、按声明顺序编码有序 slice、对 map key 排序,并对 `CanonicalJSON` 原样写入其 canonical bytes;重复 object key、trailing data、invalid UTF-8、escaped lone surrogate 一律在构造 `CanonicalJSON` 或 decode wire document 时拒绝,不能让 `encoding/json` 的 replacement behavior 把不同输入合并为同一 digest;不把 `Digest`、BaseRevision、Revision、Index 或 ExecutionGrant 编入 digest。 - -已发布 `SchemaVersion` 的 canonical 编码和 digest 规则永久冻结;字段增删只能进入新的 SchemaVersion,旧事件按其自带版本校验。同一个 Run 不允许由写入不同 SchemaVersion 的进程混跑:升级窗口内先全量部署可读写新版本的代码,再开始写入新版本;否则同一 command 的重放会因编码不同被误判为 `ErrCommandConflict`。 - -CommandEnvelope 和 AgentEvent 的 `SchemaVersion`、`Type` 是 Run 持久化协议字段;Type 必须与 sealed AgentCommand/Fact 的具体变体一致,未知版本或类型直接拒绝。跨 domain causation 固定在 immutable `RunHeader.CausationID`,command/event wire 不增加第二级 lineage 字段。`agent/run` 必须提供正式 wire codec:decode 时先 canonicalize 整个 document,再读 `Type`,恢复具体 command/fact variant,校验 command/fact digest,并要求 decoded value 重新 canonical marshal 后与输入 canonical document 等价;不能依赖 `encoding/json` 自动反序列化 interface 字段,也不能接受 duplicate key、大小写模糊字段名或其他会被 Go decoder 合并/宽容的形态。`DigestCommand`/`DigestFact` 对 `SchemaVersion`、`Type` 和内容做 canonical digest,并排除 `Digest` 字段自身;`Revision` 只用于 authority 的 CAS,不进入 digest。 - -Evolve 的折叠语义与事件编码同属永久兼容契约:已发布 SchemaVersion 的事件必须永远能被折叠出与写入当时相同的状态。conformance kit 为每个已发布 SchemaVersion 冻结 golden transition stream 与对应的状态字节,任何 versioned Evolve 实现变更都必须通过全部历史版本的 golden 校验。 - -### 5.6 Runtime 不拥有 Planner - -agent 只声明供 Loop 依赖注入的 `RequestPlanner` port;Planner 的实现和语义属于 -application/Memoh。Runtime 不调用这个 port,也不读取 Planner 的 context: - -```go -// package loop; run is "github.com/memohai/twilight/agent/run" -type RequestPlanner interface { - Plan(context.Context, run.PlanningHint) (RequestPlan, error) -} - -type RequestPlan struct { - Model run.ModelRef - Request sdk.Request // boundary value; Loop freezes to ModelRequest before PrepareModelRequest - InputIDs []run.InputID - Tools []run.ToolSpec // frozen ToolSpec/ToolDefinition sidecars - PlanningToken run.PlanningToken // application-owned freshness token -} - -// package run -type PlanningHint struct { - RunID RunID - SourceStep StepID - Inputs []AgentInput -} -``` - -Planner 可以读取 application 自己的 history、memory、workspace 和 queue-safe 输入,但不直接修改 Runtime。它返回 `RequestPlan`(完整边界 `sdk.Request`、ModelRef、已消费的 InputID 集合和 application-owned `PlanningToken`);Loop 先 freeze 成 agent `ModelRequest`,随后提交 `PrepareModelRequest`。Memoh 在事务外构造请求,在提交时用自己的 context revision/CAS 和 create-if-absent 确保并发 Planner 只冻结一份结果。`PlanningToken` 只是供 adapter 验证 planner 输入是否新鲜的 opaque token;agent 不解释其内容,也不把它当成 authority revision。 - -Planner 所需的 history 必须由宿主提供:Memoh 从 durable history projection 读取;in-process -调用者可以用一个简单的内存 history projection 或 planner 自己持有的会话上下文。Runtime -不负责把 AgentEvent 推送给 Planner,也不因此新增 history/store 方法。 - -`PrepareModelRequest` 必须使用 Planner 开始前 `Load(ctx, runID)` 得到的 BaseRevision,并按当前顺序携带完整的 `PendingInputs` `InputIDs` 集合。相同 RunID 和 Revision 的并发 Planner 使用同一个 `DeriveModelRequestCommandID`:相同请求得到 `CommitAlreadyApplied`,不同请求得到 `ErrCommandConflict`;如果 Planner 在新的 Revision 上重试,则生成新的 CommandID。Memoh adapter 同时检查 `PlanningToken` 的 context revision,后到者不能覆盖已经冻结的请求。 - -Loop 校验 `RequestPlan.Model`(若提供)与 frozen `ModelRequest.Model` 一致;Runtime 通过共享 Decide 规则再次校验 `PrepareModelRequest.Model` 与 `PrepareModelRequest.Request.Model` 一致。固定模型限制若存在,属于 Memoh/host policy,不进入 agent MachineState。Planner 的 context 一致性由 Memoh 在自己的 queue-safe admission/planning 边界保证,不由 agent 解释。 - -### 5.7 外部 response - -agent 不提供第三个 Loop 操作。Memoh/application 的 response ingress: - -1. 验证用户权限、Run/Step/Call 身份和 payload。 -2. 用 `Load(ctx, runID)` 读取 authority snapshot,确认目标 Call 仍为 Waiting;这个 ingress 读取不取得 Loop 的执行租约。 -3. 用 `ResponseRequest.RunID/StepID/CallID/ID` 路由响应。将 approval 转成 - `ApproveToolCall`/`RejectToolCall`,将外部结果转成 `SubmitToolResponse`; - `ResponseRequest.RequestDigest` 是原请求的摘要,用户决定或答案的摘要单独放在 - command 的 `ResponseDigest`,由 `DigestToolResponseDecision` / `DigestToolResponsePayload` - 计算,并用 `DeriveResponseCommandID` 生成稳定 CommandID。 -4. 通过同一个 `Runtime.Commit` 提交;重复响应按 AlreadyApplied/Conflict 处理。 - -响应提交后,若有 Pending Call 或 ToolStep 已可自动关闭,Memoh 写入幂等 wake/outbox;其他 Waiting Call 保持原状态。响应入口不执行工具,也不调用模型。 - -如果同一 ToolStep 还有 Executing Call,响应提交仍写入自己的 wake;执行 worker 的完成提交或 recovery 也必须写 wake。下一次 Loop 从 `Load(ctx, runID)` 重新判断两类事实,不要求把它们合并成一个等待状态。 - -### 5.8 queue 边界 - -steer/follow-up 的数据结构和仲裁属于 Memoh: - -```text -ModelStep 完成且没有 tool calls -ToolStep 自动关闭 -``` - -只有在这些 boundary,Memoh 才能把 queue 输入绑定到下一次 Planner 请求或创建后续 Run。ToolStep 中间的新输入不能修改已经冻结的 ModelStep,也不能跳过 Pending/Waiting Call。 - -steer 必须进入紧接着的 eligible ModelStep 规划上下文。Application coordinator 在 queue admission 与 Plan/Prepare 之间建立线性化顺序:steer 获胜时,先将 durable admission 与 `Runtime.Commit(AcceptInput)` 原子或可幂等协调,再允许规划;Prepare 已建立 frozen ModelStep 时,新输入等待下一 eligible boundary。AcceptInput 推进 Revision 后,基于旧 Revision 的 Prepare 自然返回 `ErrStaleRuntime`,Loop 重新 `Load(ctx, runID)` 并从 `PlanningHint.Inputs` 重新规划。共享 `run.Runtime` 不读取 queue;同库事务、private coordination capability 或 inbox/outbox 均属于 Application adapter。in-process harness 由宿主执行相同顺序。 - -## 6. Loop 算法 - -### 6.1 Loop 结构 - -```go -// package loop; run is "github.com/memohai/twilight/agent/run" -type Loop struct { - Models ModelCatalog - Tools ToolCatalog - Planner RequestPlanner - Execution ExecutionPolicy - Streaming bool -} - -func New(ModelCatalog, ToolCatalog, RequestPlanner, ExecutionPolicy, bool) (*Loop, error) -func (l *Loop) Run(context.Context, run.Runtime, run.RunID, EventSink) (LoopResult, error) -``` - -Loop 是当前进程的解释器。它不复制权威状态,不直接访问数据库或 Memoh queue。 - -Loop 使用一个用于读取/提交的 `controlCtx`,并为每个模型或工具 worker 派生独立的执行 context。`controlCtx` 由宿主提供,或由 `context.WithoutCancel` 再加一个有限 deadline 得到;它不能因为单个 worker 取消而失效。worker 被取消后,Loop 仍必须用未被 worker 取消的 control context 提交已知结果、Unknown 或 model recovery。整个进程退出时,MemoryRuntime 的内存状态当然不会保留。 - -两类 worker 的执行 context 派生规则不同。模型 worker 的执行 context 从外层 ctx 派生:取消模型调用是安全的,未接受结果时以 `RecoverModelExecution` 释放,同一冻结 request 之后重试。工具 worker 的执行 context 不随外层 ctx 取消(由 `context.WithoutCancel(ctx)` 加每次执行的 deadline 派生):工具一旦 start 就运行到自身结束,外层 ctx 取消不会把本可正常完成的工具打断成 Unknown。工具 worker 只在两种情况下被主动取消:Run 已进入 terminal(其迟到结果按 terminal/stale 审计),或同组另一个 worker 报告 Unknown。 - -### 6.2 主算法 - -```text -Loop.Run(ctx, runtime, runID, events): - outer: - for: - if ctx is cancelled: - return LoopResult{}, ctx.Err() // 已 start 的本地 worker 先按 6.1 的派生规则收束 - snapshot, err := runtime.Load(controlCtx, runID) - if err != nil: - return err - if snapshot.State.Status is terminal: - return LoopFinished(snapshot.State.Result) - - effect, err := Machine.Next(snapshot.State) - if err != nil: - return err - switch effect: - case NeedModelRequest(hint): - plan, err := l.Planner.Plan(ctx, hint) - if err != nil: - return err - frozenRequest, err := FreezeModelRequest(plan.Request) - if err != nil: - return err - requestDigest, err := DigestRequest(frozenRequest) - if err != nil: - return err - toolsDigest, err := DigestToolSpecs(plan.Tools) - if err != nil: - return err - bindingDigest, err := DigestModelStepBinding(plan.Model, requestDigest, toolsDigest) - if err != nil: - return err - commandID := DeriveModelRequestCommandID(snapshot.State.RunID, snapshot.Revision) - stepID := DeriveModelStepID(snapshot.State.RunID, commandID, bindingDigest) - prepared, err := commit( - PrepareModelRequest{ - StepID: stepID, Model: plan.Model, Request: frozenRequest, - RequestDigest: requestDigest, InputIDs: plan.InputIDs, - PlanningToken: plan.PlanningToken, Tools: plan.Tools, - ToolsDigest: toolsDigest}, - commandID=commandID, baseRevision=snapshot.Revision, grant=zero) - if err == nil and (prepared is Accepted or AlreadyApplied): - continue outer loop - if err == ErrCommandConflict or err == ErrStaleRuntime: - continue outer loop - if err != nil: - return err - - case StartModelCall(stepID): - startID := freshCommandIDForThisCommand() - start, err := commit(StartModelExecution{StepID: stepID}, commandID=startID, - baseRevision=snapshot.Revision, grant=zero) - if err == ErrStaleRuntime or err == ErrRunTerminal: - continue outer loop - if err != nil: - return err - if start is AlreadyApplied: - continue outer loop - modelStep := start.Snapshot.State.Current.(ModelStep) - invoker, err := l.Models.Resolve(modelStep.Model) - var completion AgentCommand - completionID := freshCommandIDForThisCommand() - if err != nil: - completion = SubmitModelFailure{StepID: stepID, Failure: StepFailureForModel(err)} - else: - sdkRequest, err := modelStep.Request.SDK() - if err != nil: - return err - modelResult, invokeErr := invokeModel(invoker, workerCtx, sdkRequest, l.Streaming, events) - if invokeErr != nil and worker context was cancelled: - completion = RecoverModelExecution{StepID: stepID} - else if invokeErr != nil: - completion = SubmitModelFailure{StepID: stepID, Failure: StepFailureForModel(invokeErr)} - else: - bindings, bindErr := bindToolCalls(modelResult, modelStep.Request, modelStep.Tools) - if bindErr != nil: - completion = RejectModelResult{StepID: stepID, Usage: UsageFromSDK(modelResult.Usage), - Failure: StepFailure{Class: FailureMalformedModel, Message: bindErr.Error()}} - else: - frozenResult, err := FreezeModelResult(modelResult) - if err != nil: - return err - completion = SubmitModelResult{StepID: stepID, Result: frozenResult, Calls: bindings} - applied, err := commit(completion, commandID=completionID, - baseRevision=start.Snapshot.Revision, grant=start.Grant) - if err == ErrStaleRuntime or err == ErrRunTerminal: - continue outer loop - if err != nil: - return err - if applied is Accepted or AlreadyApplied: - continue outer loop - continue outer loop - - case StartToolCalls(stepID, callIDs): - startedWorkers := [] - for each call in selectByPolicy(callIDs, l.Execution): - tool, resolveErr := l.Tools.Resolve(call.ToolRef) - argErr := nil - policyErr := nil - definitionErr := nil - if resolveErr == nil: - frozenDefinition, freezeErr := FreezeToolDefinition(tool.Definition()) - if freezeErr != nil or tool.Ref() != call.ToolRef or DigestToolDefinition(frozenDefinition) != call.DefinitionDigest: - definitionErr = definitionMismatch - argErr = tool.ValidateArguments(call.Arguments) - if tool.ResponsePolicy() != call.Policy: - policyErr = definitionMismatch - } - if resolveErr != nil or argErr != nil or policyErr != nil or definitionErr != nil: - failed, err := commit(SubmitToolFailure{StepID: stepID, CallID: call.CallID, - Failure: ToolFailureFor(resolveErr, argErr, policyErr, definitionErr), Outcome: ToolOutcomeKnown}, - commandID=freshCommandIDForThisCommand(), baseRevision=snapshot.Revision, grant=zero) - if err == ErrStaleRuntime or err == ErrRunTerminal: - launchAndSettle(startedWorkers) - continue outer loop - if err != nil: - return err - if failed is Accepted or AlreadyApplied: - continue with next call - continue with next call - startID := freshCommandIDForThisCommand() - start, err := commit(StartToolCall{StepID: stepID, CallID: call.CallID}, commandID=startID, - baseRevision=snapshot.Revision, grant=zero) - if start is Accepted: - worker := launch the resolved `l.Tools` ExecutableTool for callID, - retaining start.Grant for completion - startedWorkers.append(worker) - else if err == ErrStaleRuntime or err == ErrRunTerminal: - launchAndSettle(startedWorkers) - continue outer loop - else if err != nil: - return err - else if start is AlreadyApplied: - continue with next call without launching it - launch all accepted workers, then wait for every worker started by this Loop branch. - Workers already in Executing state belong to another live or recoverable - attempt; this branch neither launches nor waits on them. - As each worker returns, immediately commit its SubmitToolResult or SubmitToolFailure, - using that Call's start grant; a stale BaseRevision is eligible for Call-local rebase. - If one worker reports Unknown, cancel the other workers and do not commit their late results. - After all workers started by this branch have settled or been cancelled, - continue outer loop; a subsequent Load(controlCtx, runID) decides whether the remaining - Executing calls require recovery or whether more Pending calls can start. - - case WaitForResponse(requests): - return LoopWaiting(requests) - - case WaitForExecutionRecovery: - return LoopWaiting(ExecutionRecovery) - - // Every accepted event is followed by Load(controlCtx, runID); Loop does not keep a - // second authoritative MachineState. -``` - -外层 ctx 取消不会让 Loop 立即返回:模型 worker 随之取消并以 `RecoverModelExecution` 释放,工具 worker 按 6.1 的规则运行到自身结束并提交结果,随后循环顶部的 ctx 检查返回 `ctx.Err()`。取消只结束本次 Loop 执行,不改变 Run 状态。 - -`commit` 为每个 AgentCommand 生成一次 CommandID;如果提交响应丢失,使用同一个 CommandID 和 digest 重放。`CommitAlreadyApplied` 表示对应事件组已经落地,不能再次执行相应外部 effect;Loop 重新 `Load(ctx, runID)` 后根据权威状态决定下一动作。 - -伪代码中的 `commit(command, commandID, baseRevision, grant)` 是一个小型 Loop helper:它补齐 CommandEnvelope 的 Type、SchemaVersion 和 `DigestCommand`,然后调用唯一的 `Runtime.Commit`。构造 CommandEnvelope 与派生 ID 只能通过 agent 提供的 typed 构造函数与 helper;任何代码不得手工拼装信封字段。 - -`launchAndSettle(startedWorkers)` 是伪代码 helper:它等待已经接受 start 的 worker,并为每个 worker 提交完成/已知失败;worker 无法确定结果时提交 Unknown。它不能简单丢弃 worker 或 grant。 - -`freshCommandIDForThisCommand()` 为一次具体的 AgentCommand 生成新 ID,并在该提交重试范围内保存;同一 execution attempt 的 start、completion 和 recovery 是不同 command,因此各自拥有不同 ID。 - -一旦某个 `StartToolCall` 已被接受,当前分支必须启动并收束该 worker,或在无法启动时提交对应的已知失败/恢复结果;不能因为后续 Call 的 start 返回 stale/terminal 就遗弃已经返回的 grant。伪代码中的 `continue outer` 只有在先处理完本分支已接受的 start(或明确取消并提交其结果)后才允许执行。 - -`ToolFailureFor` 将 resolve error 映射为 `tool_lookup_failed`,参数校验 error 映射为 `invalid_arguments`,tool ref、response policy 或 definition digest 不匹配映射为 `tool_definition_mismatch`。 - -`invokeModel` 是说明性 Loop helper:开启 streaming 且 invoker 支持 `StreamingModelInvoker` 时消费 stream -并向 EventSink 发送 delta,否则调用 `Generate`;两条路径都只返回一个完整边界 `sdk.ModelResult`,Loop 在提交前用 `FreezeModelResult` 转为 agent `ModelResult`。 - -所有 completion commit 都遵循同一错误处理:`ErrStaleRuntime`/`ErrRunTerminal` 只触发重新 `Load(ctx, runID)` 并丢弃迟到结果,其他错误返回给调用方;只有 `CommitAccepted` 或相同 command 的 `CommitAlreadyApplied` 才表示该事实已被 authority 接受。接受后 Loop 可以把 `CommitResult.Events` 逐个包装为 `Event{Kind: EventAgentCommitted, Durability: EventCommitted, Canonical: &e}` 发送给 EventSink;发送失败不回滚提交。 - -### 6.3 模型调用 - -```text -NeedModelRequest - -> Planner.Plan - -> Commit(PrepareModelRequest) - -> Commit(StartModelExecution) - -> sdk.Generate 或 sdk.Stream - -> Commit(SubmitModelResult、SubmitModelFailure 或 RejectModelResult) -``` - -Loop 的 `Streaming` 选项决定是否优先使用可选的 `StreamingModelInvoker`;未开启或没有该实现时使用 `Generate`。stream 中的文本 delta 可以实时发给 EventSink;只有完整 `ModelResult` 的完成提交会改变 MachineState。模型请求失败时由 Machine 的 Decide 决定是否将 Run 置为 RunFailed;provider transport retry 不穿透到 Machine。 - -### 6.4 工具调用 - -```text -ToolStep.Pending - -> Commit(StartToolCall) - -> ExecutableTool.Execute - -> Commit(SubmitToolResult / SubmitToolFailure) -``` - -Loop 先解析 ToolRef、校验参数、工具定义 digest、response policy 和 binding digest,再提交 start command。多个独立 Pending Call 可以按 Loop 的 `ExecutionPolicy` 并行;每个 execution attempt 有自己的 CommandID 和 ExecutionGrant。如果解析或参数校验已经失败,直接提交 Pending Call 的已知 `SubmitToolFailure`,不经过 start barrier,也不调用外部工具。 - -如果 start command 已经成功但响应丢失,Loop 不能把 `AlreadyApplied` 当作新的执行授权;它必须重新 `Load(ctx, runID)`。旧执行仍被确认拥有时,新的 Loop 返回 `LoopWaiting(ExecutionRecovery)`;执行权失效后由 Memoh recovery 处理为 Unknown。这样即使崩溃发生在 start barrier 与真正调用之间,也不会无凭据地重复执行工具;代价是该 Call 按 Unknown 终止当前 Run。 - -### 6.5 等待与并行 - -```text -Pending + Waiting - -> 先执行 Pending,Waiting 不阻塞 - -只有 Waiting,没有 Pending - -> LoopWaiting(WaitingForResponse) - -> 若同时有 Executing,response 和 execution recovery 都必须唤醒下一次 Loop - -只有 Executing - -> 仍有有效 owner/lease 时等待当前 worker;执行权失效后等待 Runtime recovery - -全部 Call terminal - -> Machine 自动关闭 ToolStep -``` - -不同 Call 的 response 可以并行到达。一个 response 不会取消或轮换其他 Call 的执行授权。每次重新唤醒都从 `Runtime.Load(ctx, runID)` 开始。 - -一个 Loop 在自己成功 start 的 worker 尚未结束时不会返回 `LoopWaiting`;它会等待该 worker -提交完成/失败,或提交 recovery。只有加载同一 Run 的后续 Loop,才可能看到 -`Waiting + Executing` 并返回 `WaitingForResponse`;原 worker 的完成事件或 Runtime recovery -通过 Memoh outbox、scanner,或 in-process 的宿主唤醒下一次 Loop。LoopResult 不携带 worker -句柄,也不要求新 Loop 接管旧 Attempt。 - -### 6.6 错误、取消和 limit - -| 情况 | 处理 | -| --- | --- | -| provider transport/rate limit | sdk 在一次模型调用内处理;最终失败由 Machine 记录。 | -| 结构性 malformed 模型结果 | 提交 `RejectModelResult`;Loop/host 通过 Disposition 选择回到 Prepared 重试或同 transition RunFailed。 | -| tool lookup/参数错误 | 提交已知 `SubmitToolFailure`,交给下一次模型请求。 | -| 工具明确失败 | 提交已知 `SubmitToolFailure`,模型决定是否在新 Step 重试。 | -| 工具结果未知 | 提交 Unknown,RunFailed;不自动重试,不创建下一 ModelStep。 | -| 外层 ctx 在工具 start 前取消 | 不再 start 新 Call;已 start 的 worker 运行到结束并提交,Loop 随后返回 `ctx.Err()`。 | -| 外层 ctx 在模型执行中取消 | 模型 worker 取消,提交 `RecoverModelExecution`,Loop 返回 `ctx.Err()`。 | -| 进程崩溃留下 Executing Call | Runtime recovery 按 §8.2 收束为 Unknown。 | -| application CancelRun | 宿主必须先提交 `CancelRun`(Run 置为 RunStopped),再取消 Loop 的 ctx;顺序颠倒会让执行中的工具收束为 Unknown,把用户停止错记为 `RunFailed(effect_unknown)`。 | -| stale Revision/grant | 丢弃本地结果,重新 `Load(ctx, runID)`;不重放旧 Step 的外部 effect。 | -| Commit 响应未知 | 同一 CommandID/digest 重放一次;仍未知则结束当前 Loop,后续 `Load(ctx, runID)` 读取 authority。 | - -计划内停机(部署、滚动升级)不走崩溃路径:宿主收到停止信号后取消 Loop 的外层 ctx,按上表语义排空——不再 start 新 Call,已 start 的工具 worker 在停机 grace 内提交完成/失败,模型执行以 recovery 释放。grace 内未能结束的工具执行才留给 lease recovery 收束为 Unknown。 - -固定模型、model-step budget 和 malformed retry budget 不进入 MachineState。Loop/host 可以使用非持久化 `ExecutionPolicy`:`ModelStepLimit=0` 表示无限,达到正数上限时在下一次 planning 前提交 `StopRun{ReasonStepLimit}`;`MalformedModelResultLimit=0` 归一化为默认值 `2`,Loop 根据当前 `ModelStep.Rejects` 选择 `RejectModelResult` 的 retry/fail-run disposition。Memoh 也可以在自己的 host policy 中实现同样逻辑。 - -## 7. Tool、approval、response 和 MCP - -### 7.1 Tool contract - -`sdk.ToolDefinition` 只描述 provider 可发现的 schema,不依赖 run,也不携带 `ResponsePolicy`。`loop.ExecutableTool.Definition()` 返回 SDK 边界类型,Loop 必须先经 `run.FreezeToolDefinition` 冻结再计算 `run.DigestToolDefinition` 或写入 `run.ToolSpec`;MachineState/AgentEvent 中保存的是 `run.ToolDefinition`。`loop.ExecutableTool` 描述应用如何执行工具并提供 response policy;模型返回后,Loop 用 `run.ToolRef`、definition digest 和 policy 生成冻结的 `run.ToolCallBinding`。恢复时 schema、工具版本或 policy 不匹配都不能静默换版本。 - -```go -// package loop; run is github.com/memohai/twilight/agent/run -type ExecutableTool interface { - Ref() run.ToolRef - Definition() sdk.ToolDefinition - ResponsePolicy() run.ResponsePolicy - ValidateArguments(run.CanonicalJSON) error - Execute(context.Context, ToolExecutionRequest) ToolExecutionOutcome -} -``` - -`ResponsePolicy` 决定工具是直接执行、需要 approval,还是等待外部 response;`ValidateArguments` 在 start barrier 前运行并且不能产生外部 effect。工具定义及其 digest 同时承担工具版本绑定。工具进度通过 `ToolProgressSink` 进入 EventSink,只是实时观察。需要影响恢复的状态必须提交 AgentCommand;工具内部的瞬时观察仍只发送 EventSink provisional event。 - -Loop 为每个执行 worker 创建绑定了 RunID/StepID/CallID 的 `ToolProgressSink`;其 `Publish` 只是向同一个 `EventSink` 发出 provisional `EventToolProgress`,不调用 Runtime,也不产生 AgentCommand。 - -### 7.2 approval 和 ask-user - -approval 和 ask-user 都是 ToolCall response policy,Step 类型保持 ModelStep/ToolStep: - -```text -approval required -> Waiting(Approval) -ask_user -> Waiting(ExternalResponse) -``` - -批准后仍要执行实际工具;拒绝产生已知 permission-denied 结果;ask-user 的答案直接完成对应 Call。多个 Waiting Call 同时存在时,每个响应按 CallID 独立推进。 - -### 7.3 MCP - -Application adapter 把 MCP tool 转换为 `sdk.ToolDefinition` 和 `loop.ExecutableTool`。MCP server 的连接、认证、生命周期和产品权限由 Memoh/application 管理;Loop 不依赖 SDK 的 MCP session。 - -## 8. 同一 Run execution semantics 的 Runtime implementations - -### 8.1 `run.MemoryRuntime` - -`run.MemoryRuntime` 是 RunID-addressed、多 Run 的 in-process reference runtime: - -```text -empty Run collection - RunID -> (immutable RunHeader + MachineState projection + TransitionRecord log + per-Run mutex) -``` - -`NewMemoryRuntime()` 创建空 collection,不接收 initial state。`Create` 仅持有 collection map lock 以建立或查找 RunID entry;相同 canonical header 返回 `Created=false`,不同 header 返回 `ErrCreateConflict`。`Load`、`Commit` 和 `Record` 先按 RunID 查找 entry,再使用该 Run 的独立锁: - -```text -Load(runID) - 在该 Run 的锁内返回当前 MachineState 和 Revision - -Commit(envelope.RunID) - 在该 Run 的锁内判定本进程 grant 有效性,调用共享 EvaluateCommit, - 原子保存 MachineState、完整 TransitionRecord 和幂等索引; - 接受 start command 时返回本进程的 opaque ExecutionGrant - -Record(runID) - 在同一 Run 锁边界读取 detached header、snapshot 和完整 log, - 验证 header/每个 record,并 fold 验证 snapshot -``` - -collection map lock 只保护 create/lookup;不同 Run 的 Load、Commit 和 Record 能以各自 per-Run lock 并行。 - -它可以省掉 durable **机制**: - -```text -lease / fencing / distributed claim / worker heartbeat -DB transaction / durable outbox / crash 后跨机器 recovery -persistent execution queue -``` - -但不能省掉 durable **语义**: - -```text -RunHeader -TransitionRecord complete append -AgentCommand -> Decide -> Fact[] -> EvolveVersion -execution grant/start barrier -idempotency -replay/fold invariants -``` - -因此 in-process 和 durable 是同一 `run.Runtime` contract 的不同实现。`Record(ctx, runID)` 产出 `RunRecord{RunHeader, RuntimeSnapshot, TransitionRecord log}` 这一致 verified read,供恢复、materialization、导入和诊断。跨 package export 由 Application 另行组合 Session archive 与 Artifact `BindingManifest`,并分别通过各 domain 的 verified import boundary。 - -MemoryRuntime 不保存 Session history、context、queue、long-term memory 或 product artifacts。需要多轮上下文的 in-process harness 在 Runtime 外组合 Session/Extension/Chatlog memory implementations、Artifact capabilities、Turn Coordinator 和 RequestPlanner。 - -### 8.2 Durable Run runtime adapter - -Durable adapter 位于 Application/Product(例如 Memoh),通过 PostgreSQL transaction/CAS 或等价存储实现同一个 `run.Runtime`: - -```text -Create(NewRun) - 验证 NewRun;按其 SchemaVersion 构造 immutable Revision-0 RunHeader - 对相同 RunID 做 header collision 判定:相同 header 返回 Created=false, - 不同 header 返回 ErrCreateConflict - -Load(runID) - 读取该 Run 的 MachineState projection、revision 和必要的私有 control-plane metadata - 不创建 Attempt,不取得 lease;control-plane 不出现在 RuntimeSnapshot - -Commit(envelope.RunID) - 判定私有 owner/fence/lease/recovery record 的有效性 - 对 StartModelExecution/StartToolCall 在同一事务内建立 Attempt/lease - 调用共享 EvaluateCommit - 原子保存 MachineState、TransitionRecord、idempotency index, - 并可保存 materialization delivery outbox 与必要的 queue claim outcome - -Record(runID) - 在同一 DB snapshot 中读取 immutable header、MachineState 和完整 TransitionRecords, - 验证 header/records 并 fold 验证 snapshot,再返回 detached RunRecord -``` - -`Load`、`Commit` 和 `Record` 对缺失 RunID 返回 `ErrRunNotFound`。 - -Attempt、owner、fence、lease、outbox row 和数据库 schema 不进入 `run.MachineState`。它们只保证多个 Loop attempt 不会同时取得同一个 Step/Call 的执行权。MachineState storage schema 与 Run event schema 独立版本化;需要重建状态时(存储布局迁移、一致性核对)用 `FoldRun` 从 header 和 transition log 折叠,这是运维操作而非日常路径。 - -Durable worker 实例可绑定 owner identity;只有当前 owner 能提交其取得的 start/completion/recovery。response、cancel、host StopRun 使用不带 worker grant 的 ingress/control adapter。lease/recovery scanner 必须通过正常 command + `EvaluateCommit` 提交 Unknown 或 Model recovery,不能直接改写 MachineState。Create、Load、Commit 和 Record 共同以 RunID 定址同一 collection;Record 的 verified read 为导入、迁移、诊断和 adapter conformance 提供一致输入。 - -### 8.3 外部 effect 的保证 - -```text -AgentCommand commit -> TransitionRecord - effectively-once(identity + digest) - -Model call - lease 失效后可使用同一冻结 request 重复;不同 attempt 可能返回不同结果, - 只有先被 Runtime 接受的结果推进 Run - -Tool effect - start command 提交后才发生,但结果可能在提交前丢失 -``` - -Run core 无法判断 Unknown effect 是否已经发生,也不假设支付系统或其他外部系统提供查询接口。因此 Unknown 保守地终止当前 Run;如果产品需要继续,只能创建新的 Run。非幂等工具不能由 Runtime 获得 exactly-once 保证。 - -## 9. Events 和 context - -### 9.1 Canonical Event Plane - -状态变化的提交路径固定为: - -```text -Loop / response ingress - -> AgentCommand - -> Runtime.Commit (EvaluateCommit: 幂等/类别校验 + Machine.Decide + Machine.EvolveVersion) - -> MachineState + TransitionRecord in one atomic boundary - -> EventSink / replay / projection / OTel -``` - -`AgentCommand` 表示“希望发生的状态变化”;`AgentEvent` 表示“authority 已接受的事实”,`TransitionRecord` 是持久化的 authority aggregate。一个接受的 command 构成一次 transition,产出一个或多个事实,并以完整 `TransitionRecord` 原子保存。AgentEvent 必须具备: - -```text -RunID + (Revision, Index) 全序身份;Revision 是 transition 计数,Index 是组内序 -CommandID 产生这次 transition 的 command -Digest 该事实内容的 canonical digest -SchemaVersion + Type wire 兼容和 sealed fact discriminator -Fact 已接受的事实内容 -``` - -MachineState 是 Run execution authority;`RunHeader + TransitionRecord log` 是 canonical record(§5.1)。Create 原子保存 header 与 Revision-0 state;Commit 原子保存新 state 与完整 TransitionRecord。Durable adapter 按 RunID/Revision 保留 records,并可在 Commit 事务附带写入 delivery outbox;MemoryRuntime 在进程内保留相同 records。公共读取统一通过 `Record(ctx, runID)` 返回一致 verified record。 - -Replay(`FoldRun`)按 RunID/Revision 取出 TransitionRecord,从经 `ValidateRunHeader` 验证的 `RunHeader.InitialState`(Revision=0)开始依次展开其中的 AgentEvent,并调用对应 `SchemaVersion` 的 `Machine.EvolveVersion` 折叠。折叠只依赖 Evolve,不重新运行 Decide——决策结果已经记录在事实里;折叠不产生任何外部 effect。它服务导入/迁移(目标侧以 fold 结果为初始权威状态,不信任上传的 snapshot)、conformance 等价测试与运维核对;日常恢复不经过它。fold 过程中 RunID 不匹配、SchemaVersion/Type 不支持、同一 transition 的 CommandID/CommandDigest 不一致、Revision/Index 缺洞、fact digest 或 transition digest 不匹配都拒绝折叠——对导入而言即拒绝该上传;对核对而言即报告损坏,后续处理是运维决定。 - -Replay 的起点是 `RunHeader` 中已建立并持久化的最小 `MachineState`;初始用户输入通过 `AcceptInput` transition 重放。Turn linkage 保存在 Session Events,queue admission lineage 由 Application 记录;二者通过稳定 causation/provenance 与 RunHeader 关联。 - -canonical event 只记录影响语义状态、恢复和审计的已接受事实:Step 的建立/启动/恢复/关闭、模型结果的接受与拒绝、工具结果、响应、active Run 的输入接受和 Run terminal(§3.6 的 Fact 词表)。模型文本 delta、工具 stdout、下载百分比和其他瞬时 progress 不进入 AgentEvent;它们仍可在提交前通过 EventSink 发送 provisional observation。 - -### 9.2 EventSink - -EventSink 是实时观察出口;MachineState 与 canonical records 保持 authority。Loop 在收到 `CommitResult.Events` 后可以发送对应的 committed observation,也可以发送不改变权威状态的 provisional observation: - -```text -ModelTextDelta、ToolProgress - 可以在完成提交前发送,都是 provisional - -ToolStarted - 只能在 start command Accepted 后发送 - -ToolCompleted、Run terminal - 先由 Runtime Commit;Loop 随后发送观察事件 - durable adapter 可在同一事务保存指向该 transition 的 delivery outbox - Turn Coordinator 仍按 RunRecord coverage 经 SemanticAppender materialize -``` - -EventSink 丢失、重复或来自旧 Attempt 都不改变 MachineState 或 AgentEvent。客户端出现 gap 时从 durable AgentEvent、snapshot 或最终 `RunResult` 重建。Loop 默认忽略观察通道错误,不因此重试模型/工具。 - -并行工具的观察事件通过 `CallID` 关联到具体 ToolCall;模型事件的 `CallID` 为空。 -`Event.Sequence` 只在一次观察流内单调递增,与 `AgentEvent.Revision` 分属不同 identity,并排除在 canonical digest/idempotency 之外。`Durability` 仅说明这条观察是否对应已提交事实;AgentEvent identity/digest 继续提供 canonical identity。 - -### 9.3 context transform - -Request Planner 属于 Application。它可以在事务外读取 context,但 durable runtime adapter -必须以版本检查和 create-if-absent 冻结生成的 request。已冻结 ModelStep 不受后来输入影响。 - -这里的版本检查由 durable runtime adapter 实现:`run.Runtime` 只看到带有 `BaseRevision` -的 `PrepareModelRequest`,不理解 Memoh 的 context revision,也不读取 queue 或 history。 - -### 9.4 provider transport - -一次 `sdk.Generate` 或 `sdk.Stream` 对应一次逻辑 provider request。transport retry 在 sdk/provider client 内部发生;run 不记录它,也不为它创建新的 Step。 - -## 10. Application queue integration、session 和恢复 - -### 10.1 queue 归属 - -steer/follow-up 的 queue 数据结构、accepted order、重排、claim、apply 和取消属于 Application policy;Session 只保存 owner module 提交的长期语义 Events。两种 queue 可以共享稳定 item reference、accepted sequence、order version、取消状态和 claim provenance;消费策略不同。被选中的 item 在交给 Run core 前转换为只有 `InputID` 和 payload 的 `AgentInput`,Application 保留 item reference 与 claim provenance 的映射: - -```text -steer 优先进入当前 active Run 的 eligible boundary -follow-up 为新的 Chatlog Turn 分配新的 RunID,并通过 turn.Start 建立 linkage -``` - -active Run 的 steer 通过 `NextStep(input)` 生成 `AcceptInput` command;terminal Run 的 follow-up 由 Application admission 创建新 Run,再对新 Run 提交首个 `AcceptInput`。Run core 不接收 queue item、priority、order 或 claim。 - -重排必须带 order version;过期版本、未知 item、重复 item 和越过已 claim item 的操作都拒绝。 - -### 10.2 queue-safe boundary - -Application 只在以下 boundary 仲裁 queue: - -```text -ModelStep 完成且没有 tool calls -ToolStep 自动关闭 -``` - -ModelStep 执行中、ToolStep 有 Pending/Executing/Waiting Call 时,不消费新的 queue 输入。queue action、对应 `InputAccepted` transition 以及 claim provenance 在 Application transaction 或可幂等 outbox 中一起提交,已 claim item 不能回退或越过。 - -没有 tool calls 的 ModelStep 会使当前 Run 到达 `RunCompleted`。Application 可以在这个 queue-safe boundary 消费 steer 并创建 follow-up Run;这不改变已经完成的 Run,也不把 queue policy 放进 Machine。 - -### 10.3 Turn settlement 与 follow-up admission - -terminal Run 与 settled Turn 是不同协议状态。`agent/turn` 先以 verified `RunRecord` 完成 `MaterializeAll`,再将 `twilight.turn/run_settled` 与 `TurnCompleted`/`TurnFailed` 写入同一 semantic Session commit。Session busy、渠道呈现和后续 queue admission 由 Application projection/policy 决定。 - -follow-up 在新 Run 的第一个 ModelStep 前完成 durable input 与 Turn intent admission。Application 分配新的 TurnID、RunID、causation 和 execution binding,再调用 `turn.Start`;Coordinator 先提交 `run_requested` linkage,随后 `Runtime.Create(NewRun)` 并按序提交首批 `AcceptInput`。Application scanner 从未 settled linkage 恢复。 - -### 10.4 多 response 恢复 - -```text -ToolStep T1 - A Completed - B Waiting(response=101) - C Waiting(response=102) - D Pending -``` - -response 101 只完成 B;D 仍可执行,不必等待 C。response 102 再完成 C;D 完成后 Machine 自动关闭 T1,并允许下一 ModelStep。每个 response 有自己的 row、CommandID 和 wake,不再受旧协议“一个 deferred 只能保存一个 approval”的限制。 - -## 11. Domain 拆分与实施顺序 - -当前 root `agent` 包是 Run execution domain 的实现雏形;它不应继续吸收 Session、Queue、Artifact、Context 或产品 orchestration。拆包以 target package 为准,不为尚未合并的 API 保留 agent-level compatibility façade。 - -### 阶段 A:抽取 `agent/es` - -1. 从现有 Run codec/transition/rebuild 中抽出不认识 run 语义的机制:canonical record encoding、payload/record digest hook、revision/index 校验、complete-record validation 和 generic fold runner。 -2. `es` 不 import `run`、`session`、`artifact`、`turn` 或 Application domain。 -3. Run 的 `TransitionRecord` 先适配/包装 `es.Record`;保持 Run-specific `CommandID`、`CommandDigest`、sealed Fact codec 在 `run`。 -4. 为 `es` 添加独立 conformance:partial record tail、revision/index gap、digest mismatch、schema/type mismatch、canonical JSON ambiguity。 - -### 阶段 B:移动现有 core 到 `agent/run` - -1. 将现有 root `agent` 中的 Machine、Runtime、MemoryRuntime、model data、codec、runtimetest 移到 `agent/run`;将 Loop、planner/model/tool/event contract 移到 `agent/run/loop`。 -2. 新 admission 使用 `BuildNewRun`/`Runtime.Create`,初始输入通过 `AcceptInput` transition。当前导出的 `InitializeRun` 与 `BuildRunHeader` 只服务迁移和低层测试,新 admission 不调用它们;删除或正式弃用另行处理。`BuildRunHeaderFromNewRun` 是 Runtime adapter 按 version 建立同一 Revision-0 header 的共享构造入口。 -3. 实现 `NewRun`、`BuildNewRun`/`ValidateNewRun`、`BuildRunHeaderFromNewRun` 与 `Runtime.Create`:Create 按 version 建立正式 immutable Revision-0 `RunHeader`;`MachineState` 是日常 execution authority,rebuild/fold API 以 header 为 canonical 起点。 -4. 让 `MemoryRuntime` 成为 RunID-addressed multi-Run collection 的最轻 reference runtime:`NewMemoryRuntime()` 返回空 collection,collection map lock 仅用于 create/lookup,每个 Run 使用独立锁;它可以没有 lease/DB/heartbeat,但不能跳过 execution event semantics。 -5. 更新 import path、examples、conformance 和 golden streams;此时尚未合并,不保留 root `agent` compatibility wrapper。 - -### 阶段 C:durable application adapter - -Memoh 的既有第一批 durable adapter 只对应此前的单 Run `Load/Commit` contract:它验证了 `MachineState` 与 `TransitionRecord` 的持久化、旧接口的 transaction/CAS、command 幂等和执行租约/授权。该 adapter 尚未实现本规范的 collection `Create/Load/Commit/Record` contract,必须完成以下迁移后才能作为最新 contract 的实现;authority JSON 继续在 PostgreSQL `JSONB` 读写边界按 RFC 8785/JCS 重新 canonicalize 并校验 digest。 - -1. **迁移 Create**:实现 `NewRun` 验证、按 version 构造 immutable Revision-0 header、同 RunID header collision 判定,以及与 application causation 的原子或可幂等写入。 -2. **迁移 Load/Commit**:按 RunID 定址 collection;Load 显式接收 RunID,Commit 使用 envelope 的 RunID,缺失 Run 返回 `ErrRunNotFound`。 -3. **迁移 Record**:在同一 DB snapshot 读取 header、MachineState 与完整 TransitionRecords,验证 records 并 fold 验证 snapshot,返回 detached `RunRecord`。 -4. **修复 durable authority**:保证 immutable table policy、Record 单一 read snapshot,以及 recovery lease/fence 的原子绑定和消费。 -5. **迁移验收**:执行共享 `run/runtimetest.RunConformance` 与 §14.6 的 durable integration matrix。 - -### 阶段 D:实现 Session、Artifact、Extension、Chatlog 与 Turn - -协议 authority 已分别冻结在: - -```text -docs/design/agent-session.md -docs/design/agent-artifact.md -docs/design/agent-session-extension.md -docs/design/agent-session-chatlog.md -docs/design/agent-turn.md -``` - -实现顺序以 Memory reference vertical slice 为先:Session Store 与 conformance、Artifact Binding/Ledger、Extension Catalog/SemanticAppender、Chatlog projections、Turn Coordinator/FactMapper/MaterializeAll。Durable adapter 随后实现 PostgreSQL Session/Artifact tables、semantic append recovery 与 Runtime→Session delivery outbox。Queue 继续由 Application 管理。 - -### 阶段 E:simple in-process harness(example 级) - -`agent/harness`(或 application example package)组合 in-process 各件,定位为示例与测试工具;正式交付和 Runtime interface 仍由各 domain 提供: - -```text -run.NewMemoryRuntime() // empty multi-Run collection -+ session/artifact/extension/chatlog memory implementations -+ turn.Coordinator + BuildNewRun/Create/AcceptInput -+ loop.New(...).Run(ctx, runtime, runID, sink) -+ in-memory loop.EventSink -+ application loop.RequestPlanner(读取 Context projection) -``` - -它不复制 Run Machine,不把 chat history 塞进 `MemoryRuntime`。它用于 local example、test、prototype,以及证明 local/durable 是同一 Run execution semantics 的不同 runtime implementation。 - -## 12. Cross-domain orchestration contract - -Application 组合 `run`、`session`、`artifact`、Extension、Chatlog、Turn 与产品 queue/context policy: - -```text -resolved Session Events + Artifact Bindings - -> chatlog Context projection - -> RequestPlanner - -> run.PrepareModelRequest - -Turn Start semantic commit - -> TurnOpened + InputDelivered* + user MessageCommitted + twilight.turn/run_requested - -> Runtime.Create(persisted NewRun) + ordered AcceptInput commits - -> Run TransitionRecords - -> turn.MaterializeAll(verified RunRecord) - -> Chatlog Events through SemanticAppender - -> exact terminal RunEnded - -> twilight.turn/run_settled + TurnCompleted/TurnFailed in one semantic commit - -queue claim - -> Application admission policy - -> persisted input/Turn intent - -> turn.Start/Resume or safe-boundary AcceptInput -``` - -每个 ModelStep 应记录足以审计输入来源的 `ContextManifest`(位置可为 `ModelStepPrepared` 中的 immutable reference 或 companion artifact): - -```text -ContextManifest { - resolved_session_head - binding_ids - memory_projection_version - compact_projection_version -} -``` - -它只记录 provenance,不复制完整 prompt/history。下一 Run 的 context 必须由 Session ES、Artifacts、Memory/Compact projection 构造,不能依赖读取已完成 Run 的 execution log。 - -## 13. 并行工作边界 - -| 工作 | 目标包/层 | 依赖 | -| --- | --- | --- | -| sdk Request/ModelResult/stream | `sdk` | provider adapter | -| generic record/digest/fold mechanism | `agent/es` | jsonstable | -| Run Machine、Runtime、MemoryRuntime | `agent/run` | es + sdk | -| Loop 与 planner/model/tool/event ports | `agent/run/loop` | run + sdk | -| RunHeader、run codec、runtimetest | `agent/run` | es | -| Session Store/MemoryStore/replay/fork conformance | `agent/session` | es + jsonstable | -| Ref/Binding/RetentionClaim 与 memory conformance | `agent/artifact` | es + jsonstable | -| static Catalog、SemanticAppender、projection | `agent/session/extension` | session + artifact | -| Chatlog ontology、Surface/Context projections | `agent/session/chatlog` | session extension + artifact | -| Turn Coordinator、FactMapper、MaterializeAll | `agent/turn` | run + session extension + chatlog | -| Steer/follow-up/run queue policy | Application/Product | turn + session + run | -| durable runtime, DB schema, owner/fence/lease/outbox | Application/Memoh | run + session + artifact contracts | -| simple in-process harness | `agent/harness` or examples | run + session + artifact + extension + chatlog + turn | - -## 14. 测试矩阵 - -### 14.1 sdk - -```text -Generate/Stream 都返回一次完整 ModelResult -stream finish、EOF、中断和 malformed part -provider retry 不改变一次调用语义 -旧 wrapper 的单次调用兼容 -``` - -### 14.2 Machine/Loop - -```text -无当前 Step -> NeedModelRequest -NextStep(input) -> AcceptInput -> InputAccepted -> pending input appears in PlanningHint -terminal follow-up -> Application allocates stable RunID/causation, calls Runtime.Create(NewRun), then AcceptInput(input), without mutating old Run -PrepareModelRequest -> [ModelStepPrepared] -> ModelStep -ModelExecuting lease recovery -> same frozen ModelStep can start again -SubmitModelResult 有 tools -> [ModelStepCompleted, ToolStepOpened],保存完整 Call set -SubmitModelResult 无 tools -> [ModelStepCompleted, RunEnded{RunCompleted}],并返回该 ModelResult -结构性 malformed -> RejectModelResult -> [ModelStepRejected],Step 回到 Prepared,usage 已累计 -RejectModelResult{Disposition: FailRun} -> [ModelStepRejected, RunEnded{RunFailed, malformed_model_result}] -参数无法解析的单个 Call -> Pending,start 前以 invalid_arguments 已知失败关闭 -unknown ToolRef/invalid arguments -> Pending Call 的已知失败,不提交 start -approval approved -> [ToolCallApproved] -> Pending -> start -> tool execute -approval rejected -> [ToolCallFailed{Known, permission_denied}] -多个 Pending 并行;Waiting 不阻塞其他 Pending -Waiting 与 Executing 并存时,response 和 execution wake 都有效 -response 只推进对应 Waiting Call -Waiting result carries RunID/StepID/CallID/ResponseID for response routing -最后一个 Call terminal -> [..., ToolStepClosed];host step limit 在下一 planning 边界提交 StopRun(step_limit) -RunEnded 只能是事实序列的最后一个事实 -已知失败进入下一次模型上下文 -Unknown -> [ToolCallFailed{Unknown}, RunEnded{RunFailed, effect_unknown}],不创建下一 ModelStep -Pending + Executing 并存时不误关 ToolStep -RunStopped 与 worker cancellation 区分 -外层 ctx 取消:模型执行以 ModelStepRecovered 释放,工具 worker 运行到结束后 Loop 返回 ctx.Err() -MachineState.Usage 逐字段累计 ModelStepCompleted 与 ModelStepRejected;terminal 时复制到 RunResult.Usage -Decide 拒绝时不产出部分事实;接受时事实组与 MachineState 原子提交 -Evolve 不读产品配置/Loop policy、无 IO;对 Decide 产出的全部事实全定义 -TransitionRecord 按 RunID/Revision 可 replay,内部 AgentEvent 按 (Revision, Index) 保持事件流顺序,重复提交不产生第二组 -replay 只经 EvolveVersion 折叠,不重新运行 Decide;golden transition stream 折叠出冻结的状态字节 -EventSink provisional/committed 发射点 -并行 EventSink 事件包含 CallID,Waiting result 可路由到目标 Call -Streaming=true 但 invoker 不支持 streaming -> Generate fallback -``` +本次重构把 Agent Core 收敛为相互独立的 Run、Session、Artifact、Extension、Chatlog 和 Turn 协议,并保留 `sdk` 作为单次 provider transport boundary。 -### 14.3 Runtime conformance +## 2. 已接受的架构决策 -conformance 测试由 `agent/run` 以可运行测试包(`agent/run/runtimetest`)交付;Factory 返回空 Runtime collection,suite 通过 `BuildNewRun`/`Create` 建立每个 case 的 Run。MemoryRuntime 与任意 durable runtime adapter 直接运行同一套件,不各自转写矩阵: +### 2.1 authority ```text -Create(valid NewRun) -> immutable Revision-0 header + Created=true -Create(same RunID, same canonical header) -> Created=false;different header -> ErrCreateConflict -Load/Commit/Record(missing RunID) -> ErrRunNotFound -Record -> same consistency boundary 的 Header + Snapshot + complete TransitionRecords,并完成 fold verification -同一 Run 的并发 Create -> 恰一个 Created=true,其余 Created=false -Record 与 Commit 并发 -> 每次 Record 都是可独立 fold 的一致点 -多个 Run Create/Commit/Record -> RunID isolation,互不串状态或完整 log -一个 Run 的 ExecutionGrant 用于另一 Run -> ErrStaleRuntime,两个 Run 均可继续完成 -same CommandID + digest -> CommitAlreadyApplied + 原事件组 -same CommandID + different digest -> ErrCommandConflict -AlreadyApplied 不重新运行 Decide;事件组逐字节等于首次提交 -并发 Commit 的 Revision/CAS 行为 -同一 Run/Revision 的并发 Planner:相同请求 AlreadyApplied,不同请求 CommandConflict -Planner InputIDs 必须完整匹配 PendingInputs;Tools/ToolsDigest 与 frozen ModelRequest 一一对应 -并行 Call 的 start/response 在旧 Revision 上按目标 Call rebase -Pending Call 的 lookup/argument failure 在旧 Revision 上按目标 Call rebase -工具 ref/definition digest 变化 -> tool_definition_mismatch,且不调用工具 -ToolCallState 非法字段组合 -> Runtime.Load(ctx, runID)/Evolve 拒绝 -ToolCallState 的 BindingDigest 与 frozen ToolSpec 不匹配 -> tool_definition_mismatch -相同 ResponseID 不同 payload -> ErrCommandConflict -start Accepted 后才授予外部执行权 -AlreadyApplied 不重新授予执行权 -stale grant/Revision 被拒绝 -一次 transition 的事件共享 Revision,Index 连续,提交后 State.Revision == transition Revision -ToolCall progress 按 CallID 合并 -已关闭 ToolStep 不重复关闭或创建新 Step -Loop/host model-step limit 通过 StopRun(step_limit) 形成事实,不进入 MachineState -RunStopped/RunFailed 保留最近已接受的 ModelResult -Cancel 与 Unknown 的提交先后决定终态 -CancelRun 在过期 BaseRevision 上对非 terminal Run 重新评估 -RejectModelResult 必须带有效 Model grant;AlreadyApplied 重放不重复累计 usage -持久化状态与按 EvolveVersion 折叠的 TransitionRecord log 一致(replay-fold 等价,双写正确性的回归测试) -FoldRun/FoldTransitions 对 Revision/Index 缺洞、事实 digest 或 transition digest 不匹配拒绝折叠(导入/迁移的验证入口) -ModelStepPrepared/ToolStepOpened 自包含:携带的 digest 折叠后可重现 Step 身份 -golden transition stream:固定 v1 命令序列折叠出冻结的状态字节(回归测试;迁移功能排期时升级为版本冻结校验) +MachineState Run 的语义状态投影 +TransitionRecord canonical Run commit record +Runtime Run authority、RunID-addressed access 与 atomic commit boundary +Session Events 跨 Run 的长期语义事实 ``` -### 14.4 `agent/es` +Run snapshot 支持直接恢复执行;immutable header 与 transition log 支持 verified replay、materialization 与审计。Session 只接收经 Turn materialization 的长期语义 events。 -```text -record 内 event 的 stream/revision/index/type/payload digest 一致性 -record digest 覆盖完整 event group,partial tail 必须拒绝 -revision gap、index gap、重复/冲突 identity、schema/type mismatch 拒绝 -canonical JSON duplicate key/trailing data/invalid UTF-8/lone surrogate 拒绝 -generic FoldRecords 不运行 domain Decide 或任何 IO -``` - -### 14.5 Session、Artifact、Extension、Chatlog 与 Turn - -各 domain 的完整 conformance 由对应 authority 文档定义: +### 2.2 package layout ```text -agent-session.md Header/Commit/Event、CAS、resolved replay、fork、snapshot、import -agent-artifact.md Ref/Binding/BindingSet/RetentionClaim、provider boundary、archive -agent-session-extension.md Catalog、codec/upcast、Binding admission、SemanticAppender、projection -agent-session-chatlog.md Message/Input/Turn/Item ontology、Surface/Context projection -agent-turn.md Start/Resume/Stop、MaterializeAll、settlement、crash recovery +agent/es shared ES primitives +agent/jsonstable immutable canonical JSON +agent/run Run Machine、persisted protocol、Runtime、MemoryRuntime +agent/run/loop in-process model/tool interpreter 与 observation ports +agent/session Event-first Session kernel +agent/session/extension static modules、codec、semantic append、projection +agent/session/chatlog first-party Message ontology +agent/artifact Ref、Binding、RetentionClaim +agent/turn Turn→Run coordination 与 Run→Session materialization ``` -Application queue tests 覆盖 admission durability、claim provenance、steer safe-boundary 与 follow-up 新 Turn/new Run linkage;queue policy 不进入 Core conformance。 +文件用于提高同一 package 内的导航性;subpackage 只用于依赖限制和独立变化轴。Loop 因依赖 SDK execution、streaming、并发和工具 ports 而独立成 `agent/run/loop`。Machine、Runtime、protocol 与 MemoryRuntime 保持在根 `agent/run`,避免 sealed variants、codec、Decide/Evolve 和 adapter internals 之间形成 cycle 或镜像 DTO。 -### 14.6 Durable application integration +依赖方向为: ```text -queue FIFO、accepted-order reorder、typed ID isolation -assigned follow-up 只由正确的 durable admission claim -Create 的 RunHeader 与 Revision-0 MachineState 原子一致;每次 Commit 的 TransitionRecord 与新 MachineState 原子一致 -Record 在同一 DB snapshot 读取 header/state/log 并完成 fold verification -Run transition delivery outbox 丢失或重复时,Turn 从 RunRecord 按 revision 完成 coverage -所有 Session semantic writes 经 SemanticAppender;Binding claim 的 crash points 可恢复 -assistant/tool-result/settlement 依 stable CommitID/EventID exactly-once materialize -多 response rows 与逐次 wake/idempotency -lease expiry/recovery/unknown outcome -queue admission 与 Prepare 竞态由 Application 线性化:AcceptInput 先提交则旧 Revision Prepare stale;Prepare 先提交则 steer 等待下一 eligible boundary -并行 Call 中一个 Unknown 后撤销其他 grant,迟到结果不改变 Run 终态 -terminal Run 与 settled Turn 分离;settlement 只发生在完整 materialization 后 -archive/GC 前验证 materialization coverage、run_settled terminal reference 与 Artifact retention -EventSink gap 后由 verified RunRecord 与 Session projections 对账 -``` - -## 15. Memoh queue integration boundary - -Application 的 queue/admission policy 负责 claim、priority、steer/follow-up 和 wake。Application host 组合 Turn Coordinator、`loop.RequestPlanner`、`loop.Loop` 与 durable Runtime adapter;queue 仲裁只发生在 queue-safe boundary,并将输入 durable admission 与对应 `AcceptInput` 通过事务或可幂等恢复协调。每次 response 只推进目标 Call。branch/claim/recovery policy 保持在 Application,不进入 `agent/run`。 - -## 16. 实施前置条件 - -1. Durable Application adapter 迁移到 RunID-addressed `Create/Load/Commit/Record`:Create collision、missing-run errors 与 `Record` 的同一 DB snapshot fold verification 成为基础能力。 -2. 实现 Session、Artifact、Extension、Chatlog 与 Turn 的 Memory reference vertical slice 及其 conformance。 -3. Durable adapter 冻结内部 Attempt、owner、fence、lease 和 recovery grace 规则,并实现可恢复的 transition delivery outbox;这些不进入 `run` public API。 -4. Durable Session/Artifact adapter 实现 `SemanticAppender` intent/claim recovery、stable materialization identities 与 terminal settlement。 -5. 工具失败不由 Run core 调度 retry timer;已知失败交给下一次模型,未知结果终止当前 Run。非幂等外部 effect 只能承诺 at-least-once。 -6. Request Planner 从已提交的 Context projection 构造完整、可冻结的边界 `sdk.Request`,由 Loop freeze 为 `run.ModelRequest`。 - -## 17. 待确认决策 - -实现前仍需确认: - -1. queue capacity、expiry 和产品授权是否进入 Application queue policy。 -2. breaking release 版本和 durable protocol upgrade window。 -3. EventSink payload schema,以及是否需要在 durable outbox 中加入跨进程 execution epoch。 - -本规范已经固定:Cancel 与 Unknown 按提交先后决定终态;`run.ModelRequest` 冻结完整 generation options,streaming 是 `loop.StreamingModelInvoker` 的可选执行路径且保持 Run command/event 语义。Run Machine 采用 Decide/Evolve 拆分:Decide 承载决策并在提交时产出事实,Evolve 是机械、版本内稳定的折叠;MachineState 为 Run execution authority,`RunHeader + TransitionRecord log` 为 canonical record。Create 原子保存 header 与 Revision-0 state;Commit 原子保存新 state 与 transition;分歧属实现缺陷并由运维处理(§5.1)。结构性 malformed 模型结果通过 `RejectModelResult` disposition 在同一冻结 request 上重试或失败;fixed model/limits 留在 Application;usage 在 MachineState 内逐字段累计;steer 由 Application queue-safe admission gate 保证进入下一个 ModelStep;计划内停机使用排空,Unknown 语义覆盖崩溃和 lease 失效。 - -本规范采用 MachineState 为 Run execution authority、`RunHeader + TransitionRecord log` 为 canonical record(§5.1):稳定条件(ontology 冻结、versioned Evolve 版本内稳定、事实自包含)由 Decide/Evolve 拆分保障,TransitionDigest 保护单个 transition 内部的完整事件组。恢复执行走 `Load(ctx, runID)`;`FoldRun` 服务导入/迁移与诊断。重新收紧为日志权威的触发条件(执行历史 fork 成为产品功能,或状态存储不可信而日志存储可信的部署形态)记录于 §5.1。跨 Run 语义不从旧 Run log 读取,而由 Session ES、artifact、memory/context projection 构造。 - -## 附录 A:最小 public API 草案 - -```go -package run - -import ( - "context" - "encoding/json" - "errors" - - "github.com/memohai/twilight-ai/agent/es" - "github.com/memohai/twilight-ai/agent/jsonstable" - "github.com/memohai/twilight-ai/sdk" -) - -type RunID string -type StepID string -type CallID string -type CommandID string -type ResponseID string -type InputID string -type ToolRef string -type ModelRef string -type Digest string -// PlanningToken is opaque to agent; the application uses it to identify the -// context revision from which a RequestPlan was built. -type PlanningToken string - -type RunStatus uint8 - -const ( - RunActive RunStatus = iota - RunCompleted - RunStopped - RunFailed -) - -type RunReason string - -const ( - ReasonCancelled RunReason = "cancelled" - ReasonStepLimit RunReason = "step_limit" - ReasonProviderFailure RunReason = "provider_failure" - ReasonMalformedModel RunReason = "malformed_model_result" - ReasonEffectUnknown RunReason = "effect_unknown" -) - -type RunFailure struct { - Class string - Message string - CallID CallID -} - -// Run-owned JSON-stable persisted data. SDK request/result/tool values are -// converted at Loop/provider boundaries via Freeze* and SDK(); Runtime never -// stores sdk.MessagePart interfaces, map[string]any provider metadata, or -// caller-owned JSON bytes. CanonicalJSON is an opaque immutable value constructed -// only by parsing/canonicalizing JSON at the boundary. -type CanonicalJSON = jsonstable.Value -type ProviderMetadata map[string]CanonicalJSON -type CacheControl struct { Type string; TTL string } -type Message struct { Role MessageRole; Content []MessagePart; Usage *Usage } -type MessagePart struct { /* sealed text/reasoning/image/file/tool-call/tool-result fields */ } -type ResponseFormat struct { Type ResponseFormatType; JSONSchema CanonicalJSON } -type ToolChoice struct { Mode ToolChoiceMode; Tool string } -type ToolDefinition struct { Name string; Description string; Parameters CanonicalJSON; CacheControl *CacheControl } -type ModelRequest struct { /* Model/System/Messages/Tools/options/ProviderOptions */ } -type Usage struct { /* token counters and details; Add is field-wise */ } -type ModelToolCall struct { ToolCallID string; ToolName string; Input CanonicalJSON; ProviderMetadata ProviderMetadata } -type ReasoningPart struct { /* text/id/format/model/provider metadata */ } -type Source struct { /* source identity and provider metadata */ } -type GeneratedFile struct { Data string; MediaType string } -type ResponseMetadata struct { ID string; ModelID string; Timestamp string; Headers map[string]string } -type ModelResult struct { /* text/reasoning/finish/usage/sources/files/tool calls/response */ } - -func FreezeModelRequest(sdk.Request) (ModelRequest, error) -func (ModelRequest) SDK() (sdk.Request, error) -func FreezeModelResult(sdk.ModelResult) (ModelResult, error) -func (ModelResult) SDK() (sdk.ModelResult, error) -func FreezeToolCallInput(any) (CanonicalJSON, error) -func FreezeToolDefinition(sdk.ToolDefinition) (ToolDefinition, error) -func (ToolDefinition) SDK() sdk.ToolDefinition - -type RunResult struct { - Status RunStatus - Reason RunReason - Failure *RunFailure - Model *ModelResult - Usage Usage // MachineState.Usage 在 terminal 时的副本 -} - -type StepRef struct { - RunID RunID - ID StepID - Digest Digest // immutable step binding digest; progress is not included -} - -// Step is sealed by the run package. Runtime implementations return values -// created by the Machine rules; callers cannot add another Step variant. -type Step interface { - step() - Ref() StepRef -} - -type ModelStep struct { - RefValue StepRef - Request ModelRequest - RequestDigest Digest - Model ModelRef - Tools []ToolSpec - ToolsDigest Digest - Status ModelStepStatus - Rejects int // accepted ModelStepRejected count; progress, not part of RefValue.Digest -} - -func (ModelStep) step() {} -func (s ModelStep) Ref() StepRef { return s.RefValue } - -type ModelStepStatus uint8 - -const ( - ModelPrepared ModelStepStatus = iota - ModelExecuting -) - -type ToolStep struct { - RefValue StepRef - Source StepID - Calls []ToolCallState -} - -func (ToolStep) step() {} -func (s ToolStep) Ref() StepRef { return s.RefValue } - -type ToolCallStatus uint8 - -const ( - ToolPending ToolCallStatus = iota - ToolExecuting - ToolWaiting - ToolCompleted - ToolFailed -) - -type ToolCallState struct { - CallID CallID - ToolRef ToolRef - DefinitionDigest Digest - BindingDigest Digest - Arguments CanonicalJSON - Policy ResponsePolicy - Status ToolCallStatus - Result *ToolExecutionResult - Failure *ToolCallFailure - Waiting *ResponseRequest -} - -func ValidateToolCallState(ToolCallState) error - -type ToolCallFailure struct { - Failure ToolFailure - Outcome ToolFailureOutcome -} - -type ToolFailure struct { - Class string - Message string -} - -type ToolExecutionResult struct { Output CanonicalJSON } - -type ToolFailureOutcome uint8 - -const ( - ToolOutcomeKnown ToolFailureOutcome = iota - ToolOutcomeUnknown -) - -const ( - FailurePermissionDenied = "permission_denied" - FailureToolLookup = "tool_lookup_failed" - FailureInvalidArguments = "invalid_arguments" - FailureMalformedModel = "malformed_model_result" - FailureDefinitionMismatch = "tool_definition_mismatch" - FailureExecution = "execution_failed" - FailureEffectUnknown = "effect_unknown" -) - -type ResponsePolicy uint8 - -const ( - DirectExecution ResponsePolicy = iota - ApprovalRequired - ExternalResponse -) - -type ResponseRequest struct { - RunID RunID - StepID StepID - CallID CallID - ID ResponseID - Kind ResponseKind - Payload CanonicalJSON - RequestDigest Digest // digest of the request payload, not a user response -} - -type ResponseKind string - -const ( - ResponseApproval ResponseKind = "approval" - ResponseExternal ResponseKind = "external_response" -) - -type MachineState struct { - RunID RunID - Status RunStatus - Current Step - PendingInputs []AgentInput - ModelSteps int - Usage Usage // 已接受 ModelStepCompleted/ModelStepRejected 的逐字段累计 - LastModelResult *ModelResult - Result *RunResult -} - -// AgentCommand is the intent submitted through Runtime.Commit for an existing -// Run. Accepting one command constitutes one transition. -type AgentCommand interface { agentCommand() } - -// Fact is one committed outcome produced by Machine.Decide. Facts are wrapped -// as AgentEvents; Machine.Evolve folds them mechanically. -type Fact interface { fact() } - -type AgentInput struct { - ID InputID - Payload CanonicalJSON -} - -// NextStep creates the command consumed by an active Run at a safe boundary. -func NextStep(input AgentInput) AcceptInput - -// --- Commands (intent) --- - -type PrepareModelRequest struct { - StepID StepID - Model ModelRef - Request ModelRequest - RequestDigest Digest - InputIDs []InputID - PlanningToken PlanningToken - Tools []ToolSpec - ToolsDigest Digest -} -func (PrepareModelRequest) agentCommand() {} - -type StartModelExecution struct { StepID StepID } -func (StartModelExecution) agentCommand() {} - -// Releases or recovers model execution: no provider result was accepted, so -// the same frozen request may be prepared for another attempt. -type RecoverModelExecution struct { StepID StepID } -func (RecoverModelExecution) agentCommand() {} - -type SubmitModelResult struct { - StepID StepID - Result ModelResult - Calls []ToolCallBinding -} -func (SubmitModelResult) agentCommand() {} - -type SubmitModelFailure struct { StepID StepID; Failure StepFailure } -func (SubmitModelFailure) agentCommand() {} - -type ModelRejectDisposition uint8 -const ( - ModelRejectRetry ModelRejectDisposition = iota - ModelRejectFailRun -) - -// A structurally malformed model result. Disposition records the host/Loop -// policy decision: retry same frozen request or fail the Run. -type RejectModelResult struct { - StepID StepID - Usage Usage - Failure StepFailure - Disposition ModelRejectDisposition -} -func (RejectModelResult) agentCommand() {} - -type StartToolCall struct { StepID StepID; CallID CallID } -func (StartToolCall) agentCommand() {} - -type SubmitToolResult struct { - StepID StepID - CallID CallID - Result ToolExecutionResult -} -func (SubmitToolResult) agentCommand() {} - -type SubmitToolFailure struct { - StepID StepID - CallID CallID - Failure ToolFailure - Outcome ToolFailureOutcome -} -func (SubmitToolFailure) agentCommand() {} - -// ResponseDigest is the canonical digest of this approval decision payload. -type ApproveToolCall struct { StepID StepID; CallID CallID; ResponseID ResponseID; ResponseDigest Digest } -func (ApproveToolCall) agentCommand() {} - -// ResponseDigest is the canonical digest of this rejection payload. Decide -// records the outcome as ToolCallFailed{Known, permission_denied}. -type RejectToolCall struct { StepID StepID; CallID CallID; ResponseID ResponseID; ResponseDigest Digest; Reason string } -func (RejectToolCall) agentCommand() {} - -type SubmitToolResponse struct { - StepID StepID - CallID CallID - ResponseID ResponseID - ResponseDigest Digest // digest of the answer payload - Payload CanonicalJSON -} -func (SubmitToolResponse) agentCommand() {} - -type CancelRun struct { Reason RunReason } -func (CancelRun) agentCommand() {} - -type StopRun struct { Reason RunReason } // currently ReasonStepLimit -func (StopRun) agentCommand() {} - -type AcceptInput struct { Input AgentInput } -func (AcceptInput) agentCommand() {} - -// --- Facts (committed outcomes) --- - -type ModelStepPrepared struct { - StepID StepID - Model ModelRef - Request ModelRequest - RequestDigest Digest - InputIDs []InputID - Tools []ToolSpec - ToolsDigest Digest - BindingDigest Digest // Decide 算好携带;Evolve 折叠时不重算(事实自包含) -} -func (ModelStepPrepared) fact() {} - -type ModelStepStarted struct { StepID StepID } -func (ModelStepStarted) fact() {} - -type ModelStepRecovered struct { StepID StepID } -func (ModelStepRecovered) fact() {} - -type ModelStepRejected struct { - StepID StepID - Usage Usage - Failure StepFailure -} -func (ModelStepRejected) fact() {} - -type ModelStepCompleted struct { - StepID StepID - Result ModelResult -} -func (ModelStepCompleted) fact() {} - -type ToolStepOpened struct { - StepID StepID // the new ToolStep - Source StepID // the completed ModelStep - Calls []ToolCallBinding -} -func (ToolStepOpened) fact() {} - -type ToolCallStarted struct { StepID StepID; CallID CallID } -func (ToolCallStarted) fact() {} - -type ToolCallApproved struct { StepID StepID; CallID CallID; ResponseID ResponseID; ResponseDigest Digest } -func (ToolCallApproved) fact() {} - -type ToolCallCompleted struct { - StepID StepID - CallID CallID - Result ToolExecutionResult -} -func (ToolCallCompleted) fact() {} - -type ToolCallAnswered struct { - StepID StepID - CallID CallID - ResponseID ResponseID - ResponseDigest Digest - Payload CanonicalJSON -} -func (ToolCallAnswered) fact() {} - -type ToolCallFailed struct { - StepID StepID - CallID CallID - Failure ToolFailure - Outcome ToolFailureOutcome -} -func (ToolCallFailed) fact() {} - -type ToolStepClosed struct { StepID StepID } -func (ToolStepClosed) fact() {} - -type InputAccepted struct { Input AgentInput } -func (InputAccepted) fact() {} - -// Terminal fact. Always the last fact of its transition. -type RunEnded struct { - Status RunStatus // RunCompleted, RunStopped or RunFailed - Reason RunReason - Failure *RunFailure -} -func (RunEnded) fact() {} - -type StepFailure struct { Class string; Message string } - -type ToolCallBinding struct { - CallID CallID - ToolRef ToolRef - DefinitionDigest Digest - BindingDigest Digest // definition, policy and canonical arguments - Arguments CanonicalJSON - Policy ResponsePolicy // unresolved ToolRef uses DirectExecution - Response *ResponseRequest // Decide 在 ToolStepOpened 中派生并填充;调用方提交时留空 -} - -// ToolSpec is the agent-side sidecar for a provider-neutral frozen ToolDefinition. -// ResponsePolicy is intentionally kept out of sdk to preserve package layering. -type ToolSpec struct { - Ref ToolRef - Definition ToolDefinition - DefinitionDigest Digest - Policy ResponsePolicy -} - -func Next(MachineState) (Effect, error) -func Decide(MachineState, AgentCommand) ([]Fact, error) -func Evolve(MachineState, Fact) (MachineState, error) -func EvolveVersion(uint16, MachineState, Fact) (MachineState, error) - -type RunHeader struct { - SchemaVersion uint16 - RunID RunID - InitialStateVersion uint16 - InitialState MachineState - InitialStateDigest es.Digest - CausationID es.CausationID - HeaderDigest es.Digest -} - -// NewRun contains only immutable, versioned run-creation data. Create builds -// Revision 0 from it; initial inputs always use AcceptInput after Create. -type NewRun struct { - SchemaVersion uint16 - RunID RunID - CausationID es.CausationID -} - -type CreateResult struct { - Header RunHeader - Created bool // false when the same canonical header was already created -} - -// RunRecord is a detached, consistent verified read of one Run. -type RunRecord struct { - Header RunHeader - Snapshot RuntimeSnapshot - Transitions []TransitionRecord -} - -func BuildNewRun(RunID, es.CausationID) (NewRun, error) -func ValidateNewRun(NewRun) error -func BuildRunHeaderFromNewRun(NewRun) (RunHeader, error) -func ValidateRunHeader(*RunHeader) error - -type Effect interface { effect() } - -type NeedModelRequest struct { Hint PlanningHint } -func (NeedModelRequest) effect() {} - -type StartModelCall struct { StepID StepID } -func (StartModelCall) effect() {} - -type StartToolCalls struct { StepID StepID; CallIDs []CallID } -func (StartToolCalls) effect() {} - -type WaitForResponse struct { Requests []ResponseRequest } -func (WaitForResponse) effect() {} - -type WaitForExecutionRecovery struct{} -func (WaitForExecutionRecovery) effect() {} - -type PlanningHint struct { - RunID RunID - SourceStep StepID - Inputs []AgentInput -} - -type Runtime interface { - Create(context.Context, NewRun) (CreateResult, error) - Load(context.Context, RunID) (RuntimeSnapshot, error) - Commit(context.Context, CommitRequest) (CommitResult, error) // Command envelope carries RunID - Record(context.Context, RunID) (RunRecord, error) -} - -// The representation is implementation-defined. Callers only pass it back; -// it is not a Step identity or a user credential. -type ExecutionGrant string - -type RuntimeSnapshot struct { - State MachineState - Revision uint64 // 已接受的 transition 数;初始状态为 0 -} - -type CommandEnvelope struct { - SchemaVersion uint16 - Type string - RunID RunID - ID CommandID - Digest Digest - Command AgentCommand -} - -type AgentEvent struct { - SchemaVersion uint16 - Type string - RunID RunID - Revision uint64 - Index uint16 - CommandID CommandID - CommandDigest Digest - Digest Digest - Fact Fact -} - -type TransitionRecord struct { - SchemaVersion uint16 - RunID RunID - Revision uint64 - CommandID CommandID - CommandDigest Digest - Events []AgentEvent - TransitionDigest Digest -} - -type CommitRequest struct { - BaseRevision uint64 - Grant ExecutionGrant - Command CommandEnvelope -} - -type CommitStatus uint8 - -const ( - CommitAccepted CommitStatus = iota - CommitAlreadyApplied -) - -type CommitResult struct { - Status CommitStatus - Snapshot RuntimeSnapshot - Events []AgentEvent // 该 transition 的完整事件组 - Grant ExecutionGrant // 仅 Accepted 的 start command 会返回 -} - -type DecisionKind uint8 - -const ( - DecisionApply DecisionKind = iota - DecisionAlreadyApplied - DecisionConflict - DecisionStale - DecisionTerminal -) - -type CommitDecision struct { - Kind DecisionKind - NewState MachineState - Events []AgentEvent - Transition TransitionRecord -} - -// Shared, pure commit evaluation. Both runtimes call this single -// implementation inside their own critical section / transaction. -func EvaluateCommit( - cur MachineState, curRevision uint64, - prior *TransitionRecord, - req CommitRequest, - grantValid bool, - recoveryValid bool, -) (CommitDecision, error) - -func EncodeCommand(CommandEnvelope) ([]byte, error) // 不包含 Digest 字段 -func DigestCommand(schemaVersion uint16, typ string, command AgentCommand) (Digest, error) -func EncodeFact(schemaVersion uint16, typ string, fact Fact) ([]byte, error) -func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) -func BuildNewRun(RunID, es.CausationID) (NewRun, error) -func ValidateNewRun(NewRun) error -func BuildRunHeaderFromNewRun(NewRun) (RunHeader, error) -func ValidateRunHeader(*RunHeader) error -func BuildTransitionRecord([]AgentEvent) (TransitionRecord, error) -func ValidateTransitionRecord(*TransitionRecord) error -func DigestTransitionRecord(*TransitionRecord) (Digest, error) -func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineState, uint64, error) -func FoldRun(header *RunHeader, records []TransitionRecord) (MachineState, uint64, error) -func DigestRequest(ModelRequest) (Digest, error) -func DigestToolDefinition(ToolDefinition) (Digest, error) -func DigestToolSpec(ToolSpec) (Digest, error) -func DigestToolSpecs([]ToolSpec) (Digest, error) -func DigestModelStepBinding(model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) -func DeriveModelRequestCommandID(RunID, uint64) CommandID -func DeriveModelStepID(RunID, CommandID, Digest) StepID -func DeriveToolStepID(StepID, Digest) StepID -func DeriveResponseID(RunID, StepID, CallID, ResponseKind) ResponseID -func DeriveResponseCommandID(RunID, StepID, CallID, ResponseID) CommandID -func DeriveInputCommandID(RunID, InputID) CommandID - -var ErrCreateConflict = errors.New("agent: run create conflict") -var ErrRunNotFound = errors.New("agent: run not found") -var ErrCommandConflict = errors.New("agent: command identity conflict") -var ErrStaleRuntime = errors.New("agent: stale runtime version or grant") -var ErrRunTerminal = errors.New("agent: run is terminal") - +Application -> agent/turn + agent/run/loop + adapters +agent/run/loop -> agent/run + sdk +agent/run -> agent/es + agent/jsonstable + sdk +agent/turn -> agent/run + agent/session + agent/session/chatlog + extensions ``` -`agent/run/loop` 的 public execution API 为: +根 `agent/run` 不提供 Loop alias、wrapper 或 façade。 -```go -package loop +### 2.3 boundary decisions -import ( - "context" - "encoding/json" +- `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 从 Session facts 与 `Runtime.Record` 重建,不保存隐藏的长期状态。 +- Run→Session materialization 按完整 revision coverage 和 stable identity exactly-once 收敛。 +- Durable owner/fence/lease/recovery/outbox 是 adapter control plane,不进入 `run.Runtime` public contract。 - run "github.com/memohai/twilight/agent/run" - "github.com/memohai/twilight/sdk" -) +## 3. 已完成迁移 -type ExecutionPolicy struct { - MaxParallel int - ModelStepLimit int - MalformedModelResultLimit int -} +| 工作 | 状态 | +|---|---| +| 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 | 完成 | +| RunID-addressed `Runtime.Create/Load/Commit/Record` | 完成 | +| multi-Run `MemoryRuntime` 与 Runtime conformance | 完成 | +| `agent/run/loop` package extraction | 完成 | +| Session/Artifact/Extension/Turn protocols | 规范草案完成,实施待完成 | +| Chatlog protocol | 草案,wire/schema 尚未冻结 | +| PostgreSQL durable Run adapter(旧接口) | 历史 prototype,迁移未完成 | -type RequestPlanner interface { - Plan(context.Context, run.PlanningHint) (RequestPlan, error) -} +当前正式调用形态为 Application 组合 shared `run.Runtime` 与 `loop.Loop`。Loop 不保存 authority state;Runtime 不读取 queue 或 planner context。 -type RequestPlan struct { - Model run.ModelRef - Request sdk.Request - InputIDs []run.InputID - PlanningToken run.PlanningToken - Tools []run.ToolSpec -} +## 4. 后续实施工作 -type ModelCatalog interface { Resolve(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) } +### 4.1 Core reference implementations -type ToolCatalog interface { Resolve(run.ToolRef) (ExecutableTool, error) } +- 冻结 Session、Artifact 与 Chatlog v1 wire profiles、domain separators、wire-size validation 和 golden fixtures; +- 实现 Session、Artifact、Extension、Chatlog 的 Memory implementations 与 conformance; +- 实现 `turn.Coordinator`、first-party Turn module、`FactMapper`、`MaterializeAll` 和 settlement recovery; +- 跑通 Input → Turn → Context → Run/Loop → Session Events → replay 的最小 vertical slice。 -type ToolExecutionRequest struct { - RunID run.RunID - StepID run.StepID - CallID run.CallID - ToolRef run.ToolRef - DefinitionDigest run.Digest - Arguments run.CanonicalJSON - Progress ToolProgressSink -} +### 4.2 durable adapters -type ExecutableTool interface { - Ref() run.ToolRef - Definition() sdk.ToolDefinition - ResponsePolicy() run.ResponsePolicy - ValidateArguments(run.CanonicalJSON) error - Execute(context.Context, ToolExecutionRequest) ToolExecutionOutcome -} +- 将 PostgreSQL durable Run adapter 迁移到统一 `run.Runtime`; +- 让 `Record` 在单一数据库 read snapshot 内读取并验证 header/state/log; +- 收紧 Run authority tables 的 immutable RLS policy; +- 将 recovery record 绑定具体 lease/fence 并原子消费; +- 实现过期 execution recovery scanner; +- 增加 transition delivery outbox,作为 Turn materialization 的 delivery 优化; +- 设计 Session Store、projection snapshot、Artifact Binding/Claim 与 semantic append intent tables。 -type ToolExecutionOutcome interface { toolExecutionOutcome() } -type ToolExecutionSucceeded struct { Result run.ToolExecutionResult } -func (ToolExecutionSucceeded) toolExecutionOutcome() {} -type ToolExecutionFailed struct { Failure run.ToolFailure } -func (ToolExecutionFailed) toolExecutionOutcome() {} -type ToolExecutionUnknown struct { Failure run.ToolFailure } -func (ToolExecutionUnknown) toolExecutionOutcome() {} +### 4.3 Application migration -type ToolProgressSink interface { Publish(context.Context, ToolProgress) } -type ToolProgress struct { Payload json.RawMessage } +- 组合 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。 -type EventSink interface { Emit(context.Context, Event) error } -type Event struct { - RunID run.RunID - StepID run.StepID - CallID run.CallID - Sequence uint64 - Kind EventKind - Durability EventDurability - Payload json.RawMessage - Canonical *run.AgentEvent -} - -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 LoopDisposition uint8 -const ( LoopWaiting LoopDisposition = iota; LoopFinished ) - -type WaitReason string -const ( - WaitingForResponse WaitReason = "waiting_for_response" - ExecutionRecovery WaitReason = "execution_recovery" -) - -type LoopResult struct { - Disposition LoopDisposition - Reason WaitReason - Waiting []run.ResponseRequest - Result *run.RunResult -} - -type Loop struct { - Models ModelCatalog - Tools ToolCatalog - Planner RequestPlanner - Execution ExecutionPolicy - Streaming bool -} - -func New(ModelCatalog, ToolCatalog, RequestPlanner, ExecutionPolicy, bool) (*Loop, error) -func (l *Loop) Run(context.Context, run.Runtime, run.RunID, EventSink) (LoopResult, error) -``` +## 5. 完成标准 -实现必须保证所有返回的 Step、Call、Request、Result 和等待 payload 具有只读快照语义;调用方不能通过修改 slice、map 或 JSON bytes view 改变 Runtime 状态。`AgentCommand`、`Fact`、`Effect` 使用 `agent/run` 的 sealed interface;ToolExecutionOutcome 使用 `agent/run/loop` 的 sealed interface,外部实现不能添加未定义变体。构造 CommandEnvelope 与派生 CommandID/ResponseID 只能通过 agent 提供的 typed 构造函数;手工拼装信封字段属于实现错误。 +重构在以下条件全部成立时结束: -## 附录 B:核心不变量 +- Memory 与 PostgreSQL adapters 通过相同 Runtime conformance; +- Turn materialization 对 crash、重复 delivery 和 unknown response 可恢复; +- Session/Artifact/Extension/Chatlog reference implementations 通过各自 conformance; +- production request context 和 UI surface 由 Session projections 提供; +- legacy history 不再承担 canonical write authority; +- Run、Session 与 Artifact 的 durable integrity/recovery paths 有持续 CI 覆盖。 -1. sdk 的一次 `Generate` 或 `Stream` 对应一次 provider request;transport retry 不创建新的 Step。 -2. `agent/run/loop.Loop` 是唯一的 Run 多步执行算法;Run 的权威状态由 Runtime 持有,Loop 不保存第二份。 -3. Runtime 公开 `Create`、`Load`、`Commit` 和 `Record`;Planner、queue 和工具入口不进入 Runtime contract。 -4. Machine 是完整的 Run/Step/ToolCall 语义规则;决策只在 `Decide` 中、只在提交时运行一次,`Evolve` 是机械折叠。Runtime 通过共享 `EvaluateCommit` 调用它们,不复刻规则。 -5. Step 是 durable resume boundary,只有 ModelStep 和 ToolStep;ToolCall 是 ToolStep 内的 progress。 -6. ModelStep 完成有 tool calls 时产出 `ToolStepOpened`;全部 Call 到达可关闭终态时,同一 transition 产出 `ToolStepClosed`。终态一律以 `RunEnded` 显式产出,且它是其 transition 的最后一个事实。 -7. Pending Call 必须在 start command Accepted 后才可执行;多个独立 Pending Call 可按 ExecutionPolicy 并行。 -8. Waiting response 只推进对应 Call;approval approved 先变 Pending,随后由 Loop 执行工具。日志记录结果事实(`ToolCallFailed{permission_denied}`、`RunEnded{cancelled}`),不记录请求本身。 -9. 幂等按 command 判定:相同 CommandID/digest 重放返回 CommitAlreadyApplied 与原事件组(不重新运行 Decide),不重复写入 projection、history、queue action 或 outbox;相同 CommandID 不同 digest 冲突。 -10. 一次接受的 transition 使 Revision 恰好加一;其全部事实共享该 Revision,Index 组内连续,提交后 `Snapshot.Revision` 等于该 Revision。 -11. MachineState 是 Run execution authority,durable continuation 经 `Load(ctx, runID)` 直接恢复;`RunHeader + TransitionRecord log` 是 canonical record。Create 原子保存 header 与 Revision-0 state,Commit 原子保存新 state 与 transition。对任意 Revision,状态必须等于 `RunHeader.InitialState` 经 `flatten(TransitionRecord[].Events)` 折叠的结果;replay-fold 等价测试守护该性质,分歧由运维流程处理。 -12. Evolve 的折叠语义与事件编码同属永久兼容契约,按 SchemaVersion 冻结;Replay 通过 `EvolveVersion` 选择历史语义;Decide 的决策规则可随版本演进,因为决策结果已记录为事实。 -13. 已知工具失败交给下一次模型请求;Unknown 终止 Run,不自动重试、不查询外部系统。 -14. worker cancellation 不等于 RunStopped;业务停止必须提交控制 command。宿主的业务停止先提交 `CancelRun`,再取消 Loop 的 ctx;ctx 取消本身只结束执行尝试,工具 worker 运行到自身结束。 -15. EventSink 只是实时观察;TransitionRecord、durable snapshot 和 outbox 才是 replay/recovery 依据,AgentEvent 是 transition 内部和观察出口的事实流视图。 -16. `run.MemoryRuntime` 用进程内同步;durable runtime adapter 用事务、CAS 和内部 Attempt/owner/fence/lease;两者共享 `EvaluateCommit` 与 Run Machine 规则,但不共享存储实现。 -17. 结构性 malformed 的模型结果以 `RejectModelResult` 累计 usage;Disposition 决定回到 Prepared 重试或同 transition RunFailed。单个 Call 的参数解析失败归类为已知 `invalid_arguments`,并进入下一次模型请求。 +后续协议修改直接更新对应正式规范;本文只更新迁移状态和历史决策,不再承载 wire、Machine、Runtime 或 Loop 算法。 diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index 782f4bb..891e6a8 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -2,7 +2,7 @@ 状态:设计规范。 -本文定义 `agent/turn` 对一个 Chatlog Turn 的协调协议;“必须”“应该”均为协议约束。 +本文定义 `agent/turn` 对一个 Chatlog Turn 的协调协议;“必须”“应该”均为协议约束。Run Machine、Runtime 与 Loop 的 authority 是 [agent-run.md](agent-run.md);本文只规定 Turn 如何消费该协议。 ## 1. 模型与范围 @@ -41,6 +41,13 @@ const ( SettlementStopped Settlement = "stopped" ) +type ResumeDisposition string +const ( + ResumeWaitingForResponse ResumeDisposition = "waiting_for_response" + ResumeWaitingForRecovery ResumeDisposition = "waiting_for_recovery" + ResumeFinished ResumeDisposition = "finished" +) + type RunHeadRef struct { RunID run.RunID; Revision uint64; TransitionDigest es.Digest } type ResultReference struct { RunID run.RunID; Revision uint64; EventIndex uint16 @@ -108,18 +115,9 @@ type ExecutionBindingRegistry interface { Resolve(ExecutionBindingRef) (RunDrive **TRN-API-2** binding registry 以同一个 shared `run.Runtime` 组合 driver。driver 接收 `DriveRequest{Ref,RunID}`,并经 shared Runtime 读写;其 Run access 以该 Runtime contract 完成,persisted binding 含义保持稳定。 -**TRN-API-3** 正式 Run contract 为: - -```go -Runtime.Create(context.Context, run.NewRun) (run.CreateResult, error) -Runtime.Load(context.Context, run.RunID) (run.RuntimeSnapshot, error) -Runtime.Commit(context.Context, run.CommitRequest) (run.CommitResult, error) -Runtime.Record(context.Context, run.RunID) (run.RunRecord, error) -``` +**TRN-API-3** Coordinator、driver、mapper 与 settlement 只通过 [agent-run.md](agent-run.md) 的 `run.Runtime` contract 协调 Run。input/transition 全部经 Commit 持久化;绕过 Runtime authority 的写入不构成 Turn progress。 -`CommitRequest.Command` 为含 RunID 的 envelope。Coordinator、driver、mapper、settlement 通过该 contract 协调 Run,input/transition 经 Commit 持久化;实现对绕过 Commit 的 input/transition 写入返回拒绝。 - -**TRN-API-4** 相同 canonical header 的 Create 返回 `Created=false`;同 RunID 的 header 差异返回 `run.ErrCreateConflict`;缺少 Run 的 Load、Commit、Record 返回 `run.ErrRunNotFound`。Coordinator 分别处理 conflict、corrupt、unavailable,且仅从 persisted linkage 取得计划。 +**TRN-API-4** Coordinator 按 Run contract 处理 idempotent Create、create conflict、missing Run、corrupt record 与 unavailable,并且只从 persisted linkage 取得创建计划;Turn 不重新定义 Runtime 的 collision、commit、grant 或 record 语义。 ```go type Service interface { @@ -134,15 +132,18 @@ type StartRequest struct { } type StartResponse struct { Ref TurnRef; RunID run.RunID; PlanDigest es.Digest; Operation StartOperationDigest - Created bool; Result *ResultReference; Waiting []run.ResponseRequest + Created bool; Disposition ResumeDisposition; Result *ResultReference; Waiting []run.ResponseRequest } type ResumeRequest struct { Ref TurnRef } -type ResumeResponse struct { Ref TurnRef; RunID run.RunID; Result *ResultReference; Waiting []run.ResponseRequest } +type ResumeResponse struct { + Ref TurnRef; RunID run.RunID; Disposition ResumeDisposition + Result *ResultReference; Waiting []run.ResponseRequest +} type StopRequest struct { Ref TurnRef; Reason string } type StopResponse struct { Ref TurnRef; RunID run.RunID; Result *ResultReference } ``` -**TRN-API-5** DTO 均为值语义。Start 对既有 linkage 的新 RunID 或计划改写返回 conflict;Resume/Stop 按 Ref 解析唯一 unsettled linkage。Waiting 表示当前 snapshot 的 response request;terminal fact 与后续计划授权由 RunRecord 和 persisted linkage 提供。 +**TRN-API-5** DTO 均为值语义。Start 对既有 linkage 的新 RunID 或计划改写返回 conflict;Resume/Stop 按 Ref 解析唯一 unsettled linkage。`Disposition` 取 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`;前两者表示 Run 仍为 active,后者必须带 `Result`。`Waiting` 只列当前 snapshot 的 response requests,执行恢复等待不伪造 response request;terminal fact 与后续计划授权由 RunRecord 和 persisted linkage 提供。 ## 4. Start admission @@ -200,9 +201,9 @@ run_requested{RunRequestedPayload} driver.Drive(ctx, DriveRequest{Ref: ref, RunID: linkage.RunID}) ``` -无论 driver 返回完成、等待、error,Coordinator 均再次 Record、调用 `MaterializeAll(record)` 补齐完整新前缀,并据 record snapshot/terminal fact 返回 Waiting 或 Result。 +无论 driver 返回完成、等待、error,Coordinator 均再次 Record、调用 `MaterializeAll(record)` 补齐完整新前缀,并据 record snapshot/terminal fact 返回 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`。前两者不返回 terminal Result;等待用户响应时携带 snapshot 中的 response requests,同时存在 execution recovery 时保留这些 requests 并由 `ExecutionRecovery` 标记;只有没有 response request 时才返回空 `Waiting`,表示由 recovery authority 负责后续唤醒。 -**TRN-RSM-6** driver 的 provisional stream、返回错误、网络响应丢失、context cancel 作为观察结果处理。driver 以 shared Runtime 的 Load/Commit 推进 MachineState;并发 Resume 依 Runtime command idempotency、revision、grant、record 一致性收敛。具体执行器可以使用 `agent/run/loop.Loop`,其 `LoopResult` 保持在 Application driver 内;Coordinator 始终以随后读取的 `RunRecord` 判断 waiting、terminal 与 settlement。 +**TRN-RSM-6** driver 的 provisional stream、返回错误、网络响应丢失、context cancel 作为观察结果处理。driver 以 shared Runtime 的 Load/Commit 推进 MachineState;并发 Resume 依 Runtime command idempotency、revision、grant、record 一致性收敛。具体执行器可以使用 `agent/run/loop.Loop`;其 `LoopResult` 映射为本节的 `ResumeDisposition`,并由 Coordinator 始终以随后读取的 `RunRecord` 判断 waiting、execution recovery、terminal 与 settlement。 **TRN-STP-1** Stop 先解析 linkage、Load。terminal Run 进入 record/materialize/settlement;active Run 以稳定 domain-separated CancelRun CommandID 构造 `CancelRun{Reason:ReasonCancelled}`,并经 shared Runtime Commit。 @@ -214,21 +215,21 @@ driver.Drive(ctx, DriveRequest{Ref: ref, RunID: linkage.RunID}) **TRN-SET-1** `RunRecord` 为 materialization、unknown resolution、recovery、settlement 的唯一一致 verified read。它含 detached 且相互一致的 Header、Snapshot、完整 TransitionRecord sequence;消费者验证 header、records、fold、snapshot,并拒绝拼接独立 reads。 -**TRN-SET-2** terminal 判断读取匹配 terminal status 的最后 `RunEnded` AgentEvent。Coordinator 仅从该 exact event 构建 ResultReference,RunResult、snapshot revision、driver return 与其他 event 不参与该构建。terminal fact 缺失、多个或未处于末尾时触发 corruption。 +**TRN-SET-2** terminal 判断读取最后一个 `RunEnded` AgentEvent,并验证其 `End` 是唯一合法 terminal variant;Coordinator 从该 variant 派生 `RunStatus` 后构建 ResultReference。RunResult、snapshot revision、driver return 与其他 event 不参与该构建。terminal fact 缺失、多个、variant 非法或未处于末尾时触发 corruption。 **TRN-SET-3** settlement mapping 固定如下: -| RunEnded status | run_settled settlement | 同一 Session commit 的 chatlog event | +| RunEnded variant(派生 status) | run_settled settlement | 同一 Session commit 的 chatlog event | |---|---|---| -| `RunCompleted` | `completed` | `TurnCompleted{TurnID}` | -| `RunFailed` | `failed` | `TurnFailed{TurnID, FailureClass}` | -| `RunStopped` | `stopped` | `TurnFailed{TurnID, FailureClass:"stopped"}` | +| `RunCompletedEnd` | `completed` | `TurnCompleted{TurnID}` | +| `RunFailedEnd` | `failed` | `TurnFailed{TurnID, FailureClass}` | +| `RunStoppedEnd` | `stopped` | `TurnFailed{TurnID, FailureClass:"stopped"}` | RunFailed 的 FailureClass 取 terminal failure class;没有 failure 时采用稳定非空 class。`RunStopped` 映射 `TurnFailed{..., FailureClass:"stopped"}`。 **TRN-SET-4** run_settled 与对应 TurnCompleted/TurnFailed 位于同一 semantic Session commit,CommitID/EventID 由 stable terminal ResultReference 派生。相同 reference retry 幂等;settlement/result reference 差异触发 conflict。 -**TRN-SET-5** unknown external effect 的 persisted MachineState/terminal record 已包含 `ToolCallFailed{Outcome:Unknown, FailureEffectUnknown}` 加 terminal `RunEnded{RunFailed,...}`。Coordinator materialize unknown tool result 并 failed settle。 +**TRN-SET-5** unknown external effect 的 persisted MachineState/terminal record 已包含 `ToolCallFailed{Outcome:Unknown, FailureEffectUnknown}` 加 terminal `RunEnded{End: RunFailedEnd{...}}`。Coordinator materialize unknown tool result 并 failed settle。 ## 7. materialization diff --git a/sdk/generate_text.go b/sdk/generate_text.go index 6005021..304793d 100644 --- a/sdk/generate_text.go +++ b/sdk/generate_text.go @@ -15,7 +15,8 @@ func (c *Client) GenerateText(ctx context.Context, options ...GenerateOption) (s // 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.Loop instead of this SDK 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 { diff --git a/sdk/model_result.go b/sdk/model_result.go index ad6c61a..6fcf02e 100644 --- a/sdk/model_result.go +++ b/sdk/model_result.go @@ -1,9 +1,8 @@ package sdk -// ModelResult is one complete model response (spec §2.1): 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 agent or -// the application, never here. +// 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 diff --git a/sdk/model_stream.go b/sdk/model_stream.go index d419fba..287d8cc 100644 --- a/sdk/model_stream.go +++ b/sdk/model_stream.go @@ -1,8 +1,8 @@ package sdk // ModelStream is the streaming counterpart of one model call. It yields -// realtime parts and assembles exactly one ModelResult; both execution paths -// of a ModelInvoker must produce the same final result (spec §4.1). +// 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 diff --git a/sdk/request.go b/sdk/request.go index 12900b1..5e1d57b 100644 --- a/sdk/request.go +++ b/sdk/request.go @@ -2,7 +2,7 @@ package sdk import "encoding/json" -// Request is the complete, frozen input of one model call (spec §2.1). +// Request is the complete, frozen input of one model call. // // It is pure data: no provider client, no interface values, no callbacks. // The model is a provider-scoped string ID; provider binding happens when a diff --git a/sdk/stream_text.go b/sdk/stream_text.go index e4bd7fa..cde83d5 100644 --- a/sdk/stream_text.go +++ b/sdk/stream_text.go @@ -9,8 +9,8 @@ import ( // 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.Loop instead of this SDK -// loop. +// 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. From e997dec87003a1ffd4bf085c82a42017c4e49494 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 1 Sep 2026 02:19:12 +0800 Subject: [PATCH 36/81] Stop Loop from surfacing tool approval waits Next returns Idle when a ToolStep has only Waiting calls. Loop stops without copying ResponseRequests onto LoopResult. Application reads WaitingCalls from the Runtime snapshot and commits approval. Also remove deprecated RunSeed, StopRun, Initialize, and ToolStepClosed from the run protocol. --- agent/run/alias_test.go | 6 +- agent/run/canonical_test.go | 10 -- agent/run/codec.go | 4 - agent/run/codec_test.go | 1 - agent/run/command.go | 27 +---- agent/run/commit.go | 2 +- agent/run/decide.go | 20 +--- agent/run/evolve.go | 30 ------ agent/run/fact.go | 10 -- agent/run/fold_test.go | 2 +- agent/run/loop/contract.go | 25 ++--- agent/run/loop/helpers_test.go | 19 ++-- agent/run/loop/loop.go | 20 ++-- agent/run/loop/loop_test.go | 115 +++++++++++++++------ agent/run/machine_test.go | 98 +++++++----------- agent/run/next.go | 45 ++++++--- agent/run/protocol.go | 15 --- agent/run/regression_test.go | 16 +-- agent/run/runtime_helpers_test.go | 4 +- agent/run/state.go | 49 --------- docs/design/agent-run.md | 18 ++-- docs/design/agent-runtime-refactor.md | 16 +-- docs/design/agent-session-chatlog.md | 25 ++--- docs/design/agent-session-extension.md | 133 +++++++++++++++++-------- docs/design/agent-session.md | 49 +++++++-- docs/design/agent-turn.md | 12 +-- 26 files changed, 362 insertions(+), 409 deletions(-) diff --git a/agent/run/alias_test.go b/agent/run/alias_test.go index b8454ec..aa58c87 100644 --- a/agent/run/alias_test.go +++ b/agent/run/alias_test.go @@ -45,7 +45,7 @@ func TestMemoryRuntimeClonesInitialState(t *testing.T) { } func TestCommitSnapshotsCommandPayloadBeforeFoldingState(t *testing.T) { - rt := newTestRuntime(t, RunConfig{Model: "m-1"}) + rt := newTestRuntime(t) raw := []byte(`{"v":"one"}`) payload, err := ParseCanonicalJSON(raw) if err != nil { @@ -95,7 +95,7 @@ func TestCommitSnapshotsCommandPayloadBeforeFoldingState(t *testing.T) { } func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { - rt := newTestRuntime(t, RunConfig{Model: "m-1"}) + rt := newTestRuntime(t) snap, _ := rt.Load(context.Background(), "run-1") req := ModelRequest{ Model: "m-1", @@ -144,7 +144,7 @@ func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { } func TestLoadSnapshotDoesNotAliasFrozenRequest(t *testing.T) { - rt := newTestRuntime(t, RunConfig{Model: "m-1"}) + rt := newTestRuntime(t) meta := map[string]any{"provider": map[string]any{"sig": "s1"}} req := sdk.Request{ Model: "m-1", diff --git a/agent/run/canonical_test.go b/agent/run/canonical_test.go index e704874..dea2298 100644 --- a/agent/run/canonical_test.go +++ b/agent/run/canonical_test.go @@ -124,16 +124,6 @@ func TestDigestCommandIdentity(t *testing.T) { } } -func TestDigestFactTypeChecked(t *testing.T) { - f := ToolStepClosed{StepID: "s2"} - if _, err := DigestFact(SchemaVersion1, "tool_step_closed", f); err != nil { - t.Fatal(err) - } - if _, err := DigestFact(SchemaVersion1, "run_ended", f); err == nil { - t.Fatal("expected type/variant mismatch error") - } -} - func TestDeriveStability(t *testing.T) { // Fixed inputs must produce fixed outputs across processes; freeze a few. id1 := DeriveModelRequestCommandID("run-1", 7) diff --git a/agent/run/codec.go b/agent/run/codec.go index 730b8fb..15f7466 100644 --- a/agent/run/codec.go +++ b/agent/run/codec.go @@ -337,8 +337,6 @@ func decodeCommandVariant(typ string, raw json.RawMessage) (AgentCommand, error) return decodeCommandAs[SubmitToolResponse](raw) case "cancel_run": return decodeCommandAs[CancelRun](raw) - case "stop_run": - return decodeCommandAs[StopRun](raw) case "accept_input": return decodeCommandAs[AcceptInput](raw) default: @@ -373,8 +371,6 @@ func decodeFactVariant(typ string, raw json.RawMessage) (Fact, error) { return decodeFactAs[ToolCallAnswered](raw) case "tool_call_failed": return decodeFactAs[ToolCallFailed](raw) - case "tool_step_closed": - return decodeFactAs[ToolStepClosed](raw) case "input_accepted": return decodeFactAs[InputAccepted](raw) case "run_ended": diff --git a/agent/run/codec_test.go b/agent/run/codec_test.go index 43671b0..1967bc8 100644 --- a/agent/run/codec_test.go +++ b/agent/run/codec_test.go @@ -60,7 +60,6 @@ func TestAgentEventJSONRoundTripRestoresVariants(t *testing.T) { ToolCallCompleted{StepID: "ts", CallID: "c", Result: ToolExecutionResult{Output: cj(`{"ok":true}`)}}, ToolCallAnswered{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp", Payload: cj(`{"answer":1}`)}, ToolCallFailed{StepID: "ts", CallID: "c", Failure: ToolFailure{Class: FailureExecution}, Outcome: ToolOutcomeKnown}, - ToolStepClosed{StepID: "ts"}, InputAccepted{Input: AgentInput{ID: "in", Payload: cj(`{"q":"hi"}`)}}, RunEnded{End: RunCompletedEnd{}}, } diff --git a/agent/run/command.go b/agent/run/command.go index 174bca2..7719a73 100644 --- a/agent/run/command.go +++ b/agent/run/command.go @@ -2,7 +2,7 @@ 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 fifteen variants below exist. +// interface is sealed: only the fourteen variants below exist. type AgentCommand interface{ agentCommand() } // AgentInput is a queue-safe input: a stable ID plus an immutable payload. @@ -15,21 +15,6 @@ type AgentInput struct { // NextStep creates the command consumed by an active Run at a safe boundary. func NextStep(input AgentInput) AcceptInput { return AcceptInput{Input: input} } -// RunSeed is the legacy admission seed for a new Run. It is not a command: it -// never goes through Runtime.Commit. New admission uses NewRun with -// Runtime.Create, then submits every initial input with AcceptInput. -// -// Deprecated: use NewRun, Runtime.Create, and AcceptInput. -type RunSeed struct { - Input AgentInput `json:"input"` -} - -// NextRun creates the legacy admission seed used by application admission. It -// does not allocate a RunID, claim a queue item, or mutate an existing Run. -// -// Deprecated: use InitializeRun and AcceptInput. -func NextRun(input AgentInput) RunSeed { return RunSeed{Input: input} } - // PrepareModelRequest freezes the next model request. Its CommandID is // derived from the loaded Revision, which is also its concurrency control. type PrepareModelRequest struct { @@ -187,14 +172,6 @@ type CancelRun struct { func (CancelRun) agentCommand() {} -// StopRun is retained for compatibility with hosts that explicitly configure -// a non-cancellation stop policy. New code normally uses CancelRun. -type StopRun struct { - Reason RunReason `json:"reason"` -} - -func (StopRun) agentCommand() {} - // AcceptInput appends one queue-safe input to PendingInputs. Idempotent per // (RunID, InputID) with identical payload. type AcceptInput struct { @@ -232,8 +209,6 @@ func commandType(c AgentCommand) string { return "submit_tool_response" case CancelRun: return "cancel_run" - case StopRun: - return "stop_run" case AcceptInput: return "accept_input" default: diff --git a/agent/run/commit.go b/agent/run/commit.go index 70490f4..06c824f 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -55,7 +55,7 @@ func categorize(c AgentCommand) commandCategory { return catIngress // known failure on Pending uses empty grant; Executing path checks grant below case ApproveToolCall, RejectToolCall, SubmitToolResponse, AcceptInput: return catIngress - case CancelRun, StopRun: + case CancelRun: return catRunControl case RecoverModelExecution: return catRecovery diff --git a/agent/run/decide.go b/agent/run/decide.go index 4391c58..e739b33 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -58,8 +58,6 @@ func Decide(s MachineState, c AgentCommand) ([]Fact, error) { return decideSubmitToolResponse(&s, &cmd) case CancelRun: return decideCancelRun(&s, cmd) - case StopRun: - return decideStopRun(&s, cmd) case AcceptInput: return decideAcceptInput(&s, cmd) default: @@ -532,11 +530,10 @@ func decideSubmitToolResponse(s *MachineState, cmd *SubmitToolResponse) ([]Fact, return facts, nil } -// --- rules 12-14: cancel, compatibility stop and input --- +// --- rules 12-13: cancel and input --- func decideCancelRun(s *MachineState, cmd CancelRun) ([]Fact, error) { - // CancelRun always records RunStopped(cancelled). Host/system stops use - // StopRun so cancellation cannot forge system reasons. + // CancelRun always records RunStopped(cancelled). if cmd.Reason != "" && cmd.Reason != ReasonCancelled { return nil, rejectionf("cancel: reason must be empty or %q", ReasonCancelled) } @@ -567,19 +564,6 @@ func unknownExecutingCalls(s *MachineState, stepID StepID, target CallID, failur return facts } -func decideStopRun(s *MachineState, cmd StopRun) ([]Fact, error) { - if cmd.Reason != ReasonStepLimit { - return nil, rejectionf("stop: compatibility reason must be %q", ReasonStepLimit) - } - // StopRun is the legacy host-policy command. It is accepted at a safe - // boundary after the current Step has settled; an executing effect keeps its - // own settlement/recovery path and cannot be silently discarded here. - if s.Current != nil { - return nil, rejectionf("stop: run has a current step") - } - return []Fact{RunEnded{End: RunStoppedEnd{Reason: cmd.Reason}}}, nil -} - func decideAcceptInput(s *MachineState, cmd AcceptInput) ([]Fact, error) { if s.Current != nil { return nil, rejectionf("accept input: run has a current step") diff --git a/agent/run/evolve.go b/agent/run/evolve.go index 28e18e9..b1b51ca 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -170,23 +170,6 @@ func evolveV1(s MachineState, f Fact) (MachineState, error) { c.Waiting = nil }) - case ToolStepClosed: - ts, ok := s.Current.(ToolStep) - if !ok || ts.RefValue.ID != fact.StepID { - // v1 logs written before implicit closure may contain this - // redundant fact after the final call update. It is a no-op when - // the step was already closed by the mechanical fold. - if s.Current == nil && s.LastClosedStep == fact.StepID { - return s, nil - } - return s, fmt.Errorf("agent: evolve: tool step %q is not current", fact.StepID) - } - closed := cloneStep(ts).(ToolStep) - s.LastToolStep = &closed - s.Current = nil - s.LastClosedStep = fact.StepID - return s, nil - case InputAccepted: for _, in := range s.PendingInputs { if in.ID == fact.Input.ID { @@ -373,19 +356,6 @@ func validateFactTransition(s MachineState, f Fact) error { default: return fmt.Errorf("agent: evolve: unknown failure outcome %d", fact.Outcome) } - case ToolStepClosed: - ts, ok := s.Current.(ToolStep) - if !ok || ts.RefValue.ID != fact.StepID || len(ts.Calls) == 0 { - if s.Status == RunActive && s.Current == nil && s.LastClosedStep == fact.StepID { - return nil - } - return fmt.Errorf("agent: evolve: tool step %q is not closable", fact.StepID) - } - for _, call := range ts.Calls { - if call.Status != ToolCompleted && call.Status != ToolFailed { - return fmt.Errorf("agent: evolve: tool step %q still has non-terminal calls", fact.StepID) - } - } case InputAccepted: if s.Status.Terminal() || s.Current != nil { return errors.New("agent: evolve: input accepted outside an empty active boundary") diff --git a/agent/run/fact.go b/agent/run/fact.go index 108f735..3cfeebc 100644 --- a/agent/run/fact.go +++ b/agent/run/fact.go @@ -125,14 +125,6 @@ type ToolCallFailed struct { func (ToolCallFailed) fact() {} -// ToolStepClosed is retained only for folding legacy v1 logs. New transitions -// close a ToolStep implicitly when its final ToolCall reaches a terminal state. -type ToolStepClosed struct { - StepID StepID `json:"stepId"` -} - -func (ToolStepClosed) fact() {} - // InputAccepted appends one input to PendingInputs. type InputAccepted struct { Input AgentInput `json:"input"` @@ -309,8 +301,6 @@ func factType(f Fact) string { return "tool_call_answered" case ToolCallFailed: return "tool_call_failed" - case ToolStepClosed: - return "tool_step_closed" case InputAccepted: return "input_accepted" case RunEnded: diff --git a/agent/run/fold_test.go b/agent/run/fold_test.go index ac6df81..958348e 100644 --- a/agent/run/fold_test.go +++ b/agent/run/fold_test.go @@ -259,7 +259,7 @@ func TestFoldRejectsTamperedFact(t *testing.T) { // ModelStepPrepared is self-contained: the binding digest folds verbatim and // reproduces the step identity without recomputation. func TestRegressionPreparedFactSelfContained(t *testing.T) { - s := newRun(t, testConfig()) + s := newRun(t) prep, cmdID := buildPrepare(t, s, testRequest(), nil) facts := mustDecide(t, s, prep) fact := facts[0].(ModelStepPrepared) diff --git a/agent/run/loop/contract.go b/agent/run/loop/contract.go index 5e2054d..e9aa5f0 100644 --- a/agent/run/loop/contract.go +++ b/agent/run/loop/contract.go @@ -147,9 +147,8 @@ type Event struct { // ExecutionPolicy is host-owned loop policy. It is not persisted in // MachineState or events. ToolExecution controls dispatch mode and -// MaxParallel bounds workers launched by this Loop. The model-step and -// malformed-result limit fields remain source-compatible fields; malformed -// result disposition is selected by OnMalformedModelResult. +// MaxParallel bounds workers launched by this Loop. Malformed model result +// disposition is selected by OnMalformedModelResult. type ExecutionPolicy struct { ToolExecution ToolExecutionMode // OnMalformedModelResult chooses the disposition recorded for a malformed @@ -158,11 +157,6 @@ type ExecutionPolicy struct { // MaxParallel bounds local tool workers. Zero means all eligible calls in // the current batch may run concurrently. MaxParallel int - // Deprecated compatibility fields retained for older callers. They do not - // change Machine transitions; hosts select any step/retry policy outside - // the core protocol. - ModelStepLimit int - MalformedModelResultLimit int } type LoopDisposition uint8 @@ -174,13 +168,11 @@ const ( type LoopResult struct { Disposition LoopDisposition - // Reason is retained for source compatibility. Waiting and - // ExecutionRecovery are the authoritative waiting signals. - Reason WaitReason - Waiting []run.ResponseRequest - // ExecutionRecovery is true when at least one ToolCall remains Executing. - // When Waiting is also non-empty, the host must wake on either response or - // execution recovery. + // Reason is retained for source compatibility. ExecutionRecovery is + // the authoritative signal that a live execution needs recovery. + Reason WaitReason + // ExecutionRecovery is true when at least one call remains Executing + // after this Loop has no further executable effect. ExecutionRecovery bool Result *run.RunResult } @@ -188,6 +180,5 @@ type LoopResult struct { type WaitReason string const ( - WaitingForResponse WaitReason = "waiting_for_response" - ExecutionRecovery WaitReason = "execution_recovery" + ExecutionRecovery WaitReason = "execution_recovery" ) diff --git a/agent/run/loop/helpers_test.go b/agent/run/loop/helpers_test.go index c8cbf0a..0d4568c 100644 --- a/agent/run/loop/helpers_test.go +++ b/agent/run/loop/helpers_test.go @@ -11,7 +11,7 @@ const testModel ModelRef = "m-1" func cj(raw string) CanonicalJSON { return MustParseCanonicalJSON(raw) } -func newTestRuntime(t *testing.T, _ RunConfig) *MemoryRuntime { +func newTestRuntime(t *testing.T) *MemoryRuntime { t.Helper() rt := NewMemoryRuntime() newRun, err := BuildNewRun("run-1", "") @@ -50,14 +50,17 @@ func recordEvents(t testing.TB, rt Runtime, runID RunID) []AgentEvent { return events } -type modelStepLimitRuntime struct{ Runtime } - -func (r modelStepLimitRuntime) Load(ctx context.Context, runID RunID) (RuntimeSnapshot, error) { - snapshot, err := r.Runtime.Load(ctx, runID) - if err == nil { - snapshot.State.ModelSteps = 1 +func snapshotWaiting(t *testing.T, rt Runtime, runID RunID) []ResponseRequest { + t.Helper() + snap, err := rt.Load(context.Background(), runID) + if err != nil { + t.Fatal(err) + } + reqs := WaitingCalls(snap.State) + if len(reqs) == 0 { + t.Fatal("expected Waiting calls on the snapshot") } - return snapshot, err + return reqs } func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDecision, reason string) Digest { diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index 74dfb86..734c1b3 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -70,8 +70,8 @@ func New(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy 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 || policy.ModelStepLimit < 0 || policy.MalformedModelResultLimit < 0 { - return nil, errors.New("agent: loop: negative compatibility policy") + if policy.MaxParallel < 0 { + return nil, errors.New("agent: loop: negative MaxParallel") } if policy.ToolExecution == "" { policy.ToolExecution = ToolExecutionParallel @@ -265,10 +265,10 @@ func (l *Loop) resumeCachedStart(ctx context.Context, runtime run.Runtime, event } } -// Run drives the Run until it finishes, must wait, 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. +// 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, runtime run.Runtime, runID run.RunID, events EventSink) (LoopResult, error) { if ctx == nil { return LoopResult{}, errors.New("agent: loop: nil context") @@ -341,12 +341,8 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev if err := l.runToolCalls(ctx, runtime, events, &snapshot, eff); err != nil { return LoopResult{}, err } - case run.WaitForResponse: - reason := WaitingForResponse - if eff.ExecutionRecovery { - reason = ExecutionRecovery - } - return LoopResult{Disposition: LoopWaiting, Reason: reason, Waiting: eff.Requests, ExecutionRecovery: eff.ExecutionRecovery}, nil + case run.Idle: + return LoopResult{Disposition: LoopWaiting}, nil case run.WaitForExecutionRecovery: return LoopResult{Disposition: LoopWaiting, Reason: ExecutionRecovery, ExecutionRecovery: true}, nil default: diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go index 058fcd2..7b6b6a8 100644 --- a/agent/run/loop/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -131,7 +131,7 @@ func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { func loopRuntime(t *testing.T) *MemoryRuntime { t.Helper() - return newTestRuntime(t, RunConfig{Model: "m-1"}) + return newTestRuntime(t) } func textResult(text string) sdk.ModelResult { @@ -234,18 +234,18 @@ func TestLoopApprovalWaitsAndResumes(t *testing.T) { loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) - // First run: reaches Waiting(Approval) and returns. + // First run: no executable effect remains; approval lives on the snapshot. res, err := loop.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } - if res.Disposition != LoopWaiting || res.Reason != WaitingForResponse || len(res.Waiting) != 1 { + if res.Disposition != LoopWaiting || res.ExecutionRecovery { t.Fatalf("res = %+v", res) } if executed.Load() { t.Fatal("tool executed before approval") } - wait := res.Waiting[0] + wait := snapshotWaiting(t, rt, "run-1")[0] if wait.Kind != ResponseApproval { t.Fatalf("kind = %v", wait.Kind) } @@ -277,6 +277,84 @@ func TestLoopApprovalWaitsAndResumes(t *testing.T) { } } +func TestLoopYieldsWaitingBeforeStartingPending(t *testing.T) { + var workRan, askRan atomic.Bool + askSpec := toolSpec(t, "ask", ApprovalRequired) + workSpec := toolSpec(t, "work", DirectExecution) + ask := &fakeTool{ref: "ask", def: askSpec.Definition.SDK(), policy: ApprovalRequired, + execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { + askRan.Store(true) + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: cj(`"asked"`)}} + }} + work := &fakeTool{ref: "work", def: workSpec.Definition.SDK(), policy: DirectExecution, + execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { + workRan.Store(true) + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: cj(`"worked"`)}} + }} + invoker := &fakeInvoker{results: []sdk.ModelResult{ + { + FinishReason: sdk.FinishReasonToolCalls, + Usage: sdk.Usage{TotalTokens: 2}, + ToolCalls: []sdk.ToolCall{ + {ToolCallID: "cA", ToolName: "ask", Input: `{"x":1}`}, + {ToolCallID: "cB", ToolName: "work", Input: `{"x":1}`}, + }, + }, + textResult("after"), + }} + rt := loopRuntime(t) + loop, err := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"ask": ask, "work": work}}, + staticPlanner{specs: []ToolSpec{askSpec, workSpec}}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + + res, err := loop.Run(context.Background(), rt, "run-1", nil) + if err != nil { + t.Fatal(err) + } + if res.Disposition != LoopWaiting || res.ExecutionRecovery { + t.Fatalf("first run = %+v", res) + } + if !workRan.Load() { + t.Fatal("DirectExecution tools must start in the same Run that returns LoopWaiting") + } + if askRan.Load() { + t.Fatal("approval tool executed before ApproveToolCall") + } + + wait := snapshotWaiting(t, rt, "run-1")[0] + if wait.CallID != "cA" { + t.Fatalf("waiting call = %s, want cA", wait.CallID) + } + cmdID := DeriveResponseCommandID(wait.RunID, wait.StepID, wait.CallID, wait.ID) + env, err := BuildEnvelope(wait.RunID, cmdID, ApproveToolCall{ + StepID: wait.StepID, CallID: wait.CallID, ResponseID: wait.ID, + ResponseDigest: responseDecisionDigest(t, ResponseApproval, ResponseDecisionApproved, ""), + }) + if err != nil { + t.Fatal(err) + } + snap, err := rt.Load(context.Background(), "run-1") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { + t.Fatal(err) + } + + res, err = loop.Run(context.Background(), rt, "run-1", nil) + if err != nil { + t.Fatal(err) + } + if res.Disposition != LoopFinished || res.Result.Model.Text != "after" { + t.Fatalf("after approval = %+v", res) + } + if !askRan.Load() { + t.Fatal("approved tool never executed") + } +} + func TestLoopUnknownOutcomeFailsRun(t *testing.T) { spec := toolSpec(t, "echo", DirectExecution) echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, @@ -571,35 +649,6 @@ func TestLoopCtxCancelReturnsWithoutFailingRun(t *testing.T) { } } -func TestLoopModelStepLimitCompatibilityFieldDoesNotAffectRun(t *testing.T) { - rt := NewMemoryRuntime() - newRun, err := BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(context.Background(), newRun); err != nil { - t.Fatal(err) - } - env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", "input-1"), AcceptInput{Input: AgentInput{ID: "input-1", Payload: cj(`{"q":"hi"}`)}}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: 0, Command: env}); err != nil { - t.Fatal(err) - } - loop, err := New(fakeCatalog{&fakeInvoker{results: []sdk.ModelResult{textResult("completed")}}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{ModelStepLimit: 1}, false) - if err != nil { - t.Fatal(err) - } - res, err := loop.Run(context.Background(), rt, "run-1", nil) - if err != nil { - t.Fatal(err) - } - if res.Disposition != LoopFinished || res.Result == nil || res.Result.Model.Text != "completed" { - t.Fatalf("loop result = %+v", res) - } -} - func TestLoopMalformedModelResultDispositionFailsRun(t *testing.T) { rt := loopRuntime(t) bad := sdk.ModelResult{ diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go index f32ddc8..c0d912f 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -12,13 +12,9 @@ import ( const testModel ModelRef = "m-1" -func testConfig() RunConfig { - return RunConfig{Model: testModel} -} - func cj(raw string) CanonicalJSON { return MustParseCanonicalJSON(raw) } -func newRun(t *testing.T, _ RunConfig) MachineState { +func newRun(t *testing.T) MachineState { t.Helper() s, err := InitializeRun("run-1") if err != nil { @@ -177,7 +173,7 @@ func advanceToExecuting(t *testing.T, s MachineState, req sdk.Request, specs []T // --- tests --- -func TestInitializeRunIsMinimalAndLegacyConfigIsNotState(t *testing.T) { +func TestInitializeRunIsMinimal(t *testing.T) { s, err := InitializeRun("r") if err != nil { t.Fatal(err) @@ -185,20 +181,10 @@ func TestInitializeRunIsMinimalAndLegacyConfigIsNotState(t *testing.T) { if s.RunID != "r" || s.Status != RunActive || len(s.PendingInputs) != 0 { t.Fatalf("initial state = %+v", s) } - legacy, err := Initialize("r", RunConfig{Model: "m"}, NextRun(AgentInput{ID: "i"})) - if err != nil { - t.Fatal(err) - } - if legacy.RunID != "r" || len(legacy.PendingInputs) != 1 { - t.Fatalf("legacy initial state = %+v", legacy) - } - if _, err := Initialize("r", RunConfig{Model: "m", ModelStepLimit: -1}, NextRun(AgentInput{ID: "i"})); err == nil { - t.Fatal("negative step limit accepted") - } } func TestNextOnFreshRunNeedsModelRequest(t *testing.T) { - s := newRun(t, testConfig()) + s := newRun(t) eff, err := Next(s) if err != nil { t.Fatal(err) @@ -213,7 +199,7 @@ func TestNextOnFreshRunNeedsModelRequest(t *testing.T) { } func TestPrepareConsumesInputsAndCounts(t *testing.T) { - s := newRun(t, testConfig()) + s := newRun(t) prep, _ := buildPrepare(t, s, testRequest(), nil) facts := mustDecide(t, s, prep) if len(facts) != 1 { @@ -232,7 +218,7 @@ func TestPrepareConsumesInputsAndCounts(t *testing.T) { } func TestPrepareRejectsIncompleteInputIDs(t *testing.T) { - s := newRun(t, testConfig()) + s := newRun(t) prep, _ := buildPrepare(t, s, testRequest(), nil) prep.InputIDs = nil if _, err := Decide(s, prep); err == nil { @@ -241,7 +227,7 @@ func TestPrepareRejectsIncompleteInputIDs(t *testing.T) { } func TestModelCompleteNoToolsEndsRun(t *testing.T) { - s := newRun(t, testConfig()) + s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(), nil) result, err := FreezeModelResult(sdk.ModelResult{Text: "done", FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 7}}) if err != nil { @@ -269,7 +255,7 @@ func TestModelCompleteNoToolsEndsRun(t *testing.T) { func TestModelCompleteWithToolsOpensToolStep(t *testing.T) { def := testToolDef("t") spec := makeSpec(t, def, DirectExecution) - s := newRun(t, testConfig()) + s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) b := makeBinding(t, "c1", spec, `{"x":1}`) @@ -300,7 +286,7 @@ func TestModelCompleteWithToolsOpensToolStep(t *testing.T) { func TestApprovalCallOpensWaitingWithDerivedResponse(t *testing.T) { def := testToolDef("t") spec := makeSpec(t, def, ApprovalRequired) - s := newRun(t, testConfig()) + s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) b := makeBinding(t, "c1", spec, `{}`) @@ -319,14 +305,17 @@ func TestApprovalCallOpensWaitingWithDerivedResponse(t *testing.T) { t.Fatalf("status = %v, want Waiting", ts.Calls[0].Status) } - // Next must surface WaitForResponse with the routable request. + // Next has no executable effect; Application reads the request from state. eff, err := Next(s) if err != nil { t.Fatal(err) } - wait, ok := eff.(WaitForResponse) - if !ok || len(wait.Requests) != 1 || wait.Requests[0].ID != want { - t.Fatalf("effect = %#v", eff) + if _, ok := eff.(Idle); !ok { + t.Fatalf("effect = %#v, want Idle", eff) + } + reqs := WaitingCalls(s) + if len(reqs) != 1 || reqs[0].ID != want { + t.Fatalf("WaitingCalls = %#v", reqs) } if _, err := Decide(s, ApproveToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: want, ResponseDigest: "sha256:bad"}); err == nil { @@ -344,7 +333,7 @@ func TestApprovalCallOpensWaitingWithDerivedResponse(t *testing.T) { func TestRejectRecordsPermissionDenied(t *testing.T) { def := testToolDef("t") spec := makeSpec(t, def, ApprovalRequired) - s := newRun(t, testConfig()) + s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) b := makeBinding(t, "c1", spec, `{}`) facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) @@ -371,7 +360,7 @@ func TestRejectRecordsPermissionDenied(t *testing.T) { func TestExternalResponseRequiresPayloadDigest(t *testing.T) { def := testToolDef("ask") spec := makeSpec(t, def, ExternalResponse) - s := newRun(t, testConfig()) + s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) b := makeBinding(t, "c1", spec, `{}`) facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("ask", `{}`, "c1"), Calls: []ToolCallBinding{b}}) @@ -392,7 +381,7 @@ func TestExternalResponseRequiresPayloadDigest(t *testing.T) { func TestUnknownFailureEndsRun(t *testing.T) { def := testToolDef("t") spec := makeSpec(t, def, DirectExecution) - s := newRun(t, testConfig()) + s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) b := makeBinding(t, "c1", spec, `{}`) facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) @@ -423,7 +412,7 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { defA, defB := testToolDef("a"), testToolDef("b") specA := makeSpec(t, defA, ApprovalRequired) specB := makeSpec(t, defB, DirectExecution) - s := newRun(t, testConfig()) + s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(defA, defB), []ToolSpec{specA, specB}) bA := makeBinding(t, "cA", specA, `{}`) @@ -460,6 +449,17 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { } 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 != "cA" { + 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 @@ -477,7 +477,7 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { } func TestRejectModelResultDispositionRetriesThenFails(t *testing.T) { - s := newRun(t, testConfig()) + s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(), nil) usage := Usage{TotalTokens: 3} @@ -517,7 +517,7 @@ func TestRejectModelResultDispositionRetriesThenFails(t *testing.T) { } func TestModelRecoveryKeepsFrozenRequestAndCounts(t *testing.T) { - s := newRun(t, testConfig()) + s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(), nil) s = fold(t, s, mustDecide(t, s, RecoverModelExecution{StepID: stepID})) ms := s.Current.(ModelStep) @@ -532,32 +532,8 @@ func TestModelRecoveryKeepsFrozenRequestAndCounts(t *testing.T) { } } -func TestStopRunProducesStepLimit(t *testing.T) { - s := newRun(t, testConfig()) - facts := mustDecide(t, s, StopRun{Reason: ReasonStepLimit}) - if len(facts) != 1 { - t.Fatalf("facts = %d, want [ended]", len(facts)) - } - ended := facts[0].(RunEnded) - stopped, ok := ended.End.(RunStoppedEnd) - if !ok || stopped.Reason != ReasonStepLimit { - t.Fatalf("ended = %+v", ended.End) - } - if _, err := Decide(s, StopRun{Reason: ReasonCancelled}); err == nil { - t.Fatal("StopRun accepted cancellation reason") - } -} - -func TestStopRunRequiresSafeBoundary(t *testing.T) { - s := newRun(t, testConfig()) - s, stepID := advanceToExecuting(t, s, testRequest(), nil) - if _, err := Decide(s, StopRun{Reason: ReasonStepLimit}); err == nil { - t.Fatalf("StopRun accepted while ModelStep %q was executing", stepID) - } -} - func TestCancelProducesRunStopped(t *testing.T) { - s := newRun(t, testConfig()) + s := newRun(t) facts := mustDecide(t, s, CancelRun{}) ended := facts[0].(RunEnded) stopped, ok := ended.End.(RunStoppedEnd) @@ -567,7 +543,7 @@ func TestCancelProducesRunStopped(t *testing.T) { } func TestAcceptInputIdempotentPerID(t *testing.T) { - s := newRun(t, testConfig()) + 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 { @@ -587,7 +563,7 @@ func TestAcceptInputIdempotentPerID(t *testing.T) { } func TestAcceptInputRejectsSeedDuplicateID(t *testing.T) { - s := newRun(t, testConfig()) + s := newRun(t) _, err := 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) @@ -665,7 +641,7 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { } func TestEvolveRejectsModelPrepareOverCurrentStep(t *testing.T) { - s := newRun(t, testConfig()) + s := newRun(t) s, _ = advanceToExecuting(t, s, testRequest(), nil) _, err := Evolve(s, ModelStepPrepared{ StepID: "other", @@ -685,7 +661,7 @@ func TestReplayEquivalence(t *testing.T) { // all facts, refold from initial, and compare canonical serializations. def := testToolDef("t") spec := makeSpec(t, def, DirectExecution) - initial := newRun(t, testConfig()) + initial := newRun(t) var log []Fact s := initial diff --git a/agent/run/next.go b/agent/run/next.go index e716cda..a7d1aa4 100644 --- a/agent/run/next.go +++ b/agent/run/next.go @@ -24,20 +24,37 @@ type StartToolCalls struct { func (StartToolCalls) effect() {} -type WaitForResponse struct { - Requests []ResponseRequest - // ExecutionRecovery is true when another call in the same ToolStep is - // still Executing. The caller must wake on either a response or recovery; - // waiting for a response does not hide the in-flight execution. - ExecutionRecovery bool -} +// Idle means the Run is still active and Next has no executable effect. +// The usual case is a ToolStep whose remaining live calls are Waiting. +// Application reads those calls from MachineState; Loop does not interpret them. +type Idle struct{} -func (WaitForResponse) effect() {} +func (Idle) effect() {} type WaitForExecutionRecovery struct{} func (WaitForExecutionRecovery) 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 +} + // PlanningHint is what the Loop hands the application RequestPlanner. type PlanningHint struct { RunID RunID @@ -73,16 +90,14 @@ func Next(s MachineState) (Effect, error) { return WaitForExecutionRecovery{}, nil case ToolStep: var pending []CallID - var waiting []ResponseRequest + waiting := false executing := false for _, c := range cur.Calls { switch c.Status { case ToolPending: pending = append(pending, c.CallID) case ToolWaiting: - if c.Waiting != nil { - waiting = append(waiting, *c.Waiting) - } + waiting = true case ToolExecuting: executing = true } @@ -90,12 +105,12 @@ func Next(s MachineState) (Effect, error) { if len(pending) > 0 { return StartToolCalls{StepID: cur.RefValue.ID, CallIDs: pending}, nil } - if len(waiting) > 0 { - return WaitForResponse{Requests: waiting, ExecutionRecovery: executing}, nil - } if executing { return WaitForExecutionRecovery{}, nil } + if waiting { + 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 step variant %T", s.Current) diff --git a/agent/run/protocol.go b/agent/run/protocol.go index 4cd698a..edb239b 100644 --- a/agent/run/protocol.go +++ b/agent/run/protocol.go @@ -92,21 +92,6 @@ func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) { return sha256Digest(body), nil } -// EncodeRunSeed renders the canonical bytes of an admission seed so admission -// records can reuse the protocol's identity rules. -func EncodeRunSeed(seed RunSeed) ([]byte, error) { - return encodeEnvelopeBody(currentSchemaVersion, "run_seed", seed) -} - -// DigestRunSeed computes the canonical digest of an admission seed. -func DigestRunSeed(schemaVersion uint16, seed RunSeed) (Digest, error) { - body, err := encodeEnvelopeBody(schemaVersion, "run_seed", seed) - if err != nil { - return "", err - } - return sha256Digest(body), nil -} - type toolResponseDecisionDigestBody struct { Kind ResponseKind `json:"kind"` Decision ResponseDecision `json:"decision"` diff --git a/agent/run/regression_test.go b/agent/run/regression_test.go index cc613af..1bab9a7 100644 --- a/agent/run/regression_test.go +++ b/agent/run/regression_test.go @@ -13,7 +13,7 @@ import ( // Finding 1: a result carrying tool calls with zero bindings must not // silently complete the run. func TestRegressionZeroBindingsWithToolCallsRejected(t *testing.T) { - s := newRun(t, testConfig()) + s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(), nil) result := modelResultWithCalls("c1") // has tool calls if _, err := Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: nil}); err == nil { @@ -28,7 +28,7 @@ func TestRegressionBindingMustMatchModelResult(t *testing.T) { danger := testToolDef("danger") specSafe := makeSpec(t, safe, DirectExecution) specDanger := makeSpec(t, danger, DirectExecution) - s := newRun(t, testConfig()) + s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(safe, danger), []ToolSpec{specSafe, specDanger}) // Model called "safe" with {"a":1}; binding claims "danger" with {"rm":"-rf"}. @@ -79,7 +79,7 @@ func TestRegressionJCSNumberSemantics(t *testing.T) { func TestRegressionToolStepIDReproducible(t *testing.T) { def := testToolDef("t") spec := makeSpec(t, def, ApprovalRequired) // Response-filled path - s := newRun(t, testConfig()) + s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) b := makeBinding(t, "c1", spec, `{}`) facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) @@ -107,7 +107,7 @@ func TestRegressionInvalidToolOutputCannotBeConstructed(t *testing.T) { func TestRegressionExternalResponseCanBeRejected(t *testing.T) { def := testToolDef("ask") spec := makeSpec(t, def, ExternalResponse) - s := newRun(t, testConfig()) + s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) b := makeBinding(t, "c1", spec, `{}`) facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("ask", `{}`, "c1"), Calls: []ToolCallBinding{b}}) @@ -194,7 +194,7 @@ func TestRegressionRuntimeReturnsAreIsolated(t *testing.T) { func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { def := testToolDef("t") spec := makeSpec(t, def, DirectExecution) - s := newRun(t, testConfig()) + s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) b := makeBinding(t, "c1", spec, `{}`) facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) @@ -216,9 +216,9 @@ func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { // Low-severity finding: CancelRun cannot forge a system reason. func TestRegressionCancelReasonFixed(t *testing.T) { - s := newRun(t, testConfig()) - if _, err := Decide(s, CancelRun{Reason: ReasonStepLimit}); err == nil { - t.Fatal("CancelRun forged step_limit into the log") + s := newRun(t) + if _, err := 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) diff --git a/agent/run/runtime_helpers_test.go b/agent/run/runtime_helpers_test.go index f37c673..2cc13f0 100644 --- a/agent/run/runtime_helpers_test.go +++ b/agent/run/runtime_helpers_test.go @@ -10,7 +10,7 @@ import ( // Shared helpers for package-local agent tests. Runtime conformance lives in // agent/runtimetest so durable Runtime implementations can reuse it. -func newTestRuntime(t *testing.T, _ RunConfig) *MemoryRuntime { +func newTestRuntime(t *testing.T) *MemoryRuntime { t.Helper() rt := NewMemoryRuntime() newRun, err := BuildNewRun("run-1", "") @@ -85,7 +85,7 @@ func mustCommit(t *testing.T, rt Runtime, id CommandID, base uint64, grant Execu // stepID and the model grant. func preparedRuntime(t *testing.T, tools []sdk.ToolDefinition, specs []ToolSpec) (*MemoryRuntime, StepID, ExecutionGrant) { t.Helper() - rt := newTestRuntime(t, RunConfig{Model: "m-1", ModelRejectLimit: 2}) + rt := newTestRuntime(t) snap, _ := rt.Load(context.Background(), "run-1") req := testRequest(tools...) prep, cmdID := buildPrepareFromSnap(t, snap, req, specs) diff --git a/agent/run/state.go b/agent/run/state.go index 44428a9..f99b1cc 100644 --- a/agent/run/state.go +++ b/agent/run/state.go @@ -20,7 +20,6 @@ type RunReason string const ( ReasonCancelled RunReason = "cancelled" - ReasonStepLimit RunReason = "step_limit" ReasonProviderFailure RunReason = "provider_failure" ReasonMalformedModel RunReason = "malformed_model_result" ReasonEffectUnknown RunReason = "effect_unknown" @@ -40,23 +39,6 @@ type RunResult struct { Usage Usage `json:"usage"` } -// RunConfig is retained only for the deprecated Initialize compatibility -// helper. It is not stored in MachineState, AgentEvent, or TransitionRecord. -// New code should use InitializeRun and keep fixed-model / limit policy in the -// host or Loop ExecutionPolicy. -// -// Deprecated: use InitializeRun and submit seed input with AcceptInput. -type RunConfig struct { - Model ModelRef `json:"model"` - // Deprecated compatibility policy. New code leaves limits at zero and - // applies any host policy outside the core state machine. - ModelStepLimit int `json:"modelStepLimit,omitempty"` - ModelRejectLimit int `json:"modelRejectLimit,omitempty"` -} - -const DefaultMalformedModelResultLimit = 2 -const DefaultModelRejectLimit = DefaultMalformedModelResultLimit - type StepFailure struct { Class string `json:"class"` Message string `json:"message,omitempty"` @@ -410,34 +392,3 @@ func InitializeRun(run RunID) (MachineState, error) { } return MachineState{RunID: run, Status: RunActive}, nil } - -// Initialize builds a legacy initial MachineState with the admission seed -// already present in PendingInputs. RunConfig is validated for legacy callers -// but is intentionally not stored in MachineState. -// -// Deprecated: use InitializeRun and submit seed input with AcceptInput so the -// seed enters the transition log. -func Initialize(run RunID, cfg RunConfig, seed RunSeed) (MachineState, error) { - if cfg.Model == "" { - return MachineState{}, errors.New("agent: initialize: empty RunConfig.Model") - } - if seed.Input.ID == "" { - return MachineState{}, errors.New("agent: initialize: seed input requires an InputID") - } - s, err := InitializeRun(run) - if err != nil { - return MachineState{}, err - } - if cfg.ModelStepLimit < 0 { - return MachineState{}, errors.New("agent: initialize: negative ModelStepLimit") - } - if cfg.ModelRejectLimit < 0 { - return MachineState{}, errors.New("agent: initialize: negative ModelRejectLimit") - } - input, err := snapshotJSONStable(seed.Input) - if err != nil { - return MachineState{}, err - } - s.PendingInputs = []AgentInput{input} - return s, nil -} diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 88f94ac..a6a25eb 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -258,11 +258,12 @@ Loop 在提交 start 前生成并保留 `Claim`、`CommandID` 与 command digest | Model Prepared | `StartModelCall` | | Model Executing | `WaitForExecutionRecovery` | | ToolStep 有 Pending calls | `StartToolCalls` | -| ToolStep 无 Pending、含 Waiting 且无 Executing | `WaitForResponse` | -| ToolStep 含 Waiting 且含 Executing | `WaitForResponse{ExecutionRecovery:true}` | -| ToolStep 含 Executing 且无 Waiting | `WaitForExecutionRecovery` | +| ToolStep 无 Pending、含 Executing | `WaitForExecutionRecovery` | +| ToolStep 无 Pending、无 Executing、含 Waiting | `Idle` | -**RUN-MCH-4** Effect 由调用方每次 Load 后重新派生。`PrepareModelRequest.InputIDs` 必须与当前 PendingInputs 等长、同顺序、逐项相同;prepare 接受后一次消费全部 pending input。ToolStep 的 Waiting call 允许同一 step 中仍可执行的 Pending call 继续运行。Waiting 与 Executing 同时存在时返回 `WaitForResponse{ExecutionRecovery:true}`,让宿主同时响应用户输入和执行恢复;仅存在 Executing 时返回 `WaitForExecutionRecovery`。恢复唤醒由 Runtime/application 的 recovery authority 提供。 +Waiting call 上的 `ResponseRequest` 由 `WaitingCalls(state)` 从 MachineState 读取,不进入 Effect。 + +**RUN-MCH-4** Effect 由调用方每次 Load 后重新派生。`PrepareModelRequest.InputIDs` 必须与当前 PendingInputs 等长、同顺序、逐项相同;prepare 接受后一次消费全部 pending input。ToolStep 的 Waiting call 禁止 Start,同一 step 中的 Pending call 仍可执行。无 Pending 且仍有 Executing 时返回 `WaitForExecutionRecovery`。无 Pending、无 Executing、仍有 Waiting 时返回 `Idle`:Run 仍为 active,解释器没有可执行 effect。Application 从 snapshot 读取 `WaitingCalls` 并提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse`。执行恢复由 Runtime/application 的 recovery authority 提供。 ## 5. Runtime 与 Commit @@ -370,8 +371,7 @@ type ExecutionPolicy struct { } type LoopResult struct { Disposition LoopDisposition // LoopWaiting | LoopFinished - Reason WaitReason // waiting_for_response | execution_recovery - Waiting []run.ResponseRequest + Reason WaitReason // execution_recovery;Idle 时为空 ExecutionRecovery bool Result *run.RunResult } @@ -381,7 +381,7 @@ func (*Loop) Run(context.Context, run.Runtime, run.RunID, EventSink) (LoopResult **RUN-LOP-1** `ExecutionPolicy` 是 Loop 的本地执行策略,包含 `ToolExecution`、`MaxParallel` 和可选的 malformed-result handler;未指定 `ToolExecution` 时使用 `parallel`,正数 `MaxParallel` 限制当前 ToolStep 的本地 worker 数量,零值允许当前批次的所有可执行 call 并行。nil handler 时结构错误的模型结果选择 `ModelRejectFailRun`;重试由 handler 明确返回 `ModelRejectRetry`。`streaming` 表示是否请求可用的流式模型端口;两种模式都产生同一完整 `sdk.ModelResult`。 -`LoopResult` 的语义固定为:`LoopWaiting` 时 `Result` 为 nil;`Waiting` 列出当前 snapshot 中的 response requests,`ExecutionRecovery` 表示还存在需要 recovery authority 处理的 executing call。`Reason` 提供兼容读取;两者同时存在时使用 `ExecutionRecovery` 唤醒优先级并保留 `Waiting`。`LoopFinished` 时 `Result` 非 nil,并等于 terminal RunRecord 派生的 `RunResult`。 +`LoopResult` 的语义固定为:`LoopWaiting` 时 `Result` 为 nil,表示没有可执行 effect、Run 仍为 active。`ExecutionRecovery` 为 true 时至少有一个 call 仍为 Executing,由 recovery authority 唤醒。`Reason` 在该情况下为 `execution_recovery`,Idle 时为空。Waiting call 不进入 `LoopResult`;Application 通过 `Runtime.Load` / `Record` 与 `WaitingCalls` 读取。`LoopFinished` 时 `Result` 非 nil,并等于 terminal RunRecord 派生的 `RunResult`。 `RequestPlanner` 从 `PlanningHint` 接收 Run 边界事实;它使用自己注入的 history、session context、memory、attachments 与 product policy 组装 `sdk.Request`。Runtime 验证并冻结 planner 返回的 request,Planner 管理 application context。 @@ -403,11 +403,11 @@ Loop.Run(ctx, runtime, runID, sink): **RUN-LOP-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须保留 start command 的 ID、digest 和 claim,直到完成 settlement;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。streaming 与 non-streaming 必须产生同一种完整 `sdk.ModelResult`;delta 只发 EventSink。provider failure 提交 `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`;只有 Accepted owner 可执行。`parallel` 模式并发执行同一 ToolStep 中所有可执行的 Pending call,`sequential` 模式按 call 顺序逐个执行;每个结果以自己的 grant 提交。tool panic 或 effect 状态无法确定的错误转为 Unknown;一个 Unknown 取消同批 sibling workers,并由 Machine 在同一 terminal transition 中记录目标及仍 Executing sibling 的 `ToolCallFailed(Unknown)`,最后追加 `RunEnded(failed/effect_unknown)`。`CancelRun` 也在 `RunEnded(stopped/cancelled)` 前记录所有仍 Executing call 的 Unknown。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 +**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`;只有 Accepted owner 可执行。`parallel` 模式并发执行同一 ToolStep 中所有可执行的 Pending call,`sequential` 模式按 call 顺序逐个执行;每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call 在本次 `Run` 内 Start 并结算。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `WaitingCalls`,提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 之后再次 `Run`。`Next` 返回 `WaitForExecutionRecovery` 时 Loop 返回 `LoopWaiting` 且 `ExecutionRecovery` 为 true。tool panic 或 effect 状态无法确定的错误转为 Unknown;一个 Unknown 取消同批 sibling workers,并由 Machine 在同一 terminal transition 中记录目标及仍 Executing sibling 的 `ToolCallFailed(Unknown)`,最后追加 `RunEnded(failed/effect_unknown)`。`CancelRun` 也在 `RunEnded(stopped/cancelled)` 前记录所有仍 Executing call 的 Unknown。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 **RUN-LOP-5** model 与 tool worker 都接收外层 ctx;Loop 对已接受 effect 使用独立 control context 完成 known/unknown outcome settlement。Application 的业务停止顺序为先 Commit `CancelRun`,再取消 Loop ctx。非 sentinel Commit error 以同 CommandID/digest 重放一次;仍未知时返回错误,由后续 Load/Record 查询 authority。stale/terminal/conflict 触发 reload/drop,旧 external effect 保持单次执行尝试。工具实现配合 context 返回;永久阻塞由 application/durable recovery 处理。 -Waiting response 不由 Loop 自行制造业务输入。Application 以 stable ResponseID、derived CommandID 与 payload/decision digest 提交 `ApproveToolCall`、`RejectToolCall` 或 `SubmitToolResponse`;随后再次运行 Loop。 +Waiting call 的批准与外部结果由 Application 提交。Loop 不生成、不返回、不解释 `ResponseRequest`。Application 以 snapshot 中的 stable ResponseID、derived CommandID 与 payload/decision digest 提交 `ApproveToolCall`、`RejectToolCall` 或 `SubmitToolResponse`;随后再次运行 Loop。 ## 8. EventSink 与边界 diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 776a0f4..40607c7 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -11,7 +11,7 @@ | 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) | -| Extension framework | [agent-session-extension.md](agent-session-extension.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) | @@ -25,7 +25,7 @@ - queue、Session history 和 Run progress 容易形成多份长期事实; - package 边界无法表达不同变化周期。 -本次重构把 Agent Core 收敛为相互独立的 Run、Session、Artifact、Extension、Chatlog 和 Turn 协议,并保留 `sdk` 作为单次 provider transport boundary。 +本次重构把 Agent Core 收敛为相互独立的 Run、Session、Artifact、Session Module、Chatlog 和 Turn 协议,并保留 `sdk` 作为单次 provider transport boundary。 ## 2. 已接受的架构决策 @@ -48,7 +48,7 @@ agent/jsonstable immutable canonical JSON agent/run Run Machine、persisted protocol、Runtime、MemoryRuntime agent/run/loop in-process model/tool interpreter 与 observation ports agent/session Event-first Session kernel -agent/session/extension static modules、codec、semantic append、projection +agent/session/extension Session Module Framework:static modules、codec、semantic append、projection agent/session/chatlog first-party Message ontology agent/artifact Ref、Binding、RetentionClaim agent/turn Turn→Run coordination 与 Run→Session materialization @@ -62,7 +62,7 @@ agent/turn Turn→Run coordination 与 Run→Session materializat Application -> agent/turn + agent/run/loop + adapters agent/run/loop -> agent/run + sdk agent/run -> agent/es + agent/jsonstable + sdk -agent/turn -> agent/run + agent/session + agent/session/chatlog + extensions +agent/turn -> agent/run + agent/session + agent/session/chatlog + session modules ``` 根 `agent/run` 不提供 Loop alias、wrapper 或 façade。 @@ -88,8 +88,8 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + extensions | RunID-addressed `Runtime.Create/Load/Commit/Record` | 完成 | | multi-Run `MemoryRuntime` 与 Runtime conformance | 完成 | | `agent/run/loop` package extraction | 完成 | -| Session/Artifact/Extension/Turn protocols | 规范草案完成,实施待完成 | -| Chatlog protocol | 草案,wire/schema 尚未冻结 | +| Session/Artifact/Session Module/Turn protocols | 规范草案完成,实施待完成 | +| Chatlog protocol | 草案,payload fields 与 golden fixtures 尚未冻结 | | PostgreSQL durable Run adapter(旧接口) | 历史 prototype,迁移未完成 | 当前正式调用形态为 Application 组合 shared `run.Runtime` 与 `loop.Loop`。Loop 不保存 authority state;Runtime 不读取 queue 或 planner context。 @@ -99,7 +99,7 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + extensions ### 4.1 Core reference implementations - 冻结 Session、Artifact 与 Chatlog v1 wire profiles、domain separators、wire-size validation 和 golden fixtures; -- 实现 Session、Artifact、Extension、Chatlog 的 Memory implementations 与 conformance; +- 实现 Session、Artifact、Session Module、Chatlog 的 Memory implementations 与 conformance; - 实现 `turn.Coordinator`、first-party Turn module、`FactMapper`、`MaterializeAll` 和 settlement recovery; - 跑通 Input → Turn → Context → Run/Loop → Session Events → replay 的最小 vertical slice。 @@ -126,7 +126,7 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + extensions - Memory 与 PostgreSQL adapters 通过相同 Runtime conformance; - Turn materialization 对 crash、重复 delivery 和 unknown response 可恢复; -- Session/Artifact/Extension/Chatlog reference implementations 通过各自 conformance; +- 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 覆盖。 diff --git a/docs/design/agent-session-chatlog.md b/docs/design/agent-session-chatlog.md index 6207a23..b1c6667 100644 --- a/docs/design/agent-session-chatlog.md +++ b/docs/design/agent-session-chatlog.md @@ -1,8 +1,8 @@ -# Twilight Agent Session Chatlog Extension +# Twilight Agent Session Chatlog Module -状态:设计草案,wire schema/version 尚未冻结。 +状态:设计草案,payload 字段、输入 limits 与 golden fixtures 尚未冻结。 -本文定义 `agent/session/chatlog` first-party Module,依赖 [Session](agent-session.md) 与 [Extension Framework](agent-session-extension.md)。文中的“必须”“不得”“应该”是草案冻结时应保留的协议约束;canonical JSON 与 digest 遵循 `agent/jsonstable`、`agent/es`。 +本文定义 `agent/session/chatlog` first-party Module,依赖 [Session](agent-session.md) 与 [Session Module Framework](agent-session-extension.md)。文中的“必须”“不得”“应该”是草案冻结时应保留的协议约束;canonical JSON 与 digest 遵循 `agent/jsonstable`、`agent/es`。 ## 1. module 与 ontology @@ -112,22 +112,23 @@ func (MessageBindingExtractor) BindingIDs(value any) ([]artifact.BindingID, erro type MessageRegistry interface { extension.RuntimeRegistry RegistryID() extension.RegistryID - Version() uint16 EncodeMessagePreimage(Message) (jsonstable.Value, error) EncodeMessage(Message) (jsonstable.Value, error) DecodeMessage(jsonstable.Value) (Message, error) } ``` -**CHT-COD-1** Decode 必须 wire-first:先检查 object、explicit discriminator、schema、unknown fields 和 limits,再构造 typed value。Encode/Decode 均拒绝 nil/typed nil、kind mismatch、未知 discriminator、cycle 和超限输入。有效值必须 `Encode → Decode → Encode` canonical-equivalent。 +**CHT-COD-1** Decode 必须 wire-first:先检查 object、explicit discriminator、Session protocol profile、unknown fields 和 limits,再构造 typed value。Encode/Decode 均拒绝 nil/typed nil、kind mismatch、未知 discriminator、cycle 和超限输入。有效值必须 `Encode → Decode → Encode` canonical-equivalent。 -**CHT-COD-2** registry 是 startup static、immutable,kind 有唯一 codec,不依赖 Go type name。Message registry 实现 `extension.RuntimeRegistry.BindingExtractor()`,且必须返回 `(MessageBindingExtractor, true)`,不得以 typed nil 表示缺失。该 extractor 只接受 typed `MessageCommittedPayload`,并按 appearance order 返回其 `Message` 中顶层 ReferencePart 与 ToolResult 单层 child 的全部 ArtifactBindingRef;错误的 typed value 必须拒绝。它是 Extension Binding extraction 的唯一 Chatlog 入口。v1 draft 的 discriminator 为本节定义的 `Part*` 和 `RefArtifactBinding` 值;后续 extension kind 使用其 owner namespace。 +**CHT-COD-2** registry 是 startup static、immutable,kind 有唯一 codec,不依赖 Go type name。Message registry 实现 `extension.RuntimeRegistry.BindingExtractor()`,且必须返回 `(MessageBindingExtractor, true)`,不得以 typed nil 表示缺失。该 extractor 只接受 typed `MessageCommittedPayload`,并按 appearance order 返回其 `Message` 中顶层 ReferencePart 与 ToolResult 单层 child 的全部 ArtifactBindingRef;错误的 typed value 必须拒绝。它是 Chatlog Module binding extraction 的唯一入口。当前 draft 的 discriminator 为本节定义的 `Part*` 和 `RefArtifactBinding` 值;后续 Module kind 使用其 owner namespace。 -**CHT-COD-3** `EncodeMessagePreimage` 编码 ID、TurnID、role、有序 InputIDs/ItemIDs、parts、每个 ref identity 和允许 extension fields,排除 Digest。`MessageDigest=Digest("twilight.chatlog/message/v1", registry ID, registry version, preimage)`;`EncodeMessage` 编码同一 preimage 加 Digest,Decode 必须重算。MessageCommitted payload 的 typed shape 是 `MessageCommittedPayload{Message Message}`;它的 wire codec 必须将 `message` 编码为 `EncodeMessage(Message)` 得到的 canonical message value,不得 generic re-marshal 或重复其中任一字段。 +**CHT-COD-3** `EncodeMessagePreimage` 编码 ID、TurnID、role、有序 InputIDs/ItemIDs、parts、每个 ref identity 和声明的附加字段,排除 Digest。`MessageDigest=Digest("twilight.chatlog/message/v1", registry ID, preimage)`;`EncodeMessage` 编码同一 preimage 加 Digest,Decode 必须重算。MessageCommitted payload 的 typed shape 是 `MessageCommittedPayload{Message Message}`;它的 wire codec 必须将 `message` 编码为 `EncodeMessage(Message)` 得到的 canonical message value,不得 generic re-marshal 或重复其中任一字段。 -Note:本规范保持 draft;v1 freeze 前准确开放项仅为 wire field names、输入 limits、registry version 与 golden fixtures。 +`twilight.chatlog/message/v1` 是 Message digest 的 domain identity,沿用 Session `ProtocolVersion` 的兼容性规则;它不构成独立的 event schema version。 -## 5. event schemas +Note:本规范保持 draft;v1 freeze 前准确开放项仅为 wire field names、输入 limits 与 golden fixtures。 + +## 5. event payloads 下列 payload 均为 object,identity 为 string,整数按 Session profile 编码;未列字段在 v1 默认拒绝。 @@ -156,7 +157,7 @@ type MessageDigestPair struct { MessageID MessageID; Digest es.Digest } type ContextCheckpointInvalidatedPayload struct { CheckpointID CheckpointID; Reason string } ``` -`ModuleDescriptor` 中 `twilight.chatlog/message_committed` 的每个有效 EventSchema 必须注册 Message registry requirement,并声明唯一 Binding reference: +`ModuleDescriptor` 中 `twilight.chatlog/message_committed` 的 EventDefinition 必须注册 Message registry requirement,并声明唯一 Binding reference: ```go extension.BindingReferenceDefinition{ @@ -167,7 +168,7 @@ extension.BindingReferenceDefinition{ } ``` -`AllowedSchemes:nil`(或 empty)明确表示任意 Catalog-registered 且支持 `EventBound` 的 scheme;非空 slice 才是显式允许列表。它不表示允许 ephemeral。该 declaration 使 Message extractor 覆盖的每一个 Binding 都接受 Extension admission。 +`AllowedSchemes:nil`(或 empty)明确表示任意 Catalog-registered 且支持 `EventBound` 的 scheme;非空 slice 才是显式允许列表。它不表示允许 ephemeral。该 declaration 使 Message extractor 覆盖的每一个 Binding 都接受 Module admission。 **CHT-EVT-1** Event names依次为 `input_submitted`、`input_delivered`、`input_withdrawn`、`input_rejected`、`turn_opened`、`turn_completed`、`turn_failed`、`turn_superseded`、`item_opened`、`item_updated`、`item_completed`、`item_failed`、`item_superseded`、`message_committed`、`message_superseded`、`context_checkpoint_created`、`context_checkpoint_invalidated`,均以 `twilight.chatlog/` 为前缀。 @@ -195,7 +196,7 @@ type Surface struct { func ContextFold(events []extension.DecodedEvent) ([]Message, error) ``` -**CHT-CTX-1** 输入必须是已验证、decode、upcast 后、按 resolved ancestry segment/event order 的 events。ContextFold 只输出有效 MessageCommitted 经 supersession/checkpoint 处理后的有序 `[]Message`;它不得接收 raw commits、resolve content、访问 IO 或 provider transport。 +**CHT-CTX-1** 输入必须是已验证、decode 后、按 resolved ancestry segment/event order 的 events。ContextFold 只输出有效 MessageCommitted 经 supersession/checkpoint 处理后的有序 `[]Message`;它不得接收 raw commits、resolve content、访问 IO 或 provider transport。 **CHT-CTX-2** fold 必须再次执行 ancestry-wide MessageID single-creation、role/call pairing、active unresolved-call 检查以及 unknown/indeterminate replacement 规则。checkpoint 只在 CHT-EVT-3 的 coverage gap、base/summary/retained digest 与 retained order 均匹配且仍 compatible 时使用;应用后按 `[SummaryMessage] + Retained` 继续仅 fold checkpoint event 之后的 tail。 diff --git a/docs/design/agent-session-extension.md b/docs/design/agent-session-extension.md index 5a338b0..f6e1ff8 100644 --- a/docs/design/agent-session-extension.md +++ b/docs/design/agent-session-extension.md @@ -1,63 +1,102 @@ -# Twilight Agent Session Extension Framework +# Twilight Agent Session Module Framework 状态:设计规范 -本文定义建立在 `agent/session` 与 `agent/artifact` 之上的静态 `agent/session/extension`。文中的“必须”“不得”“应该”是协议约束;JSON canonicalization 与 digest 遵循 `agent/jsonstable`、`agent/es`。 +本文定义建立在 `agent/session` 与 `agent/artifact` 之上的静态 Session Module Framework。实现包路径暂为 `agent/session/extension`;文中的“必须”“不得”“应该”是协议约束;JSON canonicalization 与 digest 遵循 `agent/jsonstable`、`agent/es`。 ## 1. 范围与依赖 ```text -agent/artifact ← agent/session/extension → agent/session +agent/artifact ← Session Module Framework → agent/session ↑ first-party 与 Application modules ``` -Framework 负责 Module ownership、static startup composition、immutable Catalog、event codec/schema/upcast、Binding declaration、SemanticAppender 和 pure projection。first-party modules 可包括 `twilight.chatlog` 与 `twilight.turn`。它不支持运行时动态代码加载;新组合必须构建新 immutable Catalog。 +Framework 负责 Module ownership、static startup composition、immutable Catalog、typed event +codec、Binding declaration、SemanticAppender 和 pure projection。first-party modules 可包括 +`twilight.chatlog` 与 `twilight.turn`。每次进程启动构建一套与 Session protocol version +绑定的 immutable Catalog。 **EXT-SCP-1** Session Store protocol 由 Session kernel 负责;多 Session transaction、package import、saga、operation log 与 provider policy 由 Application/adapter 负责。未知 event 的 archive Binding manifest 由 Application archive coordinator 处理。 +`SemanticAppender` 是 Session Module Framework 面向 typed producer 的写入协调器:它执行 codec、binding +admission、claim/journal 编排,并调用 Session Store 的 canonical append。claim、journal 与 +artifact binding 的一致性由 Session Module Framework 维护;Session kernel 继续维护 commit、CAS、digest 与 +replay 规则。 + ## 2. Module、Catalog 与版本 ```go type ModuleID string -type ModuleVersion uint16 type ProjectionID string type ProjectionVersion uint16 type RegistryID string type ModuleDescriptor struct { - ID ModuleID; Version ModuleVersion + ID ModuleID Events []EventDefinition Projections []ProjectionDefinition Registries []CodecRegistryDescriptor Schemes []artifact.SchemeDefinition } -type Catalog struct { /* immutable, built only by BuildCatalog */ } -type CatalogBuildRequest struct { Modules []ModuleDescriptor; Registries []RuntimeRegistry } +type Catalog struct { + ProtocolVersion uint16 + Profile session.ProtocolProfile + // immutable indexes for modules, events, projections, registries and schemes +} +type CatalogBuildRequest struct { + ProtocolVersion uint16 + Profile session.ProtocolProfile + Modules []ModuleDescriptor + Registries []RuntimeRegistry +} type RuntimeRegistry interface { Descriptor() CodecRegistryDescriptor BindingExtractor() (BindingExtractor, bool) } func BuildCatalog(CatalogBuildRequest) (*Catalog, error) +func (c *Catalog) LookupEvent(session.EventType) (ModuleID, EventDefinition, bool) +func (c *Catalog) ModuleForEvent(session.EventType) (ModuleID, bool) +func (c *Catalog) LookupProjection(ProjectionID, ProjectionVersion) (ProjectionDefinition, bool) ``` -**EXT-CAT-1** ModuleID 使用小写 ASCII namespace;`twilight.*` 保留 first-party。一个 Catalog 中 ModuleID、EventType、ProjectionID、Scheme、RegistryID 均唯一。Build defensive-copy 所有 descriptor 和 runtime registry,成功后只读且与注册顺序无关。 +`Catalog` 是进程启动时的 Module 目录:它把一个协议版本下可用的 Module、EventType、 +payload codec、projection、runtime registry 与 artifact scheme 组合成只读索引。它供 +typed append、decode、binding admission 与 projection lookup 使用;Session 数据、Run +状态和事件日志继续保存在各自的 authority 中。`Catalog.Profile` 是同一版本的 +`session.ProtocolProfile`,两者在 `BuildCatalog` 时绑定。 + +**EXT-CAT-1** ModuleID 使用小写 ASCII namespace;`twilight.*` 保留 first-party。EventType +采用 `/` 形式,owner namespace 来自前缀。一个 Catalog 中 ModuleID、 +EventType、ProjectionID、Scheme、RegistryID 均唯一。Build defensive-copy 所有 descriptor +和 runtime registry,成功后只读且与注册顺序无关。 -| 变化 | ModuleVersion | Event SchemaVersion | ProjectionVersion | -|---|---:|---:|---:| -| 新 EventType | 增加 | 从 1 开始 | 受影响时增加 | -| 修改 payload 或 Binding 声明 | 增加 | 增加 | fold 受影响时增加 | -| 修改 reducer/state codec | 增加 | 不变 | 增加 | -| 增加无关 Module | Catalog composition 改变 | 不变 | 不变 | +事件类型在一个 Catalog 中只有一个当前定义。`ProtocolVersion` 覆盖已经持久化的 +payload 格式与 Session wire;ProjectionVersion 只标识 projection 的派生状态格式。Catalog +本身属于进程启动时的组合配置,不进入 Session wire、event digest 或 commit identity。 +现有 EventType 的 wire identity 由其 ProtocolVersion 固定。版本判断遵循旧 reader 的 +语义兼容性:旧 reader 读取新 writer 的 log 后仍能保持 canonical validation、commit/fork/ +replay 顺序、history/context/recovery 与 digest chain 语义时,继续使用当前 ProtocolVersion。 +安全可忽略的新 persisted EventType 与真正 optional 的 payload 字段属于这一类;非持久化 +projection、registry 或 scheme 的增加沿用当前版本。 -**EXT-CAT-2** Build 必须拒绝 duplicate owner、namespace mismatch、schema gap、重复版本、registry requirement mismatch、upcast gap/cycle/ambiguity、非法 Binding declaration、以及任何 Session event declaration 的 `RequiredDurability < EventBound`。Binding declaration 引用 RegistryID 时,Catalog 必须找到匹配 runtime registry,并要求其 `BindingExtractor()` 返回 `(extractor, true)` 且 extractor 非 nil/非 typed nil;`false` 明确表示该 registry 不支持 Binding extraction,不能被该 declaration 引用。 +旧 reader 无法保持上述语义的 event、payload、envelope 或 commit 变化提升 +`ProtocolVersion`。影响 Session 核心重建的新增 EventType 也属于这一类。ProjectionVersion +管理单个派生快照的 state codec;projection 实现变化只有在改变 Session 核心重建语义时 +才推动 ProtocolVersion。 -## 3. event schema、codec 与 upcast +**EXT-CAT-2** Build 必须拒绝 duplicate owner、namespace mismatch、无效 ProtocolVersion、registry requirement mismatch、非法 Binding declaration、以及任何 Session event declaration 的 `RequiredDurability < EventBound`。Binding declaration 引用 RegistryID 时,Catalog 必须找到匹配 runtime registry,并要求其 `BindingExtractor()` 返回 `(extractor, true)` 且 extractor 非 nil/非 typed nil;`false` 明确表示该 registry 不支持 Binding extraction,不能被该 declaration 引用。 + +**EXT-CAT-3** `BuildCatalog` 要求 `Profile` 非 nil(含 typed nil)且 +`Profile.Version() == ProtocolVersion`。一个 Catalog 只服务一个 ProtocolVersion;typed +append、event decode、projection run 与 binding admission 先校验 Session Header 的版本 +与 Catalog 版本一致。当前版本之外的 Session 先经外部 migration 转换,再进入该 Catalog。 + +## 3. event codec ```go -type EventDefinition struct { Type session.EventType; Schemas []EventSchema; Upcasters []Upcaster } -type EventSchema struct { - Version uint16 +type EventDefinition struct { + Type session.EventType Codec PayloadCodec Bindings []BindingReferenceDefinition RegistryRequirements []CodecRegistryRequirement @@ -68,24 +107,24 @@ type PayloadCodec interface { Validate(value any) error } type CodecRegistryDescriptor struct { - ID RegistryID; Version uint16; WireManifest jsonstable.Value; WireProfile string -} -type CodecRegistryRequirement struct { ID RegistryID; Version uint16; WireManifest jsonstable.Value } -type Upcaster interface { - FromVersion() uint16; ToVersion() uint16 - Upcast(jsonstable.Value) (jsonstable.Value, error) + ID RegistryID; WireManifest jsonstable.Value; WireProfile string } +type CodecRegistryRequirement struct { ID RegistryID; WireManifest jsonstable.Value } type DecodedEvent struct { Event session.SessionEvent; ModuleID ModuleID - PersistedVersion uint16; TargetVersion uint16; Value any + Value any } ``` **EXT-COD-1** descriptor 是 wire manifest/profile;runtime registry 是显式提供的 immutable implementation。descriptor 不声称 hash 或证明代码。Catalog 必须逐字段匹配 requirement 和 runtime descriptor。 -**EXT-COD-2** codec、Validate、Upcast、Binding extraction 必须纯、确定、无 IO,不读 clock/random/environment/mutable global。Decode wire-first:先验证 object、discriminator(如适用)、kind、schema、limits 和 unknown policy,再构造 value。Encode/Decode 必须拒绝 nil、typed nil、kind mismatch、未知 kind 和非 canonical value;有效值须满足 `Encode → Decode → Encode` 的 canonical round-trip。 +**EXT-COD-2** codec、Validate、Binding extraction 必须纯、确定、无 IO,不读 clock/random/environment/mutable global。Decode wire-first:先验证 object、discriminator(如适用)、kind、ProtocolVersion/profile 和 limits,再构造 value。Encode/Decode 必须拒绝 nil、typed nil、kind mismatch、未知 kind 和非 canonical value;有效值须满足 `Encode → Decode → Encode` 的 canonical round-trip。 -**EXT-COD-3** 一个 EventType 的 upcast 路径只能线性向前;upcast 仅为 projection 生成 typed view,不改写 committed Session events。Catalog 对未知 EventType 不作 decode。 +**EXT-COD-3** EventDefinition 的 Codec 遵循 Catalog 的 ProtocolVersion。已提交事件的 +payload 保持原始 canonical bytes。旧 reader 可以安全保留并忽略的新 EventType 或真正 +optional 的 payload 字段沿用当前 ProtocolVersion;旧 reader 无法保持 Session 核心语义的 +变化提升 ProtocolVersion,并由外部 migration tool 生成当前版本 stream。Catalog 对未知 +EventType 保留 raw payload,projection 按 `RequireComplete` 规则决定是否接受该事件。 ## 4. Binding reference declaration @@ -112,7 +151,7 @@ type BindingExtractor interface { BindingIDs(value any) ([]artifact.BindingID, e ```go type TypedEvent struct { - EventID session.EventID; Type session.EventType; SchemaVersion uint16 + EventID session.EventID; Type session.EventType RecordedAtUnixMilli int64; SourceEvents []session.EventID; Value any } type SemanticAppendRequest struct { @@ -168,7 +207,7 @@ type AtomicSemanticCommitter interface { } ``` -**EXT-APP-1** request 的 Events 是完整 group,不能为空。Appender 对每个 TypedEvent lookup schema、validate、canonical encode、decode-round-trip 和 extract;任一失败不作 IO。它将全部 occurrence 组成 sorted-unique union,并通过 `artifact.BindingSetBuilder.Build(ctx, union)` 构造完整 BindingSet;Extension 只对 Build 已 resolve 的 Binding 按 declaration 执行 Scheme、最低 durability 与 host access admission。Appender 必须将 TypedEvent 的 `EventID`、`RecordedAtUnixMilli`、`SourceEvents` 与 canonical Payload、Type、SchemaVersion 逐字段映射为 `session.UncommittedEvent`,不得生成或替换其中任一值。`SourceEvents` 仍由 Session 的 bytewise sorted-unique、same-resolved-stream 规则验证。 +**EXT-APP-1** request 的 Events 是完整 group,不能为空。Appender 对每个 TypedEvent lookup event definition、validate、canonical encode、decode-round-trip 和 extract;任一失败不作 IO。它将全部 occurrence 组成 sorted-unique union,并通过 `artifact.BindingSetBuilder.Build(ctx, union)` 构造完整 BindingSet;Session Module Framework 只对 Build 已 resolve 的 Binding 按 declaration 执行 Scheme、最低 durability 与 host access policy。Appender 必须将 TypedEvent 的 `EventID`、`RecordedAtUnixMilli`、`SourceEvents` 与 canonical Payload、Type 逐字段映射为 `session.UncommittedEvent`,不得生成或替换其中任一值。`SourceEvents` 仍由 Session 的 bytewise sorted-unique、same-resolved-stream 规则验证。 **EXT-APP-2** 对 nonempty BindingSet,先经 `Store.Header(ctx, SessionID)` 读取并验证该 Session 的 Header,唯一派生为: @@ -181,7 +220,7 @@ ClaimOwner = {Kind:"twilight.session/commit", Authority:string(SessionID), Identity:string(CommitID)} ``` -claim profile version 固定为 `1`;整数使用 Extension canonical string profile(无前导零的十进制),其中 Session `ProtocolVersion` 只能取自已验证 Header。Artifact `WireVersion` 已由 `BindingSet.RefSetDigest` 覆盖,绝不另入 preimage。ClaimID 不含 ExpectedHead。先 Build set,再派生 claim;调用者不得提供或覆盖它们。相同 CommitID retry 的 typed event identity/time/source/payload 或 BindingSet 不同均为 conflict;完全相同 immutable request 的 append fingerprint 稳定。`ExpectedHead` 不入该 fingerprint,且是同一 pending intent 唯一可更新的 CanonicalRequest 字段。retry、recovery 及导入 `twilight.session/commit` active claim 时都必须以该 Header 和同一 preimage 重算并验证 ClaimID。 +claim profile version 固定为 `1`;整数使用 Session Module canonical string profile(无前导零的十进制),其中 Session `ProtocolVersion` 只能取自已验证 Header。Artifact `WireVersion` 已由 `BindingSet.RefSetDigest` 覆盖,绝不另入 preimage。ClaimID 不含 ExpectedHead。先 Build set,再派生 claim;调用者不得提供或覆盖它们。相同 CommitID retry 的 typed event identity/time/source/payload 或 BindingSet 不同均为 conflict;完全相同 immutable request 的 append fingerprint 稳定。`ExpectedHead` 不入该 fingerprint,且是同一 pending intent 唯一可更新的 CanonicalRequest 字段。retry、recovery 及导入 `twilight.session/commit` active claim 时都必须以该 Header 和同一 preimage 重算并验证 ClaimID。 **EXT-APP-3** 没有 Binding 时不创建 BindingSet、intent 或 claim。否则先构造含 ClaimID、ClaimOwner、BindingSet、Fingerprint、完整 CanonicalRequest 和 Pending State 的 intent,且非原子流程严格为 `journal.Prepare → ledger.Prepare → Store.Commit`。journal `Prepare` 对既有同一 ClaimID 的记录,必须逐字段验证 Fingerprint、ClaimOwner、BindingSet 及 CanonicalRequest 的所有 immutable 字段;只有全部相同才幂等,并且仅可更新 Pending intent 的 `CanonicalRequest.ExpectedHead`,其他差异均为 conflict。`ledger.Prepare` 必须使用 intent 内的 ClaimID、ClaimOwner、BindingSet。支持 `AtomicSemanticCommitter` 的 adapter 可跳过单独 choreography,但必须原子持久化同一完整 intent 与匹配 Prepared claim,或直接原子 commit 并建立匹配 Active claim;两种路径均不得在 Event 可见前缺少可恢复 claim plan,并使用等价 terminal handling。 @@ -213,22 +252,33 @@ journal 已存在而 claim 尚不存在是正常 crash point:NotFound 只表 ## 6. pure projection 与 snapshot ```go -type Consumption struct { Type session.EventType; Versions []uint16 } type ProjectionDefinition struct { ID ProjectionID; Version ProjectionVersion - Consumes []Consumption; Ignores []session.EventType; RequireComplete []ModuleID + Consumes []session.EventType; Ignores []session.EventType; RequireComplete []ModuleID Initial func() (any, error) Apply func(any, DecodedEvent) (any, error) StateCodec PayloadCodec } -type ProjectionRunRequest struct { Definition ProjectionDefinition; Events []session.SessionEvent; InitialState any } +type ProjectionRunRequest struct { + Catalog *Catalog + ProtocolVersion uint16 + Definition ProjectionDefinition + Events []session.SessionEvent + InitialState any +} type ProjectionRunResult struct { State any; Applied uint64; Ignored uint64 } type ProjectionRunner interface { Run(ProjectionRunRequest) (ProjectionRunResult, error) } ``` -**EXT-PRJ-1** Initial、Apply、StateCodec、runner decode/upcast 都必须 pure。runner 只接受已验证的 complete commit sequence;一个 commit 内任一 event 失败,不得发布该 commit 的 partial state。 +**EXT-PRJ-1** Initial、Apply、StateCodec 和 runner decode 都必须 pure。runner 要求 +`Catalog` 非 nil、`Catalog.ProtocolVersion == ProtocolVersion`,并只接受已验证的 complete +commit sequence;一个 commit 内任一 event 失败,不得发布该 commit 的 partial state。 -**EXT-PRJ-2** `Consumes` 表示必须 decode/upcast/handle 的类型版本,`Ignores` 是显式已知跳过,二者不得重叠。出现属于 `RequireComplete` module 的 unknown 或未分类 event 必须失败。其他 module event 可忽略。 +**EXT-PRJ-2** `Catalog` 为每个 EventType 提供唯一 ModuleID 与 EventDefinition; +`ModuleForEvent` 依据 EventType 前缀识别 owner,因此 required module 的未知事件可以被 +发现。`Consumes` 表示必须 decode/handle 的 EventType,`Ignores` 是显式已知跳过,二者不得重叠。 +出现属于 `RequireComplete` module 的 unknown 或未分类 event 必须失败;其他 module event +可忽略。 **EXT-PRJ-3** snapshot 使用 Session snapshot envelope。只有 ProjectionID、ProjectionVersion、StateCodec canonical validation 和 Session coverage 都匹配时可复用;否则从 log 重建。 @@ -238,8 +288,9 @@ type ProjectionRunner interface { Run(ProjectionRunRequest) (ProjectionRunResult type ErrorCode string const ( ErrInvalid ErrorCode = "invalid"; ErrUnknownEvent ErrorCode = "unknown_event" - ErrSchema ErrorCode = "schema"; ErrCodec ErrorCode = "codec" - ErrBinding ErrorCode = "binding"; ErrConflict ErrorCode = "conflict" + ErrCodec ErrorCode = "codec"; ErrBinding ErrorCode = "binding" + ErrConflict ErrorCode = "conflict" + ErrUnsupportedProfile ErrorCode = "unsupported_profile" ErrIndeterminate ErrorCode = "indeterminate" ) type Error struct { Code ErrorCode; Type session.EventType; Detail string } @@ -248,8 +299,8 @@ func (Error) Error() string Conformance 必须验证: -- **EXT-CAT-1、EXT-CAT-2**:static immutable Catalog、所有 ownership/version conflict、拒绝低于 EventBound 的 Session declaration、optional BindingExtractor availability/typed-nil rejection; -- **EXT-COD-1、EXT-COD-2、EXT-COD-3**:wire-first、安全 codec、canonical round-trip 和 linear upcast; +- **EXT-CAT-1、EXT-CAT-2、EXT-CAT-3**:static immutable Catalog、Profile/ProtocolVersion binding、所有 ownership/protocol conflict、拒绝低于 EventBound 的 Session declaration、optional BindingExtractor availability/typed-nil rejection; +- **EXT-COD-1、EXT-COD-2、EXT-COD-3**:wire-first、安全 codec、ProtocolVersion 匹配、兼容性分类与 canonical round-trip; - **EXT-REF-1、EXT-REF-2**:pointer/custom registry 全量提取、cardinality、extractor requirement、scheme/durability admission; - **EXT-APP-1、EXT-APP-2、EXT-APP-3、EXT-APP-4**:TypedEvent→UncommittedEvent 全字段映射、由 Header ProtocolVersion/claim profile v1/RefSetDigest 派生的 stable ClaimID、完整 intent 的逐字段 journal idempotency(仅 ExpectedHead 可更新)、durable intent-before-claim、claim-before-commit、journal-only claim rebuild、claim-only journal-missing indeterminate、terminal abort evidence、active-claim import revalidation、atomic capability 等价性与 crash recovery; - **EXT-PRJ-1、EXT-PRJ-2、EXT-PRJ-3**:pure fold、commit boundary、Consumes/Ignores/RequireComplete 和 snapshot equivalence。 diff --git a/docs/design/agent-session.md b/docs/design/agent-session.md index 0ecd067..d0c8515 100644 --- a/docs/design/agent-session.md +++ b/docs/design/agent-session.md @@ -27,11 +27,13 @@ Header 与 Commit 是 Events 的 canonical persistent representation,提供原 ```text Session kernel envelope、顺序、CAS、fork、snapshot、canonical import、integrity -Extension event ontology、typed codec、payload validation、projection +Session modules event ontology、typed codec、payload validation、projection ``` Payload 对 Session kernel 是 opaque canonical JSON。缓存和 snapshot 是可重建派生数据。 +`ProtocolVersion` 是 Session Event Sourcing 的唯一持久化版本,统一约束 header、event payload、commit、fork、digest profile 与 codec。ProjectionVersion 只描述派生快照的 state codec。 + **SES-SCP-1** kernel 不依赖领域 payload,也不对其执行 schema validation 或解释。resolved replay 的唯一顺序是 root-to-target ancestry segment order,再在每个 segment 内按 `(revision, index)`;`EventPosition` 只标识 event/cursor,不定义跨 segment 的排序;时钟只作 metadata。 ## 2. wire types 与 profile @@ -62,7 +64,6 @@ type SessionEvent struct { EventID EventID Index uint16 Type EventType - SchemaVersion uint16 RecordedAtUnixMilli int64 SourceEvents []EventID Payload jsonstable.Value @@ -71,7 +72,6 @@ type SessionEvent struct { type UncommittedEvent struct { EventID EventID Type EventType - SchemaVersion uint16 RecordedAtUnixMilli int64 SourceEvents []EventID Payload jsonstable.Value @@ -92,7 +92,7 @@ type Head struct { Revision es.Revision; Digest es.Digest } **SES-WIR-1** identity 非空且稳定;EventID 在 resolved ancestry 内唯一。header 不可变,revision 从 1 连续递增,commit 至少有一个 event,event Index 从 0 连续递增。revision 1 的 `PreviousDigest=HeaderDigest`,其后为前一 CommitDigest。 -**SES-WIR-2** `ProtocolProfile` 冻结 envelope field、null/omission、精确 integer encoding、unknown-field policy、array order 与下列 digest preimage;所有 digest 依 `agent/es` 的 versioned domain separator。 +**SES-WIR-2** `ProtocolProfile` 冻结整个 Event Sourcing wire:envelope、event payload、commit、fork、null/omission、精确 integer encoding、unknown-field policy、array order 与下列 digest preimage;所有 digest 依 `agent/es` 的 versioned domain separator。 ```go type ProtocolProfile interface { @@ -116,6 +116,28 @@ type ProtocolProfile interface { } ``` +第一版运行实例绑定一个 `ProtocolProfile`。该 profile 的 `Version()` 是实例接受的 +`ProtocolVersion`;`Create`、`Commit`、`Replay`、`Fork`、snapshot 与 canonical import +都使用同一 profile。读取 Session Header、Commit、snapshot 或 ancestry archive 时, +实例先校验其中的 `ProtocolVersion` 与绑定 profile 相等,再执行对应的 decode、digest +和 replay。版本不匹配返回 `ErrUnsupportedProfile`。 + +第一版支持当前协议版本。历史 Session 由外部 migration tool 使用旧 profile 读取并 +写出完整的当前版本 stream;原始 stream 可以作为 archive 保留。迁移产物再通过 +`ImportCanonical` 导入当前实例。Session kernel 的版本选择来自运行实例配置与已验证 +Header 的匹配关系。 + +**SES-WIR-3** 同一个 Session 的 `SessionHeader.ProtocolVersion`、所有 +`SessionCommit.ProtocolVersion`、`Snapshot.ProtocolVersion`、 +`ImmutableAncestryArchive.ProtocolVersion` 与所选 `ProtocolProfile.Version()` 必须相等。 +Store 从已验证 Header 派生后续操作使用的版本,调用方提交的版本字段只能通过一致性校验。 + +**SES-WIR-4** `ProtocolVersion` 在旧版本 reader 读取新 writer 产生的 log 后无法保持正确 +语义时递增。正确语义包括 canonical validation、commit/fork/replay 顺序、历史与 context +重建、recovery 判断以及 digest chain 验证。旧 reader 可以安全保留并忽略的新 EventType +或真正 optional 的 payload 字段保持当前版本;影响上述语义的 event、字段、envelope 或 +commit 规则变化进入新版本,并由外部 migration tool 生成当前版本 stream。 + Header、event、commit 分别覆盖自身以外的全部持久字段。event digest 额外覆盖 origin SessionID、revision 和 index。每个 `ValidateCanonical*` 必须执行 decode → encode 并要求 canonical-equivalent wire,同时验证对应 digest;它是 adapter/import 的唯一 canonical round-trip validation 入口。`SourceEvents` 是 bytewise sorted-unique set,且只可引用同一 resolved Session stream 内已存在的 EventID;跨 stream provenance 必须编码在 payload 的 owner-defined `SourceRef` 中。opaque Payload 必须已经 canonical,完整 value 进入 event digest 和 append fingerprint。空 stream head 是 `{0, HeaderDigest}`。 ## 3. Store API 与 errors @@ -154,7 +176,7 @@ type ForkResult struct { Header SessionHeader; Created bool } type CanonicalImportRequest struct { Header SessionHeader; Commits []SessionCommit; AncestryArchive *ImmutableAncestryArchive } type CanonicalImportResult struct { Header SessionHeader; Imported uint64; Head Head; AlreadyPresent bool } -type ImmutableAncestryArchive struct { ProfileVersion uint16; Headers []SessionHeader; Commits []SessionCommit; ArchiveDigest es.Digest } +type ImmutableAncestryArchive struct { ProtocolVersion uint16; Headers []SessionHeader; Commits []SessionCommit; ArchiveDigest es.Digest } type SnapshotRequest struct { SessionID SessionID; ProjectionKey ProjectionKey; ProjectionVersion uint16; AtOrBefore ResolvedHead } type SnapshotResult struct { Snapshot *Snapshot; Found bool; Covers bool } @@ -186,7 +208,7 @@ type Error struct { Code ErrorCode; Operation string; SessionID SessionID; Commi func (Error) Error() string ``` -**SES-API-1** Store.Commit 是唯一 append Store protocol port。它必须原子持久化完整 commit 与 head;普通 producer 的 capability exposure 是 Extension 的责任。`ImportCanonical` 只给 trusted adapter、recovery/import coordinator 或 test。 +**SES-API-1** Store.Commit 是唯一 append Store protocol port。它必须原子持久化完整 commit 与 head;普通 producer 的 capability exposure 由 Session Module Framework 提供。`ImportCanonical` 只给 trusted adapter、recovery/import coordinator 或 test。 ## 4. append 与 idempotency @@ -249,7 +271,13 @@ type Snapshot struct { ## 8. canonical import -**SES-IMP-1** canonical import 仅处理 Session records,不解释 payload。它必须经 ProtocolProfile 的 `ValidateCanonical*` 入口验证 profile、header、完整 chain、event uniqueness 和 supplied positions/digests;只接受完整 stream 或已有可验证 predecessor 的 contiguous tail。`ImmutableAncestryArchive` 的 canonical preimage 是 profile version、按 root-to-target segment order 的 Headers、每个 segment 按 local `(revision,index)` 的完整 Commits,排除 `ArchiveDigest`;ArchiveDigest 覆盖该 preimage。archive codec 同样必须 decode→encode canonical-equivalent 并验证 digest。 +**SES-IMP-1** canonical import 仅处理 Session records,不解释 payload。它必须经当前 +`ProtocolProfile` 的 `ValidateCanonical*` 入口验证 profile、header、完整 chain、event +uniqueness 和 supplied positions/digests;只接受完整 stream 或已有可验证 predecessor +的 contiguous tail。`ImmutableAncestryArchive` 的 canonical preimage 是 +`ProtocolVersion`、按 root-to-target segment order 的 Headers、每个 segment 按 local +`(revision,index)` 的完整 Commits,排除 `ArchiveDigest`;ArchiveDigest 覆盖该 preimage。 +archive codec 同样必须 decode→encode canonical-equivalent 并验证 digest。 **SES-IMP-2** 同 `(SessionID,CommitID)` 仅在 canonical commit 逐字段相同才幂等;不同 header、revision、group 或 digest 为 conflict。fork child import 必须有本地 existing verified parent boundary,或随 request 提供经过验证 immutable ancestry archive;不得只信任 ParentFork 声明。 @@ -257,11 +285,14 @@ Application/import coordinator 负责跨 Session package、业务事务与恢复 ## 9. boundaries 与 conformance -Extension 可对 opaque payload 作 typed encode/decode;它不得改变 Store 的 CAS、CommitID、digest 或 replay 语义。unknown event 必须 raw-preserve 和 raw-replay。`agent/turn` 消费/追加 Events 并协调 Run;Session kernel 仍只处理纯 envelope 与 stream 机制。Run、queue、provider 与 Application policy 均在 kernel 外。 +Session modules 可对 opaque payload 作 typed encode/decode;Store 继续负责 CAS、CommitID、digest +与 replay 语义。unknown event 保留原始 payload 并支持原样 replay。`agent/turn` 消费/追加 +Events 并协调 Run;Session kernel 处理 envelope 与 stream 机制。Run、queue、provider 与 +Application policy 使用各自的边界。 Conformance 必须验证: -- **SES-WIR-1、SES-WIR-2**:wire/profile freeze、所有 Encode/Decode 与 `ValidateCanonical*` round-trip、digest preimage、同-stream SourceEvents、EventID uniqueness、complete commit; +- **SES-WIR-1、SES-WIR-2、SES-WIR-3、SES-WIR-4**:wire/profile freeze、版本一致性、兼容性判定、所有 Encode/Decode 与 `ValidateCanonical*` round-trip、digest preimage、同-stream SourceEvents、EventID uniqueness、complete commit; - **SES-API-1、SES-APP-1、SES-APP-2**:atomic CAS、concurrent writer、CommitID exact idempotency 和 failure classification; - **SES-REP-1、SES-REP-2、SES-REP-3**:local/resolved replay 的 segment-first 顺序、完整 EventPosition After 语义、cursor/token binding、tamper/gap failure; - **SES-FRK-1、SES-FRK-2**:ForkID idempotency、parent boundary 和 RetainClosure; diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index 891e6a8..d25f7a9 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -20,7 +20,7 @@ **TRN-SCP-3** Coordinator 从 Session facts 和 `Runtime.Record` 重建;`MachineState` 提供 execution authority,`TransitionRecord` 提供 canonical record。Coordinator 编排持久化事实,以 injected capability 执行协调动作;Session、Run、driver、mapper cursor、terminal verdict、队列 lease、provider client、per-Run handle 与额外权限均由各自 canonical owner 提供。 -**TRN-SCP-4** Coordinator 通过 `extension.SemanticAppender` 完成所有 Session 写入;Extension 负责 Binding admission、claim 及其恢复。`artifact` 由其 canonical owner 管理。 +**TRN-SCP-4** Coordinator 通过 `extension.SemanticAppender` 完成所有 Session 写入;Session Module Framework 负责 Binding admission、claim 及其恢复。`artifact` 由其 canonical owner 管理。 **TRN-SCP-5** Application 管理 model、provider、tool、prompt、token、approval、queue、channel、并发、重试时机及其他业务 policy。Coordinator 按 persisted binding 解析 driver 并协调协议步骤,历史计划仅按其 persisted choices 执行。 @@ -143,7 +143,7 @@ type StopRequest struct { Ref TurnRef; Reason string } type StopResponse struct { Ref TurnRef; RunID run.RunID; Result *ResultReference } ``` -**TRN-API-5** DTO 均为值语义。Start 对既有 linkage 的新 RunID 或计划改写返回 conflict;Resume/Stop 按 Ref 解析唯一 unsettled linkage。`Disposition` 取 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`;前两者表示 Run 仍为 active,后者必须带 `Result`。`Waiting` 只列当前 snapshot 的 response requests,执行恢复等待不伪造 response request;terminal fact 与后续计划授权由 RunRecord 和 persisted linkage 提供。 +**TRN-API-5** DTO 均为值语义。Start 对既有 linkage 的新 RunID 或计划改写返回 conflict;Resume/Stop 按 Ref 解析唯一 unsettled linkage。`Disposition` 取 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`;前两者表示 Run 仍为 active,后者必须带 `Result`。`Waiting` 只列当前 snapshot 的 `WaitingCalls`;Loop/driver 不返回这些 request。执行恢复等待不伪造 response request;terminal fact 与后续计划授权由 RunRecord 和 persisted linkage 提供。 ## 4. Start admission @@ -157,7 +157,7 @@ type StopResponse struct { Ref TurnRef; RunID run.RunID; Result *ResultReference 4. InitialInputs 为 Run ingress payload,UserMessage 按其 chatlog wire 规则持久化; 5. `UserMessage.Role=RoleUser`、`TurnID=Ref.TurnID`,并经 Message registry 重算 digest; 6. UserMessage.InputIDs 精确等于 ordered InputIDs,ItemIDs 为空; -7. input 已 submitted、未终结、可 delivered;message 的其余 role、part、binding、canonical 约束由 chatlog/Extension admission 执行。 +7. input 已 submitted、未终结、可 delivered;message 的其余 role、part、binding、canonical 约束由 chatlog/Module admission 执行。 **TRN-STR-3** v1 Start 写入一个原子 semantic Session commit,顺序严格为: @@ -201,9 +201,9 @@ run_requested{RunRequestedPayload} driver.Drive(ctx, DriveRequest{Ref: ref, RunID: linkage.RunID}) ``` -无论 driver 返回完成、等待、error,Coordinator 均再次 Record、调用 `MaterializeAll(record)` 补齐完整新前缀,并据 record snapshot/terminal fact 返回 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`。前两者不返回 terminal Result;等待用户响应时携带 snapshot 中的 response requests,同时存在 execution recovery 时保留这些 requests 并由 `ExecutionRecovery` 标记;只有没有 response request 时才返回空 `Waiting`,表示由 recovery authority 负责后续唤醒。 +无论 driver 返回完成、没有可执行 effect、error,Coordinator 均再次 Record、调用 `MaterializeAll(record)` 补齐完整新前缀,并据 record snapshot/terminal fact 返回 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`。前两者不返回 terminal Result。snapshot 含 Waiting call 时 `Waiting` 列为 `WaitingCalls`;`ExecutionRecovery` 仅在 snapshot 仍有 Executing call 时为真。Loop 返回 `LoopWaiting` 且 `ExecutionRecovery` 为 true 时 Disposition 为 `ResumeWaitingForRecovery`,即使 snapshot 同时含 Waiting call。仅有 Waiting、无 Executing 时 Disposition 为 `ResumeWaitingForResponse`。空 `Waiting` 且需要 recovery 时由 recovery authority 负责后续唤醒。 -**TRN-RSM-6** driver 的 provisional stream、返回错误、网络响应丢失、context cancel 作为观察结果处理。driver 以 shared Runtime 的 Load/Commit 推进 MachineState;并发 Resume 依 Runtime command idempotency、revision、grant、record 一致性收敛。具体执行器可以使用 `agent/run/loop.Loop`;其 `LoopResult` 映射为本节的 `ResumeDisposition`,并由 Coordinator 始终以随后读取的 `RunRecord` 判断 waiting、execution recovery、terminal 与 settlement。 +**TRN-RSM-6** driver 的 provisional stream、返回错误、网络响应丢失、context cancel 作为观察结果处理。driver 以 shared Runtime 的 Load/Commit 推进 MachineState;并发 Resume 依 Runtime command idempotency、revision、grant、record 一致性收敛。具体执行器可以使用 `agent/run/loop.Loop`。Loop 只解释可执行 effect(prepare、start model、start tools)以及 execution recovery;`Idle` 映射为 `LoopWaiting` 且不携带 response request。Coordinator 始终以随后读取的 `RunRecord` 判断 Waiting call、execution recovery、terminal 与 settlement。Application 对 Runtime 提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 后再 Resume。 **TRN-STP-1** Stop 先解析 linkage、Load。terminal Run 进入 record/materialize/settlement;active Run 以稳定 domain-separated CancelRun CommandID 构造 `CancelRun{Reason:ReasonCancelled}`,并经 shared Runtime Commit。 @@ -255,7 +255,7 @@ Digest("twilight.turn/source-fact/v1", 同一 event 的 source ID 随 retry、Session head、mapper process 保持稳定。`FactMap.SourceFacts` 按 target transition event 顺序,精确列出实际产生本 batch `Events` 的 AgentEvents;每个输出 event 可追溯到其中 source fact。`Events` 为空时 `SourceFacts` 为空。后续 revision 仅处理其 target transition 的输出与 source fact。 -**TRN-MAT-3** materialization CommitID 使用 `twilight.turn/materialize/v1` domain,覆盖 SessionID、TurnID、RunID、MapperVersion、TargetRevision、TargetTransitionDigest、ordered target-source SourceFactIDs。输出 EventID 使用 `twilight.turn/materialized-event/v1` domain,覆盖 CommitID、ordinal、event type、schema version、canonical payload digest;CAS rebase 后保持字节稳定。 +**TRN-MAT-3** materialization CommitID 使用 `twilight.turn/materialize/v1` domain,覆盖 SessionID、TurnID、RunID、MapperVersion、TargetRevision、TargetTransitionDigest、ordered target-source SourceFactIDs。输出 EventID 使用 `twilight.turn/materialized-event/v1` domain,覆盖 CommitID、ordinal、event type、canonical payload digest;CAS rebase 后保持字节稳定。 **TRN-MAT-4** mapper 为 pure deterministic total-prefix function:相同 FactMapRequest 产生同一 ordered FactMap,输入为请求中的持久化 facts。IO、clock、random、Application defaults、前次调用状态不参与 Map。MapperVersion 是协议输入;升级使用显式新 version,既有事实保持原 version。 From 1eaf4ffbdf0fe638aaebd45468f7739575568e77 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 1 Sep 2026 14:42:33 +0800 Subject: [PATCH 37/81] Bind run protocol dispatch to Header.SchemaVersion Digest, decode, Decide, and Evolve now select ProtocolV1 helpers by schema version. Commit accepts a command only when its SchemaVersion matches the Run header, not the process-global current version. Loop stamps snapshot.SchemaVersion on envelopes. --- agent/run/alias_test.go | 6 +- agent/run/codec.go | 26 ++++++-- agent/run/commit.go | 23 +++++-- agent/run/creation.go | 2 +- agent/run/decide.go | 39 ++++++----- agent/run/evolve.go | 10 +-- agent/run/header.go | 31 ++++++--- agent/run/header_test.go | 20 ++++++ agent/run/loop/helpers_test.go | 2 +- agent/run/loop/loop.go | 34 +++++----- agent/run/loop/loop_test.go | 4 +- agent/run/loop/regression_test.go | 2 +- agent/run/machine_test.go | 18 ++--- agent/run/memory_runtime.go | 10 +-- agent/run/protocol.go | 94 +++++++++++++------------- agent/run/protocol_v1.go | 99 ++++++++++++++++++++++++++++ agent/run/runtime.go | 3 + agent/run/runtime_helpers_test.go | 6 +- agent/run/runtimetest/conformance.go | 8 +-- docs/design/agent-run.md | 3 + 20 files changed, 308 insertions(+), 132 deletions(-) create mode 100644 agent/run/protocol_v1.go diff --git a/agent/run/alias_test.go b/agent/run/alias_test.go index aa58c87..ba2e14f 100644 --- a/agent/run/alias_test.go +++ b/agent/run/alias_test.go @@ -103,16 +103,16 @@ func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { "p": cj(`{"b":2,"a":1}`), }, } - reqDigest, err := DigestRequest(req) + reqDigest, err := DigestRequest(SchemaVersion1, req) if err != nil { t.Fatal(err) } - toolsDigest, err := DigestToolSpecs(nil) + toolsDigest, err := DigestToolSpecs(SchemaVersion1, nil) if err != nil { t.Fatal(err) } model := ModelRef(req.Model) - binding, err := DigestModelStepBinding(model, reqDigest, toolsDigest) + binding, err := DigestModelStepBinding(SchemaVersion1, model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } diff --git a/agent/run/codec.go b/agent/run/codec.go index 15f7466..2d822f5 100644 --- a/agent/run/codec.go +++ b/agent/run/codec.go @@ -123,7 +123,7 @@ func (e *CommandEnvelope) UnmarshalJSON(raw []byte) error { if !isSupportedSchemaVersion(wire.SchemaVersion) { return fmt.Errorf("agent: codec: unsupported schema version %d", wire.SchemaVersion) } - cmd, err := decodeCommandVariant(wire.Type, wire.Command) + cmd, err := decodeCommandVariant(wire.SchemaVersion, wire.Type, wire.Command) if err != nil { return err } @@ -215,7 +215,7 @@ func (e *AgentEvent) UnmarshalJSON(raw []byte) error { if !isSupportedSchemaVersion(wire.SchemaVersion) { return fmt.Errorf("agent: codec: unsupported schema version %d", wire.SchemaVersion) } - fact, err := decodeFactVariant(wire.Type, wire.Fact) + fact, err := decodeFactVariant(wire.SchemaVersion, wire.Type, wire.Fact) if err != nil { return err } @@ -306,7 +306,16 @@ func decodeFactAs[T Fact](raw json.RawMessage) (Fact, error) { return f, err } -func decodeCommandVariant(typ string, raw json.RawMessage) (AgentCommand, error) { +func decodeCommandVariant(schemaVersion uint16, typ string, raw json.RawMessage) (AgentCommand, error) { + switch schemaVersion { + case SchemaVersion1: + return decodeCommandVariantV1(typ, raw) + default: + return nil, unsupportedSchemaVersion(schemaVersion) + } +} + +func decodeCommandVariantV1(typ string, raw json.RawMessage) (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) } @@ -344,7 +353,16 @@ func decodeCommandVariant(typ string, raw json.RawMessage) (AgentCommand, error) } } -func decodeFactVariant(typ string, raw json.RawMessage) (Fact, error) { +func decodeFactVariant(schemaVersion uint16, typ string, raw json.RawMessage) (Fact, error) { + switch schemaVersion { + case SchemaVersion1: + return decodeFactVariantV1(typ, raw) + default: + return nil, unsupportedSchemaVersion(schemaVersion) + } +} + +func decodeFactVariantV1(typ string, raw json.RawMessage) (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) } diff --git a/agent/run/commit.go b/agent/run/commit.go index 06c824f..e4db365 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -103,6 +103,7 @@ func EvaluateCommit( req CommitRequest, grantValid bool, recoveryValid bool, + runSchemaVersion uint16, ) (CommitDecision, error) { env := req.Command @@ -113,8 +114,11 @@ func EvaluateCommit( 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 env.SchemaVersion != currentSchemaVersion { - return CommitDecision{}, fmt.Errorf("agent: commit: unsupported schema version %d", env.SchemaVersion) + if err := requireSchemaVersion(runSchemaVersion); err != nil { + return CommitDecision{}, err + } + if env.SchemaVersion != runSchemaVersion { + return CommitDecision{}, fmt.Errorf("agent: commit: command schema %d does not match run schema %d", env.SchemaVersion, runSchemaVersion) } wantDigest, err := DigestCommand(env.SchemaVersion, env.Type, env.Command) if err != nil { @@ -174,7 +178,7 @@ func EvaluateCommit( } // Step 5: Decide once, fold with Evolve. - facts, err := Decide(cur, env.Command) + facts, err := DecideVersion(env.SchemaVersion, cur, env.Command) if err != nil { switch { case errors.Is(err, ErrRunTerminal): @@ -247,16 +251,25 @@ func EvaluateCommit( // canonical digest. This is the only sanctioned construction path; callers // never hand-assemble envelope fields (RUN-WIR-3). func BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) { + return BuildEnvelopeVersion(currentSchemaVersion, run, id, cmd) +} + +// BuildEnvelopeVersion stamps schemaVersion onto the envelope. Runtime.Commit +// accepts it only when it matches the Run header's SchemaVersion. +func BuildEnvelopeVersion(schemaVersion uint16, run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) { + if err := requireSchemaVersion(schemaVersion); err != nil { + return CommandEnvelope{}, err + } typ := commandType(cmd) if typ == "" { return CommandEnvelope{}, fmt.Errorf("agent: envelope: unknown command variant %T", cmd) } - d, err := DigestCommand(currentSchemaVersion, typ, cmd) + d, err := DigestCommand(schemaVersion, typ, cmd) if err != nil { return CommandEnvelope{}, err } return CommandEnvelope{ - SchemaVersion: currentSchemaVersion, + SchemaVersion: schemaVersion, Type: typ, RunID: run, ID: id, diff --git a/agent/run/creation.go b/agent/run/creation.go index ba44479..645521d 100644 --- a/agent/run/creation.go +++ b/agent/run/creation.go @@ -63,7 +63,7 @@ const newRunV1InitialStateVersion uint16 = 1 func buildRunHeaderV1(run NewRun) (RunHeader, error) { initial := MachineState{RunID: run.RunID, Status: RunActive} - stateBytes, err := encodeMachineStateWire(&initial) + stateBytes, err := encodeMachineStateV1(&initial) if err != nil { return RunHeader{}, err } diff --git a/agent/run/decide.go b/agent/run/decide.go index e739b33..f8bf6f4 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -28,12 +28,21 @@ func rejectionf(format string, args ...any) error { // //nolint:gocritic // hugeParam: public protocol boundary is intentionally value-based: Decide(state, command) -> facts. func Decide(s MachineState, c AgentCommand) ([]Fact, error) { + return DecideVersion(currentSchemaVersion, s, c) +} + +// DecideVersion validates a command using schemaVersion's digest and shape +// rules. Runtime.Commit must pass the Run header's SchemaVersion. +func DecideVersion(schemaVersion uint16, s MachineState, c AgentCommand) ([]Fact, error) { + if err := requireSchemaVersion(schemaVersion); err != nil { + return nil, err + } if s.Status.Terminal() { return nil, ErrRunTerminal } switch cmd := c.(type) { case PrepareModelRequest: - return decidePrepareModelRequest(&s, &cmd) + return decidePrepareModelRequest(schemaVersion, &s, &cmd) case StartModelExecution: return decideStartModelExecution(&s, cmd) case RecoverModelExecution: @@ -51,11 +60,11 @@ func Decide(s MachineState, c AgentCommand) ([]Fact, error) { case SubmitToolFailure: return decideSubmitToolFailure(&s, cmd) case ApproveToolCall: - return decideApproveToolCall(&s, cmd) + return decideApproveToolCall(schemaVersion, &s, cmd) case RejectToolCall: - return decideRejectToolCall(&s, &cmd) + return decideRejectToolCall(schemaVersion, &s, &cmd) case SubmitToolResponse: - return decideSubmitToolResponse(&s, &cmd) + return decideSubmitToolResponse(schemaVersion, &s, &cmd) case CancelRun: return decideCancelRun(&s, cmd) case AcceptInput: @@ -67,7 +76,7 @@ func Decide(s MachineState, c AgentCommand) ([]Fact, error) { // --- rule 1: PrepareModelRequest --- -func decidePrepareModelRequest(s *MachineState, cmd *PrepareModelRequest) ([]Fact, error) { +func decidePrepareModelRequest(schemaVersion uint16, s *MachineState, cmd *PrepareModelRequest) ([]Fact, error) { if s.Current != nil { return nil, rejectionf("prepare: run already has a current step") } @@ -98,7 +107,7 @@ func decidePrepareModelRequest(s *MachineState, cmd *PrepareModelRequest) ([]Fac if spec.Definition.Name != cmd.Request.Tools[i].Name { return nil, rejectionf("prepare: ToolSpec[%d] %q does not match request tool %q", i, spec.Definition.Name, cmd.Request.Tools[i].Name) } - wantDigest, err := DigestToolDefinition(cmd.Request.Tools[i]) + wantDigest, err := DigestToolDefinition(schemaVersion, cmd.Request.Tools[i]) if err != nil { return nil, err } @@ -106,21 +115,21 @@ func decidePrepareModelRequest(s *MachineState, cmd *PrepareModelRequest) ([]Fac return nil, rejectionf("prepare: ToolSpec[%d] definition digest mismatch", i) } } - wantReq, err := DigestRequest(cmd.Request) + wantReq, err := DigestRequest(schemaVersion, cmd.Request) if err != nil { return nil, err } if cmd.RequestDigest != wantReq { return nil, rejectionf("prepare: request digest mismatch") } - wantTools, err := DigestToolSpecs(cmd.Tools) + wantTools, err := DigestToolSpecs(schemaVersion, cmd.Tools) if err != nil { return nil, err } if cmd.ToolsDigest != wantTools { return nil, rejectionf("prepare: tools digest mismatch") } - binding, err := DigestModelStepBinding(cmd.Model, cmd.RequestDigest, cmd.ToolsDigest) + binding, err := DigestModelStepBinding(schemaVersion, cmd.Model, cmd.RequestDigest, cmd.ToolsDigest) if err != nil { return nil, err } @@ -463,11 +472,11 @@ func waitingCall(s *MachineState, step StepID, call CallID, kind ResponseKind, r return ts, i, nil } -func decideApproveToolCall(s *MachineState, cmd ApproveToolCall) ([]Fact, error) { +func decideApproveToolCall(schemaVersion uint16, s *MachineState, cmd ApproveToolCall) ([]Fact, error) { if _, _, err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseApproval, cmd.ResponseID); err != nil { return nil, err } - wantDigest, err := DigestToolResponseDecision(ResponseApproval, ResponseDecisionApproved, "") + wantDigest, err := DigestToolResponseDecision(schemaVersion, ResponseApproval, ResponseDecisionApproved, "") if err != nil { return nil, err } @@ -477,7 +486,7 @@ func decideApproveToolCall(s *MachineState, cmd ApproveToolCall) ([]Fact, error) return []Fact{ToolCallApproved(cmd)}, nil } -func decideRejectToolCall(s *MachineState, cmd *RejectToolCall) ([]Fact, error) { +func decideRejectToolCall(schemaVersion uint16, 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. Spec §4.2 lists Waiting -> Failed(Known) @@ -498,7 +507,7 @@ func decideRejectToolCall(s *MachineState, cmd *RejectToolCall) ([]Fact, error) 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 := DigestToolResponseDecision(c.Waiting.Kind, ResponseDecisionRejected, cmd.Reason) + wantDigest, err := DigestToolResponseDecision(schemaVersion, c.Waiting.Kind, ResponseDecisionRejected, cmd.Reason) if err != nil { return nil, err } @@ -514,12 +523,12 @@ func decideRejectToolCall(s *MachineState, cmd *RejectToolCall) ([]Fact, error) return facts, nil } -func decideSubmitToolResponse(s *MachineState, cmd *SubmitToolResponse) ([]Fact, error) { +func decideSubmitToolResponse(schemaVersion uint16, s *MachineState, cmd *SubmitToolResponse) ([]Fact, error) { _, _, err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseExternal, cmd.ResponseID) if err != nil { return nil, err } - wantDigest, err := DigestToolResponsePayload(cmd.Payload) + wantDigest, err := DigestToolResponsePayload(schemaVersion, cmd.Payload) if err != nil { return nil, err } diff --git a/agent/run/evolve.go b/agent/run/evolve.go index b1b51ca..8a6bfc3 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -216,15 +216,15 @@ func validateFactTransition(s MachineState, f Fact) error { if ModelRef(fact.Request.Model) != fact.Model { return errors.New("agent: evolve: model step prepared model mismatch") } - requestDigest, err := DigestRequest(fact.Request) + requestDigest, err := DigestRequest(SchemaVersion1, fact.Request) if err != nil || requestDigest != fact.RequestDigest { return errors.New("agent: evolve: model step prepared request digest mismatch") } - toolsDigest, err := DigestToolSpecs(fact.Tools) + toolsDigest, err := DigestToolSpecs(SchemaVersion1, fact.Tools) if err != nil || toolsDigest != fact.ToolsDigest { return errors.New("agent: evolve: model step prepared tools digest mismatch") } - binding, err := DigestModelStepBinding(fact.Model, fact.RequestDigest, fact.ToolsDigest) + binding, err := DigestModelStepBinding(SchemaVersion1, fact.Model, fact.RequestDigest, fact.ToolsDigest) if err != nil || binding != fact.BindingDigest { return errors.New("agent: evolve: model step prepared binding digest mismatch") } @@ -312,7 +312,7 @@ func validateFactTransition(s MachineState, f Fact) error { if call.Waiting.ID != fact.ResponseID { return fmt.Errorf("agent: evolve: tool call %q response ID mismatch", fact.CallID) } - want, err := DigestToolResponseDecision(ResponseApproval, ResponseDecisionApproved, "") + want, err := DigestToolResponseDecision(SchemaVersion1, ResponseApproval, ResponseDecisionApproved, "") if err != nil || want != fact.ResponseDigest { return fmt.Errorf("agent: evolve: tool call %q approval digest mismatch", fact.CallID) } @@ -329,7 +329,7 @@ func validateFactTransition(s MachineState, f Fact) error { if call.Waiting.ID != fact.ResponseID { return fmt.Errorf("agent: evolve: tool call %q response ID mismatch", fact.CallID) } - want, err := DigestToolResponsePayload(fact.Payload) + want, err := DigestToolResponsePayload(SchemaVersion1, fact.Payload) if err != nil || want != fact.ResponseDigest { return fmt.Errorf("agent: evolve: tool call %q response digest mismatch", fact.CallID) } diff --git a/agent/run/header.go b/agent/run/header.go index 7aaa531..4218b2b 100644 --- a/agent/run/header.go +++ b/agent/run/header.go @@ -13,11 +13,24 @@ import ( // each can evolve only through its documented versioning rules (RUN-CMP-1). const snapshotSchemaVersion uint16 = 1 -// encodeMachineStateWire renders the canonical bytes of a MachineState for -// header digests. The interface-typed Current step is flattened the same way -// state equivalence comparison does. -func encodeMachineStateWire(s *MachineState) ([]byte, error) { - return marshalCanonical(stateComparable(s)) +// encodeMachineStateVersion renders the canonical bytes of a MachineState for +// header digests under schemaVersion. +func encodeMachineStateVersion(schemaVersion uint16, s *MachineState) ([]byte, error) { + switch schemaVersion { + case SchemaVersion1: + return encodeMachineStateV1(s) + default: + return nil, unsupportedSchemaVersion(schemaVersion) + } +} + +func validateHeaderInitialStateVersion(h *RunHeader) error { + switch h.SchemaVersion { + case SchemaVersion1: + return validateHeaderV1(h) + default: + return unsupportedSchemaVersion(h.SchemaVersion) + } } // RunHeader is the formal persisted Revision-0 protocol record (RUN-NEW-1). @@ -55,7 +68,7 @@ func BuildRunHeader(runID RunID, causationID es.CausationID) (RunHeader, error) if err != nil { return RunHeader{}, err } - stateBytes, err := encodeMachineStateWire(&initial) + stateBytes, err := encodeMachineStateVersion(currentSchemaVersion, &initial) if err != nil { return RunHeader{}, err } @@ -101,8 +114,8 @@ func ValidateRunHeader(h *RunHeader) error { if !isSupportedSchemaVersion(h.SchemaVersion) { return fmt.Errorf("agent: run header: unsupported schema version %d", h.SchemaVersion) } - if h.InitialStateVersion != snapshotSchemaVersion { - return fmt.Errorf("agent: run header: unsupported initial state version %d", h.InitialStateVersion) + if err := validateHeaderInitialStateVersion(h); err != nil { + return err } if h.InitialState.RunID != h.RunID { return errors.New("agent: run header: initial state RunID mismatch") @@ -114,7 +127,7 @@ func ValidateRunHeader(h *RunHeader) error { h.InitialState.Usage != (Usage{}) { return errors.New("agent: run header: initial state is not a minimal Revision-0 state") } - stateBytes, err := encodeMachineStateWire(&h.InitialState) + stateBytes, err := encodeMachineStateVersion(h.SchemaVersion, &h.InitialState) if err != nil { return err } diff --git a/agent/run/header_test.go b/agent/run/header_test.go index 0db973c..2a51a68 100644 --- a/agent/run/header_test.go +++ b/agent/run/header_test.go @@ -48,6 +48,26 @@ func TestValidateRunHeaderRejectsTampering(t *testing.T) { } } +func TestCommitRejectsCommandSchemaMismatch(t *testing.T) { + rt := NewMemoryRuntime() + newRun, err := BuildNewRun("run-1", "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(t.Context(), newRun); err != nil { + t.Fatal(err) + } + in := AgentInput{ID: "seed", Payload: MustParseCanonicalJSON(`{"q":"hi"}`)} + env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), NextStep(in)) + if err != nil { + t.Fatal(err) + } + env.SchemaVersion = 2 + if _, err := rt.Commit(t.Context(), CommitRequest{BaseRevision: 0, Command: env}); err == nil { + t.Fatal("v2 envelope accepted on v1 run") + } +} + func TestFoldRunFromHeaderMatchesRuntime(t *testing.T) { h, err := BuildRunHeader("run-1", "") if err != nil { diff --git a/agent/run/loop/helpers_test.go b/agent/run/loop/helpers_test.go index 0d4568c..df4751b 100644 --- a/agent/run/loop/helpers_test.go +++ b/agent/run/loop/helpers_test.go @@ -65,7 +65,7 @@ func snapshotWaiting(t *testing.T, rt Runtime, runID RunID) []ResponseRequest { func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDecision, reason string) Digest { t.Helper() - digest, err := DigestToolResponseDecision(kind, decision, reason) + digest, err := DigestToolResponseDecision(SchemaVersion1, kind, decision, reason) if err != nil { t.Fatal(err) } diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index 734c1b3..3869606 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -198,7 +198,7 @@ func (l *Loop) resumeSettlement(ctx context.Context, runtime run.Runtime, events l.forgetStart(key) continue } - res, err := l.commit(context.WithoutCancel(ctx), runtime, runID, attempt.commandID, attempt.base, attempt.grant, attempt.command) + res, err := l.commit(context.WithoutCancel(ctx), runtime, runID, attempt.commandID, attempt.base, attempt.grant, attempt.command, snapshot.SchemaVersion) if err != nil { if retriable(err) { l.forgetSettlement(key) @@ -356,8 +356,8 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev // digest (RUN-LOP-5): if the first attempt actually // committed and only the response was lost, the replay returns AlreadyApplied // instead of abandoning a live grant or re-executing an expensive step. -func (l *Loop) commit(ctx context.Context, runtime run.Runtime, runID run.RunID, id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) (run.CommitResult, error) { - env, err := run.BuildEnvelope(runID, id, cmd) +func (l *Loop) commit(ctx context.Context, runtime run.Runtime, runID run.RunID, id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand, schemaVersion uint16) (run.CommitResult, error) { + env, err := run.BuildEnvelopeVersion(schemaVersion, runID, id, cmd) if err != nil { return run.CommitResult{}, err } @@ -426,15 +426,15 @@ func (l *Loop) planAndPrepare(ctx context.Context, runtime run.Runtime, events E if run.ModelRef(frozenRequest.Model) != model { return fmt.Errorf("agent: loop: request model %q does not match plan model %q", frozenRequest.Model, model) } - requestDigest, err := run.DigestRequest(frozenRequest) + requestDigest, err := run.DigestRequest(snapshot.SchemaVersion, frozenRequest) if err != nil { return err } - toolsDigest, err := run.DigestToolSpecs(plan.Tools) + toolsDigest, err := run.DigestToolSpecs(snapshot.SchemaVersion, plan.Tools) if err != nil { return err } - binding, err := run.DigestModelStepBinding(model, requestDigest, toolsDigest) + binding, err := run.DigestModelStepBinding(snapshot.SchemaVersion, model, requestDigest, toolsDigest) if err != nil { return err } @@ -449,7 +449,7 @@ func (l *Loop) planAndPrepare(ctx context.Context, runtime run.Runtime, events E PlanningToken: plan.PlanningToken, Tools: plan.Tools, ToolsDigest: toolsDigest, - }) + }, snapshot.SchemaVersion) if err == nil { // ModelStepPrepared carries the frozen request — the most informative // fact of the run; observers must see it like every other accepted @@ -479,7 +479,7 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve runID := snapshot.State.RunID key := startKey{runID: runID, stepID: stepID} attempt := l.startFor(key) - start, err := l.commit(ctx, runtime, runID, attempt.commandID, snapshot.Revision, "", run.StartModelExecution{StepID: stepID, Claim: attempt.claim}) + start, err := l.commit(ctx, runtime, runID, attempt.commandID, snapshot.Revision, "", run.StartModelExecution{StepID: stepID, Claim: attempt.claim}, snapshot.SchemaVersion) if err != nil { if retriable(err) { l.forgetStart(key) @@ -551,7 +551,7 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve } settlement := l.settlementFor(key, completionID, start.Snapshot.Revision, start.Grant, completion) settlementCtx := context.WithoutCancel(ctx) - res, err := l.commit(settlementCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command) + res, err := l.commit(settlementCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command, snapshot.SchemaVersion) if err != nil { if retriable(err) { l.forgetSettlement(key) @@ -755,7 +755,7 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: freezeErr.Error()} break } - defDigest, digestErr := run.DigestToolDefinition(toolDef) + defDigest, digestErr := run.DigestToolDefinition(snapshot.SchemaVersion, toolDef) if digestErr != nil { known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: digestErr.Error()} break @@ -774,9 +774,9 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve if known != nil { // Known failure of a Pending call: no start barrier, no tool call. res, err := l.commit(ctx, runtime, runID, freshCommandID(), snapshot.Revision, "", - run.SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: *known, Outcome: run.ToolOutcomeKnown}) + run.SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: *known, Outcome: run.ToolOutcomeKnown}, snapshot.SchemaVersion) if err != nil { - settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started) + settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, snapshot.SchemaVersion) if !retriable(err) { return err } @@ -788,9 +788,9 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve attempt := l.startFor(key) start, err := l.commit(ctx, runtime, runID, attempt.commandID, snapshot.Revision, "", - run.StartToolCall{StepID: eff.StepID, CallID: callID, Claim: attempt.claim}) + run.StartToolCall{StepID: eff.StepID, CallID: callID, Claim: attempt.claim}, snapshot.SchemaVersion) if err != nil { - settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started) + settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, snapshot.SchemaVersion) if retriable(err) { // A sentinel rejection proves this start did not acquire the // call. Drop the local claim so a later snapshot can create a @@ -823,7 +823,7 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Revision, tool: tool, key: key}) } - return l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started) + return l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, snapshot.SchemaVersion) } func toolCallFromSnapshot(state run.MachineState, stepID run.StepID, callID run.CallID) (run.ToolCallState, bool) { @@ -845,7 +845,7 @@ func toolCallFromSnapshot(state run.MachineState, stepID run.StepID, callID run. // resulting outcome can still reach Runtime (RUN-LOP-5). One Unknown cancels // sibling workers. 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 run.Runtime, events EventSink, runID run.RunID, stepID run.StepID, started []startedWorker) error { +func (l *Loop) settleWorkers(ctx context.Context, runtime run.Runtime, events EventSink, runID run.RunID, stepID run.StepID, started []startedWorker, schemaVersion uint16) error { if len(started) == 0 { return nil } @@ -907,7 +907,7 @@ func (l *Loop) settleWorkers(ctx context.Context, runtime run.Runtime, events Ev // ErrRunTerminal and are dropped (audit is the adapter's job). // The one-shot same-CommandID replay lives inside l.commit. settlement := l.settlementFor(w.key, freshCommandID(), w.base, w.grant, cmd) - res, err := l.commit(controlCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command) + res, err := l.commit(controlCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command, schemaVersion) switch { case err == nil: l.forgetSettlement(w.key) diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go index 7b6b6a8..6ab66ee 100644 --- a/agent/run/loop/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -122,7 +122,7 @@ func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { if err != nil { t.Fatal(err) } - d, err := DigestToolDefinition(frozen) + d, err := DigestToolDefinition(SchemaVersion1, frozen) if err != nil { t.Fatal(err) } @@ -516,7 +516,7 @@ func TestToolStartStaleDropsLocalClaim(t *testing.T) { BindingDigest: bindingDigest, Arguments: args, Policy: DirectExecution, Status: ToolPending, }}, }, - }, Revision: 1} + }, Revision: 1, SchemaVersion: SchemaVersion1} if err := loop.runToolCalls(context.Background(), staleCommitRuntime{}, nil, snapshot, StartToolCalls{StepID: stepID, CallIDs: []CallID{"c1"}}); err != nil { diff --git a/agent/run/loop/regression_test.go b/agent/run/loop/regression_test.go index c60554f..6fc05ae 100644 --- a/agent/run/loop/regression_test.go +++ b/agent/run/loop/regression_test.go @@ -61,7 +61,7 @@ func TestRegressionAliasedToolRefExecutes(t *testing.T) { if err != nil { t.Fatal(err) } - d, err := DigestToolDefinition(frozenDef) + d, err := DigestToolDefinition(SchemaVersion1, frozenDef) if err != nil { t.Fatal(err) } diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go index c0d912f..abfefd1 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -62,16 +62,16 @@ func buildPrepare(t *testing.T, s MachineState, req sdk.Request, specs []ToolSpe if err != nil { t.Fatal(err) } - reqDigest, err := DigestRequest(frozenReq) + reqDigest, err := DigestRequest(SchemaVersion1, frozenReq) if err != nil { t.Fatal(err) } - toolsDigest, err := DigestToolSpecs(specs) + toolsDigest, err := DigestToolSpecs(SchemaVersion1, specs) if err != nil { t.Fatal(err) } model := ModelRef(frozenReq.Model) - binding, err := DigestModelStepBinding(model, reqDigest, toolsDigest) + binding, err := DigestModelStepBinding(SchemaVersion1, model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -98,7 +98,7 @@ func makeSpec(t *testing.T, def sdk.ToolDefinition, policy ResponsePolicy) ToolS if err != nil { t.Fatal(err) } - d, err := DigestToolDefinition(frozen) + d, err := DigestToolDefinition(SchemaVersion1, frozen) if err != nil { t.Fatal(err) } @@ -107,7 +107,7 @@ func makeSpec(t *testing.T, def sdk.ToolDefinition, policy ResponsePolicy) ToolS func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDecision, reason string) Digest { t.Helper() - d, err := DigestToolResponseDecision(kind, decision, reason) + d, err := DigestToolResponseDecision(SchemaVersion1, kind, decision, reason) if err != nil { t.Fatal(err) } @@ -116,7 +116,7 @@ func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDe func responsePayloadDigest(t *testing.T, payload CanonicalJSON) Digest { t.Helper() - d, err := DigestToolResponsePayload(payload) + d, err := DigestToolResponsePayload(SchemaVersion1, payload) if err != nil { t.Fatal(err) } @@ -595,15 +595,15 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { } prepared := func(ids ...InputID) ModelStepPrepared { request := ModelRequest{Model: string(testModel)} - requestDigest, err := DigestRequest(request) + requestDigest, err := DigestRequest(SchemaVersion1, request) if err != nil { t.Fatal(err) } - toolsDigest, err := DigestToolSpecs(nil) + toolsDigest, err := DigestToolSpecs(SchemaVersion1, nil) if err != nil { t.Fatal(err) } - binding, err := DigestModelStepBinding(testModel, requestDigest, toolsDigest) + binding, err := DigestModelStepBinding(SchemaVersion1, testModel, requestDigest, toolsDigest) if err != nil { t.Fatal(err) } diff --git a/agent/run/memory_runtime.go b/agent/run/memory_runtime.go index f02e3bf..812206b 100644 --- a/agent/run/memory_runtime.go +++ b/agent/run/memory_runtime.go @@ -131,7 +131,7 @@ func (m *MemoryRuntime) Load(ctx context.Context, runID RunID) (RuntimeSnapshot, entry.mu.Unlock() return RuntimeSnapshot{}, err } - snapshot := RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision} + snapshot := RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision, SchemaVersion: entry.header.SchemaVersion} header := cloneRunHeader(entry.header) entry.mu.Unlock() if err := ValidateRunHeader(&header); err != nil { @@ -230,7 +230,7 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe copy := cloneTransitionRecord(&record) prior = © } - decision, err := EvaluateCommit(entry.state, entry.revision, prior, req, grantValid, recoveryValid) + decision, err := EvaluateCommit(entry.state, entry.revision, prior, req, grantValid, recoveryValid, entry.header.SchemaVersion) if err != nil { return CommitResult{}, err } @@ -249,7 +249,7 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe } } return CommitResult{Status: CommitAlreadyApplied, - Snapshot: RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision}, + Snapshot: RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision, SchemaVersion: entry.header.SchemaVersion}, Events: cloneEvents(decision.Events), Grant: grant}, nil case DecisionConflict: return CommitResult{}, ErrCommandConflict @@ -294,7 +294,7 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe entry.startGrants = make(map[CommandID]ExecutionGrant) } return CommitResult{Status: CommitAccepted, - Snapshot: RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision}, + Snapshot: RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision, SchemaVersion: entry.header.SchemaVersion}, Events: cloneEvents(stored.Events), Grant: minted}, nil } @@ -316,7 +316,7 @@ func (m *MemoryRuntime) Record(ctx context.Context, runID RunID) (RunRecord, err return RunRecord{}, err } header := cloneRunHeader(entry.header) - snapshot := RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision} + snapshot := RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision, SchemaVersion: header.SchemaVersion} transitions := cloneTransitionRecords(entry.log) entry.mu.Unlock() if err := checkContext(ctx); err != nil { diff --git a/agent/run/protocol.go b/agent/run/protocol.go index edb239b..9caa3a6 100644 --- a/agent/run/protocol.go +++ b/agent/run/protocol.go @@ -1,7 +1,6 @@ package run import ( - "errors" "fmt" "github.com/memohai/twilight/agent/es" @@ -99,22 +98,15 @@ type toolResponseDecisionDigestBody struct { } // DigestToolResponseDecision computes the content digest for approval and -// rejection ingress. The ResponseID remains the routing/idempotency key; this -// digest binds the actual decision payload recorded in the command/fact. -func DigestToolResponseDecision(kind ResponseKind, decision ResponseDecision, reason string) (Digest, error) { - if kind != ResponseApproval && kind != ResponseExternal { - return "", fmt.Errorf("agent: response decision: unsupported kind %q", kind) +// rejection ingress under schemaVersion. The ResponseID remains the +// routing/idempotency key; this digest binds the decision payload. +func DigestToolResponseDecision(schemaVersion uint16, kind ResponseKind, decision ResponseDecision, reason string) (Digest, error) { + switch schemaVersion { + case SchemaVersion1: + return digestToolResponseDecisionV1(kind, decision, reason) + default: + return "", unsupportedSchemaVersion(schemaVersion) } - if decision != ResponseDecisionApproved && decision != ResponseDecisionRejected { - return "", fmt.Errorf("agent: response decision: unsupported decision %q", decision) - } - body, err := encodeEnvelopeBody(currentSchemaVersion, "tool_response_decision", toolResponseDecisionDigestBody{ - Kind: kind, Decision: decision, Reason: reason, - }) - if err != nil { - return "", err - } - return sha256Digest(body), nil } type toolResponsePayloadDigestBody struct { @@ -122,65 +114,71 @@ type toolResponsePayloadDigestBody struct { } // DigestToolResponsePayload computes the content digest for an ExternalResponse -// answer payload. -func DigestToolResponsePayload(payload CanonicalJSON) (Digest, error) { - body, err := encodeEnvelopeBody(currentSchemaVersion, "tool_response_payload", toolResponsePayloadDigestBody{Payload: payload}) - if err != nil { - return "", err +// answer payload under schemaVersion. +func DigestToolResponsePayload(schemaVersion uint16, payload CanonicalJSON) (Digest, error) { + switch schemaVersion { + case SchemaVersion1: + return digestToolResponsePayloadV1(payload) + default: + return "", unsupportedSchemaVersion(schemaVersion) } - return sha256Digest(body), nil } // DigestRequest covers every field of a frozen ModelRequest with no exclusions -// (RUN-WIR-2). +// (RUN-WIR-2). schemaVersion selects the digest preimage. // //nolint:gocritic // hugeParam: digest covers the complete immutable ModelRequest value. -func DigestRequest(req ModelRequest) (Digest, error) { - body, err := encodeEnvelopeBody(currentSchemaVersion, "model_request", req) - if err != nil { - return "", err +func DigestRequest(schemaVersion uint16, req ModelRequest) (Digest, error) { + switch schemaVersion { + case SchemaVersion1: + return digestRequestV1(req) + default: + return "", unsupportedSchemaVersion(schemaVersion) } - return sha256Digest(body), nil } // DigestToolDefinition covers one provider-neutral tool definition. -func DigestToolDefinition(def ToolDefinition) (Digest, error) { - body, err := encodeEnvelopeBody(currentSchemaVersion, "tool_definition", def) - if err != nil { - return "", err +func DigestToolDefinition(schemaVersion uint16, def ToolDefinition) (Digest, error) { + switch schemaVersion { + case SchemaVersion1: + return digestToolDefinitionV1(def) + default: + return "", unsupportedSchemaVersion(schemaVersion) } - return sha256Digest(body), nil } // DigestToolSpec covers one agent ToolSpec (ref, definition, digest, policy). // //nolint:gocritic // hugeParam: digest covers the complete immutable ToolSpec value. -func DigestToolSpec(spec ToolSpec) (Digest, error) { - body, err := encodeEnvelopeBody(currentSchemaVersion, "tool_spec", spec) - if err != nil { - return "", err +func DigestToolSpec(schemaVersion uint16, spec ToolSpec) (Digest, error) { + switch schemaVersion { + case SchemaVersion1: + return digestToolSpecV1(spec) + default: + return "", unsupportedSchemaVersion(schemaVersion) } - return sha256Digest(body), nil } // DigestToolSpecs covers an ordered ToolSpec list: ref, schema, order and // policy all participate (RUN-MCH-2). -func DigestToolSpecs(specs []ToolSpec) (Digest, error) { - body, err := encodeEnvelopeBody(currentSchemaVersion, "tool_specs", specs) - if err != nil { - return "", err +func DigestToolSpecs(schemaVersion uint16, specs []ToolSpec) (Digest, error) { + switch schemaVersion { + case SchemaVersion1: + return digestToolSpecsV1(specs) + default: + return "", unsupportedSchemaVersion(schemaVersion) } - return sha256Digest(body), nil } // DigestModelStepBinding combines model, request digest and tools digest into // the immutable ModelStep binding digest. -func DigestModelStepBinding(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") +func DigestModelStepBinding(schemaVersion uint16, model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) { + switch schemaVersion { + case SchemaVersion1: + return digestModelStepBindingV1(model, requestDigest, toolsDigest) + default: + return "", unsupportedSchemaVersion(schemaVersion) } - return sha256Digest([]byte(namespacedHash("twilight/model-step-binding", - string(model), string(requestDigest), string(toolsDigest)))), nil } // digestBindingSet covers the full ordered pre-Response call set of one diff --git a/agent/run/protocol_v1.go b/agent/run/protocol_v1.go new file mode 100644 index 0000000..51dcf1d --- /dev/null +++ b/agent/run/protocol_v1.go @@ -0,0 +1,99 @@ +package run + +import ( + "errors" + "fmt" +) + +// SchemaVersion1 digest, encode, and decode helpers. Replay of a v1 Run must +// call these even after currentSchemaVersion moves forward. + +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) { + 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 +} + +func encodeMachineStateV1(s *MachineState) ([]byte, error) { + return marshalCanonical(stateComparable(s)) +} + +func initialStateVersionV1() uint16 { return 1 } + +func validateHeaderV1(h *RunHeader) error { + if h.InitialStateVersion != initialStateVersionV1() { + return fmt.Errorf("agent: run header: unsupported initial state version %d for schema %d", h.InitialStateVersion, SchemaVersion1) + } + return nil +} + +func unsupportedSchemaVersion(schemaVersion uint16) error { + return fmt.Errorf("agent: unsupported schema version %d", schemaVersion) +} + +func requireSchemaVersion(schemaVersion uint16) error { + if !isSupportedSchemaVersion(schemaVersion) { + return unsupportedSchemaVersion(schemaVersion) + } + return nil +} + + + +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/runtime.go b/agent/run/runtime.go index f9cb339..d79319e 100644 --- a/agent/run/runtime.go +++ b/agent/run/runtime.go @@ -20,6 +20,9 @@ type RuntimeSnapshot struct { State MachineState // Revision counts accepted transitions; the initial state is 0. Revision uint64 + // SchemaVersion is the Run header protocol version. Loop and Application + // must stamp this version on every new command (RUN-CMT-7). + SchemaVersion uint16 } type CommitRequest struct { diff --git a/agent/run/runtime_helpers_test.go b/agent/run/runtime_helpers_test.go index 2cc13f0..8ca93b9 100644 --- a/agent/run/runtime_helpers_test.go +++ b/agent/run/runtime_helpers_test.go @@ -103,16 +103,16 @@ func buildPrepareFromSnap(t *testing.T, snap RuntimeSnapshot, req sdk.Request, s if err != nil { t.Fatal(err) } - reqDigest, err := DigestRequest(frozenReq) + reqDigest, err := DigestRequest(SchemaVersion1, frozenReq) if err != nil { t.Fatal(err) } - toolsDigest, err := DigestToolSpecs(specs) + toolsDigest, err := DigestToolSpecs(SchemaVersion1, specs) if err != nil { t.Fatal(err) } model := ModelRef(frozenReq.Model) - binding, err := DigestModelStepBinding(model, reqDigest, toolsDigest) + binding, err := DigestModelStepBinding(SchemaVersion1, model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } diff --git a/agent/run/runtimetest/conformance.go b/agent/run/runtimetest/conformance.go index 0a070d4..339ae18 100644 --- a/agent/run/runtimetest/conformance.go +++ b/agent/run/runtimetest/conformance.go @@ -474,16 +474,16 @@ func buildPrepareFromSnap(t testing.TB, snap *run.RuntimeSnapshot, req *sdk.Requ if err != nil { t.Fatal(err) } - reqDigest, err := run.DigestRequest(frozenReq) + reqDigest, err := run.DigestRequest(run.SchemaVersion1, frozenReq) if err != nil { t.Fatal(err) } - toolsDigest, err := run.DigestToolSpecs(specs) + toolsDigest, err := run.DigestToolSpecs(run.SchemaVersion1, specs) if err != nil { t.Fatal(err) } model := run.ModelRef(frozenReq.Model) - binding, err := run.DigestModelStepBinding(model, reqDigest, toolsDigest) + binding, err := run.DigestModelStepBinding(run.SchemaVersion1, model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -519,7 +519,7 @@ func makeSpec(t testing.TB, def sdk.ToolDefinition) run.ToolSpec { if err != nil { t.Fatal(err) } - d, err := run.DigestToolDefinition(frozen) + d, err := run.DigestToolDefinition(run.SchemaVersion1, frozen) if err != nil { t.Fatal(err) } diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index a6a25eb..6730fe5 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -277,6 +277,7 @@ type Runtime interface { type RuntimeSnapshot struct { State MachineState // detached in-process view Revision uint64 + SchemaVersion uint16 // RunHeader.SchemaVersion } type CommitRequest struct { BaseRevision uint64 @@ -318,6 +319,8 @@ type CommitResult struct { **RUN-CMT-6** Commit 必须原子保存新 MachineState 与完整 TransitionRecord,保证 event group 完整写入。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 +**RUN-CMT-7** 每个 Run 的协议版本是 `RunHeader.SchemaVersion`,在 Create 时冻结。`RuntimeSnapshot.SchemaVersion` 必须等于该 header。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Run 的 header 版本;不得使用进程全局 `currentSchemaVersion` 作为写入许可。digest、decode、`DecideVersion` 与 `EvolveVersion` 均按该 version 分发到 `ProtocolV1`(`digestRequestV1`、`decodeCommandVariantV1`、`evolveV1` 等)。v1 Run 的 replay 必须继续使用 v1 digest preimage,即使进程已经把 `currentSchemaVersion` 升到 2。`BuildEnvelopeVersion` 是 Loop 写入该 Run 时的构造入口。 + `MemoryRuntime` 提供 multi-Run in-process reference implementation:collection lock 保护 Run map,每个 Run 使用独立锁。跨进程 durable recovery 由其他 Runtime adapter 提供。 ## 6. Loop ports 与 policy From 47ac9b189be36d10d027f9a371211f7821ffdd48 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 1 Sep 2026 14:44:17 +0800 Subject: [PATCH 38/81] Make Next return Idle when nothing is executable WaitForExecutionRecovery is no longer an Effect. Model Executing and ToolStep with no Pending calls both yield Idle. Loop sets ExecutionRecovery from NeedsRecovery on the snapshot. Application reads WaitingCalls and ExecutingCalls from MachineState. --- agent/run/loop/loop.go | 9 ++++-- agent/run/next.go | 64 ++++++++++++++++++++++++++++----------- docs/design/agent-run.md | 11 +++---- docs/design/agent-turn.md | 2 +- 4 files changed, 59 insertions(+), 27 deletions(-) diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index 3869606..218ca10 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -342,9 +342,12 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev return LoopResult{}, err } case run.Idle: - return LoopResult{Disposition: LoopWaiting}, nil - case run.WaitForExecutionRecovery: - return LoopResult{Disposition: LoopWaiting, Reason: ExecutionRecovery, ExecutionRecovery: true}, nil + 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) } diff --git a/agent/run/next.go b/agent/run/next.go index a7d1aa4..bb1d3dd 100644 --- a/agent/run/next.go +++ b/agent/run/next.go @@ -25,16 +25,12 @@ type StartToolCalls struct { func (StartToolCalls) effect() {} // Idle means the Run is still active and Next has no executable effect. -// The usual case is a ToolStep whose remaining live calls are Waiting. -// Application reads those calls from MachineState; Loop does not interpret them. +// Application inspects MachineState with WaitingCalls, ExecutingCalls, and +// NeedsRecovery. Loop does not interpret those queries. type Idle struct{} func (Idle) effect() {} -type WaitForExecutionRecovery struct{} - -func (WaitForExecutionRecovery) 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 { @@ -55,6 +51,45 @@ func WaitingCalls(s MachineState) []ResponseRequest { 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. type PlanningHint struct { RunID RunID @@ -87,28 +122,23 @@ func Next(s MachineState) (Effect, error) { if cur.Status == ModelPrepared { return StartModelCall{StepID: cur.RefValue.ID}, nil } - return WaitForExecutionRecovery{}, nil + return Idle{}, nil case ToolStep: var pending []CallID - waiting := false - executing := false + live := false for _, c := range cur.Calls { switch c.Status { case ToolPending: pending = append(pending, c.CallID) - case ToolWaiting: - waiting = true - case ToolExecuting: - executing = true + live = true + case ToolWaiting, ToolExecuting: + live = true } } if len(pending) > 0 { return StartToolCalls{StepID: cur.RefValue.ID, CallIDs: pending}, nil } - if executing { - return WaitForExecutionRecovery{}, nil - } - if waiting { + if live { return Idle{}, nil } return nil, rejectionf("next: tool step %q has no live calls but was not closed", cur.RefValue.ID) diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 6730fe5..cdeda06 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -256,14 +256,13 @@ Loop 在提交 start 前生成并保留 `Claim`、`CommandID` 与 command digest | terminal | 无 | | Current=nil | `NeedModelRequest{PlanningHint}` | | Model Prepared | `StartModelCall` | -| Model Executing | `WaitForExecutionRecovery` | +| Model Executing | `Idle` | | ToolStep 有 Pending calls | `StartToolCalls` | -| ToolStep 无 Pending、含 Executing | `WaitForExecutionRecovery` | -| ToolStep 无 Pending、无 Executing、含 Waiting | `Idle` | +| ToolStep 无 Pending、仍有 Waiting 或 Executing | `Idle` | -Waiting call 上的 `ResponseRequest` 由 `WaitingCalls(state)` 从 MachineState 读取,不进入 Effect。 +Waiting call 上的 `ResponseRequest` 由 `WaitingCalls(state)` 读取。Executing call 由 `ExecutingCalls(state)` 读取。`NeedsRecovery(state)` 在 Model Executing 或 ToolStep 无 Pending 且仍有 Executing 时为 true。这些查询不是 Effect。 -**RUN-MCH-4** Effect 由调用方每次 Load 后重新派生。`PrepareModelRequest.InputIDs` 必须与当前 PendingInputs 等长、同顺序、逐项相同;prepare 接受后一次消费全部 pending input。ToolStep 的 Waiting call 禁止 Start,同一 step 中的 Pending call 仍可执行。无 Pending 且仍有 Executing 时返回 `WaitForExecutionRecovery`。无 Pending、无 Executing、仍有 Waiting 时返回 `Idle`:Run 仍为 active,解释器没有可执行 effect。Application 从 snapshot 读取 `WaitingCalls` 并提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse`。执行恢复由 Runtime/application 的 recovery authority 提供。 +**RUN-MCH-4** Effect 由调用方每次 Load 后重新派生。`PrepareModelRequest.InputIDs` 必须与当前 PendingInputs 等长、同顺序、逐项相同;prepare 接受后一次消费全部 pending input。ToolStep 的 Waiting call 禁止 Start,同一 step 中的 Pending call 仍可执行。没有可执行 Start 时 `Next` 返回 `Idle`。Application 从 snapshot 读取 `WaitingCalls` 并提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse`。执行恢复由 Runtime/application 的 recovery authority 根据 `NeedsRecovery` 提供。 ## 5. Runtime 与 Commit @@ -406,7 +405,7 @@ Loop.Run(ctx, runtime, runID, sink): **RUN-LOP-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须保留 start command 的 ID、digest 和 claim,直到完成 settlement;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。streaming 与 non-streaming 必须产生同一种完整 `sdk.ModelResult`;delta 只发 EventSink。provider failure 提交 `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`;只有 Accepted owner 可执行。`parallel` 模式并发执行同一 ToolStep 中所有可执行的 Pending call,`sequential` 模式按 call 顺序逐个执行;每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call 在本次 `Run` 内 Start 并结算。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `WaitingCalls`,提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 之后再次 `Run`。`Next` 返回 `WaitForExecutionRecovery` 时 Loop 返回 `LoopWaiting` 且 `ExecutionRecovery` 为 true。tool panic 或 effect 状态无法确定的错误转为 Unknown;一个 Unknown 取消同批 sibling workers,并由 Machine 在同一 terminal transition 中记录目标及仍 Executing sibling 的 `ToolCallFailed(Unknown)`,最后追加 `RunEnded(failed/effect_unknown)`。`CancelRun` 也在 `RunEnded(stopped/cancelled)` 前记录所有仍 Executing call 的 Unknown。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 +**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`;只有 Accepted owner 可执行。`parallel` 模式并发执行同一 ToolStep 中所有可执行的 Pending call,`sequential` 模式按 call 顺序逐个执行;每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call 在本次 `Run` 内 Start 并结算。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `WaitingCalls`,提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 之后再次 `Run`。tool panic 或 effect 状态无法确定的错误转为 Unknown;一个 Unknown 取消同批 sibling workers,并由 Machine 在同一 terminal transition 中记录目标及仍 Executing sibling 的 `ToolCallFailed(Unknown)`,最后追加 `RunEnded(failed/effect_unknown)`。`CancelRun` 也在 `RunEnded(stopped/cancelled)` 前记录所有仍 Executing call 的 Unknown。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 **RUN-LOP-5** model 与 tool worker 都接收外层 ctx;Loop 对已接受 effect 使用独立 control context 完成 known/unknown outcome settlement。Application 的业务停止顺序为先 Commit `CancelRun`,再取消 Loop ctx。非 sentinel Commit error 以同 CommandID/digest 重放一次;仍未知时返回错误,由后续 Load/Record 查询 authority。stale/terminal/conflict 触发 reload/drop,旧 external effect 保持单次执行尝试。工具实现配合 context 返回;永久阻塞由 application/durable recovery 处理。 diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index d25f7a9..2cce9e6 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -203,7 +203,7 @@ driver.Drive(ctx, DriveRequest{Ref: ref, RunID: linkage.RunID}) 无论 driver 返回完成、没有可执行 effect、error,Coordinator 均再次 Record、调用 `MaterializeAll(record)` 补齐完整新前缀,并据 record snapshot/terminal fact 返回 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`。前两者不返回 terminal Result。snapshot 含 Waiting call 时 `Waiting` 列为 `WaitingCalls`;`ExecutionRecovery` 仅在 snapshot 仍有 Executing call 时为真。Loop 返回 `LoopWaiting` 且 `ExecutionRecovery` 为 true 时 Disposition 为 `ResumeWaitingForRecovery`,即使 snapshot 同时含 Waiting call。仅有 Waiting、无 Executing 时 Disposition 为 `ResumeWaitingForResponse`。空 `Waiting` 且需要 recovery 时由 recovery authority 负责后续唤醒。 -**TRN-RSM-6** driver 的 provisional stream、返回错误、网络响应丢失、context cancel 作为观察结果处理。driver 以 shared Runtime 的 Load/Commit 推进 MachineState;并发 Resume 依 Runtime command idempotency、revision、grant、record 一致性收敛。具体执行器可以使用 `agent/run/loop.Loop`。Loop 只解释可执行 effect(prepare、start model、start tools)以及 execution recovery;`Idle` 映射为 `LoopWaiting` 且不携带 response request。Coordinator 始终以随后读取的 `RunRecord` 判断 Waiting call、execution recovery、terminal 与 settlement。Application 对 Runtime 提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 后再 Resume。 +**TRN-RSM-6** driver 的 provisional stream、返回错误、网络响应丢失、context cancel 作为观察结果处理。driver 以 shared Runtime 的 Load/Commit 推进 MachineState;并发 Resume 依 Runtime command idempotency、revision、grant、record 一致性收敛。具体执行器可以使用 `agent/run/loop.Loop`。Loop 只解释可执行 effect(prepare、start model、start tools)。`Idle` 映射为 `LoopWaiting`;`ExecutionRecovery` 来自 `NeedsRecovery(snapshot)`,不携带 response request。Coordinator 始终以随后读取的 `RunRecord` 判断 Waiting call、execution recovery、terminal 与 settlement。Application 对 Runtime 提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 后再 Resume。 **TRN-STP-1** Stop 先解析 linkage、Load。terminal Run 进入 record/materialize/settlement;active Run 以稳定 domain-separated CancelRun CommandID 构造 `CancelRun{Reason:ReasonCancelled}`,并经 shared Runtime Commit。 From 57e7bf5e94704ce11a0c4a04e942fc9522421b28 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 1 Sep 2026 15:17:44 +0800 Subject: [PATCH 39/81] Select run protocol once at the header boundary ProtocolFor(Header.SchemaVersion) returns the implementation for digest, decode, Decide, Evolve, and BuildEnvelope. Loop and EvaluateCommit call those methods without a version argument. v1 replay uses digestRequestV1 directly instead of re-dispatching through DigestRequest(schemaVersion, ...). --- agent/run/alias_test.go | 6 +- agent/run/canonical_test.go | 27 +++-- agent/run/codec.go | 44 +++----- agent/run/codec_test.go | 8 +- agent/run/commit.go | 46 ++------ agent/run/creation.go | 2 +- agent/run/decide.go | 48 ++++----- agent/run/evolve.go | 31 ++---- agent/run/fold.go | 17 +-- agent/run/fold_test.go | 5 - agent/run/header.go | 28 ++--- agent/run/loop/helpers_test.go | 2 +- agent/run/loop/loop.go | 53 ++++++--- agent/run/loop/loop_test.go | 2 +- agent/run/loop/regression_test.go | 2 +- agent/run/machine_test.go | 18 ++-- agent/run/memory_runtime.go | 6 +- agent/run/protocol.go | 156 +++++++++++++-------------- agent/run/protocol_test.go | 36 +++++++ agent/run/protocol_v1.go | 113 +++++++++++++++++-- agent/run/runtime.go | 8 +- agent/run/runtime_helpers_test.go | 6 +- agent/run/runtimetest/conformance.go | 8 +- agent/run/transition.go | 6 +- docs/design/agent-run.md | 15 +-- 25 files changed, 395 insertions(+), 298 deletions(-) create mode 100644 agent/run/protocol_test.go diff --git a/agent/run/alias_test.go b/agent/run/alias_test.go index ba2e14f..aa58c87 100644 --- a/agent/run/alias_test.go +++ b/agent/run/alias_test.go @@ -103,16 +103,16 @@ func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { "p": cj(`{"b":2,"a":1}`), }, } - reqDigest, err := DigestRequest(SchemaVersion1, req) + reqDigest, err := DigestRequest(req) if err != nil { t.Fatal(err) } - toolsDigest, err := DigestToolSpecs(SchemaVersion1, nil) + toolsDigest, err := DigestToolSpecs(nil) if err != nil { t.Fatal(err) } model := ModelRef(req.Model) - binding, err := DigestModelStepBinding(SchemaVersion1, model, reqDigest, toolsDigest) + binding, err := DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } diff --git a/agent/run/canonical_test.go b/agent/run/canonical_test.go index dea2298..f973c7b 100644 --- a/agent/run/canonical_test.go +++ b/agent/run/canonical_test.go @@ -96,7 +96,7 @@ func TestCanonicalDeterminism(t *testing.T) { func TestDigestCommandIdentity(t *testing.T) { cmd := StartToolCall{StepID: "s1", CallID: "c1", Claim: "claim-1"} - d1, err := DigestCommand(SchemaVersion1, "start_tool_call", cmd) + d1, err := DigestCommand("start_tool_call", cmd) if err != nil { t.Fatal(err) } @@ -104,22 +104,29 @@ func TestDigestCommandIdentity(t *testing.T) { t.Fatalf("bad digest wire form: %s", d1) } // Same content, same digest. - d2, _ := DigestCommand(SchemaVersion1, "start_tool_call", StartToolCall{StepID: "s1", CallID: "c1", Claim: "claim-1"}) + d2, _ := DigestCommand("start_tool_call", StartToolCall{StepID: "s1", CallID: "c1", Claim: "claim-1"}) if d1 != d2 { t.Fatal("same command produced different digests") } // Different content differs. - d3, _ := DigestCommand(SchemaVersion1, "start_tool_call", StartToolCall{StepID: "s1", CallID: "c2", Claim: "claim-1"}) + d3, _ := DigestCommand("start_tool_call", StartToolCall{StepID: "s1", CallID: "c2", Claim: "claim-1"}) if d1 == d3 { t.Fatal("different commands produced the same digest") } - // Schema version participates. - d4, _ := DigestCommand(2, "start_tool_call", cmd) - if d1 == d4 { - t.Fatal("schema version did not affect digest") + // Schema version participates in the digest preimage. + 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") } // Type mismatch is rejected. - if _, err := DigestCommand(SchemaVersion1, "cancel_run", cmd); err == nil { + if _, err := DigestCommand("cancel_run", cmd); err == nil { t.Fatal("expected type/variant mismatch error") } } @@ -190,7 +197,7 @@ func TestSchemaVersion1Golden(t *testing.T) { if string(body) != wantBody { t.Fatalf("golden body changed:\n got %q\nwant %q", body, wantBody) } - d, err := DigestCommand(SchemaVersion1, "cancel_run", cmd) + d, err := DigestCommand("cancel_run", cmd) if err != nil { t.Fatal(err) } @@ -202,7 +209,7 @@ func TestSchemaVersion1Golden(t *testing.T) { } fact := InputAccepted{Input: AgentInput{ID: "in-1", Payload: cj(`{"text":"hi"}`)}} - fbody, err := EncodeFact(SchemaVersion1, "input_accepted", fact) + fbody, err := EncodeFact("input_accepted", fact) if err != nil { t.Fatal(err) } diff --git a/agent/run/codec.go b/agent/run/codec.go index 2d822f5..fbdf3d4 100644 --- a/agent/run/codec.go +++ b/agent/run/codec.go @@ -120,14 +120,15 @@ func (e *CommandEnvelope) UnmarshalJSON(raw []byte) error { if err := decodeStrictJSON(raw, &wire); err != nil { return err } - if !isSupportedSchemaVersion(wire.SchemaVersion) { - return fmt.Errorf("agent: codec: unsupported schema version %d", wire.SchemaVersion) + proto, err := ProtocolFor(wire.SchemaVersion) + if err != nil { + return err } - cmd, err := decodeCommandVariant(wire.SchemaVersion, wire.Type, wire.Command) + cmd, err := proto.DecodeCommand(wire.Type, wire.Command) if err != nil { return err } - want, err := DigestCommand(wire.SchemaVersion, wire.Type, cmd) + want, err := proto.DigestCommand(wire.Type, cmd) if err != nil { return err } @@ -212,14 +213,15 @@ func (e *AgentEvent) UnmarshalJSON(raw []byte) error { if err := decodeStrictJSON(raw, &wire); err != nil { return err } - if !isSupportedSchemaVersion(wire.SchemaVersion) { - return fmt.Errorf("agent: codec: unsupported schema version %d", wire.SchemaVersion) + proto, err := ProtocolFor(wire.SchemaVersion) + if err != nil { + return err } - fact, err := decodeFactVariant(wire.SchemaVersion, wire.Type, wire.Fact) + fact, err := proto.DecodeFact(wire.Type, wire.Fact) if err != nil { return err } - want, err := DigestFact(wire.SchemaVersion, wire.Type, fact) + want, err := proto.DigestFact(wire.Type, fact) if err != nil { return err } @@ -294,28 +296,19 @@ func decodeStrictJSON(raw []byte, dst any) error { return nil } -func decodeCommandAs[T AgentCommand](raw json.RawMessage) (AgentCommand, error) { +func decodeCommandAs[T AgentCommand](raw []byte) (AgentCommand, error) { var c T err := decodeStrictJSON(raw, &c) return c, err } -func decodeFactAs[T Fact](raw json.RawMessage) (Fact, error) { +func decodeFactAs[T Fact](raw []byte) (Fact, error) { var f T err := decodeStrictJSON(raw, &f) return f, err } -func decodeCommandVariant(schemaVersion uint16, typ string, raw json.RawMessage) (AgentCommand, error) { - switch schemaVersion { - case SchemaVersion1: - return decodeCommandVariantV1(typ, raw) - default: - return nil, unsupportedSchemaVersion(schemaVersion) - } -} - -func decodeCommandVariantV1(typ string, raw json.RawMessage) (AgentCommand, error) { +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) } @@ -353,16 +346,7 @@ func decodeCommandVariantV1(typ string, raw json.RawMessage) (AgentCommand, erro } } -func decodeFactVariant(schemaVersion uint16, typ string, raw json.RawMessage) (Fact, error) { - switch schemaVersion { - case SchemaVersion1: - return decodeFactVariantV1(typ, raw) - default: - return nil, unsupportedSchemaVersion(schemaVersion) - } -} - -func decodeFactVariantV1(typ string, raw json.RawMessage) (Fact, error) { +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) } diff --git a/agent/run/codec_test.go b/agent/run/codec_test.go index 1967bc8..187944d 100644 --- a/agent/run/codec_test.go +++ b/agent/run/codec_test.go @@ -65,7 +65,7 @@ func TestAgentEventJSONRoundTripRestoresVariants(t *testing.T) { } for i, fact := range facts { typ := factType(fact) - digest, err := DigestFact(currentSchemaVersion, typ, fact) + digest, err := DigestFact(typ, fact) if err != nil { t.Fatalf("DigestFact(%T): %v", fact, err) } @@ -105,7 +105,7 @@ func TestTransitionRecordJSONRoundTripRestoresVariants(t *testing.T) { events := make([]AgentEvent, len(facts)) for i, fact := range facts { typ := factType(fact) - digest, err := DigestFact(currentSchemaVersion, typ, fact) + digest, err := DigestFact(typ, fact) if err != nil { t.Fatal(err) } @@ -189,7 +189,7 @@ func TestWireCodecRejectsUnknownTypeAndDigestMismatch(t *testing.T) { } fact := RunEnded{End: RunCompletedEnd{}} - digest, err := DigestFact(currentSchemaVersion, factType(fact), fact) + digest, err := DigestFact(factType(fact), fact) if err != nil { t.Fatal(err) } @@ -212,7 +212,7 @@ func TestRunEndedTaggedUnionRejectsInvalidValues(t *testing.T) { "unknown end variant": {End: fakeRunEnd{}}, } { t.Run(name, func(t *testing.T) { - if _, err := DigestFact(currentSchemaVersion, "run_ended", fact); err == nil { + if _, err := DigestFact("run_ended", fact); err == nil { t.Fatal("invalid tagged terminal value was accepted") } }) diff --git a/agent/run/commit.go b/agent/run/commit.go index e4db365..95b8dc9 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -103,7 +103,7 @@ func EvaluateCommit( req CommitRequest, grantValid bool, recoveryValid bool, - runSchemaVersion uint16, + proto Protocol, ) (CommitDecision, error) { env := req.Command @@ -114,13 +114,13 @@ func EvaluateCommit( 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 := requireSchemaVersion(runSchemaVersion); err != nil { - return CommitDecision{}, err + if proto == nil { + return CommitDecision{}, fmt.Errorf("agent: commit: nil protocol") } - if env.SchemaVersion != runSchemaVersion { - return CommitDecision{}, fmt.Errorf("agent: commit: command schema %d does not match run schema %d", env.SchemaVersion, runSchemaVersion) + if env.SchemaVersion != proto.Version() { + return CommitDecision{}, fmt.Errorf("agent: commit: command schema %d does not match run schema %d", env.SchemaVersion, proto.Version()) } - wantDigest, err := DigestCommand(env.SchemaVersion, env.Type, env.Command) + wantDigest, err := proto.DigestCommand(env.Type, env.Command) if err != nil { return CommitDecision{}, err } @@ -178,7 +178,7 @@ func EvaluateCommit( } // Step 5: Decide once, fold with Evolve. - facts, err := DecideVersion(env.SchemaVersion, cur, env.Command) + facts, err := proto.Decide(cur, env.Command) if err != nil { switch { case errors.Is(err, ErrRunTerminal): @@ -219,17 +219,17 @@ func EvaluateCommit( if err != nil { return CommitDecision{}, err } - state, err = EvolveVersion(env.SchemaVersion, state, f) + state, err = proto.Evolve(state, f) if err != nil { return CommitDecision{}, err } typ := factType(f) - fd, err := DigestFact(env.SchemaVersion, typ, f) + fd, err := proto.DigestFact(typ, f) if err != nil { return CommitDecision{}, err } events[i] = AgentEvent{ - SchemaVersion: env.SchemaVersion, + SchemaVersion: proto.Version(), Type: typ, RunID: cur.RunID, Revision: newRevision, @@ -251,31 +251,7 @@ func EvaluateCommit( // canonical digest. This is the only sanctioned construction path; callers // never hand-assemble envelope fields (RUN-WIR-3). func BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) { - return BuildEnvelopeVersion(currentSchemaVersion, run, id, cmd) -} - -// BuildEnvelopeVersion stamps schemaVersion onto the envelope. Runtime.Commit -// accepts it only when it matches the Run header's SchemaVersion. -func BuildEnvelopeVersion(schemaVersion uint16, run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) { - if err := requireSchemaVersion(schemaVersion); err != nil { - return CommandEnvelope{}, err - } - typ := commandType(cmd) - if typ == "" { - return CommandEnvelope{}, fmt.Errorf("agent: envelope: unknown command variant %T", cmd) - } - d, err := DigestCommand(schemaVersion, typ, cmd) - if err != nil { - return CommandEnvelope{}, err - } - return CommandEnvelope{ - SchemaVersion: schemaVersion, - Type: typ, - RunID: run, - ID: id, - Digest: d, - Command: cmd, - }, nil + return ProtocolV1.BuildEnvelope(run, id, cmd) } // checkDerivedCommandID enforces the derived-identity rules of RUN-WIR-3. diff --git a/agent/run/creation.go b/agent/run/creation.go index 645521d..06bf103 100644 --- a/agent/run/creation.go +++ b/agent/run/creation.go @@ -107,7 +107,7 @@ var ( ) func cloneRuntimeSnapshot(snapshot RuntimeSnapshot) RuntimeSnapshot { - return RuntimeSnapshot{State: cloneMachineState(&snapshot.State), Revision: snapshot.Revision} + return RuntimeSnapshot{State: cloneMachineState(&snapshot.State), Revision: snapshot.Revision, SchemaVersion: snapshot.SchemaVersion} } func cloneRunHeader(header RunHeader) RunHeader { diff --git a/agent/run/decide.go b/agent/run/decide.go index f8bf6f4..04f1a36 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -20,29 +20,23 @@ func rejectionf(format string, args ...any) error { return fmt.Errorf("agent: reject: "+format, args...) } -// Decide validates one command against the current state and produces the -// complete fact sequence of its transition (RUN-MCH-3). All decisions — -// acceptance, derived consequences, terminal transitions — happen here, run -// exactly once per accepted command; the output is frozen. Any precondition -// failure rejects the whole command with no partial facts. +// Decide validates one command against the current write schema and produces +// the complete fact sequence of its transition (RUN-MCH-3). Replay of a +// persisted Run must use ProtocolFor(header.SchemaVersion).Decide. // //nolint:gocritic // hugeParam: public protocol boundary is intentionally value-based: Decide(state, command) -> facts. func Decide(s MachineState, c AgentCommand) ([]Fact, error) { - return DecideVersion(currentSchemaVersion, s, c) + return ProtocolV1.Decide(s, c) } -// DecideVersion validates a command using schemaVersion's digest and shape -// rules. Runtime.Commit must pass the Run header's SchemaVersion. -func DecideVersion(schemaVersion uint16, s MachineState, c AgentCommand) ([]Fact, error) { - if err := requireSchemaVersion(schemaVersion); err != nil { - return nil, err - } +//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(schemaVersion, &s, &cmd) + return decidePrepareModelRequest(&s, &cmd) case StartModelExecution: return decideStartModelExecution(&s, cmd) case RecoverModelExecution: @@ -60,11 +54,11 @@ func DecideVersion(schemaVersion uint16, s MachineState, c AgentCommand) ([]Fact case SubmitToolFailure: return decideSubmitToolFailure(&s, cmd) case ApproveToolCall: - return decideApproveToolCall(schemaVersion, &s, cmd) + return decideApproveToolCall(&s, cmd) case RejectToolCall: - return decideRejectToolCall(schemaVersion, &s, &cmd) + return decideRejectToolCall(&s, &cmd) case SubmitToolResponse: - return decideSubmitToolResponse(schemaVersion, &s, &cmd) + return decideSubmitToolResponse(&s, &cmd) case CancelRun: return decideCancelRun(&s, cmd) case AcceptInput: @@ -76,7 +70,7 @@ func DecideVersion(schemaVersion uint16, s MachineState, c AgentCommand) ([]Fact // --- rule 1: PrepareModelRequest --- -func decidePrepareModelRequest(schemaVersion uint16, s *MachineState, cmd *PrepareModelRequest) ([]Fact, error) { +func decidePrepareModelRequest(s *MachineState, cmd *PrepareModelRequest) ([]Fact, error) { if s.Current != nil { return nil, rejectionf("prepare: run already has a current step") } @@ -107,7 +101,7 @@ func decidePrepareModelRequest(schemaVersion uint16, s *MachineState, cmd *Prepa if spec.Definition.Name != cmd.Request.Tools[i].Name { return nil, rejectionf("prepare: ToolSpec[%d] %q does not match request tool %q", i, spec.Definition.Name, cmd.Request.Tools[i].Name) } - wantDigest, err := DigestToolDefinition(schemaVersion, cmd.Request.Tools[i]) + wantDigest, err := digestToolDefinitionV1(cmd.Request.Tools[i]) if err != nil { return nil, err } @@ -115,21 +109,21 @@ func decidePrepareModelRequest(schemaVersion uint16, s *MachineState, cmd *Prepa return nil, rejectionf("prepare: ToolSpec[%d] definition digest mismatch", i) } } - wantReq, err := DigestRequest(schemaVersion, cmd.Request) + wantReq, err := digestRequestV1(cmd.Request) if err != nil { return nil, err } if cmd.RequestDigest != wantReq { return nil, rejectionf("prepare: request digest mismatch") } - wantTools, err := DigestToolSpecs(schemaVersion, cmd.Tools) + wantTools, err := digestToolSpecsV1(cmd.Tools) if err != nil { return nil, err } if cmd.ToolsDigest != wantTools { return nil, rejectionf("prepare: tools digest mismatch") } - binding, err := DigestModelStepBinding(schemaVersion, cmd.Model, cmd.RequestDigest, cmd.ToolsDigest) + binding, err := digestModelStepBindingV1(cmd.Model, cmd.RequestDigest, cmd.ToolsDigest) if err != nil { return nil, err } @@ -472,11 +466,11 @@ func waitingCall(s *MachineState, step StepID, call CallID, kind ResponseKind, r return ts, i, nil } -func decideApproveToolCall(schemaVersion uint16, s *MachineState, cmd ApproveToolCall) ([]Fact, error) { +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 := DigestToolResponseDecision(schemaVersion, ResponseApproval, ResponseDecisionApproved, "") + wantDigest, err := digestToolResponseDecisionV1(ResponseApproval, ResponseDecisionApproved, "") if err != nil { return nil, err } @@ -486,7 +480,7 @@ func decideApproveToolCall(schemaVersion uint16, s *MachineState, cmd ApproveToo return []Fact{ToolCallApproved(cmd)}, nil } -func decideRejectToolCall(schemaVersion uint16, s *MachineState, cmd *RejectToolCall) ([]Fact, error) { +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. Spec §4.2 lists Waiting -> Failed(Known) @@ -507,7 +501,7 @@ func decideRejectToolCall(schemaVersion uint16, s *MachineState, cmd *RejectTool 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 := DigestToolResponseDecision(schemaVersion, c.Waiting.Kind, ResponseDecisionRejected, cmd.Reason) + wantDigest, err := digestToolResponseDecisionV1(c.Waiting.Kind, ResponseDecisionRejected, cmd.Reason) if err != nil { return nil, err } @@ -523,12 +517,12 @@ func decideRejectToolCall(schemaVersion uint16, s *MachineState, cmd *RejectTool return facts, nil } -func decideSubmitToolResponse(schemaVersion uint16, s *MachineState, cmd *SubmitToolResponse) ([]Fact, error) { +func decideSubmitToolResponse(s *MachineState, cmd *SubmitToolResponse) ([]Fact, error) { _, _, err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseExternal, cmd.ResponseID) if err != nil { return nil, err } - wantDigest, err := DigestToolResponsePayload(schemaVersion, cmd.Payload) + wantDigest, err := digestToolResponsePayloadV1(cmd.Payload) if err != nil { return nil, err } diff --git a/agent/run/evolve.go b/agent/run/evolve.go index 8a6bfc3..3856502 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -5,26 +5,13 @@ import ( "fmt" ) -// Evolve folds one fact with the current schema version. Persisted replay uses -// EvolveVersion so future schema versions can keep their historical folding -// semantics while this convenience API remains value-based. +// Evolve folds one fact with the current write schema. Persisted replay must +// use ProtocolFor(header.SchemaVersion).Evolve so historical folding semantics +// stay bound to the Run that produced the fact. // //nolint:gocritic // hugeParam: public fold boundary must stay value-based: Evolve(state, fact) -> new state. func Evolve(s MachineState, f Fact) (MachineState, error) { - return EvolveVersion(currentSchemaVersion, s, f) -} - -// EvolveVersion folds one persisted fact using the Evolve semantics for its -// SchemaVersion. It is mechanical: no IO, no policy decisions, and no Decide. -// -//nolint:gocritic // hugeParam: public replay boundary must stay value-based: EvolveVersion(version, state, fact) -> new state. -func EvolveVersion(schemaVersion uint16, s MachineState, f Fact) (MachineState, error) { - switch schemaVersion { - case SchemaVersion1: - return evolveV1(s, f) - default: - return s, fmt.Errorf("agent: evolve: unsupported schema version %d", schemaVersion) - } + return ProtocolV1.Evolve(s, f) } // evolveV1 is the current fold semantics for the pre-release SchemaVersion1. @@ -216,15 +203,15 @@ func validateFactTransition(s MachineState, f Fact) error { if ModelRef(fact.Request.Model) != fact.Model { return errors.New("agent: evolve: model step prepared model mismatch") } - requestDigest, err := DigestRequest(SchemaVersion1, fact.Request) + requestDigest, err := digestRequestV1(fact.Request) if err != nil || requestDigest != fact.RequestDigest { return errors.New("agent: evolve: model step prepared request digest mismatch") } - toolsDigest, err := DigestToolSpecs(SchemaVersion1, fact.Tools) + toolsDigest, err := digestToolSpecsV1(fact.Tools) if err != nil || toolsDigest != fact.ToolsDigest { return errors.New("agent: evolve: model step prepared tools digest mismatch") } - binding, err := DigestModelStepBinding(SchemaVersion1, fact.Model, fact.RequestDigest, fact.ToolsDigest) + binding, err := digestModelStepBindingV1(fact.Model, fact.RequestDigest, fact.ToolsDigest) if err != nil || binding != fact.BindingDigest { return errors.New("agent: evolve: model step prepared binding digest mismatch") } @@ -312,7 +299,7 @@ func validateFactTransition(s MachineState, f Fact) error { if call.Waiting.ID != fact.ResponseID { return fmt.Errorf("agent: evolve: tool call %q response ID mismatch", fact.CallID) } - want, err := DigestToolResponseDecision(SchemaVersion1, ResponseApproval, ResponseDecisionApproved, "") + want, err := digestToolResponseDecisionV1(ResponseApproval, ResponseDecisionApproved, "") if err != nil || want != fact.ResponseDigest { return fmt.Errorf("agent: evolve: tool call %q approval digest mismatch", fact.CallID) } @@ -329,7 +316,7 @@ func validateFactTransition(s MachineState, f Fact) error { if call.Waiting.ID != fact.ResponseID { return fmt.Errorf("agent: evolve: tool call %q response ID mismatch", fact.CallID) } - want, err := DigestToolResponsePayload(SchemaVersion1, fact.Payload) + want, err := digestToolResponsePayloadV1(fact.Payload) if err != nil || want != fact.ResponseDigest { return fmt.Errorf("agent: evolve: tool call %q response digest mismatch", fact.CallID) } diff --git a/agent/run/fold.go b/agent/run/fold.go index 53b1a62..9fa427f 100644 --- a/agent/run/fold.go +++ b/agent/run/fold.go @@ -14,7 +14,7 @@ import ( var ErrLogTruncated = errors.New("agent: transition log ends below the expected revision") // FoldEvents rebuilds a MachineState by folding a complete flat event stream -// from the initial (Revision 0) state with EvolveVersion only: no Decide, no +// from the initial (Revision 0) state with Protocol.Evolve only: no Decide, no // external effects, no command replay (RUN-NEW-2). It verifies (Revision, // Index) ordering and per-fact digests as it goes. Authority runtimes should // prefer FoldTransitions because only TransitionRecord can prove the last @@ -32,8 +32,9 @@ func FoldEvents(initial MachineState, events []AgentEvent) (MachineState, uint64 if e.RunID != initial.RunID { return initial, 0, fmt.Errorf("agent: fold: event run %q does not match initial run %q", e.RunID, initial.RunID) } - if !isSupportedSchemaVersion(e.SchemaVersion) { - return initial, 0, fmt.Errorf("agent: fold: unsupported schema version %d", e.SchemaVersion) + proto, err := ProtocolFor(e.SchemaVersion) + if err != nil { + return initial, 0, err } typ := factType(e.Fact) if typ == "" || e.Type != typ { @@ -61,7 +62,7 @@ func FoldEvents(initial MachineState, events []AgentEvent) (MachineState, uint64 } index = e.Index } - wantDigest, err := DigestFact(e.SchemaVersion, e.Type, e.Fact) + wantDigest, err := proto.DigestFact(e.Type, e.Fact) if err != nil { return initial, 0, err } @@ -72,7 +73,7 @@ func FoldEvents(initial MachineState, events []AgentEvent) (MachineState, uint64 if err != nil { return initial, 0, err } - state, err = EvolveVersion(e.SchemaVersion, state, fact) + state, err = proto.Evolve(state, fact) if err != nil { return initial, 0, err } @@ -104,11 +105,15 @@ func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineS supportsRunSchema, inspectTransitionEvent, func(schemaVersion uint16, state MachineState, event AgentEvent) (MachineState, error) { + proto, err := ProtocolFor(schemaVersion) + if err != nil { + return MachineState{}, err + } fact, err := snapshotFact(event.Fact) if err != nil { return MachineState{}, err } - return EvolveVersion(schemaVersion, state, fact) + return proto.Evolve(state, fact) }, ) if err != nil { diff --git a/agent/run/fold_test.go b/agent/run/fold_test.go index 958348e..0a92114 100644 --- a/agent/run/fold_test.go +++ b/agent/run/fold_test.go @@ -225,11 +225,6 @@ func TestFoldRejectsUnsupportedSchemaVersion(t *testing.T) { entry.mu.Unlock() log[0].SchemaVersion = 99 - digest, err := DigestFact(log[0].SchemaVersion, log[0].Type, log[0].Fact) - if err != nil { - t.Fatal(err) - } - log[0].Digest = digest if _, _, err := FoldEvents(initial, log); err == nil { t.Fatal("unsupported schema version folded silently") } diff --git a/agent/run/header.go b/agent/run/header.go index 4218b2b..fe91787 100644 --- a/agent/run/header.go +++ b/agent/run/header.go @@ -2,7 +2,6 @@ package run import ( "errors" - "fmt" "github.com/memohai/twilight/agent/es" ) @@ -13,24 +12,20 @@ import ( // each can evolve only through its documented versioning rules (RUN-CMP-1). const snapshotSchemaVersion uint16 = 1 -// encodeMachineStateVersion renders the canonical bytes of a MachineState for -// header digests under schemaVersion. func encodeMachineStateVersion(schemaVersion uint16, s *MachineState) ([]byte, error) { - switch schemaVersion { - case SchemaVersion1: - return encodeMachineStateV1(s) - default: - return nil, unsupportedSchemaVersion(schemaVersion) + proto, err := ProtocolFor(schemaVersion) + if err != nil { + return nil, err } + return proto.EncodeMachineState(s) } func validateHeaderInitialStateVersion(h *RunHeader) error { - switch h.SchemaVersion { - case SchemaVersion1: - return validateHeaderV1(h) - default: - return unsupportedSchemaVersion(h.SchemaVersion) + proto, err := ProtocolFor(h.SchemaVersion) + if err != nil { + return err } + return proto.ValidateHeader(h) } // RunHeader is the formal persisted Revision-0 protocol record (RUN-NEW-1). @@ -68,13 +63,13 @@ func BuildRunHeader(runID RunID, causationID es.CausationID) (RunHeader, error) if err != nil { return RunHeader{}, err } - stateBytes, err := encodeMachineStateVersion(currentSchemaVersion, &initial) + stateBytes, err := ProtocolV1.EncodeMachineState(&initial) if err != nil { return RunHeader{}, err } stateDigest := sha256Digest(stateBytes) header := RunHeader{ - SchemaVersion: currentSchemaVersion, + SchemaVersion: ProtocolV1.Version(), RunID: runID, InitialStateVersion: snapshotSchemaVersion, InitialState: initial, @@ -111,9 +106,6 @@ func ValidateRunHeader(h *RunHeader) error { if h.RunID == "" { return errors.New("agent: run header: empty RunID") } - if !isSupportedSchemaVersion(h.SchemaVersion) { - return fmt.Errorf("agent: run header: unsupported schema version %d", h.SchemaVersion) - } if err := validateHeaderInitialStateVersion(h); err != nil { return err } diff --git a/agent/run/loop/helpers_test.go b/agent/run/loop/helpers_test.go index df4751b..0d4568c 100644 --- a/agent/run/loop/helpers_test.go +++ b/agent/run/loop/helpers_test.go @@ -65,7 +65,7 @@ func snapshotWaiting(t *testing.T, rt Runtime, runID RunID) []ResponseRequest { func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDecision, reason string) Digest { t.Helper() - digest, err := DigestToolResponseDecision(SchemaVersion1, kind, decision, reason) + digest, err := DigestToolResponseDecision(kind, decision, reason) if err != nil { t.Fatal(err) } diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index 218ca10..f34c958 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -188,6 +188,10 @@ func (l *Loop) resumeSettlement(ctx context.Context, runtime run.Runtime, events } } l.settlementsMu.Unlock() + proto, err := snapshot.Protocol() + if err != nil { + return false, err + } for _, key := range keys { attempt, ok := l.lookupSettlement(key) if !ok { @@ -198,7 +202,7 @@ func (l *Loop) resumeSettlement(ctx context.Context, runtime run.Runtime, events l.forgetStart(key) continue } - res, err := l.commit(context.WithoutCancel(ctx), runtime, runID, attempt.commandID, attempt.base, attempt.grant, attempt.command, snapshot.SchemaVersion) + res, err := l.commit(context.WithoutCancel(ctx), runtime, runID, attempt.commandID, attempt.base, attempt.grant, attempt.command, proto) if err != nil { if retriable(err) { l.forgetSettlement(key) @@ -359,8 +363,11 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev // digest (RUN-LOP-5): if the first attempt actually // committed and only the response was lost, the replay returns AlreadyApplied // instead of abandoning a live grant or re-executing an expensive step. -func (l *Loop) commit(ctx context.Context, runtime run.Runtime, runID run.RunID, id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand, schemaVersion uint16) (run.CommitResult, error) { - env, err := run.BuildEnvelopeVersion(schemaVersion, runID, id, cmd) +func (l *Loop) commit(ctx context.Context, runtime run.Runtime, runID run.RunID, id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand, proto run.Protocol) (run.CommitResult, error) { + if proto == nil { + return run.CommitResult{}, errors.New("agent: loop: nil protocol") + } + env, err := proto.BuildEnvelope(runID, id, cmd) if err != nil { return run.CommitResult{}, err } @@ -429,15 +436,19 @@ func (l *Loop) planAndPrepare(ctx context.Context, runtime run.Runtime, events E if run.ModelRef(frozenRequest.Model) != model { return fmt.Errorf("agent: loop: request model %q does not match plan model %q", frozenRequest.Model, model) } - requestDigest, err := run.DigestRequest(snapshot.SchemaVersion, frozenRequest) + proto, err := snapshot.Protocol() + if err != nil { + return err + } + requestDigest, err := proto.DigestRequest(frozenRequest) if err != nil { return err } - toolsDigest, err := run.DigestToolSpecs(snapshot.SchemaVersion, plan.Tools) + toolsDigest, err := proto.DigestToolSpecs(plan.Tools) if err != nil { return err } - binding, err := run.DigestModelStepBinding(snapshot.SchemaVersion, model, requestDigest, toolsDigest) + binding, err := proto.DigestModelStepBinding(model, requestDigest, toolsDigest) if err != nil { return err } @@ -452,7 +463,7 @@ func (l *Loop) planAndPrepare(ctx context.Context, runtime run.Runtime, events E PlanningToken: plan.PlanningToken, Tools: plan.Tools, ToolsDigest: toolsDigest, - }, snapshot.SchemaVersion) + }, proto) if err == nil { // ModelStepPrepared carries the frozen request — the most informative // fact of the run; observers must see it like every other accepted @@ -480,9 +491,13 @@ func (l *Loop) planAndPrepare(ctx context.Context, runtime run.Runtime, events E func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, stepID run.StepID) error { runID := snapshot.State.RunID + proto, err := snapshot.Protocol() + if err != nil { + return err + } key := startKey{runID: runID, stepID: stepID} attempt := l.startFor(key) - start, err := l.commit(ctx, runtime, runID, attempt.commandID, snapshot.Revision, "", run.StartModelExecution{StepID: stepID, Claim: attempt.claim}, snapshot.SchemaVersion) + start, err := l.commit(ctx, runtime, runID, attempt.commandID, snapshot.Revision, "", run.StartModelExecution{StepID: stepID, Claim: attempt.claim}, proto) if err != nil { if retriable(err) { l.forgetStart(key) @@ -554,7 +569,7 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve } settlement := l.settlementFor(key, completionID, start.Snapshot.Revision, start.Grant, completion) settlementCtx := context.WithoutCancel(ctx) - res, err := l.commit(settlementCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command, snapshot.SchemaVersion) + res, err := l.commit(settlementCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command, proto) if err != nil { if retriable(err) { l.forgetSettlement(key) @@ -706,6 +721,10 @@ func toolCallIndex(step run.ToolStep, callID run.CallID) int { func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, 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) @@ -758,7 +777,7 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: freezeErr.Error()} break } - defDigest, digestErr := run.DigestToolDefinition(snapshot.SchemaVersion, toolDef) + defDigest, digestErr := proto.DigestToolDefinition(toolDef) if digestErr != nil { known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: digestErr.Error()} break @@ -777,9 +796,9 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve if known != nil { // Known failure of a Pending call: no start barrier, no tool call. res, err := l.commit(ctx, runtime, runID, freshCommandID(), snapshot.Revision, "", - run.SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: *known, Outcome: run.ToolOutcomeKnown}, snapshot.SchemaVersion) + 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, snapshot.SchemaVersion) + settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, proto) if !retriable(err) { return err } @@ -791,9 +810,9 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve attempt := l.startFor(key) start, err := l.commit(ctx, runtime, runID, attempt.commandID, snapshot.Revision, "", - run.StartToolCall{StepID: eff.StepID, CallID: callID, Claim: attempt.claim}, snapshot.SchemaVersion) + run.StartToolCall{StepID: eff.StepID, CallID: callID, Claim: attempt.claim}, proto) if err != nil { - settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, snapshot.SchemaVersion) + settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, proto) if retriable(err) { // A sentinel rejection proves this start did not acquire the // call. Drop the local claim so a later snapshot can create a @@ -826,7 +845,7 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Revision, tool: tool, key: key}) } - return l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, snapshot.SchemaVersion) + 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) { @@ -848,7 +867,7 @@ func toolCallFromSnapshot(state run.MachineState, stepID run.StepID, callID run. // resulting outcome can still reach Runtime (RUN-LOP-5). One Unknown cancels // sibling workers. 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 run.Runtime, events EventSink, runID run.RunID, stepID run.StepID, started []startedWorker, schemaVersion uint16) error { +func (l *Loop) settleWorkers(ctx context.Context, runtime run.Runtime, events EventSink, runID run.RunID, stepID run.StepID, started []startedWorker, proto run.Protocol) error { if len(started) == 0 { return nil } @@ -910,7 +929,7 @@ func (l *Loop) settleWorkers(ctx context.Context, runtime run.Runtime, events Ev // ErrRunTerminal and are dropped (audit is the adapter's job). // The one-shot same-CommandID replay lives inside l.commit. settlement := l.settlementFor(w.key, freshCommandID(), w.base, w.grant, cmd) - res, err := l.commit(controlCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command, schemaVersion) + res, err := l.commit(controlCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command, proto) switch { case err == nil: l.forgetSettlement(w.key) diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go index 6ab66ee..244ccf4 100644 --- a/agent/run/loop/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -122,7 +122,7 @@ func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { if err != nil { t.Fatal(err) } - d, err := DigestToolDefinition(SchemaVersion1, frozen) + d, err := DigestToolDefinition(frozen) if err != nil { t.Fatal(err) } diff --git a/agent/run/loop/regression_test.go b/agent/run/loop/regression_test.go index 6fc05ae..c60554f 100644 --- a/agent/run/loop/regression_test.go +++ b/agent/run/loop/regression_test.go @@ -61,7 +61,7 @@ func TestRegressionAliasedToolRefExecutes(t *testing.T) { if err != nil { t.Fatal(err) } - d, err := DigestToolDefinition(SchemaVersion1, frozenDef) + d, err := DigestToolDefinition(frozenDef) if err != nil { t.Fatal(err) } diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go index abfefd1..c0d912f 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -62,16 +62,16 @@ func buildPrepare(t *testing.T, s MachineState, req sdk.Request, specs []ToolSpe if err != nil { t.Fatal(err) } - reqDigest, err := DigestRequest(SchemaVersion1, frozenReq) + reqDigest, err := DigestRequest(frozenReq) if err != nil { t.Fatal(err) } - toolsDigest, err := DigestToolSpecs(SchemaVersion1, specs) + toolsDigest, err := DigestToolSpecs(specs) if err != nil { t.Fatal(err) } model := ModelRef(frozenReq.Model) - binding, err := DigestModelStepBinding(SchemaVersion1, model, reqDigest, toolsDigest) + binding, err := DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -98,7 +98,7 @@ func makeSpec(t *testing.T, def sdk.ToolDefinition, policy ResponsePolicy) ToolS if err != nil { t.Fatal(err) } - d, err := DigestToolDefinition(SchemaVersion1, frozen) + d, err := DigestToolDefinition(frozen) if err != nil { t.Fatal(err) } @@ -107,7 +107,7 @@ func makeSpec(t *testing.T, def sdk.ToolDefinition, policy ResponsePolicy) ToolS func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDecision, reason string) Digest { t.Helper() - d, err := DigestToolResponseDecision(SchemaVersion1, kind, decision, reason) + d, err := DigestToolResponseDecision(kind, decision, reason) if err != nil { t.Fatal(err) } @@ -116,7 +116,7 @@ func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDe func responsePayloadDigest(t *testing.T, payload CanonicalJSON) Digest { t.Helper() - d, err := DigestToolResponsePayload(SchemaVersion1, payload) + d, err := DigestToolResponsePayload(payload) if err != nil { t.Fatal(err) } @@ -595,15 +595,15 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { } prepared := func(ids ...InputID) ModelStepPrepared { request := ModelRequest{Model: string(testModel)} - requestDigest, err := DigestRequest(SchemaVersion1, request) + requestDigest, err := DigestRequest(request) if err != nil { t.Fatal(err) } - toolsDigest, err := DigestToolSpecs(SchemaVersion1, nil) + toolsDigest, err := DigestToolSpecs(nil) if err != nil { t.Fatal(err) } - binding, err := DigestModelStepBinding(SchemaVersion1, testModel, requestDigest, toolsDigest) + binding, err := DigestModelStepBinding(testModel, requestDigest, toolsDigest) if err != nil { t.Fatal(err) } diff --git a/agent/run/memory_runtime.go b/agent/run/memory_runtime.go index 812206b..055dc97 100644 --- a/agent/run/memory_runtime.go +++ b/agent/run/memory_runtime.go @@ -230,7 +230,11 @@ func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitRe copy := cloneTransitionRecord(&record) prior = © } - decision, err := EvaluateCommit(entry.state, entry.revision, prior, req, grantValid, recoveryValid, entry.header.SchemaVersion) + proto, err := ProtocolFor(entry.header.SchemaVersion) + if err != nil { + return CommitResult{}, err + } + decision, err := EvaluateCommit(entry.state, entry.revision, prior, req, grantValid, recoveryValid, proto) if err != nil { return CommitResult{}, err } diff --git a/agent/run/protocol.go b/agent/run/protocol.go index 9caa3a6..14fbac9 100644 --- a/agent/run/protocol.go +++ b/agent/run/protocol.go @@ -46,6 +46,51 @@ func encodeEnvelopeBody(schemaVersion uint16, typ string, body any) ([]byte, err return es.EncodeTypedPayload(schemaVersion, typ, body) } +// Protocol is the closed set of digest, decode, Decide, Evolve, and envelope +// operations for one SchemaVersion. ProtocolFor selects it once from the Run +// header; subsequent calls do not take a version argument (RUN-CMT-7). +type Protocol interface { + Version() uint16 + + DigestRequest(req ModelRequest) (Digest, error) + DigestToolDefinition(def ToolDefinition) (Digest, error) + DigestToolSpec(spec ToolSpec) (Digest, error) + DigestToolSpecs(specs []ToolSpec) (Digest, error) + DigestModelStepBinding(model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) + DigestToolResponseDecision(kind ResponseKind, decision ResponseDecision, reason string) (Digest, error) + DigestToolResponsePayload(payload CanonicalJSON) (Digest, error) + DigestCommand(typ string, command AgentCommand) (Digest, error) + DigestFact(typ string, fact Fact) (Digest, error) + EncodeFact(typ string, fact Fact) ([]byte, error) + + DecodeCommand(typ string, raw []byte) (AgentCommand, error) + DecodeFact(typ string, raw []byte) (Fact, error) + + Decide(s MachineState, c AgentCommand) ([]Fact, error) + Evolve(s MachineState, f Fact) (MachineState, error) + BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) + + EncodeMachineState(s *MachineState) ([]byte, error) + ValidateHeader(h *RunHeader) error +} + +// ProtocolV1 is the SchemaVersion1 implementation. New Runs write with this +// protocol; replay of a persisted v1 Run must keep using it after later +// versions exist. +var ProtocolV1 Protocol = protocolV1{} + +// ProtocolFor selects the protocol implementation 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 nil, unsupportedSchemaVersion(schemaVersion) + } +} + // EncodeCommand renders the canonical bytes of a command envelope, excluding // the Digest field. // @@ -62,33 +107,18 @@ func EncodeCommand(env CommandEnvelope) ([]byte, error) { return append([]byte(header), body...), nil } -// DigestCommand computes the canonical digest of one command. -func DigestCommand(schemaVersion uint16, typ string, command AgentCommand) (Digest, error) { - if typ == "" || typ != commandType(command) { - return "", fmt.Errorf("agent: digest: type %q does not match command variant", typ) - } - body, err := encodeEnvelopeBody(schemaVersion, typ, command) - if err != nil { - return "", err - } - return sha256Digest(body), nil +// DigestCommand computes the canonical digest of one command under the current +// write schema. Replay of a persisted Run must use ProtocolFor on the header. +func DigestCommand(typ string, command AgentCommand) (Digest, error) { + return ProtocolV1.DigestCommand(typ, command) } -// EncodeFact renders the canonical bytes of one fact. -func EncodeFact(schemaVersion uint16, typ string, fact Fact) ([]byte, error) { - if typ == "" || typ != factType(fact) { - return nil, fmt.Errorf("agent: encode: type %q does not match fact variant", typ) - } - return encodeEnvelopeBody(schemaVersion, typ, fact) +func EncodeFact(typ string, fact Fact) ([]byte, error) { + return ProtocolV1.EncodeFact(typ, fact) } -// DigestFact computes the canonical digest of one fact. -func DigestFact(schemaVersion uint16, typ string, fact Fact) (Digest, error) { - body, err := EncodeFact(schemaVersion, typ, fact) - if err != nil { - return "", err - } - return sha256Digest(body), nil +func DigestFact(typ string, fact Fact) (Digest, error) { + return ProtocolV1.DigestFact(typ, fact) } type toolResponseDecisionDigestBody struct { @@ -98,15 +128,10 @@ type toolResponseDecisionDigestBody struct { } // DigestToolResponseDecision computes the content digest for approval and -// rejection ingress under schemaVersion. The ResponseID remains the +// rejection ingress under the current write schema. The ResponseID remains the // routing/idempotency key; this digest binds the decision payload. -func DigestToolResponseDecision(schemaVersion uint16, kind ResponseKind, decision ResponseDecision, reason string) (Digest, error) { - switch schemaVersion { - case SchemaVersion1: - return digestToolResponseDecisionV1(kind, decision, reason) - default: - return "", unsupportedSchemaVersion(schemaVersion) - } +func DigestToolResponseDecision(kind ResponseKind, decision ResponseDecision, reason string) (Digest, error) { + return ProtocolV1.DigestToolResponseDecision(kind, decision, reason) } type toolResponsePayloadDigestBody struct { @@ -114,71 +139,42 @@ type toolResponsePayloadDigestBody struct { } // DigestToolResponsePayload computes the content digest for an ExternalResponse -// answer payload under schemaVersion. -func DigestToolResponsePayload(schemaVersion uint16, payload CanonicalJSON) (Digest, error) { - switch schemaVersion { - case SchemaVersion1: - return digestToolResponsePayloadV1(payload) - default: - return "", unsupportedSchemaVersion(schemaVersion) - } +// answer payload under the current write schema. +func DigestToolResponsePayload(payload CanonicalJSON) (Digest, error) { + return ProtocolV1.DigestToolResponsePayload(payload) } // DigestRequest covers every field of a frozen ModelRequest with no exclusions -// (RUN-WIR-2). schemaVersion selects the digest preimage. +// (RUN-WIR-2), using the current write schema. // //nolint:gocritic // hugeParam: digest covers the complete immutable ModelRequest value. -func DigestRequest(schemaVersion uint16, req ModelRequest) (Digest, error) { - switch schemaVersion { - case SchemaVersion1: - return digestRequestV1(req) - default: - return "", unsupportedSchemaVersion(schemaVersion) - } +func DigestRequest(req ModelRequest) (Digest, error) { + return ProtocolV1.DigestRequest(req) } -// DigestToolDefinition covers one provider-neutral tool definition. -func DigestToolDefinition(schemaVersion uint16, def ToolDefinition) (Digest, error) { - switch schemaVersion { - case SchemaVersion1: - return digestToolDefinitionV1(def) - default: - return "", unsupportedSchemaVersion(schemaVersion) - } +// DigestToolDefinition covers one provider-neutral tool definition under the +// current write schema. +func DigestToolDefinition(def ToolDefinition) (Digest, error) { + return ProtocolV1.DigestToolDefinition(def) } -// DigestToolSpec covers one agent ToolSpec (ref, definition, digest, policy). +// DigestToolSpec covers one agent ToolSpec under the current write schema. // //nolint:gocritic // hugeParam: digest covers the complete immutable ToolSpec value. -func DigestToolSpec(schemaVersion uint16, spec ToolSpec) (Digest, error) { - switch schemaVersion { - case SchemaVersion1: - return digestToolSpecV1(spec) - default: - return "", unsupportedSchemaVersion(schemaVersion) - } +func DigestToolSpec(spec ToolSpec) (Digest, error) { + return ProtocolV1.DigestToolSpec(spec) } -// DigestToolSpecs covers an ordered ToolSpec list: ref, schema, order and -// policy all participate (RUN-MCH-2). -func DigestToolSpecs(schemaVersion uint16, specs []ToolSpec) (Digest, error) { - switch schemaVersion { - case SchemaVersion1: - return digestToolSpecsV1(specs) - default: - return "", unsupportedSchemaVersion(schemaVersion) - } +// DigestToolSpecs covers an ordered ToolSpec list under the current write +// schema: ref, schema, order and policy all participate (RUN-MCH-2). +func DigestToolSpecs(specs []ToolSpec) (Digest, error) { + return ProtocolV1.DigestToolSpecs(specs) } // DigestModelStepBinding combines model, request digest and tools digest into -// the immutable ModelStep binding digest. -func DigestModelStepBinding(schemaVersion uint16, model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) { - switch schemaVersion { - case SchemaVersion1: - return digestModelStepBindingV1(model, requestDigest, toolsDigest) - default: - return "", unsupportedSchemaVersion(schemaVersion) - } +// the immutable ModelStep binding digest under the current write schema. +func DigestModelStepBinding(model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) { + return ProtocolV1.DigestModelStepBinding(model, requestDigest, toolsDigest) } // digestBindingSet covers the full ordered pre-Response call set of one diff --git a/agent/run/protocol_test.go b/agent/run/protocol_test.go new file mode 100644 index 0000000..b403807 --- /dev/null +++ b/agent/run/protocol_test.go @@ -0,0 +1,36 @@ +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 != ProtocolV1 { + t.Fatal("ProtocolFor(1) did not return 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") + } +} diff --git a/agent/run/protocol_v1.go b/agent/run/protocol_v1.go index 51dcf1d..5ceba36 100644 --- a/agent/run/protocol_v1.go +++ b/agent/run/protocol_v1.go @@ -5,8 +5,108 @@ import ( "fmt" ) -// SchemaVersion1 digest, encode, and decode helpers. Replay of a v1 Run must -// call these even after currentSchemaVersion moves forward. +// protocolV1 is the SchemaVersion1 implementation. Replay of a v1 Run must +// call these methods even after currentSchemaVersion moves forward. +type protocolV1 struct{} + +func (protocolV1) Version() uint16 { return SchemaVersion1 } + +func (protocolV1) DigestRequest(req ModelRequest) (Digest, error) { + return digestRequestV1(req) +} + +func (protocolV1) DigestToolDefinition(def ToolDefinition) (Digest, error) { + return digestToolDefinitionV1(def) +} + +func (protocolV1) DigestToolSpec(spec ToolSpec) (Digest, error) { + return digestToolSpecV1(spec) +} + +func (protocolV1) DigestToolSpecs(specs []ToolSpec) (Digest, error) { + return digestToolSpecsV1(specs) +} + +func (protocolV1) DigestModelStepBinding(model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) { + return digestModelStepBindingV1(model, requestDigest, toolsDigest) +} + +func (protocolV1) DigestToolResponseDecision(kind ResponseKind, decision ResponseDecision, reason string) (Digest, error) { + return digestToolResponseDecisionV1(kind, decision, reason) +} + +func (protocolV1) DigestToolResponsePayload(payload CanonicalJSON) (Digest, error) { + return digestToolResponsePayloadV1(payload) +} + +func (protocolV1) DigestCommand(typ string, command AgentCommand) (Digest, error) { + if typ == "" || typ != commandType(command) { + return "", fmt.Errorf("agent: digest: type %q does not match command variant", typ) + } + body, err := encodeEnvelopeBody(SchemaVersion1, typ, command) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +func (protocolV1) EncodeFact(typ string, fact Fact) ([]byte, error) { + if typ == "" || typ != factType(fact) { + return nil, fmt.Errorf("agent: encode: type %q does not match fact variant", typ) + } + return encodeEnvelopeBody(SchemaVersion1, typ, fact) +} + +func (p protocolV1) DigestFact(typ string, fact Fact) (Digest, error) { + body, err := p.EncodeFact(typ, fact) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +func (protocolV1) DecodeCommand(typ string, raw []byte) (AgentCommand, error) { + return decodeCommandVariantV1(typ, raw) +} + +func (protocolV1) DecodeFact(typ string, raw []byte) (Fact, error) { + return decodeFactVariantV1(typ, raw) +} + +func (protocolV1) Decide(s MachineState, c AgentCommand) ([]Fact, error) { + return decideV1(s, c) +} + +func (protocolV1) Evolve(s MachineState, f Fact) (MachineState, error) { + return evolveV1(s, f) +} + +func (p protocolV1) BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) { + typ := commandType(cmd) + if typ == "" { + return CommandEnvelope{}, fmt.Errorf("agent: envelope: unknown command variant %T", cmd) + } + d, err := p.DigestCommand(typ, cmd) + if err != nil { + return CommandEnvelope{}, err + } + return CommandEnvelope{ + SchemaVersion: SchemaVersion1, + Type: typ, + RunID: run, + ID: id, + Digest: d, + Command: cmd, + }, nil +} + +func (protocolV1) EncodeMachineState(s *MachineState) ([]byte, error) { + return encodeMachineStateV1(s) +} + +func (protocolV1) ValidateHeader(h *RunHeader) error { + return validateHeaderV1(h) +} func digestRequestV1(req ModelRequest) (Digest, error) { body, err := encodeEnvelopeBody(SchemaVersion1, "model_request", req) @@ -81,15 +181,6 @@ func unsupportedSchemaVersion(schemaVersion uint16) error { return fmt.Errorf("agent: unsupported schema version %d", schemaVersion) } -func requireSchemaVersion(schemaVersion uint16) error { - if !isSupportedSchemaVersion(schemaVersion) { - return unsupportedSchemaVersion(schemaVersion) - } - return nil -} - - - 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") diff --git a/agent/run/runtime.go b/agent/run/runtime.go index d79319e..f3be384 100644 --- a/agent/run/runtime.go +++ b/agent/run/runtime.go @@ -21,10 +21,16 @@ type RuntimeSnapshot struct { // Revision counts accepted transitions; the initial state is 0. Revision uint64 // SchemaVersion is the Run header protocol version. Loop and Application - // must stamp this version on every new command (RUN-CMT-7). + // select ProtocolFor(SchemaVersion) once; they must not stamp a process-global + // currentSchemaVersion (RUN-CMT-7). SchemaVersion uint16 } +// Protocol returns the protocol implementation frozen on this Run's header. +func (s RuntimeSnapshot) Protocol() (Protocol, error) { + return ProtocolFor(s.SchemaVersion) +} + type CommitRequest struct { BaseRevision uint64 Grant ExecutionGrant diff --git a/agent/run/runtime_helpers_test.go b/agent/run/runtime_helpers_test.go index 8ca93b9..2cc13f0 100644 --- a/agent/run/runtime_helpers_test.go +++ b/agent/run/runtime_helpers_test.go @@ -103,16 +103,16 @@ func buildPrepareFromSnap(t *testing.T, snap RuntimeSnapshot, req sdk.Request, s if err != nil { t.Fatal(err) } - reqDigest, err := DigestRequest(SchemaVersion1, frozenReq) + reqDigest, err := DigestRequest(frozenReq) if err != nil { t.Fatal(err) } - toolsDigest, err := DigestToolSpecs(SchemaVersion1, specs) + toolsDigest, err := DigestToolSpecs(specs) if err != nil { t.Fatal(err) } model := ModelRef(frozenReq.Model) - binding, err := DigestModelStepBinding(SchemaVersion1, model, reqDigest, toolsDigest) + binding, err := DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } diff --git a/agent/run/runtimetest/conformance.go b/agent/run/runtimetest/conformance.go index 339ae18..0a070d4 100644 --- a/agent/run/runtimetest/conformance.go +++ b/agent/run/runtimetest/conformance.go @@ -474,16 +474,16 @@ func buildPrepareFromSnap(t testing.TB, snap *run.RuntimeSnapshot, req *sdk.Requ if err != nil { t.Fatal(err) } - reqDigest, err := run.DigestRequest(run.SchemaVersion1, frozenReq) + reqDigest, err := run.DigestRequest(frozenReq) if err != nil { t.Fatal(err) } - toolsDigest, err := run.DigestToolSpecs(run.SchemaVersion1, specs) + toolsDigest, err := run.DigestToolSpecs(specs) if err != nil { t.Fatal(err) } model := run.ModelRef(frozenReq.Model) - binding, err := run.DigestModelStepBinding(run.SchemaVersion1, model, reqDigest, toolsDigest) + binding, err := run.DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -519,7 +519,7 @@ func makeSpec(t testing.TB, def sdk.ToolDefinition) run.ToolSpec { if err != nil { t.Fatal(err) } - d, err := run.DigestToolDefinition(run.SchemaVersion1, frozen) + d, err := run.DigestToolDefinition(frozen) if err != nil { t.Fatal(err) } diff --git a/agent/run/transition.go b/agent/run/transition.go index ec214b3..fc704b9 100644 --- a/agent/run/transition.go +++ b/agent/run/transition.go @@ -56,7 +56,11 @@ func inspectTransitionEvent(event AgentEvent) (es.EventMetadata, error) { if typ == "" || event.Type != typ { return es.EventMetadata{}, fmt.Errorf("event type %q does not match fact variant %T", event.Type, event.Fact) } - wantDigest, err := DigestFact(event.SchemaVersion, event.Type, event.Fact) + proto, err := ProtocolFor(event.SchemaVersion) + if err != nil { + return es.EventMetadata{}, err + } + wantDigest, err := proto.DigestFact(event.Type, event.Fact) if err != nil { return es.EventMetadata{}, err } diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index cdeda06..6cda868 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -13,7 +13,7 @@ Runtime Run authority 与 atomic Commit boundary loop.Loop 当前进程的 execution interpreter ``` -`MachineState` 决定 Run 当前可执行动作。每次接受的 command 产生一个完整 `TransitionRecord`,其 facts 经 `EvolveVersion` 从 `RunHeader.InitialState` 重放后必须得到同 revision 的 `MachineState`。 +`MachineState` 决定 Run 当前可执行动作。每次接受的 command 产生一个完整 `TransitionRecord`,其 facts 经该 Run 的 `Protocol.Evolve` 从 `RunHeader.InitialState` 重放后必须得到同 revision 的 `MachineState`。 Run 的职责分成五个相互独立的层面: @@ -56,7 +56,7 @@ type Digest = es.Digest Run 持久化协议保存 run-owned frozen values。模型请求、模型结果、消息、工具定义、usage、provider metadata 与所有动态 JSON 在进入 command/fact 前,分别经 `FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`、`FreezeToolCallInput` 等入口转为纯数据和 immutable `CanonicalJSON`。Runtime 接收 agent-owned value;调用方负责在边界前完成冻结。 -**RUN-WIR-2** 当前 pre-release schema v1 使用 RFC 8785/JCS canonical bytes。字段、omission、array order、type discriminator、digest preimage 与 `EvolveVersion` 语义在发布前继续演进,发布后永久冻结。command、fact 和 transition decoder 必须拒绝 unknown version/type、duplicate key、unknown field、trailing data、非法 UTF-8、非 canonical-equivalent wire 与 digest mismatch。精确 identity 和 digest 使用 JSON string;当前 v1 revision/index/schema fields 使用整数 wire shape。 +**RUN-WIR-2** 当前 pre-release schema v1 使用 RFC 8785/JCS canonical bytes。字段、omission、array order、type discriminator、digest preimage 与 `Protocol.Evolve` 语义在发布前继续演进,发布后永久冻结。command、fact 和 transition decoder 必须拒绝 unknown version/type、duplicate key、unknown field、trailing data、非法 UTF-8、非 canonical-equivalent wire 与 digest mismatch。精确 identity 和 digest 使用 JSON string;当前 v1 revision/index/schema fields 使用整数 wire shape。 ```go type CommandEnvelope struct { @@ -136,7 +136,7 @@ type RunRecord struct { Header 创建后 immutable。`InitialStateDigest` 覆盖 frozen initial state;`HeaderDigest` 覆盖 schema、RunID、initial-state version/digest 与 causation。`ValidateRunHeader` 验证这些约束。 -**RUN-NEW-2** `FoldRun(header, transitions)` 先验证 header,再按 revision/index 使用 `EvolveVersion` 折叠完整 transition sequence。Fold 过程执行纯状态重建。import、diagnostic 与 `Runtime.Record` integrity verification 从 header 开始;外部 snapshot 通过 FoldRun 结果校验。 +**RUN-NEW-2** `FoldRun(header, transitions)` 先验证 header,再按 revision/index 使用 `ProtocolFor(header.SchemaVersion).Evolve` 折叠完整 transition sequence。Fold 过程执行纯状态重建。import、diagnostic 与 `Runtime.Record` integrity verification 从 header 开始;外部 snapshot 通过 FoldRun 结果校验。 ## 4. Machine @@ -227,7 +227,7 @@ ToolCall: `ToolStepClosed` 作为旧 v1 transition 的兼容 fact 保留;新 command 在最终 ToolCall fact 中完成 ToolStep 的关闭。 -**RUN-MCH-3** `Decide(state, command)` 执行全部验证与 derived consequence,一次返回该 transition 的完整 ordered fact group;验证成功后返回完整 facts。`EvolveVersion(version,state,fact)` 机械折叠 fact,依赖 fact 携带的完整数据。accepted facts 必须 self-contained;若 transition terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 +**RUN-MCH-3** `Decide(state, command)` 执行全部验证与 derived consequence,一次返回该 transition 的完整 ordered fact group;验证成功后返回完整 facts。`Protocol.Evolve(state, fact)` 机械折叠 fact,依赖 fact 携带的完整数据。accepted facts 必须 self-contained;若 transition terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 启动 command 的最小公共形状为: @@ -278,6 +278,7 @@ type RuntimeSnapshot struct { Revision uint64 SchemaVersion uint16 // RunHeader.SchemaVersion } +func (RuntimeSnapshot) Protocol() (Protocol, error) // ProtocolFor(SchemaVersion) type CommitRequest struct { BaseRevision uint64 Grant ExecutionGrant @@ -305,7 +306,7 @@ type CommitResult struct { 5 terminal check 6 validate hard CAS / target state / execution grant / recovery authority 7 facts = Decide(current, command) exactly once -8 snapshot each fact; EvolveVersion in order; assign next Revision and Index +8 snapshot each fact; Protocol.Evolve in order; assign next Revision and Index 9 build and validate one complete TransitionRecord 10 atomically persist transition and new MachineState ``` @@ -318,7 +319,7 @@ type CommitResult struct { **RUN-CMT-6** Commit 必须原子保存新 MachineState 与完整 TransitionRecord,保证 event group 完整写入。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 -**RUN-CMT-7** 每个 Run 的协议版本是 `RunHeader.SchemaVersion`,在 Create 时冻结。`RuntimeSnapshot.SchemaVersion` 必须等于该 header。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Run 的 header 版本;不得使用进程全局 `currentSchemaVersion` 作为写入许可。digest、decode、`DecideVersion` 与 `EvolveVersion` 均按该 version 分发到 `ProtocolV1`(`digestRequestV1`、`decodeCommandVariantV1`、`evolveV1` 等)。v1 Run 的 replay 必须继续使用 v1 digest preimage,即使进程已经把 `currentSchemaVersion` 升到 2。`BuildEnvelopeVersion` 是 Loop 写入该 Run 时的构造入口。 +**RUN-CMT-7** 每个 Run 的协议版本是 `RunHeader.SchemaVersion`,在 Create 时冻结。`RuntimeSnapshot.SchemaVersion` 必须等于该 header。`ProtocolFor(header.SchemaVersion)` 在 Run 边界选出该版本的 `Protocol`;digest、decode、Decide、Evolve、BuildEnvelope 是该对象上的方法,不再接受 version 参数。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Protocol 的 Version。不得使用进程全局 `currentSchemaVersion` 作为写入许可。v1 Run 的 replay 必须继续使用 `ProtocolV1`,即使进程已经把 `currentSchemaVersion` 升到 2。Loop 通过 `RuntimeSnapshot.Protocol()` 构造写入该 Run 的 envelope。 `MemoryRuntime` 提供 multi-Run in-process reference implementation:collection lock 保护 Run map,每个 Run 使用独立锁。跨进程 durable recovery 由其他 Runtime adapter 提供。 @@ -435,7 +436,7 @@ Queue 只能在 `Current=nil` 的 safe boundary 提交 `AcceptInput`;Applicati ## 9. compatibility 与 conformance -**RUN-CMP-1** 当前 pre-release schema v1 的 command/fact discriminator、wire fields、canonical digest、derived ID 和 `EvolveVersion(1)` 由 golden fixtures 保护;发布前有意修改协议时必须同步更新 fixture。v1 发布后,新增 variant、字段或折叠语义必须进入新 schema version,并继续 decode/fold 全部已发布版本。同一 Run 的 writer 不得混写不兼容 schema。 +**RUN-CMP-1** 当前 pre-release schema v1 的 command/fact discriminator、wire fields、canonical digest、derived ID 和 `ProtocolV1.Evolve` 由 golden fixtures 保护;发布前有意修改协议时必须同步更新 fixture。v1 发布后,新增 variant、字段或折叠语义必须进入新 schema version,并继续 decode/fold 全部已发布版本。同一 Run 的 writer 不得混写不兼容 schema。 **RUN-CMP-2** Runtime conformance 必须覆盖: From 73e9a07475c774c1ae926431ef0bc46c8545eb87 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 1 Sep 2026 15:37:20 +0800 Subject: [PATCH 40/81] Bind run protocol functions on a Protocol value Protocol is a struct whose fields are the digest, decode, Decide, and Evolve functions for one schema version. ProtocolFor fills them once from the header. There is no protocolV1 type; v2 adds a ProtocolFor branch and the functions that actually differ. --- agent/run/commit.go | 4 +- agent/run/loop/loop.go | 4 +- agent/run/protocol.go | 232 +++++++++++++++++++++++++++++++------ agent/run/protocol_test.go | 14 ++- agent/run/protocol_v1.go | 104 +---------------- docs/design/agent-run.md | 2 +- 6 files changed, 214 insertions(+), 146 deletions(-) diff --git a/agent/run/commit.go b/agent/run/commit.go index 95b8dc9..8c9f68c 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -114,8 +114,8 @@ func EvaluateCommit( 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 proto == nil { - return CommitDecision{}, fmt.Errorf("agent: commit: nil protocol") + if err := proto.ready(); err != nil { + return CommitDecision{}, err } if env.SchemaVersion != proto.Version() { return CommitDecision{}, fmt.Errorf("agent: commit: command schema %d does not match run schema %d", env.SchemaVersion, proto.Version()) diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index f34c958..4bcd4b7 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -364,8 +364,8 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev // committed and only the response was lost, the replay returns AlreadyApplied // instead of abandoning a live grant or re-executing an expensive step. func (l *Loop) commit(ctx context.Context, runtime run.Runtime, runID run.RunID, id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand, proto run.Protocol) (run.CommitResult, error) { - if proto == nil { - return run.CommitResult{}, errors.New("agent: loop: nil protocol") + if proto.Version() == 0 { + return run.CommitResult{}, errors.New("agent: loop: uninitialized protocol") } env, err := proto.BuildEnvelope(runID, id, cmd) if err != nil { diff --git a/agent/run/protocol.go b/agent/run/protocol.go index 14fbac9..41faa5e 100644 --- a/agent/run/protocol.go +++ b/agent/run/protocol.go @@ -46,49 +46,207 @@ func encodeEnvelopeBody(schemaVersion uint16, typ string, body any) ([]byte, err return es.EncodeTypedPayload(schemaVersion, typ, body) } -// Protocol is the closed set of digest, decode, Decide, Evolve, and envelope -// operations for one SchemaVersion. ProtocolFor selects it once from the Run -// header; subsequent calls do not take a version argument (RUN-CMT-7). -type Protocol interface { - Version() uint16 - - DigestRequest(req ModelRequest) (Digest, error) - DigestToolDefinition(def ToolDefinition) (Digest, error) - DigestToolSpec(spec ToolSpec) (Digest, error) - DigestToolSpecs(specs []ToolSpec) (Digest, error) - DigestModelStepBinding(model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) - DigestToolResponseDecision(kind ResponseKind, decision ResponseDecision, reason string) (Digest, error) - DigestToolResponsePayload(payload CanonicalJSON) (Digest, error) - DigestCommand(typ string, command AgentCommand) (Digest, error) - DigestFact(typ string, fact Fact) (Digest, error) - EncodeFact(typ string, fact Fact) ([]byte, error) - - DecodeCommand(typ string, raw []byte) (AgentCommand, error) - DecodeFact(typ string, raw []byte) (Fact, error) - - Decide(s MachineState, c AgentCommand) ([]Fact, error) - Evolve(s MachineState, f Fact) (MachineState, error) - BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) - - EncodeMachineState(s *MachineState) ([]byte, error) - ValidateHeader(h *RunHeader) error -} - -// ProtocolV1 is the SchemaVersion1 implementation. New Runs write with this -// protocol; replay of a persisted v1 Run must keep using it after later -// versions exist. -var ProtocolV1 Protocol = protocolV1{} - -// ProtocolFor selects the protocol implementation 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. +// 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) + 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) + validateHeader func(*RunHeader) error +} + +// ProtocolV1 is the SchemaVersion1 binding. New Runs write with this protocol; +// replay of a persisted v1 Run must keep using it after later versions exist. +var ProtocolV1 = Protocol{ + version: SchemaVersion1, + digestRequest: digestRequestV1, + digestToolDefinition: digestToolDefinitionV1, + digestToolSpec: digestToolSpecV1, + digestToolSpecs: digestToolSpecsV1, + digestModelStepBinding: digestModelStepBindingV1, + digestToolResponseDecision: digestToolResponseDecisionV1, + digestToolResponsePayload: digestToolResponsePayloadV1, + decodeCommand: decodeCommandVariantV1, + decodeFact: decodeFactVariantV1, + decide: decideV1, + evolve: evolveV1, + encodeMachineState: encodeMachineStateV1, + validateHeader: validateHeaderV1, +} + +// 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 nil, unsupportedSchemaVersion(schemaVersion) + 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) +} + +func (p Protocol) DigestCommand(typ string, command AgentCommand) (Digest, error) { + if err := p.ready(); err != nil { + return "", err + } + if typ == "" || typ != commandType(command) { + return "", fmt.Errorf("agent: digest: type %q does not match command variant", typ) + } + body, err := encodeEnvelopeBody(p.version, typ, command) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +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) +} + +func (p Protocol) BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) { + typ := commandType(cmd) + if typ == "" { + return CommandEnvelope{}, fmt.Errorf("agent: envelope: unknown command variant %T", cmd) + } + d, err := p.DigestCommand(typ, cmd) + if err != nil { + return CommandEnvelope{}, err + } + return CommandEnvelope{ + SchemaVersion: p.version, + Type: typ, + RunID: run, + ID: id, + Digest: d, + Command: cmd, + }, nil +} + +func (p Protocol) EncodeMachineState(s *MachineState) ([]byte, error) { + if err := p.ready(); err != nil { + return nil, err + } + return p.encodeMachineState(s) +} + +func (p Protocol) ValidateHeader(h *RunHeader) error { + if err := p.ready(); err != nil { + return err } + return p.validateHeader(h) } // EncodeCommand renders the canonical bytes of a command envelope, excluding diff --git a/agent/run/protocol_test.go b/agent/run/protocol_test.go index b403807..c196f11 100644 --- a/agent/run/protocol_test.go +++ b/agent/run/protocol_test.go @@ -10,8 +10,8 @@ func TestProtocolForSelectsV1(t *testing.T) { if p.Version() != SchemaVersion1 { t.Fatalf("version = %d", p.Version()) } - if p != ProtocolV1 { - t.Fatal("ProtocolFor(1) did not return ProtocolV1") + if p.Version() != ProtocolV1.Version() { + t.Fatal("ProtocolFor(1) did not bind ProtocolV1") } if _, err := ProtocolFor(0); err == nil { t.Fatal("schema 0 accepted") @@ -34,3 +34,13 @@ func TestRuntimeSnapshotProtocol(t *testing.T) { 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 index 5ceba36..39d3573 100644 --- a/agent/run/protocol_v1.go +++ b/agent/run/protocol_v1.go @@ -5,108 +5,8 @@ import ( "fmt" ) -// protocolV1 is the SchemaVersion1 implementation. Replay of a v1 Run must -// call these methods even after currentSchemaVersion moves forward. -type protocolV1 struct{} - -func (protocolV1) Version() uint16 { return SchemaVersion1 } - -func (protocolV1) DigestRequest(req ModelRequest) (Digest, error) { - return digestRequestV1(req) -} - -func (protocolV1) DigestToolDefinition(def ToolDefinition) (Digest, error) { - return digestToolDefinitionV1(def) -} - -func (protocolV1) DigestToolSpec(spec ToolSpec) (Digest, error) { - return digestToolSpecV1(spec) -} - -func (protocolV1) DigestToolSpecs(specs []ToolSpec) (Digest, error) { - return digestToolSpecsV1(specs) -} - -func (protocolV1) DigestModelStepBinding(model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) { - return digestModelStepBindingV1(model, requestDigest, toolsDigest) -} - -func (protocolV1) DigestToolResponseDecision(kind ResponseKind, decision ResponseDecision, reason string) (Digest, error) { - return digestToolResponseDecisionV1(kind, decision, reason) -} - -func (protocolV1) DigestToolResponsePayload(payload CanonicalJSON) (Digest, error) { - return digestToolResponsePayloadV1(payload) -} - -func (protocolV1) DigestCommand(typ string, command AgentCommand) (Digest, error) { - if typ == "" || typ != commandType(command) { - return "", fmt.Errorf("agent: digest: type %q does not match command variant", typ) - } - body, err := encodeEnvelopeBody(SchemaVersion1, typ, command) - if err != nil { - return "", err - } - return sha256Digest(body), nil -} - -func (protocolV1) EncodeFact(typ string, fact Fact) ([]byte, error) { - if typ == "" || typ != factType(fact) { - return nil, fmt.Errorf("agent: encode: type %q does not match fact variant", typ) - } - return encodeEnvelopeBody(SchemaVersion1, typ, fact) -} - -func (p protocolV1) DigestFact(typ string, fact Fact) (Digest, error) { - body, err := p.EncodeFact(typ, fact) - if err != nil { - return "", err - } - return sha256Digest(body), nil -} - -func (protocolV1) DecodeCommand(typ string, raw []byte) (AgentCommand, error) { - return decodeCommandVariantV1(typ, raw) -} - -func (protocolV1) DecodeFact(typ string, raw []byte) (Fact, error) { - return decodeFactVariantV1(typ, raw) -} - -func (protocolV1) Decide(s MachineState, c AgentCommand) ([]Fact, error) { - return decideV1(s, c) -} - -func (protocolV1) Evolve(s MachineState, f Fact) (MachineState, error) { - return evolveV1(s, f) -} - -func (p protocolV1) BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) { - typ := commandType(cmd) - if typ == "" { - return CommandEnvelope{}, fmt.Errorf("agent: envelope: unknown command variant %T", cmd) - } - d, err := p.DigestCommand(typ, cmd) - if err != nil { - return CommandEnvelope{}, err - } - return CommandEnvelope{ - SchemaVersion: SchemaVersion1, - Type: typ, - RunID: run, - ID: id, - Digest: d, - Command: cmd, - }, nil -} - -func (protocolV1) EncodeMachineState(s *MachineState) ([]byte, error) { - return encodeMachineStateV1(s) -} - -func (protocolV1) ValidateHeader(h *RunHeader) error { - return validateHeaderV1(h) -} +// 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) diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 6cda868..00db126 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -319,7 +319,7 @@ type CommitResult struct { **RUN-CMT-6** Commit 必须原子保存新 MachineState 与完整 TransitionRecord,保证 event group 完整写入。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 -**RUN-CMT-7** 每个 Run 的协议版本是 `RunHeader.SchemaVersion`,在 Create 时冻结。`RuntimeSnapshot.SchemaVersion` 必须等于该 header。`ProtocolFor(header.SchemaVersion)` 在 Run 边界选出该版本的 `Protocol`;digest、decode、Decide、Evolve、BuildEnvelope 是该对象上的方法,不再接受 version 参数。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Protocol 的 Version。不得使用进程全局 `currentSchemaVersion` 作为写入许可。v1 Run 的 replay 必须继续使用 `ProtocolV1`,即使进程已经把 `currentSchemaVersion` 升到 2。Loop 通过 `RuntimeSnapshot.Protocol()` 构造写入该 Run 的 envelope。 +**RUN-CMT-7** 每个 Run 的协议版本是 `RunHeader.SchemaVersion`,在 Create 时冻结。`RuntimeSnapshot.SchemaVersion` 必须等于该 header。`ProtocolFor(header.SchemaVersion)` 在 Run 边界返回绑定了该版本 digest/decode/Decide/Evolve 函数的 `Protocol` 值;随后的方法调用不再接受 version 参数。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Protocol 的 Version。不得使用进程全局 `currentSchemaVersion` 作为写入许可。v1 Run 的 replay 必须继续使用 `ProtocolV1`,即使进程已经把 `currentSchemaVersion` 升到 2。Loop 通过 `RuntimeSnapshot.Protocol()` 构造写入该 Run 的 envelope。 `MemoryRuntime` 提供 multi-Run in-process reference implementation:collection lock 保护 Run map,每个 Run 使用独立锁。跨进程 durable recovery 由其他 Runtime adapter 提供。 From 46530b112e8b474da12eff870299e4ecef05098b Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 1 Sep 2026 15:58:46 +0800 Subject: [PATCH 41/81] Freeze ToolStep scheduling at model result SubmitModelResult snapshots ToolExecution and MaxParallel onto ToolStepOpened. Resume honors the frozen ToolStep.Scheduling instead of the live Loop ExecutionPolicy. Empty mode stays empty so default parallel does not enter the v1 golden state digest. ModelCatalog must resolve a ModelRef to equivalent execution for the life of a Run. --- agent/run/command.go | 7 ++++--- agent/run/decide.go | 5 +++++ agent/run/evolve.go | 11 ++++++++--- agent/run/fact.go | 1 + agent/run/loop/contract.go | 14 +++++++------- agent/run/loop/loop.go | 12 ++++++++---- agent/run/machine_test.go | 32 ++++++++++++++++++++++++++++++++ agent/run/state.go | 33 ++++++++++++++++++++++++++++++--- docs/design/agent-run.md | 8 +++++--- 9 files changed, 100 insertions(+), 23 deletions(-) diff --git a/agent/run/command.go b/agent/run/command.go index 7719a73..ee899e9 100644 --- a/agent/run/command.go +++ b/agent/run/command.go @@ -56,9 +56,10 @@ 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"` + StepID StepID `json:"stepId"` + Result ModelResult `json:"result"` + Calls []ToolCallBinding `json:"calls,omitempty"` + Scheduling ToolScheduling `json:"scheduling,omitzero"` } func (SubmitModelResult) agentCommand() {} diff --git a/agent/run/decide.go b/agent/run/decide.go index 04f1a36..1e6593f 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -290,11 +290,16 @@ func decideSubmitModelResult(s *MachineState, cmd *SubmitModelResult) ([]Fact, e RequestDigest: reqDigest, } } + scheduling, err := normalizeToolScheduling(cmd.Scheduling) + if err != nil { + return nil, rejectionf("model result: %v", err) + } return []Fact{completed, ToolStepOpened{ StepID: toolStepID, Source: cmd.StepID, BindingSetDigest: setDigest, Calls: bindings, + Scheduling: scheduling, }}, nil } diff --git a/agent/run/evolve.go b/agent/run/evolve.go index 3856502..396b003 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -117,10 +117,15 @@ func evolveV1(s MachineState, f Fact) (MachineState, error) { return s, err } } + scheduling, err := normalizeToolScheduling(fact.Scheduling) + if err != nil { + return s, fmt.Errorf("agent: evolve: tool step scheduling: %w", err) + } s.Current = ToolStep{ - RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: fact.BindingSetDigest}, - Source: fact.Source, - Calls: calls, + RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: fact.BindingSetDigest}, + Source: fact.Source, + Calls: calls, + Scheduling: scheduling, } return s, nil diff --git a/agent/run/fact.go b/agent/run/fact.go index 3cfeebc..7e416fe 100644 --- a/agent/run/fact.go +++ b/agent/run/fact.go @@ -73,6 +73,7 @@ type ToolStepOpened struct { Source StepID `json:"source"` // the completed ModelStep BindingSetDigest Digest `json:"bindingSetDigest"` Calls []ToolCallBinding `json:"calls"` + Scheduling ToolScheduling `json:"scheduling,omitzero"` } func (ToolStepOpened) fact() {} diff --git a/agent/run/loop/contract.go b/agent/run/loop/contract.go index e9aa5f0..c877315 100644 --- a/agent/run/loop/contract.go +++ b/agent/run/loop/contract.go @@ -32,7 +32,8 @@ type RequestPlan struct { } // ModelCatalog resolves a frozen run.ModelRef into an invoker at execution time; -// provider binding never enters the frozen request. +// 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 { Resolve(run.ModelRef) (ModelInvoker, error) } @@ -97,8 +98,8 @@ type ToolProgress struct { Payload json.RawMessage } -// ToolExecutionMode controls how independent Pending calls are dispatched by -// a Loop. The mode is an execution concern; it is not part of MachineState. +// ToolExecutionMode is Loop-local until SubmitModelResult snapshots it onto +// ToolStep.Scheduling. type ToolExecutionMode string const ( @@ -145,10 +146,9 @@ type Event struct { Canonical *run.AgentEvent } -// ExecutionPolicy is host-owned loop policy. It is not persisted in -// MachineState or events. ToolExecution controls dispatch mode and -// MaxParallel bounds workers launched by this Loop. Malformed model result -// disposition is selected by OnMalformedModelResult. +// 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 diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index 4bcd4b7..ed4f04f 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -80,6 +80,10 @@ func New(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy starts: make(map[startKey]startAttempt), settlements: make(map[startKey]settlementAttempt), runs: make(map[run.RunID]struct{})}, nil } +func (l *Loop) toolScheduling() run.ToolScheduling { + return run.ToolScheduling{Mode: run.ToolScheduleMode(l.Execution.ToolExecution), MaxParallel: l.Execution.MaxParallel} +} + func (l *Loop) startFor(key startKey) startAttempt { l.startsMu.Lock() defer l.startsMu.Unlock() @@ -557,7 +561,7 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve 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} + completion = run.SubmitModelResult{StepID: stepID, Result: frozenResult, Calls: bindings, Scheduling: l.toolScheduling()} } } } @@ -731,11 +735,11 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve } limit := len(eff.CallIDs) - if l.Execution.ToolExecution == ToolExecutionSequential { + if ts.Scheduling.Mode == run.ToolScheduleSequential { limit = 1 } - if l.Execution.MaxParallel > 0 && l.Execution.MaxParallel < limit { - limit = l.Execution.MaxParallel + if ts.Scheduling.MaxParallel > 0 && ts.Scheduling.MaxParallel < limit { + limit = ts.Scheduling.MaxParallel } var started []startedWorker for _, callID := range eff.CallIDs { diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go index c0d912f..ce02146 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -378,6 +378,38 @@ func TestExternalResponseRequiresPayloadDigest(t *testing.T) { } } +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, "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, "c1", spec, `{}`) + _, err := 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 TestUnknownFailureEndsRun(t *testing.T) { def := testToolDef("t") spec := makeSpec(t, def, DirectExecution) diff --git a/agent/run/state.go b/agent/run/state.go index f99b1cc..4448700 100644 --- a/agent/run/state.go +++ b/agent/run/state.go @@ -245,10 +245,37 @@ func ValidateToolCallState(c ToolCallState) error { 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"` + RefValue StepRef `json:"ref"` + Source StepID `json:"source"` + Calls []ToolCallState `json:"calls"` + Scheduling ToolScheduling `json:"scheduling,omitzero"` } func (ToolStep) step() {} diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 00db126..65f1890 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -213,7 +213,7 @@ ToolCall: | `PrepareModelRequest` | 无 Current,完整有序消费 PendingInputs,request/tools digests 有效;`ModelStepPrepared` | | `StartModelExecution` | Model Prepared;`ModelStepStarted`。command 必须携带本次 start 的 `ExecutionClaim` | | `RecoverModelExecution` | Model Executing;`ModelStepRecovered`。恢复 durable attempt 时携带该 attempt 的 `Claim` | -| `SubmitModelResult` | Model Executing;`ModelStepCompleted`,随后无 calls 时 `RunEnded(completed)`,有 calls 时 `ToolStepOpened` | +| `SubmitModelResult` | Model Executing;`ModelStepCompleted`,随后无 calls 时 `RunEnded(completed)`,有 calls 时 `ToolStepOpened`(携带冻结的 `Scheduling`) | | `SubmitModelFailure` | Model Executing;`RunEnded(failed/provider_failure)` | | `RejectModelResult` | Model Executing;`ModelStepRejected`,由调用方显式选择回到 Prepared 或在同一 transition 追加 `RunEnded(failed/malformed_model_result)` | | `StartToolCall` | Tool Pending;`ToolCallStarted`。command 必须携带本次 start 的 `ExecutionClaim` | @@ -382,7 +382,9 @@ func New(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy func (*Loop) Run(context.Context, run.Runtime, run.RunID, EventSink) (LoopResult, error) ``` -**RUN-LOP-1** `ExecutionPolicy` 是 Loop 的本地执行策略,包含 `ToolExecution`、`MaxParallel` 和可选的 malformed-result handler;未指定 `ToolExecution` 时使用 `parallel`,正数 `MaxParallel` 限制当前 ToolStep 的本地 worker 数量,零值允许当前批次的所有可执行 call 并行。nil handler 时结构错误的模型结果选择 `ModelRejectFailRun`;重试由 handler 明确返回 `ModelRejectRetry`。`streaming` 表示是否请求可用的流式模型端口;两种模式都产生同一完整 `sdk.ModelResult`。 +**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`。 + +**RUN-LOP-7** `ModelRef` 是冻结请求中的执行身份。`ModelCatalog.Resolve` 在同一 Run 生命周期内必须把同一 `ModelRef` 解析为等价的执行语义。provider 绑定不进入 frozen request,因此 Catalog 不得把同一 ref 改绑到不同实现。 `LoopResult` 的语义固定为:`LoopWaiting` 时 `Result` 为 nil,表示没有可执行 effect、Run 仍为 active。`ExecutionRecovery` 为 true 时至少有一个 call 仍为 Executing,由 recovery authority 唤醒。`Reason` 在该情况下为 `execution_recovery`,Idle 时为空。Waiting call 不进入 `LoopResult`;Application 通过 `Runtime.Load` / `Record` 与 `WaitingCalls` 读取。`LoopFinished` 时 `Result` 非 nil,并等于 terminal RunRecord 派生的 `RunResult`。 @@ -406,7 +408,7 @@ Loop.Run(ctx, runtime, runID, sink): **RUN-LOP-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须保留 start command 的 ID、digest 和 claim,直到完成 settlement;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。streaming 与 non-streaming 必须产生同一种完整 `sdk.ModelResult`;delta 只发 EventSink。provider failure 提交 `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`;只有 Accepted owner 可执行。`parallel` 模式并发执行同一 ToolStep 中所有可执行的 Pending call,`sequential` 模式按 call 顺序逐个执行;每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call 在本次 `Run` 内 Start 并结算。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `WaitingCalls`,提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 之后再次 `Run`。tool panic 或 effect 状态无法确定的错误转为 Unknown;一个 Unknown 取消同批 sibling workers,并由 Machine 在同一 terminal transition 中记录目标及仍 Executing sibling 的 `ToolCallFailed(Unknown)`,最后追加 `RunEnded(failed/effect_unknown)`。`CancelRun` 也在 `RunEnded(stopped/cancelled)` 前记录所有仍 Executing call 的 Unknown。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 +**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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call 在本次 `Run` 内 Start 并结算。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `WaitingCalls`,提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 之后再次 `Run`。tool panic 或 effect 状态无法确定的错误转为 Unknown;一个 Unknown 取消同批 sibling workers,并由 Machine 在同一 terminal transition 中记录目标及仍 Executing sibling 的 `ToolCallFailed(Unknown)`,最后追加 `RunEnded(failed/effect_unknown)`。`CancelRun` 也在 `RunEnded(stopped/cancelled)` 前记录所有仍 Executing call 的 Unknown。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 **RUN-LOP-5** model 与 tool worker 都接收外层 ctx;Loop 对已接受 effect 使用独立 control context 完成 known/unknown outcome settlement。Application 的业务停止顺序为先 Commit `CancelRun`,再取消 Loop ctx。非 sentinel Commit error 以同 CommandID/digest 重放一次;仍未知时返回错误,由后续 Load/Record 查询 authority。stale/terminal/conflict 触发 reload/drop,旧 external effect 保持单次执行尝试。工具实现配合 context 返回;永久阻塞由 application/durable recovery 处理。 From 4e6242e3b914f5de815a414307b49d970bb78bbc Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 1 Sep 2026 16:00:52 +0800 Subject: [PATCH 42/81] Keep catalog resolve errors off the run result ModelCatalog.Resolve failure or a nil invoker recovers the ModelStep and returns from Loop. The model was never invoked, so the Run stays active instead of ending as provider_failure. --- agent/run/loop/loop.go | 15 ++++++++++++--- agent/run/loop/loop_test.go | 28 ++++++++++++++++++++++++++++ docs/design/agent-run.md | 2 +- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index ed4f04f..6c5a3af 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -534,10 +534,16 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve } var completion run.AgentCommand + var catalogErr error invoker, resolveErr := l.Models.Resolve(modelStep.Model) - if resolveErr != nil { - completion = run.SubmitModelFailure{StepID: stepID, Failure: run.StepFailure{Class: run.FailureProvider, Message: resolveErr.Error()}} - } else { + switch { + case resolveErr != nil: + catalogErr = resolveErr + completion = run.RecoverModelExecution{StepID: stepID, Claim: attempt.claim} + case invoker == nil: + catalogErr = errors.New("model catalog returned a nil invoker") + completion = run.RecoverModelExecution{StepID: stepID, Claim: attempt.claim} + default: // Model workers derive from the outer ctx: cancelling a model call is // safe, the frozen request retries after recovery (RUN-LOP-3). sdkRequest, err := modelStep.Request.SDK() @@ -585,6 +591,9 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve l.forgetSettlement(key) l.forgetStart(key) l.emitCommitted(ctx, events, runID, res.Events) + if catalogErr != nil { + return fmt.Errorf("agent: loop: model catalog: %w", catalogErr) + } return nil } diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go index 244ccf4..907d052 100644 --- a/agent/run/loop/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "sync" "sync/atomic" "testing" @@ -167,6 +168,33 @@ func TestLoopSingleModelCallCompletes(t *testing.T) { } } +type errCatalog struct{ err error } + +func (c errCatalog) Resolve(ModelRef) (ModelInvoker, error) { return nil, c.err } + +func TestLoopModelCatalogErrorDoesNotFailRun(t *testing.T) { + rt := loopRuntime(t) + loop, err := New(errCatalog{errors.New("missing provider")}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + _, err = loop.Run(context.Background(), rt, "run-1", nil) + if err == nil || !strings.Contains(err.Error(), "missing provider") { + t.Fatalf("err = %v", err) + } + snap, loadErr := rt.Load(context.Background(), "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) + } +} + func TestLoopRejectsConcurrentRunForSameID(t *testing.T) { rt := loopRuntime(t) invoker := &blockingInvoker{started: make(chan struct{}), release: make(chan struct{})} diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 65f1890..0709238 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -406,7 +406,7 @@ Loop.Run(ctx, runtime, runID, sink): **RUN-LOP-2** `NeedModelRequest` 调用 Planner,冻结 sdk.Request,验证 model、ordered InputIDs 与 ToolSpecs,计算 request/tools/binding digests 和 derived CommandID/StepID,再提交 Prepare。prepare stale 后重新 Load;同 revision 的内容拒绝不得 livelock 重试。业务停止统一使用 `CancelRun`。 -**RUN-LOP-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须保留 start command 的 ID、digest 和 claim,直到完成 settlement;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。streaming 与 non-streaming 必须产生同一种完整 `sdk.ModelResult`;delta 只发 EventSink。provider failure 提交 `SubmitModelFailure`;ctx cancellation 提交 `RecoverModelExecution`;结构、binding 或 freeze 失败提交 `RejectModelResult`,并由调用方显式选择 retry 或 fail-run。成功结果只提交一次 `SubmitModelResult`。 +**RUN-LOP-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须保留 start command 的 ID、digest 和 claim,直到完成 settlement;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。streaming 与 non-streaming 必须产生同一种完整 `sdk.ModelResult`;delta 只发 EventSink。`ModelCatalog.Resolve` 失败或返回 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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call 在本次 `Run` 内 Start 并结算。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `WaitingCalls`,提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 之后再次 `Run`。tool panic 或 effect 状态无法确定的错误转为 Unknown;一个 Unknown 取消同批 sibling workers,并由 Machine 在同一 terminal transition 中记录目标及仍 Executing sibling 的 `ToolCallFailed(Unknown)`,最后追加 `RunEnded(failed/effect_unknown)`。`CancelRun` 也在 `RunEnded(stopped/cancelled)` 前记录所有仍 Executing call 的 Unknown。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 From e799f01829f64e4d1225b9fb487af05d21b5fd08 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 1 Sep 2026 16:03:15 +0800 Subject: [PATCH 43/81] Project cancelled in-flight effects onto RunResult CancelRun still ends as stopped/cancelled, but Executing tool calls and an Executing ModelStep are recorded on RunStoppedEnd and copied onto RunResult as UncertainCalls and UncertainModel. --- agent/run/clone.go | 5 +++++ agent/run/decide.go | 16 ++++++++++++++- agent/run/evolve.go | 7 ++++++- agent/run/fact.go | 42 +++++++++++++++++++++++++++------------ agent/run/machine_test.go | 41 ++++++++++++++++++++++++++++++++++++++ agent/run/state.go | 14 ++++++++----- docs/design/agent-run.md | 10 +++++++--- 7 files changed, 112 insertions(+), 23 deletions(-) diff --git a/agent/run/clone.go b/agent/run/clone.go index f27eb8d..34f36e8 100644 --- a/agent/run/clone.go +++ b/agent/run/clone.go @@ -284,6 +284,7 @@ func cloneRunResult(r *RunResult) *RunResult { f := *c.Failure c.Failure = &f } + c.UncertainCalls = append([]CallID(nil), c.UncertainCalls...) c.Model = cloneModelResult(c.Model) return &c } @@ -382,6 +383,10 @@ func cloneFact(f Fact) Fact { 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/decide.go b/agent/run/decide.go index 1e6593f..3052fa0 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -546,7 +546,21 @@ func decideCancelRun(s *MachineState, cmd CancelRun) ([]Fact, error) { return nil, rejectionf("cancel: reason must be empty or %q", ReasonCancelled) } facts := unknownExecutingCalls(s, "", "", ToolFailure{Class: FailureEffectUnknown, Message: "execution cancelled before settlement"}) - facts = append(facts, RunEnded{End: RunStoppedEnd{Reason: ReasonCancelled}}) + 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 } diff --git a/agent/run/evolve.go b/agent/run/evolve.go index 396b003..56dfc8e 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -179,13 +179,18 @@ func evolveV1(s MachineState, f Fact) (MachineState, error) { status, reason, failure := endProjection(normalized.End) s.Status = status s.Current = nil - s.Result = &RunResult{ + result := &RunResult{ Status: status, Reason: reason, Failure: failure, Model: s.LastModelResult, Usage: s.Usage, } + if stopped, ok := normalized.End.(RunStoppedEnd); ok { + result.UncertainCalls = append([]CallID(nil), stopped.UncertainCalls...) + result.UncertainModel = stopped.UncertainModel + } + s.Result = result return s, nil default: diff --git a/agent/run/fact.go b/agent/run/fact.go index 7e416fe..22cdf9d 100644 --- a/agent/run/fact.go +++ b/agent/run/fact.go @@ -137,7 +137,11 @@ func (InputAccepted) fact() {} type RunEnd interface{ runEnd() } type RunCompletedEnd struct{} -type RunStoppedEnd struct{ Reason RunReason } +type RunStoppedEnd struct { + Reason RunReason + UncertainCalls []CallID `json:"uncertainCalls,omitempty"` + UncertainModel StepID `json:"uncertainModel,omitempty"` +} type RunFailedEnd struct { Reason RunReason Failure RunFailure @@ -161,11 +165,11 @@ func (r RunEnded) effectiveEnd() (RunEnd, error) { return r.End, nil } -func legacyEnd(status RunStatus, reason RunReason, failure *RunFailure) (RunEnd, error) { +func legacyEnd(status RunStatus, reason RunReason, failure *RunFailure, uncertainCalls []CallID, uncertainModel StepID) (RunEnd, error) { switch status { case RunCompleted: - if reason != "" || failure != nil { - return nil, errors.New("agent: run ended: completed outcome cannot carry reason or failure") + if reason != "" || failure != nil || len(uncertainCalls) > 0 || uncertainModel != "" { + return nil, errors.New("agent: run ended: completed outcome cannot carry reason, failure, or uncertain effects") } return RunCompletedEnd{}, nil case RunStopped: @@ -175,7 +179,7 @@ func legacyEnd(status RunStatus, reason RunReason, failure *RunFailure) (RunEnd, if failure != nil { return nil, errors.New("agent: run ended: stopped outcome cannot carry failure") } - return RunStoppedEnd{Reason: reason}, nil + return RunStoppedEnd{Reason: reason, UncertainCalls: uncertainCalls, UncertainModel: uncertainModel}, nil case RunFailed: if failure == nil { return nil, errors.New("agent: run ended: failed outcome requires failure") @@ -186,6 +190,9 @@ func legacyEnd(status RunStatus, reason RunReason, failure *RunFailure) (RunEnd, if failure.Class == "" { return nil, errors.New("agent: run ended: failed outcome requires a failure class") } + if len(uncertainCalls) > 0 || uncertainModel != "" { + return nil, errors.New("agent: run ended: failed outcome cannot carry uncertain-effect fields") + } return RunFailedEnd{Reason: reason, Failure: *failure}, nil default: return nil, fmt.Errorf("agent: run ended: invalid status %d", status) @@ -244,19 +251,28 @@ func (r RunEnded) MarshalJSON() ([]byte, error) { return nil, err } type wire struct { - Status RunStatus `json:"status"` - Reason RunReason `json:"reason,omitempty"` - Failure *RunFailure `json:"failure,omitempty"` + Status RunStatus `json:"status"` + Reason RunReason `json:"reason,omitempty"` + Failure *RunFailure `json:"failure,omitempty"` + UncertainCalls []CallID `json:"uncertainCalls,omitempty"` + UncertainModel StepID `json:"uncertainModel,omitempty"` } status, reason, failure := endProjection(n.End) - return json.Marshal(wire{Status: status, Reason: reason, Failure: failure}) + out := wire{Status: status, Reason: reason, Failure: failure} + if stopped, ok := n.End.(RunStoppedEnd); ok { + out.UncertainCalls = stopped.UncertainCalls + out.UncertainModel = stopped.UncertainModel + } + return json.Marshal(out) } func (r *RunEnded) UnmarshalJSON(raw []byte) error { var wire struct { - Status RunStatus `json:"status"` - Reason RunReason `json:"reason,omitempty"` - Failure *RunFailure `json:"failure,omitempty"` + Status RunStatus `json:"status"` + Reason RunReason `json:"reason,omitempty"` + Failure *RunFailure `json:"failure,omitempty"` + UncertainCalls []CallID `json:"uncertainCalls,omitempty"` + UncertainModel StepID `json:"uncertainModel,omitempty"` } dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() @@ -269,7 +285,7 @@ func (r *RunEnded) UnmarshalJSON(raw []byte) error { } return err } - end, err := legacyEnd(wire.Status, wire.Reason, wire.Failure) + end, err := legacyEnd(wire.Status, wire.Reason, wire.Failure, wire.UncertainCalls, wire.UncertainModel) if err != nil { return err } diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go index ce02146..4677c91 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -572,6 +572,47 @@ func TestCancelProducesRunStopped(t *testing.T) { if !ok || stopped.Reason != ReasonCancelled { t.Fatalf("ended = %+v", ended.End) } + if len(stopped.UncertainCalls) != 0 || stopped.UncertainModel != "" { + t.Fatalf("idle cancel recorded uncertain effects: %+v", stopped) + } +} + +func TestCancelSurfacesUncertainToolCalls(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, "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: "c1"})) + + facts = mustDecide(t, s, CancelRun{}) + ended := facts[len(facts)-1].(RunEnded) + stopped := ended.End.(RunStoppedEnd) + if stopped.Reason != ReasonCancelled || len(stopped.UncertainCalls) != 1 || stopped.UncertainCalls[0] != "c1" { + t.Fatalf("stopped = %+v", stopped) + } + s = fold(t, s, facts) + if s.Result == nil || len(s.Result.UncertainCalls) != 1 || s.Result.UncertainCalls[0] != "c1" { + t.Fatalf("result = %+v", s.Result) + } +} + +func TestCancelSurfacesUncertainModelStep(t *testing.T) { + s := newRun(t) + s, stepID := advanceToExecuting(t, s, testRequest(), nil) + facts := mustDecide(t, s, CancelRun{}) + ended := facts[0].(RunEnded) + stopped := ended.End.(RunStoppedEnd) + if stopped.UncertainModel != stepID { + t.Fatalf("uncertain model = %q, want %q", stopped.UncertainModel, stepID) + } + s = fold(t, s, facts) + if s.Result == nil || s.Result.UncertainModel != stepID { + t.Fatalf("result = %+v", s.Result) + } } func TestAcceptInputIdempotentPerID(t *testing.T) { diff --git a/agent/run/state.go b/agent/run/state.go index 4448700..3641abe 100644 --- a/agent/run/state.go +++ b/agent/run/state.go @@ -32,11 +32,15 @@ type RunFailure struct { } type RunResult struct { - Status RunStatus `json:"status"` - Reason RunReason `json:"reason,omitempty"` - Failure *RunFailure `json:"failure,omitempty"` - Model *ModelResult `json:"model,omitempty"` - Usage Usage `json:"usage"` + 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"` + Model *ModelResult `json:"model,omitempty"` + Usage Usage `json:"usage"` } type StepFailure struct { diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 0709238..ecabd74 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -171,7 +171,11 @@ Run status 为 `RunActive | RunCompleted | RunStopped | RunFailed`。`RunComplet type RunEnd interface{ runEnd() } type RunCompletedEnd struct{} -type RunStoppedEnd struct { Reason RunReason } +type RunStoppedEnd struct { + Reason RunReason + UncertainCalls []CallID + UncertainModel StepID +} type RunFailedEnd struct { Reason RunReason Failure RunFailure @@ -184,7 +188,7 @@ func (RunFailedEnd) runEnd() {} type RunEnded struct { End RunEnd } ``` -`RunEnded.End` 必须恰好是上述三个 variant 之一;`RunStoppedEnd.Reason` 必须非空,`RunFailedEnd.Reason` 必须是失败原因,`RunFailedEnd.Failure.Class` 必须非空。`RunEnded` 是 terminal transition 的最后一个 fact。RunStatus、RunResult 等读取模型从该 union 派生。当前 v1 wire body 使用 `status/reason/failure` 字段;codec 负责在 wire 与 union 之间做严格映射,并拒绝 `RunActive`、缺失字段或多余字段。 +`RunEnded.End` 必须恰好是上述三个 variant 之一;`RunStoppedEnd.Reason` 必须非空,`RunFailedEnd.Reason` 必须是失败原因,`RunFailedEnd.Failure.Class` 必须非空。`RunEnded` 是 terminal transition 的最后一个 fact。RunStatus、RunResult 等读取模型从该 union 派生。当前 v1 wire body 使用 `status/reason/failure` 以及可选的 `uncertainCalls` / `uncertainModel`;codec 负责在 wire 与 union 之间做严格映射,并拒绝 `RunActive`、缺失字段或多余字段。Cancel 时仍 Executing 的 tool call 与 model step 必须写入 `RunStoppedEnd` 并投影到 `RunResult`,不得只留下 `stopped/cancelled`。 ```text ModelStep: Prepared -> Executing -> Completed @@ -223,7 +227,7 @@ ToolCall: | `ApproveToolCall` | Waiting(Approval);`ToolCallApproved` | | `RejectToolCall` | Waiting(Approval/ExternalResponse);`ToolCallFailed(Known/permission_denied)`,最后一个 call 进入 terminal 时隐式关闭 ToolStep | | `SubmitToolResponse` | Waiting(ExternalResponse);`ToolCallAnswered`,最后一个 call 进入 terminal 时隐式关闭 ToolStep | -| `CancelRun` | active;`RunEnded(stopped/cancelled)` | +| `CancelRun` | active;仍 Executing 的 tool call 记 `ToolCallFailed(Unknown)`,随后 `RunEnded(stopped/cancelled)`,并在 `RunStoppedEnd` / `RunResult` 上列出 `UncertainCalls` 与 `UncertainModel` | `ToolStepClosed` 作为旧 v1 transition 的兼容 fact 保留;新 command 在最终 ToolCall fact 中完成 ToolStep 的关闭。 From 0183a94cf5e60e7469808b94cf8966c3c96028bd Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 1 Sep 2026 16:04:45 +0800 Subject: [PATCH 44/81] Separate external-response reject from permission_denied RejectToolCall on ApprovalRequired stays permission_denied. Rejecting an ExternalResponse wait records response_rejected. The spec no longer keeps ToolStepClosed as a compatibility fact. --- agent/run/command.go | 8 +++++--- agent/run/decide.go | 6 +++++- agent/run/regression_test.go | 2 +- agent/run/state.go | 1 + docs/design/agent-run.md | 4 ++-- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/agent/run/command.go b/agent/run/command.go index ee899e9..6035eeb 100644 --- a/agent/run/command.go +++ b/agent/run/command.go @@ -140,9 +140,11 @@ type ApproveToolCall struct { func (ApproveToolCall) agentCommand() {} -// RejectToolCall rejects a Waiting(Approval or ExternalResponse) call. Decide -// records the outcome as ToolCallFailed{Known, permission_denied}. ResponseDigest -// must be DigestToolResponseDecision(waiting kind, ResponseDecisionRejected, Reason). +// 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"` diff --git a/agent/run/decide.go b/agent/run/decide.go index 3052fa0..619fa33 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -513,10 +513,14 @@ func decideRejectToolCall(s *MachineState, cmd *RejectToolCall) ([]Fact, error) 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: FailurePermissionDenied, Message: cmd.Reason}, + Failure: ToolFailure{Class: class, Message: cmd.Reason}, Outcome: ToolOutcomeKnown, }} return facts, nil diff --git a/agent/run/regression_test.go b/agent/run/regression_test.go index 1bab9a7..dac1a9c 100644 --- a/agent/run/regression_test.go +++ b/agent/run/regression_test.go @@ -121,7 +121,7 @@ func TestRegressionExternalResponseCanBeRejected(t *testing.T) { t.Fatalf("external-response call cannot be rejected: %v", err) } failed := facts[0].(ToolCallFailed) - if failed.Failure.Class != FailurePermissionDenied || failed.Outcome != ToolOutcomeKnown { + if failed.Failure.Class != FailureResponseRejected || failed.Outcome != ToolOutcomeKnown { t.Fatalf("failed = %+v", failed) } s = fold(t, s, facts) diff --git a/agent/run/state.go b/agent/run/state.go index 3641abe..6281a96 100644 --- a/agent/run/state.go +++ b/agent/run/state.go @@ -50,6 +50,7 @@ type StepFailure struct { const ( FailurePermissionDenied = "permission_denied" + FailureResponseRejected = "response_rejected" FailureToolLookup = "tool_lookup_failed" FailureInvalidArguments = "invalid_arguments" FailureMalformedModel = "malformed_model_result" diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index ecabd74..49f6549 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -225,11 +225,11 @@ ToolCall: | `SubmitToolFailure(Known)` | Tool Pending/Executing;`ToolCallFailed(Known)`,最后一个 call 进入 terminal 时隐式关闭 ToolStep | | `SubmitToolFailure(Unknown)` | Tool Executing;`ToolCallFailed(Unknown)`、`RunEnded(failed/effect_unknown)` | | `ApproveToolCall` | Waiting(Approval);`ToolCallApproved` | -| `RejectToolCall` | Waiting(Approval/ExternalResponse);`ToolCallFailed(Known/permission_denied)`,最后一个 call 进入 terminal 时隐式关闭 ToolStep | +| `RejectToolCall` | Waiting(Approval) 记 `ToolCallFailed(Known/permission_denied)`;Waiting(ExternalResponse) 记 `ToolCallFailed(Known/response_rejected)`。最后一个 call 进入 terminal 时隐式关闭 ToolStep | | `SubmitToolResponse` | Waiting(ExternalResponse);`ToolCallAnswered`,最后一个 call 进入 terminal 时隐式关闭 ToolStep | | `CancelRun` | active;仍 Executing 的 tool call 记 `ToolCallFailed(Unknown)`,随后 `RunEnded(stopped/cancelled)`,并在 `RunStoppedEnd` / `RunResult` 上列出 `UncertainCalls` 与 `UncertainModel` | -`ToolStepClosed` 作为旧 v1 transition 的兼容 fact 保留;新 command 在最终 ToolCall fact 中完成 ToolStep 的关闭。 +最后一个 ToolCall 进入 Completed 或 Failed 时,Decide 隐式关闭 ToolStep;没有独立的 `ToolStepClosed` fact。 **RUN-MCH-3** `Decide(state, command)` 执行全部验证与 derived consequence,一次返回该 transition 的完整 ordered fact group;验证成功后返回完整 facts。`Protocol.Evolve(state, fact)` 机械折叠 fact,依赖 fact 携带的完整数据。accepted facts 必须 self-contained;若 transition terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 From 05800c91c5bb2fbdd363c8d3c0e0e44b0aecb89c Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 1 Sep 2026 16:25:45 +0800 Subject: [PATCH 45/81] Align run spec with Protocol, recovery, and cancel Update type listings for RunResult, ToolScheduling, and Protocol. Document Protocol.BuildEnvelope, FoldRun per-record schema, Next's terminal error, Evolve closing ToolStep, and NeedsRecovery as the ExecutionRecovery signal including Model Executing. --- agent/run/decide.go | 2 +- agent/run/evolve.go | 4 +-- agent/run/fact.go | 2 +- agent/run/loop/contract.go | 5 ++-- agent/run/next.go | 2 +- docs/design/agent-run.md | 57 ++++++++++++++++++++++++++++---------- docs/design/agent-turn.md | 2 +- 7 files changed, 51 insertions(+), 23 deletions(-) diff --git a/agent/run/decide.go b/agent/run/decide.go index 619fa33..c38c19f 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -22,7 +22,7 @@ func rejectionf(format string, args ...any) error { // Decide validates one command against the current write schema and produces // the complete fact sequence of its transition (RUN-MCH-3). Replay of a -// persisted Run must use ProtocolFor(header.SchemaVersion).Decide. +// persisted Run must use ProtocolFor(run schema).Decide. // //nolint:gocritic // hugeParam: public protocol boundary is intentionally value-based: Decide(state, command) -> facts. func Decide(s MachineState, c AgentCommand) ([]Fact, error) { diff --git a/agent/run/evolve.go b/agent/run/evolve.go index 56dfc8e..ef18c21 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -6,8 +6,8 @@ import ( ) // Evolve folds one fact with the current write schema. Persisted replay must -// use ProtocolFor(header.SchemaVersion).Evolve so historical folding semantics -// stay bound to the Run that produced the fact. +// use ProtocolFor on the fact or transition SchemaVersion so historical +// folding semantics stay bound to the Run that produced the fact. // //nolint:gocritic // hugeParam: public fold boundary must stay value-based: Evolve(state, fact) -> new state. func Evolve(s MachineState, f Fact) (MachineState, error) { diff --git a/agent/run/fact.go b/agent/run/fact.go index 22cdf9d..35503ca 100644 --- a/agent/run/fact.go +++ b/agent/run/fact.go @@ -10,7 +10,7 @@ import ( // 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 fourteen variants below exist. +// interface is sealed: only the thirteen variants below exist. type Fact interface{ fact() } // ModelStepPrepared establishes the frozen ModelStep and consumes the listed diff --git a/agent/run/loop/contract.go b/agent/run/loop/contract.go index c877315..1c9726d 100644 --- a/agent/run/loop/contract.go +++ b/agent/run/loop/contract.go @@ -171,8 +171,9 @@ type LoopResult struct { // Reason is retained for source compatibility. ExecutionRecovery is // the authoritative signal that a live execution needs recovery. Reason WaitReason - // ExecutionRecovery is true when at least one call remains Executing - // after this Loop has no further executable effect. + // 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. ExecutionRecovery bool Result *run.RunResult } diff --git a/agent/run/next.go b/agent/run/next.go index bb1d3dd..1c0f66d 100644 --- a/agent/run/next.go +++ b/agent/run/next.go @@ -102,7 +102,7 @@ type PlanningHint struct { } // Next derives the pending effect from the current state (RUN-MCH-4). -// Terminal states yield no effect; callers check Status first. +// 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) { diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 49f6549..87ec1d9 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -91,7 +91,7 @@ type TransitionRecord struct { `CommandEnvelope.Digest` 覆盖 schema、type 与完整 command;RunID、CommandID、BaseRevision 和 grant 属于 envelope/commit metadata。`AgentEvent.Digest` 覆盖 schema、type 与完整 fact。`TransitionDigest` 覆盖自身以外的完整 transition,包括有序 event group。 -**RUN-WIR-3** 一个 transition 的 event 数至少为 1;其 RunID、Revision、CommandID、CommandDigest 相同,Index 从 0 连续递增。revision 从 1 连续递增。构造 command 必须使用 `BuildEnvelope`;构造与验证 transition 必须使用 `BuildTransitionRecord`、`ValidateTransitionRecord`。所有公开返回值具有 detached snapshot 语义。 +**RUN-WIR-3** 一个 transition 的 event 数至少为 1;其 RunID、Revision、CommandID、CommandDigest 相同,Index 从 0 连续递增。revision 从 1 连续递增。写入已存在的 Run 时,构造 command 必须使用该 Run 的 `Protocol.BuildEnvelope`(Loop 通过 `RuntimeSnapshot.Protocol()` 取得)。包级 `BuildEnvelope` 只服务当前写入 schema,供新 Run 与测试。构造与验证 transition 必须使用 `BuildTransitionRecord`、`ValidateTransitionRecord`。所有公开返回值具有 detached snapshot 语义。 下列 identity 稳定派生并由 Commit 验证: @@ -103,7 +103,8 @@ type TransitionRecord struct { | ResponseID | RunID、ToolStepID、CallID、ResponseKind | | response CommandID | RunID、StepID、CallID、ResponseID | | input CommandID | RunID、InputID | -| recovery CommandID | RunID、StepID、Claim、CallID(如适用)、recovery kind | +| model recovery CommandID | RunID、StepID、Claim | +| system recovery CommandID | RunID、StepID、CallID、recovery record | 同一派生 identity 的内容变化通过 command digest 触发 conflict。`PlanningToken` 是 Application-owned opaque freshness token,属于 prepare command identity 内容;Planner 负责解释它的 freshness,`ModelStepPrepared` 保存冻结请求及其 digest。 @@ -136,7 +137,7 @@ type RunRecord struct { Header 创建后 immutable。`InitialStateDigest` 覆盖 frozen initial state;`HeaderDigest` 覆盖 schema、RunID、initial-state version/digest 与 causation。`ValidateRunHeader` 验证这些约束。 -**RUN-NEW-2** `FoldRun(header, transitions)` 先验证 header,再按 revision/index 使用 `ProtocolFor(header.SchemaVersion).Evolve` 折叠完整 transition sequence。Fold 过程执行纯状态重建。import、diagnostic 与 `Runtime.Record` integrity verification 从 header 开始;外部 snapshot 通过 FoldRun 结果校验。 +**RUN-NEW-2** `FoldRun(header, transitions)` 先验证 header,再按 revision/index 折叠完整 transition sequence。每条 `TransitionRecord` 按其自身 `SchemaVersion` 绑定 `Protocol` 再 `Evolve`。合法 Run 的每条 record 的 SchemaVersion 必须等于 `header.SchemaVersion`(RUN-CMP-1)。Fold 过程执行纯状态重建。import、diagnostic 与 `Runtime.Record` integrity verification 从 header 开始;外部 snapshot 通过 FoldRun 结果校验。 ## 4. Machine @@ -158,9 +159,23 @@ type RunResult struct { Status RunStatus Reason RunReason Failure *RunFailure + UncertainCalls []CallID + UncertainModel StepID Model *ModelResult Usage Usage } + +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 +} ``` Run status 为 `RunActive | RunCompleted | RunStopped | RunFailed`。`RunCompleted`、`RunStopped` 与 `RunFailed` 是 terminal status。`RunStatus` 表示当前 MachineState 的投影;终态 fact 使用 RunEnd union 表达具体结果。`Step` 是 sealed interface,只有 `ModelStep` 与 `ToolStep`。 @@ -221,17 +236,17 @@ ToolCall: | `SubmitModelFailure` | Model Executing;`RunEnded(failed/provider_failure)` | | `RejectModelResult` | Model Executing;`ModelStepRejected`,由调用方显式选择回到 Prepared 或在同一 transition 追加 `RunEnded(failed/malformed_model_result)` | | `StartToolCall` | Tool Pending;`ToolCallStarted`。command 必须携带本次 start 的 `ExecutionClaim` | -| `SubmitToolResult` | Tool Executing;`ToolCallCompleted`,最后一个 call 进入 terminal 时隐式关闭 ToolStep | -| `SubmitToolFailure(Known)` | Tool Pending/Executing;`ToolCallFailed(Known)`,最后一个 call 进入 terminal 时隐式关闭 ToolStep | +| `SubmitToolResult` | Tool Executing;`ToolCallCompleted`。该 fact 经 Evolve 后若全部 call 已 terminal,则关闭 ToolStep | +| `SubmitToolFailure(Known)` | Tool Pending/Executing;`ToolCallFailed(Known)`。Evolve 后若全部 call 已 terminal,则关闭 ToolStep | | `SubmitToolFailure(Unknown)` | Tool Executing;`ToolCallFailed(Unknown)`、`RunEnded(failed/effect_unknown)` | | `ApproveToolCall` | Waiting(Approval);`ToolCallApproved` | -| `RejectToolCall` | Waiting(Approval) 记 `ToolCallFailed(Known/permission_denied)`;Waiting(ExternalResponse) 记 `ToolCallFailed(Known/response_rejected)`。最后一个 call 进入 terminal 时隐式关闭 ToolStep | -| `SubmitToolResponse` | Waiting(ExternalResponse);`ToolCallAnswered`,最后一个 call 进入 terminal 时隐式关闭 ToolStep | -| `CancelRun` | active;仍 Executing 的 tool call 记 `ToolCallFailed(Unknown)`,随后 `RunEnded(stopped/cancelled)`,并在 `RunStoppedEnd` / `RunResult` 上列出 `UncertainCalls` 与 `UncertainModel` | +| `RejectToolCall` | Waiting(Approval) 记 `ToolCallFailed(Known/permission_denied)`;Waiting(ExternalResponse) 记 `ToolCallFailed(Known/response_rejected)`。Evolve 后若全部 call 已 terminal,则关闭 ToolStep | +| `SubmitToolResponse` | Waiting(ExternalResponse);`ToolCallAnswered`。Evolve 后若全部 call 已 terminal,则关闭 ToolStep | +| `CancelRun` | active;仍 Executing 的 tool call 记 `ToolCallFailed(Unknown)`,随后 `RunEnded(stopped/cancelled)`,并在 `RunStoppedEnd` / `RunResult` 上列出 `UncertainCalls` 与 `UncertainModel`。仅 Waiting、没有 Executing 时不给 Waiting call 记 Failed;`RunEnded` 清除 Current | -最后一个 ToolCall 进入 Completed 或 Failed 时,Decide 隐式关闭 ToolStep;没有独立的 `ToolStepClosed` fact。 +没有独立的 `ToolStepClosed` fact。最后一个 ToolCall 进入 Completed 或 Failed 时,`Evolve` 在折叠该 fact 后若全部 call 已 terminal,则把 Current 清掉并写入 `LastToolStep` / `LastClosedStep`。 -**RUN-MCH-3** `Decide(state, command)` 执行全部验证与 derived consequence,一次返回该 transition 的完整 ordered fact group;验证成功后返回完整 facts。`Protocol.Evolve(state, fact)` 机械折叠 fact,依赖 fact 携带的完整数据。accepted facts 必须 self-contained;若 transition terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 +**RUN-MCH-3** `Protocol.Decide(state, command)` 执行全部验证与 derived consequence,一次返回该 transition 的完整 ordered fact group;验证成功后返回完整 facts。包级 `Decide` 委托当前写入 schema。`Protocol.Evolve(state, fact)` 机械折叠 fact,依赖 fact 携带的完整数据。accepted facts 必须 self-contained;若 transition terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 启动 command 的最小公共形状为: @@ -257,7 +272,7 @@ Loop 在提交 start 前生成并保留 `Claim`、`CommandID` 与 command digest | state | effect | |---|---| -| terminal | 无 | +| terminal | 返回 `ErrRunTerminal`,没有 effect | | Current=nil | `NeedModelRequest{PlanningHint}` | | Model Prepared | `StartModelCall` | | Model Executing | `Idle` | @@ -282,7 +297,17 @@ type RuntimeSnapshot struct { Revision uint64 SchemaVersion uint16 // RunHeader.SchemaVersion } +type Protocol struct { + // ProtocolFor 一次绑定该 SchemaVersion 的 digest、decode、Decide、Evolve。 + // 方法不再接受 version 参数。零值 Version()==0,不得调用。 +} +func ProtocolFor(schemaVersion uint16) (Protocol, error) func (RuntimeSnapshot) Protocol() (Protocol, error) // ProtocolFor(SchemaVersion) +func (Protocol) Version() uint16 +func (Protocol) BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) +func (Protocol) Decide(MachineState, AgentCommand) ([]Fact, error) +func (Protocol) Evolve(MachineState, Fact) (MachineState, error) +var ProtocolV1 Protocol // SchemaVersion1 绑定;包级 DigestRequest/BuildEnvelope 委托它 type CommitRequest struct { BaseRevision uint64 Grant ExecutionGrant @@ -309,7 +334,7 @@ type CommitResult struct { 4 same CommandID/different digest -> conflict 5 terminal check 6 validate hard CAS / target state / execution grant / recovery authority -7 facts = Decide(current, command) exactly once +7 facts = Protocol.Decide(current, command) exactly once 8 snapshot each fact; Protocol.Evolve in order; assign next Revision and Index 9 build and validate one complete TransitionRecord 10 atomically persist transition and new MachineState @@ -323,7 +348,7 @@ type CommitResult struct { **RUN-CMT-6** Commit 必须原子保存新 MachineState 与完整 TransitionRecord,保证 event group 完整写入。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 -**RUN-CMT-7** 每个 Run 的协议版本是 `RunHeader.SchemaVersion`,在 Create 时冻结。`RuntimeSnapshot.SchemaVersion` 必须等于该 header。`ProtocolFor(header.SchemaVersion)` 在 Run 边界返回绑定了该版本 digest/decode/Decide/Evolve 函数的 `Protocol` 值;随后的方法调用不再接受 version 参数。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Protocol 的 Version。不得使用进程全局 `currentSchemaVersion` 作为写入许可。v1 Run 的 replay 必须继续使用 `ProtocolV1`,即使进程已经把 `currentSchemaVersion` 升到 2。Loop 通过 `RuntimeSnapshot.Protocol()` 构造写入该 Run 的 envelope。 +**RUN-CMT-7** 每个 Run 的协议版本是 `RunHeader.SchemaVersion`,在 Create 时冻结。`RuntimeSnapshot.SchemaVersion` 必须等于该 header。`ProtocolFor(header.SchemaVersion)` 在 Run 边界返回绑定了该版本 digest/decode/Decide/Evolve 函数的 `Protocol` 值;随后的方法调用不再接受 version 参数。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Protocol 的 Version。不得使用进程全局 `currentSchemaVersion` 作为写入许可。v1 Run 的 replay 必须继续使用 `ProtocolV1`,即使进程已经把 `currentSchemaVersion` 升到 2。Loop 通过 `RuntimeSnapshot.Protocol().BuildEnvelope` 构造写入该 Run 的 envelope。 `MemoryRuntime` 提供 multi-Run in-process reference implementation:collection lock 保护 Run map,每个 Run 使用独立锁。跨进程 durable recovery 由其他 Runtime adapter 提供。 @@ -390,7 +415,7 @@ func (*Loop) Run(context.Context, run.Runtime, run.RunID, EventSink) (LoopResult **RUN-LOP-7** `ModelRef` 是冻结请求中的执行身份。`ModelCatalog.Resolve` 在同一 Run 生命周期内必须把同一 `ModelRef` 解析为等价的执行语义。provider 绑定不进入 frozen request,因此 Catalog 不得把同一 ref 改绑到不同实现。 -`LoopResult` 的语义固定为:`LoopWaiting` 时 `Result` 为 nil,表示没有可执行 effect、Run 仍为 active。`ExecutionRecovery` 为 true 时至少有一个 call 仍为 Executing,由 recovery authority 唤醒。`Reason` 在该情况下为 `execution_recovery`,Idle 时为空。Waiting call 不进入 `LoopResult`;Application 通过 `Runtime.Load` / `Record` 与 `WaitingCalls` 读取。`LoopFinished` 时 `Result` 非 nil,并等于 terminal RunRecord 派生的 `RunResult`。 +`LoopResult` 的语义固定为:`LoopWaiting` 时 `Result` 为 nil,表示没有可执行 effect、Run 仍为 active。`ExecutionRecovery` 等于 `NeedsRecovery(state)`:Model Executing,或 ToolStep 无 Pending 且仍有 Executing。该值为 true 时由 recovery authority 唤醒,`Reason` 为 `execution_recovery`;否则 `Reason` 为空。Waiting call 不进入 `LoopResult`;Application 通过 `Runtime.Load` / `Record` 与 `WaitingCalls` 读取。`LoopFinished` 时 `Result` 非 nil,并等于 terminal RunRecord 派生的 `RunResult`。 `RequestPlanner` 从 `PlanningHint` 接收 Run 边界事实;它使用自己注入的 history、session context、memory、attachments 与 product policy 组装 `sdk.Request`。Runtime 验证并冻结 planner 返回的 request,Planner 管理 application context。 @@ -402,6 +427,8 @@ Loop.Run(ctx, runtime, runID, sink): snapshot = Runtime.Load(runID) validate snapshot.State.RunID == runID if terminal: emit observational run_finished; return Finished(snapshot.Result) + if resumeSettlement: continue // 重放可能已提交、响应丢失的 settlement + if resumeCachedStart: continue // 重放本进程已接受、尚未结算的 start effect = run.Next(snapshot.State) dispatch effect ``` @@ -412,7 +439,7 @@ Loop.Run(ctx, runtime, runID, sink): **RUN-LOP-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须保留 start command 的 ID、digest 和 claim,直到完成 settlement;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。streaming 与 non-streaming 必须产生同一种完整 `sdk.ModelResult`;delta 只发 EventSink。`ModelCatalog.Resolve` 失败或返回 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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call 在本次 `Run` 内 Start 并结算。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `WaitingCalls`,提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 之后再次 `Run`。tool panic 或 effect 状态无法确定的错误转为 Unknown;一个 Unknown 取消同批 sibling workers,并由 Machine 在同一 terminal transition 中记录目标及仍 Executing sibling 的 `ToolCallFailed(Unknown)`,最后追加 `RunEnded(failed/effect_unknown)`。`CancelRun` 也在 `RunEnded(stopped/cancelled)` 前记录所有仍 Executing call 的 Unknown。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 +**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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call 在本次 `Run` 内 Start 并结算。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `WaitingCalls`,提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 之后再次 `Run`。tool panic 或 effect 状态无法确定的错误转为 Unknown;一个 Unknown 取消同批 sibling workers,并由 Machine 在同一 terminal transition 中记录目标及仍 Executing sibling 的 `ToolCallFailed(Unknown)`,最后追加 `RunEnded(failed/effect_unknown)`。`CancelRun` 在 `RunEnded(stopped/cancelled)` 前记录所有仍 Executing call 的 Unknown,并把这些 CallID 与仍 Executing 的 ModelStep 写入 `RunStoppedEnd` / `RunResult` 的 `UncertainCalls`、`UncertainModel`。仅 Waiting、没有 Executing 时不给 Waiting call 记 Failed。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 **RUN-LOP-5** model 与 tool worker 都接收外层 ctx;Loop 对已接受 effect 使用独立 control context 完成 known/unknown outcome settlement。Application 的业务停止顺序为先 Commit `CancelRun`,再取消 Loop ctx。非 sentinel Commit error 以同 CommandID/digest 重放一次;仍未知时返回错误,由后续 Load/Record 查询 authority。stale/terminal/conflict 触发 reload/drop,旧 external effect 保持单次执行尝试。工具实现配合 context 返回;永久阻塞由 application/durable recovery 处理。 diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index 2cce9e6..3ef865c 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -201,7 +201,7 @@ run_requested{RunRequestedPayload} driver.Drive(ctx, DriveRequest{Ref: ref, RunID: linkage.RunID}) ``` -无论 driver 返回完成、没有可执行 effect、error,Coordinator 均再次 Record、调用 `MaterializeAll(record)` 补齐完整新前缀,并据 record snapshot/terminal fact 返回 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`。前两者不返回 terminal Result。snapshot 含 Waiting call 时 `Waiting` 列为 `WaitingCalls`;`ExecutionRecovery` 仅在 snapshot 仍有 Executing call 时为真。Loop 返回 `LoopWaiting` 且 `ExecutionRecovery` 为 true 时 Disposition 为 `ResumeWaitingForRecovery`,即使 snapshot 同时含 Waiting call。仅有 Waiting、无 Executing 时 Disposition 为 `ResumeWaitingForResponse`。空 `Waiting` 且需要 recovery 时由 recovery authority 负责后续唤醒。 +无论 driver 返回完成、没有可执行 effect、error,Coordinator 均再次 Record、调用 `MaterializeAll(record)` 补齐完整新前缀,并据 record snapshot/terminal fact 返回 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`。前两者不返回 terminal Result。snapshot 含 Waiting call 时 `Waiting` 列为 `WaitingCalls`。`ExecutionRecovery` 与 `NeedsRecovery(snapshot)` 相同:Model Executing,或 ToolStep 无 Pending 且仍有 Executing tool call。Loop 返回 `LoopWaiting` 且 `ExecutionRecovery` 为 true 时 Disposition 为 `ResumeWaitingForRecovery`,即使 snapshot 同时含 Waiting call。仅有 Waiting、无 Executing、Model 非 Executing 时 Disposition 为 `ResumeWaitingForResponse`。空 `Waiting` 且需要 recovery 时由 recovery authority 负责后续唤醒。 **TRN-RSM-6** driver 的 provisional stream、返回错误、网络响应丢失、context cancel 作为观察结果处理。driver 以 shared Runtime 的 Load/Commit 推进 MachineState;并发 Resume 依 Runtime command idempotency、revision、grant、record 一致性收敛。具体执行器可以使用 `agent/run/loop.Loop`。Loop 只解释可执行 effect(prepare、start model、start tools)。`Idle` 映射为 `LoopWaiting`;`ExecutionRecovery` 来自 `NeedsRecovery(snapshot)`,不携带 response request。Coordinator 始终以随后读取的 `RunRecord` 判断 Waiting call、execution recovery、terminal 与 settlement。Application 对 Runtime 提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 后再 Resume。 From d2b3c521bc0f901972d41c609bd3dfbbe3baa4b3 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 1 Sep 2026 16:51:06 +0800 Subject: [PATCH 46/81] Fill remaining run spec gaps against the implementation List ModelStep and the full Protocol method set. Qualify that Loop stops starting DirectExecution calls when the outer context is cancelled. Record that Cancel without an Evolve close leaves LastToolStep unchanged. Extend Loop conformance with catalog resolve, frozen scheduling, and uncertain cancel projection. --- docs/design/agent-run.md | 48 +++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 87ec1d9..d79cb79 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -165,6 +165,17 @@ type RunResult struct { Usage Usage } +type ModelStepStatus uint8 // Prepared | Executing +type ModelStep struct { + RefValue StepRef + Request ModelRequest + RequestDigest Digest + Model ModelRef + Tools []ToolSpec + ToolsDigest Digest + Status ModelStepStatus + Rejects int // 已接受的 ModelStepRejected 次数;不进入 RefValue.Digest +} type ToolScheduleMode string // "parallel" | "sequential";空值按 parallel 解释 type ToolScheduling struct { Mode ToolScheduleMode @@ -220,7 +231,7 @@ ToolCall: Waiting(ExternalResponse) -> Completed | Failed(Known) ``` -**RUN-MCH-1** MachineState 保存 Run 的 execution semantics。`LastToolStep` 保存最近一个已关闭 ToolStep 的只读投影,必须与 transition log 折叠出的最后关闭 step 一致,供下一次 planner 构造模型请求。terminal state 吸收所有未幂等命令;`RunEnded` 建立唯一 terminal result。RunResult 在该 terminal transition 中建立,并由后续 snapshot/record 读取。 +**RUN-MCH-1** MachineState 保存 Run 的 execution semantics。`LastToolStep` 保存最近一个经 Evolve 关闭路径写下的 ToolStep 只读投影,必须与 transition log 折叠出的最后关闭 step 一致,供下一次 planner 构造模型请求。Cancel 只清 Current、不走关闭路径时不改写 `LastToolStep`。terminal state 吸收所有未幂等命令;`RunEnded` 建立唯一 terminal result。RunResult 在该 terminal transition 中建立,并由后续 snapshot/record 读取。 **RUN-MCH-2** `ToolCallBinding` 冻结 CallID、ToolRef、definition digest、canonical arguments、response policy 与 binding digest。已知工具使用匹配 frozen ToolSpec 的 ref/digest/policy;未知工具保留为同名 unresolved DirectExecution binding,并在执行前收束为已知 lookup failure。approval/external response 的 `ResponseRequest` 由 Decide 稳定派生。Unknown outcome 使用 `effect_unknown`,并使 Run 进入 `RunFailed(effect_unknown)`;后续处理由新的 Run 决定。 @@ -242,9 +253,9 @@ ToolCall: | `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`。Evolve 后若全部 call 已 terminal,则关闭 ToolStep | -| `CancelRun` | active;仍 Executing 的 tool call 记 `ToolCallFailed(Unknown)`,随后 `RunEnded(stopped/cancelled)`,并在 `RunStoppedEnd` / `RunResult` 上列出 `UncertainCalls` 与 `UncertainModel`。仅 Waiting、没有 Executing 时不给 Waiting call 记 Failed;`RunEnded` 清除 Current | +| `CancelRun` | active;仍 Executing 的 tool call 记 `ToolCallFailed(Unknown)`,随后 `RunEnded(stopped/cancelled)`,并在 `RunStoppedEnd` / `RunResult` 上列出 `UncertainCalls` 与 `UncertainModel`。仅 Waiting、没有 Executing 时不给 Waiting call 记 Failed;`RunEnded` 清除 Current,且不更新 `LastToolStep` | -没有独立的 `ToolStepClosed` fact。最后一个 ToolCall 进入 Completed 或 Failed 时,`Evolve` 在折叠该 fact 后若全部 call 已 terminal,则把 Current 清掉并写入 `LastToolStep` / `LastClosedStep`。 +没有独立的 `ToolStepClosed` fact。最后一个 ToolCall 进入 Completed 或 Failed 时,`Evolve` 在折叠该 fact 后若全部 call 已 terminal,则把 Current 清掉并写入 `LastToolStep` / `LastClosedStep`。Cancel 只清 Current、不经这条关闭路径时,`LastToolStep` 保持原值。 **RUN-MCH-3** `Protocol.Decide(state, command)` 执行全部验证与 derived consequence,一次返回该 transition 的完整 ordered fact group;验证成功后返回完整 facts。包级 `Decide` 委托当前写入 schema。`Protocol.Evolve(state, fact)` 机械折叠 fact,依赖 fact 携带的完整数据。accepted facts 必须 self-contained;若 transition terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 @@ -298,16 +309,30 @@ type RuntimeSnapshot struct { SchemaVersion uint16 // RunHeader.SchemaVersion } type Protocol struct { - // ProtocolFor 一次绑定该 SchemaVersion 的 digest、decode、Decide、Evolve。 - // 方法不再接受 version 参数。零值 Version()==0,不得调用。 + // ProtocolFor 一次绑定该 SchemaVersion 的函数。方法不再接受 version 参数。 + // 零值 Version()==0,不得调用。 } func ProtocolFor(schemaVersion uint16) (Protocol, error) func (RuntimeSnapshot) Protocol() (Protocol, error) // ProtocolFor(SchemaVersion) func (Protocol) Version() uint16 -func (Protocol) BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) +func (Protocol) DigestRequest(ModelRequest) (Digest, error) +func (Protocol) DigestToolDefinition(ToolDefinition) (Digest, error) +func (Protocol) DigestToolSpec(ToolSpec) (Digest, error) +func (Protocol) DigestToolSpecs([]ToolSpec) (Digest, error) +func (Protocol) DigestModelStepBinding(ModelRef, Digest, Digest) (Digest, error) +func (Protocol) DigestToolResponseDecision(ResponseKind, ResponseDecision, string) (Digest, error) +func (Protocol) DigestToolResponsePayload(CanonicalJSON) (Digest, error) +func (Protocol) DigestCommand(typ string, command AgentCommand) (Digest, error) +func (Protocol) DigestFact(typ string, fact Fact) (Digest, error) +func (Protocol) EncodeFact(typ string, fact Fact) ([]byte, error) +func (Protocol) DecodeCommand(typ string, raw []byte) (AgentCommand, error) +func (Protocol) DecodeFact(typ string, raw []byte) (Fact, error) func (Protocol) Decide(MachineState, AgentCommand) ([]Fact, error) func (Protocol) Evolve(MachineState, Fact) (MachineState, error) -var ProtocolV1 Protocol // SchemaVersion1 绑定;包级 DigestRequest/BuildEnvelope 委托它 +func (Protocol) BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) +func (Protocol) EncodeMachineState(*MachineState) ([]byte, error) +func (Protocol) ValidateHeader(*RunHeader) error +var ProtocolV1 Protocol // SchemaVersion1 绑定;包级 DigestRequest/BuildEnvelope/Decide/Evolve 委托它 type CommitRequest struct { BaseRevision uint64 Grant ExecutionGrant @@ -403,7 +428,7 @@ type ExecutionPolicy struct { } type LoopResult struct { Disposition LoopDisposition // LoopWaiting | LoopFinished - Reason WaitReason // execution_recovery;Idle 时为空 + Reason WaitReason // 仅 ExecutionRecovery 时为 execution_recovery;否则为空 ExecutionRecovery bool Result *run.RunResult } @@ -439,7 +464,7 @@ Loop.Run(ctx, runtime, runID, sink): **RUN-LOP-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须保留 start command 的 ID、digest 和 claim,直到完成 settlement;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。streaming 与 non-streaming 必须产生同一种完整 `sdk.ModelResult`;delta 只发 EventSink。`ModelCatalog.Resolve` 失败或返回 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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call 在本次 `Run` 内 Start 并结算。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `WaitingCalls`,提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 之后再次 `Run`。tool panic 或 effect 状态无法确定的错误转为 Unknown;一个 Unknown 取消同批 sibling workers,并由 Machine 在同一 terminal transition 中记录目标及仍 Executing sibling 的 `ToolCallFailed(Unknown)`,最后追加 `RunEnded(failed/effect_unknown)`。`CancelRun` 在 `RunEnded(stopped/cancelled)` 前记录所有仍 Executing call 的 Unknown,并把这些 CallID 与仍 Executing 的 ModelStep 写入 `RunStoppedEnd` / `RunResult` 的 `UncertainCalls`、`UncertainModel`。仅 Waiting、没有 Executing 时不给 Waiting call 记 Failed。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 +**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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call,在外层 ctx 未取消时于本次 `Run` 内按冻结 Scheduling 分批 Start 并结算;ctx 已取消时停止再 Start,只结算已持有 grant 的 call。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `WaitingCalls`,提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 之后再次 `Run`。tool panic 或 effect 状态无法确定的错误转为 Unknown;一个 Unknown 取消同批 sibling workers,并由 Machine 在同一 terminal transition 中记录目标及仍 Executing sibling 的 `ToolCallFailed(Unknown)`,最后追加 `RunEnded(failed/effect_unknown)`。`CancelRun` 在 `RunEnded(stopped/cancelled)` 前记录所有仍 Executing call 的 Unknown,并把这些 CallID 与仍 Executing 的 ModelStep 写入 `RunStoppedEnd` / `RunResult` 的 `UncertainCalls`、`UncertainModel`。仅 Waiting、没有 Executing 时不给 Waiting call 记 Failed。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 **RUN-LOP-5** model 与 tool worker 都接收外层 ctx;Loop 对已接受 effect 使用独立 control context 完成 known/unknown outcome settlement。Application 的业务停止顺序为先 Commit `CancelRun`,再取消 Loop ctx。非 sentinel Commit error 以同 CommandID/digest 重放一次;仍未知时返回错误,由后续 Load/Record 查询 authority。stale/terminal/conflict 触发 reload/drop,旧 external effect 保持单次执行尝试。工具实现配合 context 返回;永久阻塞由 application/durable recovery 处理。 @@ -484,7 +509,10 @@ Loop conformance 必须覆盖: - 单模型完成、tool round trip、approval/external response wait/resume; - known failure 继续、Unknown terminal、tool panic、aliased ToolRef 与 validation; -- parallel/sequential tool execution、ctx cancellation、model recovery、explicit malformed-result disposition; +- parallel/sequential 按冻结 `ToolStep.Scheduling` 调度,不得改用当时 ExecutionPolicy; +- `ModelCatalog.Resolve` 失败或 nil 时恢复 ModelStep、Run 保持 active; +- ctx cancellation、model recovery、explicit malformed-result disposition; +- Cancel 将 Executing tool/model 投影到 `UncertainCalls` / `UncertainModel`;ExternalResponse reject 为 `response_rejected`; - streaming delta 与 nil result、EventSink committed observation; - stale/unknown commit response、prepare no-progress rejection 与无 livelock。 From 9b607d8234827de266f36ab923197a5fb7d5d1ef Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 1 Sep 2026 20:11:13 +0800 Subject: [PATCH 47/81] Split Runtime persistence into a Store contract Runtime evaluates commits; Store persists header, state, log, and leases. MemoryStore is the in-process Store. NewRuntime(store) is the single Runtime implementation. Expired leases set recoveryValid so grantless Recover works; zero deadline never expires. --- agent/run/fold.go | 27 --- agent/run/memory_runtime.go | 344 +++--------------------------------- agent/run/memory_store.go | 172 ++++++++++++++++++ agent/run/store.go | 86 +++++++++ agent/run/store_test.go | 104 +++++++++++ agent/run/stored_runtime.go | 337 +++++++++++++++++++++++++++++++++++ docs/design/agent-run.md | 2 +- 7 files changed, 727 insertions(+), 345 deletions(-) create mode 100644 agent/run/memory_store.go create mode 100644 agent/run/store.go create mode 100644 agent/run/store_test.go create mode 100644 agent/run/stored_runtime.go diff --git a/agent/run/fold.go b/agent/run/fold.go index 9fa427f..f168de0 100644 --- a/agent/run/fold.go +++ b/agent/run/fold.go @@ -122,33 +122,6 @@ func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineS return state, uint64(revision), nil } -// Rebuild is an optional diagnostic (RUN-CMT-2): it refolds the state from -// the transition log and replaces the in-memory state with the fold result. -// Runtime.Commit remains the normal state transition path. A log shorter than -// the last committed revision returns ErrLogTruncated (audit gap). It returns -// true when the refolded state differs from the stored state, which identifies -// an Evolve bug, out-of-band write, or storage corruption. -func (m *MemoryRuntime) Rebuild(runID RunID) (rebuilt bool, err error) { - entry, err := m.entry(runID) - if err != nil { - return false, err - } - entry.mu.Lock() - defer entry.mu.Unlock() - - folded, maxRevision, err := FoldTransitions(cloneMachineState(&entry.initial), entry.log) - if err != nil { - return false, err - } - if maxRevision < entry.watermark { - return false, fmt.Errorf("%w: log ends at %d, watermark %d", ErrLogTruncated, maxRevision, entry.watermark) - } - diverged := entry.revision != maxRevision || !statesEquivalent(&entry.state, &folded) - entry.state = folded - entry.revision = maxRevision - return diverged, nil -} - // statesEquivalent compares two states via their canonical serialization — // the same identity rule the protocol uses everywhere else. func statesEquivalent(a, b *MachineState) bool { diff --git a/agent/run/memory_runtime.go b/agent/run/memory_runtime.go index 055dc97..470a7c8 100644 --- a/agent/run/memory_runtime.go +++ b/agent/run/memory_runtime.go @@ -1,345 +1,55 @@ package run import ( - "context" - "crypto/rand" - "encoding/hex" "errors" "fmt" - "sync" - "time" ) -// MemoryRuntime is the in-process reference Runtime. Its collection lock is -// used only to create or find stable per-Run entries; each entry has an -// independent lock for state, canonical commit records, revision, and grants. -// Runtime operations check context before and after entry access and before -// mutating the protected state. +// MemoryRuntime is the in-process reference Runtime: a Store-backed runtime +// over MemoryStore. Tests that mutate storage for Rebuild diagnostics use +// this concrete type; Loop and Turn depend only on Runtime. type MemoryRuntime struct { - mu sync.RWMutex - runs map[RunID]*memoryRun -} - -type memoryRun struct { - mu sync.Mutex - header RunHeader - state MachineState - revision uint64 - initial MachineState - watermark uint64 - transitions map[CommandID]TransitionRecord - log []TransitionRecord - grants map[string]ExecutionGrant - claims map[string]ExecutionClaim - // startGrants lets an exact start-command replay recover the capability - // that was issued before its response was lost. - startGrants map[CommandID]ExecutionGrant -} - -func lockMemory(ctx context.Context, mu *sync.Mutex) error { - for { - if mu.TryLock() { - return nil - } - timer := time.NewTimer(time.Millisecond) - select { - case <-ctx.Done(): - if !timer.Stop() { - <-timer.C - } - return ctx.Err() - case <-timer.C: - } - } + *runtime + mem *MemoryStore } // NewMemoryRuntime creates an empty, RunID-addressed in-process Runtime. +// Leases do not expire; grantless recovery requires a Store with deadlines. func NewMemoryRuntime() *MemoryRuntime { - return &MemoryRuntime{runs: make(map[RunID]*memoryRun)} + mem := NewMemoryStore() + return &MemoryRuntime{runtime: newRuntime(mem, RuntimeOptions{}), mem: mem} } func (m *MemoryRuntime) entry(runID RunID) (*memoryRun, error) { - if m == nil { + if m == nil || m.mem == nil { return nil, errors.New("agent: memory runtime: nil runtime") } - m.mu.RLock() - entry := m.runs[runID] - m.mu.RUnlock() - if entry == nil { - return nil, ErrRunNotFound - } - return entry, nil -} - -func (m *MemoryRuntime) Create(ctx context.Context, run NewRun) (CreateResult, error) { - if err := checkContext(ctx); err != nil { - return CreateResult{}, err - } - header, err := BuildRunHeaderFromNewRun(run) - if err != nil { - return CreateResult{}, err - } - if m == nil { - return CreateResult{}, errors.New("agent: memory runtime: nil runtime") - } - - m.mu.Lock() - defer m.mu.Unlock() - if err := checkContext(ctx); err != nil { - return CreateResult{}, err - } - if existing := m.runs[run.RunID]; existing != nil { - // Header is immutable after admission, so the collection lock protects - // this lookup without taking the entry's execution lock. - existingHeader := cloneRunHeader(existing.header) - equal, err := canonicalHeadersEqual(existingHeader, header) - if err != nil { - return CreateResult{}, err - } - if !equal { - return CreateResult{}, ErrCreateConflict - } - return CreateResult{Header: existingHeader, Created: false}, nil - } - - stored := cloneRunHeader(header) - initial := cloneMachineState(&stored.InitialState) - m.runs[run.RunID] = &memoryRun{ - header: stored, - state: cloneMachineState(&initial), - initial: initial, - transitions: make(map[CommandID]TransitionRecord), - grants: make(map[string]ExecutionGrant), - claims: make(map[string]ExecutionClaim), - startGrants: make(map[CommandID]ExecutionGrant), - } - return CreateResult{Header: cloneRunHeader(stored), Created: true}, nil + return m.mem.entry(runID) } -func (m *MemoryRuntime) Load(ctx context.Context, runID RunID) (RuntimeSnapshot, error) { - if err := checkContext(ctx); err != nil { - return RuntimeSnapshot{}, err - } +// Rebuild is an optional diagnostic (RUN-CMT-2): it refolds the state from +// the transition log and replaces the stored state with the fold result. +// Runtime.Commit remains the normal state transition path. A log shorter than +// the last committed revision returns ErrLogTruncated (audit gap). It returns +// true when the refolded state differs from the stored state, which identifies +// an Evolve bug, out-of-band write, or storage corruption. +func (m *MemoryRuntime) Rebuild(runID RunID) (rebuilt bool, err error) { entry, err := m.entry(runID) if err != nil { - return RuntimeSnapshot{}, err - } - if err := lockMemory(ctx, &entry.mu); err != nil { - return RuntimeSnapshot{}, err - } - if err := checkContext(ctx); err != nil { - entry.mu.Unlock() - return RuntimeSnapshot{}, err - } - snapshot := RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision, SchemaVersion: entry.header.SchemaVersion} - header := cloneRunHeader(entry.header) - entry.mu.Unlock() - if err := ValidateRunHeader(&header); err != nil { - return RuntimeSnapshot{}, fmt.Errorf("agent: memory runtime: invalid header: %w", err) - } - if err := ValidateMachineState(&snapshot.State); err != nil { - return RuntimeSnapshot{}, fmt.Errorf("agent: memory runtime: invalid snapshot: %w", err) - } - return snapshot, nil -} - -func verifyRuntimeSnapshot(header *RunHeader, initial *MachineState, snapshot RuntimeSnapshot, transitions []TransitionRecord) error { - if err := ValidateRunHeader(header); err != nil { - return err - } - folded, revision, err := FoldTransitions(*initial, transitions) - if err != nil { - return err - } - if revision != snapshot.Revision || !statesEquivalent(&folded, &snapshot.State) { - return errors.New("snapshot diverges from transition log") - } - return nil -} - -func grantKey(c AgentCommand) string { - switch cmd := c.(type) { - case StartModelExecution: - return "model/" + string(cmd.StepID) - case SubmitModelResult: - return "model/" + string(cmd.StepID) - case SubmitModelFailure: - return "model/" + string(cmd.StepID) - case RejectModelResult: - return "model/" + string(cmd.StepID) - case RecoverModelExecution: - return "model/" + string(cmd.StepID) - case StartToolCall: - return "call/" + string(cmd.StepID) + "/" + string(cmd.CallID) - case SubmitToolResult: - return "call/" + string(cmd.StepID) + "/" + string(cmd.CallID) - case SubmitToolFailure: - return "call/" + string(cmd.StepID) + "/" + string(cmd.CallID) - default: - return "" - } -} - -func newGrant() ExecutionGrant { - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - panic(fmt.Sprintf("agent: memory runtime: %v", err)) - } - return ExecutionGrant(hex.EncodeToString(b[:])) -} - -func (entry *memoryRun) forgetStartGrant(grant ExecutionGrant) { - if grant == "" { - return - } - for commandID, candidate := range entry.startGrants { - if candidate == grant { - delete(entry.startGrants, commandID) - } - } -} - -// Commit atomically evaluates and writes the Run addressed by Command.RunID. -// -//nolint:gocritic // hugeParam: CommitRequest is the value DTO of the Runtime authority boundary. -func (m *MemoryRuntime) Commit(ctx context.Context, req CommitRequest) (CommitResult, error) { - if err := checkContext(ctx); err != nil { - return CommitResult{}, err - } - entry, err := m.entry(req.Command.RunID) - if err != nil { - return CommitResult{}, err - } - if err := lockMemory(ctx, &entry.mu); err != nil { - return CommitResult{}, err + return false, err } + entry.mu.Lock() defer entry.mu.Unlock() - if err := checkContext(ctx); err != nil { - return CommitResult{}, err - } - - key := grantKey(req.Command.Command) - grantValid := key != "" && req.Grant != "" && entry.grants[key] == req.Grant - if cmd, ok := req.Command.Command.(RecoverModelExecution); ok && req.Grant != "" { - grantValid = grantValid && entry.claims[key] == cmd.Claim - } - recoveryValid := false - - var prior *TransitionRecord - if record, ok := entry.transitions[req.Command.ID]; ok { - copy := cloneTransitionRecord(&record) - prior = © - } - proto, err := ProtocolFor(entry.header.SchemaVersion) - if err != nil { - return CommitResult{}, err - } - decision, err := EvaluateCommit(entry.state, entry.revision, prior, req, grantValid, recoveryValid, proto) - if err != nil { - return CommitResult{}, err - } - switch decision.Kind { - case DecisionAlreadyApplied: - var grant ExecutionGrant - if _, ok := req.Command.Command.(StartModelExecution); ok { - candidate := entry.startGrants[req.Command.ID] - if candidate != "" && entry.grants[key] == candidate { - grant = candidate - } - } else if _, ok := req.Command.Command.(StartToolCall); ok { - candidate := entry.startGrants[req.Command.ID] - if candidate != "" && entry.grants[key] == candidate { - grant = candidate - } - } - return CommitResult{Status: CommitAlreadyApplied, - Snapshot: RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision, SchemaVersion: entry.header.SchemaVersion}, - Events: cloneEvents(decision.Events), Grant: grant}, nil - case DecisionConflict: - return CommitResult{}, ErrCommandConflict - case DecisionStale: - if decision.Reject != nil && !errors.Is(decision.Reject, ErrStaleRuntime) { - return CommitResult{}, fmt.Errorf("%w: %w", ErrStaleRuntime, decision.Reject) - } - return CommitResult{}, ErrStaleRuntime - case DecisionTerminal: - return CommitResult{}, ErrRunTerminal - } - stored := cloneTransitionRecord(&decision.Transition) - entry.state = cloneMachineState(&decision.NewState) - entry.revision++ - entry.watermark = entry.revision - entry.transitions[req.Command.ID] = stored - entry.log = append(entry.log, stored) - - var minted ExecutionGrant - switch cmd := req.Command.Command.(type) { - case StartModelExecution, StartToolCall: - minted = newGrant() - entry.grants[key] = minted - switch c := any(cmd).(type) { - case StartModelExecution: - entry.claims[key] = c.Claim - case StartToolCall: - entry.claims[key] = c.Claim - } - entry.startGrants[req.Command.ID] = minted - case SubmitModelResult, SubmitModelFailure, RejectModelResult, RecoverModelExecution, - SubmitToolResult, SubmitToolFailure: - activeGrant := entry.grants[key] - delete(entry.grants, key) - delete(entry.claims, key) - entry.forgetStartGrant(activeGrant) - } - if entry.state.Status.Terminal() { - entry.grants = make(map[string]ExecutionGrant) - entry.claims = make(map[string]ExecutionClaim) - entry.startGrants = make(map[CommandID]ExecutionGrant) - } - return CommitResult{Status: CommitAccepted, - Snapshot: RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision, SchemaVersion: entry.header.SchemaVersion}, - Events: cloneEvents(stored.Events), Grant: minted}, nil -} - -// Record returns a detached consistent point-in-time snapshot and verifies it -// against the immutable header and complete transition records. -func (m *MemoryRuntime) Record(ctx context.Context, runID RunID) (RunRecord, error) { - if err := checkContext(ctx); err != nil { - return RunRecord{}, err - } - entry, err := m.entry(runID) - if err != nil { - return RunRecord{}, err - } - if err := lockMemory(ctx, &entry.mu); err != nil { - return RunRecord{}, err - } - if err := checkContext(ctx); err != nil { - entry.mu.Unlock() - return RunRecord{}, err - } - header := cloneRunHeader(entry.header) - snapshot := RuntimeSnapshot{State: cloneMachineState(&entry.state), Revision: entry.revision, SchemaVersion: header.SchemaVersion} - transitions := cloneTransitionRecords(entry.log) - entry.mu.Unlock() - if err := checkContext(ctx); err != nil { - return RunRecord{}, err - } - if err := ValidateRunHeader(&header); err != nil { - return RunRecord{}, fmt.Errorf("agent: memory runtime: invalid header: %w", err) - } - for i := range transitions { - if err := ValidateTransitionRecord(&transitions[i]); err != nil { - return RunRecord{}, fmt.Errorf("agent: memory runtime: invalid transition %d: %w", i, err) - } - } - folded, revision, err := FoldRun(&header, transitions) + folded, maxRevision, err := FoldTransitions(cloneMachineState(&entry.initial), entry.log) if err != nil { - return RunRecord{}, fmt.Errorf("agent: memory runtime: fold: %w", err) + return false, err } - if revision != snapshot.Revision || !statesEquivalent(&folded, &snapshot.State) { - return RunRecord{}, errors.New("agent: memory runtime: snapshot diverges from transition log") + if maxRevision < entry.watermark { + return false, fmt.Errorf("%w: log ends at %d, watermark %d", ErrLogTruncated, maxRevision, entry.watermark) } - return RunRecord{Header: cloneRunHeader(header), Snapshot: cloneRuntimeSnapshot(snapshot), Transitions: cloneTransitionRecords(transitions)}, nil + diverged := entry.revision != maxRevision || !statesEquivalent(&entry.state, &folded) + entry.state = folded + entry.revision = maxRevision + return diverged, nil } diff --git a/agent/run/memory_store.go b/agent/run/memory_store.go new file mode 100644 index 0000000..4a52ee4 --- /dev/null +++ b/agent/run/memory_store.go @@ -0,0 +1,172 @@ +package run + +import ( + "context" + "errors" + "sync" + "time" +) + +// MemoryStore is the in-process Store. Collection lock protects the Run map; +// each Run has an independent lock. Lease deadlines are stored; a zero +// deadline never expires. +type MemoryStore struct { + mu sync.RWMutex + runs map[RunID]*memoryRun +} + +type memoryRun struct { + mu sync.Mutex + header RunHeader + state MachineState + revision uint64 + initial MachineState + watermark uint64 + transitions map[CommandID]TransitionRecord + log []TransitionRecord + leases map[string]ExecutionLease + startGrants map[CommandID]ExecutionGrant +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{runs: make(map[RunID]*memoryRun)} +} + +func lockMemory(ctx context.Context, mu *sync.Mutex) error { + for { + if mu.TryLock() { + return nil + } + timer := time.NewTimer(time.Millisecond) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return ctx.Err() + case <-timer.C: + } + } +} + +func (s *MemoryStore) entry(runID RunID) (*memoryRun, error) { + if s == nil { + return nil, errors.New("agent: memory store: nil store") + } + s.mu.RLock() + entry := s.runs[runID] + s.mu.RUnlock() + if entry == nil { + return nil, ErrRunNotFound + } + return entry, nil +} + +func (e *memoryRun) stored() StoredRun { + return StoredRun{ + Header: e.header, + State: e.state, + Revision: e.revision, + Watermark: e.watermark, + Log: e.log, + Transitions: e.transitions, + Leases: e.leases, + StartGrants: e.startGrants, + } +} + +func (e *memoryRun) apply(s StoredRun) { + e.header = s.Header + e.state = s.State + e.revision = s.Revision + e.watermark = s.Watermark + e.log = s.Log + e.transitions = s.Transitions + e.leases = s.Leases + e.startGrants = s.StartGrants +} + +func (s *MemoryStore) Create(ctx context.Context, header RunHeader) (bool, RunHeader, error) { + if err := checkContext(ctx); err != nil { + return false, RunHeader{}, err + } + if s == nil { + return false, RunHeader{}, errors.New("agent: memory store: nil store") + } + s.mu.Lock() + defer s.mu.Unlock() + if err := checkContext(ctx); err != nil { + return false, RunHeader{}, err + } + if existing := s.runs[header.RunID]; existing != nil { + return false, cloneRunHeader(existing.header), nil + } + stored := cloneRunHeader(header) + initial := cloneMachineState(&stored.InitialState) + s.runs[header.RunID] = &memoryRun{ + header: stored, + state: cloneMachineState(&initial), + initial: initial, + transitions: make(map[CommandID]TransitionRecord), + leases: make(map[string]ExecutionLease), + startGrants: make(map[CommandID]ExecutionGrant), + } + return true, cloneRunHeader(stored), nil +} + +func (s *MemoryStore) View(ctx context.Context, id RunID) (StoredRun, error) { + if err := checkContext(ctx); err != nil { + return StoredRun{}, err + } + entry, err := s.entry(id) + if err != nil { + return StoredRun{}, err + } + if err := lockMemory(ctx, &entry.mu); err != nil { + return StoredRun{}, err + } + defer entry.mu.Unlock() + if err := checkContext(ctx); err != nil { + return StoredRun{}, err + } + return cloneStoredRun(entry.stored()), nil +} + +func (s *MemoryStore) Update(ctx context.Context, id RunID, fn func(*StoredRun) error) error { + if err := checkContext(ctx); err != nil { + return err + } + entry, err := s.entry(id) + if err != nil { + return err + } + if err := lockMemory(ctx, &entry.mu); err != nil { + return err + } + defer entry.mu.Unlock() + if err := checkContext(ctx); err != nil { + return err + } + cur := cloneStoredRun(entry.stored()) + if err := fn(&cur); err != nil { + return err + } + entry.apply(cur) + return nil +} + +func (s *MemoryStore) ListIDs(ctx context.Context) ([]RunID, error) { + if err := checkContext(ctx); err != nil { + return nil, err + } + if s == nil { + return nil, errors.New("agent: memory store: nil store") + } + s.mu.RLock() + defer s.mu.RUnlock() + ids := make([]RunID, 0, len(s.runs)) + for id := range s.runs { + ids = append(ids, id) + } + return ids, nil +} diff --git a/agent/run/store.go b/agent/run/store.go new file mode 100644 index 0000000..d93876c --- /dev/null +++ b/agent/run/store.go @@ -0,0 +1,86 @@ +package run + +import ( + "context" + "time" +) + +// ExecutionLease is the Store occupancy record for one live start. Grant is +// the capability returned to the owner Loop. Deadline zero means the lease +// does not expire (process-lifetime occupancy). +type ExecutionLease struct { + Claim ExecutionClaim + Grant ExecutionGrant + StartCommandID CommandID + Deadline time.Time +} + +// StoredRun is one Run's persisted view. Store implementations clone on the +// way out of View and isolate Update mutations until fn returns nil. +type StoredRun struct { + Header RunHeader + State MachineState + Revision uint64 + Watermark uint64 + Log []TransitionRecord + Transitions map[CommandID]TransitionRecord + Leases map[string]ExecutionLease + StartGrants map[CommandID]ExecutionGrant +} + +// Store persists Runs for one Runtime. It does not call EvaluateCommit, +// Decide, or Evolve. SQLite and Postgres adapters implement this contract; +// MemoryStore is the in-process implementation. +type Store interface { + Create(ctx context.Context, header RunHeader) (created bool, existing RunHeader, err error) + View(ctx context.Context, id RunID) (StoredRun, error) + Update(ctx context.Context, id RunID, fn func(*StoredRun) error) error + ListIDs(ctx context.Context) ([]RunID, error) +} + +func cloneStoredRun(s StoredRun) StoredRun { + out := StoredRun{ + Header: cloneRunHeader(s.Header), + State: cloneMachineState(&s.State), + Revision: s.Revision, + Watermark: s.Watermark, + } + if s.Log != nil { + out.Log = cloneTransitionRecords(s.Log) + } + if s.Transitions != nil { + out.Transitions = make(map[CommandID]TransitionRecord, len(s.Transitions)) + for id, rec := range s.Transitions { + out.Transitions[id] = cloneTransitionRecord(&rec) + } + } + if s.Leases != nil { + out.Leases = make(map[string]ExecutionLease, len(s.Leases)) + for k, v := range s.Leases { + out.Leases[k] = v + } + } + if s.StartGrants != nil { + out.StartGrants = make(map[CommandID]ExecutionGrant, len(s.StartGrants)) + for k, v := range s.StartGrants { + out.StartGrants[k] = v + } + } + return out +} + +func (s *StoredRun) forgetStartGrant(grant ExecutionGrant) { + if grant == "" || s.StartGrants == nil { + return + } + for commandID, candidate := range s.StartGrants { + if candidate == grant { + delete(s.StartGrants, commandID) + } + } +} + +func (s *StoredRun) clearLeases() { + s.Leases = make(map[string]ExecutionLease) + s.StartGrants = make(map[CommandID]ExecutionGrant) +} diff --git a/agent/run/store_test.go b/agent/run/store_test.go new file mode 100644 index 0000000..6e3ca9c --- /dev/null +++ b/agent/run/store_test.go @@ -0,0 +1,104 @@ +package run + +import ( + "context" + "testing" + "time" +) + +func TestExpiredLeaseAllowsGrantlessModelRecovery(t *testing.T) { + clock := time.Unix(1000, 0) + store := NewMemoryStore() + rt := NewRuntimeWithOptions(store, RuntimeOptions{ + LeaseTTL: time.Second, + Now: func() time.Time { return clock }, + }) + ctx := context.Background() + newRun, err := BuildNewRun("run-1", "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(ctx, newRun); err != nil { + t.Fatal(err) + } + in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} + env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(ctx, CommitRequest{Command: env}); err != nil { + t.Fatal(err) + } + + snap, err := rt.Load(ctx, "run-1") + if err != nil { + t.Fatal(err) + } + prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) + if _, err := commitCmd(t, rt, cmdID, snap.Revision, "", prep); err != nil { + t.Fatal(err) + } + claim := ExecutionClaim("claim-recover") + startEnv, err := BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) + if err != nil { + t.Fatal(err) + } + start, err := rt.Commit(ctx, CommitRequest{Command: startEnv}) + if err != nil { + t.Fatal(err) + } + if start.Grant == "" { + t.Fatal("start returned no grant") + } + + clock = time.Unix(1002, 0) + recoverEnv, err := BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, claim), RecoverModelExecution{StepID: prep.StepID, Claim: claim}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(ctx, CommitRequest{Command: recoverEnv}); err != nil { + t.Fatal(err) + } + snap, err = rt.Load(ctx, "run-1") + if err != nil { + t.Fatal(err) + } + ms, ok := snap.State.Current.(ModelStep) + if !ok || ms.Status != ModelPrepared { + t.Fatalf("after grantless recover: %+v", snap.State.Current) + } +} + +func TestZeroDeadlineRejectsGrantlessRecovery(t *testing.T) { + rt := NewMemoryRuntime() + ctx := context.Background() + newRun, err := BuildNewRun("run-1", "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(ctx, newRun); err != nil { + t.Fatal(err) + } + in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} + env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(ctx, CommitRequest{Command: env}); err != nil { + t.Fatal(err) + } + snap, _ := rt.Load(ctx, "run-1") + prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) + if _, err := commitCmd(t, rt, cmdID, snap.Revision, "", prep); err != nil { + t.Fatal(err) + } + claim := ExecutionClaim("claim-1") + startEnv, _ := BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) + if _, err := rt.Commit(ctx, CommitRequest{Command: startEnv}); err != nil { + t.Fatal(err) + } + recoverEnv, _ := BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, claim), RecoverModelExecution{StepID: prep.StepID, Claim: claim}) + if _, err := rt.Commit(ctx, CommitRequest{Command: recoverEnv}); err == nil { + t.Fatal("grantless recover accepted on non-expiring lease") + } +} diff --git a/agent/run/stored_runtime.go b/agent/run/stored_runtime.go new file mode 100644 index 0000000..90bd956 --- /dev/null +++ b/agent/run/stored_runtime.go @@ -0,0 +1,337 @@ +package run + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" +) + +// runtime is the single Runtime implementation over a Store. Loop and Turn +// depend only on the Runtime interface. +type runtime struct { + store Store + leaseTTL time.Duration + now func() time.Time +} + +// RuntimeOptions configures lease occupancy for a Store-backed Runtime. +// LeaseTTL 0 means leases do not expire (grant-holder recovery only). +type RuntimeOptions struct { + LeaseTTL time.Duration + Now func() time.Time +} + +// NewRuntime constructs a Runtime over store. SQLite and Postgres adapters +// pass their Store; tests use MemoryStore. +func NewRuntime(store Store) Runtime { + return NewRuntimeWithOptions(store, RuntimeOptions{}) +} + +func NewRuntimeWithOptions(store Store, opts RuntimeOptions) Runtime { + return newRuntime(store, opts) +} + +func newRuntime(store Store, opts RuntimeOptions) *runtime { + now := opts.Now + if now == nil { + now = time.Now + } + return &runtime{store: store, leaseTTL: opts.LeaseTTL, now: now} +} + +func (r *runtime) Create(ctx context.Context, run NewRun) (CreateResult, error) { + if err := checkContext(ctx); err != nil { + return CreateResult{}, err + } + if r == nil || r.store == nil { + return CreateResult{}, errors.New("agent: runtime: nil store") + } + header, err := BuildRunHeaderFromNewRun(run) + if err != nil { + return CreateResult{}, err + } + created, existing, err := r.store.Create(ctx, header) + if err != nil { + return CreateResult{}, err + } + if !created { + equal, err := canonicalHeadersEqual(existing, header) + if err != nil { + return CreateResult{}, err + } + if !equal { + return CreateResult{}, ErrCreateConflict + } + return CreateResult{Header: existing, Created: false}, nil + } + return CreateResult{Header: cloneRunHeader(header), Created: true}, nil +} + +func (r *runtime) Load(ctx context.Context, runID RunID) (RuntimeSnapshot, error) { + if err := checkContext(ctx); err != nil { + return RuntimeSnapshot{}, err + } + stored, err := r.store.View(ctx, runID) + if err != nil { + return RuntimeSnapshot{}, err + } + snapshot := RuntimeSnapshot{State: stored.State, Revision: stored.Revision, SchemaVersion: stored.Header.SchemaVersion} + if err := ValidateRunHeader(&stored.Header); err != nil { + return RuntimeSnapshot{}, fmt.Errorf("agent: runtime: invalid header: %w", err) + } + if err := ValidateMachineState(&snapshot.State); err != nil { + return RuntimeSnapshot{}, fmt.Errorf("agent: runtime: invalid snapshot: %w", err) + } + return snapshot, nil +} + +func (r *runtime) Commit(ctx context.Context, req CommitRequest) (CommitResult, error) { + if err := checkContext(ctx); err != nil { + return CommitResult{}, err + } + var result CommitResult + err := r.store.Update(ctx, req.Command.RunID, func(stored *StoredRun) error { + var err error + result, err = r.evaluateAndApply(stored, req) + return err + }) + return result, err +} + +func (r *runtime) evaluateAndApply(stored *StoredRun, req CommitRequest) (CommitResult, error) { + key := grantKey(req.Command.Command) + lease, hasLease := stored.Leases[key] + grantValid := hasLease && req.Grant != "" && lease.Grant == req.Grant + if cmd, ok := req.Command.Command.(RecoverModelExecution); ok && req.Grant != "" { + grantValid = grantValid && lease.Claim == cmd.Claim + } + recoveryValid := hasLease && req.Grant == "" && r.leaseExpired(lease) + + var prior *TransitionRecord + if record, ok := stored.Transitions[req.Command.ID]; ok { + copy := cloneTransitionRecord(&record) + prior = © + } + proto, err := ProtocolFor(stored.Header.SchemaVersion) + if err != nil { + return CommitResult{}, err + } + decision, err := EvaluateCommit(stored.State, stored.Revision, prior, req, grantValid, recoveryValid, proto) + if err != nil { + return CommitResult{}, err + } + switch decision.Kind { + case DecisionAlreadyApplied: + var grant ExecutionGrant + if _, ok := req.Command.Command.(StartModelExecution); ok { + candidate := stored.StartGrants[req.Command.ID] + if candidate != "" && stored.Leases[key].Grant == candidate { + grant = candidate + } + } else if _, ok := req.Command.Command.(StartToolCall); ok { + candidate := stored.StartGrants[req.Command.ID] + if candidate != "" && stored.Leases[key].Grant == candidate { + grant = candidate + } + } + return CommitResult{Status: CommitAlreadyApplied, + Snapshot: RuntimeSnapshot{State: cloneMachineState(&stored.State), Revision: stored.Revision, SchemaVersion: stored.Header.SchemaVersion}, + Events: cloneEvents(decision.Events), Grant: grant}, nil + case DecisionConflict: + return CommitResult{}, ErrCommandConflict + case DecisionStale: + if decision.Reject != nil && !errors.Is(decision.Reject, ErrStaleRuntime) { + return CommitResult{}, fmt.Errorf("%w: %w", ErrStaleRuntime, decision.Reject) + } + return CommitResult{}, ErrStaleRuntime + case DecisionTerminal: + return CommitResult{}, ErrRunTerminal + } + + storedRecord := cloneTransitionRecord(&decision.Transition) + stored.State = cloneMachineState(&decision.NewState) + stored.Revision++ + stored.Watermark = stored.Revision + if stored.Transitions == nil { + stored.Transitions = make(map[CommandID]TransitionRecord) + } + stored.Transitions[req.Command.ID] = storedRecord + stored.Log = append(stored.Log, storedRecord) + + var minted ExecutionGrant + switch cmd := req.Command.Command.(type) { + case StartModelExecution, StartToolCall: + minted = newGrant() + if stored.Leases == nil { + stored.Leases = make(map[string]ExecutionLease) + } + if stored.StartGrants == nil { + stored.StartGrants = make(map[CommandID]ExecutionGrant) + } + lease := ExecutionLease{Grant: minted, StartCommandID: req.Command.ID, Deadline: r.leaseDeadline()} + switch c := any(cmd).(type) { + case StartModelExecution: + lease.Claim = c.Claim + case StartToolCall: + lease.Claim = c.Claim + } + stored.Leases[key] = lease + stored.StartGrants[req.Command.ID] = minted + case SubmitModelResult, SubmitModelFailure, RejectModelResult, RecoverModelExecution, + SubmitToolResult, SubmitToolFailure: + active := stored.Leases[key] + delete(stored.Leases, key) + stored.forgetStartGrant(active.Grant) + } + if stored.State.Status.Terminal() { + stored.clearLeases() + } + return CommitResult{Status: CommitAccepted, + Snapshot: RuntimeSnapshot{State: cloneMachineState(&stored.State), Revision: stored.Revision, SchemaVersion: stored.Header.SchemaVersion}, + Events: cloneEvents(storedRecord.Events), Grant: minted}, nil +} + +func (r *runtime) Record(ctx context.Context, runID RunID) (RunRecord, error) { + if err := checkContext(ctx); err != nil { + return RunRecord{}, err + } + stored, err := r.store.View(ctx, runID) + if err != nil { + return RunRecord{}, err + } + header := stored.Header + snapshot := RuntimeSnapshot{State: stored.State, Revision: stored.Revision, SchemaVersion: header.SchemaVersion} + transitions := stored.Log + if err := ValidateRunHeader(&header); err != nil { + return RunRecord{}, fmt.Errorf("agent: runtime: invalid header: %w", err) + } + for i := range transitions { + if err := ValidateTransitionRecord(&transitions[i]); err != nil { + return RunRecord{}, fmt.Errorf("agent: runtime: invalid transition %d: %w", i, err) + } + } + folded, revision, err := FoldRun(&header, transitions) + if err != nil { + return RunRecord{}, fmt.Errorf("agent: runtime: fold: %w", err) + } + if revision != snapshot.Revision || !statesEquivalent(&folded, &snapshot.State) { + return RunRecord{}, errors.New("agent: runtime: snapshot diverges from transition log") + } + return RunRecord{Header: cloneRunHeader(header), Snapshot: cloneRuntimeSnapshot(snapshot), Transitions: cloneTransitionRecords(transitions)}, nil +} + +// RecoverExpired commits grantless recovery for every expired lease that +// still occupies an Executing target. Hosts call this on a timer; Loop does not. +func (r *runtime) RecoverExpired(ctx context.Context) (int, error) { + ids, err := r.store.ListIDs(ctx) + if err != nil { + return 0, err + } + n := 0 + for _, id := range ids { + stored, err := r.store.View(ctx, id) + if err != nil { + return n, err + } + proto, err := ProtocolFor(stored.Header.SchemaVersion) + if err != nil { + return n, err + } + for key, lease := range stored.Leases { + if !r.leaseExpired(lease) { + continue + } + cmd, cmdID, ok := recoveryCommand(stored.State, key, lease) + if !ok { + continue + } + env, err := proto.BuildEnvelope(id, cmdID, cmd) + if err != nil { + return n, err + } + _, err = r.Commit(ctx, CommitRequest{Command: env}) + if err != nil { + if errors.Is(err, ErrStaleRuntime) || errors.Is(err, ErrRunTerminal) { + continue + } + return n, err + } + n++ + } + } + return n, nil +} + +func recoveryCommand(state MachineState, key string, lease ExecutionLease) (AgentCommand, CommandID, bool) { + switch cur := state.Current.(type) { + case ModelStep: + if cur.Status != ModelExecuting || key != "model/"+string(cur.RefValue.ID) { + return nil, "", false + } + return RecoverModelExecution{StepID: cur.RefValue.ID, Claim: lease.Claim}, + DeriveModelRecoveryCommandID(state.RunID, cur.RefValue.ID, lease.Claim), true + case ToolStep: + prefix := "call/" + string(cur.RefValue.ID) + "/" + if !strings.HasPrefix(key, prefix) { + return nil, "", false + } + callID := CallID(strings.TrimPrefix(key, prefix)) + return SubmitToolFailure{ + StepID: cur.RefValue.ID, + CallID: callID, + Failure: ToolFailure{Class: FailureEffectUnknown, Message: "lease expired"}, + Outcome: ToolOutcomeUnknown, + }, DeriveSystemCommandID(state.RunID, cur.RefValue.ID, callID, string(lease.Grant)), true + default: + return nil, "", false + } +} + +func (r *runtime) leaseDeadline() time.Time { + if r.leaseTTL <= 0 { + return time.Time{} + } + return r.now().Add(r.leaseTTL) +} + +func (r *runtime) leaseExpired(lease ExecutionLease) bool { + if lease.Deadline.IsZero() { + return false + } + return !r.now().Before(lease.Deadline) +} + +func grantKey(c AgentCommand) string { + switch cmd := c.(type) { + case StartModelExecution: + return "model/" + string(cmd.StepID) + case SubmitModelResult: + return "model/" + string(cmd.StepID) + case SubmitModelFailure: + return "model/" + string(cmd.StepID) + case RejectModelResult: + return "model/" + string(cmd.StepID) + case RecoverModelExecution: + return "model/" + string(cmd.StepID) + case StartToolCall: + return "call/" + string(cmd.StepID) + "/" + string(cmd.CallID) + case SubmitToolResult: + return "call/" + string(cmd.StepID) + "/" + string(cmd.CallID) + case SubmitToolFailure: + return "call/" + string(cmd.StepID) + "/" + string(cmd.CallID) + default: + return "" + } +} + +func newGrant() ExecutionGrant { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("agent: runtime: %v", err)) + } + return ExecutionGrant(hex.EncodeToString(b[:])) +} diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index d79cb79..4bee875 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -375,7 +375,7 @@ type CommitResult struct { **RUN-CMT-7** 每个 Run 的协议版本是 `RunHeader.SchemaVersion`,在 Create 时冻结。`RuntimeSnapshot.SchemaVersion` 必须等于该 header。`ProtocolFor(header.SchemaVersion)` 在 Run 边界返回绑定了该版本 digest/decode/Decide/Evolve 函数的 `Protocol` 值;随后的方法调用不再接受 version 参数。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Protocol 的 Version。不得使用进程全局 `currentSchemaVersion` 作为写入许可。v1 Run 的 replay 必须继续使用 `ProtocolV1`,即使进程已经把 `currentSchemaVersion` 升到 2。Loop 通过 `RuntimeSnapshot.Protocol().BuildEnvelope` 构造写入该 Run 的 envelope。 -`MemoryRuntime` 提供 multi-Run in-process reference implementation:collection lock 保护 Run map,每个 Run 使用独立锁。跨进程 durable recovery 由其他 Runtime adapter 提供。 +`Runtime` 的唯一实现叠在 `Store` 上:`Store` 持久化 header、state、transition log 与 `ExecutionLease`,不调用 Decide/Evolve。`MemoryStore`、SQLite、Postgres 实现同一合同。lease 的 `Deadline` 为零表示不超时(进程内占用);过期且无 settlement 时 Runtime 将 `recoveryValid` 置真,允许 grantless Recover。`RecoverExpired` 扫描过期 lease 并提交恢复 command。`NewMemoryRuntime` 使用 `MemoryStore` 且 lease 不超时。 ## 6. Loop ports 与 policy From 7ed15be7b4d174ef4b83f3f563aa7f123c913489 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 2 Sep 2026 14:53:09 +0800 Subject: [PATCH 48/81] Drop MemoryRuntime and rebuild through Store Keep a single Runtime constructor over MemoryStore. Rebuild is a Store operation so diagnostic refold does not depend on a Memory-only type. --- agent/run/alias_test.go | 41 +----- agent/run/canonical_test.go | 14 ++ agent/run/fold_test.go | 187 +++++++++++---------------- agent/run/header_test.go | 47 +------ agent/run/loop/helpers_test.go | 4 +- agent/run/memory_conformance_test.go | 8 +- agent/run/memory_runtime.go | 55 -------- agent/run/memory_store.go | 5 +- agent/run/runtime_helpers_test.go | 28 +++- agent/run/store.go | 29 +++++ agent/run/store_test.go | 2 +- 11 files changed, 155 insertions(+), 265 deletions(-) delete mode 100644 agent/run/memory_runtime.go diff --git a/agent/run/alias_test.go b/agent/run/alias_test.go index aa58c87..d1f8b72 100644 --- a/agent/run/alias_test.go +++ b/agent/run/alias_test.go @@ -7,43 +7,6 @@ import ( "github.com/memohai/twilight/sdk" ) -func TestMemoryRuntimeClonesInitialState(t *testing.T) { - raw := []byte(`{"q":"hi"}`) - payload, err := ParseCanonicalJSON(raw) - if err != nil { - t.Fatal(err) - } - rt := NewMemoryRuntime() - newRun, err := BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(context.Background(), newRun); err != nil { - t.Fatal(err) - } - // Initial payload admission uses the actual AcceptInput transition, never a - // synthetic non-Revision-0 initial state. - acceptInput(t, rt, "run-1", AgentInput{ID: "seed", Payload: payload}) - - copy(raw, []byte(`{"q":"no"}`)) - payload = cj(`{"q":"mutated"}`) - - snap, err := rt.Load(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } - if got := snap.State.PendingInputs[0].Payload.String(); got != `{"q":"hi"}` { - t.Fatalf("runtime initial payload aliased caller state: %s", got) - } - diverged, err := rt.Rebuild("run-1") - if err != nil { - t.Fatal(err) - } - if diverged { - t.Fatal("rebuild diverged after caller mutated initial state") - } -} - func TestCommitSnapshotsCommandPayloadBeforeFoldingState(t *testing.T) { rt := newTestRuntime(t) raw := []byte(`{"v":"one"}`) @@ -85,7 +48,7 @@ func TestCommitSnapshotsCommandPayloadBeforeFoldingState(t *testing.T) { } } } - diverged, err := rt.Rebuild("run-1") + diverged, err := rebuildRun(t, rt, "run-1") if err != nil { t.Fatal(err) } @@ -255,7 +218,7 @@ func TestCommitResultEventsDoNotAliasStateOrLog(t *testing.T) { } } } - diverged, err := rt.Rebuild("run-1") + diverged, err := rebuildRun(t, rt, "run-1") if err != nil { t.Fatal(err) } diff --git a/agent/run/canonical_test.go b/agent/run/canonical_test.go index f973c7b..d8c0f0d 100644 --- a/agent/run/canonical_test.go +++ b/agent/run/canonical_test.go @@ -69,6 +69,9 @@ 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) @@ -182,6 +185,17 @@ func TestDigestBindingCanonicalizesArguments(t *testing.T) { 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 diff --git a/agent/run/fold_test.go b/agent/run/fold_test.go index 0a92114..05ab43b 100644 --- a/agent/run/fold_test.go +++ b/agent/run/fold_test.go @@ -15,7 +15,7 @@ import ( // fullRunRuntime drives one complete run (prepare -> model -> tool -> done) // and returns the runtime. -func fullRunRuntime(t *testing.T) *MemoryRuntime { +func fullRunRuntime(t *testing.T) Runtime { t.Helper() def := testToolDef("t") spec := makeSpec(t, def, DirectExecution) @@ -47,16 +47,21 @@ func fullRunRuntime(t *testing.T) *MemoryRuntime { return rt } -func currentStepID(t *testing.T, rt *MemoryRuntime) StepID { +func currentStepID(t *testing.T, rt Runtime) StepID { t.Helper() snap, err := rt.Load(context.Background(), "run-1") if err != nil { t.Fatal(err) } - if snap.State.Current == nil { - t.Fatal("no current step") + switch cur := snap.State.Current.(type) { + case ModelStep: + return cur.Ref().ID + case ToolStep: + return cur.Ref().ID + default: + t.Fatalf("current = %T, want ModelStep or ToolStep", snap.State.Current) + return "" } - return snap.State.Current.Ref().ID } // A healthy runtime rebuilds without divergence: with a correct @@ -64,7 +69,7 @@ func currentStepID(t *testing.T, rt *MemoryRuntime) StepID { func TestRebuildHealthyIsNoop(t *testing.T) { rt := fullRunRuntime(t) before, _ := rt.Load(context.Background(), "run-1") - diverged, err := rt.Rebuild("run-1") + diverged, err := rebuildRun(t, rt, "run-1") if err != nil { t.Fatal(err) } @@ -91,7 +96,7 @@ func TestRebuildRepairsCorruptedSnapshot(t *testing.T) { entry.state.Result = nil entry.mu.Unlock() - diverged, err := rt.Rebuild("run-1") + diverged, err := rebuildRun(t, rt, "run-1") if err != nil { t.Fatal(err) } @@ -118,7 +123,7 @@ func TestRebuildHaltsOnTruncatedTail(t *testing.T) { entry.log = cloneTransitionRecords(entry.log[:len(entry.log)-1]) entry.mu.Unlock() - _, err := rt.Rebuild("run-1") + _, err := rebuildRun(t, rt, "run-1") if !errors.Is(err, ErrLogTruncated) { t.Fatalf("err = %v, want ErrLogTruncated", err) } @@ -135,120 +140,84 @@ func TestRebuildHaltsOnPartialTailTransition(t *testing.T) { last.Events = last.Events[:len(last.Events)-1] entry.mu.Unlock() - _, err := rt.Rebuild("run-1") + _, err := rebuildRun(t, rt, "run-1") if err == nil { t.Fatal("partial tail transition folded silently") } } -// A gap in the middle of the transition log is log damage, not a rebuild input. -func TestFoldTransitionsRejectsInteriorGap(t *testing.T) { +func TestFoldRejectsDamagedLog(t *testing.T) { rt := fullRunRuntime(t) entry := memoryEntry(t, rt) entry.mu.Lock() - var holed []TransitionRecord - for i := range entry.log { - if entry.log[i].Revision == 3 { // drop one interior transition - continue - } - holed = append(holed, cloneTransitionRecord(&entry.log[i])) - } - log := holed - initial := cloneMachineState(&entry.initial) - entry.mu.Unlock() - - if _, _, err := FoldTransitions(initial, log); err == nil { - t.Fatal("interior transition gap folded silently") - } -} - -// A gap in the middle of a complete flat event stream is still rejected. -func TestFoldEventsRejectsInteriorGap(t *testing.T) { - rt := fullRunRuntime(t) - entry := memoryEntry(t, rt) - entry.mu.Lock() - flat := flattenTransitionRecords(entry.log) - initial := cloneMachineState(&entry.initial) + initial := cloneMachineState(&entry.header.InitialState) + transitions := cloneTransitionRecords(entry.log) + events := flattenTransitionRecords(entry.log) entry.mu.Unlock() - var holed []AgentEvent - for i := range flat { - if flat[i].Revision == 3 { // drop one interior transition - continue + t.Run("interior transition gap", func(t *testing.T) { + var holed []TransitionRecord + for i := range transitions { + if transitions[i].Revision == 3 { + continue + } + holed = append(holed, cloneTransitionRecord(&transitions[i])) } - holed = append(holed, flat[i]) - } - if _, _, err := FoldEvents(initial, holed); err == nil { - t.Fatal("interior event gap folded silently") - } -} - -func TestFoldRejectsRunIDMismatch(t *testing.T) { - rt := fullRunRuntime(t) - entry := memoryEntry(t, rt) - entry.mu.Lock() - log := flattenTransitionRecords(entry.log) - initial := cloneMachineState(&entry.initial) - entry.mu.Unlock() - - log[0].RunID = "other-run" - if _, _, err := FoldEvents(initial, log); err == nil { - t.Fatal("run mismatch folded silently") - } -} - -func TestFoldRejectsTransitionCommandIdentityChange(t *testing.T) { - rt := fullRunRuntime(t) - entry := memoryEntry(t, rt) - entry.mu.Lock() - log := flattenTransitionRecords(entry.log) - initial := cloneMachineState(&entry.initial) - entry.mu.Unlock() - - for i := range log { - if log[i].Index == 1 { - log[i].CommandID = "other-command" - break + if _, _, err := FoldTransitions(cloneMachineState(&initial), holed); err == nil { + t.Fatal("interior transition gap folded silently") } - } - if _, _, err := FoldEvents(initial, log); err == nil { - t.Fatal("same-revision command identity change folded silently") - } -} - -func TestFoldRejectsUnsupportedSchemaVersion(t *testing.T) { - rt := fullRunRuntime(t) - entry := memoryEntry(t, rt) - entry.mu.Lock() - log := flattenTransitionRecords(entry.log) - initial := cloneMachineState(&entry.initial) - entry.mu.Unlock() - - log[0].SchemaVersion = 99 - if _, _, err := FoldEvents(initial, log); err == nil { - t.Fatal("unsupported schema version folded silently") - } -} - -// A tampered fact fails its digest check during fold. -func TestFoldRejectsTamperedFact(t *testing.T) { - rt := fullRunRuntime(t) - entry := memoryEntry(t, rt) - entry.mu.Lock() - log := flattenTransitionRecords(entry.log) - initial := cloneMachineState(&entry.initial) - entry.mu.Unlock() - - for i := range log { - if f, ok := log[i].Fact.(ToolCallCompleted); ok { - f.Result.Output = cj(`"tampered"`) - log[i].Fact = f - break + }) + t.Run("interior event gap", func(t *testing.T) { + var holed []AgentEvent + for i := range events { + if events[i].Revision == 3 { + continue + } + holed = append(holed, events[i]) } - } - if _, _, err := FoldEvents(initial, log); err == nil { - t.Fatal("tampered fact folded silently") - } + if _, _, err := FoldEvents(cloneMachineState(&initial), holed); err == nil { + t.Fatal("interior event gap folded silently") + } + }) + t.Run("run id mismatch", func(t *testing.T) { + log := flattenTransitionRecords(transitions) + log[0].RunID = "other-run" + if _, _, err := FoldEvents(cloneMachineState(&initial), log); err == nil { + t.Fatal("run mismatch folded silently") + } + }) + t.Run("command identity change", func(t *testing.T) { + log := flattenTransitionRecords(transitions) + for i := range log { + if log[i].Index == 1 { + log[i].CommandID = "other-command" + break + } + } + if _, _, err := FoldEvents(cloneMachineState(&initial), log); err == nil { + t.Fatal("same-revision command identity change folded silently") + } + }) + t.Run("unsupported schema version", func(t *testing.T) { + log := flattenTransitionRecords(transitions) + log[0].SchemaVersion = 99 + if _, _, err := FoldEvents(cloneMachineState(&initial), log); err == nil { + t.Fatal("unsupported schema version folded silently") + } + }) + t.Run("tampered fact", func(t *testing.T) { + log := flattenTransitionRecords(transitions) + for i := range log { + if f, ok := log[i].Fact.(ToolCallCompleted); ok { + f.Result.Output = cj(`"tampered"`) + log[i].Fact = f + break + } + } + if _, _, err := FoldEvents(cloneMachineState(&initial), log); err == nil { + t.Fatal("tampered fact folded silently") + } + }) } // ModelStepPrepared is self-contained: the binding digest folds verbatim and diff --git a/agent/run/header_test.go b/agent/run/header_test.go index 2a51a68..362f801 100644 --- a/agent/run/header_test.go +++ b/agent/run/header_test.go @@ -49,7 +49,7 @@ func TestValidateRunHeaderRejectsTampering(t *testing.T) { } func TestCommitRejectsCommandSchemaMismatch(t *testing.T) { - rt := NewMemoryRuntime() + rt := NewRuntime(NewMemoryStore()) newRun, err := BuildNewRun("run-1", "") if err != nil { t.Fatal(err) @@ -67,48 +67,3 @@ func TestCommitRejectsCommandSchemaMismatch(t *testing.T) { t.Fatal("v2 envelope accepted on v1 run") } } - -func TestFoldRunFromHeaderMatchesRuntime(t *testing.T) { - h, err := BuildRunHeader("run-1", "") - if err != nil { - t.Fatal(err) - } - rt := NewMemoryRuntime() - newRun, err := BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(t.Context(), newRun); err != nil { - t.Fatal(err) - } - - // Drive one transition: accept the seed input at Revision 1 (RUN-NEW-1 — - // seed enters the log, not the header). - in := AgentInput{ID: "seed", Payload: MustParseCanonicalJSON(`{"q":"hi"}`)} - env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), NextStep(in)) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(t.Context(), CommitRequest{BaseRevision: 0, Command: env}); err != nil { - t.Fatal(err) - } - - record, err := rt.Record(t.Context(), "run-1") - if err != nil { - t.Fatal(err) - } - folded, rev, err := FoldRun(&h, record.Transitions) - if err != nil { - t.Fatal(err) - } - if rev != 1 { - t.Fatalf("rev = %d", rev) - } - live, err := rt.Load(t.Context(), "run-1") - if err != nil { - t.Fatal(err) - } - if !statesEquivalent(&live.State, &folded) { - t.Fatal("header fold diverged from live state") - } -} diff --git a/agent/run/loop/helpers_test.go b/agent/run/loop/helpers_test.go index 0d4568c..9e7dfd9 100644 --- a/agent/run/loop/helpers_test.go +++ b/agent/run/loop/helpers_test.go @@ -11,9 +11,9 @@ const testModel ModelRef = "m-1" func cj(raw string) CanonicalJSON { return MustParseCanonicalJSON(raw) } -func newTestRuntime(t *testing.T) *MemoryRuntime { +func newTestRuntime(t *testing.T) Runtime { t.Helper() - rt := NewMemoryRuntime() + rt := NewRuntime(NewMemoryStore()) newRun, err := BuildNewRun("run-1", "") if err != nil { t.Fatal(err) diff --git a/agent/run/memory_conformance_test.go b/agent/run/memory_conformance_test.go index a2539cb..d7e335e 100644 --- a/agent/run/memory_conformance_test.go +++ b/agent/run/memory_conformance_test.go @@ -7,10 +7,10 @@ import ( "github.com/memohai/twilight/agent/run/runtimetest" ) -// MemoryRuntime is the reference implementation of the Runtime contract; it -// runs the same shared suite durable adapters import (RUN-CMP-2). +// MemoryStore-backed Runtime is the in-process reference; it runs the same +// shared suite durable adapters import (RUN-CMP-2). // Dependency chain: run_test -> runtimetest -> run (the net/http/httptest // layout), so the loop is broken by the external test package. -func TestMemoryRuntimeConformance(t *testing.T) { - runtimetest.RunConformance(t, func() run.Runtime { return run.NewMemoryRuntime() }) +func TestMemoryStoreRuntimeConformance(t *testing.T) { + runtimetest.RunConformance(t, func() run.Runtime { return run.NewRuntime(run.NewMemoryStore()) }) } diff --git a/agent/run/memory_runtime.go b/agent/run/memory_runtime.go deleted file mode 100644 index 470a7c8..0000000 --- a/agent/run/memory_runtime.go +++ /dev/null @@ -1,55 +0,0 @@ -package run - -import ( - "errors" - "fmt" -) - -// MemoryRuntime is the in-process reference Runtime: a Store-backed runtime -// over MemoryStore. Tests that mutate storage for Rebuild diagnostics use -// this concrete type; Loop and Turn depend only on Runtime. -type MemoryRuntime struct { - *runtime - mem *MemoryStore -} - -// NewMemoryRuntime creates an empty, RunID-addressed in-process Runtime. -// Leases do not expire; grantless recovery requires a Store with deadlines. -func NewMemoryRuntime() *MemoryRuntime { - mem := NewMemoryStore() - return &MemoryRuntime{runtime: newRuntime(mem, RuntimeOptions{}), mem: mem} -} - -func (m *MemoryRuntime) entry(runID RunID) (*memoryRun, error) { - if m == nil || m.mem == nil { - return nil, errors.New("agent: memory runtime: nil runtime") - } - return m.mem.entry(runID) -} - -// Rebuild is an optional diagnostic (RUN-CMT-2): it refolds the state from -// the transition log and replaces the stored state with the fold result. -// Runtime.Commit remains the normal state transition path. A log shorter than -// the last committed revision returns ErrLogTruncated (audit gap). It returns -// true when the refolded state differs from the stored state, which identifies -// an Evolve bug, out-of-band write, or storage corruption. -func (m *MemoryRuntime) Rebuild(runID RunID) (rebuilt bool, err error) { - entry, err := m.entry(runID) - if err != nil { - return false, err - } - entry.mu.Lock() - defer entry.mu.Unlock() - - folded, maxRevision, err := FoldTransitions(cloneMachineState(&entry.initial), entry.log) - if err != nil { - return false, err - } - if maxRevision < entry.watermark { - return false, fmt.Errorf("%w: log ends at %d, watermark %d", ErrLogTruncated, maxRevision, entry.watermark) - } - diverged := entry.revision != maxRevision || !statesEquivalent(&entry.state, &folded) - entry.state = folded - entry.revision = maxRevision - return diverged, nil -} diff --git a/agent/run/memory_store.go b/agent/run/memory_store.go index 4a52ee4..675eeb1 100644 --- a/agent/run/memory_store.go +++ b/agent/run/memory_store.go @@ -20,7 +20,6 @@ type memoryRun struct { header RunHeader state MachineState revision uint64 - initial MachineState watermark uint64 transitions map[CommandID]TransitionRecord log []TransitionRecord @@ -102,11 +101,9 @@ func (s *MemoryStore) Create(ctx context.Context, header RunHeader) (bool, RunHe return false, cloneRunHeader(existing.header), nil } stored := cloneRunHeader(header) - initial := cloneMachineState(&stored.InitialState) s.runs[header.RunID] = &memoryRun{ header: stored, - state: cloneMachineState(&initial), - initial: initial, + state: cloneMachineState(&stored.InitialState), transitions: make(map[CommandID]TransitionRecord), leases: make(map[string]ExecutionLease), startGrants: make(map[CommandID]ExecutionGrant), diff --git a/agent/run/runtime_helpers_test.go b/agent/run/runtime_helpers_test.go index 2cc13f0..e5db587 100644 --- a/agent/run/runtime_helpers_test.go +++ b/agent/run/runtime_helpers_test.go @@ -10,9 +10,9 @@ import ( // Shared helpers for package-local agent tests. Runtime conformance lives in // agent/runtimetest so durable Runtime implementations can reuse it. -func newTestRuntime(t *testing.T) *MemoryRuntime { +func newTestRuntime(t *testing.T) Runtime { t.Helper() - rt := NewMemoryRuntime() + rt := NewRuntime(NewMemoryStore()) newRun, err := BuildNewRun("run-1", "") if err != nil { t.Fatal(err) @@ -26,6 +26,20 @@ func newTestRuntime(t *testing.T) *MemoryRuntime { return rt } +func runtimeStore(t testing.TB, rt Runtime) Store { + t.Helper() + r, ok := rt.(*runtime) + if !ok { + t.Fatalf("runtime %T is not *runtime", rt) + } + return r.store +} + +func rebuildRun(t testing.TB, rt Runtime, runID RunID) (bool, error) { + t.Helper() + return Rebuild(context.Background(), runtimeStore(t, rt), runID) +} + func recordEvents(t testing.TB, rt Runtime, runID RunID) []AgentEvent { t.Helper() record, err := rt.Record(context.Background(), runID) @@ -35,9 +49,13 @@ func recordEvents(t testing.TB, rt Runtime, runID RunID) []AgentEvent { return flattenTransitionRecords(record.Transitions) } -func memoryEntry(t testing.TB, rt *MemoryRuntime) *memoryRun { +func memoryEntry(t testing.TB, rt Runtime) *memoryRun { t.Helper() - entry, err := rt.entry("run-1") + store, ok := runtimeStore(t, rt).(*MemoryStore) + if !ok { + t.Fatal("runtime is not backed by MemoryStore") + } + entry, err := store.entry("run-1") if err != nil { t.Fatal(err) } @@ -83,7 +101,7 @@ func mustCommit(t *testing.T, rt Runtime, id CommandID, base uint64, grant Execu // preparedRuntime returns a runtime advanced to an Executing ModelStep, plus // stepID and the model grant. -func preparedRuntime(t *testing.T, tools []sdk.ToolDefinition, specs []ToolSpec) (*MemoryRuntime, StepID, ExecutionGrant) { +func preparedRuntime(t *testing.T, tools []sdk.ToolDefinition, specs []ToolSpec) (Runtime, StepID, ExecutionGrant) { t.Helper() rt := newTestRuntime(t) snap, _ := rt.Load(context.Background(), "run-1") diff --git a/agent/run/store.go b/agent/run/store.go index d93876c..c8ed719 100644 --- a/agent/run/store.go +++ b/agent/run/store.go @@ -2,6 +2,8 @@ package run import ( "context" + "errors" + "fmt" "time" ) @@ -84,3 +86,30 @@ func (s *StoredRun) clearLeases() { s.Leases = make(map[string]ExecutionLease) s.StartGrants = make(map[CommandID]ExecutionGrant) } + +// Rebuild is an optional diagnostic (RUN-CMT-2): it refolds the state from +// the transition log and replaces the stored state with the fold result. +// Runtime.Commit remains the normal state transition path. A log shorter than +// the last committed revision returns ErrLogTruncated (audit gap). It returns +// true when the refolded state differs from the stored state, which identifies +// an Evolve bug, out-of-band write, or storage corruption. +func Rebuild(ctx context.Context, store Store, runID RunID) (rebuilt bool, err error) { + if store == nil { + return false, errors.New("agent: rebuild: nil store") + } + var diverged bool + err = store.Update(ctx, runID, func(stored *StoredRun) error { + folded, maxRevision, foldErr := FoldTransitions(cloneMachineState(&stored.Header.InitialState), stored.Log) + if foldErr != nil { + return foldErr + } + if maxRevision < stored.Watermark { + return fmt.Errorf("%w: log ends at %d, watermark %d", ErrLogTruncated, maxRevision, stored.Watermark) + } + diverged = stored.Revision != maxRevision || !statesEquivalent(&stored.State, &folded) + stored.State = folded + stored.Revision = maxRevision + return nil + }) + return diverged, err +} diff --git a/agent/run/store_test.go b/agent/run/store_test.go index 6e3ca9c..2baa1a1 100644 --- a/agent/run/store_test.go +++ b/agent/run/store_test.go @@ -70,7 +70,7 @@ func TestExpiredLeaseAllowsGrantlessModelRecovery(t *testing.T) { } func TestZeroDeadlineRejectsGrantlessRecovery(t *testing.T) { - rt := NewMemoryRuntime() + rt := NewRuntime(NewMemoryStore()) ctx := context.Background() newRun, err := BuildNewRun("run-1", "") if err != nil { From e432e207d02e8eef48f155ea22f3059f05289128 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 2 Sep 2026 14:55:52 +0800 Subject: [PATCH 49/81] Keep unknown tool outcomes on the active run Unknown now records ToolCallFailed for that executing call and leaves the Run active. Occupancy without a step is Open, so AcceptInput and Prepare no longer key off a nil Current. Feature coverage moves into the runtest driver. --- agent/run/clone.go | 15 +- agent/run/command.go | 9 +- agent/run/creation.go | 2 +- agent/run/creation_test.go | 160 +--- agent/run/decide.go | 32 +- agent/run/evolve.go | 24 +- agent/run/fold.go | 4 + agent/run/header.go | 2 +- agent/run/loop/helpers_test.go | 22 - agent/run/loop/loop.go | 17 +- agent/run/loop/loop_test.go | 363 +-------- agent/run/loop/regression_test.go | 19 +- agent/run/machine_test.go | 345 +++------ agent/run/next.go | 4 +- agent/run/regression_test.go | 150 +--- agent/run/runtest/approval_test.go | 59 ++ agent/run/runtest/cancel_test.go | 34 + agent/run/runtest/error_test.go | 31 + agent/run/runtest/feature.go | 454 ++++++++++++ agent/run/runtest/require.go | 253 +++++++ agent/run/runtest/script.go | 126 ++++ agent/run/runtest/tools_test.go | 45 ++ agent/run/runtest/unknown_test.go | 31 + agent/run/runtimetest/conformance.go | 1018 ++++++++++++++------------ agent/run/state.go | 27 +- agent/run/stored_runtime.go | 4 +- docs/design/agent-run.md | 44 +- 27 files changed, 1805 insertions(+), 1489 deletions(-) create mode 100644 agent/run/runtest/approval_test.go create mode 100644 agent/run/runtest/cancel_test.go create mode 100644 agent/run/runtest/error_test.go create mode 100644 agent/run/runtest/feature.go create mode 100644 agent/run/runtest/require.go create mode 100644 agent/run/runtest/script.go create mode 100644 agent/run/runtest/tools_test.go create mode 100644 agent/run/runtest/unknown_test.go diff --git a/agent/run/clone.go b/agent/run/clone.go index 34f36e8..2587c36 100644 --- a/agent/run/clone.go +++ b/agent/run/clone.go @@ -307,6 +307,19 @@ func cloneStep(s Step) Step { } } +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 @@ -318,7 +331,7 @@ func cloneToolStepPtr(s *ToolStep) *ToolStep { func cloneMachineState(s *MachineState) MachineState { out := *s if out.Current != nil { - out.Current = cloneStep(out.Current) + out.Current = cloneCurrent(out.Current) } out.LastToolStep = cloneToolStepPtr(out.LastToolStep) out.PendingInputs = cloneAgentInputs(out.PendingInputs) diff --git a/agent/run/command.go b/agent/run/command.go index 6035eeb..2fcf9ff 100644 --- a/agent/run/command.go +++ b/agent/run/command.go @@ -12,7 +12,7 @@ type AgentInput struct { Payload CanonicalJSON `json:"payload"` } -// NextStep creates the command consumed by an active Run at a safe boundary. +// NextStep creates the AcceptInput command consumed at Open. func NextStep(input AgentInput) AcceptInput { return AcceptInput{Input: input} } // PrepareModelRequest freezes the next model request. Its CommandID is @@ -119,7 +119,8 @@ 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 terminates the Run. +// 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"` @@ -175,8 +176,8 @@ type CancelRun struct { func (CancelRun) agentCommand() {} -// AcceptInput appends one queue-safe input to PendingInputs. Idempotent per -// (RunID, InputID) with identical payload. +// AcceptInput appends one input to PendingInputs while Current is Open. +// Idempotent per (RunID, InputID) with identical payload. type AcceptInput struct { Input AgentInput `json:"input"` } diff --git a/agent/run/creation.go b/agent/run/creation.go index 06bf103..5197bed 100644 --- a/agent/run/creation.go +++ b/agent/run/creation.go @@ -62,7 +62,7 @@ func BuildRunHeaderFromNewRun(run NewRun) (RunHeader, error) { const newRunV1InitialStateVersion uint16 = 1 func buildRunHeaderV1(run NewRun) (RunHeader, error) { - initial := MachineState{RunID: run.RunID, Status: RunActive} + initial := MachineState{RunID: run.RunID, Status: RunActive, Current: Open{}} stateBytes, err := encodeMachineStateV1(&initial) if err != nil { return RunHeader{}, err diff --git a/agent/run/creation_test.go b/agent/run/creation_test.go index 960a744..f95e325 100644 --- a/agent/run/creation_test.go +++ b/agent/run/creation_test.go @@ -2,9 +2,6 @@ package run import ( "context" - "errors" - "fmt" - "sync" "testing" "github.com/memohai/twilight/agent/es" @@ -19,13 +16,6 @@ func mustNewRun(t testing.TB, id RunID, cause es.CausationID) NewRun { return run } -func mustCreateRun(t testing.TB, rt Runtime, id RunID) { - t.Helper() - if _, err := rt.Create(context.Background(), mustNewRun(t, id, "session-1")); err != nil { - t.Fatal(err) - } -} - func acceptInput(t testing.TB, rt Runtime, id RunID, input AgentInput) CommitResult { t.Helper() snapshot, err := rt.Load(context.Background(), id) @@ -62,154 +52,8 @@ func TestNewRunValidationAndV1HeaderGolden(t *testing.T) { if err != nil { t.Fatal(err) } - if header.InitialStateDigest != "sha256:6dd6e9f67d4b9e6c1d2c72d20a95276de2a8c18453a5cbea3f5d602087770468" || - header.HeaderDigest != "sha256:f51f1136690ef97ea4fef7385f76b48ec41933fc66f30283df673bafce64367d" { + if header.InitialStateDigest != "sha256:a991d300554d8c5b70573baf427e3087794b0ab844ed389cc167925db76676ff" || + header.HeaderDigest != "sha256:5dc6599107fd006f1638eb83d7c4a7ff757c58d2c59d2e827e2065a0fafd5db6" { t.Fatalf("v1 header changed: %+v", header) } } - -func TestRuntimeCreateIdempotentAndConflict(t *testing.T) { - rt := NewMemoryRuntime() - first, err := rt.Create(context.Background(), mustNewRun(t, "run-1", "session-1")) - if err != nil || !first.Created { - t.Fatalf("first Create = %+v, %v", first, err) - } - first.Header.InitialState.RunID = "mutated" - second, err := rt.Create(context.Background(), mustNewRun(t, "run-1", "session-1")) - if err != nil || second.Created || second.Header.InitialState.RunID != "run-1" { - t.Fatalf("idempotent Create = %+v, %v", second, err) - } - if _, err := rt.Create(context.Background(), mustNewRun(t, "run-1", "session-2")); !errors.Is(err, ErrCreateConflict) { - t.Fatalf("conflict error = %v", err) - } -} - -func TestRuntimeMissingRun(t *testing.T) { - rt := NewMemoryRuntime() - if _, err := rt.Load(context.Background(), "missing"); !errors.Is(err, ErrRunNotFound) { - t.Fatalf("Load error = %v", err) - } - if _, err := rt.Record(context.Background(), "missing"); !errors.Is(err, ErrRunNotFound) { - t.Fatalf("Record error = %v", err) - } - env, err := BuildEnvelope("missing", "command-1", CancelRun{}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(context.Background(), CommitRequest{Command: env}); !errors.Is(err, ErrRunNotFound) { - t.Fatalf("Commit error = %v", err) - } -} - -func TestRuntimeRecordFoldAndIsolation(t *testing.T) { - rt := NewMemoryRuntime() - mustCreateRun(t, rt, "run-1") - zero, err := rt.Record(context.Background(), "run-1") - if err != nil || zero.Snapshot.Revision != 0 || len(zero.Transitions) != 0 { - t.Fatalf("revision-zero record = %+v, %v", zero, err) - } - acceptInput(t, rt, "run-1", AgentInput{ID: "in-1", Payload: MustParseCanonicalJSON(`{"q":"hi"}`)}) - record, err := rt.Record(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } - folded, revision, err := FoldRun(&record.Header, record.Transitions) - if err != nil || revision != record.Snapshot.Revision || !statesEquivalent(&folded, &record.Snapshot.State) { - t.Fatalf("record fold = revision %d record %+v err %v", revision, record, err) - } - record.Header.InitialState.RunID = "mutated" - record.Snapshot.State.RunID = "mutated" - record.Transitions[0].Events[0].RunID = "mutated" - fresh, err := rt.Record(context.Background(), "run-1") - if err != nil || fresh.Header.InitialState.RunID != "run-1" || fresh.Snapshot.State.RunID != "run-1" || fresh.Transitions[0].Events[0].RunID != "run-1" { - t.Fatalf("record aliases authority: %+v, %v", fresh, err) - } -} - -func TestRuntimeConcurrentCreateAndRunIsolation(t *testing.T) { - rt := NewMemoryRuntime() - newRun := mustNewRun(t, "run-1", "session-1") - const callers = 32 - results := make(chan CreateResult, callers) - errs := make(chan error, callers) - var group sync.WaitGroup - for range callers { - group.Add(1) - go func() { - defer group.Done() - result, err := rt.Create(context.Background(), newRun) - if err != nil { - errs <- err - return - } - results <- result - }() - } - group.Wait() - close(results) - close(errs) - created := 0 - for err := range errs { - t.Fatal(err) - } - for result := range results { - if result.Created { - created++ - } - } - if created != 1 { - t.Fatalf("created = %d", created) - } - mustCreateRun(t, rt, "run-2") - acceptInput(t, rt, "run-1", AgentInput{ID: "one", Payload: MustParseCanonicalJSON(`1`)}) - acceptInput(t, rt, "run-2", AgentInput{ID: "two", Payload: MustParseCanonicalJSON(`2`)}) - one, _ := rt.Load(context.Background(), "run-1") - two, _ := rt.Load(context.Background(), "run-2") - if len(one.State.PendingInputs) != 1 || one.State.PendingInputs[0].ID != "one" || len(two.State.PendingInputs) != 1 || two.State.PendingInputs[0].ID != "two" { - t.Fatalf("cross-run state leaked: one=%+v two=%+v", one.State.PendingInputs, two.State.PendingInputs) - } -} - -func TestRuntimeRecordConcurrentWithCommit(t *testing.T) { - rt := NewMemoryRuntime() - mustCreateRun(t, rt, "run-1") - const commits = 64 - done := make(chan struct{}) - go func() { - defer close(done) - for i := 0; i < commits; i++ { - input := AgentInput{ID: InputID(fmt.Sprintf("in-%d", i)), Payload: MustParseCanonicalJSON(`null`)} - for { - snapshot, err := rt.Load(context.Background(), "run-1") - if err != nil { - t.Errorf("Load: %v", err) - return - } - env, _ := BuildEnvelope("run-1", DeriveInputCommandID("run-1", input.ID), AcceptInput{Input: input}) - _, err = rt.Commit(context.Background(), CommitRequest{BaseRevision: snapshot.Revision, Command: env}) - if errors.Is(err, ErrStaleRuntime) { - continue - } - if err != nil { - t.Errorf("Commit: %v", err) - } - break - } - } - }() - for { - record, err := rt.Record(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } - folded, revision, err := FoldRun(&record.Header, record.Transitions) - if err != nil || revision != record.Snapshot.Revision || !statesEquivalent(&folded, &record.Snapshot.State) { - t.Fatalf("inconsistent record: rev=%d snapshot=%d err=%v", revision, record.Snapshot.Revision, err) - } - select { - case <-done: - return - default: - } - } -} diff --git a/agent/run/decide.go b/agent/run/decide.go index c38c19f..0d565f6 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -71,8 +71,8 @@ func decideV1(s MachineState, c AgentCommand) ([]Fact, error) { // --- rule 1: PrepareModelRequest --- func decidePrepareModelRequest(s *MachineState, cmd *PrepareModelRequest) ([]Fact, error) { - if s.Current != nil { - return nil, rejectionf("prepare: run already has a current step") + if !atOpen(s.Current) { + return nil, rejectionf("prepare: run is not at Open") } if cmd.StepID == "" { return nil, rejectionf("prepare: empty StepID") @@ -436,12 +436,7 @@ func decideSubmitToolFailure(s *MachineState, cmd SubmitToolFailure) ([]Fact, er if failure.Class != FailureEffectUnknown { return nil, rejectionf("tool failure: unknown outcome must use %s", FailureEffectUnknown) } - facts := unknownExecutingCalls(s, cmd.StepID, cmd.CallID, failure) - facts = append(facts, RunEnded{End: RunFailedEnd{ - Reason: ReasonEffectUnknown, - Failure: RunFailure{Class: FailureEffectUnknown, Message: failure.Message, CallID: cmd.CallID}, - }}) - return facts, nil + 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) } @@ -549,7 +544,7 @@ func decideCancelRun(s *MachineState, cmd CancelRun) ([]Fact, error) { 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"}) + 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 { @@ -568,12 +563,11 @@ func decideCancelRun(s *MachineState, cmd CancelRun) ([]Fact, error) { return facts, nil } -// unknownExecutingCalls records every started effect that will be abandoned by -// a terminal transition. The target is optional for cancellation; all current -// Executing calls are included exactly once in call order. -func unknownExecutingCalls(s *MachineState, stepID StepID, target CallID, failure ToolFailure) []Fact { +// 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 || (stepID != "" && ts.RefValue.ID != stepID) { + if !ok { return nil } facts := make([]Fact, 0, len(ts.Calls)) @@ -581,18 +575,14 @@ func unknownExecutingCalls(s *MachineState, stepID StepID, target CallID, failur if call.Status != ToolExecuting { continue } - f := failure - if target != "" && call.CallID != target { - f.Message = "sibling execution result unknown after terminal transition" - } - facts = append(facts, ToolCallFailed{StepID: ts.RefValue.ID, CallID: call.CallID, Failure: f, Outcome: ToolOutcomeUnknown}) + facts = append(facts, ToolCallFailed{StepID: ts.RefValue.ID, CallID: call.CallID, Failure: failure, Outcome: ToolOutcomeUnknown}) } return facts } func decideAcceptInput(s *MachineState, cmd AcceptInput) ([]Fact, error) { - if s.Current != nil { - return nil, rejectionf("accept input: run has a current step") + if !atOpen(s.Current) { + return nil, rejectionf("accept input: run is not at Open") } if cmd.Input.ID == "" { return nil, rejectionf("accept input: empty InputID") diff --git a/agent/run/evolve.go b/agent/run/evolve.go index ef18c21..a018f18 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -23,8 +23,8 @@ func evolveV1(s MachineState, f Fact) (MachineState, error) { } switch fact := f.(type) { case ModelStepPrepared: - if s.Current != nil { - return s, fmt.Errorf("agent: evolve: model step prepared while a step is current") + if !atOpen(s.Current) { + return s, fmt.Errorf("agent: evolve: model step prepared while run is not at Open") } // v1 preparation is the atomic consumption boundary for pending inputs. // A persisted fact must name every pending input exactly once, in queue @@ -87,12 +87,12 @@ func evolveV1(s MachineState, f Fact) (MachineState, error) { result := fact.Result s.LastModelResult = &result s.Usage = s.Usage.Add(fact.Result.Usage) - s.Current = nil + s.Current = Open{} return s, nil case ToolStepOpened: - if s.Current != nil { - return s, fmt.Errorf("agent: evolve: tool step opened while a step is current") + if !atOpen(s.Current) { + return s, fmt.Errorf("agent: evolve: tool step opened while run is not at Open") } calls := make([]ToolCallState, len(fact.Calls)) for i, b := range fact.Calls { @@ -204,8 +204,8 @@ func evolveV1(s MachineState, f Fact) (MachineState, error) { func validateFactTransition(s MachineState, f Fact) error { switch fact := f.(type) { case ModelStepPrepared: - if s.Status.Terminal() || s.Current != nil { - return fmt.Errorf("agent: evolve: model step prepared while run is not at a model boundary") + if s.Status.Terminal() || !atOpen(s.Current) { + return fmt.Errorf("agent: evolve: model step prepared while run is not at Open") } if fact.StepID == "" || fact.Model == "" || fact.RequestDigest == "" || fact.ToolsDigest == "" || fact.BindingDigest == "" { return errors.New("agent: evolve: model step prepared is missing identity or digest") @@ -246,8 +246,8 @@ func validateFactTransition(s MachineState, f Fact) error { return fmt.Errorf("agent: evolve: model step %q is not Executing", fact.StepID) } case ToolStepOpened: - if s.Status.Terminal() || s.Current != nil || len(fact.Calls) == 0 { - return fmt.Errorf("agent: evolve: tool step opened outside an empty active boundary") + if s.Status.Terminal() || !atOpen(s.Current) || len(fact.Calls) == 0 { + return fmt.Errorf("agent: evolve: tool step opened outside Open") } if fact.StepID == "" || fact.Source == "" || fact.BindingSetDigest == "" { return errors.New("agent: evolve: tool step is missing identity or digest") @@ -354,8 +354,8 @@ func validateFactTransition(s MachineState, f Fact) error { return fmt.Errorf("agent: evolve: unknown failure outcome %d", fact.Outcome) } case InputAccepted: - if s.Status.Terminal() || s.Current != nil { - return errors.New("agent: evolve: input accepted outside an empty active boundary") + if s.Status.Terminal() || !atOpen(s.Current) { + return errors.New("agent: evolve: input accepted outside Open") } case RunEnded: if s.Status.Terminal() { @@ -424,7 +424,7 @@ func evolveCall(s *MachineState, step StepID, call CallID, apply func(*ToolCallS closed := ts closed.Calls = calls s.LastToolStep = &closed - s.Current = nil + s.Current = Open{} s.LastClosedStep = step } else { s.Current = ts diff --git a/agent/run/fold.go b/agent/run/fold.go index f168de0..af55cac 100644 --- a/agent/run/fold.go +++ b/agent/run/fold.go @@ -146,9 +146,13 @@ func stateComparable(s *MachineState) map[string]any { m["lastToolStep"] = s.LastToolStep } switch cur := s.Current.(type) { + case Open: + m["current"] = "open" case ModelStep: + m["current"] = "model" m["modelStep"] = cur case ToolStep: + m["current"] = "tool" m["toolStep"] = cur } return m diff --git a/agent/run/header.go b/agent/run/header.go index fe91787..994ecde 100644 --- a/agent/run/header.go +++ b/agent/run/header.go @@ -112,7 +112,7 @@ func ValidateRunHeader(h *RunHeader) error { if h.InitialState.RunID != h.RunID { return errors.New("agent: run header: initial state RunID mismatch") } - if h.InitialState.Status != RunActive || h.InitialState.Current != nil || + if h.InitialState.Status != RunActive || !atOpen(h.InitialState.Current) || len(h.InitialState.PendingInputs) != 0 || h.InitialState.ModelSteps != 0 || h.InitialState.Result != nil || h.InitialState.LastModelResult != nil || h.InitialState.LastClosedStep != "" || h.InitialState.LastToolStep != nil || diff --git a/agent/run/loop/helpers_test.go b/agent/run/loop/helpers_test.go index 9e7dfd9..d146588 100644 --- a/agent/run/loop/helpers_test.go +++ b/agent/run/loop/helpers_test.go @@ -49,25 +49,3 @@ func recordEvents(t testing.TB, rt Runtime, runID RunID) []AgentEvent { } return events } - -func snapshotWaiting(t *testing.T, rt Runtime, runID RunID) []ResponseRequest { - t.Helper() - snap, err := rt.Load(context.Background(), runID) - if err != nil { - t.Fatal(err) - } - reqs := WaitingCalls(snap.State) - if len(reqs) == 0 { - t.Fatal("expected Waiting calls on the snapshot") - } - return reqs -} - -func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDecision, reason string) Digest { - t.Helper() - digest, err := DigestToolResponseDecision(kind, decision, reason) - if err != nil { - t.Fatal(err) - } - return digest -} diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index 6c5a3af..99b4cd6 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -877,18 +877,13 @@ func toolCallFromSnapshot(state run.MachineState, stepID run.StepID, callID run. // 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). One Unknown cancels -// sibling workers. A non-sentinel commit error leaves the same command in 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 run.Runtime, events EventSink, runID run.RunID, stepID run.StepID, started []startedWorker, proto run.Protocol) error { if len(started) == 0 { return nil } - // The tool sees cancellation so cooperative implementations can stop. The - // settlement path uses controlCtx, which is independent of worker - // cancellation and can record the resulting known/unknown outcome. - execCtx, cancelAll := context.WithCancel(ctx) - defer cancelAll() controlCtx := context.WithoutCancel(ctx) var mu sync.Mutex @@ -908,10 +903,9 @@ func (l *Loop) settleWorkers(ctx context.Context, runtime run.Runtime, events Ev Arguments: w.call.Arguments, Progress: &progressSink{events: events, run: runID, step: stepID, call: w.call.CallID}, } - outcome := executeToolSafely(execCtx, w.tool, &req) + outcome := executeToolSafely(ctx, w.tool, &req) var cmd run.AgentCommand - unknown := false switch o := outcome.(type) { case ToolExecutionSucceeded: cmd = run.SubmitToolResult{StepID: stepID, CallID: w.call.CallID, Result: o.Result} @@ -928,11 +922,9 @@ func (l *Loop) settleWorkers(ctx context.Context, runtime run.Runtime, events Ev } failure.Class = run.FailureEffectUnknown cmd = run.SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, Failure: failure, Outcome: run.ToolOutcomeUnknown} - unknown = true default: cmd = run.SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: "tool returned no outcome"}, Outcome: run.ToolOutcomeUnknown} - unknown = true } mu.Lock() @@ -962,9 +954,6 @@ func (l *Loop) settleWorkers(ctx context.Context, runtime run.Runtime, events Ev firstErr = fmt.Errorf("agent: loop: settling call %q: %w", w.call.CallID, err) } } - if unknown { - cancelAll() // one Unknown cancels sibling workers (RUN-LOP-4) - } }(w) } wg.Wait() diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go index 907d052..978d7cc 100644 --- a/agent/run/loop/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "strings" "sync" "sync/atomic" "testing" @@ -19,7 +18,6 @@ import ( type fakeInvoker struct { results []sdk.ModelResult - errs []error calls atomic.Int32 } @@ -28,9 +26,6 @@ func (f *fakeInvoker) Generate(ctx context.Context, req sdk.Request) (sdk.ModelR return sdk.ModelResult{}, err } n := int(f.calls.Add(1)) - 1 - if n < len(f.errs) && f.errs[n] != nil { - return sdk.ModelResult{}, f.errs[n] - } if n >= len(f.results) { return sdk.ModelResult{}, errors.New("fake: no scripted result") } @@ -100,22 +95,6 @@ func (p staticPlanner) Plan(_ context.Context, hint PlanningHint) (RequestPlan, return RequestPlan{Model: model, Request: req, InputIDs: ids, Tools: p.specs}, nil } -type resultCheckingPlanner struct { - specs []ToolSpec - saw atomic.Bool -} - -func (p *resultCheckingPlanner) Plan(ctx context.Context, hint PlanningHint) (RequestPlan, error) { - if hint.LastToolStep != nil { - for _, call := range hint.LastToolStep.Calls { - if call.CallID == "c1" && call.Status == ToolCompleted && call.Result != nil && call.Result.Output.String() == `{"x":1}` { - p.saw.Store(true) - } - } - } - return staticPlanner{specs: p.specs}.Plan(ctx, hint) -} - func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { t.Helper() def := sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} @@ -130,7 +109,7 @@ func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { return ToolSpec{Ref: ToolRef(name), Definition: frozen, DefinitionDigest: d, Policy: policy} } -func loopRuntime(t *testing.T) *MemoryRuntime { +func loopRuntime(t *testing.T) Runtime { t.Helper() return newTestRuntime(t) } @@ -149,52 +128,6 @@ func toolCallResult(ids ...string) sdk.ModelResult { // --- tests --- -func TestLoopSingleModelCallCompletes(t *testing.T) { - rt := loopRuntime(t) - loop, err := New(fakeCatalog{&fakeInvoker{results: []sdk.ModelResult{textResult("hello")}}}, - fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) - if err != nil { - t.Fatal(err) - } - res, err := loop.Run(context.Background(), rt, "run-1", nil) - if err != nil { - t.Fatal(err) - } - if res.Disposition != LoopFinished || res.Result == nil || res.Result.Status != RunCompleted { - t.Fatalf("res = %+v", res) - } - if res.Result.Model.Text != "hello" { - t.Fatalf("text = %q", res.Result.Model.Text) - } -} - -type errCatalog struct{ err error } - -func (c errCatalog) Resolve(ModelRef) (ModelInvoker, error) { return nil, c.err } - -func TestLoopModelCatalogErrorDoesNotFailRun(t *testing.T) { - rt := loopRuntime(t) - loop, err := New(errCatalog{errors.New("missing provider")}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) - if err != nil { - t.Fatal(err) - } - _, err = loop.Run(context.Background(), rt, "run-1", nil) - if err == nil || !strings.Contains(err.Error(), "missing provider") { - t.Fatalf("err = %v", err) - } - snap, loadErr := rt.Load(context.Background(), "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) - } -} - func TestLoopRejectsConcurrentRunForSameID(t *testing.T) { rt := loopRuntime(t) invoker := &blockingInvoker{started: make(chan struct{}), release: make(chan struct{})} @@ -218,245 +151,54 @@ func TestLoopRejectsConcurrentRunForSameID(t *testing.T) { } } -func TestLoopToolRoundTrip(t *testing.T) { - spec := toolSpec(t, "echo", DirectExecution) - echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, - execute: func(_ context.Context, req ToolExecutionRequest) ToolExecutionOutcome { - return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: req.Arguments}} - }} - invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("done")}} - rt := loopRuntime(t) - planner := &resultCheckingPlanner{specs: []ToolSpec{spec}} - loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, - planner, ExecutionPolicy{}, false) +type errCatalog struct{ err error } - res, err := loop.Run(context.Background(), rt, "run-1", nil) - if err != nil { - t.Fatal(err) - } - if res.Disposition != LoopFinished || res.Result.Status != RunCompleted || res.Result.Model.Text != "done" { - t.Fatalf("res = %+v", res) - } - if invoker.calls.Load() != 2 { - t.Fatalf("model calls = %d, want 2", invoker.calls.Load()) - } - if !planner.saw.Load() { - t.Fatal("second planning call did not receive the completed tool result") - } - // Usage accumulated across both model steps. - if res.Result.Usage.TotalTokens != 3 { - t.Fatalf("usage = %d, want 3", res.Result.Usage.TotalTokens) - } -} +func (c errCatalog) Resolve(ModelRef) (ModelInvoker, error) { return nil, c.err } -func TestLoopApprovalWaitsAndResumes(t *testing.T) { - spec := toolSpec(t, "echo", ApprovalRequired) - executed := atomic.Bool{} - echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: ApprovalRequired, - execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { - executed.Store(true) - return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: cj(`"ok"`)}} - }} - invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("after")}} +func TestLoopModelCatalogErrorRecoversWithFreshLoop(t *testing.T) { rt := loopRuntime(t) - loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, - staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) - - // First run: no executable effect remains; approval lives on the snapshot. - res, err := loop.Run(context.Background(), rt, "run-1", nil) - if err != nil { - t.Fatal(err) - } - if res.Disposition != LoopWaiting || res.ExecutionRecovery { - t.Fatalf("res = %+v", res) - } - if executed.Load() { - t.Fatal("tool executed before approval") - } - wait := snapshotWaiting(t, rt, "run-1")[0] - if wait.Kind != ResponseApproval { - t.Fatalf("kind = %v", wait.Kind) - } - - // Ingress approves via the derived response command ID (RUN-WIR-3). - cmdID := DeriveResponseCommandID(wait.RunID, wait.StepID, wait.CallID, wait.ID) - env, err := BuildEnvelope(wait.RunID, cmdID, ApproveToolCall{ - StepID: wait.StepID, CallID: wait.CallID, ResponseID: wait.ID, - ResponseDigest: responseDecisionDigest(t, ResponseApproval, ResponseDecisionApproved, ""), - }) + missing := errors.New("missing provider") + broken, err := New(errCatalog{missing}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) if err != nil { t.Fatal(err) } - snap, _ := rt.Load(context.Background(), "run-1") - if _, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { - t.Fatal(err) - } - - // Wake: a new Loop run executes the tool and finishes. - res, err = loop.Run(context.Background(), rt, "run-1", nil) - if err != nil { - t.Fatal(err) - } - if res.Disposition != LoopFinished || res.Result.Model.Text != "after" { - t.Fatalf("res = %+v", res) - } - if !executed.Load() { - t.Fatal("approved tool never executed") + _, err = broken.Run(context.Background(), rt, "run-1", nil) + if !errors.Is(err, missing) { + t.Fatalf("err = %v, want %v", err, missing) } -} - -func TestLoopYieldsWaitingBeforeStartingPending(t *testing.T) { - var workRan, askRan atomic.Bool - askSpec := toolSpec(t, "ask", ApprovalRequired) - workSpec := toolSpec(t, "work", DirectExecution) - ask := &fakeTool{ref: "ask", def: askSpec.Definition.SDK(), policy: ApprovalRequired, - execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { - askRan.Store(true) - return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: cj(`"asked"`)}} - }} - work := &fakeTool{ref: "work", def: workSpec.Definition.SDK(), policy: DirectExecution, - execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { - workRan.Store(true) - return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: cj(`"worked"`)}} - }} - invoker := &fakeInvoker{results: []sdk.ModelResult{ - { - FinishReason: sdk.FinishReasonToolCalls, - Usage: sdk.Usage{TotalTokens: 2}, - ToolCalls: []sdk.ToolCall{ - {ToolCallID: "cA", ToolName: "ask", Input: `{"x":1}`}, - {ToolCallID: "cB", ToolName: "work", Input: `{"x":1}`}, - }, - }, - textResult("after"), - }} - rt := loopRuntime(t) - loop, err := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"ask": ask, "work": work}}, - staticPlanner{specs: []ToolSpec{askSpec, workSpec}}, ExecutionPolicy{}, false) - if err != nil { - t.Fatal(err) - } - - res, err := loop.Run(context.Background(), rt, "run-1", nil) - if err != nil { - t.Fatal(err) - } - if res.Disposition != LoopWaiting || res.ExecutionRecovery { - t.Fatalf("first run = %+v", res) - } - if !workRan.Load() { - t.Fatal("DirectExecution tools must start in the same Run that returns LoopWaiting") - } - if askRan.Load() { - t.Fatal("approval tool executed before ApproveToolCall") - } - - wait := snapshotWaiting(t, rt, "run-1")[0] - if wait.CallID != "cA" { - t.Fatalf("waiting call = %s, want cA", wait.CallID) + snap, loadErr := rt.Load(context.Background(), "run-1") + if loadErr != nil { + t.Fatal(loadErr) } - cmdID := DeriveResponseCommandID(wait.RunID, wait.StepID, wait.CallID, wait.ID) - env, err := BuildEnvelope(wait.RunID, cmdID, ApproveToolCall{ - StepID: wait.StepID, CallID: wait.CallID, ResponseID: wait.ID, - ResponseDigest: responseDecisionDigest(t, ResponseApproval, ResponseDecisionApproved, ""), - }) - if err != nil { - t.Fatal(err) + if snap.State.Status != RunActive { + t.Fatalf("status = %v", snap.State.Status) } - snap, err := rt.Load(context.Background(), "run-1") - if err != nil { - t.Fatal(err) + ms, ok := snap.State.Current.(ModelStep) + if !ok || ms.Status != ModelPrepared { + t.Fatalf("current = %+v", snap.State.Current) } - if _, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { - t.Fatal(err) + if snap.State.ModelSteps != 1 { + t.Fatalf("ModelSteps = %d, want 1", snap.State.ModelSteps) } - res, err = loop.Run(context.Background(), rt, "run-1", nil) + invoker := &fakeInvoker{results: []sdk.ModelResult{textResult("resumed")}} + ready, err := New(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) if err != nil { t.Fatal(err) } - if res.Disposition != LoopFinished || res.Result.Model.Text != "after" { - t.Fatalf("after approval = %+v", res) - } - if !askRan.Load() { - t.Fatal("approved tool never executed") - } -} - -func TestLoopUnknownOutcomeFailsRun(t *testing.T) { - spec := toolSpec(t, "echo", DirectExecution) - echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, - execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { - return ToolExecutionUnknown{Failure: ToolFailure{Class: FailureEffectUnknown, Message: "lost"}} - }} - invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1")}} - rt := loopRuntime(t) - loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, - staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) - - res, err := loop.Run(context.Background(), rt, "run-1", nil) + res, err := ready.Run(context.Background(), rt, "run-1", nil) if err != nil { t.Fatal(err) } - if res.Disposition != LoopFinished || res.Result.Status != RunFailed || res.Result.Reason != ReasonEffectUnknown { - t.Fatalf("res = %+v", res) - } - if res.Result.Failure == nil || res.Result.Failure.CallID != "c1" { - t.Fatalf("failure = %+v", res.Result.Failure) - } -} - -func TestLoopKnownToolFailureContinues(t *testing.T) { - spec := toolSpec(t, "echo", DirectExecution) - echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, - execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { - return ToolExecutionFailed{Failure: ToolFailure{Class: FailureExecution, Message: "boom"}} - }} - invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("recovered")}} - rt := loopRuntime(t) - loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, - staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) - - res, err := loop.Run(context.Background(), rt, "run-1", nil) - if err != nil { - t.Fatal(err) - } - // Known failure feeds the next model request; run completes. - if res.Result.Status != RunCompleted || res.Result.Model.Text != "recovered" { + if res.Result == nil || res.Result.Model.Text != "resumed" { t.Fatalf("res = %+v", res) } -} - -func TestLoopUnknownToolRefClosesAsLookupFailure(t *testing.T) { - // Model calls a tool that is not in the catalog (and not in specs). - invoker := &fakeInvoker{results: []sdk.ModelResult{ - func() sdk.ModelResult { - r := sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls} - r.ToolCalls = []sdk.ToolCall{{ToolCallID: "c1", ToolName: "ghost", Input: `{}`}} - return r - }(), - textResult("moved on"), - }} - rt := loopRuntime(t) - loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{tools: map[ToolRef]ExecutableTool{}}, - staticPlanner{}, ExecutionPolicy{}, false) - - res, err := loop.Run(context.Background(), rt, "run-1", nil) + final, err := rt.Load(context.Background(), "run-1") if err != nil { t.Fatal(err) } - if res.Result.Status != RunCompleted || res.Result.Model.Text != "moved on" { - t.Fatalf("res = %+v", res) - } - // The failed call must be recorded as tool_lookup_failed in the log. - found := false - for _, e := range recordEvents(t, rt, "run-1") { - if f, ok := e.Fact.(ToolCallFailed); ok && f.Failure.Class == FailureToolLookup { - found = true - } - } - if !found { - t.Fatal("no tool_lookup_failed fact in the event log") + if final.State.ModelSteps != 1 { + t.Fatalf("ModelSteps = %d after resume, want 1", final.State.ModelSteps) } } @@ -556,7 +298,7 @@ func TestToolStartStaleDropsLocalClaim(t *testing.T) { } type responseLossRuntime struct { - *MemoryRuntime + Runtime mu sync.Mutex count map[CommandID]int loseModelStart bool @@ -564,12 +306,11 @@ type responseLossRuntime struct { func newResponseLossRuntime(t *testing.T) *responseLossRuntime { t.Helper() - base := loopRuntime(t) - return &responseLossRuntime{MemoryRuntime: base, count: make(map[CommandID]int)} + return &responseLossRuntime{Runtime: loopRuntime(t), count: make(map[CommandID]int)} } func (r *responseLossRuntime) Commit(ctx context.Context, req CommitRequest) (CommitResult, error) { - result, err := r.MemoryRuntime.Commit(ctx, req) + result, err := r.Runtime.Commit(ctx, req) if err != nil { return result, err } @@ -649,34 +390,6 @@ func TestLoopReplaysSettlementWithoutRepeatingTool(t *testing.T) { } } -func TestLoopCtxCancelReturnsWithoutFailingRun(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - invoker := &fakeInvoker{errs: []error{context.Canceled}} - rt := loopRuntime(t) - loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) - - cancel() // cancelled before the model call - _, err := loop.Run(ctx, rt, "run-1", nil) - if !errors.Is(err, context.Canceled) { - t.Fatalf("err = %v, want context.Canceled", err) - } - // Run must still be active (recovery released it), never failed. - snap, _ := rt.Load(context.Background(), "run-1") - if snap.State.Status != RunActive { - t.Fatalf("status = %v, want RunActive", snap.State.Status) - } - // A fresh Loop with a working invoker resumes the same frozen request. - invoker2 := &fakeInvoker{results: []sdk.ModelResult{textResult("resumed")}} - loop2, _ := New(fakeCatalog{invoker2}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) - res, err := loop2.Run(context.Background(), rt, "run-1", nil) - if err != nil { - t.Fatal(err) - } - if res.Result == nil || res.Result.Model.Text != "resumed" { - t.Fatalf("res = %+v", res) - } -} - func TestLoopMalformedModelResultDispositionFailsRun(t *testing.T) { rt := loopRuntime(t) bad := sdk.ModelResult{ @@ -717,24 +430,6 @@ func (panicPlanner) Plan(context.Context, PlanningHint) (RequestPlan, error) { panic("planner should not be called") } -func TestLoopCancelRunViaCommand(t *testing.T) { - // Host order: commit CancelRun first, then cancel ctx (RUN-LOP-5). - rt := loopRuntime(t) - snap, _ := rt.Load(context.Background(), "run-1") - env, _ := BuildEnvelope("run-1", "cancel-1", CancelRun{}) - if _, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { - t.Fatal(err) - } - loop, _ := New(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) - res, err := loop.Run(context.Background(), rt, "run-1", nil) - if err != nil { - t.Fatal(err) - } - if res.Result.Status != RunStopped || res.Result.Reason != ReasonCancelled { - t.Fatalf("res = %+v", res) - } -} - // cancellingInvoker cancels the outer ctx from inside Generate, simulating a // shutdown arriving mid-execution. type cancellingInvoker struct{ cancel context.CancelFunc } diff --git a/agent/run/loop/regression_test.go b/agent/run/loop/regression_test.go index c60554f..99ddd90 100644 --- a/agent/run/loop/regression_test.go +++ b/agent/run/loop/regression_test.go @@ -18,7 +18,7 @@ func TestRegressionToolPanicBecomesUnknown(t *testing.T) { execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { panic("nil map write") }} - invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1")}} + 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) @@ -27,11 +27,22 @@ func TestRegressionToolPanicBecomesUnknown(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Result.Status != RunFailed || res.Result.Reason != ReasonEffectUnknown { + if res.Result.Status != RunCompleted { t.Fatalf("res = %+v", res.Result) } - if !strings.Contains(res.Result.Failure.Message, "panic") { - t.Fatalf("failure = %+v", res.Result.Failure) + found := false + for _, e := range recordEvents(t, rt, "run-1") { + failed, ok := e.Fact.(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") } } diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go index 4677c91..ae39f4a 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -178,7 +178,7 @@ func TestInitializeRunIsMinimal(t *testing.T) { if err != nil { t.Fatal(err) } - if s.RunID != "r" || s.Status != RunActive || len(s.PendingInputs) != 0 { + if s.RunID != "r" || s.Status != RunActive || !atOpen(s.Current) || len(s.PendingInputs) != 0 { t.Fatalf("initial state = %+v", s) } } @@ -226,32 +226,6 @@ func TestPrepareRejectsIncompleteInputIDs(t *testing.T) { } } -func TestModelCompleteNoToolsEndsRun(t *testing.T) { - s := newRun(t) - s, stepID := advanceToExecuting(t, s, testRequest(), nil) - result, err := FreezeModelResult(sdk.ModelResult{Text: "done", FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 7}}) - if err != nil { - t.Fatal(err) - } - facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: result}) - if len(facts) != 2 { - t.Fatalf("facts = %d, want [completed, ended]", len(facts)) - } - if _, ok := facts[1].(RunEnded); !ok { - t.Fatalf("facts[1] = %T", facts[1]) - } - s = fold(t, s, facts) - if s.Status != RunCompleted { - t.Fatalf("status = %v", s.Status) - } - if s.Result == nil || s.Result.Model == nil || s.Result.Model.Text != "done" { - t.Fatalf("result = %+v", s.Result) - } - if s.Result.Usage.TotalTokens != 7 { - t.Fatalf("usage not accumulated into result: %+v", s.Result.Usage) - } -} - func TestModelCompleteWithToolsOpensToolStep(t *testing.T) { def := testToolDef("t") spec := makeSpec(t, def, DirectExecution) @@ -283,80 +257,6 @@ func TestModelCompleteWithToolsOpensToolStep(t *testing.T) { } } -func TestApprovalCallOpensWaitingWithDerivedResponse(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, "c1", spec, `{}`) - facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) - opened := facts[1].(ToolStepOpened) - if opened.Calls[0].Response == nil { - t.Fatal("approval call has no derived ResponseRequest") - } - want := DeriveResponseID(s.RunID, opened.StepID, "c1", ResponseApproval) - if opened.Calls[0].Response.ID != want { - t.Fatal("ResponseID not derived per spec") - } - s = fold(t, s, facts) - ts := s.Current.(ToolStep) - if ts.Calls[0].Status != ToolWaiting { - t.Fatalf("status = %v, want Waiting", ts.Calls[0].Status) - } - - // Next has no executable effect; Application reads the request from state. - eff, err := Next(s) - if err != nil { - t.Fatal(err) - } - if _, ok := eff.(Idle); !ok { - t.Fatalf("effect = %#v, want Idle", eff) - } - reqs := WaitingCalls(s) - if len(reqs) != 1 || reqs[0].ID != want { - t.Fatalf("WaitingCalls = %#v", reqs) - } - - if _, err := Decide(s, ApproveToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: want, ResponseDigest: "sha256:bad"}); err == nil { - t.Fatal("approval with bad response digest accepted") - } - - // Approve -> Pending; then start -> execute path. - s = fold(t, s, mustDecide(t, s, ApproveToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: want, - ResponseDigest: responseDecisionDigest(t, ResponseApproval, ResponseDecisionApproved, "")})) - if s.Current.(ToolStep).Calls[0].Status != ToolPending { - t.Fatal("approved call is not Pending") - } -} - -func TestRejectRecordsPermissionDenied(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, "c1", spec, `{}`) - facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) - opened := facts[1].(ToolStepOpened) - s = fold(t, s, facts) - respID := opened.Calls[0].Response.ID - - facts = mustDecide(t, s, RejectToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: respID, - ResponseDigest: responseDecisionDigest(t, ResponseApproval, ResponseDecisionRejected, "no"), Reason: "no"}) - // Single call: the Known failure implicitly closes the ToolStep. - if len(facts) != 1 { - t.Fatalf("facts = %d, want [failed]", len(facts)) - } - failed := facts[0].(ToolCallFailed) - if failed.Failure.Class != FailurePermissionDenied || failed.Outcome != ToolOutcomeKnown { - t.Fatalf("failed = %+v", failed) - } - s = fold(t, s, facts) - if s.Current != nil || s.Status != RunActive { - t.Fatal("run should continue with no current step") - } -} - func TestExternalResponseRequiresPayloadDigest(t *testing.T) { def := testToolDef("ask") spec := makeSpec(t, def, ExternalResponse) @@ -376,6 +276,27 @@ func TestExternalResponseRequiresPayloadDigest(t *testing.T) { 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, "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 := Decide(s, RejectToolCall{StepID: opened.StepID, CallID: "c1", 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) { @@ -410,36 +331,6 @@ func TestToolSchedulingRejectsUnknownMode(t *testing.T) { } } -func TestUnknownFailureEndsRun(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, "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: "c1"})) - - facts = mustDecide(t, s, SubmitToolFailure{StepID: opened.StepID, CallID: "c1", Outcome: ToolOutcomeUnknown}) - if len(facts) != 2 { - t.Fatalf("facts = %d, want [failed, ended]", len(facts)) - } - ended := facts[1].(RunEnded) - failed, ok := ended.End.(RunFailedEnd) - if !ok || failed.Reason != ReasonEffectUnknown || failed.Failure.CallID != "c1" { - t.Fatalf("ended = %+v", ended.End) - } - s = fold(t, s, facts) - if s.Status != RunFailed { - t.Fatal("run not failed") - } - // Terminal absorbs: further commands rejected. - if _, err := Decide(s, CancelRun{}); !errors.Is(err, ErrRunTerminal) { - t.Fatalf("err = %v, want ErrRunTerminal", err) - } -} - func TestParallelWaitingDoesNotBlockPending(t *testing.T) { defA, defB := testToolDef("a"), testToolDef("b") specA := makeSpec(t, defA, ApprovalRequired) @@ -503,11 +394,73 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { t.Fatalf("facts = %d, want [completed]", len(facts)) } s = fold(t, s, facts) - if s.Current != nil { + 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, "cA", specA, `{}`) + bB := makeBinding(t, "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: "cA"})) + s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: "cB"})) + + facts = mustDecide(t, s, SubmitToolFailure{ + StepID: opened.StepID, + CallID: "cA", + 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 != "cA" || 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: "cB", 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) @@ -548,73 +501,6 @@ func TestRejectModelResultDispositionRetriesThenFails(t *testing.T) { } } -func TestModelRecoveryKeepsFrozenRequestAndCounts(t *testing.T) { - s := newRun(t) - s, stepID := advanceToExecuting(t, s, testRequest(), nil) - s = fold(t, s, mustDecide(t, s, RecoverModelExecution{StepID: stepID})) - ms := s.Current.(ModelStep) - if ms.Status != ModelPrepared { - t.Fatal("recovered step not Prepared") - } - if s.ModelSteps != 1 { - t.Fatal("recovery must not recount model steps") - } - if s.Usage.TotalTokens != 0 { - t.Fatal("recovery must not change usage") - } -} - -func TestCancelProducesRunStopped(t *testing.T) { - s := newRun(t) - facts := mustDecide(t, s, CancelRun{}) - ended := facts[0].(RunEnded) - stopped, ok := ended.End.(RunStoppedEnd) - if !ok || stopped.Reason != ReasonCancelled { - t.Fatalf("ended = %+v", ended.End) - } - if len(stopped.UncertainCalls) != 0 || stopped.UncertainModel != "" { - t.Fatalf("idle cancel recorded uncertain effects: %+v", stopped) - } -} - -func TestCancelSurfacesUncertainToolCalls(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, "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: "c1"})) - - facts = mustDecide(t, s, CancelRun{}) - ended := facts[len(facts)-1].(RunEnded) - stopped := ended.End.(RunStoppedEnd) - if stopped.Reason != ReasonCancelled || len(stopped.UncertainCalls) != 1 || stopped.UncertainCalls[0] != "c1" { - t.Fatalf("stopped = %+v", stopped) - } - s = fold(t, s, facts) - if s.Result == nil || len(s.Result.UncertainCalls) != 1 || s.Result.UncertainCalls[0] != "c1" { - t.Fatalf("result = %+v", s.Result) - } -} - -func TestCancelSurfacesUncertainModelStep(t *testing.T) { - s := newRun(t) - s, stepID := advanceToExecuting(t, s, testRequest(), nil) - facts := mustDecide(t, s, CancelRun{}) - ended := facts[0].(RunEnded) - stopped := ended.End.(RunStoppedEnd) - if stopped.UncertainModel != stepID { - t.Fatalf("uncertain model = %q, want %q", stopped.UncertainModel, stepID) - } - s = fold(t, s, facts) - if s.Result == nil || s.Result.UncertainModel != stepID { - t.Fatalf("result = %+v", s.Result) - } -} - func TestAcceptInputIdempotentPerID(t *testing.T) { s := newRun(t) facts := mustDecide(t, s, NextStep(AgentInput{ID: "in-2", Payload: cj(`1`)})) @@ -643,12 +529,6 @@ func TestAcceptInputRejectsSeedDuplicateID(t *testing.T) { } } -func TestSubmitModelResultRequiresCanonicalToolInput(t *testing.T) { - if _, err := ParseCanonicalJSON([]byte(`{"path":`)); err == nil { - t.Fatal("constructed non-canonical model tool input") - } -} - func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { minimal, err := InitializeRun("run-1") if err != nil { @@ -707,7 +587,7 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { if err != nil { t.Fatal(err) } - if next.Current == nil || len(next.PendingInputs) != 0 { + if _, ok := next.Current.(ModelStep); !ok || len(next.PendingInputs) != 0 { t.Fatalf("prepared state = %+v", next) } }) @@ -728,56 +608,3 @@ func TestEvolveRejectsModelPrepareOverCurrentStep(t *testing.T) { t.Fatal("Evolve accepted ModelStepPrepared over an existing step") } } - -func TestReplayEquivalence(t *testing.T) { - // state = fold(Evolve, initial, events): run a full happy path, capture - // all facts, refold from initial, and compare canonical serializations. - def := testToolDef("t") - spec := makeSpec(t, def, DirectExecution) - initial := newRun(t) - var log []Fact - - s := initial - step := func(c AgentCommand) { - facts := mustDecide(t, s, c) - log = append(log, facts...) - s = fold(t, s, facts) - } - prep, _ := buildPrepare(t, s, testRequest(def), []ToolSpec{spec}) - step(prep) - step(StartModelExecution{StepID: prep.StepID}) - b := makeBinding(t, "c1", spec, `{}`) - step(SubmitModelResult{StepID: prep.StepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) - ts := s.Current.(ToolStep) - step(StartToolCall{StepID: ts.RefValue.ID, CallID: "c1"}) - step(SubmitToolResult{StepID: ts.RefValue.ID, CallID: "c1", Result: ToolExecutionResult{Output: cj(`"ok"`)}}) - - replayed := fold(t, initial, log) - a, err := json.Marshal(stateSnapshotForTest(s)) - if err != nil { - t.Fatal(err) - } - bts, err := json.Marshal(stateSnapshotForTest(replayed)) - if err != nil { - t.Fatal(err) - } - if string(a) != string(bts) { - t.Fatalf("replay diverged:\n live %s\n replay %s", a, bts) - } -} - -// stateSnapshotForTest flattens MachineState including the unexported-ish -// Current step for comparison. -func stateSnapshotForTest(s MachineState) map[string]any { - m := map[string]any{ - "runId": s.RunID, "status": s.Status, "modelSteps": s.ModelSteps, - "usage": s.Usage, "pending": s.PendingInputs, "result": s.Result, - } - switch cur := s.Current.(type) { - case ModelStep: - m["model"] = cur - case ToolStep: - m["tool"] = cur - } - return m -} diff --git a/agent/run/next.go b/agent/run/next.go index 1c0f66d..5afe4f1 100644 --- a/agent/run/next.go +++ b/agent/run/next.go @@ -110,7 +110,7 @@ func Next(s MachineState) (Effect, error) { return nil, ErrRunTerminal } switch cur := s.Current.(type) { - case nil: + case Open: return NeedModelRequest{Hint: PlanningHint{ RunID: s.RunID, SourceStep: s.LastClosedStep, @@ -143,6 +143,6 @@ func Next(s MachineState) (Effect, error) { } return nil, rejectionf("next: tool step %q has no live calls but was not closed", cur.RefValue.ID) default: - return nil, rejectionf("next: unknown step variant %T", s.Current) + return nil, rejectionf("next: unknown current variant %T", s.Current) } } diff --git a/agent/run/regression_test.go b/agent/run/regression_test.go index dac1a9c..6228deb 100644 --- a/agent/run/regression_test.go +++ b/agent/run/regression_test.go @@ -1,28 +1,16 @@ package run -import ( - "context" - "testing" +import "testing" - "github.com/memohai/twilight/sdk" -) - -// Regression tests for the code-review findings on the phase A/B -// implementation. Each test pins one fixed defect. - -// Finding 1: a result carrying tool calls with zero bindings must not -// silently complete the run. func TestRegressionZeroBindingsWithToolCallsRejected(t *testing.T) { s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(), nil) - result := modelResultWithCalls("c1") // has tool calls + result := modelResultWithCalls("c1") if _, err := Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: nil}); err == nil { t.Fatal("result with tool calls and no bindings completed the run") } } -// Finding 2: a binding naming a different tool / different arguments than the -// model result must be rejected even with self-consistent digests. func TestRegressionBindingMustMatchModelResult(t *testing.T) { safe := testToolDef("safe") danger := testToolDef("danger") @@ -31,54 +19,21 @@ func TestRegressionBindingMustMatchModelResult(t *testing.T) { s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(safe, danger), []ToolSpec{specSafe, specDanger}) - // Model called "safe" with {"a":1}; binding claims "danger" with {"rm":"-rf"}. evil := makeBinding(t, "c1", specDanger, `{"rm":"-rf"}`) result := modelResultWithNamedCalls("safe", `{"a":1}`, "c1") if _, err := Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: []ToolCallBinding{evil}}); err == nil { t.Fatal("binding for a tool the model never called was accepted") } - // Same tool, different arguments: also rejected. tampered := makeBinding(t, "c1", specSafe, `{"a":999}`) if _, err := Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: []ToolCallBinding{tampered}}); err == nil { t.Fatal("binding with tampered arguments was accepted") } } -// Finding 4: protocol JSON follows RFC 8785 / IEEE-754 semantics, so the -// PostgreSQL JSONB rendering of a number has the same digest as its wire -// spelling. Exact large identifiers must be JSON strings. -func TestRegressionJCSNumberSemantics(t *testing.T) { - d1, err := digestToolCallBinding("c", "", DirectExecution, cj(`{"n":1e+21}`)) - if err != nil { - t.Fatal(err) - } - d2, err := digestToolCallBinding("c", "", DirectExecution, cj(`{"n":1000000000000000000000}`)) - if err != nil { - t.Fatal(err) - } - if d1 != d2 { - t.Fatal("equivalent JCS numbers produced different binding digests") - } - - 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") - } -} - -// Finding 5 + 10: the ToolStep ID must reproduce from the persisted StepRef -// digest, and the digest is carried in the fact (not recomputed by Evolve). func TestRegressionToolStepIDReproducible(t *testing.T) { def := testToolDef("t") - spec := makeSpec(t, def, ApprovalRequired) // Response-filled path + spec := makeSpec(t, def, ApprovalRequired) s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) b := makeBinding(t, "c1", spec, `{}`) @@ -94,103 +49,6 @@ func TestRegressionToolStepIDReproducible(t *testing.T) { } } -// Finding 3: invalid tool output cannot enter the authority contract. Tools -// return CanonicalJSON, so malformed JSON is rejected at construction rather -// than later inside BuildEnvelope/DigestCommand. -func TestRegressionInvalidToolOutputCannotBeConstructed(t *testing.T) { - if _, err := ParseCanonicalJSON([]byte(`{broken`)); err == nil { - t.Fatal("malformed JSON constructed as CanonicalJSON") - } -} - -// Finding 7: an abandoned ask-user call has a failure exit via RejectToolCall. -func TestRegressionExternalResponseCanBeRejected(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, "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 := Decide(s, RejectToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: respID, - ResponseDigest: responseDecisionDigest(t, ResponseExternal, ResponseDecisionRejected, "user dismissed"), Reason: "user dismissed"}) - if err != nil { - t.Fatalf("external-response call cannot be rejected: %v", 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 || s.Current != nil { - t.Fatal("run should continue after abandoning the ask-user call") - } -} - -// Finding 8: duplicate object keys and trailing data are rejected, not -// silently canonicalized. -func TestRegressionCanonicalRejectsAmbiguousInput(t *testing.T) { - for _, in := range []string{ - `{"a":1,"a":2}`, - `{"dry_run":true,"dry_run":false}`, - `{"x":1}]`, - `{"a":1}}}`, - `[1,2]]`, - } { - if _, err := canonicalJSON([]byte(in)); err == nil { - t.Fatalf("ambiguous input %q was canonicalized", in) - } - } - // Invalid UTF-8 is rejected rather than collapsed to U+FFFD. - if _, err := canonicalJSON([]byte("{\"a\":\"\xff\"}")); err == nil { - t.Fatal("invalid UTF-8 was canonicalized") - } -} - -// Finding 9: Runtime return values are isolated snapshots — mutating them -// must not reach authoritative state or committed events. -func TestRegressionRuntimeReturnsAreIsolated(t *testing.T) { - def := testToolDef("t") - spec := makeSpec(t, def, ApprovalRequired) - rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) - b := makeBinding(t, "c1", spec, `{"k":"original"}`) - snap, err := rt.Load(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } - res := mustCommit(t, rt, "complete-1", snap.Revision, grant, - SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("t", `{"k":"original"}`, "c1"), Calls: []ToolCallBinding{b}}) - - // Mutate byte views returned from immutable CanonicalJSON values. - opened := res.Events[1].Fact.(ToolStepOpened) - argBytes := opened.Calls[0].Arguments.RawMessage() - argBytes[2] = 'X' - // Mutate the snapshot's waiting payload view. - snap, _ = rt.Load(context.Background(), "run-1") - ts := snap.State.Current.(ToolStep) - payloadBytes := ts.Calls[0].Waiting.Payload.RawMessage() - payloadBytes[2] = 'Y' - - // Authority must be unchanged: reload and verify the argument bytes. - fresh, _ := rt.Load(context.Background(), "run-1") - got := fresh.State.Current.(ToolStep).Calls[0].Arguments - if got.String() != `{"k":"original"}` { - t.Fatalf("authoritative arguments mutated through a returned view: %s", got.String()) - } - stored := recordEvents(t, rt, "run-1") - for _, e := range stored { - if f, ok := e.Fact.(ToolStepOpened); ok { - if f.Calls[0].Arguments.String() != `{"k":"original"}` { - t.Fatalf("stored event mutated through a returned view: %s", f.Calls[0].Arguments.String()) - } - } - } -} - -// Finding 11: Evolve rejects illegal ToolCallState combinations. func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { def := testToolDef("t") spec := makeSpec(t, def, DirectExecution) @@ -202,7 +60,6 @@ func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { s = fold(t, s, facts) s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: "c1"})) - // Unknown outcome with a non-effect_unknown class is illegal (RUN-MCH-2). _, err := Evolve(s, ToolCallFailed{ StepID: opened.StepID, CallID: "c1", @@ -214,7 +71,6 @@ func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { } } -// Low-severity finding: CancelRun cannot forge a system reason. func TestRegressionCancelReasonFixed(t *testing.T) { s := newRun(t) if _, err := Decide(s, CancelRun{Reason: RunReason("other")}); err == nil { diff --git a/agent/run/runtest/approval_test.go b/agent/run/runtest/approval_test.go new file mode 100644 index 0000000..808dd9d --- /dev/null +++ b/agent/run/runtest/approval_test.go @@ -0,0 +1,59 @@ +package runtest_test + +import ( + "testing" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/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) + if got := f.Waiting().CallID; got != "cA" { + t.Fatalf("waiting = %s, want cA", got) + } + 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..3a6a9c2 --- /dev/null +++ b/agent/run/runtest/cancel_test.go @@ -0,0 +1,34 @@ +package runtest_test + +import ( + "testing" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/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..3a6e899 --- /dev/null +++ b/agent/run/runtest/error_test.go @@ -0,0 +1,31 @@ +package runtest_test + +import ( + "context" + "errors" + "testing" + + "github.com/memohai/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..2493684 --- /dev/null +++ b/agent/run/runtest/feature.go @@ -0,0 +1,454 @@ +// 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/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/twilight/sdk" +) + +const ( + defaultRunID = "run-1" + defaultModel = "m-1" +) + +// 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 + tools map[run.ToolRef]*scriptTool + invoker *scriptInvoker + planner *scriptPlanner + loop *loop.Loop + seq int + + modelStepID run.StepID + modelGrant run.ExecutionGrant + 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 := run.NewRuntime(run.NewMemoryStore()) + newRun, err := run.BuildNewRun(defaultRunID, "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(context.Background(), newRun); err != nil { + t.Fatal(err) + } + f := &Feature{ + t: t, + ctx: context.Background(), + runCtx: context.Background(), + runID: defaultRunID, + rt: rt, + model: defaultModel, + tools: make(map[run.ToolRef]*scriptTool), + } + f.commit(run.AcceptInput{Input: run.AgentInput{ + ID: "seed", + Payload: run.MustParseCanonicalJSON(`{"q":"hi"}`), + }}, "") + 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 := f.mustSpec(name, policy) + f.specs = append(f.specs, spec) + f.tools[spec.Ref] = &scriptTool{ + ref: spec.Ref, + def: spec.Definition.SDK(), + 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, 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) + } + id := f.commandID(cmd, snap) + cmd = withClaim(id, cmd) + env, err := proto.BuildEnvelope(f.runID, id, cmd) + if err != nil { + return err + } + _, err = f.rt.Commit(f.ctx, run.CommitRequest{BaseRevision: snap.Revision, 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.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.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() + res := f.commit(run.StartModelExecution{StepID: f.modelStepID}, "") + f.modelGrant = res.Grant + return f +} + +// ExecutingTool leaves the named tool call Executing (no Loop). +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() + 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: string(callID), 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, ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, + BindingDigest: binding, Arguments: args, Policy: spec.Policy, + }}, + }, f.modelGrant) + 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} + 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, 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, grant run.ExecutionGrant) run.CommitResult { + f.t.Helper() + snap := f.load() + proto, err := snap.Protocol() + if err != nil { + f.t.Fatal(err) + } + id := f.commandID(cmd, snap) + cmd = withClaim(id, cmd) + env, err := proto.BuildEnvelope(f.runID, id, cmd) + if err != nil { + f.t.Fatal(err) + } + res, err := f.rt.Commit(f.ctx, run.CommitRequest{ + BaseRevision: snap.Revision, Grant: grant, 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.Revision) + case run.RecoverModelExecution: + return run.DeriveModelRecoveryCommandID(f.runID, c.StepID, 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, spec.Definition.SDK()) + } + 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.Revision) + 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, + }, "") +} + +func (f *Feature) mustSpec(name string, policy run.ResponsePolicy) run.ToolSpec { + 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.DigestToolDefinition(frozen) + if err != nil { + f.t.Fatal(err) + } + return run.ToolSpec{Ref: run.ToolRef(name), Definition: frozen, DefinitionDigest: d, Policy: policy} +} + +func (f *Feature) facts() []run.Fact { + f.t.Helper() + record, err := f.rt.Record(f.ctx, f.runID) + if err != nil { + f.t.Fatal(err) + } + var out []run.Fact + for _, tr := range record.Transitions { + for _, e := range tr.Events { + out = append(out, e.Fact) + } + } + return out +} + +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..392c0d9 --- /dev/null +++ b/agent/run/runtest/require.go @@ -0,0 +1,253 @@ +package runtest + +import ( + "errors" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/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) + } +} + +// RequireCompleted checks the Run finished with the model text. +func (f *Feature) RequireCompleted(text string) { + f.t.Helper() + s := f.state() + if s.Status != run.RunCompleted || s.Result == nil || s.Result.Model == nil || s.Result.Model.Text != text { + f.t.Fatalf("state = %+v", s) + } + if f.loop != nil && f.last.Disposition != loop.LoopFinished { + f.t.Fatalf("loop = %+v, want Finished", f.last) + } +} + +// 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() + 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 received the completed call output. +func (f *Feature) RequirePlannerSawTool(callID run.CallID, output string) { + f.t.Helper() + if f.planner == nil || f.planner.lastHint.LastToolStep == nil { + f.t.Fatal("planner has no LastToolStep") + } + for _, call := range f.planner.lastHint.LastToolStep.Calls { + if call.CallID == callID && call.Status == run.ToolCompleted && call.Result != nil && call.Result.Output.String() == output { + return + } + } + f.t.Fatalf("LastToolStep = %+v, want completed %s %s", f.planner.lastHint.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() + 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() + 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() + 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(f.runID, "after-terminal", run.CancelRun{}) + if err != nil { + f.t.Fatal(err) + } + _, err = f.rt.Commit(f.ctx, run.CommitRequest{BaseRevision: snap.Revision, 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..4573998 --- /dev/null +++ b/agent/run/runtest/script.go @@ -0,0 +1,126 @@ +package runtest + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/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 +} + +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") + } + return s.results[n], nil +} + +type scriptCatalog struct { + invoker loop.ModelInvoker + err error +} + +func (c scriptCatalog) Resolve(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) Resolve(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 + 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, spec.Definition.SDK()) + } + 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..94017e2 --- /dev/null +++ b/agent/run/runtest/tools_test.go @@ -0,0 +1,45 @@ +package runtest_test + +import ( + "testing" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/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..f64f3db --- /dev/null +++ b/agent/run/runtest/unknown_test.go @@ -0,0 +1,31 @@ +package runtest_test + +import ( + "testing" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/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/runtimetest/conformance.go b/agent/run/runtimetest/conformance.go index 0a070d4..ac7729a 100644 --- a/agent/run/runtimetest/conformance.go +++ b/agent/run/runtimetest/conformance.go @@ -1,15 +1,14 @@ // Package runtimetest contains the shared run.Runtime conformance suite. // Durable Runtime implementations run this suite in their own tests instead -// of copying MemoryRuntime-specific assertions: +// of copying MemoryStore-backed assertions: // // func TestMyRuntimeConformance(t *testing.T) { // runtimetest.RunConformance(t, func() run.Runtime { return newMyRuntime() }) // } // // The suite exercises only the public run API, so it holds for any Runtime -// that honors the contract (RUN-CMP-2): command idempotency, revision/index -// assignment, grant lifecycle, call-local rebase, prepare hard-CAS, terminal -// arbitration, and replay-fold equivalence. +// that honors the contract (RUN-CMP-2). Eight groups cover Create, Record, +// Isolation, CommandIdentity, Prepare, Grant, CallLocalRebase, and Cancel. package runtimetest import ( @@ -28,173 +27,313 @@ import ( // Factory constructs an empty Runtime under test. type Factory func() run.Runtime -// RunConformance executes the shared Runtime conformance suite. +// RunConformance executes the shared Runtime conformance suite (RUN-CMP-2). +// Group names are a directory; each check is a separate function with its own Runtime. func RunConformance(t *testing.T, newRuntime Factory) { t.Helper() - t.Run("CreateReturnsValidDetachedHeader", func(t *testing.T) { testCreateReturnsValidDetachedHeader(t, newRuntime) }) - t.Run("CreateRetryAndConflict", func(t *testing.T) { testCreateRetryAndConflict(t, newRuntime) }) - t.Run("MissingRunOperations", func(t *testing.T) { testMissingRunOperations(t, newRuntime) }) - t.Run("RecordRevisionZero", func(t *testing.T) { testRecordRevisionZero(t, newRuntime) }) - t.Run("RecordFoldsAcceptedTransition", func(t *testing.T) { testRecordFoldsAcceptedTransition(t, newRuntime) }) - t.Run("ReturnedValuesAreDetached", func(t *testing.T) { testReturnedValuesAreDetached(t, newRuntime) }) - t.Run("ConcurrentCreate", func(t *testing.T) { testConcurrentCreate(t, newRuntime) }) - t.Run("RunsAreIsolated", func(t *testing.T) { testRunsAreIsolated(t, newRuntime) }) - t.Run("CrossRunGrantIsRejected", func(t *testing.T) { testCrossRunGrantIsRejected(t, newRuntime) }) - t.Run("RecordConcurrentWithCommit", func(t *testing.T) { testRecordConcurrentWithCommit(t, newRuntime) }) - t.Run("IdempotentReplay", func(t *testing.T) { testIdempotentReplay(t, newRuntime) }) - t.Run("RevisionAndIndex", func(t *testing.T) { testRevisionAndIndex(t, newRuntime) }) - t.Run("StartGrantLifecycle", func(t *testing.T) { testStartGrantLifecycle(t, newRuntime) }) - t.Run("DerivedReplayIgnoresNewBaseRevision", func(t *testing.T) { testDerivedReplayIgnoresNewBaseRevision(t, newRuntime) }) + t.Run("Create", func(t *testing.T) { + t.Run("RetryAndConflict", func(t *testing.T) { testCreateRetryAndConflict(t, newRuntime) }) + t.Run("Missing", func(t *testing.T) { testCreateMissing(t, newRuntime) }) + t.Run("Concurrent", func(t *testing.T) { testCreateConcurrent(t, newRuntime) }) + }) + t.Run("Record", func(t *testing.T) { + t.Run("FoldAccepted", func(t *testing.T) { testRecordFoldAccepted(t, newRuntime) }) + t.Run("ReplayAndIndex", func(t *testing.T) { testRecordReplayAndIndex(t, newRuntime) }) + t.Run("ConcurrentWithCommit", func(t *testing.T) { testRecordConcurrentWithCommit(t, newRuntime) }) + }) + t.Run("Isolation", func(t *testing.T) { + t.Run("DetachedReturns", func(t *testing.T) { testIsolationDetachedReturns(t, newRuntime) }) + t.Run("Runs", func(t *testing.T) { testIsolationRuns(t, newRuntime) }) + t.Run("CrossRunGrant", func(t *testing.T) { testIsolationCrossRunGrant(t, newRuntime) }) + }) + t.Run("CommandIdentity", func(t *testing.T) { + t.Run("IdempotentReplay", func(t *testing.T) { testCommandIdempotentReplay(t, newRuntime) }) + t.Run("DerivedIDs", func(t *testing.T) { testCommandDerivedIDs(t, newRuntime) }) + }) + t.Run("Prepare", func(t *testing.T) { + t.Run("HardCAS", func(t *testing.T) { testPrepareHardCAS(t, newRuntime) }) + t.Run("ReplayAfterProgress", func(t *testing.T) { testPrepareReplayAfterProgress(t, newRuntime) }) + }) + t.Run("Grant", func(t *testing.T) { + t.Run("Lifecycle", func(t *testing.T) { testGrant(t, newRuntime) }) + t.Run("HolderRecover", func(t *testing.T) { testGrantHolderRecover(t, newRuntime) }) + t.Run("GrantlessRejectedWhileLive", func(t *testing.T) { testGrantGrantlessRejectedWhileLive(t, newRuntime) }) + }) t.Run("CallLocalRebase", func(t *testing.T) { testCallLocalRebase(t, newRuntime) }) - t.Run("PrepareDerivedIdentity", func(t *testing.T) { testPrepareDerivedIdentity(t, newRuntime) }) - t.Run("PrepareIsHardCAS", func(t *testing.T) { testPrepareIsHardCAS(t, newRuntime) }) - t.Run("CancelRebasesAndUnknownWins", func(t *testing.T) { testCancelRebasesAndUnknownWins(t, newRuntime) }) - t.Run("CancelOnStaleBase", func(t *testing.T) { testCancelOnStaleBase(t, newRuntime) }) - t.Run("ReplayFoldMatchesState", func(t *testing.T) { testReplayFoldMatchesState(t, newRuntime) }) - t.Run("AcceptInputByInputID", func(t *testing.T) { testAcceptInputByInputID(t, newRuntime) }) - t.Run("DerivedCommandIDEnforced", func(t *testing.T) { testDerivedCommandIDEnforced(t, newRuntime) }) + t.Run("Cancel", func(t *testing.T) { + t.Run("StaleBase", func(t *testing.T) { testCancelStaleBase(t, newRuntime) }) + t.Run("AfterUnknown", func(t *testing.T) { testCancelAfterUnknown(t, newRuntime) }) + t.Run("UnknownRemaining", func(t *testing.T) { testCancelUnknownRemaining(t, newRuntime) }) + }) } -func testCreateReturnsValidDetachedHeader(t *testing.T, newRuntime Factory) { - rt := newRuntime() - newRun, err := run.BuildNewRun("run-create", "cause-1") +type conformanceCase struct { + t testing.TB + runID run.RunID + initial run.MachineState + rt run.Runtime + events []run.AgentEvent +} + +func newCase(t testing.TB, newRuntime Factory) *conformanceCase { + t.Helper() + return newCaseOnRuntime(t, newRuntime(), "run-1") +} + +func newCaseOnRuntime(t testing.TB, rt run.Runtime, runID run.RunID) *conformanceCase { + t.Helper() + newRun, err := run.BuildNewRun(runID, "") if err != nil { t.Fatal(err) } created, err := rt.Create(context.Background(), newRun) - if err != nil || !created.Created { - t.Fatalf("first Create = %+v, %v", created, err) + if err != nil { + t.Fatal(err) } - if err := run.ValidateRunHeader(&created.Header); err != nil { - t.Fatalf("Create returned invalid Header: %v", err) + return &conformanceCase{ + t: t, runID: runID, initial: created.Header.InitialState, rt: rt, } - created.Header.InitialState.RunID = "mutated" - record, err := rt.Record(context.Background(), "run-create") +} + +func (c *conformanceCase) load() run.RuntimeSnapshot { + c.t.Helper() + snap, err := c.rt.Load(context.Background(), c.runID) if err != nil { - t.Fatal(err) + c.t.Fatal(err) } - if record.Header.InitialState.RunID != "run-create" { - t.Fatal("Create Header aliases runtime state") + return snap +} + +func (c *conformanceCase) commit(id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) (run.CommitResult, error) { + c.t.Helper() + cmd = withTestExecutionClaim(id, cmd) + env, err := run.BuildEnvelope(c.runID, id, cmd) + if err != nil { + c.t.Fatal(err) } + res, err := c.rt.Commit(context.Background(), run.CommitRequest{BaseRevision: base, Grant: grant, Command: env}) + if err == nil && res.Status == run.CommitAccepted { + c.events = append(c.events, res.Events...) + } + return res, err } -func testCreateRetryAndConflict(t *testing.T, newRuntime Factory) { - rt := newRuntime() - first, err := run.BuildNewRun("run-create", "cause-1") +func withTestExecutionClaim(id run.CommandID, cmd run.AgentCommand) run.AgentCommand { + claim := run.ExecutionClaim("test-claim/" + 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 + } +} + +func (c *conformanceCase) mustCommit(id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) run.CommitResult { + c.t.Helper() + res, err := c.commit(id, base, grant, cmd) if err != nil { - t.Fatal(err) + c.t.Fatalf("commit %T: %v", cmd, err) } - if _, err := rt.Create(context.Background(), first); err != nil { - t.Fatal(err) + return res +} + +func preparedCase(t testing.TB, newRuntime Factory, tools []sdk.ToolDefinition, specs []run.ToolSpec) (*conformanceCase, run.StepID, run.ExecutionGrant) { + t.Helper() + c := newCase(t, newRuntime) + snap := c.load() + req := request(tools...) + prep, cmdID := buildPrepareFromSnap(t, &snap, &req, specs) + c.mustCommit(cmdID, snap.Revision, "", prep) + start := c.mustCommit("start-1", 1, "", run.StartModelExecution{StepID: prep.StepID}) + if start.Grant == "" { + t.Fatal("accepted start returned no grant") } - retry, err := rt.Create(context.Background(), first) - if err != nil || retry.Created { - t.Fatalf("retry Create = %+v, %v", retry, err) + return c, prep.StepID, start.Grant +} + +func prepareAndStart(t testing.TB, c *conformanceCase) (run.StepID, run.ExecutionGrant) { + t.Helper() + snap := c.load() + req := request() + prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) + c.mustCommit(cmdID, snap.Revision, "", prep) + start := c.mustCommit(run.CommandID("start-"+string(c.runID)), snap.Revision+1, "", run.StartModelExecution{StepID: prep.StepID}) + if start.Grant == "" { + t.Fatal("accepted start returned no grant") } - retry.Header.InitialState.RunID = "mutated" - record, err := rt.Record(context.Background(), first.RunID) + return prep.StepID, start.Grant +} + +func buildPrepareFromSnap(t testing.TB, snap *run.RuntimeSnapshot, req *sdk.Request, specs []run.ToolSpec) (run.PrepareModelRequest, run.CommandID) { + t.Helper() + frozenReq, err := run.FreezeModelRequest(*req) if err != nil { t.Fatal(err) } - if record.Header.InitialState.RunID != first.RunID { - t.Fatal("retry Create Header aliases runtime state") + reqDigest, err := run.DigestRequest(frozenReq) + if err != nil { + t.Fatal(err) } - conflict, err := run.BuildNewRun("run-create", "cause-2") + toolsDigest, err := run.DigestToolSpecs(specs) if err != nil { t.Fatal(err) } - if _, err := rt.Create(context.Background(), conflict); !errors.Is(err, run.ErrCreateConflict) { - t.Fatalf("conflicting Create error = %v, want ErrCreateConflict", err) + model := run.ModelRef(frozenReq.Model) + binding, err := run.DigestModelStepBinding(model, reqDigest, toolsDigest) + if err != nil { + t.Fatal(err) + } + cmdID := run.DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) + stepID := run.DeriveModelStepID(snap.State.RunID, cmdID, binding) + ids := make([]run.InputID, len(snap.State.PendingInputs)) + for i, in := range snap.State.PendingInputs { + ids[i] = in.ID } + return run.PrepareModelRequest{ + StepID: stepID, Model: model, Request: frozenReq, + RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, + }, cmdID } -func testMissingRunOperations(t *testing.T, newRuntime Factory) { - rt := newRuntime() - if _, err := rt.Load(context.Background(), "missing"); !errors.Is(err, run.ErrRunNotFound) { - t.Fatalf("Load error = %v, want ErrRunNotFound", err) - } - if _, err := rt.Record(context.Background(), "missing"); !errors.Is(err, run.ErrRunNotFound) { - t.Fatalf("Record error = %v, want ErrRunNotFound", err) +func request(tools ...sdk.ToolDefinition) sdk.Request { + return sdk.Request{ + Model: "m-1", + Messages: []sdk.Message{sdk.UserMessage("hi")}, + Tools: tools, } - env, err := run.BuildEnvelope("missing", "cancel", run.CancelRun{}) +} + +func toolDef(name string) sdk.ToolDefinition { + return sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} +} + +func mustJSON(raw string) run.CanonicalJSON { return run.MustParseCanonicalJSON(raw) } + +func makeSpec(t testing.TB, def sdk.ToolDefinition) run.ToolSpec { + t.Helper() + frozen, err := run.FreezeToolDefinition(def) if err != nil { t.Fatal(err) } - if _, err := rt.Commit(context.Background(), run.CommitRequest{Command: env}); !errors.Is(err, run.ErrRunNotFound) { - t.Fatalf("Commit error = %v, want ErrRunNotFound", err) + d, err := run.DigestToolDefinition(frozen) + if err != nil { + t.Fatal(err) } + return run.ToolSpec{Ref: run.ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: run.DirectExecution} } -func testRecordRevisionZero(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - record, err := c.rt.Record(context.Background(), c.runID) +func makeBinding(t testing.TB, callID string, spec *run.ToolSpec) run.ToolCallBinding { + t.Helper() + parsedArgs := mustJSON(`{}`) + bd, err := run.DigestToolCallBinding(run.CallID(callID), spec.DefinitionDigest, spec.Policy, parsedArgs) if err != nil { t.Fatal(err) } - if record.Snapshot.Revision != 0 || len(record.Transitions) != 0 { - t.Fatalf("revision-zero Record = %+v", record) + return run.ToolCallBinding{ + CallID: run.CallID(callID), + ToolRef: spec.Ref, + DefinitionDigest: spec.DefinitionDigest, + BindingDigest: bd, + Arguments: parsedArgs, + Policy: spec.Policy, } } -func testRecordFoldsAcceptedTransition(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - input := run.AgentInput{ID: "in-1", Payload: mustJSON(`{"q":"hi"}`)} - res := c.mustCommit(run.DeriveInputCommandID(c.runID, input.ID), 0, "", run.AcceptInput{Input: input}) - if res.Status != run.CommitAccepted { - t.Fatalf("accept status = %v", res.Status) +func openedToolStepID(t testing.TB, res *run.CommitResult) run.StepID { + t.Helper() + if len(res.Events) < 2 { + t.Fatalf("events = %d, want ToolStepOpened at index 1", len(res.Events)) } - record, err := c.rt.Record(context.Background(), c.runID) - if err != nil { - t.Fatal(err) + opened, ok := res.Events[1].Fact.(run.ToolStepOpened) + if !ok { + t.Fatalf("event[1] fact = %T, want ToolStepOpened", res.Events[1].Fact) } - if len(record.Transitions) != 1 { - t.Fatalf("transitions = %d, want 1", len(record.Transitions)) + return opened.StepID +} + +func modelResultWithCalls(callIDs ...string) run.ModelResult { + r := sdk.ModelResult{ + FinishReason: sdk.FinishReasonToolCalls, + Usage: sdk.Usage{InputTokens: 10, OutputTokens: 5, TotalTokens: 15}, } - folded, revision, err := run.FoldRun(&record.Header, record.Transitions) - if err != nil { - t.Fatal(err) + for _, id := range callIDs { + r.ToolCalls = append(r.ToolCalls, sdk.ToolCall{ToolCallID: id, ToolName: "t", Input: `{}`}) } - foldedJSON, _ := json.Marshal(stateComparable(&folded)) - snapshotJSON, _ := json.Marshal(stateComparable(&record.Snapshot.State)) - if revision != record.Snapshot.Revision || !bytes.Equal(foldedJSON, snapshotJSON) { - t.Fatalf("FoldRun = revision %d state %s, Record = revision %d state %s", revision, foldedJSON, record.Snapshot.Revision, snapshotJSON) + frozen, err := run.FreezeModelResult(r) + if err != nil { + panic(err) } + return frozen } -func testReturnedValuesAreDetached(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - newRun, err := run.BuildNewRun(c.runID, "") +func testCreateRetryAndConflict(t *testing.T, newRuntime Factory) { + rt := newRuntime() + first, err := run.BuildNewRun("run-create", "cause-1") if err != nil { t.Fatal(err) } - created, err := c.rt.Create(context.Background(), newRun) - if err != nil || created.Created { - t.Fatalf("retry Create = %+v, %v", created, err) + created, err := rt.Create(context.Background(), first) + if err != nil || !created.Created { + t.Fatalf("first Create = %+v, %v", created, err) + } + if err := run.ValidateRunHeader(&created.Header); err != nil { + t.Fatalf("Create returned invalid Header: %v", err) } created.Header.InitialState.RunID = "mutated" - input := run.AgentInput{ID: "in-1", Payload: mustJSON(`1`)} - res := c.mustCommit(run.DeriveInputCommandID(c.runID, input.ID), 0, "", run.AcceptInput{Input: input}) - res.Snapshot.State.RunID = "mutated" - if len(res.Events) > 0 { - res.Events[0].RunID = "mutated" + record, err := rt.Record(context.Background(), "run-create") + if err != nil { + t.Fatal(err) } - record, err := c.rt.Record(context.Background(), c.runID) + if record.Header.InitialState.RunID != "run-create" { + t.Fatal("Create Header aliases runtime state") + } + retry, err := rt.Create(context.Background(), first) + if err != nil || retry.Created { + t.Fatalf("retry Create = %+v, %v", retry, err) + } + retry.Header.InitialState.RunID = "mutated" + record, err = rt.Record(context.Background(), first.RunID) if err != nil { t.Fatal(err) } - record.Header.InitialState.RunID = "mutated" - record.Snapshot.State.RunID = "mutated" - record.Transitions[0].Events[0].RunID = "mutated" - fresh, err := c.rt.Record(context.Background(), c.runID) + if record.Header.InitialState.RunID != first.RunID { + t.Fatal("retry Create Header aliases runtime state") + } + conflict, err := run.BuildNewRun("run-create", "cause-2") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(context.Background(), conflict); !errors.Is(err, run.ErrCreateConflict) { + t.Fatalf("conflicting Create error = %v, want ErrCreateConflict", err) + } +} + +func testCreateMissing(t *testing.T, newRuntime Factory) { + rt := newRuntime() + if _, err := rt.Load(context.Background(), "missing"); !errors.Is(err, run.ErrRunNotFound) { + t.Fatalf("Load error = %v, want ErrRunNotFound", err) + } + if _, err := rt.Record(context.Background(), "missing"); !errors.Is(err, run.ErrRunNotFound) { + t.Fatalf("Record error = %v, want ErrRunNotFound", err) + } + env, err := run.BuildEnvelope("missing", "cancel", run.CancelRun{}) if err != nil { t.Fatal(err) } - if fresh.Header.InitialState.RunID != c.runID || fresh.Snapshot.State.RunID != c.runID || fresh.Transitions[0].Events[0].RunID != c.runID { - t.Fatal("returned Header, Snapshot, or Transitions alias runtime state") + if _, err := rt.Commit(context.Background(), run.CommitRequest{Command: env}); !errors.Is(err, run.ErrRunNotFound) { + t.Fatalf("Commit error = %v, want ErrRunNotFound", err) } } -func testConcurrentCreate(t *testing.T, newRuntime Factory) { +func testCreateConcurrent(t *testing.T, newRuntime Factory) { rt := newRuntime() newRun, err := run.BuildNewRun("run-concurrent-create", "cause-1") if err != nil { @@ -222,75 +361,87 @@ func testConcurrentCreate(t *testing.T, newRuntime Factory) { for err := range errs { t.Fatal(err) } - created := 0 + createdCount := 0 for result := range results { if result.Created { - created++ + createdCount++ } } - if created != 1 { - t.Fatalf("Created=true results = %d, want 1", created) + if createdCount != 1 { + t.Fatalf("Created=true results = %d, want 1", createdCount) } } -func testRunsAreIsolated(t *testing.T, newRuntime Factory) { - rt := newRuntime() - for _, id := range []run.RunID{"run-one", "run-two"} { - newRun, err := run.BuildNewRun(id, "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(context.Background(), newRun); err != nil { - t.Fatal(err) - } - input := run.AgentInput{ID: "same-input", Payload: mustJSON(`{"run":"` + string(id) + `"}`)} - env, err := run.BuildEnvelope(id, run.DeriveInputCommandID(id, input.ID), run.AcceptInput{Input: input}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(context.Background(), run.CommitRequest{Command: env}); err != nil { - t.Fatal(err) - } +func testRecordFoldAccepted(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime) + record, err := c.rt.Record(context.Background(), c.runID) + if err != nil { + t.Fatal(err) } - one, err := rt.Record(context.Background(), "run-one") + if record.Snapshot.Revision != 0 || len(record.Transitions) != 0 { + t.Fatalf("revision-zero Record = %+v", record) + } + input := run.AgentInput{ID: "in-1", Payload: mustJSON(`{"q":"hi"}`)} + res := c.mustCommit(run.DeriveInputCommandID(c.runID, input.ID), 0, "", run.AcceptInput{Input: input}) + if res.Status != run.CommitAccepted { + t.Fatalf("accept status = %v", res.Status) + } + record, err = c.rt.Record(context.Background(), c.runID) if err != nil { t.Fatal(err) } - two, err := rt.Record(context.Background(), "run-two") + if len(record.Transitions) != 1 { + t.Fatalf("transitions = %d, want 1", len(record.Transitions)) + } + folded, revision, err := run.FoldRun(&record.Header, record.Transitions) if err != nil { t.Fatal(err) } - if len(one.Snapshot.State.PendingInputs) != 1 || len(two.Snapshot.State.PendingInputs) != 1 || - one.Snapshot.State.PendingInputs[0].Payload.String() == two.Snapshot.State.PendingInputs[0].Payload.String() { - t.Fatalf("runs leaked state or commands: one=%+v two=%+v", one.Snapshot.State.PendingInputs, two.Snapshot.State.PendingInputs) + foldedJSON, _ := json.Marshal(stateComparable(&folded)) + snapshotJSON, _ := json.Marshal(stateComparable(&record.Snapshot.State)) + if revision != record.Snapshot.Revision || !bytes.Equal(foldedJSON, snapshotJSON) { + t.Fatalf("FoldRun = revision %d state %s, Record = revision %d state %s", revision, foldedJSON, record.Snapshot.Revision, snapshotJSON) + } +} + +func testRecordReplayAndIndex(t *testing.T, newRuntime Factory) { + def := toolDef("t") + spec := makeSpec(t, def) + c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []run.ToolSpec{spec}) + b := makeBinding(t, "c1", &spec) + res := c.mustCommit("complete-1", 2, grant, + run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) + if len(res.Events) != 2 { + t.Fatalf("events = %d", len(res.Events)) } - for _, record := range []run.RunRecord{one, two} { - if len(record.Transitions) != 1 || record.Transitions[0].RunID != record.Header.RunID { - t.Fatalf("run %q has foreign transition log: %+v", record.Header.RunID, record.Transitions) + for i, e := range res.Events { + if e.Revision != res.Snapshot.Revision { + t.Fatalf("event revision %d != snapshot %d", e.Revision, res.Snapshot.Revision) } - for _, event := range record.Transitions[0].Events { - if event.RunID != record.Header.RunID { - t.Fatalf("run %q has foreign event %+v", record.Header.RunID, event) - } + if int(e.Index) != i { + t.Fatalf("index[%d] = %d", i, e.Index) + } + if e.CommandID != "complete-1" { + t.Fatal("command id not stamped") } } -} - -func testCrossRunGrantIsRejected(t *testing.T, newRuntime Factory) { - rt := newRuntime() - one := newCaseOnRuntime(t, rt, "grant-run-one") - two := newCaseOnRuntime(t, rt, "grant-run-two") - stepOne, grantOne := prepareAndStart(t, one) - stepTwo, grantTwo := prepareAndStart(t, two) - result, err := run.FreezeModelResult(sdk.ModelResult{Text: "ok"}) + toolStep := openedToolStepID(t, &res) + sRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: "c1"}) + c.mustCommit("done-c1", sRes.Snapshot.Revision, sRes.Grant, + run.SubmitToolResult{StepID: toolStep, CallID: "c1", Result: run.ToolExecutionResult{Output: mustJSON(`"ok"`)}}) + foldedState, lastRev, err := run.FoldEvents(c.initial, c.events) if err != nil { - t.Fatal(err) + t.Fatalf("FoldEvents: %v", err) } - if _, err := two.commit("foreign-grant", 2, grantOne, run.SubmitModelResult{StepID: stepTwo, Result: result}); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("cross-run grant error = %v, want ErrStaleRuntime", err) + live := c.load() + a, _ := json.Marshal(stateComparable(&live.State)) + bts, _ := json.Marshal(stateComparable(&foldedState)) + if !bytes.Equal(a, bts) { + t.Fatalf("replay diverged:\n live %s\n replay %s", a, bts) + } + if live.Revision != lastRev { + t.Fatalf("snapshot revision %d != last event revision %d", live.Revision, lastRev) } - one.mustCommit("complete-one", 2, grantOne, run.SubmitModelResult{StepID: stepOne, Result: result}) - two.mustCommit("complete-two", 2, grantTwo, run.SubmitModelResult{StepID: stepTwo, Result: result}) } func testRecordConcurrentWithCommit(t *testing.T, newRuntime Factory) { @@ -302,12 +453,11 @@ func testRecordConcurrentWithCommit(t *testing.T, newRuntime Factory) { if err != nil { t.Fatal(err) } - input := run.AgentInput{ID: run.InputID(fmt.Sprintf("in-%d", i)), Payload: mustJSON(`null`)} - env, err := run.BuildEnvelope(c.runID, run.DeriveInputCommandID(c.runID, input.ID), run.AcceptInput{Input: input}) + in := run.AgentInput{ID: run.InputID(fmt.Sprintf("in-%d", i)), Payload: mustJSON(`null`)} + env, err := run.BuildEnvelope(c.runID, run.DeriveInputCommandID(c.runID, in.ID), run.AcceptInput{Input: in}) if err != nil { t.Fatal(err) } - start := make(chan struct{}) var ready sync.WaitGroup ready.Add(2) @@ -325,15 +475,14 @@ func testRecordConcurrentWithCommit(t *testing.T, newRuntime Factory) { go func() { ready.Done() <-start - record, err := rt.Record(context.Background(), c.runID) + rec, err := rt.Record(context.Background(), c.runID) recordCh <- struct { record run.RunRecord err error - }{record: record, err: err} + }{record: rec, err: err} }() ready.Wait() close(start) - if err := <-commitCh; err != nil { t.Fatal(err) } @@ -341,17 +490,17 @@ func testRecordConcurrentWithCommit(t *testing.T, newRuntime Factory) { if observed.err != nil { t.Fatal(observed.err) } - folded, revision, err := run.FoldRun(&observed.record.Header, observed.record.Transitions) + foldedRun, rev, err := run.FoldRun(&observed.record.Header, observed.record.Transitions) if err != nil { t.Fatal(err) } - foldedJSON, _ := json.Marshal(stateComparable(&folded)) + foldedJSON, _ := json.Marshal(stateComparable(&foldedRun)) snapshotJSON, _ := json.Marshal(stateComparable(&observed.record.Snapshot.State)) - if revision != observed.record.Snapshot.Revision || !bytes.Equal(foldedJSON, snapshotJSON) { + if rev != observed.record.Snapshot.Revision || !bytes.Equal(foldedJSON, snapshotJSON) { t.Fatalf("inconsistent Record at revision %d", observed.record.Snapshot.Revision) } - if wantBefore, wantAfter := snapshot.Revision, snapshot.Revision+1; revision != wantBefore && revision != wantAfter { - t.Fatalf("concurrent Record revision = %d, want %d or %d", revision, wantBefore, wantAfter) + if wantBefore, wantAfter := snapshot.Revision, snapshot.Revision+1; rev != wantBefore && rev != wantAfter { + t.Fatalf("concurrent Record revision = %d, want %d or %d", rev, wantBefore, wantAfter) } } final, err := rt.Record(context.Background(), c.runID) @@ -363,214 +512,100 @@ func testRecordConcurrentWithCommit(t *testing.T, newRuntime Factory) { } } -type conformanceCase struct { - t testing.TB - runID run.RunID - initial run.MachineState - rt run.Runtime - events []run.AgentEvent -} - -func newCase(t testing.TB, newRuntime Factory) *conformanceCase { - t.Helper() - return newCaseOnRuntime(t, newRuntime(), "run-1") -} - -func newCaseOnRuntime(t testing.TB, rt run.Runtime, runID run.RunID) *conformanceCase { - t.Helper() - newRun, err := run.BuildNewRun(runID, "") - if err != nil { - t.Fatal(err) - } - created, err := rt.Create(context.Background(), newRun) - if err != nil { - t.Fatal(err) - } - return &conformanceCase{ - t: t, runID: runID, initial: created.Header.InitialState, rt: rt, - } -} - -func (c *conformanceCase) load() run.RuntimeSnapshot { - c.t.Helper() - snap, err := c.rt.Load(context.Background(), c.runID) - if err != nil { - c.t.Fatal(err) - } - return snap -} - -func (c *conformanceCase) commit(id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) (run.CommitResult, error) { - c.t.Helper() - cmd = withTestExecutionClaim(id, cmd) - env, err := run.BuildEnvelope(c.runID, id, cmd) - if err != nil { - c.t.Fatal(err) - } - res, err := c.rt.Commit(context.Background(), run.CommitRequest{BaseRevision: base, Grant: grant, Command: env}) - if err == nil && res.Status == run.CommitAccepted { - c.events = append(c.events, res.Events...) - } - return res, err -} - -func withTestExecutionClaim(id run.CommandID, cmd run.AgentCommand) run.AgentCommand { - claim := run.ExecutionClaim("test-claim/" + 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 - } -} - -func (c *conformanceCase) mustCommit(id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) run.CommitResult { - c.t.Helper() - res, err := c.commit(id, base, grant, cmd) - if err != nil { - c.t.Fatalf("commit %T: %v", cmd, err) - } - return res -} - -func preparedCase(t testing.TB, newRuntime Factory, tools []sdk.ToolDefinition, specs []run.ToolSpec) (*conformanceCase, run.StepID, run.ExecutionGrant) { - t.Helper() +func testIsolationDetachedReturns(t *testing.T, newRuntime Factory) { c := newCase(t, newRuntime) - snap := c.load() - req := request(tools...) - prep, cmdID := buildPrepareFromSnap(t, &snap, &req, specs) - c.mustCommit(cmdID, snap.Revision, "", prep) - start := c.mustCommit("start-1", 1, "", run.StartModelExecution{StepID: prep.StepID}) - if start.Grant == "" { - t.Fatal("accepted start returned no grant") - } - return c, prep.StepID, start.Grant -} - -func prepareAndStart(t testing.TB, c *conformanceCase) (run.StepID, run.ExecutionGrant) { - t.Helper() - snap := c.load() - req := request() - prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) - c.mustCommit(cmdID, snap.Revision, "", prep) - start := c.mustCommit(run.CommandID("start-"+string(c.runID)), snap.Revision+1, "", run.StartModelExecution{StepID: prep.StepID}) - if start.Grant == "" { - t.Fatal("accepted start returned no grant") - } - return prep.StepID, start.Grant -} - -func buildPrepareFromSnap(t testing.TB, snap *run.RuntimeSnapshot, req *sdk.Request, specs []run.ToolSpec) (run.PrepareModelRequest, run.CommandID) { - t.Helper() - frozenReq, err := run.FreezeModelRequest(*req) + newRun, err := run.BuildNewRun(c.runID, "") if err != nil { t.Fatal(err) } - reqDigest, err := run.DigestRequest(frozenReq) - if err != nil { - t.Fatal(err) + created, err := c.rt.Create(context.Background(), newRun) + if err != nil || created.Created { + t.Fatalf("retry Create = %+v, %v", created, err) } - toolsDigest, err := run.DigestToolSpecs(specs) + created.Header.InitialState.RunID = "mutated" + input := run.AgentInput{ID: "in-1", Payload: mustJSON(`1`)} + res := c.mustCommit(run.DeriveInputCommandID(c.runID, input.ID), 0, "", run.AcceptInput{Input: input}) + res.Snapshot.State.RunID = "mutated" + if len(res.Events) > 0 { + res.Events[0].RunID = "mutated" + } + record, err := c.rt.Record(context.Background(), c.runID) if err != nil { t.Fatal(err) } - model := run.ModelRef(frozenReq.Model) - binding, err := run.DigestModelStepBinding(model, reqDigest, toolsDigest) + record.Header.InitialState.RunID = "mutated" + record.Snapshot.State.RunID = "mutated" + record.Transitions[0].Events[0].RunID = "mutated" + fresh, err := c.rt.Record(context.Background(), c.runID) if err != nil { t.Fatal(err) } - cmdID := run.DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) - stepID := run.DeriveModelStepID(snap.State.RunID, cmdID, binding) - ids := make([]run.InputID, len(snap.State.PendingInputs)) - for i, in := range snap.State.PendingInputs { - ids[i] = in.ID - } - return run.PrepareModelRequest{ - StepID: stepID, Model: model, Request: frozenReq, - RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, - }, cmdID -} - -func request(tools ...sdk.ToolDefinition) sdk.Request { - return sdk.Request{ - Model: "m-1", - Messages: []sdk.Message{sdk.UserMessage("hi")}, - Tools: tools, + if fresh.Header.InitialState.RunID != c.runID || fresh.Snapshot.State.RunID != c.runID || fresh.Transitions[0].Events[0].RunID != c.runID { + t.Fatal("returned Header, Snapshot, or Transitions alias runtime state") } } -func toolDef(name string) sdk.ToolDefinition { - return sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} -} - -func mustJSON(raw string) run.CanonicalJSON { return run.MustParseCanonicalJSON(raw) } - -func makeSpec(t testing.TB, def sdk.ToolDefinition) run.ToolSpec { - t.Helper() - frozen, err := run.FreezeToolDefinition(def) - if err != nil { - t.Fatal(err) +func testIsolationRuns(t *testing.T, newRuntime Factory) { + rt := newRuntime() + for _, id := range []run.RunID{"run-one", "run-two"} { + nr, err := run.BuildNewRun(id, "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(context.Background(), nr); err != nil { + t.Fatal(err) + } + in := run.AgentInput{ID: "same-input", Payload: mustJSON(`{"run":"` + string(id) + `"}`)} + env, err := run.BuildEnvelope(id, run.DeriveInputCommandID(id, in.ID), run.AcceptInput{Input: in}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(context.Background(), run.CommitRequest{Command: env}); err != nil { + t.Fatal(err) + } } - d, err := run.DigestToolDefinition(frozen) + one, err := rt.Record(context.Background(), "run-one") if err != nil { t.Fatal(err) } - return run.ToolSpec{Ref: run.ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: run.DirectExecution} -} - -func makeBinding(t testing.TB, callID string, spec *run.ToolSpec) run.ToolCallBinding { - t.Helper() - parsedArgs := mustJSON(`{}`) - bd, err := run.DigestToolCallBinding(run.CallID(callID), spec.DefinitionDigest, spec.Policy, parsedArgs) + two, err := rt.Record(context.Background(), "run-two") if err != nil { t.Fatal(err) } - return run.ToolCallBinding{ - CallID: run.CallID(callID), - ToolRef: spec.Ref, - DefinitionDigest: spec.DefinitionDigest, - BindingDigest: bd, - Arguments: parsedArgs, - Policy: spec.Policy, - } -} - -func openedToolStepID(t testing.TB, res *run.CommitResult) run.StepID { - t.Helper() - if len(res.Events) < 2 { - t.Fatalf("events = %d, want ToolStepOpened at index 1", len(res.Events)) + if len(one.Snapshot.State.PendingInputs) != 1 || len(two.Snapshot.State.PendingInputs) != 1 || + one.Snapshot.State.PendingInputs[0].Payload.String() == two.Snapshot.State.PendingInputs[0].Payload.String() { + t.Fatalf("runs leaked state or commands: one=%+v two=%+v", one.Snapshot.State.PendingInputs, two.Snapshot.State.PendingInputs) } - opened, ok := res.Events[1].Fact.(run.ToolStepOpened) - if !ok { - t.Fatalf("event[1] fact = %T, want ToolStepOpened", res.Events[1].Fact) + for _, rec := range []run.RunRecord{one, two} { + if len(rec.Transitions) != 1 || rec.Transitions[0].RunID != rec.Header.RunID { + t.Fatalf("run %q has foreign transition log: %+v", rec.Header.RunID, rec.Transitions) + } + for _, event := range rec.Transitions[0].Events { + if event.RunID != rec.Header.RunID { + t.Fatalf("run %q has foreign event %+v", rec.Header.RunID, event) + } + } } - return opened.StepID } -func modelResultWithCalls(callIDs ...string) run.ModelResult { - r := sdk.ModelResult{ - 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: "t", Input: `{}`}) - } - frozen, err := run.FreezeModelResult(r) +func testIsolationCrossRunGrant(t *testing.T, newRuntime Factory) { + rt := newRuntime() + oneCase := newCaseOnRuntime(t, rt, "grant-run-one") + twoCase := newCaseOnRuntime(t, rt, "grant-run-two") + stepOne, grantOne := prepareAndStart(t, oneCase) + stepTwo, grantTwo := prepareAndStart(t, twoCase) + result, err := run.FreezeModelResult(sdk.ModelResult{Text: "ok"}) if err != nil { - panic(err) + t.Fatal(err) } - return frozen + if _, err := twoCase.commit("foreign-grant", 2, grantOne, run.SubmitModelResult{StepID: stepTwo, Result: result}); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("cross-run grant error = %v, want ErrStaleRuntime", err) + } + oneCase.mustCommit("complete-one", 2, grantOne, run.SubmitModelResult{StepID: stepOne, Result: result}) + twoCase.mustCommit("complete-two", 2, grantTwo, run.SubmitModelResult{StepID: stepTwo, Result: result}) } -func testIdempotentReplay(t *testing.T, newRuntime Factory) { +func testCommandIdempotentReplay(t *testing.T, newRuntime Factory) { c := newCase(t, newRuntime) res1 := c.mustCommit("cancel-1", 0, "", run.CancelRun{}) if res1.Status != run.CommitAccepted || len(res1.Events) != 1 { @@ -593,31 +628,83 @@ func testIdempotentReplay(t *testing.T, newRuntime Factory) { } } -func testRevisionAndIndex(t *testing.T, newRuntime Factory) { - def := toolDef("t") - spec := makeSpec(t, def) - c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []run.ToolSpec{spec}) +func testCommandDerivedIDs(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime) + _, err := c.commit("random-id", 0, "", run.NextStep(run.AgentInput{ID: "in-1", Payload: mustJSON(`1`)})) + if err == nil { + t.Fatal("AcceptInput with non-derived CommandID accepted") + } + _, err = c.commit("random-id-2", 0, "", run.ApproveToolCall{StepID: "s", CallID: "c", ResponseID: "r"}) + if err == nil { + t.Fatal("ApproveToolCall with non-derived CommandID accepted") + } + in := run.AgentInput{ID: "in-9", Payload: mustJSON(`{"t":"x"}`)} + id := run.DeriveInputCommandID("run-1", in.ID) + accepted := c.mustCommit(id, 0, "", run.NextStep(in)) + if accepted.Status != run.CommitAccepted { + t.Fatal("first accept rejected") + } + replayed := c.mustCommit(id, 0, "", run.NextStep(in)) + if replayed.Status != run.CommitAlreadyApplied { + t.Fatalf("status = %v", replayed.Status) + } + _, err = c.commit(id, 0, "", run.NextStep(run.AgentInput{ID: "in-9", Payload: mustJSON(`{"t":"y"}`)})) + if !errors.Is(err, run.ErrCommandConflict) { + t.Fatalf("err = %v, want ErrCommandConflict", err) + } +} - b := makeBinding(t, "c1", &spec) - res := c.mustCommit("complete-1", 2, grant, - run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) - if len(res.Events) != 2 { - t.Fatalf("events = %d", len(res.Events)) +func testPrepareHardCAS(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime) + snap := c.load() + req := request() + prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) + if _, err := c.commit("wrong-prepare-id", snap.Revision, "", prep); !errors.Is(err, run.ErrCommandConflict) { + t.Fatalf("wrong prepare id err = %v, want ErrCommandConflict", err) } - for i, e := range res.Events { - if e.Revision != res.Snapshot.Revision { - t.Fatalf("event revision %d != snapshot %d", e.Revision, res.Snapshot.Revision) - } - if int(e.Index) != i { - t.Fatalf("index[%d] = %d", i, e.Index) - } - if e.CommandID != "complete-1" { - t.Fatal("command id not stamped") - } + bad := prep + bad.StepID = "wrong-step" + if _, err := c.commit(cmdID, snap.Revision, "", bad); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("bad prepare StepID err = %v, want ErrStaleRuntime", err) + } + c.mustCommit(cmdID, snap.Revision, "", prep) + otherReq := request() + otherReq.System = "different" + prep2, cmdID2 := buildPrepareFromSnap(t, &snap, &otherReq, nil) + if cmdID2 != cmdID { + t.Fatal("same revision must derive the same command id") + } + if _, err := c.commit(cmdID2, snap.Revision, "", prep2); !errors.Is(err, run.ErrCommandConflict) { + t.Fatalf("err = %v, want ErrCommandConflict", err) + } + res := c.mustCommit(cmdID, snap.Revision, "", prep) + if res.Status != run.CommitAlreadyApplied { + t.Fatalf("status = %v", res.Status) + } +} + +func testPrepareReplayAfterProgress(t *testing.T, newRuntime Factory) { + c := newCase(t, newRuntime) + snap := c.load() + input := run.AgentInput{ID: "input-1", Payload: mustJSON(`{"text":"hi"}`)} + accepted := c.mustCommit(run.DeriveInputCommandID(c.runID, input.ID), snap.Revision, "", run.AcceptInput{Input: input}) + req := request() + prep, cmdID := buildPrepareFromSnap(t, &accepted.Snapshot, &req, nil) + prepared := c.mustCommit(cmdID, accepted.Snapshot.Revision, "", prep) + start := c.mustCommit("start-derived-replay", prepared.Snapshot.Revision, "", run.StartModelExecution{StepID: prep.StepID}) + ok, err := run.FreezeModelResult(sdk.ModelResult{Text: "done"}) + if err != nil { + t.Fatal(err) + } + c.mustCommit("finish-derived-replay", start.Snapshot.Revision, start.Grant, run.SubmitModelResult{StepID: prep.StepID, Result: ok}) + current := c.load() + replayed, err := c.commit(cmdID, current.Revision, "", prep) + if err != nil || replayed.Status != run.CommitAlreadyApplied { + t.Fatalf("prepare replay after progress = %+v, %v", replayed, err) } } -func testStartGrantLifecycle(t *testing.T, newRuntime Factory) { +func testGrant(t *testing.T, newRuntime Factory) { c, stepID, grant := preparedCase(t, newRuntime, nil, nil) // The authority rejects an unbound start before it can mint ownership. empty, err := run.BuildEnvelope(c.runID, "empty-claim", run.StartModelExecution{StepID: stepID}) @@ -655,27 +742,28 @@ func testStartGrantLifecycle(t *testing.T, newRuntime Factory) { } } -func testDerivedReplayIgnoresNewBaseRevision(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - snap := c.load() - input := run.AgentInput{ID: "input-1", Payload: mustJSON(`{"text":"hi"}`)} - accepted := c.mustCommit(run.DeriveInputCommandID(c.runID, input.ID), snap.Revision, "", run.AcceptInput{Input: input}) - req := request() - prep, cmdID := buildPrepareFromSnap(t, &accepted.Snapshot, &req, nil) - prepared := c.mustCommit(cmdID, accepted.Snapshot.Revision, "", prep) - start := c.mustCommit("start-derived-replay", prepared.Snapshot.Revision, "", run.StartModelExecution{StepID: prep.StepID}) - ok, err := run.FreezeModelResult(sdk.ModelResult{Text: "done"}) - if err != nil { - t.Fatal(err) +func startClaim(id run.CommandID) run.ExecutionClaim { + return run.ExecutionClaim("test-claim/" + string(id)) +} + +func testGrantHolderRecover(t *testing.T, newRuntime Factory) { + c, stepID, grant := preparedCase(t, newRuntime, nil, nil) + claim := startClaim("start-1") + id := run.DeriveModelRecoveryCommandID(c.runID, stepID, claim) + res := c.mustCommit(id, 2, grant, run.RecoverModelExecution{StepID: stepID, Claim: claim}) + ms, ok := res.Snapshot.State.Current.(run.ModelStep) + if !ok || ms.Status != run.ModelPrepared { + t.Fatalf("after grant-holder recover: %+v", res.Snapshot.State.Current) } - c.mustCommit("finish-derived-replay", start.Snapshot.Revision, start.Grant, run.SubmitModelResult{StepID: prep.StepID, Result: ok}) - current := c.load() - // The original prepare command was derived from accepted.Snapshot.Revision. - // A retry after later progress must still replay by command identity rather - // than fail because it was sent with the current base revision. - replayed, err := c.commit(cmdID, current.Revision, "", prep) - if err != nil || replayed.Status != run.CommitAlreadyApplied { - t.Fatalf("prepare replay after progress = %+v, %v", replayed, err) +} + +func testGrantGrantlessRejectedWhileLive(t *testing.T, newRuntime Factory) { + c, stepID, _ := preparedCase(t, newRuntime, nil, nil) + claim := startClaim("start-1") + id := run.DeriveModelRecoveryCommandID(c.runID, stepID, claim) + _, err := c.commit(id, 2, "", run.RecoverModelExecution{StepID: stepID, Claim: claim}) + if !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("grantless recover while live err = %v, want ErrStaleRuntime", err) } } @@ -718,69 +806,7 @@ func testCallLocalRebase(t *testing.T, newRuntime Factory) { } } -func testPrepareDerivedIdentity(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - snap := c.load() - req := request() - prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) - - if _, err := c.commit("wrong-prepare-id", snap.Revision, "", prep); !errors.Is(err, run.ErrCommandConflict) { - t.Fatalf("wrong prepare id err = %v, want ErrCommandConflict", err) - } - - bad := prep - bad.StepID = "wrong-step" - _, err := c.commit(cmdID, snap.Revision, "", bad) - if !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("bad prepare StepID err = %v, want ErrStaleRuntime", err) - } -} - -func testPrepareIsHardCAS(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - snap := c.load() - req := request() - prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) - c.mustCommit(cmdID, snap.Revision, "", prep) - - otherReq := request() - otherReq.System = "different" - prep2, cmdID2 := buildPrepareFromSnap(t, &snap, &otherReq, nil) - if cmdID2 != cmdID { - t.Fatal("same revision must derive the same command id") - } - _, err := c.commit(cmdID2, snap.Revision, "", prep2) - if !errors.Is(err, run.ErrCommandConflict) { - t.Fatalf("err = %v, want ErrCommandConflict", err) - } - res := c.mustCommit(cmdID, snap.Revision, "", prep) - if res.Status != run.CommitAlreadyApplied { - t.Fatalf("status = %v", res.Status) - } -} - -func testCancelRebasesAndUnknownWins(t *testing.T, newRuntime Factory) { - def := toolDef("t") - spec := makeSpec(t, def) - c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []run.ToolSpec{spec}) - b := makeBinding(t, "c1", &spec) - res := c.mustCommit("complete-1", 2, grant, - run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) - toolStep := openedToolStepID(t, &res) - startRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: "c1"}) - - unknown := c.mustCommit("unk-1", startRes.Snapshot.Revision, startRes.Grant, - run.SubmitToolFailure{StepID: toolStep, CallID: "c1", Outcome: run.ToolOutcomeUnknown}) - if unknown.Snapshot.State.Status != run.RunFailed { - t.Fatal("unknown did not fail the run") - } - _, err := c.commit("cancel-late", 0, "", run.CancelRun{}) - if !errors.Is(err, run.ErrRunTerminal) { - t.Fatalf("late cancel err = %v, want ErrRunTerminal", err) - } -} - -func testCancelOnStaleBase(t *testing.T, newRuntime Factory) { +func testCancelStaleBase(t *testing.T, newRuntime Factory) { c, _, _ := preparedCase(t, newRuntime, nil, nil) res := c.mustCommit("cancel-1", 0, "", run.CancelRun{}) if res.Status != run.CommitAccepted || res.Snapshot.State.Status != run.RunStopped { @@ -788,60 +814,74 @@ func testCancelOnStaleBase(t *testing.T, newRuntime Factory) { } } -func testReplayFoldMatchesState(t *testing.T, newRuntime Factory) { +func testCancelAfterUnknown(t *testing.T, newRuntime Factory) { def := toolDef("t") spec := makeSpec(t, def) c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []run.ToolSpec{spec}) b := makeBinding(t, "c1", &spec) - res := c.mustCommit("complete-1", 2, grant, + opened := c.mustCommit("complete-1", 2, grant, run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) - toolStep := openedToolStepID(t, &res) - sRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: "c1"}) - c.mustCommit("done-c1", sRes.Snapshot.Revision, sRes.Grant, - run.SubmitToolResult{StepID: toolStep, CallID: "c1", Result: run.ToolExecutionResult{Output: mustJSON(`"ok"`)}}) - - folded, lastRev, err := run.FoldEvents(c.initial, c.events) - if err != nil { - t.Fatalf("FoldEvents: %v", err) + toolStep := openedToolStepID(t, &opened) + startRes := c.mustCommit("start-c1", opened.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: "c1"}) + unknown := c.mustCommit("unk-1", startRes.Snapshot.Revision, startRes.Grant, + run.SubmitToolFailure{StepID: toolStep, CallID: "c1", Outcome: run.ToolOutcomeUnknown}) + if unknown.Snapshot.State.Status != run.RunActive { + t.Fatalf("unknown status = %v, want active", unknown.Snapshot.State.Status) } - live := c.load() - a, _ := json.Marshal(stateComparable(&live.State)) - bts, _ := json.Marshal(stateComparable(&folded)) - if !bytes.Equal(a, bts) { - t.Fatalf("replay diverged:\n live %s\n replay %s", a, bts) + if _, ok := unknown.Snapshot.State.Current.(run.Open); !ok { + t.Fatalf("current = %+v, want Open", unknown.Snapshot.State.Current) } - if live.Revision != lastRev { - t.Fatalf("snapshot revision %d != last event revision %d", live.Revision, lastRev) + cancelled := c.mustCommit("cancel-1", unknown.Snapshot.Revision, "", run.CancelRun{}) + if cancelled.Snapshot.State.Status != run.RunStopped { + t.Fatalf("cancel status = %v, want stopped", cancelled.Snapshot.State.Status) + } + if n := len(cancelled.Snapshot.State.Result.UncertainCalls); n != 0 { + t.Fatalf("UncertainCalls = %v, want empty", cancelled.Snapshot.State.Result.UncertainCalls) } } -func testAcceptInputByInputID(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - in := run.AgentInput{ID: "in-9", Payload: mustJSON(`{"t":"x"}`)} - id := run.DeriveInputCommandID("run-1", in.ID) - res1 := c.mustCommit(id, 0, "", run.NextStep(in)) - if res1.Status != run.CommitAccepted { - t.Fatal("first accept rejected") +func testCancelUnknownRemaining(t *testing.T, newRuntime Factory) { + defA, defB := toolDef("a"), toolDef("b") + specA := makeSpec(t, defA) + specB := makeSpec(t, defB) + c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{defA, defB}, []run.ToolSpec{specA, specB}) + bA := makeBinding(t, "cA", &specA) + bB := makeBinding(t, "cB", &specB) + r, err := run.FreezeModelResult(sdk.ModelResult{ + FinishReason: sdk.FinishReasonToolCalls, + ToolCalls: []sdk.ToolCall{ + {ToolCallID: "cA", ToolName: "a", Input: `{}`}, + {ToolCallID: "cB", ToolName: "b", Input: `{}`}, + }, + }) + if err != nil { + t.Fatal(err) } - res2 := c.mustCommit(id, 0, "", run.NextStep(in)) - if res2.Status != run.CommitAlreadyApplied { - t.Fatalf("status = %v", res2.Status) + opened := c.mustCommit("complete-1", 2, grant, + run.SubmitModelResult{StepID: stepID, Result: r, Calls: []run.ToolCallBinding{bA, bB}}) + toolStep := openedToolStepID(t, &opened) + base := opened.Snapshot.Revision + startA := c.mustCommit("start-A", base, "", run.StartToolCall{StepID: toolStep, CallID: "cA"}) + startB := c.mustCommit("start-B", base, "", run.StartToolCall{StepID: toolStep, CallID: "cB"}) + if startB.Status != run.CommitAccepted || startB.Grant == "" { + t.Fatalf("start B: %+v", startB) } - _, err := c.commit(id, 0, "", run.NextStep(run.AgentInput{ID: "in-9", Payload: mustJSON(`{"t":"y"}`)})) - if !errors.Is(err, run.ErrCommandConflict) { - t.Fatalf("err = %v, want ErrCommandConflict", err) + unknown := c.mustCommit("unk-A", startA.Snapshot.Revision, startA.Grant, + run.SubmitToolFailure{StepID: toolStep, CallID: "cA", Outcome: run.ToolOutcomeUnknown}) + if unknown.Snapshot.State.Status != run.RunActive { + t.Fatalf("unknown status = %v, want active", unknown.Snapshot.State.Status) } -} - -func testDerivedCommandIDEnforced(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - _, err := c.commit("random-id", 0, "", run.NextStep(run.AgentInput{ID: "in-1", Payload: mustJSON(`1`)})) - if err == nil { - t.Fatal("AcceptInput with non-derived CommandID accepted") + ts, ok := unknown.Snapshot.State.Current.(run.ToolStep) + if !ok || len(ts.Calls) != 2 || ts.Calls[0].Status != run.ToolFailed || ts.Calls[1].Status != run.ToolExecuting { + t.Fatalf("calls after unknown = %+v", unknown.Snapshot.State.Current) } - _, err = c.commit("random-id-2", 0, "", run.ApproveToolCall{StepID: "s", CallID: "c", ResponseID: "r"}) - if err == nil { - t.Fatal("ApproveToolCall with non-derived CommandID accepted") + cancelled := c.mustCommit("cancel-1", unknown.Snapshot.Revision, "", run.CancelRun{}) + if cancelled.Snapshot.State.Status != run.RunStopped { + t.Fatalf("cancel status = %v, want stopped", cancelled.Snapshot.State.Status) + } + got := cancelled.Snapshot.State.Result.UncertainCalls + if len(got) != 1 || got[0] != "cB" { + t.Fatalf("UncertainCalls = %v, want [cB]", got) } } @@ -856,9 +896,13 @@ func stateComparable(s *run.MachineState) map[string]any { m["lastToolStep"] = s.LastToolStep } switch cur := s.Current.(type) { + case run.Open: + m["current"] = "open" case run.ModelStep: + m["current"] = "model" m["modelStep"] = cur case run.ToolStep: + m["current"] = "tool" m["toolStep"] = cur } return m diff --git a/agent/run/state.go b/agent/run/state.go index 6281a96..ff3672f 100644 --- a/agent/run/state.go +++ b/agent/run/state.go @@ -127,6 +127,23 @@ type Step interface { Ref() StepRef } +// Current is the contents of an Active run. Open is the enterable interval: +// AcceptInput and Prepare are legal, and Next returns NeedModelRequest. +type Current interface{ current() } + +// Open is Active with no ModelStep or ToolStep. +type Open struct{} + +func (Open) current() {} + +func (ModelStep) current() {} +func (ToolStep) current() {} + +func atOpen(c Current) bool { + _, ok := c.(Open) + return ok +} + type ModelStepStatus uint8 const ( @@ -303,7 +320,7 @@ func (s *ToolStep) callIndex(id CallID) int { type MachineState struct { RunID RunID `json:"runId"` Status RunStatus `json:"status"` - Current Step `json:"-"` // serialized by adapters with their snapshot schema + Current Current `json:"-"` PendingInputs []AgentInput `json:"pendingInputs,omitempty"` ModelSteps int `json:"modelSteps"` // LastClosedStep is the most recently closed ToolStep; PlanningHint's @@ -372,12 +389,16 @@ func ValidateMachineState(s *MachineState) error { if s.Result == nil || s.Result.Status != s.Status { return errors.New("agent: state: terminal state has no matching result") } - } else if s.Result != nil { + return nil + } + if s.Result != nil { return errors.New("agent: state: active state has a result") } switch current := s.Current.(type) { + case Open: 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") @@ -422,5 +443,5 @@ func InitializeRun(run RunID) (MachineState, error) { if run == "" { return MachineState{}, errors.New("agent: initialize: empty RunID") } - return MachineState{RunID: run, Status: RunActive}, nil + return MachineState{RunID: run, Status: RunActive, Current: Open{}}, nil } diff --git a/agent/run/stored_runtime.go b/agent/run/stored_runtime.go index 90bd956..c5a91b0 100644 --- a/agent/run/stored_runtime.go +++ b/agent/run/stored_runtime.go @@ -225,7 +225,9 @@ func (r *runtime) Record(ctx context.Context, runID RunID) (RunRecord, error) { } // RecoverExpired commits grantless recovery for every expired lease that -// still occupies an Executing target. Hosts call this on a timer; Loop does not. +// still occupies an Executing target. An expired tool call is settled as +// Unknown; an expired model step is recovered to Prepared. Hosts call this +// on a timer; Loop does not. The dying process writes nothing. func (r *runtime) RecoverExpired(ctx context.Context) (int, error) { ids, err := r.store.ListIDs(ctx) if err != nil { diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 4bee875..ccfc930 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -25,13 +25,13 @@ Model / Tool = 一次模型请求或一次工具调用的 effect 执行器 Request Planner = application context 到 sdk.Request 的投影器 ``` -Machine 处理已冻结的值和已提交的事实;Loop 解释 `Next` 产生的 transient effect;Runtime 保存并验证 Machine 的推进;Model/Tool 执行一次外部 effect;Request Planner 组装下一次模型请求。MemoryRuntime 和 durable adapter 实现同一个 Runtime contract,分别使用内存锁和数据库事务保存 authority。 +Machine 处理已冻结的值和已提交的事实;Loop 解释 `Next` 产生的 transient effect;Runtime 保存并验证 Machine 的推进;Model/Tool 执行一次外部 effect;Request Planner 组装下一次模型请求。`Runtime` 叠在 `Store` 上:`MemoryStore` 使用进程内锁,SQLite 与 Postgres 使用数据库事务,它们保存同一份 authority。 `Step` 是 Run 的持久化恢复边界;`execution attempt` 表示某个 Loop 进程对该 Step 或 ToolCall 的一次易失执行。一个 Step 可以有多个 attempt,Machine 只接受带有效 grant 的 settlement。Attempt 的执行控制信息由 start command 的 `ExecutionClaim` 和 Runtime 返回的 opaque `ExecutionGrant` 表达。 每次 accepted command 产生的 `TransitionRecord.Events` 构成 canonical event plane:它与新的 MachineState 在同一 Runtime commit 中写入,按 revision/index 有序,可用于 replay、materialization 和审计。`EventSink` 转发这些已提交事件及临时 delta;canonical authority 由 Runtime.Record 提供。 -**RUN-SCP-1** `agent/run` 拥有 Run identity、persisted frozen values、Machine、command/fact protocol、wire codec、fold、Runtime contract 与 MemoryRuntime。`agent/run/loop` 拥有 planner/model/tool ports、streaming、并发执行、EventSink 与 Loop policy;它单向依赖 `agent/run`,根 package 不依赖 loop。 +**RUN-SCP-1** `agent/run` 拥有 Run identity、persisted frozen values、Machine、command/fact protocol、wire codec、fold、Runtime contract、Store 与 MemoryStore。`agent/run/loop` 拥有 planner/model/tool ports、streaming、并发执行、EventSink 与 Loop policy;它单向依赖 `agent/run`,根 package 不依赖 loop。 **RUN-SCP-2** Session 长期语义、Turn→Run 协调、Run→Session materialization、Artifact、queue/claim、provider registry、权限、durable lease/fence/outbox 与产品 policy 分别由其 package 或 Application 拥有。对应 authority 为 [agent-session.md](agent-session.md)、[agent-turn.md](agent-turn.md) 与 [agent-artifact.md](agent-artifact.md)。 @@ -133,7 +133,7 @@ type RunRecord struct { } ``` -**RUN-NEW-1** `BuildNewRun` 建立当前 version 的 creation value;`BuildRunHeaderFromNewRun` 按 `NewRun.SchemaVersion` 的冻结规则建立 header。v1 Revision-0 state 恰为:相同 RunID、`RunActive`、无 Current、无 pending input、零 model step、零 usage、无 result。初始输入必须随后通过 `AcceptInput` transition 进入 log。 +**RUN-NEW-1** `BuildNewRun` 建立当前 version 的 creation value;`BuildRunHeaderFromNewRun` 按 `NewRun.SchemaVersion` 的冻结规则建立 header。v1 Revision-0 state 恰为:相同 RunID、`RunActive`、`Current=Open`、无 pending input、零 model step、零 usage、无 result。初始输入必须随后通过 `AcceptInput` transition 进入 log。 Header 创建后 immutable。`InitialStateDigest` 覆盖 frozen initial state;`HeaderDigest` 覆盖 schema、RunID、initial-state version/digest 与 causation。`ValidateRunHeader` 验证这些约束。 @@ -142,10 +142,16 @@ Header 创建后 immutable。`InitialStateDigest` 覆盖 frozen initial state; ## 4. Machine ```go +type Current interface{ current() } +type Open struct{} +func (Open) current() {} +func (ModelStep) current() {} +func (ToolStep) current() {} + type MachineState struct { RunID RunID Status RunStatus - Current Step + Current Current PendingInputs []AgentInput ModelSteps int LastClosedStep StepID @@ -189,7 +195,9 @@ type ToolStep struct { } ``` -Run status 为 `RunActive | RunCompleted | RunStopped | RunFailed`。`RunCompleted`、`RunStopped` 与 `RunFailed` 是 terminal status。`RunStatus` 表示当前 MachineState 的投影;终态 fact 使用 RunEnd union 表达具体结果。`Step` 是 sealed interface,只有 `ModelStep` 与 `ToolStep`。 +`Status` 是 Run 的生命周期:`RunActive | RunCompleted | RunStopped | RunFailed`。后三者是终态。`RunStatus` 表示当前 MachineState 的投影;终态 fact 使用 RunEnd union 表达具体结果。 + +`Current` 是 Active 期间的内容。`Open` 是可进入区间:可提交 `AcceptInput` 与 `PrepareModelRequest`,`Next` 返回 `NeedModelRequest`。`ModelStep` 与 `ToolStep` 表示正在进行的步骤。终态的 `Current` 为空;终态由 `Status` 表达,不另设 Current variant。Active 的 `Current` 不得为空。`Step` 仍只有 `ModelStep` 与 `ToolStep`,提供 `Ref()`。 终态 fact 使用 Go 的 sealed-union 形式,终态结构由合法的 RunEnd variant 构成: @@ -231,16 +239,16 @@ ToolCall: Waiting(ExternalResponse) -> Completed | Failed(Known) ``` -**RUN-MCH-1** MachineState 保存 Run 的 execution semantics。`LastToolStep` 保存最近一个经 Evolve 关闭路径写下的 ToolStep 只读投影,必须与 transition log 折叠出的最后关闭 step 一致,供下一次 planner 构造模型请求。Cancel 只清 Current、不走关闭路径时不改写 `LastToolStep`。terminal state 吸收所有未幂等命令;`RunEnded` 建立唯一 terminal result。RunResult 在该 terminal transition 中建立,并由后续 snapshot/record 读取。 +**RUN-MCH-1** MachineState 保存 Run 的 execution semantics。`LastToolStep` 保存最近一个经 Evolve 关闭路径写下的 ToolStep 只读投影,必须与 transition log 折叠出的最后关闭 step 一致,供下一次 planner 构造模型请求。Cancel 经 `RunEnded` 把 `Current` 置空、不走关闭路径时不改写 `LastToolStep`。terminal state 吸收所有未幂等命令;`RunEnded` 建立唯一 terminal result。RunResult 在该 terminal transition 中建立,并由后续 snapshot/record 读取。 -**RUN-MCH-2** `ToolCallBinding` 冻结 CallID、ToolRef、definition digest、canonical arguments、response policy 与 binding digest。已知工具使用匹配 frozen ToolSpec 的 ref/digest/policy;未知工具保留为同名 unresolved DirectExecution binding,并在执行前收束为已知 lookup failure。approval/external response 的 `ResponseRequest` 由 Decide 稳定派生。Unknown outcome 使用 `effect_unknown`,并使 Run 进入 `RunFailed(effect_unknown)`;后续处理由新的 Run 决定。 +**RUN-MCH-2** `ToolCallBinding` 冻结 CallID、ToolRef、definition digest、canonical arguments、response policy 与 binding digest。已知工具使用匹配 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` | 无 Current;`InputAccepted` | -| `PrepareModelRequest` | 无 Current,完整有序消费 PendingInputs,request/tools digests 有效;`ModelStepPrepared` | +| `AcceptInput` | `Open`;`InputAccepted` | +| `PrepareModelRequest` | `Open`,完整有序消费 PendingInputs,request/tools digests 有效;`ModelStepPrepared` | | `StartModelExecution` | Model Prepared;`ModelStepStarted`。command 必须携带本次 start 的 `ExecutionClaim` | | `RecoverModelExecution` | Model Executing;`ModelStepRecovered`。恢复 durable attempt 时携带该 attempt 的 `Claim` | | `SubmitModelResult` | Model Executing;`ModelStepCompleted`,随后无 calls 时 `RunEnded(completed)`,有 calls 时 `ToolStepOpened`(携带冻结的 `Scheduling`) | @@ -249,13 +257,13 @@ ToolCall: | `StartToolCall` | Tool Pending;`ToolCallStarted`。command 必须携带本次 start 的 `ExecutionClaim` | | `SubmitToolResult` | Tool Executing;`ToolCallCompleted`。该 fact 经 Evolve 后若全部 call 已 terminal,则关闭 ToolStep | | `SubmitToolFailure(Known)` | Tool Pending/Executing;`ToolCallFailed(Known)`。Evolve 后若全部 call 已 terminal,则关闭 ToolStep | -| `SubmitToolFailure(Unknown)` | Tool Executing;`ToolCallFailed(Unknown)`、`RunEnded(failed/effect_unknown)` | +| `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`。Evolve 后若全部 call 已 terminal,则关闭 ToolStep | -| `CancelRun` | active;仍 Executing 的 tool call 记 `ToolCallFailed(Unknown)`,随后 `RunEnded(stopped/cancelled)`,并在 `RunStoppedEnd` / `RunResult` 上列出 `UncertainCalls` 与 `UncertainModel`。仅 Waiting、没有 Executing 时不给 Waiting call 记 Failed;`RunEnded` 清除 Current,且不更新 `LastToolStep` | +| `CancelRun` | active;先把仍 Executing 的 tool call 记 `ToolCallFailed(Unknown)`,随后 `RunEnded(stopped/cancelled)`,并在 `RunStoppedEnd` / `RunResult` 上列出 `UncertainCalls` 与 `UncertainModel`。仅 Waiting、没有 Executing 时不给 Waiting call 记 Failed;`RunEnded` 把 `Current` 置空,且不更新 `LastToolStep` | -没有独立的 `ToolStepClosed` fact。最后一个 ToolCall 进入 Completed 或 Failed 时,`Evolve` 在折叠该 fact 后若全部 call 已 terminal,则把 Current 清掉并写入 `LastToolStep` / `LastClosedStep`。Cancel 只清 Current、不经这条关闭路径时,`LastToolStep` 保持原值。 +没有独立的 `ToolStepClosed` fact。最后一个 ToolCall 进入 Completed 或 Failed 时,`Evolve` 在折叠该 fact 后若全部 call 已 terminal,则把 Current 设为 `Open` 并写入 `LastToolStep` / `LastClosedStep`。Cancel 经 `RunEnded` 置空 Current、不经这条关闭路径时,`LastToolStep` 保持原值。 **RUN-MCH-3** `Protocol.Decide(state, command)` 执行全部验证与 derived consequence,一次返回该 transition 的完整 ordered fact group;验证成功后返回完整 facts。包级 `Decide` 委托当前写入 schema。`Protocol.Evolve(state, fact)` 机械折叠 fact,依赖 fact 携带的完整数据。accepted facts 必须 self-contained;若 transition terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 @@ -284,7 +292,7 @@ Loop 在提交 start 前生成并保留 `Claim`、`CommandID` 与 command digest | state | effect | |---|---| | terminal | 返回 `ErrRunTerminal`,没有 effect | -| Current=nil | `NeedModelRequest{PlanningHint}` | +| `Open` | `NeedModelRequest{PlanningHint}` | | Model Prepared | `StartModelCall` | | Model Executing | `Idle` | | ToolStep 有 Pending calls | `StartToolCalls` | @@ -369,13 +377,13 @@ type CommitResult struct { **RUN-CMT-4** 幂等键为 `(RunID,CommandID)`。相同 digest 返回 `CommitAlreadyApplied`、当前 snapshot 与原完整 event group,且不得再次 Decide、分配 revision 或产生外部 effect。对于 `StartModelExecution` 和 `StartToolCall`,Runtime 还必须验证 command 中的 `ExecutionClaim`:相同 command ID、相同 digest、相同 claim 的精确重放在 grant 仍 live 时返回原 start grant;不同 claim 触发 `ErrCommandConflict`,并保持现有执行授权。非 start command 的 replay 不返回 grant。revision 每个 accepted command 恰加 1。 -**RUN-CMT-5** accepted `StartModelExecution`/`StartToolCall` 为目标签发新 grant;该 start 的 `CommitAccepted` 和在 grant 仍 live 时满足精确 replay 条件的 `CommitAlreadyApplied` 返回同一个 grant。若该 start 已 settlement 或 Run 已 terminal,精确 replay 仍返回 `CommitAlreadyApplied`,并返回空 grant。model result/failure/reject 与 executing tool result/known failure 必须携带 live target grant。settlement 接受后 grant 失效;terminal transition 撤销全部 grant。公共 Runtime 的 `RecoverModelExecution` 由 live grant holder 提交;durable 实现内部可以在自己验证 execution recovery record 后无 grant 提交。Executing tool 的 recovery 使用同一条 `SubmitToolFailure{Outcome:Unknown}` command:工具 owner 必须携带 live grant;recovery scanner 仅在 Runtime 验证其 lease/claim 已失效且没有已接受 settlement 时无 grant 提交。scanner 使用上表的 deterministic recovery CommandID,因而 recovery update 也遵守同一 `(RunID, CommandID)` 幂等规则。durable adapter 保存 `(command ID, claim) -> grant` 精确 replay 所需的私有 start record;attempt、owner、fence、lease 与 recovery record 由 adapter 内部管理。 +**RUN-CMT-5** accepted `StartModelExecution`/`StartToolCall` 为目标签发新 grant;该 start 的 `CommitAccepted` 和在 grant 仍 live 时满足精确 replay 条件的 `CommitAlreadyApplied` 返回同一个 grant。若该 start 已 settlement 或 Run 已 terminal,精确 replay 仍返回 `CommitAlreadyApplied`,并返回空 grant。model result/failure/reject 与 executing tool result/known failure 必须携带 live target grant。settlement 接受后 grant 失效;terminal transition 撤销全部 grant。公共 Runtime 的 `RecoverModelExecution` 由 live grant holder 提交;durable 实现内部可以在自己验证 execution recovery record 后无 grant 提交。Executing tool 的 recovery 使用同一条 `SubmitToolFailure{Outcome:Unknown}` command:工具 owner 必须携带 live grant;recovery scanner 仅在 Runtime 验证其 lease/claim 已失效且没有已接受 settlement 时无 grant 提交。该 Unknown 只结算这一 call,Run 保持 Active。scanner 使用上表的 deterministic recovery CommandID,因而 recovery update 也遵守同一 `(RunID, CommandID)` 幂等规则。durable adapter 保存 `(command ID, claim) -> grant` 精确 replay 所需的私有 start record;attempt、owner、fence、lease 与 recovery record 由 adapter 内部管理。 **RUN-CMT-6** Commit 必须原子保存新 MachineState 与完整 TransitionRecord,保证 event group 完整写入。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 **RUN-CMT-7** 每个 Run 的协议版本是 `RunHeader.SchemaVersion`,在 Create 时冻结。`RuntimeSnapshot.SchemaVersion` 必须等于该 header。`ProtocolFor(header.SchemaVersion)` 在 Run 边界返回绑定了该版本 digest/decode/Decide/Evolve 函数的 `Protocol` 值;随后的方法调用不再接受 version 参数。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Protocol 的 Version。不得使用进程全局 `currentSchemaVersion` 作为写入许可。v1 Run 的 replay 必须继续使用 `ProtocolV1`,即使进程已经把 `currentSchemaVersion` 升到 2。Loop 通过 `RuntimeSnapshot.Protocol().BuildEnvelope` 构造写入该 Run 的 envelope。 -`Runtime` 的唯一实现叠在 `Store` 上:`Store` 持久化 header、state、transition log 与 `ExecutionLease`,不调用 Decide/Evolve。`MemoryStore`、SQLite、Postgres 实现同一合同。lease 的 `Deadline` 为零表示不超时(进程内占用);过期且无 settlement 时 Runtime 将 `recoveryValid` 置真,允许 grantless Recover。`RecoverExpired` 扫描过期 lease 并提交恢复 command。`NewMemoryRuntime` 使用 `MemoryStore` 且 lease 不超时。 +`Runtime` 的唯一实现叠在 `Store` 上:`Store` 持久化 header、state、transition log 与 `ExecutionLease`,不调用 Decide/Evolve。`MemoryStore`、SQLite、Postgres 实现同一合同。lease 的 `Deadline` 为零表示不超时(进程内占用);过期且无 settlement 时 Runtime 将 `recoveryValid` 置真,允许 grantless Recover。进程崩溃时不写 settlement。`RecoverExpired` 扫描过期 lease:对每个过期 Executing tool call 无 grant 提交 `SubmitToolFailure{Unknown}`,对过期 Executing model 提交 `RecoverModelExecution`。该 Run 保持 Active,同一 RunID 继续。`Rebuild` 从 header 与 transition log 重折 snapshot,供诊断使用。进程内宿主使用 `NewRuntime(NewMemoryStore())`,lease 不超时,grantless recover 被拒绝。生产崩溃恢复使用带 TTL 的 Store。 ## 6. Loop ports 与 policy @@ -464,7 +472,7 @@ Loop.Run(ctx, runtime, runID, sink): **RUN-LOP-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须保留 start command 的 ID、digest 和 claim,直到完成 settlement;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。streaming 与 non-streaming 必须产生同一种完整 `sdk.ModelResult`;delta 只发 EventSink。`ModelCatalog.Resolve` 失败或返回 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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call,在外层 ctx 未取消时于本次 `Run` 内按冻结 Scheduling 分批 Start 并结算;ctx 已取消时停止再 Start,只结算已持有 grant 的 call。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `WaitingCalls`,提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 之后再次 `Run`。tool panic 或 effect 状态无法确定的错误转为 Unknown;一个 Unknown 取消同批 sibling workers,并由 Machine 在同一 terminal transition 中记录目标及仍 Executing sibling 的 `ToolCallFailed(Unknown)`,最后追加 `RunEnded(failed/effect_unknown)`。`CancelRun` 在 `RunEnded(stopped/cancelled)` 前记录所有仍 Executing call 的 Unknown,并把这些 CallID 与仍 Executing 的 ModelStep 写入 `RunStoppedEnd` / `RunResult` 的 `UncertainCalls`、`UncertainModel`。仅 Waiting、没有 Executing 时不给 Waiting call 记 Failed。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 +**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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call,在外层 ctx 未取消时于本次 `Run` 内按冻结 Scheduling 分批 Start 并结算;ctx 已取消时停止再 Start,只结算已持有 grant 的 call。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `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、没有 Executing 时不给 Waiting call 记 Failed。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 **RUN-LOP-5** model 与 tool worker 都接收外层 ctx;Loop 对已接受 effect 使用独立 control context 完成 known/unknown outcome settlement。Application 的业务停止顺序为先 Commit `CancelRun`,再取消 Loop ctx。非 sentinel Commit error 以同 CommandID/digest 重放一次;仍未知时返回错误,由后续 Load/Record 查询 authority。stale/terminal/conflict 触发 reload/drop,旧 external effect 保持单次执行尝试。工具实现配合 context 返回;永久阻塞由 application/durable recovery 处理。 @@ -490,7 +498,7 @@ type Event struct { **RUN-LOP-6** EventSink 提供 realtime observation,Loop 通过序列化调用向 sink 发送事件。`EventAgentCommitted` 携带 accepted transition 中的 canonical AgentEvent;text/reasoning delta、tool progress、tool lifecycle 与 run-finished observation 可丢失、重复或断流。sink failure 保持 Commit 结果;恢复、materialization 与审计读取 `Runtime.Record`,EventSink gap 通过 canonical record 对账。 -Queue 只能在 `Current=nil` 的 safe boundary 提交 `AcceptInput`;Application 负责 admission 与 planning 之间的线性化。Run→Session materialization、terminal settlement、stable cross-domain IDs 与 crash recovery 由 [agent-turn.md](agent-turn.md) 定义。 +可进入区间是 `Current=Open`。Application 在此提交 `AcceptInput`,并负责 admission 与 planning 之间的线性化。Loop 不解释 queue 或 steer:`Open` 时立刻 `NeedModelRequest`。Run→Session materialization、terminal settlement、stable cross-domain IDs 与 crash recovery 由 [agent-turn.md](agent-turn.md) 定义。 ## 9. compatibility 与 conformance @@ -508,7 +516,7 @@ Queue 只能在 `Current=nil` 的 safe boundary 提交 `AcceptInput`;Applicati Loop conformance 必须覆盖: - 单模型完成、tool round trip、approval/external response wait/resume; -- known failure 继续、Unknown terminal、tool panic、aliased ToolRef 与 validation; +- known failure 继续、Unknown 继续、tool panic、aliased ToolRef 与 validation; - parallel/sequential 按冻结 `ToolStep.Scheduling` 调度,不得改用当时 ExecutionPolicy; - `ModelCatalog.Resolve` 失败或 nil 时恢复 ModelStep、Run 保持 active; - ctx cancellation、model recovery、explicit malformed-result disposition; From 856f6055c1a254511910bbf441d8fe03db7a9323 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 2 Sep 2026 14:56:12 +0800 Subject: [PATCH 50/81] Rewrite session, chatlog, and turn specs EventType is twilight/module/name. Chatlog owns content, Turn owns round and Run linkage, and the reference assembly records Binding, Planner, and the shared user-text payload. --- docs/design/agent-reference-assembly.md | 84 ++++++ docs/design/agent-runtime-refactor.md | 10 +- docs/design/agent-session-chatlog.md | 278 ++++++++++++-------- docs/design/agent-session-extension.md | 33 ++- docs/design/agent-turn.md | 324 +++++++++++++----------- 5 files changed, 450 insertions(+), 279 deletions(-) create mode 100644 docs/design/agent-reference-assembly.md diff --git a/docs/design/agent-reference-assembly.md b/docs/design/agent-reference-assembly.md new file mode 100644 index 0000000..6d5389a --- /dev/null +++ b/docs/design/agent-reference-assembly.md @@ -0,0 +1,84 @@ +# Twilight Agent 参考组装 + +状态:设计草案。与 [Run](agent-run.md)、[Turn](agent-turn.md)、[Chatlog](agent-session-chatlog.md) 冲突时以各正式规范为准。 + +本文规定 Memory 参考 agent 的三处组装:ExecutionBinding 公开字段、Planner、用户正文在 Chatlog Input 与 Run AgentInput 上的同一份 payload。 + +## 1. ExecutionBinding + +Session 保存 `ExecutionBindingRef{ID, Digest}`。Digest 覆盖公开配置: + +```go +type BindingPublic struct { + SchemaVersion uint16 // 1 + Model run.ModelRef + Tools []run.ToolSpec + Streaming bool + PlannerID string // "twilight/turn/planner/context-v1" + SystemPrompt string +} +``` + +**REF-BND-1** `Digest = Digest("twilight/turn/binding", canonical(BindingPublic))`。 + +**REF-BND-2** `Resolve(ref)` 在 Digest 匹配时返回 RunDriver:公开配置、ModelCatalog、ToolCatalog、Planner。同一 Run 内同一 ModelRef 的解析语义保持等价(RUN-LOP-7)。 + +**REF-BND-3** 参考 Planner 的 `RequestPlan.Model` 等于 `BindingPublic.Model`。 + +## 2. Planner + +Planner ID:`twilight/turn/planner/context-v1`。 + +```go +func Plan(ctx context.Context, hint run.PlanningHint, fold []chatlog.Entry, pub BindingPublic) (loop.RequestPlan, error) +``` + +**REF-PLN-1** `fold` 为 `ContextFold` 的输出(含已应用的 checkpoint)。 + +**REF-PLN-2** `sdk.Messages` 顺序: + +1. `pub.SystemPrompt` 非空时一条 system message; +2. 按 `fold`:`input` → user;`assistant` → assistant;`tool_result` → tool;`summary` → assistant text。 + +Coordinator 在 Drive 前执行 `MaterializeAll`。Planner 消费当时的 fold。`hint.LastToolStep` 对应的 `tool_result` 已在 fold 中。 + +**REF-PLN-3** `hint.Inputs` 与本 Turn 已 delivered、且属于本次 Prepare 的 Input 按 ID 对齐。这些 Input 已在 fold 中时,只使用 fold。 + +**REF-PLN-4** `RequestPlan.Model = pub.Model`,`Tools = pub.Tools`,`InputIDs` 为本次消费的 PendingInput IDs。`PlanningToken` 随 fold 的 Entry digest 序列或 Binding Digest 变化。 + +**REF-PLN-5** 无附件时 TextPart 直接写入 sdk.Message。ReferencePart 经 ContextMaterializer 转换。 + +## 3. 用户正文 + +同一份 canonical JSON: + +```text +twilight/chatlog/input_submitted.Content +run.AgentInput.Payload +``` + +**REF-INP-1** v1 形状为 `{"text":"<用户字符串>"}`。 + +**REF-INP-2** `StartRequest.InputIDs[i] == InitialInputs[i].ID`,且等于已 submitted 的 InputID;`InitialInputs[i].Payload` 等于该 Input 的 Content。`input_delivered` 把 InputID 挂到 TurnID。 + +**REF-INP-3** Planner 把 `{"text":...}` 投影为 sdk user text。 + +## 4. Memory 组成 + +```text +sessionStore = session.NewMemoryStore() +runRuntime = run.NewRuntime(run.NewMemoryStore()) +catalog = extension.BuildCatalog(protocol, []Module{chatlog.Module, turn.Module}) +appender = extension.NewSemanticAppender(sessionStore, catalog, artifacts) +bindings = Resolve(ExecutionBindingRef) -> loop.New(models, tools, contextPlanner, policy, pub.Streaming) +coordinator = turn.Coordinator{Sessions, Appender, Runtime: runRuntime, Bindings, Mapper: turn.MapperV1} + +input_submitted +coordinator.Start + twilight/turn/started + twilight/chatlog/input_delivered* + Runtime.Create + AcceptInput* + Loop.Run + MaterializeAll → twilight/chatlog/assistant, twilight/chatlog/tool_result + twilight/turn/completed | twilight/turn/failed +``` + +参考 agent 的工具 ResponsePolicy 为 `DirectExecution`。ContextFold 在无 checkpoint 时输出全部有效条目。 diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 40607c7..843abcd 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -14,6 +14,7 @@ | 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) | +| 参考组装(Binding / Planner / Input) | [agent-reference-assembly.md](agent-reference-assembly.md) | ## 1. 背景 @@ -45,7 +46,7 @@ Run snapshot 支持直接恢复执行;immutable header 与 transition log 支 ```text agent/es shared ES primitives agent/jsonstable immutable canonical JSON -agent/run Run Machine、persisted protocol、Runtime、MemoryRuntime +agent/run Run Machine、persisted protocol、Runtime、Store、MemoryStore agent/run/loop in-process model/tool interpreter 与 observation ports agent/session Event-first Session kernel agent/session/extension Session Module Framework:static modules、codec、semantic append、projection @@ -54,7 +55,7 @@ agent/artifact Ref、Binding、RetentionClaim agent/turn Turn→Run coordination 与 Run→Session materialization ``` -文件用于提高同一 package 内的导航性;subpackage 只用于依赖限制和独立变化轴。Loop 因依赖 SDK execution、streaming、并发和工具 ports 而独立成 `agent/run/loop`。Machine、Runtime、protocol 与 MemoryRuntime 保持在根 `agent/run`,避免 sealed variants、codec、Decide/Evolve 和 adapter internals 之间形成 cycle 或镜像 DTO。 +文件用于提高同一 package 内的导航性;subpackage 只用于依赖限制和独立变化轴。Loop 因依赖 SDK execution、streaming、并发和工具 ports 而独立成 `agent/run/loop`。Machine、Runtime、protocol 与 Store 保持在根 `agent/run`,避免 sealed variants、codec、Decide/Evolve 和 adapter internals 之间形成 cycle 或镜像 DTO。 依赖方向为: @@ -86,10 +87,11 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + session mo | Decide/Evolve/Next Run Machine | 完成 | | RunHeader、TransitionRecord、wire codec、fold/golden tests | 完成 | | RunID-addressed `Runtime.Create/Load/Commit/Record` | 完成 | -| multi-Run `MemoryRuntime` 与 Runtime conformance | 完成 | +| multi-Run Store-backed Runtime 与 Runtime conformance | 完成 | | `agent/run/loop` package extraction | 完成 | | Session/Artifact/Session Module/Turn protocols | 规范草案完成,实施待完成 | -| Chatlog protocol | 草案,payload fields 与 golden fixtures 尚未冻结 | +| Chatlog protocol | 草案,payload 与 golden 尚未冻结 | +| 参考组装 | 草案 | | PostgreSQL durable Run adapter(旧接口) | 历史 prototype,迁移未完成 | 当前正式调用形态为 Application 组合 shared `run.Runtime` 与 `loop.Loop`。Loop 不保存 authority state;Runtime 不读取 queue 或 planner context。 diff --git a/docs/design/agent-session-chatlog.md b/docs/design/agent-session-chatlog.md index b1c6667..dfc741c 100644 --- a/docs/design/agent-session-chatlog.md +++ b/docs/design/agent-session-chatlog.md @@ -2,224 +2,282 @@ 状态:设计草案,payload 字段、输入 limits 与 golden fixtures 尚未冻结。 -本文定义 `agent/session/chatlog` first-party Module,依赖 [Session](agent-session.md) 与 [Session Module Framework](agent-session-extension.md)。文中的“必须”“不得”“应该”是草案冻结时应保留的协议约束;canonical JSON 与 digest 遵循 `agent/jsonstable`、`agent/es`。 +本文定义 `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 -ModuleID = twilight.chatlog -Event prefix = twilight.chatlog/ -Projections = twilight.chatlog/surface, twilight.chatlog/context +Source = twilight +ModuleID = chatlog +EventType = twilight/chatlog/ +Projections = twilight/chatlog/surface, twilight/chatlog/context ``` -committed chatlog Events 是 persisted chatlog facts;Surface 和 Context 都是从这些 Events fold 的 pure projections。Attachment 是独立 domain;外部内容只能经 Message 的 `ReferencePart` 关联 BindingID。 +Chatlog 保存对话内容:Input、assistant、tool_result、summary、checkpoint。Surface 与 Context 是对这些 events 的纯投影。每条内容携带 `TurnID`;回合的创建、结束与 Run linkage 由 `twilight/turn/` 事件表达。外部内容经 `ReferencePart` 关联 Artifact BindingID。 -**CHT-SCP-1** Module 的 authority 是会话语义;Application/provider adapter 负责模型调用、provider transport、发送策略、外部内容读取与审计 policy;`twilight.turn` 负责 Turn execution linkage,chatlog payload 必须限于会话语义。 +流式 `text_delta` / `reasoning_delta` 由 Loop EventSink 发送,属于临时观察。Chatlog 权威是已提交的条目。 + +**CHT-SCP-1** 本模块拥有对话内容。Application 拥有模型调用、provider transport、发送策略与审计。`turn` 拥有回合与 Run linkage。 ## 2. stable entity 与生命周期 ```go -type InputID string type TurnID string -type ItemID string -type MessageID string +type InputID string +type AssistantID string +type ToolResultID string +type SummaryID string type CallID string type CheckpointID string -type ItemKind string type EntityStatus string ``` -InputID、TurnID、ItemID、MessageID 在 resolved ancestry 内唯一;CallID 在 Turn 内唯一。replacement graph 无环,且一个实体至多一个直接 replacement。 +`TurnID` 与 turn 模块同一 identity。InputID、AssistantID、ToolResultID、SummaryID 在 resolved ancestry 内唯一;CallID 在同一 Turn 内唯一。replacement graph 无环,一个实体至多一个直接 replacement。 | 实体 | 创建 | 可变过程 | 终态/替换 | 不变量 | |---|---|---|---|---| -| Input | InputSubmitted | 无 | Delivered / Withdrawn / Rejected | 只终结一次 | -| Turn | TurnOpened | 无 | Completed / Failed / Superseded | replacement 指向新 Turn | -| Item | ItemOpened | ItemUpdated* | Completed / Failed / Superseded | `(TurnID, Sequence)` 唯一;version 连续 | -| Message | MessageCommitted | 无 | 可被 MessageSuperseded | immutable;ID 单次创建 | -| Checkpoint | ContextCheckpointCreated | 无 | Invalidated | 仅指向已有 EventPosition | +| 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 | 指向已有 EventPosition | -**CHT-LIF-1** reducer 必须拒绝 identity mutation、非法状态迁移、version gap、sequence collision、replacement conflict 与重复 MessageID(即使 canonical value 相同)。一个 item 的 Completed 必须携带 final version 与 final content digest。 +**CHT-LIF-1** reducer 拒绝 identity mutation、非法状态迁移、replacement conflict 与重复 ID。模型步骤进行中走 EventSink;定稿写入 `assistant` 或 `tool_result`。 -## 3. semantic Message interfaces +## 3. parts 与条目 ```go -type MessagePartKind string +type PartKind string type ContentRefKind string -type MessageRole string const ( - RoleUser MessageRole = "user" - RoleAssistant MessageRole = "assistant" - RoleToolResult MessageRole = "tool_result" - PartText MessagePartKind = "twilight.chatlog/text" - PartReasoning MessagePartKind = "twilight.chatlog/reasoning" - PartToolCall MessagePartKind = "twilight.chatlog/tool_call" - PartToolResult MessagePartKind = "twilight.chatlog/tool_result" - PartReference MessagePartKind = "twilight.chatlog/reference" - RefArtifactBinding ContentRefKind = "twilight.chatlog/artifact_binding_ref" + 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 MessagePart interface { PartKind() MessagePartKind } +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() MessagePartKind +func (ReferencePart) PartKind() PartKind type TextPart struct { Text string } -func (TextPart) PartKind() MessagePartKind +func (TextPart) PartKind() PartKind type ReasoningPart struct { Text string } -func (ReasoningPart) PartKind() MessagePartKind +func (ReasoningPart) PartKind() PartKind type ToolCallPart struct { CallID CallID; Name string; Input jsonstable.Value } -func (ToolCallPart) PartKind() MessagePartKind +func (ToolCallPart) PartKind() PartKind + type ToolResultStatus string const ( - ToolSuccess ToolResultStatus = "success"; ToolError ToolResultStatus = "error" - ToolUnknown ToolResultStatus = "unknown"; ToolIndeterminate ToolResultStatus = "indeterminate" + ToolSuccess ToolResultStatus = "success" + ToolError ToolResultStatus = "error" + ToolUnknown ToolResultStatus = "unknown" + ToolIndeterminate ToolResultStatus = "indeterminate" ) -type ToolResultPart struct { CallID CallID; Status ToolResultStatus; Parts []MessagePart } -func (ToolResultPart) PartKind() MessagePartKind -type Message struct { - ID MessageID; TurnID TurnID; Role MessageRole - InputIDs []InputID; ItemIDs []ItemID; Parts []MessagePart; Digest es.Digest + +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 + Digest es.Digest +} +type ToolResult struct { + ID ToolResultID + TurnID TurnID + CallID CallID + Status ToolResultStatus + Parts []Part + 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-MSG-1** Parts、InputIDs、ItemIDs 均有序。`ArtifactBindingRef` 的 identity 为 discriminator 与 exact BindingID。所有 interface value 必须非 nil 且非 typed nil;part kind 必须匹配 concrete value。ReferencePart 不重复声明 MediaType;consumer 从 Binding 的 identity-bound Ref 得到它,并仍将其作为 untrusted metadata。 +**CHT-ENT-1** Parts 有序。`ArtifactBindingRef` 的 identity 为 discriminator 与 BindingID。interface value 非 nil;part kind 与 concrete value 匹配。ReferencePart 的 MediaType 来自 Artifact Ref。 -**CHT-MSG-2** assistant message 可有 Text、Reasoning、Reference、ToolCall;每个 `(TurnID,CallID)` 只有一个 ToolCall。user message 的每个 InputID 必须属于该 Turn 的 `TurnOpened.InputIDs`,已被 `InputDelivered` 指向该 Turn,且尚未被其他 user message 消费;不得消费未声明、未 delivered 或已消费 input。 +**CHT-ENT-2** 每个 `(TurnID,CallID)` 在 assistant 中至多一个 ToolCall。`tool_result` 对应同 Turn 已有的 call。`unknown` 与 `indeterminate` 为历史终态;active Context 视该 call 为未解决,直到 `tool_result_superseded` 换成 `success` 或 `error`。每个 unresolved result 至多一个 replacement。`tool_result_superseded` 只在对应 Turn 尚未写入 `twilight/turn/completed` 或 `twilight/turn/failed` 时接受。 -`tool_result` role 只可有对应既有 call 的一个 ToolResult。`unknown` 与 `indeterminate` 是 terminal historical result,但 active Context 视其 call unresolved。唯一处置是 `MessageSuperseded` 将该结果 Message 指向同一 Turn、同一 CallID 的新 replacement tool-result Message;replacement 必须为 `success` 或 `error`,且成功/错误后该 active call resolved。每个 unresolved historical result 最多一个 replacement,replacement chain 中同一 call 只有一个 active Message;不得以新 call、另一 event 或删除历史结果处置。 +**CHT-ENT-3** ToolResult 的 nested Parts 为单层 TextPart 或 ReferencePart。外部内容使用 `ReferencePart`。 -**CHT-MSG-3** ToolResult 的 v1 nested Parts 只允许单层 TextPart 或 ReferencePart;不得嵌套 Reasoning、ToolCall、ToolResult 或另一层 child。ItemIDs 必须指向同一 Turn 中已 completed 且 final digest 匹配的 item;message role、TurnID、item sequence 必须符合已折叠状态。`ItemOpened.Content` 与 `ItemUpdated.Content` 在 v1 只承载 inline canonical content,不得承载 durable external BindingID 或 artifact reference;外部内容必须使用 Message `ReferencePart`。 +**CHT-ENT-4** 用户侧内容是 Input。`input_delivered` 把 Input 挂到 TurnID;Context 将已 delivered 的 Input 作为用户条目。 ## 4. canonical wire codec -运行时 interface 是 typed model;wire 是 static registry 的 discriminated union。 +运行时是 typed model;wire 是 static registry 的 discriminated union。 ```go type PartCodec interface { - Kind() MessagePartKind - EncodePart(MessagePart) (jsonstable.Value, error) - DecodePart(jsonstable.Value) (MessagePart, error) + 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) } -type MessageBindingExtractor struct { /* immutable Message registry implementation */ } -func (MessageBindingExtractor) BindingIDs(value any) ([]artifact.BindingID, error) - -type MessageRegistry interface { - extension.RuntimeRegistry - RegistryID() extension.RegistryID - EncodeMessagePreimage(Message) (jsonstable.Value, error) - EncodeMessage(Message) (jsonstable.Value, error) - DecodeMessage(jsonstable.Value) (Message, error) -} ``` -**CHT-COD-1** Decode 必须 wire-first:先检查 object、explicit discriminator、Session protocol profile、unknown fields 和 limits,再构造 typed value。Encode/Decode 均拒绝 nil/typed nil、kind mismatch、未知 discriminator、cycle 和超限输入。有效值必须 `Encode → Decode → Encode` canonical-equivalent。 +**CHT-COD-1** Decode 先检查 object、discriminator、Session protocol profile、unknown fields 和 limits,再构造 typed value。Encode/Decode 拒绝 nil、kind mismatch、未知 discriminator、cycle 和超限输入。有效值满足 `Encode → Decode → Encode` canonical-equivalent。 -**CHT-COD-2** registry 是 startup static、immutable,kind 有唯一 codec,不依赖 Go type name。Message registry 实现 `extension.RuntimeRegistry.BindingExtractor()`,且必须返回 `(MessageBindingExtractor, true)`,不得以 typed nil 表示缺失。该 extractor 只接受 typed `MessageCommittedPayload`,并按 appearance order 返回其 `Message` 中顶层 ReferencePart 与 ToolResult 单层 child 的全部 ArtifactBindingRef;错误的 typed value 必须拒绝。它是 Chatlog Module binding extraction 的唯一入口。当前 draft 的 discriminator 为本节定义的 `Part*` 和 `RefArtifactBinding` 值;后续 Module kind 使用其 owner namespace。 +**CHT-COD-2** registry 在启动时固定,kind 有唯一 codec。BindingExtractor 按 appearance order 返回 assistant、tool_result、summary 中 ReferencePart 的 BindingID。 -**CHT-COD-3** `EncodeMessagePreimage` 编码 ID、TurnID、role、有序 InputIDs/ItemIDs、parts、每个 ref identity 和声明的附加字段,排除 Digest。`MessageDigest=Digest("twilight.chatlog/message/v1", registry ID, preimage)`;`EncodeMessage` 编码同一 preimage 加 Digest,Decode 必须重算。MessageCommitted payload 的 typed shape 是 `MessageCommittedPayload{Message Message}`;它的 wire codec 必须将 `message` 编码为 `EncodeMessage(Message)` 得到的 canonical message value,不得 generic re-marshal 或重复其中任一字段。 +**CHT-COD-3** EventType 为 `twilight/chatlog/`。条目 Digest 的 domain 与 EventType 相同,覆盖 ID、TurnID(若有)、有序 parts 或 Content、ref identity。wire 与 digest 形状由 Session `ProtocolVersion` 决定: -`twilight.chatlog/message/v1` 是 Message digest 的 domain identity,沿用 Session `ProtocolVersion` 的兼容性规则;它不构成独立的 event schema version。 +```text +Digest("twilight/chatlog/input_submitted", ...) +Digest("twilight/chatlog/assistant", ...) +Digest("twilight/chatlog/tool_result", ...) +Digest("twilight/chatlog/summary", ...) +``` -Note:本规范保持 draft;v1 freeze 前准确开放项仅为 wire field names、输入 limits 与 golden fixtures。 +v1 freeze 前开放项:wire field names、输入 limits、golden fixtures。 ## 5. event payloads -下列 payload 均为 object,identity 为 string,整数按 Session profile 编码;未列字段在 v1 默认拒绝。 +payload 为 object,identity 为 string,整数按 Session profile 编码。未列字段在 v1 拒绝。 ```go -type InputSubmittedPayload struct { InputID InputID; Content jsonstable.Value; SubmittedAtUnixMilli int64 } +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 TurnOpenedPayload struct { TurnID TurnID; InputIDs []InputID } -type TurnCompletedPayload struct { TurnID TurnID } -type TurnFailedPayload struct { TurnID TurnID; FailureClass string } -type TurnSupersededPayload struct { TurnID TurnID; ReplacementTurnID TurnID } -type ItemOpenedPayload struct { ItemID ItemID; TurnID TurnID; Sequence uint32; Kind ItemKind; Version uint32; Content jsonstable.Value; ContentDigest es.Digest } -type ItemUpdatedPayload struct { ItemID ItemID; Version uint32; Content jsonstable.Value; ContentDigest es.Digest } -type ItemCompletedPayload struct { ItemID ItemID; FinalVersion uint32; FinalDigest es.Digest } -type ItemFailedPayload struct { ItemID ItemID; FailureClass string } -type ItemSupersededPayload struct { ItemID ItemID; ReplacementItemID ItemID } -type MessageCommittedPayload struct { Message Message } -type MessageSupersededPayload struct { MessageID MessageID; ReplacementMessageID MessageID } -type ContextCheckpointCreatedPayload struct { - CheckpointID CheckpointID; CoveredThrough session.EventPosition - BaseContextDigest es.Digest; SummaryMessageID MessageID; SummaryMessageDigest es.Digest - Retained []MessageDigestPair; Digest es.Digest + +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.EventPosition + BaseContextDigest es.Digest + SummaryID SummaryID + SummaryDigest es.Digest + Retained []EntryDigestPair + Digest es.Digest } -type MessageDigestPair struct { MessageID MessageID; Digest es.Digest } -type ContextCheckpointInvalidatedPayload struct { CheckpointID CheckpointID; Reason string } +type EntryDigestPair struct { Kind EntryKind; ID string; Digest es.Digest } +type CheckpointInvalidatedPayload struct { CheckpointID CheckpointID; Reason string } ``` -`ModuleDescriptor` 中 `twilight.chatlog/message_committed` 的 EventDefinition 必须注册 Message registry requirement,并声明唯一 Binding reference: +`assistant`、`tool_result`、`summary` 的 EventDefinition 注册 parts registry: ```go extension.BindingReferenceDefinition{ - RegistryID: "twilight.chatlog/message", + RegistryID: "twilight/chatlog/parts", Cardinality: extension.Cardinality{Min: 0}, AllowedSchemes: nil, RequiredDurability: artifact.EventBound, } ``` -`AllowedSchemes:nil`(或 empty)明确表示任意 Catalog-registered 且支持 `EventBound` 的 scheme;非空 slice 才是显式允许列表。它不表示允许 ephemeral。该 declaration 使 Message extractor 覆盖的每一个 Binding 都接受 Module admission。 +`AllowedSchemes` 为空表示任意已注册且支持 `EventBound` 的 scheme。 -**CHT-EVT-1** Event names依次为 `input_submitted`、`input_delivered`、`input_withdrawn`、`input_rejected`、`turn_opened`、`turn_completed`、`turn_failed`、`turn_superseded`、`item_opened`、`item_updated`、`item_completed`、`item_failed`、`item_superseded`、`message_committed`、`message_superseded`、`context_checkpoint_created`、`context_checkpoint_invalidated`,均以 `twilight.chatlog/` 为前缀。 +**CHT-EVT-1** EventType: -**CHT-EVT-2** InputSubmitted 创建 input;Delivered/Withdrawn/Rejected 只能一次终结它。`InputDelivered` 必须指向已存在、尚未终结的 TurnOpened,且该 TurnOpened.InputIDs 明确声明该 InputID。TurnOpened 的 InputIDs 必须是 submitted、distinct 且未终结 inputs;Turn 终结后不得新增 Item 或 Message。ItemOpened 从 version 1 起,ItemUpdated 每次加一。Item ContentDigest 的唯一算法为 `Digest("twilight.chatlog/item-content/v1", ItemID, TurnID, Kind, Version, canonical Content)`:ItemOpened 用 payload 的 ItemID/TurnID/Kind/Version/Content 重算并匹配其 ContentDigest;ItemUpdated 用当前 item 的 ItemID/TurnID/Kind 和该 update 的 Version/Content 重算并替换当前 digest;ItemCompleted 的 FinalVersion 和 FinalDigest 必须分别匹配当前/latest version 与该 latest digest。MessageCommitted payload codec 必须 decode/validate typed `MessageCommittedPayload.Message`,再创建其 MessageID;同一 resolved ancestry 中只能创建一次。 +```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。同一 Start commit 中 `twilight/turn/started` 列出这些 InputIDs。AssistantID、ToolResultID、SummaryID 在 ancestry 内单次创建。 -**CHT-EVT-3** `BaseContextDigest` 覆盖截至 `CoveredThrough`(必须早于该 checkpoint event)的 canonical ordered active Message sequence,即有序 `(MessageID, MessageDigest)`,而非仅 ID;该 base sequence 不包含 summary。`SummaryMessageID` 必须在 `CoveredThrough` 之后、checkpoint event 之前已由 `MessageCommitted` 创建且仍 active,其 digest 必须等于 `SummaryMessageDigest`。在 `CoveredThrough` 与 checkpoint event 之间,除该指定 summary 外不得有其他 context-contributing `MessageCommitted` 或 checkpoint;surface/log-only facts 可以出现。`Retained` 必须是 base sequence 的有序子集,digest 均匹配且不得重复。checkpoint digest 覆盖 registry identity、CoveredThrough、BaseContextDigest、summary pair 和 ordered retained pairs。应用有效 checkpoint 的结果固定为 `[SummaryMessage] + Retained`;tail 仅从 checkpoint event 之后开始 fold,绝不再次折叠 summary。显式 invalidation,或 checkpoint 之后 summary、任一 retained 或任一 base source message 的 supersession,都会使该 checkpoint incompatible;projection 必须回退到更早 compatible checkpoint 或完整 fold,不得对 checkpoint 内部结果局部应用 supersession。 +**CHT-EVT-3** checkpoint Digest 的 domain 为 `twilight/chatlog/checkpoint_created`。`BaseContextDigest` 覆盖截至 `CoveredThrough` 的有序 active Context 序列 `(Kind, ID, Digest)`。`CoveredThrough` 早于该 checkpoint。`SummaryID` 落在 `CoveredThrough` 与 checkpoint 之间,且已由 `summary` 创建。该间隙内仅有这一条 summary。`Retained` 为 base 序列的有序子集。合法 checkpoint 下 Context 为 `[Summary] + Retained`,再 fold checkpoint 之后的 tail。checkpoint 在显式 invalidate,或 summary / Retained / base source 被 supersede 之后失效;projection 回退到更早合法 checkpoint,或从全量 events 重折。 ## 6. Surface projection ```go -type SurfaceItem struct { ItemID ItemID; TurnID TurnID; Sequence uint32; Status EntityStatus; Version uint32; Digest es.Digest } -type SurfaceMessage struct { MessageID MessageID; Position session.EventPosition } -type ConversationTurn struct { TurnID TurnID; Status EntityStatus; ItemOrder []ItemID; SupersededBy TurnID } +type SurfaceEntry struct { + Kind EntryKind + ID string + Position session.EventPosition +} type Surface struct { - Turns []ConversationTurn; Items map[ItemID]SurfaceItem; Messages map[MessageID]Message - MessageOrder []SurfaceMessage + Inputs map[InputID]Input + Assistants map[AssistantID]Assistant + ToolResults map[ToolResultID]ToolResult + Summaries map[SummaryID]Summary + EntryOrder []SurfaceEntry } ``` -**CHT-SUR-1** SurfaceFold 是 pure deterministic fold,只消费 chatlog decoded events。它按 stable IDs 聚合 active、terminal 与 superseded 状态;`MessageOrder` 是 resolved replay order 的稳定完整 MessageCommitted sequence,并以 Position 保留 source event identity,调用方不得从 map iteration 推导顺序。展示 grouping 由调用方从 ItemOrder 和 MessageOrder 派生。 +**CHT-SUR-1** SurfaceFold 消费 chatlog decoded events。`EntryOrder` 为 resolved replay 顺序下的 delivered input、assistant、tool_result、summary,并带 Position。回合列表由 turn 投影提供,按 `TurnID` 连接。 ## 7. Context projection ```go -func ContextFold(events []extension.DecodedEvent) ([]Message, error) +func ContextFold(events []extension.DecodedEvent) ([]Entry, error) ``` -**CHT-CTX-1** 输入必须是已验证、decode 后、按 resolved ancestry segment/event order 的 events。ContextFold 只输出有效 MessageCommitted 经 supersession/checkpoint 处理后的有序 `[]Message`;它不得接收 raw commits、resolve content、访问 IO 或 provider transport。 +**CHT-CTX-1** 输入为已验证、按 ancestry 排序的 chatlog events。输出为 delivered input、assistant、tool_result、summary 经 supersession 与 checkpoint 处理后的有序 `[]Entry`。ContextFold 为纯函数。 -**CHT-CTX-2** fold 必须再次执行 ancestry-wide MessageID single-creation、role/call pairing、active unresolved-call 检查以及 unknown/indeterminate replacement 规则。checkpoint 只在 CHT-EVT-3 的 coverage gap、base/summary/retained digest 与 retained order 均匹配且仍 compatible 时使用;应用后按 `[SummaryMessage] + Retained` 继续仅 fold checkpoint event 之后的 tail。 +**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 { MessageID MessageID; BindingID artifact.BindingID; Operation string; Result string; Detail string } -type MaterializedMessage struct { Role MessageRole; Parts []jsonstable.Value } -type MaterializationResult struct { Messages []MaterializedMessage; Decisions []MaterializationDecision } +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, []Message, MaterializationTarget) (MaterializationResult, error) + Materialize(context.Context, []Entry, MaterializationTarget) (MaterializationResult, error) } ``` -**CHT-MAT-1** materializer 是 IO boundary,可把 semantic message 转为 target 表示并报告 decisions;它不得改写 Message 或 committed chatlog Events。provider capability、fallback、audit 以及何时持久化 decision 都是 Application policy,本规范不要求发送前追加任何事实。 +**CHT-MAT-1** materializer 把语义条目转为目标模型表示。committed chatlog Events 保持不变。provider capability 与发送策略由 Application 决定。Planner 组装见 [参考组装](agent-reference-assembly.md)。 ## 9. conformance -Conformance 必须验证: - -- **CHT-LIF-1、CHT-EVT-1、CHT-EVT-2**:entity lifecycle、InputDelivered 的 opened/declaration 约束、sequence/version、Item ContentDigest 的 opened/update 重算与 latest completion matching、replacement 与 ancestry-wide MessageID; -- **CHT-MSG-1、CHT-MSG-2、CHT-MSG-3**:role/turn/order、declared-delivered-unconsumed input、CallID pairing、unknown/indeterminate 的唯一 replacement resolution、nested v1 grammar 与无 Item external BindingID; -- **CHT-COD-1、CHT-COD-2、CHT-COD-3**:wire-first codec、nil/kind/limit rejection、Message registry RuntimeRegistry extractor、`message_committed` 的 EventBound registry declaration、typed MessageCommittedPayload canonical message wire、digest closure 与完整 BindingIDs; -- **CHT-SUR-1、CHT-CTX-1、CHT-CTX-2**:pure Surface/Context、稳定 MessageOrder、decoded input、checkpoint 的 base 不含 summary、summary placement/coverage gap、retained ordered subset、`[Summary] + Retained` replacement、checkpoint 后 tail,以及 invalidation 或 source/summary/retained supersession 的回退; -- **CHT-MAT-1**:materializer port 不越过 Application policy boundary。 +- **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; +- **CHT-MAT-1**:materializer 为 IO 边界。 diff --git a/docs/design/agent-session-extension.md b/docs/design/agent-session-extension.md index f6e1ff8..dd89600 100644 --- a/docs/design/agent-session-extension.md +++ b/docs/design/agent-session-extension.md @@ -13,9 +13,9 @@ agent/artifact ← Session Module Framework → agent/session ``` Framework 负责 Module ownership、static startup composition、immutable Catalog、typed event -codec、Binding declaration、SemanticAppender 和 pure projection。first-party modules 可包括 -`twilight.chatlog` 与 `twilight.turn`。每次进程启动构建一套与 Session protocol version -绑定的 immutable Catalog。 +codec、Binding declaration、SemanticAppender 和 pure projection。first-party Source 为 +`twilight`,其 Module 为 `chatlog` 与 `turn`。每次进程启动构建一套与 Session protocol +version 绑定的 immutable Catalog。Application 注册自己的 Source(例如 `acme`)与 Module。 **EXT-SCP-1** Session Store protocol 由 Session kernel 负责;多 Session transaction、package import、saga、operation log 与 provider policy 由 Application/adapter 负责。未知 event 的 archive Binding manifest 由 Application archive coordinator 处理。 @@ -27,12 +27,16 @@ replay 规则。 ## 2. Module、Catalog 与版本 ```go +type SourceID string type ModuleID string type ProjectionID string type ProjectionVersion uint16 type RegistryID string +const SourceTwilight SourceID = "twilight" + type ModuleDescriptor struct { + Source SourceID ID ModuleID Events []EventDefinition Projections []ProjectionDefinition @@ -55,8 +59,8 @@ type RuntimeRegistry interface { BindingExtractor() (BindingExtractor, bool) } func BuildCatalog(CatalogBuildRequest) (*Catalog, error) -func (c *Catalog) LookupEvent(session.EventType) (ModuleID, EventDefinition, bool) -func (c *Catalog) ModuleForEvent(session.EventType) (ModuleID, bool) +func (c *Catalog) LookupEvent(session.EventType) (SourceID, ModuleID, EventDefinition, bool) +func (c *Catalog) ModuleForEvent(session.EventType) (SourceID, ModuleID, bool) func (c *Catalog) LookupProjection(ProjectionID, ProjectionVersion) (ProjectionDefinition, bool) ``` @@ -66,9 +70,12 @@ typed append、decode、binding admission 与 projection lookup 使用;Session 状态和事件日志继续保存在各自的 authority 中。`Catalog.Profile` 是同一版本的 `session.ProtocolProfile`,两者在 `BuildCatalog` 时绑定。 -**EXT-CAT-1** ModuleID 使用小写 ASCII namespace;`twilight.*` 保留 first-party。EventType -采用 `/` 形式,owner namespace 来自前缀。一个 Catalog 中 ModuleID、 -EventType、ProjectionID、Scheme、RegistryID 均唯一。Build defensive-copy 所有 descriptor +**EXT-CAT-1** `SourceID` 与 `ModuleID` 为小写 ASCII。first-party Source 为 `twilight`, +其 Module 为 `chatlog` 与 `turn`。Application 注册自己的 Source(例如 `acme`)。 +EventType 为 `//`,例如 `twilight/chatlog/assistant`、 +`twilight/turn/started`。Digest domain 与 EventType 相同;wire 与 digest 形状由 Catalog +绑定的 Session `ProtocolVersion` 决定。一个 Catalog 中 `(Source, ModuleID)`、EventType、 +ProjectionID、Scheme、RegistryID 均唯一。Build defensive-copy 所有 descriptor 和 runtime registry,成功后只读且与注册顺序无关。 事件类型在一个 Catalog 中只有一个当前定义。`ProtocolVersion` 覆盖已经持久化的 @@ -212,15 +219,15 @@ type AtomicSemanticCommitter interface { **EXT-APP-2** 对 nonempty BindingSet,先经 `Store.Header(ctx, SessionID)` 读取并验证该 Session 的 Header,唯一派生为: ```text -ClaimID = Digest("twilight.session-extension/claim/v1", +ClaimID = Digest("twilight/session-extension/claim", claim-profile-version "1", extension-canonical-string(Header.ProtocolVersion), SessionID, CommitID, BindingSet.RefSetDigest) -ClaimOwner = {Kind:"twilight.session/commit", +ClaimOwner = {Kind:"twilight/session/commit", Authority:string(SessionID), Identity:string(CommitID)} ``` -claim profile version 固定为 `1`;整数使用 Session Module canonical string profile(无前导零的十进制),其中 Session `ProtocolVersion` 只能取自已验证 Header。Artifact `WireVersion` 已由 `BindingSet.RefSetDigest` 覆盖,绝不另入 preimage。ClaimID 不含 ExpectedHead。先 Build set,再派生 claim;调用者不得提供或覆盖它们。相同 CommitID retry 的 typed event identity/time/source/payload 或 BindingSet 不同均为 conflict;完全相同 immutable request 的 append fingerprint 稳定。`ExpectedHead` 不入该 fingerprint,且是同一 pending intent 唯一可更新的 CanonicalRequest 字段。retry、recovery 及导入 `twilight.session/commit` active claim 时都必须以该 Header 和同一 preimage 重算并验证 ClaimID。 +claim profile version 固定为 `1`;整数使用 Session Module canonical string profile(无前导零的十进制),其中 Session `ProtocolVersion` 只能取自已验证 Header。Artifact `WireVersion` 已由 `BindingSet.RefSetDigest` 覆盖,绝不另入 preimage。ClaimID 不含 ExpectedHead。先 Build set,再派生 claim;调用者不得提供或覆盖它们。相同 CommitID retry 的 typed event identity/time/source/payload 或 BindingSet 不同均为 conflict;完全相同 immutable request 的 append fingerprint 稳定。`ExpectedHead` 不入该 fingerprint,且是同一 pending intent 唯一可更新的 CanonicalRequest 字段。retry、recovery 及导入 `twilight/session/commit` active claim 时都必须以该 Header 和同一 preimage 重算并验证 ClaimID。 **EXT-APP-3** 没有 Binding 时不创建 BindingSet、intent 或 claim。否则先构造含 ClaimID、ClaimOwner、BindingSet、Fingerprint、完整 CanonicalRequest 和 Pending State 的 intent,且非原子流程严格为 `journal.Prepare → ledger.Prepare → Store.Commit`。journal `Prepare` 对既有同一 ClaimID 的记录,必须逐字段验证 Fingerprint、ClaimOwner、BindingSet 及 CanonicalRequest 的所有 immutable 字段;只有全部相同才幂等,并且仅可更新 Pending intent 的 `CanonicalRequest.ExpectedHead`,其他差异均为 conflict。`ledger.Prepare` 必须使用 intent 内的 ClaimID、ClaimOwner、BindingSet。支持 `AtomicSemanticCommitter` 的 adapter 可跳过单独 choreography,但必须原子持久化同一完整 intent 与匹配 Prepared claim,或直接原子 commit 并建立匹配 Active claim;两种路径均不得在 Event 可见前缺少可恢复 claim plan,并使用等价 terminal handling。 @@ -274,8 +281,8 @@ type ProjectionRunner interface { Run(ProjectionRunRequest) (ProjectionRunResult `Catalog` 非 nil、`Catalog.ProtocolVersion == ProtocolVersion`,并只接受已验证的 complete commit sequence;一个 commit 内任一 event 失败,不得发布该 commit 的 partial state。 -**EXT-PRJ-2** `Catalog` 为每个 EventType 提供唯一 ModuleID 与 EventDefinition; -`ModuleForEvent` 依据 EventType 前缀识别 owner,因此 required module 的未知事件可以被 +**EXT-PRJ-2** `Catalog` 为每个 EventType 提供唯一 `(Source, ModuleID)` 与 EventDefinition; +`ModuleForEvent` 按 EventType 的 `//` 前缀识别 owner,因此 required module 的未知事件可以被 发现。`Consumes` 表示必须 decode/handle 的 EventType,`Ignores` 是显式已知跳过,二者不得重叠。 出现属于 `RequireComplete` module 的 unknown 或未分类 event 必须失败;其他 module event 可忽略。 diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index 3ef865c..523baba 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -1,35 +1,36 @@ # Twilight Agent Turn 协议 -状态:设计规范。 +状态:设计草案。 -本文定义 `agent/turn` 对一个 Chatlog Turn 的协调协议;“必须”“应该”均为协议约束。Run Machine、Runtime 与 Loop 的 authority 是 [agent-run.md](agent-run.md);本文只规定 Turn 如何消费该协议。 +本文定义 `agent/turn`:回合生命周期与 Run linkage。“必须”“应该”为协议约束。Run 的 authority 是 [agent-run.md](agent-run.md);对话内容的 authority 是 [agent-session-chatlog.md](agent-session-chatlog.md)。 ## 1. 模型与范围 -| Concern | Canonical owner / record | Coordinator action | +| Concern | Canonical owner | Coordinator | |---|---|---| -| `MachineState` | execution authority | 经 shared Runtime 驱动、读取和验证 | -| `TransitionRecord` | canonical Run record | Record、fold、materialize 完整前缀 | -| Session events | verified resolved stream | SemanticAppender 提交、replay linkage 与 settlement | -| Runtime | 唯一 Run access 与 atomic Commit boundary | Create、Load、Commit、Record | -| Application policy | Application | 建立 binding,提供 driver 与业务 policy | +| 回合存在与结束 | `twilight/turn/` events | Start / Stop / settlement | +| `MachineState` | Run Runtime | Load / Commit / Record | +| `TransitionRecord` | Run Runtime | fold、materialize 完整前缀 | +| 对话内容 | `twilight/chatlog/` events | Start 时 delivered input;Drive 后写出 assistant / tool_result | +| Application policy | Application | binding、driver、产品策略 | -**TRN-SCP-1** `agent/turn.Coordinator` 为 concrete Coordinator,`twilight.turn` 为唯一 first-party `ModuleID`。一个 Turn 恰有一个 primary Run;retry 重用该 RunID。replacement Turn 通过 chatlog replacement graph 建立其 primary Run;同 Turn 新建 primary Run 的请求触发 conflict。 +**TRN-SCP-1** Source 为 `twilight`,ModuleID 为 `turn`。`Coordinator` 协调一个 Turn 与一个 primary Run。retry 复用该 RunID。replacement 经 `twilight/turn/superseded` 指向新 Turn 与新 RunID。同一 Turn 上第二个 primary Run 为 conflict。 -**TRN-SCP-2** primary Run 保持既有 identity。放弃或替换对话时,Application 创建 replacement Turn 与新 RunID,并使用 chatlog replacement graph。实现拒绝旧 Turn 或旧 RunID 的 replacement reuse。subagent 运行于独立 Session、独立 Turn。 +**TRN-SCP-2** primary RunID 在该 Turn 内存活期内保持不变。subagent 使用独立 Session 与独立 Turn。 -**TRN-SCP-3** Coordinator 从 Session facts 和 `Runtime.Record` 重建;`MachineState` 提供 execution authority,`TransitionRecord` 提供 canonical record。Coordinator 编排持久化事实,以 injected capability 执行协调动作;Session、Run、driver、mapper cursor、terminal verdict、队列 lease、provider client、per-Run handle 与额外权限均由各自 canonical owner 提供。 +**TRN-SCP-3** Coordinator 从 Session facts 与 `Runtime.Record` 重建。 -**TRN-SCP-4** Coordinator 通过 `extension.SemanticAppender` 完成所有 Session 写入;Session Module Framework 负责 Binding admission、claim 及其恢复。`artifact` 由其 canonical owner 管理。 +**TRN-SCP-4** Session 写入经 `extension.SemanticAppender`。Artifact 由其 owner 管理。 -**TRN-SCP-5** Application 管理 model、provider、tool、prompt、token、approval、queue、channel、并发、重试时机及其他业务 policy。Coordinator 按 persisted binding 解析 driver 并协调协议步骤,历史计划仅按其 persisted choices 执行。 +**TRN-SCP-5** Application 管理 model、provider、tool、prompt、token、approval、queue 与并发。Coordinator 按 persisted binding 解析 driver。参考 Planner 每次 Plan 使用 Binding 的 `ModelRef`。 -**TRN-SCP-6** Application 在开始前建立 immutable execution binding,并持久化公开 `ID`、`Digest` 与 mapper version。serialized binding 仅含这些公开值;运行时对象、credential、secret、可变 policy 由 Application 管理。binding registry 精确解析持久化 identity;缺失 binding 返回 `binding_unavailable`。 +**TRN-SCP-6** Start 之前建立 immutable execution binding。Session 保存 `ID` 与 `Digest`。密钥与 client 留在进程内。Resolve 失败返回 `binding_unavailable`。公开字段见 [参考组装](agent-reference-assembly.md)。 ## 2. identity、计划与事件 ```go -type TurnRef struct { SessionID session.SessionID; TurnID chatlog.TurnID } +type TurnID string +type TurnRef struct { SessionID session.SessionID; TurnID TurnID } type ExecutionBindingRef struct { ID ExecutionBindingID; Digest es.Digest } type MapperVersion string type SourceFactID string @@ -60,45 +61,90 @@ type RunCreateSpec struct { ExecutionBinding ExecutionBindingRef MapperVersion MapperVersion } -type RunRequestedPayload struct { - TurnID chatlog.TurnID; RunID run.RunID - Create RunCreateSpec; PlanDigest es.Digest + +type StartedPayload struct { + TurnID TurnID + InputIDs []chatlog.InputID + RunID run.RunID + Create RunCreateSpec + PlanDigest es.Digest } -type RunSettledPayload struct { - TurnID chatlog.TurnID; RunID run.RunID; Settlement Settlement +type CompletedPayload struct { + TurnID TurnID + RunID run.RunID + Settlement Settlement Head RunHeadRef Result ResultReference } +type FailedPayload struct { + TurnID TurnID + RunID run.RunID + Settlement Settlement + FailureClass string + Head RunHeadRef + Result ResultReference +} +type SupersededPayload struct { + TurnID TurnID + ReplacementTurnID TurnID +} ``` -**TRN-ID-1** `TurnRef`、RunID、binding ID、MapperVersion、chatlog identity 与 digest 必须非空稳定。`ExecutionBindingRef` 仅编码公开 identity 与 digest;secret、token、provider handle、policy 分别由其 owner 管理。 +**TRN-ID-1** `TurnRef`、RunID、binding ID、MapperVersion、InputID 与 digest 非空且稳定。`ExecutionBindingRef` 编码公开 identity 与 digest。 -**TRN-ID-2** `RunHeadRef` 指向经验证的 transition head;结果查询使用 `ResultReference`。`RunSettledPayload.Head` 精确等于 terminal `RunRecord` 的 head(RunID、revision、transition digest),该 head transition 包含其 `Result` 所指向的最后一个 terminal `RunEnded`。`ResultReference` 精确指向 terminal `RunEnded` AgentEvent;其 RunID、revision、index、type、digest 与 `RunRecord` 的该 event 逐字段相同。 +**TRN-ID-2** `Head` 等于 terminal `RunRecord` 的 head。`Result` 指向该 record 中的 `RunEnded`。 -**TRN-ID-3** `RunCreateSpec.Run.RunID` 等于 requested payload 的 RunID,完整 `NewRun` 通过 `run.ValidateNewRun`。InitialInputs 为有序 immutable admission data,随后以 `AcceptInput` transition 进入 Run;revision-0 header 与 Run collision identity 保持稳定。 +**TRN-ID-3** `RunCreateSpec.Run.RunID` 等于 payload 的 RunID。`NewRun` 经 `run.ValidateNewRun`。InitialInputs 随后以 `AcceptInput` 进入 Run。 -**TRN-ID-4** `PlanDigest` 使用 `twilight.turn/plan/v1` domain,覆盖完整 versioned `RunCreateSpec`:完整 NewRun、有序每个 initial input 的 ID/canonical payload、ExecutionBindingRef、MapperVersion。字段、presence、顺序或版本变化均触发 digest 变化。 +**TRN-ID-4** `PlanDigest = Digest("twilight/turn/plan", canonical(RunCreateSpec))`。 ```go type StartOperationDigest es.Digest func DigestStartOperation( - sessionID session.SessionID, turnID chatlog.TurnID, runID run.RunID, - planDigest es.Digest, userMessageDigest es.Digest, inputIDs []chatlog.InputID, + sessionID session.SessionID, turnID TurnID, runID run.RunID, + planDigest es.Digest, inputIDs []chatlog.InputID, ) (StartOperationDigest, error) ``` -**TRN-ID-5** StartOperationDigest 使用 `twilight.turn/start-operation/v1` domain,按序覆盖 SessionID、TurnID、RunID、PlanDigest、UserMessage.Digest、ordered InputIDs。它排除 head、clock、registry state、进程 identity,并作为 Start commit、event identity、response 的稳定 identity。 +**TRN-ID-5** `StartOperationDigest = Digest("twilight/turn/start-operation", SessionID, TurnID, RunID, PlanDigest, ordered InputIDs)`。用户正文 identity 在对应 `twilight/chatlog/input_submitted` 中。 + +**TRN-EVT-1** EventType: + +```text +twilight/turn/started +twilight/turn/completed +twilight/turn/failed +twilight/turn/superseded +``` + +`started` 含 RunCreateSpec。`completed` 与 `failed` 含 Settlement 与 Result。unsettled linkage 是尚未 completed、failed 或 superseded 的 `started`。 -**TRN-EVT-1** 最小 committed ontology 为 `twilight.turn/run_requested` 与 `twilight.turn/run_settled`,payload 分别为 `RunRequestedPayload`、`RunSettledPayload`。transient UI delta、driver return、outbox record、观察日志提供辅助信息;ontology 查询仅接受这两个 committed event。 +**TRN-EVT-2** Start 的 EventID 与 CommitID 由 StartOperationDigest、event kind、group ordinal 派生。retry 复用同一 identity。 -**TRN-EVT-2** Start 的 EventID、CommitID 由 StartOperationDigest、固定 event kind、group ordinal 在版本化 domain 中唯一派生;retry 重用该 identity。materialization、settlement 使用本规范所列 stable identity,恢复性 commit ID 使用确定性派生。 +**TRN-EVT-3** resolved stream 内每个 TurnID 至多一条 unsettled `started`。相同 canonical payload 幂等;差异为 conflict。 -**TRN-EVT-3** resolved stream 内 `(TurnID,RunID)` 的 `run_requested` 唯一;一个 unsettled Turn 持有一个 linkage。相同 canonical payload 幂等;payload、PlanDigest、event identity 或 linkage 差异触发 conflict。 +**TRN-PRJ-1** ProjectionID 为 `twilight/turn/surface`。消费 `started`、`completed`、`failed`、`superseded`: -## 3. API 与 authority boundary +```go +type TurnView struct { + TurnID TurnID + Status string // started | completed | failed | superseded + InputIDs []chatlog.InputID + RunID run.RunID + Settlement Settlement + ReplacementTurnID TurnID +} +type TurnSurface struct { + Order []TurnID + Turns map[TurnID]TurnView +} +``` + +UI 按 `TurnID` 连接 `twilight/chatlog/surface` 的条目。 + +## 3. API ```go -// run is github.com/memohai/twilight/agent/run. type Coordinator struct { Sessions session.Store Appender extension.SemanticAppender @@ -109,26 +155,19 @@ type Coordinator struct { type DriveRequest struct { Ref TurnRef; RunID run.RunID } type RunDriver interface { Drive(context.Context, DriveRequest) error } type ExecutionBindingRegistry interface { Resolve(ExecutionBindingRef) (RunDriver, error) } -``` - -**TRN-API-1** 上列字段为 injected capability。每个 Coordinator 实例依据 Session facts 与 `Runtime.Record` 接续;Session、Run、driver、mapper cursor、terminal verdict 均由其 canonical record 提供,跨调用状态通过这些 records 重建。 - -**TRN-API-2** binding registry 以同一个 shared `run.Runtime` 组合 driver。driver 接收 `DriveRequest{Ref,RunID}`,并经 shared Runtime 读写;其 Run access 以该 Runtime contract 完成,persisted binding 含义保持稳定。 - -**TRN-API-3** Coordinator、driver、mapper 与 settlement 只通过 [agent-run.md](agent-run.md) 的 `run.Runtime` contract 协调 Run。input/transition 全部经 Commit 持久化;绕过 Runtime authority 的写入不构成 Turn progress。 -**TRN-API-4** Coordinator 按 Run contract 处理 idempotent Create、create conflict、missing Run、corrupt record 与 unavailable,并且只从 persisted linkage 取得创建计划;Turn 不重新定义 Runtime 的 collision、commit、grant 或 record 语义。 - -```go type Service interface { Start(context.Context, StartRequest) (StartResponse, error) Resume(context.Context, ResumeRequest) (ResumeResponse, error) Stop(context.Context, StopRequest) (StopResponse, error) } type StartRequest struct { - Ref TurnRef; Run run.NewRun; InputIDs []chatlog.InputID - UserMessage chatlog.Message; InitialInputs []run.AgentInput - ExecutionBinding ExecutionBindingRef; MapperVersion MapperVersion + Ref TurnRef + Run run.NewRun + InputIDs []chatlog.InputID + InitialInputs []run.AgentInput + ExecutionBinding ExecutionBindingRef + MapperVersion MapperVersion } type StartResponse struct { Ref TurnRef; RunID run.RunID; PlanDigest es.Digest; Operation StartOperationDigest @@ -143,181 +182,162 @@ type StopRequest struct { Ref TurnRef; Reason string } type StopResponse struct { Ref TurnRef; RunID run.RunID; Result *ResultReference } ``` -**TRN-API-5** DTO 均为值语义。Start 对既有 linkage 的新 RunID 或计划改写返回 conflict;Resume/Stop 按 Ref 解析唯一 unsettled linkage。`Disposition` 取 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`;前两者表示 Run 仍为 active,后者必须带 `Result`。`Waiting` 只列当前 snapshot 的 `WaitingCalls`;Loop/driver 不返回这些 request。执行恢复等待不伪造 response request;terminal fact 与后续计划授权由 RunRecord 和 persisted linkage 提供。 +**TRN-API-1** Coordinator 从 Session facts 与 `Runtime.Record` 接续。 + +**TRN-API-2** Registry 用同一 `run.Runtime` 组装 driver。 + +**TRN-API-3** Run 的写入经 `run.Runtime`。 + +**TRN-API-4** Create、conflict、missing、corrupt 按 Run contract 处理。创建计划来自 persisted `started`。 + +**TRN-API-5** DTO 为值语义。`Waiting` 为 snapshot 的 `WaitingCalls`。`Disposition` 为 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`。 -## 4. Start admission +## 4. Start -**TRN-STR-1** Start 依次验证、写入 Session intent/linkage、调用 Runtime.Create。Start 的 Session append causation 等于 persisted `NewRun.CausationID`;恢复沿用该 causation。 +**TRN-STR-1** Start 验证请求、追加 Session group、调用 `Runtime.Create`。causation 等于 `NewRun.CausationID`。 -**TRN-STR-2** StartRequest 满足: +**TRN-STR-2** StartRequest: 1. Ref、RunID、binding ref、mapper version 非空; -2. InputIDs 与 InitialInputs 等长、无重复、顺序完全相同; -3. `InitialInputs[i].ID=InputIDs[i]`,其 payload 与对应 `InputSubmitted` admission payload 完全对应; -4. InitialInputs 为 Run ingress payload,UserMessage 按其 chatlog wire 规则持久化; -5. `UserMessage.Role=RoleUser`、`TurnID=Ref.TurnID`,并经 Message registry 重算 digest; -6. UserMessage.InputIDs 精确等于 ordered InputIDs,ItemIDs 为空; -7. input 已 submitted、未终结、可 delivered;message 的其余 role、part、binding、canonical 约束由 chatlog/Module admission 执行。 +2. InputIDs 与 InitialInputs 等长、无重复、顺序相同; +3. `InitialInputs[i].ID = InputIDs[i]`,Payload 等于对应 `twilight/chatlog/input_submitted` 的 Content; +4. Input 为 submitted,可 delivered。 -**TRN-STR-3** v1 Start 写入一个原子 semantic Session commit,顺序严格为: +**TRN-STR-3** 一次原子 commit,顺序为: ```text -TurnOpened{TurnID, InputIDs} -InputDelivered{InputIDs[0], TurnID} +twilight/turn/started{TurnID, InputIDs, RunID, Create, PlanDigest} +twilight/chatlog/input_delivered{InputIDs[0], TurnID} ... -InputDelivered{InputIDs[n-1], TurnID} -MessageCommitted{UserMessage} -run_requested{RunRequestedPayload} +twilight/chatlog/input_delivered{InputIDs[n-1], TurnID} ``` -恰有一个 MessageCommitted,且为该 user message;该 group 的 chatlog/turn event 集合即如上。空 input group 为 `TurnOpened → MessageCommitted → run_requested`。 +InputIDs 为空时 group 仅含 `twilight/turn/started`。 -**TRN-STR-4** Start 构造 RunCreateSpec、PlanDigest、StartOperationDigest、stable CommitID/EventID group,再调用 `SemanticAppender.AppendSemantic`。applied/already-applied 表示同一成功事实;head conflict 进行 CAS rebase,并保留完整 group 的 identity、payload、输入顺序、causation。 +**TRN-STR-4** 派生 RunCreateSpec、PlanDigest、StartOperationDigest 与 group identity,再 `AppendSemantic`。相同 identity 为 applied / already-applied。head conflict 时 CAS rebase,identity 与 payload 保持不变。 -**TRN-STR-5** semantic append 确认 intent 后,Start 重新 resolved replay 精确解析 linkage,再调用 `Runtime.Create(ctx, linkage.Create.Run)`。Created true/false 均进入 Resume;Create conflict、linkage 不唯一、payload 不符、header 非法返回失败并保留诊断事实。 +**TRN-STR-5** append 成功后 replay 解析 linkage,再 `Runtime.Create`。Created 为 true 或 false 均进入 Resume。 -**TRN-STR-6** Start 在 Create 后调用 Resume;inputs 投递、model 驱动、Session settlement 均收敛到恢复算法。 +**TRN-STR-6** Create 之后调用 Resume。 ## 5. Resume、Drive 与 Stop -**TRN-RSM-1** Resume 在 verified resolved stream 查询 Ref 的唯一 unsettled `run_requested`。无 linkage、多个 linkage、已 settled linkage、payload identity 冲突分别返回 not-found、corrupt 或 conflict;计划取自 persisted linkage。 +**TRN-RSM-1** Resume 查找 Ref 上唯一 unsettled `twilight/turn/started`。 -**TRN-RSM-2** Resume 对 persisted `linkage.Create.Run` 再调 Create,验证 RunID collision identity;成功后执行 `EnsureInitialInputs`。 +**TRN-RSM-2** 对 persisted `Create.Run` 再 Create,然后 `EnsureInitialInputs`。 -**TRN-RSM-3** EnsureInitialInputs 对 persisted InitialInputs 按序逐项: +**TRN-RSM-3** 按序对 InitialInputs:Load,以 `DeriveInputCommandID` 提交 `AcceptInput`。Accepted 与 AlreadyApplied 均前进。结果未知时先 Record。 -1. `Runtime.Load(RunID)` 取得 revision; -2. 用 `run.DeriveInputCommandID(RunID, Input.ID)` 与 `run.BuildEnvelope(RunID, commandID, run.AcceptInput{Input})` 建 envelope; -3. 以该 revision 提交 CommitRequest; -4. CommitAccepted 或同 command 的 CommitAlreadyApplied 均前进; -5. stale 时 Load 后以同 identity 重试,terminal 时停止投递并读 record; -6. 调用结果 unknown 时先 Record;完整 transition 出现该 command 后前进,否则返回 indeterminate。 +**TRN-RSM-4** payload 或顺序与 persisted spec 不一致为 conflict。 -**TRN-RSM-4** input payload/顺序差异触发 conflict。partial crash 后,已接受前缀和未接受后缀以同 command identity 收敛;进度由 snapshot 与 Record 共同验证,本地 cursor 不构成进度证据。 +**TRN-RSM-5** 投递完成后 Record,`MaterializeAll`,再 `driver.Drive`。Drive 返回后再 Record 与 `MaterializeAll`,并据此设置 Disposition。`NeedsRecovery` 为 true 时为 `ResumeWaitingForRecovery`;仅有 WaitingCalls 时为 `ResumeWaitingForResponse`。 -**TRN-RSM-5** inputs 后先 Record,并调用 `MaterializeAll(record)` 按 revision 递增补齐 revision 1 至 record head 的全部 transition;terminal record 随后进入 settlement。active Run 解析 persisted binding 并调用: +**TRN-RSM-6** EventSink 的 `text_delta` / `reasoning_delta` 为临时观察。Waiting 由 Application 提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 后再次 Resume。 -```go -driver.Drive(ctx, DriveRequest{Ref: ref, RunID: linkage.RunID}) -``` - -无论 driver 返回完成、没有可执行 effect、error,Coordinator 均再次 Record、调用 `MaterializeAll(record)` 补齐完整新前缀,并据 record snapshot/terminal fact 返回 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`。前两者不返回 terminal Result。snapshot 含 Waiting call 时 `Waiting` 列为 `WaitingCalls`。`ExecutionRecovery` 与 `NeedsRecovery(snapshot)` 相同:Model Executing,或 ToolStep 无 Pending 且仍有 Executing tool call。Loop 返回 `LoopWaiting` 且 `ExecutionRecovery` 为 true 时 Disposition 为 `ResumeWaitingForRecovery`,即使 snapshot 同时含 Waiting call。仅有 Waiting、无 Executing、Model 非 Executing 时 Disposition 为 `ResumeWaitingForResponse`。空 `Waiting` 且需要 recovery 时由 recovery authority 负责后续唤醒。 - -**TRN-RSM-6** driver 的 provisional stream、返回错误、网络响应丢失、context cancel 作为观察结果处理。driver 以 shared Runtime 的 Load/Commit 推进 MachineState;并发 Resume 依 Runtime command idempotency、revision、grant、record 一致性收敛。具体执行器可以使用 `agent/run/loop.Loop`。Loop 只解释可执行 effect(prepare、start model、start tools)。`Idle` 映射为 `LoopWaiting`;`ExecutionRecovery` 来自 `NeedsRecovery(snapshot)`,不携带 response request。Coordinator 始终以随后读取的 `RunRecord` 判断 Waiting call、execution recovery、terminal 与 settlement。Application 对 Runtime 提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 后再 Resume。 - -**TRN-STP-1** Stop 先解析 linkage、Load。terminal Run 进入 record/materialize/settlement;active Run 以稳定 domain-separated CancelRun CommandID 构造 `CancelRun{Reason:ReasonCancelled}`,并经 shared Runtime Commit。 +**TRN-STP-1** Stop 解析 linkage。terminal Run 走 Record、materialize、settlement。active Run 提交 `CancelRun{Reason:ReasonCancelled}`。 -**TRN-STP-2** Cancel command identity 使用 `twilight.turn/cancel-run/v1` domain,至少覆盖 SessionID、TurnID、RunID、固定 cancel reason;Stop retry 重用它。StopRequest.Reason 供 Application audit/presentation,Run 采用固定 cancellation reason。 +**TRN-STP-2** Cancel CommandID = `Digest("twilight/turn/cancel-run", SessionID, TurnID, RunID, ReasonCancelled)`。StopRequest.Reason 供审计。 -**TRN-STP-3** Stop Commit outcome unknown 时先 Record 查询完整 transition 是否含该 command;证明已应用后继续,未能证明时返回 indeterminate。CommitAccepted、CommitAlreadyApplied 或 Record 已证明应用时,Stop 重新 Record,调用 `MaterializeAll(record)` 补齐全部 transition,再从 exact terminal `RunEnded` 构造 ResultReference 并提交 settlement。StopResponse 在 settlement 成功或 already-applied 后返回 Result;indeterminate 路径保留 Session Turn 当前状态。 +**TRN-STP-3** Commit 结果未知时先 Record。已应用则 MaterializeAll,再按 `RunEnded` 写入 `twilight/turn/completed` 或 `twilight/turn/failed`。 -## 6. record、结果与 settlement +## 6. settlement -**TRN-SET-1** `RunRecord` 为 materialization、unknown resolution、recovery、settlement 的唯一一致 verified read。它含 detached 且相互一致的 Header、Snapshot、完整 TransitionRecord sequence;消费者验证 header、records、fold、snapshot,并拒绝拼接独立 reads。 +**TRN-SET-1** `RunRecord` 为 materialize、recovery、settlement 的一致读取。 -**TRN-SET-2** terminal 判断读取最后一个 `RunEnded` AgentEvent,并验证其 `End` 是唯一合法 terminal variant;Coordinator 从该 variant 派生 `RunStatus` 后构建 ResultReference。RunResult、snapshot revision、driver return 与其他 event 不参与该构建。terminal fact 缺失、多个、variant 非法或未处于末尾时触发 corruption。 +**TRN-SET-2** 终态取最后一个 `RunEnded`。 -**TRN-SET-3** settlement mapping 固定如下: +**TRN-SET-3** -| RunEnded variant(派生 status) | run_settled settlement | 同一 Session commit 的 chatlog event | +| RunEnded | EventType | Settlement | |---|---|---| -| `RunCompletedEnd` | `completed` | `TurnCompleted{TurnID}` | -| `RunFailedEnd` | `failed` | `TurnFailed{TurnID, FailureClass}` | -| `RunStoppedEnd` | `stopped` | `TurnFailed{TurnID, FailureClass:"stopped"}` | +| `RunCompletedEnd` | `twilight/turn/completed` | `completed` | +| `RunFailedEnd` | `twilight/turn/failed` | `failed` | +| `RunStoppedEnd` | `twilight/turn/failed` | `stopped`(`FailureClass:"stopped"`) | -RunFailed 的 FailureClass 取 terminal failure class;没有 failure 时采用稳定非空 class。`RunStopped` 映射 `TurnFailed{..., FailureClass:"stopped"}`。 +**TRN-SET-4** settlement 的 CommitID / EventID 由 ResultReference 派生。相同 reference 幂等。 -**TRN-SET-4** run_settled 与对应 TurnCompleted/TurnFailed 位于同一 semantic Session commit,CommitID/EventID 由 stable terminal ResultReference 派生。相同 reference retry 幂等;settlement/result reference 差异触发 conflict。 - -**TRN-SET-5** unknown external effect 的 persisted MachineState/terminal record 已包含 `ToolCallFailed{Outcome:Unknown, FailureEffectUnknown}` 加 terminal `RunEnded{End: RunFailedEnd{...}}`。Coordinator materialize unknown tool result 并 failed settle。 +**TRN-SET-5** Turn 尚未 `completed`/`failed` 时,`ToolCallFailed{Unknown}` 按 TRN-MAP-1 写成 `twilight/chatlog/tool_result`(status=`unknown`)。Turn 终态只由 `RunEnded` 按上表写入。 ## 7. materialization ```go type FactMapRequest struct { - TurnID chatlog.TurnID; RunID run.RunID; MapperVersion MapperVersion - Prefix []run.TransitionRecord // complete through target + TurnID TurnID; RunID run.RunID; MapperVersion MapperVersion + Prefix []run.TransitionRecord TargetRevision uint64; TargetTransitionDigest es.Digest } type FactMap struct { SourceFacts []SourceFactID; Events []extension.TypedEvent } type FactMapper interface { Map(FactMapRequest) (FactMap, error) } ``` -**TRN-MAT-1** `MaterializeAll(record)` 按 revision 1 至 `record.Snapshot.Revision` 递增枚举每条 transition,并为 revision `r` 构造一个 FactMapRequest。Prefix 从 revision 1 连续至 TargetRevision,且含 target 的完整 transition group;每条 record/event 均经验证。TargetRevision/TargetTransitionDigest 等于 prefix 尾。每次 Map 处理 TargetRevision 的完整 target transition;`Prefix[1..target]` 提供 pure context。FactMapRequest 覆盖完整 prefix 与 target;mapper 输入排除 partial transition、provisional delta、current binding、Session projection cache、hidden state。 +**TRN-MAT-1** `MaterializeAll` 按 revision 1 至 head 递增。每次 Map 的输入是截至 target 的完整 prefix。EventSink delta 留在观察面。 -**TRN-MAT-2** SourceFactID 唯一规则为: +**TRN-MAT-2** ```text -Digest("twilight.turn/source-fact/v1", - RunID, Transition.Revision, Transition.TransitionDigest, - AgentEvent.Index, AgentEvent.Type, AgentEvent.Digest) +SourceFactID = Digest("twilight/turn/source-fact", + RunID, Revision, TransitionDigest, Index, Type, EventDigest) ``` -同一 event 的 source ID 随 retry、Session head、mapper process 保持稳定。`FactMap.SourceFacts` 按 target transition event 顺序,精确列出实际产生本 batch `Events` 的 AgentEvents;每个输出 event 可追溯到其中 source fact。`Events` 为空时 `SourceFacts` 为空。后续 revision 仅处理其 target transition 的输出与 source fact。 - -**TRN-MAT-3** materialization CommitID 使用 `twilight.turn/materialize/v1` domain,覆盖 SessionID、TurnID、RunID、MapperVersion、TargetRevision、TargetTransitionDigest、ordered target-source SourceFactIDs。输出 EventID 使用 `twilight.turn/materialized-event/v1` domain,覆盖 CommitID、ordinal、event type、canonical payload digest;CAS rebase 后保持字节稳定。 +**TRN-MAT-3** materialization CommitID 的 domain 为 `twilight/turn/materialize`;输出 EventID 的 domain 为 `twilight/turn/materialized-event`。 -**TRN-MAT-4** mapper 为 pure deterministic total-prefix function:相同 FactMapRequest 产生同一 ordered FactMap,输入为请求中的持久化 facts。IO、clock、random、Application defaults、前次调用状态不参与 Map。MapperVersion 是协议输入;升级使用显式新 version,既有事实保持原 version。 +**TRN-MAT-4** Map 为确定性纯函数。MapperVersion 为协议输入。 -**TRN-MAT-5** Map 空 Events 时 SourceFacts 为空,该 target transition 以可重试 deterministic no-op 完成 coverage。非空 batch 先以 stable CommitID `LookupCommit`;找到逐字段相同 commit 即完成该 target transition,未找到则按当前 head append,head conflict 后重新 replay/fold,以同一完整 target transition Map,直至 append 或 conflict。`MaterializeAll` 仅在每个 revision 分别达到 matched commit、applied commit 或 empty-map coverage 后前进;空 map 保持无 commit。 +**TRN-MAT-5** 空 Events 时 SourceFacts 为空,该 revision coverage 完成。非空 batch 先 `LookupCommit`。 -**TRN-MAT-6** transactional outbox 服务于 Session adapter 投递优化。source coverage、EventID、CommitID、materialized state、settlement 由 Session facts 和 RunRecord 重建。 +**TRN-MAT-6** outbox 优化投递;coverage 以 Session facts 与 RunRecord 为准。 -### 7.1 v1 映射表 +### 7.1 v1 映射 -**TRN-MAP-1** v1 映射下表所列 AgentEvent;未列 AgentEvent 产生零个 chatlog event,未来扩展使用新 MapperVersion。`ItemID=DeriveItemID(TurnID,StepID,MapperVersion)`;UI delta 用于呈现。 +**TRN-MAP-1** 下表列出产生 chatlog events 的 AgentEvent。其余 AgentEvent 的 FactMap 为空。 -| AgentEvent | 必须产生的 chatlog facts | +| AgentEvent | chatlog event | |---|---| -| `ModelStepPrepared` | `ItemOpened`;sequence 依完整 Run prefix 的稳定 model-step 顺序 | -| `ModelStepCompleted` | 该 item 的 `ItemUpdated`、`ItemCompleted`,一个 RoleAssistant MessageCommitted,含 text/reasoning/reference 与全部 tool calls | -| `ToolCallCompleted` / `ToolCallAnswered` | 对应 CallID 的 RoleToolResult MessageCommitted,status=`success` | -| `ToolCallFailed`,Outcome=`Known` | 对应 CallID 的 RoleToolResult MessageCommitted,status=`error` | -| `ToolCallFailed`,Outcome=`Unknown` 或 failure class=`effect_unknown` | 对应 CallID 的 RoleToolResult MessageCommitted,status=`unknown` | -| terminal failed/stopped `RunEnded` | 该 Turn 每个尚未 completed item 的 `ItemFailed` | +| `ModelStepCompleted` | `twilight/chatlog/assistant` | +| `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` | + +`ModelStepPrepared` 期间 EventSink 可发送 `text_delta` / `reasoning_delta`。回合结束由 `twilight/turn/completed` 或 `twilight/turn/failed` 表达。 -**TRN-MAP-2** assistant Message.ItemIDs 仅指向同 Turn 已 completed 且 final digest 匹配 item;tool-call part 的 CallID/顺序与 ModelStepCompleted result 一致。tool-result message 遵守 chatlog 的既有 call、同 Turn、单 active result/replacement 规则。 +**TRN-MAP-2** `AssistantID = Digest("twilight/chatlog/assistant-id", TurnID, ModelStepID, MapperVersion)`。`ToolResultID = Digest("twilight/chatlog/tool-result-id", TurnID, CallID, MapperVersion)`。assistant 的 ToolCall 顺序与模型结果一致。tool_result 与同 Turn 的 call 配对。 -**TRN-MAP-3** `ToolCallFailed.Outcome=Known` 映射 error;`Outcome=Unknown`、`FailureEffectUnknown` 或持久事实无法区分的结果映射 unknown。mapper 以持久事实完成该判定。 +**TRN-MAP-3** Known 对应 `error`;Unknown 对应 `unknown`。 -**TRN-MAP-4** ItemUpdated/ItemCompleted 的 content、version、final digest 从完整 persisted model result canonical projection 导出;流式 text delta 可丢弃且不持久化为映射事实,replay 使用 canonical projection。 +**TRN-MAP-4** assistant 正文来自冻结的 model result。 ## 8. recovery -**TRN-REC-1** recovery 扫描 resolved stream 的 unsettled linkages,按稳定 `(SessionID,TurnID,RunID)` 顺序 Resume。实现验证 linkage、RunRecord、mapper input;header、transition、digest、fold、source ID、settlement 差异触发 fail loudly,并保留历史事实,不修补记录。 +**TRN-REC-1** 按 `(SessionID,TurnID,RunID)` 扫描 unsettled `twilight/turn/started` 并 Resume。 -**TRN-REC-2** 最少覆盖如下 crash/concurrency matrix: +**TRN-REC-2** -| 情形 | 必须恢复动作 | +| 情形 | 动作 | |---|---| -| Start 已提交、Run 缺失 | 以 persisted NewRun Create 后 Resume | -| Create retry/响应丢失 | 同 NewRun Create,Created=false 为同一 Run | -| initial inputs 仅前缀 | 逐项 Load、相同 derived command Commit,已应用跳过 | -| transition 已提交、materialization 缺失 | Record,按 revision 递增执行 MaterializeAll,以每个完整 target prefix Map/append | -| mapper 空 batch | 保持无 append;后续 Record 可再次 Map | -| materialization 响应丢失 | stable CommitID LookupCommit,匹配即成功 | -| Run terminal、settlement 缺失 | Record/materialize 后从 exact RunEnded 同 commit settle | -| model result unknown | 查询 `Runtime.Record` 的 MachineState/transition record;accepted result 未获证明时执行恢复,不派生猜测结果 | -| tool effect unknown | 查询 authority;已有 unknown terminal facts 时 materialize/failed settle,不重执行 effect | -| Record 与并发 Commit | 丢弃过时 record、重读;每次 Map 使用自洽完整 prefix | +| `started` 已提交、Run 缺失 | Create persisted NewRun 后 Resume | +| Create 响应丢失 | 同一 NewRun 再 Create | +| InitialInputs 仅前缀 | 按 derived command 继续 AcceptInput | +| transition 已提交、chatlog 落后 | MaterializeAll | +| Map 为空 | 该 revision coverage 完成 | +| materialize 响应丢失 | LookupCommit | +| Run 已终态、settlement 缺失 | 按 RunEnded 写 `twilight/turn/completed` 或 `twilight/turn/failed` | +| 模型结果未知 | 以 Record 为准 | +| 工具效果未知 | 对该 Executing call materialize status=`unknown`;Run 保持 Active,同一 Turn、同一 RunID 继续。lease 过期后 `RecoverExpired` 提交该 call 的 Unknown | +| Record 与并发 Commit | 重读 Record | -**TRN-REC-3** Commit、Create、append、query 的 unknown outcome 一律先查 authority:Run 使用 Record,Session 使用 LookupCommit 与 verified replay。terminal abort evidence 集合为 verified terminal RunRecord、匹配 ResultReference 的 `RunEnded` 与同 commit settlement;查询失败、瞬时 NotFound、本地超时保持 indeterminate。indeterminate 保留 effect、replacement、Turn terminalization 的后续决策。 +**TRN-REC-3** Commit、Create、append 结果未知时先查 Runtime.Record 或 Session LookupCommit。 -**TRN-REC-4** binding 缺失返回可恢复 `binding_unavailable` 并保留 linkage;registry 仅解析 persisted binding,已 terminal Run 无需 binding,继续 materialize/settle。 +**TRN-REC-4** binding 缺失返回 `binding_unavailable`。已终态 Run 继续 materialize 与 settle。 -**TRN-REC-5** recovery 在 start barrier 后遵循 MachineState、grant/recovery 规则、完整 transition record 所允许的 command 推进 Run。Coordinator 重驱动、读取、投影,并以 record 重建执行历史;重复越过 start barrier 的 model/tool effect 触发协议拒绝。 +**TRN-REC-5** 执行推进遵循 MachineState 与 grant。 ## 9. conformance -实现按下列条款映射验证: - -- **TRN-SCP-1 至 TRN-SCP-6**:一 Turn/primary Run、retry/replacement/subagent 边界、Coordinator 重建与 MachineState authority、SemanticAppender、Application policy ownership、immutable binding/no defaults; -- **TRN-ID-1 至 TRN-ID-5、TRN-EVT-1 至 TRN-EVT-3**:完整 types、PlanDigest、start-operation domain、stable EventID/CommitID、最小 events、unique linkage; -- **TRN-API-1 至 TRN-API-5**:Session/Runtime 重建、shared Runtime driver、四方法 contract、Create/missing errors、Service/Start/Resume/Stop DTO; -- **TRN-STR-1 至 TRN-STR-6**:intent-before-Create、causation、InputIDs/InitialInputs exact group、合法 user message、原子严格顺序、retry; -- **TRN-RSM-1 至 TRN-RSM-6、TRN-STP-1 至 TRN-STP-3**:unique linkage、Create retry、partial inputs、DriveRequest、record-after-drive、stable CancelRun、unknown query-first、Stop 的 MaterializeAll 与 settlement、Turn settlement 时机; -- **TRN-SET-1 至 TRN-SET-5**:verified RunRecord、terminal RunEnded ResultReference、completed/failed/stopped、同 commit settlement、effect unknown failed history; -- **TRN-MAT-1 至 TRN-MAT-6、TRN-MAP-1 至 TRN-MAP-4**:MaterializeAll 的 revision 1..head 完整 coverage、complete prefix 与 target-only FactMap source coverage、SourceFactID domain、materialization identity、pure versioned mapper、empty Events/SourceFacts no-op、LookupCommit/CAS rebase、outbox、v1 table; -- **TRN-REC-1 至 TRN-REC-5**:crash matrix、unknown query-first、missing binding、concurrent Record、effect 与执行历史恢复。 - -第一版 conformance 对 memory 与 durable Runtime adapter 使用同一断言;Application、provider、transport 行为以本协议为准。 +- **TRN-SCP-1 至 TRN-SCP-6**:一 Turn 一 primary Run、Source `twilight`、ModuleID `turn`; +- **TRN-ID-1 至 TRN-EVT-3**:所列 EventType 与 `twilight/turn/plan`; +- **TRN-API-1 至 TRN-API-5**:StartRequest 含 InputIDs 与 InitialInputs; +- **TRN-STR-1 至 TRN-STR-6**:Start group 为 `twilight/turn/started` 加 `twilight/chatlog/input_delivered*`; +- **TRN-RSM-1 至 TRN-STP-3**:unsettled `started`、Drive、`twilight/turn/cancel-run`; +- **TRN-SET-1 至 TRN-SET-5**:settlement 为 `twilight/turn/completed` 或 `twilight/turn/failed`; +- **TRN-MAT-1 至 TRN-MAP-4**:v1 表; +- **TRN-REC-1 至 TRN-REC-5**:上表恢复情形。 From 29257cb37401b32447e5e6c5eff0a1a6a9acdbf7 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 2 Sep 2026 15:23:52 +0800 Subject: [PATCH 51/81] Split the Loop interpreter across focused files Keep one package. Loop construction and Run stay in loop.go; start caches, model execution, tool settlement, and EventSink live beside it. --- agent/run/loop/cache.go | 184 +++++++++ agent/run/loop/events.go | 70 ++++ agent/run/loop/loop.go | 797 --------------------------------------- agent/run/loop/model.go | 303 +++++++++++++++ agent/run/loop/tool.go | 270 +++++++++++++ 5 files changed, 827 insertions(+), 797 deletions(-) create mode 100644 agent/run/loop/cache.go create mode 100644 agent/run/loop/events.go create mode 100644 agent/run/loop/model.go create mode 100644 agent/run/loop/tool.go diff --git a/agent/run/loop/cache.go b/agent/run/loop/cache.go new file mode 100644 index 0000000..b52fb50 --- /dev/null +++ b/agent/run/loop/cache.go @@ -0,0 +1,184 @@ +package loop + +import ( + "context" + + run "github.com/memohai/twilight/agent/run" +) + +type startKey struct { + runID run.RunID + stepID run.StepID + callID run.CallID +} + +type startAttempt struct { + commandID run.CommandID + claim run.ExecutionClaim +} + +type settlementAttempt struct { + commandID run.CommandID + base uint64 + grant run.ExecutionGrant + command run.AgentCommand +} + +func (l *Loop) startFor(key startKey) startAttempt { + l.startsMu.Lock() + defer l.startsMu.Unlock() + if attempt, ok := l.starts[key]; ok { + return attempt + } + attempt := startAttempt{commandID: freshCommandID(), claim: freshExecutionClaim()} + l.starts[key] = attempt + return attempt +} + +func (l *Loop) forgetStart(key startKey) { + l.startsMu.Lock() + delete(l.starts, key) + l.startsMu.Unlock() +} + +func (l *Loop) lookupStart(key startKey) (startAttempt, bool) { + l.startsMu.Lock() + defer l.startsMu.Unlock() + attempt, ok := l.starts[key] + return attempt, ok +} + +func (l *Loop) settlementFor(key startKey, commandID run.CommandID, base uint64, grant run.ExecutionGrant, command run.AgentCommand) settlementAttempt { + l.settlementsMu.Lock() + defer l.settlementsMu.Unlock() + if attempt, ok := l.settlements[key]; ok { + return attempt + } + attempt := settlementAttempt{commandID: commandID, base: base, grant: grant, command: command} + l.settlements[key] = attempt + return attempt +} + +func (l *Loop) lookupSettlement(key startKey) (settlementAttempt, bool) { + l.settlementsMu.Lock() + defer l.settlementsMu.Unlock() + attempt, ok := l.settlements[key] + return attempt, ok +} + +func (l *Loop) forgetSettlement(key startKey) { + l.settlementsMu.Lock() + delete(l.settlements, key) + l.settlementsMu.Unlock() +} + +func (l *Loop) forgetRunCaches(runID run.RunID) { + l.startsMu.Lock() + for key := range l.starts { + if key.runID == runID { + delete(l.starts, key) + } + } + l.startsMu.Unlock() + l.settlementsMu.Lock() + for key := range l.settlements { + if key.runID == runID { + delete(l.settlements, key) + } + } + l.settlementsMu.Unlock() +} + +// resumeSettlement replays a result whose first commit may have succeeded +// while its response was lost. The same command identity makes the retry +// idempotent and avoids re-running the external effect. +func (l *Loop) resumeSettlement(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot) (bool, error) { + runID := snapshot.State.RunID + l.settlementsMu.Lock() + keys := make([]startKey, 0) + for key := range l.settlements { + if key.runID == runID { + keys = append(keys, key) + } + } + l.settlementsMu.Unlock() + proto, err := snapshot.Protocol() + if err != nil { + return false, err + } + for _, key := range keys { + attempt, ok := l.lookupSettlement(key) + if !ok { + continue + } + if !settlementTargetActive(snapshot.State, key) { + l.forgetSettlement(key) + l.forgetStart(key) + continue + } + res, err := l.commit(context.WithoutCancel(ctx), runtime, runID, attempt.commandID, attempt.base, attempt.grant, attempt.command, proto) + if err != nil { + if retriable(err) { + l.forgetSettlement(key) + l.forgetStart(key) + return true, nil + } + return false, err + } + l.forgetSettlement(key) + l.forgetStart(key) + l.emitCommitted(ctx, events, runID, res.Events) + return true, nil + } + return false, nil +} + +func settlementTargetActive(state run.MachineState, key startKey) bool { + switch current := state.Current.(type) { + case run.ModelStep: + return key.callID == "" && current.RefValue.ID == key.stepID && current.Status == run.ModelExecuting + case run.ToolStep: + if current.RefValue.ID != key.stepID { + return false + } + for _, call := range current.Calls { + if call.CallID == key.callID { + return call.Status == run.ToolExecuting + } + } + } + return false +} + +// resumeCachedStart re-enters an accepted start after a Loop.Run returned +// before receiving its grant. The authority state is Executing, so Next alone +// would otherwise wait for recovery instead of replaying the known start. +func (l *Loop) resumeCachedStart(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot) (bool, error) { + runID := snapshot.State.RunID + switch current := snapshot.State.Current.(type) { + case run.ModelStep: + if current.Status != run.ModelExecuting { + return false, nil + } + if _, ok := l.lookupStart(startKey{runID: runID, stepID: current.RefValue.ID}); !ok { + return false, nil + } + return true, l.runModelStep(ctx, runtime, events, snapshot, current.RefValue.ID) + case run.ToolStep: + var ids []run.CallID + for _, call := range current.Calls { + if call.Status != run.ToolExecuting { + continue + } + if _, ok := l.lookupStart(startKey{runID: runID, stepID: current.RefValue.ID, callID: call.CallID}); ok { + ids = append(ids, call.CallID) + } + } + if len(ids) == 0 { + return false, nil + } + return true, l.runToolCalls(ctx, runtime, events, snapshot, run.StartToolCalls{StepID: current.RefValue.ID, CallIDs: ids}) + default: + return false, nil + } +} diff --git a/agent/run/loop/events.go b/agent/run/loop/events.go new file mode 100644 index 0000000..a39414e --- /dev/null +++ b/agent/run/loop/events.go @@ -0,0 +1,70 @@ +package loop + +import ( + "context" + "encoding/json" + "sync" + + run "github.com/memohai/twilight/agent/run" +) + +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, runID run.RunID, committed []run.AgentEvent) { + if events == nil { + return + } + for i := range committed { + e := committed[i] + _ = events.Emit(ctx, Event{ + RunID: runID, + Kind: EventAgentCommitted, + Durability: EventCommitted, + Canonical: &e, + }) + } +} + +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/loop.go b/agent/run/loop/loop.go index 99b4cd6..a46c83b 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -4,14 +4,11 @@ import ( "context" "crypto/rand" "encoding/hex" - "encoding/json" "errors" "fmt" "sync" run "github.com/memohai/twilight/agent/run" - - "github.com/memohai/twilight/sdk" ) // Loop is the in-process interpreter of one Run (RUN-LOP-2). It holds no @@ -38,24 +35,6 @@ type Loop struct { eventsMu sync.Mutex } -type startKey struct { - runID run.RunID - stepID run.StepID - callID run.CallID -} - -type startAttempt struct { - commandID run.CommandID - claim run.ExecutionClaim -} - -type settlementAttempt struct { - commandID run.CommandID - base uint64 - grant run.ExecutionGrant - command run.AgentCommand -} - // 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 { @@ -84,30 +63,6 @@ func (l *Loop) toolScheduling() run.ToolScheduling { return run.ToolScheduling{Mode: run.ToolScheduleMode(l.Execution.ToolExecution), MaxParallel: l.Execution.MaxParallel} } -func (l *Loop) startFor(key startKey) startAttempt { - l.startsMu.Lock() - defer l.startsMu.Unlock() - if attempt, ok := l.starts[key]; ok { - return attempt - } - attempt := startAttempt{commandID: freshCommandID(), claim: freshExecutionClaim()} - l.starts[key] = attempt - return attempt -} - -func (l *Loop) forgetStart(key startKey) { - l.startsMu.Lock() - delete(l.starts, key) - l.startsMu.Unlock() -} - -func (l *Loop) lookupStart(key startKey) (startAttempt, bool) { - l.startsMu.Lock() - defer l.startsMu.Unlock() - attempt, ok := l.starts[key] - return attempt, ok -} - func (l *Loop) acquireRun(runID run.RunID) error { l.runsMu.Lock() defer l.runsMu.Unlock() @@ -124,155 +79,6 @@ func (l *Loop) releaseRun(runID run.RunID) { l.runsMu.Unlock() } -func (l *Loop) settlementFor(key startKey, commandID run.CommandID, base uint64, grant run.ExecutionGrant, command run.AgentCommand) settlementAttempt { - l.settlementsMu.Lock() - defer l.settlementsMu.Unlock() - if attempt, ok := l.settlements[key]; ok { - return attempt - } - attempt := settlementAttempt{commandID: commandID, base: base, grant: grant, command: command} - l.settlements[key] = attempt - return attempt -} - -func (l *Loop) lookupSettlement(key startKey) (settlementAttempt, bool) { - l.settlementsMu.Lock() - defer l.settlementsMu.Unlock() - attempt, ok := l.settlements[key] - return attempt, ok -} - -func (l *Loop) forgetSettlement(key startKey) { - l.settlementsMu.Lock() - delete(l.settlements, key) - l.settlementsMu.Unlock() -} - -func (l *Loop) forgetRunCaches(runID run.RunID) { - l.startsMu.Lock() - for key := range l.starts { - if key.runID == runID { - delete(l.starts, key) - } - } - l.startsMu.Unlock() - l.settlementsMu.Lock() - for key := range l.settlements { - if key.runID == runID { - delete(l.settlements, key) - } - } - l.settlementsMu.Unlock() -} - -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) -} - -// resumeSettlement replays a result whose first commit may have succeeded -// while its response was lost. The same command identity makes the retry -// idempotent and avoids re-running the external effect. -func (l *Loop) resumeSettlement(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot) (bool, error) { - runID := snapshot.State.RunID - l.settlementsMu.Lock() - keys := make([]startKey, 0) - for key := range l.settlements { - if key.runID == runID { - keys = append(keys, key) - } - } - l.settlementsMu.Unlock() - proto, err := snapshot.Protocol() - if err != nil { - return false, err - } - for _, key := range keys { - attempt, ok := l.lookupSettlement(key) - if !ok { - continue - } - if !settlementTargetActive(snapshot.State, key) { - l.forgetSettlement(key) - l.forgetStart(key) - continue - } - res, err := l.commit(context.WithoutCancel(ctx), runtime, runID, attempt.commandID, attempt.base, attempt.grant, attempt.command, proto) - if err != nil { - if retriable(err) { - l.forgetSettlement(key) - l.forgetStart(key) - return true, nil - } - return false, err - } - l.forgetSettlement(key) - l.forgetStart(key) - l.emitCommitted(ctx, events, runID, res.Events) - return true, nil - } - return false, nil -} - -func settlementTargetActive(state run.MachineState, key startKey) bool { - switch current := state.Current.(type) { - case run.ModelStep: - return key.callID == "" && current.RefValue.ID == key.stepID && current.Status == run.ModelExecuting - case run.ToolStep: - if current.RefValue.ID != key.stepID { - return false - } - for _, call := range current.Calls { - if call.CallID == key.callID { - return call.Status == run.ToolExecuting - } - } - } - return false -} - -// resumeCachedStart re-enters an accepted start after a Loop.Run returned -// before receiving its grant. The authority state is Executing, so Next alone -// would otherwise wait for recovery instead of replaying the known start. -func (l *Loop) resumeCachedStart(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot) (bool, error) { - runID := snapshot.State.RunID - switch current := snapshot.State.Current.(type) { - case run.ModelStep: - if current.Status != run.ModelExecuting { - return false, nil - } - if _, ok := l.lookupStart(startKey{runID: runID, stepID: current.RefValue.ID}); !ok { - return false, nil - } - return true, l.runModelStep(ctx, runtime, events, snapshot, current.RefValue.ID) - case run.ToolStep: - var ids []run.CallID - for _, call := range current.Calls { - if call.Status != run.ToolExecuting { - continue - } - if _, ok := l.lookupStart(startKey{runID: runID, stepID: current.RefValue.ID, callID: call.CallID}); ok { - ids = append(ids, call.CallID) - } - } - if len(ids) == 0 { - return false, nil - } - return true, l.runToolCalls(ctx, runtime, events, snapshot, run.StartToolCalls{StepID: current.RefValue.ID, CallIDs: ids}) - default: - return false, nil - } -} - // 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 @@ -403,606 +209,3 @@ func freshExecutionClaim() run.ExecutionClaim { } return run.ExecutionClaim(hex.EncodeToString(b[:])) } - -func (l *Loop) emitCommitted(ctx context.Context, events EventSink, runID run.RunID, committed []run.AgentEvent) { - if events == nil { - return - } - for i := range committed { - e := committed[i] - _ = events.Emit(ctx, Event{ - RunID: runID, - Kind: EventAgentCommitted, - Durability: EventCommitted, - Canonical: &e, - }) - } -} - -// --- NeedModelRequest --- - -func (l *Loop) planAndPrepare(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, hint run.PlanningHint) error { - 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.Revision) - stepID := run.DeriveModelStepID(snapshot.State.RunID, cmdID, binding) - res, err := l.commit(ctx, runtime, snapshot.State.RunID, cmdID, snapshot.Revision, "", 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, 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.Revision == snapshot.Revision { - 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 --- - -func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, stepID run.StepID) error { - runID := snapshot.State.RunID - proto, err := snapshot.Protocol() - if err != nil { - return err - } - key := startKey{runID: runID, stepID: stepID} - attempt := l.startFor(key) - start, err := l.commit(ctx, runtime, runID, attempt.commandID, snapshot.Revision, "", run.StartModelExecution{StepID: stepID, Claim: attempt.claim}, proto) - if err != nil { - if retriable(err) { - l.forgetStart(key) - return nil - } - // The start may have committed while its response was lost. Keep the - // command identity so a later Run can replay it and recover the grant. - return err - } - if start.Status == run.CommitAlreadyApplied && start.Grant == "" { - l.forgetStart(key) - return nil // another attempt owns it; reload - } - if start.Grant == "" { - return errors.New("agent: loop: start model returned no execution grant") - } - l.emitCommitted(ctx, events, runID, start.Events) - - modelStep, ok := start.Snapshot.State.Current.(run.ModelStep) - if !ok || modelStep.RefValue.ID != stepID || modelStep.Status != run.ModelExecuting { - // An exact start replay can race with another owner that already - // settled the step. The Runtime returns the original grant for replay, - // but executing again would duplicate the provider effect; reload and - // let the next machine state decide what to do. - if start.Status == run.CommitAlreadyApplied { - l.forgetStart(key) - return nil - } - return fmt.Errorf("agent: loop: started step %q is not current", stepID) - } - - var completion run.AgentCommand - var catalogErr error - invoker, resolveErr := l.Models.Resolve(modelStep.Model) - switch { - case resolveErr != nil: - catalogErr = resolveErr - completion = run.RecoverModelExecution{StepID: stepID, Claim: attempt.claim} - case invoker == nil: - catalogErr = errors.New("model catalog returned a nil invoker") - completion = run.RecoverModelExecution{StepID: stepID, Claim: attempt.claim} - default: - // Model workers derive from the outer ctx: cancelling a model call is - // safe, the frozen request retries after recovery (RUN-LOP-3). - sdkRequest, err := modelStep.Request.SDK() - if err != nil { - completion = run.SubmitModelFailure{StepID: stepID, Failure: run.StepFailure{Class: run.FailureProvider, Message: err.Error()}} - } else { - result, invokeErr := l.invokeModel(ctx, invoker, &sdkRequest, runID, stepID, events) - switch { - case invokeErr != nil && ctx.Err() != nil: - completion = run.RecoverModelExecution{StepID: stepID, Claim: attempt.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()} - } - } - } - } - - completionID := freshCommandID() - if _, recovering := completion.(run.RecoverModelExecution); recovering { - completionID = run.DeriveModelRecoveryCommandID(runID, stepID, attempt.claim) - } - settlement := l.settlementFor(key, completionID, start.Snapshot.Revision, start.Grant, completion) - settlementCtx := context.WithoutCancel(ctx) - res, err := l.commit(settlementCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command, proto) - if err != nil { - if retriable(err) { - l.forgetSettlement(key) - l.forgetStart(key) - return nil - } - return err - } - l.forgetSettlement(key) - l.forgetStart(key) - l.emitCommitted(ctx, events, runID, res.Events) - if catalogErr != nil { - return fmt.Errorf("agent: loop: model catalog: %w", catalogErr) - } - return 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.Definition.Name] = s - } - seen := make(map[string]bool, len(result.ToolCalls)) - bindings := make([]run.ToolCallBinding, len(result.ToolCalls)) - for i, tc := range result.ToolCalls { - if tc.ToolCallID == "" { - return nil, fmt.Errorf("tool call %d has an empty id", i) - } - if seen[tc.ToolCallID] { - return nil, fmt.Errorf("duplicate tool call id %q", tc.ToolCallID) - } - seen[tc.ToolCallID] = true - args, err := run.FreezeToolCallInput(tc.Input) - if err != nil { - return nil, fmt.Errorf("tool call %q input: %w", tc.ToolCallID, err) - } - b := run.ToolCallBinding{ - CallID: run.CallID(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 -} - -// --- StartToolCalls --- - -type startedWorker struct { - call run.ToolCallState - grant run.ExecutionGrant - base uint64 - tool ExecutableTool - key startKey -} - -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) runToolCalls(ctx context.Context, runtime run.Runtime, 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] - key := startKey{runID: runID, stepID: eff.StepID, callID: callID} - if call.Status != run.ToolPending { - if call.Status == run.ToolExecuting { - if _, ok := l.lookupStart(key); !ok { - continue - } - } else { - continue - } - } - - tool, resolveErr := l.Tools.Resolve(call.ToolRef) - var known *run.ToolFailure - switch { - case resolveErr != nil: - known = &run.ToolFailure{Class: run.FailureToolLookup, Message: resolveErr.Error()} - default: - if tool == nil { - known = &run.ToolFailure{Class: run.FailureToolLookup, Message: "tool catalog returned a nil tool"} - break - } - toolDef, freezeErr := run.FreezeToolDefinition(tool.Definition()) - if freezeErr != nil { - known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: freezeErr.Error()} - break - } - defDigest, digestErr := proto.DigestToolDefinition(toolDef) - if digestErr != nil { - known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: digestErr.Error()} - break - } - switch { - case tool.Ref() != call.ToolRef || defDigest != call.DefinitionDigest: - known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: "tool definition digest mismatch"} - case tool.ResponsePolicy() != call.Policy: - known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: "response policy mismatch"} - default: - if argErr := tool.ValidateArguments(call.Arguments); argErr != nil { - known = &run.ToolFailure{Class: run.FailureInvalidArguments, Message: argErr.Error()} - } - } - } - if known != nil { - // Known failure of a Pending call: no start barrier, no tool call. - res, err := l.commit(ctx, runtime, runID, freshCommandID(), snapshot.Revision, "", - 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, runID, res.Events) - continue - } - - attempt := l.startFor(key) - start, err := l.commit(ctx, runtime, runID, attempt.commandID, snapshot.Revision, "", - run.StartToolCall{StepID: eff.StepID, CallID: callID, Claim: attempt.claim}, proto) - if err != nil { - settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, proto) - if retriable(err) { - // A sentinel rejection proves this start did not acquire the - // call. Drop the local claim so a later snapshot can create a - // fresh start attempt or observe the other owner. - l.forgetStart(key) - return settleErr - } - return err - } - if start.Status == run.CommitAlreadyApplied && start.Grant == "" { - l.forgetStart(key) - continue // another attempt owns this call - } - if start.Grant == "" { - l.forgetStart(key) - return errors.New("agent: loop: start tool returned no execution grant") - } - if startedCall, ok := toolCallFromSnapshot(start.Snapshot.State, eff.StepID, callID); !ok || startedCall.Status != run.ToolExecuting { - // A replay may arrive after another worker has settled this call. - // Keep the original grant in the Runtime's replay record, but never - // invoke an effect for a call that is no longer Executing. - l.forgetStart(key) - continue - } - l.emitCommitted(ctx, events, runID, start.Events) - if events != nil { - _ = events.Emit(ctx, Event{RunID: runID, StepID: eff.StepID, CallID: callID, - Kind: EventToolStarted, Durability: EventCommitted}) - } - started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Revision, tool: tool, key: key}) - } - - 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 run.Runtime, 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 with the worker's own grant on its 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. - settlement := l.settlementFor(w.key, freshCommandID(), w.base, w.grant, cmd) - res, err := l.commit(controlCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command, proto) - switch { - case err == nil: - l.forgetSettlement(w.key) - l.forgetStart(w.key) - l.emitCommitted(ctx, events, runID, res.Events) - if events != nil { - _ = events.Emit(ctx, Event{RunID: runID, StepID: stepID, CallID: w.call.CallID, - Kind: EventToolCompleted, Durability: EventCommitted}) - } - case retriable(err): - l.forgetSettlement(w.key) - l.forgetStart(w.key) - // Terminal/stale: the authority already settled this call or - // the run; the result is intentionally dropped. - default: - if firstErr == nil { - firstErr = fmt.Errorf("agent: loop: settling call %q: %w", w.call.CallID, err) - } - } - }(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) -} - -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/model.go b/agent/run/loop/model.go new file mode 100644 index 0000000..4783f9b --- /dev/null +++ b/agent/run/loop/model.go @@ -0,0 +1,303 @@ +package loop + +import ( + "context" + "errors" + "fmt" + + run "github.com/memohai/twilight/agent/run" + + "github.com/memohai/twilight/sdk" +) + +func (l *Loop) planAndPrepare(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, hint run.PlanningHint) error { + 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.Revision) + stepID := run.DeriveModelStepID(snapshot.State.RunID, cmdID, binding) + res, err := l.commit(ctx, runtime, snapshot.State.RunID, cmdID, snapshot.Revision, "", 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, 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.Revision == snapshot.Revision { + 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 --- + +func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, stepID run.StepID) error { + runID := snapshot.State.RunID + proto, err := snapshot.Protocol() + if err != nil { + return err + } + key := startKey{runID: runID, stepID: stepID} + attempt := l.startFor(key) + start, err := l.commit(ctx, runtime, runID, attempt.commandID, snapshot.Revision, "", run.StartModelExecution{StepID: stepID, Claim: attempt.claim}, proto) + if err != nil { + if retriable(err) { + l.forgetStart(key) + return nil + } + // The start may have committed while its response was lost. Keep the + // command identity so a later Run can replay it and recover the grant. + return err + } + if start.Status == run.CommitAlreadyApplied && start.Grant == "" { + l.forgetStart(key) + return nil // another attempt owns it; reload + } + if start.Grant == "" { + return errors.New("agent: loop: start model returned no execution grant") + } + l.emitCommitted(ctx, events, runID, start.Events) + + modelStep, ok := start.Snapshot.State.Current.(run.ModelStep) + if !ok || modelStep.RefValue.ID != stepID || modelStep.Status != run.ModelExecuting { + // An exact start replay can race with another owner that already + // settled the step. The Runtime returns the original grant for replay, + // but executing again would duplicate the provider effect; reload and + // let the next machine state decide what to do. + if start.Status == run.CommitAlreadyApplied { + l.forgetStart(key) + return nil + } + return fmt.Errorf("agent: loop: started step %q is not current", stepID) + } + + var completion run.AgentCommand + var catalogErr error + invoker, resolveErr := l.Models.Resolve(modelStep.Model) + switch { + case resolveErr != nil: + catalogErr = resolveErr + completion = run.RecoverModelExecution{StepID: stepID, Claim: attempt.claim} + case invoker == nil: + catalogErr = errors.New("model catalog returned a nil invoker") + completion = run.RecoverModelExecution{StepID: stepID, Claim: attempt.claim} + default: + // Model workers derive from the outer ctx: cancelling a model call is + // safe, the frozen request retries after recovery (RUN-LOP-3). + sdkRequest, err := modelStep.Request.SDK() + if err != nil { + completion = run.SubmitModelFailure{StepID: stepID, Failure: run.StepFailure{Class: run.FailureProvider, Message: err.Error()}} + } else { + result, invokeErr := l.invokeModel(ctx, invoker, &sdkRequest, runID, stepID, events) + switch { + case invokeErr != nil && ctx.Err() != nil: + completion = run.RecoverModelExecution{StepID: stepID, Claim: attempt.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()} + } + } + } + } + + completionID := freshCommandID() + if _, recovering := completion.(run.RecoverModelExecution); recovering { + completionID = run.DeriveModelRecoveryCommandID(runID, stepID, attempt.claim) + } + settlement := l.settlementFor(key, completionID, start.Snapshot.Revision, start.Grant, completion) + settlementCtx := context.WithoutCancel(ctx) + res, err := l.commit(settlementCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command, proto) + if err != nil { + if retriable(err) { + l.forgetSettlement(key) + l.forgetStart(key) + return nil + } + return err + } + l.forgetSettlement(key) + l.forgetStart(key) + l.emitCommitted(ctx, events, runID, res.Events) + if catalogErr != nil { + return fmt.Errorf("agent: loop: model catalog: %w", catalogErr) + } + return 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.Definition.Name] = s + } + seen := make(map[string]bool, len(result.ToolCalls)) + bindings := make([]run.ToolCallBinding, len(result.ToolCalls)) + for i, tc := range result.ToolCalls { + if tc.ToolCallID == "" { + return nil, fmt.Errorf("tool call %d has an empty id", i) + } + if seen[tc.ToolCallID] { + return nil, fmt.Errorf("duplicate tool call id %q", tc.ToolCallID) + } + seen[tc.ToolCallID] = true + args, err := run.FreezeToolCallInput(tc.Input) + if err != nil { + return nil, fmt.Errorf("tool call %q input: %w", tc.ToolCallID, err) + } + b := run.ToolCallBinding{ + CallID: run.CallID(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/tool.go b/agent/run/loop/tool.go new file mode 100644 index 0000000..17bd217 --- /dev/null +++ b/agent/run/loop/tool.go @@ -0,0 +1,270 @@ +package loop + +import ( + "context" + "errors" + "fmt" + "sync" + + run "github.com/memohai/twilight/agent/run" +) + +type startedWorker struct { + call run.ToolCallState + grant run.ExecutionGrant + base uint64 + tool ExecutableTool + key startKey +} + +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) runToolCalls(ctx context.Context, runtime run.Runtime, 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] + key := startKey{runID: runID, stepID: eff.StepID, callID: callID} + if call.Status != run.ToolPending { + if call.Status == run.ToolExecuting { + if _, ok := l.lookupStart(key); !ok { + continue + } + } else { + continue + } + } + + tool, resolveErr := l.Tools.Resolve(call.ToolRef) + var known *run.ToolFailure + switch { + case resolveErr != nil: + known = &run.ToolFailure{Class: run.FailureToolLookup, Message: resolveErr.Error()} + default: + if tool == nil { + known = &run.ToolFailure{Class: run.FailureToolLookup, Message: "tool catalog returned a nil tool"} + break + } + toolDef, freezeErr := run.FreezeToolDefinition(tool.Definition()) + if freezeErr != nil { + known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: freezeErr.Error()} + break + } + defDigest, digestErr := proto.DigestToolDefinition(toolDef) + if digestErr != nil { + known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: digestErr.Error()} + break + } + switch { + case tool.Ref() != call.ToolRef || defDigest != call.DefinitionDigest: + known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: "tool definition digest mismatch"} + case tool.ResponsePolicy() != call.Policy: + known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: "response policy mismatch"} + default: + if argErr := tool.ValidateArguments(call.Arguments); argErr != nil { + known = &run.ToolFailure{Class: run.FailureInvalidArguments, Message: argErr.Error()} + } + } + } + if known != nil { + // Known failure of a Pending call: no start barrier, no tool call. + res, err := l.commit(ctx, runtime, runID, freshCommandID(), snapshot.Revision, "", + 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, runID, res.Events) + continue + } + + attempt := l.startFor(key) + start, err := l.commit(ctx, runtime, runID, attempt.commandID, snapshot.Revision, "", + run.StartToolCall{StepID: eff.StepID, CallID: callID, Claim: attempt.claim}, proto) + if err != nil { + settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, proto) + if retriable(err) { + // A sentinel rejection proves this start did not acquire the + // call. Drop the local claim so a later snapshot can create a + // fresh start attempt or observe the other owner. + l.forgetStart(key) + return settleErr + } + return err + } + if start.Status == run.CommitAlreadyApplied && start.Grant == "" { + l.forgetStart(key) + continue // another attempt owns this call + } + if start.Grant == "" { + l.forgetStart(key) + return errors.New("agent: loop: start tool returned no execution grant") + } + if startedCall, ok := toolCallFromSnapshot(start.Snapshot.State, eff.StepID, callID); !ok || startedCall.Status != run.ToolExecuting { + // A replay may arrive after another worker has settled this call. + // Keep the original grant in the Runtime's replay record, but never + // invoke an effect for a call that is no longer Executing. + l.forgetStart(key) + continue + } + l.emitCommitted(ctx, events, runID, start.Events) + if events != nil { + _ = events.Emit(ctx, Event{RunID: runID, StepID: eff.StepID, CallID: callID, + Kind: EventToolStarted, Durability: EventCommitted}) + } + started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Revision, tool: tool, key: key}) + } + + 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 run.Runtime, 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 with the worker's own grant on its 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. + settlement := l.settlementFor(w.key, freshCommandID(), w.base, w.grant, cmd) + res, err := l.commit(controlCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command, proto) + switch { + case err == nil: + l.forgetSettlement(w.key) + l.forgetStart(w.key) + l.emitCommitted(ctx, events, runID, res.Events) + if events != nil { + _ = events.Emit(ctx, Event{RunID: runID, StepID: stepID, CallID: w.call.CallID, + Kind: EventToolCompleted, Durability: EventCommitted}) + } + case retriable(err): + l.forgetSettlement(w.key) + l.forgetStart(w.key) + // Terminal/stale: the authority already settled this call or + // the run; the result is intentionally dropped. + default: + if firstErr == nil { + firstErr = fmt.Errorf("agent: loop: settling call %q: %w", w.call.CallID, err) + } + } + }(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) +} From 5aa7ffa1f74df613b598822544059904d8e6710a Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 2 Sep 2026 16:15:45 +0800 Subject: [PATCH 52/81] Align Loop recovery and Run specs with the review Keep empty ToolExecution unset until freeze. Resume executing tools without grantless Known failures. Export RecoverExpired, bind grantless model recovery to the lease claim, and FoldRun through Rebuild. --- agent/run/clone.go | 4 +- agent/run/commit.go | 14 ++-- agent/run/decide.go | 10 +-- agent/run/evolve.go | 4 +- agent/run/fact.go | 3 +- agent/run/loop/contract.go | 3 +- agent/run/loop/loop.go | 9 ++- agent/run/loop/loop_test.go | 10 +++ agent/run/loop/model.go | 3 +- agent/run/loop/tool.go | 101 +++++++++++++++--------- agent/run/runtime.go | 3 + agent/run/state.go | 5 +- agent/run/store.go | 2 +- agent/run/store_test.go | 100 +++++++++++++++++++++++ agent/run/stored_runtime.go | 6 ++ docs/design/agent-reference-assembly.md | 2 +- docs/design/agent-run.md | 33 ++++---- docs/design/agent-session-chatlog.md | 5 +- docs/design/agent-session-extension.md | 4 +- docs/design/agent-turn.md | 13 +-- 20 files changed, 244 insertions(+), 90 deletions(-) diff --git a/agent/run/clone.go b/agent/run/clone.go index 2587c36..b2d1f17 100644 --- a/agent/run/clone.go +++ b/agent/run/clone.go @@ -2,8 +2,8 @@ package run import "encoding/json" -// Deep-copy helpers: Runtime return values must be read-only snapshots (spec -// appendix A) — a caller mutating a returned slice or map must never reach +// 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 diff --git a/agent/run/commit.go b/agent/run/commit.go index 8c9f68c..e8448c5 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -90,11 +90,11 @@ func requiresGrant(s *MachineState, c AgentCommand) bool { } } -// EvaluateCommit is the single, pure commit evaluation both runtimes call -// inside their own critical section (RUN-CMT-3). grantValid and recoveryValid -// are the control-plane verdicts the Runtime supplies: whether req.Grant is -// the live grant for the command's target, and whether a grantless recovery -// command matches the Runtime's own lease-expiry record. +// EvaluateCommit is the single, pure commit evaluation the Store-backed +// Runtime calls inside its critical section (RUN-CMT-3). grantValid and +// recoveryValid are the control-plane verdicts the Runtime supplies: whether +// req.Grant is the live grant for the command's target, and whether a +// grantless recovery command matches an expired lease. // //nolint:gocritic // hugeParam: public pure commit evaluator keeps state/request as value protocol inputs. func EvaluateCommit( @@ -160,12 +160,12 @@ func EvaluateCommit( return CommitDecision{Kind: DecisionConflict, Reject: err}, nil } - // Step 7 precheck: terminal absorbs non-duplicate commands. + // Terminal absorbs non-duplicate commands (after replay). if cur.Status.Terminal() { return CommitDecision{Kind: DecisionTerminal, Reject: ErrRunTerminal}, nil } - // Step 4: BaseRevision and authorization. + // BaseRevision and authorization. cat := categorize(env.Command) if cat == catPlan && req.BaseRevision != curRevision { return CommitDecision{Kind: DecisionStale, Reject: ErrStaleRuntime}, nil diff --git a/agent/run/decide.go b/agent/run/decide.go index 0d565f6..57b3b44 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -197,8 +197,8 @@ func decideSubmitModelResult(s *MachineState, cmd *SubmitModelResult) ([]Fact, e } // Validate bindings against the frozen ToolSpecs and the model result, - // then freeze the full call set (Waiting requests included) here so both - // runtimes derive identical ToolStepOpened facts. + // then freeze the full call set (Waiting requests included) here so + // Decide produces identical ToolStepOpened facts. 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)) } @@ -483,9 +483,9 @@ func decideApproveToolCall(s *MachineState, cmd ApproveToolCall) ([]Fact, error) 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. Spec §4.2 lists Waiting -> Failed(Known) - // as legal; without this, an abandoned ask-user call would strand the run - // with CancelRun as the only escape. + // 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 diff --git a/agent/run/evolve.go b/agent/run/evolve.go index a018f18..e8522a5 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -414,8 +414,8 @@ func evolveCall(s *MachineState, step StepID, call CallID, apply func(*ToolCallS } calls := append([]ToolCallState(nil), ts.Calls...) apply(&calls[i]) - // Spec §4.2: Evolve must reject illegal field combinations, e.g. an - // unknown-outcome failure whose class is not effect_unknown. + // Reject illegal field combinations, e.g. an unknown-outcome failure + // whose class is not effect_unknown. if err := ValidateToolCallState(calls[i]); err != nil { return *s, err } diff --git a/agent/run/fact.go b/agent/run/fact.go index 35503ca..1f4681c 100644 --- a/agent/run/fact.go +++ b/agent/run/fact.go @@ -55,7 +55,8 @@ type ModelStepRejected struct { func (ModelStepRejected) fact() {} // ModelStepCompleted accepts one model result: usage is accumulated, -// LastModelResult is written, the current step is cleared. +// LastModelResult is written, Current becomes Open. The same transition may +// then open a ToolStep or end the Run. type ModelStepCompleted struct { StepID StepID `json:"stepId"` Result ModelResult `json:"result"` diff --git a/agent/run/loop/contract.go b/agent/run/loop/contract.go index 1c9726d..438452a 100644 --- a/agent/run/loop/contract.go +++ b/agent/run/loop/contract.go @@ -168,8 +168,7 @@ const ( type LoopResult struct { Disposition LoopDisposition - // Reason is retained for source compatibility. ExecutionRecovery is - // the authoritative signal that a live execution needs recovery. + // 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 diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index a46c83b..ebfdbc5 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -52,15 +52,16 @@ func New(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy if policy.MaxParallel < 0 { return nil, errors.New("agent: loop: negative MaxParallel") } - if policy.ToolExecution == "" { - policy.ToolExecution = ToolExecutionParallel - } return &Loop{Models: models, Tools: tools, Planner: planner, Execution: policy, Streaming: streaming, starts: make(map[startKey]startAttempt), settlements: make(map[startKey]settlementAttempt), runs: make(map[run.RunID]struct{})}, nil } func (l *Loop) toolScheduling() run.ToolScheduling { - return run.ToolScheduling{Mode: run.ToolScheduleMode(l.Execution.ToolExecution), MaxParallel: l.Execution.MaxParallel} + 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 { diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go index 978d7cc..3431704 100644 --- a/agent/run/loop/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -128,6 +128,16 @@ func toolCallResult(ids ...string) sdk.ModelResult { // --- 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{})} diff --git a/agent/run/loop/model.go b/agent/run/loop/model.go index 4783f9b..6ecaa3b 100644 --- a/agent/run/loop/model.go +++ b/agent/run/loop/model.go @@ -137,7 +137,8 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve // safe, the frozen request retries after recovery (RUN-LOP-3). sdkRequest, err := modelStep.Request.SDK() if err != nil { - completion = run.SubmitModelFailure{StepID: stepID, Failure: run.StepFailure{Class: run.FailureProvider, Message: err.Error()}} + failure := run.StepFailure{Class: run.FailureMalformedModel, Message: err.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 { diff --git a/agent/run/loop/tool.go b/agent/run/loop/tool.go index 17bd217..614469e 100644 --- a/agent/run/loop/tool.go +++ b/agent/run/loop/tool.go @@ -26,6 +26,34 @@ func toolCallIndex(step run.ToolStep, callID run.CallID) int { return -1 } +func (l *Loop) resolveExecutableTool(proto run.Protocol, call run.ToolCallState) (ExecutableTool, *run.ToolFailure) { + tool, resolveErr := l.Tools.Resolve(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 run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, eff run.StartToolCalls) error { runID := snapshot.State.RunID proto, err := snapshot.Protocol() @@ -59,48 +87,20 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve } call := ts.Calls[i] key := startKey{runID: runID, stepID: eff.StepID, callID: callID} - if call.Status != run.ToolPending { - if call.Status == run.ToolExecuting { - if _, ok := l.lookupStart(key); !ok { - continue - } - } else { + resuming := false + switch call.Status { + case run.ToolPending: + case run.ToolExecuting: + if _, ok := l.lookupStart(key); !ok { continue } - } - - tool, resolveErr := l.Tools.Resolve(call.ToolRef) - var known *run.ToolFailure - switch { - case resolveErr != nil: - known = &run.ToolFailure{Class: run.FailureToolLookup, Message: resolveErr.Error()} + resuming = true default: - if tool == nil { - known = &run.ToolFailure{Class: run.FailureToolLookup, Message: "tool catalog returned a nil tool"} - break - } - toolDef, freezeErr := run.FreezeToolDefinition(tool.Definition()) - if freezeErr != nil { - known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: freezeErr.Error()} - break - } - defDigest, digestErr := proto.DigestToolDefinition(toolDef) - if digestErr != nil { - known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: digestErr.Error()} - break - } - switch { - case tool.Ref() != call.ToolRef || defDigest != call.DefinitionDigest: - known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: "tool definition digest mismatch"} - case tool.ResponsePolicy() != call.Policy: - known = &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: "response policy mismatch"} - default: - if argErr := tool.ValidateArguments(call.Arguments); argErr != nil { - known = &run.ToolFailure{Class: run.FailureInvalidArguments, Message: argErr.Error()} - } - } + continue } - if known != nil { + + tool, known := l.resolveExecutableTool(proto, call) + if known != nil && !resuming { // Known failure of a Pending call: no start barrier, no tool call. res, err := l.commit(ctx, runtime, runID, freshCommandID(), snapshot.Revision, "", run.SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: *known, Outcome: run.ToolOutcomeKnown}, proto) @@ -149,6 +149,31 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve _ = events.Emit(ctx, Event{RunID: runID, StepID: eff.StepID, CallID: callID, Kind: EventToolStarted, Durability: EventCommitted}) } + if resuming && known != nil { + failure := *known + if failure.Class != run.FailureEffectUnknown { + if failure.Message == "" { + failure.Message = "tool reported " + failure.Class + } + failure.Class = run.FailureEffectUnknown + } + settlement := l.settlementFor(key, freshCommandID(), start.Snapshot.Revision, start.Grant, + run.SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: failure, Outcome: run.ToolOutcomeUnknown}) + res, err := l.commit(context.WithoutCancel(ctx), runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command, proto) + if err != nil { + settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, proto) + if retriable(err) { + l.forgetSettlement(key) + l.forgetStart(key) + return settleErr + } + return err + } + l.forgetSettlement(key) + l.forgetStart(key) + l.emitCommitted(ctx, events, runID, res.Events) + continue + } started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Revision, tool: tool, key: key}) } diff --git a/agent/run/runtime.go b/agent/run/runtime.go index f3be384..8bc7b9a 100644 --- a/agent/run/runtime.go +++ b/agent/run/runtime.go @@ -11,6 +11,9 @@ type Runtime interface { Load(context.Context, RunID) (RuntimeSnapshot, error) Commit(context.Context, CommitRequest) (CommitResult, error) Record(context.Context, RunID) (RunRecord, error) + // RecoverExpired grantless-commits recovery for expired execution leases. + // Hosts call it on a timer; Loop does not. + RecoverExpired(context.Context) (int, error) } type RuntimeSnapshot struct { diff --git a/agent/run/state.go b/agent/run/state.go index ff3672f..88c5c73 100644 --- a/agent/run/state.go +++ b/agent/run/state.go @@ -22,7 +22,10 @@ const ( ReasonCancelled RunReason = "cancelled" ReasonProviderFailure RunReason = "provider_failure" ReasonMalformedModel RunReason = "malformed_model_result" - ReasonEffectUnknown RunReason = "effect_unknown" + // 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 { diff --git a/agent/run/store.go b/agent/run/store.go index c8ed719..f19d523 100644 --- a/agent/run/store.go +++ b/agent/run/store.go @@ -99,7 +99,7 @@ func Rebuild(ctx context.Context, store Store, runID RunID) (rebuilt bool, err e } var diverged bool err = store.Update(ctx, runID, func(stored *StoredRun) error { - folded, maxRevision, foldErr := FoldTransitions(cloneMachineState(&stored.Header.InitialState), stored.Log) + folded, maxRevision, foldErr := FoldRun(&stored.Header, stored.Log) if foldErr != nil { return foldErr } diff --git a/agent/run/store_test.go b/agent/run/store_test.go index 2baa1a1..75120be 100644 --- a/agent/run/store_test.go +++ b/agent/run/store_test.go @@ -2,6 +2,7 @@ package run import ( "context" + "errors" "testing" "time" ) @@ -102,3 +103,102 @@ func TestZeroDeadlineRejectsGrantlessRecovery(t *testing.T) { t.Fatal("grantless recover accepted on non-expiring lease") } } + +func TestGrantlessModelRecoveryRejectsWrongClaim(t *testing.T) { + clock := time.Unix(1000, 0) + rt := NewRuntimeWithOptions(NewMemoryStore(), RuntimeOptions{ + LeaseTTL: time.Second, + Now: func() time.Time { return clock }, + }) + ctx := context.Background() + newRun, err := BuildNewRun("run-1", "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(ctx, newRun); err != nil { + t.Fatal(err) + } + in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} + env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(ctx, CommitRequest{Command: env}); err != nil { + t.Fatal(err) + } + snap, _ := rt.Load(ctx, "run-1") + prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) + if _, err := commitCmd(t, rt, cmdID, snap.Revision, "", prep); err != nil { + t.Fatal(err) + } + claim := ExecutionClaim("claim-recover") + startEnv, err := BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(ctx, CommitRequest{Command: startEnv}); err != nil { + t.Fatal(err) + } + clock = time.Unix(1002, 0) + wrong := ExecutionClaim("other-claim") + recoverEnv, err := BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, wrong), RecoverModelExecution{StepID: prep.StepID, Claim: wrong}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(ctx, CommitRequest{Command: recoverEnv}); !errors.Is(err, ErrStaleRuntime) { + t.Fatalf("wrong claim err = %v, want ErrStaleRuntime", err) + } +} + +func TestRecoverExpiredRecoversExecutingModel(t *testing.T) { + clock := time.Unix(1000, 0) + rt := NewRuntimeWithOptions(NewMemoryStore(), RuntimeOptions{ + LeaseTTL: time.Second, + Now: func() time.Time { return clock }, + }) + ctx := context.Background() + newRun, err := BuildNewRun("run-1", "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(ctx, newRun); err != nil { + t.Fatal(err) + } + in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} + env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(ctx, CommitRequest{Command: env}); err != nil { + t.Fatal(err) + } + snap, _ := rt.Load(ctx, "run-1") + prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) + if _, err := commitCmd(t, rt, cmdID, snap.Revision, "", prep); err != nil { + t.Fatal(err) + } + claim := ExecutionClaim("claim-scan") + startEnv, err := BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(ctx, CommitRequest{Command: startEnv}); err != nil { + t.Fatal(err) + } + clock = time.Unix(1002, 0) + n, err := rt.RecoverExpired(ctx) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("recovered = %d, want 1", n) + } + snap, err = rt.Load(ctx, "run-1") + if err != nil { + t.Fatal(err) + } + ms, ok := snap.State.Current.(ModelStep) + if !ok || ms.Status != ModelPrepared { + t.Fatalf("after RecoverExpired: %+v", snap.State.Current) + } +} diff --git a/agent/run/stored_runtime.go b/agent/run/stored_runtime.go index c5a91b0..135d363 100644 --- a/agent/run/stored_runtime.go +++ b/agent/run/stored_runtime.go @@ -80,6 +80,9 @@ func (r *runtime) Load(ctx context.Context, runID RunID) (RuntimeSnapshot, error return RuntimeSnapshot{}, err } snapshot := RuntimeSnapshot{State: stored.State, Revision: stored.Revision, SchemaVersion: stored.Header.SchemaVersion} + if stored.Header.RunID != runID || stored.State.RunID != runID { + return RuntimeSnapshot{}, fmt.Errorf("agent: runtime: stored RunID %q/%q does not match %q", stored.Header.RunID, stored.State.RunID, runID) + } if err := ValidateRunHeader(&stored.Header); err != nil { return RuntimeSnapshot{}, fmt.Errorf("agent: runtime: invalid header: %w", err) } @@ -110,6 +113,9 @@ func (r *runtime) evaluateAndApply(stored *StoredRun, req CommitRequest) (Commit grantValid = grantValid && lease.Claim == cmd.Claim } recoveryValid := hasLease && req.Grant == "" && r.leaseExpired(lease) + if cmd, ok := req.Command.Command.(RecoverModelExecution); ok && req.Grant == "" { + recoveryValid = recoveryValid && lease.Claim == cmd.Claim + } var prior *TransitionRecord if record, ok := stored.Transitions[req.Command.ID]; ok { diff --git a/docs/design/agent-reference-assembly.md b/docs/design/agent-reference-assembly.md index 6d5389a..ccb8d68 100644 --- a/docs/design/agent-reference-assembly.md +++ b/docs/design/agent-reference-assembly.md @@ -68,7 +68,7 @@ run.AgentInput.Payload ```text sessionStore = session.NewMemoryStore() runRuntime = run.NewRuntime(run.NewMemoryStore()) -catalog = extension.BuildCatalog(protocol, []Module{chatlog.Module, turn.Module}) +catalog = extension.BuildCatalog(CatalogBuildRequest{ProtocolVersion, Profile, Modules: []ModuleDescriptor{chatlog.Module, turn.Module}}) appender = extension.NewSemanticAppender(sessionStore, catalog, artifacts) bindings = Resolve(ExecutionBindingRef) -> loop.New(models, tools, contextPlanner, policy, pub.Streaming) coordinator = turn.Coordinator{Sessions, Appender, Runtime: runRuntime, Bindings, Mapper: turn.MapperV1} diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index ccfc930..95ebc94 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -261,9 +261,9 @@ ToolCall: | `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`。Evolve 后若全部 call 已 terminal,则关闭 ToolStep | -| `CancelRun` | active;先把仍 Executing 的 tool call 记 `ToolCallFailed(Unknown)`,随后 `RunEnded(stopped/cancelled)`,并在 `RunStoppedEnd` / `RunResult` 上列出 `UncertainCalls` 与 `UncertainModel`。仅 Waiting、没有 Executing 时不给 Waiting call 记 Failed;`RunEnded` 把 `Current` 置空,且不更新 `LastToolStep` | +| `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` / `LastClosedStep`。Cancel 经 `RunEnded` 置空 Current、不经这条关闭路径时,`LastToolStep` 保持原值。 +没有独立的 `ToolStepClosed` fact。最后一个 ToolCall 进入 Completed 或 Failed 时,`Evolve` 在折叠该 fact 后若全部 call 已 terminal,则把 Current 设为 `Open` 并写入 `LastToolStep` / `LastClosedStep`。Cancel 的 Unknown fact 同样走这条关闭规则;`RunEnded` 再把 Current 置空。 **RUN-MCH-3** `Protocol.Decide(state, command)` 执行全部验证与 derived consequence,一次返回该 transition 的完整 ordered fact group;验证成功后返回完整 facts。包级 `Decide` 委托当前写入 schema。`Protocol.Evolve(state, fact)` 机械折叠 fact,依赖 fact 携带的完整数据。accepted facts 必须 self-contained;若 transition terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 @@ -310,6 +310,7 @@ type Runtime interface { Load(context.Context, RunID) (RuntimeSnapshot, error) Commit(context.Context, CommitRequest) (CommitResult, error) Record(context.Context, RunID) (RunRecord, error) + RecoverExpired(context.Context) (int, error) } type RuntimeSnapshot struct { State MachineState // detached in-process view @@ -361,23 +362,24 @@ type CommitResult struct { 所有 Runtime implementation 在自己的 critical section/transaction 内调用同一个 pure `EvaluateCommit`。顺序固定为: ```text -1 validate envelope RunID/schema/type/digest and derived CommandID +1 validate envelope RunID/schema/type/digest 2 lookup prior transition by CommandID 3 exact digest replay -> AlreadyApplied + original complete event group 4 same CommandID/different digest -> conflict -5 terminal check -6 validate hard CAS / target state / execution grant / recovery authority -7 facts = Protocol.Decide(current, command) exactly once -8 snapshot each fact; Protocol.Evolve in order; assign next Revision and Index -9 build and validate one complete TransitionRecord -10 atomically persist transition and new MachineState +5 derived CommandID check (after replay) +6 terminal check +7 validate hard CAS / target state / execution grant / recovery authority +8 facts = Protocol.Decide(current, command) exactly once +9 snapshot each fact; Protocol.Evolve in order; assign next Revision and Index +10 build and validate one complete TransitionRecord +11 atomically persist transition and new MachineState ``` **RUN-CMT-3** `PrepareModelRequest` 是 hard-CAS command:BaseRevision 必须等于 current revision。其他 command 通过当前 target state 和 grant 做 call-local rebase;stale BaseRevision 本身不阻止无冲突的 ingress/control/settlement。相同 command 的 replay 判定先于 terminal check,因此 terminal Run 仍能返回原 transition。 **RUN-CMT-4** 幂等键为 `(RunID,CommandID)`。相同 digest 返回 `CommitAlreadyApplied`、当前 snapshot 与原完整 event group,且不得再次 Decide、分配 revision 或产生外部 effect。对于 `StartModelExecution` 和 `StartToolCall`,Runtime 还必须验证 command 中的 `ExecutionClaim`:相同 command ID、相同 digest、相同 claim 的精确重放在 grant 仍 live 时返回原 start grant;不同 claim 触发 `ErrCommandConflict`,并保持现有执行授权。非 start command 的 replay 不返回 grant。revision 每个 accepted command 恰加 1。 -**RUN-CMT-5** accepted `StartModelExecution`/`StartToolCall` 为目标签发新 grant;该 start 的 `CommitAccepted` 和在 grant 仍 live 时满足精确 replay 条件的 `CommitAlreadyApplied` 返回同一个 grant。若该 start 已 settlement 或 Run 已 terminal,精确 replay 仍返回 `CommitAlreadyApplied`,并返回空 grant。model result/failure/reject 与 executing tool result/known failure 必须携带 live target grant。settlement 接受后 grant 失效;terminal transition 撤销全部 grant。公共 Runtime 的 `RecoverModelExecution` 由 live grant holder 提交;durable 实现内部可以在自己验证 execution recovery record 后无 grant 提交。Executing tool 的 recovery 使用同一条 `SubmitToolFailure{Outcome:Unknown}` command:工具 owner 必须携带 live grant;recovery scanner 仅在 Runtime 验证其 lease/claim 已失效且没有已接受 settlement 时无 grant 提交。该 Unknown 只结算这一 call,Run 保持 Active。scanner 使用上表的 deterministic recovery CommandID,因而 recovery update 也遵守同一 `(RunID, CommandID)` 幂等规则。durable adapter 保存 `(command ID, claim) -> grant` 精确 replay 所需的私有 start record;attempt、owner、fence、lease 与 recovery record 由 adapter 内部管理。 +**RUN-CMT-5** accepted `StartModelExecution`/`StartToolCall` 为目标签发新 grant;该 start 的 `CommitAccepted` 和在 grant 仍 live 时满足精确 replay 条件的 `CommitAlreadyApplied` 返回同一个 grant。若该 start 已 settlement 或 Run 已 terminal,精确 replay 仍返回 `CommitAlreadyApplied`,并返回空 grant。model result/failure/reject 与 executing tool result/known failure 必须携带 live target grant。settlement 接受后 grant 失效;terminal transition 撤销全部 grant。`RecoverModelExecution` 由 live grant holder 提交,或在 Runtime 验证 lease 已过期且 command Claim 等于该 lease 的 Claim 后无 grant 提交。Executing tool 的 recovery 使用同一条 `SubmitToolFailure{Outcome:Unknown}` command:工具 owner 必须携带 live grant;`RecoverExpired` 仅在 lease 已过期且没有已接受 settlement 时无 grant 提交。该 Unknown 只结算这一 call,Run 保持 Active。scanner 使用确定性 recovery CommandID,因而 recovery update 也遵守同一 `(RunID, CommandID)` 幂等规则。attempt、owner、fence、lease 与 recovery record 由 Store adapter 内部管理。 **RUN-CMT-6** Commit 必须原子保存新 MachineState 与完整 TransitionRecord,保证 event group 完整写入。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 @@ -388,10 +390,7 @@ type CommitResult struct { ## 6. Loop ports 与 policy ```go -// package agent/run/loop -type RequestPlanner interface { - Plan(context.Context, run.PlanningHint) (RequestPlan, error) -} +// package agent/run type PlanningHint struct { RunID RunID SourceStep StepID @@ -399,6 +398,10 @@ type PlanningHint struct { LastToolStep *ToolStep LastModelResult *ModelResult } +// package agent/run/loop +type RequestPlanner interface { + Plan(context.Context, run.PlanningHint) (RequestPlan, error) +} type RequestPlan struct { Model run.ModelRef Request sdk.Request @@ -472,7 +475,7 @@ Loop.Run(ctx, runtime, runID, sink): **RUN-LOP-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须保留 start command 的 ID、digest 和 claim,直到完成 settlement;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。streaming 与 non-streaming 必须产生同一种完整 `sdk.ModelResult`;delta 只发 EventSink。`ModelCatalog.Resolve` 失败或返回 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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call,在外层 ctx 未取消时于本次 `Run` 内按冻结 Scheduling 分批 Start 并结算;ctx 已取消时停止再 Start,只结算已持有 grant 的 call。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `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、没有 Executing 时不给 Waiting call 记 Failed。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。 +**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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call,在外层 ctx 未取消时于本次 `Run` 内按冻结 Scheduling 分批 Start 并结算;ctx 已取消时停止再 Start,只结算已持有 grant 的 call。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `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;Executing 且本进程持有 start cache 时只重放 start 并结算,不得再提交 grantless Known。 **RUN-LOP-5** model 与 tool worker 都接收外层 ctx;Loop 对已接受 effect 使用独立 control context 完成 known/unknown outcome settlement。Application 的业务停止顺序为先 Commit `CancelRun`,再取消 Loop ctx。非 sentinel Commit error 以同 CommandID/digest 重放一次;仍未知时返回错误,由后续 Load/Record 查询 authority。stale/terminal/conflict 触发 reload/drop,旧 external effect 保持单次执行尝试。工具实现配合 context 返回;永久阻塞由 application/durable recovery 处理。 diff --git a/docs/design/agent-session-chatlog.md b/docs/design/agent-session-chatlog.md index dfc741c..b3cf879 100644 --- a/docs/design/agent-session-chatlog.md +++ b/docs/design/agent-session-chatlog.md @@ -13,7 +13,7 @@ EventType = twilight/chatlog/ Projections = twilight/chatlog/surface, twilight/chatlog/context ``` -Chatlog 保存对话内容:Input、assistant、tool_result、summary、checkpoint。Surface 与 Context 是对这些 events 的纯投影。每条内容携带 `TurnID`;回合的创建、结束与 Run linkage 由 `twilight/turn/` 事件表达。外部内容经 `ReferencePart` 关联 Artifact BindingID。 +Chatlog 保存对话内容:Input、assistant、tool_result、summary、checkpoint。Surface 与 Context 是对这些 events 的纯投影。`assistant` 与 `tool_result` 携带 `TurnID`;Input 在 `input_delivered` 之后挂上 TurnID;summary 与 checkpoint 不携带 TurnID。回合的创建、结束与 Run linkage 由 `twilight/turn/` 事件表达。外部内容经 `ReferencePart` 关联 Artifact BindingID。 流式 `text_delta` / `reasoning_delta` 由 Loop EventSink 发送,属于临时观察。Chatlog 权威是已提交的条目。 @@ -29,7 +29,6 @@ type ToolResultID string type SummaryID string type CallID string type CheckpointID string -type EntityStatus string ``` `TurnID` 与 turn 模块同一 identity。InputID、AssistantID、ToolResultID、SummaryID 在 resolved ancestry 内唯一;CallID 在同一 Turn 内唯一。replacement graph 无环,一个实体至多一个直接 replacement。 @@ -123,7 +122,7 @@ type Entry struct { **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。`unknown` 与 `indeterminate` 为历史终态;active Context 视该 call 为未解决,直到 `tool_result_superseded` 换成 `success` 或 `error`。每个 unresolved result 至多一个 replacement。`tool_result_superseded` 只在对应 Turn 尚未写入 `twilight/turn/completed` 或 `twilight/turn/failed` 时接受。 +**CHT-ENT-2** 每个 `(TurnID,CallID)` 在 assistant 中至多一个 ToolCall。`tool_result` 对应同 Turn 已有的 call。CallID 在同一 Turn 内唯一:同一 Turn 的后续 ModelStep 不得复用已出现的 CallID。`unknown` 为 v1 mapper 写入的未决终态;`indeterminate` 保留给历史条目,v1 mapper 不产出。active Context 视 unresolved call 为未解决,直到 Application 在 Turn 尚未 `twilight/turn/completed` 或 `twilight/turn/failed` 时写入 `tool_result_superseded`,换成 `success` 或 `error`。每个 unresolved result 至多一个 replacement。v1 FactMapper 不写 `tool_result_superseded`。 **CHT-ENT-3** ToolResult 的 nested Parts 为单层 TextPart 或 ReferencePart。外部内容使用 `ReferencePart`。 diff --git a/docs/design/agent-session-extension.md b/docs/design/agent-session-extension.md index dd89600..05d2ee4 100644 --- a/docs/design/agent-session-extension.md +++ b/docs/design/agent-session-extension.md @@ -148,7 +148,7 @@ type BindingOccurrence struct { BindingID artifact.BindingID; Location string } type BindingExtractor interface { BindingIDs(value any) ([]artifact.BindingID, error) } ``` -**EXT-REF-1** declaration 恰选一种:非空 JSONPointer,或非空 RegistryID。JSONPointer 路径仅在普通 canonical JSON payload 上执行;RegistryID 路径把该 event 的 decoded typed value 交给对应 RuntimeRegistry 的 BindingExtractor。custom registry(包括 Message registry)必须以 `BindingIDs(value)` 返回内部全部引用,不能回退为 JSONPointer 猜测。提取保留 occurrence appearance order,随后 append group 才 sorted-unique。 +**EXT-REF-1** declaration 恰选一种:非空 JSONPointer,或非空 RegistryID。JSONPointer 路径仅在普通 canonical JSON payload 上执行;RegistryID 路径把该 event 的 decoded typed value 交给对应 RuntimeRegistry 的 BindingExtractor。custom registry(包括 `twilight/chatlog/parts`)必须以 `BindingIDs(value)` 返回内部全部引用,不能回退为 JSONPointer 猜测。提取保留 occurrence appearance order,随后 append group 才 sorted-unique。 **EXT-REF-2** Catalog 验证 cardinality、pointer grammar、RegistryID extractor availability 与 scheme/durability declaration。admission 解析每个 Binding,验证 Scheme、最低 durability、resolvability 与 host access policy;任何遗漏或违反均拒绝整个 group。所有 Session event declaration 的最低 durability 至少为 `EventBound`。 @@ -246,7 +246,7 @@ AppendSemantic(request): unknown result: retain Prepared claim and pending intent; return Indeterminate ``` -只有 claim 已 Activate,`SemanticApplied` 或 `SemanticAlreadyApplied` 才能返回;Activate 或 terminal marking 的未知/失败必须保留 Prepared/pending state 并返回 `SemanticIndeterminate`。`HeadConflict`、`CommitConflict`、`Invalid` 分别映射为同名 Semantic outcome,后两者仅在 journal 已标记 Aborted 且 AbortPrepared 成功后返回。A `HeadConflict` retry 保持相同 CommitID、fingerprint、ClaimID 和所有 event identity/time/source/payload/BindingSet,仅更新 ExpectedHead。`LookupCommit` 找到 canonical commit 时 Activate;返回 NotFound 时保留 Prepared/pending state,留待 retry 或 recovery。明确的 terminal `Invalid` 或 `CommitConflict` 才可先 journal 标记 `Aborted`,再 AbortPrepared。 +只有 claim 已 Activate,`SemanticApplied` 或 `SemanticAlreadyApplied` 才能返回;Activate 或 terminal marking 的未知/失败必须保留 Prepared/pending state 并返回 `SemanticIndeterminate`。`HeadConflict`、`CommitConflict`、`Invalid` 分别映射为同名 Semantic outcome,后两者仅在 journal 已标记 Aborted 且 AbortPrepared 成功后返回。`HeadConflict` 重试保持相同 CommitID、fingerprint、ClaimID 和所有 event identity/time/source/payload/BindingSet,仅更新 ExpectedHead。`LookupCommit` 找到 canonical commit 时 Activate;返回 NotFound 时保留 Prepared/pending state,留待 retry 或 recovery。明确的 terminal `Invalid` 或 `CommitConflict` 才可先 journal 标记 `Aborted`,再 AbortPrepared。 **EXT-APP-4** recovery 扫描 journal Pending 与 ledger PreparedClaims 的并集。 diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index 523baba..8f85ca8 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -128,7 +128,7 @@ twilight/turn/superseded ```go type TurnView struct { TurnID TurnID - Status string // started | completed | failed | superseded + Status string // started | completed | failed | stopped | superseded InputIDs []chatlog.InputID RunID run.RunID Settlement Settlement @@ -140,7 +140,7 @@ type TurnSurface struct { } ``` -UI 按 `TurnID` 连接 `twilight/chatlog/surface` 的条目。 +`Settlement=stopped` 时 `Status` 为 `stopped`。UI 按 `TurnID` 连接 `twilight/chatlog/surface` 的条目。 ## 3. API @@ -190,7 +190,9 @@ type StopResponse struct { Ref TurnRef; RunID run.RunID; Result *ResultReference **TRN-API-4** Create、conflict、missing、corrupt 按 Run contract 处理。创建计划来自 persisted `started`。 -**TRN-API-5** DTO 为值语义。`Waiting` 为 snapshot 的 `WaitingCalls`。`Disposition` 为 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`。 +**TRN-API-5** DTO 为值语义。`Waiting` 为 snapshot 的 `WaitingCalls`。`Disposition` 为 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`。`NeedsRecovery` 为 true 时 Coordinator 返回 `ResumeWaitingForRecovery`;Application 调用 `Runtime.RecoverExpired` 后再 Resume。 + +**TRN-API-6** `twilight/turn/superseded` 由 Application 追加,指向 replacement Turn 与新 RunID。Coordinator 的 Start / Resume / Stop 不写该事件。同一 Turn 上第二个 primary Run 仍为 conflict。 ## 4. Start @@ -252,7 +254,7 @@ InputIDs 为空时 group 仅含 `twilight/turn/started`。 |---|---|---| | `RunCompletedEnd` | `twilight/turn/completed` | `completed` | | `RunFailedEnd` | `twilight/turn/failed` | `failed` | -| `RunStoppedEnd` | `twilight/turn/failed` | `stopped`(`FailureClass:"stopped"`) | +| `RunStoppedEnd` | `twilight/turn/failed` | `stopped`(`FailureClass:"cancelled"`) | **TRN-SET-4** settlement 的 CommitID / EventID 由 ResultReference 派生。相同 reference 幂等。 @@ -300,7 +302,7 @@ SourceFactID = Digest("twilight/turn/source-fact", `ModelStepPrepared` 期间 EventSink 可发送 `text_delta` / `reasoning_delta`。回合结束由 `twilight/turn/completed` 或 `twilight/turn/failed` 表达。 -**TRN-MAP-2** `AssistantID = Digest("twilight/chatlog/assistant-id", TurnID, ModelStepID, MapperVersion)`。`ToolResultID = Digest("twilight/chatlog/tool-result-id", TurnID, CallID, MapperVersion)`。assistant 的 ToolCall 顺序与模型结果一致。tool_result 与同 Turn 的 call 配对。 +**TRN-MAP-2** `AssistantID = Digest("twilight/chatlog/assistant-id", TurnID, ModelStepID, MapperVersion)`。`ToolResultID = Digest("twilight/chatlog/tool-result-id", TurnID, CallID, MapperVersion)`。assistant 的 ToolCall 顺序与模型结果一致。tool_result 与同 Turn 的 call 配对。同一 Turn 内 CallID 不得跨 ModelStep 复用,否则 `ToolResultID` 冲突。 **TRN-MAP-3** Known 对应 `error`;Unknown 对应 `unknown`。 @@ -321,6 +323,7 @@ SourceFactID = Digest("twilight/turn/source-fact", | Map 为空 | 该 revision coverage 完成 | | materialize 响应丢失 | LookupCommit | | Run 已终态、settlement 缺失 | 按 RunEnded 写 `twilight/turn/completed` 或 `twilight/turn/failed` | +| 模型 Executing、lease 过期 | `RecoverExpired` 提交 `RecoverModelExecution`;Run 保持 Active,同一 Turn、同一 RunID 继续 | | 模型结果未知 | 以 Record 为准 | | 工具效果未知 | 对该 Executing call materialize status=`unknown`;Run 保持 Active,同一 Turn、同一 RunID 继续。lease 过期后 `RecoverExpired` 提交该 call 的 Unknown | | Record 与并发 Commit | 重读 Record | From 932497731d0f145153287e58190921c7d9cb2f2b Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 2 Sep 2026 19:34:18 +0800 Subject: [PATCH 53/81] Make Store append-only with a per-Run commit section; add snapshot codec and lease renewal Store contract - Replace View/Update(fn)/ListIDs with LoadHead, LoadLog(from), LoadRecord, LookupTransition, Commit(fn RunTx) (*Append), RenewLease, ExpiredLeases, ReplaceSnapshot. No method can delete or rewrite a transition. - Store.Commit is the Run's critical section: Runtime evaluates against the RunTx head and the Store persists the returned Append in the same transaction (RUN-CMT-2). Load and Commit no longer touch the log. - Drop the StartGrants table; the grant lives only on its lease. Snapshot codec - Protocol.EncodeMachineState/DecodeMachineState define a persisted wire for MachineState including Current. SQLite stores snapshot + log; Record and Rebuild verify the snapshot through FoldRun. Golden digests unchanged. Lease renewal (RUN-CMT-8) - Runtime.RenewLease extends a live lease; Loop workers heartbeat at ExecutionPolicy.LeaseRenewInterval and stop when renewal is rejected. LeaseTTL bounds recovery delay instead of tool duration. - RecoverExpired loads only Runs returned by Store.ExpiredLeases. Protocol surface - Remove package-level Decide/Evolve/Digest*/BuildEnvelope/EncodeCommand, BuildRunHeader and currentSchemaVersion; version binds once at the Run boundary via ProtocolFor or RuntimeSnapshot.Protocol(). Specs - agent-run.md: append-only Store section, RUN-CMT-8, updated RUN-WIR-3, RUN-MCH-3, RUN-CMT-2, RUN-CMT-7, RUN-LOP-1, RUN-CMP-2. - Session, Artifact, Session Extension downgraded to draft until a Memory vertical slice passes conformance. - Refactor doc: lease/recovery belongs to run.Runtime; sdk.Request freeze evaluation recorded in 4.4; sdk/request.go digest comment corrected. Conformance - New RunLeaseRenewalConformance; snapshot codec round-trip and malformed wire tests; SQLite reopen test; Loop long-tool heartbeat test. MemoryStore and SQLite pass Runtime, recovery and renewal suites; go test -race clean. Co-Authored-By: Claude Fable 5.1 --- agent/run/alias_test.go | 6 +- agent/run/canonical_test.go | 12 +- agent/run/codec_test.go | 24 +- agent/run/commit.go | 7 - agent/run/creation.go | 4 - agent/run/creation_test.go | 2 +- agent/run/decide.go | 9 - agent/run/evolve.go | 9 - agent/run/fold.go | 33 +- agent/run/fold_test.go | 12 +- agent/run/header.go | 36 -- agent/run/header_test.go | 23 +- agent/run/loop/contract.go | 6 + agent/run/loop/helpers_test.go | 2 +- agent/run/loop/lease.go | 59 +++ agent/run/loop/lease_test.go | 100 +++++ agent/run/loop/loop.go | 3 + agent/run/loop/loop_test.go | 2 +- agent/run/loop/model.go | 6 +- agent/run/loop/regression_test.go | 2 +- agent/run/loop/tool.go | 4 +- agent/run/machine_test.go | 50 +-- agent/run/memory_conformance_test.go | 13 + agent/run/memory_store.go | 198 ++++++--- agent/run/protocol.go | 99 +---- agent/run/protocol_v1.go | 4 - agent/run/recover.go | 36 ++ agent/run/recover_test.go | 99 +++++ agent/run/regression_test.go | 10 +- agent/run/runtest/feature.go | 6 +- agent/run/runtime.go | 4 + agent/run/runtime_helpers_test.go | 8 +- agent/run/runtimetest/conformance.go | 59 ++- agent/run/runtimetest/recovery.go | 284 ++++++++++++ agent/run/snapshot.go | 121 ++++++ agent/run/snapshot_test.go | 81 ++++ agent/run/sqlitestore/store.go | 573 +++++++++++++++++++++++++ agent/run/sqlitestore/store_test.go | 380 ++++++++++++++++ agent/run/store.go | 218 ++++++---- agent/run/store_test.go | 22 +- agent/run/stored_runtime.go | 277 +++++++----- docs/design/agent-artifact.md | 2 +- docs/design/agent-run.md | 52 ++- docs/design/agent-runtime-refactor.md | 33 +- docs/design/agent-session-extension.md | 2 +- docs/design/agent-session.md | 4 +- go.mod | 10 +- go.sum | 52 ++- sdk/request.go | 11 +- 49 files changed, 2485 insertions(+), 584 deletions(-) create mode 100644 agent/run/loop/lease.go create mode 100644 agent/run/loop/lease_test.go create mode 100644 agent/run/recover.go create mode 100644 agent/run/recover_test.go create mode 100644 agent/run/runtimetest/recovery.go create mode 100644 agent/run/snapshot.go create mode 100644 agent/run/snapshot_test.go create mode 100644 agent/run/sqlitestore/store.go create mode 100644 agent/run/sqlitestore/store_test.go diff --git a/agent/run/alias_test.go b/agent/run/alias_test.go index d1f8b72..7be4356 100644 --- a/agent/run/alias_test.go +++ b/agent/run/alias_test.go @@ -66,16 +66,16 @@ func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { "p": cj(`{"b":2,"a":1}`), }, } - reqDigest, err := DigestRequest(req) + reqDigest, err := ProtocolV1.DigestRequest(req) if err != nil { t.Fatal(err) } - toolsDigest, err := DigestToolSpecs(nil) + toolsDigest, err := ProtocolV1.DigestToolSpecs(nil) if err != nil { t.Fatal(err) } model := ModelRef(req.Model) - binding, err := DigestModelStepBinding(model, reqDigest, toolsDigest) + binding, err := ProtocolV1.DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } diff --git a/agent/run/canonical_test.go b/agent/run/canonical_test.go index d8c0f0d..9d0714d 100644 --- a/agent/run/canonical_test.go +++ b/agent/run/canonical_test.go @@ -99,7 +99,7 @@ func TestCanonicalDeterminism(t *testing.T) { func TestDigestCommandIdentity(t *testing.T) { cmd := StartToolCall{StepID: "s1", CallID: "c1", Claim: "claim-1"} - d1, err := DigestCommand("start_tool_call", cmd) + d1, err := ProtocolV1.DigestCommand("start_tool_call", cmd) if err != nil { t.Fatal(err) } @@ -107,12 +107,12 @@ func TestDigestCommandIdentity(t *testing.T) { t.Fatalf("bad digest wire form: %s", d1) } // Same content, same digest. - d2, _ := DigestCommand("start_tool_call", StartToolCall{StepID: "s1", CallID: "c1", Claim: "claim-1"}) + d2, _ := ProtocolV1.DigestCommand("start_tool_call", StartToolCall{StepID: "s1", CallID: "c1", Claim: "claim-1"}) if d1 != d2 { t.Fatal("same command produced different digests") } // Different content differs. - d3, _ := DigestCommand("start_tool_call", StartToolCall{StepID: "s1", CallID: "c2", Claim: "claim-1"}) + d3, _ := ProtocolV1.DigestCommand("start_tool_call", StartToolCall{StepID: "s1", CallID: "c2", Claim: "claim-1"}) if d1 == d3 { t.Fatal("different commands produced the same digest") } @@ -129,7 +129,7 @@ func TestDigestCommandIdentity(t *testing.T) { t.Fatal("schema version did not affect digest preimage") } // Type mismatch is rejected. - if _, err := DigestCommand("cancel_run", cmd); err == nil { + if _, err := ProtocolV1.DigestCommand("cancel_run", cmd); err == nil { t.Fatal("expected type/variant mismatch error") } } @@ -211,7 +211,7 @@ func TestSchemaVersion1Golden(t *testing.T) { if string(body) != wantBody { t.Fatalf("golden body changed:\n got %q\nwant %q", body, wantBody) } - d, err := DigestCommand("cancel_run", cmd) + d, err := ProtocolV1.DigestCommand("cancel_run", cmd) if err != nil { t.Fatal(err) } @@ -223,7 +223,7 @@ func TestSchemaVersion1Golden(t *testing.T) { } fact := InputAccepted{Input: AgentInput{ID: "in-1", Payload: cj(`{"text":"hi"}`)}} - fbody, err := EncodeFact("input_accepted", fact) + fbody, err := ProtocolV1.EncodeFact("input_accepted", fact) if err != nil { t.Fatal(err) } diff --git a/agent/run/codec_test.go b/agent/run/codec_test.go index 187944d..5a55e91 100644 --- a/agent/run/codec_test.go +++ b/agent/run/codec_test.go @@ -26,9 +26,9 @@ func TestCommandEnvelopeJSONRoundTripRestoresVariants(t *testing.T) { AcceptInput{Input: AgentInput{ID: "in", Payload: cj(`{"q":"hi"}`)}}, } for _, cmd := range commands { - env, err := BuildEnvelope("run-1", CommandID("cmd-"+commandType(cmd)), cmd) + env, err := ProtocolV1.BuildEnvelope("run-1", CommandID("cmd-"+commandType(cmd)), cmd) if err != nil { - t.Fatalf("BuildEnvelope(%T): %v", cmd, err) + t.Fatalf("ProtocolV1.BuildEnvelope(%T): %v", cmd, err) } raw, err := json.Marshal(env) if err != nil { @@ -65,12 +65,12 @@ func TestAgentEventJSONRoundTripRestoresVariants(t *testing.T) { } for i, fact := range facts { typ := factType(fact) - digest, err := DigestFact(typ, fact) + digest, err := ProtocolV1.DigestFact(typ, fact) if err != nil { - t.Fatalf("DigestFact(%T): %v", fact, err) + t.Fatalf("ProtocolV1.DigestFact(%T): %v", fact, err) } event := AgentEvent{ - SchemaVersion: currentSchemaVersion, + SchemaVersion: SchemaVersion1, Type: typ, RunID: "run-1", Revision: uint64(i + 1), @@ -105,12 +105,12 @@ func TestTransitionRecordJSONRoundTripRestoresVariants(t *testing.T) { events := make([]AgentEvent, len(facts)) for i, fact := range facts { typ := factType(fact) - digest, err := DigestFact(typ, fact) + digest, err := ProtocolV1.DigestFact(typ, fact) if err != nil { t.Fatal(err) } events[i] = AgentEvent{ - SchemaVersion: currentSchemaVersion, + SchemaVersion: SchemaVersion1, Type: typ, RunID: "run-1", Revision: 1, @@ -151,7 +151,7 @@ func TestTransitionRecordJSONRoundTripRestoresVariants(t *testing.T) { func TestWireCodecRejectsAmbiguousJSONBeforeVariantDecode(t *testing.T) { cmd := AcceptInput{Input: AgentInput{ID: "in", Payload: cj(`1`)}} - env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", "in"), cmd) + env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", "in"), cmd) if err != nil { t.Fatal(err) } @@ -171,7 +171,7 @@ func TestWireCodecRejectsAmbiguousJSONBeforeVariantDecode(t *testing.T) { } func TestWireCodecRejectsUnknownTypeAndDigestMismatch(t *testing.T) { - env, err := BuildEnvelope("run-1", "cmd-1", CancelRun{}) + env, err := ProtocolV1.BuildEnvelope("run-1", "cmd-1", CancelRun{}) if err != nil { t.Fatal(err) } @@ -189,11 +189,11 @@ func TestWireCodecRejectsUnknownTypeAndDigestMismatch(t *testing.T) { } fact := RunEnded{End: RunCompletedEnd{}} - digest, err := DigestFact(factType(fact), fact) + digest, err := ProtocolV1.DigestFact(factType(fact), fact) if err != nil { t.Fatal(err) } - event := AgentEvent{SchemaVersion: currentSchemaVersion, Type: factType(fact), RunID: "run-1", Revision: 1, CommandID: "cmd", CommandDigest: env.Digest, Digest: digest, Fact: fact} + event := AgentEvent{SchemaVersion: SchemaVersion1, Type: factType(fact), RunID: "run-1", Revision: 1, CommandID: "cmd", CommandDigest: env.Digest, Digest: digest, Fact: fact} raw, err = json.Marshal(event) if err != nil { t.Fatal(err) @@ -212,7 +212,7 @@ func TestRunEndedTaggedUnionRejectsInvalidValues(t *testing.T) { "unknown end variant": {End: fakeRunEnd{}}, } { t.Run(name, func(t *testing.T) { - if _, err := DigestFact("run_ended", fact); err == nil { + if _, err := ProtocolV1.DigestFact("run_ended", fact); err == nil { t.Fatal("invalid tagged terminal value was accepted") } }) diff --git a/agent/run/commit.go b/agent/run/commit.go index e8448c5..a0ac1bc 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -247,13 +247,6 @@ func EvaluateCommit( return CommitDecision{Kind: DecisionApply, NewState: state, Events: transition.Events, Transition: transition}, nil } -// BuildEnvelope assembles a CommandEnvelope with its type discriminator and -// canonical digest. This is the only sanctioned construction path; callers -// never hand-assemble envelope fields (RUN-WIR-3). -func BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) { - return ProtocolV1.BuildEnvelope(run, id, cmd) -} - // checkDerivedCommandID enforces the derived-identity rules of RUN-WIR-3. // AcceptInput derives from (RunID, InputID); approval/rejection/answer derive // from (RunID, StepID, CallID, ResponseID). Approve and reject of the same diff --git a/agent/run/creation.go b/agent/run/creation.go index 5197bed..160d297 100644 --- a/agent/run/creation.go +++ b/agent/run/creation.go @@ -106,10 +106,6 @@ var ( ErrRunNotFound = errors.New("agent: run not found") ) -func cloneRuntimeSnapshot(snapshot RuntimeSnapshot) RuntimeSnapshot { - return RuntimeSnapshot{State: cloneMachineState(&snapshot.State), Revision: snapshot.Revision, SchemaVersion: snapshot.SchemaVersion} -} - func cloneRunHeader(header RunHeader) RunHeader { header.InitialState = cloneMachineState(&header.InitialState) return header diff --git a/agent/run/creation_test.go b/agent/run/creation_test.go index f95e325..2839e53 100644 --- a/agent/run/creation_test.go +++ b/agent/run/creation_test.go @@ -22,7 +22,7 @@ func acceptInput(t testing.TB, rt Runtime, id RunID, input AgentInput) CommitRes if err != nil { t.Fatal(err) } - envelope, err := BuildEnvelope(id, DeriveInputCommandID(id, input.ID), AcceptInput{Input: input}) + envelope, err := ProtocolV1.BuildEnvelope(id, DeriveInputCommandID(id, input.ID), AcceptInput{Input: input}) if err != nil { t.Fatal(err) } diff --git a/agent/run/decide.go b/agent/run/decide.go index 57b3b44..3849d8f 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -20,15 +20,6 @@ func rejectionf(format string, args ...any) error { return fmt.Errorf("agent: reject: "+format, args...) } -// Decide validates one command against the current write schema and produces -// the complete fact sequence of its transition (RUN-MCH-3). Replay of a -// persisted Run must use ProtocolFor(run schema).Decide. -// -//nolint:gocritic // hugeParam: public protocol boundary is intentionally value-based: Decide(state, command) -> facts. -func Decide(s MachineState, c AgentCommand) ([]Fact, error) { - return ProtocolV1.Decide(s, c) -} - //nolint:gocritic // hugeParam: v1 Decide is value-based. func decideV1(s MachineState, c AgentCommand) ([]Fact, error) { if s.Status.Terminal() { diff --git a/agent/run/evolve.go b/agent/run/evolve.go index e8522a5..db6aee2 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -5,15 +5,6 @@ import ( "fmt" ) -// Evolve folds one fact with the current write schema. Persisted replay must -// use ProtocolFor on the fact or transition SchemaVersion so historical -// folding semantics stay bound to the Run that produced the fact. -// -//nolint:gocritic // hugeParam: public fold boundary must stay value-based: Evolve(state, fact) -> new state. -func Evolve(s MachineState, f Fact) (MachineState, error) { - return ProtocolV1.Evolve(s, f) -} - // evolveV1 is the current fold semantics for the pre-release SchemaVersion1. // //nolint:gocritic // hugeParam: v1 fold body intentionally preserves value-state semantics. diff --git a/agent/run/fold.go b/agent/run/fold.go index af55cac..d241ba7 100644 --- a/agent/run/fold.go +++ b/agent/run/fold.go @@ -122,38 +122,13 @@ func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineS return state, uint64(revision), nil } -// statesEquivalent compares two states via their canonical serialization — -// the same identity rule the protocol uses everywhere else. +// statesEquivalent compares two states via their canonical snapshot encoding, +// the same identity rule the protocol uses for the initial-state digest. func statesEquivalent(a, b *MachineState) bool { - ab, errA := marshalCanonical(stateComparable(a)) - bb, errB := marshalCanonical(stateComparable(b)) + ab, errA := encodeMachineStateV1(a) + bb, errB := encodeMachineStateV1(b) if errA != nil || errB != nil { return false } return bytes.Equal(ab, bb) } - -// stateComparable flattens MachineState including the interface-typed Current -// step, which encoding/json cannot round-trip on its own. -func stateComparable(s *MachineState) map[string]any { - m := map[string]any{ - "runId": s.RunID, "status": s.Status, - "modelSteps": s.ModelSteps, "lastClosedStep": s.LastClosedStep, - "usage": s.Usage, "pendingInputs": s.PendingInputs, - "lastModelResult": s.LastModelResult, "result": s.Result, - } - if s.LastToolStep != nil { - m["lastToolStep"] = s.LastToolStep - } - switch cur := s.Current.(type) { - case Open: - m["current"] = "open" - case ModelStep: - m["current"] = "model" - m["modelStep"] = cur - case ToolStep: - m["current"] = "tool" - m["toolStep"] = cur - } - return m -} diff --git a/agent/run/fold_test.go b/agent/run/fold_test.go index 05ab43b..8929d04 100644 --- a/agent/run/fold_test.go +++ b/agent/run/fold_test.go @@ -10,7 +10,7 @@ import ( // Event-sourcing arbitration tests (RUN-SCP-1, RUN-CMT-2): the complete canonical // TransitionRecord is the diagnostic commit record; the snapshot is a -// rebuildable same-transaction projection; the revision watermark witnesses +// rebuildable same-transaction projection; the head revision witnesses // log-tail completeness. // fullRunRuntime drives one complete run (prepare -> model -> tool -> done) @@ -112,14 +112,14 @@ func TestRebuildRepairsCorruptedSnapshot(t *testing.T) { } } -// A log tail below the watermark halts with ErrLogTruncated: accepted facts -// are gone and continuing would repeat gated executions. +// A log tail below the head revision halts with ErrLogTruncated: accepted +// facts are gone and continuing would repeat gated executions. func TestRebuildHaltsOnTruncatedTail(t *testing.T) { rt := fullRunRuntime(t) entry := memoryEntry(t, rt) entry.mu.Lock() - // Simulate selective damage: drop the last transition while the watermark - // (separate storage in a durable adapter) survives. + // Simulate selective damage: drop the last transition while the head + // revision (separate storage in a durable adapter) survives. entry.log = cloneTransitionRecords(entry.log[:len(entry.log)-1]) entry.mu.Unlock() @@ -257,7 +257,7 @@ func TestGoldenEventStreamV1(t *testing.T) { if maxRev != 9 { t.Fatalf("golden stream has %d transitions, want 9", maxRev) } - stateBytes, err := marshalCanonical(stateComparable(&folded)) + stateBytes, err := ProtocolV1.EncodeMachineState(&folded) if err != nil { t.Fatal(err) } diff --git a/agent/run/header.go b/agent/run/header.go index 994ecde..a13fbfc 100644 --- a/agent/run/header.go +++ b/agent/run/header.go @@ -6,12 +6,6 @@ import ( "github.com/memohai/twilight/agent/es" ) -// snapshotSchemaVersion versions the MachineState wire shape used inside -// RunHeader.InitialState. It is independent of the event SchemaVersion. Both -// shapes remain pre-release until the protocol is published; after publication -// each can evolve only through its documented versioning rules (RUN-CMP-1). -const snapshotSchemaVersion uint16 = 1 - func encodeMachineStateVersion(schemaVersion uint16, s *MachineState) ([]byte, error) { proto, err := ProtocolFor(schemaVersion) if err != nil { @@ -54,36 +48,6 @@ type runHeaderDigestBody struct { CausationID es.CausationID `json:"causationId,omitempty"` } -// BuildRunHeader creates the immutable header for a new Run. The initial -// state is the minimal InitializeRun state: no seed input, no model policy, -// no limits — the first input arrives as the Revision-1 AcceptInput -// transition (RUN-NEW-1). -func BuildRunHeader(runID RunID, causationID es.CausationID) (RunHeader, error) { - initial, err := InitializeRun(runID) - if err != nil { - return RunHeader{}, err - } - stateBytes, err := ProtocolV1.EncodeMachineState(&initial) - if err != nil { - return RunHeader{}, err - } - stateDigest := sha256Digest(stateBytes) - header := RunHeader{ - SchemaVersion: ProtocolV1.Version(), - RunID: runID, - InitialStateVersion: snapshotSchemaVersion, - InitialState: initial, - InitialStateDigest: stateDigest, - CausationID: causationID, - } - headerDigest, err := digestRunHeader(&header) - if err != nil { - return RunHeader{}, err - } - header.HeaderDigest = headerDigest - return header, nil -} - func digestRunHeader(h *RunHeader) (Digest, error) { body, err := encodeEnvelopeBody(h.SchemaVersion, "run_header", runHeaderDigestBody{ SchemaVersion: h.SchemaVersion, diff --git a/agent/run/header_test.go b/agent/run/header_test.go index 362f801..32ac9d1 100644 --- a/agent/run/header_test.go +++ b/agent/run/header_test.go @@ -7,8 +7,22 @@ import ( "github.com/memohai/twilight/agent/es" ) +func buildTestHeader(t *testing.T, causation es.CausationID) RunHeader { + t.Helper() + newRun, err := BuildNewRun("run-1", causation) + if err != nil { + t.Fatal(err) + } + h, err := BuildRunHeaderFromNewRun(newRun) + if err != nil { + t.Fatal(err) + } + return h +} + func TestBuildRunHeaderRoundTrip(t *testing.T) { - h, err := BuildRunHeader("run-1", es.CausationID("session:entry-9")) + h := buildTestHeader(t, es.CausationID("session:entry-9")) + var err error if err != nil { t.Fatal(err) } @@ -24,10 +38,7 @@ func TestBuildRunHeaderRoundTrip(t *testing.T) { } func TestValidateRunHeaderRejectsTampering(t *testing.T) { - h, err := BuildRunHeader("run-1", "") - if err != nil { - t.Fatal(err) - } + h := buildTestHeader(t, "") // Tampered causation changes the header digest preimage. tampered := h tampered.CausationID = "forged" @@ -58,7 +69,7 @@ func TestCommitRejectsCommandSchemaMismatch(t *testing.T) { t.Fatal(err) } in := AgentInput{ID: "seed", Payload: MustParseCanonicalJSON(`{"q":"hi"}`)} - env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), NextStep(in)) + env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), NextStep(in)) if err != nil { t.Fatal(err) } diff --git a/agent/run/loop/contract.go b/agent/run/loop/contract.go index 438452a..37cfb46 100644 --- a/agent/run/loop/contract.go +++ b/agent/run/loop/contract.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "time" run "github.com/memohai/twilight/agent/run" @@ -157,6 +158,11 @@ type ExecutionPolicy struct { // MaxParallel bounds local tool workers. Zero means all eligible calls in // the current batch may run concurrently. MaxParallel int + // LeaseRenewInterval is how often a running model or tool worker renews + // its execution lease through Runtime.RenewLease. It must be well below + // the Runtime's LeaseTTL (RUN-CMT-8); zero disables renewal and is only + // correct for a Runtime whose leases do not expire. + LeaseRenewInterval time.Duration } type LoopDisposition uint8 diff --git a/agent/run/loop/helpers_test.go b/agent/run/loop/helpers_test.go index d146588..61f2cab 100644 --- a/agent/run/loop/helpers_test.go +++ b/agent/run/loop/helpers_test.go @@ -21,7 +21,7 @@ func newTestRuntime(t *testing.T) Runtime { if _, err := rt.Create(context.Background(), newRun); err != nil { t.Fatal(err) } - env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", "seed"), AcceptInput{ + env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", "seed"), AcceptInput{ Input: AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)}, }) if err != nil { diff --git a/agent/run/loop/lease.go b/agent/run/loop/lease.go new file mode 100644 index 0000000..093d142 --- /dev/null +++ b/agent/run/loop/lease.go @@ -0,0 +1,59 @@ +package loop + +import ( + "context" + "errors" + "time" + + run "github.com/memohai/twilight/agent/run" +) + +// keepLease renews the execution lease behind grant every interval until the +// returned stop function is called or the renewal is rejected. The worker +// context it returns is cancelled when the Runtime reports the lease is no +// longer ours (ErrStaleRuntime): the target was recovered under us, so +// continuing the effect can only produce a result nobody will accept. +// +// With interval <= 0 renewal is disabled and the worker context is ctx itself. +func (l *Loop) keepLease(ctx context.Context, runtime run.Runtime, runID run.RunID, stepID run.StepID, callID run.CallID, grant run.ExecutionGrant) (workerCtx context.Context, stop func()) { + interval := l.Execution.LeaseRenewInterval + if interval <= 0 { + return ctx, func() {} + } + workerCtx, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + control := context.WithoutCancel(ctx) + go func() { + defer close(done) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-workerCtx.Done(): + return + case <-ticker.C: + } + err := runtime.RenewLease(control, runID, stepID, callID, grant) + if err == nil { + continue + } + if errors.Is(err, run.ErrStaleRuntime) || errors.Is(err, run.ErrRunTerminal) || errors.Is(err, run.ErrRunNotFound) { + cancel() + return + } + // Transport failure: keep the worker running and retry on the + // next tick; the lease still has TTL minus one interval left. + } + }() + var once bool + return workerCtx, func() { + if once { + return + } + once = true + cancel() + <-done + } +} diff --git a/agent/run/loop/lease_test.go b/agent/run/loop/lease_test.go new file mode 100644 index 0000000..c11a3e5 --- /dev/null +++ b/agent/run/loop/lease_test.go @@ -0,0 +1,100 @@ +package loop + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + . "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/sdk" +) + +type renewCountingRuntime struct { + Runtime + renewals atomic.Int32 +} + +func (r *renewCountingRuntime) RenewLease(ctx context.Context, runID RunID, stepID StepID, callID CallID, grant ExecutionGrant) error { + r.renewals.Add(1) + return r.Runtime.RenewLease(ctx, runID, stepID, callID, grant) +} + +// A tool that runs longer than the lease TTL keeps its lease alive through +// the Loop heartbeat, so the scanner does not settle it as Unknown and the +// worker's own result is accepted. +func TestLoopRenewsLeaseDuringLongTool(t *testing.T) { + var mu sync.Mutex + clock := time.Unix(1000, 0) + now := func() time.Time { mu.Lock(); defer mu.Unlock(); return clock } + advance := func(d time.Duration) { mu.Lock(); defer mu.Unlock(); clock = clock.Add(d) } + + base := NewRuntimeWithOptions(NewMemoryStore(), RuntimeOptions{LeaseTTL: 200 * time.Millisecond, Now: now}) + rt := &renewCountingRuntime{Runtime: base} + newRun, err := BuildNewRun("run-1", "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(context.Background(), newRun); err != nil { + t.Fatal(err) + } + env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", "seed"), AcceptInput{Input: AgentInput{ID: "seed", Payload: cj(`{}`)}}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(context.Background(), CommitRequest{Command: env}); err != nil { + t.Fatal(err) + } + + spec := toolSpec(t, "slow", DirectExecution) + slow := &fakeTool{ref: "slow", def: spec.Definition.SDK(), policy: DirectExecution, + execute: func(ctx context.Context, req ToolExecutionRequest) ToolExecutionOutcome { + // Simulate a tool that outlives the TTL: advance the clock past + // several deadlines while the heartbeat keeps renewing. + for i := 0; i < 4; i++ { + time.Sleep(30 * time.Millisecond) + advance(150 * time.Millisecond) + if _, err := rt.RecoverExpired(context.Background()); err != nil { + return ToolExecutionFailed{Failure: ToolFailure{Class: FailureExecution, Message: err.Error()}} + } + } + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: req.Arguments}} + }} + slowCall := sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{TotalTokens: 2}, + ToolCalls: []sdk.ToolCall{{ToolCallID: "c1", ToolName: "slow", Input: `{"x":1}`}}} + invoker := &fakeInvoker{results: []sdk.ModelResult{slowCall, textResult("done")}} + interpreter, err := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"slow": slow}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{LeaseRenewInterval: 10 * time.Millisecond}, false) + if err != nil { + t.Fatal(err) + } + res, err := interpreter.Run(context.Background(), rt, "run-1", nil) + if err != nil { + t.Fatal(err) + } + if res.Disposition != LoopFinished || res.Result == nil || res.Result.Status != RunCompleted { + t.Fatalf("res = %+v", res) + } + if rt.renewals.Load() == 0 { + t.Fatal("lease was never renewed") + } + record, err := rt.Record(context.Background(), "run-1") + if err != nil { + t.Fatal(err) + } + for _, tr := range record.Transitions { + for _, ev := range tr.Events { + if f, ok := ev.Fact.(ToolCallFailed); ok && f.Outcome == ToolOutcomeUnknown { + t.Fatalf("tool call settled Unknown despite heartbeat: %+v", f) + } + } + } +} + +func TestNewRejectsNegativeLeaseRenewInterval(t *testing.T) { + _, err := New(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{LeaseRenewInterval: -1}, false) + if err == nil { + t.Fatal("negative LeaseRenewInterval accepted") + } +} diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index ebfdbc5..a2ac6bd 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -52,6 +52,9 @@ func New(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy if policy.MaxParallel < 0 { return nil, errors.New("agent: loop: negative MaxParallel") } + if policy.LeaseRenewInterval < 0 { + return nil, errors.New("agent: loop: negative LeaseRenewInterval") + } return &Loop{Models: models, Tools: tools, Planner: planner, Execution: policy, Streaming: streaming, starts: make(map[startKey]startAttempt), settlements: make(map[startKey]settlementAttempt), runs: make(map[run.RunID]struct{})}, nil } diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go index 3431704..f62f25d 100644 --- a/agent/run/loop/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -102,7 +102,7 @@ func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { if err != nil { t.Fatal(err) } - d, err := DigestToolDefinition(frozen) + d, err := ProtocolV1.DigestToolDefinition(frozen) if err != nil { t.Fatal(err) } diff --git a/agent/run/loop/model.go b/agent/run/loop/model.go index 6ecaa3b..4eae0d9 100644 --- a/agent/run/loop/model.go +++ b/agent/run/loop/model.go @@ -140,9 +140,11 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve failure := run.StepFailure{Class: run.FailureMalformedModel, Message: err.Error()} completion = run.RejectModelResult{StepID: stepID, Failure: failure, Disposition: l.modelRejectDisposition(modelStep, failure)} } else { - result, invokeErr := l.invokeModel(ctx, invoker, &sdkRequest, runID, stepID, events) + workerCtx, stopLease := l.keepLease(ctx, runtime, runID, stepID, "", start.Grant) + result, invokeErr := l.invokeModel(workerCtx, invoker, &sdkRequest, runID, stepID, events) + stopLease() switch { - case invokeErr != nil && ctx.Err() != nil: + case invokeErr != nil && workerCtx.Err() != nil: completion = run.RecoverModelExecution{StepID: stepID, Claim: attempt.claim} case invokeErr != nil: completion = run.SubmitModelFailure{StepID: stepID, Failure: run.StepFailure{Class: run.FailureProvider, Message: invokeErr.Error()}} diff --git a/agent/run/loop/regression_test.go b/agent/run/loop/regression_test.go index 99ddd90..b200e0b 100644 --- a/agent/run/loop/regression_test.go +++ b/agent/run/loop/regression_test.go @@ -72,7 +72,7 @@ func TestRegressionAliasedToolRefExecutes(t *testing.T) { if err != nil { t.Fatal(err) } - d, err := DigestToolDefinition(frozenDef) + d, err := ProtocolV1.DigestToolDefinition(frozenDef) if err != nil { t.Fatal(err) } diff --git a/agent/run/loop/tool.go b/agent/run/loop/tool.go index 614469e..77dc2d8 100644 --- a/agent/run/loop/tool.go +++ b/agent/run/loop/tool.go @@ -222,7 +222,9 @@ func (l *Loop) settleWorkers(ctx context.Context, runtime run.Runtime, events Ev Arguments: w.call.Arguments, Progress: &progressSink{events: events, run: runID, step: stepID, call: w.call.CallID}, } - outcome := executeToolSafely(ctx, w.tool, &req) + workerCtx, stopLease := l.keepLease(ctx, runtime, runID, stepID, w.call.CallID, w.grant) + outcome := executeToolSafely(workerCtx, w.tool, &req) + stopLease() var cmd run.AgentCommand switch o := outcome.(type) { diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go index ae39f4a..c5296a5 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -25,9 +25,9 @@ func newRun(t *testing.T) MachineState { func mustDecide(t *testing.T, s MachineState, c AgentCommand) []Fact { t.Helper() - facts, err := Decide(s, c) + facts, err := ProtocolV1.Decide(s, c) if err != nil { - t.Fatalf("Decide(%T): %v", c, err) + t.Fatalf("ProtocolV1.Decide(%T): %v", c, err) } return facts } @@ -36,9 +36,9 @@ func fold(t *testing.T, s MachineState, facts []Fact) MachineState { t.Helper() for _, f := range facts { var err error - s, err = Evolve(s, f) + s, err = ProtocolV1.Evolve(s, f) if err != nil { - t.Fatalf("Evolve(%T): %v", f, err) + t.Fatalf("ProtocolV1.Evolve(%T): %v", f, err) } } return s @@ -62,16 +62,16 @@ func buildPrepare(t *testing.T, s MachineState, req sdk.Request, specs []ToolSpe if err != nil { t.Fatal(err) } - reqDigest, err := DigestRequest(frozenReq) + reqDigest, err := ProtocolV1.DigestRequest(frozenReq) if err != nil { t.Fatal(err) } - toolsDigest, err := DigestToolSpecs(specs) + toolsDigest, err := ProtocolV1.DigestToolSpecs(specs) if err != nil { t.Fatal(err) } model := ModelRef(frozenReq.Model) - binding, err := DigestModelStepBinding(model, reqDigest, toolsDigest) + binding, err := ProtocolV1.DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -98,7 +98,7 @@ func makeSpec(t *testing.T, def sdk.ToolDefinition, policy ResponsePolicy) ToolS if err != nil { t.Fatal(err) } - d, err := DigestToolDefinition(frozen) + d, err := ProtocolV1.DigestToolDefinition(frozen) if err != nil { t.Fatal(err) } @@ -107,7 +107,7 @@ func makeSpec(t *testing.T, def sdk.ToolDefinition, policy ResponsePolicy) ToolS func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDecision, reason string) Digest { t.Helper() - d, err := DigestToolResponseDecision(kind, decision, reason) + d, err := ProtocolV1.DigestToolResponseDecision(kind, decision, reason) if err != nil { t.Fatal(err) } @@ -116,7 +116,7 @@ func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDe func responsePayloadDigest(t *testing.T, payload CanonicalJSON) Digest { t.Helper() - d, err := DigestToolResponsePayload(payload) + d, err := ProtocolV1.DigestToolResponsePayload(payload) if err != nil { t.Fatal(err) } @@ -221,7 +221,7 @@ func TestPrepareRejectsIncompleteInputIDs(t *testing.T) { s := newRun(t) prep, _ := buildPrepare(t, s, testRequest(), nil) prep.InputIDs = nil - if _, err := Decide(s, prep); err == nil { + if _, err := ProtocolV1.Decide(s, prep); err == nil { t.Fatal("prepare with missing InputIDs accepted") } } @@ -268,7 +268,7 @@ func TestExternalResponseRequiresPayloadDigest(t *testing.T) { s = fold(t, s, facts) respID := opened.Calls[0].Response.ID payload := cj(`{"answer":"ok"}`) - if _, err := Decide(s, SubmitToolResponse{StepID: opened.StepID, CallID: "c1", ResponseID: respID, ResponseDigest: "sha256:bad", Payload: payload}); err == nil { + if _, err := ProtocolV1.Decide(s, SubmitToolResponse{StepID: opened.StepID, CallID: "c1", 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: "c1", ResponseID: respID, @@ -284,7 +284,7 @@ func TestExternalResponseRequiresPayloadDigest(t *testing.T) { opened = facts[1].(ToolStepOpened) s = fold(t, s, facts) respID = opened.Calls[0].Response.ID - facts, err := Decide(s, RejectToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: respID, + facts, err := ProtocolV1.Decide(s, RejectToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: respID, ResponseDigest: responseDecisionDigest(t, ResponseExternal, ResponseDecisionRejected, "user dismissed"), Reason: "user dismissed"}) if err != nil { t.Fatal(err) @@ -322,7 +322,7 @@ func TestToolSchedulingRejectsUnknownMode(t *testing.T) { s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) b := makeBinding(t, "c1", spec, `{}`) - _, err := Decide(s, SubmitModelResult{ + _, err := ProtocolV1.Decide(s, SubmitModelResult{ StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}, Scheduling: ToolScheduling{Mode: "round-robin"}, }) @@ -516,14 +516,14 @@ func TestAcceptInputIdempotentPerID(t *testing.T) { // AcceptInput rejected while a step is current. prep, _ := buildPrepare(t, s, testRequest(), nil) s = fold(t, s, mustDecide(t, s, prep)) - if _, err := Decide(s, NextStep(AgentInput{ID: "in-3"})); err == nil { + if _, err := ProtocolV1.Decide(s, NextStep(AgentInput{ID: "in-3"})); err == nil { t.Fatal("AcceptInput accepted with a current step") } } func TestAcceptInputRejectsSeedDuplicateID(t *testing.T) { s := newRun(t) - _, err := Decide(s, NextStep(AgentInput{ID: "seed", Payload: cj(`{"q":"other"}`)})) + _, 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) } @@ -539,7 +539,7 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { s := minimal for _, id := range ids { var foldErr error - s, foldErr = Evolve(s, InputAccepted{Input: AgentInput{ID: id, Payload: cj(`null`)}}) + s, foldErr = ProtocolV1.Evolve(s, InputAccepted{Input: AgentInput{ID: id, Payload: cj(`null`)}}) if foldErr != nil { t.Fatal(foldErr) } @@ -548,15 +548,15 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { } prepared := func(ids ...InputID) ModelStepPrepared { request := ModelRequest{Model: string(testModel)} - requestDigest, err := DigestRequest(request) + requestDigest, err := ProtocolV1.DigestRequest(request) if err != nil { t.Fatal(err) } - toolsDigest, err := DigestToolSpecs(nil) + toolsDigest, err := ProtocolV1.DigestToolSpecs(nil) if err != nil { t.Fatal(err) } - binding, err := DigestModelStepBinding(testModel, requestDigest, toolsDigest) + binding, err := ProtocolV1.DigestModelStepBinding(testModel, requestDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -565,25 +565,25 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { t.Run("nonexistent input", func(t *testing.T) { s := withInputs("in-1") - if _, err := Evolve(s, prepared("missing")); err == nil { + 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 := Evolve(s, prepared("in-1")); err == nil { + 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 := Evolve(s, prepared("in-2", "in-1")); err == nil { + 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 := Evolve(s, prepared("in-1", "in-2")) + next, err := ProtocolV1.Evolve(s, prepared("in-1", "in-2")) if err != nil { t.Fatal(err) } @@ -596,7 +596,7 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { func TestEvolveRejectsModelPrepareOverCurrentStep(t *testing.T) { s := newRun(t) s, _ = advanceToExecuting(t, s, testRequest(), nil) - _, err := Evolve(s, ModelStepPrepared{ + _, err := ProtocolV1.Evolve(s, ModelStepPrepared{ StepID: "other", Model: testModel, Request: ModelRequest{Model: string(testModel)}, diff --git a/agent/run/memory_conformance_test.go b/agent/run/memory_conformance_test.go index d7e335e..4ac671a 100644 --- a/agent/run/memory_conformance_test.go +++ b/agent/run/memory_conformance_test.go @@ -2,6 +2,7 @@ package run_test import ( "testing" + "time" "github.com/memohai/twilight/agent/run" "github.com/memohai/twilight/agent/run/runtimetest" @@ -14,3 +15,15 @@ import ( func TestMemoryStoreRuntimeConformance(t *testing.T) { runtimetest.RunConformance(t, func() run.Runtime { return run.NewRuntime(run.NewMemoryStore()) }) } + +func TestMemoryStoreRuntimeRecovery(t *testing.T) { + runtimetest.RunRecoveryConformance(t, func(now func() time.Time, ttl time.Duration) run.Runtime { + return run.NewRuntimeWithOptions(run.NewMemoryStore(), run.RuntimeOptions{LeaseTTL: ttl, Now: now}) + }) +} + +func TestMemoryStoreRuntimeLeaseRenewal(t *testing.T) { + runtimetest.RunLeaseRenewalConformance(t, func(now func() time.Time, ttl time.Duration) run.Runtime { + return run.NewRuntimeWithOptions(run.NewMemoryStore(), run.RuntimeOptions{LeaseTTL: ttl, Now: now}) + }) +} diff --git a/agent/run/memory_store.go b/agent/run/memory_store.go index 675eeb1..09c29f0 100644 --- a/agent/run/memory_store.go +++ b/agent/run/memory_store.go @@ -7,8 +7,8 @@ import ( "time" ) -// MemoryStore is the in-process Store. Collection lock protects the Run map; -// each Run has an independent lock. Lease deadlines are stored; a zero +// MemoryStore is the in-process Store. The collection lock protects the Run +// map; each Run has an independent lock. Lease deadlines are stored; a zero // deadline never expires. type MemoryStore struct { mu sync.RWMutex @@ -20,11 +20,9 @@ type memoryRun struct { header RunHeader state MachineState revision uint64 - watermark uint64 - transitions map[CommandID]TransitionRecord log []TransitionRecord + transitions map[CommandID]int // index into log leases map[string]ExecutionLease - startGrants map[CommandID]ExecutionGrant } func NewMemoryStore() *MemoryStore { @@ -61,30 +59,30 @@ func (s *MemoryStore) entry(runID RunID) (*memoryRun, error) { return entry, nil } -func (e *memoryRun) stored() StoredRun { - return StoredRun{ - Header: e.header, - State: e.state, - Revision: e.revision, - Watermark: e.watermark, - Log: e.log, - Transitions: e.transitions, - Leases: e.leases, - StartGrants: e.startGrants, +// locked runs fn with the Run's lock held. +func (s *MemoryStore) locked(ctx context.Context, id RunID, fn func(*memoryRun) error) error { + if err := checkContext(ctx); err != nil { + return err + } + entry, err := s.entry(id) + if err != nil { + return err + } + if err := lockMemory(ctx, &entry.mu); err != nil { + return err + } + defer entry.mu.Unlock() + if err := checkContext(ctx); err != nil { + return err } + return fn(entry) } -func (e *memoryRun) apply(s StoredRun) { - e.header = s.Header - e.state = s.State - e.revision = s.Revision - e.watermark = s.Watermark - e.log = s.Log - e.transitions = s.Transitions - e.leases = s.Leases - e.startGrants = s.StartGrants +func (e *memoryRun) head() RunHead { + return cloneRunHead(&RunHead{Header: e.header, State: e.state, Revision: e.revision, Leases: e.leases}) } +//nolint:gocritic // hugeParam: Store.Create takes RunHeader by value as the persisted creation record. func (s *MemoryStore) Create(ctx context.Context, header RunHeader) (bool, RunHeader, error) { if err := checkContext(ctx); err != nil { return false, RunHeader{}, err @@ -104,55 +102,96 @@ func (s *MemoryStore) Create(ctx context.Context, header RunHeader) (bool, RunHe s.runs[header.RunID] = &memoryRun{ header: stored, state: cloneMachineState(&stored.InitialState), - transitions: make(map[CommandID]TransitionRecord), + transitions: make(map[CommandID]int), leases: make(map[string]ExecutionLease), - startGrants: make(map[CommandID]ExecutionGrant), } return true, cloneRunHeader(stored), nil } -func (s *MemoryStore) View(ctx context.Context, id RunID) (StoredRun, error) { - if err := checkContext(ctx); err != nil { - return StoredRun{}, err - } - entry, err := s.entry(id) - if err != nil { - return StoredRun{}, err - } - if err := lockMemory(ctx, &entry.mu); err != nil { - return StoredRun{}, err - } - defer entry.mu.Unlock() - if err := checkContext(ctx); err != nil { - return StoredRun{}, err - } - return cloneStoredRun(entry.stored()), nil +func (s *MemoryStore) LoadHead(ctx context.Context, id RunID) (RunHead, error) { + var head RunHead + err := s.locked(ctx, id, func(e *memoryRun) error { + head = e.head() + return nil + }) + return head, err } -func (s *MemoryStore) Update(ctx context.Context, id RunID, fn func(*StoredRun) error) error { - if err := checkContext(ctx); err != nil { - return err - } - entry, err := s.entry(id) - if err != nil { - return err - } - if err := lockMemory(ctx, &entry.mu); err != nil { - return err - } - defer entry.mu.Unlock() - if err := checkContext(ctx); err != nil { - return err - } - cur := cloneStoredRun(entry.stored()) - if err := fn(&cur); err != nil { - return err +func (s *MemoryStore) LoadLog(ctx context.Context, id RunID, from uint64) ([]TransitionRecord, error) { + var out []TransitionRecord + err := s.locked(ctx, id, func(e *memoryRun) error { + for i := range e.log { + if e.log[i].Revision >= from { + out = append(out, cloneTransitionRecord(&e.log[i])) + } + } + return nil + }) + return out, err +} + +func (s *MemoryStore) LoadRecord(ctx context.Context, id RunID) (RunHead, []TransitionRecord, error) { + var head RunHead + var log []TransitionRecord + err := s.locked(ctx, id, func(e *memoryRun) error { + head = e.head() + log = cloneTransitionRecords(e.log) + return nil + }) + return head, log, err +} + +type memoryTx struct{ e *memoryRun } + +func (t memoryTx) Head() RunHead { return t.e.head() } + +func (t memoryTx) LookupTransition(command CommandID) (TransitionRecord, bool, error) { + i, ok := t.e.transitions[command] + if !ok { + return TransitionRecord{}, false, nil } - entry.apply(cur) - return nil + return cloneTransitionRecord(&t.e.log[i]), true, nil } -func (s *MemoryStore) ListIDs(ctx context.Context) ([]RunID, error) { +func (s *MemoryStore) Commit(ctx context.Context, id RunID, fn func(RunTx) (*Append, error)) error { + if fn == nil { + return errors.New("agent: memory store: nil commit fn") + } + return s.locked(ctx, id, func(e *memoryRun) error { + a, err := fn(memoryTx{e}) + if err != nil || a == nil { + return err + } + head := RunHead{Revision: e.revision} + if err := ValidateAppend(id, &head, a); err != nil { + return err + } + if _, dup := e.transitions[a.Transition.CommandID]; dup { + return ErrCommandConflict + } + record := cloneTransitionRecord(&a.Transition) + e.log = append(e.log, record) + e.transitions[record.CommandID] = len(e.log) - 1 + e.state = cloneMachineState(&a.State) + e.revision = a.Transition.Revision + e.leases = applyLeaseOps(e.leases, a.Leases) + return nil + }) +} + +func (s *MemoryStore) RenewLease(ctx context.Context, id RunID, key string, grant ExecutionGrant, deadline time.Time) error { + return s.locked(ctx, id, func(e *memoryRun) error { + lease, ok := e.leases[key] + if !ok || grant == "" || lease.Grant != grant { + return ErrStaleRuntime + } + lease.Deadline = deadline + e.leases[key] = lease + return nil + }) +} + +func (s *MemoryStore) ExpiredLeases(ctx context.Context, before time.Time) ([]ExpiredLease, error) { if err := checkContext(ctx); err != nil { return nil, err } @@ -160,10 +199,37 @@ func (s *MemoryStore) ListIDs(ctx context.Context) ([]RunID, error) { return nil, errors.New("agent: memory store: nil store") } s.mu.RLock() - defer s.mu.RUnlock() + entries := make([]*memoryRun, 0, len(s.runs)) ids := make([]RunID, 0, len(s.runs)) - for id := range s.runs { + for id, e := range s.runs { + entries = append(entries, e) ids = append(ids, id) } - return ids, nil + s.mu.RUnlock() + var out []ExpiredLease + for i, e := range entries { + if err := lockMemory(ctx, &e.mu); err != nil { + return nil, err + } + for key, lease := range e.leases { + if !lease.Deadline.IsZero() && !lease.Deadline.After(before) { + out = append(out, ExpiredLease{RunID: ids[i], Key: key, Lease: lease}) + } + } + e.mu.Unlock() + } + return out, nil +} + +func (s *MemoryStore) ReplaceSnapshot(ctx context.Context, id RunID, revision uint64, state *MachineState) error { + return s.locked(ctx, id, func(e *memoryRun) error { + if e.revision != revision { + return ErrAppendConflict + } + if state.RunID != id { + return errors.New("agent: memory store: snapshot RunID mismatch") + } + e.state = cloneMachineState(state) + return nil + }) } diff --git a/agent/run/protocol.go b/agent/run/protocol.go index 41faa5e..9b68402 100644 --- a/agent/run/protocol.go +++ b/agent/run/protocol.go @@ -11,9 +11,6 @@ import ( // Once a schema is published, its encoding and folding semantics are frozen. const SchemaVersion1 uint16 = 1 -// currentSchemaVersion is what new commands and facts are written with. -const currentSchemaVersion = SchemaVersion1 - // CommandEnvelope carries one command with its persisted protocol identity. type CommandEnvelope struct { SchemaVersion uint16 `json:"schemaVersion"` @@ -64,11 +61,14 @@ type Protocol struct { decide func(MachineState, AgentCommand) ([]Fact, error) evolve func(MachineState, Fact) (MachineState, error) encodeMachineState func(*MachineState) ([]byte, error) + decodeMachineState func([]byte) (MachineState, error) validateHeader func(*RunHeader) error } -// ProtocolV1 is the SchemaVersion1 binding. New Runs write with this protocol; -// replay of a persisted v1 Run must keep using it after later versions exist. +// 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. var ProtocolV1 = Protocol{ version: SchemaVersion1, digestRequest: digestRequestV1, @@ -83,6 +83,7 @@ var ProtocolV1 = Protocol{ decide: decideV1, evolve: evolveV1, encodeMachineState: encodeMachineStateV1, + decodeMachineState: decodeMachineStateV1, validateHeader: validateHeaderV1, } @@ -235,6 +236,9 @@ func (p Protocol) BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (Comm }, nil } +// 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 @@ -242,41 +246,20 @@ func (p Protocol) EncodeMachineState(s *MachineState) ([]byte, error) { return p.encodeMachineState(s) } -func (p Protocol) ValidateHeader(h *RunHeader) error { +// 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 err + return MachineState{}, err } - return p.validateHeader(h) + return p.decodeMachineState(raw) } -// EncodeCommand renders the canonical bytes of a command envelope, excluding -// the Digest field. -// -//nolint:gocritic // hugeParam: command envelope encoding is value-based and does not retain caller aliases. -func EncodeCommand(env CommandEnvelope) ([]byte, error) { - if env.Type == "" || env.Type != commandType(env.Command) { - return nil, fmt.Errorf("agent: encode: type %q does not match command variant", env.Type) - } - body, err := encodeEnvelopeBody(env.SchemaVersion, env.Type, env.Command) - if err != nil { - return nil, err +func (p Protocol) ValidateHeader(h *RunHeader) error { + if err := p.ready(); err != nil { + return err } - header := fmt.Sprintf("run:%d:%s:cmd:%d:%s:", len(env.RunID), env.RunID, len(env.ID), env.ID) - return append([]byte(header), body...), nil -} - -// DigestCommand computes the canonical digest of one command under the current -// write schema. Replay of a persisted Run must use ProtocolFor on the header. -func DigestCommand(typ string, command AgentCommand) (Digest, error) { - return ProtocolV1.DigestCommand(typ, command) -} - -func EncodeFact(typ string, fact Fact) ([]byte, error) { - return ProtocolV1.EncodeFact(typ, fact) -} - -func DigestFact(typ string, fact Fact) (Digest, error) { - return ProtocolV1.DigestFact(typ, fact) + return p.validateHeader(h) } type toolResponseDecisionDigestBody struct { @@ -285,56 +268,10 @@ type toolResponseDecisionDigestBody struct { Reason string `json:"reason,omitempty"` } -// DigestToolResponseDecision computes the content digest for approval and -// rejection ingress under the current write schema. The ResponseID remains the -// routing/idempotency key; this digest binds the decision payload. -func DigestToolResponseDecision(kind ResponseKind, decision ResponseDecision, reason string) (Digest, error) { - return ProtocolV1.DigestToolResponseDecision(kind, decision, reason) -} - type toolResponsePayloadDigestBody struct { Payload CanonicalJSON `json:"payload"` } -// DigestToolResponsePayload computes the content digest for an ExternalResponse -// answer payload under the current write schema. -func DigestToolResponsePayload(payload CanonicalJSON) (Digest, error) { - return ProtocolV1.DigestToolResponsePayload(payload) -} - -// DigestRequest covers every field of a frozen ModelRequest with no exclusions -// (RUN-WIR-2), using the current write schema. -// -//nolint:gocritic // hugeParam: digest covers the complete immutable ModelRequest value. -func DigestRequest(req ModelRequest) (Digest, error) { - return ProtocolV1.DigestRequest(req) -} - -// DigestToolDefinition covers one provider-neutral tool definition under the -// current write schema. -func DigestToolDefinition(def ToolDefinition) (Digest, error) { - return ProtocolV1.DigestToolDefinition(def) -} - -// DigestToolSpec covers one agent ToolSpec under the current write schema. -// -//nolint:gocritic // hugeParam: digest covers the complete immutable ToolSpec value. -func DigestToolSpec(spec ToolSpec) (Digest, error) { - return ProtocolV1.DigestToolSpec(spec) -} - -// DigestToolSpecs covers an ordered ToolSpec list under the current write -// schema: ref, schema, order and policy all participate (RUN-MCH-2). -func DigestToolSpecs(specs []ToolSpec) (Digest, error) { - return ProtocolV1.DigestToolSpecs(specs) -} - -// DigestModelStepBinding combines model, request digest and tools digest into -// the immutable ModelStep binding digest under the current write schema. -func DigestModelStepBinding(model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) { - return ProtocolV1.DigestModelStepBinding(model, requestDigest, toolsDigest) -} - // 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 diff --git a/agent/run/protocol_v1.go b/agent/run/protocol_v1.go index 39d3573..de1e93d 100644 --- a/agent/run/protocol_v1.go +++ b/agent/run/protocol_v1.go @@ -64,10 +64,6 @@ func digestToolResponsePayloadV1(payload CanonicalJSON) (Digest, error) { return sha256Digest(body), nil } -func encodeMachineStateV1(s *MachineState) ([]byte, error) { - return marshalCanonical(stateComparable(s)) -} - func initialStateVersionV1() uint16 { return 1 } func validateHeaderV1(h *RunHeader) error { diff --git a/agent/run/recover.go b/agent/run/recover.go new file mode 100644 index 0000000..dae8364 --- /dev/null +++ b/agent/run/recover.go @@ -0,0 +1,36 @@ +package run + +import ( + "context" + "errors" + "time" +) + +// RunExpiredRecovery calls RecoverExpired immediately and then every +// interval until ctx is cancelled. Hosts own this loop; Loop does not. +func RunExpiredRecovery(ctx context.Context, rt Runtime, interval time.Duration) error { + if err := checkContext(ctx); err != nil { + return err + } + if rt == nil { + return errors.New("agent: recover: nil runtime") + } + if interval <= 0 { + return errors.New("agent: recover: interval must be positive") + } + if _, err := rt.RecoverExpired(ctx); err != nil { + return err + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if _, err := rt.RecoverExpired(ctx); err != nil { + return err + } + } + } +} diff --git a/agent/run/recover_test.go b/agent/run/recover_test.go new file mode 100644 index 0000000..8831f22 --- /dev/null +++ b/agent/run/recover_test.go @@ -0,0 +1,99 @@ +package run + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +func TestRunExpiredRecoveryRejectsInvalidArgs(t *testing.T) { + rt := NewRuntime(NewMemoryStore()) + if err := RunExpiredRecovery(context.Background(), nil, time.Second); err == nil { + t.Fatal("nil runtime accepted") + } + if err := RunExpiredRecovery(context.Background(), rt, 0); err == nil { + t.Fatal("zero interval accepted") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := RunExpiredRecovery(ctx, rt, time.Second); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled ctx err = %v, want context.Canceled", err) + } +} + +func TestRunExpiredRecoveryRecoversExpiredModel(t *testing.T) { + var mu sync.Mutex + clock := time.Unix(1000, 0) + now := func() time.Time { + mu.Lock() + defer mu.Unlock() + return clock + } + set := func(ts time.Time) { + mu.Lock() + defer mu.Unlock() + clock = ts + } + rt := NewRuntimeWithOptions(NewMemoryStore(), RuntimeOptions{LeaseTTL: time.Second, Now: now}) + ctx := context.Background() + newRun, err := BuildNewRun("run-1", "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(ctx, newRun); err != nil { + t.Fatal(err) + } + in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} + env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(ctx, CommitRequest{Command: env}); err != nil { + t.Fatal(err) + } + snap, err := rt.Load(ctx, "run-1") + if err != nil { + t.Fatal(err) + } + prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) + if _, err := commitCmd(t, rt, cmdID, snap.Revision, "", prep); err != nil { + t.Fatal(err) + } + startEnv, err := ProtocolV1.BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: "claim-scan"}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(ctx, CommitRequest{Command: startEnv}); err != nil { + t.Fatal(err) + } + + loopCtx, cancel := context.WithCancel(ctx) + errCh := make(chan error, 1) + go func() { errCh <- RunExpiredRecovery(loopCtx, rt, 10*time.Millisecond) }() + set(time.Unix(1002, 0)) + + deadline := time.Now().Add(2 * time.Second) + for { + snap, err := rt.Load(ctx, "run-1") + if err != nil { + t.Fatal(err) + } + if ms, ok := snap.State.Current.(ModelStep); ok && ms.Status == ModelPrepared { + break + } + if time.Now().After(deadline) { + t.Fatalf("model still %+v", snap.State.Current) + } + select { + case err := <-errCh: + t.Fatalf("recovery loop exited: %v", err) + case <-time.After(5 * time.Millisecond): + } + } + cancel() + if err := <-errCh; !errors.Is(err, context.Canceled) { + t.Fatalf("loop err = %v, want context.Canceled", err) + } +} diff --git a/agent/run/regression_test.go b/agent/run/regression_test.go index 6228deb..2155fcd 100644 --- a/agent/run/regression_test.go +++ b/agent/run/regression_test.go @@ -6,7 +6,7 @@ func TestRegressionZeroBindingsWithToolCallsRejected(t *testing.T) { s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(), nil) result := modelResultWithCalls("c1") - if _, err := Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: nil}); err == nil { + 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") } } @@ -21,12 +21,12 @@ func TestRegressionBindingMustMatchModelResult(t *testing.T) { evil := makeBinding(t, "c1", specDanger, `{"rm":"-rf"}`) result := modelResultWithNamedCalls("safe", `{"a":1}`, "c1") - if _, err := Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: []ToolCallBinding{evil}}); err == nil { + 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, "c1", specSafe, `{"a":999}`) - if _, err := Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: []ToolCallBinding{tampered}}); err == nil { + if _, err := ProtocolV1.Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: []ToolCallBinding{tampered}}); err == nil { t.Fatal("binding with tampered arguments was accepted") } } @@ -60,7 +60,7 @@ func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { s = fold(t, s, facts) s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: "c1"})) - _, err := Evolve(s, ToolCallFailed{ + _, err := ProtocolV1.Evolve(s, ToolCallFailed{ StepID: opened.StepID, CallID: "c1", Failure: ToolFailure{Class: FailureExecution}, @@ -73,7 +73,7 @@ func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { func TestRegressionCancelReasonFixed(t *testing.T) { s := newRun(t) - if _, err := Decide(s, CancelRun{Reason: RunReason("other")}); err == nil { + if _, err := ProtocolV1.Decide(s, CancelRun{Reason: RunReason("other")}); err == nil { t.Fatal("CancelRun accepted a non-cancellation reason") } facts := mustDecide(t, s, CancelRun{}) diff --git a/agent/run/runtest/feature.go b/agent/run/runtest/feature.go index 2493684..fc839c2 100644 --- a/agent/run/runtest/feature.go +++ b/agent/run/runtest/feature.go @@ -188,7 +188,7 @@ func (f *Feature) TryCommit(cmd run.AgentCommand) error { func (f *Feature) Approve() *Feature { f.t.Helper() w := f.waiting() - digest, err := run.DigestToolResponseDecision(w.Kind, run.ResponseDecisionApproved, "") + digest, err := run.ProtocolV1.DigestToolResponseDecision(w.Kind, run.ResponseDecisionApproved, "") if err != nil { f.t.Fatal(err) } @@ -202,7 +202,7 @@ func (f *Feature) Approve() *Feature { func (f *Feature) Reject(reason string) *Feature { f.t.Helper() w := f.waiting() - digest, err := run.DigestToolResponseDecision(w.Kind, run.ResponseDecisionRejected, reason) + digest, err := run.ProtocolV1.DigestToolResponseDecision(w.Kind, run.ResponseDecisionRejected, reason) if err != nil { f.t.Fatal(err) } @@ -413,7 +413,7 @@ func (f *Feature) mustSpec(name string, policy run.ResponsePolicy) run.ToolSpec if err != nil { f.t.Fatal(err) } - d, err := run.DigestToolDefinition(frozen) + d, err := run.ProtocolV1.DigestToolDefinition(frozen) if err != nil { f.t.Fatal(err) } diff --git a/agent/run/runtime.go b/agent/run/runtime.go index 8bc7b9a..fe42e11 100644 --- a/agent/run/runtime.go +++ b/agent/run/runtime.go @@ -11,6 +11,10 @@ type Runtime interface { Load(context.Context, RunID) (RuntimeSnapshot, error) Commit(context.Context, CommitRequest) (CommitResult, error) Record(context.Context, RunID) (RunRecord, error) + // RenewLease extends the lease behind grant on the Executing target + // (stepID alone for a ModelStep, stepID+callID for a tool call). Workers + // call it while an effect runs longer than the lease TTL (RUN-CMT-8). + RenewLease(ctx context.Context, runID RunID, stepID StepID, callID CallID, grant ExecutionGrant) error // RecoverExpired grantless-commits recovery for expired execution leases. // Hosts call it on a timer; Loop does not. RecoverExpired(context.Context) (int, error) diff --git a/agent/run/runtime_helpers_test.go b/agent/run/runtime_helpers_test.go index e5db587..833df0a 100644 --- a/agent/run/runtime_helpers_test.go +++ b/agent/run/runtime_helpers_test.go @@ -65,7 +65,7 @@ func memoryEntry(t testing.TB, rt Runtime) *memoryRun { func commitCmd(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) (CommitResult, error) { t.Helper() cmd = withTestExecutionClaim(id, cmd) - env, err := BuildEnvelope("run-1", id, cmd) + env, err := ProtocolV1.BuildEnvelope("run-1", id, cmd) if err != nil { t.Fatal(err) } @@ -121,16 +121,16 @@ func buildPrepareFromSnap(t *testing.T, snap RuntimeSnapshot, req sdk.Request, s if err != nil { t.Fatal(err) } - reqDigest, err := DigestRequest(frozenReq) + reqDigest, err := ProtocolV1.DigestRequest(frozenReq) if err != nil { t.Fatal(err) } - toolsDigest, err := DigestToolSpecs(specs) + toolsDigest, err := ProtocolV1.DigestToolSpecs(specs) if err != nil { t.Fatal(err) } model := ModelRef(frozenReq.Model) - binding, err := DigestModelStepBinding(model, reqDigest, toolsDigest) + binding, err := ProtocolV1.DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } diff --git a/agent/run/runtimetest/conformance.go b/agent/run/runtimetest/conformance.go index ac7729a..dc14196 100644 --- a/agent/run/runtimetest/conformance.go +++ b/agent/run/runtimetest/conformance.go @@ -107,7 +107,7 @@ func (c *conformanceCase) load() run.RuntimeSnapshot { func (c *conformanceCase) commit(id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) (run.CommitResult, error) { c.t.Helper() cmd = withTestExecutionClaim(id, cmd) - env, err := run.BuildEnvelope(c.runID, id, cmd) + env, err := run.ProtocolV1.BuildEnvelope(c.runID, id, cmd) if err != nil { c.t.Fatal(err) } @@ -178,16 +178,16 @@ func buildPrepareFromSnap(t testing.TB, snap *run.RuntimeSnapshot, req *sdk.Requ if err != nil { t.Fatal(err) } - reqDigest, err := run.DigestRequest(frozenReq) + reqDigest, err := run.ProtocolV1.DigestRequest(frozenReq) if err != nil { t.Fatal(err) } - toolsDigest, err := run.DigestToolSpecs(specs) + toolsDigest, err := run.ProtocolV1.DigestToolSpecs(specs) if err != nil { t.Fatal(err) } model := run.ModelRef(frozenReq.Model) - binding, err := run.DigestModelStepBinding(model, reqDigest, toolsDigest) + binding, err := run.ProtocolV1.DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -223,7 +223,7 @@ func makeSpec(t testing.TB, def sdk.ToolDefinition) run.ToolSpec { if err != nil { t.Fatal(err) } - d, err := run.DigestToolDefinition(frozen) + d, err := run.ProtocolV1.DigestToolDefinition(frozen) if err != nil { t.Fatal(err) } @@ -324,7 +324,7 @@ func testCreateMissing(t *testing.T, newRuntime Factory) { if _, err := rt.Record(context.Background(), "missing"); !errors.Is(err, run.ErrRunNotFound) { t.Fatalf("Record error = %v, want ErrRunNotFound", err) } - env, err := run.BuildEnvelope("missing", "cancel", run.CancelRun{}) + env, err := run.ProtocolV1.BuildEnvelope("missing", "cancel", run.CancelRun{}) if err != nil { t.Fatal(err) } @@ -397,8 +397,8 @@ func testRecordFoldAccepted(t *testing.T, newRuntime Factory) { if err != nil { t.Fatal(err) } - foldedJSON, _ := json.Marshal(stateComparable(&folded)) - snapshotJSON, _ := json.Marshal(stateComparable(&record.Snapshot.State)) + foldedJSON := encodeState(t, &folded) + snapshotJSON := encodeState(t, &record.Snapshot.State) if revision != record.Snapshot.Revision || !bytes.Equal(foldedJSON, snapshotJSON) { t.Fatalf("FoldRun = revision %d state %s, Record = revision %d state %s", revision, foldedJSON, record.Snapshot.Revision, snapshotJSON) } @@ -434,8 +434,8 @@ func testRecordReplayAndIndex(t *testing.T, newRuntime Factory) { t.Fatalf("FoldEvents: %v", err) } live := c.load() - a, _ := json.Marshal(stateComparable(&live.State)) - bts, _ := json.Marshal(stateComparable(&foldedState)) + a := encodeState(t, &live.State) + bts := encodeState(t, &foldedState) if !bytes.Equal(a, bts) { t.Fatalf("replay diverged:\n live %s\n replay %s", a, bts) } @@ -454,7 +454,7 @@ func testRecordConcurrentWithCommit(t *testing.T, newRuntime Factory) { t.Fatal(err) } in := run.AgentInput{ID: run.InputID(fmt.Sprintf("in-%d", i)), Payload: mustJSON(`null`)} - env, err := run.BuildEnvelope(c.runID, run.DeriveInputCommandID(c.runID, in.ID), run.AcceptInput{Input: in}) + env, err := run.ProtocolV1.BuildEnvelope(c.runID, run.DeriveInputCommandID(c.runID, in.ID), run.AcceptInput{Input: in}) if err != nil { t.Fatal(err) } @@ -494,8 +494,8 @@ func testRecordConcurrentWithCommit(t *testing.T, newRuntime Factory) { if err != nil { t.Fatal(err) } - foldedJSON, _ := json.Marshal(stateComparable(&foldedRun)) - snapshotJSON, _ := json.Marshal(stateComparable(&observed.record.Snapshot.State)) + foldedJSON := encodeState(t, &foldedRun) + snapshotJSON := encodeState(t, &observed.record.Snapshot.State) if rev != observed.record.Snapshot.Revision || !bytes.Equal(foldedJSON, snapshotJSON) { t.Fatalf("inconsistent Record at revision %d", observed.record.Snapshot.Revision) } @@ -556,7 +556,7 @@ func testIsolationRuns(t *testing.T, newRuntime Factory) { t.Fatal(err) } in := run.AgentInput{ID: "same-input", Payload: mustJSON(`{"run":"` + string(id) + `"}`)} - env, err := run.BuildEnvelope(id, run.DeriveInputCommandID(id, in.ID), run.AcceptInput{Input: in}) + env, err := run.ProtocolV1.BuildEnvelope(id, run.DeriveInputCommandID(id, in.ID), run.AcceptInput{Input: in}) if err != nil { t.Fatal(err) } @@ -707,7 +707,7 @@ func testPrepareReplayAfterProgress(t *testing.T, newRuntime Factory) { func testGrant(t *testing.T, newRuntime Factory) { c, stepID, grant := preparedCase(t, newRuntime, nil, nil) // The authority rejects an unbound start before it can mint ownership. - empty, err := run.BuildEnvelope(c.runID, "empty-claim", run.StartModelExecution{StepID: stepID}) + empty, err := run.ProtocolV1.BuildEnvelope(c.runID, "empty-claim", run.StartModelExecution{StepID: stepID}) if err != nil { t.Fatal(err) } @@ -885,25 +885,14 @@ func testCancelUnknownRemaining(t *testing.T, newRuntime Factory) { } } -func stateComparable(s *run.MachineState) map[string]any { - m := map[string]any{ - "runId": s.RunID, "status": s.Status, - "modelSteps": s.ModelSteps, "lastClosedStep": s.LastClosedStep, - "usage": s.Usage, "pendingInputs": s.PendingInputs, - "lastModelResult": s.LastModelResult, "result": s.Result, - } - if s.LastToolStep != nil { - m["lastToolStep"] = s.LastToolStep - } - switch cur := s.Current.(type) { - case run.Open: - m["current"] = "open" - case run.ModelStep: - m["current"] = "model" - m["modelStep"] = cur - case run.ToolStep: - m["current"] = "tool" - m["toolStep"] = cur +// encodeState renders the canonical snapshot bytes the protocol itself uses +// for state identity, so conformance compares states by the same rule as +// Runtime.Record. +func encodeState(t testing.TB, s *run.MachineState) []byte { + t.Helper() + raw, err := run.ProtocolV1.EncodeMachineState(s) + if err != nil { + t.Fatal(err) } - return m + return raw } diff --git a/agent/run/runtimetest/recovery.go b/agent/run/runtimetest/recovery.go new file mode 100644 index 0000000..a2c099b --- /dev/null +++ b/agent/run/runtimetest/recovery.go @@ -0,0 +1,284 @@ +package runtimetest + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/sdk" +) + +// RecoveryFactory constructs an empty Runtime whose leases use ttl and whose +// clock is now. Tests advance time by reassigning a captured time.Time. +type RecoveryFactory func(now func() time.Time, ttl time.Duration) run.Runtime + +// RunRecoveryConformance covers lease expiry: grantless recover is rejected +// while a lease is live, RecoverExpired returns an Executing model to +// Prepared, and an expired Executing tool call is settled as Unknown without +// failing the Run. RunConformance stays on TTL 0. +func RunRecoveryConformance(t *testing.T, newRuntime RecoveryFactory) { + t.Helper() + t.Run("LiveLeaseRejectsGrantless", func(t *testing.T) { testRecoveryLiveLeaseRejectsGrantless(t, newRuntime) }) + t.Run("ZeroTTLRejectsGrantless", func(t *testing.T) { testRecoveryZeroTTLRejectsGrantless(t, newRuntime) }) + t.Run("ExpiredModelPrepared", func(t *testing.T) { testRecoveryExpiredModelPrepared(t, newRuntime) }) + t.Run("ExpiredToolUnknown", func(t *testing.T) { testRecoveryExpiredToolUnknown(t, newRuntime) }) + t.Run("ExpiredToolLeavesSiblingExecuting", func(t *testing.T) { testRecoveryExpiredToolLeavesSiblingExecuting(t, newRuntime) }) + t.Run("RecoverExpiredIdempotent", func(t *testing.T) { testRecoveryExpiredIdempotent(t, newRuntime) }) +} + +func recoveryPrepared(t testing.TB, newRuntime RecoveryFactory, ttl time.Duration, clock *time.Time, tools []sdk.ToolDefinition, specs []run.ToolSpec) (*conformanceCase, run.StepID, run.ExecutionGrant) { + t.Helper() + rt := newRuntime(func() time.Time { return *clock }, ttl) + c := newCaseOnRuntime(t, rt, "run-1") + snap := c.load() + req := request(tools...) + prep, cmdID := buildPrepareFromSnap(t, &snap, &req, specs) + c.mustCommit(cmdID, snap.Revision, "", prep) + start := c.mustCommit("start-1", 1, "", run.StartModelExecution{StepID: prep.StepID}) + if start.Grant == "" { + t.Fatal("accepted start returned no grant") + } + return c, prep.StepID, start.Grant +} + +func testRecoveryLiveLeaseRejectsGrantless(t *testing.T, newRuntime RecoveryFactory) { + clock := time.Unix(1000, 0) + c, stepID, _ := recoveryPrepared(t, newRuntime, time.Second, &clock, nil, nil) + claim := startClaim("start-1") + id := run.DeriveModelRecoveryCommandID(c.runID, stepID, claim) + _, err := c.commit(id, 2, "", run.RecoverModelExecution{StepID: stepID, Claim: claim}) + if !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("grantless recover while live err = %v, want ErrStaleRuntime", err) + } + n, err := c.rt.RecoverExpired(context.Background()) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("RecoverExpired while live = %d, want 0", n) + } +} + +func testRecoveryZeroTTLRejectsGrantless(t *testing.T, newRuntime RecoveryFactory) { + clock := time.Unix(1000, 0) + c, stepID, _ := recoveryPrepared(t, newRuntime, 0, &clock, nil, nil) + clock = time.Unix(1_000_000, 0) + claim := startClaim("start-1") + id := run.DeriveModelRecoveryCommandID(c.runID, stepID, claim) + _, err := c.commit(id, 2, "", run.RecoverModelExecution{StepID: stepID, Claim: claim}) + if !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("grantless recover with zero TTL err = %v, want ErrStaleRuntime", err) + } + n, err := c.rt.RecoverExpired(context.Background()) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("RecoverExpired with zero TTL = %d, want 0", n) + } +} + +func testRecoveryExpiredModelPrepared(t *testing.T, newRuntime RecoveryFactory) { + clock := time.Unix(1000, 0) + c, _, _ := recoveryPrepared(t, newRuntime, time.Second, &clock, nil, nil) + clock = time.Unix(1002, 0) + n, err := c.rt.RecoverExpired(context.Background()) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("recovered = %d, want 1", n) + } + snap := c.load() + if snap.State.RunID != c.runID { + t.Fatalf("RunID = %q, want %q", snap.State.RunID, c.runID) + } + if snap.State.Status != run.RunActive { + t.Fatalf("status = %v, want active", snap.State.Status) + } + ms, ok := snap.State.Current.(run.ModelStep) + if !ok || ms.Status != run.ModelPrepared { + t.Fatalf("after RecoverExpired: %+v", snap.State.Current) + } +} + +func testRecoveryExpiredToolUnknown(t *testing.T, newRuntime RecoveryFactory) { + clock := time.Unix(1000, 0) + def := toolDef("t") + spec := makeSpec(t, def) + c, stepID, grant := recoveryPrepared(t, newRuntime, time.Second, &clock, []sdk.ToolDefinition{def}, []run.ToolSpec{spec}) + b := makeBinding(t, "c1", &spec) + opened := c.mustCommit("complete-1", 2, grant, + run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) + toolStep := openedToolStepID(t, &opened) + c.mustCommit("start-c1", opened.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: "c1"}) + clock = time.Unix(1002, 0) + n, err := c.rt.RecoverExpired(context.Background()) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("recovered = %d, want 1", n) + } + snap := c.load() + if snap.State.Status != run.RunActive { + t.Fatalf("status = %v, want active", snap.State.Status) + } + if _, ok := snap.State.Current.(run.Open); !ok { + t.Fatalf("current = %+v, want Open", snap.State.Current) + } + if snap.State.LastToolStep == nil || len(snap.State.LastToolStep.Calls) != 1 { + t.Fatalf("LastToolStep = %+v", snap.State.LastToolStep) + } + call := snap.State.LastToolStep.Calls[0] + if call.Status != run.ToolFailed || call.Failure == nil || call.Failure.Outcome != run.ToolOutcomeUnknown || call.Failure.Failure.Class != run.FailureEffectUnknown { + t.Fatalf("unknown call = %+v", call) + } +} + +func testRecoveryExpiredToolLeavesSiblingExecuting(t *testing.T, newRuntime RecoveryFactory) { + clock := time.Unix(1000, 0) + defA, defB := toolDef("a"), toolDef("b") + specA := makeSpec(t, defA) + specB := makeSpec(t, defB) + c, stepID, grant := recoveryPrepared(t, newRuntime, time.Second, &clock, + []sdk.ToolDefinition{defA, defB}, []run.ToolSpec{specA, specB}) + bA := makeBinding(t, "cA", &specA) + bB := makeBinding(t, "cB", &specB) + r, err := run.FreezeModelResult(sdk.ModelResult{ + FinishReason: sdk.FinishReasonToolCalls, + ToolCalls: []sdk.ToolCall{ + {ToolCallID: "cA", ToolName: "a", Input: `{}`}, + {ToolCallID: "cB", ToolName: "b", Input: `{}`}, + }, + }) + if err != nil { + t.Fatal(err) + } + opened := c.mustCommit("complete-1", 2, grant, + run.SubmitModelResult{StepID: stepID, Result: r, Calls: []run.ToolCallBinding{bA, bB}}) + toolStep := openedToolStepID(t, &opened) + base := opened.Snapshot.Revision + c.mustCommit("start-A", base, "", run.StartToolCall{StepID: toolStep, CallID: "cA"}) + clock = time.Unix(1000, 500_000_000) + c.mustCommit("start-B", base, "", run.StartToolCall{StepID: toolStep, CallID: "cB"}) + clock = time.Unix(1001, 0) + n, err := c.rt.RecoverExpired(context.Background()) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("recovered = %d, want 1", n) + } + snap := c.load() + if snap.State.Status != run.RunActive { + t.Fatalf("status = %v, want active", snap.State.Status) + } + ts, ok := snap.State.Current.(run.ToolStep) + if !ok || len(ts.Calls) != 2 { + t.Fatalf("current = %+v, want ToolStep with 2 calls", snap.State.Current) + } + if ts.Calls[0].Status != run.ToolFailed || ts.Calls[0].Failure == nil || ts.Calls[0].Failure.Outcome != run.ToolOutcomeUnknown { + t.Fatalf("call A = %+v, want Unknown", ts.Calls[0]) + } + if ts.Calls[1].Status != run.ToolExecuting { + t.Fatalf("call B = %+v, want Executing", ts.Calls[1]) + } +} + +func testRecoveryExpiredIdempotent(t *testing.T, newRuntime RecoveryFactory) { + clock := time.Unix(1000, 0) + c, _, _ := recoveryPrepared(t, newRuntime, time.Second, &clock, nil, nil) + clock = time.Unix(1002, 0) + n, err := c.rt.RecoverExpired(context.Background()) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("first recovered = %d, want 1", n) + } + n, err = c.rt.RecoverExpired(context.Background()) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("second recovered = %d, want 0", n) + } + snap := c.load() + ms, ok := snap.State.Current.(run.ModelStep) + if !ok || ms.Status != run.ModelPrepared { + t.Fatalf("after second RecoverExpired: %+v", snap.State.Current) + } +} + +// RunLeaseRenewalConformance covers Runtime.RenewLease: a renewed lease is +// not recovered at its original deadline, a stale or foreign grant cannot +// renew, and renewal after settlement is rejected. +func RunLeaseRenewalConformance(t *testing.T, newRuntime RecoveryFactory) { + t.Helper() + t.Run("RenewExtendsDeadline", func(t *testing.T) { testRenewExtendsDeadline(t, newRuntime) }) + t.Run("RenewRejectsWrongGrant", func(t *testing.T) { testRenewRejectsWrongGrant(t, newRuntime) }) + t.Run("RenewAfterSettlementRejected", func(t *testing.T) { testRenewAfterSettlementRejected(t, newRuntime) }) +} + +func testRenewExtendsDeadline(t *testing.T, newRuntime RecoveryFactory) { + clock := time.Unix(1000, 0) + c, stepID, grant := recoveryPrepared(t, newRuntime, time.Second, &clock, nil, nil) + ctx := context.Background() + clock = time.Unix(1000, 800_000_000) + if err := c.rt.RenewLease(ctx, c.runID, stepID, "", grant); err != nil { + t.Fatalf("renew: %v", err) + } + // Past the original deadline, before the renewed one: nothing to recover. + clock = time.Unix(1001, 500_000_000) + n, err := c.rt.RecoverExpired(ctx) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("recovered = %d after renewal, want 0", n) + } + snap := c.load() + if ms, ok := snap.State.Current.(run.ModelStep); !ok || ms.Status != run.ModelExecuting { + t.Fatalf("current = %+v, want Executing", snap.State.Current) + } + // The owner's result is still accepted with the same grant. + c.mustCommit("complete-1", snap.Revision, grant, run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls()}) + // Past the renewed deadline the lease is gone with the settlement. + clock = time.Unix(1003, 0) + n, err = c.rt.RecoverExpired(ctx) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("recovered = %d after settlement, want 0", n) + } +} + +func testRenewRejectsWrongGrant(t *testing.T, newRuntime RecoveryFactory) { + clock := time.Unix(1000, 0) + c, stepID, _ := recoveryPrepared(t, newRuntime, time.Second, &clock, nil, nil) + ctx := context.Background() + if err := c.rt.RenewLease(ctx, c.runID, stepID, "", "not-the-grant"); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("wrong grant renew err = %v, want ErrStaleRuntime", err) + } + if err := c.rt.RenewLease(ctx, c.runID, stepID, "", ""); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("empty grant renew err = %v, want ErrStaleRuntime", err) + } + if err := c.rt.RenewLease(ctx, "missing", stepID, "", "g"); !errors.Is(err, run.ErrRunNotFound) { + t.Fatalf("missing run renew err = %v, want ErrRunNotFound", err) + } +} + +func testRenewAfterSettlementRejected(t *testing.T, newRuntime RecoveryFactory) { + clock := time.Unix(1000, 0) + c, stepID, grant := recoveryPrepared(t, newRuntime, time.Second, &clock, nil, nil) + ctx := context.Background() + snap := c.load() + c.mustCommit("complete-1", snap.Revision, grant, run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls()}) + if err := c.rt.RenewLease(ctx, c.runID, stepID, "", grant); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("renew after settlement err = %v, want ErrStaleRuntime", err) + } +} diff --git a/agent/run/snapshot.go b/agent/run/snapshot.go new file mode 100644 index 0000000..917ec31 --- /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"` + Status RunStatus `json:"status"` + ModelSteps int `json:"modelSteps"` + LastClosedStep StepID `json:"lastClosedStep"` + Usage Usage `json:"usage"` + PendingInputs []AgentInput `json:"pendingInputs"` + LastModelResult *ModelResult `json:"lastModelResult"` + 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, Status: s.Status, ModelSteps: s.ModelSteps, + LastClosedStep: s.LastClosedStep, Usage: s.Usage, PendingInputs: s.PendingInputs, + LastModelResult: s.LastModelResult, 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, Status: w.Status, ModelSteps: w.ModelSteps, + LastClosedStep: w.LastClosedStep, Usage: w.Usage, PendingInputs: w.PendingInputs, + LastModelResult: w.LastModelResult, 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..b9eab3c --- /dev/null +++ b/agent/run/snapshot_test.go @@ -0,0 +1,81 @@ +package run + +import ( + "context" + "strings" + "testing" + + "github.com/memohai/twilight/sdk" +) + +// 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) + rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) + ctx := context.Background() + + check := func(name string) { + t.Helper() + snap, err := rt.Load(ctx, "run-1") + if err != nil { + t.Fatal(err) + } + raw, err := ProtocolV1.EncodeMachineState(&snap.State) + 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(&snap.State, &decoded) { + t.Fatalf("%s: round trip changed state\n%s", name, raw) + } + if (snap.State.Current == nil) != (decoded.Current == nil) { + t.Fatalf("%s: Current presence changed: %T -> %T", name, snap.State.Current, decoded.Current) + } + } + + check("model executing") + b := makeBinding(t, "c1", spec, `{}`) + snap, _ := rt.Load(ctx, "run-1") + res := mustCommit(t, rt, "complete-1", snap.Revision, grant, + SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) + check("tool step pending") + toolStep := res.Events[1].Fact.(ToolStepOpened).StepID + sRes := mustCommit(t, rt, "start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: "c1"}) + check("tool step executing") + mustCommit(t, rt, "done-c1", sRes.Snapshot.Revision, sRes.Grant, + SubmitToolResult{StepID: toolStep, CallID: "c1", Result: ToolExecutionResult{Output: cj(`"ok"`)}}) + check("open with last tool step") + snap, _ = rt.Load(ctx, "run-1") + mustCommit(t, rt, "cancel", snap.Revision, "", CancelRun{}) + check("terminal") +} + +func TestSnapshotCodecRejectsMalformedWire(t *testing.T) { + initial, err := InitializeRun("run-1") + 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/sqlitestore/store.go b/agent/run/sqlitestore/store.go new file mode 100644 index 0000000..99a0ec4 --- /dev/null +++ b/agent/run/sqlitestore/store.go @@ -0,0 +1,573 @@ +// Package sqlitestore is the SQLite Store adapter for agent/run. +// +// It persists RunHeader, the append-only TransitionRecord log, the derived +// MachineState snapshot, and execution leases. LoadHead reads the snapshot +// directly; the log is read only by LoadLog, which Runtime.Record and Rebuild +// use for verification. Runtime.Commit remains the write path; this package +// does not call Decide or Evolve. +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "sync" + "time" + + "github.com/memohai/twilight/agent/run" + + _ "modernc.org/sqlite" // SQLite driver +) + +// Store is a file-backed run.Store. Runtime does not Close it; the host does. +type Store struct { + db *sql.DB + closed sync.Once +} + +var _ run.Store = (*Store)(nil) + +const dsnParams = "?_txlock=immediate&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" + +// Open creates or opens a SQLite database at path and applies the schema. +func Open(path string) (*Store, error) { + if path == "" { + return nil, errors.New("agent: sqlite store: empty path") + } + abs, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("agent: sqlite store: resolve path: %w", err) + } + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(abs)+dsnParams) + if err != nil { + return nil, fmt.Errorf("agent: sqlite store: open: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + ctx := context.Background() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("agent: sqlite store: ping: %w", err) + } + s := &Store{db: db} + if err := s.init(ctx); err != nil { + _ = db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) init(ctx context.Context) error { + for _, stmt := range schema { + if _, err := s.db.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("agent: sqlite store: schema: %w", err) + } + } + return nil +} + +var schema = []string{ + `CREATE TABLE IF NOT EXISTS runs ( + run_id TEXT PRIMARY KEY NOT NULL, + schema_version INTEGER NOT NULL, + header TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 0, + snapshot TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS transitions ( + run_id TEXT NOT NULL, + revision INTEGER NOT NULL, + command_id TEXT NOT NULL, + record TEXT NOT NULL, + PRIMARY KEY (run_id, revision), + UNIQUE (run_id, command_id), + FOREIGN KEY (run_id) REFERENCES runs(run_id) + )`, + `CREATE TABLE IF NOT EXISTS leases ( + run_id TEXT NOT NULL, + lease_key TEXT NOT NULL, + claim TEXT NOT NULL, + grant TEXT NOT NULL, + start_command_id TEXT NOT NULL, + deadline_unix_nano INTEGER, + PRIMARY KEY (run_id, lease_key), + FOREIGN KEY (run_id) REFERENCES runs(run_id) + )`, + `CREATE INDEX IF NOT EXISTS leases_deadline ON leases (deadline_unix_nano) WHERE deadline_unix_nano IS NOT NULL`, +} + +// Close releases the database. It is safe to call more than once. +func (s *Store) Close() error { + if s == nil { + return nil + } + var err error + s.closed.Do(func() { + if s.db != nil { + err = s.db.Close() + s.db = nil + } + }) + return err +} + +func (s *Store) ready(ctx context.Context) error { + if err := ctxErr(ctx); err != nil { + return err + } + if s == nil || s.db == nil { + return errors.New("agent: sqlite store: nil store") + } + return nil +} + +//nolint:gocritic // hugeParam: Store.Create takes RunHeader by value as the persisted creation record. +func (s *Store) Create(ctx context.Context, header run.RunHeader) (bool, run.RunHeader, error) { + if err := s.ready(ctx); err != nil { + return false, run.RunHeader{}, err + } + proto, err := run.ProtocolFor(header.SchemaVersion) + if err != nil { + return false, run.RunHeader{}, err + } + rawHeader, err := json.Marshal(header) + if err != nil { + return false, run.RunHeader{}, fmt.Errorf("agent: sqlite store: marshal header: %w", err) + } + rawSnapshot, err := proto.EncodeMachineState(&header.InitialState) + if err != nil { + return false, run.RunHeader{}, fmt.Errorf("agent: sqlite store: encode snapshot: %w", err) + } + var created bool + var existing run.RunHeader + err = s.withTx(ctx, func(tx *sql.Tx) error { + res, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO runs (run_id, schema_version, header, revision, snapshot) VALUES (?, ?, ?, 0, ?)`, + string(header.RunID), header.SchemaVersion, string(rawHeader), string(rawSnapshot)) + if err != nil { + return fmt.Errorf("agent: sqlite store: insert run: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("agent: sqlite store: insert run: %w", err) + } + if n == 1 { + created = true + existing = header + existing.InitialState.Current = run.Open{} + return nil + } + loaded, err := loadHeader(ctx, tx, header.RunID) + if err != nil { + return err + } + existing = loaded + return nil + }) + return created, existing, err +} + +func (s *Store) LoadHead(ctx context.Context, id run.RunID) (run.RunHead, error) { + if err := s.ready(ctx); err != nil { + return run.RunHead{}, err + } + var head run.RunHead + err := s.withTx(ctx, func(tx *sql.Tx) error { + var err error + head, err = loadHead(ctx, tx, id) + return err + }) + return head, err +} + +func (s *Store) LoadLog(ctx context.Context, id run.RunID, from uint64) ([]run.TransitionRecord, error) { + if err := s.ready(ctx); err != nil { + return nil, err + } + var log []run.TransitionRecord + err := s.withTx(ctx, func(tx *sql.Tx) error { + if err := requireRun(ctx, tx, id); err != nil { + return err + } + var err error + log, err = loadLog(ctx, tx, id, from) + return err + }) + return log, err +} + +// LoadRecord reads head and log inside one transaction, so the returned log +// ends exactly at head.Revision. +func (s *Store) LoadRecord(ctx context.Context, id run.RunID) (run.RunHead, []run.TransitionRecord, error) { + if err := s.ready(ctx); err != nil { + return run.RunHead{}, nil, err + } + var head run.RunHead + var log []run.TransitionRecord + err := s.withTx(ctx, func(tx *sql.Tx) error { + var err error + if head, err = loadHead(ctx, tx, id); err != nil { + return err + } + log, err = loadLog(ctx, tx, id, 1) + return err + }) + return head, log, err +} + +func loadLog(ctx context.Context, tx *sql.Tx, id run.RunID, from uint64) ([]run.TransitionRecord, error) { + rows, err := tx.QueryContext(ctx, `SELECT record FROM transitions WHERE run_id = ? AND revision >= ? ORDER BY revision ASC`, string(id), from) + if err != nil { + return nil, fmt.Errorf("agent: sqlite store: load log: %w", err) + } + defer rows.Close() + var log []run.TransitionRecord + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + return nil, fmt.Errorf("agent: sqlite store: load log: %w", err) + } + rec, err := run.DecodeTransitionRecord([]byte(raw)) + if err != nil { + return nil, fmt.Errorf("agent: sqlite store: decode transition: %w", err) + } + log = append(log, rec) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("agent: sqlite store: load log: %w", err) + } + return log, nil +} + +type sqliteTx struct { + ctx context.Context + tx *sql.Tx + id run.RunID + head run.RunHead +} + +func (t *sqliteTx) Head() run.RunHead { return t.head } + +func (t *sqliteTx) LookupTransition(command run.CommandID) (run.TransitionRecord, bool, error) { + var raw string + err := t.tx.QueryRowContext(t.ctx, `SELECT record FROM transitions WHERE run_id = ? AND command_id = ?`, string(t.id), string(command)).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return run.TransitionRecord{}, false, nil + } + if err != nil { + return run.TransitionRecord{}, false, fmt.Errorf("agent: sqlite store: lookup transition: %w", err) + } + record, err := run.DecodeTransitionRecord([]byte(raw)) + if err != nil { + return run.TransitionRecord{}, false, fmt.Errorf("agent: sqlite store: decode transition: %w", err) + } + return record, true, nil +} + +// Commit is the Run's critical section: the connection pool is size one and +// the transaction is opened with an immediate write lock, so no other writer +// can observe or advance this Run until the transaction ends. +func (s *Store) Commit(ctx context.Context, id run.RunID, fn func(run.RunTx) (*run.Append, error)) error { + if err := s.ready(ctx); err != nil { + return err + } + if fn == nil { + return errors.New("agent: sqlite store: nil commit fn") + } + return s.withTx(ctx, func(tx *sql.Tx) error { + head, err := loadHead(ctx, tx, id) + if err != nil { + return err + } + a, err := fn(&sqliteTx{ctx: ctx, tx: tx, id: id, head: head}) + if err != nil || a == nil { + return err + } + return appendTransition(ctx, tx, id, &head, a) + }) +} + +func appendTransition(ctx context.Context, tx *sql.Tx, id run.RunID, head *run.RunHead, a *run.Append) error { + if err := run.ValidateAppend(id, head, a); err != nil { + return err + } + proto, err := run.ProtocolFor(head.Header.SchemaVersion) + if err != nil { + return err + } + rawSnapshot, err := proto.EncodeMachineState(&a.State) + if err != nil { + return fmt.Errorf("agent: sqlite store: encode snapshot: %w", err) + } + rawRecord, err := json.Marshal(a.Transition) + if err != nil { + return fmt.Errorf("agent: sqlite store: marshal transition: %w", err) + } + res, err := tx.ExecContext(ctx, `UPDATE runs SET revision = ?, snapshot = ? WHERE run_id = ? AND revision = ?`, + a.Transition.Revision, string(rawSnapshot), string(id), a.ExpectedRevision) + if err != nil { + return fmt.Errorf("agent: sqlite store: advance run: %w", err) + } + if n, err := res.RowsAffected(); err != nil { + return fmt.Errorf("agent: sqlite store: advance run: %w", err) + } else if n != 1 { + return run.ErrAppendConflict + } + if _, err := tx.ExecContext(ctx, `INSERT INTO transitions (run_id, revision, command_id, record) VALUES (?, ?, ?, ?)`, + string(id), a.Transition.Revision, string(a.Transition.CommandID), string(rawRecord)); err != nil { + return fmt.Errorf("%w: %w", run.ErrCommandConflict, err) + } + return applyLeaseOps(ctx, tx, id, a.Leases) +} + +func (s *Store) RenewLease(ctx context.Context, id run.RunID, key string, grant run.ExecutionGrant, deadline time.Time) error { + if err := s.ready(ctx); err != nil { + return err + } + if grant == "" { + return run.ErrStaleRuntime + } + return s.withTx(ctx, func(tx *sql.Tx) error { + if err := requireRun(ctx, tx, id); err != nil { + return err + } + res, err := tx.ExecContext(ctx, `UPDATE leases SET deadline_unix_nano = ? WHERE run_id = ? AND lease_key = ? AND grant = ?`, + deadlineValue(deadline), string(id), key, string(grant)) + if err != nil { + return fmt.Errorf("agent: sqlite store: renew lease: %w", err) + } + if n, err := res.RowsAffected(); err != nil { + return fmt.Errorf("agent: sqlite store: renew lease: %w", err) + } else if n != 1 { + return run.ErrStaleRuntime + } + return nil + }) +} + +func (s *Store) ExpiredLeases(ctx context.Context, before time.Time) ([]run.ExpiredLease, error) { + if err := s.ready(ctx); err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, `SELECT run_id, lease_key, claim, grant, start_command_id, deadline_unix_nano + FROM leases WHERE deadline_unix_nano IS NOT NULL AND deadline_unix_nano <= ? ORDER BY run_id, lease_key`, before.UnixNano()) + if err != nil { + return nil, fmt.Errorf("agent: sqlite store: expired leases: %w", err) + } + defer rows.Close() + var out []run.ExpiredLease + for rows.Next() { + var runID, key string + lease, err := scanLease(rows, &runID, &key) + if err != nil { + return nil, err + } + out = append(out, run.ExpiredLease{RunID: run.RunID(runID), Key: key, Lease: lease}) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("agent: sqlite store: expired leases: %w", err) + } + return out, nil +} + +func (s *Store) ReplaceSnapshot(ctx context.Context, id run.RunID, revision uint64, state *run.MachineState) error { + if err := s.ready(ctx); err != nil { + return err + } + if state == nil || state.RunID != id { + return errors.New("agent: sqlite store: snapshot RunID mismatch") + } + return s.withTx(ctx, func(tx *sql.Tx) error { + schemaVersion, err := loadSchemaVersion(ctx, tx, id) + if err != nil { + return err + } + proto, err := run.ProtocolFor(schemaVersion) + if err != nil { + return err + } + raw, err := proto.EncodeMachineState(state) + if err != nil { + return fmt.Errorf("agent: sqlite store: encode snapshot: %w", err) + } + res, err := tx.ExecContext(ctx, `UPDATE runs SET snapshot = ? WHERE run_id = ? AND revision = ?`, string(raw), string(id), revision) + if err != nil { + return fmt.Errorf("agent: sqlite store: replace snapshot: %w", err) + } + if n, err := res.RowsAffected(); err != nil { + return fmt.Errorf("agent: sqlite store: replace snapshot: %w", err) + } else if n != 1 { + return run.ErrAppendConflict + } + return nil + }) +} + +func (s *Store) withTx(ctx context.Context, fn func(*sql.Tx) error) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("agent: sqlite store: begin: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := fn(tx); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("agent: sqlite store: commit: %w", err) + } + return nil +} + +func requireRun(ctx context.Context, tx *sql.Tx, id run.RunID) error { + _, err := loadSchemaVersion(ctx, tx, id) + return err +} + +func loadSchemaVersion(ctx context.Context, tx *sql.Tx, id run.RunID) (uint16, error) { + var schemaVersion uint16 + err := tx.QueryRowContext(ctx, `SELECT schema_version FROM runs WHERE run_id = ?`, string(id)).Scan(&schemaVersion) + if errors.Is(err, sql.ErrNoRows) { + return 0, run.ErrRunNotFound + } + if err != nil { + return 0, fmt.Errorf("agent: sqlite store: load run: %w", err) + } + return schemaVersion, nil +} + +func loadHead(ctx context.Context, tx *sql.Tx, id run.RunID) (run.RunHead, error) { + var headerJSON, snapshotJSON string + var rev int64 + err := tx.QueryRowContext(ctx, `SELECT header, revision, snapshot FROM runs WHERE run_id = ?`, string(id)). + Scan(&headerJSON, &rev, &snapshotJSON) + if errors.Is(err, sql.ErrNoRows) { + return run.RunHead{}, run.ErrRunNotFound + } + if err != nil { + return run.RunHead{}, fmt.Errorf("agent: sqlite store: load run: %w", err) + } + if rev < 0 { + return run.RunHead{}, fmt.Errorf("agent: sqlite store: negative revision %d", rev) + } + header, err := decodeHeader([]byte(headerJSON)) + if err != nil { + return run.RunHead{}, err + } + proto, err := run.ProtocolFor(header.SchemaVersion) + if err != nil { + return run.RunHead{}, err + } + state, err := proto.DecodeMachineState([]byte(snapshotJSON)) + if err != nil { + return run.RunHead{}, fmt.Errorf("agent: sqlite store: decode snapshot: %w", err) + } + leases, err := loadLeases(ctx, tx, id) + if err != nil { + return run.RunHead{}, err + } + return run.RunHead{Header: header, State: state, Revision: uint64(rev), Leases: leases}, nil +} + +func loadHeader(ctx context.Context, tx *sql.Tx, id run.RunID) (run.RunHeader, error) { + var headerJSON string + err := tx.QueryRowContext(ctx, `SELECT header FROM runs WHERE run_id = ?`, string(id)).Scan(&headerJSON) + if errors.Is(err, sql.ErrNoRows) { + return run.RunHeader{}, run.ErrRunNotFound + } + if err != nil { + return run.RunHeader{}, fmt.Errorf("agent: sqlite store: load header: %w", err) + } + return decodeHeader([]byte(headerJSON)) +} + +type leaseScanner interface { + Scan(dest ...any) error +} + +func scanLease(row leaseScanner, runID, key *string) (run.ExecutionLease, error) { + var claim, grant, startID string + var deadline sql.NullInt64 + if err := row.Scan(runID, key, &claim, &grant, &startID, &deadline); err != nil { + return run.ExecutionLease{}, fmt.Errorf("agent: sqlite store: scan lease: %w", err) + } + lease := run.ExecutionLease{ + Claim: run.ExecutionClaim(claim), + Grant: run.ExecutionGrant(grant), + StartCommandID: run.CommandID(startID), + } + if deadline.Valid { + lease.Deadline = time.Unix(0, deadline.Int64) + } + return lease, nil +} + +func loadLeases(ctx context.Context, tx *sql.Tx, id run.RunID) (map[string]run.ExecutionLease, error) { + rows, err := tx.QueryContext(ctx, `SELECT run_id, lease_key, claim, grant, start_command_id, deadline_unix_nano FROM leases WHERE run_id = ?`, string(id)) + if err != nil { + return nil, fmt.Errorf("agent: sqlite store: load leases: %w", err) + } + defer rows.Close() + leases := make(map[string]run.ExecutionLease) + for rows.Next() { + var runID, key string + lease, err := scanLease(rows, &runID, &key) + if err != nil { + return nil, err + } + leases[key] = lease + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("agent: sqlite store: load leases: %w", err) + } + return leases, nil +} + +func deadlineValue(deadline time.Time) any { + if deadline.IsZero() { + return nil + } + return deadline.UnixNano() +} + +func applyLeaseOps(ctx context.Context, tx *sql.Tx, id run.RunID, ops run.LeaseOps) error { + if ops.Clear { + if _, err := tx.ExecContext(ctx, `DELETE FROM leases WHERE run_id = ?`, string(id)); err != nil { + return fmt.Errorf("agent: sqlite store: clear leases: %w", err) + } + } + for _, key := range ops.Delete { + if _, err := tx.ExecContext(ctx, `DELETE FROM leases WHERE run_id = ? AND lease_key = ?`, string(id), key); err != nil { + return fmt.Errorf("agent: sqlite store: delete lease: %w", err) + } + } + for key, lease := range ops.Put { + if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO leases (run_id, lease_key, claim, grant, start_command_id, deadline_unix_nano) VALUES (?, ?, ?, ?, ?, ?)`, + string(id), key, string(lease.Claim), string(lease.Grant), string(lease.StartCommandID), deadlineValue(lease.Deadline)); err != nil { + return fmt.Errorf("agent: sqlite store: put lease: %w", err) + } + } + return nil +} + +func decodeHeader(raw []byte) (run.RunHeader, error) { + var header run.RunHeader + if err := json.Unmarshal(raw, &header); err != nil { + return run.RunHeader{}, fmt.Errorf("agent: sqlite store: decode header: %w", err) + } + // MachineState.Current is omitted from the header's JSON encoding. A valid + // header is always Open (RUN-NEW-1); ValidateRunHeader checks the digest. + header.InitialState.Current = run.Open{} + if err := run.ValidateRunHeader(&header); err != nil { + return run.RunHeader{}, fmt.Errorf("agent: sqlite store: header: %w", err) + } + return header, nil +} + +func ctxErr(ctx context.Context) error { + if ctx == nil { + return errors.New("agent: sqlite store: nil context") + } + return ctx.Err() +} diff --git a/agent/run/sqlitestore/store_test.go b/agent/run/sqlitestore/store_test.go new file mode 100644 index 0000000..c61d895 --- /dev/null +++ b/agent/run/sqlitestore/store_test.go @@ -0,0 +1,380 @@ +package sqlitestore_test + +import ( + "context" + "path/filepath" + "strconv" + "sync/atomic" + "testing" + "time" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/runtimetest" + "github.com/memohai/twilight/agent/run/sqlitestore" + "github.com/memohai/twilight/sdk" +) + +func TestOpenRejectsEmptyPath(t *testing.T) { + if _, err := sqlitestore.Open(""); err == nil { + t.Fatal("empty path accepted") + } +} + +func sqliteFactory(t *testing.T) runtimetest.Factory { + t.Helper() + var n atomic.Int64 + dir := t.TempDir() + return func() run.Runtime { + path := filepath.Join(dir, "runs-"+strconv.FormatInt(n.Add(1), 10)+".db") + store, err := sqlitestore.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + return run.NewRuntime(store) + } +} + +func sqliteRecoveryFactory(t *testing.T) runtimetest.RecoveryFactory { + t.Helper() + var n atomic.Int64 + dir := t.TempDir() + return func(now func() time.Time, ttl time.Duration) run.Runtime { + path := filepath.Join(dir, "recover-"+strconv.FormatInt(n.Add(1), 10)+".db") + store, err := sqlitestore.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + return run.NewRuntimeWithOptions(store, run.RuntimeOptions{LeaseTTL: ttl, Now: now}) + } +} + +func TestSQLiteRuntimeConformance(t *testing.T) { + runtimetest.RunConformance(t, sqliteFactory(t)) +} + +func TestSQLiteRuntimeRecovery(t *testing.T) { + runtimetest.RunRecoveryConformance(t, sqliteRecoveryFactory(t)) +} + +func TestSQLiteRuntimeLeaseRenewal(t *testing.T) { + runtimetest.RunLeaseRenewalConformance(t, sqliteRecoveryFactory(t)) +} + +// The stored snapshot is authoritative for Load: a reopened database serves +// Load without touching the transition log, and Record still verifies the +// snapshot against the full fold. +func TestSQLiteReopenLoadsSnapshotAndRecordVerifies(t *testing.T) { + path := filepath.Join(t.TempDir(), "snapshot.db") + store, err := sqlitestore.Open(path) + if err != nil { + t.Fatal(err) + } + rt := run.NewRuntime(store) + ctx := context.Background() + newRun, err := run.BuildNewRun("run-1", "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(ctx, newRun); err != nil { + t.Fatal(err) + } + startExecutingTool(t, rt, "run-1") + before, err := rt.Load(ctx, "run-1") + if err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + reopened, err := sqlitestore.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = reopened.Close() }() + rt2 := run.NewRuntime(reopened) + after, err := rt2.Load(ctx, "run-1") + if err != nil { + t.Fatal(err) + } + a, _ := run.ProtocolV1.EncodeMachineState(&before.State) + b, _ := run.ProtocolV1.EncodeMachineState(&after.State) + if string(a) != string(b) || before.Revision != after.Revision { + t.Fatalf("reopened snapshot differs:\n before %s\n after %s", a, b) + } + record, err := rt2.Record(ctx, "run-1") + if err != nil { + t.Fatal(err) + } + if record.Snapshot.Revision != after.Revision || len(record.Transitions) != int(after.Revision) { + t.Fatalf("record = revision %d transitions %d", record.Snapshot.Revision, len(record.Transitions)) + } + if diverged, err := run.Rebuild(ctx, reopened, "run-1"); err != nil || diverged { + t.Fatalf("rebuild diverged=%v err=%v", diverged, err) + } +} + +func TestSQLiteCrashReopenRecoversExpiredModel(t *testing.T) { + path := filepath.Join(t.TempDir(), "crash.db") + clock := time.Unix(1000, 0) + store, err := sqlitestore.Open(path) + if err != nil { + t.Fatal(err) + } + rt := run.NewRuntimeWithOptions(store, run.RuntimeOptions{ + LeaseTTL: time.Second, + Now: func() time.Time { return clock }, + }) + ctx := context.Background() + newRun, err := run.BuildNewRun("run-1", "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(ctx, newRun); err != nil { + t.Fatal(err) + } + stepID := startExecutingModel(t, rt, "run-1") + if err := store.Close(); err != nil { + t.Fatal(err) + } + + reopened, err := sqlitestore.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = reopened.Close() }() + rt2 := run.NewRuntimeWithOptions(reopened, run.RuntimeOptions{ + LeaseTTL: time.Second, + Now: func() time.Time { return clock }, + }) + snap, err := rt2.Load(ctx, "run-1") + if err != nil { + t.Fatal(err) + } + ms, ok := snap.State.Current.(run.ModelStep) + if !ok || ms.Status != run.ModelExecuting || ms.RefValue.ID != stepID { + t.Fatalf("after reopen: %+v", snap.State.Current) + } + + clock = time.Unix(1002, 0) + n, err := rt2.RecoverExpired(ctx) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("recovered = %d, want 1", n) + } + snap, err = rt2.Load(ctx, "run-1") + if err != nil { + t.Fatal(err) + } + if snap.State.RunID != "run-1" || snap.State.Status != run.RunActive { + t.Fatalf("after recover: %+v", snap.State) + } + ms, ok = snap.State.Current.(run.ModelStep) + if !ok || ms.Status != run.ModelPrepared { + t.Fatalf("after recover current: %+v", snap.State.Current) + } +} + +func TestSQLiteCrashReopenSettlesExpiredToolUnknown(t *testing.T) { + path := filepath.Join(t.TempDir(), "crash-tool.db") + clock := time.Unix(1000, 0) + store, err := sqlitestore.Open(path) + if err != nil { + t.Fatal(err) + } + rt := run.NewRuntimeWithOptions(store, run.RuntimeOptions{ + LeaseTTL: time.Second, + Now: func() time.Time { return clock }, + }) + ctx := context.Background() + newRun, err := run.BuildNewRun("run-1", "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(ctx, newRun); err != nil { + t.Fatal(err) + } + startExecutingTool(t, rt, "run-1") + if err := store.Close(); err != nil { + t.Fatal(err) + } + + clock = time.Unix(1002, 0) + reopened, err := sqlitestore.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = reopened.Close() }() + rt2 := run.NewRuntimeWithOptions(reopened, run.RuntimeOptions{ + LeaseTTL: time.Second, + Now: func() time.Time { return clock }, + }) + snap, err := rt2.Load(ctx, "run-1") + if err != nil { + t.Fatal(err) + } + if snap.State.Status != run.RunActive { + t.Fatalf("status after reopen = %v", snap.State.Status) + } + if _, ok := snap.State.Current.(run.ToolStep); !ok { + t.Fatalf("after reopen current = %+v, want ToolStep", snap.State.Current) + } + n, err := rt2.RecoverExpired(ctx) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("recovered = %d, want 1", n) + } + snap, err = rt2.Load(ctx, "run-1") + if err != nil { + t.Fatal(err) + } + if snap.State.RunID != "run-1" || snap.State.Status != run.RunActive { + t.Fatalf("after recover: %+v", snap.State) + } + if _, ok := snap.State.Current.(run.Open); !ok { + t.Fatalf("after recover current = %+v, want Open", snap.State.Current) + } + if snap.State.LastToolStep == nil || len(snap.State.LastToolStep.Calls) != 1 { + t.Fatalf("LastToolStep = %+v", snap.State.LastToolStep) + } + call := snap.State.LastToolStep.Calls[0] + if call.Status != run.ToolFailed || call.Failure == nil || call.Failure.Outcome != run.ToolOutcomeUnknown { + t.Fatalf("unknown call = %+v", call) + } +} + +func startExecutingModel(t *testing.T, rt run.Runtime, runID run.RunID) run.StepID { + t.Helper() + ctx := context.Background() + snap, err := rt.Load(ctx, runID) + if err != nil { + t.Fatal(err) + } + req := sdk.Request{Model: "m-1", Messages: []sdk.Message{sdk.UserMessage("hi")}} + prep, cmdID := prepareFromSnap(t, snap, req, nil) + if _, err := commit(t, rt, runID, cmdID, snap.Revision, "", prep); err != nil { + t.Fatal(err) + } + start, err := commit(t, rt, runID, "start-1", 1, "", run.StartModelExecution{StepID: prep.StepID, Claim: "claim-crash"}) + if err != nil { + t.Fatal(err) + } + if start.Grant == "" { + t.Fatal("start returned no grant") + } + return prep.StepID +} + +func startExecutingTool(t *testing.T, rt run.Runtime, runID run.RunID) { + t.Helper() + def := sdk.ToolDefinition{Name: "t", Parameters: []byte(`{"type":"object"}`)} + spec := freezeSpec(t, def) + ctx := context.Background() + snap, err := rt.Load(ctx, runID) + if err != nil { + t.Fatal(err) + } + req := sdk.Request{Model: "m-1", Messages: []sdk.Message{sdk.UserMessage("hi")}, Tools: []sdk.ToolDefinition{def}} + prep, cmdID := prepareFromSnap(t, snap, req, []run.ToolSpec{spec}) + if _, err := commit(t, rt, runID, cmdID, snap.Revision, "", prep); err != nil { + t.Fatal(err) + } + start, err := commit(t, rt, runID, "start-1", 1, "", run.StartModelExecution{StepID: prep.StepID, Claim: "claim-crash"}) + if err != nil { + t.Fatal(err) + } + args := run.MustParseCanonicalJSON(`{}`) + bd, err := run.DigestToolCallBinding("c1", spec.DefinitionDigest, spec.Policy, args) + if err != nil { + t.Fatal(err) + } + binding := run.ToolCallBinding{ + CallID: "c1", ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, + BindingDigest: bd, Arguments: args, Policy: spec.Policy, + } + result := sdk.ModelResult{ + FinishReason: sdk.FinishReasonToolCalls, + Usage: sdk.Usage{InputTokens: 10, OutputTokens: 5, TotalTokens: 15}, + ToolCalls: []sdk.ToolCall{{ToolCallID: "c1", ToolName: "t", Input: `{}`}}, + } + frozen, err := run.FreezeModelResult(result) + if err != nil { + t.Fatal(err) + } + opened, err := commit(t, rt, runID, "complete-1", 2, start.Grant, run.SubmitModelResult{ + StepID: prep.StepID, Result: frozen, Calls: []run.ToolCallBinding{binding}, + }) + if err != nil { + t.Fatal(err) + } + if len(opened.Events) < 2 { + t.Fatalf("events = %d, want ToolStepOpened", len(opened.Events)) + } + openedFact, ok := opened.Events[1].Fact.(run.ToolStepOpened) + if !ok { + t.Fatalf("event[1] = %T, want ToolStepOpened", opened.Events[1].Fact) + } + if _, err := commit(t, rt, runID, "start-c1", opened.Snapshot.Revision, "", run.StartToolCall{ + StepID: openedFact.StepID, CallID: "c1", Claim: "claim-tool", + }); err != nil { + t.Fatal(err) + } +} + +func freezeSpec(t *testing.T, def sdk.ToolDefinition) run.ToolSpec { + t.Helper() + frozen, err := run.FreezeToolDefinition(def) + if err != nil { + t.Fatal(err) + } + d, err := run.ProtocolV1.DigestToolDefinition(frozen) + if err != nil { + t.Fatal(err) + } + return run.ToolSpec{Ref: run.ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: run.DirectExecution} +} + +func prepareFromSnap(t *testing.T, snap run.RuntimeSnapshot, req sdk.Request, specs []run.ToolSpec) (run.PrepareModelRequest, run.CommandID) { + t.Helper() + frozenReq, err := run.FreezeModelRequest(req) + if err != nil { + t.Fatal(err) + } + reqDigest, err := run.ProtocolV1.DigestRequest(frozenReq) + if err != nil { + t.Fatal(err) + } + toolsDigest, err := run.ProtocolV1.DigestToolSpecs(specs) + if err != nil { + t.Fatal(err) + } + model := run.ModelRef(frozenReq.Model) + binding, err := run.ProtocolV1.DigestModelStepBinding(model, reqDigest, toolsDigest) + if err != nil { + t.Fatal(err) + } + cmdID := run.DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) + stepID := run.DeriveModelStepID(snap.State.RunID, cmdID, binding) + ids := make([]run.InputID, len(snap.State.PendingInputs)) + for i, in := range snap.State.PendingInputs { + ids[i] = in.ID + } + return run.PrepareModelRequest{ + StepID: stepID, Model: model, Request: frozenReq, + RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, + }, cmdID +} + +func commit(t *testing.T, rt run.Runtime, runID run.RunID, id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) (run.CommitResult, error) { + t.Helper() + env, err := run.ProtocolV1.BuildEnvelope(runID, id, cmd) + if err != nil { + t.Fatal(err) + } + return rt.Commit(context.Background(), run.CommitRequest{BaseRevision: base, Grant: grant, Command: env}) +} diff --git a/agent/run/store.go b/agent/run/store.go index f19d523..3c77222 100644 --- a/agent/run/store.go +++ b/agent/run/store.go @@ -17,99 +17,173 @@ type ExecutionLease struct { Deadline time.Time } -// StoredRun is one Run's persisted view. Store implementations clone on the -// way out of View and isolate Update mutations until fn returns nil. -type StoredRun struct { - Header RunHeader - State MachineState - Revision uint64 - Watermark uint64 - Log []TransitionRecord - Transitions map[CommandID]TransitionRecord - Leases map[string]ExecutionLease - StartGrants map[CommandID]ExecutionGrant +// RunHead is the current authority position of one Run: the immutable header, +// the stored MachineState snapshot at Revision, and the live leases. It never +// carries the transition log; Runtime reads the log separately through LoadLog +// only when it needs to verify or rebuild. +type RunHead struct { + Header RunHeader + State MachineState + Revision uint64 + Leases map[string]ExecutionLease } -// Store persists Runs for one Runtime. It does not call EvaluateCommit, -// Decide, or Evolve. SQLite and Postgres adapters implement this contract; -// MemoryStore is the in-process implementation. +// LeaseOps is the lease change applied atomically with one appended +// transition. Put upserts by lease key, Delete removes by key, Clear removes +// every lease of the Run before Put is applied. +type LeaseOps struct { + Put map[string]ExecutionLease + Delete []string + Clear bool +} + +// Append is the write Runtime hands back from a Commit critical section: the +// next transition, the snapshot after it, and the lease change. The Store +// persists all three atomically. ExpectedRevision must equal the head +// revision the section observed; a mismatch is a Runtime bug and returns +// ErrAppendConflict. +type Append struct { + ExpectedRevision uint64 + Transition TransitionRecord + State MachineState + Leases LeaseOps +} + +// ExpiredLease is one lease whose deadline has passed, addressed by Run and +// lease key so the recovery scanner can load only the affected Runs. +type ExpiredLease struct { + RunID RunID + Key string + Lease ExecutionLease +} + +// ErrAppendConflict reports that a write observed a revision other than the +// one it was built against. Inside a Commit section this cannot happen; it +// guards ReplaceSnapshot and adapter bugs. +var ErrAppendConflict = errors.New("agent: store: append revision conflict") + +// RunTx is the view a Commit critical section has of one Run. Both reads see +// the same authority version, and nothing else can advance the Run until the +// section returns. +type RunTx interface { + Head() RunHead + LookupTransition(CommandID) (TransitionRecord, bool, error) +} + +// Store persists Runs for one Runtime as an append-only transition log plus a +// derived MachineState snapshot and lease table. It does not call +// EvaluateCommit, Decide, or Evolve. The log is never rewritten: the only +// write paths are Create, the Append returned from a Commit section, lease +// maintenance, and ReplaceSnapshot for the derived snapshot. Every Run is a +// single-writer aggregate: Commit serializes all writers of one Run +// (RUN-CMT-2), so Runtime evaluates against a head that cannot move under it. type Store interface { + // Create stores the header and Revision-0 snapshot once per RunID. A second + // Create for the same RunID returns created=false and the stored header. Create(ctx context.Context, header RunHeader) (created bool, existing RunHeader, err error) - View(ctx context.Context, id RunID) (StoredRun, error) - Update(ctx context.Context, id RunID, fn func(*StoredRun) error) error - ListIDs(ctx context.Context) ([]RunID, error) + // LoadHead returns the header, current snapshot, revision and leases in one + // consistent read. It does not touch the log. + LoadHead(ctx context.Context, id RunID) (RunHead, error) + // LoadLog returns every TransitionRecord with Revision >= from, ordered by + // revision. from=1 returns the complete log. + LoadLog(ctx context.Context, id RunID, from uint64) ([]TransitionRecord, error) + // LoadRecord returns the head and the complete log in one consistent + // read: a concurrent Commit is either fully visible in both or + // in neither. Runtime.Record and Rebuild use it. + LoadRecord(ctx context.Context, id RunID) (RunHead, []TransitionRecord, error) + // Commit runs fn inside the Run's critical section. When fn returns a + // non-nil Append, the Store persists it in the same section; a nil Append + // with a nil error means fn decided without writing (replay or rejection + // mapped by the caller). fn's error aborts the section unchanged. + Commit(ctx context.Context, id RunID, fn func(RunTx) (*Append, error)) error + // RenewLease moves the deadline of the lease at key forward when its grant + // still equals grant; a missing or re-issued lease returns ErrStaleRuntime. + RenewLease(ctx context.Context, id RunID, key string, grant ExecutionGrant, deadline time.Time) error + // ExpiredLeases lists leases whose non-zero deadline is at or before + // before, across all Runs. + ExpiredLeases(ctx context.Context, before time.Time) ([]ExpiredLease, error) + // ReplaceSnapshot overwrites the derived snapshot at revision without + // touching the log. Rebuild is its only caller. + ReplaceSnapshot(ctx context.Context, id RunID, revision uint64, state *MachineState) error } -func cloneStoredRun(s StoredRun) StoredRun { - out := StoredRun{ - Header: cloneRunHeader(s.Header), - State: cloneMachineState(&s.State), - Revision: s.Revision, - Watermark: s.Watermark, - } - if s.Log != nil { - out.Log = cloneTransitionRecords(s.Log) - } - if s.Transitions != nil { - out.Transitions = make(map[CommandID]TransitionRecord, len(s.Transitions)) - for id, rec := range s.Transitions { - out.Transitions[id] = cloneTransitionRecord(&rec) - } +func cloneRunHead(h *RunHead) RunHead { + out := RunHead{ + Header: cloneRunHeader(h.Header), + State: cloneMachineState(&h.State), + Revision: h.Revision, } - if s.Leases != nil { - out.Leases = make(map[string]ExecutionLease, len(s.Leases)) - for k, v := range s.Leases { + if h.Leases != nil { + out.Leases = make(map[string]ExecutionLease, len(h.Leases)) + for k, v := range h.Leases { out.Leases[k] = v } } - if s.StartGrants != nil { - out.StartGrants = make(map[CommandID]ExecutionGrant, len(s.StartGrants)) - for k, v := range s.StartGrants { - out.StartGrants[k] = v - } - } return out } -func (s *StoredRun) forgetStartGrant(grant ExecutionGrant) { - if grant == "" || s.StartGrants == nil { - return - } - for commandID, candidate := range s.StartGrants { - if candidate == grant { - delete(s.StartGrants, commandID) +func applyLeaseOps(leases map[string]ExecutionLease, ops LeaseOps) map[string]ExecutionLease { + out := make(map[string]ExecutionLease, len(leases)+len(ops.Put)) + if !ops.Clear { + for k, v := range leases { + out[k] = v } } + for _, k := range ops.Delete { + delete(out, k) + } + for k, v := range ops.Put { + out[k] = v + } + return out } -func (s *StoredRun) clearLeases() { - s.Leases = make(map[string]ExecutionLease) - s.StartGrants = make(map[CommandID]ExecutionGrant) +// ValidateAppend checks the structural preconditions every Store enforces +// before writing: the Append was built against the current head, the +// transition revision is contiguous, and RunIDs agree. Store adapters call it +// inside their Commit section. +func ValidateAppend(id RunID, head *RunHead, a *Append) error { + if head.Revision != a.ExpectedRevision { + return fmt.Errorf("%w: expected %d, at %d", ErrAppendConflict, a.ExpectedRevision, head.Revision) + } + if a.Transition.RunID != id || a.Transition.Revision != a.ExpectedRevision+1 { + return fmt.Errorf("agent: store: transition %s@%d does not follow %s@%d", a.Transition.RunID, a.Transition.Revision, id, a.ExpectedRevision) + } + if a.State.RunID != id { + return fmt.Errorf("agent: store: snapshot RunID %q does not match %q", a.State.RunID, id) + } + return nil } -// Rebuild is an optional diagnostic (RUN-CMT-2): it refolds the state from -// the transition log and replaces the stored state with the fold result. -// Runtime.Commit remains the normal state transition path. A log shorter than -// the last committed revision returns ErrLogTruncated (audit gap). It returns -// true when the refolded state differs from the stored state, which identifies -// an Evolve bug, out-of-band write, or storage corruption. +// Rebuild is a diagnostic (RUN-CMT-2): it refolds the state from the complete +// transition log and replaces the derived snapshot with the fold result. The +// log is never modified. A log shorter than the head revision returns +// ErrLogTruncated (audit gap). It returns true when the refolded state differs +// from the stored snapshot, which identifies an Evolve bug, an out-of-band +// write, or storage corruption. func Rebuild(ctx context.Context, store Store, runID RunID) (rebuilt bool, err error) { if store == nil { return false, errors.New("agent: rebuild: nil store") } - var diverged bool - err = store.Update(ctx, runID, func(stored *StoredRun) error { - folded, maxRevision, foldErr := FoldRun(&stored.Header, stored.Log) - if foldErr != nil { - return foldErr - } - if maxRevision < stored.Watermark { - return fmt.Errorf("%w: log ends at %d, watermark %d", ErrLogTruncated, maxRevision, stored.Watermark) - } - diverged = stored.Revision != maxRevision || !statesEquivalent(&stored.State, &folded) - stored.State = folded - stored.Revision = maxRevision - return nil - }) - return diverged, err + head, log, err := store.LoadRecord(ctx, runID) + if err != nil { + return false, err + } + folded, maxRevision, err := FoldRun(&head.Header, log) + if err != nil { + return false, err + } + if maxRevision < head.Revision { + return false, fmt.Errorf("%w: log ends at %d, head revision %d", ErrLogTruncated, maxRevision, head.Revision) + } + if maxRevision > head.Revision { + return false, fmt.Errorf("agent: rebuild: log ends at %d beyond head revision %d", maxRevision, head.Revision) + } + if statesEquivalent(&head.State, &folded) { + return false, nil + } + if err := store.ReplaceSnapshot(ctx, runID, head.Revision, &folded); err != nil { + return true, err + } + return true, nil } diff --git a/agent/run/store_test.go b/agent/run/store_test.go index 75120be..dea1949 100644 --- a/agent/run/store_test.go +++ b/agent/run/store_test.go @@ -23,7 +23,7 @@ func TestExpiredLeaseAllowsGrantlessModelRecovery(t *testing.T) { t.Fatal(err) } in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} - env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) + env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) if err != nil { t.Fatal(err) } @@ -40,7 +40,7 @@ func TestExpiredLeaseAllowsGrantlessModelRecovery(t *testing.T) { t.Fatal(err) } claim := ExecutionClaim("claim-recover") - startEnv, err := BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) + startEnv, err := ProtocolV1.BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) if err != nil { t.Fatal(err) } @@ -53,7 +53,7 @@ func TestExpiredLeaseAllowsGrantlessModelRecovery(t *testing.T) { } clock = time.Unix(1002, 0) - recoverEnv, err := BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, claim), RecoverModelExecution{StepID: prep.StepID, Claim: claim}) + recoverEnv, err := ProtocolV1.BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, claim), RecoverModelExecution{StepID: prep.StepID, Claim: claim}) if err != nil { t.Fatal(err) } @@ -81,7 +81,7 @@ func TestZeroDeadlineRejectsGrantlessRecovery(t *testing.T) { t.Fatal(err) } in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} - env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) + env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) if err != nil { t.Fatal(err) } @@ -94,11 +94,11 @@ func TestZeroDeadlineRejectsGrantlessRecovery(t *testing.T) { t.Fatal(err) } claim := ExecutionClaim("claim-1") - startEnv, _ := BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) + startEnv, _ := ProtocolV1.BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) if _, err := rt.Commit(ctx, CommitRequest{Command: startEnv}); err != nil { t.Fatal(err) } - recoverEnv, _ := BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, claim), RecoverModelExecution{StepID: prep.StepID, Claim: claim}) + recoverEnv, _ := ProtocolV1.BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, claim), RecoverModelExecution{StepID: prep.StepID, Claim: claim}) if _, err := rt.Commit(ctx, CommitRequest{Command: recoverEnv}); err == nil { t.Fatal("grantless recover accepted on non-expiring lease") } @@ -119,7 +119,7 @@ func TestGrantlessModelRecoveryRejectsWrongClaim(t *testing.T) { t.Fatal(err) } in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} - env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) + env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) if err != nil { t.Fatal(err) } @@ -132,7 +132,7 @@ func TestGrantlessModelRecoveryRejectsWrongClaim(t *testing.T) { t.Fatal(err) } claim := ExecutionClaim("claim-recover") - startEnv, err := BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) + startEnv, err := ProtocolV1.BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) if err != nil { t.Fatal(err) } @@ -141,7 +141,7 @@ func TestGrantlessModelRecoveryRejectsWrongClaim(t *testing.T) { } clock = time.Unix(1002, 0) wrong := ExecutionClaim("other-claim") - recoverEnv, err := BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, wrong), RecoverModelExecution{StepID: prep.StepID, Claim: wrong}) + recoverEnv, err := ProtocolV1.BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, wrong), RecoverModelExecution{StepID: prep.StepID, Claim: wrong}) if err != nil { t.Fatal(err) } @@ -165,7 +165,7 @@ func TestRecoverExpiredRecoversExecutingModel(t *testing.T) { t.Fatal(err) } in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} - env, err := BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) + env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) if err != nil { t.Fatal(err) } @@ -178,7 +178,7 @@ func TestRecoverExpiredRecoversExecutingModel(t *testing.T) { t.Fatal(err) } claim := ExecutionClaim("claim-scan") - startEnv, err := BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) + startEnv, err := ProtocolV1.BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) if err != nil { t.Fatal(err) } diff --git a/agent/run/stored_runtime.go b/agent/run/stored_runtime.go index 135d363..820c11d 100644 --- a/agent/run/stored_runtime.go +++ b/agent/run/stored_runtime.go @@ -19,7 +19,9 @@ type runtime struct { } // RuntimeOptions configures lease occupancy for a Store-backed Runtime. -// LeaseTTL 0 means leases do not expire (grant-holder recovery only). +// LeaseTTL 0 means leases do not expire (grant-holder recovery only). A +// positive LeaseTTL must exceed the longest gap between a worker's start and +// its next RenewLease or settlement; see RUN-CMT-8. type RuntimeOptions struct { LeaseTTL time.Duration Now func() time.Time @@ -71,43 +73,76 @@ func (r *runtime) Create(ctx context.Context, run NewRun) (CreateResult, error) return CreateResult{Header: cloneRunHeader(header), Created: true}, nil } +func (r *runtime) loadHead(ctx context.Context, runID RunID) (RunHead, error) { + head, err := r.store.LoadHead(ctx, runID) + if err != nil { + return RunHead{}, err + } + if err := r.validateHead(runID, &head); err != nil { + return RunHead{}, err + } + return head, nil +} + +func (r *runtime) validateHead(runID RunID, head *RunHead) error { + if head.Header.RunID != runID || head.State.RunID != runID { + return fmt.Errorf("agent: runtime: stored RunID %q/%q does not match %q", head.Header.RunID, head.State.RunID, runID) + } + if err := ValidateRunHeader(&head.Header); err != nil { + return fmt.Errorf("agent: runtime: invalid header: %w", err) + } + if err := ValidateMachineState(&head.State); err != nil { + return fmt.Errorf("agent: runtime: invalid snapshot: %w", err) + } + return nil +} + +func snapshotOf(head *RunHead) RuntimeSnapshot { + return RuntimeSnapshot{State: cloneMachineState(&head.State), Revision: head.Revision, SchemaVersion: head.Header.SchemaVersion} +} + func (r *runtime) Load(ctx context.Context, runID RunID) (RuntimeSnapshot, error) { if err := checkContext(ctx); err != nil { return RuntimeSnapshot{}, err } - stored, err := r.store.View(ctx, runID) + head, err := r.loadHead(ctx, runID) if err != nil { return RuntimeSnapshot{}, err } - snapshot := RuntimeSnapshot{State: stored.State, Revision: stored.Revision, SchemaVersion: stored.Header.SchemaVersion} - if stored.Header.RunID != runID || stored.State.RunID != runID { - return RuntimeSnapshot{}, fmt.Errorf("agent: runtime: stored RunID %q/%q does not match %q", stored.Header.RunID, stored.State.RunID, runID) - } - if err := ValidateRunHeader(&stored.Header); err != nil { - return RuntimeSnapshot{}, fmt.Errorf("agent: runtime: invalid header: %w", err) - } - if err := ValidateMachineState(&snapshot.State); err != nil { - return RuntimeSnapshot{}, fmt.Errorf("agent: runtime: invalid snapshot: %w", err) - } - return snapshot, nil + return snapshotOf(&head), nil } +//nolint:gocritic // hugeParam: Runtime.Commit is the public value-based contract. func (r *runtime) Commit(ctx context.Context, req CommitRequest) (CommitResult, error) { if err := checkContext(ctx); err != nil { return CommitResult{}, err } + runID := req.Command.RunID var result CommitResult - err := r.store.Update(ctx, req.Command.RunID, func(stored *StoredRun) error { + // The whole evaluation runs inside the Store's per-Run critical section + // (RUN-CMT-2): the head cannot move between read and write, so there is + // no compare-and-swap retry and concurrent writers of one Run serialize. + err := r.store.Commit(ctx, runID, func(tx RunTx) (*Append, error) { + head := tx.Head() + if err := r.validateHead(runID, &head); err != nil { + return nil, err + } var err error - result, err = r.evaluateAndApply(stored, req) - return err + var appendReq *Append + result, appendReq, err = r.evaluate(tx, &head, &req) + return appendReq, err }) - return result, err + if err != nil { + return CommitResult{}, err + } + return result, nil } -func (r *runtime) evaluateAndApply(stored *StoredRun, req CommitRequest) (CommitResult, error) { +// evaluate runs EvaluateCommit against head and returns either a final +// result (append == nil) or the Append the caller must persist. +func (r *runtime) evaluate(tx RunTx, head *RunHead, req *CommitRequest) (CommitResult, *Append, error) { key := grantKey(req.Command.Command) - lease, hasLease := stored.Leases[key] + lease, hasLease := head.Leases[key] grantValid := hasLease && req.Grant != "" && lease.Grant == req.Grant if cmd, ok := req.Command.Command.(RecoverModelExecution); ok && req.Grant != "" { grantValid = grantValid && lease.Claim == cmd.Claim @@ -118,116 +153,117 @@ func (r *runtime) evaluateAndApply(stored *StoredRun, req CommitRequest) (Commit } var prior *TransitionRecord - if record, ok := stored.Transitions[req.Command.ID]; ok { - copy := cloneTransitionRecord(&record) - prior = © + if record, found, err := tx.LookupTransition(req.Command.ID); err != nil { + return CommitResult{}, nil, err + } else if found { + prior = &record } - proto, err := ProtocolFor(stored.Header.SchemaVersion) + proto, err := ProtocolFor(head.Header.SchemaVersion) if err != nil { - return CommitResult{}, err + return CommitResult{}, nil, err } - decision, err := EvaluateCommit(stored.State, stored.Revision, prior, req, grantValid, recoveryValid, proto) + decision, err := EvaluateCommit(head.State, head.Revision, prior, *req, grantValid, recoveryValid, proto) if err != nil { - return CommitResult{}, err + return CommitResult{}, nil, err } switch decision.Kind { case DecisionAlreadyApplied: var grant ExecutionGrant - if _, ok := req.Command.Command.(StartModelExecution); ok { - candidate := stored.StartGrants[req.Command.ID] - if candidate != "" && stored.Leases[key].Grant == candidate { - grant = candidate - } - } else if _, ok := req.Command.Command.(StartToolCall); ok { - candidate := stored.StartGrants[req.Command.ID] - if candidate != "" && stored.Leases[key].Grant == candidate { - grant = candidate + switch req.Command.Command.(type) { + case StartModelExecution, StartToolCall: + // The start's grant is live only while the lease it minted is + // still the lease on record for this target. + if hasLease && lease.StartCommandID == req.Command.ID { + grant = lease.Grant } } - return CommitResult{Status: CommitAlreadyApplied, - Snapshot: RuntimeSnapshot{State: cloneMachineState(&stored.State), Revision: stored.Revision, SchemaVersion: stored.Header.SchemaVersion}, - Events: cloneEvents(decision.Events), Grant: grant}, nil + return CommitResult{Status: CommitAlreadyApplied, Snapshot: snapshotOf(head), + Events: cloneEvents(decision.Events), Grant: grant}, nil, nil case DecisionConflict: - return CommitResult{}, ErrCommandConflict + return CommitResult{}, nil, ErrCommandConflict case DecisionStale: if decision.Reject != nil && !errors.Is(decision.Reject, ErrStaleRuntime) { - return CommitResult{}, fmt.Errorf("%w: %w", ErrStaleRuntime, decision.Reject) + return CommitResult{}, nil, fmt.Errorf("%w: %w", ErrStaleRuntime, decision.Reject) } - return CommitResult{}, ErrStaleRuntime + return CommitResult{}, nil, ErrStaleRuntime case DecisionTerminal: - return CommitResult{}, ErrRunTerminal + return CommitResult{}, nil, ErrRunTerminal } - storedRecord := cloneTransitionRecord(&decision.Transition) - stored.State = cloneMachineState(&decision.NewState) - stored.Revision++ - stored.Watermark = stored.Revision - if stored.Transitions == nil { - stored.Transitions = make(map[CommandID]TransitionRecord) - } - stored.Transitions[req.Command.ID] = storedRecord - stored.Log = append(stored.Log, storedRecord) - + appendReq := Append{ + ExpectedRevision: head.Revision, + Transition: cloneTransitionRecord(&decision.Transition), + State: cloneMachineState(&decision.NewState), + } var minted ExecutionGrant switch cmd := req.Command.Command.(type) { case StartModelExecution, StartToolCall: minted = newGrant() - if stored.Leases == nil { - stored.Leases = make(map[string]ExecutionLease) - } - if stored.StartGrants == nil { - stored.StartGrants = make(map[CommandID]ExecutionGrant) - } - lease := ExecutionLease{Grant: minted, StartCommandID: req.Command.ID, Deadline: r.leaseDeadline()} + newLease := ExecutionLease{Grant: minted, StartCommandID: req.Command.ID, Deadline: r.leaseDeadline()} switch c := any(cmd).(type) { case StartModelExecution: - lease.Claim = c.Claim + newLease.Claim = c.Claim case StartToolCall: - lease.Claim = c.Claim + newLease.Claim = c.Claim } - stored.Leases[key] = lease - stored.StartGrants[req.Command.ID] = minted + appendReq.Leases.Put = map[string]ExecutionLease{key: newLease} case SubmitModelResult, SubmitModelFailure, RejectModelResult, RecoverModelExecution, SubmitToolResult, SubmitToolFailure: - active := stored.Leases[key] - delete(stored.Leases, key) - stored.forgetStartGrant(active.Grant) + appendReq.Leases.Delete = []string{key} } - if stored.State.Status.Terminal() { - stored.clearLeases() + if decision.NewState.Status.Terminal() { + appendReq.Leases = LeaseOps{Clear: true} } - return CommitResult{Status: CommitAccepted, - Snapshot: RuntimeSnapshot{State: cloneMachineState(&stored.State), Revision: stored.Revision, SchemaVersion: stored.Header.SchemaVersion}, - Events: cloneEvents(storedRecord.Events), Grant: minted}, nil + after := RunHead{Header: head.Header, State: decision.NewState, Revision: head.Revision + 1} + return CommitResult{Status: CommitAccepted, Snapshot: snapshotOf(&after), + Events: cloneEvents(appendReq.Transition.Events), Grant: minted}, &appendReq, nil } func (r *runtime) Record(ctx context.Context, runID RunID) (RunRecord, error) { if err := checkContext(ctx); err != nil { return RunRecord{}, err } - stored, err := r.store.View(ctx, runID) + head, transitions, err := r.store.LoadRecord(ctx, runID) if err != nil { return RunRecord{}, err } - header := stored.Header - snapshot := RuntimeSnapshot{State: stored.State, Revision: stored.Revision, SchemaVersion: header.SchemaVersion} - transitions := stored.Log - if err := ValidateRunHeader(&header); err != nil { - return RunRecord{}, fmt.Errorf("agent: runtime: invalid header: %w", err) + if err := r.validateHead(runID, &head); err != nil { + return RunRecord{}, err + } + if uint64(len(transitions)) != head.Revision { + return RunRecord{}, fmt.Errorf("%w: log has %d transitions, head revision %d", ErrLogTruncated, len(transitions), head.Revision) } for i := range transitions { if err := ValidateTransitionRecord(&transitions[i]); err != nil { return RunRecord{}, fmt.Errorf("agent: runtime: invalid transition %d: %w", i, err) } } - folded, revision, err := FoldRun(&header, transitions) + folded, revision, err := FoldRun(&head.Header, transitions) if err != nil { return RunRecord{}, fmt.Errorf("agent: runtime: fold: %w", err) } - if revision != snapshot.Revision || !statesEquivalent(&folded, &snapshot.State) { + if revision != head.Revision || !statesEquivalent(&folded, &head.State) { return RunRecord{}, errors.New("agent: runtime: snapshot diverges from transition log") } - return RunRecord{Header: cloneRunHeader(header), Snapshot: cloneRuntimeSnapshot(snapshot), Transitions: cloneTransitionRecords(transitions)}, nil + return RunRecord{Header: cloneRunHeader(head.Header), Snapshot: snapshotOf(&head), Transitions: transitions}, nil +} + +// RenewLease extends the live lease behind grant on the Executing target of +// stepID/callID by one LeaseTTL from now. Workers call it periodically while +// an effect runs so a long tool call is not recovered as Unknown under it. +// With LeaseTTL zero it is a no-op after validating the grant. +func (r *runtime) RenewLease(ctx context.Context, runID RunID, stepID StepID, callID CallID, grant ExecutionGrant) error { + if err := checkContext(ctx); err != nil { + return err + } + if grant == "" { + return ErrStaleRuntime + } + key := leaseKey(stepID, callID) + if key == "" { + return errors.New("agent: runtime: renew requires a step") + } + return r.store.RenewLease(ctx, runID, key, grant, r.leaseDeadline()) } // RecoverExpired commits grantless recovery for every expired lease that @@ -235,49 +271,55 @@ func (r *runtime) Record(ctx context.Context, runID RunID) (RunRecord, error) { // Unknown; an expired model step is recovered to Prepared. Hosts call this // on a timer; Loop does not. The dying process writes nothing. func (r *runtime) RecoverExpired(ctx context.Context) (int, error) { - ids, err := r.store.ListIDs(ctx) + if r.leaseTTL <= 0 { + return 0, nil + } + expired, err := r.store.ExpiredLeases(ctx, r.now()) if err != nil { return 0, err } n := 0 - for _, id := range ids { - stored, err := r.store.View(ctx, id) + for _, e := range expired { + head, err := r.loadHead(ctx, e.RunID) if err != nil { + if errors.Is(err, ErrRunNotFound) { + continue + } return n, err } - proto, err := ProtocolFor(stored.Header.SchemaVersion) + if !r.leaseExpired(e.Lease) { + continue + } + proto, err := ProtocolFor(head.Header.SchemaVersion) if err != nil { return n, err } - for key, lease := range stored.Leases { - if !r.leaseExpired(lease) { - continue - } - cmd, cmdID, ok := recoveryCommand(stored.State, key, lease) - if !ok { + cmd, cmdID, ok := recoveryCommand(&head.State, e.Key, e.Lease) + if !ok { + continue + } + env, err := proto.BuildEnvelope(e.RunID, cmdID, cmd) + if err != nil { + return n, err + } + res, err := r.Commit(ctx, CommitRequest{Command: env}) + if err != nil { + if errors.Is(err, ErrStaleRuntime) || errors.Is(err, ErrRunTerminal) || errors.Is(err, ErrCommandConflict) { continue } - env, err := proto.BuildEnvelope(id, cmdID, cmd) - if err != nil { - return n, err - } - _, err = r.Commit(ctx, CommitRequest{Command: env}) - if err != nil { - if errors.Is(err, ErrStaleRuntime) || errors.Is(err, ErrRunTerminal) { - continue - } - return n, err - } + return n, err + } + if res.Status == CommitAccepted { n++ } } return n, nil } -func recoveryCommand(state MachineState, key string, lease ExecutionLease) (AgentCommand, CommandID, bool) { +func recoveryCommand(state *MachineState, key string, lease ExecutionLease) (AgentCommand, CommandID, bool) { switch cur := state.Current.(type) { case ModelStep: - if cur.Status != ModelExecuting || key != "model/"+string(cur.RefValue.ID) { + if cur.Status != ModelExecuting || key != leaseKey(cur.RefValue.ID, "") { return nil, "", false } return RecoverModelExecution{StepID: cur.RefValue.ID, Claim: lease.Claim}, @@ -313,24 +355,37 @@ func (r *runtime) leaseExpired(lease ExecutionLease) bool { return !r.now().Before(lease.Deadline) } +// leaseKey addresses the execution target of a start: a ModelStep by step, +// a tool call by step and call. +func leaseKey(stepID StepID, callID CallID) string { + switch { + case stepID == "": + return "" + case callID == "": + return "model/" + string(stepID) + default: + return "call/" + string(stepID) + "/" + string(callID) + } +} + func grantKey(c AgentCommand) string { switch cmd := c.(type) { case StartModelExecution: - return "model/" + string(cmd.StepID) + return leaseKey(cmd.StepID, "") case SubmitModelResult: - return "model/" + string(cmd.StepID) + return leaseKey(cmd.StepID, "") case SubmitModelFailure: - return "model/" + string(cmd.StepID) + return leaseKey(cmd.StepID, "") case RejectModelResult: - return "model/" + string(cmd.StepID) + return leaseKey(cmd.StepID, "") case RecoverModelExecution: - return "model/" + string(cmd.StepID) + return leaseKey(cmd.StepID, "") case StartToolCall: - return "call/" + string(cmd.StepID) + "/" + string(cmd.CallID) + return leaseKey(cmd.StepID, cmd.CallID) case SubmitToolResult: - return "call/" + string(cmd.StepID) + "/" + string(cmd.CallID) + return leaseKey(cmd.StepID, cmd.CallID) case SubmitToolFailure: - return "call/" + string(cmd.StepID) + "/" + string(cmd.CallID) + return leaseKey(cmd.StepID, cmd.CallID) default: return "" } diff --git a/docs/design/agent-artifact.md b/docs/design/agent-artifact.md index cba0466..5bc7197 100644 --- a/docs/design/agent-artifact.md +++ b/docs/design/agent-artifact.md @@ -1,6 +1,6 @@ # Twilight Agent Artifact Core -状态:设计规范 +状态:设计草案。无实现;wire 与 claim 状态表在 Memory reference implementation 通过 conformance 前不冻结。 本文定义 `agent/artifact`。文中的“必须”“不得”“应该”是协议约束;canonical JSON、JCS 与 domain-separated digest 使用 `agent/jsonstable` 和 `agent/es` 的通则。 diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 95ebc94..413d79c 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -91,7 +91,7 @@ type TransitionRecord struct { `CommandEnvelope.Digest` 覆盖 schema、type 与完整 command;RunID、CommandID、BaseRevision 和 grant 属于 envelope/commit metadata。`AgentEvent.Digest` 覆盖 schema、type 与完整 fact。`TransitionDigest` 覆盖自身以外的完整 transition,包括有序 event group。 -**RUN-WIR-3** 一个 transition 的 event 数至少为 1;其 RunID、Revision、CommandID、CommandDigest 相同,Index 从 0 连续递增。revision 从 1 连续递增。写入已存在的 Run 时,构造 command 必须使用该 Run 的 `Protocol.BuildEnvelope`(Loop 通过 `RuntimeSnapshot.Protocol()` 取得)。包级 `BuildEnvelope` 只服务当前写入 schema,供新 Run 与测试。构造与验证 transition 必须使用 `BuildTransitionRecord`、`ValidateTransitionRecord`。所有公开返回值具有 detached snapshot 语义。 +**RUN-WIR-3** 一个 transition 的 event 数至少为 1;其 RunID、Revision、CommandID、CommandDigest 相同,Index 从 0 连续递增。revision 从 1 连续递增。构造 command 必须使用该 Run 的 `Protocol.BuildEnvelope`(Loop 通过 `RuntimeSnapshot.Protocol()` 取得)。`agent/run` 不提供隐式选择版本的包级 `BuildEnvelope`、`Decide`、`Evolve` 或 `Digest*` 函数;新 Run 与测试显式使用 `ProtocolV1`。构造与验证 transition 必须使用 `BuildTransitionRecord`、`ValidateTransitionRecord`。所有公开返回值具有 detached snapshot 语义。 下列 identity 稳定派生并由 Commit 验证: @@ -265,7 +265,7 @@ ToolCall: 没有独立的 `ToolStepClosed` fact。最后一个 ToolCall 进入 Completed 或 Failed 时,`Evolve` 在折叠该 fact 后若全部 call 已 terminal,则把 Current 设为 `Open` 并写入 `LastToolStep` / `LastClosedStep`。Cancel 的 Unknown fact 同样走这条关闭规则;`RunEnded` 再把 Current 置空。 -**RUN-MCH-3** `Protocol.Decide(state, command)` 执行全部验证与 derived consequence,一次返回该 transition 的完整 ordered fact group;验证成功后返回完整 facts。包级 `Decide` 委托当前写入 schema。`Protocol.Evolve(state, fact)` 机械折叠 fact,依赖 fact 携带的完整数据。accepted facts 必须 self-contained;若 transition terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 +**RUN-MCH-3** `Protocol.Decide(state, command)` 执行全部验证与 derived consequence,一次返回该 transition 的完整 ordered fact group;验证成功后返回完整 facts。`Protocol.Evolve(state, fact)` 机械折叠 fact,依赖 fact 携带的完整数据。accepted facts 必须 self-contained;若 transition terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 启动 command 的最小公共形状为: @@ -310,6 +310,7 @@ type Runtime interface { Load(context.Context, RunID) (RuntimeSnapshot, error) Commit(context.Context, CommitRequest) (CommitResult, error) Record(context.Context, RunID) (RunRecord, error) + RenewLease(ctx context.Context, runID RunID, stepID StepID, callID CallID, grant ExecutionGrant) error RecoverExpired(context.Context) (int, error) } type RuntimeSnapshot struct { @@ -339,9 +340,10 @@ func (Protocol) DecodeFact(typ string, raw []byte) (Fact, error) func (Protocol) Decide(MachineState, AgentCommand) ([]Fact, error) func (Protocol) Evolve(MachineState, Fact) (MachineState, error) func (Protocol) BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) -func (Protocol) EncodeMachineState(*MachineState) ([]byte, error) +func (Protocol) EncodeMachineState(*MachineState) ([]byte, error) // 持久化 snapshot bytes,含 Current +func (Protocol) DecodeMachineState([]byte) (MachineState, error) func (Protocol) ValidateHeader(*RunHeader) error -var ProtocolV1 Protocol // SchemaVersion1 绑定;包级 DigestRequest/BuildEnvelope/Decide/Evolve 委托它 +var ProtocolV1 Protocol // SchemaVersion1 绑定;新 Run 的创建版本。没有委托它的包级函数 type CommitRequest struct { BaseRevision uint64 Grant ExecutionGrant @@ -357,7 +359,7 @@ type CommitResult struct { **RUN-CMT-1** Runtime 是 RunID-addressed collection。`Create` 原子保存 immutable header 与 Revision-0 state;相同 canonical header 幂等返回 `Created=false`,同 RunID 的不同 header 返回 `ErrCreateConflict`。缺失 Run 的 Load、Commit、Record 返回 `ErrRunNotFound`。 -**RUN-CMT-2** `Load` 返回当前 detached execution snapshot,并验证 `RunID`、revision 与 MachineState 的基本语义不变量;snapshot 与 revision 必须来自同一 authority 版本。`RuntimeSnapshot` 提供 Go 读取视图;跨实现持久化使用 Header 与 TransitionRecord,优化 snapshot 由实现管理。`Record` 在一个一致点读取 detached Header、Snapshot 和完整 TransitionRecord sequence,验证 header、每个 transition、连续 fold 与 snapshot 等价后返回;corrupt、gap 或 divergence 必须失败。普通消费者使用 Record 获取一致的完整记录。`FoldRun` 从 Header 和完整 transition sequence 重建状态;任何导入、诊断或 Record 校验先完成 FoldRun,再使用重建状态。 +**RUN-CMT-2** `Load` 返回当前 detached execution snapshot,并验证 `RunID`、revision 与 MachineState 的基本语义不变量;snapshot 与 revision 必须来自同一 authority 版本。`RuntimeSnapshot` 提供 Go 读取视图。`Protocol.EncodeMachineState` / `DecodeMachineState` 定义 MachineState 的持久化 snapshot wire(含 `Current` 判别式与 step body);Store 以该 wire 保存每个 revision 的 snapshot,`Load` 直接读取 snapshot 而不折叠日志。canonical record 仍是 Header 与 TransitionRecord;snapshot 是派生数据,`Record` 与 `Rebuild` 通过 FoldRun 校验它。`Record` 在一个一致点读取 detached Header、Snapshot 和完整 TransitionRecord sequence,验证 header、每个 transition、连续 fold 与 snapshot 等价后返回;corrupt、gap 或 divergence 必须失败。普通消费者使用 Record 获取一致的完整记录。`FoldRun` 从 Header 和完整 transition sequence 重建状态;任何导入、诊断或 Record 校验先完成 FoldRun,再使用重建状态。 所有 Runtime implementation 在自己的 critical section/transaction 内调用同一个 pure `EvaluateCommit`。顺序固定为: @@ -383,9 +385,34 @@ type CommitResult struct { **RUN-CMT-6** Commit 必须原子保存新 MachineState 与完整 TransitionRecord,保证 event group 完整写入。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 -**RUN-CMT-7** 每个 Run 的协议版本是 `RunHeader.SchemaVersion`,在 Create 时冻结。`RuntimeSnapshot.SchemaVersion` 必须等于该 header。`ProtocolFor(header.SchemaVersion)` 在 Run 边界返回绑定了该版本 digest/decode/Decide/Evolve 函数的 `Protocol` 值;随后的方法调用不再接受 version 参数。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Protocol 的 Version。不得使用进程全局 `currentSchemaVersion` 作为写入许可。v1 Run 的 replay 必须继续使用 `ProtocolV1`,即使进程已经把 `currentSchemaVersion` 升到 2。Loop 通过 `RuntimeSnapshot.Protocol().BuildEnvelope` 构造写入该 Run 的 envelope。 +**RUN-CMT-7** 每个 Run 的协议版本是 `RunHeader.SchemaVersion`,在 Create 时冻结。`RuntimeSnapshot.SchemaVersion` 必须等于该 header。`ProtocolFor(header.SchemaVersion)` 在 Run 边界返回绑定了该版本 digest/decode/Decide/Evolve 函数的 `Protocol` 值;随后的方法调用不再接受 version 参数。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Protocol 的 Version。`agent/run` 不保存进程全局的当前写入版本,也不提供隐式选择版本的包级函数;新 Run 由 `NewRun.SchemaVersion` 决定版本。v1 Run 的 replay 必须继续使用 `ProtocolV1`,即使进程已支持更高版本。Loop 通过 `RuntimeSnapshot.Protocol().BuildEnvelope` 构造写入该 Run 的 envelope。 -`Runtime` 的唯一实现叠在 `Store` 上:`Store` 持久化 header、state、transition log 与 `ExecutionLease`,不调用 Decide/Evolve。`MemoryStore`、SQLite、Postgres 实现同一合同。lease 的 `Deadline` 为零表示不超时(进程内占用);过期且无 settlement 时 Runtime 将 `recoveryValid` 置真,允许 grantless Recover。进程崩溃时不写 settlement。`RecoverExpired` 扫描过期 lease:对每个过期 Executing tool call 无 grant 提交 `SubmitToolFailure{Unknown}`,对过期 Executing model 提交 `RecoverModelExecution`。该 Run 保持 Active,同一 RunID 继续。`Rebuild` 从 header 与 transition log 重折 snapshot,供诊断使用。进程内宿主使用 `NewRuntime(NewMemoryStore())`,lease 不超时,grantless recover 被拒绝。生产崩溃恢复使用带 TTL 的 Store。 +`Runtime` 的唯一实现叠在 `Store` 上。`Store` 是追加式合同: + +```go +type RunHead struct { Header RunHeader; State MachineState; Revision uint64; Leases map[string]ExecutionLease } +type LeaseOps struct { Put map[string]ExecutionLease; Delete []string; Clear bool } +type Append struct { ExpectedRevision uint64; Transition TransitionRecord; State MachineState; Leases LeaseOps } +type ExpiredLease struct { RunID RunID; Key string; Lease ExecutionLease } +type Store interface { + Create(ctx, header RunHeader) (created bool, existing RunHeader, err error) + LoadHead(ctx, RunID) (RunHead, error) + LoadLog(ctx, RunID, from uint64) ([]TransitionRecord, error) + LoadRecord(ctx, RunID) (RunHead, []TransitionRecord, error) + LookupTransition(ctx, RunID, CommandID) (TransitionRecord, bool, error) + Commit(ctx, RunID, fn func(RunTx) (*Append, error)) error + // RunTx: Head() RunHead; LookupTransition(CommandID) (TransitionRecord, bool, error) + RenewLease(ctx, RunID, key string, grant ExecutionGrant, deadline time.Time) error + ExpiredLeases(ctx, before time.Time) ([]ExpiredLease, error) + ReplaceSnapshot(ctx, RunID, revision uint64, *MachineState) error +} +``` + +`Store` 不调用 Decide/Evolve。`Store.Commit` 是唯一推进 Run 的写路径,也是该 Run 的 critical section:Store 在其中向 Runtime 提供 `RunTx`(当前 head 与按 CommandID 查 transition),Runtime 在同一 section 内调用 `EvaluateCommit`,返回的 `Append` 由 Store 在同一事务内追加 transition、替换 snapshot 并应用 lease 变更。同一 Run 的所有写入者在此串行,head 在读与写之间不会移动,因此没有 compare-and-swap 重试;`ExpectedRevision` 与 head 不一致只可能是实现错误,返回 `ErrAppendConflict`。`MemoryStore` 用 per-Run 锁,SQLite 用 immediate 写事务,Postgres 用行锁事务实现该 section。日志不可改写:没有删除或替换 transition 的 Store 方法;`ReplaceSnapshot` 只覆盖派生 snapshot,且只由 `Rebuild` 调用。`LoadRecord` 在一个一致读取内返回 head 与完整日志,`Runtime.Record` 与 `Rebuild` 只用它。`Load` 只读 head,`Commit` 只在 section 内读 head 与一条 transition,二者的代价都与日志长度无关。`MemoryStore`、SQLite、Postgres 实现同一合同,并通过同一 conformance。 + +lease 以 `(RunID, key)` 存放,key 为 `model/` 或 `call//`,一个 target 至多一条 live lease;grant 只存在于该 lease 上,没有独立的 grant 表。lease 的 `Deadline` 为零表示不超时(进程内占用);过期且无 settlement 时 Runtime 将 `recoveryValid` 置真,允许 grantless Recover。进程崩溃时不写 settlement。`RecoverExpired` 通过 `Store.ExpiredLeases(now)` 只加载有过期 lease 的 Run:对每个过期 Executing tool call 无 grant 提交 `SubmitToolFailure{Unknown}`,对过期 Executing model 提交 `RecoverModelExecution`。该 Run 保持 Active,同一 RunID 继续。`Rebuild` 从 header 与完整 transition log 重折 snapshot,与存储的 snapshot 比较后经 `ReplaceSnapshot` 修复,供诊断使用;日志短于 head revision 返回 `ErrLogTruncated`。进程内宿主使用 `NewRuntime(NewMemoryStore())`,lease 不超时,grantless recover 被拒绝。生产崩溃恢复使用带 TTL 的 Runtime。 + +**RUN-CMT-8** lease 续期。`Runtime.RenewLease(runID, stepID, callID, grant)` 在 grant 等于该 target 当前 lease 的 grant 时,把 deadline 推后一个 `LeaseTTL`;lease 不存在、grant 不匹配或 target 已 settlement 时返回 `ErrStaleRuntime`。持有 grant 的 worker 在效果执行期间必须以远小于 `LeaseTTL` 的间隔续期(Loop 的 `ExecutionPolicy.LeaseRenewInterval`);续期返回 `ErrStaleRuntime` 表示该 target 已被 recovery 接管,worker 必须停止执行并放弃 settlement。`LeaseTTL` 是恢复延迟上界,不再是单次工具调用的时长上界。`LeaseTTL` 为零时 `RenewLease` 只验证 grant,不改变 deadline。 ## 6. Loop ports 与 policy @@ -435,6 +462,7 @@ const ( type ExecutionPolicy struct { ToolExecution ToolExecutionMode MaxParallel int + LeaseRenewInterval time.Duration OnMalformedModelResult func(run.ModelStep, run.StepFailure) run.ModelRejectDisposition } type LoopResult struct { @@ -447,7 +475,7 @@ func New(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy func (*Loop) Run(context.Context, run.Runtime, 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`。 +**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`。`LeaseRenewInterval` 是 worker 续期间隔(RUN-CMT-8):模型与工具 worker 在效果执行期间按该间隔调用 `Runtime.RenewLease`,续期被拒时取消该 worker 的 ctx;零值关闭续期,只对 lease 不超时的 Runtime 正确。 **RUN-LOP-7** `ModelRef` 是冻结请求中的执行身份。`ModelCatalog.Resolve` 在同一 Run 生命周期内必须把同一 `ModelRef` 解析为等价的执行语义。provider 绑定不进入 frozen request,因此 Catalog 不得把同一 ref 改绑到不同实现。 @@ -514,7 +542,10 @@ type Event struct { - grant 签发、隔离、精确 start replay、消费、跨 Run 拒绝与 recovery authorization; - revision/index、atomic complete TransitionRecord、fact/transition digest; - Load/Commit/Record alias isolation、多 Run 隔离; -- Record 单一一致点、FoldRun 等价、gap/tamper/corrupt failure。 +- Record 单一一致点、FoldRun 等价、gap/tamper/corrupt failure; +- lease 过期 recovery:live lease 拒绝 grantless、过期 model 回到 Prepared、过期 tool 记 Unknown 且 sibling 不受影响、RecoverExpired 幂等; +- lease 续期:续期后原 deadline 不触发 recovery、错误/空 grant 与 settlement 后续期被拒; +- snapshot codec:每个 Current variant 与终态 round-trip、拒绝 unknown field / 非法判别式 / trailing data;重开 Store 后 Load 不折叠日志且 Record 校验通过。 Loop conformance 必须覆盖: @@ -525,6 +556,7 @@ Loop conformance 必须覆盖: - ctx cancellation、model recovery、explicit malformed-result disposition; - Cancel 将 Executing tool/model 投影到 `UncertainCalls` / `UncertainModel`;ExternalResponse reject 为 `response_rejected`; - streaming delta 与 nil result、EventSink committed observation; -- stale/unknown commit response、prepare no-progress rejection 与无 livelock。 +- stale/unknown commit response、prepare no-progress rejection 与无 livelock; +- 超过 LeaseTTL 的工具调用在续期下不被记为 Unknown,其结果被接受。 历史 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 index 843abcd..08ee405 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -9,12 +9,12 @@ | 领域 | 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) | -| 参考组装(Binding / Planner / Input) | [agent-reference-assembly.md](agent-reference-assembly.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)(草案) | +| 参考组装(Binding / Planner / Input) | [agent-reference-assembly.md](agent-reference-assembly.md)(草案) | ## 1. 背景 @@ -76,7 +76,7 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + session mo - Chatlog Message 原生支持 first-party Artifact references;`sdk.Message` 只是 materialized provider transport。 - Turn Coordinator 从 Session facts 与 `Runtime.Record` 重建,不保存隐藏的长期状态。 - Run→Session materialization 按完整 revision coverage 和 stable identity exactly-once 收敛。 -- Durable owner/fence/lease/recovery/outbox 是 adapter control plane,不进入 `run.Runtime` public contract。 +- lease 与其 recovery(`RenewLease`、`RecoverExpired`)是 `run.Runtime` public contract 的一部分,由 [agent-run.md](agent-run.md) 第 5 节定义;`Store` 只持久化 lease 记录,不解释它。durable owner/fence/outbox 仍是 adapter control plane,不进入 `run.Runtime`。 ## 3. 已完成迁移 @@ -88,8 +88,10 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + session mo | RunHeader、TransitionRecord、wire codec、fold/golden tests | 完成 | | RunID-addressed `Runtime.Create/Load/Commit/Record` | 完成 | | multi-Run Store-backed Runtime 与 Runtime conformance | 完成 | +| 追加式 `Store` 合同(LoadHead / LoadLog / LoadRecord 单一致读 / Commit critical section / ExpiredLeases)、snapshot codec、lease 续期 | 完成 | +| SQLite Store adapter(snapshot 加日志、lease 表)通过 Runtime / recovery / renewal conformance | 完成 | | `agent/run/loop` package extraction | 完成 | -| Session/Artifact/Session Module/Turn protocols | 规范草案完成,实施待完成 | +| Session/Artifact/Session Module/Turn protocols | 草案,无实现;wire 在 Memory 纵向切片跑通前不冻结 | | Chatlog protocol | 草案,payload 与 golden 尚未冻结 | | 参考组装 | 草案 | | PostgreSQL durable Run adapter(旧接口) | 历史 prototype,迁移未完成 | @@ -108,7 +110,6 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + session mo ### 4.2 durable adapters - 将 PostgreSQL durable Run adapter 迁移到统一 `run.Runtime`; -- 让 `Record` 在单一数据库 read snapshot 内读取并验证 header/state/log; - 收紧 Run authority tables 的 immutable RLS policy; - 将 recovery record 绑定具体 lease/fence 并原子消费; - 实现过期 execution recovery scanner; @@ -122,6 +123,20 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + session mo - 逐步把 `bot_history_messages` 降为兼容 read model; - 在完整 materialization、terminal settlement 与 retention closure 后执行归档/GC。 +## 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. 完成标准 重构在以下条件全部成立时结束: diff --git a/docs/design/agent-session-extension.md b/docs/design/agent-session-extension.md index 05d2ee4..cb7af4a 100644 --- a/docs/design/agent-session-extension.md +++ b/docs/design/agent-session-extension.md @@ -1,6 +1,6 @@ # Twilight Agent Session Module Framework -状态:设计规范 +状态:设计草案。无实现;Catalog、SemanticAppender 与 journal 流程在 Memory reference implementation 通过 conformance 前不冻结。 本文定义建立在 `agent/session` 与 `agent/artifact` 之上的静态 Session Module Framework。实现包路径暂为 `agent/session/extension`;文中的“必须”“不得”“应该”是协议约束;JSON canonicalization 与 digest 遵循 `agent/jsonstable`、`agent/es`。 diff --git a/docs/design/agent-session.md b/docs/design/agent-session.md index d0c8515..ab74a42 100644 --- a/docs/design/agent-session.md +++ b/docs/design/agent-session.md @@ -1,10 +1,10 @@ # Twilight Agent Session Protocol -状态:设计规范 +状态:设计草案。无实现;wire、digest preimage 与 conformance 在 Memory reference implementation 与 Input → Turn → Run → Session 纵向切片跑通前不冻结。 本文定义 Twilight Session 的 Event Sourcing kernel。文中的“必须”“不得”“应该”是协议约束。 -本文冻结 Twilight 的 Session stream、并发、Fork ancestry 与 integrity 语义。 +本文规定 Twilight 的 Session stream、并发、Fork ancestry 与 integrity 语义;冻结发生在草案转为规范时。 ## 1. Events 与范围 diff --git a/go.mod b/go.mod index 0c1a3b4..6e58334 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( 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 ( @@ -25,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 8674c68..6c719dc 100644 --- a/go.sum +++ b/go.sum @@ -28,22 +28,34 @@ 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= @@ -53,12 +65,44 @@ github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5Cc 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/sdk/request.go b/sdk/request.go index 5e1d57b..87a4fc4 100644 --- a/sdk/request.go +++ b/sdk/request.go @@ -4,11 +4,12 @@ import "encoding/json" // Request is the complete, frozen input of one model call. // -// It is pure data: no provider client, no interface values, no callbacks. -// The model is a provider-scoped string ID; provider binding happens when a -// ModelCatalog resolves a ModelInvoker. Everything here participates in -// DigestRequest with no exclusions, so any field change produces a different -// request identity. +// 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"` From 81ad6ea18684abf7f17533fa5981a1fcef497a04 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 2 Sep 2026 19:50:14 +0800 Subject: [PATCH 54/81] Split Evolve into per-fact guards and applies; drop LastClosedStep - evolve.go: validateFactTransition (cyclomatic 96) becomes guardFactV1 dispatching to one guard per fact, plus small apply* folds. Guards share requireOpen / requireModelStep / requireCall / requireWaitingFor; ToolCallFailed reuses ValidateToolCallState for class/outcome agreement. - state.go: ValidateMachineState (cyclomatic 48) split into validatePendingInputs / validateLastToolStep / validateCurrent / validateCurrentToolStep. Add String() on ModelStepStatus and ToolCallStatus, ToolCallStatus.Terminal(); move current() markers next to their types. - Remove MachineState.LastClosedStep: it duplicated LastToolStep.RefValue.ID and needed a consistency check. PlanningHint.SourceStep now reads from LastToolStep. Snapshot wire drops lastClosedStep; v1 header and event stream golden digests re-frozen (pre-release). - decide.go: waitingCall returns only error (unparam). Remove unused jsonMarshal. - agent-run.md: MachineState block and closing rule updated. gocyclo findings on agent/run: 2 -> 0. go test -race ./agent/... clean. Co-Authored-By: Claude Fable 5.1 --- agent/run/arguments.go | 2 - agent/run/creation_test.go | 5 +- agent/run/decide.go | 21 +- agent/run/evolve.go | 679 ++++++++++++++++++------------------- agent/run/fold_test.go | 2 +- agent/run/header.go | 2 +- agent/run/next.go | 6 +- agent/run/snapshot.go | 5 +- agent/run/state.go | 168 +++++---- docs/design/agent-run.md | 3 +- 10 files changed, 461 insertions(+), 432 deletions(-) diff --git a/agent/run/arguments.go b/agent/run/arguments.go index 63de699..cb62486 100644 --- a/agent/run/arguments.go +++ b/agent/run/arguments.go @@ -2,8 +2,6 @@ package run import "encoding/json" -func jsonMarshal(v any) ([]byte, error) { return json.Marshal(v) } - // 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 diff --git a/agent/run/creation_test.go b/agent/run/creation_test.go index 2839e53..e92daca 100644 --- a/agent/run/creation_test.go +++ b/agent/run/creation_test.go @@ -52,8 +52,9 @@ func TestNewRunValidationAndV1HeaderGolden(t *testing.T) { if err != nil { t.Fatal(err) } - if header.InitialStateDigest != "sha256:a991d300554d8c5b70573baf427e3087794b0ab844ed389cc167925db76676ff" || - header.HeaderDigest != "sha256:5dc6599107fd006f1638eb83d7c4a7ff757c58d2c59d2e827e2065a0fafd5db6" { + // Pre-release fixture; re-frozen when lastClosedStep left the snapshot wire. + if header.InitialStateDigest != "sha256:ca282c1b24d7281f8fde3b52e03a32f3c8dbd3231a802078437a7b2469e4f86a" || + header.HeaderDigest != "sha256:7a719c28596a23bdd2e71b17beae930377f6702b11168343bd781096b77d392c" { t.Fatalf("v1 header changed: %+v", header) } } diff --git a/agent/run/decide.go b/agent/run/decide.go index 3849d8f..fe3abef 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -435,30 +435,32 @@ func decideSubmitToolFailure(s *MachineState, cmd SubmitToolFailure) ([]Fact, er // --- rules 9-10: responses --- -func waitingCall(s *MachineState, step StepID, call CallID, kind ResponseKind, resp ResponseID) (*ToolStep, int, error) { +// 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 nil, 0, err + return err } i := ts.callIndex(call) if i < 0 { - return nil, 0, rejectionf("response: unknown call %q", call) + return rejectionf("response: unknown call %q", call) } c := ts.Calls[i] if c.Status != ToolWaiting || c.Waiting == nil { - return nil, 0, rejectionf("response: call %q is not Waiting", call) + return rejectionf("response: call %q is not Waiting", call) } if c.Waiting.Kind != kind { - return nil, 0, rejectionf("response: call %q expects kind %q, got %q", call, 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 nil, 0, rejectionf("response: call %q expects ResponseID %q, got %q", call, c.Waiting.ID, resp) + return rejectionf("response: call %q expects ResponseID %q, got %q", call, c.Waiting.ID, resp) } - return ts, i, nil + return nil } func decideApproveToolCall(s *MachineState, cmd ApproveToolCall) ([]Fact, error) { - if _, _, err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseApproval, cmd.ResponseID); err != nil { + if err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseApproval, cmd.ResponseID); err != nil { return nil, err } wantDigest, err := digestToolResponseDecisionV1(ResponseApproval, ResponseDecisionApproved, "") @@ -513,8 +515,7 @@ func decideRejectToolCall(s *MachineState, cmd *RejectToolCall) ([]Fact, error) } func decideSubmitToolResponse(s *MachineState, cmd *SubmitToolResponse) ([]Fact, error) { - _, _, err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseExternal, cmd.ResponseID) - if err != nil { + if err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseExternal, cmd.ResponseID); err != nil { return nil, err } wantDigest, err := digestToolResponsePayloadV1(cmd.Payload) diff --git a/agent/run/evolve.go b/agent/run/evolve.go index db6aee2..cb0d56f 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -5,422 +5,399 @@ import ( "fmt" ) -// evolveV1 is the current fold semantics for the pre-release SchemaVersion1. +// 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 := validateFactTransition(s, f); err != nil { + if err := guardFactV1(&s, f); err != nil { return s, err } switch fact := f.(type) { case ModelStepPrepared: - if !atOpen(s.Current) { - return s, fmt.Errorf("agent: evolve: model step prepared while run is not at Open") - } - // 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 s, 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 s, fmt.Errorf("agent: evolve: model step prepared input ID at position %d = %q, want pending input %q", i, fact.InputIDs[i], input.ID) - } - } - s.Current = ModelStep{ - RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: fact.BindingDigest}, - Request: fact.Request, - RequestDigest: fact.RequestDigest, - Model: fact.Model, - Tools: fact.Tools, - ToolsDigest: fact.ToolsDigest, - Status: ModelPrepared, - } - s.ModelSteps++ - s.PendingInputs = nil - return s, nil - + return applyModelStepPrepared(s, &fact), nil case ModelStepStarted: - ms, err := evolveModelStep(&s, fact.StepID) - if err != nil { - return s, err - } - ms.Status = ModelExecuting - s.Current = *ms - return s, nil - + return applyModelStatus(s, ModelExecuting, Usage{}, false), nil case ModelStepRecovered: - ms, err := evolveModelStep(&s, fact.StepID) - if err != nil { - return s, err - } - ms.Status = ModelPrepared - s.Current = *ms - return s, nil - + return applyModelStatus(s, ModelPrepared, Usage{}, false), nil case ModelStepRejected: - ms, err := evolveModelStep(&s, fact.StepID) - if err != nil { - return s, err - } - ms.Rejects++ - ms.Status = ModelPrepared - s.Current = *ms - s.Usage = s.Usage.Add(fact.Usage) - return s, nil - + return applyModelStatus(s, ModelPrepared, fact.Usage, true), nil case ModelStepCompleted: - if _, err := evolveModelStep(&s, fact.StepID); err != nil { - return s, err - } - result := fact.Result - s.LastModelResult = &result - s.Usage = s.Usage.Add(fact.Result.Usage) - s.Current = Open{} - return s, nil - + return applyModelStepCompleted(s, &fact), nil case ToolStepOpened: - if !atOpen(s.Current) { - return s, fmt.Errorf("agent: evolve: tool step opened while run is not at Open") - } - calls := make([]ToolCallState, len(fact.Calls)) - for i, b := range fact.Calls { - status := ToolPending - var waiting *ResponseRequest - if b.Response != nil { - status = ToolWaiting - w := *b.Response - waiting = &w - } - calls[i] = ToolCallState{ - CallID: b.CallID, - ToolRef: b.ToolRef, - DefinitionDigest: b.DefinitionDigest, - BindingDigest: b.BindingDigest, - Arguments: b.Arguments, - Policy: b.Policy, - Status: status, - Waiting: waiting, - } - if err := ValidateToolCallState(calls[i]); err != nil { - return s, err - } - } - scheduling, err := normalizeToolScheduling(fact.Scheduling) - if err != nil { - return s, fmt.Errorf("agent: evolve: tool step scheduling: %w", err) - } - s.Current = ToolStep{ - RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: fact.BindingSetDigest}, - Source: fact.Source, - Calls: calls, - Scheduling: scheduling, - } - return s, nil - + return applyToolStepOpened(s, &fact), nil case ToolCallStarted: - return evolveCall(&s, fact.StepID, fact.CallID, func(c *ToolCallState) { - c.Status = ToolExecuting - }) - + return applyCall(s, fact.CallID, func(c *ToolCallState) { c.Status = ToolExecuting }), nil case ToolCallApproved: - return evolveCall(&s, fact.StepID, fact.CallID, func(c *ToolCallState) { - c.Status = ToolPending - c.Waiting = nil - }) - + return applyCall(s, fact.CallID, func(c *ToolCallState) { c.Status, c.Waiting = ToolPending, nil }), nil case ToolCallCompleted: - return evolveCall(&s, fact.StepID, fact.CallID, func(c *ToolCallState) { - c.Status = ToolCompleted + return applyCall(s, fact.CallID, func(c *ToolCallState) { r := fact.Result - c.Result = &r - c.Waiting = nil - }) - + c.Status, c.Result, c.Waiting = ToolCompleted, &r, nil + }), nil case ToolCallAnswered: - return evolveCall(&s, fact.StepID, fact.CallID, func(c *ToolCallState) { - c.Status = ToolCompleted - c.Result = &ToolExecutionResult{Output: fact.Payload} - c.Waiting = nil - }) - + return applyCall(s, fact.CallID, func(c *ToolCallState) { + c.Status, c.Result, c.Waiting = ToolCompleted, &ToolExecutionResult{Output: fact.Payload}, nil + }), nil case ToolCallFailed: - return evolveCall(&s, fact.StepID, fact.CallID, func(c *ToolCallState) { - c.Status = ToolFailed - c.Failure = &ToolCallFailure{Failure: fact.Failure, Outcome: fact.Outcome} - c.Waiting = nil - }) - + 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: - for _, in := range s.PendingInputs { - if in.ID == fact.Input.ID { - return s, nil // idempotent append per InputID - } - } - s.PendingInputs = append(append([]AgentInput(nil), s.PendingInputs...), fact.Input) - return s, nil - + return applyInputAccepted(s, &fact), nil case RunEnded: - normalized, err := fact.normalized() - if err != nil { - return s, err - } - status, reason, failure := endProjection(normalized.End) - s.Status = status - s.Current = nil - result := &RunResult{ - Status: status, - Reason: reason, - Failure: failure, - Model: s.LastModelResult, - Usage: s.Usage, + 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 applyModelStepPrepared(s MachineState, fact *ModelStepPrepared) MachineState { + s.Current = ModelStep{ + RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: fact.BindingDigest}, + Request: fact.Request, + RequestDigest: fact.RequestDigest, + Model: fact.Model, + Tools: fact.Tools, + ToolsDigest: fact.ToolsDigest, + Status: ModelPrepared, + } + s.ModelSteps++ + s.PendingInputs = nil + 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 { + result := fact.Result + s.LastModelResult = &result + s.Usage = s.Usage.Add(fact.Result.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, + 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 } - if stopped, ok := normalized.End.(RunStoppedEnd); ok { - result.UncertainCalls = append([]CallID(nil), stopped.UncertainCalls...) - result.UncertainModel = stopped.UncertainModel + } + 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 { + for _, in := range s.PendingInputs { + if in.ID == fact.Input.ID { + return s // idempotent append per InputID } - s.Result = result - return s, nil + } + s.PendingInputs = append(append([]AgentInput(nil), s.PendingInputs...), fact.Input) + return s +} - default: - return s, fmt.Errorf("agent: evolve: unknown fact variant %T", f) +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, Model: s.LastModelResult, 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 } -// validateFactTransition protects replay from a structurally valid event that -// was not a legal Machine transition. Decide performs the same checks before -// commit, while fold/recovery must defend itself without access to commands. -func validateFactTransition(s MachineState, f Fact) error { +// --- 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 s.Status.Terminal() { + return errors.New("agent: evolve: fact after terminal state") + } switch fact := f.(type) { case ModelStepPrepared: - if s.Status.Terminal() || !atOpen(s.Current) { - return fmt.Errorf("agent: evolve: model step prepared while run is not at Open") - } - if fact.StepID == "" || fact.Model == "" || fact.RequestDigest == "" || fact.ToolsDigest == "" || fact.BindingDigest == "" { - return errors.New("agent: evolve: model step prepared is missing identity or digest") - } - if ModelRef(fact.Request.Model) != fact.Model { - return errors.New("agent: evolve: model step prepared model mismatch") - } - requestDigest, err := digestRequestV1(fact.Request) - if err != nil || requestDigest != fact.RequestDigest { - return errors.New("agent: evolve: model step prepared request digest mismatch") - } - toolsDigest, err := digestToolSpecsV1(fact.Tools) - if err != nil || toolsDigest != fact.ToolsDigest { - return errors.New("agent: evolve: model step prepared tools digest mismatch") - } - binding, err := digestModelStepBindingV1(fact.Model, fact.RequestDigest, fact.ToolsDigest) - if err != nil || binding != fact.BindingDigest { - return errors.New("agent: evolve: model step prepared binding digest mismatch") - } + return guardModelStepPrepared(s, &fact) case ModelStepStarted: - ms, ok := s.Current.(ModelStep) - if !ok || ms.RefValue.ID != fact.StepID || ms.Status != ModelPrepared { - return fmt.Errorf("agent: evolve: model step %q is not Prepared", fact.StepID) - } + return requireModelStep(s, fact.StepID, ModelPrepared) case ModelStepRecovered: - ms, ok := s.Current.(ModelStep) - if !ok || ms.RefValue.ID != fact.StepID || ms.Status != ModelExecuting { - return fmt.Errorf("agent: evolve: model step %q is not Executing", fact.StepID) - } + return requireModelStep(s, fact.StepID, ModelExecuting) case ModelStepRejected: - ms, ok := s.Current.(ModelStep) - if !ok || ms.RefValue.ID != fact.StepID || ms.Status != ModelExecuting { - return fmt.Errorf("agent: evolve: model step %q is not Executing", fact.StepID) - } + return requireModelStep(s, fact.StepID, ModelExecuting) case ModelStepCompleted: - ms, ok := s.Current.(ModelStep) - if !ok || ms.RefValue.ID != fact.StepID || ms.Status != ModelExecuting { - return fmt.Errorf("agent: evolve: model step %q is not Executing", fact.StepID) - } + return requireModelStep(s, fact.StepID, ModelExecuting) case ToolStepOpened: - if s.Status.Terminal() || !atOpen(s.Current) || len(fact.Calls) == 0 { - return fmt.Errorf("agent: evolve: tool step opened outside Open") - } - if fact.StepID == "" || fact.Source == "" || fact.BindingSetDigest == "" { - return errors.New("agent: evolve: tool step is missing identity or digest") - } - // BindingSetDigest is defined over the ordered call bindings before - // derived response requests are attached. Recompute that exact input. - baseBindings := make([]ToolCallBinding, len(fact.Calls)) - for i := range fact.Calls { - baseBindings[i] = fact.Calls[i] - baseBindings[i].Response = nil - } - setDigest, err := digestBindingSet(baseBindings) - if err != nil || setDigest != 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 _, call := range fact.Calls { - if call.CallID == "" { - return errors.New("agent: evolve: tool step contains empty CallID") - } - if _, exists := seen[call.CallID]; exists { - 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) - } - wantBinding, err := DigestToolCallBinding(call.CallID, call.DefinitionDigest, call.Policy, call.Arguments) - if err != nil || wantBinding != call.BindingDigest { - return fmt.Errorf("agent: evolve: tool call %q binding digest mismatch", call.CallID) - } - if call.Response != nil { - kind := ResponseApproval - if call.Policy == ExternalResponse { - kind = ResponseExternal - } - if call.Policy != ApprovalRequired && call.Policy != ExternalResponse { - return fmt.Errorf("agent: evolve: direct call %q cannot carry a response request", call.CallID) - } - requestDigest, err := DigestToolCallBinding(call.CallID, call.DefinitionDigest, call.Policy, call.Arguments) - if err != nil { - return err - } - if err := validateResponseRequest(*call.Response, s.RunID, fact.StepID, call.CallID, kind, call.Arguments, requestDigest); err != nil { - return err - } - } - } + return guardToolStepOpened(s, &fact) case ToolCallStarted: - call, err := currentCallState(s, fact.StepID, fact.CallID) - if err != nil || call.Status != ToolPending { - return fmt.Errorf("agent: evolve: tool call %q is not Pending", fact.CallID) - } + _, err := requireCall(s, fact.StepID, fact.CallID, ToolPending) + return err case ToolCallApproved: - call, err := currentCallState(s, fact.StepID, fact.CallID) - if err != nil || call.Status != ToolWaiting || call.Waiting == nil || call.Waiting.Kind != ResponseApproval { - return fmt.Errorf("agent: evolve: tool call %q is not waiting for approval", fact.CallID) - } - if call.Waiting.ID != fact.ResponseID { - return fmt.Errorf("agent: evolve: tool call %q response ID mismatch", fact.CallID) - } - want, err := digestToolResponseDecisionV1(ResponseApproval, ResponseDecisionApproved, "") - if err != nil || want != fact.ResponseDigest { - return fmt.Errorf("agent: evolve: tool call %q approval digest mismatch", fact.CallID) - } + return guardToolCallApproved(s, &fact) case ToolCallCompleted: - call, err := currentCallState(s, fact.StepID, fact.CallID) - if err != nil || call.Status != ToolExecuting { - return fmt.Errorf("agent: evolve: tool call %q is not Executing", fact.CallID) - } + _, err := requireCall(s, fact.StepID, fact.CallID, ToolExecuting) + return err case ToolCallAnswered: - call, err := currentCallState(s, fact.StepID, fact.CallID) - if err != nil || call.Status != ToolWaiting || call.Waiting == nil || call.Waiting.Kind != ResponseExternal { - return fmt.Errorf("agent: evolve: tool call %q is not waiting for an external response", fact.CallID) - } - if call.Waiting.ID != fact.ResponseID { - return fmt.Errorf("agent: evolve: tool call %q response ID mismatch", fact.CallID) - } - want, err := digestToolResponsePayloadV1(fact.Payload) - if err != nil || want != fact.ResponseDigest { - return fmt.Errorf("agent: evolve: tool call %q response digest mismatch", fact.CallID) - } + return guardToolCallAnswered(s, &fact) case ToolCallFailed: - call, err := currentCallState(s, fact.StepID, fact.CallID) - if err != nil || (call.Status != ToolPending && call.Status != ToolExecuting && call.Status != ToolWaiting) { - return fmt.Errorf("agent: evolve: tool call %q cannot fail from its current state", fact.CallID) - } - if fact.Failure.Class == "" { - return fmt.Errorf("agent: evolve: tool call %q failure has no class", fact.CallID) - } - switch fact.Outcome { - case ToolOutcomeKnown: - if fact.Failure.Class == FailureEffectUnknown { - return fmt.Errorf("agent: evolve: known outcome cannot use %s", FailureEffectUnknown) - } - case ToolOutcomeUnknown: - if call.Status != ToolExecuting { - return fmt.Errorf("agent: evolve: unknown outcome requires Executing call") - } - if fact.Failure.Class != FailureEffectUnknown { - return fmt.Errorf("agent: evolve: unknown outcome must use %s", FailureEffectUnknown) - } - default: - return fmt.Errorf("agent: evolve: unknown failure outcome %d", fact.Outcome) - } + return guardToolCallFailed(s, &fact) case InputAccepted: - if s.Status.Terminal() || !atOpen(s.Current) { - return errors.New("agent: evolve: input accepted outside Open") - } + return requireOpen(s, "input accepted") case RunEnded: - if s.Status.Terminal() { - return errors.New("agent: evolve: duplicate terminal fact") - } - if _, err := fact.normalized(); err != nil { - return err - } + 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 } -func currentCallState(s MachineState, step StepID, call CallID) (ToolCallState, error) { +// 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 != step { - return ToolCallState{}, errors.New("not current ToolStep") + if !ok || ts.RefValue.ID != stepID { + return ToolCallState{}, fmt.Errorf("agent: evolve: tool step %q is not current", stepID) } - i := ts.callIndex(call) + i := ts.callIndex(callID) if i < 0 { - return ToolCallState{}, errors.New("unknown CallID") + return ToolCallState{}, fmt.Errorf("agent: evolve: unknown call %q", callID) } - return ts.Calls[i], nil + 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 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) +func guardModelStepPrepared(s *MachineState, fact *ModelStepPrepared) error { + if err := requireOpen(s, "model step prepared"); err != nil { + return err } - if req.ID == "" || req.ID != DeriveResponseID(runID, stepID, callID, kind) { - return fmt.Errorf("agent: evolve: response request ID mismatch for call %q", callID) + if fact.StepID == "" || fact.Model == "" || fact.RequestDigest == "" || fact.ToolsDigest == "" || fact.BindingDigest == "" { + return errors.New("agent: evolve: model step prepared is missing identity or digest") } - if req.RequestDigest != requestDigest || !req.Payload.Equal(payload) { - return fmt.Errorf("agent: evolve: response request payload mismatch for call %q", callID) + if ModelRef(fact.Request.Model) != fact.Model { + return errors.New("agent: evolve: model step prepared model mismatch") + } + // 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) + } + } + if d, err := digestRequestV1(fact.Request); err != nil || d != fact.RequestDigest { + return errors.New("agent: evolve: model step prepared request digest mismatch") + } + 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 evolveModelStep(s *MachineState, step StepID) (*ModelStep, error) { - ms, ok := s.Current.(ModelStep) - if !ok || ms.RefValue.ID != step { - return nil, fmt.Errorf("agent: evolve: model step %q is not current", step) +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 } - return &ms, 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 evolveCall(s *MachineState, step StepID, call CallID, apply func(*ToolCallState)) (MachineState, error) { - ts, ok := s.Current.(ToolStep) - if !ok || ts.RefValue.ID != step { - return *s, fmt.Errorf("agent: evolve: tool step %q is not current", step) +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") } - i := ts.callIndex(call) - if i < 0 { - return *s, fmt.Errorf("agent: evolve: unknown call %q", call) + if _, dup := seen[call.CallID]; dup { + return fmt.Errorf("agent: evolve: duplicate CallID %q", call.CallID) } - calls := append([]ToolCallState(nil), ts.Calls...) - apply(&calls[i]) - // Reject illegal field combinations, e.g. an unknown-outcome failure - // whose class is not effect_unknown. - if err := ValidateToolCallState(calls[i]); err != nil { - return *s, err + 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) } - ts.Calls = calls - if allToolCallsTerminal(calls) { - closed := ts - closed.Calls = calls - s.LastToolStep = &closed - s.Current = Open{} - s.LastClosedStep = step - } else { - s.Current = ts + 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 d, err := digestToolResponsePayloadV1(fact.Payload); err != nil || d != fact.ResponseDigest { + return fmt.Errorf("agent: evolve: tool call %q response digest mismatch", 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 *s, nil + 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 { diff --git a/agent/run/fold_test.go b/agent/run/fold_test.go index 8929d04..66b9b15 100644 --- a/agent/run/fold_test.go +++ b/agent/run/fold_test.go @@ -262,7 +262,7 @@ func TestGoldenEventStreamV1(t *testing.T) { t.Fatal(err) } got := string(sha256Digest(stateBytes)) - const frozen = "sha256:f306b9499352398094ccbba585c4c60b6f474363cd0e9cac7b4df2fcf01d7d18" + const frozen = "sha256:b76adb269cc9821c6415ab93b355725fbd0054cb5a0ca26af7ab937648708d25" if got != frozen { t.Fatalf("golden v1 state digest changed:\n got %s\nwant %s\nstate: %s", got, frozen, stateBytes) } diff --git a/agent/run/header.go b/agent/run/header.go index a13fbfc..4c70d1e 100644 --- a/agent/run/header.go +++ b/agent/run/header.go @@ -79,7 +79,7 @@ func ValidateRunHeader(h *RunHeader) error { if h.InitialState.Status != RunActive || !atOpen(h.InitialState.Current) || len(h.InitialState.PendingInputs) != 0 || h.InitialState.ModelSteps != 0 || h.InitialState.Result != nil || h.InitialState.LastModelResult != nil || - h.InitialState.LastClosedStep != "" || h.InitialState.LastToolStep != nil || + h.InitialState.LastToolStep != nil || h.InitialState.Usage != (Usage{}) { return errors.New("agent: run header: initial state is not a minimal Revision-0 state") } diff --git a/agent/run/next.go b/agent/run/next.go index 5afe4f1..c3e976d 100644 --- a/agent/run/next.go +++ b/agent/run/next.go @@ -111,9 +111,13 @@ func Next(s MachineState) (Effect, error) { } switch cur := s.Current.(type) { case Open: + var source StepID + if s.LastToolStep != nil { + source = s.LastToolStep.RefValue.ID + } return NeedModelRequest{Hint: PlanningHint{ RunID: s.RunID, - SourceStep: s.LastClosedStep, + SourceStep: source, Inputs: append([]AgentInput(nil), s.PendingInputs...), LastToolStep: cloneToolStepPtr(s.LastToolStep), LastModelResult: cloneModelResult(s.LastModelResult), diff --git a/agent/run/snapshot.go b/agent/run/snapshot.go index 917ec31..617db97 100644 --- a/agent/run/snapshot.go +++ b/agent/run/snapshot.go @@ -14,7 +14,6 @@ type machineStateWireV1 struct { RunID RunID `json:"runId"` Status RunStatus `json:"status"` ModelSteps int `json:"modelSteps"` - LastClosedStep StepID `json:"lastClosedStep"` Usage Usage `json:"usage"` PendingInputs []AgentInput `json:"pendingInputs"` LastModelResult *ModelResult `json:"lastModelResult"` @@ -36,7 +35,7 @@ const ( func machineStateToWireV1(s *MachineState) (machineStateWireV1, error) { w := machineStateWireV1{ RunID: s.RunID, Status: s.Status, ModelSteps: s.ModelSteps, - LastClosedStep: s.LastClosedStep, Usage: s.Usage, PendingInputs: s.PendingInputs, + Usage: s.Usage, PendingInputs: s.PendingInputs, LastModelResult: s.LastModelResult, Result: s.Result, LastToolStep: s.LastToolStep, } switch cur := s.Current.(type) { @@ -58,7 +57,7 @@ func machineStateToWireV1(s *MachineState) (machineStateWireV1, error) { func machineStateFromWireV1(w *machineStateWireV1) (MachineState, error) { s := MachineState{ RunID: w.RunID, Status: w.Status, ModelSteps: w.ModelSteps, - LastClosedStep: w.LastClosedStep, Usage: w.Usage, PendingInputs: w.PendingInputs, + Usage: w.Usage, PendingInputs: w.PendingInputs, LastModelResult: w.LastModelResult, Result: w.Result, LastToolStep: w.LastToolStep, } switch w.Current { diff --git a/agent/run/state.go b/agent/run/state.go index 88c5c73..faeefb9 100644 --- a/agent/run/state.go +++ b/agent/run/state.go @@ -139,9 +139,6 @@ type Open struct{} func (Open) current() {} -func (ModelStep) current() {} -func (ToolStep) current() {} - func atOpen(c Current) bool { _, ok := c.(Open) return ok @@ -154,6 +151,17 @@ const ( 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"` Request ModelRequest `json:"request"` @@ -167,7 +175,8 @@ type ModelStep struct { Rejects int `json:"rejects,omitempty"` } -func (ModelStep) step() {} +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 } @@ -182,6 +191,26 @@ const ( 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 } + type ToolExecutionResult struct { Output CanonicalJSON `json:"output"` } @@ -303,7 +332,8 @@ type ToolStep struct { Scheduling ToolScheduling `json:"scheduling,omitzero"` } -func (ToolStep) step() {} +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 } @@ -326,11 +356,9 @@ type MachineState struct { Current Current `json:"-"` PendingInputs []AgentInput `json:"pendingInputs,omitempty"` ModelSteps int `json:"modelSteps"` - // LastClosedStep is the most recently closed ToolStep; PlanningHint's - // SourceStep is read from it at the next boundary. - LastClosedStep StepID `json:"lastClosedStep,omitempty"` // LastToolStep retains the most recently closed ToolStep so the planner can - // include committed tool results in the next model request. + // include committed tool results in the next model request. Its RefValue.ID + // is the SourceStep of the next PlanningHint. LastToolStep *ToolStep `json:"lastToolStep,omitempty"` Usage Usage `json:"usage"` LastModelResult *ModelResult `json:"lastModelResult,omitempty"` @@ -355,36 +383,12 @@ func ValidateMachineState(s *MachineState) error { if s.ModelSteps < 0 { return errors.New("agent: state: negative model step count") } - seenInputs := make(map[InputID]struct{}, len(s.PendingInputs)) - for _, input := range s.PendingInputs { - if input.ID == "" { - return errors.New("agent: state: pending input has empty InputID") - } - if _, exists := seenInputs[input.ID]; exists { - return fmt.Errorf("agent: state: duplicate pending InputID %q", input.ID) - } - seenInputs[input.ID] = struct{}{} + if err := validatePendingInputs(s.PendingInputs); err != nil { + return err } - if s.LastToolStep != nil { - last := s.LastToolStep - if last.RefValue.RunID != s.RunID || last.RefValue.ID == "" || last.RefValue.Digest == "" || last.Source == "" || s.LastClosedStep != last.RefValue.ID { - return errors.New("agent: state: invalid LastToolStep projection") - } - if len(last.Calls) == 0 { - return errors.New("agent: state: LastToolStep has no calls") - } - for _, call := range last.Calls { - if err := ValidateToolCallState(call); err != nil { - return err - } - if call.Status != ToolCompleted && call.Status != ToolFailed { - return errors.New("agent: state: LastToolStep contains a live call") - } - } - } else if s.LastClosedStep != "" { - return errors.New("agent: state: LastClosedStep has no LastToolStep") + 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") @@ -397,9 +401,49 @@ func ValidateMachineState(s *MachineState) error { 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: @@ -409,32 +453,38 @@ func ValidateMachineState(s *MachineState) error { if current.Status != ModelPrepared && current.Status != ModelExecuting { return fmt.Errorf("agent: state: unknown ModelStep status %d", current.Status) } + return nil case ToolStep: - if current.RefValue.RunID != s.RunID || current.RefValue.ID == "" || current.RefValue.Digest == "" || current.Source == "" || len(current.Calls) == 0 { - return errors.New("agent: state: invalid current ToolStep identity") + 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") } - seenCalls := make(map[CallID]struct{}, len(current.Calls)) - live := false - for _, call := range current.Calls { - if call.CallID == "" { - return errors.New("agent: state: current ToolStep has empty CallID") - } - if _, exists := seenCalls[call.CallID]; exists { - return fmt.Errorf("agent: state: duplicate CallID %q", call.CallID) - } - seenCalls[call.CallID] = struct{}{} - if err := ValidateToolCallState(call); err != nil { - return err - } - if call.Status == ToolPending || call.Status == ToolExecuting || call.Status == ToolWaiting { - live = true - } + if _, dup := seen[call.CallID]; dup { + return fmt.Errorf("agent: state: duplicate CallID %q", call.CallID) } - if !live { - return errors.New("agent: state: current ToolStep has no live calls") + seen[call.CallID] = struct{}{} + if err := ValidateToolCallState(*call); err != nil { + return err } - default: - return fmt.Errorf("agent: state: unknown current step %T", s.Current) + if !call.Status.Terminal() { + live = true + } + } + if !live { + return errors.New("agent: state: current ToolStep has no live calls") } return nil } diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 413d79c..7cae20e 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -154,7 +154,6 @@ type MachineState struct { Current Current PendingInputs []AgentInput ModelSteps int - LastClosedStep StepID LastToolStep *ToolStep Usage Usage LastModelResult *ModelResult @@ -263,7 +262,7 @@ ToolCall: | `SubmitToolResponse` | Waiting(ExternalResponse);`ToolCallAnswered`。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` / `LastClosedStep`。Cancel 的 Unknown fact 同样走这条关闭规则;`RunEnded` 再把 Current 置空。 +没有独立的 `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,一次返回该 transition 的完整 ordered fact group;验证成功后返回完整 facts。`Protocol.Evolve(state, fact)` 机械折叠 fact,依赖 fact 携带的完整数据。accepted facts 必须 self-contained;若 transition terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 From 7ec4ad705f680c7e1cbadabb9e8ae01c1ade7cae Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 2 Sep 2026 21:50:11 +0800 Subject: [PATCH 55/81] Add Example_recoverableRun: crash during a tool call, recover on reopen A godoc example that is also the first external caller of agent/run: a host composes run + loop + sqlitestore, creates a Run, accepts input and drives the Loop until a tool call is Executing. The Store is closed to simulate the process dying with a live lease and no settlement. A second Runtime reopens the same database after the lease TTL: RecoverExpired settles the abandoned call as Unknown, the Run stays Active on the same RunID, the Loop re-plans from the committed tool outcome and completes. Record verifies all 9 transitions against the stored snapshot. The example exercises PlanningHint.LastToolStep, NeedsRecovery, ExecutingCalls, RuntimeOptions.Now, LeaseRenewInterval and the split model/tool catalog interfaces from outside the package. Co-Authored-By: Claude Fable 5.1 --- agent/run/example_run_test.go | 303 ++++++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 agent/run/example_run_test.go diff --git a/agent/run/example_run_test.go b/agent/run/example_run_test.go new file mode 100644 index 0000000..3cefeed --- /dev/null +++ b/agent/run/example_run_test.go @@ -0,0 +1,303 @@ +package run_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "time" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/twilight/agent/run/sqlitestore" + "github.com/memohai/twilight/sdk" +) + +// Example_recoverableRun drives one Run through a process crash. +// +// Process 1 creates the Run on a SQLite Store, accepts the user input and +// runs the Loop until the model asks for a tool. The tool never returns: the +// process dies while the call is Executing and its lease is live. Nothing is +// written on the way down. +// +// Process 2 reopens the same database. The lease has expired, so +// RecoverExpired settles the abandoned call as Unknown (the effect may or may +// not have happened) and the Run stays Active on the same RunID. The Loop +// then plans the next model request from the committed tool outcome and the +// Run completes. Record verifies the whole transition log against the stored +// snapshot. +func Example_recoverableRun() { + dir, err := os.MkdirTemp("", "twilight-run-example") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + dbPath := filepath.Join(dir, "runs.db") + ctx := context.Background() + + // A controllable clock stands in for wall time so lease expiry is + // deterministic. Production hosts leave RuntimeOptions.Now nil. + clock := &fakeClock{now: time.Unix(1_000_000, 0)} + const leaseTTL = 30 * time.Second + + // The application side: model, tool and planner survive the "crash" here + // only because both processes live in one test binary. + tool := &lookupTool{block: make(chan struct{})} + app := newExampleApp(tool) + + // ---- process 1 ---------------------------------------------------------- + store1, err := sqlitestore.Open(dbPath) + if err != nil { + panic(err) + } + rt1 := run.NewRuntimeWithOptions(store1, run.RuntimeOptions{LeaseTTL: leaseTTL, Now: clock.Now}) + + newRun, err := run.BuildNewRun("run-1", "example") + if err != nil { + panic(err) + } + if _, err := rt1.Create(ctx, newRun); err != nil { + panic(err) + } + input := run.AgentInput{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{"text":"what is the weather?"}`)} + env, err := run.ProtocolV1.BuildEnvelope("run-1", run.DeriveInputCommandID("run-1", input.ID), run.AcceptInput{Input: input}) + if err != nil { + panic(err) + } + if _, err := rt1.Commit(ctx, run.CommitRequest{Command: env}); err != nil { + panic(err) + } + + loop1, err := loop.New(app.models(), app.tools(), app, loop.ExecutionPolicy{LeaseRenewInterval: 5 * time.Second}, false) + if err != nil { + panic(err) + } + loop1Done := make(chan error, 1) + go func() { + _, err := loop1.Run(ctx, rt1, "run-1", nil) + loop1Done <- err + }() + waitForExecutingCall(ctx, rt1, "run-1", "c1") + fmt.Println("process 1: tool call c1 is Executing; process crashes") + + // The crash: the process disappears without settling. Closing the Store + // is the closest single-binary equivalent; the abandoned worker's later + // writes fail, exactly as a dead process writes nothing. + if err := store1.Close(); err != nil { + panic(err) + } + + // ---- process 2 ---------------------------------------------------------- + clock.Advance(2 * leaseTTL) + store2, err := sqlitestore.Open(dbPath) + if err != nil { + panic(err) + } + defer store2.Close() + rt2 := run.NewRuntimeWithOptions(store2, run.RuntimeOptions{LeaseTTL: leaseTTL, Now: clock.Now}) + + snap, err := rt2.Load(ctx, "run-1") + if err != nil { + panic(err) + } + fmt.Printf("process 2: reopened at revision %d, run %s, needs recovery = %v\n", + snap.Revision, statusName(snap.State.Status), run.NeedsRecovery(snap.State)) + + // Hosts run this on a timer (run.RunExpiredRecovery); one pass is enough here. + recovered, err := rt2.RecoverExpired(ctx) + if err != nil { + panic(err) + } + snap, err = rt2.Load(ctx, "run-1") + if err != nil { + panic(err) + } + call := snap.State.LastToolStep.Calls[0] + fmt.Printf("recovered %d lease: call %s is %s (%s), run %s\n", + recovered, call.CallID, call.Status, call.Failure.Failure.Class, statusName(snap.State.Status)) + + loop2, err := loop.New(app.models(), app.tools(), app, loop.ExecutionPolicy{LeaseRenewInterval: 5 * time.Second}, false) + if err != nil { + panic(err) + } + result, err := loop2.Run(ctx, rt2, "run-1", nil) + if err != nil { + panic(err) + } + fmt.Printf("process 2: run %s: %q\n", statusName(result.Result.Status), result.Result.Model.Text) + + record, err := rt2.Record(ctx, "run-1") + if err != nil { + panic(err) + } + fmt.Printf("record: %d transitions fold to the stored snapshot\n", len(record.Transitions)) + + // Let the abandoned worker exit; its settlement fails against the closed + // Store, which is the crash we simulated. + close(tool.block) + <-loop1Done + + // Output: + // process 1: tool call c1 is Executing; process crashes + // process 2: reopened at revision 5, run active, needs recovery = true + // recovered 1 lease: call c1 is Failed (effect_unknown), run active + // process 2: run completed: "done" + // record: 9 transitions fold to the stored snapshot +} + +// waitForExecutingCall polls Load until callID on the current ToolStep is +// Executing, which is the point at which a start has been accepted and its +// lease is live. +func waitForExecutingCall(ctx context.Context, rt run.Runtime, runID run.RunID, callID run.CallID) { + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + snap, err := rt.Load(ctx, runID) + if err != nil { + panic(err) + } + for _, id := range run.ExecutingCalls(snap.State) { + if id == callID { + return + } + } + time.Sleep(2 * time.Millisecond) + } + panic("tool call never reached Executing") +} + +func statusName(s run.RunStatus) string { + switch s { + case run.RunActive: + return "active" + case run.RunCompleted: + return "completed" + case run.RunStopped: + return "stopped" + default: + return "failed" + } +} + +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 +} + +func (c *fakeClock) Advance(d time.Duration) { + c.mu.Lock() + c.now = c.now.Add(d) + c.mu.Unlock() +} + +// exampleApp is the application side of the Loop: the RequestPlanner, plus +// the model and tool catalogs exposed through models() and tools(). A real +// host injects its provider client, tool registry and context assembly here. +type exampleApp struct { + tool *lookupTool + spec run.ToolSpec +} + +func newExampleApp(tool *lookupTool) *exampleApp { + frozen, err := run.FreezeToolDefinition(tool.Definition()) + if err != nil { + panic(err) + } + digest, err := run.ProtocolV1.DigestToolDefinition(frozen) + if err != nil { + panic(err) + } + return &exampleApp{tool: tool, spec: run.ToolSpec{ + Ref: tool.Ref(), Definition: frozen, DefinitionDigest: digest, Policy: run.DirectExecution, + }} +} + +// Plan projects the Run boundary facts into the next sdk.Request: the pending +// user inputs, plus the committed outcome of the previous tool step. +func (a *exampleApp) Plan(_ context.Context, hint run.PlanningHint) (loop.RequestPlan, error) { + var messages []sdk.Message + ids := make([]run.InputID, 0, len(hint.Inputs)) + for _, in := range hint.Inputs { + ids = append(ids, in.ID) + var body struct { + Text string `json:"text"` + } + if err := in.Payload.Decode(&body); err != nil { + return loop.RequestPlan{}, err + } + messages = append(messages, sdk.UserMessage(body.Text)) + } + if hint.LastToolStep != nil { + for _, call := range hint.LastToolStep.Calls { + outcome := "ok" + if call.Status == run.ToolFailed { + outcome = call.Failure.Failure.Class + } + messages = append(messages, sdk.ToolMessage(sdk.ToolResultPart{ + ToolCallID: string(call.CallID), ToolName: string(call.ToolRef), Result: outcome, + })) + } + } + return loop.RequestPlan{ + Model: "m-1", + Request: sdk.Request{Model: "m-1", Messages: messages, Tools: []sdk.ToolDefinition{a.tool.Definition()}}, + InputIDs: ids, + Tools: []run.ToolSpec{a.spec}, + }, nil +} + +type modelCatalog struct{ app *exampleApp } +type toolCatalog struct{ app *exampleApp } + +func (a *exampleApp) models() loop.ModelCatalog { return modelCatalog{a} } +func (a *exampleApp) tools() loop.ToolCatalog { return toolCatalog{a} } + +func (c modelCatalog) Resolve(run.ModelRef) (loop.ModelInvoker, error) { return c.app, nil } + +func (c toolCatalog) Resolve(ref run.ToolRef) (loop.ExecutableTool, error) { + if ref != c.app.tool.Ref() { + return nil, fmt.Errorf("unknown tool %q", ref) + } + return c.app.tool, nil +} + +// Generate is a scripted model: it asks for the tool until a tool result is +// present in the conversation, then answers. +func (a *exampleApp) 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, standing in +// for a tool call that is in flight when the process dies. +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.ToolExecutionUnknown{Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: "process died"}} + } + return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} +} From 923f1a575e681ae8dd9944dc2b083dc5080a6399 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 2 Sep 2026 22:20:35 +0800 Subject: [PATCH 56/81] Derive start and settlement CommandIDs from the ExecutionClaim Every command identity of one execution attempt now derives from its claim: DeriveStartCommandID, DeriveSettlementCommandID, DeriveModelRecoveryCommandID and DeriveToolRecoveryCommandID (RUN-WIR-3). EvaluateCommit enforces the start derivation, so a hand-minted start id is rejected before it can mint ownership. Tool recovery no longer writes the grant into the log. Loop keeps a single ClaimStore instead of the starts/settlements caches; memoryClaims is the default, ExecutionPolicy.Claims injects a durable one. A replacement Loop sharing the store replays the derived start, recovers the live grant and settles without waiting for lease expiry (TestLoopReplacementFinishesInheritedClaim). Known failure of a Pending call derives from the call alone. Test helpers treat literal start ids as attempt labels; spec identity table, RUN-LOP-3 and RUN-CMP-2 updated. Co-Authored-By: Claude Fable 5.1 --- agent/run/commit.go | 4 + agent/run/ids.go | 26 ++- agent/run/loop/cache.go | 251 ++++++++++++++------------- agent/run/loop/contract.go | 3 + agent/run/loop/lease_test.go | 96 ++++++++++ agent/run/loop/loop.go | 51 ++---- agent/run/loop/loop_test.go | 2 +- agent/run/loop/model.go | 46 ++--- agent/run/loop/tool.go | 86 ++++----- agent/run/recover_test.go | 5 +- agent/run/runtest/feature.go | 10 +- agent/run/runtime_helpers_test.go | 23 ++- agent/run/runtimetest/conformance.go | 35 ++-- agent/run/sqlitestore/store_test.go | 6 + agent/run/store_test.go | 17 +- agent/run/stored_runtime.go | 2 +- docs/design/agent-run.md | 19 +- 17 files changed, 408 insertions(+), 274 deletions(-) diff --git a/agent/run/commit.go b/agent/run/commit.go index a0ac1bc..35178ba 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -265,6 +265,10 @@ func checkDerivedCommandID(env *CommandEnvelope, baseRevision uint64) error { 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: if cmd.Claim != "" { want = DeriveModelRecoveryCommandID(env.RunID, cmd.StepID, cmd.Claim) diff --git a/agent/run/ids.go b/agent/run/ids.go index e9ba20c..55c18db 100644 --- a/agent/run/ids.go +++ b/agent/run/ids.go @@ -88,11 +88,21 @@ func DeriveInputCommandID(run RunID, input InputID) CommandID { return CommandID(namespacedHash("twilight/input-command", string(run), string(input))) } -// DeriveSystemCommandID derives the CommandID a recovery scanner uses for a -// system-issued command about one invalidated execution. The recovery record -// reference must be stable per record so replays reuse the same identity. -func DeriveSystemCommandID(run RunID, step StepID, call CallID, recoveryRecord string) CommandID { - return CommandID(namespacedHash("twilight/system-command", string(run), string(step), string(call), recoveryRecord)) +// DeriveStartCommandID derives the CommandID of StartModelExecution (empty +// call) or StartToolCall from the target and the attempt's ExecutionClaim. A +// Loop that kept only the claim can replay its start and recover the grant; +// 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 @@ -102,3 +112,9 @@ func DeriveSystemCommandID(run RunID, step StepID, call CallID, recoveryRecord s 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 grantless Unknown +// settlement RecoverExpired commits for one abandoned tool attempt. +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/cache.go b/agent/run/loop/cache.go index b52fb50..d74df1a 100644 --- a/agent/run/loop/cache.go +++ b/agent/run/loop/cache.go @@ -2,166 +2,173 @@ package loop import ( "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "sync" run "github.com/memohai/twilight/agent/run" ) -type startKey struct { +// ClaimStore is the host-injected record of this Loop's live execution +// claims. A claim is the only local state a Loop needs to reclaim an accepted +// start after its own process died: with the claim, the start CommandID, the +// settlement CommandID and the recovery CommandID all derive (RUN-WIR-3). +// +// The in-process default (memoryClaims) gives response-loss recovery within +// one process. A durable ClaimStore lets a replacement process replay the +// settlement of a tool call the dead process had finished but not reported, +// instead of waiting for lease expiry. +// +// Implementations must be safe for concurrent use. Put replaces any existing +// claim for the key; Delete of a missing key is a no-op. +type ClaimStore interface { + Put(ctx context.Context, runID run.RunID, stepID run.StepID, callID run.CallID, claim run.ExecutionClaim) error + Get(ctx context.Context, runID run.RunID, stepID run.StepID, callID run.CallID) (run.ExecutionClaim, bool, error) + Delete(ctx context.Context, runID run.RunID, stepID run.StepID, callID run.CallID) error + // DeleteRun forgets every claim of a finished Run. + DeleteRun(ctx context.Context, runID run.RunID) error +} + +type claimKey struct { runID run.RunID stepID run.StepID callID run.CallID } -type startAttempt struct { - commandID run.CommandID - claim run.ExecutionClaim +// memoryClaims is the default in-process ClaimStore. +type memoryClaims struct { + mu sync.Mutex + claims map[claimKey]run.ExecutionClaim } -type settlementAttempt struct { - commandID run.CommandID - base uint64 - grant run.ExecutionGrant - command run.AgentCommand +func newMemoryClaims() *memoryClaims { + return &memoryClaims{claims: make(map[claimKey]run.ExecutionClaim)} } -func (l *Loop) startFor(key startKey) startAttempt { - l.startsMu.Lock() - defer l.startsMu.Unlock() - if attempt, ok := l.starts[key]; ok { - return attempt - } - attempt := startAttempt{commandID: freshCommandID(), claim: freshExecutionClaim()} - l.starts[key] = attempt - return attempt +func (m *memoryClaims) Put(_ context.Context, runID run.RunID, stepID run.StepID, callID run.CallID, claim run.ExecutionClaim) error { + m.mu.Lock() + m.claims[claimKey{runID, stepID, callID}] = claim + m.mu.Unlock() + return nil } -func (l *Loop) forgetStart(key startKey) { - l.startsMu.Lock() - delete(l.starts, key) - l.startsMu.Unlock() +func (m *memoryClaims) Get(_ context.Context, runID run.RunID, stepID run.StepID, callID run.CallID) (run.ExecutionClaim, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + c, ok := m.claims[claimKey{runID, stepID, callID}] + return c, ok, nil } -func (l *Loop) lookupStart(key startKey) (startAttempt, bool) { - l.startsMu.Lock() - defer l.startsMu.Unlock() - attempt, ok := l.starts[key] - return attempt, ok +func (m *memoryClaims) Delete(_ context.Context, runID run.RunID, stepID run.StepID, callID run.CallID) error { + m.mu.Lock() + delete(m.claims, claimKey{runID, stepID, callID}) + m.mu.Unlock() + return nil } -func (l *Loop) settlementFor(key startKey, commandID run.CommandID, base uint64, grant run.ExecutionGrant, command run.AgentCommand) settlementAttempt { - l.settlementsMu.Lock() - defer l.settlementsMu.Unlock() - if attempt, ok := l.settlements[key]; ok { - return attempt +func (m *memoryClaims) DeleteRun(_ context.Context, runID run.RunID) error { + m.mu.Lock() + for k := range m.claims { + if k.runID == runID { + delete(m.claims, k) + } } - attempt := settlementAttempt{commandID: commandID, base: base, grant: grant, command: command} - l.settlements[key] = attempt - return attempt + m.mu.Unlock() + return nil +} + +// attempt is one execution attempt this Loop owns or is trying to own. Every +// command identity of the attempt derives from the claim. +type attempt struct { + runID run.RunID + stepID run.StepID + callID run.CallID + claim run.ExecutionClaim } -func (l *Loop) lookupSettlement(key startKey) (settlementAttempt, bool) { - l.settlementsMu.Lock() - defer l.settlementsMu.Unlock() - attempt, ok := l.settlements[key] - return attempt, ok +func (a attempt) startID() run.CommandID { + return run.DeriveStartCommandID(a.runID, a.stepID, a.callID, a.claim) } -func (l *Loop) forgetSettlement(key startKey) { - l.settlementsMu.Lock() - delete(l.settlements, key) - l.settlementsMu.Unlock() +func (a attempt) settlementID() run.CommandID { + return run.DeriveSettlementCommandID(a.runID, a.stepID, a.callID, a.claim) } -func (l *Loop) forgetRunCaches(runID run.RunID) { - l.startsMu.Lock() - for key := range l.starts { - if key.runID == runID { - delete(l.starts, key) - } +func (a attempt) recoveryID() run.CommandID { + return run.DeriveModelRecoveryCommandID(a.runID, a.stepID, a.claim) +} + +// claimFor returns the attempt for key, reusing a stored claim when the Loop +// (or a predecessor process sharing the ClaimStore) already started it, and +// minting and storing a fresh claim otherwise. +func (l *Loop) claimFor(ctx context.Context, runID run.RunID, stepID run.StepID, callID run.CallID) (attempt, error) { + claim, ok, err := l.Claims.Get(ctx, runID, stepID, callID) + if err != nil { + return attempt{}, fmt.Errorf("agent: loop: claim store: %w", err) } - l.startsMu.Unlock() - l.settlementsMu.Lock() - for key := range l.settlements { - if key.runID == runID { - delete(l.settlements, key) + if !ok { + claim = freshExecutionClaim() + if err := l.Claims.Put(ctx, runID, stepID, callID, claim); err != nil { + return attempt{}, fmt.Errorf("agent: loop: claim store: %w", err) } } - l.settlementsMu.Unlock() + return attempt{runID: runID, stepID: stepID, callID: callID, claim: claim}, nil } -// resumeSettlement replays a result whose first commit may have succeeded -// while its response was lost. The same command identity makes the retry -// idempotent and avoids re-running the external effect. -func (l *Loop) resumeSettlement(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot) (bool, error) { - runID := snapshot.State.RunID - l.settlementsMu.Lock() - keys := make([]startKey, 0) - for key := range l.settlements { - if key.runID == runID { - keys = append(keys, key) - } - } - l.settlementsMu.Unlock() - proto, err := snapshot.Protocol() +// hasClaim reports whether a claim for the target is already stored. +func (l *Loop) hasClaim(ctx context.Context, runID run.RunID, stepID run.StepID, callID run.CallID) (bool, error) { + _, ok, err := l.Claims.Get(ctx, runID, stepID, callID) if err != nil { - return false, err + return false, fmt.Errorf("agent: loop: claim store: %w", err) } - for _, key := range keys { - attempt, ok := l.lookupSettlement(key) - if !ok { - continue - } - if !settlementTargetActive(snapshot.State, key) { - l.forgetSettlement(key) - l.forgetStart(key) - continue - } - res, err := l.commit(context.WithoutCancel(ctx), runtime, runID, attempt.commandID, attempt.base, attempt.grant, attempt.command, proto) - if err != nil { - if retriable(err) { - l.forgetSettlement(key) - l.forgetStart(key) - return true, nil - } - return false, err - } - l.forgetSettlement(key) - l.forgetStart(key) - l.emitCommitted(ctx, events, runID, res.Events) - return true, nil - } - return false, nil + return ok, nil } -func settlementTargetActive(state run.MachineState, key startKey) bool { - switch current := state.Current.(type) { - case run.ModelStep: - return key.callID == "" && current.RefValue.ID == key.stepID && current.Status == run.ModelExecuting - case run.ToolStep: - if current.RefValue.ID != key.stepID { - return false - } - for _, call := range current.Calls { - if call.CallID == key.callID { - return call.Status == run.ToolExecuting - } +func (l *Loop) forgetClaim(ctx context.Context, a attempt) { + _ = l.Claims.Delete(context.WithoutCancel(ctx), a.runID, a.stepID, a.callID) +} + +func (l *Loop) forgetRunClaims(ctx context.Context, runID run.RunID) { + _ = l.Claims.DeleteRun(context.WithoutCancel(ctx), runID) +} + +// settle commits the owner settlement of an attempt under its derived +// CommandID. On success or on a sentinel rejection the claim is released: +// the attempt is over either way. A transport failure keeps the claim so the +// next Run (in this or a replacement process) replays the same settlement. +func (l *Loop) settle(ctx context.Context, runtime run.Runtime, events EventSink, a attempt, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand, proto run.Protocol) 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, grant, cmd, proto) + if err != nil { + if retriable(err) { + l.forgetClaim(ctx, a) + return nil } + return err } - return false + l.forgetClaim(ctx, a) + l.emitCommitted(ctx, events, a.runID, res.Events) + return nil } -// resumeCachedStart re-enters an accepted start after a Loop.Run returned -// before receiving its grant. The authority state is Executing, so Next alone -// would otherwise wait for recovery instead of replaying the known start. -func (l *Loop) resumeCachedStart(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot) (bool, error) { +// resumeOwnedStarts re-enters every Executing target this Loop holds a claim +// for. With a durable ClaimStore this is how a replacement process finishes +// what its predecessor started: the derived start ID replays and returns the +// live grant, then the effect runs (or re-runs) and settles. +func (l *Loop) resumeOwnedStarts(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot) (bool, error) { runID := snapshot.State.RunID switch current := snapshot.State.Current.(type) { case run.ModelStep: if current.Status != run.ModelExecuting { return false, nil } - if _, ok := l.lookupStart(startKey{runID: runID, stepID: current.RefValue.ID}); !ok { - return false, nil + if ok, err := l.hasClaim(ctx, runID, current.RefValue.ID, ""); err != nil || !ok { + return false, err } return true, l.runModelStep(ctx, runtime, events, snapshot, current.RefValue.ID) case run.ToolStep: @@ -170,7 +177,9 @@ func (l *Loop) resumeCachedStart(ctx context.Context, runtime run.Runtime, event if call.Status != run.ToolExecuting { continue } - if _, ok := l.lookupStart(startKey{runID: runID, stepID: current.RefValue.ID, callID: call.CallID}); ok { + if ok, err := l.hasClaim(ctx, runID, current.RefValue.ID, call.CallID); err != nil { + return false, err + } else if ok { ids = append(ids, call.CallID) } } @@ -182,3 +191,13 @@ func (l *Loop) resumeCachedStart(ctx context.Context, runtime run.Runtime, event return false, nil } } + +var errNoClaimStore = errors.New("agent: loop: nil claim store") + +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[:])) +} diff --git a/agent/run/loop/contract.go b/agent/run/loop/contract.go index 37cfb46..dc85c38 100644 --- a/agent/run/loop/contract.go +++ b/agent/run/loop/contract.go @@ -163,6 +163,9 @@ type ExecutionPolicy struct { // the Runtime's LeaseTTL (RUN-CMT-8); zero disables renewal and is only // correct for a Runtime whose leases do not expire. LeaseRenewInterval time.Duration + // Claims is the ClaimStore this Loop records live execution claims in. + // nil selects an in-process store. + Claims ClaimStore } type LoopDisposition uint8 diff --git a/agent/run/loop/lease_test.go b/agent/run/loop/lease_test.go index c11a3e5..d2469d1 100644 --- a/agent/run/loop/lease_test.go +++ b/agent/run/loop/lease_test.go @@ -98,3 +98,99 @@ func TestNewRejectsNegativeLeaseRenewInterval(t *testing.T) { t.Fatal("negative LeaseRenewInterval accepted") } } + +// A replacement Loop that shares the dead Loop's ClaimStore replays the +// derived start under the same claim, gets the live grant back, executes the +// tool and settles, without waiting for the lease to expire. +func TestLoopReplacementFinishesInheritedClaim(t *testing.T) { + rt := NewRuntimeWithOptions(NewMemoryStore(), RuntimeOptions{LeaseTTL: time.Hour}) + newRun, err := BuildNewRun("run-1", "") + if err != nil { + t.Fatal(err) + } + if _, err := rt.Create(context.Background(), newRun); err != nil { + t.Fatal(err) + } + env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", "seed"), AcceptInput{Input: AgentInput{ID: "seed", Payload: cj(`{}`)}}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(context.Background(), CommitRequest{Command: env}); err != nil { + t.Fatal(err) + } + + shared := newMemoryClaims() + spec := toolSpec(t, "echo", DirectExecution) + block := make(chan struct{}) + var executions atomic.Int32 + tool := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, + execute: func(ctx context.Context, req ToolExecutionRequest) ToolExecutionOutcome { + if executions.Add(1) == 1 { + <-block // first process "dies" here + return ToolExecutionUnknown{Failure: ToolFailure{Class: FailureEffectUnknown}} + } + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: req.Arguments}} + }} + invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("done")}} + catalog := fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": tool}} + + first, err := New(fakeCatalog{invoker}, catalog, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{Claims: shared}, false) + if err != nil { + t.Fatal(err) + } + firstDone := make(chan struct{}) + go func() { defer close(firstDone); _, _ = first.Run(context.Background(), rt, "run-1", nil) }() + deadline := time.Now().Add(5 * time.Second) + for { + snap, err := rt.Load(context.Background(), "run-1") + if err != nil { + t.Fatal(err) + } + if len(ExecutingCalls(snap.State)) == 1 { + break + } + if time.Now().After(deadline) { + t.Fatal("tool never started") + } + time.Sleep(time.Millisecond) + } + + // Lease is nowhere near expiry (1h). A second Loop with the same claims + // takes over immediately. + second, err := New(fakeCatalog{invoker}, catalog, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{Claims: shared}, false) + if err != nil { + t.Fatal(err) + } + res, err := second.Run(context.Background(), rt, "run-1", nil) + if err != nil { + t.Fatal(err) + } + if res.Disposition != LoopFinished || res.Result == nil || res.Result.Status != RunCompleted { + t.Fatalf("second loop result = %+v", res) + } + record, err := rt.Record(context.Background(), "run-1") + if err != nil { + t.Fatal(err) + } + starts := 0 + for _, tr := range record.Transitions { + for _, ev := range tr.Events { + switch f := ev.Fact.(type) { + case ToolCallStarted: + starts++ + case ToolCallFailed: + if f.Outcome == ToolOutcomeUnknown { + t.Fatalf("call settled Unknown; replacement did not inherit the claim: %+v", f) + } + } + } + } + if starts != 1 { + t.Fatalf("ToolCallStarted facts = %d, want 1 (one attempt, replayed)", starts) + } + // The first worker eventually returns; its settlement replays the same + // derived id with a different outcome and is rejected as a conflict, which + // the Loop treats as "already settled by someone else". + close(block) + <-firstDone +} diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index a2ac6bd..9c6d72e 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -2,8 +2,6 @@ package loop import ( "context" - "crypto/rand" - "encoding/hex" "errors" "fmt" "sync" @@ -19,16 +17,13 @@ type Loop struct { Planner RequestPlanner Execution ExecutionPolicy Streaming bool - - // starts retains the identity of an in-flight start command across a - // transient Run return. A commit may have succeeded while its response was - // lost; reusing the same command and claim lets the next Run recover the - // original grant without issuing a second start. - startsMu sync.Mutex - starts map[startKey]startAttempt - - settlementsMu sync.Mutex - settlements map[startKey]settlementAttempt + // Claims records the ExecutionClaim of every start this Loop has issued + // and not yet settled. Every command identity of an attempt derives from + // its claim, so this is the only local state a Loop needs to replay a + // start or settlement whose response was lost. New installs an in-process + // store; hosts that want a replacement process to finish a dead process's + // attempts inject a durable one through ExecutionPolicy.Claims. + Claims ClaimStore runsMu sync.Mutex runs map[run.RunID]struct{} @@ -55,8 +50,12 @@ func New(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy if policy.LeaseRenewInterval < 0 { return nil, errors.New("agent: loop: negative LeaseRenewInterval") } + claims := policy.Claims + if claims == nil { + claims = newMemoryClaims() + } return &Loop{Models: models, Tools: tools, Planner: planner, Execution: policy, Streaming: streaming, - starts: make(map[startKey]startAttempt), settlements: make(map[startKey]settlementAttempt), runs: make(map[run.RunID]struct{})}, nil + Claims: claims, runs: make(map[run.RunID]struct{})}, nil } func (l *Loop) toolScheduling() run.ToolScheduling { @@ -119,7 +118,7 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev return LoopResult{}, fmt.Errorf("agent: loop: runtime returned RunID %q for %q", snapshot.State.RunID, runID) } if snapshot.State.Status.Terminal() { - l.forgetRunCaches(runID) + l.forgetRunClaims(ctx, runID) if events != nil { _ = events.Emit(ctx, Event{ RunID: snapshot.State.RunID, @@ -130,12 +129,10 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev return LoopResult{Disposition: LoopFinished, Result: snapshot.State.Result}, nil } - if handled, err := l.resumeSettlement(ctx, runtime, events, &snapshot); err != nil { - return LoopResult{}, err - } else if handled { - continue + if l.Claims == nil { + return LoopResult{}, errNoClaimStore } - if handled, err := l.resumeCachedStart(ctx, runtime, events, &snapshot); err != nil { + if handled, err := l.resumeOwnedStarts(ctx, runtime, events, &snapshot); err != nil { return LoopResult{}, err } else if handled { continue @@ -197,19 +194,3 @@ func (l *Loop) commit(ctx context.Context, runtime run.Runtime, runID run.RunID, func retriable(err error) bool { return errors.Is(err, run.ErrStaleRuntime) || errors.Is(err, run.ErrRunTerminal) || errors.Is(err, run.ErrCommandConflict) } - -func freshCommandID() run.CommandID { - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - panic(fmt.Sprintf("agent: loop: %v", err)) - } - return run.CommandID(hex.EncodeToString(b[:])) -} - -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[:])) -} diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go index f62f25d..59cf84e 100644 --- a/agent/run/loop/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -302,7 +302,7 @@ func TestToolStartStaleDropsLocalClaim(t *testing.T) { StartToolCalls{StepID: stepID, CallIDs: []CallID{"c1"}}); err != nil { t.Fatal(err) } - if _, ok := loop.lookupStart(startKey{runID: "run-1", stepID: stepID, callID: "c1"}); ok { + if _, ok, _ := loop.Claims.Get(context.Background(), "run-1", stepID, "c1"); ok { t.Fatal("stale tool start retained a local execution claim") } } diff --git a/agent/run/loop/model.go b/agent/run/loop/model.go index 4eae0d9..ec36e3c 100644 --- a/agent/run/loop/model.go +++ b/agent/run/loop/model.go @@ -88,21 +88,26 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve if err != nil { return err } - key := startKey{runID: runID, stepID: stepID} - attempt := l.startFor(key) - start, err := l.commit(ctx, runtime, runID, attempt.commandID, snapshot.Revision, "", run.StartModelExecution{StepID: stepID, Claim: attempt.claim}, proto) + a, err := l.claimFor(ctx, runID, stepID, "") + if err != nil { + return err + } + start, err := l.commit(ctx, runtime, runID, a.startID(), snapshot.Revision, "", run.StartModelExecution{StepID: stepID, Claim: a.claim}, proto) if err != nil { if retriable(err) { - l.forgetStart(key) + l.forgetClaim(ctx, a) return nil } - // The start may have committed while its response was lost. Keep the - // command identity so a later Run can replay it and recover the grant. + // The start may have committed while its response was lost. The + // claim stays stored so a later Run replays the derived start ID and + // recovers the grant. return err } if start.Status == run.CommitAlreadyApplied && start.Grant == "" { - l.forgetStart(key) - return nil // another attempt owns it; reload + // Settled already: by this Loop before a lost response, or by + // recovery. Nothing left to own. + l.forgetClaim(ctx, a) + return nil } if start.Grant == "" { return errors.New("agent: loop: start model returned no execution grant") @@ -116,7 +121,7 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve // but executing again would duplicate the provider effect; reload and // let the next machine state decide what to do. if start.Status == run.CommitAlreadyApplied { - l.forgetStart(key) + l.forgetClaim(ctx, a) return nil } return fmt.Errorf("agent: loop: started step %q is not current", stepID) @@ -128,10 +133,10 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve switch { case resolveErr != nil: catalogErr = resolveErr - completion = run.RecoverModelExecution{StepID: stepID, Claim: attempt.claim} + 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: attempt.claim} + 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). @@ -145,7 +150,7 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve stopLease() switch { case invokeErr != nil && workerCtx.Err() != nil: - completion = run.RecoverModelExecution{StepID: stepID, Claim: attempt.claim} + 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: @@ -165,24 +170,9 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve } } - completionID := freshCommandID() - if _, recovering := completion.(run.RecoverModelExecution); recovering { - completionID = run.DeriveModelRecoveryCommandID(runID, stepID, attempt.claim) - } - settlement := l.settlementFor(key, completionID, start.Snapshot.Revision, start.Grant, completion) - settlementCtx := context.WithoutCancel(ctx) - res, err := l.commit(settlementCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command, proto) - if err != nil { - if retriable(err) { - l.forgetSettlement(key) - l.forgetStart(key) - return nil - } + if err := l.settle(ctx, runtime, events, a, start.Snapshot.Revision, start.Grant, completion, proto); err != nil { return err } - l.forgetSettlement(key) - l.forgetStart(key) - l.emitCommitted(ctx, events, runID, res.Events) if catalogErr != nil { return fmt.Errorf("agent: loop: model catalog: %w", catalogErr) } diff --git a/agent/run/loop/tool.go b/agent/run/loop/tool.go index 77dc2d8..ebe659c 100644 --- a/agent/run/loop/tool.go +++ b/agent/run/loop/tool.go @@ -10,11 +10,11 @@ import ( ) type startedWorker struct { - call run.ToolCallState - grant run.ExecutionGrant - base uint64 - tool ExecutableTool - key startKey + call run.ToolCallState + grant run.ExecutionGrant + base uint64 + tool ExecutableTool + attempt attempt } func toolCallIndex(step run.ToolStep, callID run.CallID) int { @@ -86,12 +86,13 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve continue } call := ts.Calls[i] - key := startKey{runID: runID, stepID: eff.StepID, callID: callID} resuming := false switch call.Status { case run.ToolPending: case run.ToolExecuting: - if _, ok := l.lookupStart(key); !ok { + if ok, err := l.hasClaim(ctx, runID, eff.StepID, callID); err != nil { + return err + } else if !ok { continue } resuming = true @@ -101,8 +102,10 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve tool, known := l.resolveExecutableTool(proto, call) if known != nil && !resuming { - // Known failure of a Pending call: no start barrier, no tool call. - res, err := l.commit(ctx, runtime, runID, freshCommandID(), snapshot.Revision, "", + // 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.Revision, "", 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) @@ -115,33 +118,35 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve continue } - attempt := l.startFor(key) - start, err := l.commit(ctx, runtime, runID, attempt.commandID, snapshot.Revision, "", - run.StartToolCall{StepID: eff.StepID, CallID: callID, Claim: attempt.claim}, proto) + a, err := l.claimFor(ctx, runID, eff.StepID, callID) + if err != nil { + return err + } + start, err := l.commit(ctx, runtime, runID, a.startID(), snapshot.Revision, "", + 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) { // A sentinel rejection proves this start did not acquire the - // call. Drop the local claim so a later snapshot can create a - // fresh start attempt or observe the other owner. - l.forgetStart(key) + // call. Drop the claim so a later snapshot can mint a fresh + // attempt or observe the other owner. + l.forgetClaim(ctx, a) return settleErr } return err } if start.Status == run.CommitAlreadyApplied && start.Grant == "" { - l.forgetStart(key) - continue // another attempt owns this call + l.forgetClaim(ctx, a) + continue // settled already; nothing left to own } if start.Grant == "" { - l.forgetStart(key) + l.forgetClaim(ctx, a) return errors.New("agent: loop: start tool returned no execution grant") } if startedCall, ok := toolCallFromSnapshot(start.Snapshot.State, eff.StepID, callID); !ok || startedCall.Status != run.ToolExecuting { // A replay may arrive after another worker has settled this call. - // Keep the original grant in the Runtime's replay record, but never - // invoke an effect for a call that is no longer Executing. - l.forgetStart(key) + // Never invoke an effect for a call that is no longer Executing. + l.forgetClaim(ctx, a) continue } l.emitCommitted(ctx, events, runID, start.Events) @@ -157,24 +162,17 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve } failure.Class = run.FailureEffectUnknown } - settlement := l.settlementFor(key, freshCommandID(), start.Snapshot.Revision, start.Grant, - run.SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: failure, Outcome: run.ToolOutcomeUnknown}) - res, err := l.commit(context.WithoutCancel(ctx), runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command, proto) - if err != nil { + if err := l.settle(ctx, runtime, events, a, start.Snapshot.Revision, start.Grant, + run.SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: failure, Outcome: run.ToolOutcomeUnknown}, proto); err != nil { settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, proto) - if retriable(err) { - l.forgetSettlement(key) - l.forgetStart(key) + if settleErr != nil { return settleErr } return err } - l.forgetSettlement(key) - l.forgetStart(key) - l.emitCommitted(ctx, events, runID, res.Events) continue } - started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Revision, tool: tool, key: key}) + started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Revision, tool: tool, attempt: a}) } return l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, proto) @@ -204,7 +202,6 @@ func (l *Loop) settleWorkers(ctx context.Context, runtime run.Runtime, events Ev return nil } controlCtx := context.WithoutCancel(ctx) - var mu sync.Mutex var wg sync.WaitGroup var firstErr error @@ -254,26 +251,15 @@ func (l *Loop) settleWorkers(ctx context.Context, runtime run.Runtime, events Ev // 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. - settlement := l.settlementFor(w.key, freshCommandID(), w.base, w.grant, cmd) - res, err := l.commit(controlCtx, runtime, runID, settlement.commandID, settlement.base, settlement.grant, settlement.command, proto) - switch { - case err == nil: - l.forgetSettlement(w.key) - l.forgetStart(w.key) - l.emitCommitted(ctx, events, runID, res.Events) - if events != nil { - _ = events.Emit(ctx, Event{RunID: runID, StepID: stepID, CallID: w.call.CallID, - Kind: EventToolCompleted, Durability: EventCommitted}) - } - case retriable(err): - l.forgetSettlement(w.key) - l.forgetStart(w.key) - // Terminal/stale: the authority already settled this call or - // the run; the result is intentionally dropped. - default: + if err := l.settle(controlCtx, runtime, events, w.attempt, w.base, w.grant, 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{RunID: runID, StepID: stepID, CallID: w.call.CallID, + Kind: EventToolCompleted, Durability: EventCommitted}) } }(w) } diff --git a/agent/run/recover_test.go b/agent/run/recover_test.go index 8831f22..d1ae15b 100644 --- a/agent/run/recover_test.go +++ b/agent/run/recover_test.go @@ -61,10 +61,7 @@ func TestRunExpiredRecoveryRecoversExpiredModel(t *testing.T) { if _, err := commitCmd(t, rt, cmdID, snap.Revision, "", prep); err != nil { t.Fatal(err) } - startEnv, err := ProtocolV1.BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: "claim-scan"}) - if err != nil { - t.Fatal(err) - } + startEnv := startEnvelope(t, "run-1", StartModelExecution{StepID: prep.StepID, Claim: "claim-scan"}) if _, err := rt.Commit(ctx, CommitRequest{Command: startEnv}); err != nil { t.Fatal(err) } diff --git a/agent/run/runtest/feature.go b/agent/run/runtest/feature.go index fc839c2..107ae00 100644 --- a/agent/run/runtest/feature.go +++ b/agent/run/runtest/feature.go @@ -174,8 +174,9 @@ func (f *Feature) TryCommit(cmd run.AgentCommand) error { 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) - cmd = withClaim(id, cmd) env, err := proto.BuildEnvelope(f.runID, id, cmd) if err != nil { return err @@ -331,8 +332,9 @@ func (f *Feature) commit(cmd run.AgentCommand, grant run.ExecutionGrant) run.Com 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) - cmd = withClaim(id, cmd) env, err := proto.BuildEnvelope(f.runID, id, cmd) if err != nil { f.t.Fatal(err) @@ -360,6 +362,10 @@ func (f *Feature) commandID(cmd run.AgentCommand, snap run.RuntimeSnapshot) run. return run.DeriveModelRequestCommandID(f.runID, snap.Revision) 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)) diff --git a/agent/run/runtime_helpers_test.go b/agent/run/runtime_helpers_test.go index 833df0a..a7851da 100644 --- a/agent/run/runtime_helpers_test.go +++ b/agent/run/runtime_helpers_test.go @@ -64,7 +64,7 @@ func memoryEntry(t testing.TB, rt Runtime) *memoryRun { func commitCmd(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) (CommitResult, error) { t.Helper() - cmd = withTestExecutionClaim(id, cmd) + cmd, id = withTestExecutionClaim("run-1", id, cmd) env, err := ProtocolV1.BuildEnvelope("run-1", id, cmd) if err != nil { t.Fatal(err) @@ -72,24 +72,37 @@ func commitCmd(t *testing.T, rt Runtime, id CommandID, base uint64, grant Execut return rt.Commit(context.Background(), CommitRequest{BaseRevision: base, Grant: grant, Command: env}) } -func withTestExecutionClaim(id CommandID, cmd AgentCommand) AgentCommand { +// withTestExecutionClaim treats id as the attempt label of a start command +// and derives claim and CommandID from it; see runtimetest. +func withTestExecutionClaim(runID RunID, id CommandID, cmd AgentCommand) (AgentCommand, CommandID) { claim := ExecutionClaim("test-claim/" + string(id)) switch c := cmd.(type) { case StartModelExecution: if c.Claim == "" { c.Claim = claim } - return c + return c, DeriveStartCommandID(runID, c.StepID, "", c.Claim) case StartToolCall: if c.Claim == "" { c.Claim = claim } - return c + return c, DeriveStartCommandID(runID, c.StepID, c.CallID, c.Claim) default: - return cmd + return cmd, id } } +// startEnvelope builds a start command's envelope under its derived id. +func startEnvelope(t testing.TB, runID RunID, cmd AgentCommand) CommandEnvelope { + t.Helper() + cmd, id := withTestExecutionClaim(runID, "start", cmd) + env, err := ProtocolV1.BuildEnvelope(runID, id, cmd) + if err != nil { + t.Fatal(err) + } + return env +} + func mustCommit(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) CommitResult { t.Helper() res, err := commitCmd(t, rt, id, base, grant, cmd) diff --git a/agent/run/runtimetest/conformance.go b/agent/run/runtimetest/conformance.go index dc14196..b8cb0e2 100644 --- a/agent/run/runtimetest/conformance.go +++ b/agent/run/runtimetest/conformance.go @@ -106,7 +106,7 @@ func (c *conformanceCase) load() run.RuntimeSnapshot { func (c *conformanceCase) commit(id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) (run.CommitResult, error) { c.t.Helper() - cmd = withTestExecutionClaim(id, cmd) + cmd, id = withTestExecutionClaim(c.runID, id, cmd) env, err := run.ProtocolV1.BuildEnvelope(c.runID, id, cmd) if err != nil { c.t.Fatal(err) @@ -118,21 +118,24 @@ func (c *conformanceCase) commit(id run.CommandID, base uint64, grant run.Execut return res, err } -func withTestExecutionClaim(id run.CommandID, cmd run.AgentCommand) run.AgentCommand { - claim := run.ExecutionClaim("test-claim/" + string(id)) +// withTestExecutionClaim treats id as the attempt label of a start command: +// the claim derives from the label and the CommandID derives from the claim +// (RUN-WIR-3), so a test that replays "start-1" hits the same identity. +// Non-start commands keep id verbatim. +func withTestExecutionClaim(runID run.RunID, id run.CommandID, cmd run.AgentCommand) (run.AgentCommand, run.CommandID) { switch c := cmd.(type) { case run.StartModelExecution: if c.Claim == "" { - c.Claim = claim + c.Claim = startClaim(id) } - return c + return c, run.DeriveStartCommandID(runID, c.StepID, "", c.Claim) case run.StartToolCall: if c.Claim == "" { - c.Claim = claim + c.Claim = startClaim(id) } - return c + return c, run.DeriveStartCommandID(runID, c.StepID, c.CallID, c.Claim) default: - return cmd + return cmd, id } } @@ -723,8 +726,20 @@ func testGrant(t *testing.T, newRuntime Factory) { if res.Status != run.CommitAlreadyApplied || res.Grant != grant { t.Fatalf("replayed start: %+v", res) } - if _, err := c.commit("start-1", 1, "", run.StartModelExecution{StepID: stepID, Claim: "different-claim"}); !errors.Is(err, run.ErrCommandConflict) { - t.Fatalf("different start claim err = %v, want ErrCommandConflict", err) + // A start under another claim is another attempt: its derived CommandID + // differs, so it is not a replay; the target is Executing, so Decide + // rejects it as stale. The live grant is untouched either way. + if _, err := c.commit("start-other", 1, "", run.StartModelExecution{StepID: stepID}); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("second start attempt err = %v, want ErrStaleRuntime", err) + } + // A start whose CommandID does not derive from its claim is rejected + // before it can mint ownership. + forged, err := run.ProtocolV1.BuildEnvelope(c.runID, "hand-minted", run.StartModelExecution{StepID: stepID, Claim: "different-claim"}) + if err != nil { + t.Fatal(err) + } + if _, err := c.rt.Commit(context.Background(), run.CommitRequest{BaseRevision: 1, Command: forged}); !errors.Is(err, run.ErrCommandConflict) { + t.Fatalf("non-derived start id err = %v, want ErrCommandConflict", err) } ok, err := run.FreezeModelResult(sdk.ModelResult{Text: "ok"}) if err != nil { diff --git a/agent/run/sqlitestore/store_test.go b/agent/run/sqlitestore/store_test.go index c61d895..e41ce76 100644 --- a/agent/run/sqlitestore/store_test.go +++ b/agent/run/sqlitestore/store_test.go @@ -372,6 +372,12 @@ func prepareFromSnap(t *testing.T, snap run.RuntimeSnapshot, req sdk.Request, sp func commit(t *testing.T, rt run.Runtime, runID run.RunID, id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) (run.CommitResult, error) { t.Helper() + switch c := cmd.(type) { + case run.StartModelExecution: + id = run.DeriveStartCommandID(runID, c.StepID, "", c.Claim) + case run.StartToolCall: + id = run.DeriveStartCommandID(runID, c.StepID, c.CallID, c.Claim) + } env, err := run.ProtocolV1.BuildEnvelope(runID, id, cmd) if err != nil { t.Fatal(err) diff --git a/agent/run/store_test.go b/agent/run/store_test.go index dea1949..eafda56 100644 --- a/agent/run/store_test.go +++ b/agent/run/store_test.go @@ -40,10 +40,7 @@ func TestExpiredLeaseAllowsGrantlessModelRecovery(t *testing.T) { t.Fatal(err) } claim := ExecutionClaim("claim-recover") - startEnv, err := ProtocolV1.BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) - if err != nil { - t.Fatal(err) - } + startEnv := startEnvelope(t, "run-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) start, err := rt.Commit(ctx, CommitRequest{Command: startEnv}) if err != nil { t.Fatal(err) @@ -94,7 +91,7 @@ func TestZeroDeadlineRejectsGrantlessRecovery(t *testing.T) { t.Fatal(err) } claim := ExecutionClaim("claim-1") - startEnv, _ := ProtocolV1.BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) + startEnv := startEnvelope(t, "run-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) if _, err := rt.Commit(ctx, CommitRequest{Command: startEnv}); err != nil { t.Fatal(err) } @@ -132,10 +129,7 @@ func TestGrantlessModelRecoveryRejectsWrongClaim(t *testing.T) { t.Fatal(err) } claim := ExecutionClaim("claim-recover") - startEnv, err := ProtocolV1.BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) - if err != nil { - t.Fatal(err) - } + startEnv := startEnvelope(t, "run-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) if _, err := rt.Commit(ctx, CommitRequest{Command: startEnv}); err != nil { t.Fatal(err) } @@ -178,10 +172,7 @@ func TestRecoverExpiredRecoversExecutingModel(t *testing.T) { t.Fatal(err) } claim := ExecutionClaim("claim-scan") - startEnv, err := ProtocolV1.BuildEnvelope("run-1", "start-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) - if err != nil { - t.Fatal(err) - } + startEnv := startEnvelope(t, "run-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) if _, err := rt.Commit(ctx, CommitRequest{Command: startEnv}); err != nil { t.Fatal(err) } diff --git a/agent/run/stored_runtime.go b/agent/run/stored_runtime.go index 820c11d..7599b29 100644 --- a/agent/run/stored_runtime.go +++ b/agent/run/stored_runtime.go @@ -335,7 +335,7 @@ func recoveryCommand(state *MachineState, key string, lease ExecutionLease) (Age CallID: callID, Failure: ToolFailure{Class: FailureEffectUnknown, Message: "lease expired"}, Outcome: ToolOutcomeUnknown, - }, DeriveSystemCommandID(state.RunID, cur.RefValue.ID, callID, string(lease.Grant)), true + }, DeriveToolRecoveryCommandID(state.RunID, cur.RefValue.ID, callID, lease.Claim), true default: return nil, "", false } diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 7cae20e..60a0d69 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -103,8 +103,11 @@ type TransitionRecord struct { | ResponseID | RunID、ToolStepID、CallID、ResponseKind | | response CommandID | RunID、StepID、CallID、ResponseID | | input CommandID | RunID、InputID | +| 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 | RunID、StepID、Claim | -| system recovery CommandID | RunID、StepID、CallID、recovery record | +| tool recovery CommandID(RecoverExpired 的 Unknown) | RunID、StepID、CallID、Claim | 同一派生 identity 的内容变化通过 command digest 触发 conflict。`PlanningToken` 是 Application-owned opaque freshness token,属于 prepare command identity 内容;Planner 负责解释它的 freshness,`ModelStepPrepared` 保存冻结请求及其 digest。 @@ -284,7 +287,7 @@ type RecoverModelExecution struct { } ``` -Loop 在提交 start 前生成并保留 `Claim`、`CommandID` 与 command digest。提交响应丢失时,Loop 以完全相同的三者重放;Runtime 对精确重放返回原 `ExecutionGrant`。恢复同一模型 attempt 时,`RecoverModelExecution` 携带原 `Claim`,其 CommandID 由 `(RunID, StepID, Claim)` 派生。若 Run 已记录 Executing 而 Loop 已丢失这些值,Runtime 的 recovery authority 处理该 execution,Loop 根据 recovery 结果继续。 +一次执行 attempt 的全部 command identity 都从其 `Claim` 派生:start、owner settlement、model recovery、tool recovery 的 CommandID 分别按上表计算,Commit 对 start 强制校验该派生。因此 Loop 只需保留 `Claim` 一个值(`loop.ClaimStore`):提交响应丢失时,以同一 Claim 重放得到同一 CommandID 与 digest,Runtime 对精确重放返回原 `ExecutionGrant`;settlement 重放同理。Claim 存在进程内时,恢复只覆盖响应丢失;宿主注入 durable ClaimStore 时,替代进程可以直接重放前一进程已开始 attempt 的 start 并完成 settlement,不必等 lease 过期。Loop 已丢失 Claim 时,由 Runtime 的 recovery authority(lease 过期)处理该 execution。 `Next(state)` 最多返回一个 transient `Effect`: @@ -462,8 +465,15 @@ type ExecutionPolicy struct { ToolExecution ToolExecutionMode MaxParallel int LeaseRenewInterval time.Duration + Claims ClaimStore // nil 为进程内存储 OnMalformedModelResult func(run.ModelStep, run.StepFailure) run.ModelRejectDisposition } +type ClaimStore interface { + Put(ctx, run.RunID, run.StepID, run.CallID, run.ExecutionClaim) error + Get(ctx, run.RunID, run.StepID, run.CallID) (run.ExecutionClaim, bool, error) + Delete(ctx, run.RunID, run.StepID, run.CallID) error + DeleteRun(ctx, run.RunID) error +} type LoopResult struct { Disposition LoopDisposition // LoopWaiting | LoopFinished Reason WaitReason // 仅 ExecutionRecovery 时为 execution_recovery;否则为空 @@ -500,7 +510,7 @@ Loop.Run(ctx, runtime, runID, sink): **RUN-LOP-2** `NeedModelRequest` 调用 Planner,冻结 sdk.Request,验证 model、ordered InputIDs 与 ToolSpecs,计算 request/tools/binding digests 和 derived CommandID/StepID,再提交 Prepare。prepare stale 后重新 Load;同 revision 的内容拒绝不得 livelock 重试。业务停止统一使用 `CancelRun`。 -**RUN-LOP-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须保留 start command 的 ID、digest 和 claim,直到完成 settlement;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。streaming 与 non-streaming 必须产生同一种完整 `sdk.ModelResult`;delta 只发 EventSink。`ModelCatalog.Resolve` 失败或返回 nil 时提交 `RecoverModelExecution` 并返回错误,不得把 Run 记为 `provider_failure`:尚未发生模型调用。provider 调用失败提交 `SubmitModelFailure`;ctx cancellation 提交 `RecoverModelExecution`;结构、binding 或 freeze 失败提交 `RejectModelResult`,并由调用方显式选择 retry 或 fail-run。成功结果只提交一次 `SubmitModelResult`。 +**RUN-LOP-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须在 `ClaimStore` 中保留该 attempt 的 claim 直到完成 settlement,其余 identity 按需派生;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。streaming 与 non-streaming 必须产生同一种完整 `sdk.ModelResult`;delta 只发 EventSink。`ModelCatalog.Resolve` 失败或返回 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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call,在外层 ctx 未取消时于本次 `Run` 内按冻结 Scheduling 分批 Start 并结算;ctx 已取消时停止再 Start,只结算已持有 grant 的 call。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `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;Executing 且本进程持有 start cache 时只重放 start 并结算,不得再提交 grantless Known。 @@ -556,6 +566,7 @@ Loop conformance 必须覆盖: - Cancel 将 Executing tool/model 投影到 `UncertainCalls` / `UncertainModel`;ExternalResponse reject 为 `response_rejected`; - streaming delta 与 nil result、EventSink committed observation; - stale/unknown commit response、prepare no-progress rejection 与无 livelock; -- 超过 LeaseTTL 的工具调用在续期下不被记为 Unknown,其结果被接受。 +- 超过 LeaseTTL 的工具调用在续期下不被记为 Unknown,其结果被接受; +- 共享 ClaimStore 的第二个 Loop 实例重放前一实例的 start 并完成 settlement,不等待 lease 过期。 历史 package 迁移、实施阶段与未完成 adapter 工作记录在 [agent-runtime-refactor.md](agent-runtime-refactor.md),本协议 authority 以本文为准。 From c8d859418da7fa8a5e057594afb8e4a90aa56ff6 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 2 Sep 2026 22:35:10 +0800 Subject: [PATCH 57/81] Add agent/turn: minimal Turn coordinator over an append-only session log The vertical slice of agent-turn.md: one Turn owns one primary Run. Start appends started + input_delivered, creates the Run, accepts inputs under their derived CommandIDs and drives. Resume rebuilds the Turn from the log and the Runtime (no coordinator state), materializes every transition above the coverage watermark through the v1 FactMapper (ModelStepCompleted -> assistant, ToolCallCompleted/Answered/Failed -> tool_result success/error/unknown), drives, then settles completed/failed from RunEnded. Stop cancels under a Turn-derived CommandID. Log is a Seq-addressed MemoryLog standing in for the Session kernel; the Session, Extension and Artifact specs stay drafts. LoopDriver is the reference RunDriver. Tests: completion with materialization and idempotent Start; approval wait and Resume; Stop settling as failed/stopped; Resume by a fresh coordinator after a crash mid tool call and lease expiry. Co-Authored-By: Claude Fable 5.1 --- agent/turn/coordinator.go | 343 ++++++++++++++++++++++++++ agent/turn/coordinator_test.go | 318 ++++++++++++++++++++++++ agent/turn/driver.go | 22 ++ agent/turn/mapper.go | 50 ++++ agent/turn/turn.go | 155 ++++++++++++ docs/design/agent-runtime-refactor.md | 7 +- 6 files changed, 892 insertions(+), 3 deletions(-) create mode 100644 agent/turn/coordinator.go create mode 100644 agent/turn/coordinator_test.go create mode 100644 agent/turn/driver.go create mode 100644 agent/turn/mapper.go create mode 100644 agent/turn/turn.go diff --git a/agent/turn/coordinator.go b/agent/turn/coordinator.go new file mode 100644 index 0000000..8a6f2b5 --- /dev/null +++ b/agent/turn/coordinator.go @@ -0,0 +1,343 @@ +package turn + +import ( + "context" + "errors" + "fmt" + + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/run" +) + +// RunDriver executes one Run until it finishes or has nothing executable. +// The reference driver wraps loop.Loop.Run; the coordinator reads the +// resulting disposition from the Runtime rather than from the driver. +type RunDriver interface { + Drive(ctx context.Context, runID run.RunID) error +} + +// Coordinator ties one Turn to one primary Run (TRN-SCP-1). It keeps no +// state of its own: every operation replays the session log and reads the +// Runtime (TRN-SCP-3). +type Coordinator struct { + Log Log + Runtime run.Runtime + Driver RunDriver +} + +var ( + ErrTurnNotFound = errors.New("agent: turn: no started turn") + ErrTurnConflict = errors.New("agent: turn: turn already started with a different run") +) + +type Disposition string + +const ( + // DispositionFinished: the Run is terminal and the Turn is settled. + DispositionFinished Disposition = "finished" + // DispositionWaitingForResponse: a tool call waits for approval or an + // external answer; Application submits it and calls Resume again. + DispositionWaitingForResponse Disposition = "waiting_for_response" + // DispositionWaitingForRecovery: an execution is in flight with no local + // owner; Application runs Runtime.RecoverExpired and calls Resume again. + DispositionWaitingForRecovery Disposition = "waiting_for_recovery" + // DispositionActive: the driver returned before an idle point (for + // example its context was cancelled); Resume continues. + DispositionActive Disposition = "active" +) + +type StartRequest struct { + Ref Ref + RunID run.RunID + Inputs []run.AgentInput +} + +type Result struct { + Ref Ref + RunID run.RunID + Disposition Disposition + Settlement Settlement + Waiting []run.ResponseRequest + Result *run.RunResult +} + +// linkage is the replayed view of one Turn. +type linkage struct { + started *StartedPayload + settled bool + covered uint64 // highest Run revision already materialized + inputs map[run.InputID]run.CanonicalJSON +} + +func (c *Coordinator) replay(ctx context.Context, ref Ref) (linkage, error) { + events, err := c.Log.Replay(ctx, ref.Session) + if err != nil { + return linkage{}, err + } + l := linkage{inputs: make(map[run.InputID]run.CanonicalJSON)} + for i := range events { + ev := &events[i] + if ev.Turn != ref.Turn { + continue + } + switch ev.Type { + case EventTurnStarted: + var p StartedPayload + if err := ev.Payload.Decode(&p); err != nil { + return linkage{}, err + } + l.started = &p + case EventTurnCompleted, EventTurnFailed: + l.settled = true + case EventInputDelivered: + var p InputDeliveredPayload + if err := ev.Payload.Decode(&p); err != nil { + return linkage{}, err + } + l.inputs[p.InputID] = p.Content + default: + if ev.Revision > l.covered { + l.covered = ev.Revision + } + } + } + return l, nil +} + +// Start appends the Turn's started group, creates the Run and resumes +// (TRN-STR-3..6). A repeated Start with the same RunID is idempotent; a +// different RunID for a started Turn is a conflict. +func (c *Coordinator) Start(ctx context.Context, req StartRequest) (Result, error) { + if req.Ref.Session == "" || req.Ref.Turn == "" || req.RunID == "" { + return Result{}, errors.New("agent: turn: start requires session, turn and run ids") + } + l, err := c.replay(ctx, req.Ref) + if err != nil { + return Result{}, err + } + if l.started != nil { + if l.started.RunID != req.RunID { + return Result{}, ErrTurnConflict + } + return c.Resume(ctx, req.Ref) + } + ids := make([]run.InputID, len(req.Inputs)) + group := make([]Event, 0, len(req.Inputs)+1) + for i, in := range req.Inputs { + if in.ID == "" { + return Result{}, fmt.Errorf("agent: turn: input %d has empty id", i) + } + ids[i] = in.ID + } + started, err := run.CanonicalJSONFromValue(StartedPayload{TurnID: req.Ref.Turn, RunID: req.RunID, InputIDs: ids}) + if err != nil { + return Result{}, err + } + group = append(group, Event{Type: EventTurnStarted, Turn: req.Ref.Turn, Payload: started}) + for _, in := range req.Inputs { + p, err := run.CanonicalJSONFromValue(InputDeliveredPayload{InputID: in.ID, TurnID: req.Ref.Turn, Content: in.Payload}) + if err != nil { + return Result{}, err + } + group = append(group, Event{Type: EventInputDelivered, Turn: req.Ref.Turn, Payload: p}) + } + if err := c.Log.Append(ctx, req.Ref.Session, group); err != nil { + return Result{}, err + } + return c.Resume(ctx, req.Ref) +} + +// Resume re-derives the Turn from the log, ensures the Run exists and holds +// every delivered input, materializes what is committed, drives, then +// materializes and settles (TRN-RSM-1..5). +func (c *Coordinator) Resume(ctx context.Context, ref Ref) (Result, error) { + l, err := c.replay(ctx, ref) + if err != nil { + return Result{}, err + } + if l.started == nil { + return Result{}, ErrTurnNotFound + } + runID := l.started.RunID + if !l.settled { + if err := c.ensureRun(ctx, ref, runID, &l); err != nil { + return Result{}, err + } + if _, err := c.materialize(ctx, ref, runID, &l); err != nil { + return Result{}, err + } + snap, err := c.Runtime.Load(ctx, runID) + if err != nil { + return Result{}, err + } + if !snap.State.Status.Terminal() { + if err := c.Driver.Drive(ctx, runID); err != nil { + return Result{}, err + } + } + } + return c.settle(ctx, ref, runID, &l) +} + +// Stop cancels an active Run under a Turn-derived CommandID and settles +// (TRN-STP-1..3). +func (c *Coordinator) Stop(ctx context.Context, ref Ref) (Result, error) { + l, err := c.replay(ctx, ref) + if err != nil { + return Result{}, err + } + if l.started == nil { + return Result{}, ErrTurnNotFound + } + runID := l.started.RunID + if !l.settled { + snap, err := c.Runtime.Load(ctx, runID) + if err != nil { + return Result{}, err + } + if !snap.State.Status.Terminal() { + proto, err := snap.Protocol() + if err != nil { + return Result{}, err + } + id := run.CommandID(es.DigestBytes([]byte("twilight/turn/cancel-run:" + string(ref.Session) + ":" + string(ref.Turn) + ":" + string(runID)))) + env, err := proto.BuildEnvelope(runID, id, run.CancelRun{Reason: run.ReasonCancelled}) + if err != nil { + return Result{}, err + } + if _, err := c.Runtime.Commit(ctx, run.CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil && !errors.Is(err, run.ErrRunTerminal) { + return Result{}, err + } + } + } + return c.settle(ctx, ref, runID, &l) +} + +// ensureRun creates the Run (idempotent) and accepts every delivered input +// through its derived CommandID; Accepted and AlreadyApplied both advance, +// a terminal Run absorbs the rest (TRN-RSM-2..3). +func (c *Coordinator) ensureRun(ctx context.Context, ref Ref, runID run.RunID, l *linkage) error { + newRun, err := run.BuildNewRun(runID, es.CausationID(string(ref.Session)+"/"+string(ref.Turn))) + if err != nil { + return err + } + if _, err := c.Runtime.Create(ctx, newRun); err != nil { + return err + } + for _, id := range l.started.InputIDs { + content, ok := l.inputs[id] + if !ok { + return fmt.Errorf("agent: turn: input %q named by started has no input_delivered", id) + } + snap, err := c.Runtime.Load(ctx, runID) + if err != nil { + return err + } + if snap.State.Status.Terminal() { + return nil + } + proto, err := snap.Protocol() + if err != nil { + return err + } + env, err := proto.BuildEnvelope(runID, run.DeriveInputCommandID(runID, id), run.AcceptInput{Input: run.AgentInput{ID: id, Payload: content}}) + if err != nil { + return err + } + if _, err := c.Runtime.Commit(ctx, run.CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { + // Not at Open (a step is in progress) means the input was already + // consumed by an earlier prepare; the derived id would have + // replayed otherwise. + if errors.Is(err, run.ErrStaleRuntime) { + continue + } + return err + } + } + return nil +} + +// materialize maps every transition above the coverage watermark into +// chatlog events and appends them as one group (TRN-MAT-1). It returns the +// verified record so callers settle from the same consistent read. +func (c *Coordinator) materialize(ctx context.Context, ref Ref, runID run.RunID, l *linkage) (run.RunRecord, error) { + record, err := c.Runtime.Record(ctx, runID) + if err != nil { + return run.RunRecord{}, err + } + var group []Event + for i := range record.Transitions { + tr := &record.Transitions[i] + if tr.Revision <= l.covered { + continue + } + mapped, err := MapTransition(ref.Turn, tr) + if err != nil { + return run.RunRecord{}, err + } + group = append(group, mapped...) + } + if len(group) > 0 { + if err := c.Log.Append(ctx, ref.Session, group); err != nil { + return run.RunRecord{}, err + } + l.covered = group[len(group)-1].Revision + } + return record, nil +} + +// settle materializes the tail and, when the Run is terminal and the Turn +// not yet settled, appends completed/failed from RunEnded (TRN-SET-3). The +// result reports the disposition read from the Runtime. +func (c *Coordinator) settle(ctx context.Context, ref Ref, runID run.RunID, l *linkage) (Result, error) { + record, err := c.materialize(ctx, ref, runID, l) + if err != nil { + return Result{}, err + } + state := &record.Snapshot.State + res := Result{Ref: ref, RunID: runID} + if !state.Status.Terminal() { + switch { + case run.NeedsRecovery(*state): + res.Disposition = DispositionWaitingForRecovery + case len(run.WaitingCalls(*state)) > 0: + res.Disposition = DispositionWaitingForResponse + res.Waiting = run.WaitingCalls(*state) + default: + res.Disposition = DispositionActive + } + return res, nil + } + res.Disposition = DispositionFinished + res.Result = state.Result + settlement, typ := settlementOf(state.Result) + res.Settlement = settlement + if !l.settled { + p := SettledPayload{TurnID: ref.Turn, RunID: runID, Settlement: settlement, Revision: record.Snapshot.Revision, Reason: string(state.Result.Reason)} + if state.Result.Failure != nil { + p.FailureClass = state.Result.Failure.Class + } else if settlement == SettlementStopped { + p.FailureClass = string(run.ReasonCancelled) + } + raw, err := run.CanonicalJSONFromValue(p) + if err != nil { + return Result{}, err + } + if err := c.Log.Append(ctx, ref.Session, []Event{{Type: typ, Turn: ref.Turn, Revision: record.Snapshot.Revision, Payload: raw}}); err != nil { + return Result{}, err + } + l.settled = true + } + return res, nil +} + +func settlementOf(r *run.RunResult) (settlement Settlement, eventType string) { + switch r.Status { + case run.RunCompleted: + return SettlementCompleted, EventTurnCompleted + case run.RunStopped: + return SettlementStopped, EventTurnFailed + default: + return SettlementFailed, EventTurnFailed + } +} diff --git a/agent/turn/coordinator_test.go b/agent/turn/coordinator_test.go new file mode 100644 index 0000000..927d54b --- /dev/null +++ b/agent/turn/coordinator_test.go @@ -0,0 +1,318 @@ +package turn_test + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/twilight/agent/turn" + "github.com/memohai/twilight/sdk" +) + +// --- application doubles ------------------------------------------------------ + +type scriptModel struct { + results []sdk.ModelResult + calls atomic.Int32 +} + +func (m *scriptModel) Resolve(run.ModelRef) (loop.ModelInvoker, error) { return m, nil } +func (m *scriptModel) Generate(ctx context.Context, _ sdk.Request) (sdk.ModelResult, error) { + if err := ctx.Err(); err != nil { + return sdk.ModelResult{}, err + } + n := int(m.calls.Add(1)) - 1 + if n >= len(m.results) { + return sdk.ModelResult{}, errors.New("no scripted result") + } + return m.results[n], nil +} + +type echoTool struct { + spec run.ToolSpec + def sdk.ToolDefinition + block chan struct{} // when non-nil, first execution blocks until closed + ran atomic.Int32 +} + +func newEchoTool(t testing.TB, policy run.ResponsePolicy) *echoTool { + t.Helper() + def := sdk.ToolDefinition{Name: "echo", Parameters: []byte(`{"type":"object"}`)} + frozen, err := run.FreezeToolDefinition(def) + if err != nil { + t.Fatal(err) + } + digest, err := run.ProtocolV1.DigestToolDefinition(frozen) + if err != nil { + t.Fatal(err) + } + return &echoTool{def: def, spec: run.ToolSpec{Ref: "echo", Definition: frozen, DefinitionDigest: digest, Policy: policy}} +} + +func (e *echoTool) Resolve(ref run.ToolRef) (loop.ExecutableTool, error) { + if ref != "echo" { + return nil, fmt.Errorf("unknown tool %q", ref) + } + return e, nil +} +func (e *echoTool) Ref() run.ToolRef { return "echo" } +func (e *echoTool) Definition() sdk.ToolDefinition { return e.def } +func (e *echoTool) ResponsePolicy() run.ResponsePolicy { return e.spec.Policy } +func (e *echoTool) ValidateArguments(run.CanonicalJSON) error { return nil } +func (e *echoTool) Execute(_ context.Context, req loop.ToolExecutionRequest) loop.ToolExecutionOutcome { + if e.ran.Add(1) == 1 && e.block != nil { + <-e.block + return loop.ToolExecutionUnknown{Failure: run.ToolFailure{Class: run.FailureEffectUnknown}} + } + return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} +} + +// planner replays delivered inputs as user messages and the last tool step +// as tool messages, which is all the scripted model needs. +type planner struct{ spec run.ToolSpec } + +func (p planner) Plan(_ context.Context, hint run.PlanningHint) (loop.RequestPlan, error) { + var msgs []sdk.Message + ids := make([]run.InputID, 0, len(hint.Inputs)) + for _, in := range hint.Inputs { + ids = append(ids, in.ID) + msgs = append(msgs, sdk.UserMessage(in.Payload.String())) + } + if hint.LastToolStep != nil { + for _, c := range hint.LastToolStep.Calls { + msgs = append(msgs, sdk.ToolMessage(sdk.ToolResultPart{ToolCallID: string(c.CallID), ToolName: "echo", Result: c.Status.String()})) + } + } + return loop.RequestPlan{Model: "m-1", Request: sdk.Request{Model: "m-1", Messages: msgs, Tools: []sdk.ToolDefinition{p.spec.Definition.SDK()}}, + InputIDs: ids, Tools: []run.ToolSpec{p.spec}}, nil +} + +func toolCall(id string) sdk.ModelResult { + return sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{TotalTokens: 1}, + ToolCalls: []sdk.ToolCall{{ToolCallID: id, ToolName: "echo", Input: `{"x":1}`}}} +} + +func text(s string) sdk.ModelResult { + return sdk.ModelResult{Text: s, FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}} +} + +type harness struct { + t *testing.T + log *turn.MemoryLog + rt run.Runtime + coord *turn.Coordinator + tool *echoTool + ref turn.Ref +} + +func newHarness(t *testing.T, rt run.Runtime, policy run.ResponsePolicy, results ...sdk.ModelResult) *harness { + t.Helper() + tool := newEchoTool(t, policy) + l, err := loop.New(&scriptModel{results: results}, tool, planner{tool.spec}, loop.ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + log := turn.NewMemoryLog() + return &harness{t: t, log: log, rt: rt, tool: tool, + coord: &turn.Coordinator{Log: log, Runtime: rt, Driver: turn.LoopDriver{Loop: l, Runtime: rt}}, + ref: turn.Ref{Session: "s-1", Turn: "t-1"}} +} + +func (h *harness) events() []turn.Event { + h.t.Helper() + evs, err := h.log.Replay(context.Background(), h.ref.Session) + if err != nil { + h.t.Fatal(err) + } + return evs +} + +func (h *harness) types() []string { + evs := h.events() + out := make([]string, 0, len(evs)) + for _, e := range evs { + out = append(out, e.Type) + } + return out +} + +func requireTypes(t *testing.T, got, want []string) { + t.Helper() + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("event types\n got %v\n want %v", got, want) + } +} + +// --- tests -------------------------------------------------------------------- + +func TestStartDrivesToCompletionAndMaterializes(t *testing.T) { + rt := run.NewRuntime(run.NewMemoryStore()) + h := newHarness(t, rt, run.DirectExecution, toolCall("c1"), text("done")) + res, err := h.coord.Start(context.Background(), turn.StartRequest{Ref: h.ref, RunID: "run-1", + Inputs: []run.AgentInput{{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{"text":"hi"}`)}}}) + if err != nil { + t.Fatal(err) + } + if res.Disposition != turn.DispositionFinished || res.Settlement != turn.SettlementCompleted || res.Result.Model.Text != "done" { + t.Fatalf("res = %+v", res) + } + requireTypes(t, h.types(), []string{ + turn.EventTurnStarted, turn.EventInputDelivered, + turn.EventAssistant, turn.EventToolResult, turn.EventAssistant, + turn.EventTurnCompleted, + }) + // Materialized events carry their Run position and the coverage watermark + // equals the terminal revision. + evs := h.events() + if evs[2].Revision == 0 || evs[len(evs)-1].Revision != 9 { + t.Fatalf("positions: %+v", evs) + } + // Start again with the same RunID is idempotent: no new events. + again, err := h.coord.Start(context.Background(), turn.StartRequest{Ref: h.ref, RunID: "run-1"}) + if err != nil || again.Disposition != turn.DispositionFinished || len(h.events()) != len(evs) { + t.Fatalf("idempotent start: %+v %v events=%d", again, err, len(h.events())) + } + if _, err := h.coord.Start(context.Background(), turn.StartRequest{Ref: h.ref, RunID: "run-2"}); !errors.Is(err, turn.ErrTurnConflict) { + t.Fatalf("second primary run err = %v", err) + } +} + +func TestApprovalWaitsThenResumes(t *testing.T) { + rt := run.NewRuntime(run.NewMemoryStore()) + h := newHarness(t, rt, run.ApprovalRequired, toolCall("c1"), text("done")) + res, err := h.coord.Start(context.Background(), turn.StartRequest{Ref: h.ref, RunID: "run-1", + Inputs: []run.AgentInput{{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{"text":"hi"}`)}}}) + if err != nil { + t.Fatal(err) + } + if res.Disposition != turn.DispositionWaitingForResponse || len(res.Waiting) != 1 { + t.Fatalf("res = %+v", res) + } + // The assistant that asked for the tool is already materialized while we wait. + requireTypes(t, h.types(), []string{turn.EventTurnStarted, turn.EventInputDelivered, turn.EventAssistant}) + + w := res.Waiting[0] + digest, err := run.ProtocolV1.DigestToolResponseDecision(w.Kind, run.ResponseDecisionApproved, "") + if err != nil { + t.Fatal(err) + } + snap, err := rt.Load(context.Background(), "run-1") + if err != nil { + t.Fatal(err) + } + env, err := run.ProtocolV1.BuildEnvelope("run-1", run.DeriveResponseCommandID("run-1", w.StepID, w.CallID, w.ID), + run.ApproveToolCall{StepID: w.StepID, CallID: w.CallID, ResponseID: w.ID, ResponseDigest: digest}) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Commit(context.Background(), run.CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { + t.Fatal(err) + } + res, err = h.coord.Resume(context.Background(), h.ref) + if err != nil || res.Disposition != turn.DispositionFinished { + t.Fatalf("resume: %+v %v", res, err) + } + requireTypes(t, h.types(), []string{turn.EventTurnStarted, turn.EventInputDelivered, turn.EventAssistant, + turn.EventToolResult, turn.EventAssistant, turn.EventTurnCompleted}) +} + +func TestStopCancelsAndSettlesAsFailedStopped(t *testing.T) { + rt := run.NewRuntime(run.NewMemoryStore()) + h := newHarness(t, rt, run.ApprovalRequired, toolCall("c1")) + if _, err := h.coord.Start(context.Background(), turn.StartRequest{Ref: h.ref, RunID: "run-1", + Inputs: []run.AgentInput{{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{}`)}}}); err != nil { + t.Fatal(err) + } + res, err := h.coord.Stop(context.Background(), h.ref) + if err != nil || res.Disposition != turn.DispositionFinished || res.Settlement != turn.SettlementStopped { + t.Fatalf("stop: %+v %v", res, err) + } + evs := h.events() + last := evs[len(evs)-1] + var p turn.SettledPayload + if err := last.Payload.Decode(&p); err != nil { + t.Fatal(err) + } + if last.Type != turn.EventTurnFailed || p.Settlement != turn.SettlementStopped || p.FailureClass != string(run.ReasonCancelled) { + t.Fatalf("settlement event = %s %+v", last.Type, p) + } + // Stop again is idempotent. + if _, err := h.coord.Stop(context.Background(), h.ref); err != nil || len(h.events()) != len(evs) { + t.Fatalf("second stop: %v events=%d", err, len(h.events())) + } +} + +// A driver whose process dies mid tool call: the Turn is resumed by a fresh +// coordinator after lease expiry. Nothing about the Turn lives outside the +// log and the Runtime, so the new coordinator rebuilds and finishes it. +func TestResumeAfterCrashRecoversAndSettles(t *testing.T) { + clock := time.Unix(1000, 0) + rt := run.NewRuntimeWithOptions(run.NewMemoryStore(), run.RuntimeOptions{LeaseTTL: time.Second, Now: func() time.Time { return clock }}) + h := newHarness(t, rt, run.DirectExecution, toolCall("c1"), text("after crash")) + h.tool.block = make(chan struct{}) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := h.coord.Start(ctx, turn.StartRequest{Ref: h.ref, RunID: "run-1", + Inputs: []run.AgentInput{{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{}`)}}}) + done <- err + }() + deadline := time.Now().Add(5 * time.Second) + for { + snap, err := rt.Load(context.Background(), "run-1") + if err == nil && len(run.ExecutingCalls(snap.State)) == 1 { + break + } + if time.Now().After(deadline) { + t.Fatal("tool never started") + } + time.Sleep(time.Millisecond) + } + // "Crash": cancel the driver's context and let the blocked worker return + // Unknown against a cancelled ctx; then drop every in-process artifact. + cancel() + close(h.tool.block) + <-done + + clock = clock.Add(2 * time.Second) + if _, err := rt.RecoverExpired(context.Background()); err != nil { + t.Fatal(err) + } + + // New process: new Loop, new Coordinator, same log and Runtime. + h2 := newHarness(t, rt, run.DirectExecution, text("after crash")) + h2.log = h.log + h2.coord.Log = h.log + res, err := h2.coord.Resume(context.Background(), h.ref) + if err != nil || res.Disposition != turn.DispositionFinished || res.Settlement != turn.SettlementCompleted { + t.Fatalf("resume after crash: %+v %v", res, err) + } + types := h2.types() + // Exactly one tool_result, and it is the Unknown recovery, followed by the + // second assistant and the settlement. + unknown := 0 + for _, e := range h2.events() { + if e.Type != turn.EventToolResult { + continue + } + var p turn.ToolResultPayload + if err := e.Payload.Decode(&p); err != nil { + t.Fatal(err) + } + if p.Status == turn.ToolUnknown { + unknown++ + } + } + if unknown != 1 { + t.Fatalf("unknown tool results = %d in %v", unknown, types) + } + if types[len(types)-1] != turn.EventTurnCompleted { + t.Fatalf("types = %v", types) + } +} diff --git a/agent/turn/driver.go b/agent/turn/driver.go new file mode 100644 index 0000000..bd015e0 --- /dev/null +++ b/agent/turn/driver.go @@ -0,0 +1,22 @@ +package turn + +import ( + "context" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" +) + +// LoopDriver is the reference RunDriver: it drives a Run with one loop.Loop +// and reports nothing about the outcome, which the Coordinator reads from +// the Runtime. +type LoopDriver struct { + Loop *loop.Loop + Runtime run.Runtime + Events loop.EventSink +} + +func (d LoopDriver) Drive(ctx context.Context, runID run.RunID) error { + _, err := d.Loop.Run(ctx, d.Runtime, runID, d.Events) + return err +} diff --git a/agent/turn/mapper.go b/agent/turn/mapper.go new file mode 100644 index 0000000..6e09a5f --- /dev/null +++ b/agent/turn/mapper.go @@ -0,0 +1,50 @@ +package turn + +import ( + "fmt" + + "github.com/memohai/twilight/agent/run" +) + +// MapTransition is the v1 FactMapper (TRN-MAP-1): it projects the AgentEvents +// of one committed transition into chatlog events for the Turn. Facts that +// carry no conversation content map to nothing. RunEnded is settlement, not +// content, and is handled by the coordinator. +func MapTransition(turnID TurnID, tr *run.TransitionRecord) ([]Event, error) { + var out []Event + for i := range tr.Events { + ev := &tr.Events[i] + var payload any + var typ string + switch f := ev.Fact.(type) { + case run.ModelStepCompleted: + calls := make([]ToolCallPayload, 0, len(f.Result.ToolCalls)) + for _, tc := range f.Result.ToolCalls { + calls = append(calls, ToolCallPayload{CallID: run.CallID(tc.ToolCallID), Name: tc.ToolName, Input: tc.Input}) + } + typ = EventAssistant + payload = AssistantPayload{TurnID: turnID, StepID: f.StepID, Text: f.Result.Text, ToolCalls: calls} + case run.ToolCallCompleted: + typ = EventToolResult + payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, Status: ToolSuccess, Output: f.Result.Output} + case run.ToolCallAnswered: + typ = EventToolResult + payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, Status: ToolSuccess, Output: f.Payload} + case run.ToolCallFailed: + status := ToolError + if f.Outcome == run.ToolOutcomeUnknown || f.Failure.Class == run.FailureEffectUnknown { + status = ToolUnknown + } + typ = EventToolResult + payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, Status: status, Failure: f.Failure.Class, Message: f.Failure.Message} + default: + continue + } + raw, err := run.CanonicalJSONFromValue(payload) + if err != nil { + return nil, fmt.Errorf("agent: turn: map %s: %w", typ, err) + } + out = append(out, Event{Type: typ, Turn: turnID, Revision: ev.Revision, Index: ev.Index, Payload: raw}) + } + return out, nil +} diff --git a/agent/turn/turn.go b/agent/turn/turn.go new file mode 100644 index 0000000..05cc719 --- /dev/null +++ b/agent/turn/turn.go @@ -0,0 +1,155 @@ +// Package turn is the minimal Turn coordinator: one Turn owns one primary +// Run, delivers inputs into it, drives it, and materializes the committed +// Run record into chatlog events on an append-only session log. +// +// This is the vertical slice described in docs/design/agent-turn.md, not the +// full Session kernel: the Log has no fork, snapshot or import, event +// identity is a sequence number, and materialization coverage is the +// highest Run revision already mapped for the Turn. Those simplifications are +// what the slice exists to test the Run API against; the Session spec stays +// a draft until this shape has been driven from a real host. +package turn + +import ( + "context" + "errors" + "sync" + + "github.com/memohai/twilight/agent/run" +) + +type SessionID string +type TurnID string + +// Ref addresses one Turn. +type Ref struct { + Session SessionID + Turn TurnID +} + +// Event types this package appends. Chatlog types follow +// agent-session-chatlog.md; turn types follow agent-turn.md. +const ( + EventTurnStarted = "twilight/turn/started" + EventTurnCompleted = "twilight/turn/completed" + EventTurnFailed = "twilight/turn/failed" + EventInputDelivered = "twilight/chatlog/input_delivered" + EventAssistant = "twilight/chatlog/assistant" + EventToolResult = "twilight/chatlog/tool_result" +) + +// Event is one committed fact on a session log. Seq is assigned by the Log. +// Revision/Index are set on events materialized from a Run transition and +// carry the source AgentEvent position; they are the coverage watermark. +type Event struct { + Seq uint64 + Type string + Turn TurnID + Revision uint64 + Index uint16 + Payload run.CanonicalJSON +} + +// Log is an append-only per-session event log. Append assigns contiguous +// Seq values and persists the whole group or nothing. +type Log interface { + Append(ctx context.Context, session SessionID, events []Event) error + Replay(ctx context.Context, session SessionID) ([]Event, error) +} + +// MemoryLog is the in-process Log. +type MemoryLog struct { + mu sync.Mutex + logs map[SessionID][]Event +} + +func NewMemoryLog() *MemoryLog { return &MemoryLog{logs: make(map[SessionID][]Event)} } + +func (m *MemoryLog) Append(ctx context.Context, session SessionID, events []Event) error { + if err := ctx.Err(); err != nil { + return err + } + if len(events) == 0 { + return errors.New("agent: turn: empty append") + } + m.mu.Lock() + defer m.mu.Unlock() + log := m.logs[session] + next := uint64(len(log)) + 1 + for i := range events { + events[i].Seq = next + next++ + } + m.logs[session] = append(log, events...) + return nil +} + +func (m *MemoryLog) Replay(ctx context.Context, session SessionID) ([]Event, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + m.mu.Lock() + defer m.mu.Unlock() + return append([]Event(nil), m.logs[session]...), nil +} + +// --- payloads ------------------------------------------------------------- + +type StartedPayload struct { + TurnID TurnID `json:"turnId"` + RunID run.RunID `json:"runId"` + InputIDs []run.InputID `json:"inputIds"` +} + +type InputDeliveredPayload struct { + InputID run.InputID `json:"inputId"` + TurnID TurnID `json:"turnId"` + Content run.CanonicalJSON `json:"content"` +} + +type Settlement string + +const ( + SettlementCompleted Settlement = "completed" + SettlementFailed Settlement = "failed" + SettlementStopped Settlement = "stopped" +) + +type SettledPayload struct { + TurnID TurnID `json:"turnId"` + RunID run.RunID `json:"runId"` + Settlement Settlement `json:"settlement"` + Revision uint64 `json:"revision"` + FailureClass string `json:"failureClass,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type ToolCallPayload struct { + CallID run.CallID `json:"callId"` + Name string `json:"name"` + Input run.CanonicalJSON `json:"input"` +} + +type AssistantPayload struct { + TurnID TurnID `json:"turnId"` + StepID run.StepID `json:"stepId"` + Text string `json:"text,omitempty"` + ToolCalls []ToolCallPayload `json:"toolCalls,omitempty"` +} + +type ToolResultStatus string + +const ( + ToolSuccess ToolResultStatus = "success" + ToolError ToolResultStatus = "error" + ToolUnknown ToolResultStatus = "unknown" +) + +type ToolResultPayload struct { + TurnID TurnID `json:"turnId"` + CallID run.CallID `json:"callId"` + Status ToolResultStatus `json:"status"` + Output run.CanonicalJSON `json:"output,omitzero"` + Failure string `json:"failure,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 08ee405..5aab4a1 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -91,7 +91,8 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + session mo | 追加式 `Store` 合同(LoadHead / LoadLog / LoadRecord 单一致读 / Commit critical section / ExpiredLeases)、snapshot codec、lease 续期 | 完成 | | SQLite Store adapter(snapshot 加日志、lease 表)通过 Runtime / recovery / renewal conformance | 完成 | | `agent/run/loop` package extraction | 完成 | -| Session/Artifact/Session Module/Turn protocols | 草案,无实现;wire 在 Memory 纵向切片跑通前不冻结 | +| Turn 最小实现(`agent/turn`:Start / Resume / Stop、v1 FactMapper、append-only MemoryLog、崩溃后 Resume) | 完成;Session kernel、Extension、Artifact 未接入,Log 为纵向切片的替身 | +| Session/Artifact/Session Module protocols | 草案,无实现;wire 在 Memory 纵向切片跑通前不冻结 | | Chatlog protocol | 草案,payload 与 golden 尚未冻结 | | 参考组装 | 草案 | | PostgreSQL durable Run adapter(旧接口) | 历史 prototype,迁移未完成 | @@ -104,8 +105,8 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + session mo - 冻结 Session、Artifact 与 Chatlog v1 wire profiles、domain separators、wire-size validation 和 golden fixtures; - 实现 Session、Artifact、Session Module、Chatlog 的 Memory implementations 与 conformance; -- 实现 `turn.Coordinator`、first-party Turn module、`FactMapper`、`MaterializeAll` 和 settlement recovery; -- 跑通 Input → Turn → Context → Run/Loop → Session Events → replay 的最小 vertical slice。 +- 把 `agent/turn` 的 `Log` 替换为 Session kernel 的 `Store` 与 `extension.SemanticAppender`,事件 identity 从 Seq 改为 spec 的派生 EventID / CommitID; +- 加 Chatlog Context projection 与参考 Planner,让 Run 的 `PlanningHint` 只提供边界事实。 ### 4.2 durable adapters From 643cc993aafe4d3cb89871ccba3dfd73f02631e1 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 2 Sep 2026 22:43:51 +0800 Subject: [PATCH 58/81] Tidy Run internals ahead of v1: tagged RunEnded wire, catalog names, ProtocolV1() - EvaluateCommit: an envelope digest mismatch is a construction fault and returns a hard error instead of ErrCommandConflict, so callers no longer reload and retry it. - Evolve: a duplicate InputAccepted is guarded as a corrupt log rather than silently deduplicated; Decide already rejects it and exact replay never reaches Evolve. - decideSubmitModelResult split into checkToolCallBindings / checkBindingAgainstResult / openToolStep. - RunEnded wire is now a tagged union ({"completed":{}} | {"stopped":{..}} | {"failed":{..}}) mirroring the Go sealed union; the flat status/reason shape and legacyEnd are gone. Golden digests unchanged (RunEnded is not in the state snapshot preimage). - ModelCatalog.ResolveModel and ToolCatalog.ResolveTool replace the two same-named Resolve methods so one type can implement both. - ProtocolV1 is a function; the package-level var could be reassigned. - agent-run.md and refactor doc 4.3 updated. Co-Authored-By: Claude Fable 5.1 --- agent/run/alias_test.go | 6 +- agent/run/canonical_test.go | 12 +-- agent/run/codec_test.go | 58 ++++++++++-- agent/run/commit.go | 5 +- agent/run/creation_test.go | 2 +- agent/run/decide.go | 131 +++++++++++++++----------- agent/run/evolve.go | 24 +++-- agent/run/example_run_test.go | 8 +- agent/run/fact.go | 126 ++++++++++--------------- agent/run/fold_test.go | 2 +- agent/run/header_test.go | 2 +- agent/run/loop/contract.go | 4 +- agent/run/loop/helpers_test.go | 2 +- agent/run/loop/lease_test.go | 4 +- agent/run/loop/loop_test.go | 8 +- agent/run/loop/model.go | 2 +- agent/run/loop/regression_test.go | 2 +- agent/run/loop/tool.go | 2 +- agent/run/machine_test.go | 61 ++++++------ agent/run/protocol.go | 6 +- agent/run/protocol_test.go | 4 +- agent/run/protocol_v1.go | 2 +- agent/run/recover_test.go | 2 +- agent/run/regression_test.go | 10 +- agent/run/runtest/feature.go | 6 +- agent/run/runtest/script.go | 4 +- agent/run/runtime_helpers_test.go | 10 +- agent/run/runtimetest/conformance.go | 22 ++--- agent/run/snapshot_test.go | 10 +- agent/run/sqlitestore/store_test.go | 14 +-- agent/run/store_test.go | 14 +-- agent/turn/coordinator_test.go | 10 +- docs/design/agent-run.md | 22 ++--- docs/design/agent-runtime-refactor.md | 9 ++ 34 files changed, 333 insertions(+), 273 deletions(-) diff --git a/agent/run/alias_test.go b/agent/run/alias_test.go index 7be4356..dd1ecb1 100644 --- a/agent/run/alias_test.go +++ b/agent/run/alias_test.go @@ -66,16 +66,16 @@ func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { "p": cj(`{"b":2,"a":1}`), }, } - reqDigest, err := ProtocolV1.DigestRequest(req) + reqDigest, err := ProtocolV1().DigestRequest(req) if err != nil { t.Fatal(err) } - toolsDigest, err := ProtocolV1.DigestToolSpecs(nil) + toolsDigest, err := ProtocolV1().DigestToolSpecs(nil) if err != nil { t.Fatal(err) } model := ModelRef(req.Model) - binding, err := ProtocolV1.DigestModelStepBinding(model, reqDigest, toolsDigest) + binding, err := ProtocolV1().DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } diff --git a/agent/run/canonical_test.go b/agent/run/canonical_test.go index 9d0714d..26eb575 100644 --- a/agent/run/canonical_test.go +++ b/agent/run/canonical_test.go @@ -99,7 +99,7 @@ func TestCanonicalDeterminism(t *testing.T) { func TestDigestCommandIdentity(t *testing.T) { cmd := StartToolCall{StepID: "s1", CallID: "c1", Claim: "claim-1"} - d1, err := ProtocolV1.DigestCommand("start_tool_call", cmd) + d1, err := ProtocolV1().DigestCommand("start_tool_call", cmd) if err != nil { t.Fatal(err) } @@ -107,12 +107,12 @@ func TestDigestCommandIdentity(t *testing.T) { t.Fatalf("bad digest wire form: %s", d1) } // Same content, same digest. - d2, _ := ProtocolV1.DigestCommand("start_tool_call", StartToolCall{StepID: "s1", CallID: "c1", Claim: "claim-1"}) + d2, _ := ProtocolV1().DigestCommand("start_tool_call", StartToolCall{StepID: "s1", CallID: "c1", Claim: "claim-1"}) if d1 != d2 { t.Fatal("same command produced different digests") } // Different content differs. - d3, _ := ProtocolV1.DigestCommand("start_tool_call", StartToolCall{StepID: "s1", CallID: "c2", Claim: "claim-1"}) + d3, _ := ProtocolV1().DigestCommand("start_tool_call", StartToolCall{StepID: "s1", CallID: "c2", Claim: "claim-1"}) if d1 == d3 { t.Fatal("different commands produced the same digest") } @@ -129,7 +129,7 @@ func TestDigestCommandIdentity(t *testing.T) { t.Fatal("schema version did not affect digest preimage") } // Type mismatch is rejected. - if _, err := ProtocolV1.DigestCommand("cancel_run", cmd); err == nil { + if _, err := ProtocolV1().DigestCommand("cancel_run", cmd); err == nil { t.Fatal("expected type/variant mismatch error") } } @@ -211,7 +211,7 @@ func TestSchemaVersion1Golden(t *testing.T) { if string(body) != wantBody { t.Fatalf("golden body changed:\n got %q\nwant %q", body, wantBody) } - d, err := ProtocolV1.DigestCommand("cancel_run", cmd) + d, err := ProtocolV1().DigestCommand("cancel_run", cmd) if err != nil { t.Fatal(err) } @@ -223,7 +223,7 @@ func TestSchemaVersion1Golden(t *testing.T) { } fact := InputAccepted{Input: AgentInput{ID: "in-1", Payload: cj(`{"text":"hi"}`)}} - fbody, err := ProtocolV1.EncodeFact("input_accepted", fact) + fbody, err := ProtocolV1().EncodeFact("input_accepted", fact) if err != nil { t.Fatal(err) } diff --git a/agent/run/codec_test.go b/agent/run/codec_test.go index 5a55e91..f44c9ca 100644 --- a/agent/run/codec_test.go +++ b/agent/run/codec_test.go @@ -26,9 +26,9 @@ func TestCommandEnvelopeJSONRoundTripRestoresVariants(t *testing.T) { AcceptInput{Input: AgentInput{ID: "in", Payload: cj(`{"q":"hi"}`)}}, } for _, cmd := range commands { - env, err := ProtocolV1.BuildEnvelope("run-1", CommandID("cmd-"+commandType(cmd)), cmd) + env, err := ProtocolV1().BuildEnvelope("run-1", CommandID("cmd-"+commandType(cmd)), cmd) if err != nil { - t.Fatalf("ProtocolV1.BuildEnvelope(%T): %v", cmd, err) + t.Fatalf("ProtocolV1().BuildEnvelope(%T): %v", cmd, err) } raw, err := json.Marshal(env) if err != nil { @@ -65,9 +65,9 @@ func TestAgentEventJSONRoundTripRestoresVariants(t *testing.T) { } for i, fact := range facts { typ := factType(fact) - digest, err := ProtocolV1.DigestFact(typ, fact) + digest, err := ProtocolV1().DigestFact(typ, fact) if err != nil { - t.Fatalf("ProtocolV1.DigestFact(%T): %v", fact, err) + t.Fatalf("ProtocolV1().DigestFact(%T): %v", fact, err) } event := AgentEvent{ SchemaVersion: SchemaVersion1, @@ -105,7 +105,7 @@ func TestTransitionRecordJSONRoundTripRestoresVariants(t *testing.T) { events := make([]AgentEvent, len(facts)) for i, fact := range facts { typ := factType(fact) - digest, err := ProtocolV1.DigestFact(typ, fact) + digest, err := ProtocolV1().DigestFact(typ, fact) if err != nil { t.Fatal(err) } @@ -151,7 +151,7 @@ func TestTransitionRecordJSONRoundTripRestoresVariants(t *testing.T) { func TestWireCodecRejectsAmbiguousJSONBeforeVariantDecode(t *testing.T) { cmd := AcceptInput{Input: AgentInput{ID: "in", Payload: cj(`1`)}} - env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", "in"), cmd) + env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", "in"), cmd) if err != nil { t.Fatal(err) } @@ -171,7 +171,7 @@ func TestWireCodecRejectsAmbiguousJSONBeforeVariantDecode(t *testing.T) { } func TestWireCodecRejectsUnknownTypeAndDigestMismatch(t *testing.T) { - env, err := ProtocolV1.BuildEnvelope("run-1", "cmd-1", CancelRun{}) + env, err := ProtocolV1().BuildEnvelope("run-1", "cmd-1", CancelRun{}) if err != nil { t.Fatal(err) } @@ -189,7 +189,7 @@ func TestWireCodecRejectsUnknownTypeAndDigestMismatch(t *testing.T) { } fact := RunEnded{End: RunCompletedEnd{}} - digest, err := ProtocolV1.DigestFact(factType(fact), fact) + digest, err := ProtocolV1().DigestFact(factType(fact), fact) if err != nil { t.Fatal(err) } @@ -212,7 +212,7 @@ func TestRunEndedTaggedUnionRejectsInvalidValues(t *testing.T) { "unknown end variant": {End: fakeRunEnd{}}, } { t.Run(name, func(t *testing.T) { - if _, err := ProtocolV1.DigestFact("run_ended", fact); err == nil { + if _, err := ProtocolV1().DigestFact("run_ended", fact); err == nil { t.Fatal("invalid tagged terminal value was accepted") } }) @@ -222,3 +222,43 @@ func TestRunEndedTaggedUnionRejectsInvalidValues(t *testing.T) { 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/commit.go b/agent/run/commit.go index 35178ba..5d0a132 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -125,7 +125,10 @@ func EvaluateCommit( return CommitDecision{}, err } if env.Digest != wantDigest { - return CommitDecision{Kind: DecisionConflict, Reject: fmt.Errorf("agent: commit: envelope digest mismatch")}, nil + // A digest that does not cover the command is a construction or + // transport fault, not a competing writer: surface it as a hard error + // so callers do not reload and retry it forever. + return CommitDecision{}, fmt.Errorf("agent: commit: envelope digest mismatch") } // A start claim is part of the command identity. Rejecting an empty claim // here prevents an unbound worker from acquiring execution ownership. diff --git a/agent/run/creation_test.go b/agent/run/creation_test.go index e92daca..0604140 100644 --- a/agent/run/creation_test.go +++ b/agent/run/creation_test.go @@ -22,7 +22,7 @@ func acceptInput(t testing.TB, rt Runtime, id RunID, input AgentInput) CommitRes if err != nil { t.Fatal(err) } - envelope, err := ProtocolV1.BuildEnvelope(id, DeriveInputCommandID(id, input.ID), AcceptInput{Input: input}) + envelope, err := ProtocolV1().BuildEnvelope(id, DeriveInputCommandID(id, input.ID), AcceptInput{Input: input}) if err != nil { t.Fatal(err) } diff --git a/agent/run/decide.go b/agent/run/decide.go index fe3abef..3d50cd6 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -186,10 +186,21 @@ func decideSubmitModelResult(s *MachineState, cmd *SubmitModelResult) ([]Fact, e } 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 +} - // Validate bindings against the frozen ToolSpecs and the model result, - // then freeze the full call set (Waiting requests included) here so - // Decide produces identical ToolStepOpened facts. +// 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)) } @@ -199,8 +210,9 @@ func decideSubmitModelResult(s *MachineState, cmd *SubmitModelResult) ([]Fact, e } seen := make(map[CallID]bool, len(cmd.Calls)) bindings := make([]ToolCallBinding, len(cmd.Calls)) - for i, b := range cmd.Calls { - rc := cmd.Result.ToolCalls[i] + for i := range cmd.Calls { + b := cmd.Calls[i] + rc := &cmd.Result.ToolCalls[i] if b.CallID == "" { return nil, rejectionf("model result: binding %d has empty CallID", i) } @@ -211,87 +223,94 @@ func decideSubmitModelResult(s *MachineState, cmd *SubmitModelResult) ([]Fact, e 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 +} - // Cross-check the binding against the model result: the authority - // accepts only bindings for the tool the model actually named, with - // the arguments the model actually produced (RUN-MCH-2). - if spec, known := specByName[rc.ToolName]; known { - if b.ToolRef != spec.Ref { - return nil, 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 nil, rejectionf("model result: binding %q definition digest does not match frozen ToolSpec", b.CallID) - } - if b.Policy != spec.Policy { - return nil, rejectionf("model result: binding %q policy does not match frozen ToolSpec", b.CallID) - } - } else { - // Unknown ToolRef stays an unresolved DirectExecution binding with - // an empty definition digest; StartToolCalls records the lookup - // failure (RUN-MCH-2). - if string(b.ToolRef) != rc.ToolName { - return nil, 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 nil, rejectionf("model result: unresolved binding %q must be DirectExecution with empty digest", b.CallID) - } +// 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) } - wantArgs, argsCanonical := canonicalArgumentsForCompare(rc.Input) - if !argsCanonical { - return nil, rejectionf("model result: call %q input is not frozen canonical JSON", b.CallID) + if b.DefinitionDigest != spec.DefinitionDigest { + return rejectionf("model result: binding %q definition digest does not match frozen ToolSpec", b.CallID) } - if !b.Arguments.Equal(wantArgs) { - return nil, rejectionf("model result: binding %q arguments do not match the model result", b.CallID) + if b.Policy != spec.Policy { + return rejectionf("model result: binding %q policy does not match frozen ToolSpec", b.CallID) } - wantBinding, err := digestToolCallBinding(b.CallID, b.DefinitionDigest, b.Policy, b.Arguments) - if err != nil { - return nil, err + } 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.BindingDigest != wantBinding { - return nil, rejectionf("model result: binding %q binding digest mismatch", b.CallID) + if b.Policy != DirectExecution || b.DefinitionDigest != "" { + return rejectionf("model result: unresolved binding %q must be DirectExecution with empty digest", b.CallID) } - bindings[i] = b - bindings[i].Response = nil // derived below; callers leave it empty } + 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 nil, err + return ToolStepOpened{}, err } - toolStepID := DeriveToolStepID(cmd.StepID, setDigest) + toolStepID := DeriveToolStepID(source, setDigest) for i := range bindings { - if bindings[i].Policy != ApprovalRequired && bindings[i].Policy != ExternalResponse { + kind, waits := responseKindForPolicy(bindings[i].Policy) + if !waits { continue } - kind := ResponseApproval - if bindings[i].Policy == ExternalResponse { - kind = ResponseExternal - } reqDigest, err := digestToolCallBinding(bindings[i].CallID, bindings[i].DefinitionDigest, bindings[i].Policy, bindings[i].Arguments) if err != nil { - return nil, err + return ToolStepOpened{}, err } bindings[i].Response = &ResponseRequest{ - RunID: s.RunID, + RunID: runID, StepID: toolStepID, CallID: bindings[i].CallID, - ID: DeriveResponseID(s.RunID, toolStepID, bindings[i].CallID, kind), + ID: DeriveResponseID(runID, toolStepID, bindings[i].CallID, kind), Kind: kind, Payload: bindings[i].Arguments, RequestDigest: reqDigest, } } - scheduling, err := normalizeToolScheduling(cmd.Scheduling) + normalized, err := normalizeToolScheduling(scheduling) if err != nil { - return nil, rejectionf("model result: %v", err) + return ToolStepOpened{}, rejectionf("model result: %v", err) } - return []Fact{completed, ToolStepOpened{ + return ToolStepOpened{ StepID: toolStepID, - Source: cmd.StepID, + Source: source, BindingSetDigest: setDigest, Calls: bindings, - Scheduling: scheduling, - }}, nil + Scheduling: normalized, + }, nil } // canonicalArgumentsForCompare canonicalizes a model result's tool input for diff --git a/agent/run/evolve.go b/agent/run/evolve.go index cb0d56f..d9368fd 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -134,11 +134,6 @@ func applyCall(s MachineState, callID CallID, mutate func(*ToolCallState)) Machi } func applyInputAccepted(s MachineState, fact *InputAccepted) MachineState { - for _, in := range s.PendingInputs { - if in.ID == fact.Input.ID { - return s // idempotent append per InputID - } - } s.PendingInputs = append(append([]AgentInput(nil), s.PendingInputs...), fact.Input) return s } @@ -189,7 +184,7 @@ func guardFactV1(s *MachineState, f Fact) error { case ToolCallFailed: return guardToolCallFailed(s, &fact) case InputAccepted: - return requireOpen(s, "input accepted") + return guardInputAccepted(s, &fact) case RunEnded: return validateRunEnd(fact.End) default: @@ -233,6 +228,23 @@ func requireCall(s *MachineState, stepID StepID, callID CallID, statuses ...Tool return ToolCallState{}, fmt.Errorf("agent: evolve: tool call %q is %s", callID, call.Status) } +func guardInputAccepted(s *MachineState, fact *InputAccepted) error { + if err := requireOpen(s, "input accepted"); err != nil { + return err + } + 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 diff --git a/agent/run/example_run_test.go b/agent/run/example_run_test.go index 3cefeed..28ff515 100644 --- a/agent/run/example_run_test.go +++ b/agent/run/example_run_test.go @@ -62,7 +62,7 @@ func Example_recoverableRun() { panic(err) } input := run.AgentInput{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{"text":"what is the weather?"}`)} - env, err := run.ProtocolV1.BuildEnvelope("run-1", run.DeriveInputCommandID("run-1", input.ID), run.AcceptInput{Input: input}) + env, err := run.ProtocolV1().BuildEnvelope("run-1", run.DeriveInputCommandID("run-1", input.ID), run.AcceptInput{Input: input}) if err != nil { panic(err) } @@ -210,7 +210,7 @@ func newExampleApp(tool *lookupTool) *exampleApp { if err != nil { panic(err) } - digest, err := run.ProtocolV1.DigestToolDefinition(frozen) + digest, err := run.ProtocolV1().DigestToolDefinition(frozen) if err != nil { panic(err) } @@ -259,9 +259,9 @@ type toolCatalog struct{ app *exampleApp } func (a *exampleApp) models() loop.ModelCatalog { return modelCatalog{a} } func (a *exampleApp) tools() loop.ToolCatalog { return toolCatalog{a} } -func (c modelCatalog) Resolve(run.ModelRef) (loop.ModelInvoker, error) { return c.app, nil } +func (c modelCatalog) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { return c.app, nil } -func (c toolCatalog) Resolve(ref run.ToolRef) (loop.ExecutableTool, error) { +func (c toolCatalog) ResolveTool(ref run.ToolRef) (loop.ExecutableTool, error) { if ref != c.app.tool.Ref() { return nil, fmt.Errorf("unknown tool %q", ref) } diff --git a/agent/run/fact.go b/agent/run/fact.go index 1f4681c..8e066b0 100644 --- a/agent/run/fact.go +++ b/agent/run/fact.go @@ -159,47 +159,6 @@ type RunEnded struct { func (RunEnded) fact() {} -func (r RunEnded) effectiveEnd() (RunEnd, error) { - if err := validateRunEnd(r.End); err != nil { - return nil, err - } - return r.End, nil -} - -func legacyEnd(status RunStatus, reason RunReason, failure *RunFailure, uncertainCalls []CallID, uncertainModel StepID) (RunEnd, error) { - switch status { - case RunCompleted: - if reason != "" || failure != nil || len(uncertainCalls) > 0 || uncertainModel != "" { - return nil, errors.New("agent: run ended: completed outcome cannot carry reason, failure, or uncertain effects") - } - return RunCompletedEnd{}, nil - case RunStopped: - if reason == "" { - return nil, errors.New("agent: run ended: stopped outcome requires a reason") - } - if failure != nil { - return nil, errors.New("agent: run ended: stopped outcome cannot carry failure") - } - return RunStoppedEnd{Reason: reason, UncertainCalls: uncertainCalls, UncertainModel: uncertainModel}, nil - case RunFailed: - if failure == nil { - return nil, errors.New("agent: run ended: failed outcome requires failure") - } - if reason == "" { - return nil, errors.New("agent: run ended: failed outcome requires a reason") - } - if failure.Class == "" { - return nil, errors.New("agent: run ended: failed outcome requires a failure class") - } - if len(uncertainCalls) > 0 || uncertainModel != "" { - return nil, errors.New("agent: run ended: failed outcome cannot carry uncertain-effect fields") - } - return RunFailedEnd{Reason: reason, Failure: *failure}, nil - default: - return nil, fmt.Errorf("agent: run ended: invalid status %d", status) - } -} - func validateRunEnd(end RunEnd) error { switch e := end.(type) { case RunCompletedEnd: @@ -222,14 +181,6 @@ func validateRunEnd(end RunEnd) error { } } -func (r RunEnded) normalized() (RunEnded, error) { - end, err := r.effectiveEnd() - if err != nil { - return RunEnded{}, err - } - return RunEnded{End: end}, nil -} - func endProjection(end RunEnd) (RunStatus, RunReason, *RunFailure) { switch e := end.(type) { case RunCompletedEnd: @@ -244,40 +195,47 @@ func endProjection(end RunEnd) (RunStatus, RunReason, *RunFailure) { } } -// MarshalJSON preserves the current v1 wire shape while deriving it from the -// sealed terminal union. +// 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) { - n, err := r.normalized() - if err != nil { + if err := validateRunEnd(r.End); err != nil { return nil, err } - type wire struct { - Status RunStatus `json:"status"` - Reason RunReason `json:"reason,omitempty"` - Failure *RunFailure `json:"failure,omitempty"` - UncertainCalls []CallID `json:"uncertainCalls,omitempty"` - UncertainModel StepID `json:"uncertainModel,omitempty"` - } - status, reason, failure := endProjection(n.End) - out := wire{Status: status, Reason: reason, Failure: failure} - if stopped, ok := n.End.(RunStoppedEnd); ok { - out.UncertainCalls = stopped.UncertainCalls - out.UncertainModel = stopped.UncertainModel + 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(out) + return json.Marshal(w) } func (r *RunEnded) UnmarshalJSON(raw []byte) error { - var wire struct { - Status RunStatus `json:"status"` - Reason RunReason `json:"reason,omitempty"` - Failure *RunFailure `json:"failure,omitempty"` - UncertainCalls []CallID `json:"uncertainCalls,omitempty"` - UncertainModel StepID `json:"uncertainModel,omitempty"` - } + var w runEndWire dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() - if err := dec.Decode(&wire); err != nil { + if err := dec.Decode(&w); err != nil { return err } if err := dec.Decode(&struct{}{}); err != io.EOF { @@ -286,8 +244,24 @@ func (r *RunEnded) UnmarshalJSON(raw []byte) error { } return err } - end, err := legacyEnd(wire.Status, wire.Reason, wire.Failure, wire.UncertainCalls, wire.UncertainModel) - if err != nil { + 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} diff --git a/agent/run/fold_test.go b/agent/run/fold_test.go index 66b9b15..65ee553 100644 --- a/agent/run/fold_test.go +++ b/agent/run/fold_test.go @@ -257,7 +257,7 @@ func TestGoldenEventStreamV1(t *testing.T) { if maxRev != 9 { t.Fatalf("golden stream has %d transitions, want 9", maxRev) } - stateBytes, err := ProtocolV1.EncodeMachineState(&folded) + stateBytes, err := ProtocolV1().EncodeMachineState(&folded) if err != nil { t.Fatal(err) } diff --git a/agent/run/header_test.go b/agent/run/header_test.go index 32ac9d1..cad3364 100644 --- a/agent/run/header_test.go +++ b/agent/run/header_test.go @@ -69,7 +69,7 @@ func TestCommitRejectsCommandSchemaMismatch(t *testing.T) { t.Fatal(err) } in := AgentInput{ID: "seed", Payload: MustParseCanonicalJSON(`{"q":"hi"}`)} - env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), NextStep(in)) + env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), NextStep(in)) if err != nil { t.Fatal(err) } diff --git a/agent/run/loop/contract.go b/agent/run/loop/contract.go index dc85c38..e10ca0e 100644 --- a/agent/run/loop/contract.go +++ b/agent/run/loop/contract.go @@ -36,7 +36,7 @@ type RequestPlan struct { // 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 { - Resolve(run.ModelRef) (ModelInvoker, error) + ResolveModel(run.ModelRef) (ModelInvoker, error) } type ModelInvoker interface { @@ -50,7 +50,7 @@ type StreamingModelInvoker interface { } type ToolCatalog interface { - Resolve(run.ToolRef) (ExecutableTool, error) + ResolveTool(run.ToolRef) (ExecutableTool, error) } type ToolExecutionRequest struct { diff --git a/agent/run/loop/helpers_test.go b/agent/run/loop/helpers_test.go index 61f2cab..b602a37 100644 --- a/agent/run/loop/helpers_test.go +++ b/agent/run/loop/helpers_test.go @@ -21,7 +21,7 @@ func newTestRuntime(t *testing.T) Runtime { if _, err := rt.Create(context.Background(), newRun); err != nil { t.Fatal(err) } - env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", "seed"), AcceptInput{ + env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", "seed"), AcceptInput{ Input: AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)}, }) if err != nil { diff --git a/agent/run/loop/lease_test.go b/agent/run/loop/lease_test.go index d2469d1..24e24af 100644 --- a/agent/run/loop/lease_test.go +++ b/agent/run/loop/lease_test.go @@ -39,7 +39,7 @@ func TestLoopRenewsLeaseDuringLongTool(t *testing.T) { if _, err := rt.Create(context.Background(), newRun); err != nil { t.Fatal(err) } - env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", "seed"), AcceptInput{Input: AgentInput{ID: "seed", Payload: cj(`{}`)}}) + env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", "seed"), AcceptInput{Input: AgentInput{ID: "seed", Payload: cj(`{}`)}}) if err != nil { t.Fatal(err) } @@ -111,7 +111,7 @@ func TestLoopReplacementFinishesInheritedClaim(t *testing.T) { if _, err := rt.Create(context.Background(), newRun); err != nil { t.Fatal(err) } - env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", "seed"), AcceptInput{Input: AgentInput{ID: "seed", Payload: cj(`{}`)}}) + env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", "seed"), AcceptInput{Input: AgentInput{ID: "seed", Payload: cj(`{}`)}}) if err != nil { t.Fatal(err) } diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go index 59cf84e..75a067f 100644 --- a/agent/run/loop/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -45,7 +45,7 @@ func (b *blockingInvoker) Generate(context.Context, sdk.Request) (sdk.ModelResul type fakeCatalog struct{ invoker ModelInvoker } -func (c fakeCatalog) Resolve(ModelRef) (ModelInvoker, error) { return c.invoker, nil } +func (c fakeCatalog) ResolveModel(ModelRef) (ModelInvoker, error) { return c.invoker, nil } type fakeTool struct { ref ToolRef @@ -65,7 +65,7 @@ func (f *fakeTool) Execute(ctx context.Context, req ToolExecutionRequest) ToolEx type fakeToolCatalog struct{ tools map[ToolRef]ExecutableTool } -func (c fakeToolCatalog) Resolve(ref ToolRef) (ExecutableTool, error) { +func (c fakeToolCatalog) ResolveTool(ref ToolRef) (ExecutableTool, error) { t, ok := c.tools[ref] if !ok { return nil, fmt.Errorf("unknown tool %q", ref) @@ -102,7 +102,7 @@ func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { if err != nil { t.Fatal(err) } - d, err := ProtocolV1.DigestToolDefinition(frozen) + d, err := ProtocolV1().DigestToolDefinition(frozen) if err != nil { t.Fatal(err) } @@ -163,7 +163,7 @@ func TestLoopRejectsConcurrentRunForSameID(t *testing.T) { type errCatalog struct{ err error } -func (c errCatalog) Resolve(ModelRef) (ModelInvoker, error) { return nil, c.err } +func (c errCatalog) ResolveModel(ModelRef) (ModelInvoker, error) { return nil, c.err } func TestLoopModelCatalogErrorRecoversWithFreshLoop(t *testing.T) { rt := loopRuntime(t) diff --git a/agent/run/loop/model.go b/agent/run/loop/model.go index ec36e3c..e5ff2f5 100644 --- a/agent/run/loop/model.go +++ b/agent/run/loop/model.go @@ -129,7 +129,7 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve var completion run.AgentCommand var catalogErr error - invoker, resolveErr := l.Models.Resolve(modelStep.Model) + invoker, resolveErr := l.Models.ResolveModel(modelStep.Model) switch { case resolveErr != nil: catalogErr = resolveErr diff --git a/agent/run/loop/regression_test.go b/agent/run/loop/regression_test.go index b200e0b..631a2c0 100644 --- a/agent/run/loop/regression_test.go +++ b/agent/run/loop/regression_test.go @@ -72,7 +72,7 @@ func TestRegressionAliasedToolRefExecutes(t *testing.T) { if err != nil { t.Fatal(err) } - d, err := ProtocolV1.DigestToolDefinition(frozenDef) + d, err := ProtocolV1().DigestToolDefinition(frozenDef) if err != nil { t.Fatal(err) } diff --git a/agent/run/loop/tool.go b/agent/run/loop/tool.go index ebe659c..03de6f9 100644 --- a/agent/run/loop/tool.go +++ b/agent/run/loop/tool.go @@ -27,7 +27,7 @@ func toolCallIndex(step run.ToolStep, callID run.CallID) int { } func (l *Loop) resolveExecutableTool(proto run.Protocol, call run.ToolCallState) (ExecutableTool, *run.ToolFailure) { - tool, resolveErr := l.Tools.Resolve(call.ToolRef) + tool, resolveErr := l.Tools.ResolveTool(call.ToolRef) if resolveErr != nil { return nil, &run.ToolFailure{Class: run.FailureToolLookup, Message: resolveErr.Error()} } diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go index c5296a5..70cc0f9 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -25,9 +25,9 @@ func newRun(t *testing.T) MachineState { func mustDecide(t *testing.T, s MachineState, c AgentCommand) []Fact { t.Helper() - facts, err := ProtocolV1.Decide(s, c) + facts, err := ProtocolV1().Decide(s, c) if err != nil { - t.Fatalf("ProtocolV1.Decide(%T): %v", c, err) + t.Fatalf("ProtocolV1().Decide(%T): %v", c, err) } return facts } @@ -36,9 +36,9 @@ 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) + s, err = ProtocolV1().Evolve(s, f) if err != nil { - t.Fatalf("ProtocolV1.Evolve(%T): %v", f, err) + t.Fatalf("ProtocolV1().Evolve(%T): %v", f, err) } } return s @@ -62,16 +62,16 @@ func buildPrepare(t *testing.T, s MachineState, req sdk.Request, specs []ToolSpe if err != nil { t.Fatal(err) } - reqDigest, err := ProtocolV1.DigestRequest(frozenReq) + reqDigest, err := ProtocolV1().DigestRequest(frozenReq) if err != nil { t.Fatal(err) } - toolsDigest, err := ProtocolV1.DigestToolSpecs(specs) + toolsDigest, err := ProtocolV1().DigestToolSpecs(specs) if err != nil { t.Fatal(err) } model := ModelRef(frozenReq.Model) - binding, err := ProtocolV1.DigestModelStepBinding(model, reqDigest, toolsDigest) + binding, err := ProtocolV1().DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -98,7 +98,7 @@ func makeSpec(t *testing.T, def sdk.ToolDefinition, policy ResponsePolicy) ToolS if err != nil { t.Fatal(err) } - d, err := ProtocolV1.DigestToolDefinition(frozen) + d, err := ProtocolV1().DigestToolDefinition(frozen) if err != nil { t.Fatal(err) } @@ -107,7 +107,7 @@ func makeSpec(t *testing.T, def sdk.ToolDefinition, policy ResponsePolicy) ToolS func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDecision, reason string) Digest { t.Helper() - d, err := ProtocolV1.DigestToolResponseDecision(kind, decision, reason) + d, err := ProtocolV1().DigestToolResponseDecision(kind, decision, reason) if err != nil { t.Fatal(err) } @@ -116,7 +116,7 @@ func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDe func responsePayloadDigest(t *testing.T, payload CanonicalJSON) Digest { t.Helper() - d, err := ProtocolV1.DigestToolResponsePayload(payload) + d, err := ProtocolV1().DigestToolResponsePayload(payload) if err != nil { t.Fatal(err) } @@ -221,7 +221,7 @@ 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 { + if _, err := ProtocolV1().Decide(s, prep); err == nil { t.Fatal("prepare with missing InputIDs accepted") } } @@ -268,7 +268,7 @@ func TestExternalResponseRequiresPayloadDigest(t *testing.T) { 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: "c1", ResponseID: respID, ResponseDigest: "sha256:bad", Payload: payload}); err == nil { + if _, err := ProtocolV1().Decide(s, SubmitToolResponse{StepID: opened.StepID, CallID: "c1", 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: "c1", ResponseID: respID, @@ -284,7 +284,7 @@ func TestExternalResponseRequiresPayloadDigest(t *testing.T) { 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: "c1", ResponseID: respID, + facts, err := ProtocolV1().Decide(s, RejectToolCall{StepID: opened.StepID, CallID: "c1", ResponseID: respID, ResponseDigest: responseDecisionDigest(t, ResponseExternal, ResponseDecisionRejected, "user dismissed"), Reason: "user dismissed"}) if err != nil { t.Fatal(err) @@ -322,7 +322,7 @@ func TestToolSchedulingRejectsUnknownMode(t *testing.T) { s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) b := makeBinding(t, "c1", spec, `{}`) - _, err := ProtocolV1.Decide(s, SubmitModelResult{ + _, err := ProtocolV1().Decide(s, SubmitModelResult{ StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}, Scheduling: ToolScheduling{Mode: "round-robin"}, }) @@ -501,29 +501,30 @@ func TestRejectModelResultDispositionRetriesThenFails(t *testing.T) { } } -func TestAcceptInputIdempotentPerID(t *testing.T) { +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)) } - // Same fact folded twice appends once. - s = fold(t, s, facts) - if len(s.PendingInputs) != 2 { - t.Fatal("InputAccepted fold is not idempotent per InputID") + // 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") } // AcceptInput rejected while a step is current. prep, _ := buildPrepare(t, s, testRequest(), nil) s = fold(t, s, mustDecide(t, s, prep)) - if _, err := ProtocolV1.Decide(s, NextStep(AgentInput{ID: "in-3"})); err == nil { + if _, err := ProtocolV1().Decide(s, NextStep(AgentInput{ID: "in-3"})); err == nil { t.Fatal("AcceptInput accepted with a current step") } } func TestAcceptInputRejectsSeedDuplicateID(t *testing.T) { s := newRun(t) - _, err := ProtocolV1.Decide(s, NextStep(AgentInput{ID: "seed", Payload: cj(`{"q":"other"}`)})) + _, 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) } @@ -539,7 +540,7 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { s := minimal for _, id := range ids { var foldErr error - s, foldErr = ProtocolV1.Evolve(s, InputAccepted{Input: AgentInput{ID: id, Payload: cj(`null`)}}) + s, foldErr = ProtocolV1().Evolve(s, InputAccepted{Input: AgentInput{ID: id, Payload: cj(`null`)}}) if foldErr != nil { t.Fatal(foldErr) } @@ -548,15 +549,15 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { } prepared := func(ids ...InputID) ModelStepPrepared { request := ModelRequest{Model: string(testModel)} - requestDigest, err := ProtocolV1.DigestRequest(request) + requestDigest, err := ProtocolV1().DigestRequest(request) if err != nil { t.Fatal(err) } - toolsDigest, err := ProtocolV1.DigestToolSpecs(nil) + toolsDigest, err := ProtocolV1().DigestToolSpecs(nil) if err != nil { t.Fatal(err) } - binding, err := ProtocolV1.DigestModelStepBinding(testModel, requestDigest, toolsDigest) + binding, err := ProtocolV1().DigestModelStepBinding(testModel, requestDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -565,25 +566,25 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { t.Run("nonexistent input", func(t *testing.T) { s := withInputs("in-1") - if _, err := ProtocolV1.Evolve(s, prepared("missing")); err == nil { + 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 { + 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 { + 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")) + next, err := ProtocolV1().Evolve(s, prepared("in-1", "in-2")) if err != nil { t.Fatal(err) } @@ -596,7 +597,7 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { func TestEvolveRejectsModelPrepareOverCurrentStep(t *testing.T) { s := newRun(t) s, _ = advanceToExecuting(t, s, testRequest(), nil) - _, err := ProtocolV1.Evolve(s, ModelStepPrepared{ + _, err := ProtocolV1().Evolve(s, ModelStepPrepared{ StepID: "other", Model: testModel, Request: ModelRequest{Model: string(testModel)}, diff --git a/agent/run/protocol.go b/agent/run/protocol.go index 9b68402..b41c8e4 100644 --- a/agent/run/protocol.go +++ b/agent/run/protocol.go @@ -69,7 +69,9 @@ type Protocol struct { // 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. -var ProtocolV1 = Protocol{ +func ProtocolV1() Protocol { return protocolV1 } + +var protocolV1 = Protocol{ version: SchemaVersion1, digestRequest: digestRequestV1, digestToolDefinition: digestToolDefinitionV1, @@ -93,7 +95,7 @@ var ProtocolV1 = Protocol{ func ProtocolFor(schemaVersion uint16) (Protocol, error) { switch schemaVersion { case SchemaVersion1: - return ProtocolV1, nil + return protocolV1, nil default: return Protocol{}, unsupportedSchemaVersion(schemaVersion) } diff --git a/agent/run/protocol_test.go b/agent/run/protocol_test.go index c196f11..20d474a 100644 --- a/agent/run/protocol_test.go +++ b/agent/run/protocol_test.go @@ -10,8 +10,8 @@ func TestProtocolForSelectsV1(t *testing.T) { if p.Version() != SchemaVersion1 { t.Fatalf("version = %d", p.Version()) } - if p.Version() != ProtocolV1.Version() { - t.Fatal("ProtocolFor(1) did not bind ProtocolV1") + if p.Version() != ProtocolV1().Version() { + t.Fatal("ProtocolFor(1) did not bind ProtocolV1()") } if _, err := ProtocolFor(0); err == nil { t.Fatal("schema 0 accepted") diff --git a/agent/run/protocol_v1.go b/agent/run/protocol_v1.go index de1e93d..6bf445c 100644 --- a/agent/run/protocol_v1.go +++ b/agent/run/protocol_v1.go @@ -5,7 +5,7 @@ import ( "fmt" ) -// SchemaVersion1 digest, decode, Decide, and Evolve helpers. ProtocolV1 binds +// 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) { diff --git a/agent/run/recover_test.go b/agent/run/recover_test.go index d1ae15b..579f409 100644 --- a/agent/run/recover_test.go +++ b/agent/run/recover_test.go @@ -46,7 +46,7 @@ func TestRunExpiredRecoveryRecoversExpiredModel(t *testing.T) { t.Fatal(err) } in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} - env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) + env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) if err != nil { t.Fatal(err) } diff --git a/agent/run/regression_test.go b/agent/run/regression_test.go index 2155fcd..8916697 100644 --- a/agent/run/regression_test.go +++ b/agent/run/regression_test.go @@ -6,7 +6,7 @@ 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 { + 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") } } @@ -21,12 +21,12 @@ func TestRegressionBindingMustMatchModelResult(t *testing.T) { evil := makeBinding(t, "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 { + 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, "c1", specSafe, `{"a":999}`) - if _, err := ProtocolV1.Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: []ToolCallBinding{tampered}}); err == nil { + if _, err := ProtocolV1().Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: []ToolCallBinding{tampered}}); err == nil { t.Fatal("binding with tampered arguments was accepted") } } @@ -60,7 +60,7 @@ func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { s = fold(t, s, facts) s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: "c1"})) - _, err := ProtocolV1.Evolve(s, ToolCallFailed{ + _, err := ProtocolV1().Evolve(s, ToolCallFailed{ StepID: opened.StepID, CallID: "c1", Failure: ToolFailure{Class: FailureExecution}, @@ -73,7 +73,7 @@ func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { func TestRegressionCancelReasonFixed(t *testing.T) { s := newRun(t) - if _, err := ProtocolV1.Decide(s, CancelRun{Reason: RunReason("other")}); err == nil { + if _, err := ProtocolV1().Decide(s, CancelRun{Reason: RunReason("other")}); err == nil { t.Fatal("CancelRun accepted a non-cancellation reason") } facts := mustDecide(t, s, CancelRun{}) diff --git a/agent/run/runtest/feature.go b/agent/run/runtest/feature.go index 107ae00..c164d62 100644 --- a/agent/run/runtest/feature.go +++ b/agent/run/runtest/feature.go @@ -189,7 +189,7 @@ func (f *Feature) TryCommit(cmd run.AgentCommand) error { func (f *Feature) Approve() *Feature { f.t.Helper() w := f.waiting() - digest, err := run.ProtocolV1.DigestToolResponseDecision(w.Kind, run.ResponseDecisionApproved, "") + digest, err := run.ProtocolV1().DigestToolResponseDecision(w.Kind, run.ResponseDecisionApproved, "") if err != nil { f.t.Fatal(err) } @@ -203,7 +203,7 @@ func (f *Feature) Approve() *Feature { func (f *Feature) Reject(reason string) *Feature { f.t.Helper() w := f.waiting() - digest, err := run.ProtocolV1.DigestToolResponseDecision(w.Kind, run.ResponseDecisionRejected, reason) + digest, err := run.ProtocolV1().DigestToolResponseDecision(w.Kind, run.ResponseDecisionRejected, reason) if err != nil { f.t.Fatal(err) } @@ -419,7 +419,7 @@ func (f *Feature) mustSpec(name string, policy run.ResponsePolicy) run.ToolSpec if err != nil { f.t.Fatal(err) } - d, err := run.ProtocolV1.DigestToolDefinition(frozen) + d, err := run.ProtocolV1().DigestToolDefinition(frozen) if err != nil { f.t.Fatal(err) } diff --git a/agent/run/runtest/script.go b/agent/run/runtest/script.go index 4573998..a4d15f6 100644 --- a/agent/run/runtest/script.go +++ b/agent/run/runtest/script.go @@ -56,7 +56,7 @@ type scriptCatalog struct { err error } -func (c scriptCatalog) Resolve(run.ModelRef) (loop.ModelInvoker, error) { +func (c scriptCatalog) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { if c.err != nil { return nil, c.err } @@ -94,7 +94,7 @@ type scriptToolCatalog struct { tools map[run.ToolRef]loop.ExecutableTool } -func (c scriptToolCatalog) Resolve(ref run.ToolRef) (loop.ExecutableTool, error) { +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) diff --git a/agent/run/runtime_helpers_test.go b/agent/run/runtime_helpers_test.go index a7851da..898e4ea 100644 --- a/agent/run/runtime_helpers_test.go +++ b/agent/run/runtime_helpers_test.go @@ -65,7 +65,7 @@ func memoryEntry(t testing.TB, rt Runtime) *memoryRun { func commitCmd(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) (CommitResult, error) { t.Helper() cmd, id = withTestExecutionClaim("run-1", id, cmd) - env, err := ProtocolV1.BuildEnvelope("run-1", id, cmd) + env, err := ProtocolV1().BuildEnvelope("run-1", id, cmd) if err != nil { t.Fatal(err) } @@ -96,7 +96,7 @@ func withTestExecutionClaim(runID RunID, id CommandID, cmd AgentCommand) (AgentC func startEnvelope(t testing.TB, runID RunID, cmd AgentCommand) CommandEnvelope { t.Helper() cmd, id := withTestExecutionClaim(runID, "start", cmd) - env, err := ProtocolV1.BuildEnvelope(runID, id, cmd) + env, err := ProtocolV1().BuildEnvelope(runID, id, cmd) if err != nil { t.Fatal(err) } @@ -134,16 +134,16 @@ func buildPrepareFromSnap(t *testing.T, snap RuntimeSnapshot, req sdk.Request, s if err != nil { t.Fatal(err) } - reqDigest, err := ProtocolV1.DigestRequest(frozenReq) + reqDigest, err := ProtocolV1().DigestRequest(frozenReq) if err != nil { t.Fatal(err) } - toolsDigest, err := ProtocolV1.DigestToolSpecs(specs) + toolsDigest, err := ProtocolV1().DigestToolSpecs(specs) if err != nil { t.Fatal(err) } model := ModelRef(frozenReq.Model) - binding, err := ProtocolV1.DigestModelStepBinding(model, reqDigest, toolsDigest) + binding, err := ProtocolV1().DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } diff --git a/agent/run/runtimetest/conformance.go b/agent/run/runtimetest/conformance.go index b8cb0e2..7ba684e 100644 --- a/agent/run/runtimetest/conformance.go +++ b/agent/run/runtimetest/conformance.go @@ -107,7 +107,7 @@ func (c *conformanceCase) load() run.RuntimeSnapshot { func (c *conformanceCase) commit(id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) (run.CommitResult, error) { c.t.Helper() cmd, id = withTestExecutionClaim(c.runID, id, cmd) - env, err := run.ProtocolV1.BuildEnvelope(c.runID, id, cmd) + env, err := run.ProtocolV1().BuildEnvelope(c.runID, id, cmd) if err != nil { c.t.Fatal(err) } @@ -181,16 +181,16 @@ func buildPrepareFromSnap(t testing.TB, snap *run.RuntimeSnapshot, req *sdk.Requ if err != nil { t.Fatal(err) } - reqDigest, err := run.ProtocolV1.DigestRequest(frozenReq) + reqDigest, err := run.ProtocolV1().DigestRequest(frozenReq) if err != nil { t.Fatal(err) } - toolsDigest, err := run.ProtocolV1.DigestToolSpecs(specs) + toolsDigest, err := run.ProtocolV1().DigestToolSpecs(specs) if err != nil { t.Fatal(err) } model := run.ModelRef(frozenReq.Model) - binding, err := run.ProtocolV1.DigestModelStepBinding(model, reqDigest, toolsDigest) + binding, err := run.ProtocolV1().DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -226,7 +226,7 @@ func makeSpec(t testing.TB, def sdk.ToolDefinition) run.ToolSpec { if err != nil { t.Fatal(err) } - d, err := run.ProtocolV1.DigestToolDefinition(frozen) + d, err := run.ProtocolV1().DigestToolDefinition(frozen) if err != nil { t.Fatal(err) } @@ -327,7 +327,7 @@ func testCreateMissing(t *testing.T, newRuntime Factory) { if _, err := rt.Record(context.Background(), "missing"); !errors.Is(err, run.ErrRunNotFound) { t.Fatalf("Record error = %v, want ErrRunNotFound", err) } - env, err := run.ProtocolV1.BuildEnvelope("missing", "cancel", run.CancelRun{}) + env, err := run.ProtocolV1().BuildEnvelope("missing", "cancel", run.CancelRun{}) if err != nil { t.Fatal(err) } @@ -457,7 +457,7 @@ func testRecordConcurrentWithCommit(t *testing.T, newRuntime Factory) { t.Fatal(err) } in := run.AgentInput{ID: run.InputID(fmt.Sprintf("in-%d", i)), Payload: mustJSON(`null`)} - env, err := run.ProtocolV1.BuildEnvelope(c.runID, run.DeriveInputCommandID(c.runID, in.ID), run.AcceptInput{Input: in}) + env, err := run.ProtocolV1().BuildEnvelope(c.runID, run.DeriveInputCommandID(c.runID, in.ID), run.AcceptInput{Input: in}) if err != nil { t.Fatal(err) } @@ -559,7 +559,7 @@ func testIsolationRuns(t *testing.T, newRuntime Factory) { t.Fatal(err) } in := run.AgentInput{ID: "same-input", Payload: mustJSON(`{"run":"` + string(id) + `"}`)} - env, err := run.ProtocolV1.BuildEnvelope(id, run.DeriveInputCommandID(id, in.ID), run.AcceptInput{Input: in}) + env, err := run.ProtocolV1().BuildEnvelope(id, run.DeriveInputCommandID(id, in.ID), run.AcceptInput{Input: in}) if err != nil { t.Fatal(err) } @@ -710,7 +710,7 @@ func testPrepareReplayAfterProgress(t *testing.T, newRuntime Factory) { func testGrant(t *testing.T, newRuntime Factory) { c, stepID, grant := preparedCase(t, newRuntime, nil, nil) // The authority rejects an unbound start before it can mint ownership. - empty, err := run.ProtocolV1.BuildEnvelope(c.runID, "empty-claim", run.StartModelExecution{StepID: stepID}) + empty, err := run.ProtocolV1().BuildEnvelope(c.runID, "empty-claim", run.StartModelExecution{StepID: stepID}) if err != nil { t.Fatal(err) } @@ -734,7 +734,7 @@ func testGrant(t *testing.T, newRuntime Factory) { } // A start whose CommandID does not derive from its claim is rejected // before it can mint ownership. - forged, err := run.ProtocolV1.BuildEnvelope(c.runID, "hand-minted", run.StartModelExecution{StepID: stepID, Claim: "different-claim"}) + forged, err := run.ProtocolV1().BuildEnvelope(c.runID, "hand-minted", run.StartModelExecution{StepID: stepID, Claim: "different-claim"}) if err != nil { t.Fatal(err) } @@ -905,7 +905,7 @@ func testCancelUnknownRemaining(t *testing.T, newRuntime Factory) { // Runtime.Record. func encodeState(t testing.TB, s *run.MachineState) []byte { t.Helper() - raw, err := run.ProtocolV1.EncodeMachineState(s) + raw, err := run.ProtocolV1().EncodeMachineState(s) if err != nil { t.Fatal(err) } diff --git a/agent/run/snapshot_test.go b/agent/run/snapshot_test.go index b9eab3c..d4f3514 100644 --- a/agent/run/snapshot_test.go +++ b/agent/run/snapshot_test.go @@ -22,11 +22,11 @@ func TestSnapshotCodecRoundTrip(t *testing.T) { if err != nil { t.Fatal(err) } - raw, err := ProtocolV1.EncodeMachineState(&snap.State) + raw, err := ProtocolV1().EncodeMachineState(&snap.State) if err != nil { t.Fatalf("%s: encode: %v", name, err) } - decoded, err := ProtocolV1.DecodeMachineState(raw) + decoded, err := ProtocolV1().DecodeMachineState(raw) if err != nil { t.Fatalf("%s: decode: %v\n%s", name, err, raw) } @@ -60,7 +60,7 @@ func TestSnapshotCodecRejectsMalformedWire(t *testing.T) { if err != nil { t.Fatal(err) } - good, err := ProtocolV1.EncodeMachineState(&initial) + good, err := ProtocolV1().EncodeMachineState(&initial) if err != nil { t.Fatal(err) } @@ -71,11 +71,11 @@ func TestSnapshotCodecRejectsMalformedWire(t *testing.T) { "active without current": strings.Replace(string(good), `"current":"open",`, ``, 1), "trailing data": string(good) + `{}`, } { - if _, err := ProtocolV1.DecodeMachineState([]byte(raw)); err == nil { + if _, err := ProtocolV1().DecodeMachineState([]byte(raw)); err == nil { t.Fatalf("%s: accepted\n%s", name, raw) } } - if _, err := ProtocolV1.DecodeMachineState(good); err != nil { + if _, err := ProtocolV1().DecodeMachineState(good); err != nil { t.Fatalf("canonical wire rejected: %v", err) } } diff --git a/agent/run/sqlitestore/store_test.go b/agent/run/sqlitestore/store_test.go index e41ce76..68572f8 100644 --- a/agent/run/sqlitestore/store_test.go +++ b/agent/run/sqlitestore/store_test.go @@ -98,8 +98,8 @@ func TestSQLiteReopenLoadsSnapshotAndRecordVerifies(t *testing.T) { if err != nil { t.Fatal(err) } - a, _ := run.ProtocolV1.EncodeMachineState(&before.State) - b, _ := run.ProtocolV1.EncodeMachineState(&after.State) + a, _ := run.ProtocolV1().EncodeMachineState(&before.State) + b, _ := run.ProtocolV1().EncodeMachineState(&after.State) if string(a) != string(b) || before.Revision != after.Revision { t.Fatalf("reopened snapshot differs:\n before %s\n after %s", a, b) } @@ -332,7 +332,7 @@ func freezeSpec(t *testing.T, def sdk.ToolDefinition) run.ToolSpec { if err != nil { t.Fatal(err) } - d, err := run.ProtocolV1.DigestToolDefinition(frozen) + d, err := run.ProtocolV1().DigestToolDefinition(frozen) if err != nil { t.Fatal(err) } @@ -345,16 +345,16 @@ func prepareFromSnap(t *testing.T, snap run.RuntimeSnapshot, req sdk.Request, sp if err != nil { t.Fatal(err) } - reqDigest, err := run.ProtocolV1.DigestRequest(frozenReq) + reqDigest, err := run.ProtocolV1().DigestRequest(frozenReq) if err != nil { t.Fatal(err) } - toolsDigest, err := run.ProtocolV1.DigestToolSpecs(specs) + toolsDigest, err := run.ProtocolV1().DigestToolSpecs(specs) if err != nil { t.Fatal(err) } model := run.ModelRef(frozenReq.Model) - binding, err := run.ProtocolV1.DigestModelStepBinding(model, reqDigest, toolsDigest) + binding, err := run.ProtocolV1().DigestModelStepBinding(model, reqDigest, toolsDigest) if err != nil { t.Fatal(err) } @@ -378,7 +378,7 @@ func commit(t *testing.T, rt run.Runtime, runID run.RunID, id run.CommandID, bas case run.StartToolCall: id = run.DeriveStartCommandID(runID, c.StepID, c.CallID, c.Claim) } - env, err := run.ProtocolV1.BuildEnvelope(runID, id, cmd) + env, err := run.ProtocolV1().BuildEnvelope(runID, id, cmd) if err != nil { t.Fatal(err) } diff --git a/agent/run/store_test.go b/agent/run/store_test.go index eafda56..71080fc 100644 --- a/agent/run/store_test.go +++ b/agent/run/store_test.go @@ -23,7 +23,7 @@ func TestExpiredLeaseAllowsGrantlessModelRecovery(t *testing.T) { t.Fatal(err) } in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} - env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) + env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) if err != nil { t.Fatal(err) } @@ -50,7 +50,7 @@ func TestExpiredLeaseAllowsGrantlessModelRecovery(t *testing.T) { } clock = time.Unix(1002, 0) - recoverEnv, err := ProtocolV1.BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, claim), RecoverModelExecution{StepID: prep.StepID, Claim: claim}) + recoverEnv, err := ProtocolV1().BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, claim), RecoverModelExecution{StepID: prep.StepID, Claim: claim}) if err != nil { t.Fatal(err) } @@ -78,7 +78,7 @@ func TestZeroDeadlineRejectsGrantlessRecovery(t *testing.T) { t.Fatal(err) } in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} - env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) + env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) if err != nil { t.Fatal(err) } @@ -95,7 +95,7 @@ func TestZeroDeadlineRejectsGrantlessRecovery(t *testing.T) { if _, err := rt.Commit(ctx, CommitRequest{Command: startEnv}); err != nil { t.Fatal(err) } - recoverEnv, _ := ProtocolV1.BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, claim), RecoverModelExecution{StepID: prep.StepID, Claim: claim}) + recoverEnv, _ := ProtocolV1().BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, claim), RecoverModelExecution{StepID: prep.StepID, Claim: claim}) if _, err := rt.Commit(ctx, CommitRequest{Command: recoverEnv}); err == nil { t.Fatal("grantless recover accepted on non-expiring lease") } @@ -116,7 +116,7 @@ func TestGrantlessModelRecoveryRejectsWrongClaim(t *testing.T) { t.Fatal(err) } in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} - env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) + env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) if err != nil { t.Fatal(err) } @@ -135,7 +135,7 @@ func TestGrantlessModelRecoveryRejectsWrongClaim(t *testing.T) { } clock = time.Unix(1002, 0) wrong := ExecutionClaim("other-claim") - recoverEnv, err := ProtocolV1.BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, wrong), RecoverModelExecution{StepID: prep.StepID, Claim: wrong}) + recoverEnv, err := ProtocolV1().BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, wrong), RecoverModelExecution{StepID: prep.StepID, Claim: wrong}) if err != nil { t.Fatal(err) } @@ -159,7 +159,7 @@ func TestRecoverExpiredRecoversExecutingModel(t *testing.T) { t.Fatal(err) } in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} - env, err := ProtocolV1.BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) + env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) if err != nil { t.Fatal(err) } diff --git a/agent/turn/coordinator_test.go b/agent/turn/coordinator_test.go index 927d54b..58eb5a3 100644 --- a/agent/turn/coordinator_test.go +++ b/agent/turn/coordinator_test.go @@ -21,7 +21,7 @@ type scriptModel struct { calls atomic.Int32 } -func (m *scriptModel) Resolve(run.ModelRef) (loop.ModelInvoker, error) { return m, nil } +func (m *scriptModel) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { return m, nil } func (m *scriptModel) Generate(ctx context.Context, _ sdk.Request) (sdk.ModelResult, error) { if err := ctx.Err(); err != nil { return sdk.ModelResult{}, err @@ -47,14 +47,14 @@ func newEchoTool(t testing.TB, policy run.ResponsePolicy) *echoTool { if err != nil { t.Fatal(err) } - digest, err := run.ProtocolV1.DigestToolDefinition(frozen) + digest, err := run.ProtocolV1().DigestToolDefinition(frozen) if err != nil { t.Fatal(err) } return &echoTool{def: def, spec: run.ToolSpec{Ref: "echo", Definition: frozen, DefinitionDigest: digest, Policy: policy}} } -func (e *echoTool) Resolve(ref run.ToolRef) (loop.ExecutableTool, error) { +func (e *echoTool) ResolveTool(ref run.ToolRef) (loop.ExecutableTool, error) { if ref != "echo" { return nil, fmt.Errorf("unknown tool %q", ref) } @@ -197,7 +197,7 @@ func TestApprovalWaitsThenResumes(t *testing.T) { requireTypes(t, h.types(), []string{turn.EventTurnStarted, turn.EventInputDelivered, turn.EventAssistant}) w := res.Waiting[0] - digest, err := run.ProtocolV1.DigestToolResponseDecision(w.Kind, run.ResponseDecisionApproved, "") + digest, err := run.ProtocolV1().DigestToolResponseDecision(w.Kind, run.ResponseDecisionApproved, "") if err != nil { t.Fatal(err) } @@ -205,7 +205,7 @@ func TestApprovalWaitsThenResumes(t *testing.T) { if err != nil { t.Fatal(err) } - env, err := run.ProtocolV1.BuildEnvelope("run-1", run.DeriveResponseCommandID("run-1", w.StepID, w.CallID, w.ID), + env, err := run.ProtocolV1().BuildEnvelope("run-1", run.DeriveResponseCommandID("run-1", w.StepID, w.CallID, w.ID), run.ApproveToolCall{StepID: w.StepID, CallID: w.CallID, ResponseID: w.ID, ResponseDigest: digest}) if err != nil { t.Fatal(err) diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 60a0d69..de9e63e 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -91,7 +91,7 @@ type TransitionRecord struct { `CommandEnvelope.Digest` 覆盖 schema、type 与完整 command;RunID、CommandID、BaseRevision 和 grant 属于 envelope/commit metadata。`AgentEvent.Digest` 覆盖 schema、type 与完整 fact。`TransitionDigest` 覆盖自身以外的完整 transition,包括有序 event group。 -**RUN-WIR-3** 一个 transition 的 event 数至少为 1;其 RunID、Revision、CommandID、CommandDigest 相同,Index 从 0 连续递增。revision 从 1 连续递增。构造 command 必须使用该 Run 的 `Protocol.BuildEnvelope`(Loop 通过 `RuntimeSnapshot.Protocol()` 取得)。`agent/run` 不提供隐式选择版本的包级 `BuildEnvelope`、`Decide`、`Evolve` 或 `Digest*` 函数;新 Run 与测试显式使用 `ProtocolV1`。构造与验证 transition 必须使用 `BuildTransitionRecord`、`ValidateTransitionRecord`。所有公开返回值具有 detached snapshot 语义。 +**RUN-WIR-3** 一个 transition 的 event 数至少为 1;其 RunID、Revision、CommandID、CommandDigest 相同,Index 从 0 连续递增。revision 从 1 连续递增。构造 command 必须使用该 Run 的 `Protocol.BuildEnvelope`(Loop 通过 `RuntimeSnapshot.Protocol()` 取得)。`agent/run` 不提供隐式选择版本的包级 `BuildEnvelope`、`Decide`、`Evolve` 或 `Digest*` 函数;新 Run 与测试显式使用 `ProtocolV1()`。构造与验证 transition 必须使用 `BuildTransitionRecord`、`ValidateTransitionRecord`。所有公开返回值具有 detached snapshot 语义。 下列 identity 稳定派生并由 Commit 验证: @@ -224,7 +224,7 @@ func (RunFailedEnd) runEnd() {} type RunEnded struct { End RunEnd } ``` -`RunEnded.End` 必须恰好是上述三个 variant 之一;`RunStoppedEnd.Reason` 必须非空,`RunFailedEnd.Reason` 必须是失败原因,`RunFailedEnd.Failure.Class` 必须非空。`RunEnded` 是 terminal transition 的最后一个 fact。RunStatus、RunResult 等读取模型从该 union 派生。当前 v1 wire body 使用 `status/reason/failure` 以及可选的 `uncertainCalls` / `uncertainModel`;codec 负责在 wire 与 union 之间做严格映射,并拒绝 `RunActive`、缺失字段或多余字段。Cancel 时仍 Executing 的 tool call 与 model step 必须写入 `RunStoppedEnd` 并投影到 `RunResult`,不得只留下 `stopped/cancelled`。 +`RunEnded.End` 必须恰好是上述三个 variant 之一;`RunStoppedEnd.Reason` 必须非空,`RunFailedEnd.Reason` 必须是失败原因,`RunFailedEnd.Failure.Class` 必须非空。`RunEnded` 是 terminal transition 的最后一个 fact。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`,不得只留下 `stopped/cancelled`。 ```text ModelStep: Prepared -> Executing -> Completed @@ -345,7 +345,7 @@ func (Protocol) BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (Comman func (Protocol) EncodeMachineState(*MachineState) ([]byte, error) // 持久化 snapshot bytes,含 Current func (Protocol) DecodeMachineState([]byte) (MachineState, error) func (Protocol) ValidateHeader(*RunHeader) error -var ProtocolV1 Protocol // SchemaVersion1 绑定;新 Run 的创建版本。没有委托它的包级函数 +func ProtocolV1() Protocol // SchemaVersion1 绑定;新 Run 的创建版本。没有委托它的包级函数 type CommitRequest struct { BaseRevision uint64 Grant ExecutionGrant @@ -366,7 +366,7 @@ type CommitResult struct { 所有 Runtime implementation 在自己的 critical section/transaction 内调用同一个 pure `EvaluateCommit`。顺序固定为: ```text -1 validate envelope RunID/schema/type/digest +1 validate envelope RunID/schema/type/digest(digest 不匹配为不可重试错误,不映射为 conflict) 2 lookup prior transition by CommandID 3 exact digest replay -> AlreadyApplied + original complete event group 4 same CommandID/different digest -> conflict @@ -387,7 +387,7 @@ type CommitResult struct { **RUN-CMT-6** Commit 必须原子保存新 MachineState 与完整 TransitionRecord,保证 event group 完整写入。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 -**RUN-CMT-7** 每个 Run 的协议版本是 `RunHeader.SchemaVersion`,在 Create 时冻结。`RuntimeSnapshot.SchemaVersion` 必须等于该 header。`ProtocolFor(header.SchemaVersion)` 在 Run 边界返回绑定了该版本 digest/decode/Decide/Evolve 函数的 `Protocol` 值;随后的方法调用不再接受 version 参数。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Protocol 的 Version。`agent/run` 不保存进程全局的当前写入版本,也不提供隐式选择版本的包级函数;新 Run 由 `NewRun.SchemaVersion` 决定版本。v1 Run 的 replay 必须继续使用 `ProtocolV1`,即使进程已支持更高版本。Loop 通过 `RuntimeSnapshot.Protocol().BuildEnvelope` 构造写入该 Run 的 envelope。 +**RUN-CMT-7** 每个 Run 的协议版本是 `RunHeader.SchemaVersion`,在 Create 时冻结。`RuntimeSnapshot.SchemaVersion` 必须等于该 header。`ProtocolFor(header.SchemaVersion)` 在 Run 边界返回绑定了该版本 digest/decode/Decide/Evolve 函数的 `Protocol` 值;随后的方法调用不再接受 version 参数。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Protocol 的 Version。`agent/run` 不保存进程全局的当前写入版本,也不提供隐式选择版本的包级函数;新 Run 由 `NewRun.SchemaVersion` 决定版本。v1 Run 的 replay 必须继续使用 `ProtocolV1()`,即使进程已支持更高版本。Loop 通过 `RuntimeSnapshot.Protocol().BuildEnvelope` 构造写入该 Run 的 envelope。 `Runtime` 的唯一实现叠在 `Store` 上。`Store` 是追加式合同: @@ -438,10 +438,10 @@ type RequestPlan struct { PlanningToken run.PlanningToken Tools []run.ToolSpec } -type ModelCatalog interface { Resolve(run.ModelRef) (ModelInvoker, error) } +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 { Resolve(run.ToolRef) (ExecutableTool, error) } +type ToolCatalog interface { ResolveTool(run.ToolRef) (ExecutableTool, error) } type ExecutableTool interface { Ref() run.ToolRef Definition() sdk.ToolDefinition @@ -486,7 +486,7 @@ func (*Loop) Run(context.Context, run.Runtime, run.RunID, EventSink) (LoopResult **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`。`LeaseRenewInterval` 是 worker 续期间隔(RUN-CMT-8):模型与工具 worker 在效果执行期间按该间隔调用 `Runtime.RenewLease`,续期被拒时取消该 worker 的 ctx;零值关闭续期,只对 lease 不超时的 Runtime 正确。 -**RUN-LOP-7** `ModelRef` 是冻结请求中的执行身份。`ModelCatalog.Resolve` 在同一 Run 生命周期内必须把同一 `ModelRef` 解析为等价的执行语义。provider 绑定不进入 frozen request,因此 Catalog 不得把同一 ref 改绑到不同实现。 +**RUN-LOP-7** `ModelRef` 是冻结请求中的执行身份。`ModelCatalog.ResolveModel` 在同一 Run 生命周期内必须把同一 `ModelRef` 解析为等价的执行语义。provider 绑定不进入 frozen request,因此 Catalog 不得把同一 ref 改绑到不同实现。 `LoopResult` 的语义固定为:`LoopWaiting` 时 `Result` 为 nil,表示没有可执行 effect、Run 仍为 active。`ExecutionRecovery` 等于 `NeedsRecovery(state)`:Model Executing,或 ToolStep 无 Pending 且仍有 Executing。该值为 true 时由 recovery authority 唤醒,`Reason` 为 `execution_recovery`;否则 `Reason` 为空。Waiting call 不进入 `LoopResult`;Application 通过 `Runtime.Load` / `Record` 与 `WaitingCalls` 读取。`LoopFinished` 时 `Result` 非 nil,并等于 terminal RunRecord 派生的 `RunResult`。 @@ -510,7 +510,7 @@ Loop.Run(ctx, runtime, runID, sink): **RUN-LOP-2** `NeedModelRequest` 调用 Planner,冻结 sdk.Request,验证 model、ordered InputIDs 与 ToolSpecs,计算 request/tools/binding digests 和 derived CommandID/StepID,再提交 Prepare。prepare stale 后重新 Load;同 revision 的内容拒绝不得 livelock 重试。业务停止统一使用 `CancelRun`。 -**RUN-LOP-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须在 `ClaimStore` 中保留该 attempt 的 claim 直到完成 settlement,其余 identity 按需派生;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。streaming 与 non-streaming 必须产生同一种完整 `sdk.ModelResult`;delta 只发 EventSink。`ModelCatalog.Resolve` 失败或返回 nil 时提交 `RecoverModelExecution` 并返回错误,不得把 Run 记为 `provider_failure`:尚未发生模型调用。provider 调用失败提交 `SubmitModelFailure`;ctx cancellation 提交 `RecoverModelExecution`;结构、binding 或 freeze 失败提交 `RejectModelResult`,并由调用方显式选择 retry 或 fail-run。成功结果只提交一次 `SubmitModelResult`。 +**RUN-LOP-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须在 `ClaimStore` 中保留该 attempt 的 claim 直到完成 settlement,其余 identity 按需派生;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call,在外层 ctx 未取消时于本次 `Run` 内按冻结 Scheduling 分批 Start 并结算;ctx 已取消时停止再 Start,只结算已持有 grant 的 call。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `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;Executing 且本进程持有 start cache 时只重放 start 并结算,不得再提交 grantless Known。 @@ -542,7 +542,7 @@ type Event struct { ## 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、字段或折叠语义必须进入新 schema version,并继续 decode/fold 全部已发布版本。同一 Run 的 writer 不得混写不兼容 schema。 +**RUN-CMP-1** 当前 pre-release schema v1 的 command/fact discriminator、wire fields、canonical digest、derived ID 和 `ProtocolV1().Evolve` 由 golden fixtures 保护;发布前有意修改协议时必须同步更新 fixture。v1 发布后,新增 variant、字段或折叠语义必须进入新 schema version,并继续 decode/fold 全部已发布版本。同一 Run 的 writer 不得混写不兼容 schema。 **RUN-CMP-2** Runtime conformance 必须覆盖: @@ -561,7 +561,7 @@ Loop conformance 必须覆盖: - 单模型完成、tool round trip、approval/external response wait/resume; - known failure 继续、Unknown 继续、tool panic、aliased ToolRef 与 validation; - parallel/sequential 按冻结 `ToolStep.Scheduling` 调度,不得改用当时 ExecutionPolicy; -- `ModelCatalog.Resolve` 失败或 nil 时恢复 ModelStep、Run 保持 active; +- `ModelCatalog.ResolveModel` 失败或 nil 时恢复 ModelStep、Run 保持 active; - ctx cancellation、model recovery、explicit malformed-result disposition; - Cancel 将 Executing tool/model 投影到 `UncertainCalls` / `UncertainModel`;ExternalResponse reject 为 `response_rejected`; - streaming delta 与 nil result、EventSink committed observation; diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 5aab4a1..7701bef 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -124,6 +124,15 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + session mo - 逐步把 `bot_history_messages` 降为兼容 read model; - 在完整 materialization、terminal settlement 与 retention closure 后执行归档/GC。 +## 4.3 Run 内部整理(已完成) + +- envelope digest 不匹配从 `ErrCommandConflict` 改为不可重试错误,调用方不再对构造错误 reload 重试; +- Evolve 对重复 `InputAccepted` 报错而非静默去重; +- `decideSubmitModelResult` 拆为 binding 校验与 ToolStep 派生两步; +- `RunEnded` wire 改为 tagged union,与 Go sealed union 对称; +- `ModelCatalog.ResolveModel` / `ToolCatalog.ResolveTool`,一个类型可同时实现两者; +- `ProtocolV1` 改为函数,不可被重新赋值。 + ## 4.4 sdk.Request 作为冻结类型的评估 结论:请求层保留 `run.ModelRequest` 镜像,但把镜像的理由收窄到具体字段;消息层与结果层必须保留镜像。 From 015779a6eb14cedb2f3a3e3390c55db5360c65ca Mon Sep 17 00:00:00 2001 From: Fodesu Date: Thu, 3 Sep 2026 01:35:54 +0800 Subject: [PATCH 59/81] Derive CallID from (ModelStepID, index); keep the provider id as ProviderCallID The Run's CallID is now DeriveCallID(source, index): an authority-owned identity that enters facts, lease keys, derived CommandIDs and chatlog, and cannot collide across ModelSteps or depend on provider behaviour. The model's tool_call_id rides along as ToolCallBinding.ProviderCallID / ToolCallState.ProviderCallID, used only when a Planner echoes the call and its result back to the model. Decide enforces both: CallID must be the derived value, ProviderCallID must match the result at that index. Empty or repeated provider ids (vLLM/llama.cpp style call_0, prompt-parsed tools) no longer reject the result. - loop.bindToolCalls derives ids and no longer treats duplicate provider ids as malformed; invalid UTF-8 input remains the malformed path. - turn.MapTransition takes the record prefix so tool_result events carry the ProviderCallID from ToolStepOpened; payloads gain providerCallId. - Planners in example, runtest and live test echo ProviderCallID. - Test fixtures build bindings by (step, index, providerID); runtest resolves provider ids to derived CallIDs. New regression test covers provider id reuse and a forged non-derived CallID. - v1 event-stream golden re-frozen (pre-release). Spec identity table, RUN-MCH-2 and TRN-MAP-2 updated. - live_test: full payload logging, TWILIGHT_LIVE_RECORD_OUT dumps the RunRecord, 6 minute deadline. Co-Authored-By: Claude Fable 5.1 --- agent/run/decide.go | 14 +- agent/run/evolve.go | 5 +- agent/run/example_run_test.go | 20 ++- agent/run/fold_test.go | 10 +- agent/run/ids.go | 8 + agent/run/loop/loop_test.go | 19 ++- agent/run/loop/model.go | 22 +-- agent/run/machine_test.go | 95 ++++++++--- agent/run/regression_test.go | 12 +- agent/run/runtest/approval_test.go | 4 +- agent/run/runtest/feature.go | 28 +++- agent/run/runtest/require.go | 14 ++ agent/run/runtimetest/conformance.go | 46 +++--- agent/run/runtimetest/recovery.go | 12 +- agent/run/snapshot_test.go | 6 +- agent/run/sqlitestore/store_test.go | 7 +- agent/run/state.go | 6 +- agent/turn/coordinator.go | 5 +- agent/turn/coordinator_test.go | 2 +- agent/turn/live_test.go | 237 +++++++++++++++++++++++++++ agent/turn/mapper.go | 34 +++- agent/turn/turn.go | 20 ++- docs/design/agent-run.md | 3 +- docs/design/agent-turn.md | 2 +- 24 files changed, 490 insertions(+), 141 deletions(-) create mode 100644 agent/turn/live_test.go diff --git a/agent/run/decide.go b/agent/run/decide.go index 3d50cd6..74a4f6e 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -213,11 +213,11 @@ func checkToolCallBindings(ms *ModelStep, cmd *SubmitModelResult) ([]ToolCallBin for i := range cmd.Calls { b := cmd.Calls[i] rc := &cmd.Result.ToolCalls[i] - if b.CallID == "" { - return nil, rejectionf("model result: binding %d has empty CallID", 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 string(b.CallID) != rc.ToolCallID { - return nil, rejectionf("model result: binding %d CallID %q does not match result call %q", i, b.CallID, rc.ToolCallID) + 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) @@ -582,11 +582,11 @@ func unknownExecutingCalls(s *MachineState, failure ToolFailure) []Fact { return nil } facts := make([]Fact, 0, len(ts.Calls)) - for _, call := range ts.Calls { - if call.Status != ToolExecuting { + for i := range ts.Calls { + if ts.Calls[i].Status != ToolExecuting { continue } - facts = append(facts, ToolCallFailed{StepID: ts.RefValue.ID, CallID: call.CallID, Failure: failure, Outcome: ToolOutcomeUnknown}) + facts = append(facts, ToolCallFailed{StepID: ts.RefValue.ID, CallID: ts.Calls[i].CallID, Failure: failure, Outcome: ToolOutcomeUnknown}) } return facts } diff --git a/agent/run/evolve.go b/agent/run/evolve.go index d9368fd..f1ba8ba 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -96,6 +96,7 @@ func applyToolStepOpened(s MachineState, fact *ToolStepOpened) MachineState { for i, b := range fact.Calls { calls[i] = ToolCallState{ CallID: b.CallID, + ProviderCallID: b.ProviderCallID, ToolRef: b.ToolRef, DefinitionDigest: b.DefinitionDigest, BindingDigest: b.BindingDigest, @@ -416,8 +417,8 @@ func allToolCallsTerminal(calls []ToolCallState) bool { if len(calls) == 0 { return false } - for _, call := range calls { - if call.Status != ToolCompleted && call.Status != ToolFailed { + for i := range calls { + if !calls[i].Status.Terminal() { return false } } diff --git a/agent/run/example_run_test.go b/agent/run/example_run_test.go index 28ff515..2941b23 100644 --- a/agent/run/example_run_test.go +++ b/agent/run/example_run_test.go @@ -116,7 +116,7 @@ func Example_recoverableRun() { } call := snap.State.LastToolStep.Calls[0] fmt.Printf("recovered %d lease: call %s is %s (%s), run %s\n", - recovered, call.CallID, call.Status, call.Failure.Failure.Class, statusName(snap.State.Status)) + recovered, call.ProviderCallID, call.Status, call.Failure.Failure.Class, statusName(snap.State.Status)) loop2, err := loop.New(app.models(), app.tools(), app, loop.ExecutionPolicy{LeaseRenewInterval: 5 * time.Second}, false) if err != nil { @@ -147,19 +147,21 @@ func Example_recoverableRun() { // record: 9 transitions fold to the stored snapshot } -// waitForExecutingCall polls Load until callID on the current ToolStep is -// Executing, which is the point at which a start has been accepted and its -// lease is live. -func waitForExecutingCall(ctx context.Context, rt run.Runtime, runID run.RunID, callID run.CallID) { +// waitForExecutingCall polls Load until the call the model issued under +// providerID is Executing, which is the point at which a start has been +// accepted and its lease is live. +func waitForExecutingCall(ctx context.Context, rt run.Runtime, runID run.RunID, providerID string) { deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { snap, err := rt.Load(ctx, runID) if err != nil { panic(err) } - for _, id := range run.ExecutingCalls(snap.State) { - if id == callID { - return + if ts, ok := snap.State.Current.(run.ToolStep); ok { + for _, call := range ts.Calls { + if call.ProviderCallID == providerID && call.Status == run.ToolExecuting { + return + } } } time.Sleep(2 * time.Millisecond) @@ -241,7 +243,7 @@ func (a *exampleApp) Plan(_ context.Context, hint run.PlanningHint) (loop.Reques outcome = call.Failure.Failure.Class } messages = append(messages, sdk.ToolMessage(sdk.ToolResultPart{ - ToolCallID: string(call.CallID), ToolName: string(call.ToolRef), Result: outcome, + ToolCallID: call.ProviderCallID, ToolName: string(call.ToolRef), Result: outcome, })) } } diff --git a/agent/run/fold_test.go b/agent/run/fold_test.go index 65ee553..3ae2e5f 100644 --- a/agent/run/fold_test.go +++ b/agent/run/fold_test.go @@ -20,7 +20,7 @@ func fullRunRuntime(t *testing.T) Runtime { def := testToolDef("t") spec := makeSpec(t, def, DirectExecution) rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) - b := makeBinding(t, "c1", spec, `{}`) + b := makeBinding(t, stepID, 0, "c1", spec, `{}`) snap, err := rt.Load(context.Background(), "run-1") if err != nil { t.Fatal(err) @@ -28,9 +28,9 @@ func fullRunRuntime(t *testing.T) Runtime { res := mustCommit(t, rt, "complete-1", snap.Revision, grant, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) toolStep := res.Events[1].Fact.(ToolStepOpened).StepID - sRes := mustCommit(t, rt, "start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: "c1"}) + sRes := mustCommit(t, rt, "start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: cid(stepID, 0)}) mustCommit(t, rt, "done-c1", sRes.Snapshot.Revision, sRes.Grant, - SubmitToolResult{StepID: toolStep, CallID: "c1", Result: ToolExecutionResult{Output: cj(`"ok"`)}}) + SubmitToolResult{StepID: toolStep, CallID: cid(stepID, 0), Result: ToolExecutionResult{Output: cj(`"ok"`)}}) snap, err = rt.Load(context.Background(), "run-1") if err != nil { t.Fatal(err) @@ -262,7 +262,9 @@ func TestGoldenEventStreamV1(t *testing.T) { t.Fatal(err) } got := string(sha256Digest(stateBytes)) - const frozen = "sha256:b76adb269cc9821c6415ab93b355725fbd0054cb5a0ca26af7ab937648708d25" + // Pre-release fixture; re-frozen when CallID became derived and + // providerCallId joined the binding. + const frozen = "sha256:d3d3586e173be5be4bc7889993fa9c3456d33a58e354ad160cd89609aae9fe90" if got != frozen { t.Fatalf("golden v1 state digest changed:\n got %s\nwant %s\nstate: %s", got, frozen, stateBytes) } diff --git a/agent/run/ids.go b/agent/run/ids.go index 55c18db..2947cde 100644 --- a/agent/run/ids.go +++ b/agent/run/ids.go @@ -62,6 +62,14 @@ 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 { diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go index 75a067f..25af86a 100644 --- a/agent/run/loop/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -272,7 +272,8 @@ func (staleCommitRuntime) Commit(context.Context, CommitRequest) (CommitResult, func TestToolStartStaleDropsLocalClaim(t *testing.T) { spec := toolSpec(t, "echo", DirectExecution) args := cj(`{}`) - bindingDigest, err := DigestToolCallBinding("c1", spec.DefinitionDigest, spec.Policy, args) + callID := DeriveCallID("model-1", 0) + bindingDigest, err := DigestToolCallBinding(callID, spec.DefinitionDigest, spec.Policy, args) if err != nil { t.Fatal(err) } @@ -292,17 +293,17 @@ func TestToolStartStaleDropsLocalClaim(t *testing.T) { RefValue: StepRef{RunID: "run-1", ID: stepID, Digest: Digest("sha256:step")}, Source: "model-1", Calls: []ToolCallState{{ - CallID: "c1", ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, + CallID: callID, ProviderCallID: "c1", ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, BindingDigest: bindingDigest, Arguments: args, Policy: DirectExecution, Status: ToolPending, }}, }, }, Revision: 1, SchemaVersion: SchemaVersion1} if err := loop.runToolCalls(context.Background(), staleCommitRuntime{}, nil, snapshot, - StartToolCalls{StepID: stepID, CallIDs: []CallID{"c1"}}); err != nil { + StartToolCalls{StepID: stepID, CallIDs: []CallID{callID}}); err != nil { t.Fatal(err) } - if _, ok, _ := loop.Claims.Get(context.Background(), "run-1", stepID, "c1"); ok { + if _, ok, _ := loop.Claims.Get(context.Background(), "run-1", stepID, callID); ok { t.Fatal("stale tool start retained a local execution claim") } } @@ -402,13 +403,13 @@ func TestLoopReplaysSettlementWithoutRepeatingTool(t *testing.T) { 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: "dup", ToolName: "echo", Input: `{"x":1}`}, - {ToolCallID: "dup", ToolName: "echo", Input: `{"x":2}`}, - }, - Usage: sdk.Usage{TotalTokens: 1}, + 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{ diff --git a/agent/run/loop/model.go b/agent/run/loop/model.go index e5ff2f5..00733a8 100644 --- a/agent/run/loop/model.go +++ b/agent/run/loop/model.go @@ -257,25 +257,21 @@ func (l *Loop) bindToolCalls(result *sdk.ModelResult, step *run.ModelStep) ([]ru for _, s := range step.Tools { specByName[s.Definition.Name] = s } - seen := make(map[string]bool, len(result.ToolCalls)) bindings := make([]run.ToolCallBinding, len(result.ToolCalls)) for i, tc := range result.ToolCalls { - if tc.ToolCallID == "" { - return nil, fmt.Errorf("tool call %d has an empty id", i) - } - if seen[tc.ToolCallID] { - return nil, fmt.Errorf("duplicate tool call id %q", tc.ToolCallID) - } - seen[tc.ToolCallID] = true args, err := run.FreezeToolCallInput(tc.Input) if err != nil { - return nil, fmt.Errorf("tool call %q input: %w", tc.ToolCallID, err) + 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.CallID(tc.ToolCallID), - ToolRef: run.ToolRef(tc.ToolName), - Arguments: args, - Policy: run.DirectExecution, + 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 diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go index 70cc0f9..d81108d 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -123,15 +123,19 @@ func responsePayloadDigest(t *testing.T, payload CanonicalJSON) Digest { return d } -func makeBinding(t *testing.T, callID string, spec ToolSpec, args string) ToolCallBinding { +// 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) - bd, err := digestToolCallBinding(CallID(callID), spec.DefinitionDigest, spec.Policy, parsedArgs) + callID := DeriveCallID(source, index) + bd, err := digestToolCallBinding(callID, spec.DefinitionDigest, spec.Policy, parsedArgs) if err != nil { t.Fatal(err) } return ToolCallBinding{ - CallID: CallID(callID), + CallID: callID, + ProviderCallID: providerID, ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, BindingDigest: bd, @@ -140,6 +144,9 @@ func makeBinding(t *testing.T, callID string, spec ToolSpec, args string) ToolCa } } +// 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...) } @@ -232,7 +239,7 @@ func TestModelCompleteWithToolsOpensToolStep(t *testing.T) { s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) - b := makeBinding(t, "c1", spec, `{"x":1}`) + 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)) @@ -262,16 +269,16 @@ func TestExternalResponseRequiresPayloadDigest(t *testing.T) { spec := makeSpec(t, def, ExternalResponse) s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) - b := makeBinding(t, "c1", 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: "c1", ResponseID: respID, ResponseDigest: "sha256:bad", Payload: payload}); err == nil { + 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: "c1", ResponseID: respID, + 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)) @@ -279,12 +286,12 @@ func TestExternalResponseRequiresPayloadDigest(t *testing.T) { s = newRun(t) s, stepID = advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) - b = makeBinding(t, "c1", 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: "c1", ResponseID: respID, + 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) @@ -304,7 +311,7 @@ func TestToolSchedulingFrozenOnToolStepOpened(t *testing.T) { spec := makeSpec(t, def, DirectExecution) s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) - b := makeBinding(t, "c1", 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}, @@ -321,7 +328,7 @@ func TestToolSchedulingRejectsUnknownMode(t *testing.T) { spec := makeSpec(t, def, DirectExecution) s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) - b := makeBinding(t, "c1", 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"}, @@ -338,8 +345,8 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(defA, defB), []ToolSpec{specA, specB}) - bA := makeBinding(t, "cA", specA, `{}`) - bB := makeBinding(t, "cB", 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}, @@ -360,13 +367,13 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { t.Fatal(err) } start, ok := eff.(StartToolCalls) - if !ok || len(start.CallIDs) != 1 || start.CallIDs[0] != "cB" { + 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: "cB"})) - facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: "cB", Result: ToolExecutionResult{Output: cj(`"ok"`)}}) + 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)) } @@ -379,17 +386,17 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { 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 != "cA" { + 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: "cA", ResponseID: respID, + 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: "cA"})) - facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: "cA", Result: ToolExecutionResult{Output: cj(`"done"`)}}) + 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)) } @@ -406,8 +413,8 @@ func TestUnknownToolFailureSettlesOnlyThatCall(t *testing.T) { s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(defA, defB), []ToolSpec{specA, specB}) - bA := makeBinding(t, "cA", specA, `{}`) - bB := makeBinding(t, "cB", 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}, @@ -422,12 +429,12 @@ func TestUnknownToolFailureSettlesOnlyThatCall(t *testing.T) { 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: "cA"})) - s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: "cB"})) + 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: "cA", + CallID: cid(stepID, 0), Failure: ToolFailure{Class: FailureEffectUnknown, Message: "lost"}, Outcome: ToolOutcomeUnknown, }) @@ -435,7 +442,7 @@ func TestUnknownToolFailureSettlesOnlyThatCall(t *testing.T) { t.Fatalf("facts = %d, want [ToolCallFailed]", len(facts)) } failed := facts[0].(ToolCallFailed) - if failed.CallID != "cA" || failed.Outcome != ToolOutcomeUnknown { + if failed.CallID != cid(stepID, 0) || failed.Outcome != ToolOutcomeUnknown { t.Fatalf("failed = %+v", failed) } s = fold(t, s, facts) @@ -451,7 +458,7 @@ func TestUnknownToolFailureSettlesOnlyThatCall(t *testing.T) { } s = fold(t, s, mustDecide(t, s, SubmitToolResult{ - StepID: opened.StepID, CallID: "cB", Result: ToolExecutionResult{Output: cj(`"ok"`)}, + 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) @@ -609,3 +616,37 @@ func TestEvolveRejectsModelPrepareOverCurrentStep(t *testing.T) { 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/regression_test.go b/agent/run/regression_test.go index 8916697..9de9b2e 100644 --- a/agent/run/regression_test.go +++ b/agent/run/regression_test.go @@ -19,13 +19,13 @@ func TestRegressionBindingMustMatchModelResult(t *testing.T) { s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(safe, danger), []ToolSpec{specSafe, specDanger}) - evil := makeBinding(t, "c1", specDanger, `{"rm":"-rf"}`) + 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, "c1", specSafe, `{"a":999}`) + 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") } @@ -36,7 +36,7 @@ func TestRegressionToolStepIDReproducible(t *testing.T) { spec := makeSpec(t, def, ApprovalRequired) s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) - b := makeBinding(t, "c1", 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 { @@ -54,15 +54,15 @@ func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { spec := makeSpec(t, def, DirectExecution) s := newRun(t) s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) - b := makeBinding(t, "c1", 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: "c1"})) + s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: cid(stepID, 0)})) _, err := ProtocolV1().Evolve(s, ToolCallFailed{ StepID: opened.StepID, - CallID: "c1", + CallID: cid(stepID, 0), Failure: ToolFailure{Class: FailureExecution}, Outcome: ToolOutcomeUnknown, }) diff --git a/agent/run/runtest/approval_test.go b/agent/run/runtest/approval_test.go index 808dd9d..4f7c5b1 100644 --- a/agent/run/runtest/approval_test.go +++ b/agent/run/runtest/approval_test.go @@ -47,9 +47,7 @@ func TestApprovalYieldsAfterDirectExecution(t *testing.T) { f.Model(runtest.Calls(runtest.Call("ask", "cA"), runtest.Call("work", "cB")), runtest.Text("after")) f.Run() f.RequireWaiting(run.ResponseApproval) - if got := f.Waiting().CallID; got != "cA" { - t.Fatalf("waiting = %s, want cA", got) - } + f.RequireWaitingProvider("cA") f.RequireRan("work") f.RequireNotRan("ask") f.Approve() diff --git a/agent/run/runtest/feature.go b/agent/run/runtest/feature.go index c164d62..8e7244d 100644 --- a/agent/run/runtest/feature.go +++ b/agent/run/runtest/feature.go @@ -230,7 +230,27 @@ func (f *Feature) ExecutingModel() *Feature { return f } -// ExecutingTool leaves the named tool call Executing (no Loop). +// 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 @@ -246,6 +266,8 @@ func (f *Feature) ExecutingTool(name string, callID run.CallID) *Feature { 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 { @@ -255,7 +277,7 @@ func (f *Feature) ExecutingTool(name string, callID run.CallID) *Feature { FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{TotalTokens: 2}, ToolCalls: []sdk.ToolCall{{ - ToolCallID: string(callID), ToolName: string(spec.Ref), Input: `{"x":1}`, + ToolCallID: providerID, ToolName: string(spec.Ref), Input: `{"x":1}`, }}, }) if err != nil { @@ -265,7 +287,7 @@ func (f *Feature) ExecutingTool(name string, callID run.CallID) *Feature { StepID: f.modelStepID, Result: frozen, Calls: []run.ToolCallBinding{{ - CallID: callID, ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, + CallID: callID, ProviderCallID: providerID, ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, BindingDigest: binding, Arguments: args, Policy: spec.Policy, }}, }, f.modelGrant) diff --git a/agent/run/runtest/require.go b/agent/run/runtest/require.go index 392c0d9..3b023ae 100644 --- a/agent/run/runtest/require.go +++ b/agent/run/runtest/require.go @@ -23,6 +23,15 @@ func (f *Feature) RequireWaiting(kind run.ResponseKind) { } } +// 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 with the model text. func (f *Feature) RequireCompleted(text string) { f.t.Helper() @@ -81,6 +90,7 @@ func (f *Feature) RequireOpen() { // 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) @@ -143,6 +153,7 @@ func (f *Feature) RequireUsage(total int) { // RequirePlannerSawTool checks the next Plan received the completed call output. func (f *Feature) RequirePlannerSawTool(callID run.CallID, output string) { f.t.Helper() + callID = f.callByProvider(string(callID)) if f.planner == nil || f.planner.lastHint.LastToolStep == nil { f.t.Fatal("planner has no LastToolStep") } @@ -157,6 +168,7 @@ func (f *Feature) RequirePlannerSawTool(callID run.CallID, output string) { // 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 { @@ -181,6 +193,7 @@ func (f *Feature) RequireFailureClass(class string) { // 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) @@ -213,6 +226,7 @@ func (f *Feature) RequireNoUncertain() { // 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") diff --git a/agent/run/runtimetest/conformance.go b/agent/run/runtimetest/conformance.go index 7ba684e..60877ae 100644 --- a/agent/run/runtimetest/conformance.go +++ b/agent/run/runtimetest/conformance.go @@ -233,15 +233,19 @@ func makeSpec(t testing.TB, def sdk.ToolDefinition) run.ToolSpec { return run.ToolSpec{Ref: run.ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: run.DirectExecution} } -func makeBinding(t testing.TB, callID string, spec *run.ToolSpec) run.ToolCallBinding { +// makeBinding builds the binding for the index-th call of source whose +// provider id is providerID; tests address the call by run.DeriveCallID. +func makeBinding(t testing.TB, source run.StepID, index int, providerID string, spec *run.ToolSpec) run.ToolCallBinding { t.Helper() parsedArgs := mustJSON(`{}`) - bd, err := run.DigestToolCallBinding(run.CallID(callID), spec.DefinitionDigest, spec.Policy, parsedArgs) + callID := run.DeriveCallID(source, index) + bd, err := run.DigestToolCallBinding(callID, spec.DefinitionDigest, spec.Policy, parsedArgs) if err != nil { t.Fatal(err) } return run.ToolCallBinding{ - CallID: run.CallID(callID), + CallID: callID, + ProviderCallID: providerID, ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, BindingDigest: bd, @@ -411,7 +415,7 @@ func testRecordReplayAndIndex(t *testing.T, newRuntime Factory) { def := toolDef("t") spec := makeSpec(t, def) c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []run.ToolSpec{spec}) - b := makeBinding(t, "c1", &spec) + b := makeBinding(t, stepID, 0, "c1", &spec) res := c.mustCommit("complete-1", 2, grant, run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) if len(res.Events) != 2 { @@ -429,9 +433,9 @@ func testRecordReplayAndIndex(t *testing.T, newRuntime Factory) { } } toolStep := openedToolStepID(t, &res) - sRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: "c1"}) + sRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0)}) c.mustCommit("done-c1", sRes.Snapshot.Revision, sRes.Grant, - run.SubmitToolResult{StepID: toolStep, CallID: "c1", Result: run.ToolExecutionResult{Output: mustJSON(`"ok"`)}}) + run.SubmitToolResult{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0), Result: run.ToolExecutionResult{Output: mustJSON(`"ok"`)}}) foldedState, lastRev, err := run.FoldEvents(c.initial, c.events) if err != nil { t.Fatalf("FoldEvents: %v", err) @@ -788,8 +792,8 @@ func testCallLocalRebase(t *testing.T, newRuntime Factory) { specB := makeSpec(t, defB) c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{defA, defB}, []run.ToolSpec{specA, specB}) - bA := makeBinding(t, "cA", &specA) - bB := makeBinding(t, "cB", &specB) + bA := makeBinding(t, stepID, 0, "cA", &specA) + bB := makeBinding(t, stepID, 1, "cB", &specB) r, err := run.FreezeModelResult(sdk.ModelResult{ FinishReason: sdk.FinishReasonToolCalls, ToolCalls: []sdk.ToolCall{ @@ -805,17 +809,17 @@ func testCallLocalRebase(t *testing.T, newRuntime Factory) { toolStep := openedToolStepID(t, &res) base := res.Snapshot.Revision - startA := c.mustCommit("start-A", base, "", run.StartToolCall{StepID: toolStep, CallID: "cA"}) - startB := c.mustCommit("start-B", base, "", run.StartToolCall{StepID: toolStep, CallID: "cB"}) + startA := c.mustCommit("start-A", base, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0)}) + startB := c.mustCommit("start-B", base, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 1)}) if startB.Status != run.CommitAccepted || startB.Grant == "" { t.Fatal("stale-base start of an untouched Pending call must rebase") } doneA := c.mustCommit("done-A", base, startA.Grant, - run.SubmitToolResult{StepID: toolStep, CallID: "cA", Result: run.ToolExecutionResult{Output: mustJSON(`1`)}}) + run.SubmitToolResult{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0), Result: run.ToolExecutionResult{Output: mustJSON(`1`)}}) if doneA.Status != run.CommitAccepted { t.Fatal("owner completion on stale base must rebase") } - _, err = c.commit("start-A2", base, "", run.StartToolCall{StepID: toolStep, CallID: "cA"}) + _, err = c.commit("start-A2", base, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0)}) if !errors.Is(err, run.ErrStaleRuntime) { t.Fatalf("restart of settled call err = %v, want ErrStaleRuntime", err) } @@ -833,13 +837,13 @@ func testCancelAfterUnknown(t *testing.T, newRuntime Factory) { def := toolDef("t") spec := makeSpec(t, def) c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []run.ToolSpec{spec}) - b := makeBinding(t, "c1", &spec) + b := makeBinding(t, stepID, 0, "c1", &spec) opened := c.mustCommit("complete-1", 2, grant, run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) toolStep := openedToolStepID(t, &opened) - startRes := c.mustCommit("start-c1", opened.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: "c1"}) + startRes := c.mustCommit("start-c1", opened.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0)}) unknown := c.mustCommit("unk-1", startRes.Snapshot.Revision, startRes.Grant, - run.SubmitToolFailure{StepID: toolStep, CallID: "c1", Outcome: run.ToolOutcomeUnknown}) + run.SubmitToolFailure{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0), Outcome: run.ToolOutcomeUnknown}) if unknown.Snapshot.State.Status != run.RunActive { t.Fatalf("unknown status = %v, want active", unknown.Snapshot.State.Status) } @@ -860,8 +864,8 @@ func testCancelUnknownRemaining(t *testing.T, newRuntime Factory) { specA := makeSpec(t, defA) specB := makeSpec(t, defB) c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{defA, defB}, []run.ToolSpec{specA, specB}) - bA := makeBinding(t, "cA", &specA) - bB := makeBinding(t, "cB", &specB) + bA := makeBinding(t, stepID, 0, "cA", &specA) + bB := makeBinding(t, stepID, 1, "cB", &specB) r, err := run.FreezeModelResult(sdk.ModelResult{ FinishReason: sdk.FinishReasonToolCalls, ToolCalls: []sdk.ToolCall{ @@ -876,13 +880,13 @@ func testCancelUnknownRemaining(t *testing.T, newRuntime Factory) { run.SubmitModelResult{StepID: stepID, Result: r, Calls: []run.ToolCallBinding{bA, bB}}) toolStep := openedToolStepID(t, &opened) base := opened.Snapshot.Revision - startA := c.mustCommit("start-A", base, "", run.StartToolCall{StepID: toolStep, CallID: "cA"}) - startB := c.mustCommit("start-B", base, "", run.StartToolCall{StepID: toolStep, CallID: "cB"}) + startA := c.mustCommit("start-A", base, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0)}) + startB := c.mustCommit("start-B", base, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 1)}) if startB.Status != run.CommitAccepted || startB.Grant == "" { t.Fatalf("start B: %+v", startB) } unknown := c.mustCommit("unk-A", startA.Snapshot.Revision, startA.Grant, - run.SubmitToolFailure{StepID: toolStep, CallID: "cA", Outcome: run.ToolOutcomeUnknown}) + run.SubmitToolFailure{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0), Outcome: run.ToolOutcomeUnknown}) if unknown.Snapshot.State.Status != run.RunActive { t.Fatalf("unknown status = %v, want active", unknown.Snapshot.State.Status) } @@ -895,7 +899,7 @@ func testCancelUnknownRemaining(t *testing.T, newRuntime Factory) { t.Fatalf("cancel status = %v, want stopped", cancelled.Snapshot.State.Status) } got := cancelled.Snapshot.State.Result.UncertainCalls - if len(got) != 1 || got[0] != "cB" { + if len(got) != 1 || got[0] != run.DeriveCallID(stepID, 1) { t.Fatalf("UncertainCalls = %v, want [cB]", got) } } diff --git a/agent/run/runtimetest/recovery.go b/agent/run/runtimetest/recovery.go index a2c099b..737c2a8 100644 --- a/agent/run/runtimetest/recovery.go +++ b/agent/run/runtimetest/recovery.go @@ -109,11 +109,11 @@ func testRecoveryExpiredToolUnknown(t *testing.T, newRuntime RecoveryFactory) { def := toolDef("t") spec := makeSpec(t, def) c, stepID, grant := recoveryPrepared(t, newRuntime, time.Second, &clock, []sdk.ToolDefinition{def}, []run.ToolSpec{spec}) - b := makeBinding(t, "c1", &spec) + b := makeBinding(t, stepID, 0, "c1", &spec) opened := c.mustCommit("complete-1", 2, grant, run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) toolStep := openedToolStepID(t, &opened) - c.mustCommit("start-c1", opened.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: "c1"}) + c.mustCommit("start-c1", opened.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0)}) clock = time.Unix(1002, 0) n, err := c.rt.RecoverExpired(context.Background()) if err != nil { @@ -145,8 +145,8 @@ func testRecoveryExpiredToolLeavesSiblingExecuting(t *testing.T, newRuntime Reco specB := makeSpec(t, defB) c, stepID, grant := recoveryPrepared(t, newRuntime, time.Second, &clock, []sdk.ToolDefinition{defA, defB}, []run.ToolSpec{specA, specB}) - bA := makeBinding(t, "cA", &specA) - bB := makeBinding(t, "cB", &specB) + bA := makeBinding(t, stepID, 0, "cA", &specA) + bB := makeBinding(t, stepID, 1, "cB", &specB) r, err := run.FreezeModelResult(sdk.ModelResult{ FinishReason: sdk.FinishReasonToolCalls, ToolCalls: []sdk.ToolCall{ @@ -161,9 +161,9 @@ func testRecoveryExpiredToolLeavesSiblingExecuting(t *testing.T, newRuntime Reco run.SubmitModelResult{StepID: stepID, Result: r, Calls: []run.ToolCallBinding{bA, bB}}) toolStep := openedToolStepID(t, &opened) base := opened.Snapshot.Revision - c.mustCommit("start-A", base, "", run.StartToolCall{StepID: toolStep, CallID: "cA"}) + c.mustCommit("start-A", base, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0)}) clock = time.Unix(1000, 500_000_000) - c.mustCommit("start-B", base, "", run.StartToolCall{StepID: toolStep, CallID: "cB"}) + c.mustCommit("start-B", base, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 1)}) clock = time.Unix(1001, 0) n, err := c.rt.RecoverExpired(context.Background()) if err != nil { diff --git a/agent/run/snapshot_test.go b/agent/run/snapshot_test.go index d4f3514..9207d76 100644 --- a/agent/run/snapshot_test.go +++ b/agent/run/snapshot_test.go @@ -39,16 +39,16 @@ func TestSnapshotCodecRoundTrip(t *testing.T) { } check("model executing") - b := makeBinding(t, "c1", spec, `{}`) + b := makeBinding(t, stepID, 0, "c1", spec, `{}`) snap, _ := rt.Load(ctx, "run-1") res := mustCommit(t, rt, "complete-1", snap.Revision, grant, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) check("tool step pending") toolStep := res.Events[1].Fact.(ToolStepOpened).StepID - sRes := mustCommit(t, rt, "start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: "c1"}) + sRes := mustCommit(t, rt, "start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: cid(stepID, 0)}) check("tool step executing") mustCommit(t, rt, "done-c1", sRes.Snapshot.Revision, sRes.Grant, - SubmitToolResult{StepID: toolStep, CallID: "c1", Result: ToolExecutionResult{Output: cj(`"ok"`)}}) + SubmitToolResult{StepID: toolStep, CallID: cid(stepID, 0), Result: ToolExecutionResult{Output: cj(`"ok"`)}}) check("open with last tool step") snap, _ = rt.Load(ctx, "run-1") mustCommit(t, rt, "cancel", snap.Revision, "", CancelRun{}) diff --git a/agent/run/sqlitestore/store_test.go b/agent/run/sqlitestore/store_test.go index 68572f8..9bac121 100644 --- a/agent/run/sqlitestore/store_test.go +++ b/agent/run/sqlitestore/store_test.go @@ -289,12 +289,13 @@ func startExecutingTool(t *testing.T, rt run.Runtime, runID run.RunID) { t.Fatal(err) } args := run.MustParseCanonicalJSON(`{}`) - bd, err := run.DigestToolCallBinding("c1", spec.DefinitionDigest, spec.Policy, args) + callID := run.DeriveCallID(prep.StepID, 0) + bd, err := run.DigestToolCallBinding(callID, spec.DefinitionDigest, spec.Policy, args) if err != nil { t.Fatal(err) } binding := run.ToolCallBinding{ - CallID: "c1", ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, + CallID: callID, ProviderCallID: "c1", ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, BindingDigest: bd, Arguments: args, Policy: spec.Policy, } result := sdk.ModelResult{ @@ -320,7 +321,7 @@ func startExecutingTool(t *testing.T, rt run.Runtime, runID run.RunID) { t.Fatalf("event[1] = %T, want ToolStepOpened", opened.Events[1].Fact) } if _, err := commit(t, rt, runID, "start-c1", opened.Snapshot.Revision, "", run.StartToolCall{ - StepID: openedFact.StepID, CallID: "c1", Claim: "claim-tool", + StepID: openedFact.StepID, CallID: callID, Claim: "claim-tool", }); err != nil { t.Fatal(err) } diff --git a/agent/run/state.go b/agent/run/state.go index faeefb9..50b692a 100644 --- a/agent/run/state.go +++ b/agent/run/state.go @@ -107,7 +107,10 @@ type ToolSpec struct { // ToolCallBinding is one frozen call inside ToolStepOpened. type ToolCallBinding struct { - CallID CallID `json:"callId"` + 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 @@ -234,6 +237,7 @@ type ToolCallFailure struct { type ToolCallState struct { CallID CallID `json:"callId"` + ProviderCallID string `json:"providerCallId,omitempty"` ToolRef ToolRef `json:"toolRef"` DefinitionDigest Digest `json:"definitionDigest"` BindingDigest Digest `json:"bindingDigest"` diff --git a/agent/turn/coordinator.go b/agent/turn/coordinator.go index 8a6f2b5..0794b23 100644 --- a/agent/turn/coordinator.go +++ b/agent/turn/coordinator.go @@ -267,11 +267,10 @@ func (c *Coordinator) materialize(ctx context.Context, ref Ref, runID run.RunID, } var group []Event for i := range record.Transitions { - tr := &record.Transitions[i] - if tr.Revision <= l.covered { + if record.Transitions[i].Revision <= l.covered { continue } - mapped, err := MapTransition(ref.Turn, tr) + mapped, err := MapTransition(ref.Turn, record.Transitions[:i+1]) if err != nil { return run.RunRecord{}, err } diff --git a/agent/turn/coordinator_test.go b/agent/turn/coordinator_test.go index 58eb5a3..e7323e2 100644 --- a/agent/turn/coordinator_test.go +++ b/agent/turn/coordinator_test.go @@ -85,7 +85,7 @@ func (p planner) Plan(_ context.Context, hint run.PlanningHint) (loop.RequestPla } if hint.LastToolStep != nil { for _, c := range hint.LastToolStep.Calls { - msgs = append(msgs, sdk.ToolMessage(sdk.ToolResultPart{ToolCallID: string(c.CallID), ToolName: "echo", Result: c.Status.String()})) + msgs = append(msgs, sdk.ToolMessage(sdk.ToolResultPart{ToolCallID: c.ProviderCallID, ToolName: "echo", Result: c.Status.String()})) } } return loop.RequestPlan{Model: "m-1", Request: sdk.Request{Model: "m-1", Messages: msgs, Tools: []sdk.ToolDefinition{p.spec.Definition.SDK()}}, diff --git a/agent/turn/live_test.go b/agent/turn/live_test.go new file mode 100644 index 0000000..a8a0bfa --- /dev/null +++ b/agent/turn/live_test.go @@ -0,0 +1,237 @@ +//go:build live + +// Live end-to-end test: agent/turn -> agent/run/loop -> a real model through +// the sdk. Runs only with `go test -tags live` and the environment below. +// +// TWILIGHT_LIVE_BASE_URL OpenAI-compatible chat completions base URL +// TWILIGHT_LIVE_API_KEY bearer key +// TWILIGHT_LIVE_MODEL model id (default gpt-5.4) +package turn_test + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/twilight/agent/turn" + "github.com/memohai/twilight/provider/openai/completions" + "github.com/memohai/twilight/sdk" +) + +// calculator is a real tool: it evaluates "a op b" and returns the number. +type calculator struct{ spec run.ToolSpec } + +func newCalculator(t *testing.T) *calculator { + t.Helper() + def := sdk.ToolDefinition{ + Name: "calculator", + Description: "Evaluate an arithmetic expression of the form ' ' where op is + - * /.", + Parameters: []byte(`{"type":"object","properties":{"expression":{"type":"string"}},"required":["expression"]}`), + } + frozen, err := run.FreezeToolDefinition(def) + if err != nil { + t.Fatal(err) + } + digest, err := run.ProtocolV1().DigestToolDefinition(frozen) + if err != nil { + t.Fatal(err) + } + return &calculator{spec: run.ToolSpec{Ref: "calculator", Definition: frozen, DefinitionDigest: digest, Policy: run.DirectExecution}} +} + +func (c *calculator) ResolveTool(ref run.ToolRef) (loop.ExecutableTool, error) { + if ref != "calculator" { + return nil, fmt.Errorf("unknown tool %q", ref) + } + return c, nil +} +func (c *calculator) Ref() run.ToolRef { return "calculator" } +func (c *calculator) Definition() sdk.ToolDefinition { return c.spec.Definition.SDK() } +func (c *calculator) ResponsePolicy() run.ResponsePolicy { return run.DirectExecution } +func (c *calculator) ValidateArguments(run.CanonicalJSON) error { return nil } +func (c *calculator) Execute(_ context.Context, req loop.ToolExecutionRequest) loop.ToolExecutionOutcome { + var args struct { + Expression string `json:"expression"` + } + if err := req.Arguments.Decode(&args); err != nil { + return loop.ToolExecutionFailed{Failure: run.ToolFailure{Class: run.FailureInvalidArguments, Message: err.Error()}} + } + fields := strings.Fields(strings.NewReplacer("*", " * ", "+", " + ", "-", " - ", "/", " / ").Replace(args.Expression)) + if len(fields) != 3 { + return loop.ToolExecutionFailed{Failure: run.ToolFailure{Class: run.FailureInvalidArguments, Message: "expected ' '"}} + } + a, errA := strconv.Atoi(fields[0]) + b, errB := strconv.Atoi(fields[2]) + if errA != nil || errB != nil { + return loop.ToolExecutionFailed{Failure: run.ToolFailure{Class: run.FailureInvalidArguments, Message: "operands must be integers"}} + } + var v int + switch fields[1] { + case "+": + v = a + b + case "-": + v = a - b + case "*": + v = a * b + case "/": + if b == 0 { + return loop.ToolExecutionFailed{Failure: run.ToolFailure{Class: run.FailureExecution, Message: "division by zero"}} + } + v = a / b + default: + return loop.ToolExecutionFailed{Failure: run.ToolFailure{Class: run.FailureInvalidArguments, Message: "unknown operator"}} + } + out, _ := run.CanonicalJSONFromValue(map[string]int{"result": v}) + return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: out}} +} + +// livePlanner assembles the whole conversation from the Run boundary: the +// system prompt, the delivered inputs, and for a follow-up step the +// assistant's tool calls plus their committed results. +type livePlanner struct { + model run.ModelRef + spec run.ToolSpec +} + +func (p livePlanner) Plan(_ context.Context, hint run.PlanningHint) (loop.RequestPlan, error) { + msgs := []sdk.Message{sdk.SystemMessage("You are a precise assistant. Use the calculator tool for any arithmetic. Reply with just the final number once you have it.")} + ids := make([]run.InputID, 0, len(hint.Inputs)) + for _, in := range hint.Inputs { + ids = append(ids, in.ID) + var body struct { + Text string `json:"text"` + } + if err := in.Payload.Decode(&body); err != nil { + return loop.RequestPlan{}, err + } + msgs = append(msgs, sdk.UserMessage(body.Text)) + } + if hint.LastModelResult != nil && hint.LastToolStep != nil { + // Replay the assistant turn that issued the calls, then each result. + var parts []sdk.MessagePart + if hint.LastModelResult.Text != "" { + parts = append(parts, sdk.TextPart{Text: hint.LastModelResult.Text}) + } + for _, tc := range hint.LastModelResult.ToolCalls { + input, err := tc.Input.Any() + if err != nil { + return loop.RequestPlan{}, err + } + parts = append(parts, sdk.ToolCallPart{ToolCallID: tc.ToolCallID, ToolName: tc.ToolName, Input: input}) + } + msgs = append(msgs, sdk.Message{Role: sdk.MessageRoleAssistant, Content: parts}) + var results []sdk.ToolResultPart + for _, call := range hint.LastToolStep.Calls { + var result any + isErr := false + switch { + case call.Result != nil: + result, _ = call.Result.Output.Any() + case call.Failure != nil: + result, isErr = call.Failure.Failure.Class+": "+call.Failure.Failure.Message, true + } + results = append(results, sdk.ToolResultPart{ToolCallID: call.ProviderCallID, ToolName: string(call.ToolRef), Result: result, IsError: isErr}) + } + msgs = append(msgs, sdk.ToolMessage(results...)) + } + return loop.RequestPlan{ + Model: p.model, + Request: sdk.Request{Model: string(p.model), Messages: msgs, Tools: []sdk.ToolDefinition{p.spec.Definition.SDK()}}, + InputIDs: ids, + Tools: []run.ToolSpec{p.spec}, + }, nil +} + +type liveCatalog struct{ model *sdk.Model } + +func (c liveCatalog) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { return c.model, nil } + +func TestLiveTurnWithRealModelAndTool(t *testing.T) { + baseURL, apiKey := os.Getenv("TWILIGHT_LIVE_BASE_URL"), os.Getenv("TWILIGHT_LIVE_API_KEY") + if baseURL == "" || apiKey == "" { + t.Skip("TWILIGHT_LIVE_BASE_URL / TWILIGHT_LIVE_API_KEY not set") + } + modelID := os.Getenv("TWILIGHT_LIVE_MODEL") + if modelID == "" { + modelID = "gpt-5.4" + } + provider := completions.New(completions.WithAPIKey(apiKey), completions.WithBaseURL(baseURL)) + model := provider.ChatModel(modelID) + + calc := newCalculator(t) + l, err := loop.New(liveCatalog{model}, calc, livePlanner{model: run.ModelRef(modelID), spec: calc.spec}, loop.ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + rt := run.NewRuntime(run.NewMemoryStore()) + log := turn.NewMemoryLog() + coord := &turn.Coordinator{Log: log, Runtime: rt, Driver: turn.LoopDriver{Loop: l, Runtime: rt}} + + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) + defer cancel() + ref := turn.Ref{Session: "live-s1", Turn: "live-t1"} + res, err := coord.Start(ctx, turn.StartRequest{Ref: ref, RunID: "live-run-1", + Inputs: []run.AgentInput{{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{"text":"What is 17 * 23? Use the calculator."}`)}}}) + if err != nil { + t.Fatal(err) + } + if res.Disposition != turn.DispositionFinished || res.Settlement != turn.SettlementCompleted { + if res.Result != nil && res.Result.Failure != nil { + t.Fatalf("turn %s: reason=%s class=%s message=%s", res.Settlement, res.Result.Reason, res.Result.Failure.Class, res.Result.Failure.Message) + } + t.Fatalf("turn did not complete: disposition=%s settlement=%s result=%+v", res.Disposition, res.Settlement, res.Result) + } + if !strings.Contains(res.Result.Model.Text, "391") { + t.Fatalf("final answer %q does not contain 391", res.Result.Model.Text) + } + + events, err := log.Replay(ctx, ref.Session) + if err != nil { + t.Fatal(err) + } + var sawToolResult bool + t.Logf("=== session log: %d events", len(events)) + for _, e := range events { + t.Logf("seq=%d %-32s rev=%d idx=%d\n%s", e.Seq, e.Type, e.Revision, e.Index, e.Payload.String()) + if e.Type == turn.EventToolResult { + var p turn.ToolResultPayload + if err := e.Payload.Decode(&p); err != nil { + t.Fatal(err) + } + if p.Status != turn.ToolSuccess || !strings.Contains(p.Output.String(), "391") { + t.Fatalf("tool result = %+v", p) + } + sawToolResult = true + } + } + if !sawToolResult { + t.Fatal("model did not call the tool") + } + record, err := rt.Record(ctx, "live-run-1") + if err != nil { + t.Fatal(err) + } + t.Logf("=== run record: %d transitions, usage %+v", len(record.Transitions), record.Snapshot.State.Usage) + for _, tr := range record.Transitions { + for _, ev := range tr.Events { + t.Logf("rev=%d idx=%d %-22s command=%s", ev.Revision, ev.Index, ev.Type, ev.CommandID) + } + } + if out := os.Getenv("TWILIGHT_LIVE_RECORD_OUT"); out != "" { + raw, err := json.MarshalIndent(record, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(out, raw, 0o644); err != nil { + t.Fatal(err) + } + t.Logf("=== full RunRecord written to %s (%d bytes)", out, len(raw)) + } +} diff --git a/agent/turn/mapper.go b/agent/turn/mapper.go index 6e09a5f..d74a53b 100644 --- a/agent/turn/mapper.go +++ b/agent/turn/mapper.go @@ -7,10 +7,26 @@ import ( ) // MapTransition is the v1 FactMapper (TRN-MAP-1): it projects the AgentEvents -// of one committed transition into chatlog events for the Turn. Facts that -// carry no conversation content map to nothing. RunEnded is settlement, not -// content, and is handled by the coordinator. -func MapTransition(turnID TurnID, tr *run.TransitionRecord) ([]Event, error) { +// of the last transition in prefix into chatlog events for the Turn. prefix is +// the complete record up to and including that transition (TRN-MAT-1), which +// is how a tool_result learns the ProviderCallID its ToolStepOpened carried. +// Facts with no conversation content map to nothing; RunEnded is settlement +// and is handled by the coordinator. +func MapTransition(turnID TurnID, prefix []run.TransitionRecord) ([]Event, error) { + if len(prefix) == 0 { + return nil, nil + } + provider := make(map[run.CallID]string) + for i := range prefix { + for j := range prefix[i].Events { + if opened, ok := prefix[i].Events[j].Fact.(run.ToolStepOpened); ok { + for _, b := range opened.Calls { + provider[b.CallID] = b.ProviderCallID + } + } + } + } + tr := &prefix[len(prefix)-1] var out []Event for i := range tr.Events { ev := &tr.Events[i] @@ -19,24 +35,24 @@ func MapTransition(turnID TurnID, tr *run.TransitionRecord) ([]Event, error) { switch f := ev.Fact.(type) { case run.ModelStepCompleted: calls := make([]ToolCallPayload, 0, len(f.Result.ToolCalls)) - for _, tc := range f.Result.ToolCalls { - calls = append(calls, ToolCallPayload{CallID: run.CallID(tc.ToolCallID), Name: tc.ToolName, Input: tc.Input}) + for j, tc := range f.Result.ToolCalls { + calls = append(calls, ToolCallPayload{CallID: run.DeriveCallID(f.StepID, j), ProviderCallID: tc.ToolCallID, Name: tc.ToolName, Input: tc.Input}) } typ = EventAssistant payload = AssistantPayload{TurnID: turnID, StepID: f.StepID, Text: f.Result.Text, ToolCalls: calls} case run.ToolCallCompleted: typ = EventToolResult - payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, Status: ToolSuccess, Output: f.Result.Output} + payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, ProviderCallID: provider[f.CallID], Status: ToolSuccess, Output: f.Result.Output} case run.ToolCallAnswered: typ = EventToolResult - payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, Status: ToolSuccess, Output: f.Payload} + payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, ProviderCallID: provider[f.CallID], Status: ToolSuccess, Output: f.Payload} case run.ToolCallFailed: status := ToolError if f.Outcome == run.ToolOutcomeUnknown || f.Failure.Class == run.FailureEffectUnknown { status = ToolUnknown } typ = EventToolResult - payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, Status: status, Failure: f.Failure.Class, Message: f.Failure.Message} + payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, ProviderCallID: provider[f.CallID], Status: status, Failure: f.Failure.Class, Message: f.Failure.Message} default: continue } diff --git a/agent/turn/turn.go b/agent/turn/turn.go index 05cc719..780dc60 100644 --- a/agent/turn/turn.go +++ b/agent/turn/turn.go @@ -125,9 +125,10 @@ type SettledPayload struct { } type ToolCallPayload struct { - CallID run.CallID `json:"callId"` - Name string `json:"name"` - Input run.CanonicalJSON `json:"input"` + CallID run.CallID `json:"callId"` + ProviderCallID string `json:"providerCallId,omitempty"` + Name string `json:"name"` + Input run.CanonicalJSON `json:"input"` } type AssistantPayload struct { @@ -146,10 +147,11 @@ const ( ) type ToolResultPayload struct { - TurnID TurnID `json:"turnId"` - CallID run.CallID `json:"callId"` - Status ToolResultStatus `json:"status"` - Output run.CanonicalJSON `json:"output,omitzero"` - Failure string `json:"failure,omitempty"` - Message string `json:"message,omitempty"` + TurnID TurnID `json:"turnId"` + CallID run.CallID `json:"callId"` + ProviderCallID string `json:"providerCallId,omitempty"` + Status ToolResultStatus `json:"status"` + Output run.CanonicalJSON `json:"output,omitzero"` + Failure string `json:"failure,omitempty"` + Message string `json:"message,omitempty"` } diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index de9e63e..cf58d0e 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -100,6 +100,7 @@ type TransitionRecord struct { | PrepareModelRequest CommandID | RunID、loaded BaseRevision | | 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 | @@ -243,7 +244,7 @@ ToolCall: **RUN-MCH-1** MachineState 保存 Run 的 execution semantics。`LastToolStep` 保存最近一个经 Evolve 关闭路径写下的 ToolStep 只读投影,必须与 transition log 折叠出的最后关闭 step 一致,供下一次 planner 构造模型请求。Cancel 经 `RunEnded` 把 `Current` 置空、不走关闭路径时不改写 `LastToolStep`。terminal state 吸收所有未幂等命令;`RunEnded` 建立唯一 terminal result。RunResult 在该 terminal transition 中建立,并由后续 snapshot/record 读取。 -**RUN-MCH-2** `ToolCallBinding` 冻结 CallID、ToolRef、definition digest、canonical arguments、response policy 与 binding digest。已知工具使用匹配 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。 +**RUN-MCH-2** `ToolCallBinding` 冻结 CallID、ProviderCallID、ToolRef、definition digest、canonical arguments、response policy 与 binding digest。`CallID` 由 Run 派生(`DeriveCallID(source, index)`),是 Run 内的持久化 identity,进入 fact、lease key、派生 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 规则为: diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index 8f85ca8..a18fee7 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -302,7 +302,7 @@ SourceFactID = Digest("twilight/turn/source-fact", `ModelStepPrepared` 期间 EventSink 可发送 `text_delta` / `reasoning_delta`。回合结束由 `twilight/turn/completed` 或 `twilight/turn/failed` 表达。 -**TRN-MAP-2** `AssistantID = Digest("twilight/chatlog/assistant-id", TurnID, ModelStepID, MapperVersion)`。`ToolResultID = Digest("twilight/chatlog/tool-result-id", TurnID, CallID, MapperVersion)`。assistant 的 ToolCall 顺序与模型结果一致。tool_result 与同 Turn 的 call 配对。同一 Turn 内 CallID 不得跨 ModelStep 复用,否则 `ToolResultID` 冲突。 +**TRN-MAP-2** `AssistantID = Digest("twilight/chatlog/assistant-id", TurnID, ModelStepID, MapperVersion)`。`ToolResultID = Digest("twilight/chatlog/tool-result-id", TurnID, CallID, MapperVersion)`。assistant 的 ToolCall 顺序与模型结果一致。tool_result 与同 Turn 的 call 配对。CallID 由 Run 从 `(ModelStepID, index)` 派生,同一 Turn 内天然不跨 ModelStep 复用;模型发出的 `tool_call_id` 以 `ProviderCallID` 随 assistant / tool_result 一并记录,供 Planner 回传。 **TRN-MAP-3** Known 对应 `error`;Unknown 对应 `unknown`。 From 501afc7eca0ce5f03766b14cb1814b8561c3b733 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Thu, 3 Sep 2026 13:34:41 +0800 Subject: [PATCH 60/81] Add turn.ContextFold and ContextPlanner: plan from the session log The live run showed the second model request had no user message: the example planner read only PlanningHint.Inputs, which the first prepare had consumed. PlanningHint is boundary facts by design; the conversation must come from the session log. - ContextFold projects a session log into ordered Entries (delivered input, assistant, tool_result) across every Turn (CHT-CTX-1/2, minimal: no summary/checkpoint yet). - ContextPlanner is the reference RequestPlanner (REF-PLN): system prompt, the fold as user/assistant/tool messages, then the tool step that closed inside this Loop.Run and is not yet materialized, taken from hint.LastModelResult/LastToolStep. sdk.Message is produced here and never stored. Tool results echo ProviderCallID and the model-facing tool name. - ToolResultPayload gains Name; the mapper indexes provider id and name per derived CallID from ModelStepCompleted across the record prefix. - Tests: the second request now carries system/user/assistant/tool; a second Turn on the same session sees the first Turn's whole conversation. The live test uses ContextPlanner; request 6 of the live record now includes the user message. Co-Authored-By: Claude Fable 5.1 --- agent/turn/context.go | 236 +++++++++++++++++++++++++++++++++ agent/turn/coordinator_test.go | 102 ++++++++++---- agent/turn/live_test.go | 67 +--------- agent/turn/mapper.go | 15 ++- agent/turn/turn.go | 17 ++- 5 files changed, 334 insertions(+), 103 deletions(-) create mode 100644 agent/turn/context.go diff --git a/agent/turn/context.go b/agent/turn/context.go new file mode 100644 index 0000000..8f91096 --- /dev/null +++ b/agent/turn/context.go @@ -0,0 +1,236 @@ +package turn + +import ( + "context" + "fmt" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/twilight/sdk" +) + +// EntryKind is the kind of one conversation entry in a folded Context. +type EntryKind string + +const ( + EntryInput EntryKind = "input" + EntryAssistant EntryKind = "assistant" + EntryToolResult EntryKind = "tool_result" +) + +// Entry is one element of the model-visible conversation, derived from +// committed chatlog events (CHT-CTX-1). Exactly one of the payload fields is +// set according to Kind. +type Entry struct { + Kind EntryKind + Turn TurnID + Seq uint64 + Input *InputDeliveredPayload + Assistant *AssistantPayload + ToolResult *ToolResultPayload +} + +// ContextFold projects a session log into the ordered conversation the model +// should see: delivered inputs, assistant outputs and tool results across +// every Turn of the session, in commit order. It is a pure function of the +// events; turn lifecycle events contribute nothing (CHT-CTX-1, CHT-CTX-2). +// +// This is the minimal fold: no summary, checkpoint or supersession handling +// yet; those arrive with the Chatlog module. +func ContextFold(events []Event) ([]Entry, error) { + entries := make([]Entry, 0, len(events)) + for i := range events { + ev := &events[i] + switch ev.Type { + case EventInputDelivered: + var p InputDeliveredPayload + if err := ev.Payload.Decode(&p); err != nil { + return nil, fmt.Errorf("agent: turn: fold input_delivered seq %d: %w", ev.Seq, err) + } + entries = append(entries, Entry{Kind: EntryInput, Turn: ev.Turn, Seq: ev.Seq, Input: &p}) + case EventAssistant: + var p AssistantPayload + if err := ev.Payload.Decode(&p); err != nil { + return nil, fmt.Errorf("agent: turn: fold assistant seq %d: %w", ev.Seq, err) + } + entries = append(entries, Entry{Kind: EntryAssistant, Turn: ev.Turn, Seq: ev.Seq, Assistant: &p}) + case EventToolResult: + var p ToolResultPayload + if err := ev.Payload.Decode(&p); err != nil { + return nil, fmt.Errorf("agent: turn: fold tool_result seq %d: %w", ev.Seq, err) + } + entries = append(entries, Entry{Kind: EntryToolResult, Turn: ev.Turn, Seq: ev.Seq, ToolResult: &p}) + } + } + return entries, nil +} + +// ContextPlanner is the reference RequestPlanner (REF-PLN): it assembles the +// next sdk.Request from the session's folded chatlog plus the Run boundary +// facts in PlanningHint. sdk.Message is produced here and nowhere else; it is +// never stored. +// +// Within one Loop.Run the Coordinator has not yet materialized the tool step +// that just closed, so the planner appends hint.LastModelResult and +// hint.LastToolStep itself when the fold does not already contain that step +// (REF-PLN-3). Pending inputs named by hint.Inputs are consumed by this plan +// and are already in the fold as input_delivered events. +type ContextPlanner struct { + Log Log + Session SessionID + Model run.ModelRef + Tools []run.ToolSpec + System string + // InputText extracts the user-visible 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.Log == nil || p.Session == "" || p.Model == "" { + return loop.RequestPlan{}, fmt.Errorf("agent: turn: context planner requires Log, Session and Model") + } + events, err := p.Log.Replay(ctx, p.Session) + if err != nil { + return loop.RequestPlan{}, err + } + entries, err := ContextFold(events) + if err != nil { + return loop.RequestPlan{}, err + } + var msgs []sdk.Message + if p.System != "" { + msgs = append(msgs, sdk.SystemMessage(p.System)) + } + inputText := p.InputText + if inputText == nil { + inputText = v1InputText + } + folded := make(map[run.StepID]bool) + for i := range entries { + e := &entries[i] + switch e.Kind { + case EntryInput: + text, err := inputText(e.Input.Content) + if err != nil { + return loop.RequestPlan{}, err + } + msgs = append(msgs, sdk.UserMessage(text)) + case EntryAssistant: + folded[e.Assistant.StepID] = true + msg, err := assistantMessage(e.Assistant.Text, e.Assistant.ToolCalls) + if err != nil { + return loop.RequestPlan{}, err + } + msgs = append(msgs, msg) + case EntryToolResult: + part, err := toolResultPart(e.ToolResult) + if err != nil { + return loop.RequestPlan{}, err + } + msgs = append(msgs, sdk.ToolMessage(part)) + } + } + // The step that just closed inside this Loop.Run is committed on the Run + // but not yet on the session log; add it from the boundary facts. + if hint.LastToolStep != nil && hint.LastModelResult != nil && !folded[hint.LastToolStep.Source] { + calls := make([]ToolCallPayload, 0, len(hint.LastModelResult.ToolCalls)) + for i, tc := range hint.LastModelResult.ToolCalls { + calls = append(calls, ToolCallPayload{CallID: run.DeriveCallID(hint.LastToolStep.Source, i), ProviderCallID: tc.ToolCallID, Name: tc.ToolName, Input: tc.Input}) + } + msg, err := assistantMessage(hint.LastModelResult.Text, calls) + if err != nil { + return loop.RequestPlan{}, err + } + msgs = append(msgs, msg) + names := make(map[run.CallID]string, len(calls)) + for _, c := range calls { + names[c.CallID] = c.Name + } + parts := make([]sdk.ToolResultPart, 0, len(hint.LastToolStep.Calls)) + for i := range hint.LastToolStep.Calls { + c := &hint.LastToolStep.Calls[i] + r := toolResultFromState(c) + r.Name = names[c.CallID] + part, err := toolResultPart(r) + if err != nil { + return loop.RequestPlan{}, err + } + parts = append(parts, part) + } + if len(parts) > 0 { + msgs = append(msgs, sdk.ToolMessage(parts...)) + } + } + ids := make([]run.InputID, 0, len(hint.Inputs)) + for _, in := range hint.Inputs { + ids = append(ids, in.ID) + } + defs := make([]sdk.ToolDefinition, 0, len(p.Tools)) + for _, spec := range p.Tools { + defs = append(defs, spec.Definition.SDK()) + } + return loop.RequestPlan{ + Model: p.Model, + Request: sdk.Request{Model: string(p.Model), Messages: msgs, Tools: defs}, + InputIDs: ids, + Tools: p.Tools, + }, nil +} + +func v1InputText(content run.CanonicalJSON) (string, error) { + var body struct { + Text string `json:"text"` + } + if err := content.Decode(&body); err != nil { + return "", fmt.Errorf("agent: turn: input payload: %w", err) + } + return body.Text, nil +} + +func assistantMessage(text string, calls []ToolCallPayload) (sdk.Message, error) { + var parts []sdk.MessagePart + if text != "" { + parts = append(parts, sdk.TextPart{Text: text}) + } + for _, c := range calls { + input, err := c.Input.Any() + if err != nil { + return sdk.Message{}, err + } + parts = append(parts, sdk.ToolCallPart{ToolCallID: c.ProviderCallID, ToolName: c.Name, Input: input}) + } + return sdk.Message{Role: sdk.MessageRoleAssistant, Content: parts}, nil +} + +func toolResultPart(r *ToolResultPayload) (sdk.ToolResultPart, error) { + part := sdk.ToolResultPart{ToolCallID: r.ProviderCallID, ToolName: r.Name} + switch r.Status { + case ToolSuccess: + out, err := r.Output.Any() + if err != nil { + return sdk.ToolResultPart{}, err + } + part.Result = out + case ToolError: + part.Result, part.IsError = r.Failure+": "+r.Message, true + case ToolUnknown: + part.Result, part.IsError = "tool outcome unknown: "+r.Message, true + default: + return sdk.ToolResultPart{}, fmt.Errorf("agent: turn: unknown tool result status %q", r.Status) + } + return part, nil +} + +func toolResultFromState(c *run.ToolCallState) *ToolResultPayload { + p := &ToolResultPayload{CallID: c.CallID, ProviderCallID: c.ProviderCallID} + switch { + case c.Result != nil: + p.Status, p.Output = ToolSuccess, c.Result.Output + case c.Failure != nil && c.Failure.Outcome == run.ToolOutcomeUnknown: + p.Status, p.Failure, p.Message = ToolUnknown, c.Failure.Failure.Class, c.Failure.Failure.Message + case c.Failure != nil: + p.Status, p.Failure, p.Message = ToolError, c.Failure.Failure.Class, c.Failure.Failure.Message + } + return p +} diff --git a/agent/turn/coordinator_test.go b/agent/turn/coordinator_test.go index e7323e2..3470003 100644 --- a/agent/turn/coordinator_test.go +++ b/agent/turn/coordinator_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sync" "sync/atomic" "testing" "time" @@ -17,15 +18,20 @@ import ( // --- application doubles ------------------------------------------------------ type scriptModel struct { - results []sdk.ModelResult - calls atomic.Int32 + results []sdk.ModelResult + calls atomic.Int32 + mu sync.Mutex + requests []sdk.Request // every request the model received, in order } func (m *scriptModel) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { return m, nil } -func (m *scriptModel) Generate(ctx context.Context, _ sdk.Request) (sdk.ModelResult, error) { +func (m *scriptModel) Generate(ctx context.Context, req sdk.Request) (sdk.ModelResult, error) { if err := ctx.Err(); err != nil { return sdk.ModelResult{}, err } + m.mu.Lock() + m.requests = append(m.requests, req) + m.mu.Unlock() n := int(m.calls.Add(1)) - 1 if n >= len(m.results) { return sdk.ModelResult{}, errors.New("no scripted result") @@ -72,26 +78,6 @@ func (e *echoTool) Execute(_ context.Context, req loop.ToolExecutionRequest) loo return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} } -// planner replays delivered inputs as user messages and the last tool step -// as tool messages, which is all the scripted model needs. -type planner struct{ spec run.ToolSpec } - -func (p planner) Plan(_ context.Context, hint run.PlanningHint) (loop.RequestPlan, error) { - var msgs []sdk.Message - ids := make([]run.InputID, 0, len(hint.Inputs)) - for _, in := range hint.Inputs { - ids = append(ids, in.ID) - msgs = append(msgs, sdk.UserMessage(in.Payload.String())) - } - if hint.LastToolStep != nil { - for _, c := range hint.LastToolStep.Calls { - msgs = append(msgs, sdk.ToolMessage(sdk.ToolResultPart{ToolCallID: c.ProviderCallID, ToolName: "echo", Result: c.Status.String()})) - } - } - return loop.RequestPlan{Model: "m-1", Request: sdk.Request{Model: "m-1", Messages: msgs, Tools: []sdk.ToolDefinition{p.spec.Definition.SDK()}}, - InputIDs: ids, Tools: []run.ToolSpec{p.spec}}, nil -} - func toolCall(id string) sdk.ModelResult { return sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{TotalTokens: 1}, ToolCalls: []sdk.ToolCall{{ToolCallID: id, ToolName: "echo", Input: `{"x":1}`}}} @@ -107,22 +93,45 @@ type harness struct { rt run.Runtime coord *turn.Coordinator tool *echoTool + model *scriptModel ref turn.Ref } func newHarness(t *testing.T, rt run.Runtime, policy run.ResponsePolicy, results ...sdk.ModelResult) *harness { + return newHarnessOnLog(t, rt, turn.NewMemoryLog(), policy, results...) +} + +// newHarnessOnLog builds a fresh Loop and Coordinator over an existing log, +// which is how a second process or a second Turn joins the same session. +func newHarnessOnLog(t *testing.T, rt run.Runtime, log *turn.MemoryLog, policy run.ResponsePolicy, results ...sdk.ModelResult) *harness { t.Helper() tool := newEchoTool(t, policy) - l, err := loop.New(&scriptModel{results: results}, tool, planner{tool.spec}, loop.ExecutionPolicy{}, false) + model := &scriptModel{results: results} + planner := &turn.ContextPlanner{Log: log, Session: "s-1", Model: "m-1", Tools: []run.ToolSpec{tool.spec}, System: "test system"} + l, err := loop.New(model, tool, planner, loop.ExecutionPolicy{}, false) if err != nil { t.Fatal(err) } - log := turn.NewMemoryLog() - return &harness{t: t, log: log, rt: rt, tool: tool, + return &harness{t: t, log: log, rt: rt, tool: tool, model: model, coord: &turn.Coordinator{Log: log, Runtime: rt, Driver: turn.LoopDriver{Loop: l, Runtime: rt}}, ref: turn.Ref{Session: "s-1", Turn: "t-1"}} } +// roles returns the role sequence of the i-th request the model saw. +func (h *harness) roles(i int) []string { + h.t.Helper() + h.model.mu.Lock() + defer h.model.mu.Unlock() + if i >= len(h.model.requests) { + h.t.Fatalf("model saw %d requests, want index %d", len(h.model.requests), i) + } + out := make([]string, 0, len(h.model.requests[i].Messages)) + for _, m := range h.model.requests[i].Messages { + out = append(out, string(m.Role)) + } + return out +} + func (h *harness) events() []turn.Event { h.t.Helper() evs, err := h.log.Replay(context.Background(), h.ref.Session) @@ -172,6 +181,11 @@ func TestStartDrivesToCompletionAndMaterializes(t *testing.T) { if evs[2].Revision == 0 || evs[len(evs)-1].Revision != 9 { t.Fatalf("positions: %+v", evs) } + // The second model request carries the whole conversation so far, not + // only the boundary facts: system, the user input, the assistant tool + // call and its result. + requireTypes(t, h.roles(0), []string{"system", "user"}) + requireTypes(t, h.roles(1), []string{"system", "user", "assistant", "tool"}) // Start again with the same RunID is idempotent: no new events. again, err := h.coord.Start(context.Background(), turn.StartRequest{Ref: h.ref, RunID: "run-1"}) if err != nil || again.Disposition != turn.DispositionFinished || len(h.events()) != len(evs) { @@ -286,9 +300,7 @@ func TestResumeAfterCrashRecoversAndSettles(t *testing.T) { } // New process: new Loop, new Coordinator, same log and Runtime. - h2 := newHarness(t, rt, run.DirectExecution, text("after crash")) - h2.log = h.log - h2.coord.Log = h.log + h2 := newHarnessOnLog(t, rt, h.log, run.DirectExecution, text("after crash")) res, err := h2.coord.Resume(context.Background(), h.ref) if err != nil || res.Disposition != turn.DispositionFinished || res.Settlement != turn.SettlementCompleted { t.Fatalf("resume after crash: %+v %v", res, err) @@ -316,3 +328,35 @@ func TestResumeAfterCrashRecoversAndSettles(t *testing.T) { t.Fatalf("types = %v", types) } } + +// A second Turn on the same session plans from the whole session history: the +// model sees the first Turn's input, tool exchange and answer before the new +// input. Nothing about the first Turn is held in memory by the second Loop. +func TestSecondTurnSeesFirstTurnConversation(t *testing.T) { + rt := run.NewRuntime(run.NewMemoryStore()) + h1 := newHarness(t, rt, run.DirectExecution, toolCall("c1"), text("first answer")) + if _, err := h1.coord.Start(context.Background(), turn.StartRequest{Ref: h1.ref, RunID: "run-1", + Inputs: []run.AgentInput{{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{"text":"first question"}`)}}}); err != nil { + t.Fatal(err) + } + h2 := newHarnessOnLog(t, rt, h1.log, run.DirectExecution, text("second answer")) + h2.ref = turn.Ref{Session: "s-1", Turn: "t-2"} + res, err := h2.coord.Start(context.Background(), turn.StartRequest{Ref: h2.ref, RunID: "run-2", + Inputs: []run.AgentInput{{ID: "in-2", Payload: run.MustParseCanonicalJSON(`{"text":"second question"}`)}}}) + if err != nil || res.Disposition != turn.DispositionFinished || res.Result.Model.Text != "second answer" { + t.Fatalf("second turn: %+v %v", res, err) + } + requireTypes(t, h2.roles(0), []string{"system", "user", "assistant", "tool", "assistant", "user"}) + req := h2.model.requests[0] + if got := req.Messages[1].Content[0].(sdk.TextPart).Text; got != "first question" { + t.Fatalf("first user message = %q", got) + } + if got := req.Messages[5].Content[0].(sdk.TextPart).Text; got != "second question" { + t.Fatalf("second user message = %q", got) + } + // The log now holds both Turns in order. + types := h2.types() + if types[0] != turn.EventTurnStarted || types[len(types)-1] != turn.EventTurnCompleted { + t.Fatalf("types = %v", types) + } +} diff --git a/agent/turn/live_test.go b/agent/turn/live_test.go index a8a0bfa..a36cc42 100644 --- a/agent/turn/live_test.go +++ b/agent/turn/live_test.go @@ -92,63 +92,6 @@ func (c *calculator) Execute(_ context.Context, req loop.ToolExecutionRequest) l return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: out}} } -// livePlanner assembles the whole conversation from the Run boundary: the -// system prompt, the delivered inputs, and for a follow-up step the -// assistant's tool calls plus their committed results. -type livePlanner struct { - model run.ModelRef - spec run.ToolSpec -} - -func (p livePlanner) Plan(_ context.Context, hint run.PlanningHint) (loop.RequestPlan, error) { - msgs := []sdk.Message{sdk.SystemMessage("You are a precise assistant. Use the calculator tool for any arithmetic. Reply with just the final number once you have it.")} - ids := make([]run.InputID, 0, len(hint.Inputs)) - for _, in := range hint.Inputs { - ids = append(ids, in.ID) - var body struct { - Text string `json:"text"` - } - if err := in.Payload.Decode(&body); err != nil { - return loop.RequestPlan{}, err - } - msgs = append(msgs, sdk.UserMessage(body.Text)) - } - if hint.LastModelResult != nil && hint.LastToolStep != nil { - // Replay the assistant turn that issued the calls, then each result. - var parts []sdk.MessagePart - if hint.LastModelResult.Text != "" { - parts = append(parts, sdk.TextPart{Text: hint.LastModelResult.Text}) - } - for _, tc := range hint.LastModelResult.ToolCalls { - input, err := tc.Input.Any() - if err != nil { - return loop.RequestPlan{}, err - } - parts = append(parts, sdk.ToolCallPart{ToolCallID: tc.ToolCallID, ToolName: tc.ToolName, Input: input}) - } - msgs = append(msgs, sdk.Message{Role: sdk.MessageRoleAssistant, Content: parts}) - var results []sdk.ToolResultPart - for _, call := range hint.LastToolStep.Calls { - var result any - isErr := false - switch { - case call.Result != nil: - result, _ = call.Result.Output.Any() - case call.Failure != nil: - result, isErr = call.Failure.Failure.Class+": "+call.Failure.Failure.Message, true - } - results = append(results, sdk.ToolResultPart{ToolCallID: call.ProviderCallID, ToolName: string(call.ToolRef), Result: result, IsError: isErr}) - } - msgs = append(msgs, sdk.ToolMessage(results...)) - } - return loop.RequestPlan{ - Model: p.model, - Request: sdk.Request{Model: string(p.model), Messages: msgs, Tools: []sdk.ToolDefinition{p.spec.Definition.SDK()}}, - InputIDs: ids, - Tools: []run.ToolSpec{p.spec}, - }, nil -} - type liveCatalog struct{ model *sdk.Model } func (c liveCatalog) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { return c.model, nil } @@ -166,12 +109,16 @@ func TestLiveTurnWithRealModelAndTool(t *testing.T) { model := provider.ChatModel(modelID) calc := newCalculator(t) - l, err := loop.New(liveCatalog{model}, calc, livePlanner{model: run.ModelRef(modelID), spec: calc.spec}, loop.ExecutionPolicy{}, false) + rt := run.NewRuntime(run.NewMemoryStore()) + log := turn.NewMemoryLog() + planner := &turn.ContextPlanner{ + Log: log, Session: "live-s1", Model: run.ModelRef(modelID), Tools: []run.ToolSpec{calc.spec}, + System: "You are a precise assistant. Use the calculator tool for any arithmetic. Reply with just the final number once you have it.", + } + l, err := loop.New(liveCatalog{model}, calc, planner, loop.ExecutionPolicy{}, false) if err != nil { t.Fatal(err) } - rt := run.NewRuntime(run.NewMemoryStore()) - log := turn.NewMemoryLog() coord := &turn.Coordinator{Log: log, Runtime: rt, Driver: turn.LoopDriver{Loop: l, Runtime: rt}} ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) diff --git a/agent/turn/mapper.go b/agent/turn/mapper.go index d74a53b..25f8fea 100644 --- a/agent/turn/mapper.go +++ b/agent/turn/mapper.go @@ -16,12 +16,13 @@ func MapTransition(turnID TurnID, prefix []run.TransitionRecord) ([]Event, error if len(prefix) == 0 { return nil, nil } - provider := make(map[run.CallID]string) + type callInfo struct{ provider, name string } + calls := make(map[run.CallID]callInfo) for i := range prefix { for j := range prefix[i].Events { - if opened, ok := prefix[i].Events[j].Fact.(run.ToolStepOpened); ok { - for _, b := range opened.Calls { - provider[b.CallID] = b.ProviderCallID + if completed, ok := prefix[i].Events[j].Fact.(run.ModelStepCompleted); ok { + for k, tc := range completed.Result.ToolCalls { + calls[run.DeriveCallID(completed.StepID, k)] = callInfo{provider: tc.ToolCallID, name: tc.ToolName} } } } @@ -42,17 +43,17 @@ func MapTransition(turnID TurnID, prefix []run.TransitionRecord) ([]Event, error payload = AssistantPayload{TurnID: turnID, StepID: f.StepID, Text: f.Result.Text, ToolCalls: calls} case run.ToolCallCompleted: typ = EventToolResult - payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, ProviderCallID: provider[f.CallID], Status: ToolSuccess, Output: f.Result.Output} + payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, ProviderCallID: calls[f.CallID].provider, Name: calls[f.CallID].name, Status: ToolSuccess, Output: f.Result.Output} case run.ToolCallAnswered: typ = EventToolResult - payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, ProviderCallID: provider[f.CallID], Status: ToolSuccess, Output: f.Payload} + payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, ProviderCallID: calls[f.CallID].provider, Name: calls[f.CallID].name, Status: ToolSuccess, Output: f.Payload} case run.ToolCallFailed: status := ToolError if f.Outcome == run.ToolOutcomeUnknown || f.Failure.Class == run.FailureEffectUnknown { status = ToolUnknown } typ = EventToolResult - payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, ProviderCallID: provider[f.CallID], Status: status, Failure: f.Failure.Class, Message: f.Failure.Message} + payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, ProviderCallID: calls[f.CallID].provider, Name: calls[f.CallID].name, Status: status, Failure: f.Failure.Class, Message: f.Failure.Message} default: continue } diff --git a/agent/turn/turn.go b/agent/turn/turn.go index 780dc60..0fafbe4 100644 --- a/agent/turn/turn.go +++ b/agent/turn/turn.go @@ -147,11 +147,14 @@ const ( ) type ToolResultPayload struct { - TurnID TurnID `json:"turnId"` - CallID run.CallID `json:"callId"` - ProviderCallID string `json:"providerCallId,omitempty"` - Status ToolResultStatus `json:"status"` - Output run.CanonicalJSON `json:"output,omitzero"` - Failure string `json:"failure,omitempty"` - Message string `json:"message,omitempty"` + TurnID TurnID `json:"turnId"` + CallID run.CallID `json:"callId"` + ProviderCallID string `json:"providerCallId,omitempty"` + // Name is the tool name the model used for this call, echoed back with + // the result for providers that pair on it. + Name string `json:"name,omitempty"` + Status ToolResultStatus `json:"status"` + Output run.CanonicalJSON `json:"output,omitzero"` + Failure string `json:"failure,omitempty"` + Message string `json:"message,omitempty"` } From ad4d78727ab70e3a304365c16d46ee22c60c5d2b Mon Sep 17 00:00:00 2001 From: Fodesu Date: Fri, 4 Sep 2026 19:53:47 +0800 Subject: [PATCH 61/81] Design: single Session ES; Run becomes the twilight/run Session Module Replace the two-log design (per-Run RunHeader + TransitionRecord store, Turn materialization into the Session) with one Session stream: - agent-run.md: Run facts are twilight/run/ events; CommitID = CommandID; Runtime commits inside the Session critical section; MachineState is the twilight/run/machine projection. Facts keep only digests: request bodies go to a content-addressed FrozenValueStore, model/tool output goes to the chatlog companion events in the same commit (RUN-WIR-4). Prepare hard CAS keys on RunPosition, not the Session head. Companion/Attach on Commit. - agent-turn.md: Turn:Run 1:N with attempt_failed / Retry / Settle; companion mapping replaces FactMapper, MaterializeAll, ResultReference, coverage and outbox; Stop settles via Attach. - agent-session.md: add CommitIn critical-section port alongside CAS Commit. - extension/chatlog/reference-assembly: run as first-party module, ProviderCallID and SourceDigest on chatlog parts, single-store composition. - agent-runtime-refactor.md: record the decision, the byte analysis behind it, and the code migration list. Co-Authored-By: Claude Fable 5.1 --- docs/design/agent-reference-assembly.md | 35 ++- docs/design/agent-run.md | 346 ++++++++++++------------ docs/design/agent-runtime-refactor.md | 115 +++++--- docs/design/agent-session-chatlog.md | 19 +- docs/design/agent-session-extension.md | 12 +- docs/design/agent-session.md | 29 +- docs/design/agent-turn.md | 337 ++++++++++------------- 7 files changed, 464 insertions(+), 429 deletions(-) diff --git a/docs/design/agent-reference-assembly.md b/docs/design/agent-reference-assembly.md index ccb8d68..9383c96 100644 --- a/docs/design/agent-reference-assembly.md +++ b/docs/design/agent-reference-assembly.md @@ -12,7 +12,8 @@ Session 保存 `ExecutionBindingRef{ID, Digest}`。Digest 覆盖公开配置: type BindingPublic struct { SchemaVersion uint16 // 1 Model run.ModelRef - Tools []run.ToolSpec + Tools []run.ToolSpec // ref、definition digest、policy + Definitions []run.ToolDefinition // 与 Tools 一一对应的本体 Streaming bool PlannerID string // "twilight/turn/planner/context-v1" SystemPrompt string @@ -33,18 +34,18 @@ Planner ID:`twilight/turn/planner/context-v1`。 func Plan(ctx context.Context, hint run.PlanningHint, fold []chatlog.Entry, pub BindingPublic) (loop.RequestPlan, error) ``` -**REF-PLN-1** `fold` 为 `ContextFold` 的输出(含已应用的 checkpoint)。 +**REF-PLN-1** `fold` 为 `ContextFold` 对该 Session chatlog 事件的输出(含已应用的 checkpoint)。Planner 在每次 Plan 时读取 `twilight/chatlog/context` 投影(snapshot 加 tail)。 **REF-PLN-2** `sdk.Messages` 顺序: 1. `pub.SystemPrompt` 非空时一条 system message; -2. 按 `fold`:`input` → user;`assistant` → assistant;`tool_result` → tool;`summary` → assistant text。 +2. 按 `fold`:`input` → user;`assistant` → assistant(ToolCallPart 的 `ProviderCallID` 写入 `sdk.ToolCallPart.ToolCallID`);`tool_result` → tool(以同 Turn assistant 中同 CallID 的 `ProviderCallID` 配对);`summary` → assistant text。 -Coordinator 在 Drive 前执行 `MaterializeAll`。Planner 消费当时的 fold。`hint.LastToolStep` 对应的 `tool_result` 已在 fold 中。 +上一步的 assistant 与 tool_result 已随对应 Run 事实同 commit 提交,Planner 消费时的 fold 总是包含它们;`PlanningHint` 不再携带模型结果或工具结果。 -**REF-PLN-3** `hint.Inputs` 与本 Turn 已 delivered、且属于本次 Prepare 的 Input 按 ID 对齐。这些 Input 已在 fold 中时,只使用 fold。 +**REF-PLN-3** `hint.Inputs` 与本 Turn 已 delivered、且属于本次 Prepare 的 Input 按 ID 对齐。这些 Input 已在 fold 中,只使用 fold。 -**REF-PLN-4** `RequestPlan.Model = pub.Model`,`Tools = pub.Tools`,`InputIDs` 为本次消费的 PendingInput IDs。`PlanningToken` 随 fold 的 Entry digest 序列或 Binding Digest 变化。 +**REF-PLN-4** `RequestPlan.Model = pub.Model`,`Tools = pub.Tools`,`Request.Tools` 由 `pub.Definitions` 生成,`InputIDs` 为本次消费的 PendingInput IDs。`PlanningToken` 随 fold 的 Entry digest 序列或 Binding Digest 变化。 **REF-PLN-5** 无附件时 TextPart 直接写入 sdk.Message。ReferencePart 经 ContextMaterializer 转换。 @@ -59,7 +60,7 @@ run.AgentInput.Payload **REF-INP-1** v1 形状为 `{"text":"<用户字符串>"}`。 -**REF-INP-2** `StartRequest.InputIDs[i] == InitialInputs[i].ID`,且等于已 submitted 的 InputID;`InitialInputs[i].Payload` 等于该 Input 的 Content。`input_delivered` 把 InputID 挂到 TurnID。 +**REF-INP-2** `StartRequest.InputIDs[i] == InitialInputs[i].ID`,且等于已 submitted 的 InputID;`InitialInputs[i].Payload` 等于该 Input 的 Content。`input_delivered` 把 InputID 挂到 TurnID;`twilight/run/input_accepted` 在同一 commit 把同一 payload 交给 Run。 **REF-INP-3** Planner 把 `{"text":...}` 投影为 sdk user text。 @@ -67,18 +68,24 @@ run.AgentInput.Payload ```text sessionStore = session.NewMemoryStore() -runRuntime = run.NewRuntime(run.NewMemoryStore()) -catalog = extension.BuildCatalog(CatalogBuildRequest{ProtocolVersion, Profile, Modules: []ModuleDescriptor{chatlog.Module, turn.Module}}) +catalog = extension.BuildCatalog(CatalogBuildRequest{ProtocolVersion, Profile, + Modules: []ModuleDescriptor{chatlog.Module, turn.Module, runmod.Module}}) appender = extension.NewSemanticAppender(sessionStore, catalog, artifacts) +runtime = runmod.NewRuntime(sessionStore, catalog, runmod.NewMemoryFrozenValues(), runmod.NewMemoryLeases(), turn.CompanionV1(catalog)) bindings = Resolve(ExecutionBindingRef) -> loop.New(models, tools, contextPlanner, policy, pub.Streaming) -coordinator = turn.Coordinator{Sessions, Appender, Runtime: runRuntime, Bindings, Mapper: turn.MapperV1} +coordinator = turn.Coordinator{Sessions: sessionStore, Appender: appender, Runtime: runtime, Bindings: bindings} input_submitted coordinator.Start - twilight/turn/started + twilight/chatlog/input_delivered* - Runtime.Create + AcceptInput* + Loop.Run - MaterializeAll → twilight/chatlog/assistant, twilight/chatlog/tool_result - twilight/turn/completed | twilight/turn/failed + commit 1: twilight/turn/started + twilight/chatlog/input_delivered* + twilight/run/created + twilight/run/input_accepted* + Loop.Run + commit: twilight/run/model_step_prepared (请求本体 → FrozenValueStore) + commit: twilight/run/model_step_started + commit: twilight/run/model_step_completed + twilight/run/tool_step_opened + twilight/chatlog/assistant + commit: twilight/run/tool_call_started + commit: twilight/run/tool_call_completed + twilight/chatlog/tool_result + ... + commit: twilight/run/model_step_completed + twilight/run/ended + twilight/chatlog/assistant + twilight/turn/completed ``` 参考 agent 的工具 ResponsePolicy 为 `DirectExecution`。ContextFold 在无 checkpoint 时输出全部有效条目。 diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index cf58d0e..261b6fc 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -1,44 +1,44 @@ # Twilight Agent Run Protocol -状态:设计规范 +状态:设计规范。Machine、command/fact 规则与 Loop 为规范;第 5 节的 Runtime 与存储层于 2026-09-04 按单一 Session ES 修订,实现尚未跟进(见 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 6 节)。 -本文定义 `agent/run` 与 `agent/run/loop`。文中的“必须”“不得”“应该”是协议约束;canonical JSON、JCS 与 domain-separated digest 使用 `agent/jsonstable` 和 `agent/es` 的通则。 +本文定义 `agent/run`、`agent/run/loop` 与 Run 作为 Session Module 的存储形态。文中的"必须""不得""应该"是协议约束;canonical JSON、JCS 与 domain-separated digest 使用 `agent/jsonstable` 和 `agent/es` 的通则。 ## 1. 范围与 authority ```text -MachineState Run 的语义状态投影 -RunHeader + TransitionRecord[] canonical verified Run record -Runtime Run authority 与 atomic Commit boundary -loop.Loop 当前进程的 execution interpreter +Session stream 唯一 authority:twilight/run/ 事实与 turn、chatlog 事件同在一条 stream +MachineState Run 的语义状态投影(twilight/run/machine),snapshot 为派生缓存 +Runtime Run 的 command 入口:在 Session commit 临界区内 Decide、Evolve、追加 +loop.Loop 当前进程的 execution interpreter +FrozenValueStore 内容寻址旁存:模型请求本体与工具定义本体,按 digest 存取 ``` -`MachineState` 决定 Run 当前可执行动作。每次接受的 command 产生一个完整 `TransitionRecord`,其 facts 经该 Run 的 `Protocol.Evolve` 从 `RunHeader.InitialState` 重放后必须得到同 revision 的 `MachineState`。 +`MachineState` 决定 Run 当前可执行动作。每次接受的 command 产生一个 SessionCommit,其中的 `twilight/run/` 事件经 `Protocol.Evolve` 从 `twilight/run/created` 重放后必须得到同一 `MachineState`。 Run 的职责分成五个相互独立的层面: ```text Agent Machine = Run/Step 状态与合法转移(Decide、Evolve、Next) Agent Loop = Machine effect 的进程内解释器 -Runtime = 一个 Run 的 authority、并发校验与原子提交 +Runtime = command 到 SessionCommit 的原子提交边界、grant/lease、recovery Model / Tool = 一次模型请求或一次工具调用的 effect 执行器 -Request Planner = application context 到 sdk.Request 的投影器 +Request Planner = Session context 到 sdk.Request 的投影器 ``` -Machine 处理已冻结的值和已提交的事实;Loop 解释 `Next` 产生的 transient effect;Runtime 保存并验证 Machine 的推进;Model/Tool 执行一次外部 effect;Request Planner 组装下一次模型请求。`Runtime` 叠在 `Store` 上:`MemoryStore` 使用进程内锁,SQLite 与 Postgres 使用数据库事务,它们保存同一份 authority。 +Machine 处理已冻结的值和已提交的事实;Loop 解释 `Next` 产生的 transient effect;Runtime 保存并验证 Machine 的推进;Model/Tool 执行一次外部 effect;Request Planner 组装下一次模型请求。 -`Step` 是 Run 的持久化恢复边界;`execution attempt` 表示某个 Loop 进程对该 Step 或 ToolCall 的一次易失执行。一个 Step 可以有多个 attempt,Machine 只接受带有效 grant 的 settlement。Attempt 的执行控制信息由 start command 的 `ExecutionClaim` 和 Runtime 返回的 opaque `ExecutionGrant` 表达。 +`Step` 是 Run 的持久化恢复边界;`execution attempt` 表示某个 Loop 进程对该 Step 或 ToolCall 的一次易失执行。一个 Step 可以有多个 attempt,Machine 只接受带有效 grant 的 settlement。Attempt 的执行控制信息由 start command 的 `ExecutionClaim` 和 Runtime 返回的 opaque `ExecutionGrant` 表达;它们不进入 stream。 -每次 accepted command 产生的 `TransitionRecord.Events` 构成 canonical event plane:它与新的 MachineState 在同一 Runtime commit 中写入,按 revision/index 有序,可用于 replay、materialization 和审计。`EventSink` 转发这些已提交事件及临时 delta;canonical authority 由 Runtime.Record 提供。 +**RUN-SCP-1** `agent/run` 拥有 Run identity、persisted frozen values、Machine、command/fact protocol、fact codec、fold 与 `Runtime` contract;它依赖 `agent/session` 的 identity 与 Store 类型,不依赖 loop。`agent/run/loop` 拥有 planner/model/tool ports、streaming、并发执行、EventSink 与 Loop policy。`agent/session/run` 是 Run 的 Session Module 实现:EventDefinition、`twilight/run/machine` projection、`Runtime` 实现、FrozenValueStore 与 lease 表的 adapter。 -**RUN-SCP-1** `agent/run` 拥有 Run identity、persisted frozen values、Machine、command/fact protocol、wire codec、fold、Runtime contract、Store 与 MemoryStore。`agent/run/loop` 拥有 planner/model/tool ports、streaming、并发执行、EventSink 与 Loop policy;它单向依赖 `agent/run`,根 package 不依赖 loop。 - -**RUN-SCP-2** Session 长期语义、Turn→Run 协调、Run→Session materialization、Artifact、queue/claim、provider registry、权限、durable lease/fence/outbox 与产品 policy 分别由其 package 或 Application 拥有。对应 authority 为 [agent-session.md](agent-session.md)、[agent-turn.md](agent-turn.md) 与 [agent-artifact.md](agent-artifact.md)。 +**RUN-SCP-2** Run 是 first-party Session Module(Source `twilight`,ModuleID `run`)。Turn 的创建、attempt 归属与结算、Run 事实到对话内容的 companion 映射由 [agent-turn.md](agent-turn.md) 定义;对话内容 ontology 由 [agent-session-chatlog.md](agent-session-chatlog.md) 定义;stream、commit、projection 与 snapshot 机制由 [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 TurnID string // 与 agent/turn 同一 identity;定义在 agent/run 以避免依赖环 type StepID string type CallID string type CommandID string @@ -52,52 +52,44 @@ type ExecutionGrant string type Digest = es.Digest ``` -**RUN-WIR-1** identity 必须非空、稳定且为有效 UTF-8。`ExecutionClaim` 由 Loop 为一次 start command 生成并在该 command 的重试中保持不变,用于绑定 start command 与执行尝试。`ExecutionGrant` 是 Runtime 签发的 opaque capability,交还给签发它的 Runtime 完成 settlement。两者服务于执行授权;Run 跨 domain causation 记录在 immutable `RunHeader.CausationID`。 +**RUN-WIR-1** identity 必须非空、稳定且为有效 UTF-8。`ExecutionClaim` 由 Loop 为一次 start command 生成并在该 command 的重试中保持不变,用于绑定 start command 与执行尝试。`ExecutionGrant` 是 Runtime 签发的 opaque capability,交还给签发它的 Runtime 完成 settlement。两者服务于执行授权,不进入 fact。Run 跨 domain causation 记录在 `twilight/run/created` 的 `CausationID`。 -Run 持久化协议保存 run-owned frozen values。模型请求、模型结果、消息、工具定义、usage、provider metadata 与所有动态 JSON 在进入 command/fact 前,分别经 `FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`、`FreezeToolCallInput` 等入口转为纯数据和 immutable `CanonicalJSON`。Runtime 接收 agent-owned value;调用方负责在边界前完成冻结。 +Run 持久化协议保存 run-owned frozen values。模型请求、模型结果、消息、工具定义、usage、provider metadata 与所有动态 JSON 在进入 command 前,分别经 `FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`、`FreezeToolCallInput` 等入口转为纯数据和 immutable `CanonicalJSON`。Runtime 接收 agent-owned value;调用方负责在边界前完成冻结。 -**RUN-WIR-2** 当前 pre-release schema v1 使用 RFC 8785/JCS canonical bytes。字段、omission、array order、type discriminator、digest preimage 与 `Protocol.Evolve` 语义在发布前继续演进,发布后永久冻结。command、fact 和 transition decoder 必须拒绝 unknown version/type、duplicate key、unknown field、trailing data、非法 UTF-8、非 canonical-equivalent wire 与 digest mismatch。精确 identity 和 digest 使用 JSON string;当前 v1 revision/index/schema fields 使用整数 wire shape。 +**RUN-WIR-2** Run 事实是 Session event:EventType 为 `twilight/run/`,payload 为 canonical JSON object,第一层携带 `runId`;envelope、revision、index、digest chain 与 idempotency 由 Session kernel 提供,Run 不另设 envelope。fact codec 属于 Session `ProtocolVersion`(EXT-COD-3),必须拒绝 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 Type string + SessionID session.SessionID RunID RunID ID CommandID - Digest Digest + Digest Digest // 覆盖 type 与完整 command,含 transient 内容 Command AgentCommand } -type AgentEvent struct { - SchemaVersion uint16 - Type string - RunID RunID - Revision uint64 - Index uint16 - CommandID CommandID - CommandDigest Digest - Digest Digest - Fact Fact -} -type TransitionRecord struct { - SchemaVersion uint16 - RunID RunID - Revision uint64 - CommandID CommandID - CommandDigest Digest - Events []AgentEvent - TransitionDigest Digest -} ``` -`CommandEnvelope.Digest` 覆盖 schema、type 与完整 command;RunID、CommandID、BaseRevision 和 grant 属于 envelope/commit metadata。`AgentEvent.Digest` 覆盖 schema、type 与完整 fact。`TransitionDigest` 覆盖自身以外的完整 transition,包括有序 event group。 +command 不持久化。`CommandEnvelope.ID` 就是该 command 产生的 SessionCommit 的 `CommitID`;`Digest` 只用于 Runtime 在临界区内比对精确重放。 + +**RUN-WIR-3** 一个 command 恰产生一个 SessionCommit;其 `twilight/run/` 事件 Index 从 0 连续递增,companion 事件(TRN-CMP)与调用方附加事件(`CommitRequest.Attach`)依次紧随其后。Run 事件的 `EventID = Digest("twilight/run/event", CommandID, index)`;`RecordedAtUnixMilli` 由 Runtime 的时钟填入,是 metadata,不参与 Run 的任何派生。构造 command 必须使用该 Session 版本的 `Protocol.BuildEnvelope`(Loop 通过 `RuntimeSnapshot.Protocol()` 取得)。`agent/run` 不提供隐式选择版本的包级 `BuildEnvelope`、`Decide`、`Evolve` 或 `Digest*` 函数;新 Session 与测试显式使用 `ProtocolV1()`。 -**RUN-WIR-3** 一个 transition 的 event 数至少为 1;其 RunID、Revision、CommandID、CommandDigest 相同,Index 从 0 连续递增。revision 从 1 连续递增。构造 command 必须使用该 Run 的 `Protocol.BuildEnvelope`(Loop 通过 `RuntimeSnapshot.Protocol()` 取得)。`agent/run` 不提供隐式选择版本的包级 `BuildEnvelope`、`Decide`、`Evolve` 或 `Digest*` 函数;新 Run 与测试显式使用 `ProtocolV1()`。构造与验证 transition 必须使用 `BuildTransitionRecord`、`ValidateTransitionRecord`。所有公开返回值具有 detached snapshot 语义。 +**RUN-WIR-4** 内容与执行状态分离。fact 只保存执行状态与内容 digest,内容本体落在两处: + +| 内容 | fact 中的字段 | 本体位置 | +|---|---|---| +| 冻结模型请求 `ModelRequest`(含工具定义) | `ModelStepPrepared.RequestDigest` | `FrozenValueStore`,key 为 RequestDigest | +| 工具定义 `ToolDefinition` | `ToolSpec.DefinitionDigest`,只用于执行前校验 | 请求本体内;不另设存储 | +| 模型输出文本、reasoning、tool call 列表 | `ModelStepCompleted.ResultDigest` | 同 commit 的 `twilight/chatlog/assistant`,其 `SourceDigest` 等于 ResultDigest | +| 工具输出 | `ToolCallCompleted.OutputDigest` / `ToolCallAnswered.ResponseDigest` | 同 commit 的 `twilight/chatlog/tool_result`,其 `SourceDigest` 等于该 digest | +| tool call 参数 | `ToolCallBinding.Arguments` | fact 本身(执行不得依赖 chatlog 解码) | + +`FrozenValueStore` 是内容寻址存储:`Put(digest, bytes)` 幂等,`Get(digest)`。请求本体的有效期是该 ModelStep 从 Prepared 到终结;step 终结后 adapter 可按保留策略删除或归档,Record 校验不依赖本体。 下列 identity 稳定派生并由 Commit 验证: | identity | preimage | |---|---| -| PrepareModelRequest CommandID | RunID、loaded BaseRevision | +| PrepareModelRequest CommandID | RunID、loaded `RunPosition`(该 RunID 最后一条事件的 (revision, index)) | | ModelStep StepID | RunID、prepare CommandID、model/request/tools binding digest | | ToolStep StepID | source ModelStepID、ordered binding-set digest | | CallID | source ModelStepID、该 call 在模型结果 `ToolCalls` 中的位置 | @@ -110,38 +102,34 @@ type TransitionRecord struct { | model recovery CommandID | RunID、StepID、Claim | | tool recovery CommandID(RecoverExpired 的 Unknown) | RunID、StepID、CallID、Claim | -同一派生 identity 的内容变化通过 command digest 触发 conflict。`PlanningToken` 是 Application-owned opaque freshness token,属于 prepare command identity 内容;Planner 负责解释它的 freshness,`ModelStepPrepared` 保存冻结请求及其 digest。 +同一派生 identity 的内容变化在 Session kernel 表现为 `CommitConflict`(同 CommitID、不同 event group)。`PlanningToken` 是 Application-owned opaque freshness token,属于 prepare command identity 内容。 ## 3. 创建与 canonical record ```go type NewRun struct { - SchemaVersion uint16 RunID RunID + Turn TurnID + Attempt uint32 CausationID es.CausationID } -type RunHeader struct { - SchemaVersion uint16 +type RunCreated struct { RunID RunID + Turn TurnID + Attempt uint32 InitialStateVersion uint16 - InitialState MachineState - InitialStateDigest Digest CausationID es.CausationID - HeaderDigest Digest } -type CreateResult struct { Header RunHeader; Created bool } type RunRecord struct { - Header RunHeader + Created session.EventPosition Snapshot RuntimeSnapshot - Transitions []TransitionRecord + Events []session.SessionEvent // 该 RunID 的全部 twilight/run/ 事件,按 resolved 顺序 } ``` -**RUN-NEW-1** `BuildNewRun` 建立当前 version 的 creation value;`BuildRunHeaderFromNewRun` 按 `NewRun.SchemaVersion` 的冻结规则建立 header。v1 Revision-0 state 恰为:相同 RunID、`RunActive`、`Current=Open`、无 pending input、零 model step、零 usage、无 result。初始输入必须随后通过 `AcceptInput` transition 进入 log。 +**RUN-NEW-1** `twilight/run/created` 是 Run 的第一个事实。v1 初始状态恰为:相同 RunID、Turn、Attempt、`RunActive`、`Current=Open`、无 pending input、零 model step、零 usage、无 result。初始输入随后以 `twilight/run/input_accepted` 进入同一 commit(TRN-STR-2)。`Protocol.BuildCreateGroup(NewRun, []AgentInput)` 返回 `created` 与 `input_accepted` 的 typed events,Coordinator 不自行编码。同一 RunID 第二条 `created` 为 Evolve 错误。 -Header 创建后 immutable。`InitialStateDigest` 覆盖 frozen initial state;`HeaderDigest` 覆盖 schema、RunID、initial-state version/digest 与 causation。`ValidateRunHeader` 验证这些约束。 - -**RUN-NEW-2** `FoldRun(header, transitions)` 先验证 header,再按 revision/index 折叠完整 transition sequence。每条 `TransitionRecord` 按其自身 `SchemaVersion` 绑定 `Protocol` 再 `Evolve`。合法 Run 的每条 record 的 SchemaVersion 必须等于 `header.SchemaVersion`(RUN-CMP-1)。Fold 过程执行纯状态重建。import、diagnostic 与 `Runtime.Record` integrity verification 从 header 开始;外部 snapshot 通过 FoldRun 结果校验。 +**RUN-NEW-2** `FoldRun(events)` 按 resolved 顺序折叠该 RunID 的完整事件序列,第一条必须是 `created`。Fold 过程执行纯状态重建。import、诊断与 `Runtime.Record` integrity verification 都经 FoldRun;snapshot 通过 FoldRun 结果校验。 ## 4. Machine @@ -154,13 +142,14 @@ func (ToolStep) current() {} type MachineState struct { RunID RunID + Turn TurnID + Attempt uint32 Status RunStatus Current Current PendingInputs []AgentInput ModelSteps int LastToolStep *ToolStep Usage Usage - LastModelResult *ModelResult Result *RunResult } @@ -170,21 +159,24 @@ type RunResult struct { Failure *RunFailure UncertainCalls []CallID UncertainModel StepID - Model *ModelResult Usage Usage } type ModelStepStatus uint8 // Prepared | Executing type ModelStep struct { RefValue StepRef - Request ModelRequest - RequestDigest Digest + RequestDigest Digest // 本体在 FrozenValueStore Model ModelRef Tools []ToolSpec ToolsDigest Digest Status ModelStepStatus Rejects int // 已接受的 ModelStepRejected 次数;不进入 RefValue.Digest } +type ToolSpec struct { + Ref ToolRef + DefinitionDigest Digest // 本体在 FrozenValueStore + Policy ResponsePolicy +} type ToolScheduleMode string // "parallel" | "sequential";空值按 parallel 解释 type ToolScheduling struct { Mode ToolScheduleMode @@ -202,6 +194,8 @@ type ToolStep struct { `Current` 是 Active 期间的内容。`Open` 是可进入区间:可提交 `AcceptInput` 与 `PrepareModelRequest`,`Next` 返回 `NeedModelRequest`。`ModelStep` 与 `ToolStep` 表示正在进行的步骤。终态的 `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 @@ -225,13 +219,13 @@ func (RunFailedEnd) runEnd() {} type RunEnded struct { End RunEnd } ``` -`RunEnded.End` 必须恰好是上述三个 variant 之一;`RunStoppedEnd.Reason` 必须非空,`RunFailedEnd.Reason` 必须是失败原因,`RunFailedEnd.Failure.Class` 必须非空。`RunEnded` 是 terminal transition 的最后一个 fact。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`,不得只留下 `stopped/cancelled`。 +`RunEnded.End` 必须恰好是上述三个 variant 之一;`RunStoppedEnd.Reason` 必须非空,`RunFailedEnd.Reason` 必须是失败原因,`RunFailedEnd.Failure.Class` 必须非空。`RunEnded` 是 terminal commit 中最后一个 `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,或同 transition 失败 Run) + +-> Rejected (retry 回到 Prepared,或同 commit 失败 Run) ToolCall: Pending -> Executing -> Completed @@ -242,7 +236,9 @@ ToolCall: Waiting(ExternalResponse) -> Completed | Failed(Known) ``` -**RUN-MCH-1** MachineState 保存 Run 的 execution semantics。`LastToolStep` 保存最近一个经 Evolve 关闭路径写下的 ToolStep 只读投影,必须与 transition log 折叠出的最后关闭 step 一致,供下一次 planner 构造模型请求。Cancel 经 `RunEnded` 把 `Current` 置空、不走关闭路径时不改写 `LastToolStep`。terminal state 吸收所有未幂等命令;`RunEnded` 建立唯一 terminal result。RunResult 在该 terminal transition 中建立,并由后续 snapshot/record 读取。 +Recovered 回到 Prepared 后,下一次 Start 重发同一 `RequestDigest` 的请求,Loop 经 `Runtime.FrozenRequest` 取回本体。这是冻结请求被重用的唯一情形;step 终结后下一步由 Planner 重新组装。 + +**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、lease key、派生 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。 @@ -251,24 +247,24 @@ ToolCall: | command | precondition / facts | |---|---| | `AcceptInput` | `Open`;`InputAccepted` | -| `PrepareModelRequest` | `Open`,完整有序消费 PendingInputs,request/tools digests 有效;`ModelStepPrepared` | +| `PrepareModelRequest` | `Open`,完整有序消费 PendingInputs,request/tools digests 有效;`ModelStepPrepared`。command 携带请求与工具定义本体,fact 只留 digest,本体由 Runtime 写入 FrozenValueStore | | `StartModelExecution` | Model Prepared;`ModelStepStarted`。command 必须携带本次 start 的 `ExecutionClaim` | | `RecoverModelExecution` | Model Executing;`ModelStepRecovered`。恢复 durable attempt 时携带该 attempt 的 `Claim` | -| `SubmitModelResult` | Model Executing;`ModelStepCompleted`,随后无 calls 时 `RunEnded(completed)`,有 calls 时 `ToolStepOpened`(携带冻结的 `Scheduling`) | +| `SubmitModelResult` | Model Executing;`ModelStepCompleted{Usage, FinishReason, ResultDigest}`,随后无 calls 时 `RunEnded(completed)`,有 calls 时 `ToolStepOpened`(携带冻结的 `Scheduling` 与 bindings)。command 携带冻结 `ModelResult` 本体,companion 写 `twilight/chatlog/assistant` | | `SubmitModelFailure` | Model Executing;`RunEnded(failed/provider_failure)` | -| `RejectModelResult` | Model Executing;`ModelStepRejected`,由调用方显式选择回到 Prepared 或在同一 transition 追加 `RunEnded(failed/malformed_model_result)` | +| `RejectModelResult` | Model Executing;`ModelStepRejected`,由调用方显式选择回到 Prepared 或在同一 commit 追加 `RunEnded(failed/malformed_model_result)` | | `StartToolCall` | Tool Pending;`ToolCallStarted`。command 必须携带本次 start 的 `ExecutionClaim` | -| `SubmitToolResult` | Tool Executing;`ToolCallCompleted`。该 fact 经 Evolve 后若全部 call 已 terminal,则关闭 ToolStep | +| `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`。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,一次返回该 transition 的完整 ordered fact group;验证成功后返回完整 facts。`Protocol.Evolve(state, fact)` 机械折叠 fact,依赖 fact 携带的完整数据。accepted facts 必须 self-contained;若 transition terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 +**RUN-MCH-3** `Protocol.Decide(state, command)` 执行全部验证与 derived consequence,一次返回该 commit 的完整 ordered fact group;验证成功后返回完整 facts。`Protocol.Evolve(state, fact)` 机械折叠 fact,依赖 fact 携带的完整执行状态数据。accepted facts 必须 self-contained;若 commit terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 启动 command 的最小公共形状为: @@ -288,7 +284,7 @@ type RecoverModelExecution struct { } ``` -一次执行 attempt 的全部 command identity 都从其 `Claim` 派生:start、owner settlement、model recovery、tool recovery 的 CommandID 分别按上表计算,Commit 对 start 强制校验该派生。因此 Loop 只需保留 `Claim` 一个值(`loop.ClaimStore`):提交响应丢失时,以同一 Claim 重放得到同一 CommandID 与 digest,Runtime 对精确重放返回原 `ExecutionGrant`;settlement 重放同理。Claim 存在进程内时,恢复只覆盖响应丢失;宿主注入 durable ClaimStore 时,替代进程可以直接重放前一进程已开始 attempt 的 start 并完成 settlement,不必等 lease 过期。Loop 已丢失 Claim 时,由 Runtime 的 recovery authority(lease 过期)处理该 execution。 +一次执行 attempt 的全部 command identity 都从其 `Claim` 派生:start、owner settlement、model recovery、tool recovery 的 CommandID 分别按上表计算,Commit 对 start 强制校验该派生。因此 Loop 只需保留 `Claim` 一个值(`loop.ClaimStore`):提交响应丢失时,以同一 Claim 重放得到同一 CommandID,Runtime 对精确重放返回原 `ExecutionGrant`;settlement 重放同理。Claim 存在进程内时,恢复只覆盖响应丢失;宿主注入 durable ClaimStore 时,替代进程可以直接重放前一进程已开始 attempt 的 start 并完成 settlement,不必等 lease 过期。Loop 已丢失 Claim 时,由 Runtime 的 recovery authority(lease 过期)处理该 execution。 `Next(state)` 最多返回一个 transient `Effect`: @@ -303,130 +299,142 @@ type RecoverModelExecution struct { Waiting call 上的 `ResponseRequest` 由 `WaitingCalls(state)` 读取。Executing call 由 `ExecutingCalls(state)` 读取。`NeedsRecovery(state)` 在 Model Executing 或 ToolStep 无 Pending 且仍有 Executing 时为 true。这些查询不是 Effect。 -**RUN-MCH-4** Effect 由调用方每次 Load 后重新派生。`PrepareModelRequest.InputIDs` 必须与当前 PendingInputs 等长、同顺序、逐项相同;prepare 接受后一次消费全部 pending input。ToolStep 的 Waiting call 禁止 Start,同一 step 中的 Pending call 仍可执行。没有可执行 Start 时 `Next` 返回 `Idle`。Application 从 snapshot 读取 `WaitingCalls` 并提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse`。执行恢复由 Runtime/application 的 recovery authority 根据 `NeedsRecovery` 提供。 +**RUN-MCH-4** Effect 由调用方每次 Load 后重新派生。`PrepareModelRequest.InputIDs` 必须与当前 PendingInputs 等长、同顺序、逐项相同;prepare 接受后一次消费全部 pending input。ToolStep 的 Waiting call 禁止 Start,同一 step 中的 Pending call 仍可执行。没有可执行 Start 时 `Next` 返回 `Idle`。Application 从投影读取 `WaitingCalls` 并提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse`。执行恢复由 Runtime 的 recovery authority 根据 `NeedsRecovery` 提供。 -## 5. Runtime 与 Commit +## 5. Runtime、投影与 Commit ```go type Runtime interface { - Create(context.Context, NewRun) (CreateResult, error) - Load(context.Context, RunID) (RuntimeSnapshot, error) - Commit(context.Context, CommitRequest) (CommitResult, error) - Record(context.Context, RunID) (RunRecord, error) - RenewLease(ctx context.Context, runID RunID, stepID StepID, callID CallID, grant ExecutionGrant) error + 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) + RenewLease(ctx context.Context, sessionID session.SessionID, runID RunID, stepID StepID, callID CallID, grant ExecutionGrant) error RecoverExpired(context.Context) (int, error) } +// RunPosition 是该 RunID 最后一条 twilight/run/ 事件在 stream 中的位置; +// 只有这个 Run 自己的事件会移动它。 +type RunPosition struct { Revision es.Revision; Index uint16 } type RuntimeSnapshot struct { State MachineState // detached in-process view - Revision uint64 - SchemaVersion uint16 // RunHeader.SchemaVersion + Position RunPosition + Head session.Head // 投影覆盖到的 Session head + ProtocolVersion uint16 +} + +// Companion 把一个 commit 的 Run facts 与 command 携带的 transient 内容映射为 +// 其他模块的事件(对话内容、Turn completed)。实现由 agent/turn 提供(TRN-CMP)。 +type CompanionRequest struct { + Session session.SessionID + Turn TurnID + RunID RunID + Command AgentCommand + Facts []Fact + State MachineState // Evolve 后 + RecordedAtUnixMilli int64 +} +type Companion interface { + Version() string + Map(CompanionRequest) ([]session.UncommittedEvent, error) } type Protocol struct { - // ProtocolFor 一次绑定该 SchemaVersion 的函数。方法不再接受 version 参数。 - // 零值 Version()==0,不得调用。 + // ProtocolFor 一次绑定该 ProtocolVersion 的函数。方法不再接受 version 参数。 } -func ProtocolFor(schemaVersion uint16) (Protocol, error) -func (RuntimeSnapshot) Protocol() (Protocol, error) // ProtocolFor(SchemaVersion) +func ProtocolFor(protocolVersion 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) DigestToolSpec(ToolSpec) (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) DigestToolResponsePayload(CanonicalJSON) (Digest, error) func (Protocol) DigestCommand(typ string, command AgentCommand) (Digest, error) -func (Protocol) DigestFact(typ string, fact Fact) (Digest, error) -func (Protocol) EncodeFact(typ string, fact Fact) ([]byte, error) -func (Protocol) DecodeCommand(typ string, raw []byte) (AgentCommand, error) -func (Protocol) DecodeFact(typ string, raw []byte) (Fact, error) +func (Protocol) EncodeFact(typ string, fact Fact) (jsonstable.Value, error) +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(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) -func (Protocol) EncodeMachineState(*MachineState) ([]byte, error) // 持久化 snapshot bytes,含 Current -func (Protocol) DecodeMachineState([]byte) (MachineState, error) -func (Protocol) ValidateHeader(*RunHeader) error -func ProtocolV1() Protocol // SchemaVersion1 绑定;新 Run 的创建版本。没有委托它的包级函数 +func (Protocol) BuildEnvelope(session.SessionID, RunID, CommandID, AgentCommand) (CommandEnvelope, error) +func (Protocol) BuildCreateGroup(NewRun, []AgentInput) ([]Fact, error) // created 与 input_accepted;编码为 Session event 由 agent/session/run 完成 +func (Protocol) EncodeMachineState(*MachineState) (jsonstable.Value, error) +func (Protocol) DecodeMachineState(jsonstable.Value) (MachineState, error) +func ProtocolV1() Protocol + type CommitRequest struct { - BaseRevision uint64 + Base RunPosition // Load 时的 Position;PrepareModelRequest 为 hard CAS Grant ExecutionGrant Command CommandEnvelope + Attach []session.UncommittedEvent // 调用方附加事件,追加在 companion 之后;例如 Coordinator.Stop 的 twilight/turn/failed } type CommitResult struct { Status CommitStatus // CommitAccepted | CommitAlreadyApplied Snapshot RuntimeSnapshot - Events []AgentEvent + Commit session.SessionCommit // 完整 commit:run facts 与 companion events Grant ExecutionGrant } ``` -**RUN-CMT-1** Runtime 是 RunID-addressed collection。`Create` 原子保存 immutable header 与 Revision-0 state;相同 canonical header 幂等返回 `Created=false`,同 RunID 的不同 header 返回 `ErrCreateConflict`。缺失 Run 的 Load、Commit、Record 返回 `ErrRunNotFound`。 +**RUN-CMT-1** Runtime 按 `(SessionID, RunID)` 寻址。Run 由 Coordinator 的 Start commit 创建(TRN-STR-2),Runtime 没有 `Create`。缺失 Run 的 Load、Commit、Record 返回 `ErrRunNotFound`。 + +**RUN-CMT-2** 投影 `twilight/run/machine` 消费全部 `twilight/run/` 事件,忽略其他模块事件(EXT-PRJ-2 的 `Ignores`),状态为: + +```go +type MachineProjection struct { + Active map[RunID]MachineState // 非终态 Run + Positions map[RunID]RunPosition // 每个 Run 最后一条事件的位置 + Results map[RunID]RunResult // 已终结 Run 的结果 +} +``` -**RUN-CMT-2** `Load` 返回当前 detached execution snapshot,并验证 `RunID`、revision 与 MachineState 的基本语义不变量;snapshot 与 revision 必须来自同一 authority 版本。`RuntimeSnapshot` 提供 Go 读取视图。`Protocol.EncodeMachineState` / `DecodeMachineState` 定义 MachineState 的持久化 snapshot wire(含 `Current` 判别式与 step body);Store 以该 wire 保存每个 revision 的 snapshot,`Load` 直接读取 snapshot 而不折叠日志。canonical record 仍是 Header 与 TransitionRecord;snapshot 是派生数据,`Record` 与 `Rebuild` 通过 FoldRun 校验它。`Record` 在一个一致点读取 detached Header、Snapshot 和完整 TransitionRecord sequence,验证 header、每个 transition、连续 fold 与 snapshot 等价后返回;corrupt、gap 或 divergence 必须失败。普通消费者使用 Record 获取一致的完整记录。`FoldRun` 从 Header 和完整 transition sequence 重建状态;任何导入、诊断或 Record 校验先完成 FoldRun,再使用重建状态。 +ProjectionKey 为 `twilight/run/machine`,snapshot 使用 Session snapshot envelope(SES-SNP)。`Load` 读取 snapshot 后只 fold snapshot 之后、类型前缀为 `twilight/run/` 的 tail,代价与 Session 全量日志长度无关。终态 Run 的 MachineState 不保留在投影内,读取终态 Run 的完整状态使用 `Record`。`Record` 在一个一致点 resolved replay 该 RunID 的全部 `twilight/run/` 事件,FoldRun 重建并与投影比对,corrupt、gap 或 divergence 必须失败。 -所有 Runtime implementation 在自己的 critical section/transaction 内调用同一个 pure `EvaluateCommit`。顺序固定为: +**RUN-CMT-3** Commit 在 Session Store 的临界区内完成(SES-API-2)。所有 Runtime implementation 在该 section 内调用同一个 pure `EvaluateCommit`,顺序固定为: ```text -1 validate envelope RunID/schema/type/digest(digest 不匹配为不可重试错误,不映射为 conflict) -2 lookup prior transition by CommandID -3 exact digest replay -> AlreadyApplied + original complete event group -4 same CommandID/different digest -> conflict -5 derived CommandID check (after replay) -6 terminal check -7 validate hard CAS / target state / execution grant / recovery authority -8 facts = Protocol.Decide(current, command) exactly once -9 snapshot each fact; Protocol.Evolve in order; assign next Revision and Index -10 build and validate one complete TransitionRecord -11 atomically persist transition and new MachineState +1 validate envelope SessionID/RunID/type/digest(digest 不匹配为不可重试错误) +2 tx.LookupCommit(CommitID = CommandID) +3 found -> AlreadyApplied + 原完整 commit;start 精确重放且 grant live 时返回原 grant +4 derived CommandID check +5 state = tx.Projection(twilight/run/machine).Active[RunID];缺失为 ErrRunNotFound;terminal check +6 validate hard CAS(prepare 的 Base == 投影中该 Run 的 Position)/ target state / execution grant / recovery authority +7 facts = Protocol.Decide(state, command) exactly once +8 Protocol.Evolve in order;encode run events +9 companion events = Companion.Map(...)(TRN-CMP);再追加 request.Attach +10 frozen bodies -> FrozenValueStore.Put(幂等) +11 tx.Append(events);apply lease ops;replace projection snapshot —— 同一事务 ``` -**RUN-CMT-3** `PrepareModelRequest` 是 hard-CAS command:BaseRevision 必须等于 current revision。其他 command 通过当前 target state 和 grant 做 call-local rebase;stale BaseRevision 本身不阻止无冲突的 ingress/control/settlement。相同 command 的 replay 判定先于 terminal check,因此 terminal Run 仍能返回原 transition。 +`Attach` 中的事件必须属于 Catalog 已知的 EventType,且不得是 `twilight/run/` 事件;Runtime 不解释其内容,只保证它们与本 commit 同时可见。 -**RUN-CMT-4** 幂等键为 `(RunID,CommandID)`。相同 digest 返回 `CommitAlreadyApplied`、当前 snapshot 与原完整 event group,且不得再次 Decide、分配 revision 或产生外部 effect。对于 `StartModelExecution` 和 `StartToolCall`,Runtime 还必须验证 command 中的 `ExecutionClaim`:相同 command ID、相同 digest、相同 claim 的精确重放在 grant 仍 live 时返回原 start grant;不同 claim 触发 `ErrCommandConflict`,并保持现有执行授权。非 start command 的 replay 不返回 grant。revision 每个 accepted command 恰加 1。 +**RUN-CMT-4** `PrepareModelRequest` 是 hard-CAS command:`Base` 必须等于 section 内投影记录的该 Run 的 `Position`。其他模块写入 Session 不移动 Position,因此不会使 Prepare 失效。其他 command 通过当前 target state 和 grant 做 call-local rebase;stale Base 本身不阻止无冲突的 ingress/control/settlement。相同 command 的 replay 判定先于 terminal check,因此 terminal Run 仍能返回原 commit。 -**RUN-CMT-5** accepted `StartModelExecution`/`StartToolCall` 为目标签发新 grant;该 start 的 `CommitAccepted` 和在 grant 仍 live 时满足精确 replay 条件的 `CommitAlreadyApplied` 返回同一个 grant。若该 start 已 settlement 或 Run 已 terminal,精确 replay 仍返回 `CommitAlreadyApplied`,并返回空 grant。model result/failure/reject 与 executing tool result/known failure 必须携带 live target grant。settlement 接受后 grant 失效;terminal transition 撤销全部 grant。`RecoverModelExecution` 由 live grant holder 提交,或在 Runtime 验证 lease 已过期且 command Claim 等于该 lease 的 Claim 后无 grant 提交。Executing tool 的 recovery 使用同一条 `SubmitToolFailure{Outcome:Unknown}` command:工具 owner 必须携带 live grant;`RecoverExpired` 仅在 lease 已过期且没有已接受 settlement 时无 grant 提交。该 Unknown 只结算这一 call,Run 保持 Active。scanner 使用确定性 recovery CommandID,因而 recovery update 也遵守同一 `(RunID, CommandID)` 幂等规则。attempt、owner、fence、lease 与 recovery record 由 Store adapter 内部管理。 +**RUN-CMT-5** 幂等键为 Session kernel 的 `(SessionID, CommitID)`,CommitID 等于 CommandID。同 CommandID 的精确重放返回 `CommitAlreadyApplied`、当前 snapshot 与原完整 commit,且不得再次 Decide 或产生外部 effect。对于 `StartModelExecution` 和 `StartToolCall`,Runtime 还必须验证 command 中的 `ExecutionClaim`:相同 CommandID、相同 digest、相同 claim 的精确重放在 grant 仍 live 时返回原 start grant;不同 claim 触发 `ErrCommandConflict`,并保持现有执行授权。非 start command 的 replay 不返回 grant。 -**RUN-CMT-6** Commit 必须原子保存新 MachineState 与完整 TransitionRecord,保证 event group 完整写入。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 +**RUN-CMT-6** accepted `StartModelExecution`/`StartToolCall` 为目标签发新 grant;该 start 的 `CommitAccepted` 和在 grant 仍 live 时满足精确 replay 条件的 `CommitAlreadyApplied` 返回同一个 grant。若该 start 已 settlement 或 Run 已 terminal,精确 replay 仍返回 `CommitAlreadyApplied`,并返回空 grant。model result/failure/reject 与 executing tool result/known failure 必须携带 live target grant。settlement 接受后 grant 失效;terminal commit 撤销该 Run 全部 grant。`RecoverModelExecution` 由 live grant holder 提交,或在 Runtime 验证 lease 已过期且 command Claim 等于该 lease 的 Claim 后无 grant 提交。Executing tool 的 recovery 使用同一条 `SubmitToolFailure{Outcome:Unknown}` command:工具 owner 必须携带 live grant;`RecoverExpired` 仅在 lease 已过期且没有已接受 settlement 时无 grant 提交。该 Unknown 只结算这一 call,Run 保持 Active。 -**RUN-CMT-7** 每个 Run 的协议版本是 `RunHeader.SchemaVersion`,在 Create 时冻结。`RuntimeSnapshot.SchemaVersion` 必须等于该 header。`ProtocolFor(header.SchemaVersion)` 在 Run 边界返回绑定了该版本 digest/decode/Decide/Evolve 函数的 `Protocol` 值;随后的方法调用不再接受 version 参数。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Protocol 的 Version。`agent/run` 不保存进程全局的当前写入版本,也不提供隐式选择版本的包级函数;新 Run 由 `NewRun.SchemaVersion` 决定版本。v1 Run 的 replay 必须继续使用 `ProtocolV1()`,即使进程已支持更高版本。Loop 通过 `RuntimeSnapshot.Protocol().BuildEnvelope` 构造写入该 Run 的 envelope。 +**RUN-CMT-7** Commit 必须原子保存完整 SessionCommit、lease 变更与投影 snapshot;`FrozenValueStore.Put` 幂等,可在 section 内或之前完成。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 -`Runtime` 的唯一实现叠在 `Store` 上。`Store` 是追加式合同: +**RUN-CMT-8** Run 事实的协议版本是 Session 的 `ProtocolVersion`。`RuntimeSnapshot.ProtocolVersion` 等于 Session Header 的版本;`ProtocolFor(version)` 返回绑定该版本 digest/codec/Decide/Evolve 的 `Protocol`。`agent/run` 不保存进程全局的当前写入版本。历史 Session 由外部 migration tool 迁移(SES-WIR-4)。 -```go -type RunHead struct { Header RunHeader; State MachineState; Revision uint64; Leases map[string]ExecutionLease } -type LeaseOps struct { Put map[string]ExecutionLease; Delete []string; Clear bool } -type Append struct { ExpectedRevision uint64; Transition TransitionRecord; State MachineState; Leases LeaseOps } -type ExpiredLease struct { RunID RunID; Key string; Lease ExecutionLease } -type Store interface { - Create(ctx, header RunHeader) (created bool, existing RunHeader, err error) - LoadHead(ctx, RunID) (RunHead, error) - LoadLog(ctx, RunID, from uint64) ([]TransitionRecord, error) - LoadRecord(ctx, RunID) (RunHead, []TransitionRecord, error) - LookupTransition(ctx, RunID, CommandID) (TransitionRecord, bool, error) - Commit(ctx, RunID, fn func(RunTx) (*Append, error)) error - // RunTx: Head() RunHead; LookupTransition(CommandID) (TransitionRecord, bool, error) - RenewLease(ctx, RunID, key string, grant ExecutionGrant, deadline time.Time) error - ExpiredLeases(ctx, before time.Time) ([]ExpiredLease, error) - ReplaceSnapshot(ctx, RunID, revision uint64, *MachineState) error -} -``` +### 5.1 控制面 -`Store` 不调用 Decide/Evolve。`Store.Commit` 是唯一推进 Run 的写路径,也是该 Run 的 critical section:Store 在其中向 Runtime 提供 `RunTx`(当前 head 与按 CommandID 查 transition),Runtime 在同一 section 内调用 `EvaluateCommit`,返回的 `Append` 由 Store 在同一事务内追加 transition、替换 snapshot 并应用 lease 变更。同一 Run 的所有写入者在此串行,head 在读与写之间不会移动,因此没有 compare-and-swap 重试;`ExpectedRevision` 与 head 不一致只可能是实现错误,返回 `ErrAppendConflict`。`MemoryStore` 用 per-Run 锁,SQLite 用 immediate 写事务,Postgres 用行锁事务实现该 section。日志不可改写:没有删除或替换 transition 的 Store 方法;`ReplaceSnapshot` 只覆盖派生 snapshot,且只由 `Rebuild` 调用。`LoadRecord` 在一个一致读取内返回 head 与完整日志,`Runtime.Record` 与 `Rebuild` 只用它。`Load` 只读 head,`Commit` 只在 section 内读 head 与一条 transition,二者的代价都与日志长度无关。`MemoryStore`、SQLite、Postgres 实现同一合同,并通过同一 conformance。 +grant、lease、ExecutionClaim、ClaimStore、幂等索引与投影 snapshot 都不进入 stream。 -lease 以 `(RunID, key)` 存放,key 为 `model/` 或 `call//`,一个 target 至多一条 live lease;grant 只存在于该 lease 上,没有独立的 grant 表。lease 的 `Deadline` 为零表示不超时(进程内占用);过期且无 settlement 时 Runtime 将 `recoveryValid` 置真,允许 grantless Recover。进程崩溃时不写 settlement。`RecoverExpired` 通过 `Store.ExpiredLeases(now)` 只加载有过期 lease 的 Run:对每个过期 Executing tool call 无 grant 提交 `SubmitToolFailure{Unknown}`,对过期 Executing model 提交 `RecoverModelExecution`。该 Run 保持 Active,同一 RunID 继续。`Rebuild` 从 header 与完整 transition log 重折 snapshot,与存储的 snapshot 比较后经 `ReplaceSnapshot` 修复,供诊断使用;日志短于 head revision 返回 `ErrLogTruncated`。进程内宿主使用 `NewRuntime(NewMemoryStore())`,lease 不超时,grantless recover 被拒绝。生产崩溃恢复使用带 TTL 的 Runtime。 +lease 以 `(SessionID, RunID, key)` 存放,key 为 `model/` 或 `call//`,一个 target 至多一条 live lease;grant 只存在于该 lease 上。lease 的 `Deadline` 为零表示不超时(进程内占用);过期且无 settlement 时 Runtime 允许 grantless Recover。进程崩溃时不写 settlement。`RecoverExpired` 通过 lease 表的过期扫描只加载有过期 lease 的 `(SessionID, RunID)`:对每个过期 Executing tool call 无 grant 提交 `SubmitToolFailure{Unknown}`,对过期 Executing model 提交 `RecoverModelExecution`。该 Run 保持 Active,同一 RunID 继续。进程内宿主使用 Memory 实现,lease 不超时,grantless recover 被拒绝。生产崩溃恢复使用带 TTL 的 Runtime。 -**RUN-CMT-8** lease 续期。`Runtime.RenewLease(runID, stepID, callID, grant)` 在 grant 等于该 target 当前 lease 的 grant 时,把 deadline 推后一个 `LeaseTTL`;lease 不存在、grant 不匹配或 target 已 settlement 时返回 `ErrStaleRuntime`。持有 grant 的 worker 在效果执行期间必须以远小于 `LeaseTTL` 的间隔续期(Loop 的 `ExecutionPolicy.LeaseRenewInterval`);续期返回 `ErrStaleRuntime` 表示该 target 已被 recovery 接管,worker 必须停止执行并放弃 settlement。`LeaseTTL` 是恢复延迟上界,不再是单次工具调用的时长上界。`LeaseTTL` 为零时 `RenewLease` 只验证 grant,不改变 deadline。 +**RUN-CMT-9** lease 续期。`Runtime.RenewLease` 在 grant 等于该 target 当前 lease 的 grant 时,把 deadline 推后一个 `LeaseTTL`;lease 不存在、grant 不匹配或 target 已 settlement 时返回 `ErrStaleRuntime`。持有 grant 的 worker 在效果执行期间必须以远小于 `LeaseTTL` 的间隔续期(Loop 的 `ExecutionPolicy.LeaseRenewInterval`);续期返回 `ErrStaleRuntime` 表示该 target 已被 recovery 接管,worker 必须停止执行并放弃 settlement。`LeaseTTL` 是恢复延迟上界。`LeaseTTL` 为零时 `RenewLease` 只验证 grant,不改变 deadline。 ## 6. Loop ports 与 policy ```go // package agent/run type PlanningHint struct { + Session session.SessionID + Turn TurnID RunID RunID SourceStep StepID Inputs []AgentInput - LastToolStep *ToolStep - LastModelResult *ModelResult } // package agent/run/loop type RequestPlanner interface { @@ -437,7 +445,7 @@ type RequestPlan struct { Request sdk.Request InputIDs []run.InputID PlanningToken run.PlanningToken - Tools []run.ToolSpec + 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) } @@ -470,10 +478,10 @@ type ExecutionPolicy struct { OnMalformedModelResult func(run.ModelStep, run.StepFailure) run.ModelRejectDisposition } type ClaimStore interface { - Put(ctx, run.RunID, run.StepID, run.CallID, run.ExecutionClaim) error - Get(ctx, run.RunID, run.StepID, run.CallID) (run.ExecutionClaim, bool, error) - Delete(ctx, run.RunID, run.StepID, run.CallID) error - DeleteRun(ctx, run.RunID) error + Put(ctx, session.SessionID, run.RunID, run.StepID, run.CallID, run.ExecutionClaim) error + Get(ctx, session.SessionID, run.RunID, run.StepID, run.CallID) (run.ExecutionClaim, bool, error) + Delete(ctx, session.SessionID, run.RunID, run.StepID, run.CallID) error + DeleteRun(ctx, session.SessionID, run.RunID) error } type LoopResult struct { Disposition LoopDisposition // LoopWaiting | LoopFinished @@ -482,24 +490,23 @@ type LoopResult struct { Result *run.RunResult } func New(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy ExecutionPolicy, streaming bool) (*Loop, error) -func (*Loop) Run(context.Context, run.Runtime, run.RunID, EventSink) (LoopResult, 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`。`LeaseRenewInterval` 是 worker 续期间隔(RUN-CMT-8):模型与工具 worker 在效果执行期间按该间隔调用 `Runtime.RenewLease`,续期被拒时取消该 worker 的 ctx;零值关闭续期,只对 lease 不超时的 Runtime 正确。 +**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`。`LeaseRenewInterval` 是 worker 续期间隔(RUN-CMT-9):模型与工具 worker 在效果执行期间按该间隔调用 `Runtime.RenewLease`,续期被拒时取消该 worker 的 ctx;零值关闭续期,只对 lease 不超时的 Runtime 正确。 **RUN-LOP-7** `ModelRef` 是冻结请求中的执行身份。`ModelCatalog.ResolveModel` 在同一 Run 生命周期内必须把同一 `ModelRef` 解析为等价的执行语义。provider 绑定不进入 frozen request,因此 Catalog 不得把同一 ref 改绑到不同实现。 -`LoopResult` 的语义固定为:`LoopWaiting` 时 `Result` 为 nil,表示没有可执行 effect、Run 仍为 active。`ExecutionRecovery` 等于 `NeedsRecovery(state)`:Model Executing,或 ToolStep 无 Pending 且仍有 Executing。该值为 true 时由 recovery authority 唤醒,`Reason` 为 `execution_recovery`;否则 `Reason` 为空。Waiting call 不进入 `LoopResult`;Application 通过 `Runtime.Load` / `Record` 与 `WaitingCalls` 读取。`LoopFinished` 时 `Result` 非 nil,并等于 terminal RunRecord 派生的 `RunResult`。 +`LoopResult` 的语义固定为:`LoopWaiting` 时 `Result` 为 nil,表示没有可执行 effect、Run 仍为 active。`ExecutionRecovery` 等于 `NeedsRecovery(state)`。该值为 true 时由 recovery authority 唤醒,`Reason` 为 `execution_recovery`;否则 `Reason` 为空。Waiting call 不进入 `LoopResult`;Application 通过投影的 `WaitingCalls` 读取。`LoopFinished` 时 `Result` 非 nil,并等于 terminal Run 的 `RunResult`。 -`RequestPlanner` 从 `PlanningHint` 接收 Run 边界事实;它使用自己注入的 history、session context、memory、attachments 与 product policy 组装 `sdk.Request`。Runtime 验证并冻结 planner 返回的 request,Planner 管理 application context。 +`RequestPlanner` 从 `PlanningHint` 接收 Run 边界事实;它从 Session 的 chatlog fold 读取对话内容(上一步的 assistant 与 tool_result 已随 Run fact 同 commit 提交),并使用自己注入的 memory、attachments 与 product policy 组装 `sdk.Request`。Runtime 验证并冻结 planner 返回的 request,Planner 管理 application context。 ## 7. Loop execution ```text -Loop.Run(ctx, runtime, runID, sink): +Loop.Run(ctx, runtime, sessionID, runID, sink): repeat: - snapshot = Runtime.Load(runID) - validate snapshot.State.RunID == runID + snapshot = Runtime.Load(sessionID, runID) if terminal: emit observational run_finished; return Finished(snapshot.Result) if resumeSettlement: continue // 重放可能已提交、响应丢失的 settlement if resumeCachedStart: continue // 重放本进程已接受、尚未结算的 start @@ -507,23 +514,24 @@ Loop.Run(ctx, runtime, runID, sink): dispatch effect ``` -每个 `Loop` 实例为每个 `RunID` 分配一个本地 driver slot。同一实例对同一 `RunID` 的并发 `Run` 调用返回 `ErrRunAlreadyRunning`;不同 `RunID` 可以并行驱动。 +每个 `Loop` 实例为每个 `(SessionID, RunID)` 分配一个本地 driver slot。同一实例对同一 Run 的并发 `Run` 调用返回 `ErrRunAlreadyRunning`;不同 Run 可以并行驱动。 -**RUN-LOP-2** `NeedModelRequest` 调用 Planner,冻结 sdk.Request,验证 model、ordered InputIDs 与 ToolSpecs,计算 request/tools/binding digests 和 derived CommandID/StepID,再提交 Prepare。prepare stale 后重新 Load;同 revision 的内容拒绝不得 livelock 重试。业务停止统一使用 `CancelRun`。 +**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-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须在 `ClaimStore` 中保留该 attempt 的 claim 直到完成 settlement,其余 identity 按需派生;缺少 grant 的 replay 进入 reload 流程。调用只使用 frozen ModelRequest 的 detached SDK materialization。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-3** `StartModelCall` 先 Commit start barrier;首次 `CommitAccepted` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须在 `ClaimStore` 中保留该 attempt 的 claim 直到完成 settlement,其余 identity 按需派生;缺少 grant 的 replay 进入 reload 流程。调用使用 `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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call,在外层 ctx 未取消时于本次 `Run` 内按冻结 Scheduling 分批 Start 并结算;ctx 已取消时停止再 Start,只结算已持有 grant 的 call。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从 snapshot 读取 `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;Executing 且本进程持有 start cache 时只重放 start 并结算,不得再提交 grantless Known。 +**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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call,在外层 ctx 未取消时于本次 `Run` 内按冻结 Scheduling 分批 Start 并结算;ctx 已取消时停止再 Start,只结算已持有 grant 的 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;Executing 且本进程持有 start cache 时只重放 start 并结算,不得再提交 grantless Known。 **RUN-LOP-5** model 与 tool worker 都接收外层 ctx;Loop 对已接受 effect 使用独立 control context 完成 known/unknown outcome settlement。Application 的业务停止顺序为先 Commit `CancelRun`,再取消 Loop ctx。非 sentinel Commit error 以同 CommandID/digest 重放一次;仍未知时返回错误,由后续 Load/Record 查询 authority。stale/terminal/conflict 触发 reload/drop,旧 external effect 保持单次执行尝试。工具实现配合 context 返回;永久阻塞由 application/durable recovery 处理。 -Waiting call 的批准与外部结果由 Application 提交。Loop 不生成、不返回、不解释 `ResponseRequest`。Application 以 snapshot 中的 stable ResponseID、derived CommandID 与 payload/decision digest 提交 `ApproveToolCall`、`RejectToolCall` 或 `SubmitToolResponse`;随后再次运行 Loop。 +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 @@ -531,31 +539,33 @@ type Event struct { Kind EventKind Durability EventDurability Payload json.RawMessage - Canonical *run.AgentEvent + Committed *session.SessionCommit } ``` -`Sequence` 仅用于同一临时观察流内的顺序(例如 ToolProgress),从 1 开始;committed observation 的权威顺序由 `AgentEvent.Revision` 与 `Index` 表达,未提供临时序号时保持 0。 +`Sequence` 仅用于同一临时观察流内的顺序(例如 ToolProgress),从 1 开始;committed observation 的权威顺序由 Session `(Revision, Index)` 表达,未提供临时序号时保持 0。 -**RUN-LOP-6** EventSink 提供 realtime observation,Loop 通过序列化调用向 sink 发送事件。`EventAgentCommitted` 携带 accepted transition 中的 canonical AgentEvent;text/reasoning delta、tool progress、tool lifecycle 与 run-finished observation 可丢失、重复或断流。sink failure 保持 Commit 结果;恢复、materialization 与审计读取 `Runtime.Record`,EventSink gap 通过 canonical record 对账。 +**RUN-LOP-6** EventSink 提供 realtime observation,Loop 通过序列化调用向 sink 发送事件。`EventAgentCommitted` 携带 accepted commit;text/reasoning delta、tool progress、tool lifecycle 与 run-finished observation 可丢失、重复或断流。sink failure 保持 Commit 结果;恢复与审计读取 Session stream,EventSink gap 通过 stream 对账。 -可进入区间是 `Current=Open`。Application 在此提交 `AcceptInput`,并负责 admission 与 planning 之间的线性化。Loop 不解释 queue 或 steer:`Open` 时立刻 `NeedModelRequest`。Run→Session materialization、terminal settlement、stable cross-domain IDs 与 crash recovery 由 [agent-turn.md](agent-turn.md) 定义。 +可进入区间是 `Current=Open`。Application 在此提交 `AcceptInput`,并负责 admission 与 planning 之间的线性化。Loop 不解释 queue 或 steer:`Open` 时立刻 `NeedModelRequest`。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、字段或折叠语义必须进入新 schema version,并继续 decode/fold 全部已发布版本。同一 Run 的 writer 不得混写不兼容 schema。 +**RUN-CMP-1** 当前 pre-release 的 command/fact discriminator、wire fields、canonical digest、derived ID 和 `ProtocolV1().Evolve` 由 golden fixtures 保护;发布前有意修改协议时必须同步更新 fixture。发布后,新增 variant、字段或折叠语义遵循 Session `ProtocolVersion` 的兼容性判定(SES-WIR-4)。 **RUN-CMP-2** Runtime conformance 必须覆盖: -- Create 首次/幂等/conflict、并发 Create、missing Run; +- Start group 建立 Run、重复 `created` 拒绝、missing Run; - command exact replay/conflict、prepare hard CAS、call-local rebase、terminal replay; - grant 签发、隔离、精确 start replay、消费、跨 Run 拒绝与 recovery authorization; -- revision/index、atomic complete TransitionRecord、fact/transition digest; -- Load/Commit/Record alias isolation、多 Run 隔离; -- Record 单一一致点、FoldRun 等价、gap/tamper/corrupt failure; +- 一 command 一 commit、run facts 在 companion 与 Attach 之前、companion 的 `SourceDigest` 等于 fact 记录的 ResultDigest / OutputDigest、Attach 拒绝 `twilight/run/` 事件; +- Prepare hard CAS 只对该 Run 自己的事件敏感:同一 Session 内其他模块的写入不使 Prepare 失效; +- 投影 snapshot 加 tail 与全量 fold 等价;Record 单一一致点、FoldRun 等价、gap/tamper/corrupt failure; +- 同一 Session 内多 Run 隔离;同一 Session 的 chatlog/turn 事件不影响 Run fold; - lease 过期 recovery:live lease 拒绝 grantless、过期 model 回到 Prepared、过期 tool 记 Unknown 且 sibling 不受影响、RecoverExpired 幂等; - lease 续期:续期后原 deadline 不触发 recovery、错误/空 grant 与 settlement 后续期被拒; -- snapshot codec:每个 Current variant 与终态 round-trip、拒绝 unknown field / 非法判别式 / trailing data;重开 Store 后 Load 不折叠日志且 Record 校验通过。 +- FrozenValueStore:Put 幂等、Recovered 后按 RequestDigest 取回同一请求、本体缺失的错误分类、step 终结后删除本体不影响 Record; +- MachineState codec:每个 Current variant 与终态 round-trip、拒绝 unknown field / 非法判别式 / trailing data。 Loop conformance 必须覆盖: @@ -563,7 +573,7 @@ Loop conformance 必须覆盖: - known failure 继续、Unknown 继续、tool panic、aliased ToolRef 与 validation; - parallel/sequential 按冻结 `ToolStep.Scheduling` 调度,不得改用当时 ExecutionPolicy; - `ModelCatalog.ResolveModel` 失败或 nil 时恢复 ModelStep、Run 保持 active; -- ctx cancellation、model recovery、explicit malformed-result disposition; +- 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; - stale/unknown commit response、prepare no-progress rejection 与无 livelock; diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 7701bef..b8be367 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -33,37 +33,39 @@ ### 2.1 authority ```text -MachineState Run 的语义状态投影 -TransitionRecord canonical Run commit record -Runtime Run authority、RunID-addressed access 与 atomic commit boundary -Session Events 跨 Run 的长期语义事实 +Session stream 唯一 authority:twilight/turn、twilight/chatlog、twilight/run 事件同在一条 stream +MachineState Run 的语义状态投影(twilight/run/machine),snapshot 为派生缓存 +Runtime Run command 的提交入口:Session commit 临界区内 Decide、Evolve、追加 +FrozenValueStore 内容寻址旁存:模型请求与工具定义本体 ``` -Run snapshot 支持直接恢复执行;immutable header 与 transition log 支持 verified replay、materialization 与审计。Session 只接收经 Turn materialization 的长期语义 events。 +2026-09-04 之前的设计为两条 ES(Run 独立的 `RunHeader + TransitionRecord[]`,Turn 把 Run 事实 materialize 到 Session)。该设计已被第 6 节记录的决定取代。 ### 2.2 package layout ```text agent/es shared ES primitives agent/jsonstable immutable canonical JSON -agent/run Run Machine、persisted protocol、Runtime、Store、MemoryStore +agent/run Run Machine、frozen values、fact codec、fold、Runtime contract agent/run/loop in-process model/tool interpreter 与 observation ports -agent/session Event-first Session kernel +agent/session Event-first Session kernel(Commit CAS 与 CommitIn 临界区) agent/session/extension Session Module Framework:static modules、codec、semantic append、projection agent/session/chatlog first-party Message ontology +agent/session/run first-party Run module:EventDefinition、machine projection、Runtime 实现、FrozenValueStore 与 lease adapter agent/artifact Ref、Binding、RetentionClaim -agent/turn Turn→Run coordination 与 Run→Session materialization +agent/turn Turn 生命周期、attempt、companion 映射 ``` -文件用于提高同一 package 内的导航性;subpackage 只用于依赖限制和独立变化轴。Loop 因依赖 SDK execution、streaming、并发和工具 ports 而独立成 `agent/run/loop`。Machine、Runtime、protocol 与 Store 保持在根 `agent/run`,避免 sealed variants、codec、Decide/Evolve 和 adapter internals 之间形成 cycle 或镜像 DTO。 +文件用于提高同一 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 + adapters -agent/run/loop -> agent/run + sdk -agent/run -> agent/es + agent/jsonstable + sdk -agent/turn -> agent/run + agent/session + agent/session/chatlog + session modules +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 ``` 根 `agent/run` 不提供 Loop alias、wrapper 或 façade。 @@ -74,9 +76,10 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + session mo - 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 从 Session facts 与 `Runtime.Record` 重建,不保存隐藏的长期状态。 -- Run→Session materialization 按完整 revision coverage 和 stable identity exactly-once 收敛。 -- lease 与其 recovery(`RenewLease`、`RecoverExpired`)是 `run.Runtime` public contract 的一部分,由 [agent-run.md](agent-run.md) 第 5 节定义;`Store` 只持久化 lease 记录,不解释它。durable owner/fence/outbox 仍是 adapter control plane,不进入 `run.Runtime`。 +- Turn Coordinator 从 `twilight/turn/surface` 与 `twilight/run/machine` 投影重建,不保存隐藏的长期状态。 +- Run 事实与其对话内容(companion)在同一 SessionCommit 写入;没有 Run→Session materialization、coverage 水位或 outbox。 +- lease 与其 recovery(`RenewLease`、`RecoverExpired`)是 `run.Runtime` public contract 的一部分,由 [agent-run.md](agent-run.md) 第 5 节定义;lease、grant、ExecutionClaim、ClaimStore、投影 snapshot 与 FrozenValueStore 是控制面或派生数据,不进入 stream。 +- Run fact 只保存执行状态与内容 digest;请求与工具定义本体在 FrozenValueStore,模型输出与工具输出在 chatlog 事件。 ## 3. 已完成迁移 @@ -86,12 +89,13 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + session mo | shared `agent/es` 与 RFC 8785 canonical JSON | 完成 | | Decide/Evolve/Next Run Machine | 完成 | | RunHeader、TransitionRecord、wire codec、fold/golden tests | 完成 | -| RunID-addressed `Runtime.Create/Load/Commit/Record` | 完成 | -| multi-Run Store-backed Runtime 与 Runtime conformance | 完成 | -| 追加式 `Store` 合同(LoadHead / LoadLog / LoadRecord 单一致读 / Commit critical section / ExpiredLeases)、snapshot codec、lease 续期 | 完成 | -| SQLite Store adapter(snapshot 加日志、lease 表)通过 Runtime / recovery / renewal conformance | 完成 | +| RunID-addressed `Runtime.Create/Load/Commit/Record` | 完成;待按第 6 节改为 Session module 形态 | +| multi-Run Store-backed Runtime 与 Runtime conformance | 完成;conformance 待迁移到 Session Store 之上 | +| 追加式 per-Run `Store` 合同、snapshot codec、lease 续期 | 完成;per-Run Store 将由 Session Store 的 `CommitIn` 取代,lease 表与 snapshot codec 保留 | +| SQLite per-Run Store adapter | 完成;随 per-Run Store 退役,SQLite 实现改为 Session Store adapter | | `agent/run/loop` package extraction | 完成 | -| Turn 最小实现(`agent/turn`:Start / Resume / Stop、v1 FactMapper、append-only MemoryLog、崩溃后 Resume) | 完成;Session kernel、Extension、Artifact 未接入,Log 为纵向切片的替身 | +| Turn 最小实现(`agent/turn`:Start / Resume / Stop、v1 FactMapper、append-only MemoryLog、崩溃后 Resume) | 完成;FactMapper 与 MaterializeAll 将改为 companion,Log 由 Session Store 取代 | +| 单一 Session ES 的设计文档(agent-run 第 5 节、agent-turn、session CommitIn、extension/chatlog 修订) | 完成,2026-09-04 | | Session/Artifact/Session Module protocols | 草案,无实现;wire 在 Memory 纵向切片跑通前不冻结 | | Chatlog protocol | 草案,payload 与 golden 尚未冻结 | | 参考组装 | 草案 | @@ -103,19 +107,18 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + session mo ### 4.1 Core reference implementations -- 冻结 Session、Artifact 与 Chatlog v1 wire profiles、domain separators、wire-size validation 和 golden fixtures; -- 实现 Session、Artifact、Session Module、Chatlog 的 Memory implementations 与 conformance; -- 把 `agent/turn` 的 `Log` 替换为 Session kernel 的 `Store` 与 `extension.SemanticAppender`,事件 identity 从 Seq 改为 spec 的派生 EventID / CommitID; -- 加 Chatlog Context projection 与参考 Planner,让 Run 的 `PlanningHint` 只提供边界事实。 +- 冻结 Session、Artifact 与 Chatlog v1 wire profiles、domain separators、wire-size validation 和 golden fixtures;Run fact 的 wire 与 golden 并入同一 ProtocolVersion; +- 实现 Session kernel Memory Store(含 `CommitIn`)、Artifact、Session Module、Chatlog 的 Memory implementations 与 conformance; +- 按第 6 节把 `agent/run` 的存储层改为 Session module; +- 把 `agent/turn` 改为 attempt 模型与 companion,`Log` 替换为 Session Store 与 `extension.SemanticAppender`; +- 加 Chatlog Context projection 与参考 Planner;`PlanningHint` 只提供边界事实。 ### 4.2 durable adapters -- 将 PostgreSQL durable Run adapter 迁移到统一 `run.Runtime`; -- 收紧 Run authority tables 的 immutable RLS policy; -- 将 recovery record 绑定具体 lease/fence 并原子消费; +- Session Store 的 SQLite 与 PostgreSQL adapter(commit、CommitIn 事务、snapshot、lease 表、FrozenValueStore); +- 收紧 Session authority tables 的 immutable RLS policy; - 实现过期 execution recovery scanner; -- 增加 transition delivery outbox,作为 Turn materialization 的 delivery 优化; -- 设计 Session Store、projection snapshot、Artifact Binding/Claim 与 semantic append intent tables。 +- 设计 Artifact Binding/Claim 与 semantic append intent tables。 ### 4.3 Application migration @@ -151,11 +154,59 @@ agent/turn -> agent/run + agent/session + agent/session/chatlog + session mo 重构在以下条件全部成立时结束: -- Memory 与 PostgreSQL adapters 通过相同 Runtime conformance; -- Turn materialization 对 crash、重复 delivery 和 unknown response 可恢复; +- 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` | 新增 `TurnID`、`RunPosition`、`Companion` 接口;`MachineState` 加 `Turn`、`Attempt`,删 `LastModelResult`;`ModelStep.Request` 改为 `RequestDigest`;`ToolSpec` 删 `Definition`;`RunResult` 删 `Model` | +| `fact.go` | 删 `RunHeader`、`TransitionRecord`、`AgentEvent`;`ModelStepCompleted` 改为 `{StepID, Usage, FinishReason, ResultDigest}`;`ToolCallCompleted`/`ToolCallAnswered` 改为 digest;新增 `RunCreated`;fact codec 输出 `jsonstable.Value` | +| `decide.go` | Prepare 校验 command 携带的本体 digest 后只写 digest;SubmitModelResult/SubmitToolResult 计算 ResultDigest/OutputDigest | +| `commit.go` | `EvaluateCommit` 改为 SessionTx 形态:LookupCommit、读投影、以 `RunPosition` 做 prepare hard CAS、Decide、Evolve、companion、Attach、SourceDigest 校验,返回 AppendRequest 与 lease ops | +| `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`;新增 `CompanionV1`、`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(含 `CommitIn`)、`agent/session/run`(module descriptor、machine projection、Runtime 实现、Memory FrozenValueStore 与 lease)、golden fixtures 重新冻结。 + 后续协议修改直接更新对应正式规范;本文只更新迁移状态和历史决策,不再承载 wire、Machine、Runtime 或 Loop 算法。 diff --git a/docs/design/agent-session-chatlog.md b/docs/design/agent-session-chatlog.md index b3cf879..0bd4f07 100644 --- a/docs/design/agent-session-chatlog.md +++ b/docs/design/agent-session-chatlog.md @@ -13,7 +13,9 @@ 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。回合的创建、结束与 Run linkage 由 `twilight/turn/` 事件表达。外部内容经 `ReferencePart` 关联 Artifact BindingID。 +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/` 事实写在同一 SessionCommit(TRN-CMP)。Run 事实只记录内容 digest,内容本体只在 chatlog 事件中出现一次。 流式 `text_delta` / `reasoning_delta` 由 Loop EventSink 发送,属于临时观察。Chatlog 权威是已提交的条目。 @@ -41,7 +43,7 @@ type CheckpointID string | Summary | `summary` | 无 | 随 checkpoint 失效 | checkpoint 的摘要正文 | | Checkpoint | `checkpoint_created` | 无 | invalidated | 指向已有 EventPosition | -**CHT-LIF-1** reducer 拒绝 identity mutation、非法状态迁移、replacement conflict 与重复 ID。模型步骤进行中走 EventSink;定稿写入 `assistant` 或 `tool_result`。 +**CHT-LIF-1** reducer 拒绝 identity mutation、非法状态迁移、replacement conflict 与重复 ID。模型步骤进行中走 EventSink;定稿随 `ModelStepCompleted` / `ToolCallCompleted` 等 Run 事实同 commit 写入 `assistant` 或 `tool_result`。同一 Turn 的多个 Run attempt 各自产生 assistant 与 tool_result;Context 按 TurnID 与 commit 顺序全部保留,attempt 之间的取舍由 Application 通过 summary 或 checkpoint 处理。 ## 3. parts 与条目 @@ -65,7 +67,12 @@ type TextPart struct { Text string } func (TextPart) PartKind() PartKind type ReasoningPart struct { Text string } func (ReasoningPart) PartKind() PartKind -type ToolCallPart struct { CallID CallID; Name string; Input jsonstable.Value } +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 @@ -86,6 +93,7 @@ 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 { @@ -94,6 +102,7 @@ type ToolResult struct { CallID CallID Status ToolResultStatus Parts []Part + SourceDigest es.Digest // 同上 Digest es.Digest } type Summary struct { @@ -122,7 +131,7 @@ type Entry struct { **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 不得复用已出现的 CallID。`unknown` 为 v1 mapper 写入的未决终态;`indeterminate` 保留给历史条目,v1 mapper 不产出。active Context 视 unresolved call 为未解决,直到 Application 在 Turn 尚未 `twilight/turn/completed` 或 `twilight/turn/failed` 时写入 `tool_result_superseded`,换成 `success` 或 `error`。每个 unresolved result 至多一个 replacement。v1 FactMapper 不写 `tool_result_superseded`。 +**CHT-ENT-2** 每个 `(TurnID,CallID)` 在 assistant 中至多一个 ToolCall。`tool_result` 对应同 Turn 已有的 call。CallID 在同一 Turn 内唯一:同一 Turn 的后续 ModelStep 与后续 Run attempt 不得复用已出现的 CallID(CallID 由 `(ModelStepID, index)` 派生,ModelStepID 含 RunID,天然满足)。`unknown` 为 v1 companion 写入的未决终态;`indeterminate` 保留给历史条目,v1 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`。 @@ -149,7 +158,7 @@ type ContentRefCodec interface { **CHT-COD-2** registry 在启动时固定,kind 有唯一 codec。BindingExtractor 按 appearance order 返回 assistant、tool_result、summary 中 ReferencePart 的 BindingID。 -**CHT-COD-3** EventType 为 `twilight/chatlog/`。条目 Digest 的 domain 与 EventType 相同,覆盖 ID、TurnID(若有)、有序 parts 或 Content、ref identity。wire 与 digest 形状由 Session `ProtocolVersion` 决定: +**CHT-COD-3** EventType 为 `twilight/chatlog/`。条目 Digest 的 domain 与 EventType 相同,覆盖 ID、TurnID(若有)、有序 parts 或 Content、ref identity、SourceDigest(若有)。wire 与 digest 形状由 Session `ProtocolVersion` 决定: ```text Digest("twilight/chatlog/input_submitted", ...) diff --git a/docs/design/agent-session-extension.md b/docs/design/agent-session-extension.md index cb7af4a..1b94fc3 100644 --- a/docs/design/agent-session-extension.md +++ b/docs/design/agent-session-extension.md @@ -14,10 +14,10 @@ agent/artifact ← Session Module Framework → agent/session Framework 负责 Module ownership、static startup composition、immutable Catalog、typed event codec、Binding declaration、SemanticAppender 和 pure projection。first-party Source 为 -`twilight`,其 Module 为 `chatlog` 与 `turn`。每次进程启动构建一套与 Session protocol +`twilight`,其 Module 为 `chatlog`、`turn` 与 `run`。每次进程启动构建一套与 Session protocol version 绑定的 immutable Catalog。Application 注册自己的 Source(例如 `acme`)与 Module。 -**EXT-SCP-1** Session Store protocol 由 Session kernel 负责;多 Session transaction、package import、saga、operation log 与 provider policy 由 Application/adapter 负责。未知 event 的 archive Binding manifest 由 Application archive coordinator 处理。 +**EXT-SCP-1** Session Store protocol 由 Session kernel 负责;多 Session transaction、package import、saga、operation log 与 provider policy 由 Application/adapter 负责。未知 event 的 archive Binding manifest 由 Application archive coordinator 处理。`run` 模块的写入经 `run.Runtime` 使用 `Store.CommitIn`(SES-API-2),不经 SemanticAppender;其 typed codec 与 projection 仍由 Catalog 提供。 `SemanticAppender` 是 Session Module Framework 面向 typed producer 的写入协调器:它执行 codec、binding admission、claim/journal 编排,并调用 Session Store 的 canonical append。claim、journal 与 @@ -66,14 +66,14 @@ func (c *Catalog) LookupProjection(ProjectionID, ProjectionVersion) (ProjectionD `Catalog` 是进程启动时的 Module 目录:它把一个协议版本下可用的 Module、EventType、 payload codec、projection、runtime registry 与 artifact scheme 组合成只读索引。它供 -typed append、decode、binding admission 与 projection lookup 使用;Session 数据、Run -状态和事件日志继续保存在各自的 authority 中。`Catalog.Profile` 是同一版本的 +typed append、decode、binding admission 与 projection lookup 使用;事件数据保存在 Session +stream 中,Catalog 只保存组合配置。`Catalog.Profile` 是同一版本的 `session.ProtocolProfile`,两者在 `BuildCatalog` 时绑定。 **EXT-CAT-1** `SourceID` 与 `ModuleID` 为小写 ASCII。first-party Source 为 `twilight`, -其 Module 为 `chatlog` 与 `turn`。Application 注册自己的 Source(例如 `acme`)。 +其 Module 为 `chatlog`、`turn` 与 `run`。Application 注册自己的 Source(例如 `acme`)。 EventType 为 `//`,例如 `twilight/chatlog/assistant`、 -`twilight/turn/started`。Digest domain 与 EventType 相同;wire 与 digest 形状由 Catalog +`twilight/turn/started`、`twilight/run/model_step_prepared`。Digest domain 与 EventType 相同;wire 与 digest 形状由 Catalog 绑定的 Session `ProtocolVersion` 决定。一个 Catalog 中 `(Source, ModuleID)`、EventType、 ProjectionID、Scheme、RegistryID 均唯一。Build defensive-copy 所有 descriptor 和 runtime registry,成功后只读且与注册顺序无关。 diff --git a/docs/design/agent-session.md b/docs/design/agent-session.md index ab74a42..136db34 100644 --- a/docs/design/agent-session.md +++ b/docs/design/agent-session.md @@ -189,6 +189,7 @@ type Store interface { Head(context.Context, SessionID) (Head, error) LookupCommit(context.Context, SessionID, CommitID) (SessionCommit, bool, error) Commit(context.Context, AppendRequest) (AppendResult, error) + CommitIn(context.Context, SessionID, CommitInFn) (AppendResult, error) Replay(context.Context, ReplayRequest) (ReplayPage, error) Fork(context.Context, ForkRequest) (ForkResult, error) ImportCanonical(context.Context, CanonicalImportRequest) (CanonicalImportResult, error) @@ -208,7 +209,20 @@ type Error struct { Code ErrorCode; Operation string; SessionID SessionID; Commi func (Error) Error() string ``` -**SES-API-1** Store.Commit 是唯一 append Store protocol port。它必须原子持久化完整 commit 与 head;普通 producer 的 capability exposure 由 Session Module Framework 提供。`ImportCanonical` 只给 trusted adapter、recovery/import coordinator 或 test。 +**SES-API-1** Store.Commit 与 Store.CommitIn 是仅有的两个 append Store protocol port。它们必须原子持久化完整 commit 与 head;普通 producer 的 capability exposure 由 Session Module Framework 提供。`ImportCanonical` 只给 trusted adapter、recovery/import coordinator 或 test。 + +```go +type SessionTx interface { + Head() Head + LookupCommit(CommitID) (SessionCommit, bool, error) + LoadSnapshot(ProjectionKey, ProjectionVersion) (SnapshotResult, error) + Tail(after Head) ([]SessionCommit, error) // 该 Session 自 after 之后的 local commits + SaveSnapshot(Snapshot) error // 与本次 append 同一事务写入 +} +type CommitInFn func(SessionTx) (*AppendRequest, error) +``` + +**SES-API-2** `Store.CommitIn(ctx, SessionID, fn CommitInFn)` 是同一 Session 的 append 临界区:Store 在 per-Session 锁或数据库事务内向 fn 提供 `SessionTx`,fn 在其中读 head、按 CommitID 查 commit、读 projection snapshot 与 tail、决定要追加的 event group;返回的 `AppendRequest` 由 Store 在同一事务内按 SES-APP 规则追加。fn 返回 nil 表示不追加。section 内 head 不移动,因此 `ExpectedHead` 与 head 不一致只可能是 fn 的实现错误,返回 `Invalid`。`CommitIn` 与 `Commit` 产生的 commit 逐字段等价,同一 CommitID 的幂等与 conflict 判定相同。两者都是 adapter 必须实现的 protocol port:`Commit`(CAS)一次请求完成比对与写入,适用于 Store 实现为远程客户端、不能在持锁期间回调调用方的部署;`CommitIn` 适用于需要在写入前基于当前状态做决定的模块(例如 Run 的 Decide)。进程内 adapter 可以用 `CommitIn` 加 head 比对函数实现 `Commit`,但 `Commit` 不因此从合同中移除。fn 必须纯且无 IO 之外的副作用;Store 不保证 fn 只被调用一次。 ## 4. append 与 idempotency @@ -286,14 +300,19 @@ Application/import coordinator 负责跨 Session package、业务事务与恢复 ## 9. boundaries 与 conformance Session modules 可对 opaque payload 作 typed encode/decode;Store 继续负责 CAS、CommitID、digest -与 replay 语义。unknown event 保留原始 payload 并支持原样 replay。`agent/turn` 消费/追加 -Events 并协调 Run;Session kernel 处理 envelope 与 stream 机制。Run、queue、provider 与 -Application policy 使用各自的边界。 +与 replay 语义。unknown event 保留原始 payload 并支持原样 replay。first-party module 为 +`chatlog`、`turn` 与 `run`:Run 的执行事实以 `twilight/run/` 事件进入同一 stream,其状态机与 +投影由 [agent-run.md](agent-run.md) 定义;Session kernel 处理 envelope 与 stream 机制。 +queue、provider 与 Application policy 使用各自的边界。 + +一条 Session stream 承载多个模块的事件;每个 projection 只消费自己声明的 EventType +(EXT-PRJ-2),并以 snapshot 加 tail 的方式读取。读取代价由 projection 消费的事件数与 +snapshot 之后的 tail 长度决定,与 stream 总长度无关。 Conformance 必须验证: - **SES-WIR-1、SES-WIR-2、SES-WIR-3、SES-WIR-4**:wire/profile freeze、版本一致性、兼容性判定、所有 Encode/Decode 与 `ValidateCanonical*` round-trip、digest preimage、同-stream SourceEvents、EventID uniqueness、complete commit; -- **SES-API-1、SES-APP-1、SES-APP-2**:atomic CAS、concurrent writer、CommitID exact idempotency 和 failure classification; +- **SES-API-1、SES-API-2、SES-APP-1、SES-APP-2**:atomic CAS、临界区 `CommitIn` 与 `Commit` 的 commit 等价、concurrent writer、CommitID exact idempotency 和 failure classification; - **SES-REP-1、SES-REP-2、SES-REP-3**:local/resolved replay 的 segment-first 顺序、完整 EventPosition After 语义、cursor/token binding、tamper/gap failure; - **SES-FRK-1、SES-FRK-2**:ForkID idempotency、parent boundary 和 RetainClosure; - **SES-SNP-1、SES-SNP-2**:coverage、ancestry validation 与 snapshot+tail 等价; diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index a18fee7..71aa884 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -1,88 +1,74 @@ # Twilight Agent Turn 协议 -状态:设计草案。 +状态:设计草案。2026-09-04 按单一 Session ES 修订:Run 事实与 Turn、Chatlog 事件同在一条 Session stream,本文不再定义 Run→Session materialization。 -本文定义 `agent/turn`:回合生命周期与 Run linkage。“必须”“应该”为协议约束。Run 的 authority 是 [agent-run.md](agent-run.md);对话内容的 authority 是 [agent-session-chatlog.md](agent-session-chatlog.md)。 +本文定义 `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. 模型与范围 -| Concern | Canonical owner | Coordinator | +```text +Turn 逻辑回合。由一组 delivered Input 触发,以 completed / failed / superseded 结束。 +Run 完成一个 Turn 的一次 attempt。同一 Turn 至多一个非终态 Run;可以有多个已终结的 Run。 +``` + +| Concern | Canonical owner | 写入者 | |---|---|---| -| 回合存在与结束 | `twilight/turn/` events | Start / Stop / settlement | -| `MachineState` | Run Runtime | Load / Commit / Record | -| `TransitionRecord` | Run Runtime | fold、materialize 完整前缀 | -| 对话内容 | `twilight/chatlog/` events | Start 时 delivered input;Drive 后写出 assistant / tool_result | -| Application policy | Application | binding、driver、产品策略 | +| 回合存在、attempt 归属与结束 | `twilight/turn/` events | Coordinator | +| Run 执行状态 | `twilight/run/` events([agent-run.md](agent-run.md)) | `run.Runtime`,由 Loop 与 Coordinator 驱动 | +| 对话内容 | `twilight/chatlog/` events | Start 时 delivered input;Run commit 内的 companion events | +| Application policy | Application | binding、driver、retry、产品策略 | -**TRN-SCP-1** Source 为 `twilight`,ModuleID 为 `turn`。`Coordinator` 协调一个 Turn 与一个 primary Run。retry 复用该 RunID。replacement 经 `twilight/turn/superseded` 指向新 Turn 与新 RunID。同一 Turn 上第二个 primary Run 为 conflict。 +**TRN-SCP-1** Source 为 `twilight`,ModuleID 为 `turn`。一个 Turn 与它的全部 Run attempt 在同一 Session stream 内。`Coordinator` 创建 Turn、创建 attempt、驱动 Run、结算 Turn。 -**TRN-SCP-2** primary RunID 在该 Turn 内存活期内保持不变。subagent 使用独立 Session 与独立 Turn。 +**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 | -**TRN-SCP-3** Coordinator 从 Session facts 与 `Runtime.Record` 重建。 +subagent 使用独立 Session 与独立 Turn。 -**TRN-SCP-4** Session 写入经 `extension.SemanticAppender`。Artifact 由其 owner 管理。 +**TRN-SCP-3** Coordinator 没有隐藏状态。它从 `twilight/turn/surface` 投影与 `twilight/run/machine` 投影重建。 -**TRN-SCP-5** Application 管理 model、provider、tool、prompt、token、approval、queue 与并发。Coordinator 按 persisted binding 解析 driver。参考 Planner 每次 Plan 使用 Binding 的 `ModelRef`。 +**TRN-SCP-4** Turn 自己的写入经 `extension.SemanticAppender`;Run 事实的写入经 `run.Runtime`,二者落在同一 `session.Store`。Artifact 由其 owner 管理。 + +**TRN-SCP-5** Application 管理 model、provider、tool、prompt、token、approval、queue、retry 决策与并发。Coordinator 按 persisted binding 解析 driver。参考 Planner 每次 Plan 使用 Binding 的 `ModelRef`。 **TRN-SCP-6** Start 之前建立 immutable execution binding。Session 保存 `ID` 与 `Digest`。密钥与 client 留在进程内。Resolve 失败返回 `binding_unavailable`。公开字段见 [参考组装](agent-reference-assembly.md)。 -## 2. identity、计划与事件 +## 2. identity 与事件 ```go -type TurnID string +type TurnID = run.TurnID // 定义在 agent/run,避免依赖环 type TurnRef struct { SessionID session.SessionID; TurnID TurnID } type ExecutionBindingRef struct { ID ExecutionBindingID; Digest es.Digest } -type MapperVersion string -type SourceFactID string +type CompanionVersion string type Settlement string const ( SettlementCompleted Settlement = "completed" - SettlementFailed Settlement = "failed" - SettlementStopped Settlement = "stopped" + SettlementFailed Settlement = "failed" + SettlementStopped Settlement = "stopped" ) -type ResumeDisposition string -const ( - ResumeWaitingForResponse ResumeDisposition = "waiting_for_response" - ResumeWaitingForRecovery ResumeDisposition = "waiting_for_recovery" - ResumeFinished ResumeDisposition = "finished" -) - -type RunHeadRef struct { RunID run.RunID; Revision uint64; TransitionDigest es.Digest } -type ResultReference struct { - RunID run.RunID; Revision uint64; EventIndex uint16 - EventType string; EventDigest es.Digest -} - -type RunCreateSpec struct { - Run run.NewRun - InitialInputs []run.AgentInput - ExecutionBinding ExecutionBindingRef - MapperVersion MapperVersion -} - type StartedPayload struct { TurnID TurnID InputIDs []chatlog.InputID - RunID run.RunID - Create RunCreateSpec + ExecutionBinding ExecutionBindingRef + Companion CompanionVersion PlanDigest es.Digest } type CompletedPayload struct { TurnID TurnID - RunID run.RunID - Settlement Settlement - Head RunHeadRef - Result ResultReference + RunID run.RunID // 产生 completed 的 attempt } type FailedPayload struct { TurnID TurnID - RunID run.RunID - Settlement Settlement + RunID run.RunID // 最后一个 attempt + Settlement Settlement // failed | stopped FailureClass string - Head RunHeadRef - Result ResultReference } type SupersededPayload struct { TurnID TurnID @@ -90,23 +76,13 @@ type SupersededPayload struct { } ``` -**TRN-ID-1** `TurnRef`、RunID、binding ID、MapperVersion、InputID 与 digest 非空且稳定。`ExecutionBindingRef` 编码公开 identity 与 digest。 - -**TRN-ID-2** `Head` 等于 terminal `RunRecord` 的 head。`Result` 指向该 record 中的 `RunEnded`。 +**TRN-ID-1** `TurnRef`、RunID、binding ID、CompanionVersion、InputID 与 digest 非空且稳定。 -**TRN-ID-3** `RunCreateSpec.Run.RunID` 等于 payload 的 RunID。`NewRun` 经 `run.ValidateNewRun`。InitialInputs 随后以 `AcceptInput` 进入 Run。 +**TRN-ID-2** `PlanDigest = Digest("twilight/turn/plan", TurnID, ExecutionBinding.Digest, Companion, ordered InputIDs)`。 -**TRN-ID-4** `PlanDigest = Digest("twilight/turn/plan", canonical(RunCreateSpec))`。 - -```go -type StartOperationDigest es.Digest -func DigestStartOperation( - sessionID session.SessionID, turnID TurnID, runID run.RunID, - planDigest es.Digest, inputIDs []chatlog.InputID, -) (StartOperationDigest, error) -``` +**TRN-ID-3** `StartOperationDigest = Digest("twilight/turn/start-operation", SessionID, TurnID, PlanDigest)`。用户正文 identity 在对应 `twilight/chatlog/input_submitted` 中。 -**TRN-ID-5** `StartOperationDigest = Digest("twilight/turn/start-operation", SessionID, TurnID, RunID, PlanDigest, ordered InputIDs)`。用户正文 identity 在对应 `twilight/chatlog/input_submitted` 中。 +**TRN-ID-4** attempt 的 RunID 由 Coordinator 派生:`RunID = Digest("twilight/turn/run", SessionID, TurnID, Attempt)`。Attempt 从 1 开始。`twilight/run/created` 携带 `TurnID` 与 `Attempt`(RUN-NEW-1)。 **TRN-EVT-1** EventType: @@ -117,21 +93,31 @@ twilight/turn/failed twilight/turn/superseded ``` -`started` 含 RunCreateSpec。`completed` 与 `failed` 含 Settlement 与 Result。unsettled linkage 是尚未 completed、failed 或 superseded 的 `started`。 +unsettled Turn 是尚未 completed、failed 或 superseded 的 `started`。 -**TRN-EVT-2** Start 的 EventID 与 CommitID 由 StartOperationDigest、event kind、group ordinal 派生。retry 复用同一 identity。 +**TRN-EVT-2** Start 的 EventID 与 CommitID 由 StartOperationDigest、event kind、group ordinal 派生。相同 canonical payload 幂等;差异为 conflict。 -**TRN-EVT-3** resolved stream 内每个 TurnID 至多一条 unsettled `started`。相同 canonical payload 幂等;差异为 conflict。 +**TRN-EVT-3** resolved stream 内每个 TurnID 至多一条 `started`,至多一条 `completed` / `failed` / `superseded`。 -**TRN-PRJ-1** ProjectionID 为 `twilight/turn/surface`。消费 `started`、`completed`、`failed`、`superseded`: +**TRN-PRJ-1** ProjectionID 为 `twilight/turn/surface`。消费 `twilight/turn/started|completed|failed|superseded` 与 `twilight/run/created|ended`: ```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 TurnView struct { TurnID TurnID - Status string // started | completed | failed | stopped | superseded + Status TurnStatus InputIDs []chatlog.InputID - RunID run.RunID - Settlement Settlement + ExecutionBinding ExecutionBindingRef + Attempts []run.RunID // 按 Attempt 递增 + ActiveRun run.RunID // Status=active 时非空 ReplacementTurnID TurnID } type TurnSurface struct { @@ -140,7 +126,7 @@ type TurnSurface struct { } ``` -`Settlement=stopped` 时 `Status` 为 `stopped`。UI 按 `TurnID` 连接 `twilight/chatlog/surface` 的条目。 +UI 按 `TurnID` 连接 `twilight/chatlog/surface` 的条目,按 `RunID` 连接 `twilight/run/machine` 的实时视图。 ## 3. API @@ -150,197 +136,150 @@ type Coordinator struct { Appender extension.SemanticAppender Runtime run.Runtime Bindings ExecutionBindingRegistry - Mapper FactMapper } type DriveRequest struct { Ref TurnRef; RunID run.RunID } type RunDriver interface { Drive(context.Context, DriveRequest) error } type ExecutionBindingRegistry interface { Resolve(ExecutionBindingRef) (RunDriver, error) } type Service interface { - Start(context.Context, StartRequest) (StartResponse, error) - Resume(context.Context, ResumeRequest) (ResumeResponse, error) - Stop(context.Context, StopRequest) (StopResponse, error) + Start(context.Context, StartRequest) (TurnResponse, error) + Resume(context.Context, TurnRequest) (TurnResponse, error) + Retry(context.Context, RetryRequest) (TurnResponse, error) + Stop(context.Context, StopRequest) (TurnResponse, error) + Settle(context.Context, SettleRequest) (TurnResponse, error) } type StartRequest struct { Ref TurnRef - Run run.NewRun InputIDs []chatlog.InputID InitialInputs []run.AgentInput ExecutionBinding ExecutionBindingRef - MapperVersion MapperVersion -} -type StartResponse struct { - Ref TurnRef; RunID run.RunID; PlanDigest es.Digest; Operation StartOperationDigest - Created bool; Disposition ResumeDisposition; Result *ResultReference; Waiting []run.ResponseRequest -} -type ResumeRequest struct { Ref TurnRef } -type ResumeResponse struct { - Ref TurnRef; RunID run.RunID; Disposition ResumeDisposition - Result *ResultReference; Waiting []run.ResponseRequest + Companion CompanionVersion } +type TurnRequest struct { Ref TurnRef } +type RetryRequest struct { Ref TurnRef; Reason string } type StopRequest struct { Ref TurnRef; Reason string } -type StopResponse struct { Ref TurnRef; RunID run.RunID; Result *ResultReference } +type SettleRequest struct { Ref TurnRef; FailureClass string } +type TurnResponse struct { + Ref TurnRef + RunID run.RunID + Attempt uint32 + Status TurnStatus + Disposition ResumeDisposition + Result *run.RunResult + Waiting []run.ResponseRequest +} +type ResumeDisposition string +const ( + ResumeWaitingForResponse ResumeDisposition = "waiting_for_response" + ResumeWaitingForRecovery ResumeDisposition = "waiting_for_recovery" + ResumeFinished ResumeDisposition = "finished" +) ``` -**TRN-API-1** Coordinator 从 Session facts 与 `Runtime.Record` 接续。 - -**TRN-API-2** Registry 用同一 `run.Runtime` 组装 driver。 +**TRN-API-1** Coordinator 从两个投影接续;每个方法先读投影再决定动作。 -**TRN-API-3** Run 的写入经 `run.Runtime`。 +**TRN-API-2** Registry 用同一 `run.Runtime` 组装 driver。Run 的写入只经 `run.Runtime`。 -**TRN-API-4** Create、conflict、missing、corrupt 按 Run contract 处理。创建计划来自 persisted `started`。 +**TRN-API-3** DTO 为值语义。`Waiting` 为 `twilight/run/machine` 的 `WaitingCalls`。`NeedsRecovery` 为 true 时返回 `ResumeWaitingForRecovery`;Application 调用 `Runtime.RecoverExpired` 后再 Resume。 -**TRN-API-5** DTO 为值语义。`Waiting` 为 snapshot 的 `WaitingCalls`。`Disposition` 为 `ResumeWaitingForResponse`、`ResumeWaitingForRecovery` 或 `ResumeFinished`。`NeedsRecovery` 为 true 时 Coordinator 返回 `ResumeWaitingForRecovery`;Application 调用 `Runtime.RecoverExpired` 后再 Resume。 +**TRN-API-4** `twilight/turn/superseded` 由 Application 追加。Coordinator 的方法不写该事件。superseded 的 Turn 若仍有非终态 Run,Application 必须先 Stop。 -**TRN-API-6** `twilight/turn/superseded` 由 Application 追加,指向 replacement Turn 与新 RunID。Coordinator 的 Start / Resume / Stop 不写该事件。同一 Turn 上第二个 primary Run 仍为 conflict。 +## 4. Start 与 Retry -## 4. Start +**TRN-STR-1** StartRequest: -**TRN-STR-1** Start 验证请求、追加 Session group、调用 `Runtime.Create`。causation 等于 `NewRun.CausationID`。 - -**TRN-STR-2** StartRequest: - -1. Ref、RunID、binding ref、mapper version 非空; +1. Ref、binding ref、companion version 非空; 2. InputIDs 与 InitialInputs 等长、无重复、顺序相同; 3. `InitialInputs[i].ID = InputIDs[i]`,Payload 等于对应 `twilight/chatlog/input_submitted` 的 Content; 4. Input 为 submitted,可 delivered。 -**TRN-STR-3** 一次原子 commit,顺序为: +**TRN-STR-2** Start 是一次原子 commit,顺序为: ```text -twilight/turn/started{TurnID, InputIDs, RunID, Create, PlanDigest} +twilight/turn/started{TurnID, InputIDs, ExecutionBinding, Companion, PlanDigest} twilight/chatlog/input_delivered{InputIDs[0], TurnID} ... twilight/chatlog/input_delivered{InputIDs[n-1], TurnID} +twilight/run/created{RunID, TurnID, Attempt:1, CausationID} +twilight/run/input_accepted{RunID, InputIDs[0], Payload} +... +twilight/run/input_accepted{RunID, InputIDs[n-1], Payload} ``` -InputIDs 为空时 group 仅含 `twilight/turn/started`。 - -**TRN-STR-4** 派生 RunCreateSpec、PlanDigest、StartOperationDigest 与 group identity,再 `AppendSemantic`。相同 identity 为 applied / already-applied。head conflict 时 CAS rebase,identity 与 payload 保持不变。 +InputIDs 为空时 group 为 `started` 加 `created`。`created` 与 `input_accepted` 的 payload 由 `run.Runtime.BuildCreateGroup` 构造并校验(RUN-NEW-1),Coordinator 不自行编码 Run 事实。 -**TRN-STR-5** append 成功后 replay 解析 linkage,再 `Runtime.Create`。Created 为 true 或 false 均进入 Resume。 +**TRN-STR-3** 派生 PlanDigest、StartOperationDigest、RunID 与 group identity,再 `AppendSemantic`。相同 identity 为 applied / already-applied。head conflict 时 CAS rebase,identity 与 payload 保持不变。 -**TRN-STR-6** Create 之后调用 Resume。 +**TRN-STR-4** append 成功后进入 Drive。 -## 5. Resume、Drive 与 Stop +**TRN-RTY-1** Retry 要求投影中该 Turn 为 `attempt_failed`。commit 为 `twilight/run/created{Attempt: n+1}` 加同一组 `input_accepted`;payload 与首个 attempt 相同,仅 RunID 与 Attempt 不同。Turn 为其他状态时 Retry 返回 conflict。 -**TRN-RSM-1** Resume 查找 Ref 上唯一 unsettled `twilight/turn/started`。 +**TRN-RTY-2** Retry 的 CommitID 由 `Digest("twilight/turn/retry", SessionID, TurnID, Attempt)` 派生。 -**TRN-RSM-2** 对 persisted `Create.Run` 再 Create,然后 `EnsureInitialInputs`。 +**TRN-RTY-3** 失败 attempt 已提交的 assistant 与 tool_result 保留在 chatlog 中并进入后续 attempt 的 Context(CHT-LIF-1)。新 attempt 的 Planner 看到前一 attempt 的部分输出与 `unknown` 工具结果,与用户中断后继续的语义一致。 -**TRN-RSM-3** 按序对 InitialInputs:Load,以 `DeriveInputCommandID` 提交 `AcceptInput`。Accepted 与 AlreadyApplied 均前进。结果未知时先 Record。 +## 5. Drive、Resume 与 Stop -**TRN-RSM-4** payload 或顺序与 persisted spec 不一致为 conflict。 +**TRN-DRV-1** Drive 解析 binding 得到 driver,调用 `driver.Drive(ctx, {Ref, RunID})`。driver 内部为 `loop.Run(ctx, runtime, SessionID, RunID, sink)`。Drive 返回后读投影设置 `Disposition`:Run 终态为 `ResumeFinished`;`NeedsRecovery` 为 true 为 `ResumeWaitingForRecovery`;仅有 WaitingCalls 为 `ResumeWaitingForResponse`。 -**TRN-RSM-5** 投递完成后 Record,`MaterializeAll`,再 `driver.Drive`。Drive 返回后再 Record 与 `MaterializeAll`,并据此设置 Disposition。`NeedsRecovery` 为 true 时为 `ResumeWaitingForRecovery`;仅有 WaitingCalls 时为 `ResumeWaitingForResponse`。 +**TRN-DRV-2** EventSink 的 `text_delta` / `reasoning_delta` 为临时观察。Waiting 由 Application 提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 后再次 Resume。 -**TRN-RSM-6** EventSink 的 `text_delta` / `reasoning_delta` 为临时观察。Waiting 由 Application 提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 后再次 Resume。 +**TRN-RSM-1** Resume 要求投影中该 Turn 为 `active`,取 `ActiveRun` 进入 Drive。`attempt_failed` 时返回该状态,由 Application 选择 Retry 或 Settle。 -**TRN-STP-1** Stop 解析 linkage。terminal Run 走 Record、materialize、settlement。active Run 提交 `CancelRun{Reason:ReasonCancelled}`。 +**TRN-STP-1** Stop 要求 Turn 为 `active`。Coordinator 提交 `CancelRun{Reason:ReasonCancelled}`,并在 `CommitRequest.Attach` 中附加 `twilight/turn/failed{Settlement:stopped, FailureClass:"cancelled"}`;两者在同一 commit 可见。结算 Turn 是 Turn 层的决定,由发起 Stop 的 Coordinator 声明,Run 事实与 companion 不推断它。Application 直接提交的 `CancelRun` 不附加结算事件,Turn 进入 `attempt_failed`。 **TRN-STP-2** Cancel CommandID = `Digest("twilight/turn/cancel-run", SessionID, TurnID, RunID, ReasonCancelled)`。StopRequest.Reason 供审计。 -**TRN-STP-3** Commit 结果未知时先 Record。已应用则 MaterializeAll,再按 `RunEnded` 写入 `twilight/turn/completed` 或 `twilight/turn/failed`。 - -## 6. settlement - -**TRN-SET-1** `RunRecord` 为 materialize、recovery、settlement 的一致读取。 - -**TRN-SET-2** 终态取最后一个 `RunEnded`。 - -**TRN-SET-3** - -| RunEnded | EventType | Settlement | -|---|---|---| -| `RunCompletedEnd` | `twilight/turn/completed` | `completed` | -| `RunFailedEnd` | `twilight/turn/failed` | `failed` | -| `RunStoppedEnd` | `twilight/turn/failed` | `stopped`(`FailureClass:"cancelled"`) | - -**TRN-SET-4** settlement 的 CommitID / EventID 由 ResultReference 派生。相同 reference 幂等。 - -**TRN-SET-5** Turn 尚未 `completed`/`failed` 时,`ToolCallFailed{Unknown}` 按 TRN-MAP-1 写成 `twilight/chatlog/tool_result`(status=`unknown`)。Turn 终态只由 `RunEnded` 按上表写入。 - -## 7. materialization - -```go -type FactMapRequest struct { - TurnID TurnID; RunID run.RunID; MapperVersion MapperVersion - Prefix []run.TransitionRecord - TargetRevision uint64; TargetTransitionDigest es.Digest -} -type FactMap struct { SourceFacts []SourceFactID; Events []extension.TypedEvent } -type FactMapper interface { Map(FactMapRequest) (FactMap, error) } -``` - -**TRN-MAT-1** `MaterializeAll` 按 revision 1 至 head 递增。每次 Map 的输入是截至 target 的完整 prefix。EventSink delta 留在观察面。 - -**TRN-MAT-2** - -```text -SourceFactID = Digest("twilight/turn/source-fact", - RunID, Revision, TransitionDigest, Index, Type, EventDigest) -``` - -**TRN-MAT-3** materialization CommitID 的 domain 为 `twilight/turn/materialize`;输出 EventID 的 domain 为 `twilight/turn/materialized-event`。 - -**TRN-MAT-4** Map 为确定性纯函数。MapperVersion 为协议输入。 +**TRN-STL-1** Settle 要求 Turn 为 `attempt_failed`,追加 `twilight/turn/failed{Settlement:failed, FailureClass}`。CommitID 由 `Digest("twilight/turn/settle", SessionID, TurnID, RunID)` 派生。 -**TRN-MAT-5** 空 Events 时 SourceFacts 为空,该 revision coverage 完成。非空 batch 先 `LookupCommit`。 +## 6. companion:Run 事实到对话内容 -**TRN-MAT-6** outbox 优化投递;coverage 以 Session facts 与 RunRecord 为准。 +Run 事实只保存执行状态与内容 digest(RUN-WIR-4)。模型文本、工具调用与工具输出以 chatlog 事件形式与产生它们的 Run 事实写在同一 SessionCommit。`run.Runtime.Commit` 在 Decide 之后、写入之前调用注入的 `run.Companion`,把本 commit 的 facts 与 command 携带的 transient 内容映射为事件,追加在 Run facts 之后。接口定义在 `agent/run`(第 5 节);本模块提供实现 `CompanionV1`,它使用 Catalog 的 chatlog 与 turn codec 编码 payload。 -### 7.1 v1 映射 +**TRN-CMP-1** `Map` 为确定性纯函数,不做 IO;时间取 `CompanionRequest.RecordedAtUnixMilli`。输出事件的 EventID 由 `(Turn, RunID, fact identity, CompanionVersion)` 派生(TRN-MAP-2),同一 commit 重放得到同一 group。 -**TRN-MAP-1** 下表列出产生 chatlog events 的 AgentEvent。其余 AgentEvent 的 FactMap 为空。 +**TRN-CMP-2** v1 映射: -| AgentEvent | chatlog event | +| Run fact | companion event | |---|---| -| `ModelStepCompleted` | `twilight/chatlog/assistant` | +| `ModelStepCompleted` | `twilight/chatlog/assistant{TurnID, Parts: text, reasoning, tool_call*}` | | `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}` | -`ModelStepPrepared` 期间 EventSink 可发送 `text_delta` / `reasoning_delta`。回合结束由 `twilight/turn/completed` 或 `twilight/turn/failed` 表达。 +其余 fact 不产生 companion。`RunEnded(failed)` 与 `RunEnded(stopped)` 都不由 companion 结算 Turn:没有附加结算事件时 Turn 进入 `attempt_failed`,由 Retry 或 Settle 决定;Coordinator.Stop 以 `Attach` 声明 stopped 结算(TRN-STP-1)。 -**TRN-MAP-2** `AssistantID = Digest("twilight/chatlog/assistant-id", TurnID, ModelStepID, MapperVersion)`。`ToolResultID = Digest("twilight/chatlog/tool-result-id", TurnID, CallID, MapperVersion)`。assistant 的 ToolCall 顺序与模型结果一致。tool_result 与同 Turn 的 call 配对。CallID 由 Run 从 `(ModelStepID, index)` 派生,同一 Turn 内天然不跨 ModelStep 复用;模型发出的 `tool_call_id` 以 `ProviderCallID` 随 assistant / tool_result 一并记录,供 Planner 回传。 +**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** Known 对应 `error`;Unknown 对应 `unknown`。 +**TRN-MAP-3** assistant 正文与工具输出来自 command 携带的冻结值。`Assistant.SourceDigest` 等于 `ModelStepCompleted.ResultDigest`,`ToolResult.SourceDigest` 等于 `ToolCallCompleted.OutputDigest` 或 `ToolCallAnswered.ResponseDigest`;chatlog 条目自身的 `Digest` 仍按 CHT-COD-3 覆盖 parts。Runtime 在写入前校验这一等式(RUN-CMT-3 第 9 步)。 -**TRN-MAP-4** assistant 正文来自冻结的 model result。 +**TRN-MAP-4** Known 对应 `error`;Unknown 对应 `unknown`。v1 companion 不写 `tool_result_superseded`。 -## 8. recovery +## 7. recovery -**TRN-REC-1** 按 `(SessionID,TurnID,RunID)` 扫描 unsettled `twilight/turn/started` 并 Resume。 +**TRN-REC-1** 恢复扫描 `twilight/turn/surface` 中 `active` 与 `attempt_failed` 的 Turn。 **TRN-REC-2** | 情形 | 动作 | |---|---| -| `started` 已提交、Run 缺失 | Create persisted NewRun 后 Resume | -| Create 响应丢失 | 同一 NewRun 再 Create | -| InitialInputs 仅前缀 | 按 derived command 继续 AcceptInput | -| transition 已提交、chatlog 落后 | MaterializeAll | -| Map 为空 | 该 revision coverage 完成 | -| materialize 响应丢失 | LookupCommit | -| Run 已终态、settlement 缺失 | 按 RunEnded 写 `twilight/turn/completed` 或 `twilight/turn/failed` | -| 模型 Executing、lease 过期 | `RecoverExpired` 提交 `RecoverModelExecution`;Run 保持 Active,同一 Turn、同一 RunID 继续 | -| 模型结果未知 | 以 Record 为准 | -| 工具效果未知 | 对该 Executing call materialize status=`unknown`;Run 保持 Active,同一 Turn、同一 RunID 继续。lease 过期后 `RecoverExpired` 提交该 call 的 Unknown | -| Record 与并发 Commit | 重读 Record | - -**TRN-REC-3** Commit、Create、append 结果未知时先查 Runtime.Record 或 Session LookupCommit。 - -**TRN-REC-4** binding 缺失返回 `binding_unavailable`。已终态 Run 继续 materialize 与 settle。 - -**TRN-REC-5** 执行推进遵循 MachineState 与 grant。 - -## 9. conformance - -- **TRN-SCP-1 至 TRN-SCP-6**:一 Turn 一 primary Run、Source `twilight`、ModuleID `turn`; -- **TRN-ID-1 至 TRN-EVT-3**:所列 EventType 与 `twilight/turn/plan`; -- **TRN-API-1 至 TRN-API-5**:StartRequest 含 InputIDs 与 InitialInputs; -- **TRN-STR-1 至 TRN-STR-6**:Start group 为 `twilight/turn/started` 加 `twilight/chatlog/input_delivered*`; -- **TRN-RSM-1 至 TRN-STP-3**:unsettled `started`、Drive、`twilight/turn/cancel-run`; -- **TRN-SET-1 至 TRN-SET-5**:settlement 为 `twilight/turn/completed` 或 `twilight/turn/failed`; -- **TRN-MAT-1 至 TRN-MAP-4**:v1 表; -- **TRN-REC-1 至 TRN-REC-5**:上表恢复情形。 +| `started` 已提交、进程在 Drive 前退出 | Resume | +| Loop 提交响应丢失 | Loop 以 ClaimStore 中的 Claim 重放(RUN-LOP-3);Runtime 按 `(SessionID, CommitID)` 幂等 | +| 模型 Executing、lease 过期 | `RecoverExpired` 提交 `RecoverModelExecution`;Run 保持 Active,同一 RunID 以同一冻结请求继续 | +| 工具效果未知 | lease 过期后 `RecoverExpired` 提交该 call 的 Unknown,companion 写 status=`unknown`;Run 保持 Active | +| Run 已 `failed`、Turn 未结算 | Turn 为 `attempt_failed`;Application 选择 Retry 或 Settle | +| Stop commit 响应丢失 | 以同一 Cancel CommandID 重放 | +| binding 缺失 | 返回 `binding_unavailable`;Turn 状态不变 | + +**TRN-REC-3** 没有跨存储的对账:Run 事实、companion 内容与 Turn 结算在同一 commit,要么全部可见要么全部不可见。 + +## 8. conformance + +- **TRN-SCP-1 至 TRN-SCP-6**:一 Turn 至多一个非终态 Run、Source `twilight`、ModuleID `turn`、无隐藏状态; +- **TRN-ID-1 至 TRN-EVT-3**:所列 EventType、`twilight/turn/plan`、`twilight/turn/run` 派生 RunID、每 Turn 至多一条结算事件; +- **TRN-PRJ-1**:surface 状态机,`active` 与 `attempt_failed` 的判定; +- **TRN-STR-1 至 TRN-RTY-2**:Start group 顺序与原子性、Retry 前置条件、Attempt 递增、幂等 CommitID; +- **TRN-DRV-1 至 TRN-STL-1**:Drive disposition、Stop 以 Attach 单 commit 结算、Application 的 Cancel 进入 `attempt_failed`、Settle 前置条件; +- **TRN-CMP-1 至 TRN-MAP-4**:companion 纯函数、v1 映射表、`SourceDigest` 等于 Run fact 记录值、同 commit 可见性、失败 attempt 内容进入 Context; +- **TRN-REC-1 至 TRN-REC-3**:上表恢复情形、崩溃后 Resume、无跨存储对账。 From 617c8ea2ad22fb620fae4767a4ddc13af08f0b90 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Fri, 4 Sep 2026 21:46:09 +0800 Subject: [PATCH 62/81] Design: second revision after architecture review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the review findings on the single-Session-ES design: - One write path: SemanticAppender gains AppendSemanticIn; run.Runtime writes through it, so companion/Attach events get codec, binding admission and claims in the same transaction (EXT-SCP-1, EXT-APP-3). - Session Store gains a control-plane KV (SES-API-3) readable/writable inside SessionTx; lease, grant and artifact claims live there and commit atomically with the event group. RecoverExpired adds a no-lease fallback keyed on start time + TTL. - Kernel ProtocolVersion covers envelope/commit only; payloads carry a top-level `v`, Registry keeps every codec version (SES-VER, EXT-REG-2); Run keeps its own created.SchemaVersion. - v1 scope: Fork/ancestry/import, Application modules/Catalog, two-phase journal, artifact Prepared state/reconciler/import move to appendices. - Snapshot is a droppable cache with SnapshotPolicy; MachineProjection drops terminal runs; turn surface records AttemptView.End. - Run keeps only an opaque OwnerID (turn fills TurnID); Companion returns ModuleEvent; Attach carries the Stop settlement. - Attempt-content policy moves to the reference Planner (REF-PLN-6). - Fingerprint excludes RecordedAtUnixMilli; Replay/Tail gain EventType prefix filter; CoverageDigest removed in favor of Through.Digest. - agent-runtime-refactor.md §7 records the review, the persistent structure/consistency table and the implementation order. Co-Authored-By: Claude Fable 5.1 --- docs/design/agent-artifact.md | 61 ++--- docs/design/agent-reference-assembly.md | 18 +- docs/design/agent-run.md | 144 ++++++------ docs/design/agent-runtime-refactor.md | 106 ++++++--- docs/design/agent-session-chatlog.md | 6 +- docs/design/agent-session-extension.md | 281 +++++++++--------------- docs/design/agent-session.md | 237 ++++++++------------ docs/design/agent-turn.md | 52 +++-- 8 files changed, 439 insertions(+), 466 deletions(-) diff --git a/docs/design/agent-artifact.md b/docs/design/agent-artifact.md index 5bc7197..099a0fe 100644 --- a/docs/design/agent-artifact.md +++ b/docs/design/agent-artifact.md @@ -1,8 +1,8 @@ # Twilight Agent Artifact Core -状态:设计草案。无实现;wire 与 claim 状态表在 Memory reference implementation 通过 conformance 前不冻结。 +状态:设计草案。无实现;wire 与 claim 状态表在 Memory reference implementation 通过 conformance 前不冻结。2026-09-04 第二次修订:v1 的 claim 只有 `Active` 与 `Released` 两态,与 Session commit 同事务写入;`Prepared` 状态、reconciler、provider 迁移 fence 与 archive import/export 移入附录,不进入 v1 conformance。 -本文定义 `agent/artifact`。文中的“必须”“不得”“应该”是协议约束;canonical JSON、JCS 与 domain-separated digest 使用 `agent/jsonstable` 和 `agent/es` 的通则。 +本文定义 `agent/artifact`。文中的"必须""不得""应该"是协议约束;canonical JSON、JCS 与 domain-separated digest 使用 `agent/jsonstable` 和 `agent/es` 的通则。 ## 1. 模型与范围 @@ -14,10 +14,12 @@ Binding:稳定 BindingID 到 immutable Ref 的映射 RetentionClaim:owner 对一个 BindingSet 的 durable 保留事实 ``` -`BindingSet` 是 claim 的内容集合。`Prepared` claim 为 in-flight owner operation 提供 GC 保护;`Active` claim 是已确认 owner fact 的 retention root;`Released` claim 不再保留任何内容。Core 不依赖 Session、Event、Chatlog 或 Application,且不解释 owner 的领域语义。Attachment 等 owner module 可以关联 `AttachmentID`、subject 与 `BindingID`,但该边界只使用 BindingID,不引入 Event 依赖。 +`BindingSet` 是 claim 的内容集合。`Active` claim 是已确认 owner fact 的 retention root;`Released` claim 不再保留任何内容。v1 中 claim 由 Session Module Framework 在写入 owner fact 的同一事务内以 `Active` 状态建立(EXT-APP-3),因此不需要 in-flight 保护状态;`Prepared` 保留给无法同事务写入的部署(附录)。Core 不依赖 Session、Event、Chatlog 或 Application,且不解释 owner 的领域语义。Attachment 等 owner module 可以关联 `AttachmentID`、subject 与 `BindingID`,但该边界只使用 BindingID,不引入 Event 依赖。 **ART-SCP-1** Core 不得解释 `ClaimOwner`,不得要求某种数据库、文件系统或 provider 实现。第一版只要求 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 @@ -109,9 +111,9 @@ type BindingStore interface { type ClaimOwner struct { Kind, Authority, Identity string } type ClaimState string const ( - ClaimPrepared ClaimState = "prepared" ClaimActive ClaimState = "active" ClaimReleased ClaimState = "released" + ClaimPrepared ClaimState = "prepared" // 仅附录的两阶段部署使用 ) // BindingSet is a canonical, resolved retention set. @@ -126,39 +128,38 @@ type ClaimCursor struct { Watermark ClaimID; After ClaimID } type ClaimPage struct { Items []RetentionClaim; Next *ClaimCursor } type ClaimOwnerQuery struct { Kind, Authority string; Identities []string } +// ClaimKV 是宿主提供的、与 owner fact 同事务的 KV 视图;Session 部署中由 +// session.SessionTx 的控制面 KV(namespace twilight/artifact/claim)适配。 +type ClaimKV interface { + Get(key string) ([]byte, bool, error) + Put(key string, value []byte) error + Delete(key string) error +} + type RetentionLedger interface { - Prepare(context.Context, ClaimID, ClaimOwner, BindingSet) (RetentionClaim, error) + // ActivateIn 在 kv 所属事务内建立或幂等确认一个 Active claim。 + ActivateIn(kv ClaimKV, ClaimID, ClaimOwner, BindingSet) (RetentionClaim, error) LookupClaim(context.Context, ClaimID) (RetentionClaim, bool, error) - Activate(context.Context, ClaimID) (RetentionClaim, error) - AbortPrepared(context.Context, ClaimID) error ReleaseActive(context.Context, ClaimID) error - PreparedClaims(context.Context, ClaimCursor) (ClaimPage, error) ClaimsByOwner(context.Context, ClaimOwnerQuery, ClaimCursor) (ClaimPage, error) - ImportActiveClaims(context.Context, []RetentionClaim) 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:`Prepare` 对同 ID、同 owner、同 set 幂等,对任何其他组合 conflict。`Prepared` 与 `Active` 都是 GC roots;GC 只忽略 `Released` claim。未知 scheme 必须保守保留。 +**ART-RET-2** claim 只接受 `EventBound` 或 `Pinned` Binding;`Ephemeral` 必须先 promote。ClaimID 必须由 owner fact identity 与 BindingSet 稳定、确定地派生,并永久绑定该 owner 与 set:`ActivateIn` 对同 ID、同 owner、同 set 幂等,对任何其他组合 conflict。`Active` claim 是 GC root;GC 只忽略 `Released` claim。未知 scheme 必须保守保留。 | 操作 | 前置状态 | 结果 | |---|---|---| -| Prepare(new ID, owner, set) | 不存在 | Prepared | -| Prepare(existing ID, exact owner/set) | Prepared/Active | 返回既有 claim | -| Prepare(existing ID, exact owner/set) | Released | conflict | -| Prepare(existing ID, other owner/set) | 任意 | conflict | -| Activate | Prepared | Active | -| Activate | Active | 幂等成功 | -| Activate | Released/不存在 | conflict/not found | -| AbortPrepared | Prepared,且有 owner operation terminally aborted 证据 | Released | -| AbortPrepared | Released | 幂等成功 | -| AbortPrepared | Active | conflict | +| ActivateIn(new ID, owner, set) | 不存在 | Active | +| ActivateIn(existing ID, exact owner/set) | Active | 幂等成功 | +| ActivateIn(existing ID, exact owner/set) | Released | conflict | +| ActivateIn(existing ID, other owner/set) | 任意 | conflict | | ReleaseActive | Active,且 owner retention 已结束 | Released | | ReleaseActive | Released | 幂等成功 | -| ReleaseActive | Prepared | conflict | +| ReleaseActive | 不存在 | not found | -**ART-RET-3** `AbortPrepared` 的调用方必须以证明 owner operation 已 terminally aborted 的 durable evidence 授权。owner operation 查询为 NotFound、unknown 或 transient failure 时,reconciler 必须保留原 `Prepared` claim 并留待后续 reconciliation。对同一 owner fact 的 retry 必须保留原 `Prepared` claim,绝不得先 abort 再复用 ID。`PreparedClaims` 与 `ClaimsByOwner` 使用 watermark cursor,按 ClaimID 稳定排序;空 owner identities 不匹配。reconciler 将已证实 owner fact 映射为 Activate、已证实 terminal abort 映射为 AbortPrepared、已证实 owner retention 结束映射为 ReleaseActive。 +**ART-RET-3** `ClaimsByOwner` 使用 watermark cursor,按 ClaimID 稳定排序;空 owner identities 不匹配。v1 没有 in-flight claim,因此没有 reconciler;`ReleaseActive` 的授权(owner retention 已结束)由 Application 的 GC policy 提供。 ## 6. provider 与 scheme boundary @@ -177,9 +178,9 @@ type ProviderBinding struct { **ART-PRO-1** registry 在 startup 组合后 immutable;每个 Scheme 有唯一 definition,verified use 需要已注册 Scheme 和唯一 `(Scheme,Authority)` provider binding。provider config、secret、物理位置与迁移属于 adapter/Application。 -**ART-PRO-2** adapter 改变物理实现时必须保持 locator resolution 不变,并以 generation/fence 防止旧位置在新位置验证可恢复前回收。具体 filesystem、DB 与迁移步骤由 adapter/Application 负责。 +## 7. archive 与 import/export(附录,不进入 v1) -## 7. archive 与 import/export +以下为预留设计,v1 实现返回 `ErrUnsupported`。 ```go type BindingManifest struct { @@ -199,6 +200,10 @@ type VerifiedImportResult struct { Bindings []BindingID; Claims []ClaimID } **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 @@ -217,10 +222,10 @@ func (Error) Error() string 实现必须以可判别 `ErrorCode` 返回预期失败;`Detail` 不得承载 provider secret。 -Conformance 必须验证: +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、全状态表、cursor pagination、Prepared/Active GC protection 与保守 reconcile; -- **ART-PRO-1、ART-PRO-2**:immutable registry、provider-instance isolation 与迁移 fence; -- **ART-ARC-1、ART-ARC-2**:manifest wire、inspection、verified all-or-nothing active-claim import 和 exact idempotency。 +- **ART-RET-1、ART-RET-2、ART-RET-3**:BindingSetBuilder/ledger 独立重算与精确验证、RefSetDigest、不可复用 released claim、两态状态表、`ActivateIn` 在宿主事务内生效与回滚、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 index 9383c96..ae479e0 100644 --- a/docs/design/agent-reference-assembly.md +++ b/docs/design/agent-reference-assembly.md @@ -49,6 +49,8 @@ func Plan(ctx context.Context, hint run.PlanningHint, fold []chatlog.Entry, pub **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: @@ -67,20 +69,20 @@ run.AgentInput.Payload ## 4. Memory 组成 ```text -sessionStore = session.NewMemoryStore() -catalog = extension.BuildCatalog(CatalogBuildRequest{ProtocolVersion, Profile, - Modules: []ModuleDescriptor{chatlog.Module, turn.Module, runmod.Module}}) -appender = extension.NewSemanticAppender(sessionStore, catalog, artifacts) -runtime = runmod.NewRuntime(sessionStore, catalog, runmod.NewMemoryFrozenValues(), runmod.NewMemoryLeases(), turn.CompanionV1(catalog)) -bindings = Resolve(ExecutionBindingRef) -> loop.New(models, tools, contextPlanner, policy, pub.Streaming) -coordinator = turn.Coordinator{Sessions: sessionStore, Appender: appender, Runtime: runtime, Bindings: bindings} +sessionStore = session.NewMemoryStore() // commit、CommitIn、snapshot、控制面 KV +registry = extension.FirstPartyRegistry(profile, chatlog.Parts) +ledger = artifact.NewLedger(...) // claim 存于控制面 KV twilight/artifact/claim +appender = extension.NewSemanticAppender(sessionStore, registry, bindings, ledger) +runtime = runmod.NewRuntime(appender, sessionStore, runmod.NewMemoryFrozenValues(), turn.CompanionV1(registry), runmod.DefaultSnapshotPolicy) +drivers = Resolve(ExecutionBindingRef) -> loop.New(models, tools, contextPlanner, policy, pub.Streaming) +coordinator = turn.Coordinator{Sessions: sessionStore, Appender: appender, Runtime: runtime, Bindings: drivers} input_submitted coordinator.Start commit 1: twilight/turn/started + twilight/chatlog/input_delivered* + twilight/run/created + twilight/run/input_accepted* Loop.Run commit: twilight/run/model_step_prepared (请求本体 → FrozenValueStore) - commit: twilight/run/model_step_started + commit: twilight/run/model_step_started (lease → 控制面 KV,同事务) commit: twilight/run/model_step_completed + twilight/run/tool_step_opened + twilight/chatlog/assistant commit: twilight/run/tool_call_started commit: twilight/run/tool_call_completed + twilight/chatlog/tool_result diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 261b6fc..a689234 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -1,6 +1,6 @@ # Twilight Agent Run Protocol -状态:设计规范。Machine、command/fact 规则与 Loop 为规范;第 5 节的 Runtime 与存储层于 2026-09-04 按单一 Session ES 修订,实现尚未跟进(见 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 6 节)。 +状态:设计规范。Machine、command/fact 规则与 Loop 为规范;第 5 节的 Runtime 与存储层于 2026-09-04 按单一 Session ES 修订(同日第二次修订:控制面进入 Session 控制面 KV、写入经 Module Framework、恢复 Run 级 SchemaVersion、snapshot 改为缓存),实现尚未跟进(见 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 6、7 节)。 本文定义 `agent/run`、`agent/run/loop` 与 Run 作为 Session Module 的存储形态。文中的"必须""不得""应该"是协议约束;canonical JSON、JCS 与 domain-separated digest 使用 `agent/jsonstable` 和 `agent/es` 的通则。 @@ -8,13 +8,14 @@ ```text Session stream 唯一 authority:twilight/run/ 事实与 turn、chatlog 事件同在一条 stream -MachineState Run 的语义状态投影(twilight/run/machine),snapshot 为派生缓存 -Runtime Run 的 command 入口:在 Session commit 临界区内 Decide、Evolve、追加 +MachineState Run 的语义状态投影(twilight/run/machine);snapshot 为可丢弃的派生缓存 +Runtime Run 的 command 入口:在 Session 临界区内 Decide、Evolve,经 Module Framework 追加 loop.Loop 当前进程的 execution interpreter -FrozenValueStore 内容寻址旁存:模型请求本体与工具定义本体,按 digest 存取 +FrozenValueStore 内容寻址旁存:模型请求本体(含工具定义),按 digest 存取 +控制面 KV Session Store 的 control-plane KV:lease、grant、durable claim;与 commit 同事务 ``` -`MachineState` 决定 Run 当前可执行动作。每次接受的 command 产生一个 SessionCommit,其中的 `twilight/run/` 事件经 `Protocol.Evolve` 从 `twilight/run/created` 重放后必须得到同一 `MachineState`。 +`MachineState` 决定 Run 当前可执行动作。每次接受的 command 产生一个 SessionCommit,其中的 `twilight/run/` 事件经该 Run 版本的 `Protocol.Evolve` 从 `twilight/run/created` 重放后必须得到同一 `MachineState`。 Run 的职责分成五个相互独立的层面: @@ -30,15 +31,15 @@ Machine 处理已冻结的值和已提交的事实;Loop 解释 `Next` 产生 `Step` 是 Run 的持久化恢复边界;`execution attempt` 表示某个 Loop 进程对该 Step 或 ToolCall 的一次易失执行。一个 Step 可以有多个 attempt,Machine 只接受带有效 grant 的 settlement。Attempt 的执行控制信息由 start command 的 `ExecutionClaim` 和 Runtime 返回的 opaque `ExecutionGrant` 表达;它们不进入 stream。 -**RUN-SCP-1** `agent/run` 拥有 Run identity、persisted frozen values、Machine、command/fact protocol、fact codec、fold 与 `Runtime` contract;它依赖 `agent/session` 的 identity 与 Store 类型,不依赖 loop。`agent/run/loop` 拥有 planner/model/tool ports、streaming、并发执行、EventSink 与 Loop policy。`agent/session/run` 是 Run 的 Session Module 实现:EventDefinition、`twilight/run/machine` projection、`Runtime` 实现、FrozenValueStore 与 lease 表的 adapter。 +**RUN-SCP-1** `agent/run` 拥有 Run identity、persisted frozen values、Machine、command/fact protocol、fact codec、fold 与 `Runtime`、`Companion` contract;它依赖 `agent/session` 的 identity 与 Store 类型,不依赖 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.SemanticAppender.AppendSemanticIn` 写入)、FrozenValueStore adapter 与控制面 KV 的 lease 编码。 -**RUN-SCP-2** Run 是 first-party Session Module(Source `twilight`,ModuleID `run`)。Turn 的创建、attempt 归属与结算、Run 事实到对话内容的 companion 映射由 [agent-turn.md](agent-turn.md) 定义;对话内容 ontology 由 [agent-session-chatlog.md](agent-session-chatlog.md) 定义;stream、commit、projection 与 snapshot 机制由 [agent-session.md](agent-session.md) 与 [agent-session-extension.md](agent-session-extension.md) 定义。Artifact、queue、provider registry、权限与产品 policy 分别由其 package 或 Application 拥有。 +**RUN-SCP-2** Run 是 first-party Session Module(Source `twilight`,ModuleID `run`)。Run 不解释它的上层实体:`OwnerID` 是 opaque 字符串,由 turn 模块以 TurnID 填充。Turn 的创建、attempt 归属与结算、Run 事实到对话内容的 companion 映射由 [agent-turn.md](agent-turn.md) 定义;对话内容 ontology 由 [agent-session-chatlog.md](agent-session-chatlog.md) 定义;stream、commit、projection、snapshot 与控制面 KV 机制由 [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 TurnID string // 与 agent/turn 同一 identity;定义在 agent/run 以避免依赖环 +type OwnerID string // 上层实体标识,Run 不解释 type StepID string type CallID string type CommandID string @@ -56,22 +57,23 @@ type Digest = es.Digest 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`;envelope、revision、index、digest chain 与 idempotency 由 Session kernel 提供,Run 不另设 envelope。fact codec 属于 Session `ProtocolVersion`(EXT-COD-3),必须拒绝 unknown type、duplicate key、unknown field、trailing data、非法 UTF-8、非 canonical-equivalent wire。精确 identity 和 digest 使用 JSON string,整数字段使用 Session profile 的整数 wire shape。 +**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。envelope、revision、index、digest chain 与 idempotency 由 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 - Digest Digest // 覆盖 type 与完整 command,含 transient 内容 + Digest Digest // 覆盖 schema、type 与完整 command,含 transient 内容 Command AgentCommand } ``` command 不持久化。`CommandEnvelope.ID` 就是该 command 产生的 SessionCommit 的 `CommitID`;`Digest` 只用于 Runtime 在临界区内比对精确重放。 -**RUN-WIR-3** 一个 command 恰产生一个 SessionCommit;其 `twilight/run/` 事件 Index 从 0 连续递增,companion 事件(TRN-CMP)与调用方附加事件(`CommitRequest.Attach`)依次紧随其后。Run 事件的 `EventID = Digest("twilight/run/event", CommandID, index)`;`RecordedAtUnixMilli` 由 Runtime 的时钟填入,是 metadata,不参与 Run 的任何派生。构造 command 必须使用该 Session 版本的 `Protocol.BuildEnvelope`(Loop 通过 `RuntimeSnapshot.Protocol()` 取得)。`agent/run` 不提供隐式选择版本的包级 `BuildEnvelope`、`Decide`、`Evolve` 或 `Digest*` 函数;新 Session 与测试显式使用 `ProtocolV1()`。 +**RUN-WIR-3** 一个 command 恰产生一个 SessionCommit;其 `twilight/run/` 事件 Index 从 0 连续递增,companion 事件(TRN-CMP)与调用方附加事件(`CommitRequest.Attach`)依次紧随其后。Run 事件的 `EventID = Digest("twilight/run/event", CommandID, index)`;`RecordedAtUnixMilli` 由 Runtime 的时钟填入,是 metadata,不参与 Run 的任何派生,也不进入 append fingerprint(SES-APP-1)。构造 command 必须使用该 Run 版本的 `Protocol.BuildEnvelope`(Loop 通过 `RuntimeSnapshot.Protocol()` 取得)。`agent/run` 不提供隐式选择版本的包级 `BuildEnvelope`、`Decide`、`Evolve` 或 `Digest*` 函数;新 Run 与测试显式使用 `ProtocolV1()`。 **RUN-WIR-4** 内容与执行状态分离。fact 只保存执行状态与内容 digest,内容本体落在两处: @@ -83,7 +85,7 @@ command 不持久化。`CommandEnvelope.ID` 就是该 command 产生的 SessionC | 工具输出 | `ToolCallCompleted.OutputDigest` / `ToolCallAnswered.ResponseDigest` | 同 commit 的 `twilight/chatlog/tool_result`,其 `SourceDigest` 等于该 digest | | tool call 参数 | `ToolCallBinding.Arguments` | fact 本身(执行不得依赖 chatlog 解码) | -`FrozenValueStore` 是内容寻址存储:`Put(digest, bytes)` 幂等,`Get(digest)`。请求本体的有效期是该 ModelStep 从 Prepared 到终结;step 终结后 adapter 可按保留策略删除或归档,Record 校验不依赖本体。 +companion 与 Attach 事件与 Run 事实一起经 Module Framework 的 admission(EXT-APP-1):它们可以携带 `ReferencePart`,其 Binding 在同一事务建立 claim。`FrozenValueStore` 是内容寻址存储:`Put(digest, bytes)` 幂等,`Get(digest)`。请求本体的有效期是该 ModelStep 从 Prepared 到终结;step 终结后 adapter 可按保留策略删除或归档,Record 校验不依赖本体。 下列 identity 稳定派生并由 Commit 验证: @@ -102,34 +104,35 @@ command 不持久化。`CommandEnvelope.ID` 就是该 command 产生的 SessionC | model recovery CommandID | RunID、StepID、Claim | | tool recovery CommandID(RecoverExpired 的 Unknown) | RunID、StepID、CallID、Claim | -同一派生 identity 的内容变化在 Session kernel 表现为 `CommitConflict`(同 CommitID、不同 event group)。`PlanningToken` 是 Application-owned opaque freshness token,属于 prepare command identity 内容。 +同一派生 identity 的内容变化在 Session kernel 表现为 `CommitConflict`(同 CommitID、不同 event group)。`PlanningToken` 是 Application-owned opaque freshness token,属于 prepare command identity 内容;Run 不校验它的语义(RUN-CMT-4)。 ## 3. 创建与 canonical record ```go type NewRun struct { + SchemaVersion uint16 RunID RunID - Turn TurnID + Owner OwnerID Attempt uint32 CausationID es.CausationID } type RunCreated struct { + SchemaVersion uint16 RunID RunID - Turn TurnID + Owner OwnerID Attempt uint32 - InitialStateVersion uint16 CausationID es.CausationID } type RunRecord struct { Created session.EventPosition Snapshot RuntimeSnapshot - Events []session.SessionEvent // 该 RunID 的全部 twilight/run/ 事件,按 resolved 顺序 + Events []session.SessionEvent // 该 RunID 的全部 twilight/run/ 事件,按 stream 顺序 } ``` -**RUN-NEW-1** `twilight/run/created` 是 Run 的第一个事实。v1 初始状态恰为:相同 RunID、Turn、Attempt、`RunActive`、`Current=Open`、无 pending input、零 model step、零 usage、无 result。初始输入随后以 `twilight/run/input_accepted` 进入同一 commit(TRN-STR-2)。`Protocol.BuildCreateGroup(NewRun, []AgentInput)` 返回 `created` 与 `input_accepted` 的 typed events,Coordinator 不自行编码。同一 RunID 第二条 `created` 为 Evolve 错误。 +**RUN-NEW-1** `twilight/run/created` 是 Run 的第一个事实。v1 初始状态恰为:相同 RunID、Owner、Attempt、`RunActive`、`Current=Open`、无 pending input、零 model step、零 usage、无 result。初始输入随后以 `twilight/run/input_accepted` 进入同一 commit(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)` 按 resolved 顺序折叠该 RunID 的完整事件序列,第一条必须是 `created`。Fold 过程执行纯状态重建。import、诊断与 `Runtime.Record` integrity verification 都经 FoldRun;snapshot 通过 FoldRun 结果校验。 +**RUN-NEW-2** `FoldRun(events)` 按 stream 顺序折叠该 RunID 的完整事件序列,第一条必须是 `created`,并按其 `SchemaVersion` 绑定 `Protocol`。Fold 过程执行纯状态重建。import、诊断与 `Runtime.Record` integrity verification 都经 FoldRun;snapshot 通过 FoldRun 结果校验。 ## 4. Machine @@ -142,7 +145,7 @@ func (ToolStep) current() {} type MachineState struct { RunID RunID - Turn TurnID + Owner OwnerID Attempt uint32 Status RunStatus Current Current @@ -174,7 +177,7 @@ type ModelStep struct { } type ToolSpec struct { Ref ToolRef - DefinitionDigest Digest // 本体在 FrozenValueStore + DefinitionDigest Digest // 本体在请求内 Policy ResponsePolicy } type ToolScheduleMode string // "parallel" | "sequential";空值按 parallel 解释 @@ -247,7 +250,7 @@ Recovered 回到 Prepared 后,下一次 Start 重发同一 `RequestDigest` 的 | command | precondition / facts | |---|---| | `AcceptInput` | `Open`;`InputAccepted` | -| `PrepareModelRequest` | `Open`,完整有序消费 PendingInputs,request/tools digests 有效;`ModelStepPrepared`。command 携带请求与工具定义本体,fact 只留 digest,本体由 Runtime 写入 FrozenValueStore | +| `PrepareModelRequest` | `Open`,完整有序消费 PendingInputs,request/tools digests 有效;`ModelStepPrepared`。command 携带请求本体,fact 只留 digest,本体由 Runtime 写入 FrozenValueStore | | `StartModelExecution` | Model Prepared;`ModelStepStarted`。command 必须携带本次 start 的 `ExecutionClaim` | | `RecoverModelExecution` | Model Executing;`ModelStepRecovered`。恢复 durable attempt 时携带该 attempt 的 `Claim` | | `SubmitModelResult` | Model Executing;`ModelStepCompleted{Usage, FinishReason, ResultDigest}`,随后无 calls 时 `RunEnded(completed)`,有 calls 时 `ToolStepOpened`(携带冻结的 `Scheduling` 与 bindings)。command 携带冻结 `ModelResult` 本体,companion 写 `twilight/chatlog/assistant` | @@ -318,15 +321,21 @@ type RunPosition struct { Revision es.Revision; Index uint16 } type RuntimeSnapshot struct { State MachineState // detached in-process view Position RunPosition - Head session.Head // 投影覆盖到的 Session head - ProtocolVersion uint16 + Head session.Head // 读取时的 Session head + SchemaVersion uint16 // created.SchemaVersion } +// ModuleEvent 是其他模块的 typed event,由 agent/session/run 经 Registry 编码。 +type ModuleEvent struct { + Type session.EventType + EventID session.EventID + Value any +} // Companion 把一个 commit 的 Run facts 与 command 携带的 transient 内容映射为 // 其他模块的事件(对话内容、Turn completed)。实现由 agent/turn 提供(TRN-CMP)。 type CompanionRequest struct { Session session.SessionID - Turn TurnID + Owner OwnerID RunID RunID Command AgentCommand Facts []Fact @@ -335,12 +344,12 @@ type CompanionRequest struct { } type Companion interface { Version() string - Map(CompanionRequest) ([]session.UncommittedEvent, error) + Map(CompanionRequest) ([]ModuleEvent, error) } type Protocol struct { - // ProtocolFor 一次绑定该 ProtocolVersion 的函数。方法不再接受 version 参数。 + // ProtocolFor 一次绑定该 SchemaVersion 的函数。方法不再接受 version 参数。 } -func ProtocolFor(protocolVersion uint16) (Protocol, error) +func ProtocolFor(schemaVersion uint16) (Protocol, error) func (RuntimeSnapshot) Protocol() (Protocol, error) func (Protocol) Version() uint16 func (Protocol) DigestRequest(ModelRequest) (Digest, error) @@ -351,12 +360,12 @@ func (Protocol) DigestModelResult(ModelResult) (Digest, error) func (Protocol) DigestToolOutput(CanonicalJSON) (Digest, error) func (Protocol) DigestToolResponseDecision(ResponseKind, ResponseDecision, string) (Digest, error) func (Protocol) DigestCommand(typ string, command AgentCommand) (Digest, error) -func (Protocol) EncodeFact(typ string, fact Fact) (jsonstable.Value, 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) // created 与 input_accepted;编码为 Session event 由 agent/session/run 完成 +func (Protocol) BuildCreateGroup(NewRun, []AgentInput) ([]Fact, error) func (Protocol) EncodeMachineState(*MachineState) (jsonstable.Value, error) func (Protocol) DecodeMachineState(jsonstable.Value) (MachineState, error) func ProtocolV1() Protocol @@ -365,65 +374,73 @@ type CommitRequest struct { Base RunPosition // Load 时的 Position;PrepareModelRequest 为 hard CAS Grant ExecutionGrant Command CommandEnvelope - Attach []session.UncommittedEvent // 调用方附加事件,追加在 companion 之后;例如 Coordinator.Stop 的 twilight/turn/failed + Attach []ModuleEvent // 调用方附加事件,追加在 companion 之后;例如 Coordinator.Stop 的 twilight/turn/failed } type CommitResult struct { Status CommitStatus // CommitAccepted | CommitAlreadyApplied Snapshot RuntimeSnapshot - Commit session.SessionCommit // 完整 commit:run facts 与 companion events + Commit session.SessionCommit // 完整 commit:run facts、companion、Attach Grant ExecutionGrant } ``` **RUN-CMT-1** Runtime 按 `(SessionID, RunID)` 寻址。Run 由 Coordinator 的 Start commit 创建(TRN-STR-2),Runtime 没有 `Create`。缺失 Run 的 Load、Commit、Record 返回 `ErrRunNotFound`。 -**RUN-CMT-2** 投影 `twilight/run/machine` 消费全部 `twilight/run/` 事件,忽略其他模块事件(EXT-PRJ-2 的 `Ignores`),状态为: +**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 最后一条事件的位置 - Results map[RunID]RunResult // 已终结 Run 的结果 + Active map[RunID]MachineState // 非终态 Run + Positions map[RunID]RunPosition // 非终态 Run 的最后事件位置 } ``` -ProjectionKey 为 `twilight/run/machine`,snapshot 使用 Session snapshot envelope(SES-SNP)。`Load` 读取 snapshot 后只 fold snapshot 之后、类型前缀为 `twilight/run/` 的 tail,代价与 Session 全量日志长度无关。终态 Run 的 MachineState 不保留在投影内,读取终态 Run 的完整状态使用 `Record`。`Record` 在一个一致点 resolved replay 该 RunID 的全部 `twilight/run/` 事件,FoldRun 重建并与投影比对,corrupt、gap 或 divergence 必须失败。 +终态 Run 在 `RunEnded` 折叠后从投影中移除;终态结果由 `Record` 与 turn surface 提供,投影大小与活动 Run 数成正比。snapshot 是可丢弃缓存(SES-SNP-1):`Load` 读取 snapshot(若存在且 `Through` 是当前前缀),再 fold 其后类型前缀为 `twilight/run/` 的 tail;没有 snapshot 时从 stream 的过滤 replay 全量 fold。写入策略由 `agent/session/run` 的 `SnapshotPolicy` 决定,默认在 Run 的 `Current` 回到 `Open` 或 Run 终结时写入,并可按 commit 计数补充;kernel 不要求每次 commit 都写。`Record` 以 `Types=[twilight/run/]` 过滤 replay 读取该 RunID 的全部事件(SES-REP-2),FoldRun 重建并与当前投影比对;corrupt、gap 或 divergence 必须失败。 -**RUN-CMT-3** Commit 在 Session Store 的临界区内完成(SES-API-2)。所有 Runtime implementation 在该 section 内调用同一个 pure `EvaluateCommit`,顺序固定为: +**RUN-CMT-3** Commit 经 `extension.SemanticAppender.AppendSemanticIn` 在 Session Store 的一个事务内完成(SES-API-2、EXT-APP-3)。所有 Runtime implementation 在 fn 内调用同一个 pure `EvaluateCommit`,顺序固定为: ```text -1 validate envelope SessionID/RunID/type/digest(digest 不匹配为不可重试错误) -2 tx.LookupCommit(CommitID = CommandID) -3 found -> AlreadyApplied + 原完整 commit;start 精确重放且 grant live 时返回原 grant -4 derived CommandID check -5 state = tx.Projection(twilight/run/machine).Active[RunID];缺失为 ErrRunNotFound;terminal check -6 validate hard CAS(prepare 的 Base == 投影中该 Run 的 Position)/ target state / execution grant / recovery authority -7 facts = Protocol.Decide(state, command) exactly once -8 Protocol.Evolve in order;encode run events -9 companion events = Companion.Map(...)(TRN-CMP);再追加 request.Attach -10 frozen bodies -> FrozenValueStore.Put(幂等) -11 tx.Append(events);apply lease ops;replace projection snapshot —— 同一事务 +AppendSemanticIn(sessionID, func(tx): + 1 validate envelope SessionID/RunID/schema/type/digest(digest 不匹配为不可重试错误) + 2 tx.LookupCommit(CommitID = CommandID) + 3 found -> AlreadyApplied + 原完整 commit;start 精确重放时读控制面 lease,grant live 则返回原 grant + 4 derived CommandID check + 5 state = fold(tx.LoadSnapshot(twilight/run/machine) + tx.Tail(after, [twilight/run/])) + 缺少 created -> ErrRunNotFound;schema 不等于 created.SchemaVersion -> 不可重试错误;terminal check + 6 validate hard CAS(prepare 的 Base == Positions[RunID])/ target state / execution grant / recovery authority + grant 与 lease 从控制面 KV twilight/run/lease 读取 + 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 tx.ControlPut / ControlDelete lease ops;按 SnapshotPolicy tx.SaveSnapshot + 11 return SemanticGroup{CommitID: CommandID, Events: run ++ companion ++ attach} +) +// Appender 在同一事务内完成 codec、Binding admission、claim 写入与 append。 ``` -`Attach` 中的事件必须属于 Catalog 已知的 EventType,且不得是 `twilight/run/` 事件;Runtime 不解释其内容,只保证它们与本 commit 同时可见。 +FrozenValueStore 的 `Put` 幂等且内容寻址,在进入事务之前完成;事务失败时留下的本体无害,可由保留策略回收。 -**RUN-CMT-4** `PrepareModelRequest` 是 hard-CAS command:`Base` 必须等于 section 内投影记录的该 Run 的 `Position`。其他模块写入 Session 不移动 Position,因此不会使 Prepare 失效。其他 command 通过当前 target state 和 grant 做 call-local rebase;stale Base 本身不阻止无冲突的 ingress/control/settlement。相同 command 的 replay 判定先于 terminal check,因此 terminal Run 仍能返回原 commit。 +**RUN-CMT-4** `PrepareModelRequest` 是 hard-CAS command:`Base` 必须等于 section 内投影记录的该 Run 的 `Position`。这是有意选择:同一 Session 内其他模块的写入(用户提交新输入、summary、checkpoint、其他 Turn 的事件)不移动 Position,因此不使 Prepare 失效;Plan 与 Prepare 之间发生的 chatlog 写入不会被本次请求包含,新鲜度由 Application 经 `PlanningToken` 与 Planner 自行负责,Run 不校验 `PlanningToken` 的语义。其他 command 通过当前 target state 和 grant 做 call-local rebase;stale Base 本身不阻止无冲突的 ingress/control/settlement。相同 command 的 replay 判定先于 terminal check,因此 terminal Run 仍能返回原 commit。 **RUN-CMT-5** 幂等键为 Session kernel 的 `(SessionID, CommitID)`,CommitID 等于 CommandID。同 CommandID 的精确重放返回 `CommitAlreadyApplied`、当前 snapshot 与原完整 commit,且不得再次 Decide 或产生外部 effect。对于 `StartModelExecution` 和 `StartToolCall`,Runtime 还必须验证 command 中的 `ExecutionClaim`:相同 CommandID、相同 digest、相同 claim 的精确重放在 grant 仍 live 时返回原 start grant;不同 claim 触发 `ErrCommandConflict`,并保持现有执行授权。非 start command 的 replay 不返回 grant。 **RUN-CMT-6** accepted `StartModelExecution`/`StartToolCall` 为目标签发新 grant;该 start 的 `CommitAccepted` 和在 grant 仍 live 时满足精确 replay 条件的 `CommitAlreadyApplied` 返回同一个 grant。若该 start 已 settlement 或 Run 已 terminal,精确 replay 仍返回 `CommitAlreadyApplied`,并返回空 grant。model result/failure/reject 与 executing tool result/known failure 必须携带 live target grant。settlement 接受后 grant 失效;terminal commit 撤销该 Run 全部 grant。`RecoverModelExecution` 由 live grant holder 提交,或在 Runtime 验证 lease 已过期且 command Claim 等于该 lease 的 Claim 后无 grant 提交。Executing tool 的 recovery 使用同一条 `SubmitToolFailure{Outcome:Unknown}` command:工具 owner 必须携带 live grant;`RecoverExpired` 仅在 lease 已过期且没有已接受 settlement 时无 grant 提交。该 Unknown 只结算这一 call,Run 保持 Active。 -**RUN-CMT-7** Commit 必须原子保存完整 SessionCommit、lease 变更与投影 snapshot;`FrozenValueStore.Put` 幂等,可在 section 内或之前完成。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 +**RUN-CMT-7** commit、lease 变更、claim 与(若写入)snapshot 在同一 Session Store 事务内生效:lease 与 grant 经 `SessionTx.ControlPut/ControlDelete` 写入,claim 由 Appender 写入,三者与 commit 同时可见或同时不可见。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 -**RUN-CMT-8** Run 事实的协议版本是 Session 的 `ProtocolVersion`。`RuntimeSnapshot.ProtocolVersion` 等于 Session Header 的版本;`ProtocolFor(version)` 返回绑定该版本 digest/codec/Decide/Evolve 的 `Protocol`。`agent/run` 不保存进程全局的当前写入版本。历史 Session 由外部 migration tool 迁移(SES-WIR-4)。 +**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 控制面 -grant、lease、ExecutionClaim、ClaimStore、幂等索引与投影 snapshot 都不进入 stream。 +grant、lease、ExecutionClaim、ClaimStore 与投影 snapshot 都不进入 stream。lease 与 durable claim 存放在 Session Store 的控制面 KV(SES-API-3),与 commit 同事务写入;snapshot 使用 Session snapshot(SES-SNP)。 + +lease 的 namespace 为 `twilight/run/lease`,key 为 `/model/` 或 `/call//`,value 为 `{grant, claim, deadline}`;一个 target 至多一条 live lease;grant 只存在于该 lease 上。`Deadline` 为零表示不超时(进程内占用);过期且无 settlement 时 Runtime 允许 grantless Recover。durable `loop.ClaimStore` 可以使用 namespace `twilight/run/claim`。 + +`RecoverExpired` 以 `Store.ControlScan(twilight/run/lease)` 枚举 lease,对 deadline 已过的 target:Executing tool call 无 grant 提交 `SubmitToolFailure{Unknown}`,Executing model 提交 `RecoverModelExecution`;该 Run 保持 Active,同一 RunID 继续。作为 KV 缺失的兜底(SES-API-3),它还扫描 `twilight/run/machine` 投影中处于 Executing 且没有 lease 记录的 target,以对应 start 事件的 `RecordedAtUnixMilli` 加 `LeaseTTL` 判定过期。因此 lease 记录丢失只使恢复延迟到 TTL 之后,不使 Run 停滞。进程内宿主使用 Memory 实现,lease 不超时,grantless recover 被拒绝。生产崩溃恢复使用带 TTL 的 Runtime。 -lease 以 `(SessionID, RunID, key)` 存放,key 为 `model/` 或 `call//`,一个 target 至多一条 live lease;grant 只存在于该 lease 上。lease 的 `Deadline` 为零表示不超时(进程内占用);过期且无 settlement 时 Runtime 允许 grantless Recover。进程崩溃时不写 settlement。`RecoverExpired` 通过 lease 表的过期扫描只加载有过期 lease 的 `(SessionID, RunID)`:对每个过期 Executing tool call 无 grant 提交 `SubmitToolFailure{Unknown}`,对过期 Executing model 提交 `RecoverModelExecution`。该 Run 保持 Active,同一 RunID 继续。进程内宿主使用 Memory 实现,lease 不超时,grantless recover 被拒绝。生产崩溃恢复使用带 TTL 的 Runtime。 +**RUN-CMT-9** lease 续期。`Runtime.RenewLease` 在临界区之外以 `Store.ControlGet/ControlPut` 执行:grant 等于该 target 当前 lease 的 grant 时,把 deadline 推后一个 `LeaseTTL`;lease 不存在、grant 不匹配或 target 已 settlement 时返回 `ErrStaleRuntime`。持有 grant 的 worker 在效果执行期间必须以远小于 `LeaseTTL` 的间隔续期(Loop 的 `ExecutionPolicy.LeaseRenewInterval`);续期返回 `ErrStaleRuntime` 表示该 target 已被 recovery 接管,worker 必须停止执行并放弃 settlement。`LeaseTTL` 是恢复延迟上界。`LeaseTTL` 为零时 `RenewLease` 只验证 grant,不改变 deadline。 -**RUN-CMT-9** lease 续期。`Runtime.RenewLease` 在 grant 等于该 target 当前 lease 的 grant 时,把 deadline 推后一个 `LeaseTTL`;lease 不存在、grant 不匹配或 target 已 settlement 时返回 `ErrStaleRuntime`。持有 grant 的 worker 在效果执行期间必须以远小于 `LeaseTTL` 的间隔续期(Loop 的 `ExecutionPolicy.LeaseRenewInterval`);续期返回 `ErrStaleRuntime` 表示该 target 已被 recovery 接管,worker 必须停止执行并放弃 settlement。`LeaseTTL` 是恢复延迟上界。`LeaseTTL` 为零时 `RenewLease` 只验证 grant,不改变 deadline。 +持久结构与一致性等级的总表见 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 7 节。 ## 6. Loop ports 与 policy @@ -431,7 +448,7 @@ lease 以 `(SessionID, RunID, key)` 存放,key 为 `model/` 或 `call/ // package agent/run type PlanningHint struct { Session session.SessionID - Turn TurnID + Owner OwnerID RunID RunID SourceStep StepID Inputs []AgentInput @@ -551,18 +568,19 @@ type Event struct { ## 9. compatibility 与 conformance -**RUN-CMP-1** 当前 pre-release 的 command/fact discriminator、wire fields、canonical digest、derived ID 和 `ProtocolV1().Evolve` 由 golden fixtures 保护;发布前有意修改协议时必须同步更新 fixture。发布后,新增 variant、字段或折叠语义遵循 Session `ProtocolVersion` 的兼容性判定(SES-WIR-4)。 +**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 必须覆盖: -- Start group 建立 Run、重复 `created` 拒绝、missing Run; +- Start group 建立 Run、重复 `created` 拒绝、missing Run、schema 与 created 不一致的 command 拒绝; - command exact replay/conflict、prepare hard CAS、call-local rebase、terminal replay; - grant 签发、隔离、精确 start replay、消费、跨 Run 拒绝与 recovery authorization; -- 一 command 一 commit、run facts 在 companion 与 Attach 之前、companion 的 `SourceDigest` 等于 fact 记录的 ResultDigest / OutputDigest、Attach 拒绝 `twilight/run/` 事件; +- 一 command 一 commit、run facts 在 companion 与 Attach 之前、companion 的 `SourceDigest` 等于 fact 记录的 ResultDigest / OutputDigest、Attach 拒绝 `twilight/run/` 事件、companion 与 Attach 经 admission 并建立 claim; - Prepare hard CAS 只对该 Run 自己的事件敏感:同一 Session 内其他模块的写入不使 Prepare 失效; -- 投影 snapshot 加 tail 与全量 fold 等价;Record 单一一致点、FoldRun 等价、gap/tamper/corrupt failure; -- 同一 Session 内多 Run 隔离;同一 Session 的 chatlog/turn 事件不影响 Run fold; -- lease 过期 recovery:live lease 拒绝 grantless、过期 model 回到 Prepared、过期 tool 记 Unknown 且 sibling 不受影响、RecoverExpired 幂等; +- commit、lease、claim、snapshot 同事务:在任一写入点注入崩溃后四者同时存在或同时缺失; +- 投影 snapshot 加 tail 与全量 fold 等价;删除 snapshot 后 Load 结果不变;终态 Run 不再出现在投影中;Record 单一一致点、FoldRun 等价、gap/tamper/corrupt failure; +- 同一 Session 内多 Run 隔离、不同 SchemaVersion 的 Run 共存;同一 Session 的 chatlog/turn 事件不影响 Run fold; +- lease 过期 recovery:live lease 拒绝 grantless、过期 model 回到 Prepared、过期 tool 记 Unknown 且 sibling 不受影响、RecoverExpired 幂等;删除 lease 记录后 Executing target 在 start 时间加 TTL 后仍被恢复; - lease 续期:续期后原 deadline 不触发 recovery、错误/空 grant 与 settlement 后续期被拒; - FrozenValueStore:Put 幂等、Recovered 后按 RequestDigest 取回同一请求、本体缺失的错误分类、step 终结后删除本体不影响 Record; - MachineState codec:每个 Current variant 与终态 round-trip、拒绝 unknown field / 非法判别式 / trailing data。 diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index b8be367..db7ad32 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -34,26 +34,27 @@ ```text Session stream 唯一 authority:twilight/turn、twilight/chatlog、twilight/run 事件同在一条 stream -MachineState Run 的语义状态投影(twilight/run/machine),snapshot 为派生缓存 -Runtime Run command 的提交入口:Session commit 临界区内 Decide、Evolve、追加 -FrozenValueStore 内容寻址旁存:模型请求与工具定义本体 +控制面 KV Session Store 的 control-plane KV:lease、grant、artifact claim;与 commit 同事务,不进入 stream +MachineState Run 的语义状态投影(twilight/run/machine),snapshot 为可丢弃缓存 +Runtime Run command 的提交入口:Session 临界区内 Decide、Evolve,经 SemanticAppender 追加 +FrozenValueStore 内容寻址旁存:模型请求本体(含工具定义) ``` -2026-09-04 之前的设计为两条 ES(Run 独立的 `RunHeader + TransitionRecord[]`,Turn 把 Run 事实 materialize 到 Session)。该设计已被第 6 节记录的决定取代。 +2026-09-04 之前的设计为两条 ES(Run 独立的 `RunHeader + TransitionRecord[]`,Turn 把 Run 事实 materialize 到 Session)。该设计已被第 6 节记录的决定取代,第 7 节记录审查后的第二次修订。 ### 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 contract +agent/run Run Machine、frozen values、fact codec、fold、Runtime 与 Companion contract agent/run/loop in-process model/tool interpreter 与 observation ports -agent/session Event-first Session kernel(Commit CAS 与 CommitIn 临界区) -agent/session/extension Session Module Framework:static modules、codec、semantic append、projection +agent/session Event-first Session kernel(Commit CAS、CommitIn 临界区、snapshot、控制面 KV) +agent/session/extension Session Module Framework:first-party Registry、payload 版本、admission、SemanticAppender、projection agent/session/chatlog first-party Message ontology -agent/session/run first-party Run module:EventDefinition、machine projection、Runtime 实现、FrozenValueStore 与 lease adapter -agent/artifact Ref、Binding、RetentionClaim -agent/turn Turn 生命周期、attempt、companion 映射 +agent/session/run first-party Run module:EventDefinition、machine projection、Runtime 实现、FrozenValueStore、lease 编码 +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` 同级。 @@ -68,6 +69,8 @@ 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 @@ -78,8 +81,10 @@ agent/turn -> agent/run + agent/session + agent/session/extension + agen - Chatlog Message 原生支持 first-party Artifact references;`sdk.Message` 只是 materialized provider transport。 - Turn Coordinator 从 `twilight/turn/surface` 与 `twilight/run/machine` 投影重建,不保存隐藏的长期状态。 - Run 事实与其对话内容(companion)在同一 SessionCommit 写入;没有 Run→Session materialization、coverage 水位或 outbox。 -- lease 与其 recovery(`RenewLease`、`RecoverExpired`)是 `run.Runtime` public contract 的一部分,由 [agent-run.md](agent-run.md) 第 5 节定义;lease、grant、ExecutionClaim、ClaimStore、投影 snapshot 与 FrozenValueStore 是控制面或派生数据,不进入 stream。 -- Run fact 只保存执行状态与内容 digest;请求与工具定义本体在 FrozenValueStore,模型输出与工具输出在 chatlog 事件。 +- 只有一条写入路径:`SemanticAppender`。Run 的 Runtime 经 `AppendSemanticIn` 写入,companion 与 Attach 事件与其他 producer 一样经 admission;artifact claim 与 commit 同事务。 +- lease、grant、durable claim 存放在 Session Store 的控制面 KV,与 commit 同事务;ExecutionClaim、投影 snapshot 与 FrozenValueStore 是控制面或派生数据,不进入 stream。 +- Run fact 只保存执行状态与内容 digest;请求本体(含工具定义)在 FrozenValueStore,模型输出与工具输出在 chatlog 事件。 +- kernel `ProtocolVersion` 只覆盖 envelope 与 commit;payload 版本由模块携带(`v` 字段),Run 保留自己的 `SchemaVersion`。 ## 3. 已完成迁移 @@ -107,18 +112,19 @@ agent/turn -> agent/run + agent/session + agent/session/extension + agen ### 4.1 Core reference implementations -- 冻结 Session、Artifact 与 Chatlog v1 wire profiles、domain separators、wire-size validation 和 golden fixtures;Run fact 的 wire 与 golden 并入同一 ProtocolVersion; -- 实现 Session kernel Memory Store(含 `CommitIn`)、Artifact、Session Module、Chatlog 的 Memory implementations 与 conformance; -- 按第 6 节把 `agent/run` 的存储层改为 Session module; -- 把 `agent/turn` 改为 attempt 模型与 companion,`Log` 替换为 Session Store 与 `extension.SemanticAppender`; -- 加 Chatlog Context projection 与参考 Planner;`PlanningHint` 只提供边界事实。 +- 实现 Session kernel Memory Store:Commit、`CommitIn`、Types 过滤 replay、snapshot、控制面 KV;v1 conformance 不含 Fork 与 import; +- 实现 `extension.FirstPartyRegistry`、payload 版本、admission 与 `SemanticAppender`(两个入口,claim 同事务);artifact 两态 ledger 的 Memory 实现; +- 按第 6、7 节把 `agent/run` 的存储层改为 Session module(`agent/session/run`); +- 把 `agent/turn` 改为 attempt 模型与 `CompanionV1`,`Log` 替换为 Session Store 与 `SemanticAppender`; +- 加 Chatlog Context projection 与参考 Planner;`PlanningHint` 只提供边界事实; +- 纵向切片跑通后再冻结 kernel `ProtocolVersion` 1 与各模块 payload 版本 1 的 golden fixtures。 ### 4.2 durable adapters -- Session Store 的 SQLite 与 PostgreSQL adapter(commit、CommitIn 事务、snapshot、lease 表、FrozenValueStore); +- Session Store 的 SQLite 与 PostgreSQL adapter(commit、CommitIn 事务、snapshot、控制面 KV、Types 索引); - 收紧 Session authority tables 的 immutable RLS policy; -- 实现过期 execution recovery scanner; -- 设计 Artifact Binding/Claim 与 semantic append intent tables。 +- 实现 `RecoverExpired` 的定期调度; +- 需要远程 Store 或跨存储 claim 时,实现 extension 附录 C 与 artifact 附录的两阶段路径。 ### 4.3 Application migration @@ -197,16 +203,66 @@ Run 从独立的 Event Sourcing 存储改为 first-party Session Module。Run | 项 | 内容 | |---|---| -| `state.go` | 新增 `TurnID`、`RunPosition`、`Companion` 接口;`MachineState` 加 `Turn`、`Attempt`,删 `LastModelResult`;`ModelStep.Request` 改为 `RequestDigest`;`ToolSpec` 删 `Definition`;`RunResult` 删 `Model` | -| `fact.go` | 删 `RunHeader`、`TransitionRecord`、`AgentEvent`;`ModelStepCompleted` 改为 `{StepID, Usage, FinishReason, ResultDigest}`;`ToolCallCompleted`/`ToolCallAnswered` 改为 digest;新增 `RunCreated`;fact codec 输出 `jsonstable.Value` | +| `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 | -| `commit.go` | `EvaluateCommit` 改为 SessionTx 形态:LookupCommit、读投影、以 `RunPosition` 做 prepare hard CAS、Decide、Evolve、companion、Attach、SourceDigest 校验,返回 AppendRequest 与 lease ops | +| `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`;新增 `CompanionV1`、`Retry`、`Settle`、surface 投影 | +| `agent/turn` | 删 `mapper.go` 的 MaterializeAll、`ResultReference`、`MemoryLog`;`TurnID` 留在 turn,写入 Run 时转为 `OwnerID`;新增 `CompanionV1`、`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(含 `CommitIn`)、`agent/session/run`(module descriptor、machine projection、Runtime 实现、Memory FrozenValueStore 与 lease)、golden fixtures 重新冻结。 +新增:`agent/session` Memory Store(Commit、`CommitIn`、Types 过滤 replay、snapshot、控制面 KV)、`agent/session/extension`(FirstPartyRegistry、payload 版本、admission、SemanticAppender)、`agent/artifact` 两态 ledger 的 KV 实现、`agent/session/run`(module descriptor、machine projection、Runtime 实现、Memory FrozenValueStore、lease 编码、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。`RecoverExpired` 增加兜底:扫描投影中 Executing 且无 lease 的 target,以 start 时间加 TTL 判定过期,KV 缺失只导致恢复延迟 | 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 持久结构与一致性等级 + +| 结构 | 等级 | 写入点 | 丢失或不一致时 | +|---|---|---|---| +| Session commit 与 head | authority | `Commit` / `CommitIn` | 不可恢复;digest chain 使损坏可检测 | +| 控制面 KV:`twilight/run/lease` | 同事务控制面 | `SessionTx.ControlPut`(start、settlement)、`Store.ControlPut`(续期) | 恢复延迟到 start 时间加 TTL;语义不受影响 | +| 控制面 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`(lease 扫描加无 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)与 conformance; +2. extension FirstPartyRegistry、payload 版本、admission、SemanticAppender 两个入口;artifact 两态 ledger; +3. `agent/session/run`:module descriptor、machine projection、Runtime 实现、FrozenValueStore、lease 编码、RecoverExpired; +4. `agent/run` 按 6.4 修改,golden 重新冻结; +5. `agent/turn` attempt 模型、CompanionV1、surface 投影; +6. 参考组装跑通 Input → Turn → Run → Session 纵向切片,再接 live 模型。 后续协议修改直接更新对应正式规范;本文只更新迁移状态和历史决策,不再承载 wire、Machine、Runtime 或 Loop 算法。 diff --git a/docs/design/agent-session-chatlog.md b/docs/design/agent-session-chatlog.md index 0bd4f07..dfe937a 100644 --- a/docs/design/agent-session-chatlog.md +++ b/docs/design/agent-session-chatlog.md @@ -15,7 +15,7 @@ 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/` 事实写在同一 SessionCommit(TRN-CMP)。Run 事实只记录内容 digest,内容本体只在 chatlog 事件中出现一次。 +`assistant` 与 `tool_result` 由 `run.Runtime` 作为 companion 事件,与产生它们的 `twilight/run/` 事实写在同一 SessionCommit(TRN-CMP)。Run 事实只记录内容 digest,内容本体只在 chatlog 事件中出现一次。companion 事件与其他 producer 的事件走同一条写入路径:`SemanticAppender` 在同一事务内执行 codec、Binding admission 并建立 claim(EXT-APP-3),因此 companion 中的 `ReferencePart` 受到与用户输入相同的保护。 流式 `text_delta` / `reasoning_delta` 由 Loop EventSink 发送,属于临时观察。Chatlog 权威是已提交的条目。 @@ -43,7 +43,7 @@ type CheckpointID string | Summary | `summary` | 无 | 随 checkpoint 失效 | checkpoint 的摘要正文 | | Checkpoint | `checkpoint_created` | 无 | invalidated | 指向已有 EventPosition | -**CHT-LIF-1** reducer 拒绝 identity mutation、非法状态迁移、replacement conflict 与重复 ID。模型步骤进行中走 EventSink;定稿随 `ModelStepCompleted` / `ToolCallCompleted` 等 Run 事实同 commit 写入 `assistant` 或 `tool_result`。同一 Turn 的多个 Run attempt 各自产生 assistant 与 tool_result;Context 按 TurnID 与 commit 顺序全部保留,attempt 之间的取舍由 Application 通过 summary 或 checkpoint 处理。 +**CHT-LIF-1** reducer 拒绝 identity mutation、非法状态迁移、replacement conflict 与重复 ID。模型步骤进行中走 EventSink;定稿随 `ModelStepCompleted` / `ToolCallCompleted` 等 Run 事实同 commit 写入 `assistant` 或 `tool_result`。同一 Turn 的多个 Run attempt 各自产生 assistant 与 tool_result,全部保留在 stream 中并出现在 ContextFold 的输出里;哪些条目进入模型请求由 Planner 决定(TRN-RTY-3、REF-PLN-6),本模块不作取舍。 ## 3. parts 与条目 @@ -158,7 +158,7 @@ type ContentRefCodec interface { **CHT-COD-2** registry 在启动时固定,kind 有唯一 codec。BindingExtractor 按 appearance order 返回 assistant、tool_result、summary 中 ReferencePart 的 BindingID。 -**CHT-COD-3** EventType 为 `twilight/chatlog/`。条目 Digest 的 domain 与 EventType 相同,覆盖 ID、TurnID(若有)、有序 parts 或 Content、ref identity、SourceDigest(若有)。wire 与 digest 形状由 Session `ProtocolVersion` 决定: +**CHT-COD-3** EventType 为 `twilight/chatlog/`。payload 第一层携带版本字段 `v`(EXT-REG-2),由 Registry 写入与读取,本模块的 codec 不读写它;chatlog payload 的非兼容变化只增加本模块的 `Current` 版本与一个新 codec。条目 Digest 的 domain 与 EventType 相同,覆盖 ID、TurnID(若有)、有序 parts 或 Content、ref identity、SourceDigest(若有),不覆盖 `v`。wire 与 digest 形状由 Session `ProtocolVersion` 决定: ```text Digest("twilight/chatlog/input_submitted", ...) diff --git a/docs/design/agent-session-extension.md b/docs/design/agent-session-extension.md index 1b94fc3..a0c03ee 100644 --- a/docs/design/agent-session-extension.md +++ b/docs/design/agent-session-extension.md @@ -1,170 +1,127 @@ # Twilight Agent Session Module Framework -状态:设计草案。无实现;Catalog、SemanticAppender 与 journal 流程在 Memory reference implementation 通过 conformance 前不冻结。 +状态:设计草案。无实现;Registry、SemanticAppender 与 projection 在 Memory reference implementation 通过 conformance 前不冻结。2026-09-04 第二次修订:v1 收缩为 first-party 固定注册表与单事务 append;Application module、通用 Catalog 与两阶段 journal 移入附录,不进入 v1 conformance。 -本文定义建立在 `agent/session` 与 `agent/artifact` 之上的静态 Session Module Framework。实现包路径暂为 `agent/session/extension`;文中的“必须”“不得”“应该”是协议约束;JSON canonicalization 与 digest 遵循 `agent/jsonstable`、`agent/es`。 +本文定义建立在 `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 与 Application modules + first-party modules: chatlog、turn、run ``` -Framework 负责 Module ownership、static startup composition、immutable Catalog、typed event -codec、Binding declaration、SemanticAppender 和 pure projection。first-party Source 为 -`twilight`,其 Module 为 `chatlog`、`turn` 与 `run`。每次进程启动构建一套与 Session protocol -version 绑定的 immutable Catalog。Application 注册自己的 Source(例如 `acme`)与 Module。 +Framework 负责 typed event codec 与 payload 版本、Binding declaration 与 admission、claim 与 commit 的同事务写入、pure projection。first-party Source 为 `twilight`,其 Module 为 `chatlog`、`turn` 与 `run`。 -**EXT-SCP-1** Session Store protocol 由 Session kernel 负责;多 Session transaction、package import、saga、operation log 与 provider policy 由 Application/adapter 负责。未知 event 的 archive Binding manifest 由 Application archive coordinator 处理。`run` 模块的写入经 `run.Runtime` 使用 `Store.CommitIn`(SES-API-2),不经 SemanticAppender;其 typed codec 与 projection 仍由 Catalog 提供。 +**EXT-SCP-1** v1 只有一个写入路径:`SemanticAppender`。它有两个入口,`AppendSemantic`(CAS)与 `AppendSemanticIn`(临界区),两者执行同一套 codec、admission 与 claim 规则,都在 Session Store 的一个事务内完成。Run 的 `Runtime` 经 `AppendSemanticIn` 写入;Turn 的 Start、Retry、Settle 经任一入口写入。raw `session.Store` 只由 Appender、Application 的 trusted adapter 与测试持有。 -`SemanticAppender` 是 Session Module Framework 面向 typed producer 的写入协调器:它执行 codec、binding -admission、claim/journal 编排,并调用 Session Store 的 canonical append。claim、journal 与 -artifact binding 的一致性由 Session Module Framework 维护;Session kernel 继续维护 commit、CAS、digest 与 -replay 规则。 +**EXT-SCP-2** v1 的模块集合在编译期固定为三个 first-party module。Application 自定义 Source 与 Module、通用 `Catalog` 构建校验、`RuntimeRegistry` 与自定义 `BindingExtractor` 见附录 B;远程或跨存储 adapter 的两阶段提交见附录 C。 -## 2. Module、Catalog 与版本 +## 2. Registry 与版本 ```go type SourceID string type ModuleID string type ProjectionID string type ProjectionVersion uint16 -type RegistryID string +type PayloadVersion uint16 const SourceTwilight SourceID = "twilight" +type EventDefinition struct { + Type session.EventType + Current PayloadVersion // Encode 使用的版本 + Codecs map[PayloadVersion]PayloadCodec // Decode 支持的全部版本 + Bindings []BindingReferenceDefinition +} type ModuleDescriptor struct { - Source SourceID ID ModuleID Events []EventDefinition Projections []ProjectionDefinition - Registries []CodecRegistryDescriptor - Schemes []artifact.SchemeDefinition } -type Catalog struct { +type Registry struct { ProtocolVersion uint16 Profile session.ProtocolProfile - // immutable indexes for modules, events, projections, registries and schemes + // immutable indexes for modules, events and projections } -type CatalogBuildRequest struct { - ProtocolVersion uint16 - Profile session.ProtocolProfile - Modules []ModuleDescriptor - Registries []RuntimeRegistry -} -type RuntimeRegistry interface { - Descriptor() CodecRegistryDescriptor - BindingExtractor() (BindingExtractor, bool) -} -func BuildCatalog(CatalogBuildRequest) (*Catalog, error) -func (c *Catalog) LookupEvent(session.EventType) (SourceID, ModuleID, EventDefinition, bool) -func (c *Catalog) ModuleForEvent(session.EventType) (SourceID, ModuleID, bool) -func (c *Catalog) LookupProjection(ProjectionID, ProjectionVersion) (ProjectionDefinition, bool) +func FirstPartyRegistry(profile session.ProtocolProfile, parts PartsRegistry) (*Registry, error) +func (r *Registry) LookupEvent(session.EventType) (ModuleID, EventDefinition, bool) +func (r *Registry) ModuleForEvent(session.EventType) (ModuleID, bool) +func (r *Registry) LookupProjection(ProjectionID, ProjectionVersion) (ProjectionDefinition, bool) +func (r *Registry) Decode(session.SessionEvent) (DecodedEvent, error) ``` -`Catalog` 是进程启动时的 Module 目录:它把一个协议版本下可用的 Module、EventType、 -payload codec、projection、runtime registry 与 artifact scheme 组合成只读索引。它供 -typed append、decode、binding admission 与 projection lookup 使用;事件数据保存在 Session -stream 中,Catalog 只保存组合配置。`Catalog.Profile` 是同一版本的 -`session.ProtocolProfile`,两者在 `BuildCatalog` 时绑定。 - -**EXT-CAT-1** `SourceID` 与 `ModuleID` 为小写 ASCII。first-party Source 为 `twilight`, -其 Module 为 `chatlog`、`turn` 与 `run`。Application 注册自己的 Source(例如 `acme`)。 -EventType 为 `//`,例如 `twilight/chatlog/assistant`、 -`twilight/turn/started`、`twilight/run/model_step_prepared`。Digest domain 与 EventType 相同;wire 与 digest 形状由 Catalog -绑定的 Session `ProtocolVersion` 决定。一个 Catalog 中 `(Source, ModuleID)`、EventType、 -ProjectionID、Scheme、RegistryID 均唯一。Build defensive-copy 所有 descriptor -和 runtime registry,成功后只读且与注册顺序无关。 - -事件类型在一个 Catalog 中只有一个当前定义。`ProtocolVersion` 覆盖已经持久化的 -payload 格式与 Session wire;ProjectionVersion 只标识 projection 的派生状态格式。Catalog -本身属于进程启动时的组合配置,不进入 Session wire、event digest 或 commit identity。 -现有 EventType 的 wire identity 由其 ProtocolVersion 固定。版本判断遵循旧 reader 的 -语义兼容性:旧 reader 读取新 writer 的 log 后仍能保持 canonical validation、commit/fork/ -replay 顺序、history/context/recovery 与 digest chain 语义时,继续使用当前 ProtocolVersion。 -安全可忽略的新 persisted EventType 与真正 optional 的 payload 字段属于这一类;非持久化 -projection、registry 或 scheme 的增加沿用当前版本。 - -旧 reader 无法保持上述语义的 event、payload、envelope 或 commit 变化提升 -`ProtocolVersion`。影响 Session 核心重建的新增 EventType 也属于这一类。ProjectionVersion -管理单个派生快照的 state codec;projection 实现变化只有在改变 Session 核心重建语义时 -才推动 ProtocolVersion。 - -**EXT-CAT-2** Build 必须拒绝 duplicate owner、namespace mismatch、无效 ProtocolVersion、registry requirement mismatch、非法 Binding declaration、以及任何 Session event declaration 的 `RequiredDurability < EventBound`。Binding declaration 引用 RegistryID 时,Catalog 必须找到匹配 runtime registry,并要求其 `BindingExtractor()` 返回 `(extractor, true)` 且 extractor 非 nil/非 typed nil;`false` 明确表示该 registry 不支持 Binding extraction,不能被该 declaration 引用。 - -**EXT-CAT-3** `BuildCatalog` 要求 `Profile` 非 nil(含 typed nil)且 -`Profile.Version() == ProtocolVersion`。一个 Catalog 只服务一个 ProtocolVersion;typed -append、event decode、projection run 与 binding admission 先校验 Session Header 的版本 -与 Catalog 版本一致。当前版本之外的 Session 先经外部 migration 转换,再进入该 Catalog。 +**EXT-REG-1** EventType 为 `twilight//`,例如 `twilight/chatlog/assistant`、`twilight/turn/started`、`twilight/run/model_step_prepared`。Digest domain 与 EventType 相同。一个 Registry 中 EventType、ProjectionID 均唯一;`FirstPartyRegistry` 构建后只读,`Profile.Version()` 必须等于 `ProtocolVersion`。 + +**EXT-REG-2** payload 版本与 kernel 版本分离(SES-VER-1)。每个 payload object 的第一层携带整数字段 `v`;`Encode` 写入 `Current`,`Decode` 读取 `v` 并选择 `Codecs[v]`。同一 EventType 的旧版本 codec 永久保留在 Registry 中,旧事件不迁移。一个模块的 payload 非兼容变化只增加该 EventType 的 `Current` 与一个新 codec,不影响其他模块,不触发 kernel 版本变化。 + +**EXT-REG-3** `Decode` 对未注册的 EventType,或已注册 EventType 的未注册 `v`,返回 `DecodedEvent{Unknown:true}` 并保留原始 payload;是否接受由 projection 的 `RequireComplete` 决定(EXT-PRJ-2)。Encode 对 `Current` 之外的版本拒绝。 ## 3. event codec ```go -type EventDefinition struct { - Type session.EventType - Codec PayloadCodec - Bindings []BindingReferenceDefinition - RegistryRequirements []CodecRegistryRequirement -} type PayloadCodec interface { - Encode(value any) (jsonstable.Value, error) + Encode(value any) (jsonstable.Value, error) // 不含 v;Registry 负责加入 Decode(wire jsonstable.Value) (any, error) Validate(value any) error } -type CodecRegistryDescriptor struct { - ID RegistryID; WireManifest jsonstable.Value; WireProfile string -} -type CodecRegistryRequirement struct { ID RegistryID; WireManifest jsonstable.Value } type DecodedEvent struct { - Event session.SessionEvent; ModuleID ModuleID + Event session.SessionEvent + ModuleID ModuleID + Version PayloadVersion Value any + Unknown bool } ``` -**EXT-COD-1** descriptor 是 wire manifest/profile;runtime registry 是显式提供的 immutable implementation。descriptor 不声称 hash 或证明代码。Catalog 必须逐字段匹配 requirement 和 runtime descriptor。 - -**EXT-COD-2** codec、Validate、Binding extraction 必须纯、确定、无 IO,不读 clock/random/environment/mutable global。Decode wire-first:先验证 object、discriminator(如适用)、kind、ProtocolVersion/profile 和 limits,再构造 value。Encode/Decode 必须拒绝 nil、typed nil、kind mismatch、未知 kind 和非 canonical value;有效值须满足 `Encode → Decode → Encode` 的 canonical round-trip。 +**EXT-COD-1** codec、Validate、Binding extraction 必须纯、确定、无 IO,不读 clock/random/environment/mutable global。Decode wire-first:先验证 object、discriminator(如适用)、kind 与 limits,再构造 value。Encode/Decode 必须拒绝 nil、typed nil、kind mismatch、未知 kind 和非 canonical value;有效值须满足 `Encode → Decode → Encode` 的 canonical round-trip。 -**EXT-COD-3** EventDefinition 的 Codec 遵循 Catalog 的 ProtocolVersion。已提交事件的 -payload 保持原始 canonical bytes。旧 reader 可以安全保留并忽略的新 EventType 或真正 -optional 的 payload 字段沿用当前 ProtocolVersion;旧 reader 无法保持 Session 核心语义的 -变化提升 ProtocolVersion,并由外部 migration tool 生成当前版本 stream。Catalog 对未知 -EventType 保留 raw payload,projection 按 `RequireComplete` 规则决定是否接受该事件。 +**EXT-COD-2** 已提交事件的 payload 保持原始 canonical bytes。`v` 字段由 Registry 在 Encode 后加入、Decode 前取出,codec 自身不读写它;payload 的其他第一层字段不得命名为 `v`。 -## 4. Binding reference declaration +## 4. Binding reference declaration 与 admission ```go type Cardinality struct { Min uint32; Max *uint32 } type BindingReferenceDefinition struct { - JSONPointer string // 仅普通 JSON payload 路径 - RegistryID RegistryID // 仅 custom runtime-registry 路径 + JSONPointer string // 普通 JSON payload 路径;与 Parts 二选一 + Parts bool // 使用 PartsRegistry 提取(chatlog 的 assistant、tool_result、summary) Cardinality Cardinality AllowedSchemes []artifact.Scheme RequiredDurability artifact.Durability } -type BindingOccurrence struct { BindingID artifact.BindingID; Location string } -type BindingExtractor interface { BindingIDs(value any) ([]artifact.BindingID, error) } +type PartsRegistry interface { + BindingIDs(value any) ([]artifact.BindingID, error) // appearance order +} ``` -**EXT-REF-1** declaration 恰选一种:非空 JSONPointer,或非空 RegistryID。JSONPointer 路径仅在普通 canonical JSON payload 上执行;RegistryID 路径把该 event 的 decoded typed value 交给对应 RuntimeRegistry 的 BindingExtractor。custom registry(包括 `twilight/chatlog/parts`)必须以 `BindingIDs(value)` 返回内部全部引用,不能回退为 JSONPointer 猜测。提取保留 occurrence appearance order,随后 append group 才 sorted-unique。 +**EXT-REF-1** declaration 恰选一种:非空 JSONPointer,或 `Parts=true`。JSONPointer 路径在 canonical JSON payload 上执行;Parts 路径把 decoded typed value 交给 `PartsRegistry`,它必须返回内部全部引用,不能回退为 JSONPointer 猜测。提取保留 appearance order,随后 group 才 sorted-unique。 -**EXT-REF-2** Catalog 验证 cardinality、pointer grammar、RegistryID extractor availability 与 scheme/durability declaration。admission 解析每个 Binding,验证 Scheme、最低 durability、resolvability 与 host access policy;任何遗漏或违反均拒绝整个 group。所有 Session event declaration 的最低 durability 至少为 `EventBound`。 +**EXT-REF-2** Registry 构建时验证 cardinality、pointer grammar 与 scheme/durability declaration;所有 declaration 的最低 durability 至少为 `EventBound`。admission 解析每个 Binding,验证 Scheme、最低 durability、resolvability 与 host access policy;任何遗漏或违反均拒绝整个 group,不作任何写入。 ## 5. SemanticAppender -普通 producer 只持有 SemanticAppender;raw `session.Store` 只能注入它或由 trusted adapter/recovery 使用。 - ```go type TypedEvent struct { EventID session.EventID; Type session.EventType RecordedAtUnixMilli int64; SourceEvents []session.EventID; Value any } +type SemanticGroup struct { + CommitID session.CommitID + CausationID es.CausationID; CorrelationID string + Events []TypedEvent +} type SemanticAppendRequest struct { - SessionID session.SessionID; ExpectedHead session.Head; CommitID session.CommitID - CausationID es.CausationID; CorrelationID string; Events []TypedEvent + SessionID session.SessionID; ExpectedHead session.Head + Group SemanticGroup +} +// SemanticTx 是 session.SessionTx 加 typed decode;所有方法在同一事务内生效。 +type SemanticTx interface { + session.SessionTx + Decode(session.SessionEvent) (DecodedEvent, error) } +type SemanticCommitFn func(SemanticTx) (*SemanticGroup, error) + type SemanticAppendOutcome string const ( SemanticApplied SemanticAppendOutcome = "applied" @@ -172,89 +129,54 @@ const ( SemanticHeadConflict SemanticAppendOutcome = "head_conflict" SemanticCommitConflict SemanticAppendOutcome = "commit_conflict" SemanticInvalid SemanticAppendOutcome = "invalid" - SemanticIndeterminate SemanticAppendOutcome = "indeterminate" + SemanticNoop SemanticAppendOutcome = "noop" // fn 返回 nil ) type SemanticAppendResult struct { - Outcome SemanticAppendOutcome; Commit *session.SessionCommit - BindingSet *artifact.BindingSet; Reconciled bool + Outcome SemanticAppendOutcome + Commit *session.SessionCommit + Claim *artifact.RetentionClaim // group 含 Binding 时非空 } type SemanticAppender interface { AppendSemantic(context.Context, SemanticAppendRequest) (SemanticAppendResult, error) + AppendSemanticIn(context.Context, session.SessionID, SemanticCommitFn) (SemanticAppendResult, error) } ``` -为闭合进程崩溃后只有 claim、却没有完整 append request 的状态,单次 semantic append 使用下列最小 durable journal;multi-Session package saga 由 Application coordination 负责。 +**EXT-APP-1** group 的 Events 是完整 group,不能为空。Appender 对每个 TypedEvent lookup EventDefinition、Validate、canonical Encode(加入 `v`)、decode round-trip 和 Binding extraction;任一失败不作写入。它将全部 occurrence 组成 sorted-unique union,并通过 `artifact.BindingSetBuilder.Build(ctx, union)` 构造完整 BindingSet,再按 declaration 执行 Scheme、最低 durability 与 host access policy。Appender 必须将 TypedEvent 的 `EventID`、`RecordedAtUnixMilli`、`SourceEvents` 与 canonical Payload、Type 逐字段映射为 `session.UncommittedEvent`,不得生成或替换其中任一值。 -```go -type SemanticAppendIntentState string -const ( - IntentPending SemanticAppendIntentState = "pending" - IntentCompleted SemanticAppendIntentState = "completed" - IntentAborted SemanticAppendIntentState = "aborted" -) -type SemanticAppendIntent struct { - ClaimID artifact.ClaimID - ClaimOwner artifact.ClaimOwner - BindingSet artifact.BindingSet - Fingerprint es.Digest - CanonicalRequest session.AppendRequest - State SemanticAppendIntentState -} -type SemanticAppendJournal interface { - Prepare(context.Context, SemanticAppendIntent) (SemanticAppendIntent, error) - Lookup(context.Context, artifact.ClaimID) (SemanticAppendIntent, bool, error) - Pending(context.Context, artifact.ClaimCursor) ([]SemanticAppendIntent, *artifact.ClaimCursor, error) - MarkTerminal(context.Context, artifact.ClaimID, SemanticAppendIntentState) error -} -// Before an Event is visible, this capability atomically persists either the -// exact full intent and its matching Prepared claim, or the commit and matching -// Active claim; each outcome leaves a recoverable claim plan. -type AtomicSemanticCommitter interface { - CommitSemanticAtomic(context.Context, SemanticAppendIntent) (session.AppendResult, error) -} -``` - -**EXT-APP-1** request 的 Events 是完整 group,不能为空。Appender 对每个 TypedEvent lookup event definition、validate、canonical encode、decode-round-trip 和 extract;任一失败不作 IO。它将全部 occurrence 组成 sorted-unique union,并通过 `artifact.BindingSetBuilder.Build(ctx, union)` 构造完整 BindingSet;Session Module Framework 只对 Build 已 resolve 的 Binding 按 declaration 执行 Scheme、最低 durability 与 host access policy。Appender 必须将 TypedEvent 的 `EventID`、`RecordedAtUnixMilli`、`SourceEvents` 与 canonical Payload、Type 逐字段映射为 `session.UncommittedEvent`,不得生成或替换其中任一值。`SourceEvents` 仍由 Session 的 bytewise sorted-unique、same-resolved-stream 规则验证。 - -**EXT-APP-2** 对 nonempty BindingSet,先经 `Store.Header(ctx, SessionID)` 读取并验证该 Session 的 Header,唯一派生为: +**EXT-APP-2** 对 nonempty BindingSet,以已验证 Header 唯一派生: ```text ClaimID = Digest("twilight/session-extension/claim", claim-profile-version "1", - extension-canonical-string(Header.ProtocolVersion), + canonical-string(Header.ProtocolVersion), SessionID, CommitID, BindingSet.RefSetDigest) ClaimOwner = {Kind:"twilight/session/commit", Authority:string(SessionID), Identity:string(CommitID)} ``` -claim profile version 固定为 `1`;整数使用 Session Module canonical string profile(无前导零的十进制),其中 Session `ProtocolVersion` 只能取自已验证 Header。Artifact `WireVersion` 已由 `BindingSet.RefSetDigest` 覆盖,绝不另入 preimage。ClaimID 不含 ExpectedHead。先 Build set,再派生 claim;调用者不得提供或覆盖它们。相同 CommitID retry 的 typed event identity/time/source/payload 或 BindingSet 不同均为 conflict;完全相同 immutable request 的 append fingerprint 稳定。`ExpectedHead` 不入该 fingerprint,且是同一 pending intent 唯一可更新的 CanonicalRequest 字段。retry、recovery 及导入 `twilight/session/commit` active claim 时都必须以该 Header 和同一 preimage 重算并验证 ClaimID。 +ClaimID 不含 ExpectedHead 与时间戳。相同 CommitID 的重试若 typed event identity、source、payload 或 BindingSet 不同为 conflict。 -**EXT-APP-3** 没有 Binding 时不创建 BindingSet、intent 或 claim。否则先构造含 ClaimID、ClaimOwner、BindingSet、Fingerprint、完整 CanonicalRequest 和 Pending State 的 intent,且非原子流程严格为 `journal.Prepare → ledger.Prepare → Store.Commit`。journal `Prepare` 对既有同一 ClaimID 的记录,必须逐字段验证 Fingerprint、ClaimOwner、BindingSet 及 CanonicalRequest 的所有 immutable 字段;只有全部相同才幂等,并且仅可更新 Pending intent 的 `CanonicalRequest.ExpectedHead`,其他差异均为 conflict。`ledger.Prepare` 必须使用 intent 内的 ClaimID、ClaimOwner、BindingSet。支持 `AtomicSemanticCommitter` 的 adapter 可跳过单独 choreography,但必须原子持久化同一完整 intent 与匹配 Prepared claim,或直接原子 commit 并建立匹配 Active claim;两种路径均不得在 Event 可见前缺少可恢复 claim plan,并使用等价 terminal handling。 +**EXT-APP-3** 两个入口都在 `Store.CommitIn` 的一个事务内完成,顺序为: ```text +AppendSemanticIn(sessionID, fn): + Store.CommitIn(sessionID, func(tx): + group = fn(SemanticTx{tx}) // nil → Noop,不追加 + if existing := tx.LookupCommit(group.CommitID): + fingerprint 相同 → AlreadyApplied;ledger.ActivateIn(tx.control, claim) 幂等重放;返回 + 否则 → CommitConflict + validate/encode/extract → build BindingSet → admission + if BindingSet 非空: ledger.ActivateIn(tx.control["twilight/artifact/claim"], ClaimID, Owner, Set) + return AppendRequest{ExpectedHead: tx.Head(), ...}) + AppendSemantic(request): - validate/encode group → extract → build BindingSet → map canonical AppendRequest - derive ClaimID/ClaimOwner and append fingerprint - if AtomicSemanticCommitter exists: - result = capability.CommitSemanticAtomic(intent) - else: - journal.Prepare(intent); ledger.Prepare(intent.ClaimID, intent.ClaimOwner, intent.BindingSet) - result = Store.Commit(intent.CanonicalRequest) - Applied/AlreadyApplied: ledger.Activate(ClaimID); journal.MarkTerminal(Completed) - HeadConflict: retain Prepared claim and pending intent; return actual head - Invalid/CommitConflict: journal.MarkTerminal(Aborted); ledger.AbortPrepared(ClaimID) - unknown result: retain Prepared claim and pending intent; return Indeterminate + 同上,fn 固定为:tx.Head() == request.ExpectedHead ? request.Group : HeadConflict ``` -只有 claim 已 Activate,`SemanticApplied` 或 `SemanticAlreadyApplied` 才能返回;Activate 或 terminal marking 的未知/失败必须保留 Prepared/pending state 并返回 `SemanticIndeterminate`。`HeadConflict`、`CommitConflict`、`Invalid` 分别映射为同名 Semantic outcome,后两者仅在 journal 已标记 Aborted 且 AbortPrepared 成功后返回。`HeadConflict` 重试保持相同 CommitID、fingerprint、ClaimID 和所有 event identity/time/source/payload/BindingSet,仅更新 ExpectedHead。`LookupCommit` 找到 canonical commit 时 Activate;返回 NotFound 时保留 Prepared/pending state,留待 retry 或 recovery。明确的 terminal `Invalid` 或 `CommitConflict` 才可先 journal 标记 `Aborted`,再 AbortPrepared。 - -**EXT-APP-4** recovery 扫描 journal Pending 与 ledger PreparedClaims 的并集。 - -对每个 journal Pending intent,第一步总是以 `intent.ClaimOwner` 和 `intent.BindingSet` 幂等调用 `ledger.Prepare(intent.ClaimID, ...)`,并逐字段验证返回 claim 的 ID、owner、set;只有其 state 为 Prepared 或 Active 且完全匹配,才可 `LookupCommit` 或 retry `Store.Commit(intent.CanonicalRequest)`。 +claim 在 `Active` 状态写入控制面 KV,与 commit 同一事务;没有 `Prepared` 状态,也没有 journal。事务失败时 commit 与 claim 都不可见;事务成功时两者同时可见。`HeadConflict`、`CommitConflict`、`Invalid` 分别映射为同名 Semantic outcome,且不留下任何写入。claim 的 Release 由 Application 在事件 retention 结束时经 `RetentionLedger.ReleaseActive` 执行,不属于 Appender。 -journal 已存在而 claim 尚不存在是正常 crash point:NotFound 只表示该 Prepare 之前尚无 claim,且上述 Prepare 必须重建 Prepared claim。找到且与 intent fingerprint 匹配的 canonical commit 时 Activate 并 MarkTerminal(Completed);只有 journal 已 durable 标记 Aborted 才可 AbortPrepared。 - -对 ledger 的每个 Prepared claim,journal 缺失或无法提供同 ID、owner、set 的 intent 时,必须保持 Prepared 并返回 `SemanticIndeterminate`/运维错误,绝不得 Commit 或 Abort。claim/journal 不匹配、非 Prepared/Active state、LookupCommit NotFound、unknown 或查询失败时,同样保留可恢复 state 并返回 indeterminate。journal 的 durable `Aborted` 标记授权 `AbortPrepared`。因此每个 crash point 都保有可恢复状态,直到完整 request 的 terminal 状态得到 durable confirmation。 +**EXT-APP-4** 崩溃恢复不需要扫描:没有 in-flight 状态。Appender 调用返回未知结果时,调用方以同一 CommitID 重试,得到 `AlreadyApplied` 或首次 `Applied`。 ## 6. pure projection 与 snapshot @@ -267,8 +189,7 @@ type ProjectionDefinition struct { StateCodec PayloadCodec } type ProjectionRunRequest struct { - Catalog *Catalog - ProtocolVersion uint16 + Registry *Registry Definition ProjectionDefinition Events []session.SessionEvent InitialState any @@ -277,17 +198,11 @@ type ProjectionRunResult struct { State any; Applied uint64; Ignored uint64 } type ProjectionRunner interface { Run(ProjectionRunRequest) (ProjectionRunResult, error) } ``` -**EXT-PRJ-1** Initial、Apply、StateCodec 和 runner decode 都必须 pure。runner 要求 -`Catalog` 非 nil、`Catalog.ProtocolVersion == ProtocolVersion`,并只接受已验证的 complete -commit sequence;一个 commit 内任一 event 失败,不得发布该 commit 的 partial state。 +**EXT-PRJ-1** Initial、Apply、StateCodec 和 runner decode 都必须 pure。runner 只接受已验证的 complete commit sequence;一个 commit 内任一 event 失败,不得发布该 commit 的 partial state。 -**EXT-PRJ-2** `Catalog` 为每个 EventType 提供唯一 `(Source, ModuleID)` 与 EventDefinition; -`ModuleForEvent` 按 EventType 的 `//` 前缀识别 owner,因此 required module 的未知事件可以被 -发现。`Consumes` 表示必须 decode/handle 的 EventType,`Ignores` 是显式已知跳过,二者不得重叠。 -出现属于 `RequireComplete` module 的 unknown 或未分类 event 必须失败;其他 module event -可忽略。 +**EXT-PRJ-2** `Consumes` 表示必须 decode/handle 的 EventType,`Ignores` 是显式已知跳过,二者不得重叠。出现属于 `RequireComplete` module 的 Unknown event 必须失败;其他 module 的 event 可忽略。读取时以 `Consumes` 与 `RequireComplete` module 的前缀作为 `Types` 过滤(SES-REP-2、SessionTx.Tail),使读取代价与 projection 消费的事件数成正比。 -**EXT-PRJ-3** snapshot 使用 Session snapshot envelope。只有 ProjectionID、ProjectionVersion、StateCodec canonical validation 和 Session coverage 都匹配时可复用;否则从 log 重建。 +**EXT-PRJ-3** snapshot 使用 Session snapshot envelope。只有 ProjectionID、ProjectionVersion、StateCodec canonical validation 和 `Through` 前缀校验都通过时可复用;否则从 log 重建。写入策略由 projection 自定,可以在 `SemanticTx.SaveSnapshot` 中与 commit 同事务写入,也可以异步写入。 ## 7. errors 与 conformance @@ -298,16 +213,34 @@ const ( ErrCodec ErrorCode = "codec"; ErrBinding ErrorCode = "binding" ErrConflict ErrorCode = "conflict" ErrUnsupportedProfile ErrorCode = "unsupported_profile" - ErrIndeterminate ErrorCode = "indeterminate" ) type Error struct { Code ErrorCode; Type session.EventType; Detail string } func (Error) Error() string ``` -Conformance 必须验证: +v1 conformance 必须验证: + +- **EXT-REG-1、EXT-REG-2、EXT-REG-3**:immutable Registry、Profile/ProtocolVersion binding、`v` 字段的写入与选择、多版本 codec 共存、Unknown 事件保留 raw payload; +- **EXT-COD-1、EXT-COD-2**:wire-first、安全 codec、canonical round-trip、`v` 保留字段; +- **EXT-REF-1、EXT-REF-2**:pointer 与 Parts 全量提取、cardinality、scheme/durability admission、拒绝时无写入; +- **EXT-APP-1 至 EXT-APP-4**:TypedEvent→UncommittedEvent 全字段映射、由 Header ProtocolVersion 与 RefSetDigest 派生的 stable ClaimID、claim 与 commit 同事务(崩溃点注入后两者同时存在或同时缺失)、AlreadyApplied 的 claim 幂等、两个入口产生等价 commit、Noop 不追加; +- **EXT-PRJ-1、EXT-PRJ-2、EXT-PRJ-3**:pure fold、commit boundary、Consumes/Ignores/RequireComplete、Types 过滤读取与全量读取等价、snapshot equivalence。 + +## 附录 B:Application module 与通用 Catalog(不进入 v1) + +Application 注册自己的 `SourceID`(例如 `acme`)与 Module 时,EventType 为 `//`;`BuildCatalog(CatalogBuildRequest)` 在启动时把多个 Source 的 ModuleDescriptor、RuntimeRegistry 与 artifact SchemeDefinition 组合为只读索引,拒绝 duplicate owner、namespace mismatch、registry requirement mismatch、非法 Binding declaration 与低于 `EventBound` 的 declaration。`RuntimeRegistry` 以 `CodecRegistryDescriptor{ID, WireManifest, WireProfile}` 描述自定义 codec 与 `BindingExtractor`,Catalog 逐字段匹配 requirement 与 descriptor。v1 的 `PartsRegistry` 是这一机制的唯一实例,直接作为 `FirstPartyRegistry` 的参数注入。 + +## 附录 C:两阶段 semantic append(不进入 v1) + +当 Session Store、RetentionLedger 或 BindingStore 不在同一事务域(远程 Store、跨数据库)时,claim 与 commit 无法同事务写入,需要 `Prepared` claim 状态与 durable `SemanticAppendJournal`: + +```text +journal.Prepare(intent{ClaimID, Owner, BindingSet, Fingerprint, CanonicalRequest, Pending}) +→ ledger.Prepare(ClaimID, Owner, Set) // Prepared claim 为 in-flight 写入提供 GC 保护 +→ Store.Commit(CanonicalRequest) +Applied/AlreadyApplied → ledger.Activate;journal.MarkTerminal(Completed) +Invalid/CommitConflict → journal.MarkTerminal(Aborted);ledger.AbortPrepared +未知结果 → 保留 Prepared 与 Pending,返回 Indeterminate +``` -- **EXT-CAT-1、EXT-CAT-2、EXT-CAT-3**:static immutable Catalog、Profile/ProtocolVersion binding、所有 ownership/protocol conflict、拒绝低于 EventBound 的 Session declaration、optional BindingExtractor availability/typed-nil rejection; -- **EXT-COD-1、EXT-COD-2、EXT-COD-3**:wire-first、安全 codec、ProtocolVersion 匹配、兼容性分类与 canonical round-trip; -- **EXT-REF-1、EXT-REF-2**:pointer/custom registry 全量提取、cardinality、extractor requirement、scheme/durability admission; -- **EXT-APP-1、EXT-APP-2、EXT-APP-3、EXT-APP-4**:TypedEvent→UncommittedEvent 全字段映射、由 Header ProtocolVersion/claim profile v1/RefSetDigest 派生的 stable ClaimID、完整 intent 的逐字段 journal idempotency(仅 ExpectedHead 可更新)、durable intent-before-claim、claim-before-commit、journal-only claim rebuild、claim-only journal-missing indeterminate、terminal abort evidence、active-claim import revalidation、atomic capability 等价性与 crash recovery; -- **EXT-PRJ-1、EXT-PRJ-2、EXT-PRJ-3**:pure fold、commit boundary、Consumes/Ignores/RequireComplete 和 snapshot equivalence。 +恢复扫描 journal Pending 与 ledger Prepared 的并集:先以 intent 幂等重建 Prepared claim,再 `LookupCommit` 决定 Activate 或重试;只有 journal 的 durable `Aborted` 标记授权 `AbortPrepared`;claim 存在而 intent 缺失时保持 Prepared 并报告 indeterminate。支持 `AtomicSemanticCommitter` 的 adapter 可以跳过 choreography,但必须原子持久化同一完整 intent 与匹配 claim。这些规则对应 artifact 附录中的 `Prepared` 状态与 reconciler。 diff --git a/docs/design/agent-session.md b/docs/design/agent-session.md index 136db34..158f8b4 100644 --- a/docs/design/agent-session.md +++ b/docs/design/agent-session.md @@ -1,65 +1,63 @@ # Twilight Agent Session Protocol -状态:设计草案。无实现;wire、digest preimage 与 conformance 在 Memory reference implementation 与 Input → Turn → Run → Session 纵向切片跑通前不冻结。 +状态:设计草案。无实现;wire、digest preimage 与 conformance 在 Memory reference implementation 与 Input → Turn → Run → Session 纵向切片跑通前不冻结。2026-09-04 第二次修订:v1 范围收缩为单 stream kernel;Fork、ancestry、canonical import 移入附录 A,不进入 v1 conformance。 -本文定义 Twilight Session 的 Event Sourcing kernel。文中的“必须”“不得”“应该”是协议约束。 - -本文规定 Twilight 的 Session stream、并发、Fork ancestry 与 integrity 语义;冻结发生在草案转为规范时。 +本文定义 Twilight Session 的 Event Sourcing kernel。文中的"必须""不得""应该"是协议约束。 ## 1. Events 与范围 ```text -Events = resolved committed SessionEvent stream +Events = committed SessionEvent stream State = Fold(Events) Persistent representation = immutable SessionHeader + ordered atomic SessionCommit records containing ordered SessionEvents + + control-plane KV(同事务写入,不进入 stream,不参与 digest chain) ``` -Committed Events 构成 Session 的长期语义事实。进入 Fold 前,Session kernel 必须验证: - -- Event 位于完整的 SessionCommit 中; -- Header、commit boundary、CAS 与 digest chain 有效; -- Fork events 已按 root 到 target 解析为确定序列。 - -Header 与 Commit 是 Events 的 canonical persistent representation,提供原子提交、完整性验证与 fork/replay framing。职责边界为: +Committed Events 构成 Session 的长期语义事实。进入 Fold 前,Session kernel 必须验证 Event 位于完整的 SessionCommit 中,且 Header、commit boundary、head 与 digest chain 有效。 ```text -Session kernel envelope、顺序、CAS、fork、snapshot、canonical import、integrity -Session modules event ontology、typed codec、payload validation、projection +Session kernel envelope、顺序、commit、临界区、snapshot、控制面 KV、integrity +Session modules event ontology、typed codec、payload 版本、payload validation、projection ``` -Payload 对 Session kernel 是 opaque canonical JSON。缓存和 snapshot 是可重建派生数据。 +Payload 对 Session kernel 是 opaque canonical JSON。snapshot 与控制面 KV 都不是语义事实:snapshot 是可重建的派生数据;控制面 KV 保存 lease、claim 等运行控制信息,由模块解释,kernel 只保证它与同一 commit 原子写入。 + +**SES-SCP-1** kernel 不依赖领域 payload,也不对其执行 schema validation 或解释。replay 的唯一顺序是 `(revision, index)`;时钟只作 metadata。 + +**SES-SCP-2** v1 的范围是一条 stream 的 kernel:header、commit、两种 append 入口、local replay、snapshot、控制面 KV。Fork、ancestry archive、canonical import 与 resolved replay 见附录 A,v1 实现返回 `ErrUnsupported`,conformance 不覆盖。header 与 digest preimage 为它们保留字段位置,日后加入不升 ProtocolVersion。 -`ProtocolVersion` 是 Session Event Sourcing 的唯一持久化版本,统一约束 header、event payload、commit、fork、digest profile 与 codec。ProjectionVersion 只描述派生快照的 state codec。 +## 2. 版本 -**SES-SCP-1** kernel 不依赖领域 payload,也不对其执行 schema validation 或解释。resolved replay 的唯一顺序是 root-to-target ancestry segment order,再在每个 segment 内按 `(revision, index)`;`EventPosition` 只标识 event/cursor,不定义跨 segment 的排序;时钟只作 metadata。 +`ProtocolVersion` 只覆盖 kernel wire:header、event envelope、commit、snapshot envelope、digest profile 与 codec 的 canonicalization 规则。它不覆盖 payload。 -## 2. wire types 与 profile +**SES-VER-1** payload 的版本由模块负责:每个 payload object 的第一层携带整数字段 `v`,模块按 `(EventType, v)` 选择 codec(EXT-REG-2)。同一 stream 内不同 event 可以携带不同的 `v`;kernel 不读取该字段。 + +**SES-VER-2** `ProtocolVersion` 在旧 kernel reader 读取新 writer 产生的 stream 后无法保持 envelope、commit 顺序或 digest chain 语义时递增。payload 字段、EventType 与模块 codec 的变化不触发 kernel 版本变化。kernel 版本变化由外部 migration tool 生成新版本 stream。 + +第一版运行实例绑定一个 `ProtocolProfile`,其 `Version()` 是实例接受的 `ProtocolVersion`。读取 Header、Commit 或 snapshot 时先校验 `ProtocolVersion` 与绑定 profile 相等,不匹配返回 `ErrUnsupportedProfile`。 + +## 3. wire types 与 profile ```go type SessionID string type CommitID string type EventID string type EventType string -type ForkID string type ProjectionKey string type CursorToken string +type ControlNamespace string type SessionHeader struct { ProtocolVersion uint16 SessionID SessionID - ParentFork *ForkPoint + ParentFork *ForkPoint // v1 必须为 nil;见附录 A CausationID es.CausationID Metadata jsonstable.Value HeaderDigest es.Digest } -type ForkPoint struct { - ParentSessionID SessionID - Revision es.Revision - HeadDigest es.Digest -} type SessionEvent struct { EventID EventID Index uint16 @@ -88,11 +86,12 @@ type SessionCommit struct { CommitDigest es.Digest } type Head struct { Revision es.Revision; Digest es.Digest } +type EventPosition struct { Revision es.Revision; Index uint16; EventDigest es.Digest } ``` -**SES-WIR-1** identity 非空且稳定;EventID 在 resolved ancestry 内唯一。header 不可变,revision 从 1 连续递增,commit 至少有一个 event,event Index 从 0 连续递增。revision 1 的 `PreviousDigest=HeaderDigest`,其后为前一 CommitDigest。 +**SES-WIR-1** identity 非空且稳定;EventID 在同一 stream 内唯一。header 不可变,revision 从 1 连续递增,commit 至少有一个 event,event Index 从 0 连续递增。revision 1 的 `PreviousDigest=HeaderDigest`,其后为前一 CommitDigest。空 stream head 是 `{0, HeaderDigest}`。 -**SES-WIR-2** `ProtocolProfile` 冻结整个 Event Sourcing wire:envelope、event payload、commit、fork、null/omission、精确 integer encoding、unknown-field policy、array order 与下列 digest preimage;所有 digest 依 `agent/es` 的 versioned domain separator。 +**SES-WIR-2** `ProtocolProfile` 冻结 kernel wire:envelope、commit、snapshot envelope、null/omission、精确 integer encoding、unknown-field policy、array order 与下列 digest preimage;所有 digest 依 `agent/es` 的 versioned domain separator。 ```go type ProtocolProfile interface { @@ -105,42 +104,19 @@ type ProtocolProfile interface { DecodeCommit(jsonstable.Value) (SessionCommit, error) EncodeSnapshot(Snapshot) (jsonstable.Value, error) DecodeSnapshot(jsonstable.Value) (Snapshot, error) - EncodeAncestryArchive(ImmutableAncestryArchive) (jsonstable.Value, error) - DecodeAncestryArchive(jsonstable.Value) (ImmutableAncestryArchive, error) ValidateCanonicalHeader(jsonstable.Value) (SessionHeader, error) ValidateCanonicalEvent(SessionID, es.Revision, jsonstable.Value) (SessionEvent, error) ValidateCanonicalCommit(jsonstable.Value) (SessionCommit, error) ValidateCanonicalSnapshot(jsonstable.Value) (Snapshot, error) - ValidateCanonicalAncestryArchive(jsonstable.Value) (ImmutableAncestryArchive, error) FingerprintAppend(AppendRequest) (es.Digest, error) } ``` -第一版运行实例绑定一个 `ProtocolProfile`。该 profile 的 `Version()` 是实例接受的 -`ProtocolVersion`;`Create`、`Commit`、`Replay`、`Fork`、snapshot 与 canonical import -都使用同一 profile。读取 Session Header、Commit、snapshot 或 ancestry archive 时, -实例先校验其中的 `ProtocolVersion` 与绑定 profile 相等,再执行对应的 decode、digest -和 replay。版本不匹配返回 `ErrUnsupportedProfile`。 +**SES-WIR-3** 同一个 Session 的 `SessionHeader.ProtocolVersion`、所有 `SessionCommit.ProtocolVersion`、`Snapshot.ProtocolVersion` 与所选 `ProtocolProfile.Version()` 必须相等。Store 从已验证 Header 派生后续操作使用的版本,调用方提交的版本字段只能通过一致性校验。 -第一版支持当前协议版本。历史 Session 由外部 migration tool 使用旧 profile 读取并 -写出完整的当前版本 stream;原始 stream 可以作为 archive 保留。迁移产物再通过 -`ImportCanonical` 导入当前实例。Session kernel 的版本选择来自运行实例配置与已验证 -Header 的匹配关系。 +Header、event、commit 分别覆盖自身以外的全部持久字段。event digest 额外覆盖 SessionID、revision 和 index。每个 `ValidateCanonical*` 必须执行 decode → encode 并要求 canonical-equivalent wire,同时验证对应 digest。`SourceEvents` 是 bytewise sorted-unique set,且只可引用同一 stream 内已存在的 EventID。opaque Payload 必须已经 canonical,完整 value 进入 event digest 和 append fingerprint。 -**SES-WIR-3** 同一个 Session 的 `SessionHeader.ProtocolVersion`、所有 -`SessionCommit.ProtocolVersion`、`Snapshot.ProtocolVersion`、 -`ImmutableAncestryArchive.ProtocolVersion` 与所选 `ProtocolProfile.Version()` 必须相等。 -Store 从已验证 Header 派生后续操作使用的版本,调用方提交的版本字段只能通过一致性校验。 - -**SES-WIR-4** `ProtocolVersion` 在旧版本 reader 读取新 writer 产生的 log 后无法保持正确 -语义时递增。正确语义包括 canonical validation、commit/fork/replay 顺序、历史与 context -重建、recovery 判断以及 digest chain 验证。旧 reader 可以安全保留并忽略的新 EventType -或真正 optional 的 payload 字段保持当前版本;影响上述语义的 event、字段、envelope 或 -commit 规则变化进入新版本,并由外部 migration tool 生成当前版本 stream。 - -Header、event、commit 分别覆盖自身以外的全部持久字段。event digest 额外覆盖 origin SessionID、revision 和 index。每个 `ValidateCanonical*` 必须执行 decode → encode 并要求 canonical-equivalent wire,同时验证对应 digest;它是 adapter/import 的唯一 canonical round-trip validation 入口。`SourceEvents` 是 bytewise sorted-unique set,且只可引用同一 resolved Session stream 内已存在的 EventID;跨 stream provenance 必须编码在 payload 的 owner-defined `SourceRef` 中。opaque Payload 必须已经 canonical,完整 value 进入 event digest 和 append fingerprint。空 stream head 是 `{0, HeaderDigest}`。 - -## 3. Store API 与 errors +## 4. Store API 与 errors ```go type CreateRequest struct { @@ -160,29 +136,36 @@ const ( ) type AppendResult struct { Disposition AppendDisposition; Commit *SessionCommit; ActualHead Head } -type ReplayMode string -const ( ReplayLocal ReplayMode = "local"; ReplayResolved ReplayMode = "resolved" ) -// EventPosition is a verified event identity and is safe to expose in APIs. -type EventPosition struct { OriginSessionID SessionID; Revision es.Revision; Index uint16; EventDigest es.Digest } -type ReplayCursor struct { Mode ReplayMode; AncestryDigest es.Digest; After *EventPosition; Token CursorToken } -type ReplayRequest struct { SessionID SessionID; Mode ReplayMode; Cursor *ReplayCursor; Limit uint32 } -type ReplayPage struct { Header SessionHeader; Commits []SessionCommit; Next *ReplayCursor; Head ResolvedHead } - -type ForkRequest struct { - ForkID ForkID; ChildSessionID SessionID; ParentSessionID SessionID - ExpectedParentBoundary ForkPoint; CausationID es.CausationID; Metadata jsonstable.Value +// 临界区内的读写视图。所有方法在同一事务内生效。 +type SessionTx interface { + Head() Head + LookupCommit(CommitID) (SessionCommit, bool, error) + // Tail 返回 after 之后的 local commits;types 非空时只返回含至少一个匹配 EventType 前缀的完整 commit。 + Tail(after Head, types []EventType) ([]SessionCommit, error) + LoadSnapshot(ProjectionKey, ProjectionVersion uint16) (SnapshotResult, error) + SaveSnapshot(Snapshot) error + ControlGet(ControlNamespace, key string) ([]byte, bool, error) + ControlPut(ControlNamespace, key string, value []byte) error + ControlDelete(ControlNamespace, key string) error } -type ForkResult struct { Header SessionHeader; Created bool } +type CommitInFn func(SessionTx) (*AppendRequest, error) -type CanonicalImportRequest struct { Header SessionHeader; Commits []SessionCommit; AncestryArchive *ImmutableAncestryArchive } -type CanonicalImportResult struct { Header SessionHeader; Imported uint64; Head Head; AlreadyPresent bool } -type ImmutableAncestryArchive struct { ProtocolVersion uint16; Headers []SessionHeader; Commits []SessionCommit; ArchiveDigest es.Digest } +type ReplayCursor struct { After *EventPosition; Token CursorToken } +type ReplayRequest struct { + SessionID SessionID + Types []EventType // 空为全部;非空为 EventType 前缀过滤 + Cursor *ReplayCursor + Limit uint32 +} +type ReplayPage struct { Header SessionHeader; Commits []SessionCommit; Next *ReplayCursor; Head Head } -type SnapshotRequest struct { SessionID SessionID; ProjectionKey ProjectionKey; ProjectionVersion uint16; AtOrBefore ResolvedHead } -type SnapshotResult struct { Snapshot *Snapshot; Found bool; Covers bool } +type SnapshotRequest struct { SessionID SessionID; ProjectionKey ProjectionKey; ProjectionVersion uint16 } +type SnapshotResult struct { Snapshot *Snapshot; Found bool } type SaveSnapshotRequest struct { Snapshot Snapshot } type SaveSnapshotResult struct { Snapshot Snapshot; Replaced bool } +type ControlEntry struct { SessionID SessionID; Namespace ControlNamespace; Key string; Value []byte } + type Store interface { Create(context.Context, CreateRequest) (SessionHeader, error) Header(context.Context, SessionID) (SessionHeader, error) @@ -191,10 +174,13 @@ type Store interface { Commit(context.Context, AppendRequest) (AppendResult, error) CommitIn(context.Context, SessionID, CommitInFn) (AppendResult, error) Replay(context.Context, ReplayRequest) (ReplayPage, error) - Fork(context.Context, ForkRequest) (ForkResult, error) - ImportCanonical(context.Context, CanonicalImportRequest) (CanonicalImportResult, error) LoadSnapshot(context.Context, SnapshotRequest) (SnapshotResult, error) SaveSnapshot(context.Context, SaveSnapshotRequest) (SaveSnapshotResult, error) + // 控制面 KV,临界区之外的读写;与 SessionTx 内的同名方法作用于同一存储。 + ControlGet(context.Context, SessionID, ControlNamespace, key string) ([]byte, bool, error) + ControlPut(context.Context, SessionID, ControlNamespace, key string, value []byte) error + ControlDelete(context.Context, SessionID, ControlNamespace, key string) error + ControlScan(context.Context, ControlNamespace, keyPrefix string, fn func(ControlEntry) (bool, error)) error // 跨全部 Session } ``` @@ -203,30 +189,24 @@ type ErrorCode string const ( ErrInvalid ErrorCode = "invalid"; ErrNotFound ErrorCode = "not_found" ErrConflict ErrorCode = "conflict"; ErrCorrupt ErrorCode = "corrupt" - ErrUnsupportedProfile ErrorCode = "unsupported_profile"; ErrUnavailable ErrorCode = "unavailable" + ErrUnsupportedProfile ErrorCode = "unsupported_profile"; ErrUnsupported ErrorCode = "unsupported" + ErrUnavailable ErrorCode = "unavailable" ) type Error struct { Code ErrorCode; Operation string; SessionID SessionID; CommitID CommitID; Detail string } func (Error) Error() string ``` -**SES-API-1** Store.Commit 与 Store.CommitIn 是仅有的两个 append Store protocol port。它们必须原子持久化完整 commit 与 head;普通 producer 的 capability exposure 由 Session Module Framework 提供。`ImportCanonical` 只给 trusted adapter、recovery/import coordinator 或 test。 +**SES-API-1** `Commit` 与 `CommitIn` 是仅有的两个 append port。它们必须原子持久化完整 commit、新 head,以及同一调用内的 snapshot 与控制面 KV 写入。普通 producer 的 capability exposure 由 Session Module Framework 提供(EXT-APP)。 -```go -type SessionTx interface { - Head() Head - LookupCommit(CommitID) (SessionCommit, bool, error) - LoadSnapshot(ProjectionKey, ProjectionVersion) (SnapshotResult, error) - Tail(after Head) ([]SessionCommit, error) // 该 Session 自 after 之后的 local commits - SaveSnapshot(Snapshot) error // 与本次 append 同一事务写入 -} -type CommitInFn func(SessionTx) (*AppendRequest, error) -``` +**SES-API-2** `CommitIn` 是同一 Session 的 append 临界区:Store 在 per-Session 锁或数据库事务内向 fn 提供 `SessionTx`,fn 在其中读 head、按 CommitID 查 commit、读 snapshot 与 tail、读写控制面 KV、决定要追加的事件;返回的 `AppendRequest` 由 Store 在同一事务内按第 5 节规则追加。fn 返回 nil 表示不追加,此时 fn 已执行的 snapshot 与 KV 写入仍提交。section 内 head 不移动,`ExpectedHead` 与 head 不一致只可能是 fn 的实现错误,返回 `Invalid`。fn 必须纯且除 SessionTx 外无副作用;Store 不保证 fn 只被调用一次。 + +`CommitIn` 与 `Commit` 产生的 commit 逐字段等价,同一 CommitID 的幂等与 conflict 判定相同。两者都是 adapter 必须实现的 port:`Commit`(CAS)一次请求完成比对与写入,适用于 Store 实现为远程客户端、不能在持锁期间回调调用方的部署;`CommitIn` 适用于需要在写入前基于当前状态做决定的模块(例如 Run 的 Decide),以及需要把 claim、lease 与 commit 放在同一事务的 producer。进程内 adapter 可以用 `CommitIn` 加 head 比对函数实现 `Commit`,但 `Commit` 不因此从合同中移除。 -**SES-API-2** `Store.CommitIn(ctx, SessionID, fn CommitInFn)` 是同一 Session 的 append 临界区:Store 在 per-Session 锁或数据库事务内向 fn 提供 `SessionTx`,fn 在其中读 head、按 CommitID 查 commit、读 projection snapshot 与 tail、决定要追加的 event group;返回的 `AppendRequest` 由 Store 在同一事务内按 SES-APP 规则追加。fn 返回 nil 表示不追加。section 内 head 不移动,因此 `ExpectedHead` 与 head 不一致只可能是 fn 的实现错误,返回 `Invalid`。`CommitIn` 与 `Commit` 产生的 commit 逐字段等价,同一 CommitID 的幂等与 conflict 判定相同。两者都是 adapter 必须实现的 protocol port:`Commit`(CAS)一次请求完成比对与写入,适用于 Store 实现为远程客户端、不能在持锁期间回调调用方的部署;`CommitIn` 适用于需要在写入前基于当前状态做决定的模块(例如 Run 的 Decide)。进程内 adapter 可以用 `CommitIn` 加 head 比对函数实现 `Commit`,但 `Commit` 不因此从合同中移除。fn 必须纯且无 IO 之外的副作用;Store 不保证 fn 只被调用一次。 +**SES-API-3** 控制面 KV 以 `(SessionID, Namespace, Key)` 寻址,值为 opaque bytes,kernel 不解释。它用于 lease、grant、claim 等需要与 commit 原子写入、但不属于语义事实的信息。`ControlScan` 按 namespace 与 key 前缀跨 Session 枚举,供恢复扫描使用;fn 返回 false 停止。Namespace 由模块以 `//` 命名。KV 不进入 digest chain,可以独立于 stream 重建或清理;模块必须保证 KV 缺失只导致恢复延迟,不导致语义错误。 -## 4. append 与 idempotency +## 5. append 与 idempotency -**SES-APP-1** CommitID 的幂等键为 `(SessionID, CommitID)`。append fingerprint 覆盖 SessionID、CommitID、causation、correlation 和完整有序 UncommittedEvent group,**不**覆盖 ExpectedHead。 +**SES-APP-1** CommitID 的幂等键为 `(SessionID, CommitID)`。append fingerprint 覆盖 SessionID、CommitID、causation、correlation 和有序 UncommittedEvent group 的 `EventID`、`Type`、`SourceEvents`、`Payload`;**不**覆盖 ExpectedHead,也**不**覆盖 `RecordedAtUnixMilli`。时间是 metadata,重试可以携带新的时间戳而得到 `AlreadyApplied`,已提交 commit 中的时间保持首次写入值。 ```text append(request): @@ -235,38 +215,19 @@ append(request): return CommitConflict validate profile, identities, payloads and ExpectedHead assign next revision and contiguous indexes; calculate event/commit digests - atomically write complete commit and new head + atomically write complete commit, new head, and pending snapshot / KV writes return Applied(commit) ``` -**SES-APP-2** CAS 不匹配返回 `HeadConflict` 和 actual head,不得 last-write-wins。任何 invalid envelope、重复 EventID、非 canonical payload 或 digest-chain violation 返回 `Invalid` 或 `Corrupt`;不得部分写入。Store 至少拒绝 resolved ancestry 内的 EventID duplicate。 +**SES-APP-2** CAS 不匹配返回 `HeadConflict` 和 actual head,不得 last-write-wins。任何 invalid envelope、重复 EventID、非 canonical payload 或 digest-chain violation 返回 `Invalid` 或 `Corrupt`;不得部分写入。Store 必须拒绝同一 stream 内的 EventID duplicate。 -## 5. replay +## 6. replay -```go -type ResolvedHead struct { SessionID SessionID; LocalHead Head; AncestryDigest es.Digest } -type RetainClosure struct { Headers []es.Digest; Boundaries []ForkPoint; Commits []es.Digest } -``` +**SES-REP-1** Replay 只返回请求 `SessionID` 自己的 commits,按 `(Revision, Index)` 递增,page 只返回完整 commit。`After=nil` 表示起点,非 nil `After` 表示该精确 digest 的最后已返回 event,续页严格从它之后开始。 -**SES-REP-1** Local replay 只返回请求 `SessionID` 自己的 commits;其每个 `EventPosition.OriginSessionID` 必须等于该 SessionID,并按 `(Revision, Index)` 递增。Resolved replay 从 root 的可见 prefix 到 target 的 local commits,严格按 root-to-target ancestry segment order,再按每个 segment local `(Revision, Index)` 返回;每个 event 保留其实际 origin position。`EventPosition` 是 verified identity/cursor,不能替代该 segment order。两种模式的 page 都只返回完整 commit;`After=nil` 表示起点,非 nil `After` 表示该精确 digest 的最后已返回 event,续页严格从它之后开始。 +**SES-REP-2** `Types` 非空时返回至少含一个匹配 EventType 前缀的完整 commit,跳过其他 commit。过滤 replay 可以验证每个返回 commit 自身的 event digest 与 commit digest,不能验证 revision 连续性与 `PreviousDigest` 链;链完整性只由无过滤 replay 验证。adapter 应维护 `(SessionID, EventType 前缀)` 到 revision 的索引,使过滤 replay 的代价与匹配 commit 数成正比。 -**SES-REP-2** `AncestryDigest` 的 preimage 是 profile、target SessionID、root 到 target 的有序 HeaderDigest、每条完整 ForkPoint、每级可见 CommitDigest 和 target local Head;不含 cursor、snapshot、load time 或 projection state。Local 与 Resolved cursor 都绑定同一 target 的该 digest:Local 虽不返回 ancestor event,仍以它验证 fork closure;Resolved 则以它确定可见 ancestry。cursor 必须携带并在续页验证它,变更则 conflict。 - -**SES-REP-3** replay 必须验证 header、revision gap、previous/commit/event digest、index 和 profile。损坏、缺口或不支持版本必须 fail loudly。cursor 的 `After` 必须是当前 mode/ancestry 中存在且 digest 匹配的完整 `EventPosition`;Local mode 还必须满足其 origin 为请求 Session。Token 是 adapter 的 opaque continuation,必须绑定 `SessionID`、Mode、AncestryDigest、完整 After 和 Limit,且不得替代这些可验证字段。 - -## 6. fork - -**SES-FRK-1** Fork 创建 immutable child header,ParentFork 精确绑定 parent 已验证 boundary;revision 0 的 boundary digest 为 parent HeaderDigest。child local revision 从 1 开始。 - -| 请求情况 | 结果 | -|---|---| -| 新 ForkID、未使用 ChildSessionID、boundary 已验证 | 创建 child | -| 同 ForkID、逐字段相同 request | 返回原 child,`Created=false` | -| 同 ForkID、请求不同 | conflict | -| ChildSessionID 已被另一 ForkID 使用 | conflict | -| parent/boundary 不存在、不匹配或损坏 | conflict/corrupt | - -**SES-FRK-2** 保留 child 时必须保留 `RetainClosure`:所有 ancestor header 与每个 fork boundary 内 commit。实现可留存 ancestor prefix,或保存经验证 immutable ancestry archive;删除、归档或 compact parent 前必须证明 child 仍可 resolved replay。 +**SES-REP-3** 无过滤 replay 必须验证 header、revision gap、previous/commit/event digest、index 和 profile。损坏、缺口或不支持版本必须 fail loudly。cursor 的 `After` 必须是当前请求中存在且 digest 匹配的完整 `EventPosition`。Token 是 adapter 的 opaque continuation,必须绑定 `SessionID`、Types、完整 After 和 Limit,且不得替代这些可验证字段。 ## 7. snapshot @@ -274,48 +235,40 @@ type RetainClosure struct { Headers []es.Digest; Boundaries []ForkPoint; Commits type Snapshot struct { ProtocolVersion uint16; SessionID SessionID ProjectionKey ProjectionKey; ProjectionVersion uint16 - ThroughHead ResolvedHead; CoverageDigest es.Digest + Through Head State jsonstable.Value; SnapshotDigest es.Digest } ``` -**SES-SNP-1** snapshot 是派生 cache。CoverageDigest 覆盖 `ThroughHead` 所解析的 root-to-target HeaderDigest/CommitDigest sequence;SnapshotDigest 覆盖全部 snapshot field(自身除外)。同 local head 而 ancestry 不同绝不等价。 +**SES-SNP-1** snapshot 是派生 cache,任何时刻可以删除并从 stream 重建。`Through` 是该 snapshot 覆盖到的 head;因为 commit 经 `PreviousDigest` 链式覆盖全部前缀,`Through.Digest` 就是 coverage 的证明,不另设 coverage digest。SnapshotDigest 覆盖全部 snapshot field(自身除外)。 -**SES-SNP-2** Save 和 Load 必须重算 ThroughHead、AncestryDigest、coverage 和 snapshot digest。coverage 是当前 sequence 的完整前缀时才可从 snapshot tail replay;缺失、过旧、损坏或 projection 不兼容时全量 replay。 +**SES-SNP-2** Load 时 `Through` 必须是当前 stream 的前缀(该 revision 的 CommitDigest 等于 `Through.Digest`),否则视为缺失并全量重折。写入频率由 projection 自己的策略决定;kernel 不要求每次 commit 都写 snapshot。同一 `(SessionID, ProjectionKey, ProjectionVersion)` 只保留最新一份。 -## 8. canonical import +## 8. boundaries 与 conformance -**SES-IMP-1** canonical import 仅处理 Session records,不解释 payload。它必须经当前 -`ProtocolProfile` 的 `ValidateCanonical*` 入口验证 profile、header、完整 chain、event -uniqueness 和 supplied positions/digests;只接受完整 stream 或已有可验证 predecessor -的 contiguous tail。`ImmutableAncestryArchive` 的 canonical preimage 是 -`ProtocolVersion`、按 root-to-target segment order 的 Headers、每个 segment 按 local -`(revision,index)` 的完整 Commits,排除 `ArchiveDigest`;ArchiveDigest 覆盖该 preimage。 -archive codec 同样必须 decode→encode canonical-equivalent 并验证 digest。 +Session modules 对 opaque payload 作 typed encode/decode 并负责 payload 版本;Store 负责 commit、CommitID、digest、replay、snapshot 与控制面 KV 的机制。unknown event 保留原始 payload 并支持原样 replay。first-party module 为 `chatlog`、`turn` 与 `run`:Run 的执行事实以 `twilight/run/` 事件进入同一 stream,其状态机与投影由 [agent-run.md](agent-run.md) 定义。 -**SES-IMP-2** 同 `(SessionID,CommitID)` 仅在 canonical commit 逐字段相同才幂等;不同 header、revision、group 或 digest 为 conflict。fork child import 必须有本地 existing verified parent boundary,或随 request 提供经过验证 immutable ancestry archive;不得只信任 ParentFork 声明。 +一条 Session stream 承载多个模块的事件;每个 projection 只消费自己声明的 EventType(EXT-PRJ-2),并以 snapshot 加过滤 tail 的方式读取。读取代价由 projection 消费的事件数与 snapshot 之后的 tail 长度决定,与 stream 总长度无关。 -Application/import coordinator 负责跨 Session package、业务事务与恢复编排。 +v1 conformance 必须验证: -## 9. boundaries 与 conformance +- **SES-SCP-2**:附录 A 的能力返回 `ErrUnsupported`,`ParentFork` 非 nil 的 header 被拒绝; +- **SES-VER-1、SES-VER-2**:kernel 不读取 payload `v`;不同 `v` 的 event 在同一 stream 共存; +- **SES-WIR-1、SES-WIR-2、SES-WIR-3**:wire/profile freeze、版本一致性、所有 Encode/Decode 与 `ValidateCanonical*` round-trip、digest preimage、same-stream SourceEvents、EventID uniqueness、complete commit; +- **SES-API-1、SES-API-2、SES-API-3、SES-APP-1、SES-APP-2**:atomic CAS、`CommitIn` 与 `Commit` 的 commit 等价、fn 返回 nil 时 KV 与 snapshot 仍提交、concurrent writer、CommitID exact idempotency(含不同时间戳的重试得到 AlreadyApplied)、failure classification、KV 与 commit 同事务、ControlScan 前缀与提前停止; +- **SES-REP-1、SES-REP-2、SES-REP-3**:顺序、完整 EventPosition After 语义、Types 过滤只返回匹配 commit、cursor/token binding、tamper/gap failure; +- **SES-SNP-1、SES-SNP-2**:Through 前缀校验、snapshot 加 tail 与全量 fold 等价、过期 snapshot 被忽略。 -Session modules 可对 opaque payload 作 typed encode/decode;Store 继续负责 CAS、CommitID、digest -与 replay 语义。unknown event 保留原始 payload 并支持原样 replay。first-party module 为 -`chatlog`、`turn` 与 `run`:Run 的执行事实以 `twilight/run/` 事件进入同一 stream,其状态机与 -投影由 [agent-run.md](agent-run.md) 定义;Session kernel 处理 envelope 与 stream 机制。 -queue、provider 与 Application policy 使用各自的边界。 +第一版包含 MemoryStore 与上述 Store conformance;不承诺特定 durable adapter。 -一条 Session stream 承载多个模块的事件;每个 projection 只消费自己声明的 EventType -(EXT-PRJ-2),并以 snapshot 加 tail 的方式读取。读取代价由 projection 消费的事件数与 -snapshot 之后的 tail 长度决定,与 stream 总长度无关。 +## 附录 A:预留能力(不进入 v1) -Conformance 必须验证: +以下能力保留设计,供后续版本在不升 kernel `ProtocolVersion` 的前提下加入。v1 实现对这些入口返回 `ErrUnsupported`。 -- **SES-WIR-1、SES-WIR-2、SES-WIR-3、SES-WIR-4**:wire/profile freeze、版本一致性、兼容性判定、所有 Encode/Decode 与 `ValidateCanonical*` round-trip、digest preimage、同-stream SourceEvents、EventID uniqueness、complete commit; -- **SES-API-1、SES-API-2、SES-APP-1、SES-APP-2**:atomic CAS、临界区 `CommitIn` 与 `Commit` 的 commit 等价、concurrent writer、CommitID exact idempotency 和 failure classification; -- **SES-REP-1、SES-REP-2、SES-REP-3**:local/resolved replay 的 segment-first 顺序、完整 EventPosition After 语义、cursor/token binding、tamper/gap failure; -- **SES-FRK-1、SES-FRK-2**:ForkID idempotency、parent boundary 和 RetainClosure; -- **SES-SNP-1、SES-SNP-2**:coverage、ancestry validation 与 snapshot+tail 等价; -- **SES-IMP-1、SES-IMP-2**:complete/tail import、exact conflict 和 verified ancestry archive。 +**Fork。** `ForkPoint{ParentSessionID, Revision, HeadDigest}`;`Fork(ForkRequest)` 创建 immutable child header,`ParentFork` 精确绑定 parent 已验证 boundary,child local revision 从 1 开始。同 ForkID 逐字段相同的请求幂等,其他为 conflict。保留 child 时必须保留 ancestor header 与 boundary 内 commit(RetainClosure)。目前没有规范内的消费者:subagent 使用独立 Session。 -第一版包含 MemoryStore 与上述 Store conformance;不承诺特定 durable adapter。 +**Resolved replay 与 AncestryDigest。** 有 Fork 后 replay 分 Local 与 Resolved 两种模式;Resolved 按 root-to-target segment order 返回。`AncestryDigest` 的 preimage 为 profile、target SessionID、root 到 target 的有序 HeaderDigest、每条 ForkPoint、每级可见 CommitDigest 与 target local Head。cursor 与 snapshot 绑定该 digest。v1 只有一个 segment,`EventPosition` 不携带 origin SessionID;加入 Fork 时以 optional 字段扩展,旧 reader 可忽略。 + +**Canonical import 与 ancestry archive。** `ImportCanonical` 只处理 Session records,不解释 payload,经 `ValidateCanonical*` 验证完整 chain 后导入完整 stream 或已有可验证 predecessor 的 contiguous tail。`ImmutableAncestryArchive` 的 preimage 为 ProtocolVersion、按 segment order 的 Headers 与 Commits。同 `(SessionID, CommitID)` 仅在 canonical commit 逐字段相同才幂等。 + +**Snapshot coverage 与 ancestry。** 有 Fork 后 snapshot 的 `Through` 扩展为 `ResolvedHead{SessionID, LocalHead, AncestryDigest}`,同 local head 而 ancestry 不同不等价。 diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index 71aa884..99db1a5 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -1,6 +1,6 @@ # Twilight Agent Turn 协议 -状态:设计草案。2026-09-04 按单一 Session ES 修订:Run 事实与 Turn、Chatlog 事件同在一条 Session stream,本文不再定义 Run→Session materialization。 +状态:设计草案。2026-09-04 按单一 Session ES 修订(同日第二次修订:companion 经 Module Framework admission、Owner 取代 Run 内的 TurnID、attempt 内容策略移交 Planner)。Run 事实与 Turn、Chatlog 事件同在一条 Session stream,本文不再定义 Run→Session materialization。 本文定义 `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)。 @@ -16,9 +16,9 @@ Run 完成一个 Turn 的一次 attempt。同一 Turn 至多一个非终态 R | 回合存在、attempt 归属与结束 | `twilight/turn/` events | Coordinator | | Run 执行状态 | `twilight/run/` events([agent-run.md](agent-run.md)) | `run.Runtime`,由 Loop 与 Coordinator 驱动 | | 对话内容 | `twilight/chatlog/` events | Start 时 delivered input;Run commit 内的 companion events | -| Application policy | Application | binding、driver、retry、产品策略 | +| Application policy | Application | binding、driver、retry、context 策略、产品策略 | -**TRN-SCP-1** Source 为 `twilight`,ModuleID 为 `turn`。一个 Turn 与它的全部 Run attempt 在同一 Session stream 内。`Coordinator` 创建 Turn、创建 attempt、驱动 Run、结算 Turn。 +**TRN-SCP-1** Source 为 `twilight`,ModuleID 为 `turn`。一个 Turn 与它的全部 Run attempt 在同一 Session stream 内。`Coordinator` 创建 Turn、创建 attempt、驱动 Run、结算 Turn。turn 依赖 run;run 不依赖 turn,Run 事实中的 `OwnerID` 由本模块以 `TurnID` 填充。 **TRN-SCP-2** Turn 与 Run 的关系为 1:N,不变量为同一 Turn 至多一个非终态 Run: @@ -32,7 +32,7 @@ subagent 使用独立 Session 与独立 Turn。 **TRN-SCP-3** Coordinator 没有隐藏状态。它从 `twilight/turn/surface` 投影与 `twilight/run/machine` 投影重建。 -**TRN-SCP-4** Turn 自己的写入经 `extension.SemanticAppender`;Run 事实的写入经 `run.Runtime`,二者落在同一 `session.Store`。Artifact 由其 owner 管理。 +**TRN-SCP-4** Turn 自己的写入经 `extension.SemanticAppender`;Run 事实的写入经 `run.Runtime`,后者同样经 `SemanticAppender.AppendSemanticIn` 落在同一 `session.Store`(EXT-SCP-1)。Artifact 由其 owner 管理。 **TRN-SCP-5** Application 管理 model、provider、tool、prompt、token、approval、queue、retry 决策与并发。Coordinator 按 persisted binding 解析 driver。参考 Planner 每次 Plan 使用 Binding 的 `ModelRef`。 @@ -41,7 +41,7 @@ subagent 使用独立 Session 与独立 Turn。 ## 2. identity 与事件 ```go -type TurnID = run.TurnID // 定义在 agent/run,避免依赖环 +type TurnID string type TurnRef struct { SessionID session.SessionID; TurnID TurnID } type ExecutionBindingRef struct { ID ExecutionBindingID; Digest es.Digest } type CompanionVersion string @@ -82,7 +82,7 @@ type SupersededPayload struct { **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` 携带 `TurnID` 与 `Attempt`(RUN-NEW-1)。 +**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: @@ -95,9 +95,9 @@ twilight/turn/superseded unsettled Turn 是尚未 completed、failed 或 superseded 的 `started`。 -**TRN-EVT-2** Start 的 EventID 与 CommitID 由 StartOperationDigest、event kind、group ordinal 派生。相同 canonical payload 幂等;差异为 conflict。 +**TRN-EVT-2** Start 的 EventID 与 CommitID 由 StartOperationDigest、event kind、group ordinal 派生。相同 canonical payload 幂等;差异为 conflict。事件时间戳不参与幂等判定(SES-APP-1)。 -**TRN-EVT-3** resolved stream 内每个 TurnID 至多一条 `started`,至多一条 `completed` / `failed` / `superseded`。 +**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|ended`: @@ -111,13 +111,18 @@ const ( TurnStopped TurnStatus = "stopped" TurnSuperseded TurnStatus = "superseded" ) +type AttemptView struct { + RunID run.RunID + Attempt uint32 + End *run.RunEnd // 非终态时为 nil +} type TurnView struct { TurnID TurnID Status TurnStatus InputIDs []chatlog.InputID ExecutionBinding ExecutionBindingRef - Attempts []run.RunID // 按 Attempt 递增 - ActiveRun run.RunID // Status=active 时非空 + Attempts []AttemptView // 按 Attempt 递增 + ActiveRun run.RunID // Status=active 时非空 ReplacementTurnID TurnID } type TurnSurface struct { @@ -126,7 +131,7 @@ type TurnSurface struct { } ``` -UI 按 `TurnID` 连接 `twilight/chatlog/surface` 的条目,按 `RunID` 连接 `twilight/run/machine` 的实时视图。 +UI 按 `TurnID` 连接 `twilight/chatlog/surface` 的条目,按 `RunID` 连接 `twilight/run/machine` 的实时视图。终态 attempt 的结果从 `twilight/run/ended` 记录在 `AttemptView.End`,不依赖 Run 投影。 ## 3. API @@ -200,13 +205,13 @@ twilight/turn/started{TurnID, InputIDs, ExecutionBinding, Companion, PlanDigest} twilight/chatlog/input_delivered{InputIDs[0], TurnID} ... twilight/chatlog/input_delivered{InputIDs[n-1], TurnID} -twilight/run/created{RunID, TurnID, Attempt:1, CausationID} +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` 的 payload 由 `run.Runtime.BuildCreateGroup` 构造并校验(RUN-NEW-1),Coordinator 不自行编码 Run 事实。 +InputIDs 为空时 group 为 `started` 加 `created`。`created` 与 `input_accepted` 的 facts 由 `run.Protocol.BuildCreateGroup` 构造(RUN-NEW-1),Coordinator 只负责把它们放入 group。 **TRN-STR-3** 派生 PlanDigest、StartOperationDigest、RunID 与 group identity,再 `AppendSemantic`。相同 identity 为 applied / already-applied。head conflict 时 CAS rebase,identity 与 payload 保持不变。 @@ -216,7 +221,7 @@ InputIDs 为空时 group 为 `started` 加 `created`。`created` 与 `input_acce **TRN-RTY-2** Retry 的 CommitID 由 `Digest("twilight/turn/retry", SessionID, TurnID, Attempt)` 派生。 -**TRN-RTY-3** 失败 attempt 已提交的 assistant 与 tool_result 保留在 chatlog 中并进入后续 attempt 的 Context(CHT-LIF-1)。新 attempt 的 Planner 看到前一 attempt 的部分输出与 `unknown` 工具结果,与用户中断后继续的语义一致。 +**TRN-RTY-3** 失败 attempt 已提交的 assistant 与 tool_result 保留在 stream 中,协议不删除、不隐藏。它们是否进入后续 attempt 的模型请求是 Application 策略,由 Planner 依据 turn surface 的 attempt 状态决定(REF-PLN-6);协议只保证内容可用。 ## 5. Drive、Resume 与 Stop @@ -234,15 +239,15 @@ InputIDs 为空时 group 为 `started` 加 `created`。`created` 与 `input_acce ## 6. companion:Run 事实到对话内容 -Run 事实只保存执行状态与内容 digest(RUN-WIR-4)。模型文本、工具调用与工具输出以 chatlog 事件形式与产生它们的 Run 事实写在同一 SessionCommit。`run.Runtime.Commit` 在 Decide 之后、写入之前调用注入的 `run.Companion`,把本 commit 的 facts 与 command 携带的 transient 内容映射为事件,追加在 Run facts 之后。接口定义在 `agent/run`(第 5 节);本模块提供实现 `CompanionV1`,它使用 Catalog 的 chatlog 与 turn codec 编码 payload。 +Run 事实只保存执行状态与内容 digest(RUN-WIR-4)。模型文本、工具调用与工具输出以 chatlog 事件形式与产生它们的 Run 事实写在同一 SessionCommit。`run.Runtime.Commit` 在 Decide 之后、写入之前调用注入的 `run.Companion`,把本 commit 的 facts 与 command 携带的 transient 内容映射为 `run.ModuleEvent`,追加在 Run facts 之后;随后整个 group 经 `SemanticAppender` 的 codec、Binding admission 与 claim 写入(EXT-APP-3)。接口定义在 `agent/run`(第 5 节);本模块提供实现 `CompanionV1`,它把 `CompanionRequest.Owner` 解释为 TurnID。 -**TRN-CMP-1** `Map` 为确定性纯函数,不做 IO;时间取 `CompanionRequest.RecordedAtUnixMilli`。输出事件的 EventID 由 `(Turn, RunID, fact identity, CompanionVersion)` 派生(TRN-MAP-2),同一 commit 重放得到同一 group。 +**TRN-CMP-1** `Map` 为确定性纯函数,不做 IO;时间取 `CompanionRequest.RecordedAtUnixMilli`。输出事件的 EventID 由 `(TurnID, RunID, fact identity, CompanionVersion)` 派生(TRN-MAP-2),同一 commit 重放得到同一 group。companion 事件可以携带 `ReferencePart`;其 Binding 由 Appender 在同一事务 admission 并建立 claim,Runtime 不另行处理。 **TRN-CMP-2** v1 映射: | Run fact | companion event | |---|---| -| `ModelStepCompleted` | `twilight/chatlog/assistant{TurnID, Parts: text, reasoning, tool_call*}` | +| `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` | @@ -270,16 +275,17 @@ Run 事实只保存执行状态与内容 digest(RUN-WIR-4)。模型文本、 | 工具效果未知 | lease 过期后 `RecoverExpired` 提交该 call 的 Unknown,companion 写 status=`unknown`;Run 保持 Active | | Run 已 `failed`、Turn 未结算 | Turn 为 `attempt_failed`;Application 选择 Retry 或 Settle | | Stop commit 响应丢失 | 以同一 Cancel CommandID 重放 | +| Start 或 Retry 响应丢失 | 以同一 CommitID 重放,得到 already-applied | | binding 缺失 | 返回 `binding_unavailable`;Turn 状态不变 | -**TRN-REC-3** 没有跨存储的对账:Run 事实、companion 内容与 Turn 结算在同一 commit,要么全部可见要么全部不可见。 +**TRN-REC-3** 没有跨存储的对账:Run 事实、companion 内容、claim 与 Turn 结算在同一事务,要么全部可见要么全部不可见。 ## 8. conformance -- **TRN-SCP-1 至 TRN-SCP-6**:一 Turn 至多一个非终态 Run、Source `twilight`、ModuleID `turn`、无隐藏状态; -- **TRN-ID-1 至 TRN-EVT-3**:所列 EventType、`twilight/turn/plan`、`twilight/turn/run` 派生 RunID、每 Turn 至多一条结算事件; -- **TRN-PRJ-1**:surface 状态机,`active` 与 `attempt_failed` 的判定; -- **TRN-STR-1 至 TRN-RTY-2**:Start group 顺序与原子性、Retry 前置条件、Attempt 递增、幂等 CommitID; +- **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`; +- **TRN-STR-1 至 TRN-RTY-3**:Start group 顺序与原子性、Retry 前置条件、Attempt 递增、幂等 CommitID、失败 attempt 内容保留在 stream; - **TRN-DRV-1 至 TRN-STL-1**:Drive disposition、Stop 以 Attach 单 commit 结算、Application 的 Cancel 进入 `attempt_failed`、Settle 前置条件; -- **TRN-CMP-1 至 TRN-MAP-4**:companion 纯函数、v1 映射表、`SourceDigest` 等于 Run fact 记录值、同 commit 可见性、失败 attempt 内容进入 Context; +- **TRN-CMP-1 至 TRN-MAP-4**:companion 纯函数、v1 映射表、`SourceDigest` 等于 Run fact 记录值、companion 中的 ReferencePart 经 admission 并建立 claim、同 commit 可见性; - **TRN-REC-1 至 TRN-REC-3**:上表恢复情形、崩溃后 Resume、无跨存储对账。 From d3261c00571f70c4e9a4178df7d2124a30e5be4d Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 5 Sep 2026 01:19:52 +0800 Subject: [PATCH 63/81] Design: lease in extension, module Requires, mid-turn input, consistency pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kernel / framework layering - Session control-plane KV gains ControlCompareAndPut, per-entry deadline and ControlExpired; the kernel carries no lease concept (SES-API-3). - extension.Lease is the shared occupancy facility: Acquire/Release inside SemanticTx, Renew via conditional put, Expired via deadline scan, Token derived from the acquiring CommitID (EXT-LSE). run is its first consumer; the projection fallback scan and "renew via session lock" are dropped. - ModuleDescriptor.Requires declares event-consumption dependencies; the Registry validates registration, acyclicity, projection Consumes and payload-version compatibility at build (EXT-REG-4, EXT-SCP-4). - BuildRegistry takes descriptors from composition; extractors are declared per EventDefinition (BindingExtractor), so extension imports no module. - ProjectionReader is the only out-of-section projection read path; Coordinator no longer holds session.Store. - EventID for all first-party events = Digest(EventType, CommitID, index), assigned by the Appender (EXT-APP-5); per-module rules removed. Mid-turn input - Turn gains Deliver: one Run commit per input (AcceptInput + attached chatlog/input_delivered), never interrupting in-flight calls (TRN-DLV). - AcceptInput is accepted in any non-terminal state; a model result with no tool calls but pending inputs returns to Open instead of ending; new WithdrawPreparedStep discards a frozen-but-unsent request when input arrives (RUN-MCH, RUN-LOP-8). turn surface tracks run/input_accepted; Retry replays all delivered inputs. - Reference assembly adds SessionDriver (Send / OnTurnSettled) mapping the inbox model's next-step / next-turn onto Deliver / Start (REF-DRV). Cleanup - Status lines without dates or revision history; stale terms removed (resolved ancestry, RegistryID/Parts, ToolIndeterminate, InitialInputs). - Redundancies removed: StartRequest.Inputs only; BindingPublic.Tools as PublicTool; Lease Attrs dropped for run (target parsed from Key). - Gaps closed: Create idempotency, CausationID/CorrelationID semantics, ProjectionKey = ProjectionID, Record compares only active runs. - agent-runtime-refactor.md §7.6–7.8 record the decisions. Co-Authored-By: Claude Fable 5.1 --- docs/design/agent-artifact.md | 6 +- docs/design/agent-reference-assembly.md | 65 ++++++++++--- docs/design/agent-run.md | 74 +++++++++------ docs/design/agent-runtime-refactor.md | 47 +++++++--- docs/design/agent-session-chatlog.md | 23 +++-- docs/design/agent-session-extension.md | 116 +++++++++++++++++++----- docs/design/agent-session.md | 31 ++++--- docs/design/agent-turn.md | 53 ++++++----- 8 files changed, 288 insertions(+), 127 deletions(-) diff --git a/docs/design/agent-artifact.md b/docs/design/agent-artifact.md index 099a0fe..3a28eae 100644 --- a/docs/design/agent-artifact.md +++ b/docs/design/agent-artifact.md @@ -1,6 +1,6 @@ # Twilight Agent Artifact Core -状态:设计草案。无实现;wire 与 claim 状态表在 Memory reference implementation 通过 conformance 前不冻结。2026-09-04 第二次修订:v1 的 claim 只有 `Active` 与 `Released` 两态,与 Session commit 同事务写入;`Prepared` 状态、reconciler、provider 迁移 fence 与 archive import/export 移入附录,不进入 v1 conformance。 +状态:设计草案。无实现;wire 与 claim 状态表在 Memory reference implementation 通过 conformance 前不冻结。v1 的 claim 只有 `Active` 与 `Released` 两态,与 Session commit 同事务写入;`Prepared` 状态、reconciler、provider 迁移 fence 与 archive import/export 在附录中,不进入 v1 conformance。 本文定义 `agent/artifact`。文中的"必须""不得""应该"是协议约束;canonical JSON、JCS 与 domain-separated digest 使用 `agent/jsonstable` 和 `agent/es` 的通则。 @@ -69,8 +69,8 @@ type WireCodec interface { DecodeRef(jsonstable.Value) (Ref, error) EncodeBinding(Binding) (jsonstable.Value, error) DecodeBinding(jsonstable.Value) (Binding, error) - EncodeManifest(BindingManifest) (jsonstable.Value, error) - DecodeManifest(jsonstable.Value) (BindingManifest, error) + EncodeManifest(BindingManifest) (jsonstable.Value, error) // 附录第 7 节;v1 返回 ErrUnsupported + DecodeManifest(jsonstable.Value) (BindingManifest, error) // 同上 } ``` diff --git a/docs/design/agent-reference-assembly.md b/docs/design/agent-reference-assembly.md index ae479e0..ec66581 100644 --- a/docs/design/agent-reference-assembly.md +++ b/docs/design/agent-reference-assembly.md @@ -2,18 +2,22 @@ 状态:设计草案。与 [Run](agent-run.md)、[Turn](agent-turn.md)、[Chatlog](agent-session-chatlog.md) 冲突时以各正式规范为准。 -本文规定 Memory 参考 agent 的三处组装:ExecutionBinding 公开字段、Planner、用户正文在 Chatlog Input 与 Run AgentInput 上的同一份 payload。 +本文规定 Memory 参考 agent 的四处组装:ExecutionBinding 公开字段、Planner、用户正文在 Chatlog Input 与 Run AgentInput 上的同一份 payload、session 作用域的输入路由(SessionDriver)。 ## 1. ExecutionBinding Session 保存 `ExecutionBindingRef{ID, Digest}`。Digest 覆盖公开配置: ```go +type PublicTool struct { + Ref run.ToolRef + Definition run.ToolDefinition + Policy run.ResponsePolicy +} type BindingPublic struct { SchemaVersion uint16 // 1 Model run.ModelRef - Tools []run.ToolSpec // ref、definition digest、policy - Definitions []run.ToolDefinition // 与 Tools 一一对应的本体 + Tools []PublicTool // ToolSpec 与 Request.Tools 都由此派生 Streaming bool PlannerID string // "twilight/turn/planner/context-v1" SystemPrompt string @@ -34,18 +38,18 @@ Planner ID:`twilight/turn/planner/context-v1`。 func Plan(ctx context.Context, hint run.PlanningHint, fold []chatlog.Entry, pub BindingPublic) (loop.RequestPlan, error) ``` -**REF-PLN-1** `fold` 为 `ContextFold` 对该 Session chatlog 事件的输出(含已应用的 checkpoint)。Planner 在每次 Plan 时读取 `twilight/chatlog/context` 投影(snapshot 加 tail)。 +**REF-PLN-1** `fold` 为 `ContextFold` 对该 Session chatlog 事件的输出(含已应用的 checkpoint)。Planner 在每次 Plan 时经 `extension.ProjectionReader` 读取 `twilight/chatlog/context` 投影(snapshot 加 tail)。 **REF-PLN-2** `sdk.Messages` 顺序: 1. `pub.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` 不再携带模型结果或工具结果。 +上一步的 assistant 与 tool_result 已随对应 Run 事实同 commit 提交,Planner 消费时的 fold 总是包含它们;`PlanningHint` 不携带模型结果或工具结果。 -**REF-PLN-3** `hint.Inputs` 与本 Turn 已 delivered、且属于本次 Prepare 的 Input 按 ID 对齐。这些 Input 已在 fold 中,只使用 fold。 +**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 = pub.Model`,`Tools = pub.Tools`,`Request.Tools` 由 `pub.Definitions` 生成,`InputIDs` 为本次消费的 PendingInput IDs。`PlanningToken` 随 fold 的 Entry digest 序列或 Binding Digest 变化。 +**REF-PLN-4** `RequestPlan.Model = pub.Model`;`Request.Tools` 与 `Tools`(ToolSpec:Ref、DefinitionDigest、Policy)都由 `pub.Tools` 派生,顺序一致;`InputIDs` 为本次消费的 PendingInput IDs。`PlanningToken` 随 fold 的 Entry digest 序列或 Binding Digest 变化。 **REF-PLN-5** 无附件时 TextPart 直接写入 sdk.Message。ReferencePart 经 ContextMaterializer 转换。 @@ -62,32 +66,63 @@ run.AgentInput.Payload **REF-INP-1** v1 形状为 `{"text":"<用户字符串>"}`。 -**REF-INP-2** `StartRequest.InputIDs[i] == InitialInputs[i].ID`,且等于已 submitted 的 InputID;`InitialInputs[i].Payload` 等于该 Input 的 Content。`input_delivered` 把 InputID 挂到 TurnID;`twilight/run/input_accepted` 在同一 commit 把同一 payload 交给 Run。 +**REF-INP-2** `StartRequest.Inputs[i].ID` 等于已 submitted 的 InputID,`Payload` 等于该 Input 的 Content。`input_delivered` 把 InputID 挂到 TurnID;`twilight/run/input_accepted` 在同一 commit 把同一 payload 交给 Run。 **REF-INP-3** Planner 把 `{"text":...}` 投影为 sdk user text。 -## 4. Memory 组成 +## 4. SessionDriver + +Coordinator 是 Turn 作用域的:Turn 结束即返回。参考组装提供一个 session 作用域的 `SessionDriver`,把用户输入按当前状态路由到 Deliver 或 Start,并在 Turn 结算后自动开启下一个 Turn。它只组合 Coordinator 与两个投影,没有自己的持久状态,不进入 turn 或 run 协议。 + +```go +type SessionDriver struct { + Coordinator turn.Service + Projections extension.ProjectionReader + Binding turn.ExecutionBindingRef // 新 Turn 使用的执行绑定 + Companion turn.CompanionVersion + NewTurnID func() turn.TurnID +} +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-1** `Send` 先读 `twilight/turn/surface`:存在 `active` 的 Turn 时调用 `Deliver`,输入进入该 Run 的下一步;否则以 `NewTurnID()`、`Binding`、`Companion` 调用 `Start`。这对应 inbox 模型中"steer 在运行中注入下一步、在空闲时开启新 turn"的行为。输入在两种情形下都已由 Application 先写入 `input_submitted`。 + +**REF-DRV-2** `OnTurnSettled` 在 Turn 进入 `completed`、`failed`、`stopped` 或 `superseded` 后调用:读 `twilight/chatlog/surface`,若存在 `submitted` 且未 delivered 的输入,按 `input_submitted` 的 stream 顺序取全部,`Start` 新 Turn 并返回;否则返回 `false`。这对应 inbox 模型的 `next-turn` 列表:已提交而未投递的输入就是该列表,不需要另一份持久结构。 + +**REF-DRV-3** Turn 为 `attempt_failed` 时 `Send` 返回 conflict,不自动 Retry 或 Settle;这两者是 Application 的决定。`Deliver` 与最后一步结果并发失败(TRN-DLV-3)时,`Send` 得到 `completed`,输入仍为 `submitted`,随后的 `OnTurnSettled` 会把它带入下一个 Turn。 + +**REF-DRV-4** 崩溃恢复:`SessionDriver` 从两个投影重建。对每个 session,先按 TRN-REC-1 处理 `active` 与 `attempt_failed` 的 Turn;没有未结算 Turn 时调用 `OnTurnSettled` 消费积压的输入。 + +## 5. Memory 组成 ```text sessionStore = session.NewMemoryStore() // commit、CommitIn、snapshot、控制面 KV -registry = extension.FirstPartyRegistry(profile, chatlog.Parts) -ledger = artifact.NewLedger(...) // claim 存于控制面 KV twilight/artifact/claim -appender = extension.NewSemanticAppender(sessionStore, registry, bindings, ledger) -runtime = runmod.NewRuntime(appender, sessionStore, runmod.NewMemoryFrozenValues(), turn.CompanionV1(registry), runmod.DefaultSnapshotPolicy) +registry = extension.BuildRegistry(profile, chatlog.Module, turn.Module, runmod.Module) +projections = extension.NewProjectionReader(sessionStore, registry) +bindingStore = artifact.NewMemoryBindingStore() +ledger = extension.NewKVLedger(sessionStore, bindingStore) // claim 存于控制面 KV twilight/artifact/claim +appender = extension.NewSemanticAppender(sessionStore, registry, bindingStore, ledger) +runtime = runmod.NewRuntime(appender, projections, extension.Leases{Store: sessionStore}, runmod.NewMemoryFrozenValues(), turn.CompanionV1(registry), runmod.DefaultSnapshotPolicy) drivers = Resolve(ExecutionBindingRef) -> loop.New(models, tools, contextPlanner, policy, pub.Streaming) -coordinator = turn.Coordinator{Sessions: sessionStore, Appender: appender, Runtime: runtime, Bindings: drivers} +coordinator = turn.Coordinator{Projections: projections, Appender: appender, Runtime: runtime, Bindings: drivers} +session = SessionDriver{Coordinator: coordinator, Projections: projections, Binding: bindingRef, Companion: turn.CompanionV1Version, NewTurnID: ...} input_submitted -coordinator.Start +session.Send // 无 active Turn → coordinator.Start commit 1: twilight/turn/started + twilight/chatlog/input_delivered* + twilight/run/created + twilight/run/input_accepted* Loop.Run commit: twilight/run/model_step_prepared (请求本体 → FrozenValueStore) commit: twilight/run/model_step_started (lease → 控制面 KV,同事务) commit: twilight/run/model_step_completed + twilight/run/tool_step_opened + twilight/chatlog/assistant commit: twilight/run/tool_call_started + input_submitted; session.Send // 有 active Turn → coordinator.Deliver + commit: twilight/run/input_accepted + twilight/chatlog/input_delivered commit: twilight/run/tool_call_completed + twilight/chatlog/tool_result + commit: twilight/run/model_step_prepared (PlanningHint.Inputs 含中途输入) ... commit: twilight/run/model_step_completed + twilight/run/ended + twilight/chatlog/assistant + twilight/turn/completed +session.OnTurnSettled // 有积压的 submitted 输入 → 开下一个 Turn ``` 参考 agent 的工具 ResponsePolicy 为 `DirectExecution`。ContextFold 在无 checkpoint 时输出全部有效条目。 diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index a689234..e304c53 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -1,6 +1,6 @@ # Twilight Agent Run Protocol -状态:设计规范。Machine、command/fact 规则与 Loop 为规范;第 5 节的 Runtime 与存储层于 2026-09-04 按单一 Session ES 修订(同日第二次修订:控制面进入 Session 控制面 KV、写入经 Module Framework、恢复 Run 级 SchemaVersion、snapshot 改为缓存),实现尚未跟进(见 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 6、7 节)。 +状态:设计规范。Machine、command/fact 规则与 Loop 已有实现;第 5 节的 Runtime 与存储层无实现,实施记录见 [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` 的通则。 @@ -12,7 +12,7 @@ MachineState Run 的语义状态投影(twilight/run/machine) Runtime Run 的 command 入口:在 Session 临界区内 Decide、Evolve,经 Module Framework 追加 loop.Loop 当前进程的 execution interpreter FrozenValueStore 内容寻址旁存:模型请求本体(含工具定义),按 digest 存取 -控制面 KV Session Store 的 control-plane KV:lease、grant、durable claim;与 commit 同事务 +extension.Lease 占用与续期:grant 是 Lease Token,ExecutionClaim 是 Holder;建立在 Session 控制面 KV 上,与 commit 同事务 ``` `MachineState` 决定 Run 当前可执行动作。每次接受的 command 产生一个 SessionCommit,其中的 `twilight/run/` 事件经该 Run 版本的 `Protocol.Evolve` 从 `twilight/run/created` 重放后必须得到同一 `MachineState`。 @@ -31,9 +31,9 @@ Machine 处理已冻结的值和已提交的事实;Loop 解释 `Next` 产生 `Step` 是 Run 的持久化恢复边界;`execution attempt` 表示某个 Loop 进程对该 Step 或 ToolCall 的一次易失执行。一个 Step 可以有多个 attempt,Machine 只接受带有效 grant 的 settlement。Attempt 的执行控制信息由 start command 的 `ExecutionClaim` 和 Runtime 返回的 opaque `ExecutionGrant` 表达;它们不进入 stream。 -**RUN-SCP-1** `agent/run` 拥有 Run identity、persisted frozen values、Machine、command/fact protocol、fact codec、fold 与 `Runtime`、`Companion` contract;它依赖 `agent/session` 的 identity 与 Store 类型,不依赖 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.SemanticAppender.AppendSemanticIn` 写入)、FrozenValueStore adapter 与控制面 KV 的 lease 编码。 +**RUN-SCP-1** `agent/run` 拥有 Run identity、persisted frozen values、Machine、command/fact protocol、fact codec、fold 与 `Runtime`、`Companion` contract;它依赖 `agent/session` 的 identity 与 Store 类型,不依赖 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.SemanticAppender.AppendSemanticIn` 写入,经 `extension.Lease` 占用与续期)、FrozenValueStore adapter。 -**RUN-SCP-2** Run 是 first-party Session Module(Source `twilight`,ModuleID `run`)。Run 不解释它的上层实体:`OwnerID` 是 opaque 字符串,由 turn 模块以 TurnID 填充。Turn 的创建、attempt 归属与结算、Run 事实到对话内容的 companion 映射由 [agent-turn.md](agent-turn.md) 定义;对话内容 ontology 由 [agent-session-chatlog.md](agent-session-chatlog.md) 定义;stream、commit、projection、snapshot 与控制面 KV 机制由 [agent-session.md](agent-session.md) 与 [agent-session-extension.md](agent-session-extension.md) 定义。Artifact、queue、provider registry、权限与产品 policy 分别由其 package 或 Application 拥有。 +**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、commit、projection、snapshot 与控制面 KV 机制由 [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 @@ -73,7 +73,7 @@ type CommandEnvelope struct { command 不持久化。`CommandEnvelope.ID` 就是该 command 产生的 SessionCommit 的 `CommitID`;`Digest` 只用于 Runtime 在临界区内比对精确重放。 -**RUN-WIR-3** 一个 command 恰产生一个 SessionCommit;其 `twilight/run/` 事件 Index 从 0 连续递增,companion 事件(TRN-CMP)与调用方附加事件(`CommitRequest.Attach`)依次紧随其后。Run 事件的 `EventID = Digest("twilight/run/event", CommandID, index)`;`RecordedAtUnixMilli` 由 Runtime 的时钟填入,是 metadata,不参与 Run 的任何派生,也不进入 append fingerprint(SES-APP-1)。构造 command 必须使用该 Run 版本的 `Protocol.BuildEnvelope`(Loop 通过 `RuntimeSnapshot.Protocol()` 取得)。`agent/run` 不提供隐式选择版本的包级 `BuildEnvelope`、`Decide`、`Evolve` 或 `Digest*` 函数;新 Run 与测试显式使用 `ProtocolV1()`。 +**RUN-WIR-3** 一个 command 恰产生一个 SessionCommit;其 `twilight/run/` 事件 Index 从 0 连续递增,companion 事件(TRN-CMP)与调用方附加事件(`CommitRequest.Attach`)依次紧随其后。所有事件的 EventID 由 Appender 按 `Digest(EventType, CommitID, index)` 统一赋值(EXT-APP-5);Runtime 提交的 commit 其 CommitID 等于 CommandID,Coordinator 写入的 Start 与 Retry group 使用该 group 的 CommitID。`RecordedAtUnixMilli` 由写入方的时钟填入,是 metadata,不参与 Run 的任何派生,也不进入 append fingerprint(SES-APP-1)。构造 command 必须使用该 Run 版本的 `Protocol.BuildEnvelope`(Loop 通过 `RuntimeSnapshot.Protocol()` 取得)。`agent/run` 不提供隐式选择版本的包级 `BuildEnvelope`、`Decide`、`Evolve` 或 `Digest*` 函数;新 Run 与测试显式使用 `ProtocolV1()`。 **RUN-WIR-4** 内容与执行状态分离。fact 只保存执行状态与内容 digest,内容本体落在两处: @@ -98,6 +98,7 @@ companion 与 Attach 事件与 Run 事实一起经 Module Framework 的 admissio | 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 | @@ -195,7 +196,7 @@ type ToolStep struct { `Status` 是 Run 的生命周期:`RunActive | RunCompleted | RunStopped | RunFailed`。后三者是终态。`RunStatus` 表示当前 MachineState 的投影;终态 fact 使用 RunEnd union 表达具体结果。 -`Current` 是 Active 期间的内容。`Open` 是可进入区间:可提交 `AcceptInput` 与 `PrepareModelRequest`,`Next` 返回 `NeedModelRequest`。`ModelStep` 与 `ToolStep` 表示正在进行的步骤。终态的 `Current` 为空;终态由 `Status` 表达,不另设 Current variant。Active 的 `Current` 不得为空。`Step` 仍只有 `ModelStep` 与 `ToolStep`,提供 `Ref()`。 +`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 边界事实。 @@ -226,9 +227,10 @@ type RunEnded struct { End RunEnd } ```text ModelStep: Prepared -> Executing -> Completed - | | - +-> Recovered-+ (回到同一 frozen request 的 Prepared) - +-> Rejected (retry 回到 Prepared,或同 commit 失败 Run) + | | | + | +-> Recovered-+ (回到同一 frozen request 的 Prepared) + | +-> Rejected (retry 回到 Prepared,或同 commit 失败 Run) + +-> Withdrawn -> Open (Prepared 期间有 pending input,放弃该请求并重规划) ToolCall: Pending -> Executing -> Completed @@ -239,7 +241,7 @@ ToolCall: Waiting(ExternalResponse) -> Completed | Failed(Known) ``` -Recovered 回到 Prepared 后,下一次 Start 重发同一 `RequestDigest` 的请求,Loop 经 `Runtime.FrozenRequest` 取回本体。这是冻结请求被重用的唯一情形;step 终结后下一步由 Planner 重新组装。 +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。 @@ -249,11 +251,12 @@ Recovered 回到 Prepared 后,下一次 Start 重发同一 `RequestDigest` 的 | command | precondition / facts | |---|---| -| `AcceptInput` | `Open`;`InputAccepted` | +| `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`。恢复 durable attempt 时携带该 attempt 的 `Claim` | -| `SubmitModelResult` | Model Executing;`ModelStepCompleted{Usage, FinishReason, ResultDigest}`,随后无 calls 时 `RunEnded(completed)`,有 calls 时 `ToolStepOpened`(携带冻结的 `Scheduling` 与 bindings)。command 携带冻结 `ModelResult` 本体,companion 写 `twilight/chatlog/assistant` | +| `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 或在同一 commit 追加 `RunEnded(failed/malformed_model_result)` | | `StartToolCall` | Tool Pending;`ToolCallStarted`。command 必须携带本次 start 的 `ExecutionClaim` | @@ -295,6 +298,7 @@ type RecoverModelExecution struct { |---|---| | terminal | 返回 `ErrRunTerminal`,没有 effect | | `Open` | `NeedModelRequest{PlanningHint}` | +| Model Prepared 且 `PendingInputs` 非空 | `WithdrawPrepared` | | Model Prepared | `StartModelCall` | | Model Executing | `Idle` | | ToolStep 有 Pending calls | `StartToolCalls` | @@ -302,7 +306,7 @@ type RecoverModelExecution struct { Waiting call 上的 `ResponseRequest` 由 `WaitingCalls(state)` 读取。Executing call 由 `ExecutingCalls(state)` 读取。`NeedsRecovery(state)` 在 Model Executing 或 ToolStep 无 Pending 且仍有 Executing 时为 true。这些查询不是 Effect。 -**RUN-MCH-4** Effect 由调用方每次 Load 后重新派生。`PrepareModelRequest.InputIDs` 必须与当前 PendingInputs 等长、同顺序、逐项相同;prepare 接受后一次消费全部 pending input。ToolStep 的 Waiting call 禁止 Start,同一 step 中的 Pending call 仍可执行。没有可执行 Start 时 `Next` 返回 `Idle`。Application 从投影读取 `WaitingCalls` 并提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse`。执行恢复由 Runtime 的 recovery authority 根据 `NeedsRecovery` 提供。 +**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`。执行恢复由 Runtime 的 recovery authority 根据 `NeedsRecovery` 提供。 ## 5. Runtime、投影与 Commit @@ -325,10 +329,9 @@ type RuntimeSnapshot struct { SchemaVersion uint16 // created.SchemaVersion } -// ModuleEvent 是其他模块的 typed event,由 agent/session/run 经 Registry 编码。 +// ModuleEvent 是其他模块的 typed event,由 agent/session/run 经 Registry 编码;EventID 由 Appender 按位置赋值(EXT-APP-5)。 type ModuleEvent struct { Type session.EventType - EventID session.EventID Value any } // Companion 把一个 commit 的 Run facts 与 command 携带的 transient 内容映射为 @@ -386,7 +389,7 @@ type CommitResult struct { **RUN-CMT-1** Runtime 按 `(SessionID, RunID)` 寻址。Run 由 Coordinator 的 Start commit 创建(TRN-STR-2),Runtime 没有 `Create`。缺失 Run 的 Load、Commit、Record 返回 `ErrRunNotFound`。 -**RUN-CMT-2** 投影 `twilight/run/machine` 消费全部 `twilight/run/` 事件,忽略其他模块事件(EXT-PRJ-2),状态为: +**RUN-CMT-2** 投影 `twilight/run/machine` 消费全部 `twilight/run/` 事件,忽略其他模块事件(EXT-PRJ-2),`RequireComplete` 为 `run`,状态为: ```go type MachineProjection struct { @@ -395,7 +398,7 @@ type MachineProjection struct { } ``` -终态 Run 在 `RunEnded` 折叠后从投影中移除;终态结果由 `Record` 与 turn surface 提供,投影大小与活动 Run 数成正比。snapshot 是可丢弃缓存(SES-SNP-1):`Load` 读取 snapshot(若存在且 `Through` 是当前前缀),再 fold 其后类型前缀为 `twilight/run/` 的 tail;没有 snapshot 时从 stream 的过滤 replay 全量 fold。写入策略由 `agent/session/run` 的 `SnapshotPolicy` 决定,默认在 Run 的 `Current` 回到 `Open` 或 Run 终结时写入,并可按 commit 计数补充;kernel 不要求每次 commit 都写。`Record` 以 `Types=[twilight/run/]` 过滤 replay 读取该 RunID 的全部事件(SES-REP-2),FoldRun 重建并与当前投影比对;corrupt、gap 或 divergence 必须失败。 +终态 Run 在 `RunEnded` 折叠后从投影中移除;终态结果由 `Record` 与 turn surface 提供,投影大小与活动 Run 数成正比。snapshot 是可丢弃缓存(SES-SNP-1):`Load` 经 `extension.ProjectionReader`(EXT-PRJ-4)读取,即 snapshot(若存在且 `Through` 是当前前缀)加其后类型前缀为 `twilight/run/` 的 tail;没有 snapshot 时从 stream 的过滤 replay 全量 fold。写入策略由 `agent/session/run` 的 `SnapshotPolicy` 决定,默认在 Run 的 `Current` 回到 `Open` 或 Run 终结时写入,并可按 commit 计数补充;kernel 不要求每次 commit 都写。`Record` 以 `Types=[twilight/run/]` 过滤 replay 读取该 RunID 的全部事件(SES-REP-2),FoldRun 重建;该 Run 仍在投影中时与投影状态比对,corrupt、gap 或 divergence 必须失败。 **RUN-CMT-3** Commit 经 `extension.SemanticAppender.AppendSemanticIn` 在 Session Store 的一个事务内完成(SES-API-2、EXT-APP-3)。所有 Runtime implementation 在 fn 内调用同一个 pure `EvaluateCommit`,顺序固定为: @@ -403,16 +406,17 @@ type MachineProjection struct { AppendSemanticIn(sessionID, func(tx): 1 validate envelope SessionID/RunID/schema/type/digest(digest 不匹配为不可重试错误) 2 tx.LookupCommit(CommitID = CommandID) - 3 found -> AlreadyApplied + 原完整 commit;start 精确重放时读控制面 lease,grant live 则返回原 grant + 3 found -> fn 返回 nil(Appender 记 Noop);Runtime 以查到的 commit 构造 CommitAlreadyApplied, + start 精确重放时 LookupLease,Token 仍 live 则返回原 grant 4 derived CommandID check 5 state = fold(tx.LoadSnapshot(twilight/run/machine) + tx.Tail(after, [twilight/run/])) 缺少 created -> ErrRunNotFound;schema 不等于 created.SchemaVersion -> 不可重试错误;terminal check 6 validate hard CAS(prepare 的 Base == Positions[RunID])/ target state / execution grant / recovery authority - grant 与 lease 从控制面 KV twilight/run/lease 读取 + grant 经 LookupLease 校验 Token;recovery authority 要求条目 deadline 已过且 command Claim 等于 Holder 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 tx.ControlPut / ControlDelete lease ops;按 SnapshotPolicy tx.SaveSnapshot + 10 start -> AcquireLease;settlement / recovery -> ReleaseLease;按 SnapshotPolicy tx.SaveSnapshot 11 return SemanticGroup{CommitID: CommandID, Events: run ++ companion ++ attach} ) // Appender 在同一事务内完成 codec、Binding admission、claim 写入与 append。 @@ -426,19 +430,26 @@ FrozenValueStore 的 `Put` 幂等且内容寻址,在进入事务之前完成 **RUN-CMT-6** accepted `StartModelExecution`/`StartToolCall` 为目标签发新 grant;该 start 的 `CommitAccepted` 和在 grant 仍 live 时满足精确 replay 条件的 `CommitAlreadyApplied` 返回同一个 grant。若该 start 已 settlement 或 Run 已 terminal,精确 replay 仍返回 `CommitAlreadyApplied`,并返回空 grant。model result/failure/reject 与 executing tool result/known failure 必须携带 live target grant。settlement 接受后 grant 失效;terminal commit 撤销该 Run 全部 grant。`RecoverModelExecution` 由 live grant holder 提交,或在 Runtime 验证 lease 已过期且 command Claim 等于该 lease 的 Claim 后无 grant 提交。Executing tool 的 recovery 使用同一条 `SubmitToolFailure{Outcome:Unknown}` command:工具 owner 必须携带 live grant;`RecoverExpired` 仅在 lease 已过期且没有已接受 settlement 时无 grant 提交。该 Unknown 只结算这一 call,Run 保持 Active。 -**RUN-CMT-7** commit、lease 变更、claim 与(若写入)snapshot 在同一 Session Store 事务内生效:lease 与 grant 经 `SessionTx.ControlPut/ControlDelete` 写入,claim 由 Appender 写入,三者与 commit 同时可见或同时不可见。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 +**RUN-CMT-7** commit、lease 变更、claim 与(若写入)snapshot 在同一 Session Store 事务内生效:lease 经 `extension.AcquireLease` / `ReleaseLease` 在 `SemanticTx` 内写入,claim 由 Appender 写入,三者与 commit 同时可见或同时不可见。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 **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 控制面 -grant、lease、ExecutionClaim、ClaimStore 与投影 snapshot 都不进入 stream。lease 与 durable claim 存放在 Session Store 的控制面 KV(SES-API-3),与 commit 同事务写入;snapshot 使用 Session snapshot(SES-SNP)。 +grant、lease、ExecutionClaim、ClaimStore 与投影 snapshot 都不进入 stream。占用与续期使用 Module Framework 的 `extension.Lease`(EXT-LSE),它建立在 Session 控制面 KV 上,与 commit 同事务;durable claim 直接使用控制面 KV;snapshot 使用 Session snapshot(SES-SNP)。Run 对这些设施的映射为: -lease 的 namespace 为 `twilight/run/lease`,key 为 `/model/` 或 `/call//`,value 为 `{grant, claim, deadline}`;一个 target 至多一条 live lease;grant 只存在于该 lease 上。`Deadline` 为零表示不超时(进程内占用);过期且无 settlement 时 Runtime 允许 grantless Recover。durable `loop.ClaimStore` 可以使用 namespace `twilight/run/claim`。 +| Run 概念 | Lease 字段 | +|---|---| +| `ExecutionGrant` | `Token`,由 Acquire 所在的 start CommitID 派生,因此 start 的精确重放得到同一 grant | +| `ExecutionClaim` | `Holder` | +| target | `Namespace = twilight/run/lease`,`Key = /model/` 或 `/call//`;三段都是定长 hex digest,恢复时由 Key 解析 target,`Attrs` 为空 | +| `LeaseTTL` | `TTL`;零表示不超时(进程内占用) | + +一个 target 至多一条 live lease。start 在提交 `ModelStepStarted` / `ToolCallStarted` 的事务内 `AcquireLease`;settlement 与 recovery 在提交对应 fact 的事务内 `ReleaseLease`。因此"日志中该 target 为 Executing"与"存在其 lease"同时成立或同时不成立。过期且无 settlement 时 Runtime 允许 grantless Recover:recovery authority 的判定为条目 deadline 已过、且 command 的 Claim 等于条目的 Holder。durable `loop.ClaimStore` 使用控制面 KV namespace `twilight/run/claim`。 -`RecoverExpired` 以 `Store.ControlScan(twilight/run/lease)` 枚举 lease,对 deadline 已过的 target:Executing tool call 无 grant 提交 `SubmitToolFailure{Unknown}`,Executing model 提交 `RecoverModelExecution`;该 Run 保持 Active,同一 RunID 继续。作为 KV 缺失的兜底(SES-API-3),它还扫描 `twilight/run/machine` 投影中处于 Executing 且没有 lease 记录的 target,以对应 start 事件的 `RecordedAtUnixMilli` 加 `LeaseTTL` 判定过期。因此 lease 记录丢失只使恢复延迟到 TTL 之后,不使 Run 停滞。进程内宿主使用 Memory 实现,lease 不超时,grantless recover 被拒绝。生产崩溃恢复使用带 TTL 的 Runtime。 +`RecoverExpired` 以 `Leases.Expired(twilight/run/lease, now)` 枚举过期条目,由 Key 解析 target:Executing tool call 无 grant 提交 `SubmitToolFailure{Unknown}`,Executing model 提交 `RecoverModelExecution`;command 的 Claim 取自 `Holder`,因此 recovery CommandID 确定,重复扫描幂等。该 Run 保持 Active,同一 RunID 继续。进程内宿主使用 Memory 实现,lease 不超时,grantless recover 被拒绝。生产崩溃恢复使用带 TTL 的 Runtime。 -**RUN-CMT-9** lease 续期。`Runtime.RenewLease` 在临界区之外以 `Store.ControlGet/ControlPut` 执行:grant 等于该 target 当前 lease 的 grant 时,把 deadline 推后一个 `LeaseTTL`;lease 不存在、grant 不匹配或 target 已 settlement 时返回 `ErrStaleRuntime`。持有 grant 的 worker 在效果执行期间必须以远小于 `LeaseTTL` 的间隔续期(Loop 的 `ExecutionPolicy.LeaseRenewInterval`);续期返回 `ErrStaleRuntime` 表示该 target 已被 recovery 接管,worker 必须停止执行并放弃 settlement。`LeaseTTL` 是恢复延迟上界。`LeaseTTL` 为零时 `RenewLease` 只验证 grant,不改变 deadline。 +**RUN-CMT-9** lease 续期。`Runtime.RenewLease` 调用 `Leases.Renew`(EXT-LSE-3):Token 等于该 target 当前 lease 的 Token 时把 deadline 推后一个 `LeaseTTL`;条目不存在、Token 不匹配或条目已被 Release 时返回 `ErrStaleRuntime`。续期在临界区之外,由 kernel 的条件写保证不会把已删除的 lease 写回。持有 grant 的 worker 在效果执行期间必须以远小于 `LeaseTTL` 的间隔续期(Loop 的 `ExecutionPolicy.LeaseRenewInterval`);续期返回 `ErrStaleRuntime` 表示该 target 已被 recovery 接管,worker 必须停止执行并放弃 settlement。`LeaseTTL` 是恢复延迟上界。`LeaseTTL` 为零时 `RenewLease` 只验证 grant,不改变 deadline。 持久结构与一致性等级的总表见 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 7 节。 @@ -535,6 +546,8 @@ Loop.Run(ctx, runtime, sessionID, runID, sink): **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` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须在 `ClaimStore` 中保留该 attempt 的 claim 直到完成 settlement,其余 identity 按需派生;缺少 grant 的 replay 进入 reload 流程。调用使用 `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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call,在外层 ctx 未取消时于本次 `Run` 内按冻结 Scheduling 分批 Start 并结算;ctx 已取消时停止再 Start,只结算已持有 grant 的 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;Executing 且本进程持有 start cache 时只重放 start 并结算,不得再提交 grantless Known。 @@ -564,7 +577,7 @@ type Event struct { **RUN-LOP-6** EventSink 提供 realtime observation,Loop 通过序列化调用向 sink 发送事件。`EventAgentCommitted` 携带 accepted commit;text/reasoning delta、tool progress、tool lifecycle 与 run-finished observation 可丢失、重复或断流。sink failure 保持 Commit 结果;恢复与审计读取 Session stream,EventSink gap 通过 stream 对账。 -可进入区间是 `Current=Open`。Application 在此提交 `AcceptInput`,并负责 admission 与 planning 之间的线性化。Loop 不解释 queue 或 steer:`Open` 时立刻 `NeedModelRequest`。Turn 创建、attempt、结算与 companion 映射由 [agent-turn.md](agent-turn.md) 定义。 +`AcceptInput` 在任意非终态提交,`PendingInputs` 就是回合中途输入的队列;Loop 不解释 queue 或 steer:`Open` 时立刻 `NeedModelRequest`,Prepare 一次消费全部 pending input。Application 负责 admission;Turn 创建、attempt、中途投递、结算与 companion 映射由 [agent-turn.md](agent-turn.md) 定义。 ## 9. compatibility 与 conformance @@ -574,14 +587,15 @@ type Event struct { - Start group 建立 Run、重复 `created` 拒绝、missing Run、schema 与 created 不一致的 command 拒绝; - command exact replay/conflict、prepare hard CAS、call-local rebase、terminal replay; +- 输入入队:`AcceptInput` 在 Open、Model Prepared、Model Executing、ToolStep(含 Waiting)都被接受;Prepared 期间入队后 `Next` 返回 `WithdrawPrepared`,Withdraw 后重规划的 Prepare 包含该输入;Executing 期间入队的输入在该步结算后的 Prepare 中被消费;无 tool call 且有 pending 输入的 `SubmitModelResult` 不结束 Run; - grant 签发、隔离、精确 start replay、消费、跨 Run 拒绝与 recovery authorization; - 一 command 一 commit、run facts 在 companion 与 Attach 之前、companion 的 `SourceDigest` 等于 fact 记录的 ResultDigest / OutputDigest、Attach 拒绝 `twilight/run/` 事件、companion 与 Attach 经 admission 并建立 claim; - Prepare hard CAS 只对该 Run 自己的事件敏感:同一 Session 内其他模块的写入不使 Prepare 失效; -- commit、lease、claim、snapshot 同事务:在任一写入点注入崩溃后四者同时存在或同时缺失; +- commit、lease、claim,以及该 commit 若写入的 snapshot 同事务:在任一写入点注入崩溃后它们同时存在或同时缺失; - 投影 snapshot 加 tail 与全量 fold 等价;删除 snapshot 后 Load 结果不变;终态 Run 不再出现在投影中;Record 单一一致点、FoldRun 等价、gap/tamper/corrupt failure; - 同一 Session 内多 Run 隔离、不同 SchemaVersion 的 Run 共存;同一 Session 的 chatlog/turn 事件不影响 Run fold; -- lease 过期 recovery:live lease 拒绝 grantless、过期 model 回到 Prepared、过期 tool 记 Unknown 且 sibling 不受影响、RecoverExpired 幂等;删除 lease 记录后 Executing target 在 start 时间加 TTL 后仍被恢复; -- lease 续期:续期后原 deadline 不触发 recovery、错误/空 grant 与 settlement 后续期被拒; +- lease 过期 recovery:live lease 拒绝 grantless、过期 model 回到 Prepared、过期 tool 记 Unknown 且 sibling 不受影响、RecoverExpired 幂等、恢复事务删除 lease 条目; +- lease 续期:续期后原 deadline 不触发 recovery、错误/空 grant 与 settlement 后续期被拒、续期与结算并发时条件写失败且不写回已删除条目; - FrozenValueStore:Put 幂等、Recovered 后按 RequestDigest 取回同一请求、本体缺失的错误分类、step 终结后删除本体不影响 Record; - MachineState codec:每个 Current variant 与终态 round-trip、拒绝 unknown field / 非法判别式 / trailing data。 @@ -598,4 +612,4 @@ Loop conformance 必须覆盖: - 超过 LeaseTTL 的工具调用在续期下不被记为 Unknown,其结果被接受; - 共享 ClaimStore 的第二个 Loop 实例重放前一实例的 start 并完成 settlement,不等待 lease 过期。 -历史 package 迁移、实施阶段与未完成 adapter 工作记录在 [agent-runtime-refactor.md](agent-runtime-refactor.md),本协议 authority 以本文为准。 +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 index db7ad32..b238435 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -50,9 +50,9 @@ 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 Event-first Session kernel(Commit CAS、CommitIn 临界区、snapshot、控制面 KV) -agent/session/extension Session Module Framework:first-party Registry、payload 版本、admission、SemanticAppender、projection +agent/session/extension Session Module Framework:first-party Registry、payload 版本、admission、SemanticAppender、Lease、projection agent/session/chatlog first-party Message ontology -agent/session/run first-party Run module:EventDefinition、machine projection、Runtime 实现、FrozenValueStore、lease 编码 +agent/session/run first-party Run module:EventDefinition、machine projection、Runtime 实现(消费 SemanticAppender 与 Lease)、FrozenValueStore agent/artifact Ref、Binding、两态 RetentionLedger agent/turn Turn 生命周期、attempt、CompanionV1 ``` @@ -205,15 +205,16 @@ Run 从独立的 Event Sourcing 存储改为 first-party Session Module。Run |---|---| | `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 | +| `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`、`Retry`、`Settle`、surface 投影 | +| `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)、`agent/session/extension`(FirstPartyRegistry、payload 版本、admission、SemanticAppender)、`agent/artifact` 两态 ledger 的 KV 实现、`agent/session/run`(module descriptor、machine projection、Runtime 实现、Memory FrozenValueStore、lease 编码、SnapshotPolicy)、golden fixtures 重新冻结。 +新增:`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,架构审查后) @@ -222,7 +223,7 @@ Run 从独立的 Event Sourcing 存储改为 first-party Session Module。Run | 审查意见 | 处理 | 位置 | |---|---|---| | 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。`RecoverExpired` 增加兜底:扫描投影中 Executing 且无 lease 的 target,以 start 时间加 TTL 判定过期,KV 缺失只导致恢复延迟 | SES-API-3、RUN-CMT-7、RUN 5.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 | 本节 | @@ -243,14 +244,14 @@ Run 从独立的 Event Sourcing 存储改为 first-party Session Module。Run | 结构 | 等级 | 写入点 | 丢失或不一致时 | |---|---|---|---| | Session commit 与 head | authority | `Commit` / `CommitIn` | 不可恢复;digest chain 使损坏可检测 | -| 控制面 KV:`twilight/run/lease` | 同事务控制面 | `SessionTx.ControlPut`(start、settlement)、`Store.ControlPut`(续期) | 恢复延迟到 start 时间加 TTL;语义不受影响 | +| 控制面 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`(lease 扫描加无 lease 兜底)与 Application 的 artifact GC(按 Active claim)。extension 附录 C 的 journal 扫描与 artifact 附录的 Prepared reconciler 不在 v1。 +v1 只有两类恢复动作:`RecoverExpired`(按 deadline 枚举过期 lease)与 Application 的 artifact GC(按 Active claim)。extension 附录 C 的 journal 扫描与 artifact 附录的 Prepared reconciler 不在 v1。 ### 7.4 后续加固 @@ -258,11 +259,35 @@ lease 的第二条出路:grant 由 `(Claim, start CommitID)` 派生,start fa ### 7.5 实施顺序 -1. Session kernel Memory Store(Commit、CommitIn、Types 过滤、snapshot、控制面 KV)与 conformance; -2. extension FirstPartyRegistry、payload 版本、admission、SemanticAppender 两个入口;artifact 两态 ledger; -3. `agent/session/run`:module descriptor、machine projection、Runtime 实现、FrozenValueStore、lease 编码、RecoverExpired; +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)。 + 后续协议修改直接更新对应正式规范;本文只更新迁移状态和历史决策,不再承载 wire、Machine、Runtime 或 Loop 算法。 diff --git a/docs/design/agent-session-chatlog.md b/docs/design/agent-session-chatlog.md index dfe937a..009e704 100644 --- a/docs/design/agent-session-chatlog.md +++ b/docs/design/agent-session-chatlog.md @@ -19,7 +19,7 @@ Chatlog 保存对话内容:Input、assistant、tool_result、summary、checkpo 流式 `text_delta` / `reasoning_delta` 由 Loop EventSink 发送,属于临时观察。Chatlog 权威是已提交的条目。 -**CHT-SCP-1** 本模块拥有对话内容。Application 拥有模型调用、provider transport、发送策略与审计。`turn` 拥有回合与 Run linkage。 +**CHT-SCP-1** 本模块拥有对话内容。Application 拥有模型调用、provider transport、发送策略与审计。`turn` 拥有回合与 Run linkage。本模块的 `Requires`(EXT-REG-4)为空:事件中的 `TurnID` 是 opaque 字符串,不需要 turn 的 codec。 ## 2. stable entity 与生命周期 @@ -33,7 +33,7 @@ type CallID string type CheckpointID string ``` -`TurnID` 与 turn 模块同一 identity。InputID、AssistantID、ToolResultID、SummaryID 在 resolved ancestry 内唯一;CallID 在同一 Turn 内唯一。replacement graph 无环,一个实体至多一个直接 replacement。 +`TurnID` 与 turn 模块同一 identity。InputID、AssistantID、ToolResultID、SummaryID 在 stream 内唯一;CallID 在同一 Turn 内唯一。replacement graph 无环,一个实体至多一个直接 replacement。 | 实体 | 创建 | 可变过程 | 终态/替换 | 不变量 | |---|---|---|---|---| @@ -80,7 +80,6 @@ const ( ToolSuccess ToolResultStatus = "success" ToolError ToolResultStatus = "error" ToolUnknown ToolResultStatus = "unknown" - ToolIndeterminate ToolResultStatus = "indeterminate" ) type Input struct { @@ -131,7 +130,7 @@ type Entry struct { **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` 为 v1 companion 写入的未决终态;`indeterminate` 保留给历史条目,v1 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-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`。 @@ -154,11 +153,11 @@ type ContentRefCodec interface { } ``` -**CHT-COD-1** Decode 先检查 object、discriminator、Session protocol profile、unknown fields 和 limits,再构造 typed value。Encode/Decode 拒绝 nil、kind mismatch、未知 discriminator、cycle 和超限输入。有效值满足 `Encode → Decode → Encode` canonical-equivalent。 +**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** registry 在启动时固定,kind 有唯一 codec。BindingExtractor 按 appearance order 返回 assistant、tool_result、summary 中 ReferencePart 的 BindingID。 +**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/`。payload 第一层携带版本字段 `v`(EXT-REG-2),由 Registry 写入与读取,本模块的 codec 不读写它;chatlog payload 的非兼容变化只增加本模块的 `Current` 版本与一个新 codec。条目 Digest 的 domain 与 EventType 相同,覆盖 ID、TurnID(若有)、有序 parts 或 Content、ref identity、SourceDigest(若有),不覆盖 `v`。wire 与 digest 形状由 Session `ProtocolVersion` 决定: +**CHT-COD-3** EventType 为 `twilight/chatlog/`。条目 Digest 的 domain 与 EventType 相同,覆盖 ID、TurnID(若有)、有序 parts 或 Content、ref identity、SourceDigest(若有),不覆盖 `v`: ```text Digest("twilight/chatlog/input_submitted", ...) @@ -203,11 +202,11 @@ type EntryDigestPair struct { Kind EntryKind; ID string; Digest es.Digest } type CheckpointInvalidatedPayload struct { CheckpointID CheckpointID; Reason string } ``` -`assistant`、`tool_result`、`summary` 的 EventDefinition 注册 parts registry: +`assistant`、`tool_result`、`summary` 的 EventDefinition 声明 parts 提取: ```go extension.BindingReferenceDefinition{ - RegistryID: "twilight/chatlog/parts", + Extractor: chatlog.PartsExtractor, Cardinality: extension.Cardinality{Min: 0}, AllowedSchemes: nil, RequiredDurability: artifact.EventBound, @@ -231,7 +230,7 @@ twilight/chatlog/checkpoint_created twilight/chatlog/checkpoint_invalidated ``` -**CHT-EVT-2** `input_submitted` 创建 Input。Delivered、Withdrawn、Rejected 各终结一次。`input_delivered` 要求 Input 仍为 submitted,并写入非空 TurnID。同一 Start commit 中 `twilight/turn/started` 列出这些 InputIDs。AssistantID、ToolResultID、SummaryID 在 ancestry 内单次创建。 +**CHT-EVT-2** `input_submitted` 创建 Input。Delivered、Withdrawn、Rejected 各终结一次。`input_delivered` 要求 Input 仍为 submitted,并写入非空 TurnID;它与把该输入交给 Run 的事实同 commit:Start group 中与 `twilight/turn/started` 一起,回合中途与 `twilight/run/input_accepted` 一起(TRN-STR-2、TRN-DLV-2)。AssistantID、ToolResultID、SummaryID 在 stream 内单次创建。 **CHT-EVT-3** checkpoint Digest 的 domain 为 `twilight/chatlog/checkpoint_created`。`BaseContextDigest` 覆盖截至 `CoveredThrough` 的有序 active Context 序列 `(Kind, ID, Digest)`。`CoveredThrough` 早于该 checkpoint。`SummaryID` 落在 `CoveredThrough` 与 checkpoint 之间,且已由 `summary` 创建。该间隙内仅有这一条 summary。`Retained` 为 base 序列的有序子集。合法 checkpoint 下 Context 为 `[Summary] + Retained`,再 fold checkpoint 之后的 tail。checkpoint 在显式 invalidate,或 summary / Retained / base source 被 supersede 之后失效;projection 回退到更早合法 checkpoint,或从全量 events 重折。 @@ -252,7 +251,7 @@ type Surface struct { } ``` -**CHT-SUR-1** SurfaceFold 消费 chatlog decoded events。`EntryOrder` 为 resolved replay 顺序下的 delivered input、assistant、tool_result、summary,并带 Position。回合列表由 turn 投影提供,按 `TurnID` 连接。 +**CHT-SUR-1** SurfaceFold 消费 chatlog decoded events,`RequireComplete` 为 `chatlog`。`EntryOrder` 为 stream 顺序下的 delivered input、assistant、tool_result、summary,并带 Position。回合列表由 turn 投影提供,按 `TurnID` 连接。 ## 7. Context projection @@ -260,7 +259,7 @@ type Surface struct { func ContextFold(events []extension.DecodedEvent) ([]Entry, error) ``` -**CHT-CTX-1** 输入为已验证、按 ancestry 排序的 chatlog events。输出为 delivered input、assistant、tool_result、summary 经 supersession 与 checkpoint 处理后的有序 `[]Entry`。ContextFold 为纯函数。 +**CHT-CTX-1** 输入为已验证、按 stream 顺序的 chatlog events,`RequireComplete` 为 `chatlog`。输出为 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。 diff --git a/docs/design/agent-session-extension.md b/docs/design/agent-session-extension.md index a0c03ee..20661e3 100644 --- a/docs/design/agent-session-extension.md +++ b/docs/design/agent-session-extension.md @@ -1,6 +1,6 @@ # Twilight Agent Session Module Framework -状态:设计草案。无实现;Registry、SemanticAppender 与 projection 在 Memory reference implementation 通过 conformance 前不冻结。2026-09-04 第二次修订:v1 收缩为 first-party 固定注册表与单事务 append;Application module、通用 Catalog 与两阶段 journal 移入附录,不进入 v1 conformance。 +状态:设计草案。无实现;Registry、SemanticAppender、Lease 与 projection 在 Memory reference implementation 通过 conformance 前不冻结。v1 为 first-party 固定注册表与单事务 append;Application module、通用 Catalog 与两阶段 journal 在附录中,不进入 v1 conformance。 本文定义建立在 `agent/session` 与 `agent/artifact` 之上的 Session Module Framework。实现包路径为 `agent/session/extension`;文中的"必须""不得""应该"是协议约束;JSON canonicalization 与 digest 遵循 `agent/jsonstable`、`agent/es`。 @@ -14,9 +14,21 @@ agent/artifact ← Session Module Framework → agent/session Framework 负责 typed event codec 与 payload 版本、Binding declaration 与 admission、claim 与 commit 的同事务写入、pure projection。first-party Source 为 `twilight`,其 Module 为 `chatlog`、`turn` 与 `run`。 -**EXT-SCP-1** v1 只有一个写入路径:`SemanticAppender`。它有两个入口,`AppendSemantic`(CAS)与 `AppendSemanticIn`(临界区),两者执行同一套 codec、admission 与 claim 规则,都在 Session Store 的一个事务内完成。Run 的 `Runtime` 经 `AppendSemanticIn` 写入;Turn 的 Start、Retry、Settle 经任一入口写入。raw `session.Store` 只由 Appender、Application 的 trusted adapter 与测试持有。 +**EXT-SCP-1** v1 只有一个写入路径:`SemanticAppender`。它有两个入口,`AppendSemantic`(CAS)与 `AppendSemanticIn`(临界区),两者执行同一套 codec、admission 与 claim 规则,都在 Session Store 的一个事务内完成。Run 的 `Runtime` 经 `AppendSemanticIn` 写入;Turn 的 Start、Retry、Settle 经任一入口写入。`session.Store` 的 append port(`Commit`、`CommitIn`)只由 Appender 调用;模块读取投影经 `ProjectionReader`(第 6 节),控制面 KV 的临界区外操作经 `Lease`(第 7 节)。 -**EXT-SCP-2** v1 的模块集合在编译期固定为三个 first-party module。Application 自定义 Source 与 Module、通用 `Catalog` 构建校验、`RuntimeRegistry` 与自定义 `BindingExtractor` 见附录 B;远程或跨存储 adapter 的两阶段提交见附录 C。 +**EXT-SCP-2** 模块集合由组装代码在启动时传入 `BuildRegistry`,运行期不变;v1 的组装恰为三个 first-party module,本层不 import 任何模块包。Application 自定义 Source 与 Module、通用 `Catalog` 构建校验与 `RuntimeRegistry` 见附录 B;远程或跨存储 adapter 的两阶段提交见附录 C。 + +**EXT-SCP-3** 建立在 kernel 机制之上、供模块共用的类型化设施属于本层,不属于 kernel。v1 有三个:`SemanticAppender`(封装 `Store.CommitIn`)、artifact claim 的 KV 适配(封装控制面 KV)、`Lease`(封装控制面 KV 的条件写与 deadline 枚举,第 7 节)。kernel 对这些设施只提供事务、条件写与 deadline 枚举(SES-API-3),不持有其语义。run 是 `Lease` 的第一个消费者;`Lease` 的 API 不得出现 run 的概念。 + +**EXT-SCP-4** 模块间依赖单向、固定,以 `Requires` 声明并由 Registry 校验(EXT-REG-4)。v1 三个模块的声明: + +| 模块 | Requires | +|---|---| +| `chatlog` | 无。事件字段 `TurnID` 是 opaque 字符串,不需要 turn 的 codec | +| `run` | 无。Run fact 只记录内容 digest,内容由 `Companion` 写成其他模块的事件;`Companion` 是 Runtime 的构造参数,不是模块依赖 | +| `turn` | `run`(`twilight/run/created` v1、`twilight/run/input_accepted` v1、`twilight/run/ended` v1)、`chatlog`(存在即可) | + +本层不提供按模块启停的机制;v1 的组装总是传入全部三个模块。 ## 2. Registry 与版本 @@ -37,27 +49,41 @@ type EventDefinition struct { } type ModuleDescriptor struct { ID ModuleID + Requires []ModuleRequirement Events []EventDefinition Projections []ProjectionDefinition } +// ModuleRequirement 声明对另一模块的依赖:消费它的哪些事件、能处理哪些 payload 版本。 +type ModuleRequirement struct { + Module ModuleID + Events map[session.EventType][]PayloadVersion // 空表示只要求该模块已注册 +} type Registry struct { ProtocolVersion uint16 Profile session.ProtocolProfile // immutable indexes for modules, events and projections } -func FirstPartyRegistry(profile session.ProtocolProfile, parts PartsRegistry) (*Registry, error) +func BuildRegistry(profile session.ProtocolProfile, modules ...ModuleDescriptor) (*Registry, error) func (r *Registry) LookupEvent(session.EventType) (ModuleID, EventDefinition, bool) func (r *Registry) ModuleForEvent(session.EventType) (ModuleID, bool) func (r *Registry) LookupProjection(ProjectionID, ProjectionVersion) (ProjectionDefinition, bool) func (r *Registry) Decode(session.SessionEvent) (DecodedEvent, error) ``` -**EXT-REG-1** EventType 为 `twilight//`,例如 `twilight/chatlog/assistant`、`twilight/turn/started`、`twilight/run/model_step_prepared`。Digest domain 与 EventType 相同。一个 Registry 中 EventType、ProjectionID 均唯一;`FirstPartyRegistry` 构建后只读,`Profile.Version()` 必须等于 `ProtocolVersion`。 +**EXT-REG-1** EventType 为 `twilight//`,例如 `twilight/chatlog/assistant`、`twilight/turn/started`、`twilight/run/model_step_prepared`。Digest domain 与 EventType 相同。一个 Registry 中 ModuleID、EventType、ProjectionID 均唯一;`BuildRegistry` 校验每个 EventDefinition 的 Type 前缀等于其模块,构建后只读,`Profile.Version()` 必须等于 `ProtocolVersion`。 **EXT-REG-2** payload 版本与 kernel 版本分离(SES-VER-1)。每个 payload object 的第一层携带整数字段 `v`;`Encode` 写入 `Current`,`Decode` 读取 `v` 并选择 `Codecs[v]`。同一 EventType 的旧版本 codec 永久保留在 Registry 中,旧事件不迁移。一个模块的 payload 非兼容变化只增加该 EventType 的 `Current` 与一个新 codec,不影响其他模块,不触发 kernel 版本变化。 **EXT-REG-3** `Decode` 对未注册的 EventType,或已注册 EventType 的未注册 `v`,返回 `DecodedEvent{Unknown:true}` 并保留原始 payload;是否接受由 projection 的 `RequireComplete` 决定(EXT-PRJ-2)。Encode 对 `Current` 之外的版本拒绝。 +**EXT-REG-4** 模块间依赖由 `Requires` 声明,Registry 构建时校验,任一失败拒绝构建: + +1. `Requires` 指向的模块都已注册,依赖图无环; +2. 每个 projection 的 `Consumes` 与 `Ignores` 中的 EventType 属于本模块或 `Requires` 中的模块;`RequireComplete` 是本模块加 `Requires` 的子集; +3. `Requires.Events` 声明的每个 EventType,被依赖模块该类型的 `Current` 版本必须在声明的版本列表中。被依赖模块升版本而依赖方未声明能处理时,在启动期失败并指出是哪个依赖,而不是在 Apply 时失败。 + +`Requires` 只表达事件消费依赖。一个模块需要另一模块提供的接口实现(例如 run 的 `Companion` 由 turn 实现)是普通的构造参数,由组装代码注入、为 nil 时构造失败,不进入 `Requires`,Registry 不校验。 + ## 3. event codec ```go @@ -83,27 +109,28 @@ type DecodedEvent struct { ```go type Cardinality struct { Min uint32; Max *uint32 } +// BindingExtractor 由声明它的模块提供,从 decoded typed value 中返回全部 Artifact 引用。 +type BindingExtractor interface { + BindingIDs(value any) ([]artifact.BindingID, error) // appearance order +} type BindingReferenceDefinition struct { - JSONPointer string // 普通 JSON payload 路径;与 Parts 二选一 - Parts bool // 使用 PartsRegistry 提取(chatlog 的 assistant、tool_result、summary) + JSONPointer string // 普通 JSON payload 路径;与 Extractor 二选一 + Extractor BindingExtractor // typed value 上的模块自定义提取(chatlog 的 parts) Cardinality Cardinality AllowedSchemes []artifact.Scheme RequiredDurability artifact.Durability } -type PartsRegistry interface { - BindingIDs(value any) ([]artifact.BindingID, error) // appearance order -} ``` -**EXT-REF-1** declaration 恰选一种:非空 JSONPointer,或 `Parts=true`。JSONPointer 路径在 canonical JSON payload 上执行;Parts 路径把 decoded typed value 交给 `PartsRegistry`,它必须返回内部全部引用,不能回退为 JSONPointer 猜测。提取保留 appearance order,随后 group 才 sorted-unique。 +**EXT-REF-1** declaration 恰选一种:非空 JSONPointer,或非 nil `Extractor`。JSONPointer 路径在 canonical JSON payload 上执行;`Extractor` 接收 decoded typed value,必须返回内部全部引用,不能回退为 JSONPointer 猜测。提取保留 appearance order,随后 group 才 sorted-unique。Extractor 由模块随 EventDefinition 一起声明,本层不维护提取器注册表。 -**EXT-REF-2** Registry 构建时验证 cardinality、pointer grammar 与 scheme/durability declaration;所有 declaration 的最低 durability 至少为 `EventBound`。admission 解析每个 Binding,验证 Scheme、最低 durability、resolvability 与 host access policy;任何遗漏或违反均拒绝整个 group,不作任何写入。 +**EXT-REF-2** `BuildRegistry` 验证 cardinality、pointer grammar、Extractor 非 nil(若声明)与 scheme/durability declaration;所有 declaration 的最低 durability 至少为 `EventBound`。admission 解析每个 Binding,验证 Scheme、最低 durability、resolvability 与 host access policy;任何遗漏或违反均拒绝整个 group,不作任何写入。 ## 5. SemanticAppender ```go type TypedEvent struct { - EventID session.EventID; Type session.EventType + Type session.EventType RecordedAtUnixMilli int64; SourceEvents []session.EventID; Value any } type SemanticGroup struct { @@ -142,7 +169,9 @@ type SemanticAppender interface { } ``` -**EXT-APP-1** group 的 Events 是完整 group,不能为空。Appender 对每个 TypedEvent lookup EventDefinition、Validate、canonical Encode(加入 `v`)、decode round-trip 和 Binding extraction;任一失败不作写入。它将全部 occurrence 组成 sorted-unique union,并通过 `artifact.BindingSetBuilder.Build(ctx, union)` 构造完整 BindingSet,再按 declaration 执行 Scheme、最低 durability 与 host access policy。Appender 必须将 TypedEvent 的 `EventID`、`RecordedAtUnixMilli`、`SourceEvents` 与 canonical Payload、Type 逐字段映射为 `session.UncommittedEvent`,不得生成或替换其中任一值。 +**EXT-APP-1** group 的 Events 是完整 group,不能为空。Appender 对每个 TypedEvent lookup EventDefinition、Validate、canonical Encode(加入 `v`)、decode round-trip 和 Binding extraction;任一失败不作写入。它将全部 occurrence 组成 sorted-unique union,并通过 `artifact.BindingSetBuilder.Build(ctx, union)` 构造完整 BindingSet,再按 declaration 执行 Scheme、最低 durability 与 host access policy。Appender 必须将 TypedEvent 的 `RecordedAtUnixMilli`、`SourceEvents` 与 canonical Payload、Type 逐字段映射为 `session.UncommittedEvent`,不得替换其中任一值。 + +**EXT-APP-5** first-party 事件的 EventID 由 Appender 统一赋值:`EventID = Digest(EventType, CommitID, index)`,index 为该事件在 group 中的位置。TypedEvent 不携带 EventID。同一 CommitID 的重放得到同一组 EventID,因此 EventID 与 CommitID 一起构成幂等判定的一部分(SES-APP-1)。模块不得自行派生 EventID。 **EXT-APP-2** 对 nonempty BindingSet,以已验证 Header 唯一派生: @@ -196,22 +225,64 @@ type ProjectionRunRequest struct { } type ProjectionRunResult struct { State any; Applied uint64; Ignored uint64 } type ProjectionRunner interface { Run(ProjectionRunRequest) (ProjectionRunResult, error) } + +// ProjectionReader 是模块与 Coordinator 的读取入口:snapshot 加过滤 tail,返回投影状态与其覆盖到的 head。 +type ProjectionReader interface { + Load(ctx context.Context, sid session.SessionID, id ProjectionID, v ProjectionVersion) (state any, through session.Head, err error) +} ``` **EXT-PRJ-1** Initial、Apply、StateCodec 和 runner decode 都必须 pure。runner 只接受已验证的 complete commit sequence;一个 commit 内任一 event 失败,不得发布该 commit 的 partial state。 **EXT-PRJ-2** `Consumes` 表示必须 decode/handle 的 EventType,`Ignores` 是显式已知跳过,二者不得重叠。出现属于 `RequireComplete` module 的 Unknown event 必须失败;其他 module 的 event 可忽略。读取时以 `Consumes` 与 `RequireComplete` module 的前缀作为 `Types` 过滤(SES-REP-2、SessionTx.Tail),使读取代价与 projection 消费的事件数成正比。 -**EXT-PRJ-3** snapshot 使用 Session snapshot envelope。只有 ProjectionID、ProjectionVersion、StateCodec canonical validation 和 `Through` 前缀校验都通过时可复用;否则从 log 重建。写入策略由 projection 自定,可以在 `SemanticTx.SaveSnapshot` 中与 commit 同事务写入,也可以异步写入。 +**EXT-PRJ-3** snapshot 使用 Session snapshot envelope,`ProjectionKey` 等于 `ProjectionID`,`ProjectionVersion` 同名。只有 ProjectionID、ProjectionVersion、StateCodec canonical validation 和 `Through` 前缀校验都通过时可复用;否则从 log 重建。写入策略由 projection 自定,可以在 `SemanticTx.SaveSnapshot` 中与 commit 同事务写入,也可以异步写入。 + +**EXT-PRJ-4** `ProjectionReader.Load` 是临界区之外读取投影的唯一入口:读 snapshot、按 EXT-PRJ-3 校验、以过滤 replay 读取其后的 tail、fold,返回状态与 `through`。同一 `ProjectionRunner` 在 `SemanticTx` 内以 `LoadSnapshot` 加 `Tail` 得到相同结果。 + +## 7. Lease:占用与续期 + +```go +type LeaseToken string +type Lease struct { + Namespace session.ControlNamespace + Key string + Holder string // 模块提供的持有者标识;同一 Holder 的重复 Acquire 幂等 + Token LeaseToken // 本层派生的凭证;Release 与 Renew 必须携带 + DeadlineUnixMilli int64 // 0 表示不超时 + Attrs jsonstable.Value // 模块自用,本层不解释 +} +type AcquireLeaseRequest struct { + Namespace session.ControlNamespace; Key string + Holder string; TTL time.Duration; Attrs jsonstable.Value +} + +// SemanticTx 内,与本次 commit 同事务 +func AcquireLease(tx SemanticTx, commitID session.CommitID, now int64, req AcquireLeaseRequest) (Lease, error) +func ReleaseLease(tx SemanticTx, ns session.ControlNamespace, key string, token LeaseToken) error +func LookupLease(tx SemanticTx, ns session.ControlNamespace, key string) (Lease, bool, error) + +// 临界区之外 +type Leases struct { Store session.Store } +func (Leases) Lookup(ctx, sid session.SessionID, ns session.ControlNamespace, key string) (Lease, bool, error) +func (Leases) Renew(ctx, sid session.SessionID, ns session.ControlNamespace, key string, token LeaseToken, ttl time.Duration, now int64) error +func (Leases) Expired(ctx, ns session.ControlNamespace, now int64, fn func(session.SessionID, Lease) (bool, error)) error +``` + +**EXT-LSE-1** 值编码为 canonical JSON `{holder, token, attrs}`,deadline 使用 KV 条目的 deadline 字段(now 加 TTL;TTL 为零时 deadline 为 0)。`Token = Digest("twilight/session-extension/lease", SessionID, Namespace, Key, Holder, commitID)`,由 Acquire 所在 commit 的 CommitID 派生,因此 Acquire 是纯函数,同一 commit 的重放得到同一 Token。 + +**EXT-LSE-2** `AcquireLease`:条目不存在时写入并返回新 Lease;条目存在且 Holder 相同时返回既有 Lease(幂等,不改 deadline);Holder 不同时返回 `ErrConflict`,无论既有条目是否已过 deadline。本层不自动回收过期条目;过期的处置由消费模块经 `Expired` 枚举后在自己的 commit 中完成,通常以 `ReleaseLease` 结束。`ReleaseLease`:条目不存在或 Token 不匹配返回 `ErrStale`,否则删除。 + +**EXT-LSE-3** `Renew` 在临界区之外执行:`ControlGet` 读到条目并核对 Token 后,以读到的值为 `expected` 调用 `ControlCompareAndPut`,只改 deadline、不改值;条目不存在、Token 不匹配、或条件写返回 false(条目已被 Release 或改写)时返回 `ErrStale`。条件写保证续期不会把已删除的条目写回。TTL 为零时 `Renew` 只校验 Token,不改变 deadline。`Expired` 以 `ControlExpired` 枚举 deadline 已过的条目并解码为 Lease;fn 返回 false 停止。 -## 7. errors 与 conformance +## 8. errors 与 conformance ```go type ErrorCode string const ( ErrInvalid ErrorCode = "invalid"; ErrUnknownEvent ErrorCode = "unknown_event" ErrCodec ErrorCode = "codec"; ErrBinding ErrorCode = "binding" - ErrConflict ErrorCode = "conflict" + ErrConflict ErrorCode = "conflict"; ErrStale ErrorCode = "stale" ErrUnsupportedProfile ErrorCode = "unsupported_profile" ) type Error struct { Code ErrorCode; Type session.EventType; Detail string } @@ -220,15 +291,16 @@ func (Error) Error() string v1 conformance 必须验证: -- **EXT-REG-1、EXT-REG-2、EXT-REG-3**:immutable Registry、Profile/ProtocolVersion binding、`v` 字段的写入与选择、多版本 codec 共存、Unknown 事件保留 raw payload; +- **EXT-REG-1、EXT-REG-2、EXT-REG-3、EXT-REG-4**:immutable Registry、Profile/ProtocolVersion binding、`v` 字段的写入与选择、多版本 codec 共存、Unknown 事件保留 raw payload、`Requires` 缺失或成环被拒绝、projection 消费未声明模块的事件被拒绝、被依赖事件版本不在声明范围被拒绝; - **EXT-COD-1、EXT-COD-2**:wire-first、安全 codec、canonical round-trip、`v` 保留字段; -- **EXT-REF-1、EXT-REF-2**:pointer 与 Parts 全量提取、cardinality、scheme/durability admission、拒绝时无写入; -- **EXT-APP-1 至 EXT-APP-4**:TypedEvent→UncommittedEvent 全字段映射、由 Header ProtocolVersion 与 RefSetDigest 派生的 stable ClaimID、claim 与 commit 同事务(崩溃点注入后两者同时存在或同时缺失)、AlreadyApplied 的 claim 幂等、两个入口产生等价 commit、Noop 不追加; -- **EXT-PRJ-1、EXT-PRJ-2、EXT-PRJ-3**:pure fold、commit boundary、Consumes/Ignores/RequireComplete、Types 过滤读取与全量读取等价、snapshot equivalence。 +- **EXT-REF-1、EXT-REF-2**:pointer 与 Extractor 全量提取、cardinality、scheme/durability admission、拒绝时无写入; +- **EXT-APP-1 至 EXT-APP-5**:TypedEvent→UncommittedEvent 全字段映射、EventID 由 CommitID 与 index 统一派生、由 Header ProtocolVersion 与 RefSetDigest 派生的 stable ClaimID、claim 与 commit 同事务(崩溃点注入后两者同时存在或同时缺失)、AlreadyApplied 的 claim 幂等、两个入口产生等价 commit、Noop 不追加; +- **EXT-LSE-1、EXT-LSE-2、EXT-LSE-3**:Token 确定派生、同 Holder 幂等、异 Holder conflict、Acquire 与 commit 同事务、Release 的 Token 校验、Renew 与 Release 并发时不复活已删除条目、TTL 为零的 Renew 不改 deadline、Expired 只返回已过 deadline 的条目; +- **EXT-PRJ-1 至 EXT-PRJ-4**:pure fold、commit boundary、Consumes/Ignores/RequireComplete、Types 过滤读取与全量读取等价、snapshot equivalence、`ProjectionReader.Load` 与临界区内读取结果一致。 ## 附录 B:Application module 与通用 Catalog(不进入 v1) -Application 注册自己的 `SourceID`(例如 `acme`)与 Module 时,EventType 为 `//`;`BuildCatalog(CatalogBuildRequest)` 在启动时把多个 Source 的 ModuleDescriptor、RuntimeRegistry 与 artifact SchemeDefinition 组合为只读索引,拒绝 duplicate owner、namespace mismatch、registry requirement mismatch、非法 Binding declaration 与低于 `EventBound` 的 declaration。`RuntimeRegistry` 以 `CodecRegistryDescriptor{ID, WireManifest, WireProfile}` 描述自定义 codec 与 `BindingExtractor`,Catalog 逐字段匹配 requirement 与 descriptor。v1 的 `PartsRegistry` 是这一机制的唯一实例,直接作为 `FirstPartyRegistry` 的参数注入。 +Application 注册自己的 `SourceID`(例如 `acme`)与 Module 时,EventType 为 `//`;`BuildCatalog(CatalogBuildRequest)` 在启动时把多个 Source 的 ModuleDescriptor、RuntimeRegistry 与 artifact SchemeDefinition 组合为只读索引,拒绝 duplicate owner、namespace mismatch、registry requirement mismatch、非法 Binding declaration 与低于 `EventBound` 的 declaration。`RuntimeRegistry` 以 `CodecRegistryDescriptor{ID, WireManifest, WireProfile}` 描述由配置装载的自定义 codec 与提取器,Catalog 逐字段匹配 requirement 与 descriptor。v1 的模块以 Go 值直接传入 `BuildRegistry`,提取器随 EventDefinition 声明,不需要这一层间接。 ## 附录 C:两阶段 semantic append(不进入 v1) diff --git a/docs/design/agent-session.md b/docs/design/agent-session.md index 158f8b4..e72637c 100644 --- a/docs/design/agent-session.md +++ b/docs/design/agent-session.md @@ -1,6 +1,6 @@ # Twilight Agent Session Protocol -状态:设计草案。无实现;wire、digest preimage 与 conformance 在 Memory reference implementation 与 Input → Turn → Run → Session 纵向切片跑通前不冻结。2026-09-04 第二次修订:v1 范围收缩为单 stream kernel;Fork、ancestry、canonical import 移入附录 A,不进入 v1 conformance。 +状态:设计草案。无实现;wire、digest preimage 与 conformance 在 Memory reference implementation 与 Input → Turn → Run → Session 纵向切片跑通前不冻结。v1 为单 stream kernel;Fork、ancestry、canonical import 在附录 A 中,不进入 v1 conformance。 本文定义 Twilight Session 的 Event Sourcing kernel。文中的"必须""不得""应该"是协议约束。 @@ -23,7 +23,7 @@ Session kernel envelope、顺序、commit、临界区、snapshot、控制面 K Session modules event ontology、typed codec、payload 版本、payload validation、projection ``` -Payload 对 Session kernel 是 opaque canonical JSON。snapshot 与控制面 KV 都不是语义事实:snapshot 是可重建的派生数据;控制面 KV 保存 lease、claim 等运行控制信息,由模块解释,kernel 只保证它与同一 commit 原子写入。 +Payload 对 Session kernel 是 opaque canonical JSON。snapshot 与控制面 KV 都不是语义事实:snapshot 是可重建的派生数据;控制面 KV 保存执行占用、保留声明等运行控制信息,由模块解释,kernel 只保证它与同一 commit 原子写入、支持条件写与按 deadline 枚举。 **SES-SCP-1** kernel 不依赖领域 payload,也不对其执行 schema validation 或解释。replay 的唯一顺序是 `(revision, index)`;时钟只作 metadata。 @@ -89,7 +89,7 @@ type Head struct { Revision es.Revision; Digest es.Digest } type EventPosition struct { Revision es.Revision; Index uint16; EventDigest es.Digest } ``` -**SES-WIR-1** identity 非空且稳定;EventID 在同一 stream 内唯一。header 不可变,revision 从 1 连续递增,commit 至少有一个 event,event Index 从 0 连续递增。revision 1 的 `PreviousDigest=HeaderDigest`,其后为前一 CommitDigest。空 stream head 是 `{0, HeaderDigest}`。 +**SES-WIR-1** identity 非空且稳定;EventID 在同一 stream 内唯一。header 不可变,revision 从 1 连续递增,commit 至少有一个 event,event Index 从 0 连续递增。revision 1 的 `PreviousDigest=HeaderDigest`,其后为前一 CommitDigest。空 stream head 是 `{0, HeaderDigest}`。`Create` 对逐字段相同的 `CreateRequest` 幂等返回既有 header,同 SessionID 的不同请求为 `Conflict`。commit 与 header 的 `CausationID`、`CorrelationID` 是 producer 自行填写的 opaque metadata,kernel 不解释;它们进入 digest 与 append fingerprint。 **SES-WIR-2** `ProtocolProfile` 冻结 kernel wire:envelope、commit、snapshot envelope、null/omission、精确 integer encoding、unknown-field policy、array order 与下列 digest preimage;所有 digest 依 `agent/es` 的 versioned domain separator。 @@ -144,8 +144,8 @@ type SessionTx interface { Tail(after Head, types []EventType) ([]SessionCommit, error) LoadSnapshot(ProjectionKey, ProjectionVersion uint16) (SnapshotResult, error) SaveSnapshot(Snapshot) error - ControlGet(ControlNamespace, key string) ([]byte, bool, error) - ControlPut(ControlNamespace, key string, value []byte) error + ControlGet(ControlNamespace, key string) (ControlEntry, bool, error) + ControlPut(ControlNamespace, key string, value []byte, deadlineUnixMilli int64) error ControlDelete(ControlNamespace, key string) error } type CommitInFn func(SessionTx) (*AppendRequest, error) @@ -164,7 +164,11 @@ type SnapshotResult struct { Snapshot *Snapshot; Found bool } type SaveSnapshotRequest struct { Snapshot Snapshot } type SaveSnapshotResult struct { Snapshot Snapshot; Replaced bool } -type ControlEntry struct { SessionID SessionID; Namespace ControlNamespace; Key string; Value []byte } +type ControlEntry struct { + SessionID SessionID; Namespace ControlNamespace; Key string + Value []byte + DeadlineUnixMilli int64 // 0 表示无 deadline;kernel 只用于枚举,不据此删除 +} type Store interface { Create(context.Context, CreateRequest) (SessionHeader, error) @@ -177,10 +181,13 @@ type Store interface { LoadSnapshot(context.Context, SnapshotRequest) (SnapshotResult, error) SaveSnapshot(context.Context, SaveSnapshotRequest) (SaveSnapshotResult, error) // 控制面 KV,临界区之外的读写;与 SessionTx 内的同名方法作用于同一存储。 - ControlGet(context.Context, SessionID, ControlNamespace, key string) ([]byte, bool, error) - ControlPut(context.Context, SessionID, ControlNamespace, key string, value []byte) error + ControlGet(context.Context, SessionID, ControlNamespace, key string) (ControlEntry, bool, error) + ControlPut(context.Context, SessionID, ControlNamespace, key string, value []byte, deadlineUnixMilli int64) error + // 条件写:仅当条目存在且当前 Value 逐字节等于 expected 时写入;返回是否写入。 + ControlCompareAndPut(context.Context, SessionID, ControlNamespace, key string, expected, value []byte, deadlineUnixMilli int64) (bool, error) ControlDelete(context.Context, SessionID, ControlNamespace, key string) error - ControlScan(context.Context, ControlNamespace, keyPrefix string, fn func(ControlEntry) (bool, error)) error // 跨全部 Session + ControlScan(context.Context, ControlNamespace, keyPrefix string, fn func(ControlEntry) (bool, error)) error // 跨全部 Session,按前缀 + ControlExpired(context.Context, ControlNamespace, beforeUnixMilli int64, fn func(ControlEntry) (bool, error)) error // 跨全部 Session,deadline 非 0 且早于 before } ``` @@ -200,9 +207,9 @@ func (Error) Error() string **SES-API-2** `CommitIn` 是同一 Session 的 append 临界区:Store 在 per-Session 锁或数据库事务内向 fn 提供 `SessionTx`,fn 在其中读 head、按 CommitID 查 commit、读 snapshot 与 tail、读写控制面 KV、决定要追加的事件;返回的 `AppendRequest` 由 Store 在同一事务内按第 5 节规则追加。fn 返回 nil 表示不追加,此时 fn 已执行的 snapshot 与 KV 写入仍提交。section 内 head 不移动,`ExpectedHead` 与 head 不一致只可能是 fn 的实现错误,返回 `Invalid`。fn 必须纯且除 SessionTx 外无副作用;Store 不保证 fn 只被调用一次。 -`CommitIn` 与 `Commit` 产生的 commit 逐字段等价,同一 CommitID 的幂等与 conflict 判定相同。两者都是 adapter 必须实现的 port:`Commit`(CAS)一次请求完成比对与写入,适用于 Store 实现为远程客户端、不能在持锁期间回调调用方的部署;`CommitIn` 适用于需要在写入前基于当前状态做决定的模块(例如 Run 的 Decide),以及需要把 claim、lease 与 commit 放在同一事务的 producer。进程内 adapter 可以用 `CommitIn` 加 head 比对函数实现 `Commit`,但 `Commit` 不因此从合同中移除。 +`CommitIn` 与 `Commit` 产生的 commit 逐字段等价,同一 CommitID 的幂等与 conflict 判定相同。两者都是 adapter 必须实现的 port:`Commit`(CAS)一次请求完成比对与写入,适用于 Store 实现为远程客户端、不能在持锁期间回调调用方的部署;`CommitIn` 适用于需要在写入前基于当前状态做决定的模块(例如 Run 的 Decide),以及需要把控制面 KV 写入与 commit 放在同一事务的 producer。进程内 adapter 可以用 `CommitIn` 加 head 比对函数实现 `Commit`,但 `Commit` 不因此从合同中移除。 -**SES-API-3** 控制面 KV 以 `(SessionID, Namespace, Key)` 寻址,值为 opaque bytes,kernel 不解释。它用于 lease、grant、claim 等需要与 commit 原子写入、但不属于语义事实的信息。`ControlScan` 按 namespace 与 key 前缀跨 Session 枚举,供恢复扫描使用;fn 返回 false 停止。Namespace 由模块以 `//` 命名。KV 不进入 digest chain,可以独立于 stream 重建或清理;模块必须保证 KV 缺失只导致恢复延迟,不导致语义错误。 +**SES-API-3** 控制面 KV 以 `(SessionID, Namespace, Key)` 寻址,值为 opaque bytes 加一个可选 deadline,kernel 不解释值,也不因 deadline 到期而删除或修改条目。它用于需要与 commit 原子写入、但不属于语义事实的运行控制信息:执行占用、保留声明等。kernel 提供且只提供三种模块自己无法提供的保证:`SessionTx` 内的写入与该 commit 同事务;`ControlCompareAndPut` 是临界区之外的原子条件写,条目缺失或当前值不等于 `expected` 时不写入并返回 false;`ControlScan` 与 `ControlExpired` 跨 Session 枚举,前者按 key 前缀,后者按 deadline 早于给定时刻,fn 返回 false 停止。占用、凭证、续期、过期后的处置等语义由使用它的模块或 Module Framework 定义(EXT-SCP-3),不进入 kernel。Namespace 由模块以 `//` 命名。KV 与 stream 在同一存储与事务域,不会单独丢失;它不进入 digest chain,因此不能通过 chain 校验,模块不得把语义事实放入 KV。 ## 5. append 与 idempotency @@ -255,7 +262,7 @@ v1 conformance 必须验证: - **SES-SCP-2**:附录 A 的能力返回 `ErrUnsupported`,`ParentFork` 非 nil 的 header 被拒绝; - **SES-VER-1、SES-VER-2**:kernel 不读取 payload `v`;不同 `v` 的 event 在同一 stream 共存; - **SES-WIR-1、SES-WIR-2、SES-WIR-3**:wire/profile freeze、版本一致性、所有 Encode/Decode 与 `ValidateCanonical*` round-trip、digest preimage、same-stream SourceEvents、EventID uniqueness、complete commit; -- **SES-API-1、SES-API-2、SES-API-3、SES-APP-1、SES-APP-2**:atomic CAS、`CommitIn` 与 `Commit` 的 commit 等价、fn 返回 nil 时 KV 与 snapshot 仍提交、concurrent writer、CommitID exact idempotency(含不同时间戳的重试得到 AlreadyApplied)、failure classification、KV 与 commit 同事务、ControlScan 前缀与提前停止; +- **SES-API-1、SES-API-2、SES-API-3、SES-APP-1、SES-APP-2**:atomic CAS、`CommitIn` 与 `Commit` 的 commit 等价、fn 返回 nil 时 KV 与 snapshot 仍提交、concurrent writer、CommitID exact idempotency(含不同时间戳的重试得到 AlreadyApplied)、failure classification、KV 与 commit 同事务、`ControlCompareAndPut` 在条目被并发删除或改写后返回 false、`ControlScan` 前缀与提前停止、`ControlExpired` 只返回 deadline 非 0 且已过的条目; - **SES-REP-1、SES-REP-2、SES-REP-3**:顺序、完整 EventPosition After 语义、Types 过滤只返回匹配 commit、cursor/token binding、tamper/gap failure; - **SES-SNP-1、SES-SNP-2**:Through 前缀校验、snapshot 加 tail 与全量 fold 等价、过期 snapshot 被忽略。 diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index 99db1a5..00a9de2 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -1,6 +1,6 @@ # Twilight Agent Turn 协议 -状态:设计草案。2026-09-04 按单一 Session ES 修订(同日第二次修订:companion 经 Module Framework admission、Owner 取代 Run 内的 TurnID、attempt 内容策略移交 Planner)。Run 事实与 Turn、Chatlog 事件同在一条 Session stream,本文不再定义 Run→Session materialization。 +状态:设计草案。现有 `agent/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)。 @@ -15,10 +15,10 @@ Run 完成一个 Turn 的一次 attempt。同一 Turn 至多一个非终态 R |---|---|---| | 回合存在、attempt 归属与结束 | `twilight/turn/` events | Coordinator | | Run 执行状态 | `twilight/run/` events([agent-run.md](agent-run.md)) | `run.Runtime`,由 Loop 与 Coordinator 驱动 | -| 对话内容 | `twilight/chatlog/` events | Start 时 delivered input;Run commit 内的 companion events | +| 对话内容 | `twilight/chatlog/` events | Start 与 Deliver 时 delivered input;Run commit 内的 companion events | | Application policy | Application | binding、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` 填充。 +**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: @@ -95,11 +95,11 @@ twilight/turn/superseded unsettled Turn 是尚未 completed、failed 或 superseded 的 `started`。 -**TRN-EVT-2** Start 的 EventID 与 CommitID 由 StartOperationDigest、event kind、group ordinal 派生。相同 canonical payload 幂等;差异为 conflict。事件时间戳不参与幂等判定(SES-APP-1)。 +**TRN-EVT-2** 本模块产生的事件(含 companion 与 Attach 产生的)的 EventID 由 Appender 按 `Digest(EventType, CommitID, index)` 统一赋值(EXT-APP-5)。Start 的 CommitID 由 StartOperationDigest 派生;Retry、Settle、Stop 的 CommitID 见各自条目。相同 canonical payload 幂等;差异为 conflict。事件时间戳不参与幂等判定(SES-APP-1)。 **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|ended`: +**TRN-PRJ-1** ProjectionID 为 `twilight/turn/surface`。消费 `twilight/turn/started|completed|failed|superseded` 与 `twilight/run/created|input_accepted|ended`,`RequireComplete` 为 `turn` 与 `run`(EXT-PRJ-2): ```go type TurnStatus string @@ -119,7 +119,7 @@ type AttemptView struct { type TurnView struct { TurnID TurnID Status TurnStatus - InputIDs []chatlog.InputID + InputIDs []chatlog.InputID // started 的初始输入,加此后经 Deliver 进入任一 attempt 的输入,按 accepted 顺序去重 ExecutionBinding ExecutionBindingRef Attempts []AttemptView // 按 Attempt 递增 ActiveRun run.RunID // Status=active 时非空 @@ -137,7 +137,7 @@ UI 按 `TurnID` 连接 `twilight/chatlog/surface` 的条目,按 `RunID` 连接 ```go type Coordinator struct { - Sessions session.Store + Projections extension.ProjectionReader Appender extension.SemanticAppender Runtime run.Runtime Bindings ExecutionBindingRegistry @@ -148,6 +148,7 @@ type ExecutionBindingRegistry interface { Resolve(ExecutionBindingRef) (RunDrive type Service interface { Start(context.Context, StartRequest) (TurnResponse, error) + Deliver(context.Context, DeliverRequest) (TurnResponse, error) Resume(context.Context, TurnRequest) (TurnResponse, error) Retry(context.Context, RetryRequest) (TurnResponse, error) Stop(context.Context, StopRequest) (TurnResponse, error) @@ -155,11 +156,11 @@ type Service interface { } type StartRequest struct { Ref TurnRef - InputIDs []chatlog.InputID - InitialInputs []run.AgentInput + Inputs []run.AgentInput // ID 为已 submitted 的 InputID,Payload 等于其 Content ExecutionBinding ExecutionBindingRef Companion CompanionVersion } +type DeliverRequest struct { Ref TurnRef; Inputs []run.AgentInput } // 回合中途追加输入 type TurnRequest struct { Ref TurnRef } type RetryRequest struct { Ref TurnRef; Reason string } type StopRequest struct { Ref TurnRef; Reason string } @@ -170,7 +171,7 @@ type TurnResponse struct { Attempt uint32 Status TurnStatus Disposition ResumeDisposition - Result *run.RunResult + End *run.RunEnd // 该 attempt 已终结时非空,来自 twilight/run/ended Waiting []run.ResponseRequest } type ResumeDisposition string @@ -181,7 +182,7 @@ const ( ) ``` -**TRN-API-1** Coordinator 从两个投影接续;每个方法先读投影再决定动作。 +**TRN-API-1** Coordinator 经 `ProjectionReader` 读取 `twilight/turn/surface` 与 `twilight/run/machine` 两个投影接续;每个方法先读投影再决定动作。Coordinator 不持有 `session.Store`。 **TRN-API-2** Registry 用同一 `run.Runtime` 组装 driver。Run 的写入只经 `run.Runtime`。 @@ -194,9 +195,9 @@ const ( **TRN-STR-1** StartRequest: 1. Ref、binding ref、companion version 非空; -2. InputIDs 与 InitialInputs 等长、无重复、顺序相同; -3. `InitialInputs[i].ID = InputIDs[i]`,Payload 等于对应 `twilight/chatlog/input_submitted` 的 Content; -4. Input 为 submitted,可 delivered。 +2. `Inputs` 无重复 ID;每个 ID 对应 chatlog 中状态为 submitted 的 Input,Payload 等于其 Content(Coordinator 经 chatlog surface 投影核对)。 + +`started.InputIDs` 与 `input_delivered`、`input_accepted` 的顺序都取 `Inputs` 的顺序。 **TRN-STR-2** Start 是一次原子 commit,顺序为: @@ -217,21 +218,27 @@ InputIDs 为空时 group 为 `started` 加 `created`。`created` 与 `input_acce **TRN-STR-4** append 成功后进入 Drive。 -**TRN-RTY-1** Retry 要求投影中该 Turn 为 `attempt_failed`。commit 为 `twilight/run/created{Attempt: n+1}` 加同一组 `input_accepted`;payload 与首个 attempt 相同,仅 RunID 与 Attempt 不同。Turn 为其他状态时 Retry 返回 conflict。 +**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. Drive、Resume 与 Stop +## 5. Deliver、Drive、Resume 与 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}`。Run 接受输入与 chatlog 把输入挂到 Turn 在同一 commit 可见。`AcceptInput` 在 Run 的任意非终态都被接受(RUN-MCH-4),Deliver 不关心 Run 当前处于哪一步。CommandID 为 Run 的 input CommandID,重放幂等;多条输入中途失败时,以剩余条目重试。 + +**TRN-DLV-3** Deliver 不取消正在进行的模型调用或工具调用;要打断用 Stop。提交后,若本进程没有在驱动该 Run,Deliver 进入 Drive;已在驱动时不动,运行中的 Loop 在下一次 Load 看到 `PendingInputs`。Deliver 与该 Run 的最后一步 `SubmitModelResult` 并发时由 Session 临界区定序:输入先提交,Run 回到 `Open` 继续;结果先提交,Run 已终结,Deliver 得到 `ErrRunTerminal` 并返回 `completed`,该输入未被 delivered。 -**TRN-DRV-1** Drive 解析 binding 得到 driver,调用 `driver.Drive(ctx, {Ref, RunID})`。driver 内部为 `loop.Run(ctx, runtime, SessionID, RunID, sink)`。Drive 返回后读投影设置 `Disposition`:Run 终态为 `ResumeFinished`;`NeedsRecovery` 为 true 为 `ResumeWaitingForRecovery`;仅有 WaitingCalls 为 `ResumeWaitingForResponse`。 +**TRN-DRV-1** Drive 解析 binding 得到 driver,调用 `driver.Drive(ctx, {Ref, RunID})`。driver 内部为 `loop.Run(ctx, runtime, SessionID, RunID, sink)`。Drive 返回后读投影设置 `Disposition` 与 `End`:Run 终态为 `ResumeFinished`,`End` 取 surface 中该 attempt 的 `AttemptView.End`;`NeedsRecovery` 为 true 为 `ResumeWaitingForRecovery`;仅有 WaitingCalls 为 `ResumeWaitingForResponse`。 **TRN-DRV-2** EventSink 的 `text_delta` / `reasoning_delta` 为临时观察。Waiting 由 Application 提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 后再次 Resume。 **TRN-RSM-1** Resume 要求投影中该 Turn 为 `active`,取 `ActiveRun` 进入 Drive。`attempt_failed` 时返回该状态,由 Application 选择 Retry 或 Settle。 -**TRN-STP-1** Stop 要求 Turn 为 `active`。Coordinator 提交 `CancelRun{Reason:ReasonCancelled}`,并在 `CommitRequest.Attach` 中附加 `twilight/turn/failed{Settlement:stopped, FailureClass:"cancelled"}`;两者在同一 commit 可见。结算 Turn 是 Turn 层的决定,由发起 Stop 的 Coordinator 声明,Run 事实与 companion 不推断它。Application 直接提交的 `CancelRun` 不附加结算事件,Turn 进入 `attempt_failed`。 +**TRN-STP-1** Stop 要求 Turn 为 `active`。Coordinator 提交 `CancelRun{Reason:ReasonCancelled}`,并在 `CommitRequest.Attach` 中附加 `twilight/turn/failed{Settlement:stopped, FailureClass:"cancelled"}`;两者在同一 commit 可见。结算 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 供审计。 @@ -241,7 +248,7 @@ InputIDs 为空时 group 为 `started` 加 `created`。`created` 与 `input_acce Run 事实只保存执行状态与内容 digest(RUN-WIR-4)。模型文本、工具调用与工具输出以 chatlog 事件形式与产生它们的 Run 事实写在同一 SessionCommit。`run.Runtime.Commit` 在 Decide 之后、写入之前调用注入的 `run.Companion`,把本 commit 的 facts 与 command 携带的 transient 内容映射为 `run.ModuleEvent`,追加在 Run facts 之后;随后整个 group 经 `SemanticAppender` 的 codec、Binding admission 与 claim 写入(EXT-APP-3)。接口定义在 `agent/run`(第 5 节);本模块提供实现 `CompanionV1`,它把 `CompanionRequest.Owner` 解释为 TurnID。 -**TRN-CMP-1** `Map` 为确定性纯函数,不做 IO;时间取 `CompanionRequest.RecordedAtUnixMilli`。输出事件的 EventID 由 `(TurnID, RunID, fact identity, CompanionVersion)` 派生(TRN-MAP-2),同一 commit 重放得到同一 group。companion 事件可以携带 `ReferencePart`;其 Binding 由 Appender 在同一事务 admission 并建立 claim,Runtime 不另行处理。 +**TRN-CMP-1** `Map` 为确定性纯函数,不做 IO;时间取 `CompanionRequest.RecordedAtUnixMilli`。输出事件不携带 EventID,由 Appender 按所在 commit 的 CommitID 与位置赋值(EXT-APP-5);条目自身的 identity(AssistantID、ToolResultID)按 TRN-MAP-2 派生。同一 commit 重放得到同一 group。companion 事件可以携带 `ReferencePart`;其 Binding 由 Appender 在同一事务 admission 并建立 claim,Runtime 不另行处理。 **TRN-CMP-2** v1 映射: @@ -253,7 +260,7 @@ Run 事实只保存执行状态与内容 digest(RUN-WIR-4)。模型文本、 | `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)。 +其余 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 复用。 @@ -275,6 +282,7 @@ Run 事实只保存执行状态与内容 digest(RUN-WIR-4)。模型文本、 | 工具效果未知 | lease 过期后 `RecoverExpired` 提交该 call 的 Unknown,companion 写 status=`unknown`;Run 保持 Active | | Run 已 `failed`、Turn 未结算 | Turn 为 `attempt_failed`;Application 选择 Retry 或 Settle | | Stop commit 响应丢失 | 以同一 Cancel CommandID 重放 | +| Deliver 中某条输入的 commit 响应丢失 | 以同一 input CommandID 重放,得到 already-applied 后继续剩余条目 | | Start 或 Retry 响应丢失 | 以同一 CommitID 重放,得到 already-applied | | binding 缺失 | 返回 `binding_unavailable`;Turn 状态不变 | @@ -284,8 +292,9 @@ Run 事实只保存执行状态与内容 digest(RUN-WIR-4)。模型文本、 - **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`; -- **TRN-STR-1 至 TRN-RTY-3**:Start group 顺序与原子性、Retry 前置条件、Attempt 递增、幂等 CommitID、失败 attempt 内容保留在 stream; +- **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-DRV-1 至 TRN-STL-1**:Drive disposition、Stop 以 Attach 单 commit 结算、Application 的 Cancel 进入 `attempt_failed`、Settle 前置条件; - **TRN-CMP-1 至 TRN-MAP-4**:companion 纯函数、v1 映射表、`SourceDigest` 等于 Run fact 记录值、companion 中的 ReferencePart 经 admission 并建立 claim、同 commit 可见性; - **TRN-REC-1 至 TRN-REC-3**:上表恢复情形、崩溃后 Resume、无跨存储对账。 From f9f42b862eb0b0532837fe759c85793335e3321c Mon Sep 17 00:00:00 2001 From: Fodesu Date: Mon, 7 Sep 2026 20:07:21 +0800 Subject: [PATCH 64/81] Run: digest-only facts, Owner/Attempt, mid-run input queue (step 1) Reshape agent/run per agent-run.md so facts carry execution state and content digests only (RUN-WIR-4): - ModelStepPrepared keeps RequestDigest; the request body lives in a FrozenValueStore (Runtime.FrozenRequest, MemoryFrozenValues). - ModelStepCompleted/ToolCallCompleted/ToolCallAnswered carry digests; MachineState drops LastModelResult, RunResult drops Model, ToolSpec drops Definition and gains Name. - RunCreated fact and BuildCreateGroup; MachineState gains Owner/Attempt. - AcceptInput legal in any non-terminal state; WithdrawPreparedStep and Next -> WithdrawPrepared; no-call result with pending inputs reopens. - PlanningHint carries boundary facts only (Owner, RunID, SourceStep). Tests and runtest/runtimetest drivers follow; golden fixtures re-frozen. Old agent/turn is behind the legacy_turn build tag until rewritten. --- agent/run/alias_test.go | 104 ++++++++++------------- agent/run/clone.go | 118 ++------------------------- agent/run/codec.go | 6 ++ agent/run/codec_test.go | 13 +-- agent/run/command.go | 24 +++++- agent/run/commit.go | 4 +- agent/run/creation.go | 24 +++++- agent/run/creation_test.go | 7 +- agent/run/decide.go | 55 ++++++++++--- agent/run/evolve.go | 79 +++++++++++++----- agent/run/example_run_test.go | 32 ++++++-- agent/run/fact.go | 89 +++++++++++++------- agent/run/fold_test.go | 9 +- agent/run/frozen.go | 100 +++++++++++++++++++++++ agent/run/header.go | 2 +- agent/run/ids.go | 10 +++ agent/run/loop/lease_test.go | 4 +- agent/run/loop/loop.go | 16 ++++ agent/run/loop/loop_test.go | 29 ++++--- agent/run/loop/model.go | 23 ++++-- agent/run/loop/regression_test.go | 4 +- agent/run/machine_test.go | 107 +++++++++++++++++++++--- agent/run/next.go | 30 ++++--- agent/run/protocol.go | 39 +++++++++ agent/run/protocol_v1.go | 45 ++++++++++ agent/run/runtest/feature.go | 16 ++-- agent/run/runtest/require.go | 52 ++++++++++-- agent/run/runtest/script.go | 21 ++++- agent/run/runtime.go | 4 + agent/run/runtimetest/conformance.go | 2 +- agent/run/snapshot.go | 25 +++--- agent/run/snapshot_test.go | 2 +- agent/run/sqlitestore/store_test.go | 2 +- agent/run/state.go | 80 ++++++++++-------- agent/run/stored_runtime.go | 57 ++++++++++++- agent/turn/README.md | 7 ++ agent/turn/context.go | 2 + agent/turn/coordinator.go | 2 + agent/turn/coordinator_test.go | 2 + agent/turn/driver.go | 2 + agent/turn/live_test.go | 2 +- agent/turn/mapper.go | 2 + agent/turn/turn.go | 2 + 43 files changed, 884 insertions(+), 371 deletions(-) create mode 100644 agent/run/frozen.go create mode 100644 agent/turn/README.md diff --git a/agent/run/alias_test.go b/agent/run/alias_test.go index dd1ecb1..c85fb20 100644 --- a/agent/run/alias_test.go +++ b/agent/run/alias_test.go @@ -57,7 +57,9 @@ func TestCommitSnapshotsCommandPayloadBeforeFoldingState(t *testing.T) { } } -func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { +// The frozen request body is stored canonically in the FrozenValueStore and +// named by digest in the fact (RUN-WIR-4). +func TestCommitCanonicalizesFrozenRequestBeforePersisting(t *testing.T) { rt := newTestRuntime(t) snap, _ := rt.Load(context.Background(), "run-1") req := ModelRequest{ @@ -94,19 +96,27 @@ func TestCommitCanonicalizesAgentOwnedJSONBeforePersisting(t *testing.T) { }) ms := res.Snapshot.State.Current.(ModelStep) - if got := ms.Request.ProviderOptions["p"].String(); got != `{"a":1,"b":2}` { - t.Fatalf("snapshot stored non-canonical provider option: %s", got) + if ms.RequestDigest != reqDigest { + t.Fatalf("snapshot RequestDigest = %s, want %s", ms.RequestDigest, reqDigest) + } + stored, err := rt.FrozenRequest(context.Background(), ms.RequestDigest) + if err != nil { + t.Fatal(err) + } + if got := stored.ProviderOptions["p"].String(); got != `{"a":1,"b":2}` { + t.Fatalf("frozen store returned non-canonical provider option: %s", got) } for _, e := range recordEvents(t, rt, "run-1") { - if f, ok := e.Fact.(ModelStepPrepared); ok { - if got := f.Request.ProviderOptions["p"].String(); got != `{"a":1,"b":2}` { - t.Fatalf("event stored non-canonical provider option: %s", got) - } + if f, ok := e.Fact.(ModelStepPrepared); ok && f.RequestDigest != reqDigest { + t.Fatalf("event RequestDigest = %s, want %s", f.RequestDigest, reqDigest) } } + if _, err := rt.FrozenRequest(context.Background(), "sha256:missing"); err == nil { + t.Fatal("unknown digest returned a request") + } } -func TestLoadSnapshotDoesNotAliasFrozenRequest(t *testing.T) { +func TestFrozenRequestDoesNotAliasStoredBody(t *testing.T) { rt := newTestRuntime(t) meta := map[string]any{"provider": map[string]any{"sig": "s1"}} req := sdk.Request{ @@ -123,57 +133,56 @@ func TestLoadSnapshotDoesNotAliasFrozenRequest(t *testing.T) { prep, cmdID := buildPrepareFromSnap(t, snap, req, nil) mustCommit(t, rt, cmdID, snap.Revision, "", prep) - snap, err := rt.Load(context.Background(), "run-1") + first, err := rt.FrozenRequest(context.Background(), prep.RequestDigest) if err != nil { t.Fatal(err) } - ms := snap.State.Current.(ModelStep) - part := ms.Request.Messages[0].Content[0] + part := first.Messages[0].Content[0] part.Text = "edited" part.ProviderMetadata["provider"] = cj(`{"sig":"bad"}`) part.ProviderMetadata["new"] = cj(`"bad"`) - ms.Request.Messages[0].Content[0] = part + first.Messages[0].Content[0] = part - snap, err = rt.Load(context.Background(), "run-1") + second, err := rt.FrozenRequest(context.Background(), prep.RequestDigest) if err != nil { t.Fatal(err) } - got := snap.State.Current.(ModelStep).Request.Messages[0].Content[0] + got := second.Messages[0].Content[0] if got.Text != "hi" { - t.Fatalf("request content aliased Load snapshot: %q", got.Text) + t.Fatalf("request content aliased a previous read: %q", got.Text) } if sig := got.ProviderMetadata["provider"].String(); sig != `{"sig":"s1"}` { - t.Fatalf("request metadata aliased Load snapshot: %v", sig) + t.Fatalf("request metadata aliased a previous read: %v", sig) } if _, ok := got.ProviderMetadata["new"]; ok { - t.Fatal("request metadata accepted mutation from Load snapshot") + t.Fatal("request metadata accepted mutation from a previous read") } } -func TestCommitResultEventsDoNotAliasStateOrLog(t *testing.T) { +// ModelStepCompleted carries the digest of the frozen result and its usage; +// the result body itself never enters state or log (RUN-WIR-4). +func TestModelStepCompletedCarriesResultDigestOnly(t *testing.T) { rt, stepID, grant := preparedRuntime(t, nil, nil) result := sdk.ModelResult{ Text: "ok", FinishReason: sdk.FinishReasonStop, + Usage: sdk.Usage{InputTokens: 2, OutputTokens: 3, TotalTokens: 5}, ReasoningParts: []sdk.ReasoningPart{{ ID: "r1", Text: "why", Format: sdk.ReasoningFormatAnthropic, ProviderMetadata: map[string]any{"anthropic": map[string]any{"signature": "s1"}}, }}, - TextProviderMetadata: map[string]any{"google": map[string]any{"thoughtSignature": "g1"}}, - Sources: []sdk.Source{{ - SourceType: "url", - ID: "src-1", - URL: "https://example.test", - ProviderMetadata: map[string]any{"p": "v"}, - }}, Response: &sdk.ResponseMetadata{ID: "resp-1", Headers: map[string]string{"h": "v"}}, } frozen, err := FreezeModelResult(result) if err != nil { t.Fatal(err) } + wantDigest, err := ProtocolV1().DigestModelResult(frozen) + if err != nil { + t.Fatal(err) + } snapshot, err := rt.Load(context.Background(), "run-1") if err != nil { t.Fatal(err) @@ -181,48 +190,23 @@ func TestCommitResultEventsDoNotAliasStateOrLog(t *testing.T) { res := mustCommit(t, rt, "done-1", snapshot.Revision, grant, SubmitModelResult{StepID: stepID, Result: frozen}) fact := res.Events[0].Fact.(ModelStepCompleted) - fact.Result.ReasoningParts[0].ProviderMetadata["anthropic"] = cj(`{"signature":"bad"}`) - fact.Result.TextProviderMetadata["google"] = cj(`{"thoughtSignature":"bad"}`) - fact.Result.Sources[0].ProviderMetadata["p"] = cj(`"bad"`) - fact.Result.Response.Headers["h"] = "bad" - res.Events[0].Fact = fact - - snap, err := rt.Load(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } - last := snap.State.LastModelResult - if last == nil { - t.Fatal("missing LastModelResult") - } - if sig := last.ReasoningParts[0].ProviderMetadata["anthropic"].String(); sig != `{"signature":"s1"}` { - t.Fatalf("state reasoning metadata aliased returned event: %v", sig) - } - if sig := last.TextProviderMetadata["google"].String(); sig != `{"thoughtSignature":"g1"}` { - t.Fatalf("state text metadata aliased returned event: %v", sig) + if fact.ResultDigest != wantDigest || fact.Usage.TotalTokens != 5 || fact.FinishReason != FinishReasonStop { + t.Fatalf("completed = %+v", fact) } - if p := last.Sources[0].ProviderMetadata["p"].String(); p != `"v"` { - t.Fatalf("state source metadata aliased returned event: %v", p) + if res.Snapshot.State.Usage.TotalTokens != 5 { + t.Fatalf("usage = %+v", res.Snapshot.State.Usage) } - if h := last.Response.Headers["h"]; h != "v" { - t.Fatalf("state response headers aliased returned event: %v", h) - } - - for _, e := range recordEvents(t, rt, "run-1") { - if f, ok := e.Fact.(ModelStepCompleted); ok { - if sig := f.Result.ReasoningParts[0].ProviderMetadata["anthropic"].String(); sig != `{"signature":"s1"}` { - t.Fatalf("log reasoning metadata aliased returned event: %v", sig) - } - if h := f.Result.Response.Headers["h"]; h != "v" { - t.Fatalf("log response headers aliased returned event: %v", h) - } - } + if res.Snapshot.State.Status != RunCompleted { + t.Fatalf("status = %v, want completed", res.Snapshot.State.Status) } + // Mutating the caller's frozen result after commit changes nothing the + // authority holds: the digest was taken before the transition. + frozen.Text = "changed" diverged, err := rebuildRun(t, rt, "run-1") if err != nil { t.Fatal(err) } if diverged { - t.Fatal("state diverged from log after returned event mutation") + t.Fatal("state diverged from log") } } diff --git a/agent/run/clone.go b/agent/run/clone.go index b2d1f17..b334d37 100644 --- a/agent/run/clone.go +++ b/agent/run/clone.go @@ -64,15 +64,6 @@ func cloneResponseRequest(r *ResponseRequest) *ResponseRequest { return &c } -func cloneToolExecutionResult(r *ToolExecutionResult) *ToolExecutionResult { - if r == nil { - return nil - } - c := *r - c.Output = cloneRaw(c.Output) - return &c -} - func cloneToolCallFailure(f *ToolCallFailure) *ToolCallFailure { if f == nil { return nil @@ -84,7 +75,7 @@ func cloneToolCallFailure(f *ToolCallFailure) *ToolCallFailure { func cloneToolCallState(c *ToolCallState) ToolCallState { out := *c out.Arguments = cloneRaw(out.Arguments) - out.Result = cloneToolExecutionResult(out.Result) + out.Result = clonePtr(out.Result) out.Failure = cloneToolCallFailure(out.Failure) out.Waiting = cloneResponseRequest(out.Waiting) return out @@ -118,12 +109,7 @@ func cloneToolSpecs(specs []ToolSpec) []ToolSpec { if specs == nil { return nil } - out := make([]ToolSpec, len(specs)) - for i, s := range specs { - s.Definition = cloneToolDefinition(s.Definition) - out[i] = s - } - return out + return append([]ToolSpec(nil), specs...) } func cloneResponseFormat(f *ResponseFormat) *ResponseFormat { @@ -199,82 +185,6 @@ func cloneToolDefinitions(defs []ToolDefinition) []ToolDefinition { return out } -func cloneReasoningParts(parts []ReasoningPart) []ReasoningPart { - if parts == nil { - return nil - } - out := make([]ReasoningPart, len(parts)) - for i, p := range parts { - p.ProviderMetadata = cloneProviderMetadata(p.ProviderMetadata) - out[i] = p - } - return out -} - -func cloneSources(sources []Source) []Source { - if sources == nil { - return nil - } - out := make([]Source, len(sources)) - for i, s := range sources { - s.ProviderMetadata = cloneProviderMetadata(s.ProviderMetadata) - out[i] = s - } - return out -} - -func cloneGeneratedFiles(files []GeneratedFile) []GeneratedFile { - if files == nil { - return nil - } - return append([]GeneratedFile(nil), files...) -} - -func cloneModelToolCall(c ModelToolCall) ModelToolCall { - c.Input = cloneRaw(c.Input) - c.ProviderMetadata = cloneProviderMetadata(c.ProviderMetadata) - return c -} - -func cloneModelToolCalls(calls []ModelToolCall) []ModelToolCall { - if calls == nil { - return nil - } - out := make([]ModelToolCall, len(calls)) - for i, c := range calls { - out[i] = cloneModelToolCall(c) - } - return out -} - -func cloneResponseMetadata(r *ResponseMetadata) *ResponseMetadata { - if r == nil { - return nil - } - c := *r - if r.Headers != nil { - c.Headers = make(map[string]string, len(r.Headers)) - for k, v := range r.Headers { - c.Headers[k] = v - } - } - return &c -} - -func cloneModelResult(r *ModelResult) *ModelResult { - if r == nil { - return nil - } - c := *r - c.ReasoningParts = cloneReasoningParts(c.ReasoningParts) - c.TextProviderMetadata = cloneProviderMetadata(c.TextProviderMetadata) - c.Sources = cloneSources(c.Sources) - c.Files = cloneGeneratedFiles(c.Files) - c.ToolCalls = cloneModelToolCalls(c.ToolCalls) - c.Response = cloneResponseMetadata(c.Response) - return &c -} - func cloneRunResult(r *RunResult) *RunResult { if r == nil { return nil @@ -285,14 +195,12 @@ func cloneRunResult(r *RunResult) *RunResult { c.Failure = &f } c.UncertainCalls = append([]CallID(nil), c.UncertainCalls...) - c.Model = cloneModelResult(c.Model) return &c } func cloneStep(s Step) Step { switch step := s.(type) { case ModelStep: - step.Request = cloneRequest(&step.Request) step.Tools = cloneToolSpecs(step.Tools) return step case ToolStep: @@ -335,7 +243,6 @@ func cloneMachineState(s *MachineState) MachineState { } out.LastToolStep = cloneToolStepPtr(out.LastToolStep) out.PendingInputs = cloneAgentInputs(out.PendingInputs) - out.LastModelResult = cloneModelResult(out.LastModelResult) out.Result = cloneRunResult(out.Result) return out } @@ -352,18 +259,15 @@ func snapshotJSONStable[T any](v T) (T, error) { 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 ModelStepCompleted: - return snapshotJSONStable(fact) case ToolStepOpened: return snapshotJSONStable(fact) - case ToolCallCompleted: - return snapshotJSONStable(fact) - case ToolCallAnswered: - return snapshotJSONStable(fact) case InputAccepted: return snapshotJSONStable(fact) default: @@ -374,24 +278,12 @@ func snapshotFact(f Fact) (Fact, error) { func cloneFact(f Fact) Fact { switch fact := f.(type) { case ModelStepPrepared: - fact.Request = cloneRequest(&fact.Request) fact.InputIDs = append([]InputID(nil), fact.InputIDs...) fact.Tools = cloneToolSpecs(fact.Tools) return fact - case ModelStepCompleted: - if r := cloneModelResult(&fact.Result); r != nil { - fact.Result = *r - } - return fact case ToolStepOpened: fact.Calls = cloneToolCallBindings(fact.Calls) return fact - case ToolCallCompleted: - fact.Result.Output = cloneRaw(fact.Result.Output) - return fact - case ToolCallAnswered: - fact.Payload = cloneRaw(fact.Payload) - return fact case InputAccepted: fact.Input = cloneAgentInput(fact.Input) return fact diff --git a/agent/run/codec.go b/agent/run/codec.go index fbdf3d4..6307a14 100644 --- a/agent/run/codec.go +++ b/agent/run/codec.go @@ -315,6 +315,8 @@ func decodeCommandVariantV1(typ string, raw []byte) (AgentCommand, error) { 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": @@ -351,8 +353,12 @@ func decodeFactVariantV1(typ string, raw []byte) (Fact, error) { 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": diff --git a/agent/run/codec_test.go b/agent/run/codec_test.go index f44c9ca..0cae172 100644 --- a/agent/run/codec_test.go +++ b/agent/run/codec_test.go @@ -11,6 +11,7 @@ import ( 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"}}, @@ -49,16 +50,18 @@ func TestCommandEnvelopeJSONRoundTripRestoresVariants(t *testing.T) { func TestAgentEventJSONRoundTripRestoresVariants(t *testing.T) { facts := []Fact{ - ModelStepPrepared{StepID: "s", Model: "m", Request: ModelRequest{Model: "m"}, RequestDigest: "sha256:req", ToolsDigest: "sha256:tools", BindingDigest: "sha256:binding"}, + 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", Result: ModelResult{Text: "ok"}}, + 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", Result: ToolExecutionResult{Output: cj(`{"ok":true}`)}}, - ToolCallAnswered{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp", Payload: cj(`{"answer":1}`)}, + 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{}}, @@ -99,7 +102,7 @@ func TestAgentEventJSONRoundTripRestoresVariants(t *testing.T) { func TestTransitionRecordJSONRoundTripRestoresVariants(t *testing.T) { facts := []Fact{ - ModelStepCompleted{StepID: "s", Result: ModelResult{Text: "ok"}}, + ModelStepCompleted{StepID: "s", FinishReason: FinishReasonStop, ResultDigest: "sha256:result"}, RunEnded{End: RunCompletedEnd{}}, } events := make([]AgentEvent, len(facts)) diff --git a/agent/run/command.go b/agent/run/command.go index 2fcf9ff..c6d4592 100644 --- a/agent/run/command.go +++ b/agent/run/command.go @@ -2,7 +2,9 @@ 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 fourteen variants below exist. +// 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. @@ -12,11 +14,13 @@ type AgentInput struct { Payload CanonicalJSON `json:"payload"` } -// NextStep creates the AcceptInput command consumed at Open. +// 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"` @@ -30,6 +34,15 @@ type PrepareModelRequest struct { 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"` @@ -176,8 +189,9 @@ type CancelRun struct { func (CancelRun) agentCommand() {} -// AcceptInput appends one input to PendingInputs while Current is Open. -// Idempotent per (RunID, InputID) with identical payload. +// 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"` } @@ -189,6 +203,8 @@ 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: diff --git a/agent/run/commit.go b/agent/run/commit.go index 5d0a132..6bd9cda 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -53,7 +53,7 @@ func categorize(c AgentCommand) commandCategory { return catRecovery // scanner path when grantless; owner path with grant } return catIngress // known failure on Pending uses empty grant; Executing path checks grant below - case ApproveToolCall, RejectToolCall, SubmitToolResponse, AcceptInput: + case ApproveToolCall, RejectToolCall, SubmitToolResponse, AcceptInput, WithdrawPreparedStep: return catIngress case CancelRun: return catRunControl @@ -262,6 +262,8 @@ func checkDerivedCommandID(env *CommandEnvelope, baseRevision uint64) error { want = DeriveModelRequestCommandID(env.RunID, baseRevision) 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: diff --git a/agent/run/creation.go b/agent/run/creation.go index 160d297..2cca15e 100644 --- a/agent/run/creation.go +++ b/agent/run/creation.go @@ -11,16 +11,26 @@ import ( ) // NewRun is the immutable, versioned creation data for a Run. RunID is -// caller-supplied so retries retain a stable identity. +// 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. +// BuildNewRun constructs a current-version Run creation value with no owner. func BuildNewRun(runID RunID, causationID es.CausationID) (NewRun, error) { - run := NewRun{SchemaVersion: SchemaVersion1, RunID: runID, CausationID: causationID} + 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 } @@ -35,6 +45,9 @@ func ValidateNewRun(run NewRun) error { 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") } @@ -62,7 +75,10 @@ func BuildRunHeaderFromNewRun(run NewRun) (RunHeader, error) { const newRunV1InitialStateVersion uint16 = 1 func buildRunHeaderV1(run NewRun) (RunHeader, error) { - initial := MachineState{RunID: run.RunID, Status: RunActive, Current: Open{}} + initial, err := InitializeRun(run.RunID, run.Owner, run.Attempt) + if err != nil { + return RunHeader{}, err + } stateBytes, err := encodeMachineStateV1(&initial) if err != nil { return RunHeader{}, err diff --git a/agent/run/creation_test.go b/agent/run/creation_test.go index 0604140..408af6e 100644 --- a/agent/run/creation_test.go +++ b/agent/run/creation_test.go @@ -52,9 +52,10 @@ func TestNewRunValidationAndV1HeaderGolden(t *testing.T) { if err != nil { t.Fatal(err) } - // Pre-release fixture; re-frozen when lastClosedStep left the snapshot wire. - if header.InitialStateDigest != "sha256:ca282c1b24d7281f8fde3b52e03a32f3c8dbd3231a802078437a7b2469e4f86a" || - header.HeaderDigest != "sha256:7a719c28596a23bdd2e71b17beae930377f6702b11168343bd781096b77d392c" { + // Pre-release fixture; re-frozen when Owner/Attempt joined the state and + // facts became digest-only (RUN-WIR-4). + if header.InitialStateDigest != "sha256:42c13ce3c1d6f3e9ffe6300bfcfaf41098e8b77e330b7740642f36594c889eb6" || + header.HeaderDigest != "sha256:dc9be6579793c0014755bc5e49ef7e427f7803a731c162878143dd9cc6716e21" { t.Fatalf("v1 header changed: %+v", header) } } diff --git a/agent/run/decide.go b/agent/run/decide.go index 74a4f6e..9d6e8aa 100644 --- a/agent/run/decide.go +++ b/agent/run/decide.go @@ -28,6 +28,8 @@ func decideV1(s MachineState, c AgentCommand) ([]Fact, error) { switch cmd := c.(type) { case PrepareModelRequest: return decidePrepareModelRequest(&s, &cmd) + case WithdrawPreparedStep: + return decideWithdrawPreparedStep(&s, cmd) case StartModelExecution: return decideStartModelExecution(&s, cmd) case RecoverModelExecution: @@ -84,13 +86,14 @@ func decidePrepareModelRequest(s *MachineState, cmd *PrepareModelRequest) ([]Fac } } // Tools must correspond one-to-one, in order, with the provider tool - // definitions inside the frozen request. + // 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.Definition.Name != cmd.Request.Tools[i].Name { - return nil, rejectionf("prepare: ToolSpec[%d] %q does not match request tool %q", i, spec.Definition.Name, cmd.Request.Tools[i].Name) + 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 { @@ -121,7 +124,6 @@ func decidePrepareModelRequest(s *MachineState, cmd *PrepareModelRequest) ([]Fac return []Fact{ModelStepPrepared{ StepID: cmd.StepID, Model: cmd.Model, - Request: cmd.Request, RequestDigest: cmd.RequestDigest, InputIDs: cmd.InputIDs, Tools: cmd.Tools, @@ -130,6 +132,22 @@ func decidePrepareModelRequest(s *MachineState, cmd *PrepareModelRequest) ([]Fac }}, 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) { @@ -175,7 +193,11 @@ func decideSubmitModelResult(s *MachineState, cmd *SubmitModelResult) ([]Fact, e if ms.Status != ModelExecuting { return nil, rejectionf("model result: step is not Executing") } - completed := ModelStepCompleted{StepID: cmd.StepID, Result: cmd.Result} + 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 @@ -184,6 +206,11 @@ func decideSubmitModelResult(s *MachineState, cmd *SubmitModelResult) ([]Fact, e 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) @@ -206,7 +233,7 @@ func checkToolCallBindings(ms *ModelStep, cmd *SubmitModelResult) ([]ToolCallBin } specByName := make(map[string]ToolSpec, len(ms.Tools)) for _, spec := range ms.Tools { - specByName[spec.Definition.Name] = spec + specByName[spec.Name] = spec } seen := make(map[CallID]bool, len(cmd.Calls)) bindings := make([]ToolCallBinding, len(cmd.Calls)) @@ -407,8 +434,11 @@ func decideSubmitToolResult(s *MachineState, cmd SubmitToolResult) ([]Fact, erro if ts.Calls[i].Status != ToolExecuting { return nil, rejectionf("tool result: call %q is not Executing", cmd.CallID) } - facts := []Fact{ToolCallCompleted(cmd)} - return facts, nil + 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) { @@ -544,8 +574,7 @@ func decideSubmitToolResponse(s *MachineState, cmd *SubmitToolResponse) ([]Fact, if cmd.ResponseDigest != wantDigest { return nil, rejectionf("response: answer payload digest mismatch") } - facts := []Fact{ToolCallAnswered(*cmd)} - return facts, nil + return []Fact{ToolCallAnswered{StepID: cmd.StepID, CallID: cmd.CallID, ResponseID: cmd.ResponseID, ResponseDigest: cmd.ResponseDigest}}, nil } // --- rules 12-13: cancel and input --- @@ -591,10 +620,10 @@ func unknownExecutingCalls(s *MachineState, failure ToolFailure) []Fact { 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 !atOpen(s.Current) { - return nil, rejectionf("accept input: run is not at Open") - } if cmd.Input.ID == "" { return nil, rejectionf("accept input: empty InputID") } diff --git a/agent/run/evolve.go b/agent/run/evolve.go index f1ba8ba..800cb80 100644 --- a/agent/run/evolve.go +++ b/agent/run/evolve.go @@ -15,8 +15,12 @@ func evolveV1(s MachineState, f Fact) (MachineState, error) { 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: @@ -33,12 +37,11 @@ func evolveV1(s MachineState, f Fact) (MachineState, error) { 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) { - r := fact.Result - c.Status, c.Result, c.Waiting = ToolCompleted, &r, nil + 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, &ToolExecutionResult{Output: fact.Payload}, nil + c.Status, c.Result, c.Waiting = ToolCompleted, &ToolCallResult{OutputDigest: fact.ResponseDigest}, nil }), nil case ToolCallFailed: return applyCall(s, fact.CallID, func(c *ToolCallState) { @@ -55,10 +58,13 @@ func evolveV1(s MachineState, f Fact) (MachineState, error) { // --- 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}, - Request: fact.Request, RequestDigest: fact.RequestDigest, Model: fact.Model, Tools: fact.Tools, @@ -70,6 +76,14 @@ func applyModelStepPrepared(s MachineState, fact *ModelStepPrepared) MachineStat 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 { @@ -84,9 +98,7 @@ func applyModelStatus(s MachineState, status ModelStepStatus, usage Usage, rejec } func applyModelStepCompleted(s MachineState, fact *ModelStepCompleted) MachineState { - result := fact.Result - s.LastModelResult = &result - s.Usage = s.Usage.Add(fact.Result.Usage) + s.Usage = s.Usage.Add(fact.Usage) s.Current = Open{} return s } @@ -143,7 +155,7 @@ 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, Model: s.LastModelResult, Usage: s.Usage} + 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 @@ -156,12 +168,26 @@ func applyRunEnded(s MachineState, fact *RunEnded) MachineState { // 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: @@ -169,6 +195,9 @@ func guardFactV1(s *MachineState, f Fact) error { 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) @@ -178,6 +207,9 @@ func guardFactV1(s *MachineState, f Fact) error { 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: @@ -229,10 +261,22 @@ func requireCall(s *MachineState, stepID StepID, callID CallID, statuses ...Tool return ToolCallState{}, fmt.Errorf("agent: evolve: tool call %q is %s", callID, call.Status) } -func guardInputAccepted(s *MachineState, fact *InputAccepted) error { - if err := requireOpen(s, "input accepted"); err != nil { - return err +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") } @@ -253,9 +297,6 @@ func guardModelStepPrepared(s *MachineState, fact *ModelStepPrepared) error { if fact.StepID == "" || fact.Model == "" || fact.RequestDigest == "" || fact.ToolsDigest == "" || fact.BindingDigest == "" { return errors.New("agent: evolve: model step prepared is missing identity or digest") } - if ModelRef(fact.Request.Model) != fact.Model { - return errors.New("agent: evolve: model step prepared model mismatch") - } // 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 @@ -268,9 +309,9 @@ func guardModelStepPrepared(s *MachineState, fact *ModelStepPrepared) error { return fmt.Errorf("agent: evolve: model step prepared input ID at position %d = %q, want pending input %q", i, fact.InputIDs[i], input.ID) } } - if d, err := digestRequestV1(fact.Request); err != nil || d != fact.RequestDigest { - return errors.New("agent: evolve: model step prepared request digest mismatch") - } + // 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") } @@ -359,8 +400,8 @@ func guardToolCallAnswered(s *MachineState, fact *ToolCallAnswered) error { if err := requireWaitingFor(&call, ResponseExternal, fact.ResponseID); err != nil { return err } - if d, err := digestToolResponsePayloadV1(fact.Payload); err != nil || d != fact.ResponseDigest { - return fmt.Errorf("agent: evolve: tool call %q response digest mismatch", fact.CallID) + if fact.ResponseDigest == "" { + return fmt.Errorf("agent: evolve: tool call %q answered without response digest", fact.CallID) } return nil } diff --git a/agent/run/example_run_test.go b/agent/run/example_run_test.go index 2941b23..367a714 100644 --- a/agent/run/example_run_test.go +++ b/agent/run/example_run_test.go @@ -70,6 +70,7 @@ func Example_recoverableRun() { panic(err) } + app.runtime = rt1 loop1, err := loop.New(app.models(), app.tools(), app, loop.ExecutionPolicy{LeaseRenewInterval: 5 * time.Second}, false) if err != nil { panic(err) @@ -118,6 +119,7 @@ func Example_recoverableRun() { fmt.Printf("recovered %d lease: call %s is %s (%s), run %s\n", recovered, call.ProviderCallID, call.Status, call.Failure.Failure.Class, statusName(snap.State.Status)) + app.runtime = rt2 loop2, err := loop.New(app.models(), app.tools(), app, loop.ExecutionPolicy{LeaseRenewInterval: 5 * time.Second}, false) if err != nil { panic(err) @@ -126,7 +128,11 @@ func Example_recoverableRun() { if err != nil { panic(err) } - fmt.Printf("process 2: run %s: %q\n", statusName(result.Result.Status), result.Result.Model.Text) + snap, err = rt2.Load(ctx, "run-1") + if err != nil { + panic(err) + } + fmt.Printf("process 2: run %s after %d model steps\n", statusName(result.Result.Status), snap.State.ModelSteps) record, err := rt2.Record(ctx, "run-1") if err != nil { @@ -143,7 +149,7 @@ func Example_recoverableRun() { // process 1: tool call c1 is Executing; process crashes // process 2: reopened at revision 5, run active, needs recovery = true // recovered 1 lease: call c1 is Failed (effect_unknown), run active - // process 2: run completed: "done" + // process 2: run completed after 2 model steps // record: 9 transitions fold to the stored snapshot } @@ -205,6 +211,9 @@ func (c *fakeClock) Advance(d time.Duration) { type exampleApp struct { tool *lookupTool spec run.ToolSpec + // runtime is the process-local Runtime the planner reads committed tool + // outcomes from; each "process" installs its own before running the Loop. + runtime run.Runtime } func newExampleApp(tool *lookupTool) *exampleApp { @@ -217,13 +226,15 @@ func newExampleApp(tool *lookupTool) *exampleApp { panic(err) } return &exampleApp{tool: tool, spec: run.ToolSpec{ - Ref: tool.Ref(), Definition: frozen, DefinitionDigest: digest, Policy: run.DirectExecution, + Ref: tool.Ref(), Name: tool.Definition().Name, DefinitionDigest: digest, Policy: run.DirectExecution, }} } // Plan projects the Run boundary facts into the next sdk.Request: the pending -// user inputs, plus the committed outcome of the previous tool step. -func (a *exampleApp) Plan(_ context.Context, hint run.PlanningHint) (loop.RequestPlan, error) { +// user inputs, plus the committed outcome of the tool step the hint names as +// SourceStep. The hint carries only that boundary; the outcome is read back +// from the Runtime, as a real planner reads it from the session projection. +func (a *exampleApp) Plan(ctx context.Context, hint run.PlanningHint) (loop.RequestPlan, error) { var messages []sdk.Message ids := make([]run.InputID, 0, len(hint.Inputs)) for _, in := range hint.Inputs { @@ -236,8 +247,15 @@ func (a *exampleApp) Plan(_ context.Context, hint run.PlanningHint) (loop.Reques } messages = append(messages, sdk.UserMessage(body.Text)) } - if hint.LastToolStep != nil { - for _, call := range hint.LastToolStep.Calls { + if hint.SourceStep != "" { + snap, err := a.runtime.Load(ctx, hint.RunID) + if err != nil { + return loop.RequestPlan{}, err + } + if snap.State.LastToolStep == nil || snap.State.LastToolStep.RefValue.ID != hint.SourceStep { + return loop.RequestPlan{}, fmt.Errorf("planner: source step %s is not the last closed tool step", hint.SourceStep) + } + for _, call := range snap.State.LastToolStep.Calls { outcome := "ok" if call.Status == run.ToolFailed { outcome = call.Failure.Failure.Class diff --git a/agent/run/fact.go b/agent/run/fact.go index 8e066b0..98c6ad6 100644 --- a/agent/run/fact.go +++ b/agent/run/fact.go @@ -6,30 +6,54 @@ import ( "errors" "fmt" "io" + + "github.com/memohai/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 thirteen variants below exist. +// 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. 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). +// 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"` - Request ModelRequest `json:"request"` - RequestDigest Digest `json:"requestDigest"` - InputIDs []InputID `json:"inputIds,omitempty"` - Tools []ToolSpec `json:"tools,omitempty"` - ToolsDigest Digest `json:"toolsDigest"` - BindingDigest Digest `json:"bindingDigest"` + 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"` @@ -54,12 +78,15 @@ type ModelStepRejected struct { func (ModelStepRejected) fact() {} -// ModelStepCompleted accepts one model result: usage is accumulated, -// LastModelResult is written, Current becomes Open. The same transition may -// then open a ToolStep or end the Run. +// 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"` - Result ModelResult `json:"result"` + StepID StepID `json:"stepId"` + Usage Usage `json:"usage"` + FinishReason FinishReason `json:"finishReason"` + ResultDigest Digest `json:"resultDigest"` } func (ModelStepCompleted) fact() {} @@ -97,22 +124,23 @@ type ToolCallApproved struct { func (ToolCallApproved) fact() {} -// ToolCallCompleted: Executing -> Completed. +// ToolCallCompleted: Executing -> Completed. OutputDigest names the tool +// output the companion carries. type ToolCallCompleted struct { - StepID StepID `json:"stepId"` - CallID CallID `json:"callId"` - Result ToolExecutionResult `json:"result"` + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + OutputDigest Digest `json:"outputDigest"` } func (ToolCallCompleted) fact() {} -// ToolCallAnswered: Waiting(ExternalResponse) -> Completed with the answer. +// 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"` - Payload CanonicalJSON `json:"payload"` + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + ResponseID ResponseID `json:"responseId"` + ResponseDigest Digest `json:"responseDigest"` } func (ToolCallAnswered) fact() {} @@ -127,7 +155,8 @@ type ToolCallFailed struct { func (ToolCallFailed) fact() {} -// InputAccepted appends one input to PendingInputs. +// 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"` } @@ -271,8 +300,12 @@ func (r *RunEnded) UnmarshalJSON(raw []byte) error { // 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: diff --git a/agent/run/fold_test.go b/agent/run/fold_test.go index 3ae2e5f..e9de230 100644 --- a/agent/run/fold_test.go +++ b/agent/run/fold_test.go @@ -209,7 +209,7 @@ func TestFoldRejectsDamagedLog(t *testing.T) { log := flattenTransitionRecords(transitions) for i := range log { if f, ok := log[i].Fact.(ToolCallCompleted); ok { - f.Result.Output = cj(`"tampered"`) + f.OutputDigest = "sha256:tampered" log[i].Fact = f break } @@ -262,9 +262,10 @@ func TestGoldenEventStreamV1(t *testing.T) { t.Fatal(err) } got := string(sha256Digest(stateBytes)) - // Pre-release fixture; re-frozen when CallID became derived and - // providerCallId joined the binding. - const frozen = "sha256:d3d3586e173be5be4bc7889993fa9c3456d33a58e354ad160cd89609aae9fe90" + // Pre-release fixture; re-frozen when facts became digest-only (RUN-WIR-4): + // ModelStepCompleted/ToolCallCompleted carry ResultDigest/OutputDigest and + // MachineState no longer holds LastModelResult. + const frozen = "sha256:5691b677142774c67ba3383309be93cc3cb212ac70469f9aeb82c47ef66e4224" if got != frozen { t.Fatalf("golden v1 state digest changed:\n got %s\nwant %s\nstate: %s", got, frozen, stateBytes) } diff --git a/agent/run/frozen.go b/agent/run/frozen.go new file mode 100644 index 0000000..96b4e8c --- /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/header.go b/agent/run/header.go index 4c70d1e..23c8432 100644 --- a/agent/run/header.go +++ b/agent/run/header.go @@ -78,7 +78,7 @@ func ValidateRunHeader(h *RunHeader) error { } if h.InitialState.Status != RunActive || !atOpen(h.InitialState.Current) || len(h.InitialState.PendingInputs) != 0 || h.InitialState.ModelSteps != 0 || - h.InitialState.Result != nil || h.InitialState.LastModelResult != nil || + h.InitialState.Result != nil || h.InitialState.LastToolStep != nil || h.InitialState.Usage != (Usage{}) { return errors.New("agent: run header: initial state is not a minimal Revision-0 state") diff --git a/agent/run/ids.go b/agent/run/ids.go index 2947cde..a960a58 100644 --- a/agent/run/ids.go +++ b/agent/run/ids.go @@ -9,6 +9,10 @@ import ( ) 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 @@ -96,6 +100,12 @@ 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. A // Loop that kept only the claim can replay its start and recover the grant; diff --git a/agent/run/loop/lease_test.go b/agent/run/loop/lease_test.go index 24e24af..50c1ecf 100644 --- a/agent/run/loop/lease_test.go +++ b/agent/run/loop/lease_test.go @@ -48,7 +48,7 @@ func TestLoopRenewsLeaseDuringLongTool(t *testing.T) { } spec := toolSpec(t, "slow", DirectExecution) - slow := &fakeTool{ref: "slow", def: spec.Definition.SDK(), policy: DirectExecution, + slow := &fakeTool{ref: "slow", def: toolDef(spec.Name), policy: DirectExecution, execute: func(ctx context.Context, req ToolExecutionRequest) ToolExecutionOutcome { // Simulate a tool that outlives the TTL: advance the clock past // several deadlines while the heartbeat keeps renewing. @@ -123,7 +123,7 @@ func TestLoopReplacementFinishesInheritedClaim(t *testing.T) { spec := toolSpec(t, "echo", DirectExecution) block := make(chan struct{}) var executions atomic.Int32 - tool := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, + tool := &fakeTool{ref: "echo", def: toolDef(spec.Name), policy: DirectExecution, execute: func(ctx context.Context, req ToolExecutionRequest) ToolExecutionOutcome { if executions.Add(1) == 1 { <-block // first process "dies" here diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index 9c6d72e..0e46b73 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -148,6 +148,22 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev 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.Revision, "", + run.WithdrawPreparedStep{StepID: eff.StepID}, proto) + if err != nil && !retriable(err) { + return LoopResult{}, err + } + if err == nil { + l.emitCommitted(ctx, events, runID, res.Events) + } case run.StartModelCall: if err := l.runModelStep(ctx, runtime, events, &snapshot, eff.StepID); err != nil { return LoopResult{}, err diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go index 25af86a..bf1c88c 100644 --- a/agent/run/loop/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -86,7 +86,7 @@ func (p staticPlanner) Plan(_ context.Context, hint PlanningHint) (RequestPlan, } req := sdk.Request{Model: string(model), Messages: []sdk.Message{sdk.UserMessage("go")}} for _, s := range p.specs { - req.Tools = append(req.Tools, s.Definition.SDK()) + req.Tools = append(req.Tools, toolDef(s.Name)) } ids := make([]InputID, len(hint.Inputs)) for i, in := range hint.Inputs { @@ -95,10 +95,15 @@ func (p staticPlanner) Plan(_ context.Context, hint PlanningHint) (RequestPlan, 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() - def := sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} - frozen, err := FreezeToolDefinition(def) + frozen, err := FreezeToolDefinition(toolDef(name)) if err != nil { t.Fatal(err) } @@ -106,7 +111,7 @@ func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { if err != nil { t.Fatal(err) } - return ToolSpec{Ref: ToolRef(name), Definition: frozen, DefinitionDigest: d, Policy: policy} + return ToolSpec{Ref: ToolRef(name), Name: name, DefinitionDigest: d, Policy: policy} } func loopRuntime(t *testing.T) Runtime { @@ -200,8 +205,8 @@ func TestLoopModelCatalogErrorRecoversWithFreshLoop(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Result == nil || res.Result.Model.Text != "resumed" { - t.Fatalf("res = %+v", res) + 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(), "run-1") if err != nil { @@ -217,7 +222,7 @@ func TestLoopParallelBounded(t *testing.T) { var concurrent, peak atomic.Int32 gate := make(chan struct{}) started := make(chan struct{}, 3) - echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, + echo := &fakeTool{ref: "echo", def: toolDef(spec.Name), policy: DirectExecution, execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { cur := concurrent.Add(1) for { @@ -277,7 +282,7 @@ func TestToolStartStaleDropsLocalClaim(t *testing.T) { if err != nil { t.Fatal(err) } - echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, + echo := &fakeTool{ref: "echo", def: toolDef(spec.Name), policy: DirectExecution, execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: args}} }} @@ -372,7 +377,7 @@ func TestLoopReplaysSettlementWithoutRepeatingTool(t *testing.T) { rt := newResponseLossRuntime(t) spec := toolSpec(t, "echo", DirectExecution) var executions atomic.Int32 - echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, + 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}} @@ -393,7 +398,7 @@ func TestLoopReplaysSettlementWithoutRepeatingTool(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Disposition != LoopFinished || res.Result == nil || res.Result.Model.Text != "done" { + if res.Disposition != LoopFinished || res.Result == nil || res.Result.Status != RunCompleted { t.Fatalf("result = %+v", res) } if executions.Load() != 1 { @@ -488,8 +493,8 @@ func TestLoopMidExecutionCancelRecoversModelStep(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Result.Model.Text != "resumed" { - t.Fatalf("res = %+v", res) + 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(), "run-1") if final.State.ModelSteps != 1 { diff --git a/agent/run/loop/model.go b/agent/run/loop/model.go index 00733a8..6c6331e 100644 --- a/agent/run/loop/model.go +++ b/agent/run/loop/model.go @@ -139,10 +139,23 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve 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). - sdkRequest, err := modelStep.Request.SDK() - if err != nil { - failure := run.StepFailure{Class: run.FailureMalformedModel, Message: err.Error()} + // 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.Revision, start.Grant, run.RecoverModelExecution{StepID: stepID, Claim: a.claim}, proto); err != nil { + return err + } + return fetchErr + } + failure := run.StepFailure{Class: run.FailureMalformedModel, Message: fetchErr.Error()} completion = run.RejectModelResult{StepID: stepID, Failure: failure, Disposition: l.modelRejectDisposition(modelStep, failure)} } else { workerCtx, stopLease := l.keepLease(ctx, runtime, runID, stepID, "", start.Grant) @@ -255,7 +268,7 @@ func (l *Loop) bindToolCalls(result *sdk.ModelResult, step *run.ModelStep) ([]ru } specByName := make(map[string]run.ToolSpec, len(step.Tools)) for _, s := range step.Tools { - specByName[s.Definition.Name] = s + specByName[s.Name] = s } bindings := make([]run.ToolCallBinding, len(result.ToolCalls)) for i, tc := range result.ToolCalls { diff --git a/agent/run/loop/regression_test.go b/agent/run/loop/regression_test.go index 631a2c0..ceeb0f5 100644 --- a/agent/run/loop/regression_test.go +++ b/agent/run/loop/regression_test.go @@ -14,7 +14,7 @@ import ( func TestRegressionToolPanicBecomesUnknown(t *testing.T) { spec := toolSpec(t, "echo", DirectExecution) - echo := &fakeTool{ref: "echo", def: spec.Definition.SDK(), policy: DirectExecution, + echo := &fakeTool{ref: "echo", def: toolDef(spec.Name), policy: DirectExecution, execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { panic("nil map write") }} @@ -76,7 +76,7 @@ func TestRegressionAliasedToolRefExecutes(t *testing.T) { if err != nil { t.Fatal(err) } - spec := ToolSpec{Ref: "fs.read", Definition: frozenDef, DefinitionDigest: d, Policy: DirectExecution} + 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 { diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go index d81108d..9502d1b 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -16,7 +16,7 @@ func cj(raw string) CanonicalJSON { return MustParseCanonicalJSON(raw) } func newRun(t *testing.T) MachineState { t.Helper() - s, err := InitializeRun("run-1") + s, err := InitializeRun("run-1", "", 0) if err != nil { t.Fatal(err) } @@ -102,7 +102,7 @@ func makeSpec(t *testing.T, def sdk.ToolDefinition, policy ResponsePolicy) ToolS if err != nil { t.Fatal(err) } - return ToolSpec{Ref: ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: policy} + 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 { @@ -181,15 +181,39 @@ func advanceToExecuting(t *testing.T, s MachineState, req sdk.Request, specs []T // --- tests --- func TestInitializeRunIsMinimal(t *testing.T) { - s, err := InitializeRun("r") + s, err := InitializeRun("r", "turn-1", 1) if err != nil { t.Fatal(err) } - if s.RunID != "r" || s.Status != RunActive || !atOpen(s.Current) || len(s.PendingInputs) != 0 { + 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) @@ -521,11 +545,75 @@ func TestAcceptInputDuplicateIsGuarded(t *testing.T) { if _, err := ProtocolV1().Evolve(s, facts[0]); err == nil { t.Fatal("duplicate InputAccepted folded silently") } - // AcceptInput rejected while a step is current. +} + +// 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)) - if _, err := ProtocolV1().Decide(s, NextStep(AgentInput{ID: "in-3"})); err == nil { - t.Fatal("AcceptInput accepted with a current step") + + // 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) } } @@ -538,7 +626,7 @@ func TestAcceptInputRejectsSeedDuplicateID(t *testing.T) { } func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { - minimal, err := InitializeRun("run-1") + minimal, err := InitializeRun("run-1", "", 0) if err != nil { t.Fatal(err) } @@ -568,7 +656,7 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { if err != nil { t.Fatal(err) } - return ModelStepPrepared{StepID: "step-1", Model: testModel, Request: request, RequestDigest: requestDigest, ToolsDigest: toolsDigest, BindingDigest: binding, InputIDs: ids} + return ModelStepPrepared{StepID: "step-1", Model: testModel, RequestDigest: requestDigest, ToolsDigest: toolsDigest, BindingDigest: binding, InputIDs: ids} } t.Run("nonexistent input", func(t *testing.T) { @@ -607,7 +695,6 @@ func TestEvolveRejectsModelPrepareOverCurrentStep(t *testing.T) { _, err := ProtocolV1().Evolve(s, ModelStepPrepared{ StepID: "other", Model: testModel, - Request: ModelRequest{Model: string(testModel)}, RequestDigest: "sha256:req", ToolsDigest: "sha256:tools", BindingDigest: "sha256:binding", diff --git a/agent/run/next.go b/agent/run/next.go index c3e976d..9c08246 100644 --- a/agent/run/next.go +++ b/agent/run/next.go @@ -17,6 +17,15 @@ type StartModelCall struct { 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 @@ -90,15 +99,14 @@ func NeedsRecovery(s MachineState) bool { } } -// PlanningHint is what the Loop hands the application RequestPlanner. +// 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 { + Owner OwnerID RunID RunID SourceStep StepID Inputs []AgentInput - // LastToolStep contains the committed results of the preceding tool - // boundary, allowing the planner to construct the next model request. - LastToolStep *ToolStep - LastModelResult *ModelResult } // Next derives the pending effect from the current state (RUN-MCH-4). @@ -116,14 +124,16 @@ func Next(s MachineState) (Effect, error) { source = s.LastToolStep.RefValue.ID } return NeedModelRequest{Hint: PlanningHint{ - RunID: s.RunID, - SourceStep: source, - Inputs: append([]AgentInput(nil), s.PendingInputs...), - LastToolStep: cloneToolStepPtr(s.LastToolStep), - LastModelResult: cloneModelResult(s.LastModelResult), + 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 diff --git a/agent/run/protocol.go b/agent/run/protocol.go index b41c8e4..b5e728a 100644 --- a/agent/run/protocol.go +++ b/agent/run/protocol.go @@ -56,6 +56,9 @@ type Protocol struct { 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) @@ -80,6 +83,9 @@ var protocolV1 = Protocol{ digestModelStepBinding: digestModelStepBindingV1, digestToolResponseDecision: digestToolResponseDecisionV1, digestToolResponsePayload: digestToolResponsePayloadV1, + digestModelResult: digestModelResultV1, + digestToolOutput: digestToolOutputV1, + buildCreateGroup: buildCreateGroupV1, decodeCommand: decodeCommandVariantV1, decodeFact: decodeFactVariantV1, decide: decideV1, @@ -159,6 +165,35 @@ func (p Protocol) DigestToolResponsePayload(payload CanonicalJSON) (Digest, erro 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) DigestCommand(typ string, command AgentCommand) (Digest, error) { if err := p.ready(); err != nil { return "", err @@ -274,6 +309,10 @@ 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 diff --git a/agent/run/protocol_v1.go b/agent/run/protocol_v1.go index 6bf445c..cbfde97 100644 --- a/agent/run/protocol_v1.go +++ b/agent/run/protocol_v1.go @@ -64,6 +64,51 @@ func digestToolResponsePayloadV1(payload CanonicalJSON) (Digest, error) { 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 initialStateVersionV1() uint16 { return 1 } func validateHeaderV1(h *RunHeader) error { diff --git a/agent/run/runtest/feature.go b/agent/run/runtest/feature.go index 8e7244d..dc9c118 100644 --- a/agent/run/runtest/feature.go +++ b/agent/run/runtest/feature.go @@ -33,6 +33,7 @@ type Feature struct { 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 @@ -64,6 +65,7 @@ func New(t testing.TB) *Feature { runID: defaultRunID, rt: rt, model: defaultModel, + defs: make(map[run.ToolRef]sdk.ToolDefinition), tools: make(map[run.ToolRef]*scriptTool), } f.commit(run.AcceptInput{Input: run.AgentInput{ @@ -77,11 +79,12 @@ func New(t testing.TB) *Feature { func (f *Feature) Tool(name string, policy run.ResponsePolicy) *Feature { f.t.Helper() f.guardConfig() - spec := f.mustSpec(name, policy) + 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: spec.Definition.SDK(), + def: def, policy: policy, } return f @@ -312,7 +315,7 @@ func (f *Feature) ensureLoop() { return } f.invoker = &scriptInvoker{results: f.results} - f.planner = &scriptPlanner{model: f.model, specs: f.specs} + 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 @@ -399,7 +402,7 @@ func (f *Feature) commitPrepare() { 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, spec.Definition.SDK()) + req.Tools = append(req.Tools, f.defs[spec.Ref]) } frozen, err := run.FreezeModelRequest(req) if err != nil { @@ -434,7 +437,8 @@ func (f *Feature) commitPrepare() { }, "") } -func (f *Feature) mustSpec(name string, policy run.ResponsePolicy) run.ToolSpec { +// 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) @@ -445,7 +449,7 @@ func (f *Feature) mustSpec(name string, policy run.ResponsePolicy) run.ToolSpec if err != nil { f.t.Fatal(err) } - return run.ToolSpec{Ref: run.ToolRef(name), Definition: frozen, DefinitionDigest: d, Policy: policy} + return run.ToolSpec{Ref: run.ToolRef(name), Name: name, DefinitionDigest: d, Policy: policy}, def } func (f *Feature) facts() []run.Fact { diff --git a/agent/run/runtest/require.go b/agent/run/runtest/require.go index 3b023ae..8362048 100644 --- a/agent/run/runtest/require.go +++ b/agent/run/runtest/require.go @@ -32,16 +32,42 @@ func (f *Feature) RequireWaitingProvider(providerID string) { } } -// RequireCompleted checks the Run finished with the model text. +// 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 || s.Result.Model == nil || s.Result.Model.Text != text { + 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. @@ -150,19 +176,29 @@ func (f *Feature) RequireUsage(total int) { } } -// RequirePlannerSawTool checks the next Plan received the completed call output. +// 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.LastToolStep == nil { - f.t.Fatal("planner has no LastToolStep") + 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 f.planner.lastHint.LastToolStep.Calls { - if call.CallID == callID && call.Status == run.ToolCompleted && call.Result != nil && call.Result.Output.String() == output { + 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 %s", f.planner.lastHint.LastToolStep.Calls, callID, output) + 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. diff --git a/agent/run/runtest/script.go b/agent/run/runtest/script.go index a4d15f6..76274b7 100644 --- a/agent/run/runtest/script.go +++ b/agent/run/runtest/script.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sync" "sync/atomic" "github.com/memohai/twilight/agent/run" @@ -38,6 +39,8 @@ func ToolCalls(name string, ids ...string) sdk.ModelResult { 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) { @@ -48,7 +51,20 @@ func (s *scriptInvoker) Generate(ctx context.Context, _ sdk.Request) (sdk.ModelR if n >= len(s.results) { return sdk.ModelResult{}, errors.New("runtest: no scripted model result") } - return s.results[n], nil + 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 { @@ -105,6 +121,7 @@ func (c scriptToolCatalog) ResolveTool(ref run.ToolRef) (loop.ExecutableTool, er type scriptPlanner struct { model run.ModelRef specs []run.ToolSpec + defs map[run.ToolRef]sdk.ToolDefinition lastHint run.PlanningHint } @@ -116,7 +133,7 @@ func (p *scriptPlanner) Plan(_ context.Context, hint run.PlanningHint) (loop.Req } req := sdk.Request{Model: string(model), Messages: []sdk.Message{sdk.UserMessage("go")}} for _, spec := range p.specs { - req.Tools = append(req.Tools, spec.Definition.SDK()) + req.Tools = append(req.Tools, p.defs[spec.Ref]) } ids := make([]run.InputID, len(hint.Inputs)) for i, in := range hint.Inputs { diff --git a/agent/run/runtime.go b/agent/run/runtime.go index fe42e11..f60f819 100644 --- a/agent/run/runtime.go +++ b/agent/run/runtime.go @@ -11,6 +11,10 @@ type Runtime interface { Load(context.Context, RunID) (RuntimeSnapshot, error) Commit(context.Context, CommitRequest) (CommitResult, error) Record(context.Context, RunID) (RunRecord, error) + // FrozenRequest returns the request body a Prepared or Executing ModelStep + // names by RequestDigest (RUN-WIR-4). The body lives in the + // FrozenValueStore, not in the fact; a missing body is ErrFrozenValueMissing. + FrozenRequest(context.Context, Digest) (ModelRequest, error) // RenewLease extends the lease behind grant on the Executing target // (stepID alone for a ModelStep, stepID+callID for a tool call). Workers // call it while an effect runs longer than the lease TTL (RUN-CMT-8). diff --git a/agent/run/runtimetest/conformance.go b/agent/run/runtimetest/conformance.go index 60877ae..78d8a5c 100644 --- a/agent/run/runtimetest/conformance.go +++ b/agent/run/runtimetest/conformance.go @@ -230,7 +230,7 @@ func makeSpec(t testing.TB, def sdk.ToolDefinition) run.ToolSpec { if err != nil { t.Fatal(err) } - return run.ToolSpec{Ref: run.ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: run.DirectExecution} + return run.ToolSpec{Ref: run.ToolRef(def.Name), Name: def.Name, DefinitionDigest: d, Policy: run.DirectExecution} } // makeBinding builds the binding for the index-th call of source whose diff --git a/agent/run/snapshot.go b/agent/run/snapshot.go index 617db97..012a1f9 100644 --- a/agent/run/snapshot.go +++ b/agent/run/snapshot.go @@ -11,14 +11,15 @@ import ( // 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"` - Status RunStatus `json:"status"` - ModelSteps int `json:"modelSteps"` - Usage Usage `json:"usage"` - PendingInputs []AgentInput `json:"pendingInputs"` - LastModelResult *ModelResult `json:"lastModelResult"` - Result *RunResult `json:"result"` - LastToolStep *ToolStep `json:"lastToolStep,omitempty"` + 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"` @@ -34,9 +35,9 @@ const ( func machineStateToWireV1(s *MachineState) (machineStateWireV1, error) { w := machineStateWireV1{ - RunID: s.RunID, Status: s.Status, ModelSteps: s.ModelSteps, + RunID: s.RunID, Owner: s.Owner, Attempt: s.Attempt, Status: s.Status, ModelSteps: s.ModelSteps, Usage: s.Usage, PendingInputs: s.PendingInputs, - LastModelResult: s.LastModelResult, Result: s.Result, LastToolStep: s.LastToolStep, + Result: s.Result, LastToolStep: s.LastToolStep, } switch cur := s.Current.(type) { case nil: @@ -56,9 +57,9 @@ func machineStateToWireV1(s *MachineState) (machineStateWireV1, error) { func machineStateFromWireV1(w *machineStateWireV1) (MachineState, error) { s := MachineState{ - RunID: w.RunID, Status: w.Status, ModelSteps: w.ModelSteps, + RunID: w.RunID, Owner: w.Owner, Attempt: w.Attempt, Status: w.Status, ModelSteps: w.ModelSteps, Usage: w.Usage, PendingInputs: w.PendingInputs, - LastModelResult: w.LastModelResult, Result: w.Result, LastToolStep: w.LastToolStep, + Result: w.Result, LastToolStep: w.LastToolStep, } switch w.Current { case "": diff --git a/agent/run/snapshot_test.go b/agent/run/snapshot_test.go index 9207d76..512ba2f 100644 --- a/agent/run/snapshot_test.go +++ b/agent/run/snapshot_test.go @@ -56,7 +56,7 @@ func TestSnapshotCodecRoundTrip(t *testing.T) { } func TestSnapshotCodecRejectsMalformedWire(t *testing.T) { - initial, err := InitializeRun("run-1") + initial, err := InitializeRun("run-1", "", 0) if err != nil { t.Fatal(err) } diff --git a/agent/run/sqlitestore/store_test.go b/agent/run/sqlitestore/store_test.go index 9bac121..801e0ad 100644 --- a/agent/run/sqlitestore/store_test.go +++ b/agent/run/sqlitestore/store_test.go @@ -337,7 +337,7 @@ func freezeSpec(t *testing.T, def sdk.ToolDefinition) run.ToolSpec { if err != nil { t.Fatal(err) } - return run.ToolSpec{Ref: run.ToolRef(def.Name), Definition: frozen, DefinitionDigest: d, Policy: run.DirectExecution} + return run.ToolSpec{Ref: run.ToolRef(def.Name), Name: def.Name, DefinitionDigest: d, Policy: run.DirectExecution} } func prepareFromSnap(t *testing.T, snap run.RuntimeSnapshot, req sdk.Request, specs []run.ToolSpec) (run.PrepareModelRequest, run.CommandID) { diff --git a/agent/run/state.go b/agent/run/state.go index 50b692a..4f00d41 100644 --- a/agent/run/state.go +++ b/agent/run/state.go @@ -41,9 +41,8 @@ type RunResult struct { // 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"` - Model *ModelResult `json:"model,omitempty"` - Usage Usage `json:"usage"` + UncertainModel StepID `json:"uncertainModel,omitempty"` + Usage Usage `json:"usage"` } type StepFailure struct { @@ -97,10 +96,13 @@ type ResponseRequest struct { } // ToolSpec is the agent-side sidecar for a provider-neutral ToolDefinition. -// ResponsePolicy is intentionally kept out of sdk to preserve package layering. +// 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"` - Definition ToolDefinition `json:"definition"` + Name string `json:"name"` DefinitionDigest Digest `json:"definitionDigest"` Policy ResponsePolicy `json:"policy"` } @@ -133,8 +135,9 @@ type Step interface { Ref() StepRef } -// Current is the contents of an Active run. Open is the enterable interval: -// AcceptInput and Prepare are legal, and Next returns NeedModelRequest. +// 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. @@ -166,8 +169,9 @@ func (s ModelStepStatus) String() string { } type ModelStep struct { - RefValue StepRef `json:"ref"` - Request ModelRequest `json:"request"` + 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"` @@ -214,10 +218,18 @@ func (s ToolCallStatus) String() string { // 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"` @@ -236,17 +248,17 @@ type ToolCallFailure struct { } 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 *ToolExecutionResult `json:"result,omitempty"` - Failure *ToolCallFailure `json:"failure,omitempty"` - Waiting *ResponseRequest `json:"waiting,omitempty"` + 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). @@ -269,8 +281,8 @@ func ValidateToolCallState(c ToolCallState) error { return fmt.Errorf("agent: call %s: waiting must have no result/failure", c.CallID) } case ToolCompleted: - if c.Result == nil { - return fmt.Errorf("agent: call %s: completed requires a result", c.CallID) + 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) @@ -353,20 +365,24 @@ func (s *ToolStep) callIndex(id CallID) int { // MachineState is the complete semantic state of one Run (RUN-MCH-1). // Control metadata (owner, fence, lease, attempts, queue claims) never -// appears here. +// 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"` + 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 - // include committed tool results in the next model request. Its RefValue.ID - // is the SourceStep of the next PlanningHint. - LastToolStep *ToolStep `json:"lastToolStep,omitempty"` - Usage Usage `json:"usage"` - LastModelResult *ModelResult `json:"lastModelResult,omitempty"` - Result *RunResult `json:"result,omitempty"` + // 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 @@ -496,9 +512,9 @@ func validateCurrentToolStep(runID RunID, ts *ToolStep) error { // 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) (MachineState, error) { +func InitializeRun(run RunID, owner OwnerID, attempt uint32) (MachineState, error) { if run == "" { return MachineState{}, errors.New("agent: initialize: empty RunID") } - return MachineState{RunID: run, Status: RunActive, Current: Open{}}, nil + return MachineState{RunID: run, Owner: owner, Attempt: attempt, Status: RunActive, Current: Open{}}, nil } diff --git a/agent/run/stored_runtime.go b/agent/run/stored_runtime.go index 7599b29..ce1c589 100644 --- a/agent/run/stored_runtime.go +++ b/agent/run/stored_runtime.go @@ -14,6 +14,7 @@ import ( // depend only on the Runtime interface. type runtime struct { store Store + frozen FrozenValueStore leaseTTL time.Duration now func() time.Time } @@ -21,10 +22,13 @@ type runtime struct { // RuntimeOptions configures lease occupancy for a Store-backed Runtime. // LeaseTTL 0 means leases do not expire (grant-holder recovery only). A // positive LeaseTTL must exceed the longest gap between a worker's start and -// its next RenewLease or settlement; see RUN-CMT-8. +// its next RenewLease or settlement; see RUN-CMT-8. FrozenValues holds the +// request bodies Prepared steps name by digest; nil selects an in-process +// store that does not survive the Runtime. type RuntimeOptions struct { - LeaseTTL time.Duration - Now func() time.Time + LeaseTTL time.Duration + Now func() time.Time + FrozenValues FrozenValueStore } // NewRuntime constructs a Runtime over store. SQLite and Postgres adapters @@ -42,7 +46,11 @@ func newRuntime(store Store, opts RuntimeOptions) *runtime { if now == nil { now = time.Now } - return &runtime{store: store, leaseTTL: opts.LeaseTTL, now: now} + frozen := opts.FrozenValues + if frozen == nil { + frozen = NewMemoryFrozenValues() + } + return &runtime{store: store, frozen: frozen, leaseTTL: opts.LeaseTTL, now: now} } func (r *runtime) Create(ctx context.Context, run NewRun) (CreateResult, error) { @@ -118,6 +126,18 @@ func (r *runtime) Commit(ctx context.Context, req CommitRequest) (CommitResult, return CommitResult{}, err } runID := req.Command.RunID + // A Prepare carries the request body; the fact will name it by digest, so + // the body must be readable before the fact is visible. Put is idempotent + // and content-addressed: a rejected Prepare leaves a harmless orphan. + if prep, ok := req.Command.Command.(PrepareModelRequest); ok { + body, err := encodeFrozenRequest(&prep.Request, prep.RequestDigest) + if err != nil { + return CommitResult{}, fmt.Errorf("%w: %w", ErrStaleRuntime, err) + } + if err := r.frozen.Put(ctx, prep.RequestDigest, body); err != nil { + return CommitResult{}, err + } + } var result CommitResult // The whole evaluation runs inside the Store's per-Run critical section // (RUN-CMT-2): the head cannot move between read and write, so there is @@ -214,6 +234,16 @@ func (r *runtime) evaluate(tx RunTx, head *RunHead, req *CommitRequest) (CommitR if decision.NewState.Status.Terminal() { appendReq.Leases = LeaseOps{Clear: true} } + // Recovery keeps the frozen request: a Recovered step resends the same + // body. Any other exit from a ModelStep (completion, rejection that fails + // the Run, withdrawal) ends the body's useful life. + if step, ok := req.Command.Command.(WithdrawPreparedStep); ok { + if ms, isModel := head.State.Current.(ModelStep); isModel && ms.RefValue.ID == step.StepID { + if dropper, can := r.frozen.(interface{ Delete(Digest) }); can { + dropper.Delete(ms.RequestDigest) + } + } + } after := RunHead{Header: head.Header, State: decision.NewState, Revision: head.Revision + 1} return CommitResult{Status: CommitAccepted, Snapshot: snapshotOf(&after), Events: cloneEvents(appendReq.Transition.Events), Grant: minted}, &appendReq, nil @@ -248,6 +278,25 @@ func (r *runtime) Record(ctx context.Context, runID RunID) (RunRecord, error) { return RunRecord{Header: cloneRunHeader(head.Header), Snapshot: snapshotOf(&head), Transitions: transitions}, nil } +// FrozenRequest returns the request body named by digest, verifying it still +// digests to that name. +func (r *runtime) FrozenRequest(ctx context.Context, digest Digest) (ModelRequest, error) { + if err := checkContext(ctx); err != nil { + return ModelRequest{}, err + } + if digest == "" { + return ModelRequest{}, errors.New("agent: runtime: empty request digest") + } + raw, ok, err := r.frozen.Get(ctx, digest) + if err != nil { + return ModelRequest{}, err + } + if !ok { + return ModelRequest{}, fmt.Errorf("%w: request %s", ErrFrozenValueMissing, digest) + } + return decodeFrozenRequest(raw, digest) +} + // RenewLease extends the live lease behind grant on the Executing target of // stepID/callID by one LeaseTTL from now. Workers call it periodically while // an effect runs so a long tool call is not recovered as Unknown under it. diff --git a/agent/turn/README.md b/agent/turn/README.md new file mode 100644 index 0000000..8df9cd8 --- /dev/null +++ b/agent/turn/README.md @@ -0,0 +1,7 @@ +# agent/turn (legacy) + +This package implements the pre-single-Session-ES Turn coordinator (per-Run +store, FactMapper, MemoryLog). It no longer matches docs/design/agent-turn.md +and is excluded from the default build with the `legacy_turn` build tag until +the new Turn implementation replaces it. Build or test it with +`go test -tags legacy_turn ./agent/turn/...`. diff --git a/agent/turn/context.go b/agent/turn/context.go index 8f91096..90345b8 100644 --- a/agent/turn/context.go +++ b/agent/turn/context.go @@ -1,3 +1,5 @@ +//go:build legacy_turn + package turn import ( diff --git a/agent/turn/coordinator.go b/agent/turn/coordinator.go index 0794b23..eca64dd 100644 --- a/agent/turn/coordinator.go +++ b/agent/turn/coordinator.go @@ -1,3 +1,5 @@ +//go:build legacy_turn + package turn import ( diff --git a/agent/turn/coordinator_test.go b/agent/turn/coordinator_test.go index 3470003..23c4655 100644 --- a/agent/turn/coordinator_test.go +++ b/agent/turn/coordinator_test.go @@ -1,3 +1,5 @@ +//go:build legacy_turn + package turn_test import ( diff --git a/agent/turn/driver.go b/agent/turn/driver.go index bd015e0..50f4c7e 100644 --- a/agent/turn/driver.go +++ b/agent/turn/driver.go @@ -1,3 +1,5 @@ +//go:build legacy_turn + package turn import ( diff --git a/agent/turn/live_test.go b/agent/turn/live_test.go index a36cc42..3f3b09b 100644 --- a/agent/turn/live_test.go +++ b/agent/turn/live_test.go @@ -1,4 +1,4 @@ -//go:build live +//go:build legacy_turn && live // Live end-to-end test: agent/turn -> agent/run/loop -> a real model through // the sdk. Runs only with `go test -tags live` and the environment below. diff --git a/agent/turn/mapper.go b/agent/turn/mapper.go index 25f8fea..9c30720 100644 --- a/agent/turn/mapper.go +++ b/agent/turn/mapper.go @@ -1,3 +1,5 @@ +//go:build legacy_turn + package turn import ( diff --git a/agent/turn/turn.go b/agent/turn/turn.go index 0fafbe4..7b3c40a 100644 --- a/agent/turn/turn.go +++ b/agent/turn/turn.go @@ -1,3 +1,5 @@ +//go:build legacy_turn + // Package turn is the minimal Turn coordinator: one Turn owns one primary // Run, delivers inputs into it, drives it, and materializes the committed // Run record into chatlog events on an append-only session log. From 253a9af159fc77a330796ded037ebab4e8a6e8b4 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Mon, 7 Sep 2026 21:52:17 +0800 Subject: [PATCH 65/81] Single Session ES: kernel, module framework, run/turn modules, reference assembly Implement the 2026-09-04 decision (agent-runtime-refactor.md 6-7) as code: - agent/session: Memory Store with Commit (CAS), CommitIn, Types-filtered replay, snapshot and control-plane KV (CAS, deadline enumeration). - agent/artifact: Ref, Binding, Memory BindingStore, two-state KV ledger. - agent/session/extension: Registry with Requires checks and payload v, SemanticAppender (both entries), Lease, ProjectionReader. - agent/session/chatlog: events, parts codec, Surface and Context. - agent/session/run: twilight/run/ events, machine projection, Runtime (EvaluateCommit inside the Session critical section, lease and recovery). - agent/run: Session-addressed Runtime contract, RunPosition; per-Run Store, sqlitestore, RunHeader and TransitionRecord removed. - agent/run/loop bound to a Session; agent/turn rewritten (Coordinator, CompanionV1, surface); agent/ref assembles everything with an example. --- agent/artifact/artifact.go | 416 ++++++++++ agent/ref/assembly.go | 181 +++++ agent/ref/binding.go | 148 ++++ agent/ref/example_test.go | 224 ++++++ agent/ref/planner.go | 186 +++++ agent/ref/turn_test.go | 234 ++++++ agent/run/alias_test.go | 212 ----- agent/run/canonical_test.go | 11 +- agent/run/clone.go | 44 -- agent/run/codec.go | 186 +---- agent/run/codec_test.go | 111 +-- agent/run/commit.go | 250 +++--- agent/run/creation.go | 92 +-- agent/run/creation_test.go | 30 +- agent/run/example_run_test.go | 323 -------- agent/run/fold.go | 134 +--- agent/run/fold_test.go | 272 ------- agent/run/frozen.go | 8 +- agent/run/header.go | 112 --- agent/run/header_test.go | 80 -- agent/run/ids.go | 8 +- agent/run/loop/bound.go | 31 + agent/run/loop/cache.go | 55 +- agent/run/loop/contract.go | 17 +- agent/run/loop/events.go | 22 +- agent/run/loop/helpers_test.go | 100 ++- agent/run/loop/lease.go | 2 +- agent/run/loop/lease_test.go | 82 +- agent/run/loop/loop.go | 23 +- agent/run/loop/loop_test.go | 56 +- agent/run/loop/model.go | 23 +- agent/run/loop/regression_test.go | 12 +- agent/run/loop/runtime_helpers_test.go | 29 - agent/run/loop/tool.go | 26 +- agent/run/machine_test.go | 2 +- agent/run/memory_conformance_test.go | 29 - agent/run/memory_store.go | 235 ------ agent/run/next.go | 3 + agent/run/protocol.go | 51 +- agent/run/protocol_v1.go | 9 - agent/run/recover_test.go | 96 --- agent/run/runtest/feature.go | 104 ++- agent/run/runtest/require.go | 4 +- agent/run/runtime.go | 114 ++- agent/run/runtime_helpers_test.go | 160 ---- agent/run/runtimetest/conformance.go | 917 ---------------------- agent/run/runtimetest/recovery.go | 284 ------- agent/run/snapshot_test.go | 48 +- agent/run/sqlitestore/store.go | 573 -------------- agent/run/sqlitestore/store_test.go | 387 --------- agent/run/store.go | 189 ----- agent/run/store_test.go | 195 ----- agent/run/stored_runtime.go | 449 ----------- agent/run/transition.go | 157 ---- agent/session/chatlog/chatlog.go | 412 ++++++++++ agent/session/chatlog/chatlog_test.go | 148 ++++ agent/session/chatlog/projection.go | 311 ++++++++ agent/session/extension/appender.go | 383 +++++++++ agent/session/extension/binding.go | 89 +++ agent/session/extension/extension_test.go | 225 ++++++ agent/session/extension/lease.go | 168 ++++ agent/session/extension/projection.go | 264 +++++++ agent/session/extension/registry.go | 441 +++++++++++ agent/session/memory.go | 644 +++++++++++++++ agent/session/memory_test.go | 152 ++++ agent/session/profile.go | 267 +++++++ agent/session/run/module.go | 150 ++++ agent/session/run/projection.go | 169 ++++ agent/session/run/runtime.go | 646 +++++++++++++++ agent/session/store.go | 154 ++++ agent/session/types.go | 182 +++++ agent/turn/README.md | 7 - agent/turn/companion.go | 143 ++++ agent/turn/context.go | 238 ------ agent/turn/coordinator.go | 629 +++++++++------ agent/turn/coordinator_test.go | 364 --------- agent/turn/driver.go | 24 - agent/turn/live_test.go | 184 ----- agent/turn/mapper.go | 69 -- agent/turn/projection.go | 209 +++++ agent/turn/turn.go | 244 +++--- docs/design/agent-artifact.md | 2 +- docs/design/agent-reference-assembly.md | 4 +- docs/design/agent-run.md | 2 +- docs/design/agent-runtime-refactor.md | 32 +- docs/design/agent-session-chatlog.md | 2 +- docs/design/agent-session-extension.md | 2 +- docs/design/agent-session.md | 2 +- docs/design/agent-turn.md | 2 +- 89 files changed, 7774 insertions(+), 6936 deletions(-) create mode 100644 agent/artifact/artifact.go create mode 100644 agent/ref/assembly.go create mode 100644 agent/ref/binding.go create mode 100644 agent/ref/example_test.go create mode 100644 agent/ref/planner.go create mode 100644 agent/ref/turn_test.go delete mode 100644 agent/run/alias_test.go delete mode 100644 agent/run/example_run_test.go delete mode 100644 agent/run/fold_test.go delete mode 100644 agent/run/header.go delete mode 100644 agent/run/header_test.go create mode 100644 agent/run/loop/bound.go delete mode 100644 agent/run/loop/runtime_helpers_test.go delete mode 100644 agent/run/memory_conformance_test.go delete mode 100644 agent/run/memory_store.go delete mode 100644 agent/run/recover_test.go delete mode 100644 agent/run/runtime_helpers_test.go delete mode 100644 agent/run/runtimetest/conformance.go delete mode 100644 agent/run/runtimetest/recovery.go delete mode 100644 agent/run/sqlitestore/store.go delete mode 100644 agent/run/sqlitestore/store_test.go delete mode 100644 agent/run/store.go delete mode 100644 agent/run/store_test.go delete mode 100644 agent/run/stored_runtime.go delete mode 100644 agent/run/transition.go create mode 100644 agent/session/chatlog/chatlog.go create mode 100644 agent/session/chatlog/chatlog_test.go create mode 100644 agent/session/chatlog/projection.go create mode 100644 agent/session/extension/appender.go create mode 100644 agent/session/extension/binding.go create mode 100644 agent/session/extension/extension_test.go create mode 100644 agent/session/extension/lease.go create mode 100644 agent/session/extension/projection.go create mode 100644 agent/session/extension/registry.go create mode 100644 agent/session/memory.go create mode 100644 agent/session/memory_test.go create mode 100644 agent/session/profile.go create mode 100644 agent/session/run/module.go create mode 100644 agent/session/run/projection.go create mode 100644 agent/session/run/runtime.go create mode 100644 agent/session/store.go create mode 100644 agent/session/types.go delete mode 100644 agent/turn/README.md create mode 100644 agent/turn/companion.go delete mode 100644 agent/turn/context.go delete mode 100644 agent/turn/coordinator_test.go delete mode 100644 agent/turn/driver.go delete mode 100644 agent/turn/live_test.go delete mode 100644 agent/turn/mapper.go create mode 100644 agent/turn/projection.go diff --git a/agent/artifact/artifact.go b/agent/artifact/artifact.go new file mode 100644 index 0000000..1c8c151 --- /dev/null +++ b/agent/artifact/artifact.go @@ -0,0 +1,416 @@ +// Package artifact is the Artifact Core (docs/design/agent-artifact.md): Ref, +// Binding and the two-state RetentionLedger. v1 keeps the Memory reference +// implementation only; claims are activated inside the host's transaction +// through ClaimKV. +package artifact + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "sync" + + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/jsonstable" +) + +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"` +} + +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] +} + +// ClaimKV is the host's same-transaction KV view. In Session deployments it +// adapts session.SessionTx's control-plane KV (namespace twilight/artifact/claim). +type ClaimKV interface { + Get(key string) ([]byte, bool, error) + Put(key string, value []byte) error + Delete(key string) error +} + +// RetentionLedger keeps Active/Released claims (ART-RET-2). +type RetentionLedger interface { + ActivateIn(kv ClaimKV, id ClaimID, owner ClaimOwner, set BindingSet) (RetentionClaim, error) + LookupClaim(context.Context, ClaimID) (RetentionClaim, bool, error) + ReleaseActive(context.Context, ClaimID) error +} + +// KVLedger stores claims as canonical JSON under the ClaimID key. Reads and +// releases outside a transaction go through the ClaimKVProvider the host +// supplies (for Session: a store-backed adapter). +type KVLedger struct { + Builder BindingSetBuilder + // Outside is the non-transactional KV view for LookupClaim/ReleaseActive. + Outside func(context.Context) ClaimKV +} + +func (l KVLedger) ActivateIn(kv ClaimKV, id ClaimID, owner ClaimOwner, set BindingSet) (RetentionClaim, error) { + 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"} + } + claim := RetentionClaim{ID: id, Owner: owner, BindingSet: set, State: ClaimActive} + raw, ok, err := kv.Get(string(id)) + if err != nil { + return RetentionClaim{}, err + } + if ok { + var existing RetentionClaim + if err := json.Unmarshal(raw, &existing); err != nil { + return RetentionClaim{}, &Error{Code: ErrCorrupt, Operation: "activate", Identity: string(id), Detail: err.Error()} + } + if existing.State != ClaimActive || existing.Owner != owner || !sameSet(existing.BindingSet, set) { + return RetentionClaim{}, &Error{Code: ErrConflict, Operation: "activate", Identity: string(id)} + } + return existing, nil + } + encoded, err := jsonstable.MarshalCanonical(claim) + if err != nil { + return RetentionClaim{}, err + } + if err := kv.Put(string(id), encoded); err != nil { + return RetentionClaim{}, err + } + return claim, nil +} + +func (l KVLedger) LookupClaim(ctx context.Context, id ClaimID) (RetentionClaim, bool, error) { + if l.Outside == nil { + return RetentionClaim{}, false, errors.New("artifact: ledger: no outside KV") + } + raw, ok, err := l.Outside(ctx).Get(string(id)) + if err != nil || !ok { + return RetentionClaim{}, false, err + } + var claim RetentionClaim + if err := json.Unmarshal(raw, &claim); err != nil { + return RetentionClaim{}, false, &Error{Code: ErrCorrupt, Operation: "lookup_claim", Identity: string(id), Detail: err.Error()} + } + return claim, true, nil +} + +func (l KVLedger) ReleaseActive(ctx context.Context, id ClaimID) error { + claim, ok, err := l.LookupClaim(ctx, id) + if err != nil { + return err + } + if !ok { + return &Error{Code: ErrNotFound, Operation: "release", Identity: string(id)} + } + if claim.State == ClaimReleased { + return nil + } + claim.State = ClaimReleased + encoded, err := jsonstable.MarshalCanonical(claim) + if err != nil { + return err + } + return l.Outside(ctx).Put(string(id), encoded) +} + +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/ref/assembly.go b/agent/ref/assembly.go new file mode 100644 index 0000000..3dbea76 --- /dev/null +++ b/agent/ref/assembly.go @@ -0,0 +1,181 @@ +package ref + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/memohai/twilight/agent/artifact" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/chatlog" + "github.com/memohai/twilight/agent/session/extension" + runmod "github.com/memohai/twilight/agent/session/run" + "github.com/memohai/twilight/agent/turn" +) + +// Options tunes the Memory assembly. +type Options struct { + // LeaseTTL zero means execution leases never expire. + LeaseTTL time.Duration + 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 + // Sink receives Loop observations; nil discards them. + Sink loop.EventSink +} + +// Memory is the fully wired in-process agent (REF 5). +type Memory struct { + Store session.Store + Registry *extension.Registry + Projections extension.ProjectionReader + Bindings *Bindings + Appender extension.SemanticAppender + Runtime *runmod.Runtime + Coordinator *turn.Coordinator + BindingStore *artifact.MemoryBindingStore + now func() time.Time +} + +// New assembles store, registry, appender, projections, runtime and +// coordinator over the three first-party modules. +func New(opts Options) (*Memory, error) { + store := opts.Store + if store == nil { + store = session.NewMemoryStore() + } + registry, err := extension.BuildRegistry(session.ProfileV1(), chatlog.Module, runmod.Module, turn.Module) + if err != nil { + return nil, err + } + bindings := artifact.NewMemoryBindingStore() + ledger := artifact.KVLedger{Builder: artifact.SetBuilder{Resolver: bindings}} + appender, err := extension.NewSemanticAppender(store, registry, artifact.SetBuilder{Resolver: bindings}, ledger) + if err != nil { + return nil, err + } + projections := extension.NewProjectionReader(store, registry) + now := opts.Now + if now == nil { + now = time.Now + } + runtime, err := runmod.NewRuntime(runmod.Config{ + Store: store, Registry: registry, Appender: appender, Projections: projections, + Frozen: opts.Frozen, Companion: turn.CompanionV1{}, LeaseTTL: opts.LeaseTTL, Now: now, + }) + if err != nil { + return nil, err + } + m := &Memory{Store: store, Registry: registry, Projections: projections, Appender: appender, Runtime: runtime, BindingStore: bindings, now: now} + m.Bindings = NewBindings(runtime, projections, opts.Sink) + m.Coordinator = &turn.Coordinator{Projections: projections, Appender: appender, Runtime: runtime, Bindings: m.Bindings, Now: now} + return m, nil +} + +// 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}) + return err +} + +// 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) + head, err := m.Store.Head(ctx, sid) + if err != nil { + return run.AgentInput{}, err + } + res, err := m.Appender.AppendSemantic(ctx, extension.SemanticAppendRequest{ + SessionID: sid, ExpectedHead: head, + Group: 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()}, + }}}, + }) + if err != nil { + return run.AgentInput{}, err + } + switch res.Outcome { + case extension.SemanticApplied, extension.SemanticAlreadyApplied: + return run.AgentInput{ID: id, Payload: content}, nil + case extension.SemanticHeadConflict: + // Another writer moved the head; the caller retries with a fresh head. + return m.SubmitInput(ctx, sid, id, text) + default: + return run.AgentInput{}, fmt.Errorf("ref: submit input: %s: %s", res.Outcome, res.Detail) + } +} + +// ChatlogSurface reads the chatlog surface projection. +func (m *Memory) ChatlogSurface(ctx context.Context, sid session.SessionID) (chatlog.Surface, error) { + state, _, err := m.Projections.Load(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.Projections.Load(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 + Binding turn.ExecutionBindingRef + Companion turn.CompanionVersion + NewTurnID func() turn.TurnID +} + +// Send is REF-DRV-1: Deliver into the active Turn, or Start a new one. +func (d *SessionDriver) Send(ctx context.Context, sid session.SessionID, inputs []run.AgentInput) (turn.TurnResponse, error) { + if d.NewTurnID == nil { + return turn.TurnResponse{}, errors.New("ref: session driver requires NewTurnID") + } + surface, err := d.Memory.TurnSurface(ctx, sid) + if err != nil { + return turn.TurnResponse{}, err + } + if active, ok := surface.Active(); ok { + return d.Coordinator.Deliver(ctx, turn.DeliverRequest{Ref: turn.TurnRef{SessionID: sid, TurnID: active.TurnID}, Inputs: inputs}) + } + 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) + } + } + return d.Coordinator.Start(ctx, turn.StartRequest{Ref: turn.TurnRef{SessionID: sid, TurnID: d.NewTurnID()}, Inputs: inputs, + ExecutionBinding: d.Binding, Companion: d.Companion}) +} + +// OnTurnSettled is REF-DRV-2: 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/binding.go b/agent/ref/binding.go new file mode 100644 index 0000000..44bb7d7 --- /dev/null +++ b/agent/ref/binding.go @@ -0,0 +1,148 @@ +// Package ref is the Memory reference assembly +// (docs/design/agent-reference-assembly.md): ExecutionBinding, the context +// Planner, the Session-scoped input router and the wiring of every layer into +// one in-process agent. +package ref + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/turn" + "github.com/memohai/twilight/sdk" +) + +// PlannerID names the reference planner (REF 2). +const PlannerID = "twilight/turn/planner/context-v1" + +type PublicTool struct { + Ref run.ToolRef `json:"ref"` + Definition run.ToolDefinition `json:"definition"` + Policy run.ResponsePolicy `json:"policy"` +} + +// BindingPublic is the digest-covered public configuration of a binding +// (REF-BND-1). Credentials and clients stay in process. +type BindingPublic struct { + SchemaVersion uint16 `json:"schemaVersion"` + Model run.ModelRef `json:"model"` + Tools []PublicTool `json:"tools,omitempty"` + Streaming bool `json:"streaming,omitempty"` + PlannerID string `json:"plannerId"` + SystemPrompt string `json:"systemPrompt,omitempty"` +} + +func DigestBinding(pub *BindingPublic) (es.Digest, error) { + raw, err := es.EncodeTypedPayload(1, "twilight/turn/binding", pub) + if err != nil { + return "", err + } + return es.DigestBytes(raw), nil +} + +// ToolSpecs derives the frozen ToolSpecs and provider definitions of pub, in +// order (REF-PLN-4). +func (pub *BindingPublic) ToolSpecs() ([]run.ToolSpec, []sdk.ToolDefinition, error) { + specs := make([]run.ToolSpec, 0, len(pub.Tools)) + defs := make([]sdk.ToolDefinition, 0, len(pub.Tools)) + for _, t := range pub.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 +} + +// Binding is one resolvable execution binding: public config plus the +// in-process model and tool catalogs. +type Binding struct { + Public BindingPublic + Models loop.ModelCatalog + Tools loop.ToolCatalog + Policy loop.ExecutionPolicy +} + +// Bindings is the in-process ExecutionBindingRegistry (REF-BND-2). +type Bindings struct { + runtime run.Runtime + projections ProjectionSource + sink loop.EventSink + + mu sync.RWMutex + byID map[turn.ExecutionBindingID]Binding +} + +// 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 NewBindings(runtime run.Runtime, projections ProjectionSource, sink loop.EventSink) *Bindings { + return &Bindings{runtime: runtime, projections: projections, sink: sink, byID: map[turn.ExecutionBindingID]Binding{}} +} + +// Register stores a binding and returns the ref the Session records. +func (b *Bindings) Register(id turn.ExecutionBindingID, binding Binding) (turn.ExecutionBindingRef, error) { + if id == "" || binding.Models == nil || binding.Tools == nil || binding.Public.Model == "" { + return turn.ExecutionBindingRef{}, errors.New("ref: binding requires id, model, model catalog and tool catalog") + } + if binding.Public.SchemaVersion == 0 { + binding.Public.SchemaVersion = 1 + } + if binding.Public.PlannerID == "" { + binding.Public.PlannerID = PlannerID + } + digest, err := DigestBinding(&binding.Public) + if err != nil { + return turn.ExecutionBindingRef{}, err + } + b.mu.Lock() + b.byID[id] = binding + b.mu.Unlock() + return turn.ExecutionBindingRef{ID: id, Digest: digest}, nil +} + +// Resolve returns a RunDriver when the ref's digest matches the registered +// public configuration (REF-BND-2). +func (b *Bindings) Resolve(ref turn.ExecutionBindingRef) (turn.RunDriver, error) { + b.mu.RLock() + binding, ok := b.byID[ref.ID] + b.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("ref: unknown binding %s", ref.ID) + } + digest, err := DigestBinding(&binding.Public) + if err != nil { + return nil, err + } + if digest != ref.Digest { + return nil, fmt.Errorf("ref: binding %s digest mismatch", ref.ID) + } + planner := &ContextPlanner{Projections: b.projections, Public: binding.Public} + l, err := loop.New(binding.Models, binding.Tools, planner, binding.Policy, binding.Public.Streaming) + if err != nil { + return nil, err + } + return loopDriver{loop: l, runtime: b.runtime, sink: b.sink}, nil +} + +// loopDriver is TRN-DRV-1: Drive is loop.Run. +type loopDriver struct { + loop *loop.Loop + runtime run.Runtime + sink loop.EventSink +} + +func (d loopDriver) Drive(ctx context.Context, req turn.DriveRequest) error { + _, err := d.loop.Run(ctx, d.runtime, req.Ref.SessionID, req.RunID, d.sink) + return err +} diff --git a/agent/ref/example_test.go b/agent/ref/example_test.go new file mode 100644 index 0000000..7973d28 --- /dev/null +++ b/agent/ref/example_test.go @@ -0,0 +1,224 @@ +package ref_test + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/memohai/twilight/agent/ref" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/chatlog" + "github.com/memohai/twilight/agent/turn" + "github.com/memohai/twilight/sdk" +) + +// Example_recoverableTurn drives one Turn through a process crash on the +// single Session stream. +// +// Process 1 creates the Session, 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 with a live lease. Nothing is written on the way down. +// +// Process 2 reopens the same Session store. The lease has expired, so +// RecoverExpired settles the abandoned call as Unknown in the same commit 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 turn surface and the chatlog then agree on what +// happened without any cross-store reconciliation. +func Example_recoverableTurn() { + ctx := context.Background() + const sid session.SessionID = "session-1" + const leaseTTL = 30 * time.Second + 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, LeaseTTL: leaseTTL, Now: clock.Now}) + if err != nil { + panic(err) + } + if err := p1.CreateSession(ctx, sid); err != nil { + panic(err) + } + binding1, err := p1.Bindings.Register("weather-agent", newBinding(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}, + ExecutionBinding: binding1, Companion: turn.CompanionV1Version}) + startDone <- err + }() + runID := waitForExecutingCall(ctx, p1, sid, ref1.TurnID) + fmt.Println("process 1: tool call is Executing; process crashes") + + // ---- process 2 ---------------------------------------------------------- + clock.Advance(2 * leaseTTL) + p2, err := ref.New(ref.Options{Store: store, Frozen: frozen, LeaseTTL: leaseTTL, Now: clock.Now}) + if err != nil { + panic(err) + } + // The binding is re-registered from the same public configuration, so the + // ref the Session recorded still resolves. + if _, err := p2.Bindings.Register("weather-agent", newBinding(tool)); err != nil { + panic(err) + } + snap, err := p2.Runtime.Load(ctx, sid, runID) + if err != nil { + panic(err) + } + fmt.Printf("process 2: run active = %v, needs recovery = %v\n", snap.State.Status == run.RunActive, run.NeedsRecovery(snap.State)) + + recovered, err := p2.Runtime.RecoverExpired(ctx) + if err != nil { + panic(err) + } + chat, err := p2.ChatlogSurface(ctx, sid) + if err != nil { + panic(err) + } + fmt.Printf("recovered %d lease; chatlog has %d tool_result(s) with status %s\n", recovered, len(chat.ToolResults), toolResultStatus(&chat)) + + resp, err := p2.Coordinator.Resume(ctx, turn.TurnRequest{Ref: 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 rejected because the + // lease it held was released by recovery. + close(tool.block) + <-startDone + + // Output: + // process 1: tool call is Executing; process crashes + // process 2: run active = true, needs recovery = true + // recovered 1 lease; 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 +} + +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 newBinding(tool *lookupTool) ref.Binding { + def, err := run.FreezeToolDefinition(tool.Definition()) + if err != nil { + panic(err) + } + return ref.Binding{ + Public: ref.BindingPublic{Model: "m-1", Tools: []ref.PublicTool{{Ref: tool.Ref(), Definition: def, Policy: run.DirectExecution}}}, + Models: modelCatalog{&scriptedModel{}}, + Tools: toolCatalog{tool}, + Policy: loop.ExecutionPolicy{LeaseRenewInterval: 5 * time.Second}, + } +} + +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 +} + +func (c *fakeClock) Advance(d time.Duration) { + c.mu.Lock() + c.now = c.now.Add(d) + c.mu.Unlock() +} + +type modelCatalog struct{ m loop.ModelInvoker } + +func (c modelCatalog) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { return c.m, nil } + +type toolCatalog struct{ tool *lookupTool } + +func (c toolCatalog) ResolveTool(ref run.ToolRef) (loop.ExecutableTool, error) { + if ref != c.tool.Ref() { + return nil, fmt.Errorf("unknown tool %q", ref) + } + return c.tool, nil +} + +// 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.ToolExecutionUnknown{Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: "process died"}} + } + return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} +} diff --git a/agent/ref/planner.go b/agent/ref/planner.go new file mode 100644 index 0000000..634a957 --- /dev/null +++ b/agent/ref/planner.go @@ -0,0 +1,186 @@ +package ref + +import ( + "context" + "errors" + "fmt" + + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/twilight/agent/session/chatlog" + "github.com/memohai/twilight/agent/session/extension" + "github.com/memohai/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 + Public BindingPublic + // 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.Public.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.Public.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.Public.Model, + Request: sdk.Request{Model: string(p.Public.Model), Messages: msgs, Tools: defs}, + InputIDs: ids, + PlanningToken: run.PlanningToken(fmt.Sprintf("%d:%s", head.Revision, 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.Public.SystemPrompt != "" { + msgs = append(msgs, sdk.SystemMessage(p.Public.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/turn_test.go b/agent/ref/turn_test.go new file mode 100644 index 0000000..d4166a3 --- /dev/null +++ b/agent/ref/turn_test.go @@ -0,0 +1,234 @@ +package ref_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/memohai/twilight/agent/ref" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/chatlog" + "github.com/memohai/twilight/agent/turn" + "github.com/memohai/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 +} + +type gateCatalog struct{ tool *gateTool } + +func (c gateCatalog) ResolveTool(ref run.ToolRef) (loop.ExecutableTool, error) { return c.tool, 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.ExecutionBindingRef, session.SessionID) { + t.Helper() + m, err := ref.New(ref.Options{LeaseTTL: time.Hour}) + if err != nil { + t.Fatal(err) + } + const sid session.SessionID = "s-1" + if err := m.CreateSession(context.Background(), sid); err != nil { + t.Fatal(err) + } + def, err := run.FreezeToolDefinition(tool.Definition()) + if err != nil { + t.Fatal(err) + } + binding, err := m.Bindings.Register("b1", ref.Binding{ + Public: ref.BindingPublic{Model: "m-1", SystemPrompt: "be brief", Tools: []ref.PublicTool{{Ref: tool.Ref(), Definition: def, Policy: run.DirectExecution}}}, + Models: modelCatalog{model}, Tools: gateCatalog{tool}, + }) + if err != nil { + t.Fatal(err) + } + return m, binding, 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}, ExecutionBinding: binding, Companion: turn.CompanionV1Version}) + 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, Binding: binding, Companion: turn.CompanionV1Version, NewTurnID: func() turn.TurnID { return "t2" }} + // Deliver commits AcceptInput + input_delivered without waiting for the + // tool; Drive is skipped because the Run is already being driven here, so + // route through the Coordinator directly in a goroutine. + deliverDone := make(chan error, 1) + go func() { + _, err := driver.Send(ctx, sid, []run.AgentInput{second}) + deliverDone <- err + }() + // 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 err := <-deliverDone; err != nil && !errors.Is(err, loop.ErrRunAlreadyRunning) { + t.Fatalf("deliver: %v", err) + } + 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) + _, _ = m.Coordinator.Start(ctx, turn.StartRequest{Ref: ref1, Inputs: []run.AgentInput{first}, ExecutionBinding: binding, Companion: turn.CompanionV1Version}) + }() + <-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, Binding: 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/alias_test.go b/agent/run/alias_test.go deleted file mode 100644 index c85fb20..0000000 --- a/agent/run/alias_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package run - -import ( - "context" - "testing" - - "github.com/memohai/twilight/sdk" -) - -func TestCommitSnapshotsCommandPayloadBeforeFoldingState(t *testing.T) { - rt := newTestRuntime(t) - raw := []byte(`{"v":"one"}`) - payload, err := ParseCanonicalJSON(raw) - if err != nil { - t.Fatal(err) - } - cmdID := DeriveInputCommandID("run-1", "in-1") - snapshot, err := rt.Load(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } - mustCommit(t, rt, cmdID, snapshot.Revision, "", AcceptInput{Input: AgentInput{ID: "in-1", Payload: payload}}) - - copy(raw, []byte(`{"v":"two"}`)) - - snap, err := rt.Load(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } - var found bool - for _, in := range snap.State.PendingInputs { - if in.ID == "in-1" { - found = true - if got := in.Payload.String(); got != `{"v":"one"}` { - t.Fatalf("state payload aliased command buffer: %s", got) - } - } - } - if !found { - t.Fatal("accepted input not found") - } - - events := recordEvents(t, rt, "run-1") - for _, e := range events { - if f, ok := e.Fact.(InputAccepted); ok && f.Input.ID == "in-1" { - if got := f.Input.Payload.String(); got != `{"v":"one"}` { - t.Fatalf("event payload aliased command buffer: %s", got) - } - } - } - diverged, err := rebuildRun(t, rt, "run-1") - if err != nil { - t.Fatal(err) - } - if diverged { - t.Fatal("state diverged from log after command buffer mutation") - } -} - -// The frozen request body is stored canonically in the FrozenValueStore and -// named by digest in the fact (RUN-WIR-4). -func TestCommitCanonicalizesFrozenRequestBeforePersisting(t *testing.T) { - rt := newTestRuntime(t) - snap, _ := rt.Load(context.Background(), "run-1") - req := ModelRequest{ - Model: "m-1", - ProviderOptions: map[string]CanonicalJSON{ - "p": cj(`{"b":2,"a":1}`), - }, - } - reqDigest, err := ProtocolV1().DigestRequest(req) - if err != nil { - t.Fatal(err) - } - toolsDigest, err := ProtocolV1().DigestToolSpecs(nil) - if err != nil { - t.Fatal(err) - } - model := ModelRef(req.Model) - binding, err := ProtocolV1().DigestModelStepBinding(model, reqDigest, toolsDigest) - if err != nil { - t.Fatal(err) - } - cmdID := DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) - ids := make([]InputID, len(snap.State.PendingInputs)) - for i, in := range snap.State.PendingInputs { - ids[i] = in.ID - } - res := mustCommit(t, rt, cmdID, snap.Revision, "", PrepareModelRequest{ - StepID: DeriveModelStepID(snap.State.RunID, cmdID, binding), - Model: model, - Request: req, - RequestDigest: reqDigest, - InputIDs: ids, - ToolsDigest: toolsDigest, - }) - - ms := res.Snapshot.State.Current.(ModelStep) - if ms.RequestDigest != reqDigest { - t.Fatalf("snapshot RequestDigest = %s, want %s", ms.RequestDigest, reqDigest) - } - stored, err := rt.FrozenRequest(context.Background(), ms.RequestDigest) - if err != nil { - t.Fatal(err) - } - if got := stored.ProviderOptions["p"].String(); got != `{"a":1,"b":2}` { - t.Fatalf("frozen store returned non-canonical provider option: %s", got) - } - for _, e := range recordEvents(t, rt, "run-1") { - if f, ok := e.Fact.(ModelStepPrepared); ok && f.RequestDigest != reqDigest { - t.Fatalf("event RequestDigest = %s, want %s", f.RequestDigest, reqDigest) - } - } - if _, err := rt.FrozenRequest(context.Background(), "sha256:missing"); err == nil { - t.Fatal("unknown digest returned a request") - } -} - -func TestFrozenRequestDoesNotAliasStoredBody(t *testing.T) { - rt := newTestRuntime(t) - meta := map[string]any{"provider": map[string]any{"sig": "s1"}} - req := sdk.Request{ - Model: "m-1", - Messages: []sdk.Message{{ - Role: sdk.MessageRoleUser, - Content: []sdk.MessagePart{sdk.TextPart{ - Text: "hi", - ProviderMetadata: meta, - }}, - }}, - } - snap, _ := rt.Load(context.Background(), "run-1") - prep, cmdID := buildPrepareFromSnap(t, snap, req, nil) - mustCommit(t, rt, cmdID, snap.Revision, "", prep) - - first, err := rt.FrozenRequest(context.Background(), prep.RequestDigest) - if err != nil { - t.Fatal(err) - } - part := first.Messages[0].Content[0] - part.Text = "edited" - part.ProviderMetadata["provider"] = cj(`{"sig":"bad"}`) - part.ProviderMetadata["new"] = cj(`"bad"`) - first.Messages[0].Content[0] = part - - second, err := rt.FrozenRequest(context.Background(), prep.RequestDigest) - if err != nil { - t.Fatal(err) - } - got := second.Messages[0].Content[0] - if got.Text != "hi" { - t.Fatalf("request content aliased a previous read: %q", got.Text) - } - if sig := got.ProviderMetadata["provider"].String(); sig != `{"sig":"s1"}` { - t.Fatalf("request metadata aliased a previous read: %v", sig) - } - if _, ok := got.ProviderMetadata["new"]; ok { - t.Fatal("request metadata accepted mutation from a previous read") - } -} - -// ModelStepCompleted carries the digest of the frozen result and its usage; -// the result body itself never enters state or log (RUN-WIR-4). -func TestModelStepCompletedCarriesResultDigestOnly(t *testing.T) { - rt, stepID, grant := preparedRuntime(t, nil, nil) - result := sdk.ModelResult{ - Text: "ok", - FinishReason: sdk.FinishReasonStop, - Usage: sdk.Usage{InputTokens: 2, OutputTokens: 3, TotalTokens: 5}, - ReasoningParts: []sdk.ReasoningPart{{ - ID: "r1", - Text: "why", - Format: sdk.ReasoningFormatAnthropic, - ProviderMetadata: map[string]any{"anthropic": map[string]any{"signature": "s1"}}, - }}, - Response: &sdk.ResponseMetadata{ID: "resp-1", Headers: map[string]string{"h": "v"}}, - } - frozen, err := FreezeModelResult(result) - if err != nil { - t.Fatal(err) - } - wantDigest, err := ProtocolV1().DigestModelResult(frozen) - if err != nil { - t.Fatal(err) - } - snapshot, err := rt.Load(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } - res := mustCommit(t, rt, "done-1", snapshot.Revision, grant, SubmitModelResult{StepID: stepID, Result: frozen}) - - fact := res.Events[0].Fact.(ModelStepCompleted) - if fact.ResultDigest != wantDigest || fact.Usage.TotalTokens != 5 || fact.FinishReason != FinishReasonStop { - t.Fatalf("completed = %+v", fact) - } - if res.Snapshot.State.Usage.TotalTokens != 5 { - t.Fatalf("usage = %+v", res.Snapshot.State.Usage) - } - if res.Snapshot.State.Status != RunCompleted { - t.Fatalf("status = %v, want completed", res.Snapshot.State.Status) - } - // Mutating the caller's frozen result after commit changes nothing the - // authority holds: the digest was taken before the transition. - frozen.Text = "changed" - diverged, err := rebuildRun(t, rt, "run-1") - if err != nil { - t.Fatal(err) - } - if diverged { - t.Fatal("state diverged from log") - } -} diff --git a/agent/run/canonical_test.go b/agent/run/canonical_test.go index 26eb575..6582a3f 100644 --- a/agent/run/canonical_test.go +++ b/agent/run/canonical_test.go @@ -136,15 +136,18 @@ func TestDigestCommandIdentity(t *testing.T) { 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) + id1 := DeriveModelRequestCommandID("run-1", RunPosition{Revision: 7}) + id2 := DeriveModelRequestCommandID("run-1", RunPosition{Revision: 7}) if id1 != id2 { t.Fatal("derive is not deterministic") } - if id1 == DeriveModelRequestCommandID("run-1", 8) { + if id1 == DeriveModelRequestCommandID("run-1", RunPosition{Revision: 8}) { t.Fatal("revision does not separate command IDs") } - if id1 == DeriveModelRequestCommandID("run-2", 7) { + if id1 == DeriveModelRequestCommandID("run-1", RunPosition{Revision: 7, Index: 1}) { + t.Fatal("index does not separate command IDs") + } + if id1 == DeriveModelRequestCommandID("run-2", RunPosition{Revision: 7}) { t.Fatal("run does not separate command IDs") } // Namespaces must not collide even with aligned parts. diff --git a/agent/run/clone.go b/agent/run/clone.go index b334d37..cf66dcd 100644 --- a/agent/run/clone.go +++ b/agent/run/clone.go @@ -297,47 +297,3 @@ func cloneFact(f Fact) Fact { return f } } - -func cloneEvents(events []AgentEvent) []AgentEvent { - if events == nil { - return nil - } - out := make([]AgentEvent, len(events)) - for i, e := range events { - e.Fact = cloneFact(e.Fact) - out[i] = e - } - return out -} - -func cloneTransitionRecord(record *TransitionRecord) TransitionRecord { - out := *record - out.Events = cloneEvents(out.Events) - return out -} - -func cloneTransitionRecords(records []TransitionRecord) []TransitionRecord { - if records == nil { - return nil - } - out := make([]TransitionRecord, len(records)) - for i := range records { - out[i] = cloneTransitionRecord(&records[i]) - } - return out -} - -func flattenTransitionRecords(records []TransitionRecord) []AgentEvent { - var total int - for i := range records { - total += len(records[i].Events) - } - if total == 0 { - return nil - } - out := make([]AgentEvent, 0, total) - for i := range records { - out = append(out, cloneEvents(records[i].Events)...) - } - return out -} diff --git a/agent/run/codec.go b/agent/run/codec.go index 6307a14..d8b0af4 100644 --- a/agent/run/codec.go +++ b/agent/run/codec.go @@ -6,62 +6,30 @@ import ( "errors" "fmt" "io" + + "github.com/memohai/twilight/agent/session" ) type commandEnvelopeWire struct { - SchemaVersion uint16 `json:"schemaVersion"` - Type string `json:"type"` - RunID RunID `json:"runId"` - ID CommandID `json:"id"` - Digest Digest `json:"digest"` - Command json.RawMessage `json:"command"` + SchemaVersion uint16 `json:"schemaVersion"` + Type string `json:"type"` + SessionID session.SessionID `json:"sessionId,omitempty"` + RunID RunID `json:"runId"` + ID CommandID `json:"id"` + Digest Digest `json:"digest"` + Command json.RawMessage `json:"command"` } type commandEnvelopeMarshal struct { - SchemaVersion uint16 `json:"schemaVersion"` - Type string `json:"type"` - RunID RunID `json:"runId"` - ID CommandID `json:"id"` - Digest Digest `json:"digest"` - Command AgentCommand `json:"command"` -} - -type agentEventWire struct { - SchemaVersion uint16 `json:"schemaVersion"` - Type string `json:"type"` - RunID RunID `json:"runId"` - Revision uint64 `json:"revision"` - Index uint16 `json:"index"` - CommandID CommandID `json:"commandId"` - CommandDigest Digest `json:"commandDigest"` - Digest Digest `json:"digest"` - Fact json.RawMessage `json:"fact"` -} - -type agentEventMarshal struct { - SchemaVersion uint16 `json:"schemaVersion"` - Type string `json:"type"` - RunID RunID `json:"runId"` - Revision uint64 `json:"revision"` - Index uint16 `json:"index"` - CommandID CommandID `json:"commandId"` - CommandDigest Digest `json:"commandDigest"` - Digest Digest `json:"digest"` - Fact Fact `json:"fact"` + SchemaVersion uint16 `json:"schemaVersion"` + Type string `json:"type"` + SessionID session.SessionID `json:"sessionId,omitempty"` + RunID RunID `json:"runId"` + ID CommandID `json:"id"` + Digest Digest `json:"digest"` + Command AgentCommand `json:"command"` } -type transitionRecordWire struct { - SchemaVersion uint16 `json:"schemaVersion"` - RunID RunID `json:"runId"` - Revision uint64 `json:"revision"` - CommandID CommandID `json:"commandId"` - CommandDigest Digest `json:"commandDigest"` - Events []AgentEvent `json:"events"` - TransitionDigest Digest `json:"transitionDigest"` -} - -type transitionRecordMarshal = transitionRecordWire - // DecodeCommandEnvelope decodes the persisted command wire shape and restores // the sealed command variant from Type. The digest is verified during decode; // malformed or unsupported wire data is rejected before it can enter Runtime. @@ -73,26 +41,6 @@ func DecodeCommandEnvelope(raw []byte) (CommandEnvelope, error) { return env, nil } -// DecodeAgentEvent decodes the persisted event wire shape and restores the -// sealed fact variant from Type. The fact digest is verified during decode. -func DecodeAgentEvent(raw []byte) (AgentEvent, error) { - var event AgentEvent - if err := decodeStrictJSON(raw, &event); err != nil { - return AgentEvent{}, err - } - return event, nil -} - -// DecodeTransitionRecord decodes the persisted transition aggregate and -// verifies that the complete event group is internally consistent. -func DecodeTransitionRecord(raw []byte) (TransitionRecord, error) { - var record TransitionRecord - if err := decodeStrictJSON(raw, &record); err != nil { - return TransitionRecord{}, err - } - return record, 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 { @@ -108,6 +56,7 @@ func (e CommandEnvelope) MarshalJSON() ([]byte, error) { return json.Marshal(commandEnvelopeMarshal{ SchemaVersion: e.SchemaVersion, Type: typ, + SessionID: e.SessionID, RunID: e.RunID, ID: e.ID, Digest: e.Digest, @@ -141,6 +90,7 @@ func (e *CommandEnvelope) UnmarshalJSON(raw []byte) error { if err := requireCanonicalEquivalent(raw, commandEnvelopeMarshal{ SchemaVersion: wire.SchemaVersion, Type: wire.Type, + SessionID: wire.SessionID, RunID: wire.RunID, ID: wire.ID, Digest: wire.Digest, @@ -151,6 +101,7 @@ func (e *CommandEnvelope) UnmarshalJSON(raw []byte) error { *e = CommandEnvelope{ SchemaVersion: wire.SchemaVersion, Type: wire.Type, + SessionID: wire.SessionID, RunID: wire.RunID, ID: wire.ID, Digest: wire.Digest, @@ -159,105 +110,6 @@ func (e *CommandEnvelope) UnmarshalJSON(raw []byte) error { return nil } -//nolint:gocritic // hugeParam: value receiver keeps json.Marshaler active for non-pointer AgentEvent values. -func (e AgentEvent) MarshalJSON() ([]byte, error) { - if e.Fact == nil { - return nil, errors.New("agent: codec: event has nil fact") - } - typ := factType(e.Fact) - if typ == "" { - return nil, fmt.Errorf("agent: codec: unknown fact variant %T", e.Fact) - } - if e.Type != "" && e.Type != typ { - return nil, fmt.Errorf("agent: codec: event type %q does not match variant %q", e.Type, typ) - } - return json.Marshal(agentEventMarshal{ - SchemaVersion: e.SchemaVersion, - Type: typ, - RunID: e.RunID, - Revision: e.Revision, - Index: e.Index, - CommandID: e.CommandID, - CommandDigest: e.CommandDigest, - Digest: e.Digest, - Fact: e.Fact, - }) -} - -//nolint:gocritic // hugeParam: value receiver keeps json.Marshaler active for non-pointer TransitionRecord values. -func (r TransitionRecord) MarshalJSON() ([]byte, error) { - if err := ValidateTransitionRecord(&r); err != nil { - return nil, err - } - return json.Marshal(transitionRecordMarshal(r)) -} - -func (r *TransitionRecord) UnmarshalJSON(raw []byte) error { - var wire transitionRecordWire - if err := decodeStrictJSON(raw, &wire); err != nil { - return err - } - record := TransitionRecord(wire) - if err := ValidateTransitionRecord(&record); err != nil { - return err - } - if err := requireCanonicalEquivalent(raw, transitionRecordMarshal(record)); err != nil { - return err - } - *r = record - return nil -} - -func (e *AgentEvent) UnmarshalJSON(raw []byte) error { - var wire agentEventWire - if err := decodeStrictJSON(raw, &wire); err != nil { - return err - } - proto, err := ProtocolFor(wire.SchemaVersion) - if err != nil { - return err - } - fact, err := proto.DecodeFact(wire.Type, wire.Fact) - if err != nil { - return err - } - want, err := proto.DigestFact(wire.Type, fact) - if err != nil { - return err - } - if wire.Digest == "" { - return errors.New("agent: codec: event missing fact digest") - } - if wire.Digest != want { - return fmt.Errorf("agent: codec: fact digest mismatch: got %s want %s", wire.Digest, want) - } - if err := requireCanonicalEquivalent(raw, agentEventMarshal{ - SchemaVersion: wire.SchemaVersion, - Type: wire.Type, - RunID: wire.RunID, - Revision: wire.Revision, - Index: wire.Index, - CommandID: wire.CommandID, - CommandDigest: wire.CommandDigest, - Digest: wire.Digest, - Fact: fact, - }); err != nil { - return err - } - *e = AgentEvent{ - SchemaVersion: wire.SchemaVersion, - Type: wire.Type, - RunID: wire.RunID, - Revision: wire.Revision, - Index: wire.Index, - CommandID: wire.CommandID, - CommandDigest: wire.CommandDigest, - Digest: wire.Digest, - Fact: fact, - } - return nil -} - func isSupportedSchemaVersion(v uint16) bool { return v == SchemaVersion1 } diff --git a/agent/run/codec_test.go b/agent/run/codec_test.go index 0cae172..3163e33 100644 --- a/agent/run/codec_test.go +++ b/agent/run/codec_test.go @@ -27,7 +27,7 @@ func TestCommandEnvelopeJSONRoundTripRestoresVariants(t *testing.T) { AcceptInput{Input: AgentInput{ID: "in", Payload: cj(`{"q":"hi"}`)}}, } for _, cmd := range commands { - env, err := ProtocolV1().BuildEnvelope("run-1", CommandID("cmd-"+commandType(cmd)), cmd) + env, err := ProtocolV1().BuildEnvelope("s-1", "run-1", CommandID("cmd-"+commandType(cmd)), cmd) if err != nil { t.Fatalf("ProtocolV1().BuildEnvelope(%T): %v", cmd, err) } @@ -48,7 +48,9 @@ func TestCommandEnvelopeJSONRoundTripRestoresVariants(t *testing.T) { } } -func TestAgentEventJSONRoundTripRestoresVariants(t *testing.T) { +// 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"}, @@ -66,95 +68,32 @@ func TestAgentEventJSONRoundTripRestoresVariants(t *testing.T) { InputAccepted{Input: AgentInput{ID: "in", Payload: cj(`{"q":"hi"}`)}}, RunEnded{End: RunCompletedEnd{}}, } - for i, fact := range facts { + for _, fact := range facts { typ := factType(fact) - digest, err := ProtocolV1().DigestFact(typ, fact) + raw, err := marshalCanonical(fact) if err != nil { - t.Fatalf("ProtocolV1().DigestFact(%T): %v", fact, err) + t.Fatalf("marshal(%T): %v", fact, err) } - event := AgentEvent{ - SchemaVersion: SchemaVersion1, - Type: typ, - RunID: "run-1", - Revision: uint64(i + 1), - Index: 0, - CommandID: CommandID("cmd"), - CommandDigest: Digest("sha256:cmd"), - Digest: digest, - Fact: fact, - } - raw, err := json.Marshal(event) + decoded, err := ProtocolV1().DecodeFact(typ, raw) if err != nil { - t.Fatalf("Marshal(%T): %v", fact, err) + t.Fatalf("DecodeFact(%T): %v\n%s", fact, err, raw) } - decoded, err := DecodeAgentEvent(raw) - if err != nil { - t.Fatalf("DecodeAgentEvent(%T): %v\n%s", fact, err, raw) + if reflect.TypeOf(decoded) != reflect.TypeOf(fact) { + t.Fatalf("decoded fact type = %T, want %T", decoded, fact) } - if reflect.TypeOf(decoded.Fact) != reflect.TypeOf(fact) { - t.Fatalf("decoded fact type = %T, want %T", decoded.Fact, 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 decoded.Type != event.Type || decoded.Digest != event.Digest || decoded.Revision != event.Revision { - t.Fatalf("decoded event = %+v, want %+v", decoded, event) + if _, err := ProtocolV1().DecodeFact("unknown", raw); err == nil { + t.Fatalf("unknown fact type decoded for %T", fact) } } } -func TestTransitionRecordJSONRoundTripRestoresVariants(t *testing.T) { - facts := []Fact{ - ModelStepCompleted{StepID: "s", FinishReason: FinishReasonStop, ResultDigest: "sha256:result"}, - RunEnded{End: RunCompletedEnd{}}, - } - events := make([]AgentEvent, len(facts)) - for i, fact := range facts { - typ := factType(fact) - digest, err := ProtocolV1().DigestFact(typ, fact) - if err != nil { - t.Fatal(err) - } - events[i] = AgentEvent{ - SchemaVersion: SchemaVersion1, - Type: typ, - RunID: "run-1", - Revision: 1, - Index: uint16(i), - CommandID: "cmd-1", - CommandDigest: "sha256:cmd", - Digest: digest, - Fact: fact, - } - } - record, err := BuildTransitionRecord(events) - if err != nil { - t.Fatal(err) - } - raw, err := json.Marshal(record) - if err != nil { - t.Fatal(err) - } - decoded, err := DecodeTransitionRecord(raw) - if err != nil { - t.Fatalf("DecodeTransitionRecord: %v\n%s", err, raw) - } - if decoded.TransitionDigest != record.TransitionDigest || len(decoded.Events) != len(record.Events) { - t.Fatalf("decoded transition = %+v, want %+v", decoded, record) - } - for i := range decoded.Events { - if reflect.TypeOf(decoded.Events[i].Fact) != reflect.TypeOf(record.Events[i].Fact) { - t.Fatalf("decoded event %d fact type = %T, want %T", i, decoded.Events[i].Fact, record.Events[i].Fact) - } - } - - partial := cloneTransitionRecord(&record) - partial.Events = partial.Events[:1] - if err := ValidateTransitionRecord(&partial); err == nil { - t.Fatal("partial transition validated") - } -} - func TestWireCodecRejectsAmbiguousJSONBeforeVariantDecode(t *testing.T) { cmd := AcceptInput{Input: AgentInput{ID: "in", Payload: cj(`1`)}} - env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", "in"), cmd) + env, err := ProtocolV1().BuildEnvelope("s-1", "run-1", DeriveInputCommandID("run-1", "in"), cmd) if err != nil { t.Fatal(err) } @@ -174,7 +113,7 @@ func TestWireCodecRejectsAmbiguousJSONBeforeVariantDecode(t *testing.T) { } func TestWireCodecRejectsUnknownTypeAndDigestMismatch(t *testing.T) { - env, err := ProtocolV1().BuildEnvelope("run-1", "cmd-1", CancelRun{}) + env, err := ProtocolV1().BuildEnvelope("s-1", "run-1", "cmd-1", CancelRun{}) if err != nil { t.Fatal(err) } @@ -191,20 +130,6 @@ func TestWireCodecRejectsUnknownTypeAndDigestMismatch(t *testing.T) { t.Fatal("bad command digest decoded") } - fact := RunEnded{End: RunCompletedEnd{}} - digest, err := ProtocolV1().DigestFact(factType(fact), fact) - if err != nil { - t.Fatal(err) - } - event := AgentEvent{SchemaVersion: SchemaVersion1, Type: factType(fact), RunID: "run-1", Revision: 1, CommandID: "cmd", CommandDigest: env.Digest, Digest: digest, Fact: fact} - raw, err = json.Marshal(event) - if err != nil { - t.Fatal(err) - } - badEventDigest := strings.Replace(string(raw), string(digest), "sha256:bad", 1) - if _, err := DecodeAgentEvent([]byte(badEventDigest)); err == nil { - t.Fatal("bad fact digest decoded") - } } func TestRunEndedTaggedUnionRejectsInvalidValues(t *testing.T) { diff --git a/agent/run/commit.go b/agent/run/commit.go index 6bd9cda..d4932bf 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -9,25 +9,23 @@ type DecisionKind uint8 const ( DecisionApply DecisionKind = iota - DecisionAlreadyApplied DecisionConflict DecisionStale DecisionTerminal ) -// CommitDecision is EvaluateCommit's verdict. Runtime.Commit maps rejections +// CommitDecision is EvaluateCommit's verdict. The Runtime maps rejections // onto the sentinel errors: Conflict -> ErrCommandConflict, Stale -> -// ErrStaleRuntime, Terminal -> ErrRunTerminal (RUN-CMT-6). +// ErrStaleRuntime, Terminal -> ErrRunTerminal (RUN-CMT-7). type CommitDecision struct { - Kind DecisionKind - NewState MachineState - Events []AgentEvent - Transition TransitionRecord + Kind DecisionKind + NewState MachineState + Facts []Fact // Reject carries the precondition failure for Conflict/Stale/Terminal. Reject error } -// commandCategory classifies a command for BaseRevision handling (RUN-CMT-3). +// commandCategory classifies a command for Base handling (RUN-CMT-4). // PrepareModelRequest is the only hard-CAS command. type commandCategory uint8 @@ -65,7 +63,7 @@ func categorize(c AgentCommand) commandCategory { } // requiresGrant reports whether this command must carry the start grant of -// its target, given the current state (RUN-CMT-5). +// its target, given the current state (RUN-CMT-6). func requiresGrant(s *MachineState, c AgentCommand) bool { switch cmd := c.(type) { case SubmitModelResult, SubmitModelFailure, RejectModelResult: @@ -90,87 +88,81 @@ func requiresGrant(s *MachineState, c AgentCommand) bool { } } -// EvaluateCommit is the single, pure commit evaluation the Store-backed -// Runtime calls inside its critical section (RUN-CMT-3). grantValid and -// recoveryValid are the control-plane verdicts the Runtime supplies: whether -// req.Grant is the live grant for the command's target, and whether a -// grantless recovery command matches an expired lease. +// ValidateEnvelope is step 1 of RUN-CMT-3: identity, schema and digest. A +// digest that does not cover the command is a construction fault and is +// returned as a hard error, never as a retriable rejection. +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()) + } + wantDigest, err := proto.DigestCommand(env.Type, env.Command) + if err != nil { + return err + } + if env.Digest != wantDigest { + return errors.New("agent: commit: envelope digest mismatch") + } + return nil +} + +// EvaluateCommit is the pure evaluation every Runtime runs inside the +// Session critical section after exact-replay lookup (RUN-CMT-3 steps 4-8). +// grantValid and recoveryValid are the control-plane verdicts the Runtime +// supplies: whether req.Grant is the live grant for the command's target, and +// whether a grantless recovery command matches an expired lease. // //nolint:gocritic // hugeParam: public pure commit evaluator keeps state/request as value protocol inputs. func EvaluateCommit( - cur MachineState, curRevision uint64, - prior *TransitionRecord, + cur MachineState, position RunPosition, req CommitRequest, grantValid bool, recoveryValid bool, proto Protocol, ) (CommitDecision, error) { env := req.Command - - // Step 1: envelope integrity. - if env.RunID == "" || env.ID == "" { - return CommitDecision{}, fmt.Errorf("agent: commit: empty RunID or CommandID") - } 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 := proto.ready(); err != nil { + if err := ValidateEnvelope(&env, proto); err != nil { return CommitDecision{}, err } - if env.SchemaVersion != proto.Version() { - return CommitDecision{}, fmt.Errorf("agent: commit: command schema %d does not match run schema %d", env.SchemaVersion, proto.Version()) - } - wantDigest, err := proto.DigestCommand(env.Type, env.Command) - if err != nil { - return CommitDecision{}, err - } - if env.Digest != wantDigest { - // A digest that does not cover the command is a construction or - // transport fault, not a competing writer: surface it as a hard error - // so callers do not reload and retry it forever. - return CommitDecision{}, fmt.Errorf("agent: commit: envelope digest mismatch") - } // A start claim is part of the command identity. Rejecting an empty claim // here prevents an unbound worker from acquiring execution ownership. switch cmd := env.Command.(type) { case StartModelExecution: if cmd.Claim == "" { - return CommitDecision{Kind: DecisionConflict, Reject: fmt.Errorf("agent: commit: model start requires an execution claim")}, nil + 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: fmt.Errorf("agent: commit: tool start requires an execution claim")}, nil + 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: fmt.Errorf("agent: commit: model recovery requires an execution claim")}, nil + return CommitDecision{Kind: DecisionConflict, Reject: errors.New("agent: commit: model recovery requires an execution claim")}, nil } } - // Steps 2-3: idempotent replay and identity conflict. - if prior != nil { - if prior.CommandDigest == env.Digest { - return CommitDecision{Kind: DecisionAlreadyApplied, Events: prior.Events, Transition: cloneTransitionRecord(prior)}, nil - } - return CommitDecision{Kind: DecisionConflict, Reject: ErrCommandConflict}, nil - } // Derived-identity families must use their derived CommandID (RUN-WIR-3): // the derivation is the idempotency index for inputs/responses/planning, so - // a caller-minted random ID cannot bypass duplicate detection. This check is - // deliberately after exact replay: BaseRevision is not part of the command - // digest, and a retry may have reloaded a newer snapshot before replaying an - // already accepted prepare command. - if err := checkDerivedCommandID(&env, req.BaseRevision); err != nil { + // 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 (after replay). + // Terminal absorbs non-duplicate commands (replay was handled before). if cur.Status.Terminal() { return CommitDecision{Kind: DecisionTerminal, Reject: ErrRunTerminal}, nil } - // BaseRevision and authorization. + // Base and authorization. cat := categorize(env.Command) - if cat == catPlan && req.BaseRevision != curRevision { + if cat == catPlan && !samePosition(req.Base, position) { return CommitDecision{Kind: DecisionStale, Reject: ErrStaleRuntime}, nil } if requiresGrant(&cur, env.Command) && !grantValid { @@ -180,7 +172,7 @@ func EvaluateCommit( return CommitDecision{Kind: DecisionStale, Reject: ErrStaleRuntime}, nil } - // Step 5: Decide once, fold with Evolve. + // Step 7: Decide once, fold with Evolve. facts, err := proto.Decide(cur, env.Command) if err != nil { switch { @@ -196,16 +188,13 @@ func EvaluateCommit( return CommitDecision{Kind: DecisionStale, Reject: err}, nil } } - newRevision := curRevision + 1 - state := cur - events := make([]AgentEvent, len(facts)) if cmd, ok := env.Command.(PrepareModelRequest); ok { if len(facts) == 0 { - return CommitDecision{}, fmt.Errorf("agent: commit: prepare produced no facts") + return CommitDecision{}, errors.New("agent: commit: prepare produced no facts") } prepared, ok := facts[0].(ModelStepPrepared) if !ok { - return CommitDecision{}, fmt.Errorf("agent: commit: prepare did not produce ModelStepPrepared") + return CommitDecision{}, errors.New("agent: commit: prepare did not produce ModelStepPrepared") } wantStep := DeriveModelStepID(env.RunID, env.ID, prepared.BindingDigest) if cmd.StepID != wantStep { @@ -213,11 +202,12 @@ func EvaluateCommit( } } + state := cur + detached := make([]Fact, len(facts)) for i, f := range facts { - // Detach every fact before it is folded or wrapped as an event. Decide - // often forwards fields from the caller's command (ModelRequest, - // ModelResult, CanonicalJSON payloads); the commit decision must not - // carry caller-owned mutable objects across the Runtime boundary. + // 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 @@ -226,40 +216,19 @@ func EvaluateCommit( if err != nil { return CommitDecision{}, err } - typ := factType(f) - fd, err := proto.DigestFact(typ, f) - if err != nil { - return CommitDecision{}, err - } - events[i] = AgentEvent{ - SchemaVersion: proto.Version(), - Type: typ, - RunID: cur.RunID, - Revision: newRevision, - Index: uint16(i), - CommandID: env.ID, - CommandDigest: env.Digest, - Digest: fd, - Fact: f, - } + detached[i] = f } - transition, err := BuildTransitionRecord(events) - if err != nil { - return CommitDecision{}, err - } - return CommitDecision{Kind: DecisionApply, NewState: state, Events: transition.Events, Transition: transition}, nil + return CommitDecision{Kind: DecisionApply, NewState: state, Facts: detached}, nil } +func samePosition(a, b RunPosition) bool { return a.Revision == b.Revision && a.Index == b.Index } + // checkDerivedCommandID enforces the derived-identity rules of RUN-WIR-3. -// AcceptInput derives from (RunID, InputID); approval/rejection/answer derive -// from (RunID, StepID, CallID, ResponseID). Approve and reject of the same -// response share one identity by design, so a decision change surfaces as -// ErrCommandConflict instead of a second fact. -func checkDerivedCommandID(env *CommandEnvelope, baseRevision uint64) error { +func checkDerivedCommandID(env *CommandEnvelope, base RunPosition) error { var want CommandID switch cmd := env.Command.(type) { case PrepareModelRequest: - want = DeriveModelRequestCommandID(env.RunID, baseRevision) + want = DeriveModelRequestCommandID(env.RunID, base) case AcceptInput: want = DeriveInputCommandID(env.RunID, cmd.Input.ID) case WithdrawPreparedStep: @@ -286,3 +255,104 @@ func checkDerivedCommandID(env *CommandEnvelope, baseRevision uint64) error { } return nil } + +// LeaseKey addresses the execution target of a start inside the Run's lease +// namespace: /model/ or /call// (RUN 5.1). +func LeaseKey(runID RunID, stepID StepID, callID CallID) string { + switch { + case stepID == "": + return "" + case callID == "": + return string(runID) + "/model/" + string(stepID) + default: + return string(runID) + "/call/" + string(stepID) + "/" + string(callID) + } +} + +// GrantTarget returns the lease key a command's grant or recovery refers to. +func GrantTarget(runID RunID, c AgentCommand) string { + switch cmd := c.(type) { + case StartModelExecution: + return LeaseKey(runID, cmd.StepID, "") + case SubmitModelResult: + return LeaseKey(runID, cmd.StepID, "") + case SubmitModelFailure: + return LeaseKey(runID, cmd.StepID, "") + case RejectModelResult: + return LeaseKey(runID, cmd.StepID, "") + case RecoverModelExecution: + return LeaseKey(runID, cmd.StepID, "") + case StartToolCall: + return LeaseKey(runID, cmd.StepID, cmd.CallID) + case SubmitToolResult: + return LeaseKey(runID, cmd.StepID, cmd.CallID) + case SubmitToolFailure: + return LeaseKey(runID, cmd.StepID, cmd.CallID) + default: + return "" + } +} + +// IsStart reports whether c acquires an execution lease. +func IsStart(c AgentCommand) bool { + switch c.(type) { + case StartModelExecution, StartToolCall: + return true + } + return false +} + +// IsSettlement reports whether c releases the lease of its target. +func IsSettlement(c AgentCommand) bool { + switch c.(type) { + case SubmitModelResult, SubmitModelFailure, RejectModelResult, RecoverModelExecution, SubmitToolResult, SubmitToolFailure: + 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 "" +} + +// RecoveryCommand builds the grantless recovery command for an expired lease +// on target (RUN 5.1): an Executing model step recovers to Prepared; an +// Executing tool call settles as Unknown. ok is false when the target is no +// longer Executing. +func RecoveryCommand(state *MachineState, target string, claim ExecutionClaim) (AgentCommand, CommandID, bool) { + switch cur := state.Current.(type) { + case ModelStep: + if cur.Status != ModelExecuting || target != LeaseKey(state.RunID, cur.RefValue.ID, "") { + return nil, "", false + } + return RecoverModelExecution{StepID: cur.RefValue.ID, Claim: claim}, + DeriveModelRecoveryCommandID(state.RunID, cur.RefValue.ID, claim), true + case ToolStep: + prefix := LeaseKey(state.RunID, cur.RefValue.ID, "x") + prefix = prefix[:len(prefix)-1] + if len(target) <= len(prefix) || target[:len(prefix)] != prefix { + return nil, "", false + } + callID := CallID(target[len(prefix):]) + if i := cur.callIndex(callID); i < 0 || cur.Calls[i].Status != ToolExecuting { + return nil, "", false + } + return SubmitToolFailure{ + StepID: cur.RefValue.ID, + CallID: callID, + Failure: ToolFailure{Class: FailureEffectUnknown, Message: "lease expired"}, + Outcome: ToolOutcomeUnknown, + }, DeriveToolRecoveryCommandID(state.RunID, cur.RefValue.ID, callID, claim), true + default: + return nil, "", false + } +} diff --git a/agent/run/creation.go b/agent/run/creation.go index 2cca15e..26a3365 100644 --- a/agent/run/creation.go +++ b/agent/run/creation.go @@ -1,7 +1,6 @@ package run import ( - "bytes" "context" "errors" "fmt" @@ -57,99 +56,18 @@ func ValidateNewRun(run NewRun) error { return nil } -// BuildRunHeaderFromNewRun constructs Revision 0 using the explicit v1 rule. -// This dispatch must not use currentSchemaVersion: later command schemas must -// never change the bytes admitted by a v1 NewRun. -func BuildRunHeaderFromNewRun(run NewRun) (RunHeader, error) { - if err := ValidateNewRun(run); err != nil { - return RunHeader{}, err - } - switch run.SchemaVersion { - case SchemaVersion1: - return buildRunHeaderV1(run) - default: - return RunHeader{}, fmt.Errorf("agent: new run: unsupported schema version %d", run.SchemaVersion) - } -} - -const newRunV1InitialStateVersion uint16 = 1 - -func buildRunHeaderV1(run NewRun) (RunHeader, error) { - initial, err := InitializeRun(run.RunID, run.Owner, run.Attempt) - if err != nil { - return RunHeader{}, err - } - stateBytes, err := encodeMachineStateV1(&initial) - if err != nil { - return RunHeader{}, err - } - header := RunHeader{ - SchemaVersion: SchemaVersion1, - RunID: run.RunID, - InitialStateVersion: newRunV1InitialStateVersion, - InitialState: initial, - InitialStateDigest: sha256Digest(stateBytes), - CausationID: run.CausationID, - } - header.HeaderDigest, err = digestRunHeader(&header) - if err != nil { - return RunHeader{}, err - } - if err := ValidateRunHeader(&header); err != nil { - return RunHeader{}, err - } - return header, nil -} - -// CreateResult is the outcome of conditionally creating one Run. -type CreateResult struct { - Header RunHeader `json:"header"` - Created bool `json:"created"` -} - -// RunRecord is one consistent, verified read of a Run. -type RunRecord struct { - Header RunHeader `json:"header"` - Snapshot RuntimeSnapshot `json:"snapshot"` - Transitions []TransitionRecord `json:"transitions"` -} - var ( - // ErrCreateConflict reports that a RunID is already associated with a - // different canonical Revision-0 header. - ErrCreateConflict = errors.New("agent: run create conflict") - // ErrRunNotFound reports an operation addressed a RunID not in this Runtime. + // ErrRunNotFound reports an operation addressed a RunID not in the Session. ErrRunNotFound = errors.New("agent: run not found") ) -func cloneRunHeader(header RunHeader) RunHeader { - header.InitialState = cloneMachineState(&header.InitialState) - return header -} - -func canonicalHeadersEqual(left, right RunHeader) (bool, error) { - if err := ValidateRunHeader(&left); err != nil { - return false, err - } - if err := ValidateRunHeader(&right); err != nil { - return false, err - } - leftBytes, err := marshalCanonical(left) - if err != nil { - return false, err - } - rightBytes, err := marshalCanonical(right) - if err != nil { - return false, err - } - return bytes.Equal(leftBytes, rightBytes), nil -} - -// checkContext avoids locking when cancellation already makes an operation +// CheckContext avoids locking when cancellation already makes an operation // inapplicable. Context is intentionally not retained by the Runtime. -func checkContext(ctx context.Context) error { +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 index 408af6e..15d9e69 100644 --- a/agent/run/creation_test.go +++ b/agent/run/creation_test.go @@ -1,7 +1,6 @@ package run import ( - "context" "testing" "github.com/memohai/twilight/agent/es" @@ -16,24 +15,7 @@ func mustNewRun(t testing.TB, id RunID, cause es.CausationID) NewRun { return run } -func acceptInput(t testing.TB, rt Runtime, id RunID, input AgentInput) CommitResult { - t.Helper() - snapshot, err := rt.Load(context.Background(), id) - if err != nil { - t.Fatal(err) - } - envelope, err := ProtocolV1().BuildEnvelope(id, DeriveInputCommandID(id, input.ID), AcceptInput{Input: input}) - if err != nil { - t.Fatal(err) - } - result, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: snapshot.Revision, Command: envelope}) - if err != nil { - t.Fatal(err) - } - return result -} - -func TestNewRunValidationAndV1HeaderGolden(t *testing.T) { +func TestNewRunValidation(t *testing.T) { created := mustNewRun(t, "run-1", "session-1") if created.SchemaVersion != SchemaVersion1 { t.Fatalf("schema = %d", created.SchemaVersion) @@ -48,14 +30,4 @@ func TestNewRunValidationAndV1HeaderGolden(t *testing.T) { t.Fatalf("invalid NewRun accepted: %+v", candidate) } } - header, err := BuildRunHeaderFromNewRun(created) - if err != nil { - t.Fatal(err) - } - // Pre-release fixture; re-frozen when Owner/Attempt joined the state and - // facts became digest-only (RUN-WIR-4). - if header.InitialStateDigest != "sha256:42c13ce3c1d6f3e9ffe6300bfcfaf41098e8b77e330b7740642f36594c889eb6" || - header.HeaderDigest != "sha256:dc9be6579793c0014755bc5e49ef7e427f7803a731c162878143dd9cc6716e21" { - t.Fatalf("v1 header changed: %+v", header) - } } diff --git a/agent/run/example_run_test.go b/agent/run/example_run_test.go deleted file mode 100644 index 367a714..0000000 --- a/agent/run/example_run_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package run_test - -import ( - "context" - "fmt" - "os" - "path/filepath" - "sync" - "sync/atomic" - "time" - - "github.com/memohai/twilight/agent/run" - "github.com/memohai/twilight/agent/run/loop" - "github.com/memohai/twilight/agent/run/sqlitestore" - "github.com/memohai/twilight/sdk" -) - -// Example_recoverableRun drives one Run through a process crash. -// -// Process 1 creates the Run on a SQLite Store, accepts the user input and -// runs the Loop until the model asks for a tool. The tool never returns: the -// process dies while the call is Executing and its lease is live. Nothing is -// written on the way down. -// -// Process 2 reopens the same database. The lease has expired, so -// RecoverExpired settles the abandoned call as Unknown (the effect may or may -// not have happened) and the Run stays Active on the same RunID. The Loop -// then plans the next model request from the committed tool outcome and the -// Run completes. Record verifies the whole transition log against the stored -// snapshot. -func Example_recoverableRun() { - dir, err := os.MkdirTemp("", "twilight-run-example") - if err != nil { - panic(err) - } - defer os.RemoveAll(dir) - dbPath := filepath.Join(dir, "runs.db") - ctx := context.Background() - - // A controllable clock stands in for wall time so lease expiry is - // deterministic. Production hosts leave RuntimeOptions.Now nil. - clock := &fakeClock{now: time.Unix(1_000_000, 0)} - const leaseTTL = 30 * time.Second - - // The application side: model, tool and planner survive the "crash" here - // only because both processes live in one test binary. - tool := &lookupTool{block: make(chan struct{})} - app := newExampleApp(tool) - - // ---- process 1 ---------------------------------------------------------- - store1, err := sqlitestore.Open(dbPath) - if err != nil { - panic(err) - } - rt1 := run.NewRuntimeWithOptions(store1, run.RuntimeOptions{LeaseTTL: leaseTTL, Now: clock.Now}) - - newRun, err := run.BuildNewRun("run-1", "example") - if err != nil { - panic(err) - } - if _, err := rt1.Create(ctx, newRun); err != nil { - panic(err) - } - input := run.AgentInput{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{"text":"what is the weather?"}`)} - env, err := run.ProtocolV1().BuildEnvelope("run-1", run.DeriveInputCommandID("run-1", input.ID), run.AcceptInput{Input: input}) - if err != nil { - panic(err) - } - if _, err := rt1.Commit(ctx, run.CommitRequest{Command: env}); err != nil { - panic(err) - } - - app.runtime = rt1 - loop1, err := loop.New(app.models(), app.tools(), app, loop.ExecutionPolicy{LeaseRenewInterval: 5 * time.Second}, false) - if err != nil { - panic(err) - } - loop1Done := make(chan error, 1) - go func() { - _, err := loop1.Run(ctx, rt1, "run-1", nil) - loop1Done <- err - }() - waitForExecutingCall(ctx, rt1, "run-1", "c1") - fmt.Println("process 1: tool call c1 is Executing; process crashes") - - // The crash: the process disappears without settling. Closing the Store - // is the closest single-binary equivalent; the abandoned worker's later - // writes fail, exactly as a dead process writes nothing. - if err := store1.Close(); err != nil { - panic(err) - } - - // ---- process 2 ---------------------------------------------------------- - clock.Advance(2 * leaseTTL) - store2, err := sqlitestore.Open(dbPath) - if err != nil { - panic(err) - } - defer store2.Close() - rt2 := run.NewRuntimeWithOptions(store2, run.RuntimeOptions{LeaseTTL: leaseTTL, Now: clock.Now}) - - snap, err := rt2.Load(ctx, "run-1") - if err != nil { - panic(err) - } - fmt.Printf("process 2: reopened at revision %d, run %s, needs recovery = %v\n", - snap.Revision, statusName(snap.State.Status), run.NeedsRecovery(snap.State)) - - // Hosts run this on a timer (run.RunExpiredRecovery); one pass is enough here. - recovered, err := rt2.RecoverExpired(ctx) - if err != nil { - panic(err) - } - snap, err = rt2.Load(ctx, "run-1") - if err != nil { - panic(err) - } - call := snap.State.LastToolStep.Calls[0] - fmt.Printf("recovered %d lease: call %s is %s (%s), run %s\n", - recovered, call.ProviderCallID, call.Status, call.Failure.Failure.Class, statusName(snap.State.Status)) - - app.runtime = rt2 - loop2, err := loop.New(app.models(), app.tools(), app, loop.ExecutionPolicy{LeaseRenewInterval: 5 * time.Second}, false) - if err != nil { - panic(err) - } - result, err := loop2.Run(ctx, rt2, "run-1", nil) - if err != nil { - panic(err) - } - snap, err = rt2.Load(ctx, "run-1") - if err != nil { - panic(err) - } - fmt.Printf("process 2: run %s after %d model steps\n", statusName(result.Result.Status), snap.State.ModelSteps) - - record, err := rt2.Record(ctx, "run-1") - if err != nil { - panic(err) - } - fmt.Printf("record: %d transitions fold to the stored snapshot\n", len(record.Transitions)) - - // Let the abandoned worker exit; its settlement fails against the closed - // Store, which is the crash we simulated. - close(tool.block) - <-loop1Done - - // Output: - // process 1: tool call c1 is Executing; process crashes - // process 2: reopened at revision 5, run active, needs recovery = true - // recovered 1 lease: call c1 is Failed (effect_unknown), run active - // process 2: run completed after 2 model steps - // record: 9 transitions fold to the stored snapshot -} - -// waitForExecutingCall polls Load until the call the model issued under -// providerID is Executing, which is the point at which a start has been -// accepted and its lease is live. -func waitForExecutingCall(ctx context.Context, rt run.Runtime, runID run.RunID, providerID string) { - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - snap, err := rt.Load(ctx, runID) - if err != nil { - panic(err) - } - if ts, ok := snap.State.Current.(run.ToolStep); ok { - for _, call := range ts.Calls { - if call.ProviderCallID == providerID && call.Status == run.ToolExecuting { - return - } - } - } - time.Sleep(2 * time.Millisecond) - } - panic("tool call never reached Executing") -} - -func statusName(s run.RunStatus) string { - switch s { - case run.RunActive: - return "active" - case run.RunCompleted: - return "completed" - case run.RunStopped: - return "stopped" - default: - return "failed" - } -} - -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 -} - -func (c *fakeClock) Advance(d time.Duration) { - c.mu.Lock() - c.now = c.now.Add(d) - c.mu.Unlock() -} - -// exampleApp is the application side of the Loop: the RequestPlanner, plus -// the model and tool catalogs exposed through models() and tools(). A real -// host injects its provider client, tool registry and context assembly here. -type exampleApp struct { - tool *lookupTool - spec run.ToolSpec - // runtime is the process-local Runtime the planner reads committed tool - // outcomes from; each "process" installs its own before running the Loop. - runtime run.Runtime -} - -func newExampleApp(tool *lookupTool) *exampleApp { - frozen, err := run.FreezeToolDefinition(tool.Definition()) - if err != nil { - panic(err) - } - digest, err := run.ProtocolV1().DigestToolDefinition(frozen) - if err != nil { - panic(err) - } - return &exampleApp{tool: tool, spec: run.ToolSpec{ - Ref: tool.Ref(), Name: tool.Definition().Name, DefinitionDigest: digest, Policy: run.DirectExecution, - }} -} - -// Plan projects the Run boundary facts into the next sdk.Request: the pending -// user inputs, plus the committed outcome of the tool step the hint names as -// SourceStep. The hint carries only that boundary; the outcome is read back -// from the Runtime, as a real planner reads it from the session projection. -func (a *exampleApp) Plan(ctx context.Context, hint run.PlanningHint) (loop.RequestPlan, error) { - var messages []sdk.Message - ids := make([]run.InputID, 0, len(hint.Inputs)) - for _, in := range hint.Inputs { - ids = append(ids, in.ID) - var body struct { - Text string `json:"text"` - } - if err := in.Payload.Decode(&body); err != nil { - return loop.RequestPlan{}, err - } - messages = append(messages, sdk.UserMessage(body.Text)) - } - if hint.SourceStep != "" { - snap, err := a.runtime.Load(ctx, hint.RunID) - if err != nil { - return loop.RequestPlan{}, err - } - if snap.State.LastToolStep == nil || snap.State.LastToolStep.RefValue.ID != hint.SourceStep { - return loop.RequestPlan{}, fmt.Errorf("planner: source step %s is not the last closed tool step", hint.SourceStep) - } - for _, call := range snap.State.LastToolStep.Calls { - outcome := "ok" - if call.Status == run.ToolFailed { - outcome = call.Failure.Failure.Class - } - messages = append(messages, sdk.ToolMessage(sdk.ToolResultPart{ - ToolCallID: call.ProviderCallID, ToolName: string(call.ToolRef), Result: outcome, - })) - } - } - return loop.RequestPlan{ - Model: "m-1", - Request: sdk.Request{Model: "m-1", Messages: messages, Tools: []sdk.ToolDefinition{a.tool.Definition()}}, - InputIDs: ids, - Tools: []run.ToolSpec{a.spec}, - }, nil -} - -type modelCatalog struct{ app *exampleApp } -type toolCatalog struct{ app *exampleApp } - -func (a *exampleApp) models() loop.ModelCatalog { return modelCatalog{a} } -func (a *exampleApp) tools() loop.ToolCatalog { return toolCatalog{a} } - -func (c modelCatalog) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { return c.app, nil } - -func (c toolCatalog) ResolveTool(ref run.ToolRef) (loop.ExecutableTool, error) { - if ref != c.app.tool.Ref() { - return nil, fmt.Errorf("unknown tool %q", ref) - } - return c.app.tool, nil -} - -// Generate is a scripted model: it asks for the tool until a tool result is -// present in the conversation, then answers. -func (a *exampleApp) 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, standing in -// for a tool call that is in flight when the process dies. -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.ToolExecutionUnknown{Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: "process died"}} - } - return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} -} diff --git a/agent/run/fold.go b/agent/run/fold.go index d241ba7..54902f8 100644 --- a/agent/run/fold.go +++ b/agent/run/fold.go @@ -4,126 +4,40 @@ import ( "bytes" "errors" "fmt" - - "github.com/memohai/twilight/agent/es" ) -// ErrLogTruncated reports that the transition log ends below the revision the -// caller expected. It identifies an audit gap for the Runtime authority; the -// adapter decides how to surface or repair that gap. -var ErrLogTruncated = errors.New("agent: transition log ends below the expected revision") - -// FoldEvents rebuilds a MachineState by folding a complete flat event stream -// from the initial (Revision 0) state with Protocol.Evolve only: no Decide, no -// external effects, no command replay (RUN-NEW-2). It verifies (Revision, -// Index) ordering and per-fact digests as it goes. Authority runtimes should -// prefer FoldTransitions because only TransitionRecord can prove the last -// transition's event group is complete. -// -//nolint:gocritic // hugeParam: public replay API folds from an initial value state without mutating caller-owned state. -func FoldEvents(initial MachineState, events []AgentEvent) (MachineState, uint64, error) { - state := initial - var revision uint64 - var index uint16 - var commandID CommandID - var commandDigest Digest - inTransition := false - for _, e := range events { - if e.RunID != initial.RunID { - return initial, 0, fmt.Errorf("agent: fold: event run %q does not match initial run %q", e.RunID, initial.RunID) - } - proto, err := ProtocolFor(e.SchemaVersion) - if err != nil { - return initial, 0, err - } - typ := factType(e.Fact) - if typ == "" || e.Type != typ { - return initial, 0, fmt.Errorf("agent: fold: event type %q does not match fact variant %T", e.Type, e.Fact) - } - switch { - case !inTransition || e.Revision != revision: - if e.Revision != revision+1 || e.Index != 0 { - return initial, 0, fmt.Errorf("agent: fold: gap at revision %d index %d (expected %d/0)", e.Revision, e.Index, revision+1) - } - if e.CommandID == "" || e.CommandDigest == "" { - return initial, 0, fmt.Errorf("agent: fold: revision %d missing command identity", e.Revision) - } - revision = e.Revision - index = 0 - commandID = e.CommandID - commandDigest = e.CommandDigest - inTransition = true - default: - if e.Index != index+1 { - return initial, 0, fmt.Errorf("agent: fold: gap at revision %d index %d (expected %d)", e.Revision, e.Index, index+1) - } - if e.CommandID != commandID || e.CommandDigest != commandDigest { - return initial, 0, fmt.Errorf("agent: fold: revision %d command identity changed within transition", e.Revision) - } - index = e.Index - } - wantDigest, err := proto.DigestFact(e.Type, e.Fact) - if err != nil { - return initial, 0, err - } - if e.Digest != wantDigest { - return initial, 0, fmt.Errorf("agent: fold: fact digest mismatch at revision %d index %d", e.Revision, e.Index) - } - fact, err := snapshotFact(e.Fact) +// 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 initial, 0, err + return MachineState{}, err } - state, err = proto.Evolve(state, fact) + state, err = proto.Evolve(state, f) if err != nil { - return initial, 0, err + return MachineState{}, fmt.Errorf("agent: fold: fact %d (%s): %w", i, factType(f), err) } } - return state, revision, nil + return state, nil } -// FoldTransitions rebuilds a MachineState from the immutable initial state -// and complete TransitionRecord sequence. Together they are the canonical -// diagnostic commit record for replay and consistency checks. -// -//nolint:gocritic // hugeParam: public replay API folds from an initial value state without mutating caller-owned state. -func FoldTransitions(initial MachineState, records []TransitionRecord) (MachineState, uint64, error) { - views := make([]es.RecordView[AgentEvent], len(records)) - for i := range records { - record := records[i] - // Run-specific metadata (command identity and aggregate digest) is - // validated by the Run adapter; es validates the generic complete - // record structure and folding order below. - if err := ValidateTransitionRecord(&record); err != nil { - return initial, 0, err - } - views[i] = transitionRecordView(&record) - } - state, revision, err := es.FoldRecords( - initial, - es.StreamID(initial.RunID), - views, - supportsRunSchema, - inspectTransitionEvent, - func(schemaVersion uint16, state MachineState, event AgentEvent) (MachineState, error) { - proto, err := ProtocolFor(schemaVersion) - if err != nil { - return MachineState{}, err - } - fact, err := snapshotFact(event.Fact) - if err != nil { - return MachineState{}, err - } - return proto.Evolve(state, fact) - }, - ) - if err != nil { - return initial, 0, err - } - return state, uint64(revision), nil -} +// StatesEquivalent compares two states via their canonical snapshot encoding. +func StatesEquivalent(a, b *MachineState) bool { return statesEquivalent(a, b) } -// statesEquivalent compares two states via their canonical snapshot encoding, -// the same identity rule the protocol uses for the initial-state digest. func statesEquivalent(a, b *MachineState) bool { ab, errA := encodeMachineStateV1(a) bb, errB := encodeMachineStateV1(b) diff --git a/agent/run/fold_test.go b/agent/run/fold_test.go deleted file mode 100644 index e9de230..0000000 --- a/agent/run/fold_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package run - -import ( - "context" - "errors" - "testing" - - "github.com/memohai/twilight/sdk" -) - -// Event-sourcing arbitration tests (RUN-SCP-1, RUN-CMT-2): the complete canonical -// TransitionRecord is the diagnostic commit record; the snapshot is a -// rebuildable same-transaction projection; the head revision witnesses -// log-tail completeness. - -// fullRunRuntime drives one complete run (prepare -> model -> tool -> done) -// and returns the runtime. -func fullRunRuntime(t *testing.T) Runtime { - t.Helper() - def := testToolDef("t") - spec := makeSpec(t, def, DirectExecution) - rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) - b := makeBinding(t, stepID, 0, "c1", spec, `{}`) - snap, err := rt.Load(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } - res := mustCommit(t, rt, "complete-1", snap.Revision, grant, - SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) - toolStep := res.Events[1].Fact.(ToolStepOpened).StepID - sRes := mustCommit(t, rt, "start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: cid(stepID, 0)}) - mustCommit(t, rt, "done-c1", sRes.Snapshot.Revision, sRes.Grant, - SubmitToolResult{StepID: toolStep, CallID: cid(stepID, 0), Result: ToolExecutionResult{Output: cj(`"ok"`)}}) - snap, err = rt.Load(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } - prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) - prepared := mustCommit(t, rt, cmdID, snap.Revision, "", prep) - start := mustCommit(t, rt, "start-2", prepared.Snapshot.Revision, "", StartModelExecution{StepID: currentStepID(t, rt)}) - final, err := FreezeModelResult(sdk.ModelResult{Text: "final", FinishReason: sdk.FinishReasonStop}) - if err != nil { - t.Fatal(err) - } - mustCommit(t, rt, "done-2", start.Snapshot.Revision, start.Grant, - SubmitModelResult{StepID: currentStepID(t, rt), Result: final}) - return rt -} - -func currentStepID(t *testing.T, rt Runtime) StepID { - t.Helper() - snap, err := rt.Load(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } - switch cur := snap.State.Current.(type) { - case ModelStep: - return cur.Ref().ID - case ToolStep: - return cur.Ref().ID - default: - t.Fatalf("current = %T, want ModelStep or ToolStep", snap.State.Current) - return "" - } -} - -// A healthy runtime rebuilds without divergence: with a correct -// implementation the arbitration branch never fires. -func TestRebuildHealthyIsNoop(t *testing.T) { - rt := fullRunRuntime(t) - before, _ := rt.Load(context.Background(), "run-1") - diverged, err := rebuildRun(t, rt, "run-1") - if err != nil { - t.Fatal(err) - } - if diverged { - t.Fatal("healthy runtime reported divergence on rebuild") - } - after, _ := rt.Load(context.Background(), "run-1") - if !statesEquivalent(&before.State, &after.State) || before.Revision != after.Revision { - t.Fatal("rebuild changed a healthy state") - } -} - -// A corrupted snapshot is repaired from the log, and the divergence is -// reported for audit. -func TestRebuildRepairsCorruptedSnapshot(t *testing.T) { - rt := fullRunRuntime(t) - want, _ := rt.Load(context.Background(), "run-1") - - // Out-of-band write: corrupt the authoritative snapshot directly. - entry := memoryEntry(t, rt) - entry.mu.Lock() - entry.state.ModelSteps = 99 - entry.state.Status = RunActive - entry.state.Result = nil - entry.mu.Unlock() - - diverged, err := rebuildRun(t, rt, "run-1") - if err != nil { - t.Fatal(err) - } - if !diverged { - t.Fatal("rebuild did not report the repaired divergence") - } - got, _ := rt.Load(context.Background(), "run-1") - if !statesEquivalent(&want.State, &got.State) { - t.Fatal("rebuild did not restore the log-derived state") - } - if got.State.Status != RunCompleted || got.State.ModelSteps != 2 { - t.Fatalf("rebuilt state = %+v", got.State.Status) - } -} - -// A log tail below the head revision halts with ErrLogTruncated: accepted -// facts are gone and continuing would repeat gated executions. -func TestRebuildHaltsOnTruncatedTail(t *testing.T) { - rt := fullRunRuntime(t) - entry := memoryEntry(t, rt) - entry.mu.Lock() - // Simulate selective damage: drop the last transition while the head - // revision (separate storage in a durable adapter) survives. - entry.log = cloneTransitionRecords(entry.log[:len(entry.log)-1]) - entry.mu.Unlock() - - _, err := rebuildRun(t, rt, "run-1") - if !errors.Is(err, ErrLogTruncated) { - t.Fatalf("err = %v, want ErrLogTruncated", err) - } -} - -func TestRebuildHaltsOnPartialTailTransition(t *testing.T) { - rt := fullRunRuntime(t) - entry := memoryEntry(t, rt) - entry.mu.Lock() - last := &entry.log[len(entry.log)-1] - if len(last.Events) < 2 { - t.Fatal("test requires a multi-event tail transition") - } - last.Events = last.Events[:len(last.Events)-1] - entry.mu.Unlock() - - _, err := rebuildRun(t, rt, "run-1") - if err == nil { - t.Fatal("partial tail transition folded silently") - } -} - -func TestFoldRejectsDamagedLog(t *testing.T) { - rt := fullRunRuntime(t) - entry := memoryEntry(t, rt) - entry.mu.Lock() - initial := cloneMachineState(&entry.header.InitialState) - transitions := cloneTransitionRecords(entry.log) - events := flattenTransitionRecords(entry.log) - entry.mu.Unlock() - - t.Run("interior transition gap", func(t *testing.T) { - var holed []TransitionRecord - for i := range transitions { - if transitions[i].Revision == 3 { - continue - } - holed = append(holed, cloneTransitionRecord(&transitions[i])) - } - if _, _, err := FoldTransitions(cloneMachineState(&initial), holed); err == nil { - t.Fatal("interior transition gap folded silently") - } - }) - t.Run("interior event gap", func(t *testing.T) { - var holed []AgentEvent - for i := range events { - if events[i].Revision == 3 { - continue - } - holed = append(holed, events[i]) - } - if _, _, err := FoldEvents(cloneMachineState(&initial), holed); err == nil { - t.Fatal("interior event gap folded silently") - } - }) - t.Run("run id mismatch", func(t *testing.T) { - log := flattenTransitionRecords(transitions) - log[0].RunID = "other-run" - if _, _, err := FoldEvents(cloneMachineState(&initial), log); err == nil { - t.Fatal("run mismatch folded silently") - } - }) - t.Run("command identity change", func(t *testing.T) { - log := flattenTransitionRecords(transitions) - for i := range log { - if log[i].Index == 1 { - log[i].CommandID = "other-command" - break - } - } - if _, _, err := FoldEvents(cloneMachineState(&initial), log); err == nil { - t.Fatal("same-revision command identity change folded silently") - } - }) - t.Run("unsupported schema version", func(t *testing.T) { - log := flattenTransitionRecords(transitions) - log[0].SchemaVersion = 99 - if _, _, err := FoldEvents(cloneMachineState(&initial), log); err == nil { - t.Fatal("unsupported schema version folded silently") - } - }) - t.Run("tampered fact", func(t *testing.T) { - log := flattenTransitionRecords(transitions) - for i := range log { - if f, ok := log[i].Fact.(ToolCallCompleted); ok { - f.OutputDigest = "sha256:tampered" - log[i].Fact = f - break - } - } - if _, _, err := FoldEvents(cloneMachineState(&initial), log); err == nil { - t.Fatal("tampered fact folded silently") - } - }) -} - -// ModelStepPrepared is self-contained: the binding digest folds verbatim and -// reproduces the step identity without recomputation. -func TestRegressionPreparedFactSelfContained(t *testing.T) { - s := newRun(t) - prep, cmdID := buildPrepare(t, s, testRequest(), nil) - facts := mustDecide(t, s, prep) - fact := facts[0].(ModelStepPrepared) - if fact.BindingDigest == "" { - t.Fatal("ModelStepPrepared carries no binding digest") - } - if DeriveModelStepID(s.RunID, cmdID, fact.BindingDigest) != fact.StepID { - t.Fatal("carried binding digest does not reproduce the step ID") - } - s = fold(t, s, facts) - ms := s.Current.(ModelStep) - if ms.RefValue.Digest != fact.BindingDigest { - t.Fatal("Evolve did not fold the carried digest verbatim") - } -} - -// Golden event stream for the current pre-release v1 command sequence. It -// protects the current fold result; deliberately changing the pre-release -// protocol requires re-freezing the fixture in the same change. After v1 is -// published, this becomes a permanent compatibility fixture. -func TestGoldenEventStreamV1(t *testing.T) { - rt := fullRunRuntime(t) - record, err := rt.Record(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } - folded, maxRev, err := FoldTransitions(cloneMachineState(&record.Header.InitialState), record.Transitions) - if err != nil { - t.Fatal(err) - } - if maxRev != 9 { - t.Fatalf("golden stream has %d transitions, want 9", maxRev) - } - stateBytes, err := ProtocolV1().EncodeMachineState(&folded) - if err != nil { - t.Fatal(err) - } - got := string(sha256Digest(stateBytes)) - // Pre-release fixture; re-frozen when facts became digest-only (RUN-WIR-4): - // ModelStepCompleted/ToolCallCompleted carry ResultDigest/OutputDigest and - // MachineState no longer holds LastModelResult. - const frozen = "sha256:5691b677142774c67ba3383309be93cc3cb212ac70469f9aeb82c47ef66e4224" - if got != frozen { - t.Fatalf("golden v1 state digest changed:\n got %s\nwant %s\nstate: %s", got, frozen, stateBytes) - } -} diff --git a/agent/run/frozen.go b/agent/run/frozen.go index 96b4e8c..5e20f3c 100644 --- a/agent/run/frozen.go +++ b/agent/run/frozen.go @@ -69,9 +69,9 @@ func (m *MemoryFrozenValues) Delete(digest Digest) { m.mu.Unlock() } -// encodeFrozenRequest renders the canonical bytes stored for a request and +// 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) { +func EncodeFrozenRequest(req *ModelRequest, want Digest) ([]byte, error) { got, err := digestRequestV1(*req) if err != nil { return nil, err @@ -82,9 +82,9 @@ func encodeFrozenRequest(req *ModelRequest, want Digest) ([]byte, error) { return marshalCanonical(req) } -// decodeFrozenRequest restores a request body and checks it still digests to +// 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) { +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) diff --git a/agent/run/header.go b/agent/run/header.go deleted file mode 100644 index 23c8432..0000000 --- a/agent/run/header.go +++ /dev/null @@ -1,112 +0,0 @@ -package run - -import ( - "errors" - - "github.com/memohai/twilight/agent/es" -) - -func encodeMachineStateVersion(schemaVersion uint16, s *MachineState) ([]byte, error) { - proto, err := ProtocolFor(schemaVersion) - if err != nil { - return nil, err - } - return proto.EncodeMachineState(s) -} - -func validateHeaderInitialStateVersion(h *RunHeader) error { - proto, err := ProtocolFor(h.SchemaVersion) - if err != nil { - return err - } - return proto.ValidateHeader(h) -} - -// RunHeader is the formal persisted Revision-0 protocol record (RUN-NEW-1). -// RunHeader plus the TransitionRecord log is the canonical record used for -// audit, projections, and verified import/replay. Runtime atomically stores the -// header with Revision-0 state during Create and stores each next state with -// its transition during Commit. Every fold starts from the header's initial -// state. The header is immutable after creation. -type RunHeader struct { - SchemaVersion uint16 `json:"schemaVersion"` - RunID RunID `json:"runId"` - InitialStateVersion uint16 `json:"initialStateVersion"` - InitialState MachineState `json:"initialState"` - InitialStateDigest Digest `json:"initialStateDigest"` - // CausationID links the Run to its creating turn/application operation. - // Opaque to run; the application defines its interpretation. - CausationID es.CausationID `json:"causationId,omitempty"` - HeaderDigest Digest `json:"headerDigest"` -} - -type runHeaderDigestBody struct { - SchemaVersion uint16 `json:"schemaVersion"` - RunID RunID `json:"runId"` - InitialStateVersion uint16 `json:"initialStateVersion"` - InitialStateDigest Digest `json:"initialStateDigest"` - CausationID es.CausationID `json:"causationId,omitempty"` -} - -func digestRunHeader(h *RunHeader) (Digest, error) { - body, err := encodeEnvelopeBody(h.SchemaVersion, "run_header", runHeaderDigestBody{ - SchemaVersion: h.SchemaVersion, - RunID: h.RunID, - InitialStateVersion: h.InitialStateVersion, - InitialStateDigest: h.InitialStateDigest, - CausationID: h.CausationID, - }) - if err != nil { - return "", err - } - return sha256Digest(body), nil -} - -// ValidateRunHeader verifies header integrity: state digest, header digest, -// and that the initial state is a legal Revision-0 state for this RunID. -// Imported/uploaded runs must pass this before their log is folded -// (RUN-NEW-2). -func ValidateRunHeader(h *RunHeader) error { - if h.RunID == "" { - return errors.New("agent: run header: empty RunID") - } - if err := validateHeaderInitialStateVersion(h); err != nil { - return err - } - if h.InitialState.RunID != h.RunID { - return errors.New("agent: run header: initial state RunID mismatch") - } - if h.InitialState.Status != RunActive || !atOpen(h.InitialState.Current) || - len(h.InitialState.PendingInputs) != 0 || h.InitialState.ModelSteps != 0 || - h.InitialState.Result != nil || - h.InitialState.LastToolStep != nil || - h.InitialState.Usage != (Usage{}) { - return errors.New("agent: run header: initial state is not a minimal Revision-0 state") - } - stateBytes, err := encodeMachineStateVersion(h.SchemaVersion, &h.InitialState) - if err != nil { - return err - } - if sha256Digest(stateBytes) != h.InitialStateDigest { - return errors.New("agent: run header: initial state digest mismatch") - } - want, err := digestRunHeader(h) - if err != nil { - return err - } - if h.HeaderDigest != want { - return errors.New("agent: run header: header digest mismatch") - } - return nil -} - -// FoldRun rebuilds the run state from the canonical header + transition log. -// It validates the header first, then folds the records (RUN-NEW-2). This is -// the entry point durable adapters and import/migration paths use; trusting an -// uploaded MachineState snapshot is never legal. -func FoldRun(header *RunHeader, records []TransitionRecord) (MachineState, uint64, error) { - if err := ValidateRunHeader(header); err != nil { - return MachineState{}, 0, err - } - return FoldTransitions(header.InitialState, records) -} diff --git a/agent/run/header_test.go b/agent/run/header_test.go deleted file mode 100644 index cad3364..0000000 --- a/agent/run/header_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package run - -import ( - "strings" - "testing" - - "github.com/memohai/twilight/agent/es" -) - -func buildTestHeader(t *testing.T, causation es.CausationID) RunHeader { - t.Helper() - newRun, err := BuildNewRun("run-1", causation) - if err != nil { - t.Fatal(err) - } - h, err := BuildRunHeaderFromNewRun(newRun) - if err != nil { - t.Fatal(err) - } - return h -} - -func TestBuildRunHeaderRoundTrip(t *testing.T) { - h := buildTestHeader(t, es.CausationID("session:entry-9")) - var err error - if err != nil { - t.Fatal(err) - } - if err := ValidateRunHeader(&h); err != nil { - t.Fatalf("fresh header invalid: %v", err) - } - if h.InitialState.RunID != "run-1" || h.InitialState.Status != RunActive { - t.Fatalf("initial state = %+v", h.InitialState) - } - if h.CausationID != "session:entry-9" { - t.Fatal("causation id not carried") - } -} - -func TestValidateRunHeaderRejectsTampering(t *testing.T) { - h := buildTestHeader(t, "") - // Tampered causation changes the header digest preimage. - tampered := h - tampered.CausationID = "forged" - if err := ValidateRunHeader(&tampered); err == nil { - t.Fatal("tampered causation accepted") - } - // A non-minimal initial state is rejected even with recomputed digests. - fat := h - fat.InitialState.ModelSteps = 3 - if err := ValidateRunHeader(&fat); err == nil || !strings.Contains(err.Error(), "minimal") { - t.Fatalf("non-minimal initial state accepted: %v", err) - } - // RunID mismatch between header and state is rejected. - cross := h - cross.RunID = "run-2" - if err := ValidateRunHeader(&cross); err == nil { - t.Fatal("cross-run header accepted") - } -} - -func TestCommitRejectsCommandSchemaMismatch(t *testing.T) { - rt := NewRuntime(NewMemoryStore()) - newRun, err := BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(t.Context(), newRun); err != nil { - t.Fatal(err) - } - in := AgentInput{ID: "seed", Payload: MustParseCanonicalJSON(`{"q":"hi"}`)} - env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), NextStep(in)) - if err != nil { - t.Fatal(err) - } - env.SchemaVersion = 2 - if _, err := rt.Commit(t.Context(), CommitRequest{BaseRevision: 0, Command: env}); err == nil { - t.Fatal("v2 envelope accepted on v1 run") - } -} diff --git a/agent/run/ids.go b/agent/run/ids.go index a960a58..154b3ee 100644 --- a/agent/run/ids.go +++ b/agent/run/ids.go @@ -53,10 +53,10 @@ func namespacedHash(namespace string, parts ...string) string { } // DeriveModelRequestCommandID derives the CommandID for PrepareModelRequest -// from the Run and the Revision the planner loaded (RUN-WIR-3): concurrent -// planners on the same Revision converge on one command identity. -func DeriveModelRequestCommandID(run RunID, revision uint64) CommandID { - return CommandID(namespacedHash("twilight/model-request", string(run), fmt.Sprintf("%d", revision))) +// 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.Revision), fmt.Sprintf("%d", position.Index))) } // DeriveModelStepID derives the frozen ModelStep identity from the Run, the diff --git a/agent/run/loop/bound.go b/agent/run/loop/bound.go new file mode 100644 index 0000000..2945046 --- /dev/null +++ b/agent/run/loop/bound.go @@ -0,0 +1,31 @@ +package loop + +import ( + "context" + + run "github.com/memohai/twilight/agent/run" + "github.com/memohai/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) +} + +func (b boundRuntime) RenewLease(ctx context.Context, runID run.RunID, stepID run.StepID, callID run.CallID, grant run.ExecutionGrant) error { + return b.rt.RenewLease(ctx, b.sid, runID, stepID, callID, grant) +} diff --git a/agent/run/loop/cache.go b/agent/run/loop/cache.go index d74df1a..d0dfdd3 100644 --- a/agent/run/loop/cache.go +++ b/agent/run/loop/cache.go @@ -9,6 +9,7 @@ import ( "sync" run "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" ) // ClaimStore is the host-injected record of this Loop's live execution @@ -24,14 +25,15 @@ import ( // Implementations must be safe for concurrent use. Put replaces any existing // claim for the key; Delete of a missing key is a no-op. type ClaimStore interface { - Put(ctx context.Context, runID run.RunID, stepID run.StepID, callID run.CallID, claim run.ExecutionClaim) error - Get(ctx context.Context, runID run.RunID, stepID run.StepID, callID run.CallID) (run.ExecutionClaim, bool, error) - Delete(ctx context.Context, runID run.RunID, stepID run.StepID, callID run.CallID) error + Put(ctx context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID, claim run.ExecutionClaim) error + Get(ctx context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID) (run.ExecutionClaim, bool, error) + Delete(ctx context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID) error // DeleteRun forgets every claim of a finished Run. - DeleteRun(ctx context.Context, runID run.RunID) error + DeleteRun(ctx context.Context, sid session.SessionID, runID run.RunID) error } type claimKey struct { + sid session.SessionID runID run.RunID stepID run.StepID callID run.CallID @@ -47,31 +49,31 @@ func newMemoryClaims() *memoryClaims { return &memoryClaims{claims: make(map[claimKey]run.ExecutionClaim)} } -func (m *memoryClaims) Put(_ context.Context, runID run.RunID, stepID run.StepID, callID run.CallID, claim run.ExecutionClaim) error { +func (m *memoryClaims) Put(_ context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID, claim run.ExecutionClaim) error { m.mu.Lock() - m.claims[claimKey{runID, stepID, callID}] = claim + m.claims[claimKey{sid, runID, stepID, callID}] = claim m.mu.Unlock() return nil } -func (m *memoryClaims) Get(_ context.Context, runID run.RunID, stepID run.StepID, callID run.CallID) (run.ExecutionClaim, bool, error) { +func (m *memoryClaims) Get(_ context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID) (run.ExecutionClaim, bool, error) { m.mu.Lock() defer m.mu.Unlock() - c, ok := m.claims[claimKey{runID, stepID, callID}] + c, ok := m.claims[claimKey{sid, runID, stepID, callID}] return c, ok, nil } -func (m *memoryClaims) Delete(_ context.Context, runID run.RunID, stepID run.StepID, callID run.CallID) error { +func (m *memoryClaims) Delete(_ context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID) error { m.mu.Lock() - delete(m.claims, claimKey{runID, stepID, callID}) + delete(m.claims, claimKey{sid, runID, stepID, callID}) m.mu.Unlock() return nil } -func (m *memoryClaims) DeleteRun(_ context.Context, runID run.RunID) error { +func (m *memoryClaims) DeleteRun(_ context.Context, sid session.SessionID, runID run.RunID) error { m.mu.Lock() for k := range m.claims { - if k.runID == runID { + if k.sid == sid && k.runID == runID { delete(m.claims, k) } } @@ -82,6 +84,7 @@ func (m *memoryClaims) DeleteRun(_ context.Context, runID run.RunID) error { // attempt is one execution attempt this Loop owns or is trying to own. Every // command identity of the attempt derives from the claim. type attempt struct { + sid session.SessionID runID run.RunID stepID run.StepID callID run.CallID @@ -103,23 +106,23 @@ func (a attempt) recoveryID() run.CommandID { // claimFor returns the attempt for key, reusing a stored claim when the Loop // (or a predecessor process sharing the ClaimStore) already started it, and // minting and storing a fresh claim otherwise. -func (l *Loop) claimFor(ctx context.Context, runID run.RunID, stepID run.StepID, callID run.CallID) (attempt, error) { - claim, ok, err := l.Claims.Get(ctx, runID, stepID, callID) +func (l *Loop) claimFor(ctx context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID) (attempt, error) { + claim, ok, err := l.Claims.Get(ctx, sid, runID, stepID, callID) if err != nil { return attempt{}, fmt.Errorf("agent: loop: claim store: %w", err) } if !ok { claim = freshExecutionClaim() - if err := l.Claims.Put(ctx, runID, stepID, callID, claim); err != nil { + if err := l.Claims.Put(ctx, sid, runID, stepID, callID, claim); err != nil { return attempt{}, fmt.Errorf("agent: loop: claim store: %w", err) } } - return attempt{runID: runID, stepID: stepID, callID: callID, claim: claim}, nil + return attempt{sid: sid, runID: runID, stepID: stepID, callID: callID, claim: claim}, nil } // hasClaim reports whether a claim for the target is already stored. -func (l *Loop) hasClaim(ctx context.Context, runID run.RunID, stepID run.StepID, callID run.CallID) (bool, error) { - _, ok, err := l.Claims.Get(ctx, runID, stepID, callID) +func (l *Loop) hasClaim(ctx context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID) (bool, error) { + _, ok, err := l.Claims.Get(ctx, sid, runID, stepID, callID) if err != nil { return false, fmt.Errorf("agent: loop: claim store: %w", err) } @@ -127,18 +130,18 @@ func (l *Loop) hasClaim(ctx context.Context, runID run.RunID, stepID run.StepID, } func (l *Loop) forgetClaim(ctx context.Context, a attempt) { - _ = l.Claims.Delete(context.WithoutCancel(ctx), a.runID, a.stepID, a.callID) + _ = l.Claims.Delete(context.WithoutCancel(ctx), a.sid, a.runID, a.stepID, a.callID) } -func (l *Loop) forgetRunClaims(ctx context.Context, runID run.RunID) { - _ = l.Claims.DeleteRun(context.WithoutCancel(ctx), runID) +func (l *Loop) forgetRunClaims(ctx context.Context, sid session.SessionID, runID run.RunID) { + _ = l.Claims.DeleteRun(context.WithoutCancel(ctx), sid, runID) } // settle commits the owner settlement of an attempt under its derived // CommandID. On success or on a sentinel rejection the claim is released: // the attempt is over either way. A transport failure keeps the claim so the // next Run (in this or a replacement process) replays the same settlement. -func (l *Loop) settle(ctx context.Context, runtime run.Runtime, events EventSink, a attempt, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand, proto run.Protocol) error { +func (l *Loop) settle(ctx context.Context, runtime boundRuntime, events EventSink, a attempt, base run.RunPosition, grant run.ExecutionGrant, cmd run.AgentCommand, proto run.Protocol) error { id := a.settlementID() if _, recovering := cmd.(run.RecoverModelExecution); recovering { id = a.recoveryID() @@ -152,7 +155,7 @@ func (l *Loop) settle(ctx context.Context, runtime run.Runtime, events EventSink return err } l.forgetClaim(ctx, a) - l.emitCommitted(ctx, events, a.runID, res.Events) + l.emitCommitted(ctx, events, runtime.sid, a.runID, &res.Commit) return nil } @@ -160,14 +163,14 @@ func (l *Loop) settle(ctx context.Context, runtime run.Runtime, events EventSink // for. With a durable ClaimStore this is how a replacement process finishes // what its predecessor started: the derived start ID replays and returns the // live grant, then the effect runs (or re-runs) and settles. -func (l *Loop) resumeOwnedStarts(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot) (bool, error) { +func (l *Loop) resumeOwnedStarts(ctx context.Context, runtime boundRuntime, events EventSink, snapshot *run.RuntimeSnapshot) (bool, error) { runID := snapshot.State.RunID switch current := snapshot.State.Current.(type) { case run.ModelStep: if current.Status != run.ModelExecuting { return false, nil } - if ok, err := l.hasClaim(ctx, runID, current.RefValue.ID, ""); err != nil || !ok { + if ok, err := l.hasClaim(ctx, runtime.sid, runID, current.RefValue.ID, ""); err != nil || !ok { return false, err } return true, l.runModelStep(ctx, runtime, events, snapshot, current.RefValue.ID) @@ -177,7 +180,7 @@ func (l *Loop) resumeOwnedStarts(ctx context.Context, runtime run.Runtime, event if call.Status != run.ToolExecuting { continue } - if ok, err := l.hasClaim(ctx, runID, current.RefValue.ID, call.CallID); err != nil { + if ok, err := l.hasClaim(ctx, runtime.sid, runID, current.RefValue.ID, call.CallID); err != nil { return false, err } else if ok { ids = append(ids, call.CallID) diff --git a/agent/run/loop/contract.go b/agent/run/loop/contract.go index e10ca0e..133fb01 100644 --- a/agent/run/loop/contract.go +++ b/agent/run/loop/contract.go @@ -7,6 +7,7 @@ import ( "time" run "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" "github.com/memohai/twilight/sdk" ) @@ -134,17 +135,19 @@ const ( ) type Event struct { - RunID run.RunID - StepID run.StepID - CallID run.CallID - // Sequence orders provisional observations within one stream. Canonical - // observations use AgentEvent.Revision/Index for authority ordering. + Session session.SessionID + RunID run.RunID + StepID run.StepID + CallID run.CallID + // Sequence orders provisional observations within one stream. Committed + // observations use the Session (Revision, Index) for authority ordering. Sequence uint64 Kind EventKind Durability EventDurability Payload json.RawMessage - // Canonical is set for a committed observation; nil for provisional. - Canonical *run.AgentEvent + // Committed is set for an EventAgentCommitted observation: the accepted + // SessionCommit (run facts, companion, attach); nil for provisional. + Committed *session.SessionCommit } // ExecutionPolicy is host-owned loop policy. ToolExecution and MaxParallel diff --git a/agent/run/loop/events.go b/agent/run/loop/events.go index a39414e..74c9296 100644 --- a/agent/run/loop/events.go +++ b/agent/run/loop/events.go @@ -6,6 +6,7 @@ import ( "sync" run "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" ) type serializedEventSink struct { @@ -22,19 +23,18 @@ func (s *serializedEventSink) Emit(ctx context.Context, event Event) error { return s.sink.Emit(ctx, event) } -func (l *Loop) emitCommitted(ctx context.Context, events EventSink, runID run.RunID, committed []run.AgentEvent) { - if events == nil { +func (l *Loop) emitCommitted(ctx context.Context, events EventSink, sid session.SessionID, runID run.RunID, committed *session.SessionCommit) { + if events == nil || committed == nil { return } - for i := range committed { - e := committed[i] - _ = events.Emit(ctx, Event{ - RunID: runID, - Kind: EventAgentCommitted, - Durability: EventCommitted, - Canonical: &e, - }) - } + c := *committed + _ = events.Emit(ctx, Event{ + Session: sid, + RunID: runID, + Kind: EventAgentCommitted, + Durability: EventCommitted, + Committed: &c, + }) } type progressSink struct { diff --git a/agent/run/loop/helpers_test.go b/agent/run/loop/helpers_test.go index b602a37..83c8cdd 100644 --- a/agent/run/loop/helpers_test.go +++ b/agent/run/loop/helpers_test.go @@ -3,49 +3,115 @@ package loop import ( "context" "testing" + "time" . "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/extension" + runmod "github.com/memohai/twilight/agent/session/run" ) -const testModel ModelRef = "m-1" +const ( + testModel ModelRef = "m-1" + testSession session.SessionID = "s-1" +) func cj(raw string) CanonicalJSON { return MustParseCanonicalJSON(raw) } -func newTestRuntime(t *testing.T) Runtime { +// 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, and a Runtime with a no-op companion. +type testStack struct { + store *session.MemoryStore + appender extension.SemanticAppender + runtime *runmod.Runtime +} + +func newTestStack(t testing.TB, ttl time.Duration, now func() time.Time) *testStack { t.Helper() - rt := NewRuntime(NewMemoryStore()) - newRun, err := BuildNewRun("run-1", "") + store := session.NewMemoryStore() + registry, err := extension.BuildRegistry(session.ProfileV1(), runmod.Module) + if err != nil { + t.Fatal(err) + } + appender, err := extension.NewSemanticAppender(store, registry, nil, nil) if err != nil { t.Fatal(err) } - if _, err := rt.Create(context.Background(), newRun); err != nil { + rt, err := runmod.NewRuntime(runmod.Config{Store: store, Registry: registry, Appender: appender, + Projections: extension.NewProjectionReader(store, registry), Companion: nopCompanion{}, LeaseTTL: ttl, Now: now}) + if err != nil { + t.Fatal(err) + } + if _, err := store.Create(context.Background(), session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: testSession}); err != nil { t.Fatal(err) } - env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", "seed"), AcceptInput{ - Input: AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)}, - }) + return &testStack{store: store, appender: appender, 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) } - snap, err := rt.Load(context.Background(), "run-1") + facts, err := ProtocolV1().BuildCreateGroup(newRun, inputs) if err != nil { t.Fatal(err) } - if _, err := rt.Commit(context.Background(), CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { + 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}}) + } + head, err := s.store.Head(context.Background(), testSession) + if err != nil { t.Fatal(err) } - return rt + res, err := s.appender.AppendSemantic(context.Background(), extension.SemanticAppendRequest{SessionID: testSession, ExpectedHead: head, Group: group}) + if err != nil { + t.Fatal(err) + } + if res.Outcome != extension.SemanticApplied { + t.Fatalf("create run: %s %s", res.Outcome, res.Detail) + } } -func recordEvents(t testing.TB, rt Runtime, runID RunID) []AgentEvent { +// newTestRuntime is a Runtime holding "run-1" seeded with one input, leases +// never expiring. +func newTestRuntime(t testing.TB) Runtime { t.Helper() - record, err := rt.Record(context.Background(), runID) + stack := newTestStack(t, 0, 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) } - var events []AgentEvent - for _, transition := range record.Transitions { - events = append(events, transition.Events...) + 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 events + return snap } diff --git a/agent/run/loop/lease.go b/agent/run/loop/lease.go index 093d142..730d14c 100644 --- a/agent/run/loop/lease.go +++ b/agent/run/loop/lease.go @@ -15,7 +15,7 @@ import ( // continuing the effect can only produce a result nobody will accept. // // With interval <= 0 renewal is disabled and the worker context is ctx itself. -func (l *Loop) keepLease(ctx context.Context, runtime run.Runtime, runID run.RunID, stepID run.StepID, callID run.CallID, grant run.ExecutionGrant) (workerCtx context.Context, stop func()) { +func (l *Loop) keepLease(ctx context.Context, runtime boundRuntime, runID run.RunID, stepID run.StepID, callID run.CallID, grant run.ExecutionGrant) (workerCtx context.Context, stop func()) { interval := l.Execution.LeaseRenewInterval if interval <= 0 { return ctx, func() {} diff --git a/agent/run/loop/lease_test.go b/agent/run/loop/lease_test.go index 50c1ecf..b505210 100644 --- a/agent/run/loop/lease_test.go +++ b/agent/run/loop/lease_test.go @@ -8,6 +8,7 @@ import ( "time" . "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" "github.com/memohai/twilight/sdk" ) @@ -16,9 +17,9 @@ type renewCountingRuntime struct { renewals atomic.Int32 } -func (r *renewCountingRuntime) RenewLease(ctx context.Context, runID RunID, stepID StepID, callID CallID, grant ExecutionGrant) error { +func (r *renewCountingRuntime) RenewLease(ctx context.Context, sid session.SessionID, runID RunID, stepID StepID, callID CallID, grant ExecutionGrant) error { r.renewals.Add(1) - return r.Runtime.RenewLease(ctx, runID, stepID, callID, grant) + return r.Runtime.RenewLease(ctx, sid, runID, stepID, callID, grant) } // A tool that runs longer than the lease TTL keeps its lease alive through @@ -30,22 +31,9 @@ func TestLoopRenewsLeaseDuringLongTool(t *testing.T) { now := func() time.Time { mu.Lock(); defer mu.Unlock(); return clock } advance := func(d time.Duration) { mu.Lock(); defer mu.Unlock(); clock = clock.Add(d) } - base := NewRuntimeWithOptions(NewMemoryStore(), RuntimeOptions{LeaseTTL: 200 * time.Millisecond, Now: now}) - rt := &renewCountingRuntime{Runtime: base} - newRun, err := BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(context.Background(), newRun); err != nil { - t.Fatal(err) - } - env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", "seed"), AcceptInput{Input: AgentInput{ID: "seed", Payload: cj(`{}`)}}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(context.Background(), CommitRequest{Command: env}); err != nil { - t.Fatal(err) - } + stack := newTestStack(t, 200*time.Millisecond, now) + stack.createRun(t, "run-1", AgentInput{ID: "seed", Payload: cj(`{}`)}) + rt := &renewCountingRuntime{Runtime: stack.runtime} spec := toolSpec(t, "slow", DirectExecution) slow := &fakeTool{ref: "slow", def: toolDef(spec.Name), policy: DirectExecution, @@ -69,7 +57,7 @@ func TestLoopRenewsLeaseDuringLongTool(t *testing.T) { if err != nil { t.Fatal(err) } - res, err := interpreter.Run(context.Background(), rt, "run-1", nil) + res, err := interpreter.Run(context.Background(), rt, testSession, "run-1", nil) if err != nil { t.Fatal(err) } @@ -79,15 +67,9 @@ func TestLoopRenewsLeaseDuringLongTool(t *testing.T) { if rt.renewals.Load() == 0 { t.Fatal("lease was never renewed") } - record, err := rt.Record(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } - for _, tr := range record.Transitions { - for _, ev := range tr.Events { - if f, ok := ev.Fact.(ToolCallFailed); ok && f.Outcome == ToolOutcomeUnknown { - t.Fatalf("tool call settled Unknown despite heartbeat: %+v", f) - } + for _, fact := range recordFacts(t, rt, "run-1") { + if f, ok := fact.(ToolCallFailed); ok && f.Outcome == ToolOutcomeUnknown { + t.Fatalf("tool call settled Unknown despite heartbeat: %+v", f) } } } @@ -103,21 +85,9 @@ func TestNewRejectsNegativeLeaseRenewInterval(t *testing.T) { // derived start under the same claim, gets the live grant back, executes the // tool and settles, without waiting for the lease to expire. func TestLoopReplacementFinishesInheritedClaim(t *testing.T) { - rt := NewRuntimeWithOptions(NewMemoryStore(), RuntimeOptions{LeaseTTL: time.Hour}) - newRun, err := BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(context.Background(), newRun); err != nil { - t.Fatal(err) - } - env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", "seed"), AcceptInput{Input: AgentInput{ID: "seed", Payload: cj(`{}`)}}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(context.Background(), CommitRequest{Command: env}); err != nil { - t.Fatal(err) - } + stack := newTestStack(t, time.Hour, nil) + stack.createRun(t, "run-1", AgentInput{ID: "seed", Payload: cj(`{}`)}) + rt := stack.runtime shared := newMemoryClaims() spec := toolSpec(t, "echo", DirectExecution) @@ -139,10 +109,10 @@ func TestLoopReplacementFinishesInheritedClaim(t *testing.T) { t.Fatal(err) } firstDone := make(chan struct{}) - go func() { defer close(firstDone); _, _ = first.Run(context.Background(), rt, "run-1", nil) }() + go func() { defer close(firstDone); _, _ = first.Run(context.Background(), rt, testSession, "run-1", nil) }() deadline := time.Now().Add(5 * time.Second) for { - snap, err := rt.Load(context.Background(), "run-1") + snap, err := rt.Load(context.Background(), testSession, "run-1") if err != nil { t.Fatal(err) } @@ -161,27 +131,21 @@ func TestLoopReplacementFinishesInheritedClaim(t *testing.T) { if err != nil { t.Fatal(err) } - res, err := second.Run(context.Background(), rt, "run-1", nil) + res, err := second.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("second loop result = %+v", res) } - record, err := rt.Record(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } starts := 0 - for _, tr := range record.Transitions { - for _, ev := range tr.Events { - switch f := ev.Fact.(type) { - case ToolCallStarted: - starts++ - case ToolCallFailed: - if f.Outcome == ToolOutcomeUnknown { - t.Fatalf("call settled Unknown; replacement did not inherit the claim: %+v", f) - } + for _, fact := range recordFacts(t, rt, "run-1") { + switch f := fact.(type) { + case ToolCallStarted: + starts++ + case ToolCallFailed: + if f.Outcome == ToolOutcomeUnknown { + t.Fatalf("call settled Unknown; replacement did not inherit the claim: %+v", f) } } } diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index 0e46b73..a7cd989 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -7,6 +7,7 @@ import ( "sync" run "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" ) // Loop is the in-process interpreter of one Run (RUN-LOP-2). It holds no @@ -86,15 +87,15 @@ func (l *Loop) releaseRun(runID run.RunID) { // 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, runtime run.Runtime, runID run.RunID, events EventSink) (LoopResult, error) { +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 runtime == nil { + if rt == nil { return LoopResult{}, errors.New("agent: loop: nil runtime") } - if runID == "" { - return LoopResult{}, errors.New("agent: loop: empty RunID") + if sid == "" || runID == "" { + return LoopResult{}, errors.New("agent: loop: empty SessionID or RunID") } if err := l.acquireRun(runID); err != nil { return LoopResult{}, err @@ -103,6 +104,7 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev if events != nil { events = &serializedEventSink{sink: events, mu: &l.eventsMu} } + runtime := boundRuntime{rt: rt, sid: sid} for { if err := ctx.Err(); err != nil { @@ -118,9 +120,10 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev return LoopResult{}, fmt.Errorf("agent: loop: runtime returned RunID %q for %q", snapshot.State.RunID, runID) } if snapshot.State.Status.Terminal() { - l.forgetRunClaims(ctx, runID) + l.forgetRunClaims(ctx, sid, runID) if events != nil { _ = events.Emit(ctx, Event{ + Session: sid, RunID: snapshot.State.RunID, Kind: EventRunFinished, Durability: EventCommitted, @@ -156,13 +159,13 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev if err != nil { return LoopResult{}, err } - res, err := l.commit(ctx, runtime, runID, run.DeriveWithdrawCommandID(runID, eff.StepID), snapshot.Revision, "", + 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, runID, res.Events) + l.emitCommitted(ctx, events, sid, runID, &res.Commit) } case run.StartModelCall: if err := l.runModelStep(ctx, runtime, events, &snapshot, eff.StepID); err != nil { @@ -190,15 +193,15 @@ func (l *Loop) Run(ctx context.Context, runtime run.Runtime, runID run.RunID, ev // digest (RUN-LOP-5): if the first attempt actually // committed and only the response was lost, the replay returns AlreadyApplied // instead of abandoning a live grant or re-executing an expensive step. -func (l *Loop) commit(ctx context.Context, runtime run.Runtime, runID run.RunID, id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand, proto run.Protocol) (run.CommitResult, error) { +func (l *Loop) commit(ctx context.Context, runtime boundRuntime, runID run.RunID, id run.CommandID, base run.RunPosition, grant run.ExecutionGrant, 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(runID, id, cmd) + env, err := proto.BuildEnvelope(runtime.sid, runID, id, cmd) if err != nil { return run.CommitResult{}, err } - req := run.CommitRequest{BaseRevision: base, Grant: grant, Command: env} + req := run.CommitRequest{Base: base, Grant: grant, Command: env} res, err := runtime.Commit(ctx, req) if err != nil && !retriable(err) { res, err = runtime.Commit(ctx, req) diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go index bf1c88c..238d9d8 100644 --- a/agent/run/loop/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -10,6 +10,7 @@ import ( "testing" . "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" "github.com/memohai/twilight/sdk" ) @@ -114,11 +115,6 @@ func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { return ToolSpec{Ref: ToolRef(name), Name: name, DefinitionDigest: d, Policy: policy} } -func loopRuntime(t *testing.T) Runtime { - t.Helper() - return newTestRuntime(t) -} - func textResult(text string) sdk.ModelResult { return sdk.ModelResult{Text: text, FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}} } @@ -153,11 +149,11 @@ func TestLoopRejectsConcurrentRunForSameID(t *testing.T) { done := make(chan error, 1) go func() { - _, runErr := loop.Run(context.Background(), rt, "run-1", nil) + _, runErr := loop.Run(context.Background(), rt, testSession, "run-1", nil) done <- runErr }() <-invoker.started - if _, err := loop.Run(context.Background(), rt, "run-1", nil); !errors.Is(err, ErrRunAlreadyRunning) { + 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) @@ -177,11 +173,11 @@ func TestLoopModelCatalogErrorRecoversWithFreshLoop(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = broken.Run(context.Background(), rt, "run-1", nil) + _, 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(), "run-1") + snap, loadErr := rt.Load(context.Background(), testSession, "run-1") if loadErr != nil { t.Fatal(loadErr) } @@ -201,14 +197,14 @@ func TestLoopModelCatalogErrorRecoversWithFreshLoop(t *testing.T) { if err != nil { t.Fatal(err) } - res, err := ready.Run(context.Background(), rt, "run-1", nil) + 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(), "run-1") + final, err := rt.Load(context.Background(), testSession, "run-1") if err != nil { t.Fatal(err) } @@ -245,7 +241,7 @@ func TestLoopParallelBounded(t *testing.T) { var res LoopResult var runErr error go func() { - res, runErr = loop.Run(context.Background(), rt, "run-1", nil) + res, runErr = loop.Run(context.Background(), rt, testSession, "run-1", nil) close(done) }() @@ -270,7 +266,7 @@ func TestLoopParallelBounded(t *testing.T) { type staleCommitRuntime struct{ Runtime } -func (staleCommitRuntime) Commit(context.Context, CommitRequest) (CommitResult, error) { +func (staleCommitRuntime) Commit(context.Context, session.SessionID, CommitRequest) (CommitResult, error) { return CommitResult{}, ErrStaleRuntime } @@ -302,13 +298,13 @@ func TestToolStartStaleDropsLocalClaim(t *testing.T) { BindingDigest: bindingDigest, Arguments: args, Policy: DirectExecution, Status: ToolPending, }}, }, - }, Revision: 1, SchemaVersion: SchemaVersion1} + }, Position: RunPosition{Revision: 1}, SchemaVersion: SchemaVersion1} - if err := loop.runToolCalls(context.Background(), staleCommitRuntime{}, nil, snapshot, + if err := loop.runToolCalls(context.Background(), boundRuntime{rt: staleCommitRuntime{}, sid: testSession}, nil, snapshot, StartToolCalls{StepID: stepID, CallIDs: []CallID{callID}}); err != nil { t.Fatal(err) } - if _, ok, _ := loop.Claims.Get(context.Background(), "run-1", stepID, callID); ok { + if _, ok, _ := loop.Claims.Get(context.Background(), testSession, "run-1", stepID, callID); ok { t.Fatal("stale tool start retained a local execution claim") } } @@ -325,8 +321,8 @@ func newResponseLossRuntime(t *testing.T) *responseLossRuntime { return &responseLossRuntime{Runtime: loopRuntime(t), count: make(map[CommandID]int)} } -func (r *responseLossRuntime) Commit(ctx context.Context, req CommitRequest) (CommitResult, error) { - result, err := r.Runtime.Commit(ctx, req) +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 } @@ -353,10 +349,10 @@ func TestLoopReplaysStartAfterTwoLostResponses(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := loop.Run(context.Background(), rt, "run-1", nil); err == nil { + 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(), "run-1") + snapshot, err := rt.Load(context.Background(), testSession, "run-1") if err != nil { t.Fatal(err) } @@ -365,7 +361,7 @@ func TestLoopReplaysStartAfterTwoLostResponses(t *testing.T) { if current, ok := snapshot.State.Current.(ModelStep); !ok || current.Status != ModelExecuting { t.Fatalf("current = %#v, want Executing ModelStep", snapshot.State.Current) } - if _, err := loop.Run(context.Background(), rt, "run-1", nil); err != nil { + if _, err := loop.Run(context.Background(), rt, testSession, "run-1", nil); err != nil { t.Fatal(err) } if invoker.calls.Load() != 1 { @@ -388,13 +384,13 @@ func TestLoopReplaysSettlementWithoutRepeatingTool(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := loop.Run(context.Background(), rt, "run-1", nil); err == nil { + 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, "run-1", nil) + res, err := loop.Run(context.Background(), rt, testSession, "run-1", nil) if err != nil { t.Fatal(err) } @@ -428,7 +424,7 @@ func TestLoopMalformedModelResultDispositionFailsRun(t *testing.T) { if err != nil { t.Fatal(err) } - res, err := loop.Run(context.Background(), rt, "run-1", nil) + res, err := loop.Run(context.Background(), rt, testSession, "run-1", nil) if err != nil { t.Fatal(err) } @@ -462,13 +458,13 @@ func TestLoopMidExecutionCancelRecoversModelStep(t *testing.T) { rt := loopRuntime(t) loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) - _, err := loop.Run(ctx, rt, "run-1", nil) + _, 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(), "run-1") + 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) @@ -477,8 +473,8 @@ func TestLoopMidExecutionCancelRecoversModelStep(t *testing.T) { t.Fatalf("ModelSteps = %d", snap.State.ModelSteps) } recovered := false - for _, e := range recordEvents(t, rt, "run-1") { - if _, ok := e.Fact.(ModelStepRecovered); ok { + for _, e := range recordFacts(t, rt, "run-1") { + if _, ok := e.(ModelStepRecovered); ok { recovered = true } } @@ -489,14 +485,14 @@ func TestLoopMidExecutionCancelRecoversModelStep(t *testing.T) { // 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, "run-1", nil) + 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(), "run-1") + 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 index 6c6331e..4c2e2f4 100644 --- a/agent/run/loop/model.go +++ b/agent/run/loop/model.go @@ -10,7 +10,8 @@ import ( "github.com/memohai/twilight/sdk" ) -func (l *Loop) planAndPrepare(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, hint run.PlanningHint) error { +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 @@ -45,9 +46,9 @@ func (l *Loop) planAndPrepare(ctx context.Context, runtime run.Runtime, events E if err != nil { return err } - cmdID := run.DeriveModelRequestCommandID(snapshot.State.RunID, snapshot.Revision) + 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.Revision, "", run.PrepareModelRequest{ + res, err := l.commit(ctx, runtime, snapshot.State.RunID, cmdID, snapshot.Position, "", run.PrepareModelRequest{ StepID: stepID, Model: model, Request: frozenRequest, @@ -61,7 +62,7 @@ func (l *Loop) planAndPrepare(ctx context.Context, runtime run.Runtime, events E // 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, snapshot.State.RunID, res.Events) + l.emitCommitted(ctx, events, runtime.sid, snapshot.State.RunID, &res.Commit) return nil } if !retriable(err) { @@ -74,7 +75,7 @@ func (l *Loop) planAndPrepare(ctx context.Context, runtime run.Runtime, events E if loadErr != nil { return loadErr } - if after.Revision == snapshot.Revision { + 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 @@ -82,17 +83,17 @@ func (l *Loop) planAndPrepare(ctx context.Context, runtime run.Runtime, events E // --- StartModelCall --- -func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, stepID run.StepID) error { +func (l *Loop) runModelStep(ctx context.Context, runtime boundRuntime, events EventSink, snapshot *run.RuntimeSnapshot, stepID run.StepID) error { runID := snapshot.State.RunID proto, err := snapshot.Protocol() if err != nil { return err } - a, err := l.claimFor(ctx, runID, stepID, "") + a, err := l.claimFor(ctx, runtime.sid, runID, stepID, "") if err != nil { return err } - start, err := l.commit(ctx, runtime, runID, a.startID(), snapshot.Revision, "", run.StartModelExecution{StepID: stepID, Claim: a.claim}, proto) + 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) { l.forgetClaim(ctx, a) @@ -112,7 +113,7 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve if start.Grant == "" { return errors.New("agent: loop: start model returned no execution grant") } - l.emitCommitted(ctx, events, runID, start.Events) + l.emitCommitted(ctx, events, runtime.sid, runID, &start.Commit) modelStep, ok := start.Snapshot.State.Current.(run.ModelStep) if !ok || modelStep.RefValue.ID != stepID || modelStep.Status != run.ModelExecuting { @@ -150,7 +151,7 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve 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.Revision, start.Grant, run.RecoverModelExecution{StepID: stepID, Claim: a.claim}, proto); err != nil { + if err := l.settle(ctx, runtime, events, a, start.Snapshot.Position, start.Grant, run.RecoverModelExecution{StepID: stepID, Claim: a.claim}, proto); err != nil { return err } return fetchErr @@ -183,7 +184,7 @@ func (l *Loop) runModelStep(ctx context.Context, runtime run.Runtime, events Eve } } - if err := l.settle(ctx, runtime, events, a, start.Snapshot.Revision, start.Grant, completion, proto); err != nil { + if err := l.settle(ctx, runtime, events, a, start.Snapshot.Position, start.Grant, completion, proto); err != nil { return err } if catalogErr != nil { diff --git a/agent/run/loop/regression_test.go b/agent/run/loop/regression_test.go index ceeb0f5..9afd98b 100644 --- a/agent/run/loop/regression_test.go +++ b/agent/run/loop/regression_test.go @@ -23,7 +23,7 @@ func TestRegressionToolPanicBecomesUnknown(t *testing.T) { interpreter, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) - res, err := interpreter.Run(context.Background(), rt, "run-1", nil) + res, err := interpreter.Run(context.Background(), rt, testSession, "run-1", nil) if err != nil { t.Fatal(err) } @@ -31,8 +31,8 @@ func TestRegressionToolPanicBecomesUnknown(t *testing.T) { t.Fatalf("res = %+v", res.Result) } found := false - for _, e := range recordEvents(t, rt, "run-1") { - failed, ok := e.Fact.(ToolCallFailed) + for _, e := range recordFacts(t, rt, "run-1") { + failed, ok := e.(ToolCallFailed) if !ok || failed.Outcome != ToolOutcomeUnknown { continue } @@ -55,7 +55,7 @@ func TestRegressionRunFinishedEmitted(t *testing.T) { }) interpreter, _ := New(fakeCatalog{&fakeInvoker{results: []sdk.ModelResult{textResult("done")}}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) - if _, err := interpreter.Run(context.Background(), rt, "run-1", sink); err != nil { + if _, err := interpreter.Run(context.Background(), rt, testSession, "run-1", sink); err != nil { t.Fatal(err) } for _, k := range kinds { @@ -95,7 +95,7 @@ func TestRegressionAliasedToolRefExecutes(t *testing.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, "run-1", nil) + res, err := interpreter.Run(context.Background(), rt, testSession, "run-1", nil) if err != nil { t.Fatal(err) } @@ -110,7 +110,7 @@ func TestRegressionAliasedToolRefExecutes(t *testing.T) { func TestRegressionStreamNilResult(t *testing.T) { rt := loopRuntime(t) interpreter, _ := New(fakeCatalog{nilResultStreamer{}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, true) - res, err := interpreter.Run(context.Background(), rt, "run-1", nil) + res, err := interpreter.Run(context.Background(), rt, testSession, "run-1", nil) if err != nil { t.Fatal(err) } diff --git a/agent/run/loop/runtime_helpers_test.go b/agent/run/loop/runtime_helpers_test.go deleted file mode 100644 index 40e1541..0000000 --- a/agent/run/loop/runtime_helpers_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package loop - -import ( - "context" - "testing" - - . "github.com/memohai/twilight/agent/run" - "github.com/memohai/twilight/sdk" -) - -func TestLoopPrepareRejectionDoesNotLivelock(t *testing.T) { - rt := loopRuntime(t) - interpreter, err := New(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{}, - badPlanner{}, ExecutionPolicy{}, false) - if err != nil { - t.Fatal(err) - } - _, err = interpreter.Run(context.Background(), rt, "run-1", nil) - if err == nil { - t.Fatal("prepare livelock not surfaced") - } -} - -// badPlanner never consumes pending inputs, so its Prepare is always rejected. -type badPlanner struct{} - -func (badPlanner) Plan(_ context.Context, _ PlanningHint) (RequestPlan, error) { - return RequestPlan{Model: testModel, Request: sdk.Request{Model: string(testModel)}}, nil -} diff --git a/agent/run/loop/tool.go b/agent/run/loop/tool.go index 03de6f9..e03bdae 100644 --- a/agent/run/loop/tool.go +++ b/agent/run/loop/tool.go @@ -12,7 +12,7 @@ import ( type startedWorker struct { call run.ToolCallState grant run.ExecutionGrant - base uint64 + base run.RunPosition tool ExecutableTool attempt attempt } @@ -54,7 +54,7 @@ func (l *Loop) resolveExecutableTool(proto run.Protocol, call run.ToolCallState) return tool, nil } -func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events EventSink, snapshot *run.RuntimeSnapshot, eff run.StartToolCalls) error { +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 { @@ -90,7 +90,7 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve switch call.Status { case run.ToolPending: case run.ToolExecuting: - if ok, err := l.hasClaim(ctx, runID, eff.StepID, callID); err != nil { + if ok, err := l.hasClaim(ctx, runtime.sid, runID, eff.StepID, callID); err != nil { return err } else if !ok { continue @@ -105,7 +105,7 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve // 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.Revision, "", + 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) @@ -114,15 +114,15 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve } return settleErr } - l.emitCommitted(ctx, events, runID, res.Events) + l.emitCommitted(ctx, events, runtime.sid, runID, &res.Commit) continue } - a, err := l.claimFor(ctx, runID, eff.StepID, callID) + a, err := l.claimFor(ctx, runtime.sid, runID, eff.StepID, callID) if err != nil { return err } - start, err := l.commit(ctx, runtime, runID, a.startID(), snapshot.Revision, "", + 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) @@ -149,9 +149,9 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve l.forgetClaim(ctx, a) continue } - l.emitCommitted(ctx, events, runID, start.Events) + l.emitCommitted(ctx, events, runtime.sid, runID, &start.Commit) if events != nil { - _ = events.Emit(ctx, Event{RunID: runID, StepID: eff.StepID, CallID: callID, + _ = events.Emit(ctx, Event{Session: runtime.sid, RunID: runID, StepID: eff.StepID, CallID: callID, Kind: EventToolStarted, Durability: EventCommitted}) } if resuming && known != nil { @@ -162,7 +162,7 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve } failure.Class = run.FailureEffectUnknown } - if err := l.settle(ctx, runtime, events, a, start.Snapshot.Revision, start.Grant, + if err := l.settle(ctx, runtime, events, a, start.Snapshot.Position, start.Grant, run.SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: failure, Outcome: run.ToolOutcomeUnknown}, proto); err != nil { settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, proto) if settleErr != nil { @@ -172,7 +172,7 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime run.Runtime, events Eve } continue } - started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Revision, tool: tool, attempt: a}) + started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Position, tool: tool, attempt: a}) } return l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, proto) @@ -197,7 +197,7 @@ func toolCallFromSnapshot(state run.MachineState, stepID run.StepID, callID run. // 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 run.Runtime, events EventSink, runID run.RunID, stepID run.StepID, started []startedWorker, proto run.Protocol) error { +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 } @@ -258,7 +258,7 @@ func (l *Loop) settleWorkers(ctx context.Context, runtime run.Runtime, events Ev return } if events != nil { - _ = events.Emit(ctx, Event{RunID: runID, StepID: stepID, CallID: w.call.CallID, + _ = events.Emit(ctx, Event{Session: runtime.sid, RunID: runID, StepID: stepID, CallID: w.call.CallID, Kind: EventToolCompleted, Durability: EventCommitted}) } }(w) diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go index 9502d1b..0aaf560 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -75,7 +75,7 @@ func buildPrepare(t *testing.T, s MachineState, req sdk.Request, specs []ToolSpe if err != nil { t.Fatal(err) } - cmdID := DeriveModelRequestCommandID(s.RunID, 0) + cmdID := DeriveModelRequestCommandID(s.RunID, RunPosition{}) stepID := DeriveModelStepID(s.RunID, cmdID, binding) ids := make([]InputID, len(s.PendingInputs)) for i, in := range s.PendingInputs { diff --git a/agent/run/memory_conformance_test.go b/agent/run/memory_conformance_test.go deleted file mode 100644 index 4ac671a..0000000 --- a/agent/run/memory_conformance_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package run_test - -import ( - "testing" - "time" - - "github.com/memohai/twilight/agent/run" - "github.com/memohai/twilight/agent/run/runtimetest" -) - -// MemoryStore-backed Runtime is the in-process reference; it runs the same -// shared suite durable adapters import (RUN-CMP-2). -// Dependency chain: run_test -> runtimetest -> run (the net/http/httptest -// layout), so the loop is broken by the external test package. -func TestMemoryStoreRuntimeConformance(t *testing.T) { - runtimetest.RunConformance(t, func() run.Runtime { return run.NewRuntime(run.NewMemoryStore()) }) -} - -func TestMemoryStoreRuntimeRecovery(t *testing.T) { - runtimetest.RunRecoveryConformance(t, func(now func() time.Time, ttl time.Duration) run.Runtime { - return run.NewRuntimeWithOptions(run.NewMemoryStore(), run.RuntimeOptions{LeaseTTL: ttl, Now: now}) - }) -} - -func TestMemoryStoreRuntimeLeaseRenewal(t *testing.T) { - runtimetest.RunLeaseRenewalConformance(t, func(now func() time.Time, ttl time.Duration) run.Runtime { - return run.NewRuntimeWithOptions(run.NewMemoryStore(), run.RuntimeOptions{LeaseTTL: ttl, Now: now}) - }) -} diff --git a/agent/run/memory_store.go b/agent/run/memory_store.go deleted file mode 100644 index 09c29f0..0000000 --- a/agent/run/memory_store.go +++ /dev/null @@ -1,235 +0,0 @@ -package run - -import ( - "context" - "errors" - "sync" - "time" -) - -// MemoryStore is the in-process Store. The collection lock protects the Run -// map; each Run has an independent lock. Lease deadlines are stored; a zero -// deadline never expires. -type MemoryStore struct { - mu sync.RWMutex - runs map[RunID]*memoryRun -} - -type memoryRun struct { - mu sync.Mutex - header RunHeader - state MachineState - revision uint64 - log []TransitionRecord - transitions map[CommandID]int // index into log - leases map[string]ExecutionLease -} - -func NewMemoryStore() *MemoryStore { - return &MemoryStore{runs: make(map[RunID]*memoryRun)} -} - -func lockMemory(ctx context.Context, mu *sync.Mutex) error { - for { - if mu.TryLock() { - return nil - } - timer := time.NewTimer(time.Millisecond) - select { - case <-ctx.Done(): - if !timer.Stop() { - <-timer.C - } - return ctx.Err() - case <-timer.C: - } - } -} - -func (s *MemoryStore) entry(runID RunID) (*memoryRun, error) { - if s == nil { - return nil, errors.New("agent: memory store: nil store") - } - s.mu.RLock() - entry := s.runs[runID] - s.mu.RUnlock() - if entry == nil { - return nil, ErrRunNotFound - } - return entry, nil -} - -// locked runs fn with the Run's lock held. -func (s *MemoryStore) locked(ctx context.Context, id RunID, fn func(*memoryRun) error) error { - if err := checkContext(ctx); err != nil { - return err - } - entry, err := s.entry(id) - if err != nil { - return err - } - if err := lockMemory(ctx, &entry.mu); err != nil { - return err - } - defer entry.mu.Unlock() - if err := checkContext(ctx); err != nil { - return err - } - return fn(entry) -} - -func (e *memoryRun) head() RunHead { - return cloneRunHead(&RunHead{Header: e.header, State: e.state, Revision: e.revision, Leases: e.leases}) -} - -//nolint:gocritic // hugeParam: Store.Create takes RunHeader by value as the persisted creation record. -func (s *MemoryStore) Create(ctx context.Context, header RunHeader) (bool, RunHeader, error) { - if err := checkContext(ctx); err != nil { - return false, RunHeader{}, err - } - if s == nil { - return false, RunHeader{}, errors.New("agent: memory store: nil store") - } - s.mu.Lock() - defer s.mu.Unlock() - if err := checkContext(ctx); err != nil { - return false, RunHeader{}, err - } - if existing := s.runs[header.RunID]; existing != nil { - return false, cloneRunHeader(existing.header), nil - } - stored := cloneRunHeader(header) - s.runs[header.RunID] = &memoryRun{ - header: stored, - state: cloneMachineState(&stored.InitialState), - transitions: make(map[CommandID]int), - leases: make(map[string]ExecutionLease), - } - return true, cloneRunHeader(stored), nil -} - -func (s *MemoryStore) LoadHead(ctx context.Context, id RunID) (RunHead, error) { - var head RunHead - err := s.locked(ctx, id, func(e *memoryRun) error { - head = e.head() - return nil - }) - return head, err -} - -func (s *MemoryStore) LoadLog(ctx context.Context, id RunID, from uint64) ([]TransitionRecord, error) { - var out []TransitionRecord - err := s.locked(ctx, id, func(e *memoryRun) error { - for i := range e.log { - if e.log[i].Revision >= from { - out = append(out, cloneTransitionRecord(&e.log[i])) - } - } - return nil - }) - return out, err -} - -func (s *MemoryStore) LoadRecord(ctx context.Context, id RunID) (RunHead, []TransitionRecord, error) { - var head RunHead - var log []TransitionRecord - err := s.locked(ctx, id, func(e *memoryRun) error { - head = e.head() - log = cloneTransitionRecords(e.log) - return nil - }) - return head, log, err -} - -type memoryTx struct{ e *memoryRun } - -func (t memoryTx) Head() RunHead { return t.e.head() } - -func (t memoryTx) LookupTransition(command CommandID) (TransitionRecord, bool, error) { - i, ok := t.e.transitions[command] - if !ok { - return TransitionRecord{}, false, nil - } - return cloneTransitionRecord(&t.e.log[i]), true, nil -} - -func (s *MemoryStore) Commit(ctx context.Context, id RunID, fn func(RunTx) (*Append, error)) error { - if fn == nil { - return errors.New("agent: memory store: nil commit fn") - } - return s.locked(ctx, id, func(e *memoryRun) error { - a, err := fn(memoryTx{e}) - if err != nil || a == nil { - return err - } - head := RunHead{Revision: e.revision} - if err := ValidateAppend(id, &head, a); err != nil { - return err - } - if _, dup := e.transitions[a.Transition.CommandID]; dup { - return ErrCommandConflict - } - record := cloneTransitionRecord(&a.Transition) - e.log = append(e.log, record) - e.transitions[record.CommandID] = len(e.log) - 1 - e.state = cloneMachineState(&a.State) - e.revision = a.Transition.Revision - e.leases = applyLeaseOps(e.leases, a.Leases) - return nil - }) -} - -func (s *MemoryStore) RenewLease(ctx context.Context, id RunID, key string, grant ExecutionGrant, deadline time.Time) error { - return s.locked(ctx, id, func(e *memoryRun) error { - lease, ok := e.leases[key] - if !ok || grant == "" || lease.Grant != grant { - return ErrStaleRuntime - } - lease.Deadline = deadline - e.leases[key] = lease - return nil - }) -} - -func (s *MemoryStore) ExpiredLeases(ctx context.Context, before time.Time) ([]ExpiredLease, error) { - if err := checkContext(ctx); err != nil { - return nil, err - } - if s == nil { - return nil, errors.New("agent: memory store: nil store") - } - s.mu.RLock() - entries := make([]*memoryRun, 0, len(s.runs)) - ids := make([]RunID, 0, len(s.runs)) - for id, e := range s.runs { - entries = append(entries, e) - ids = append(ids, id) - } - s.mu.RUnlock() - var out []ExpiredLease - for i, e := range entries { - if err := lockMemory(ctx, &e.mu); err != nil { - return nil, err - } - for key, lease := range e.leases { - if !lease.Deadline.IsZero() && !lease.Deadline.After(before) { - out = append(out, ExpiredLease{RunID: ids[i], Key: key, Lease: lease}) - } - } - e.mu.Unlock() - } - return out, nil -} - -func (s *MemoryStore) ReplaceSnapshot(ctx context.Context, id RunID, revision uint64, state *MachineState) error { - return s.locked(ctx, id, func(e *memoryRun) error { - if e.revision != revision { - return ErrAppendConflict - } - if state.RunID != id { - return errors.New("agent: memory store: snapshot RunID mismatch") - } - e.state = cloneMachineState(state) - return nil - }) -} diff --git a/agent/run/next.go b/agent/run/next.go index 9c08246..9e824d3 100644 --- a/agent/run/next.go +++ b/agent/run/next.go @@ -1,5 +1,7 @@ package run +import "github.com/memohai/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. @@ -103,6 +105,7 @@ func NeedsRecovery(s MachineState) bool { // 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 diff --git a/agent/run/protocol.go b/agent/run/protocol.go index b5e728a..10f61a8 100644 --- a/agent/run/protocol.go +++ b/agent/run/protocol.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/session" ) // SchemaVersion1 is the current pre-release wire schema. Its canonical @@ -11,29 +12,18 @@ import ( // Once a schema is published, its encoding and folding semantics are frozen. const SchemaVersion1 uint16 = 1 -// CommandEnvelope carries one command with its persisted protocol identity. +// CommandEnvelope carries one command with its protocol identity. Commands +// are not persisted: ID is the CommitID of the SessionCommit the command +// produces, and Digest lets the Runtime tell an exact replay from a conflict +// (RUN-WIR-2). type CommandEnvelope struct { - SchemaVersion uint16 `json:"schemaVersion"` - Type string `json:"type"` - RunID RunID `json:"runId"` - ID CommandID `json:"id"` - Digest Digest `json:"digest"` - Command AgentCommand `json:"command"` -} - -// AgentEvent carries one fact produced by an accepted command. All events of -// one transition share Revision, CommandID and CommandDigest; Index orders -// them within the transition. Identity is assigned by the authority. -type AgentEvent struct { - SchemaVersion uint16 `json:"schemaVersion"` - Type string `json:"type"` - RunID RunID `json:"runId"` - Revision uint64 `json:"revision"` - Index uint16 `json:"index"` - CommandID CommandID `json:"commandId"` - CommandDigest Digest `json:"commandDigest"` - Digest Digest `json:"digest"` // canonical digest of the fact - Fact Fact `json:"fact"` + SchemaVersion uint16 `json:"schemaVersion"` + Type string `json:"type"` + SessionID session.SessionID `json:"sessionId"` + RunID RunID `json:"runId"` + ID CommandID `json:"id"` + Digest Digest `json:"digest"` + Command AgentCommand `json:"command"` } // encodeEnvelopeBody is the digest input for a command: schema version, type @@ -65,7 +55,6 @@ type Protocol struct { evolve func(MachineState, Fact) (MachineState, error) encodeMachineState func(*MachineState) ([]byte, error) decodeMachineState func([]byte) (MachineState, error) - validateHeader func(*RunHeader) error } // ProtocolV1 is the SchemaVersion1 binding. New Runs are created with this @@ -92,7 +81,6 @@ var protocolV1 = Protocol{ evolve: evolveV1, encodeMachineState: encodeMachineStateV1, decodeMachineState: decodeMachineStateV1, - validateHeader: validateHeaderV1, } // ProtocolFor binds the protocol functions for a persisted schema version. @@ -254,7 +242,8 @@ func (p Protocol) Evolve(s MachineState, f Fact) (MachineState, error) { //nolin return p.evolve(s, f) } -func (p Protocol) BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) { +// 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) @@ -266,6 +255,7 @@ func (p Protocol) BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (Comm return CommandEnvelope{ SchemaVersion: p.version, Type: typ, + SessionID: sid, RunID: run, ID: id, Digest: d, @@ -273,6 +263,10 @@ func (p Protocol) BuildEnvelope(run RunID, id CommandID, cmd AgentCommand) (Comm }, 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. @@ -292,13 +286,6 @@ func (p Protocol) DecodeMachineState(raw []byte) (MachineState, error) { return p.decodeMachineState(raw) } -func (p Protocol) ValidateHeader(h *RunHeader) error { - if err := p.ready(); err != nil { - return err - } - return p.validateHeader(h) -} - type toolResponseDecisionDigestBody struct { Kind ResponseKind `json:"kind"` Decision ResponseDecision `json:"decision"` diff --git a/agent/run/protocol_v1.go b/agent/run/protocol_v1.go index cbfde97..baa1303 100644 --- a/agent/run/protocol_v1.go +++ b/agent/run/protocol_v1.go @@ -109,15 +109,6 @@ func buildCreateGroupV1(run NewRun, inputs []AgentInput) ([]Fact, error) { return facts, nil } -func initialStateVersionV1() uint16 { return 1 } - -func validateHeaderV1(h *RunHeader) error { - if h.InitialStateVersion != initialStateVersionV1() { - return fmt.Errorf("agent: run header: unsupported initial state version %d for schema %d", h.InitialStateVersion, SchemaVersion1) - } - return nil -} - func unsupportedSchemaVersion(schemaVersion uint16) error { return fmt.Errorf("agent: unsupported schema version %d", schemaVersion) } diff --git a/agent/run/recover_test.go b/agent/run/recover_test.go deleted file mode 100644 index 579f409..0000000 --- a/agent/run/recover_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package run - -import ( - "context" - "errors" - "sync" - "testing" - "time" -) - -func TestRunExpiredRecoveryRejectsInvalidArgs(t *testing.T) { - rt := NewRuntime(NewMemoryStore()) - if err := RunExpiredRecovery(context.Background(), nil, time.Second); err == nil { - t.Fatal("nil runtime accepted") - } - if err := RunExpiredRecovery(context.Background(), rt, 0); err == nil { - t.Fatal("zero interval accepted") - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - if err := RunExpiredRecovery(ctx, rt, time.Second); !errors.Is(err, context.Canceled) { - t.Fatalf("canceled ctx err = %v, want context.Canceled", err) - } -} - -func TestRunExpiredRecoveryRecoversExpiredModel(t *testing.T) { - var mu sync.Mutex - clock := time.Unix(1000, 0) - now := func() time.Time { - mu.Lock() - defer mu.Unlock() - return clock - } - set := func(ts time.Time) { - mu.Lock() - defer mu.Unlock() - clock = ts - } - rt := NewRuntimeWithOptions(NewMemoryStore(), RuntimeOptions{LeaseTTL: time.Second, Now: now}) - ctx := context.Background() - newRun, err := BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(ctx, newRun); err != nil { - t.Fatal(err) - } - in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} - env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(ctx, CommitRequest{Command: env}); err != nil { - t.Fatal(err) - } - snap, err := rt.Load(ctx, "run-1") - if err != nil { - t.Fatal(err) - } - prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) - if _, err := commitCmd(t, rt, cmdID, snap.Revision, "", prep); err != nil { - t.Fatal(err) - } - startEnv := startEnvelope(t, "run-1", StartModelExecution{StepID: prep.StepID, Claim: "claim-scan"}) - if _, err := rt.Commit(ctx, CommitRequest{Command: startEnv}); err != nil { - t.Fatal(err) - } - - loopCtx, cancel := context.WithCancel(ctx) - errCh := make(chan error, 1) - go func() { errCh <- RunExpiredRecovery(loopCtx, rt, 10*time.Millisecond) }() - set(time.Unix(1002, 0)) - - deadline := time.Now().Add(2 * time.Second) - for { - snap, err := rt.Load(ctx, "run-1") - if err != nil { - t.Fatal(err) - } - if ms, ok := snap.State.Current.(ModelStep); ok && ms.Status == ModelPrepared { - break - } - if time.Now().After(deadline) { - t.Fatalf("model still %+v", snap.State.Current) - } - select { - case err := <-errCh: - t.Fatalf("recovery loop exited: %v", err) - case <-time.After(5 * time.Millisecond): - } - } - cancel() - if err := <-errCh; !errors.Is(err, context.Canceled) { - t.Fatalf("loop err = %v, want context.Canceled", err) - } -} diff --git a/agent/run/runtest/feature.go b/agent/run/runtest/feature.go index dc9c118..70ff627 100644 --- a/agent/run/runtest/feature.go +++ b/agent/run/runtest/feature.go @@ -14,14 +14,73 @@ import ( "github.com/memohai/twilight/agent/run" "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/extension" + runmod "github.com/memohai/twilight/agent/session/run" "github.com/memohai/twilight/sdk" ) const ( - defaultRunID = "run-1" - defaultModel = "m-1" + 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.ProfileV1(), runmod.Module) + if err != nil { + t.Fatal(err) + } + appender, err := extension.NewSemanticAppender(store, registry, nil, nil) + if err != nil { + t.Fatal(err) + } + rt, err := runmod.NewRuntime(runmod.Config{Store: store, Registry: registry, Appender: appender, + Projections: extension.NewProjectionReader(store, registry), Companion: nopCompanion{}}) + 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) + } + 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}}) + } + head, err := store.Head(ctx, defaultSession) + if err != nil { + t.Fatal(err) + } + res, err := appender.AppendSemantic(ctx, extension.SemanticAppendRequest{SessionID: defaultSession, ExpectedHead: head, Group: group}) + if err != nil { + t.Fatal(err) + } + if res.Outcome != extension.SemanticApplied { + 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 @@ -50,14 +109,7 @@ type Feature struct { // model results before Run or Executing*. func New(t testing.TB) *Feature { t.Helper() - rt := run.NewRuntime(run.NewMemoryStore()) - newRun, err := run.BuildNewRun(defaultRunID, "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(context.Background(), newRun); err != nil { - t.Fatal(err) - } + rt := newRuntime(t, run.AgentInput{ID: "seed", Payload: run.MustParseCanonicalJSON(`{"q":"hi"}`)}) f := &Feature{ t: t, ctx: context.Background(), @@ -68,10 +120,6 @@ func New(t testing.TB) *Feature { defs: make(map[run.ToolRef]sdk.ToolDefinition), tools: make(map[run.ToolRef]*scriptTool), } - f.commit(run.AcceptInput{Input: run.AgentInput{ - ID: "seed", - Payload: run.MustParseCanonicalJSON(`{"q":"hi"}`), - }}, "") return f } @@ -156,7 +204,7 @@ func (f *Feature) RunError(want error) *Feature { func (f *Feature) drive() error { f.t.Helper() f.ensureLoop() - res, err := f.loop.Run(f.runCtx, f.rt, f.runID, nil) + res, err := f.loop.Run(f.runCtx, f.rt, defaultSession, f.runID, nil) f.last = res return err } @@ -180,11 +228,11 @@ func (f *Feature) TryCommit(cmd run.AgentCommand) error { f.seq++ cmd = withClaim(run.CommandID(fmt.Sprintf("attempt-%d", f.seq)), cmd) id := f.commandID(cmd, snap) - env, err := proto.BuildEnvelope(f.runID, id, cmd) + env, err := proto.BuildEnvelope(defaultSession, f.runID, id, cmd) if err != nil { return err } - _, err = f.rt.Commit(f.ctx, run.CommitRequest{BaseRevision: snap.Revision, Command: env}) + _, err = f.rt.Commit(f.ctx, defaultSession, run.CommitRequest{Base: snap.Position, Command: env}) return err } @@ -329,7 +377,7 @@ func (f *Feature) ensureLoop() { func (f *Feature) load() run.RuntimeSnapshot { f.t.Helper() - snap, err := f.rt.Load(f.ctx, f.runID) + snap, err := f.rt.Load(f.ctx, defaultSession, f.runID) if err != nil { f.t.Fatal(err) } @@ -360,12 +408,12 @@ func (f *Feature) commit(cmd run.AgentCommand, grant run.ExecutionGrant) run.Com f.seq++ cmd = withClaim(run.CommandID(fmt.Sprintf("attempt-%d", f.seq)), cmd) id := f.commandID(cmd, snap) - env, err := proto.BuildEnvelope(f.runID, id, cmd) + env, err := proto.BuildEnvelope(defaultSession, f.runID, id, cmd) if err != nil { f.t.Fatal(err) } - res, err := f.rt.Commit(f.ctx, run.CommitRequest{ - BaseRevision: snap.Revision, Grant: grant, Command: env, + res, err := f.rt.Commit(f.ctx, defaultSession, run.CommitRequest{ + Base: snap.Position, Grant: grant, Command: env, }) if err != nil { f.t.Fatalf("commit %T: %v", cmd, err) @@ -384,7 +432,7 @@ func (f *Feature) commandID(cmd run.AgentCommand, snap run.RuntimeSnapshot) run. case run.SubmitToolResponse: return run.DeriveResponseCommandID(f.runID, c.StepID, c.CallID, c.ResponseID) case run.PrepareModelRequest: - return run.DeriveModelRequestCommandID(f.runID, snap.Revision) + return run.DeriveModelRequestCommandID(f.runID, snap.Position) case run.RecoverModelExecution: return run.DeriveModelRecoveryCommandID(f.runID, c.StepID, c.Claim) case run.StartModelExecution: @@ -424,7 +472,7 @@ func (f *Feature) commitPrepare() { if err != nil { f.t.Fatal(err) } - cmdID := run.DeriveModelRequestCommandID(f.runID, snap.Revision) + 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 { @@ -454,17 +502,11 @@ func (f *Feature) mustSpec(name string, policy run.ResponsePolicy) (run.ToolSpec func (f *Feature) facts() []run.Fact { f.t.Helper() - record, err := f.rt.Record(f.ctx, f.runID) + record, err := f.rt.Record(f.ctx, defaultSession, f.runID) if err != nil { f.t.Fatal(err) } - var out []run.Fact - for _, tr := range record.Transitions { - for _, e := range tr.Events { - out = append(out, e.Fact) - } - } - return out + return record.Facts } func withClaim(id run.CommandID, cmd run.AgentCommand) run.AgentCommand { diff --git a/agent/run/runtest/require.go b/agent/run/runtest/require.go index 8362048..c17554b 100644 --- a/agent/run/runtest/require.go +++ b/agent/run/runtest/require.go @@ -292,11 +292,11 @@ func (f *Feature) RequireAbsorbsCommands() { if err != nil { f.t.Fatal(err) } - env, err := proto.BuildEnvelope(f.runID, "after-terminal", run.CancelRun{}) + env, err := proto.BuildEnvelope(defaultSession, f.runID, "after-terminal", run.CancelRun{}) if err != nil { f.t.Fatal(err) } - _, err = f.rt.Commit(f.ctx, run.CommitRequest{BaseRevision: snap.Revision, Command: env}) + _, 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/runtime.go b/agent/run/runtime.go index f60f819..6f50b56 100644 --- a/agent/run/runtime.go +++ b/agent/run/runtime.go @@ -1,51 +1,94 @@ package run -import "context" +import ( + "context" -// Runtime is the RunID-addressed authority and atomic commit boundary. Create -// establishes immutable Revision-0 state; Load, Commit, and Record operate on -// exactly one Run. MachineState is the semantic state view; planning, queues, -// and tool entry points stay outside this interface. + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/session" +) + +// RunPosition is the stream position of a Run's last twilight/run/ event. +// Only the Run's own events move it; other modules' commits 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 struct { + Revision es.Revision `json:"revision"` + Index uint16 `json:"index"` +} + +// Runtime is the Run command entry (RUN-CMT-1): addressed by (SessionID, +// RunID), it evaluates commands inside the Session critical section and +// appends facts, companion content and attached events as one SessionCommit. +// Runs are created by the Coordinator's Start group; there is no Create. type Runtime interface { - Create(context.Context, NewRun) (CreateResult, error) - Load(context.Context, RunID) (RuntimeSnapshot, error) - Commit(context.Context, CommitRequest) (CommitResult, error) - Record(context.Context, RunID) (RunRecord, error) + 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). The body lives in the - // FrozenValueStore, not in the fact; a missing body is ErrFrozenValueMissing. + // names by RequestDigest (RUN-WIR-4); a missing body is ErrFrozenValueMissing. FrozenRequest(context.Context, Digest) (ModelRequest, error) // RenewLease extends the lease behind grant on the Executing target - // (stepID alone for a ModelStep, stepID+callID for a tool call). Workers - // call it while an effect runs longer than the lease TTL (RUN-CMT-8). - RenewLease(ctx context.Context, runID RunID, stepID StepID, callID CallID, grant ExecutionGrant) error + // (stepID alone for a ModelStep, stepID+callID for a tool call). + RenewLease(ctx context.Context, sessionID session.SessionID, runID RunID, stepID StepID, callID CallID, grant ExecutionGrant) error // RecoverExpired grantless-commits recovery for expired execution leases. // Hosts call it on a timer; Loop does not. RecoverExpired(context.Context) (int, error) } type RuntimeSnapshot struct { - // State is a detached in-process view, not a portable persistence format. - // Durable implementations rebuild through RunHeader + TransitionRecords; - // any optimized stored snapshot uses an implementation-private codec. + // State is a detached in-process view. State MachineState - // Revision counts accepted transitions; the initial state is 0. - Revision uint64 - // SchemaVersion is the Run header protocol version. Loop and Application - // select ProtocolFor(SchemaVersion) once; they must not stamp a process-global - // currentSchemaVersion (RUN-CMT-7). + // Position is the Run's last event position 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 implementation frozen on this Run's header. +// 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 commit. 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 commit (TRN-CMP). Map must be +// a deterministic pure function. +type Companion interface { + Version() string + Map(CompanionRequest) ([]ModuleEvent, error) +} + type CommitRequest struct { - BaseRevision uint64 - Grant ExecutionGrant - Command CommandEnvelope + // Base is the Position the caller loaded. PrepareModelRequest treats it + // as a hard CAS; other commands rebase call-locally (RUN-CMT-4). + Base RunPosition + Grant ExecutionGrant + Command CommandEnvelope + // Attach are caller events appended after the companion events; they must + // not be twilight/run/ events. + Attach []ModuleEvent } type CommitStatus uint8 @@ -58,11 +101,18 @@ const ( type CommitResult struct { Status CommitStatus Snapshot RuntimeSnapshot - // Events is the complete event group of the transition, for Accepted and - // AlreadyApplied alike. - Events []AgentEvent - // Grant is returned for an Accepted start command and for an exact replay - // while that start is still live; after settlement or terminalization the - // replay remains idempotent but Grant is empty. + // Commit is the complete SessionCommit: run facts, companion, attach. + Commit session.SessionCommit + // Grant is returned for an Accepted start and for an exact replay while + // that start is still live; otherwise empty. Grant ExecutionGrant } + +// RunRecord is one verified read of a Run: every twilight/run/ event of the +// RunID in stream order, folded and compared with the projection. +type RunRecord struct { + Created session.EventPosition + Snapshot RuntimeSnapshot + Events []session.SessionEvent + Facts []Fact +} diff --git a/agent/run/runtime_helpers_test.go b/agent/run/runtime_helpers_test.go deleted file mode 100644 index 898e4ea..0000000 --- a/agent/run/runtime_helpers_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package run - -import ( - "context" - "testing" - - "github.com/memohai/twilight/sdk" -) - -// Shared helpers for package-local agent tests. Runtime conformance lives in -// agent/runtimetest so durable Runtime implementations can reuse it. - -func newTestRuntime(t *testing.T) Runtime { - t.Helper() - rt := NewRuntime(NewMemoryStore()) - newRun, err := BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(context.Background(), newRun); err != nil { - t.Fatal(err) - } - // Seed inputs must enter through the public Runtime boundary so the - // fixture has the same Revision-0 header and transition history as a Run. - acceptInput(t, rt, "run-1", AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)}) - return rt -} - -func runtimeStore(t testing.TB, rt Runtime) Store { - t.Helper() - r, ok := rt.(*runtime) - if !ok { - t.Fatalf("runtime %T is not *runtime", rt) - } - return r.store -} - -func rebuildRun(t testing.TB, rt Runtime, runID RunID) (bool, error) { - t.Helper() - return Rebuild(context.Background(), runtimeStore(t, rt), runID) -} - -func recordEvents(t testing.TB, rt Runtime, runID RunID) []AgentEvent { - t.Helper() - record, err := rt.Record(context.Background(), runID) - if err != nil { - t.Fatal(err) - } - return flattenTransitionRecords(record.Transitions) -} - -func memoryEntry(t testing.TB, rt Runtime) *memoryRun { - t.Helper() - store, ok := runtimeStore(t, rt).(*MemoryStore) - if !ok { - t.Fatal("runtime is not backed by MemoryStore") - } - entry, err := store.entry("run-1") - if err != nil { - t.Fatal(err) - } - return entry -} - -func commitCmd(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) (CommitResult, error) { - t.Helper() - cmd, id = withTestExecutionClaim("run-1", id, cmd) - env, err := ProtocolV1().BuildEnvelope("run-1", id, cmd) - if err != nil { - t.Fatal(err) - } - return rt.Commit(context.Background(), CommitRequest{BaseRevision: base, Grant: grant, Command: env}) -} - -// withTestExecutionClaim treats id as the attempt label of a start command -// and derives claim and CommandID from it; see runtimetest. -func withTestExecutionClaim(runID RunID, id CommandID, cmd AgentCommand) (AgentCommand, CommandID) { - claim := ExecutionClaim("test-claim/" + string(id)) - switch c := cmd.(type) { - case StartModelExecution: - if c.Claim == "" { - c.Claim = claim - } - return c, DeriveStartCommandID(runID, c.StepID, "", c.Claim) - case StartToolCall: - if c.Claim == "" { - c.Claim = claim - } - return c, DeriveStartCommandID(runID, c.StepID, c.CallID, c.Claim) - default: - return cmd, id - } -} - -// startEnvelope builds a start command's envelope under its derived id. -func startEnvelope(t testing.TB, runID RunID, cmd AgentCommand) CommandEnvelope { - t.Helper() - cmd, id := withTestExecutionClaim(runID, "start", cmd) - env, err := ProtocolV1().BuildEnvelope(runID, id, cmd) - if err != nil { - t.Fatal(err) - } - return env -} - -func mustCommit(t *testing.T, rt Runtime, id CommandID, base uint64, grant ExecutionGrant, cmd AgentCommand) CommitResult { - t.Helper() - res, err := commitCmd(t, rt, id, base, grant, cmd) - if err != nil { - t.Fatalf("commit %T: %v", cmd, err) - } - return res -} - -// preparedRuntime returns a runtime advanced to an Executing ModelStep, plus -// stepID and the model grant. -func preparedRuntime(t *testing.T, tools []sdk.ToolDefinition, specs []ToolSpec) (Runtime, StepID, ExecutionGrant) { - t.Helper() - rt := newTestRuntime(t) - snap, _ := rt.Load(context.Background(), "run-1") - req := testRequest(tools...) - prep, cmdID := buildPrepareFromSnap(t, snap, req, specs) - prepared := mustCommit(t, rt, cmdID, snap.Revision, "", prep) - start := mustCommit(t, rt, "start-1", prepared.Snapshot.Revision, "", StartModelExecution{StepID: prep.StepID}) - if start.Grant == "" { - t.Fatal("accepted start returned no grant") - } - return rt, prep.StepID, start.Grant -} - -func buildPrepareFromSnap(t *testing.T, snap RuntimeSnapshot, 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(snap.State.RunID, snap.Revision) - stepID := DeriveModelStepID(snap.State.RunID, cmdID, binding) - ids := make([]InputID, len(snap.State.PendingInputs)) - for i, in := range snap.State.PendingInputs { - ids[i] = in.ID - } - return PrepareModelRequest{ - StepID: stepID, Model: model, Request: frozenReq, - RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, - }, cmdID -} diff --git a/agent/run/runtimetest/conformance.go b/agent/run/runtimetest/conformance.go deleted file mode 100644 index 78d8a5c..0000000 --- a/agent/run/runtimetest/conformance.go +++ /dev/null @@ -1,917 +0,0 @@ -// Package runtimetest contains the shared run.Runtime conformance suite. -// Durable Runtime implementations run this suite in their own tests instead -// of copying MemoryStore-backed assertions: -// -// func TestMyRuntimeConformance(t *testing.T) { -// runtimetest.RunConformance(t, func() run.Runtime { return newMyRuntime() }) -// } -// -// The suite exercises only the public run API, so it holds for any Runtime -// that honors the contract (RUN-CMP-2). Eight groups cover Create, Record, -// Isolation, CommandIdentity, Prepare, Grant, CallLocalRebase, and Cancel. -package runtimetest - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "sync" - "testing" - - "github.com/memohai/twilight/agent/run" - "github.com/memohai/twilight/sdk" -) - -// Factory constructs an empty Runtime under test. -type Factory func() run.Runtime - -// RunConformance executes the shared Runtime conformance suite (RUN-CMP-2). -// Group names are a directory; each check is a separate function with its own Runtime. -func RunConformance(t *testing.T, newRuntime Factory) { - t.Helper() - t.Run("Create", func(t *testing.T) { - t.Run("RetryAndConflict", func(t *testing.T) { testCreateRetryAndConflict(t, newRuntime) }) - t.Run("Missing", func(t *testing.T) { testCreateMissing(t, newRuntime) }) - t.Run("Concurrent", func(t *testing.T) { testCreateConcurrent(t, newRuntime) }) - }) - t.Run("Record", func(t *testing.T) { - t.Run("FoldAccepted", func(t *testing.T) { testRecordFoldAccepted(t, newRuntime) }) - t.Run("ReplayAndIndex", func(t *testing.T) { testRecordReplayAndIndex(t, newRuntime) }) - t.Run("ConcurrentWithCommit", func(t *testing.T) { testRecordConcurrentWithCommit(t, newRuntime) }) - }) - t.Run("Isolation", func(t *testing.T) { - t.Run("DetachedReturns", func(t *testing.T) { testIsolationDetachedReturns(t, newRuntime) }) - t.Run("Runs", func(t *testing.T) { testIsolationRuns(t, newRuntime) }) - t.Run("CrossRunGrant", func(t *testing.T) { testIsolationCrossRunGrant(t, newRuntime) }) - }) - t.Run("CommandIdentity", func(t *testing.T) { - t.Run("IdempotentReplay", func(t *testing.T) { testCommandIdempotentReplay(t, newRuntime) }) - t.Run("DerivedIDs", func(t *testing.T) { testCommandDerivedIDs(t, newRuntime) }) - }) - t.Run("Prepare", func(t *testing.T) { - t.Run("HardCAS", func(t *testing.T) { testPrepareHardCAS(t, newRuntime) }) - t.Run("ReplayAfterProgress", func(t *testing.T) { testPrepareReplayAfterProgress(t, newRuntime) }) - }) - t.Run("Grant", func(t *testing.T) { - t.Run("Lifecycle", func(t *testing.T) { testGrant(t, newRuntime) }) - t.Run("HolderRecover", func(t *testing.T) { testGrantHolderRecover(t, newRuntime) }) - t.Run("GrantlessRejectedWhileLive", func(t *testing.T) { testGrantGrantlessRejectedWhileLive(t, newRuntime) }) - }) - t.Run("CallLocalRebase", func(t *testing.T) { testCallLocalRebase(t, newRuntime) }) - t.Run("Cancel", func(t *testing.T) { - t.Run("StaleBase", func(t *testing.T) { testCancelStaleBase(t, newRuntime) }) - t.Run("AfterUnknown", func(t *testing.T) { testCancelAfterUnknown(t, newRuntime) }) - t.Run("UnknownRemaining", func(t *testing.T) { testCancelUnknownRemaining(t, newRuntime) }) - }) -} - -type conformanceCase struct { - t testing.TB - runID run.RunID - initial run.MachineState - rt run.Runtime - events []run.AgentEvent -} - -func newCase(t testing.TB, newRuntime Factory) *conformanceCase { - t.Helper() - return newCaseOnRuntime(t, newRuntime(), "run-1") -} - -func newCaseOnRuntime(t testing.TB, rt run.Runtime, runID run.RunID) *conformanceCase { - t.Helper() - newRun, err := run.BuildNewRun(runID, "") - if err != nil { - t.Fatal(err) - } - created, err := rt.Create(context.Background(), newRun) - if err != nil { - t.Fatal(err) - } - return &conformanceCase{ - t: t, runID: runID, initial: created.Header.InitialState, rt: rt, - } -} - -func (c *conformanceCase) load() run.RuntimeSnapshot { - c.t.Helper() - snap, err := c.rt.Load(context.Background(), c.runID) - if err != nil { - c.t.Fatal(err) - } - return snap -} - -func (c *conformanceCase) commit(id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) (run.CommitResult, error) { - c.t.Helper() - cmd, id = withTestExecutionClaim(c.runID, id, cmd) - env, err := run.ProtocolV1().BuildEnvelope(c.runID, id, cmd) - if err != nil { - c.t.Fatal(err) - } - res, err := c.rt.Commit(context.Background(), run.CommitRequest{BaseRevision: base, Grant: grant, Command: env}) - if err == nil && res.Status == run.CommitAccepted { - c.events = append(c.events, res.Events...) - } - return res, err -} - -// withTestExecutionClaim treats id as the attempt label of a start command: -// the claim derives from the label and the CommandID derives from the claim -// (RUN-WIR-3), so a test that replays "start-1" hits the same identity. -// Non-start commands keep id verbatim. -func withTestExecutionClaim(runID run.RunID, id run.CommandID, cmd run.AgentCommand) (run.AgentCommand, run.CommandID) { - switch c := cmd.(type) { - case run.StartModelExecution: - if c.Claim == "" { - c.Claim = startClaim(id) - } - return c, run.DeriveStartCommandID(runID, c.StepID, "", c.Claim) - case run.StartToolCall: - if c.Claim == "" { - c.Claim = startClaim(id) - } - return c, run.DeriveStartCommandID(runID, c.StepID, c.CallID, c.Claim) - default: - return cmd, id - } -} - -func (c *conformanceCase) mustCommit(id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) run.CommitResult { - c.t.Helper() - res, err := c.commit(id, base, grant, cmd) - if err != nil { - c.t.Fatalf("commit %T: %v", cmd, err) - } - return res -} - -func preparedCase(t testing.TB, newRuntime Factory, tools []sdk.ToolDefinition, specs []run.ToolSpec) (*conformanceCase, run.StepID, run.ExecutionGrant) { - t.Helper() - c := newCase(t, newRuntime) - snap := c.load() - req := request(tools...) - prep, cmdID := buildPrepareFromSnap(t, &snap, &req, specs) - c.mustCommit(cmdID, snap.Revision, "", prep) - start := c.mustCommit("start-1", 1, "", run.StartModelExecution{StepID: prep.StepID}) - if start.Grant == "" { - t.Fatal("accepted start returned no grant") - } - return c, prep.StepID, start.Grant -} - -func prepareAndStart(t testing.TB, c *conformanceCase) (run.StepID, run.ExecutionGrant) { - t.Helper() - snap := c.load() - req := request() - prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) - c.mustCommit(cmdID, snap.Revision, "", prep) - start := c.mustCommit(run.CommandID("start-"+string(c.runID)), snap.Revision+1, "", run.StartModelExecution{StepID: prep.StepID}) - if start.Grant == "" { - t.Fatal("accepted start returned no grant") - } - return prep.StepID, start.Grant -} - -func buildPrepareFromSnap(t testing.TB, snap *run.RuntimeSnapshot, req *sdk.Request, specs []run.ToolSpec) (run.PrepareModelRequest, run.CommandID) { - t.Helper() - frozenReq, err := run.FreezeModelRequest(*req) - if err != nil { - t.Fatal(err) - } - reqDigest, err := run.ProtocolV1().DigestRequest(frozenReq) - if err != nil { - t.Fatal(err) - } - toolsDigest, err := run.ProtocolV1().DigestToolSpecs(specs) - if err != nil { - t.Fatal(err) - } - model := run.ModelRef(frozenReq.Model) - binding, err := run.ProtocolV1().DigestModelStepBinding(model, reqDigest, toolsDigest) - if err != nil { - t.Fatal(err) - } - cmdID := run.DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) - stepID := run.DeriveModelStepID(snap.State.RunID, cmdID, binding) - ids := make([]run.InputID, len(snap.State.PendingInputs)) - for i, in := range snap.State.PendingInputs { - ids[i] = in.ID - } - return run.PrepareModelRequest{ - StepID: stepID, Model: model, Request: frozenReq, - RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, - }, cmdID -} - -func request(tools ...sdk.ToolDefinition) sdk.Request { - return sdk.Request{ - Model: "m-1", - Messages: []sdk.Message{sdk.UserMessage("hi")}, - Tools: tools, - } -} - -func toolDef(name string) sdk.ToolDefinition { - return sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} -} - -func mustJSON(raw string) run.CanonicalJSON { return run.MustParseCanonicalJSON(raw) } - -func makeSpec(t testing.TB, def sdk.ToolDefinition) run.ToolSpec { - t.Helper() - frozen, err := run.FreezeToolDefinition(def) - if err != nil { - t.Fatal(err) - } - d, err := run.ProtocolV1().DigestToolDefinition(frozen) - if err != nil { - t.Fatal(err) - } - return run.ToolSpec{Ref: run.ToolRef(def.Name), Name: def.Name, DefinitionDigest: d, Policy: run.DirectExecution} -} - -// makeBinding builds the binding for the index-th call of source whose -// provider id is providerID; tests address the call by run.DeriveCallID. -func makeBinding(t testing.TB, source run.StepID, index int, providerID string, spec *run.ToolSpec) run.ToolCallBinding { - t.Helper() - parsedArgs := mustJSON(`{}`) - callID := run.DeriveCallID(source, index) - bd, err := run.DigestToolCallBinding(callID, spec.DefinitionDigest, spec.Policy, parsedArgs) - if err != nil { - t.Fatal(err) - } - return run.ToolCallBinding{ - CallID: callID, - ProviderCallID: providerID, - ToolRef: spec.Ref, - DefinitionDigest: spec.DefinitionDigest, - BindingDigest: bd, - Arguments: parsedArgs, - Policy: spec.Policy, - } -} - -func openedToolStepID(t testing.TB, res *run.CommitResult) run.StepID { - t.Helper() - if len(res.Events) < 2 { - t.Fatalf("events = %d, want ToolStepOpened at index 1", len(res.Events)) - } - opened, ok := res.Events[1].Fact.(run.ToolStepOpened) - if !ok { - t.Fatalf("event[1] fact = %T, want ToolStepOpened", res.Events[1].Fact) - } - return opened.StepID -} - -func modelResultWithCalls(callIDs ...string) run.ModelResult { - r := sdk.ModelResult{ - 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: "t", Input: `{}`}) - } - frozen, err := run.FreezeModelResult(r) - if err != nil { - panic(err) - } - return frozen -} - -func testCreateRetryAndConflict(t *testing.T, newRuntime Factory) { - rt := newRuntime() - first, err := run.BuildNewRun("run-create", "cause-1") - if err != nil { - t.Fatal(err) - } - created, err := rt.Create(context.Background(), first) - if err != nil || !created.Created { - t.Fatalf("first Create = %+v, %v", created, err) - } - if err := run.ValidateRunHeader(&created.Header); err != nil { - t.Fatalf("Create returned invalid Header: %v", err) - } - created.Header.InitialState.RunID = "mutated" - record, err := rt.Record(context.Background(), "run-create") - if err != nil { - t.Fatal(err) - } - if record.Header.InitialState.RunID != "run-create" { - t.Fatal("Create Header aliases runtime state") - } - retry, err := rt.Create(context.Background(), first) - if err != nil || retry.Created { - t.Fatalf("retry Create = %+v, %v", retry, err) - } - retry.Header.InitialState.RunID = "mutated" - record, err = rt.Record(context.Background(), first.RunID) - if err != nil { - t.Fatal(err) - } - if record.Header.InitialState.RunID != first.RunID { - t.Fatal("retry Create Header aliases runtime state") - } - conflict, err := run.BuildNewRun("run-create", "cause-2") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(context.Background(), conflict); !errors.Is(err, run.ErrCreateConflict) { - t.Fatalf("conflicting Create error = %v, want ErrCreateConflict", err) - } -} - -func testCreateMissing(t *testing.T, newRuntime Factory) { - rt := newRuntime() - if _, err := rt.Load(context.Background(), "missing"); !errors.Is(err, run.ErrRunNotFound) { - t.Fatalf("Load error = %v, want ErrRunNotFound", err) - } - if _, err := rt.Record(context.Background(), "missing"); !errors.Is(err, run.ErrRunNotFound) { - t.Fatalf("Record error = %v, want ErrRunNotFound", err) - } - env, err := run.ProtocolV1().BuildEnvelope("missing", "cancel", run.CancelRun{}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(context.Background(), run.CommitRequest{Command: env}); !errors.Is(err, run.ErrRunNotFound) { - t.Fatalf("Commit error = %v, want ErrRunNotFound", err) - } -} - -func testCreateConcurrent(t *testing.T, newRuntime Factory) { - rt := newRuntime() - newRun, err := run.BuildNewRun("run-concurrent-create", "cause-1") - if err != nil { - t.Fatal(err) - } - const callers = 16 - results := make(chan run.CreateResult, callers) - errs := make(chan error, callers) - var group sync.WaitGroup - for range callers { - group.Add(1) - go func() { - defer group.Done() - result, err := rt.Create(context.Background(), newRun) - if err != nil { - errs <- err - return - } - results <- result - }() - } - group.Wait() - close(results) - close(errs) - for err := range errs { - t.Fatal(err) - } - createdCount := 0 - for result := range results { - if result.Created { - createdCount++ - } - } - if createdCount != 1 { - t.Fatalf("Created=true results = %d, want 1", createdCount) - } -} - -func testRecordFoldAccepted(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - record, err := c.rt.Record(context.Background(), c.runID) - if err != nil { - t.Fatal(err) - } - if record.Snapshot.Revision != 0 || len(record.Transitions) != 0 { - t.Fatalf("revision-zero Record = %+v", record) - } - input := run.AgentInput{ID: "in-1", Payload: mustJSON(`{"q":"hi"}`)} - res := c.mustCommit(run.DeriveInputCommandID(c.runID, input.ID), 0, "", run.AcceptInput{Input: input}) - if res.Status != run.CommitAccepted { - t.Fatalf("accept status = %v", res.Status) - } - record, err = c.rt.Record(context.Background(), c.runID) - if err != nil { - t.Fatal(err) - } - if len(record.Transitions) != 1 { - t.Fatalf("transitions = %d, want 1", len(record.Transitions)) - } - folded, revision, err := run.FoldRun(&record.Header, record.Transitions) - if err != nil { - t.Fatal(err) - } - foldedJSON := encodeState(t, &folded) - snapshotJSON := encodeState(t, &record.Snapshot.State) - if revision != record.Snapshot.Revision || !bytes.Equal(foldedJSON, snapshotJSON) { - t.Fatalf("FoldRun = revision %d state %s, Record = revision %d state %s", revision, foldedJSON, record.Snapshot.Revision, snapshotJSON) - } -} - -func testRecordReplayAndIndex(t *testing.T, newRuntime Factory) { - def := toolDef("t") - spec := makeSpec(t, def) - c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []run.ToolSpec{spec}) - b := makeBinding(t, stepID, 0, "c1", &spec) - res := c.mustCommit("complete-1", 2, grant, - run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) - if len(res.Events) != 2 { - t.Fatalf("events = %d", len(res.Events)) - } - for i, e := range res.Events { - if e.Revision != res.Snapshot.Revision { - t.Fatalf("event revision %d != snapshot %d", e.Revision, res.Snapshot.Revision) - } - if int(e.Index) != i { - t.Fatalf("index[%d] = %d", i, e.Index) - } - if e.CommandID != "complete-1" { - t.Fatal("command id not stamped") - } - } - toolStep := openedToolStepID(t, &res) - sRes := c.mustCommit("start-c1", res.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0)}) - c.mustCommit("done-c1", sRes.Snapshot.Revision, sRes.Grant, - run.SubmitToolResult{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0), Result: run.ToolExecutionResult{Output: mustJSON(`"ok"`)}}) - foldedState, lastRev, err := run.FoldEvents(c.initial, c.events) - if err != nil { - t.Fatalf("FoldEvents: %v", err) - } - live := c.load() - a := encodeState(t, &live.State) - bts := encodeState(t, &foldedState) - if !bytes.Equal(a, bts) { - t.Fatalf("replay diverged:\n live %s\n replay %s", a, bts) - } - if live.Revision != lastRev { - t.Fatalf("snapshot revision %d != last event revision %d", live.Revision, lastRev) - } -} - -func testRecordConcurrentWithCommit(t *testing.T, newRuntime Factory) { - rt := newRuntime() - c := newCaseOnRuntime(t, rt, "record-concurrent") - const commits = 16 - for i := range commits { - snapshot, err := rt.Load(context.Background(), c.runID) - if err != nil { - t.Fatal(err) - } - in := run.AgentInput{ID: run.InputID(fmt.Sprintf("in-%d", i)), Payload: mustJSON(`null`)} - env, err := run.ProtocolV1().BuildEnvelope(c.runID, run.DeriveInputCommandID(c.runID, in.ID), run.AcceptInput{Input: in}) - if err != nil { - t.Fatal(err) - } - start := make(chan struct{}) - var ready sync.WaitGroup - ready.Add(2) - commitCh := make(chan error, 1) - recordCh := make(chan struct { - record run.RunRecord - err error - }, 1) - go func() { - ready.Done() - <-start - _, err := rt.Commit(context.Background(), run.CommitRequest{BaseRevision: snapshot.Revision, Command: env}) - commitCh <- err - }() - go func() { - ready.Done() - <-start - rec, err := rt.Record(context.Background(), c.runID) - recordCh <- struct { - record run.RunRecord - err error - }{record: rec, err: err} - }() - ready.Wait() - close(start) - if err := <-commitCh; err != nil { - t.Fatal(err) - } - observed := <-recordCh - if observed.err != nil { - t.Fatal(observed.err) - } - foldedRun, rev, err := run.FoldRun(&observed.record.Header, observed.record.Transitions) - if err != nil { - t.Fatal(err) - } - foldedJSON := encodeState(t, &foldedRun) - snapshotJSON := encodeState(t, &observed.record.Snapshot.State) - if rev != observed.record.Snapshot.Revision || !bytes.Equal(foldedJSON, snapshotJSON) { - t.Fatalf("inconsistent Record at revision %d", observed.record.Snapshot.Revision) - } - if wantBefore, wantAfter := snapshot.Revision, snapshot.Revision+1; rev != wantBefore && rev != wantAfter { - t.Fatalf("concurrent Record revision = %d, want %d or %d", rev, wantBefore, wantAfter) - } - } - final, err := rt.Record(context.Background(), c.runID) - if err != nil { - t.Fatal(err) - } - if final.Snapshot.Revision != commits || len(final.Transitions) != commits { - t.Fatalf("final Record = revision %d transitions %d, want %d", final.Snapshot.Revision, len(final.Transitions), commits) - } -} - -func testIsolationDetachedReturns(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - newRun, err := run.BuildNewRun(c.runID, "") - if err != nil { - t.Fatal(err) - } - created, err := c.rt.Create(context.Background(), newRun) - if err != nil || created.Created { - t.Fatalf("retry Create = %+v, %v", created, err) - } - created.Header.InitialState.RunID = "mutated" - input := run.AgentInput{ID: "in-1", Payload: mustJSON(`1`)} - res := c.mustCommit(run.DeriveInputCommandID(c.runID, input.ID), 0, "", run.AcceptInput{Input: input}) - res.Snapshot.State.RunID = "mutated" - if len(res.Events) > 0 { - res.Events[0].RunID = "mutated" - } - record, err := c.rt.Record(context.Background(), c.runID) - if err != nil { - t.Fatal(err) - } - record.Header.InitialState.RunID = "mutated" - record.Snapshot.State.RunID = "mutated" - record.Transitions[0].Events[0].RunID = "mutated" - fresh, err := c.rt.Record(context.Background(), c.runID) - if err != nil { - t.Fatal(err) - } - if fresh.Header.InitialState.RunID != c.runID || fresh.Snapshot.State.RunID != c.runID || fresh.Transitions[0].Events[0].RunID != c.runID { - t.Fatal("returned Header, Snapshot, or Transitions alias runtime state") - } -} - -func testIsolationRuns(t *testing.T, newRuntime Factory) { - rt := newRuntime() - for _, id := range []run.RunID{"run-one", "run-two"} { - nr, err := run.BuildNewRun(id, "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(context.Background(), nr); err != nil { - t.Fatal(err) - } - in := run.AgentInput{ID: "same-input", Payload: mustJSON(`{"run":"` + string(id) + `"}`)} - env, err := run.ProtocolV1().BuildEnvelope(id, run.DeriveInputCommandID(id, in.ID), run.AcceptInput{Input: in}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(context.Background(), run.CommitRequest{Command: env}); err != nil { - t.Fatal(err) - } - } - one, err := rt.Record(context.Background(), "run-one") - if err != nil { - t.Fatal(err) - } - two, err := rt.Record(context.Background(), "run-two") - if err != nil { - t.Fatal(err) - } - if len(one.Snapshot.State.PendingInputs) != 1 || len(two.Snapshot.State.PendingInputs) != 1 || - one.Snapshot.State.PendingInputs[0].Payload.String() == two.Snapshot.State.PendingInputs[0].Payload.String() { - t.Fatalf("runs leaked state or commands: one=%+v two=%+v", one.Snapshot.State.PendingInputs, two.Snapshot.State.PendingInputs) - } - for _, rec := range []run.RunRecord{one, two} { - if len(rec.Transitions) != 1 || rec.Transitions[0].RunID != rec.Header.RunID { - t.Fatalf("run %q has foreign transition log: %+v", rec.Header.RunID, rec.Transitions) - } - for _, event := range rec.Transitions[0].Events { - if event.RunID != rec.Header.RunID { - t.Fatalf("run %q has foreign event %+v", rec.Header.RunID, event) - } - } - } -} - -func testIsolationCrossRunGrant(t *testing.T, newRuntime Factory) { - rt := newRuntime() - oneCase := newCaseOnRuntime(t, rt, "grant-run-one") - twoCase := newCaseOnRuntime(t, rt, "grant-run-two") - stepOne, grantOne := prepareAndStart(t, oneCase) - stepTwo, grantTwo := prepareAndStart(t, twoCase) - result, err := run.FreezeModelResult(sdk.ModelResult{Text: "ok"}) - if err != nil { - t.Fatal(err) - } - if _, err := twoCase.commit("foreign-grant", 2, grantOne, run.SubmitModelResult{StepID: stepTwo, Result: result}); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("cross-run grant error = %v, want ErrStaleRuntime", err) - } - oneCase.mustCommit("complete-one", 2, grantOne, run.SubmitModelResult{StepID: stepOne, Result: result}) - twoCase.mustCommit("complete-two", 2, grantTwo, run.SubmitModelResult{StepID: stepTwo, Result: result}) -} - -func testCommandIdempotentReplay(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - res1 := c.mustCommit("cancel-1", 0, "", run.CancelRun{}) - if res1.Status != run.CommitAccepted || len(res1.Events) != 1 { - t.Fatalf("res1 = %+v", res1) - } - res2 := c.mustCommit("cancel-1", 0, "", run.CancelRun{}) - if res2.Status != run.CommitAlreadyApplied { - t.Fatalf("status = %v", res2.Status) - } - if len(res2.Events) != 1 || res2.Events[0].Digest != res1.Events[0].Digest || - res2.Events[0].Revision != res1.Events[0].Revision { - t.Fatal("replay did not return the original event group") - } - if res2.Snapshot.Revision != res1.Snapshot.Revision { - t.Fatal("replay advanced the revision") - } - _, err := c.commit("cancel-1", 0, "", run.CancelRun{Reason: "other"}) - if !errors.Is(err, run.ErrCommandConflict) { - t.Fatalf("err = %v, want ErrCommandConflict", err) - } -} - -func testCommandDerivedIDs(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - _, err := c.commit("random-id", 0, "", run.NextStep(run.AgentInput{ID: "in-1", Payload: mustJSON(`1`)})) - if err == nil { - t.Fatal("AcceptInput with non-derived CommandID accepted") - } - _, err = c.commit("random-id-2", 0, "", run.ApproveToolCall{StepID: "s", CallID: "c", ResponseID: "r"}) - if err == nil { - t.Fatal("ApproveToolCall with non-derived CommandID accepted") - } - in := run.AgentInput{ID: "in-9", Payload: mustJSON(`{"t":"x"}`)} - id := run.DeriveInputCommandID("run-1", in.ID) - accepted := c.mustCommit(id, 0, "", run.NextStep(in)) - if accepted.Status != run.CommitAccepted { - t.Fatal("first accept rejected") - } - replayed := c.mustCommit(id, 0, "", run.NextStep(in)) - if replayed.Status != run.CommitAlreadyApplied { - t.Fatalf("status = %v", replayed.Status) - } - _, err = c.commit(id, 0, "", run.NextStep(run.AgentInput{ID: "in-9", Payload: mustJSON(`{"t":"y"}`)})) - if !errors.Is(err, run.ErrCommandConflict) { - t.Fatalf("err = %v, want ErrCommandConflict", err) - } -} - -func testPrepareHardCAS(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - snap := c.load() - req := request() - prep, cmdID := buildPrepareFromSnap(t, &snap, &req, nil) - if _, err := c.commit("wrong-prepare-id", snap.Revision, "", prep); !errors.Is(err, run.ErrCommandConflict) { - t.Fatalf("wrong prepare id err = %v, want ErrCommandConflict", err) - } - bad := prep - bad.StepID = "wrong-step" - if _, err := c.commit(cmdID, snap.Revision, "", bad); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("bad prepare StepID err = %v, want ErrStaleRuntime", err) - } - c.mustCommit(cmdID, snap.Revision, "", prep) - otherReq := request() - otherReq.System = "different" - prep2, cmdID2 := buildPrepareFromSnap(t, &snap, &otherReq, nil) - if cmdID2 != cmdID { - t.Fatal("same revision must derive the same command id") - } - if _, err := c.commit(cmdID2, snap.Revision, "", prep2); !errors.Is(err, run.ErrCommandConflict) { - t.Fatalf("err = %v, want ErrCommandConflict", err) - } - res := c.mustCommit(cmdID, snap.Revision, "", prep) - if res.Status != run.CommitAlreadyApplied { - t.Fatalf("status = %v", res.Status) - } -} - -func testPrepareReplayAfterProgress(t *testing.T, newRuntime Factory) { - c := newCase(t, newRuntime) - snap := c.load() - input := run.AgentInput{ID: "input-1", Payload: mustJSON(`{"text":"hi"}`)} - accepted := c.mustCommit(run.DeriveInputCommandID(c.runID, input.ID), snap.Revision, "", run.AcceptInput{Input: input}) - req := request() - prep, cmdID := buildPrepareFromSnap(t, &accepted.Snapshot, &req, nil) - prepared := c.mustCommit(cmdID, accepted.Snapshot.Revision, "", prep) - start := c.mustCommit("start-derived-replay", prepared.Snapshot.Revision, "", run.StartModelExecution{StepID: prep.StepID}) - ok, err := run.FreezeModelResult(sdk.ModelResult{Text: "done"}) - if err != nil { - t.Fatal(err) - } - c.mustCommit("finish-derived-replay", start.Snapshot.Revision, start.Grant, run.SubmitModelResult{StepID: prep.StepID, Result: ok}) - current := c.load() - replayed, err := c.commit(cmdID, current.Revision, "", prep) - if err != nil || replayed.Status != run.CommitAlreadyApplied { - t.Fatalf("prepare replay after progress = %+v, %v", replayed, err) - } -} - -func testGrant(t *testing.T, newRuntime Factory) { - c, stepID, grant := preparedCase(t, newRuntime, nil, nil) - // The authority rejects an unbound start before it can mint ownership. - empty, err := run.ProtocolV1().BuildEnvelope(c.runID, "empty-claim", run.StartModelExecution{StepID: stepID}) - if err != nil { - t.Fatal(err) - } - if _, err := c.rt.Commit(context.Background(), run.CommitRequest{BaseRevision: 1, Command: empty}); !errors.Is(err, run.ErrCommandConflict) { - t.Fatalf("empty start claim err = %v, want ErrCommandConflict", err) - } - - _, err = c.commit("done-x", 2, "", run.SubmitModelResult{StepID: stepID, Result: run.ModelResult{}}) - if !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("grantless completion err = %v, want ErrStaleRuntime", err) - } - res := c.mustCommit("start-1", 1, "", run.StartModelExecution{StepID: stepID}) - if res.Status != run.CommitAlreadyApplied || res.Grant != grant { - t.Fatalf("replayed start: %+v", res) - } - // A start under another claim is another attempt: its derived CommandID - // differs, so it is not a replay; the target is Executing, so Decide - // rejects it as stale. The live grant is untouched either way. - if _, err := c.commit("start-other", 1, "", run.StartModelExecution{StepID: stepID}); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("second start attempt err = %v, want ErrStaleRuntime", err) - } - // A start whose CommandID does not derive from its claim is rejected - // before it can mint ownership. - forged, err := run.ProtocolV1().BuildEnvelope(c.runID, "hand-minted", run.StartModelExecution{StepID: stepID, Claim: "different-claim"}) - if err != nil { - t.Fatal(err) - } - if _, err := c.rt.Commit(context.Background(), run.CommitRequest{BaseRevision: 1, Command: forged}); !errors.Is(err, run.ErrCommandConflict) { - t.Fatalf("non-derived start id err = %v, want ErrCommandConflict", err) - } - ok, err := run.FreezeModelResult(sdk.ModelResult{Text: "ok"}) - if err != nil { - t.Fatal(err) - } - res = c.mustCommit("done-1", 2, grant, run.SubmitModelResult{StepID: stepID, Result: ok}) - if res.Status != run.CommitAccepted || res.Snapshot.State.Status != run.RunCompleted { - t.Fatalf("completion: %+v", res.Snapshot.State.Status) - } - // The command remains idempotently replayable, but its consumed grant is - // not returned after settlement. - res = c.mustCommit("start-1", 1, "", run.StartModelExecution{StepID: stepID}) - if res.Status != run.CommitAlreadyApplied || res.Grant != "" { - t.Fatalf("settled start replay: %+v", res) - } -} - -func startClaim(id run.CommandID) run.ExecutionClaim { - return run.ExecutionClaim("test-claim/" + string(id)) -} - -func testGrantHolderRecover(t *testing.T, newRuntime Factory) { - c, stepID, grant := preparedCase(t, newRuntime, nil, nil) - claim := startClaim("start-1") - id := run.DeriveModelRecoveryCommandID(c.runID, stepID, claim) - res := c.mustCommit(id, 2, grant, run.RecoverModelExecution{StepID: stepID, Claim: claim}) - ms, ok := res.Snapshot.State.Current.(run.ModelStep) - if !ok || ms.Status != run.ModelPrepared { - t.Fatalf("after grant-holder recover: %+v", res.Snapshot.State.Current) - } -} - -func testGrantGrantlessRejectedWhileLive(t *testing.T, newRuntime Factory) { - c, stepID, _ := preparedCase(t, newRuntime, nil, nil) - claim := startClaim("start-1") - id := run.DeriveModelRecoveryCommandID(c.runID, stepID, claim) - _, err := c.commit(id, 2, "", run.RecoverModelExecution{StepID: stepID, Claim: claim}) - if !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("grantless recover while live err = %v, want ErrStaleRuntime", err) - } -} - -func testCallLocalRebase(t *testing.T, newRuntime Factory) { - defA, defB := toolDef("a"), toolDef("b") - specA := makeSpec(t, defA) - specB := makeSpec(t, defB) - c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{defA, defB}, []run.ToolSpec{specA, specB}) - - bA := makeBinding(t, stepID, 0, "cA", &specA) - bB := makeBinding(t, stepID, 1, "cB", &specB) - r, err := run.FreezeModelResult(sdk.ModelResult{ - FinishReason: sdk.FinishReasonToolCalls, - ToolCalls: []sdk.ToolCall{ - {ToolCallID: "cA", ToolName: "a", Input: `{}`}, - {ToolCallID: "cB", ToolName: "b", Input: `{}`}, - }, - }) - if err != nil { - t.Fatal(err) - } - res := c.mustCommit("complete-1", 2, grant, - run.SubmitModelResult{StepID: stepID, Result: r, Calls: []run.ToolCallBinding{bA, bB}}) - toolStep := openedToolStepID(t, &res) - base := res.Snapshot.Revision - - startA := c.mustCommit("start-A", base, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0)}) - startB := c.mustCommit("start-B", base, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 1)}) - if startB.Status != run.CommitAccepted || startB.Grant == "" { - t.Fatal("stale-base start of an untouched Pending call must rebase") - } - doneA := c.mustCommit("done-A", base, startA.Grant, - run.SubmitToolResult{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0), Result: run.ToolExecutionResult{Output: mustJSON(`1`)}}) - if doneA.Status != run.CommitAccepted { - t.Fatal("owner completion on stale base must rebase") - } - _, err = c.commit("start-A2", base, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0)}) - if !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("restart of settled call err = %v, want ErrStaleRuntime", err) - } -} - -func testCancelStaleBase(t *testing.T, newRuntime Factory) { - c, _, _ := preparedCase(t, newRuntime, nil, nil) - res := c.mustCommit("cancel-1", 0, "", run.CancelRun{}) - if res.Status != run.CommitAccepted || res.Snapshot.State.Status != run.RunStopped { - t.Fatalf("cancel: %+v", res.Snapshot.State.Status) - } -} - -func testCancelAfterUnknown(t *testing.T, newRuntime Factory) { - def := toolDef("t") - spec := makeSpec(t, def) - c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{def}, []run.ToolSpec{spec}) - b := makeBinding(t, stepID, 0, "c1", &spec) - opened := c.mustCommit("complete-1", 2, grant, - run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) - toolStep := openedToolStepID(t, &opened) - startRes := c.mustCommit("start-c1", opened.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0)}) - unknown := c.mustCommit("unk-1", startRes.Snapshot.Revision, startRes.Grant, - run.SubmitToolFailure{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0), Outcome: run.ToolOutcomeUnknown}) - if unknown.Snapshot.State.Status != run.RunActive { - t.Fatalf("unknown status = %v, want active", unknown.Snapshot.State.Status) - } - if _, ok := unknown.Snapshot.State.Current.(run.Open); !ok { - t.Fatalf("current = %+v, want Open", unknown.Snapshot.State.Current) - } - cancelled := c.mustCommit("cancel-1", unknown.Snapshot.Revision, "", run.CancelRun{}) - if cancelled.Snapshot.State.Status != run.RunStopped { - t.Fatalf("cancel status = %v, want stopped", cancelled.Snapshot.State.Status) - } - if n := len(cancelled.Snapshot.State.Result.UncertainCalls); n != 0 { - t.Fatalf("UncertainCalls = %v, want empty", cancelled.Snapshot.State.Result.UncertainCalls) - } -} - -func testCancelUnknownRemaining(t *testing.T, newRuntime Factory) { - defA, defB := toolDef("a"), toolDef("b") - specA := makeSpec(t, defA) - specB := makeSpec(t, defB) - c, stepID, grant := preparedCase(t, newRuntime, []sdk.ToolDefinition{defA, defB}, []run.ToolSpec{specA, specB}) - bA := makeBinding(t, stepID, 0, "cA", &specA) - bB := makeBinding(t, stepID, 1, "cB", &specB) - r, err := run.FreezeModelResult(sdk.ModelResult{ - FinishReason: sdk.FinishReasonToolCalls, - ToolCalls: []sdk.ToolCall{ - {ToolCallID: "cA", ToolName: "a", Input: `{}`}, - {ToolCallID: "cB", ToolName: "b", Input: `{}`}, - }, - }) - if err != nil { - t.Fatal(err) - } - opened := c.mustCommit("complete-1", 2, grant, - run.SubmitModelResult{StepID: stepID, Result: r, Calls: []run.ToolCallBinding{bA, bB}}) - toolStep := openedToolStepID(t, &opened) - base := opened.Snapshot.Revision - startA := c.mustCommit("start-A", base, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0)}) - startB := c.mustCommit("start-B", base, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 1)}) - if startB.Status != run.CommitAccepted || startB.Grant == "" { - t.Fatalf("start B: %+v", startB) - } - unknown := c.mustCommit("unk-A", startA.Snapshot.Revision, startA.Grant, - run.SubmitToolFailure{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0), Outcome: run.ToolOutcomeUnknown}) - if unknown.Snapshot.State.Status != run.RunActive { - t.Fatalf("unknown status = %v, want active", unknown.Snapshot.State.Status) - } - ts, ok := unknown.Snapshot.State.Current.(run.ToolStep) - if !ok || len(ts.Calls) != 2 || ts.Calls[0].Status != run.ToolFailed || ts.Calls[1].Status != run.ToolExecuting { - t.Fatalf("calls after unknown = %+v", unknown.Snapshot.State.Current) - } - cancelled := c.mustCommit("cancel-1", unknown.Snapshot.Revision, "", run.CancelRun{}) - if cancelled.Snapshot.State.Status != run.RunStopped { - t.Fatalf("cancel status = %v, want stopped", cancelled.Snapshot.State.Status) - } - got := cancelled.Snapshot.State.Result.UncertainCalls - if len(got) != 1 || got[0] != run.DeriveCallID(stepID, 1) { - t.Fatalf("UncertainCalls = %v, want [cB]", got) - } -} - -// encodeState renders the canonical snapshot bytes the protocol itself uses -// for state identity, so conformance compares states by the same rule as -// Runtime.Record. -func encodeState(t testing.TB, s *run.MachineState) []byte { - t.Helper() - raw, err := run.ProtocolV1().EncodeMachineState(s) - if err != nil { - t.Fatal(err) - } - return raw -} diff --git a/agent/run/runtimetest/recovery.go b/agent/run/runtimetest/recovery.go deleted file mode 100644 index 737c2a8..0000000 --- a/agent/run/runtimetest/recovery.go +++ /dev/null @@ -1,284 +0,0 @@ -package runtimetest - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/memohai/twilight/agent/run" - "github.com/memohai/twilight/sdk" -) - -// RecoveryFactory constructs an empty Runtime whose leases use ttl and whose -// clock is now. Tests advance time by reassigning a captured time.Time. -type RecoveryFactory func(now func() time.Time, ttl time.Duration) run.Runtime - -// RunRecoveryConformance covers lease expiry: grantless recover is rejected -// while a lease is live, RecoverExpired returns an Executing model to -// Prepared, and an expired Executing tool call is settled as Unknown without -// failing the Run. RunConformance stays on TTL 0. -func RunRecoveryConformance(t *testing.T, newRuntime RecoveryFactory) { - t.Helper() - t.Run("LiveLeaseRejectsGrantless", func(t *testing.T) { testRecoveryLiveLeaseRejectsGrantless(t, newRuntime) }) - t.Run("ZeroTTLRejectsGrantless", func(t *testing.T) { testRecoveryZeroTTLRejectsGrantless(t, newRuntime) }) - t.Run("ExpiredModelPrepared", func(t *testing.T) { testRecoveryExpiredModelPrepared(t, newRuntime) }) - t.Run("ExpiredToolUnknown", func(t *testing.T) { testRecoveryExpiredToolUnknown(t, newRuntime) }) - t.Run("ExpiredToolLeavesSiblingExecuting", func(t *testing.T) { testRecoveryExpiredToolLeavesSiblingExecuting(t, newRuntime) }) - t.Run("RecoverExpiredIdempotent", func(t *testing.T) { testRecoveryExpiredIdempotent(t, newRuntime) }) -} - -func recoveryPrepared(t testing.TB, newRuntime RecoveryFactory, ttl time.Duration, clock *time.Time, tools []sdk.ToolDefinition, specs []run.ToolSpec) (*conformanceCase, run.StepID, run.ExecutionGrant) { - t.Helper() - rt := newRuntime(func() time.Time { return *clock }, ttl) - c := newCaseOnRuntime(t, rt, "run-1") - snap := c.load() - req := request(tools...) - prep, cmdID := buildPrepareFromSnap(t, &snap, &req, specs) - c.mustCommit(cmdID, snap.Revision, "", prep) - start := c.mustCommit("start-1", 1, "", run.StartModelExecution{StepID: prep.StepID}) - if start.Grant == "" { - t.Fatal("accepted start returned no grant") - } - return c, prep.StepID, start.Grant -} - -func testRecoveryLiveLeaseRejectsGrantless(t *testing.T, newRuntime RecoveryFactory) { - clock := time.Unix(1000, 0) - c, stepID, _ := recoveryPrepared(t, newRuntime, time.Second, &clock, nil, nil) - claim := startClaim("start-1") - id := run.DeriveModelRecoveryCommandID(c.runID, stepID, claim) - _, err := c.commit(id, 2, "", run.RecoverModelExecution{StepID: stepID, Claim: claim}) - if !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("grantless recover while live err = %v, want ErrStaleRuntime", err) - } - n, err := c.rt.RecoverExpired(context.Background()) - if err != nil { - t.Fatal(err) - } - if n != 0 { - t.Fatalf("RecoverExpired while live = %d, want 0", n) - } -} - -func testRecoveryZeroTTLRejectsGrantless(t *testing.T, newRuntime RecoveryFactory) { - clock := time.Unix(1000, 0) - c, stepID, _ := recoveryPrepared(t, newRuntime, 0, &clock, nil, nil) - clock = time.Unix(1_000_000, 0) - claim := startClaim("start-1") - id := run.DeriveModelRecoveryCommandID(c.runID, stepID, claim) - _, err := c.commit(id, 2, "", run.RecoverModelExecution{StepID: stepID, Claim: claim}) - if !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("grantless recover with zero TTL err = %v, want ErrStaleRuntime", err) - } - n, err := c.rt.RecoverExpired(context.Background()) - if err != nil { - t.Fatal(err) - } - if n != 0 { - t.Fatalf("RecoverExpired with zero TTL = %d, want 0", n) - } -} - -func testRecoveryExpiredModelPrepared(t *testing.T, newRuntime RecoveryFactory) { - clock := time.Unix(1000, 0) - c, _, _ := recoveryPrepared(t, newRuntime, time.Second, &clock, nil, nil) - clock = time.Unix(1002, 0) - n, err := c.rt.RecoverExpired(context.Background()) - if err != nil { - t.Fatal(err) - } - if n != 1 { - t.Fatalf("recovered = %d, want 1", n) - } - snap := c.load() - if snap.State.RunID != c.runID { - t.Fatalf("RunID = %q, want %q", snap.State.RunID, c.runID) - } - if snap.State.Status != run.RunActive { - t.Fatalf("status = %v, want active", snap.State.Status) - } - ms, ok := snap.State.Current.(run.ModelStep) - if !ok || ms.Status != run.ModelPrepared { - t.Fatalf("after RecoverExpired: %+v", snap.State.Current) - } -} - -func testRecoveryExpiredToolUnknown(t *testing.T, newRuntime RecoveryFactory) { - clock := time.Unix(1000, 0) - def := toolDef("t") - spec := makeSpec(t, def) - c, stepID, grant := recoveryPrepared(t, newRuntime, time.Second, &clock, []sdk.ToolDefinition{def}, []run.ToolSpec{spec}) - b := makeBinding(t, stepID, 0, "c1", &spec) - opened := c.mustCommit("complete-1", 2, grant, - run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) - toolStep := openedToolStepID(t, &opened) - c.mustCommit("start-c1", opened.Snapshot.Revision, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0)}) - clock = time.Unix(1002, 0) - n, err := c.rt.RecoverExpired(context.Background()) - if err != nil { - t.Fatal(err) - } - if n != 1 { - t.Fatalf("recovered = %d, want 1", n) - } - snap := c.load() - if snap.State.Status != run.RunActive { - t.Fatalf("status = %v, want active", snap.State.Status) - } - if _, ok := snap.State.Current.(run.Open); !ok { - t.Fatalf("current = %+v, want Open", snap.State.Current) - } - if snap.State.LastToolStep == nil || len(snap.State.LastToolStep.Calls) != 1 { - t.Fatalf("LastToolStep = %+v", snap.State.LastToolStep) - } - call := snap.State.LastToolStep.Calls[0] - if call.Status != run.ToolFailed || call.Failure == nil || call.Failure.Outcome != run.ToolOutcomeUnknown || call.Failure.Failure.Class != run.FailureEffectUnknown { - t.Fatalf("unknown call = %+v", call) - } -} - -func testRecoveryExpiredToolLeavesSiblingExecuting(t *testing.T, newRuntime RecoveryFactory) { - clock := time.Unix(1000, 0) - defA, defB := toolDef("a"), toolDef("b") - specA := makeSpec(t, defA) - specB := makeSpec(t, defB) - c, stepID, grant := recoveryPrepared(t, newRuntime, time.Second, &clock, - []sdk.ToolDefinition{defA, defB}, []run.ToolSpec{specA, specB}) - bA := makeBinding(t, stepID, 0, "cA", &specA) - bB := makeBinding(t, stepID, 1, "cB", &specB) - r, err := run.FreezeModelResult(sdk.ModelResult{ - FinishReason: sdk.FinishReasonToolCalls, - ToolCalls: []sdk.ToolCall{ - {ToolCallID: "cA", ToolName: "a", Input: `{}`}, - {ToolCallID: "cB", ToolName: "b", Input: `{}`}, - }, - }) - if err != nil { - t.Fatal(err) - } - opened := c.mustCommit("complete-1", 2, grant, - run.SubmitModelResult{StepID: stepID, Result: r, Calls: []run.ToolCallBinding{bA, bB}}) - toolStep := openedToolStepID(t, &opened) - base := opened.Snapshot.Revision - c.mustCommit("start-A", base, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 0)}) - clock = time.Unix(1000, 500_000_000) - c.mustCommit("start-B", base, "", run.StartToolCall{StepID: toolStep, CallID: run.DeriveCallID(stepID, 1)}) - clock = time.Unix(1001, 0) - n, err := c.rt.RecoverExpired(context.Background()) - if err != nil { - t.Fatal(err) - } - if n != 1 { - t.Fatalf("recovered = %d, want 1", n) - } - snap := c.load() - if snap.State.Status != run.RunActive { - t.Fatalf("status = %v, want active", snap.State.Status) - } - ts, ok := snap.State.Current.(run.ToolStep) - if !ok || len(ts.Calls) != 2 { - t.Fatalf("current = %+v, want ToolStep with 2 calls", snap.State.Current) - } - if ts.Calls[0].Status != run.ToolFailed || ts.Calls[0].Failure == nil || ts.Calls[0].Failure.Outcome != run.ToolOutcomeUnknown { - t.Fatalf("call A = %+v, want Unknown", ts.Calls[0]) - } - if ts.Calls[1].Status != run.ToolExecuting { - t.Fatalf("call B = %+v, want Executing", ts.Calls[1]) - } -} - -func testRecoveryExpiredIdempotent(t *testing.T, newRuntime RecoveryFactory) { - clock := time.Unix(1000, 0) - c, _, _ := recoveryPrepared(t, newRuntime, time.Second, &clock, nil, nil) - clock = time.Unix(1002, 0) - n, err := c.rt.RecoverExpired(context.Background()) - if err != nil { - t.Fatal(err) - } - if n != 1 { - t.Fatalf("first recovered = %d, want 1", n) - } - n, err = c.rt.RecoverExpired(context.Background()) - if err != nil { - t.Fatal(err) - } - if n != 0 { - t.Fatalf("second recovered = %d, want 0", n) - } - snap := c.load() - ms, ok := snap.State.Current.(run.ModelStep) - if !ok || ms.Status != run.ModelPrepared { - t.Fatalf("after second RecoverExpired: %+v", snap.State.Current) - } -} - -// RunLeaseRenewalConformance covers Runtime.RenewLease: a renewed lease is -// not recovered at its original deadline, a stale or foreign grant cannot -// renew, and renewal after settlement is rejected. -func RunLeaseRenewalConformance(t *testing.T, newRuntime RecoveryFactory) { - t.Helper() - t.Run("RenewExtendsDeadline", func(t *testing.T) { testRenewExtendsDeadline(t, newRuntime) }) - t.Run("RenewRejectsWrongGrant", func(t *testing.T) { testRenewRejectsWrongGrant(t, newRuntime) }) - t.Run("RenewAfterSettlementRejected", func(t *testing.T) { testRenewAfterSettlementRejected(t, newRuntime) }) -} - -func testRenewExtendsDeadline(t *testing.T, newRuntime RecoveryFactory) { - clock := time.Unix(1000, 0) - c, stepID, grant := recoveryPrepared(t, newRuntime, time.Second, &clock, nil, nil) - ctx := context.Background() - clock = time.Unix(1000, 800_000_000) - if err := c.rt.RenewLease(ctx, c.runID, stepID, "", grant); err != nil { - t.Fatalf("renew: %v", err) - } - // Past the original deadline, before the renewed one: nothing to recover. - clock = time.Unix(1001, 500_000_000) - n, err := c.rt.RecoverExpired(ctx) - if err != nil { - t.Fatal(err) - } - if n != 0 { - t.Fatalf("recovered = %d after renewal, want 0", n) - } - snap := c.load() - if ms, ok := snap.State.Current.(run.ModelStep); !ok || ms.Status != run.ModelExecuting { - t.Fatalf("current = %+v, want Executing", snap.State.Current) - } - // The owner's result is still accepted with the same grant. - c.mustCommit("complete-1", snap.Revision, grant, run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls()}) - // Past the renewed deadline the lease is gone with the settlement. - clock = time.Unix(1003, 0) - n, err = c.rt.RecoverExpired(ctx) - if err != nil { - t.Fatal(err) - } - if n != 0 { - t.Fatalf("recovered = %d after settlement, want 0", n) - } -} - -func testRenewRejectsWrongGrant(t *testing.T, newRuntime RecoveryFactory) { - clock := time.Unix(1000, 0) - c, stepID, _ := recoveryPrepared(t, newRuntime, time.Second, &clock, nil, nil) - ctx := context.Background() - if err := c.rt.RenewLease(ctx, c.runID, stepID, "", "not-the-grant"); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("wrong grant renew err = %v, want ErrStaleRuntime", err) - } - if err := c.rt.RenewLease(ctx, c.runID, stepID, "", ""); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("empty grant renew err = %v, want ErrStaleRuntime", err) - } - if err := c.rt.RenewLease(ctx, "missing", stepID, "", "g"); !errors.Is(err, run.ErrRunNotFound) { - t.Fatalf("missing run renew err = %v, want ErrRunNotFound", err) - } -} - -func testRenewAfterSettlementRejected(t *testing.T, newRuntime RecoveryFactory) { - clock := time.Unix(1000, 0) - c, stepID, grant := recoveryPrepared(t, newRuntime, time.Second, &clock, nil, nil) - ctx := context.Background() - snap := c.load() - c.mustCommit("complete-1", snap.Revision, grant, run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls()}) - if err := c.rt.RenewLease(ctx, c.runID, stepID, "", grant); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("renew after settlement err = %v, want ErrStaleRuntime", err) - } -} diff --git a/agent/run/snapshot_test.go b/agent/run/snapshot_test.go index 512ba2f..6c11a42 100644 --- a/agent/run/snapshot_test.go +++ b/agent/run/snapshot_test.go @@ -1,11 +1,8 @@ package run import ( - "context" "strings" "testing" - - "github.com/memohai/twilight/sdk" ) // The snapshot codec round-trips every Current variant and the terminal @@ -13,16 +10,9 @@ import ( func TestSnapshotCodecRoundTrip(t *testing.T) { def := testToolDef("t") spec := makeSpec(t, def, DirectExecution) - rt, stepID, grant := preparedRuntime(t, []sdk.ToolDefinition{def}, []ToolSpec{spec}) - ctx := context.Background() - - check := func(name string) { + check := func(name string, s MachineState) { t.Helper() - snap, err := rt.Load(ctx, "run-1") - if err != nil { - t.Fatal(err) - } - raw, err := ProtocolV1().EncodeMachineState(&snap.State) + raw, err := ProtocolV1().EncodeMachineState(&s) if err != nil { t.Fatalf("%s: encode: %v", name, err) } @@ -30,29 +20,29 @@ func TestSnapshotCodecRoundTrip(t *testing.T) { if err != nil { t.Fatalf("%s: decode: %v\n%s", name, err, raw) } - if !statesEquivalent(&snap.State, &decoded) { + if !statesEquivalent(&s, &decoded) { t.Fatalf("%s: round trip changed state\n%s", name, raw) } - if (snap.State.Current == nil) != (decoded.Current == nil) { - t.Fatalf("%s: Current presence changed: %T -> %T", name, snap.State.Current, decoded.Current) + if (s.Current == nil) != (decoded.Current == nil) { + t.Fatalf("%s: Current presence changed: %T -> %T", name, s.Current, decoded.Current) } } - check("model executing") + 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, `{}`) - snap, _ := rt.Load(ctx, "run-1") - res := mustCommit(t, rt, "complete-1", snap.Revision, grant, - SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) - check("tool step pending") - toolStep := res.Events[1].Fact.(ToolStepOpened).StepID - sRes := mustCommit(t, rt, "start-c1", res.Snapshot.Revision, "", StartToolCall{StepID: toolStep, CallID: cid(stepID, 0)}) - check("tool step executing") - mustCommit(t, rt, "done-c1", sRes.Snapshot.Revision, sRes.Grant, - SubmitToolResult{StepID: toolStep, CallID: cid(stepID, 0), Result: ToolExecutionResult{Output: cj(`"ok"`)}}) - check("open with last tool step") - snap, _ = rt.Load(ctx, "run-1") - mustCommit(t, rt, "cancel", snap.Revision, "", CancelRun{}) - check("terminal") + 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) { diff --git a/agent/run/sqlitestore/store.go b/agent/run/sqlitestore/store.go deleted file mode 100644 index 99a0ec4..0000000 --- a/agent/run/sqlitestore/store.go +++ /dev/null @@ -1,573 +0,0 @@ -// Package sqlitestore is the SQLite Store adapter for agent/run. -// -// It persists RunHeader, the append-only TransitionRecord log, the derived -// MachineState snapshot, and execution leases. LoadHead reads the snapshot -// directly; the log is read only by LoadLog, which Runtime.Record and Rebuild -// use for verification. Runtime.Commit remains the write path; this package -// does not call Decide or Evolve. -package sqlitestore - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "path/filepath" - "sync" - "time" - - "github.com/memohai/twilight/agent/run" - - _ "modernc.org/sqlite" // SQLite driver -) - -// Store is a file-backed run.Store. Runtime does not Close it; the host does. -type Store struct { - db *sql.DB - closed sync.Once -} - -var _ run.Store = (*Store)(nil) - -const dsnParams = "?_txlock=immediate&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" - -// Open creates or opens a SQLite database at path and applies the schema. -func Open(path string) (*Store, error) { - if path == "" { - return nil, errors.New("agent: sqlite store: empty path") - } - abs, err := filepath.Abs(path) - if err != nil { - return nil, fmt.Errorf("agent: sqlite store: resolve path: %w", err) - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(abs)+dsnParams) - if err != nil { - return nil, fmt.Errorf("agent: sqlite store: open: %w", err) - } - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(1) - ctx := context.Background() - if err := db.PingContext(ctx); err != nil { - _ = db.Close() - return nil, fmt.Errorf("agent: sqlite store: ping: %w", err) - } - s := &Store{db: db} - if err := s.init(ctx); err != nil { - _ = db.Close() - return nil, err - } - return s, nil -} - -func (s *Store) init(ctx context.Context) error { - for _, stmt := range schema { - if _, err := s.db.ExecContext(ctx, stmt); err != nil { - return fmt.Errorf("agent: sqlite store: schema: %w", err) - } - } - return nil -} - -var schema = []string{ - `CREATE TABLE IF NOT EXISTS runs ( - run_id TEXT PRIMARY KEY NOT NULL, - schema_version INTEGER NOT NULL, - header TEXT NOT NULL, - revision INTEGER NOT NULL DEFAULT 0, - snapshot TEXT NOT NULL - )`, - `CREATE TABLE IF NOT EXISTS transitions ( - run_id TEXT NOT NULL, - revision INTEGER NOT NULL, - command_id TEXT NOT NULL, - record TEXT NOT NULL, - PRIMARY KEY (run_id, revision), - UNIQUE (run_id, command_id), - FOREIGN KEY (run_id) REFERENCES runs(run_id) - )`, - `CREATE TABLE IF NOT EXISTS leases ( - run_id TEXT NOT NULL, - lease_key TEXT NOT NULL, - claim TEXT NOT NULL, - grant TEXT NOT NULL, - start_command_id TEXT NOT NULL, - deadline_unix_nano INTEGER, - PRIMARY KEY (run_id, lease_key), - FOREIGN KEY (run_id) REFERENCES runs(run_id) - )`, - `CREATE INDEX IF NOT EXISTS leases_deadline ON leases (deadline_unix_nano) WHERE deadline_unix_nano IS NOT NULL`, -} - -// Close releases the database. It is safe to call more than once. -func (s *Store) Close() error { - if s == nil { - return nil - } - var err error - s.closed.Do(func() { - if s.db != nil { - err = s.db.Close() - s.db = nil - } - }) - return err -} - -func (s *Store) ready(ctx context.Context) error { - if err := ctxErr(ctx); err != nil { - return err - } - if s == nil || s.db == nil { - return errors.New("agent: sqlite store: nil store") - } - return nil -} - -//nolint:gocritic // hugeParam: Store.Create takes RunHeader by value as the persisted creation record. -func (s *Store) Create(ctx context.Context, header run.RunHeader) (bool, run.RunHeader, error) { - if err := s.ready(ctx); err != nil { - return false, run.RunHeader{}, err - } - proto, err := run.ProtocolFor(header.SchemaVersion) - if err != nil { - return false, run.RunHeader{}, err - } - rawHeader, err := json.Marshal(header) - if err != nil { - return false, run.RunHeader{}, fmt.Errorf("agent: sqlite store: marshal header: %w", err) - } - rawSnapshot, err := proto.EncodeMachineState(&header.InitialState) - if err != nil { - return false, run.RunHeader{}, fmt.Errorf("agent: sqlite store: encode snapshot: %w", err) - } - var created bool - var existing run.RunHeader - err = s.withTx(ctx, func(tx *sql.Tx) error { - res, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO runs (run_id, schema_version, header, revision, snapshot) VALUES (?, ?, ?, 0, ?)`, - string(header.RunID), header.SchemaVersion, string(rawHeader), string(rawSnapshot)) - if err != nil { - return fmt.Errorf("agent: sqlite store: insert run: %w", err) - } - n, err := res.RowsAffected() - if err != nil { - return fmt.Errorf("agent: sqlite store: insert run: %w", err) - } - if n == 1 { - created = true - existing = header - existing.InitialState.Current = run.Open{} - return nil - } - loaded, err := loadHeader(ctx, tx, header.RunID) - if err != nil { - return err - } - existing = loaded - return nil - }) - return created, existing, err -} - -func (s *Store) LoadHead(ctx context.Context, id run.RunID) (run.RunHead, error) { - if err := s.ready(ctx); err != nil { - return run.RunHead{}, err - } - var head run.RunHead - err := s.withTx(ctx, func(tx *sql.Tx) error { - var err error - head, err = loadHead(ctx, tx, id) - return err - }) - return head, err -} - -func (s *Store) LoadLog(ctx context.Context, id run.RunID, from uint64) ([]run.TransitionRecord, error) { - if err := s.ready(ctx); err != nil { - return nil, err - } - var log []run.TransitionRecord - err := s.withTx(ctx, func(tx *sql.Tx) error { - if err := requireRun(ctx, tx, id); err != nil { - return err - } - var err error - log, err = loadLog(ctx, tx, id, from) - return err - }) - return log, err -} - -// LoadRecord reads head and log inside one transaction, so the returned log -// ends exactly at head.Revision. -func (s *Store) LoadRecord(ctx context.Context, id run.RunID) (run.RunHead, []run.TransitionRecord, error) { - if err := s.ready(ctx); err != nil { - return run.RunHead{}, nil, err - } - var head run.RunHead - var log []run.TransitionRecord - err := s.withTx(ctx, func(tx *sql.Tx) error { - var err error - if head, err = loadHead(ctx, tx, id); err != nil { - return err - } - log, err = loadLog(ctx, tx, id, 1) - return err - }) - return head, log, err -} - -func loadLog(ctx context.Context, tx *sql.Tx, id run.RunID, from uint64) ([]run.TransitionRecord, error) { - rows, err := tx.QueryContext(ctx, `SELECT record FROM transitions WHERE run_id = ? AND revision >= ? ORDER BY revision ASC`, string(id), from) - if err != nil { - return nil, fmt.Errorf("agent: sqlite store: load log: %w", err) - } - defer rows.Close() - var log []run.TransitionRecord - for rows.Next() { - var raw string - if err := rows.Scan(&raw); err != nil { - return nil, fmt.Errorf("agent: sqlite store: load log: %w", err) - } - rec, err := run.DecodeTransitionRecord([]byte(raw)) - if err != nil { - return nil, fmt.Errorf("agent: sqlite store: decode transition: %w", err) - } - log = append(log, rec) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("agent: sqlite store: load log: %w", err) - } - return log, nil -} - -type sqliteTx struct { - ctx context.Context - tx *sql.Tx - id run.RunID - head run.RunHead -} - -func (t *sqliteTx) Head() run.RunHead { return t.head } - -func (t *sqliteTx) LookupTransition(command run.CommandID) (run.TransitionRecord, bool, error) { - var raw string - err := t.tx.QueryRowContext(t.ctx, `SELECT record FROM transitions WHERE run_id = ? AND command_id = ?`, string(t.id), string(command)).Scan(&raw) - if errors.Is(err, sql.ErrNoRows) { - return run.TransitionRecord{}, false, nil - } - if err != nil { - return run.TransitionRecord{}, false, fmt.Errorf("agent: sqlite store: lookup transition: %w", err) - } - record, err := run.DecodeTransitionRecord([]byte(raw)) - if err != nil { - return run.TransitionRecord{}, false, fmt.Errorf("agent: sqlite store: decode transition: %w", err) - } - return record, true, nil -} - -// Commit is the Run's critical section: the connection pool is size one and -// the transaction is opened with an immediate write lock, so no other writer -// can observe or advance this Run until the transaction ends. -func (s *Store) Commit(ctx context.Context, id run.RunID, fn func(run.RunTx) (*run.Append, error)) error { - if err := s.ready(ctx); err != nil { - return err - } - if fn == nil { - return errors.New("agent: sqlite store: nil commit fn") - } - return s.withTx(ctx, func(tx *sql.Tx) error { - head, err := loadHead(ctx, tx, id) - if err != nil { - return err - } - a, err := fn(&sqliteTx{ctx: ctx, tx: tx, id: id, head: head}) - if err != nil || a == nil { - return err - } - return appendTransition(ctx, tx, id, &head, a) - }) -} - -func appendTransition(ctx context.Context, tx *sql.Tx, id run.RunID, head *run.RunHead, a *run.Append) error { - if err := run.ValidateAppend(id, head, a); err != nil { - return err - } - proto, err := run.ProtocolFor(head.Header.SchemaVersion) - if err != nil { - return err - } - rawSnapshot, err := proto.EncodeMachineState(&a.State) - if err != nil { - return fmt.Errorf("agent: sqlite store: encode snapshot: %w", err) - } - rawRecord, err := json.Marshal(a.Transition) - if err != nil { - return fmt.Errorf("agent: sqlite store: marshal transition: %w", err) - } - res, err := tx.ExecContext(ctx, `UPDATE runs SET revision = ?, snapshot = ? WHERE run_id = ? AND revision = ?`, - a.Transition.Revision, string(rawSnapshot), string(id), a.ExpectedRevision) - if err != nil { - return fmt.Errorf("agent: sqlite store: advance run: %w", err) - } - if n, err := res.RowsAffected(); err != nil { - return fmt.Errorf("agent: sqlite store: advance run: %w", err) - } else if n != 1 { - return run.ErrAppendConflict - } - if _, err := tx.ExecContext(ctx, `INSERT INTO transitions (run_id, revision, command_id, record) VALUES (?, ?, ?, ?)`, - string(id), a.Transition.Revision, string(a.Transition.CommandID), string(rawRecord)); err != nil { - return fmt.Errorf("%w: %w", run.ErrCommandConflict, err) - } - return applyLeaseOps(ctx, tx, id, a.Leases) -} - -func (s *Store) RenewLease(ctx context.Context, id run.RunID, key string, grant run.ExecutionGrant, deadline time.Time) error { - if err := s.ready(ctx); err != nil { - return err - } - if grant == "" { - return run.ErrStaleRuntime - } - return s.withTx(ctx, func(tx *sql.Tx) error { - if err := requireRun(ctx, tx, id); err != nil { - return err - } - res, err := tx.ExecContext(ctx, `UPDATE leases SET deadline_unix_nano = ? WHERE run_id = ? AND lease_key = ? AND grant = ?`, - deadlineValue(deadline), string(id), key, string(grant)) - if err != nil { - return fmt.Errorf("agent: sqlite store: renew lease: %w", err) - } - if n, err := res.RowsAffected(); err != nil { - return fmt.Errorf("agent: sqlite store: renew lease: %w", err) - } else if n != 1 { - return run.ErrStaleRuntime - } - return nil - }) -} - -func (s *Store) ExpiredLeases(ctx context.Context, before time.Time) ([]run.ExpiredLease, error) { - if err := s.ready(ctx); err != nil { - return nil, err - } - rows, err := s.db.QueryContext(ctx, `SELECT run_id, lease_key, claim, grant, start_command_id, deadline_unix_nano - FROM leases WHERE deadline_unix_nano IS NOT NULL AND deadline_unix_nano <= ? ORDER BY run_id, lease_key`, before.UnixNano()) - if err != nil { - return nil, fmt.Errorf("agent: sqlite store: expired leases: %w", err) - } - defer rows.Close() - var out []run.ExpiredLease - for rows.Next() { - var runID, key string - lease, err := scanLease(rows, &runID, &key) - if err != nil { - return nil, err - } - out = append(out, run.ExpiredLease{RunID: run.RunID(runID), Key: key, Lease: lease}) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("agent: sqlite store: expired leases: %w", err) - } - return out, nil -} - -func (s *Store) ReplaceSnapshot(ctx context.Context, id run.RunID, revision uint64, state *run.MachineState) error { - if err := s.ready(ctx); err != nil { - return err - } - if state == nil || state.RunID != id { - return errors.New("agent: sqlite store: snapshot RunID mismatch") - } - return s.withTx(ctx, func(tx *sql.Tx) error { - schemaVersion, err := loadSchemaVersion(ctx, tx, id) - if err != nil { - return err - } - proto, err := run.ProtocolFor(schemaVersion) - if err != nil { - return err - } - raw, err := proto.EncodeMachineState(state) - if err != nil { - return fmt.Errorf("agent: sqlite store: encode snapshot: %w", err) - } - res, err := tx.ExecContext(ctx, `UPDATE runs SET snapshot = ? WHERE run_id = ? AND revision = ?`, string(raw), string(id), revision) - if err != nil { - return fmt.Errorf("agent: sqlite store: replace snapshot: %w", err) - } - if n, err := res.RowsAffected(); err != nil { - return fmt.Errorf("agent: sqlite store: replace snapshot: %w", err) - } else if n != 1 { - return run.ErrAppendConflict - } - return nil - }) -} - -func (s *Store) withTx(ctx context.Context, fn func(*sql.Tx) error) error { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("agent: sqlite store: begin: %w", err) - } - defer func() { _ = tx.Rollback() }() - if err := fn(tx); err != nil { - return err - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("agent: sqlite store: commit: %w", err) - } - return nil -} - -func requireRun(ctx context.Context, tx *sql.Tx, id run.RunID) error { - _, err := loadSchemaVersion(ctx, tx, id) - return err -} - -func loadSchemaVersion(ctx context.Context, tx *sql.Tx, id run.RunID) (uint16, error) { - var schemaVersion uint16 - err := tx.QueryRowContext(ctx, `SELECT schema_version FROM runs WHERE run_id = ?`, string(id)).Scan(&schemaVersion) - if errors.Is(err, sql.ErrNoRows) { - return 0, run.ErrRunNotFound - } - if err != nil { - return 0, fmt.Errorf("agent: sqlite store: load run: %w", err) - } - return schemaVersion, nil -} - -func loadHead(ctx context.Context, tx *sql.Tx, id run.RunID) (run.RunHead, error) { - var headerJSON, snapshotJSON string - var rev int64 - err := tx.QueryRowContext(ctx, `SELECT header, revision, snapshot FROM runs WHERE run_id = ?`, string(id)). - Scan(&headerJSON, &rev, &snapshotJSON) - if errors.Is(err, sql.ErrNoRows) { - return run.RunHead{}, run.ErrRunNotFound - } - if err != nil { - return run.RunHead{}, fmt.Errorf("agent: sqlite store: load run: %w", err) - } - if rev < 0 { - return run.RunHead{}, fmt.Errorf("agent: sqlite store: negative revision %d", rev) - } - header, err := decodeHeader([]byte(headerJSON)) - if err != nil { - return run.RunHead{}, err - } - proto, err := run.ProtocolFor(header.SchemaVersion) - if err != nil { - return run.RunHead{}, err - } - state, err := proto.DecodeMachineState([]byte(snapshotJSON)) - if err != nil { - return run.RunHead{}, fmt.Errorf("agent: sqlite store: decode snapshot: %w", err) - } - leases, err := loadLeases(ctx, tx, id) - if err != nil { - return run.RunHead{}, err - } - return run.RunHead{Header: header, State: state, Revision: uint64(rev), Leases: leases}, nil -} - -func loadHeader(ctx context.Context, tx *sql.Tx, id run.RunID) (run.RunHeader, error) { - var headerJSON string - err := tx.QueryRowContext(ctx, `SELECT header FROM runs WHERE run_id = ?`, string(id)).Scan(&headerJSON) - if errors.Is(err, sql.ErrNoRows) { - return run.RunHeader{}, run.ErrRunNotFound - } - if err != nil { - return run.RunHeader{}, fmt.Errorf("agent: sqlite store: load header: %w", err) - } - return decodeHeader([]byte(headerJSON)) -} - -type leaseScanner interface { - Scan(dest ...any) error -} - -func scanLease(row leaseScanner, runID, key *string) (run.ExecutionLease, error) { - var claim, grant, startID string - var deadline sql.NullInt64 - if err := row.Scan(runID, key, &claim, &grant, &startID, &deadline); err != nil { - return run.ExecutionLease{}, fmt.Errorf("agent: sqlite store: scan lease: %w", err) - } - lease := run.ExecutionLease{ - Claim: run.ExecutionClaim(claim), - Grant: run.ExecutionGrant(grant), - StartCommandID: run.CommandID(startID), - } - if deadline.Valid { - lease.Deadline = time.Unix(0, deadline.Int64) - } - return lease, nil -} - -func loadLeases(ctx context.Context, tx *sql.Tx, id run.RunID) (map[string]run.ExecutionLease, error) { - rows, err := tx.QueryContext(ctx, `SELECT run_id, lease_key, claim, grant, start_command_id, deadline_unix_nano FROM leases WHERE run_id = ?`, string(id)) - if err != nil { - return nil, fmt.Errorf("agent: sqlite store: load leases: %w", err) - } - defer rows.Close() - leases := make(map[string]run.ExecutionLease) - for rows.Next() { - var runID, key string - lease, err := scanLease(rows, &runID, &key) - if err != nil { - return nil, err - } - leases[key] = lease - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("agent: sqlite store: load leases: %w", err) - } - return leases, nil -} - -func deadlineValue(deadline time.Time) any { - if deadline.IsZero() { - return nil - } - return deadline.UnixNano() -} - -func applyLeaseOps(ctx context.Context, tx *sql.Tx, id run.RunID, ops run.LeaseOps) error { - if ops.Clear { - if _, err := tx.ExecContext(ctx, `DELETE FROM leases WHERE run_id = ?`, string(id)); err != nil { - return fmt.Errorf("agent: sqlite store: clear leases: %w", err) - } - } - for _, key := range ops.Delete { - if _, err := tx.ExecContext(ctx, `DELETE FROM leases WHERE run_id = ? AND lease_key = ?`, string(id), key); err != nil { - return fmt.Errorf("agent: sqlite store: delete lease: %w", err) - } - } - for key, lease := range ops.Put { - if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO leases (run_id, lease_key, claim, grant, start_command_id, deadline_unix_nano) VALUES (?, ?, ?, ?, ?, ?)`, - string(id), key, string(lease.Claim), string(lease.Grant), string(lease.StartCommandID), deadlineValue(lease.Deadline)); err != nil { - return fmt.Errorf("agent: sqlite store: put lease: %w", err) - } - } - return nil -} - -func decodeHeader(raw []byte) (run.RunHeader, error) { - var header run.RunHeader - if err := json.Unmarshal(raw, &header); err != nil { - return run.RunHeader{}, fmt.Errorf("agent: sqlite store: decode header: %w", err) - } - // MachineState.Current is omitted from the header's JSON encoding. A valid - // header is always Open (RUN-NEW-1); ValidateRunHeader checks the digest. - header.InitialState.Current = run.Open{} - if err := run.ValidateRunHeader(&header); err != nil { - return run.RunHeader{}, fmt.Errorf("agent: sqlite store: header: %w", err) - } - return header, nil -} - -func ctxErr(ctx context.Context) error { - if ctx == nil { - return errors.New("agent: sqlite store: nil context") - } - return ctx.Err() -} diff --git a/agent/run/sqlitestore/store_test.go b/agent/run/sqlitestore/store_test.go deleted file mode 100644 index 801e0ad..0000000 --- a/agent/run/sqlitestore/store_test.go +++ /dev/null @@ -1,387 +0,0 @@ -package sqlitestore_test - -import ( - "context" - "path/filepath" - "strconv" - "sync/atomic" - "testing" - "time" - - "github.com/memohai/twilight/agent/run" - "github.com/memohai/twilight/agent/run/runtimetest" - "github.com/memohai/twilight/agent/run/sqlitestore" - "github.com/memohai/twilight/sdk" -) - -func TestOpenRejectsEmptyPath(t *testing.T) { - if _, err := sqlitestore.Open(""); err == nil { - t.Fatal("empty path accepted") - } -} - -func sqliteFactory(t *testing.T) runtimetest.Factory { - t.Helper() - var n atomic.Int64 - dir := t.TempDir() - return func() run.Runtime { - path := filepath.Join(dir, "runs-"+strconv.FormatInt(n.Add(1), 10)+".db") - store, err := sqlitestore.Open(path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = store.Close() }) - return run.NewRuntime(store) - } -} - -func sqliteRecoveryFactory(t *testing.T) runtimetest.RecoveryFactory { - t.Helper() - var n atomic.Int64 - dir := t.TempDir() - return func(now func() time.Time, ttl time.Duration) run.Runtime { - path := filepath.Join(dir, "recover-"+strconv.FormatInt(n.Add(1), 10)+".db") - store, err := sqlitestore.Open(path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = store.Close() }) - return run.NewRuntimeWithOptions(store, run.RuntimeOptions{LeaseTTL: ttl, Now: now}) - } -} - -func TestSQLiteRuntimeConformance(t *testing.T) { - runtimetest.RunConformance(t, sqliteFactory(t)) -} - -func TestSQLiteRuntimeRecovery(t *testing.T) { - runtimetest.RunRecoveryConformance(t, sqliteRecoveryFactory(t)) -} - -func TestSQLiteRuntimeLeaseRenewal(t *testing.T) { - runtimetest.RunLeaseRenewalConformance(t, sqliteRecoveryFactory(t)) -} - -// The stored snapshot is authoritative for Load: a reopened database serves -// Load without touching the transition log, and Record still verifies the -// snapshot against the full fold. -func TestSQLiteReopenLoadsSnapshotAndRecordVerifies(t *testing.T) { - path := filepath.Join(t.TempDir(), "snapshot.db") - store, err := sqlitestore.Open(path) - if err != nil { - t.Fatal(err) - } - rt := run.NewRuntime(store) - ctx := context.Background() - newRun, err := run.BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(ctx, newRun); err != nil { - t.Fatal(err) - } - startExecutingTool(t, rt, "run-1") - before, err := rt.Load(ctx, "run-1") - if err != nil { - t.Fatal(err) - } - if err := store.Close(); err != nil { - t.Fatal(err) - } - reopened, err := sqlitestore.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { _ = reopened.Close() }() - rt2 := run.NewRuntime(reopened) - after, err := rt2.Load(ctx, "run-1") - if err != nil { - t.Fatal(err) - } - a, _ := run.ProtocolV1().EncodeMachineState(&before.State) - b, _ := run.ProtocolV1().EncodeMachineState(&after.State) - if string(a) != string(b) || before.Revision != after.Revision { - t.Fatalf("reopened snapshot differs:\n before %s\n after %s", a, b) - } - record, err := rt2.Record(ctx, "run-1") - if err != nil { - t.Fatal(err) - } - if record.Snapshot.Revision != after.Revision || len(record.Transitions) != int(after.Revision) { - t.Fatalf("record = revision %d transitions %d", record.Snapshot.Revision, len(record.Transitions)) - } - if diverged, err := run.Rebuild(ctx, reopened, "run-1"); err != nil || diverged { - t.Fatalf("rebuild diverged=%v err=%v", diverged, err) - } -} - -func TestSQLiteCrashReopenRecoversExpiredModel(t *testing.T) { - path := filepath.Join(t.TempDir(), "crash.db") - clock := time.Unix(1000, 0) - store, err := sqlitestore.Open(path) - if err != nil { - t.Fatal(err) - } - rt := run.NewRuntimeWithOptions(store, run.RuntimeOptions{ - LeaseTTL: time.Second, - Now: func() time.Time { return clock }, - }) - ctx := context.Background() - newRun, err := run.BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(ctx, newRun); err != nil { - t.Fatal(err) - } - stepID := startExecutingModel(t, rt, "run-1") - if err := store.Close(); err != nil { - t.Fatal(err) - } - - reopened, err := sqlitestore.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { _ = reopened.Close() }() - rt2 := run.NewRuntimeWithOptions(reopened, run.RuntimeOptions{ - LeaseTTL: time.Second, - Now: func() time.Time { return clock }, - }) - snap, err := rt2.Load(ctx, "run-1") - if err != nil { - t.Fatal(err) - } - ms, ok := snap.State.Current.(run.ModelStep) - if !ok || ms.Status != run.ModelExecuting || ms.RefValue.ID != stepID { - t.Fatalf("after reopen: %+v", snap.State.Current) - } - - clock = time.Unix(1002, 0) - n, err := rt2.RecoverExpired(ctx) - if err != nil { - t.Fatal(err) - } - if n != 1 { - t.Fatalf("recovered = %d, want 1", n) - } - snap, err = rt2.Load(ctx, "run-1") - if err != nil { - t.Fatal(err) - } - if snap.State.RunID != "run-1" || snap.State.Status != run.RunActive { - t.Fatalf("after recover: %+v", snap.State) - } - ms, ok = snap.State.Current.(run.ModelStep) - if !ok || ms.Status != run.ModelPrepared { - t.Fatalf("after recover current: %+v", snap.State.Current) - } -} - -func TestSQLiteCrashReopenSettlesExpiredToolUnknown(t *testing.T) { - path := filepath.Join(t.TempDir(), "crash-tool.db") - clock := time.Unix(1000, 0) - store, err := sqlitestore.Open(path) - if err != nil { - t.Fatal(err) - } - rt := run.NewRuntimeWithOptions(store, run.RuntimeOptions{ - LeaseTTL: time.Second, - Now: func() time.Time { return clock }, - }) - ctx := context.Background() - newRun, err := run.BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(ctx, newRun); err != nil { - t.Fatal(err) - } - startExecutingTool(t, rt, "run-1") - if err := store.Close(); err != nil { - t.Fatal(err) - } - - clock = time.Unix(1002, 0) - reopened, err := sqlitestore.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { _ = reopened.Close() }() - rt2 := run.NewRuntimeWithOptions(reopened, run.RuntimeOptions{ - LeaseTTL: time.Second, - Now: func() time.Time { return clock }, - }) - snap, err := rt2.Load(ctx, "run-1") - if err != nil { - t.Fatal(err) - } - if snap.State.Status != run.RunActive { - t.Fatalf("status after reopen = %v", snap.State.Status) - } - if _, ok := snap.State.Current.(run.ToolStep); !ok { - t.Fatalf("after reopen current = %+v, want ToolStep", snap.State.Current) - } - n, err := rt2.RecoverExpired(ctx) - if err != nil { - t.Fatal(err) - } - if n != 1 { - t.Fatalf("recovered = %d, want 1", n) - } - snap, err = rt2.Load(ctx, "run-1") - if err != nil { - t.Fatal(err) - } - if snap.State.RunID != "run-1" || snap.State.Status != run.RunActive { - t.Fatalf("after recover: %+v", snap.State) - } - if _, ok := snap.State.Current.(run.Open); !ok { - t.Fatalf("after recover current = %+v, want Open", snap.State.Current) - } - if snap.State.LastToolStep == nil || len(snap.State.LastToolStep.Calls) != 1 { - t.Fatalf("LastToolStep = %+v", snap.State.LastToolStep) - } - call := snap.State.LastToolStep.Calls[0] - if call.Status != run.ToolFailed || call.Failure == nil || call.Failure.Outcome != run.ToolOutcomeUnknown { - t.Fatalf("unknown call = %+v", call) - } -} - -func startExecutingModel(t *testing.T, rt run.Runtime, runID run.RunID) run.StepID { - t.Helper() - ctx := context.Background() - snap, err := rt.Load(ctx, runID) - if err != nil { - t.Fatal(err) - } - req := sdk.Request{Model: "m-1", Messages: []sdk.Message{sdk.UserMessage("hi")}} - prep, cmdID := prepareFromSnap(t, snap, req, nil) - if _, err := commit(t, rt, runID, cmdID, snap.Revision, "", prep); err != nil { - t.Fatal(err) - } - start, err := commit(t, rt, runID, "start-1", 1, "", run.StartModelExecution{StepID: prep.StepID, Claim: "claim-crash"}) - if err != nil { - t.Fatal(err) - } - if start.Grant == "" { - t.Fatal("start returned no grant") - } - return prep.StepID -} - -func startExecutingTool(t *testing.T, rt run.Runtime, runID run.RunID) { - t.Helper() - def := sdk.ToolDefinition{Name: "t", Parameters: []byte(`{"type":"object"}`)} - spec := freezeSpec(t, def) - ctx := context.Background() - snap, err := rt.Load(ctx, runID) - if err != nil { - t.Fatal(err) - } - req := sdk.Request{Model: "m-1", Messages: []sdk.Message{sdk.UserMessage("hi")}, Tools: []sdk.ToolDefinition{def}} - prep, cmdID := prepareFromSnap(t, snap, req, []run.ToolSpec{spec}) - if _, err := commit(t, rt, runID, cmdID, snap.Revision, "", prep); err != nil { - t.Fatal(err) - } - start, err := commit(t, rt, runID, "start-1", 1, "", run.StartModelExecution{StepID: prep.StepID, Claim: "claim-crash"}) - if err != nil { - t.Fatal(err) - } - args := run.MustParseCanonicalJSON(`{}`) - callID := run.DeriveCallID(prep.StepID, 0) - bd, err := run.DigestToolCallBinding(callID, spec.DefinitionDigest, spec.Policy, args) - if err != nil { - t.Fatal(err) - } - binding := run.ToolCallBinding{ - CallID: callID, ProviderCallID: "c1", ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, - BindingDigest: bd, Arguments: args, Policy: spec.Policy, - } - result := sdk.ModelResult{ - FinishReason: sdk.FinishReasonToolCalls, - Usage: sdk.Usage{InputTokens: 10, OutputTokens: 5, TotalTokens: 15}, - ToolCalls: []sdk.ToolCall{{ToolCallID: "c1", ToolName: "t", Input: `{}`}}, - } - frozen, err := run.FreezeModelResult(result) - if err != nil { - t.Fatal(err) - } - opened, err := commit(t, rt, runID, "complete-1", 2, start.Grant, run.SubmitModelResult{ - StepID: prep.StepID, Result: frozen, Calls: []run.ToolCallBinding{binding}, - }) - if err != nil { - t.Fatal(err) - } - if len(opened.Events) < 2 { - t.Fatalf("events = %d, want ToolStepOpened", len(opened.Events)) - } - openedFact, ok := opened.Events[1].Fact.(run.ToolStepOpened) - if !ok { - t.Fatalf("event[1] = %T, want ToolStepOpened", opened.Events[1].Fact) - } - if _, err := commit(t, rt, runID, "start-c1", opened.Snapshot.Revision, "", run.StartToolCall{ - StepID: openedFact.StepID, CallID: callID, Claim: "claim-tool", - }); err != nil { - t.Fatal(err) - } -} - -func freezeSpec(t *testing.T, def sdk.ToolDefinition) run.ToolSpec { - t.Helper() - frozen, err := run.FreezeToolDefinition(def) - if err != nil { - t.Fatal(err) - } - d, err := run.ProtocolV1().DigestToolDefinition(frozen) - if err != nil { - t.Fatal(err) - } - return run.ToolSpec{Ref: run.ToolRef(def.Name), Name: def.Name, DefinitionDigest: d, Policy: run.DirectExecution} -} - -func prepareFromSnap(t *testing.T, snap run.RuntimeSnapshot, req sdk.Request, specs []run.ToolSpec) (run.PrepareModelRequest, run.CommandID) { - t.Helper() - frozenReq, err := run.FreezeModelRequest(req) - if err != nil { - t.Fatal(err) - } - reqDigest, err := run.ProtocolV1().DigestRequest(frozenReq) - if err != nil { - t.Fatal(err) - } - toolsDigest, err := run.ProtocolV1().DigestToolSpecs(specs) - if err != nil { - t.Fatal(err) - } - model := run.ModelRef(frozenReq.Model) - binding, err := run.ProtocolV1().DigestModelStepBinding(model, reqDigest, toolsDigest) - if err != nil { - t.Fatal(err) - } - cmdID := run.DeriveModelRequestCommandID(snap.State.RunID, snap.Revision) - stepID := run.DeriveModelStepID(snap.State.RunID, cmdID, binding) - ids := make([]run.InputID, len(snap.State.PendingInputs)) - for i, in := range snap.State.PendingInputs { - ids[i] = in.ID - } - return run.PrepareModelRequest{ - StepID: stepID, Model: model, Request: frozenReq, - RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest, - }, cmdID -} - -func commit(t *testing.T, rt run.Runtime, runID run.RunID, id run.CommandID, base uint64, grant run.ExecutionGrant, cmd run.AgentCommand) (run.CommitResult, error) { - t.Helper() - switch c := cmd.(type) { - case run.StartModelExecution: - id = run.DeriveStartCommandID(runID, c.StepID, "", c.Claim) - case run.StartToolCall: - id = run.DeriveStartCommandID(runID, c.StepID, c.CallID, c.Claim) - } - env, err := run.ProtocolV1().BuildEnvelope(runID, id, cmd) - if err != nil { - t.Fatal(err) - } - return rt.Commit(context.Background(), run.CommitRequest{BaseRevision: base, Grant: grant, Command: env}) -} diff --git a/agent/run/store.go b/agent/run/store.go deleted file mode 100644 index 3c77222..0000000 --- a/agent/run/store.go +++ /dev/null @@ -1,189 +0,0 @@ -package run - -import ( - "context" - "errors" - "fmt" - "time" -) - -// ExecutionLease is the Store occupancy record for one live start. Grant is -// the capability returned to the owner Loop. Deadline zero means the lease -// does not expire (process-lifetime occupancy). -type ExecutionLease struct { - Claim ExecutionClaim - Grant ExecutionGrant - StartCommandID CommandID - Deadline time.Time -} - -// RunHead is the current authority position of one Run: the immutable header, -// the stored MachineState snapshot at Revision, and the live leases. It never -// carries the transition log; Runtime reads the log separately through LoadLog -// only when it needs to verify or rebuild. -type RunHead struct { - Header RunHeader - State MachineState - Revision uint64 - Leases map[string]ExecutionLease -} - -// LeaseOps is the lease change applied atomically with one appended -// transition. Put upserts by lease key, Delete removes by key, Clear removes -// every lease of the Run before Put is applied. -type LeaseOps struct { - Put map[string]ExecutionLease - Delete []string - Clear bool -} - -// Append is the write Runtime hands back from a Commit critical section: the -// next transition, the snapshot after it, and the lease change. The Store -// persists all three atomically. ExpectedRevision must equal the head -// revision the section observed; a mismatch is a Runtime bug and returns -// ErrAppendConflict. -type Append struct { - ExpectedRevision uint64 - Transition TransitionRecord - State MachineState - Leases LeaseOps -} - -// ExpiredLease is one lease whose deadline has passed, addressed by Run and -// lease key so the recovery scanner can load only the affected Runs. -type ExpiredLease struct { - RunID RunID - Key string - Lease ExecutionLease -} - -// ErrAppendConflict reports that a write observed a revision other than the -// one it was built against. Inside a Commit section this cannot happen; it -// guards ReplaceSnapshot and adapter bugs. -var ErrAppendConflict = errors.New("agent: store: append revision conflict") - -// RunTx is the view a Commit critical section has of one Run. Both reads see -// the same authority version, and nothing else can advance the Run until the -// section returns. -type RunTx interface { - Head() RunHead - LookupTransition(CommandID) (TransitionRecord, bool, error) -} - -// Store persists Runs for one Runtime as an append-only transition log plus a -// derived MachineState snapshot and lease table. It does not call -// EvaluateCommit, Decide, or Evolve. The log is never rewritten: the only -// write paths are Create, the Append returned from a Commit section, lease -// maintenance, and ReplaceSnapshot for the derived snapshot. Every Run is a -// single-writer aggregate: Commit serializes all writers of one Run -// (RUN-CMT-2), so Runtime evaluates against a head that cannot move under it. -type Store interface { - // Create stores the header and Revision-0 snapshot once per RunID. A second - // Create for the same RunID returns created=false and the stored header. - Create(ctx context.Context, header RunHeader) (created bool, existing RunHeader, err error) - // LoadHead returns the header, current snapshot, revision and leases in one - // consistent read. It does not touch the log. - LoadHead(ctx context.Context, id RunID) (RunHead, error) - // LoadLog returns every TransitionRecord with Revision >= from, ordered by - // revision. from=1 returns the complete log. - LoadLog(ctx context.Context, id RunID, from uint64) ([]TransitionRecord, error) - // LoadRecord returns the head and the complete log in one consistent - // read: a concurrent Commit is either fully visible in both or - // in neither. Runtime.Record and Rebuild use it. - LoadRecord(ctx context.Context, id RunID) (RunHead, []TransitionRecord, error) - // Commit runs fn inside the Run's critical section. When fn returns a - // non-nil Append, the Store persists it in the same section; a nil Append - // with a nil error means fn decided without writing (replay or rejection - // mapped by the caller). fn's error aborts the section unchanged. - Commit(ctx context.Context, id RunID, fn func(RunTx) (*Append, error)) error - // RenewLease moves the deadline of the lease at key forward when its grant - // still equals grant; a missing or re-issued lease returns ErrStaleRuntime. - RenewLease(ctx context.Context, id RunID, key string, grant ExecutionGrant, deadline time.Time) error - // ExpiredLeases lists leases whose non-zero deadline is at or before - // before, across all Runs. - ExpiredLeases(ctx context.Context, before time.Time) ([]ExpiredLease, error) - // ReplaceSnapshot overwrites the derived snapshot at revision without - // touching the log. Rebuild is its only caller. - ReplaceSnapshot(ctx context.Context, id RunID, revision uint64, state *MachineState) error -} - -func cloneRunHead(h *RunHead) RunHead { - out := RunHead{ - Header: cloneRunHeader(h.Header), - State: cloneMachineState(&h.State), - Revision: h.Revision, - } - if h.Leases != nil { - out.Leases = make(map[string]ExecutionLease, len(h.Leases)) - for k, v := range h.Leases { - out.Leases[k] = v - } - } - return out -} - -func applyLeaseOps(leases map[string]ExecutionLease, ops LeaseOps) map[string]ExecutionLease { - out := make(map[string]ExecutionLease, len(leases)+len(ops.Put)) - if !ops.Clear { - for k, v := range leases { - out[k] = v - } - } - for _, k := range ops.Delete { - delete(out, k) - } - for k, v := range ops.Put { - out[k] = v - } - return out -} - -// ValidateAppend checks the structural preconditions every Store enforces -// before writing: the Append was built against the current head, the -// transition revision is contiguous, and RunIDs agree. Store adapters call it -// inside their Commit section. -func ValidateAppend(id RunID, head *RunHead, a *Append) error { - if head.Revision != a.ExpectedRevision { - return fmt.Errorf("%w: expected %d, at %d", ErrAppendConflict, a.ExpectedRevision, head.Revision) - } - if a.Transition.RunID != id || a.Transition.Revision != a.ExpectedRevision+1 { - return fmt.Errorf("agent: store: transition %s@%d does not follow %s@%d", a.Transition.RunID, a.Transition.Revision, id, a.ExpectedRevision) - } - if a.State.RunID != id { - return fmt.Errorf("agent: store: snapshot RunID %q does not match %q", a.State.RunID, id) - } - return nil -} - -// Rebuild is a diagnostic (RUN-CMT-2): it refolds the state from the complete -// transition log and replaces the derived snapshot with the fold result. The -// log is never modified. A log shorter than the head revision returns -// ErrLogTruncated (audit gap). It returns true when the refolded state differs -// from the stored snapshot, which identifies an Evolve bug, an out-of-band -// write, or storage corruption. -func Rebuild(ctx context.Context, store Store, runID RunID) (rebuilt bool, err error) { - if store == nil { - return false, errors.New("agent: rebuild: nil store") - } - head, log, err := store.LoadRecord(ctx, runID) - if err != nil { - return false, err - } - folded, maxRevision, err := FoldRun(&head.Header, log) - if err != nil { - return false, err - } - if maxRevision < head.Revision { - return false, fmt.Errorf("%w: log ends at %d, head revision %d", ErrLogTruncated, maxRevision, head.Revision) - } - if maxRevision > head.Revision { - return false, fmt.Errorf("agent: rebuild: log ends at %d beyond head revision %d", maxRevision, head.Revision) - } - if statesEquivalent(&head.State, &folded) { - return false, nil - } - if err := store.ReplaceSnapshot(ctx, runID, head.Revision, &folded); err != nil { - return true, err - } - return true, nil -} diff --git a/agent/run/store_test.go b/agent/run/store_test.go deleted file mode 100644 index 71080fc..0000000 --- a/agent/run/store_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package run - -import ( - "context" - "errors" - "testing" - "time" -) - -func TestExpiredLeaseAllowsGrantlessModelRecovery(t *testing.T) { - clock := time.Unix(1000, 0) - store := NewMemoryStore() - rt := NewRuntimeWithOptions(store, RuntimeOptions{ - LeaseTTL: time.Second, - Now: func() time.Time { return clock }, - }) - ctx := context.Background() - newRun, err := BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(ctx, newRun); err != nil { - t.Fatal(err) - } - in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} - env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(ctx, CommitRequest{Command: env}); err != nil { - t.Fatal(err) - } - - snap, err := rt.Load(ctx, "run-1") - if err != nil { - t.Fatal(err) - } - prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) - if _, err := commitCmd(t, rt, cmdID, snap.Revision, "", prep); err != nil { - t.Fatal(err) - } - claim := ExecutionClaim("claim-recover") - startEnv := startEnvelope(t, "run-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) - start, err := rt.Commit(ctx, CommitRequest{Command: startEnv}) - if err != nil { - t.Fatal(err) - } - if start.Grant == "" { - t.Fatal("start returned no grant") - } - - clock = time.Unix(1002, 0) - recoverEnv, err := ProtocolV1().BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, claim), RecoverModelExecution{StepID: prep.StepID, Claim: claim}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(ctx, CommitRequest{Command: recoverEnv}); err != nil { - t.Fatal(err) - } - snap, err = rt.Load(ctx, "run-1") - if err != nil { - t.Fatal(err) - } - ms, ok := snap.State.Current.(ModelStep) - if !ok || ms.Status != ModelPrepared { - t.Fatalf("after grantless recover: %+v", snap.State.Current) - } -} - -func TestZeroDeadlineRejectsGrantlessRecovery(t *testing.T) { - rt := NewRuntime(NewMemoryStore()) - ctx := context.Background() - newRun, err := BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(ctx, newRun); err != nil { - t.Fatal(err) - } - in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} - env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(ctx, CommitRequest{Command: env}); err != nil { - t.Fatal(err) - } - snap, _ := rt.Load(ctx, "run-1") - prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) - if _, err := commitCmd(t, rt, cmdID, snap.Revision, "", prep); err != nil { - t.Fatal(err) - } - claim := ExecutionClaim("claim-1") - startEnv := startEnvelope(t, "run-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) - if _, err := rt.Commit(ctx, CommitRequest{Command: startEnv}); err != nil { - t.Fatal(err) - } - recoverEnv, _ := ProtocolV1().BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, claim), RecoverModelExecution{StepID: prep.StepID, Claim: claim}) - if _, err := rt.Commit(ctx, CommitRequest{Command: recoverEnv}); err == nil { - t.Fatal("grantless recover accepted on non-expiring lease") - } -} - -func TestGrantlessModelRecoveryRejectsWrongClaim(t *testing.T) { - clock := time.Unix(1000, 0) - rt := NewRuntimeWithOptions(NewMemoryStore(), RuntimeOptions{ - LeaseTTL: time.Second, - Now: func() time.Time { return clock }, - }) - ctx := context.Background() - newRun, err := BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(ctx, newRun); err != nil { - t.Fatal(err) - } - in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} - env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(ctx, CommitRequest{Command: env}); err != nil { - t.Fatal(err) - } - snap, _ := rt.Load(ctx, "run-1") - prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) - if _, err := commitCmd(t, rt, cmdID, snap.Revision, "", prep); err != nil { - t.Fatal(err) - } - claim := ExecutionClaim("claim-recover") - startEnv := startEnvelope(t, "run-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) - if _, err := rt.Commit(ctx, CommitRequest{Command: startEnv}); err != nil { - t.Fatal(err) - } - clock = time.Unix(1002, 0) - wrong := ExecutionClaim("other-claim") - recoverEnv, err := ProtocolV1().BuildEnvelope("run-1", DeriveModelRecoveryCommandID("run-1", prep.StepID, wrong), RecoverModelExecution{StepID: prep.StepID, Claim: wrong}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(ctx, CommitRequest{Command: recoverEnv}); !errors.Is(err, ErrStaleRuntime) { - t.Fatalf("wrong claim err = %v, want ErrStaleRuntime", err) - } -} - -func TestRecoverExpiredRecoversExecutingModel(t *testing.T) { - clock := time.Unix(1000, 0) - rt := NewRuntimeWithOptions(NewMemoryStore(), RuntimeOptions{ - LeaseTTL: time.Second, - Now: func() time.Time { return clock }, - }) - ctx := context.Background() - newRun, err := BuildNewRun("run-1", "") - if err != nil { - t.Fatal(err) - } - if _, err := rt.Create(ctx, newRun); err != nil { - t.Fatal(err) - } - in := AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)} - env, err := ProtocolV1().BuildEnvelope("run-1", DeriveInputCommandID("run-1", in.ID), AcceptInput{Input: in}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(ctx, CommitRequest{Command: env}); err != nil { - t.Fatal(err) - } - snap, _ := rt.Load(ctx, "run-1") - prep, cmdID := buildPrepareFromSnap(t, snap, testRequest(), nil) - if _, err := commitCmd(t, rt, cmdID, snap.Revision, "", prep); err != nil { - t.Fatal(err) - } - claim := ExecutionClaim("claim-scan") - startEnv := startEnvelope(t, "run-1", StartModelExecution{StepID: prep.StepID, Claim: claim}) - if _, err := rt.Commit(ctx, CommitRequest{Command: startEnv}); err != nil { - t.Fatal(err) - } - clock = time.Unix(1002, 0) - n, err := rt.RecoverExpired(ctx) - if err != nil { - t.Fatal(err) - } - if n != 1 { - t.Fatalf("recovered = %d, want 1", n) - } - snap, err = rt.Load(ctx, "run-1") - if err != nil { - t.Fatal(err) - } - ms, ok := snap.State.Current.(ModelStep) - if !ok || ms.Status != ModelPrepared { - t.Fatalf("after RecoverExpired: %+v", snap.State.Current) - } -} diff --git a/agent/run/stored_runtime.go b/agent/run/stored_runtime.go deleted file mode 100644 index ce1c589..0000000 --- a/agent/run/stored_runtime.go +++ /dev/null @@ -1,449 +0,0 @@ -package run - -import ( - "context" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "strings" - "time" -) - -// runtime is the single Runtime implementation over a Store. Loop and Turn -// depend only on the Runtime interface. -type runtime struct { - store Store - frozen FrozenValueStore - leaseTTL time.Duration - now func() time.Time -} - -// RuntimeOptions configures lease occupancy for a Store-backed Runtime. -// LeaseTTL 0 means leases do not expire (grant-holder recovery only). A -// positive LeaseTTL must exceed the longest gap between a worker's start and -// its next RenewLease or settlement; see RUN-CMT-8. FrozenValues holds the -// request bodies Prepared steps name by digest; nil selects an in-process -// store that does not survive the Runtime. -type RuntimeOptions struct { - LeaseTTL time.Duration - Now func() time.Time - FrozenValues FrozenValueStore -} - -// NewRuntime constructs a Runtime over store. SQLite and Postgres adapters -// pass their Store; tests use MemoryStore. -func NewRuntime(store Store) Runtime { - return NewRuntimeWithOptions(store, RuntimeOptions{}) -} - -func NewRuntimeWithOptions(store Store, opts RuntimeOptions) Runtime { - return newRuntime(store, opts) -} - -func newRuntime(store Store, opts RuntimeOptions) *runtime { - now := opts.Now - if now == nil { - now = time.Now - } - frozen := opts.FrozenValues - if frozen == nil { - frozen = NewMemoryFrozenValues() - } - return &runtime{store: store, frozen: frozen, leaseTTL: opts.LeaseTTL, now: now} -} - -func (r *runtime) Create(ctx context.Context, run NewRun) (CreateResult, error) { - if err := checkContext(ctx); err != nil { - return CreateResult{}, err - } - if r == nil || r.store == nil { - return CreateResult{}, errors.New("agent: runtime: nil store") - } - header, err := BuildRunHeaderFromNewRun(run) - if err != nil { - return CreateResult{}, err - } - created, existing, err := r.store.Create(ctx, header) - if err != nil { - return CreateResult{}, err - } - if !created { - equal, err := canonicalHeadersEqual(existing, header) - if err != nil { - return CreateResult{}, err - } - if !equal { - return CreateResult{}, ErrCreateConflict - } - return CreateResult{Header: existing, Created: false}, nil - } - return CreateResult{Header: cloneRunHeader(header), Created: true}, nil -} - -func (r *runtime) loadHead(ctx context.Context, runID RunID) (RunHead, error) { - head, err := r.store.LoadHead(ctx, runID) - if err != nil { - return RunHead{}, err - } - if err := r.validateHead(runID, &head); err != nil { - return RunHead{}, err - } - return head, nil -} - -func (r *runtime) validateHead(runID RunID, head *RunHead) error { - if head.Header.RunID != runID || head.State.RunID != runID { - return fmt.Errorf("agent: runtime: stored RunID %q/%q does not match %q", head.Header.RunID, head.State.RunID, runID) - } - if err := ValidateRunHeader(&head.Header); err != nil { - return fmt.Errorf("agent: runtime: invalid header: %w", err) - } - if err := ValidateMachineState(&head.State); err != nil { - return fmt.Errorf("agent: runtime: invalid snapshot: %w", err) - } - return nil -} - -func snapshotOf(head *RunHead) RuntimeSnapshot { - return RuntimeSnapshot{State: cloneMachineState(&head.State), Revision: head.Revision, SchemaVersion: head.Header.SchemaVersion} -} - -func (r *runtime) Load(ctx context.Context, runID RunID) (RuntimeSnapshot, error) { - if err := checkContext(ctx); err != nil { - return RuntimeSnapshot{}, err - } - head, err := r.loadHead(ctx, runID) - if err != nil { - return RuntimeSnapshot{}, err - } - return snapshotOf(&head), nil -} - -//nolint:gocritic // hugeParam: Runtime.Commit is the public value-based contract. -func (r *runtime) Commit(ctx context.Context, req CommitRequest) (CommitResult, error) { - if err := checkContext(ctx); err != nil { - return CommitResult{}, err - } - runID := req.Command.RunID - // A Prepare carries the request body; the fact will name it by digest, so - // the body must be readable before the fact is visible. Put is idempotent - // and content-addressed: a rejected Prepare leaves a harmless orphan. - if prep, ok := req.Command.Command.(PrepareModelRequest); ok { - body, err := encodeFrozenRequest(&prep.Request, prep.RequestDigest) - if err != nil { - return CommitResult{}, fmt.Errorf("%w: %w", ErrStaleRuntime, err) - } - if err := r.frozen.Put(ctx, prep.RequestDigest, body); err != nil { - return CommitResult{}, err - } - } - var result CommitResult - // The whole evaluation runs inside the Store's per-Run critical section - // (RUN-CMT-2): the head cannot move between read and write, so there is - // no compare-and-swap retry and concurrent writers of one Run serialize. - err := r.store.Commit(ctx, runID, func(tx RunTx) (*Append, error) { - head := tx.Head() - if err := r.validateHead(runID, &head); err != nil { - return nil, err - } - var err error - var appendReq *Append - result, appendReq, err = r.evaluate(tx, &head, &req) - return appendReq, err - }) - if err != nil { - return CommitResult{}, err - } - return result, nil -} - -// evaluate runs EvaluateCommit against head and returns either a final -// result (append == nil) or the Append the caller must persist. -func (r *runtime) evaluate(tx RunTx, head *RunHead, req *CommitRequest) (CommitResult, *Append, error) { - key := grantKey(req.Command.Command) - lease, hasLease := head.Leases[key] - grantValid := hasLease && req.Grant != "" && lease.Grant == req.Grant - if cmd, ok := req.Command.Command.(RecoverModelExecution); ok && req.Grant != "" { - grantValid = grantValid && lease.Claim == cmd.Claim - } - recoveryValid := hasLease && req.Grant == "" && r.leaseExpired(lease) - if cmd, ok := req.Command.Command.(RecoverModelExecution); ok && req.Grant == "" { - recoveryValid = recoveryValid && lease.Claim == cmd.Claim - } - - var prior *TransitionRecord - if record, found, err := tx.LookupTransition(req.Command.ID); err != nil { - return CommitResult{}, nil, err - } else if found { - prior = &record - } - proto, err := ProtocolFor(head.Header.SchemaVersion) - if err != nil { - return CommitResult{}, nil, err - } - decision, err := EvaluateCommit(head.State, head.Revision, prior, *req, grantValid, recoveryValid, proto) - if err != nil { - return CommitResult{}, nil, err - } - switch decision.Kind { - case DecisionAlreadyApplied: - var grant ExecutionGrant - switch req.Command.Command.(type) { - case StartModelExecution, StartToolCall: - // The start's grant is live only while the lease it minted is - // still the lease on record for this target. - if hasLease && lease.StartCommandID == req.Command.ID { - grant = lease.Grant - } - } - return CommitResult{Status: CommitAlreadyApplied, Snapshot: snapshotOf(head), - Events: cloneEvents(decision.Events), Grant: grant}, nil, nil - case DecisionConflict: - return CommitResult{}, nil, ErrCommandConflict - case DecisionStale: - if decision.Reject != nil && !errors.Is(decision.Reject, ErrStaleRuntime) { - return CommitResult{}, nil, fmt.Errorf("%w: %w", ErrStaleRuntime, decision.Reject) - } - return CommitResult{}, nil, ErrStaleRuntime - case DecisionTerminal: - return CommitResult{}, nil, ErrRunTerminal - } - - appendReq := Append{ - ExpectedRevision: head.Revision, - Transition: cloneTransitionRecord(&decision.Transition), - State: cloneMachineState(&decision.NewState), - } - var minted ExecutionGrant - switch cmd := req.Command.Command.(type) { - case StartModelExecution, StartToolCall: - minted = newGrant() - newLease := ExecutionLease{Grant: minted, StartCommandID: req.Command.ID, Deadline: r.leaseDeadline()} - switch c := any(cmd).(type) { - case StartModelExecution: - newLease.Claim = c.Claim - case StartToolCall: - newLease.Claim = c.Claim - } - appendReq.Leases.Put = map[string]ExecutionLease{key: newLease} - case SubmitModelResult, SubmitModelFailure, RejectModelResult, RecoverModelExecution, - SubmitToolResult, SubmitToolFailure: - appendReq.Leases.Delete = []string{key} - } - if decision.NewState.Status.Terminal() { - appendReq.Leases = LeaseOps{Clear: true} - } - // Recovery keeps the frozen request: a Recovered step resends the same - // body. Any other exit from a ModelStep (completion, rejection that fails - // the Run, withdrawal) ends the body's useful life. - if step, ok := req.Command.Command.(WithdrawPreparedStep); ok { - if ms, isModel := head.State.Current.(ModelStep); isModel && ms.RefValue.ID == step.StepID { - if dropper, can := r.frozen.(interface{ Delete(Digest) }); can { - dropper.Delete(ms.RequestDigest) - } - } - } - after := RunHead{Header: head.Header, State: decision.NewState, Revision: head.Revision + 1} - return CommitResult{Status: CommitAccepted, Snapshot: snapshotOf(&after), - Events: cloneEvents(appendReq.Transition.Events), Grant: minted}, &appendReq, nil -} - -func (r *runtime) Record(ctx context.Context, runID RunID) (RunRecord, error) { - if err := checkContext(ctx); err != nil { - return RunRecord{}, err - } - head, transitions, err := r.store.LoadRecord(ctx, runID) - if err != nil { - return RunRecord{}, err - } - if err := r.validateHead(runID, &head); err != nil { - return RunRecord{}, err - } - if uint64(len(transitions)) != head.Revision { - return RunRecord{}, fmt.Errorf("%w: log has %d transitions, head revision %d", ErrLogTruncated, len(transitions), head.Revision) - } - for i := range transitions { - if err := ValidateTransitionRecord(&transitions[i]); err != nil { - return RunRecord{}, fmt.Errorf("agent: runtime: invalid transition %d: %w", i, err) - } - } - folded, revision, err := FoldRun(&head.Header, transitions) - if err != nil { - return RunRecord{}, fmt.Errorf("agent: runtime: fold: %w", err) - } - if revision != head.Revision || !statesEquivalent(&folded, &head.State) { - return RunRecord{}, errors.New("agent: runtime: snapshot diverges from transition log") - } - return RunRecord{Header: cloneRunHeader(head.Header), Snapshot: snapshotOf(&head), Transitions: transitions}, nil -} - -// FrozenRequest returns the request body named by digest, verifying it still -// digests to that name. -func (r *runtime) FrozenRequest(ctx context.Context, digest Digest) (ModelRequest, error) { - if err := checkContext(ctx); err != nil { - return ModelRequest{}, err - } - if digest == "" { - return ModelRequest{}, errors.New("agent: runtime: empty request digest") - } - raw, ok, err := r.frozen.Get(ctx, digest) - if err != nil { - return ModelRequest{}, err - } - if !ok { - return ModelRequest{}, fmt.Errorf("%w: request %s", ErrFrozenValueMissing, digest) - } - return decodeFrozenRequest(raw, digest) -} - -// RenewLease extends the live lease behind grant on the Executing target of -// stepID/callID by one LeaseTTL from now. Workers call it periodically while -// an effect runs so a long tool call is not recovered as Unknown under it. -// With LeaseTTL zero it is a no-op after validating the grant. -func (r *runtime) RenewLease(ctx context.Context, runID RunID, stepID StepID, callID CallID, grant ExecutionGrant) error { - if err := checkContext(ctx); err != nil { - return err - } - if grant == "" { - return ErrStaleRuntime - } - key := leaseKey(stepID, callID) - if key == "" { - return errors.New("agent: runtime: renew requires a step") - } - return r.store.RenewLease(ctx, runID, key, grant, r.leaseDeadline()) -} - -// RecoverExpired commits grantless recovery for every expired lease that -// still occupies an Executing target. An expired tool call is settled as -// Unknown; an expired model step is recovered to Prepared. Hosts call this -// on a timer; Loop does not. The dying process writes nothing. -func (r *runtime) RecoverExpired(ctx context.Context) (int, error) { - if r.leaseTTL <= 0 { - return 0, nil - } - expired, err := r.store.ExpiredLeases(ctx, r.now()) - if err != nil { - return 0, err - } - n := 0 - for _, e := range expired { - head, err := r.loadHead(ctx, e.RunID) - if err != nil { - if errors.Is(err, ErrRunNotFound) { - continue - } - return n, err - } - if !r.leaseExpired(e.Lease) { - continue - } - proto, err := ProtocolFor(head.Header.SchemaVersion) - if err != nil { - return n, err - } - cmd, cmdID, ok := recoveryCommand(&head.State, e.Key, e.Lease) - if !ok { - continue - } - env, err := proto.BuildEnvelope(e.RunID, cmdID, cmd) - if err != nil { - return n, err - } - res, err := r.Commit(ctx, CommitRequest{Command: env}) - if err != nil { - if errors.Is(err, ErrStaleRuntime) || errors.Is(err, ErrRunTerminal) || errors.Is(err, ErrCommandConflict) { - continue - } - return n, err - } - if res.Status == CommitAccepted { - n++ - } - } - return n, nil -} - -func recoveryCommand(state *MachineState, key string, lease ExecutionLease) (AgentCommand, CommandID, bool) { - switch cur := state.Current.(type) { - case ModelStep: - if cur.Status != ModelExecuting || key != leaseKey(cur.RefValue.ID, "") { - return nil, "", false - } - return RecoverModelExecution{StepID: cur.RefValue.ID, Claim: lease.Claim}, - DeriveModelRecoveryCommandID(state.RunID, cur.RefValue.ID, lease.Claim), true - case ToolStep: - prefix := "call/" + string(cur.RefValue.ID) + "/" - if !strings.HasPrefix(key, prefix) { - return nil, "", false - } - callID := CallID(strings.TrimPrefix(key, prefix)) - return SubmitToolFailure{ - StepID: cur.RefValue.ID, - CallID: callID, - Failure: ToolFailure{Class: FailureEffectUnknown, Message: "lease expired"}, - Outcome: ToolOutcomeUnknown, - }, DeriveToolRecoveryCommandID(state.RunID, cur.RefValue.ID, callID, lease.Claim), true - default: - return nil, "", false - } -} - -func (r *runtime) leaseDeadline() time.Time { - if r.leaseTTL <= 0 { - return time.Time{} - } - return r.now().Add(r.leaseTTL) -} - -func (r *runtime) leaseExpired(lease ExecutionLease) bool { - if lease.Deadline.IsZero() { - return false - } - return !r.now().Before(lease.Deadline) -} - -// leaseKey addresses the execution target of a start: a ModelStep by step, -// a tool call by step and call. -func leaseKey(stepID StepID, callID CallID) string { - switch { - case stepID == "": - return "" - case callID == "": - return "model/" + string(stepID) - default: - return "call/" + string(stepID) + "/" + string(callID) - } -} - -func grantKey(c AgentCommand) string { - switch cmd := c.(type) { - case StartModelExecution: - return leaseKey(cmd.StepID, "") - case SubmitModelResult: - return leaseKey(cmd.StepID, "") - case SubmitModelFailure: - return leaseKey(cmd.StepID, "") - case RejectModelResult: - return leaseKey(cmd.StepID, "") - case RecoverModelExecution: - return leaseKey(cmd.StepID, "") - case StartToolCall: - return leaseKey(cmd.StepID, cmd.CallID) - case SubmitToolResult: - return leaseKey(cmd.StepID, cmd.CallID) - case SubmitToolFailure: - return leaseKey(cmd.StepID, cmd.CallID) - default: - return "" - } -} - -func newGrant() ExecutionGrant { - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - panic(fmt.Sprintf("agent: runtime: %v", err)) - } - return ExecutionGrant(hex.EncodeToString(b[:])) -} diff --git a/agent/run/transition.go b/agent/run/transition.go deleted file mode 100644 index fc704b9..0000000 --- a/agent/run/transition.go +++ /dev/null @@ -1,157 +0,0 @@ -package run - -import ( - "errors" - "fmt" - - "github.com/memohai/twilight/agent/es" -) - -// TransitionRecord is the atomic authority record for one accepted -// Runtime.Commit. A transition owns the complete ordered AgentEvent group for -// one Revision; runtimes should persist it as the unit of log completeness and -// expose its Events as the public committed event stream. -type TransitionRecord struct { - SchemaVersion uint16 `json:"schemaVersion"` - RunID RunID `json:"runId"` - Revision uint64 `json:"revision"` - CommandID CommandID `json:"commandId"` - CommandDigest Digest `json:"commandDigest"` - Events []AgentEvent `json:"events"` - TransitionDigest Digest `json:"transitionDigest"` -} - -type transitionRecordDigestBody struct { - SchemaVersion uint16 `json:"schemaVersion"` - RunID RunID `json:"runId"` - Revision uint64 `json:"revision"` - CommandID CommandID `json:"commandId"` - CommandDigest Digest `json:"commandDigest"` - Events []AgentEvent `json:"events"` -} - -func transitionRecordBody(record *TransitionRecord) transitionRecordDigestBody { - return transitionRecordDigestBody{ - SchemaVersion: record.SchemaVersion, - RunID: record.RunID, - Revision: record.Revision, - CommandID: record.CommandID, - CommandDigest: record.CommandDigest, - Events: record.Events, - } -} - -func transitionRecordView(record *TransitionRecord) es.RecordView[AgentEvent] { - return es.RecordView[AgentEvent]{ - SchemaVersion: record.SchemaVersion, - StreamID: es.StreamID(record.RunID), - Revision: es.Revision(record.Revision), - Events: record.Events, - } -} - -//nolint:gocritic // EventInspector is value-based so generic records do not retain mutable event pointers. -func inspectTransitionEvent(event AgentEvent) (es.EventMetadata, error) { - typ := factType(event.Fact) - if typ == "" || event.Type != typ { - return es.EventMetadata{}, fmt.Errorf("event type %q does not match fact variant %T", event.Type, event.Fact) - } - proto, err := ProtocolFor(event.SchemaVersion) - if err != nil { - return es.EventMetadata{}, err - } - wantDigest, err := proto.DigestFact(event.Type, event.Fact) - if err != nil { - return es.EventMetadata{}, err - } - if event.Digest != wantDigest { - return es.EventMetadata{}, fmt.Errorf("fact digest mismatch at revision %d index %d", event.Revision, event.Index) - } - return es.EventMetadata{ - SchemaVersion: event.SchemaVersion, - StreamID: es.StreamID(event.RunID), - Revision: es.Revision(event.Revision), - Index: es.Index(event.Index), - }, nil -} - -func supportsRunSchema(version uint16) bool { return isSupportedSchemaVersion(version) } - -// DigestTransitionRecord computes the digest for one transition aggregate. The -// digest binds the transition identity and the complete ordered event group; -// TransitionDigest itself is excluded from the digest input. -func DigestTransitionRecord(record *TransitionRecord) (Digest, error) { - if record == nil { - return "", errors.New("agent: transition: nil record") - } - body, err := encodeEnvelopeBody(record.SchemaVersion, "transition_record", transitionRecordBody(record)) - if err != nil { - return "", err - } - return es.DigestBytes(body), nil -} - -// BuildTransitionRecord freezes and validates the complete event group of one -// accepted command as an atomic transition record. -func BuildTransitionRecord(events []AgentEvent) (TransitionRecord, error) { - if len(events) == 0 { - return TransitionRecord{}, errors.New("agent: transition: empty event group") - } - if len(events) > int(^uint16(0))+1 { - return TransitionRecord{}, fmt.Errorf("agent: transition: event group too large: %d", len(events)) - } - frozen := cloneEvents(events) - first := frozen[0] - record := TransitionRecord{ - SchemaVersion: first.SchemaVersion, - RunID: first.RunID, - Revision: first.Revision, - CommandID: first.CommandID, - CommandDigest: first.CommandDigest, - Events: frozen, - } - digest, err := DigestTransitionRecord(&record) - if err != nil { - return TransitionRecord{}, err - } - record.TransitionDigest = digest - if err := ValidateTransitionRecord(&record); err != nil { - return TransitionRecord{}, err - } - return record, nil -} - -// ValidateTransitionRecord verifies that a transition is internally complete: -// every nested event belongs to the transition, indexes are contiguous, fact -// digests match, and the transition digest binds the whole aggregate. -func ValidateTransitionRecord(record *TransitionRecord) error { - if record == nil { - return errors.New("agent: transition: nil record") - } - if record.RunID == "" || record.Revision == 0 || record.CommandID == "" || record.CommandDigest == "" { - return errors.New("agent: transition: missing identity") - } - if err := es.ValidateRecordView(transitionRecordView(record), supportsRunSchema, inspectTransitionEvent); err != nil { - return fmt.Errorf("agent: transition: %w", err) - } - for i := range record.Events { - e := record.Events[i] - if e.CommandID != record.CommandID || e.CommandDigest != record.CommandDigest { - return fmt.Errorf("agent: transition: revision %d command identity changed within transition", record.Revision) - } - if _, terminal := e.Fact.(RunEnded); terminal && i != len(record.Events)-1 { - return fmt.Errorf("agent: transition: terminal fact must be the final event") - } - } - if record.TransitionDigest == "" { - return errors.New("agent: transition: missing digest") - } - wantDigest, err := DigestTransitionRecord(record) - if err != nil { - return err - } - if record.TransitionDigest != wantDigest { - return fmt.Errorf("agent: transition: digest mismatch at revision %d", record.Revision) - } - return nil -} diff --git a/agent/session/chatlog/chatlog.go b/agent/session/chatlog/chatlog.go new file mode 100644 index 0000000..5ac77b1 --- /dev/null +++ b/agent/session/chatlog/chatlog.go @@ -0,0 +1,412 @@ +// 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/memohai/twilight/agent/artifact" + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/jsonstable" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/extension" +) + +const ModuleID extension.ModuleID = "chatlog" + +type ( + TurnID string + InputID string + AssistantID string + ToolResultID string + SummaryID string + CallID string +) + +// EventTypes (CHT-EVT-1). v1 companion and coordinator write the first six; +// the rest are registered so their payloads are decodable. +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" +) + +// --- 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}) +} + +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"` +} + +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 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{ + 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), + }, + 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..96f50db --- /dev/null +++ b/agent/session/chatlog/chatlog_test.go @@ -0,0 +1,148 @@ +package chatlog + +import ( + "testing" + + "github.com/memohai/twilight/agent/jsonstable" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/extension" +) + +func registry(t *testing.T) *extension.Registry { + t.Helper() + r, err := extension.BuildRegistry(session.ProfileV1(), 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") + } +} diff --git a/agent/session/chatlog/projection.go b/agent/session/chatlog/projection.go new file mode 100644 index 0000000..886f4a6 --- /dev/null +++ b/agent/session/chatlog/projection.go @@ -0,0 +1,311 @@ +package chatlog + +import ( + "errors" + "fmt" + + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/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"` + Position session.EventPosition `json:"position"` +} + +// 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"` + 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} + +var SurfaceProjection = extension.ProjectionDefinition{ + ID: SurfaceProjectionID, Version: 1, + Consumes: chatlogConsumes, + RequireComplete: []extension.ModuleID{ModuleID}, + 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{}}, nil + }, + Apply: applySurface, + StateCodec: extension.JSONStateCodec[Surface]{}, +} + +func applySurface(state any, e extension.DecodedEvent) (any, error) { + s := state.(Surface) + s = cloneSurface(s) + pos := session.EventPosition{Revision: e.Revision, Index: e.Event.Index, EventDigest: e.Event.EventDigest} + 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), Position: 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), Position: 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), Position: 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), Position: pos}) + 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)), EntryOrder: append([]SurfaceEntry(nil), s.EntryOrder...), nextSeq: s.nextSeq} + 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). +type Entry struct { + Kind EntryKind `json:"kind"` + ID string `json:"id"` + Digest es.Digest `json:"digest"` + Input *Input `json:"input,omitempty"` + Assistant *Assistant `json:"assistant,omitempty"` + ToolResult *ToolResult `json:"toolResult,omitempty"` + Summary *Summary `json:"summary,omitempty"` +} + +// Context is the projection state: the ordered entries plus the bookkeeping +// ContextFold needs (submitted inputs awaiting delivery, superseded results). +type Context struct { + Entries []Entry `json:"entries"` + Pending map[InputID]Input `json:"pending,omitempty"` + Superseded map[ToolResultID]ToolResultID `json:"superseded,omitempty"` +} + +var ContextProjection = extension.ProjectionDefinition{ + ID: ContextProjectionID, Version: 1, + Consumes: chatlogConsumes, + RequireComplete: []extension.ModuleID{ModuleID}, + 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)} + 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, 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, Assistant: &a}) + case ToolResultPayload: + r := p.ToolResult + c.Entries = append(c.Entries, Entry{Kind: EntryToolResult, ID: string(r.ID), Digest: r.Digest, ToolResult: &r}) + case ToolResultSupersededPayload: + c.Superseded[p.ToolResultID] = p.ReplacementToolResultID + kept := c.Entries[:0:0] + for _, en := range c.Entries { + if en.Kind == EntryToolResult && en.ID == string(p.ToolResultID) { + continue + } + kept = append(kept, en) + } + c.Entries = kept + case SummaryPayload: + s := p.Summary + c.Entries = append(c.Entries, Entry{Kind: EntrySummary, ID: string(s.ID), Digest: s.Digest, Summary: &s}) + default: + return nil, fmt.Errorf("chatlog context: unexpected %T", e.Value) + } + return c, 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.ModuleID != 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/appender.go b/agent/session/extension/appender.go new file mode 100644 index 0000000..0e59ebd --- /dev/null +++ b/agent/session/extension/appender.go @@ -0,0 +1,383 @@ +package extension + +import ( + "context" + "errors" + "fmt" + + "github.com/memohai/twilight/agent/artifact" + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/session" +) + +// ClaimNamespace is the control-plane namespace artifact claims live in. +const ClaimNamespace session.ControlNamespace = "twilight/artifact/claim" + +// TypedEvent is a module value plus event metadata; it carries no EventID +// (EXT-APP-5). +type TypedEvent struct { + Type session.EventType + RecordedAtUnixMilli int64 + SourceEvents []session.EventID + Value any +} + +type SemanticGroup struct { + CommitID session.CommitID + CausationID es.CausationID + CorrelationID string + Events []TypedEvent +} + +type SemanticAppendRequest struct { + SessionID session.SessionID + ExpectedHead session.Head + Group SemanticGroup +} + +// SemanticTx is the kernel transaction plus typed decode. +type SemanticTx interface { + session.SessionTx + Decode(session.SessionEvent) (DecodedEvent, error) + SessionID() session.SessionID + Registry() *Registry +} + +type SemanticCommitFn func(SemanticTx) (*SemanticGroup, error) + +type SemanticAppendOutcome string + +const ( + SemanticApplied SemanticAppendOutcome = "applied" + SemanticAlreadyApplied SemanticAppendOutcome = "already_applied" + SemanticHeadConflict SemanticAppendOutcome = "head_conflict" + SemanticCommitConflict SemanticAppendOutcome = "commit_conflict" + SemanticInvalid SemanticAppendOutcome = "invalid" + SemanticNoop SemanticAppendOutcome = "noop" +) + +type SemanticAppendResult struct { + Outcome SemanticAppendOutcome + Commit *session.SessionCommit + Claim *artifact.RetentionClaim + Detail string +} + +// SemanticAppender is the only write path (EXT-SCP-1). +type SemanticAppender interface { + AppendSemantic(context.Context, SemanticAppendRequest) (SemanticAppendResult, error) + AppendSemanticIn(context.Context, session.SessionID, SemanticCommitFn) (SemanticAppendResult, error) +} + +// DeriveEventID is the Appender's EventID rule (EXT-APP-5). +func DeriveEventID(typ session.EventType, commitID session.CommitID, index int) session.EventID { + raw, _ := es.EncodeTypedPayload(session.ProtocolVersion1, "twilight/session-extension/event-id", []string{string(typ), string(commitID), fmt.Sprintf("%d", index)}) + return session.EventID(es.DigestBytes(raw)) +} + +// DeriveClaimID is EXT-APP-2. +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)) +} + +type appender struct { + store session.Store + registry *Registry + builder artifact.BindingSetBuilder + ledger artifact.RetentionLedger +} + +// NewSemanticAppender assembles the write path. builder and ledger may be nil +// only when no registered event declares Bindings. +func NewSemanticAppender(store session.Store, registry *Registry, builder artifact.BindingSetBuilder, ledger artifact.RetentionLedger) (SemanticAppender, error) { + if store == nil || registry == nil { + return nil, errors.New("extension: appender: nil store or registry") + } + return &appender{store: store, registry: registry, builder: builder, ledger: ledger}, nil +} + +type semanticTx struct { + session.SessionTx + sid session.SessionID + registry *Registry +} + +func (t *semanticTx) Decode(e session.SessionEvent) (DecodedEvent, error) { + return t.registry.Decode(e) +} +func (t *semanticTx) SessionID() session.SessionID { return t.sid } +func (t *semanticTx) Registry() *Registry { return t.registry } + +type txClaimKV struct { + tx session.SessionTx +} + +func (k txClaimKV) Get(key string) ([]byte, bool, error) { + e, ok, err := k.tx.ControlGet(ClaimNamespace, key) + if err != nil || !ok { + return nil, false, err + } + return e.Value, true, nil +} +func (k txClaimKV) Put(key string, value []byte) error { + return k.tx.ControlPut(ClaimNamespace, key, value, 0) +} +func (k txClaimKV) Delete(key string) error { return k.tx.ControlDelete(ClaimNamespace, key) } + +// StoreClaimKV is the outside-the-transaction ClaimKV over a Store, for the +// ledger's LookupClaim and ReleaseActive. +func StoreClaimKV(store session.Store, sid session.SessionID) func(context.Context) artifact.ClaimKV { + return func(ctx context.Context) artifact.ClaimKV { return storeClaimKV{store, sid, ctx} } +} + +type storeClaimKV struct { + store session.Store + sid session.SessionID + ctx context.Context +} + +func (k storeClaimKV) Get(key string) ([]byte, bool, error) { + e, ok, err := k.store.ControlGet(k.ctx, k.sid, ClaimNamespace, key) + if err != nil || !ok { + return nil, false, err + } + return e.Value, true, nil +} +func (k storeClaimKV) Put(key string, value []byte) error { + return k.store.ControlPut(k.ctx, k.sid, ClaimNamespace, key, value, 0) +} +func (k storeClaimKV) Delete(key string) error { + return k.store.ControlDelete(k.ctx, k.sid, ClaimNamespace, key) +} + +func (a *appender) AppendSemantic(ctx context.Context, req SemanticAppendRequest) (SemanticAppendResult, error) { + return a.AppendSemanticIn(ctx, req.SessionID, func(tx SemanticTx) (*SemanticGroup, error) { + group := req.Group + // A retry of an already committed group is judged by fingerprint, not + // by the head it was first attempted against (SES-APP-1). + if _, found, err := tx.LookupCommit(group.CommitID); err != nil { + return nil, err + } else if found { + return &group, nil + } + if tx.Head() != req.ExpectedHead { + return nil, errHeadConflict + } + return &group, nil + }) +} + +var errHeadConflict = errors.New("extension: head conflict") + +// errDiscard aborts the kernel transaction so a rejected group leaves no +// snapshot or KV write behind; the semantic outcome is carried separately. +var errDiscard = errors.New("extension: discard transaction") + +// AppendSemanticIn is EXT-APP-3: fn decides the group inside the kernel's +// critical section; codec, admission, claim and append happen in the same +// transaction. +func (a *appender) AppendSemanticIn(ctx context.Context, sid session.SessionID, fn SemanticCommitFn) (SemanticAppendResult, error) { + if fn == nil { + return SemanticAppendResult{}, errors.New("extension: appender: nil fn") + } + var result SemanticAppendResult + header, err := a.store.Header(ctx, sid) + if err != nil { + return SemanticAppendResult{}, err + } + appendRes, err := a.store.CommitIn(ctx, sid, func(tx session.SessionTx) (*session.AppendRequest, error) { + stx := &semanticTx{SessionTx: tx, sid: sid, registry: a.registry} + group, err := fn(stx) + if err != nil { + if errors.Is(err, errHeadConflict) { + result = SemanticAppendResult{Outcome: SemanticHeadConflict} + return nil, errDiscard + } + return nil, err + } + if group == nil { + result = SemanticAppendResult{Outcome: SemanticNoop} + return nil, nil + } + if group.CommitID == "" || len(group.Events) == 0 { + result = SemanticAppendResult{Outcome: SemanticInvalid, Detail: "empty CommitID or event group"} + return nil, errDiscard + } + req, claim, invalid, err := a.prepare(ctx, tx, &header, sid, group) + if err != nil { + return nil, err + } + if invalid != "" { + result = SemanticAppendResult{Outcome: SemanticInvalid, Detail: invalid} + return nil, errDiscard + } + // Exact replay short-circuits before admission writes anything new; + // the claim of a replayed commit is re-activated idempotently. + if existing, ok, err := tx.LookupCommit(group.CommitID); err != nil { + return nil, err + } else if ok { + have, err := a.registry.Profile.FingerprintAppend(*req) + if err != nil { + return nil, err + } + want, err := fingerprintOf(a.registry.Profile, &existing) + if err != nil { + return nil, err + } + if have != want { + result = SemanticAppendResult{Outcome: SemanticCommitConflict} + return nil, errDiscard + } + if claim != nil { + if _, err := a.ledger.ActivateIn(txClaimKV{tx}, claim.ID, claim.Owner, claim.BindingSet); err != nil { + return nil, err + } + } + c := existing + result = SemanticAppendResult{Outcome: SemanticAlreadyApplied, Commit: &c, Claim: claim} + return nil, nil + } + if claim != nil { + if _, err := a.ledger.ActivateIn(txClaimKV{tx}, claim.ID, claim.Owner, claim.BindingSet); err != nil { + return nil, err + } + } + result = SemanticAppendResult{Claim: claim} + req.ExpectedHead = tx.Head() + return req, nil + }) + if err != nil { + if errors.Is(err, errDiscard) { + return result, nil + } + return SemanticAppendResult{}, err + } + if result.Outcome != "" { + return result, nil + } + switch appendRes.Disposition { + case session.AppendApplied: + result.Outcome = SemanticApplied + result.Commit = appendRes.Commit + case session.AppendAlreadyApplied: + result.Outcome = SemanticAlreadyApplied + result.Commit = appendRes.Commit + case session.AppendCommitConflict: + result.Outcome = SemanticCommitConflict + case session.AppendHeadConflict: + result.Outcome = SemanticHeadConflict + default: + result.Outcome = SemanticInvalid + result.Detail = appendRes.Detail + } + return result, nil +} + +// prepare validates, encodes and extracts bindings for the group. It returns +// the kernel AppendRequest (ExpectedHead unset) and the claim to activate. +func (a *appender) prepare(ctx context.Context, tx session.SessionTx, header *session.SessionHeader, sid session.SessionID, group *SemanticGroup) (*session.AppendRequest, *artifact.RetentionClaim, string, error) { + req := &session.AppendRequest{SessionID: sid, CommitID: group.CommitID, CausationID: group.CausationID, CorrelationID: group.CorrelationID, + Events: make([]session.UncommittedEvent, len(group.Events))} + var refs []artifact.BindingID + for i, te := range group.Events { + _, def, ok := a.registry.LookupEvent(te.Type) + if !ok { + return nil, nil, fmt.Sprintf("event %d: unknown type %s", i, te.Type), nil + } + payload, _, err := a.registry.Encode(te.Type, te.Value) + if err != nil { + return nil, nil, fmt.Sprintf("event %d: %v", i, err), nil + } + for _, decl := range def.Bindings { + ids, err := decl.extract(te.Value, payload) + if err != nil { + return 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, fmt.Sprintf("event %d: binding cardinality violated", i), nil + } + for _, id := range ids { + if invalid, err := a.admit(ctx, id, &decl); err != nil { + return nil, nil, "", err + } else if invalid != "" { + return nil, nil, fmt.Sprintf("event %d: %s", i, invalid), nil + } + } + refs = append(refs, ids...) + } + req.Events[i] = session.UncommittedEvent{ + EventID: DeriveEventID(te.Type, group.CommitID, i), + Type: te.Type, + RecordedAtUnixMilli: te.RecordedAtUnixMilli, + SourceEvents: session.SortedUniqueEventIDs(te.SourceEvents), + Payload: payload, + } + } + if len(refs) == 0 { + return req, nil, "", nil + } + if a.builder == nil || a.ledger == nil { + return nil, nil, "group references artifacts but no ledger is configured", nil + } + set, err := a.builder.Build(ctx, refs) + if err != nil { + var aerr *artifact.Error + if errors.As(err, &aerr) { + return nil, nil, "binding set: " + aerr.Error(), nil + } + return nil, nil, "", err + } + claim := &artifact.RetentionClaim{ + ID: DeriveClaimID(header.ProtocolVersion, sid, group.CommitID, set.RefSetDigest), + Owner: artifact.ClaimOwner{Kind: "twilight/session/commit", Authority: string(sid), Identity: string(group.CommitID)}, + BindingSet: set, + State: artifact.ClaimActive, + } + return req, claim, "", nil +} + +func (a *appender) admit(ctx context.Context, id artifact.BindingID, decl *BindingReferenceDefinition) (string, error) { + resolver, ok := a.builder.(interface { + ResolveBinding(context.Context, artifact.BindingID) (artifact.Binding, error) + }) + if !ok { + if sb, isSet := a.builder.(artifact.SetBuilder); isSet { + resolver = sb.Resolver + } + } + if resolver == nil { + return "no binding resolver configured", nil + } + binding, err := resolver.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 +} + +func fingerprintOf(p session.ProtocolProfile, c *session.SessionCommit) (es.Digest, error) { + events := make([]session.UncommittedEvent, len(c.Events)) + for i, e := range c.Events { + events[i] = session.UncommittedEvent{EventID: e.EventID, Type: e.Type, SourceEvents: e.SourceEvents, Payload: e.Payload} + } + return p.FingerprintAppend(session.AppendRequest{SessionID: c.SessionID, CommitID: c.CommitID, CausationID: c.CausationID, CorrelationID: c.CorrelationID, Events: events}) +} diff --git a/agent/session/extension/binding.go b/agent/session/extension/binding.go new file mode 100644 index 0000000..13f3cf1 --- /dev/null +++ b/agent/session/extension/binding.go @@ -0,0 +1,89 @@ +package extension + +import ( + "errors" + "strings" + + "github.com/memohai/twilight/agent/artifact" + "github.com/memohai/twilight/agent/jsonstable" +) + +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. +type BindingReferenceDefinition struct { + JSONPointer string + Extractor BindingExtractor + Cardinality Cardinality + AllowedSchemes []artifact.Scheme + RequiredDurability artifact.Durability +} + +func (d *BindingReferenceDefinition) validate() error { + if (d.JSONPointer == "") == (d.Extractor == nil) { + return errors.New("binding declaration needs exactly one of JSONPointer or Extractor") + } + if d.JSONPointer != "" && !strings.HasPrefix(d.JSONPointer, "/") { + return errors.New("JSONPointer must start with /") + } + 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 +} + +// extract returns the references one declaration finds in value/payload. +func (d *BindingReferenceDefinition) extract(value any, payload jsonstable.Value) ([]artifact.BindingID, error) { + if d.Extractor != nil { + return d.Extractor.BindingIDs(value) + } + node, err := payload.Any() + if err != nil { + return nil, err + } + for _, seg := range strings.Split(strings.TrimPrefix(d.JSONPointer, "/"), "/") { + seg = strings.ReplaceAll(strings.ReplaceAll(seg, "~1", "/"), "~0", "~") + obj, ok := node.(map[string]any) + if !ok { + return nil, nil + } + node, ok = obj[seg] + if !ok { + return nil, nil + } + } + switch v := node.(type) { + case string: + return []artifact.BindingID{artifact.BindingID(v)}, nil + case []any: + out := make([]artifact.BindingID, 0, len(v)) + for _, item := range v { + s, ok := item.(string) + if !ok { + return nil, errors.New("binding pointer array holds a non-string") + } + out = append(out, artifact.BindingID(s)) + } + return out, nil + default: + return nil, errors.New("binding pointer does not address a string or string array") + } +} diff --git a/agent/session/extension/extension_test.go b/agent/session/extension/extension_test.go new file mode 100644 index 0000000..a99f004 --- /dev/null +++ b/agent/session/extension/extension_test.go @@ -0,0 +1,225 @@ +package extension + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/memohai/twilight/agent/jsonstable" + "github.com/memohai/twilight/agent/session" +) + +type notePayload struct { + Text string `json:"text"` +} + +type noteState struct { + Notes []string `json:"notes"` +} + +func noteModule(id ModuleID, requires ...ModuleRequirement) ModuleDescriptor { + typ := ModulePrefix(id) + "note" + return ModuleDescriptor{ID: id, Requires: requires, + Events: []EventDefinition{{Type: typ, Current: 1, Codecs: map[PayloadVersion]PayloadCodec{1: JSONCodec[notePayload]{}}}}, + Projections: []ProjectionDefinition{{ + ID: ProjectionID(string(typ) + "s"), Version: 1, Consumes: []session.EventType{typ}, RequireComplete: []ModuleID{id}, + Initial: func() (any, error) { return noteState{}, nil }, + Apply: func(state any, e DecodedEvent) (any, error) { + s := state.(noteState) + s.Notes = append(append([]string(nil), s.Notes...), e.Value.(notePayload).Text) + return s, nil + }, + StateCodec: JSONStateCodec[noteState]{}, + }}, + } +} + +func TestBuildRegistryValidatesRequires(t *testing.T) { + cases := map[string][]ModuleDescriptor{ + "unregistered dependency": {noteModule("a", ModuleRequirement{Module: "zzz"})}, + "cycle": {noteModule("a", ModuleRequirement{Module: "b"}), noteModule("b", ModuleRequirement{Module: "a"})}, + "unhandled version": {noteModule("a"), noteModule("b", ModuleRequirement{Module: "a", + Events: map[session.EventType][]PayloadVersion{ModulePrefix("a") + "note": {2}}})}, + "event outside module": {{ID: "a", Events: []EventDefinition{{Type: "twilight/b/x", Current: 1, Codecs: map[PayloadVersion]PayloadCodec{1: JSONCodec[notePayload]{}}}}}}, + } + for name, modules := range cases { + if _, err := BuildRegistry(session.ProfileV1(), modules...); err == nil { + t.Errorf("%s: registry built", name) + } + } + if _, err := BuildRegistry(session.ProfileV1(), noteModule("a"), noteModule("b", ModuleRequirement{Module: "a", + Events: map[session.EventType][]PayloadVersion{ModulePrefix("a") + "note": {1}}})); err != nil { + t.Fatalf("valid registry: %v", err) + } +} + +// Encode adds v; Decode selects the codec by v and keeps unknown versions raw. +func TestRegistryPayloadVersion(t *testing.T) { + r, err := BuildRegistry(session.ProfileV1(), noteModule("a")) + if err != nil { + t.Fatal(err) + } + typ := ModulePrefix("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(typ, notePayload{}); err != nil { + t.Fatalf("encode zero value: %v", err) + } + if _, _, err := r.Encode("twilight/a/other", notePayload{}); err == nil { + t.Fatal("unknown type encoded") + } +} + +func newAppender(t *testing.T) (session.Store, *Registry, SemanticAppender, session.SessionID) { + t.Helper() + store := session.NewMemoryStore() + r, err := BuildRegistry(session.ProfileV1(), noteModule("a")) + if err != nil { + t.Fatal(err) + } + a, err := NewSemanticAppender(store, r, nil, nil) + if err != nil { + t.Fatal(err) + } + if _, err := store.Create(context.Background(), session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: "s"}); err != nil { + t.Fatal(err) + } + return store, r, a, "s" +} + +// Both append entries derive EventIDs from (type, CommitID, index), reject a +// same-ID different group, and the reader sees the projection with snapshot +// and tail equivalent. +func TestSemanticAppenderAndProjectionReader(t *testing.T) { + store, r, a, sid := newAppender(t) + ctx := context.Background() + typ := ModulePrefix("a") + "note" + group := SemanticGroup{CommitID: "c1", Events: []TypedEvent{{Type: typ, Value: notePayload{Text: "one"}}}} + res, err := a.AppendSemanticIn(ctx, sid, func(tx SemanticTx) (*SemanticGroup, error) { return &group, nil }) + if err != nil || res.Outcome != SemanticApplied { + t.Fatalf("append in = %+v %v", res, err) + } + if res.Commit.Events[0].EventID != DeriveEventID(typ, "c1", 0) { + t.Fatal("EventID not derived from (type, CommitID, index)") + } + head, _ := store.Head(ctx, sid) + res2, err := a.AppendSemantic(ctx, SemanticAppendRequest{SessionID: sid, ExpectedHead: head, + Group: SemanticGroup{CommitID: "c2", Events: []TypedEvent{{Type: typ, Value: notePayload{Text: "two"}}}}}) + if err != nil || res2.Outcome != SemanticApplied { + t.Fatalf("append = %+v %v", res2, err) + } + replay, _ := a.AppendSemantic(ctx, SemanticAppendRequest{SessionID: sid, ExpectedHead: head, Group: group}) + if replay.Outcome != SemanticAlreadyApplied { + t.Fatalf("replay = %+v", replay) + } + conflict, _ := a.AppendSemantic(ctx, SemanticAppendRequest{SessionID: sid, ExpectedHead: head, + Group: SemanticGroup{CommitID: "c1", Events: []TypedEvent{{Type: typ, Value: notePayload{Text: "changed"}}}}}) + if conflict.Outcome != SemanticCommitConflict { + t.Fatalf("conflict = %+v", conflict) + } + stale, _ := a.AppendSemantic(ctx, SemanticAppendRequest{SessionID: sid, ExpectedHead: head, + Group: SemanticGroup{CommitID: "c3", Events: []TypedEvent{{Type: typ, Value: notePayload{Text: "three"}}}}}) + if stale.Outcome != SemanticHeadConflict { + t.Fatalf("stale = %+v", stale) + } + invalid, _ := a.AppendSemantic(ctx, SemanticAppendRequest{SessionID: sid, ExpectedHead: session.Head{Revision: res2.Commit.Revision, Digest: res2.Commit.CommitDigest}, + Group: SemanticGroup{CommitID: "c4", Events: []TypedEvent{{Type: "twilight/a/unknown", Value: notePayload{}}}}}) + if invalid.Outcome != SemanticInvalid { + t.Fatalf("invalid = %+v", invalid) + } + + reader := NewProjectionReader(store, r) + def, _ := r.LookupProjection(ProjectionID(string(typ)+"s"), 1) + state, through, err := reader.Load(ctx, sid, def.ID, def.Version) + if err != nil || through.Revision != 2 { + t.Fatalf("load = %+v %+v %v", state, through, err) + } + full := state.(noteState).Notes + // Snapshot after the first commit, then the tail must give the same state. + _, err = a.AppendSemanticIn(ctx, sid, func(tx SemanticTx) (*SemanticGroup, error) { + s, _, err := LoadIn(tx, &def) + if err != nil { + return nil, err + } + if len(s.(noteState).Notes) != 2 { + t.Fatalf("LoadIn = %+v", s) + } + return nil, SaveSnapshotIn(tx, &def, noteState{Notes: []string{"one"}}, session.Head{Revision: 1, Digest: res.Commit.CommitDigest}) + }) + if err != nil { + t.Fatal(err) + } + state, _, err = reader.Load(ctx, sid, def.ID, def.Version) + if err != nil || len(state.(noteState).Notes) != len(full) || state.(noteState).Notes[1] != "two" { + t.Fatalf("snapshot+tail = %+v, full = %v, %v", state, full, err) + } +} + +func TestLeaseLifecycle(t *testing.T) { + store, _, a, sid := newAppender(t) + ctx := context.Background() + typ := ModulePrefix("a") + "note" + var lease Lease + _, err := a.AppendSemanticIn(ctx, sid, func(tx SemanticTx) (*SemanticGroup, error) { + l, err := AcquireLease(tx, sid, "c1", 1000, AcquireLeaseRequest{Namespace: "twilight/a/lease", Key: "k", Holder: "h1", TTL: time.Second}) + if err != nil { + return nil, err + } + lease = l + if again, err := AcquireLease(tx, sid, "c1", 1000, AcquireLeaseRequest{Namespace: "twilight/a/lease", Key: "k", Holder: "h1", TTL: time.Second}); err != nil || again.Token != l.Token { + t.Fatalf("same holder re-acquire = %+v %v", again, err) + } + if _, err := AcquireLease(tx, sid, "c1", 1000, AcquireLeaseRequest{Namespace: "twilight/a/lease", Key: "k", Holder: "h2"}); !errors.Is(err, &Error{Code: ErrConflict}) { + t.Fatalf("other holder = %v, want conflict", err) + } + return &SemanticGroup{CommitID: "c1", Events: []TypedEvent{{Type: typ, Value: notePayload{Text: "start"}}}}, nil + }) + if err != nil { + t.Fatal(err) + } + if lease.Token != DeriveLeaseToken(sid, "twilight/a/lease", "k", "h1", "c1") || lease.DeadlineUnixMilli != 2000 { + t.Fatalf("lease = %+v", lease) + } + leases := Leases{Store: store} + if err := leases.Renew(ctx, sid, "twilight/a/lease", "k", lease.Token, time.Second, 1500); err != nil { + t.Fatalf("renew: %v", err) + } + if err := leases.Renew(ctx, sid, "twilight/a/lease", "k", "bad", time.Second, 1500); !errors.Is(err, &Error{Code: ErrStale}) { + t.Fatalf("renew with bad token = %v", err) + } + var expired []string + _ = leases.Expired(ctx, "twilight/a/lease", 2400, func(_ session.SessionID, l Lease) (bool, error) { expired = append(expired, l.Key); return true, nil }) + if len(expired) != 0 { + t.Fatalf("renewed lease expired early: %v", expired) + } + _ = leases.Expired(ctx, "twilight/a/lease", 2600, func(_ session.SessionID, l Lease) (bool, error) { expired = append(expired, l.Key); return true, nil }) + if len(expired) != 1 { + t.Fatalf("expired = %v", expired) + } + _, err = a.AppendSemanticIn(ctx, sid, func(tx SemanticTx) (*SemanticGroup, error) { + if err := ReleaseLease(tx, "twilight/a/lease", "k", "bad"); !errors.Is(err, &Error{Code: ErrStale}) { + t.Fatalf("release with bad token = %v", err) + } + if err := ReleaseLease(tx, "twilight/a/lease", "k", lease.Token); err != nil { + return nil, err + } + return &SemanticGroup{CommitID: "c2", Events: []TypedEvent{{Type: typ, Value: notePayload{Text: "settle"}}}}, nil + }) + if err != nil { + t.Fatal(err) + } + if err := leases.Renew(ctx, sid, "twilight/a/lease", "k", lease.Token, time.Second, 1500); !errors.Is(err, &Error{Code: ErrStale}) { + t.Fatalf("renew after release = %v", err) + } +} diff --git a/agent/session/extension/lease.go b/agent/session/extension/lease.go new file mode 100644 index 0000000..adef9c6 --- /dev/null +++ b/agent/session/extension/lease.go @@ -0,0 +1,168 @@ +package extension + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/jsonstable" + "github.com/memohai/twilight/agent/session" +) + +type LeaseToken string + +// Lease is one occupancy record in the control-plane KV (EXT-LSE). +type Lease struct { + Namespace session.ControlNamespace + Key string + Holder string + Token LeaseToken + DeadlineUnixMilli int64 + Attrs jsonstable.Value +} + +type AcquireLeaseRequest struct { + Namespace session.ControlNamespace + Key string + Holder string + TTL time.Duration + Attrs jsonstable.Value +} + +type leaseValue struct { + Holder string `json:"holder"` + Token LeaseToken `json:"token"` + Attrs jsonstable.Value `json:"attrs,omitempty"` +} + +// DeriveLeaseToken is the pure Token derivation of EXT-LSE-1. +func DeriveLeaseToken(sid session.SessionID, ns session.ControlNamespace, key, holder string, commitID session.CommitID) LeaseToken { + raw, _ := es.EncodeTypedPayload(session.ProtocolVersion1, "twilight/session-extension/lease", []string{string(sid), string(ns), key, holder, string(commitID)}) + return LeaseToken(es.DigestBytes(raw)) +} + +// AcquireLease writes or idempotently confirms a lease inside the commit +// transaction (EXT-LSE-2). sid is the Session the tx belongs to. +func AcquireLease(tx session.SessionTx, sid session.SessionID, commitID session.CommitID, now int64, req AcquireLeaseRequest) (Lease, error) { + if req.Namespace == "" || req.Key == "" || req.Holder == "" { + return Lease{}, &Error{Code: ErrInvalid, Detail: "lease requires namespace, key and holder"} + } + existing, ok, err := LookupLease(tx, req.Namespace, req.Key) + if err != nil { + return Lease{}, err + } + if ok { + if existing.Holder == req.Holder { + return existing, nil + } + return Lease{}, &Error{Code: ErrConflict, Detail: "lease held by another holder"} + } + lease := Lease{Namespace: req.Namespace, Key: req.Key, Holder: req.Holder, + Token: DeriveLeaseToken(sid, req.Namespace, req.Key, req.Holder, commitID), Attrs: req.Attrs} + if req.TTL > 0 { + lease.DeadlineUnixMilli = now + req.TTL.Milliseconds() + } + value, err := encodeLease(&lease) + if err != nil { + return Lease{}, err + } + if err := tx.ControlPut(req.Namespace, req.Key, value, lease.DeadlineUnixMilli); err != nil { + return Lease{}, err + } + return lease, nil +} + +// ReleaseLease deletes the lease when token matches; otherwise ErrStale. +func ReleaseLease(tx session.SessionTx, ns session.ControlNamespace, key string, token LeaseToken) error { + existing, ok, err := LookupLease(tx, ns, key) + if err != nil { + return err + } + if !ok || existing.Token != token { + return &Error{Code: ErrStale, Detail: "lease missing or token mismatch"} + } + return tx.ControlDelete(ns, key) +} + +func LookupLease(tx session.SessionTx, ns session.ControlNamespace, key string) (Lease, bool, error) { + entry, ok, err := tx.ControlGet(ns, key) + if err != nil || !ok { + return Lease{}, false, err + } + lease, err := decodeLease(entry) + if err != nil { + return Lease{}, false, err + } + return lease, true, nil +} + +// Leases is the outside-the-critical-section facade (EXT-LSE-3). +type Leases struct{ Store session.Store } + +func (l Leases) Lookup(ctx context.Context, sid session.SessionID, ns session.ControlNamespace, key string) (Lease, bool, error) { + entry, ok, err := l.Store.ControlGet(ctx, sid, ns, key) + if err != nil || !ok { + return Lease{}, false, err + } + lease, err := decodeLease(entry) + if err != nil { + return Lease{}, false, err + } + return lease, true, nil +} + +// Renew pushes the deadline by ttl with a conditional write; a missing entry, +// a token mismatch or a lost race with Release returns ErrStale. ttl zero only +// validates the token. +func (l Leases) Renew(ctx context.Context, sid session.SessionID, ns session.ControlNamespace, key string, token LeaseToken, ttl time.Duration, now int64) error { + entry, ok, err := l.Store.ControlGet(ctx, sid, ns, key) + if err != nil { + return err + } + if !ok { + return &Error{Code: ErrStale, Detail: "lease missing"} + } + lease, err := decodeLease(entry) + if err != nil { + return err + } + if lease.Token != token { + return &Error{Code: ErrStale, Detail: "lease token mismatch"} + } + if ttl <= 0 { + return nil + } + written, err := l.Store.ControlCompareAndPut(ctx, sid, ns, key, entry.Value, entry.Value, now+ttl.Milliseconds()) + if err != nil { + return err + } + if !written { + return &Error{Code: ErrStale, Detail: "lease released or rewritten during renew"} + } + return nil +} + +// Expired enumerates leases whose deadline passed before now. +func (l Leases) Expired(ctx context.Context, ns session.ControlNamespace, now int64, fn func(session.SessionID, Lease) (bool, error)) error { + return l.Store.ControlExpired(ctx, ns, now, func(entry session.ControlEntry) (bool, error) { + lease, err := decodeLease(entry) + if err != nil { + return false, err + } + return fn(entry.SessionID, lease) + }) +} + +func encodeLease(l *Lease) ([]byte, error) { + return jsonstable.MarshalCanonical(leaseValue{Holder: l.Holder, Token: l.Token, Attrs: l.Attrs}) +} + +func decodeLease(entry session.ControlEntry) (Lease, error) { + var v leaseValue + if err := json.Unmarshal(entry.Value, &v); err != nil { + return Lease{}, errors.New("extension: lease: corrupt value") + } + return Lease{Namespace: entry.Namespace, Key: entry.Key, Holder: v.Holder, Token: v.Token, DeadlineUnixMilli: entry.DeadlineUnixMilli, Attrs: v.Attrs}, nil +} diff --git a/agent/session/extension/projection.go b/agent/session/extension/projection.go new file mode 100644 index 0000000..4dba065 --- /dev/null +++ b/agent/session/extension/projection.go @@ -0,0 +1,264 @@ +package extension + +import ( + "context" + "errors" + "fmt" + + "github.com/memohai/twilight/agent/jsonstable" + "github.com/memohai/twilight/agent/session" +) + +// ProjectionDefinition is a pure fold over decoded events (EXT-PRJ-1). +type ProjectionDefinition struct { + ID ProjectionID + Version ProjectionVersion + Consumes []session.EventType + Ignores []session.EventType + RequireComplete []ModuleID + Initial func() (any, error) + Apply func(any, DecodedEvent) (any, error) + StateCodec PayloadCodec +} + +// TypeFilter is the Types filter a reader uses for this projection: the +// consumed types plus the full prefixes of RequireComplete modules. +func (d *ProjectionDefinition) TypeFilter() []session.EventType { + var out []session.EventType + for _, m := range d.RequireComplete { + out = append(out, ModulePrefix(m)) + } + out = append(out, d.Consumes...) + return out +} + +// Fold applies the commits to state. It fails on an Unknown event of a +// RequireComplete module and never publishes a partial commit. +func Fold(registry *Registry, def *ProjectionDefinition, state any, commits []session.SessionCommit) (any, error) { + consumes := make(map[session.EventType]struct{}, len(def.Consumes)) + for _, t := range def.Consumes { + consumes[t] = struct{}{} + } + ignores := make(map[session.EventType]struct{}, len(def.Ignores)) + for _, t := range def.Ignores { + ignores[t] = struct{}{} + } + required := make(map[ModuleID]struct{}, len(def.RequireComplete)) + for _, m := range def.RequireComplete { + required[m] = struct{}{} + } + for ci := range commits { + next := state + for _, e := range commits[ci].Events { + if _, skip := ignores[e.Type]; skip { + continue + } + if _, want := consumes[e.Type]; !want { + // Unknown event of a required module: refuse rather than skip. + module, registered := registry.ModuleForEvent(e.Type) + if _, mustBeComplete := required[module]; mustBeComplete && !registered { + return nil, &Error{Code: ErrUnknownEvent, Type: e.Type, Detail: fmt.Sprintf("projection %q requires complete module %q", def.ID, module)} + } + continue + } + decoded, err := registry.Decode(e) + if err != nil { + return nil, err + } + decoded.Revision = commits[ci].Revision + if decoded.Unknown { + if _, mustBeComplete := required[decoded.ModuleID]; mustBeComplete { + return nil, &Error{Code: ErrUnknownEvent, Type: e.Type, Detail: fmt.Sprintf("projection %q cannot decode v%d", def.ID, decoded.Version)} + } + continue + } + next, err = def.Apply(next, decoded) + if err != nil { + return nil, fmt.Errorf("projection %s: revision %d: %w", def.ID, commits[ci].Revision, err) + } + } + state = next + } + return state, nil +} + +// ProjectionReader loads a projection outside the critical section +// (EXT-PRJ-4): snapshot (if it is a prefix of the stream) plus filtered tail. +type ProjectionReader interface { + Load(ctx context.Context, sid session.SessionID, id ProjectionID, v ProjectionVersion) (state any, through session.Head, err error) +} + +type reader struct { + store session.Store + registry *Registry +} + +func NewProjectionReader(store session.Store, registry *Registry) ProjectionReader { + return &reader{store: store, registry: registry} +} + +func (r *reader) Load(ctx context.Context, sid session.SessionID, id ProjectionID, v ProjectionVersion) (any, session.Head, error) { + def, ok := r.registry.LookupProjection(id, v) + if !ok { + return nil, session.Head{}, &Error{Code: ErrInvalid, Detail: fmt.Sprintf("unknown projection %q v%d", id, v)} + } + state, after, err := r.startState(ctx, sid, &def) + if err != nil { + return nil, session.Head{}, err + } + page, err := r.store.Replay(ctx, session.ReplayRequest{SessionID: sid, Types: def.TypeFilter()}) + if err != nil { + return nil, session.Head{}, err + } + // Filtered replay pages by commit; a Types filter cannot use the digest + // chain, so we page until Next is nil. + commits := page.Commits + for page.Next != nil { + page, err = r.store.Replay(ctx, session.ReplayRequest{SessionID: sid, Types: def.TypeFilter(), Cursor: page.Next}) + if err != nil { + return nil, session.Head{}, err + } + commits = append(commits, page.Commits...) + } + // Commits at or before the snapshot head are already folded. + var tail []session.SessionCommit + for i := range commits { + if commits[i].Revision > after.Revision { + tail = append(tail, commits[i]) + } + } + state, err = Fold(r.registry, &def, state, tail) + if err != nil { + return nil, session.Head{}, err + } + return state, page.Head, nil +} + +// startState returns the snapshot state when the snapshot is a valid prefix +// of the stream; otherwise the projection's initial state and the empty head. +func (r *reader) startState(ctx context.Context, sid session.SessionID, def *ProjectionDefinition) (any, session.Head, error) { + res, err := r.store.LoadSnapshot(ctx, session.SnapshotRequest{SessionID: sid, ProjectionKey: session.ProjectionKey(def.ID), ProjectionVersion: uint16(def.Version)}) + if err != nil { + return nil, session.Head{}, err + } + if res.Found { + if state, ok := r.snapshotState(ctx, sid, def, res.Snapshot); ok { + return state, res.Snapshot.Through, nil + } + } + state, err := def.Initial() + return state, session.Head{}, err +} + +func (r *reader) snapshotState(ctx context.Context, sid session.SessionID, def *ProjectionDefinition, snap *session.Snapshot) (any, bool) { + if err := r.registry.Profile.ValidateSnapshot(*snap); err != nil { + return nil, false + } + if !r.isPrefix(ctx, sid, snap.Through) { + return nil, false + } + state, err := def.StateCodec.Decode(snap.State) + if err != nil { + return nil, false + } + return state, true +} + +func (r *reader) isPrefix(ctx context.Context, sid session.SessionID, through session.Head) bool { + if through.Revision == 0 { + return false + } + // Replay one unfiltered page up to Through; the kernel verifies the chain. + page, err := r.store.Replay(ctx, session.ReplayRequest{SessionID: sid, Limit: uint32(through.Revision)}) + if err != nil || len(page.Commits) < int(through.Revision) { + return false + } + return page.Commits[through.Revision-1].CommitDigest == through.Digest +} + +// LoadIn folds a projection inside a transaction from snapshot plus tail. It +// returns the state and the head it covers. +func LoadIn(tx SemanticTx, def *ProjectionDefinition) (any, session.Head, error) { + registry := tx.Registry() + res, err := tx.LoadSnapshot(session.ProjectionKey(def.ID), uint16(def.Version)) + if err != nil { + return nil, session.Head{}, err + } + var state any + var after session.Head + if res.Found && registry.Profile.ValidateSnapshot(*res.Snapshot) == nil { + if s, err := def.StateCodec.Decode(res.Snapshot.State); err == nil { + // Tail validates Through against the stream; a stale snapshot makes + // Tail fail and we fall back to a full fold. + if commits, terr := tx.Tail(res.Snapshot.Through, def.TypeFilter()); terr == nil { + state = s + after = res.Snapshot.Through + folded, err := Fold(registry, def, state, commits) + if err != nil { + return nil, session.Head{}, err + } + return folded, tx.Head(), nil + } + } + } + state, err = def.Initial() + if err != nil { + return nil, session.Head{}, err + } + commits, err := tx.Tail(after, def.TypeFilter()) + if err != nil { + return nil, session.Head{}, err + } + folded, err := Fold(registry, def, state, commits) + if err != nil { + return nil, session.Head{}, err + } + return folded, tx.Head(), nil +} + +// SaveSnapshotIn encodes state and stores it as the projection's snapshot +// covering through, in the same transaction. +func SaveSnapshotIn(tx SemanticTx, def *ProjectionDefinition, state any, through session.Head) error { + if through.Revision == 0 { + return nil + } + encoded, err := def.StateCodec.Encode(state) + if err != nil { + return err + } + snap := session.Snapshot{ProtocolVersion: tx.Registry().ProtocolVersion, SessionID: tx.SessionID(), + ProjectionKey: session.ProjectionKey(def.ID), ProjectionVersion: uint16(def.Version), Through: through, State: encoded} + d, err := tx.Registry().Profile.SnapshotDigest(snap) + if err != nil { + return err + } + snap.SnapshotDigest = d + return tx.SaveSnapshot(snap) +} + +// 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 snapshot state") + } + if err := StrictDecode(wire, &v); err != nil { + return nil, err + } + return v, nil +} diff --git a/agent/session/extension/registry.go b/agent/session/extension/registry.go new file mode 100644 index 0000000..2da9eae --- /dev/null +++ b/agent/session/extension/registry.go @@ -0,0 +1,441 @@ +// Package extension is the Session Module Framework +// (docs/design/agent-session-extension.md): typed event codecs with payload +// versions, Binding admission, the single write path (SemanticAppender), +// pure projections and the Lease facility built on the control-plane KV. +package extension + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/jsonstable" + "github.com/memohai/twilight/agent/session" +) + +type ( + SourceID string + ModuleID string + ProjectionID string + ProjectionVersion uint16 + PayloadVersion uint16 +) + +const SourceTwilight SourceID = "twilight" + +// 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 +} + +// ModuleRequirement declares that a module consumes another module's events +// and which payload versions it can handle (EXT-REG-4). +type ModuleRequirement struct { + Module ModuleID + Events map[session.EventType][]PayloadVersion +} + +type ModuleDescriptor struct { + ID ModuleID + Requires []ModuleRequirement + Events []EventDefinition + Projections []ProjectionDefinition +} + +type DecodedEvent struct { + Event session.SessionEvent + Revision es.Revision // set by Fold; zero when decoded outside a commit + ModuleID ModuleID + Version PayloadVersion + Value any + Unknown bool +} + +// Registry is the immutable index built once at startup (EXT-REG-1). +type Registry struct { + ProtocolVersion uint16 + Profile session.ProtocolProfile + + modules map[ModuleID]ModuleDescriptor + events map[session.EventType]eventEntry + projections map[projectionKey]projectionEntry +} + +type eventEntry struct { + module ModuleID + def EventDefinition +} + +type projectionKey struct { + id ProjectionID + version ProjectionVersion +} + +type projectionEntry struct { + module ModuleID + def ProjectionDefinition +} + +// BuildRegistry validates the module set and freezes the indexes. +func BuildRegistry(profile session.ProtocolProfile, modules ...ModuleDescriptor) (*Registry, error) { + if profile == nil { + return nil, errors.New("extension: registry: nil profile") + } + r := &Registry{ProtocolVersion: profile.Version(), Profile: profile, + modules: make(map[ModuleID]ModuleDescriptor), events: make(map[session.EventType]eventEntry), projections: make(map[projectionKey]projectionEntry)} + for _, m := range modules { + if m.ID == "" || strings.Contains(string(m.ID), "/") { + return nil, &Error{Code: ErrInvalid, Detail: fmt.Sprintf("invalid module id %q", m.ID)} + } + if _, dup := r.modules[m.ID]; dup { + return nil, &Error{Code: ErrInvalid, Detail: fmt.Sprintf("duplicate module %q", m.ID)} + } + r.modules[m.ID] = m + prefix := session.EventType(fmt.Sprintf("%s/%s/", SourceTwilight, 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 %q", m.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: m.ID, 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: m.ID, 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 { + // 1. registered and acyclic. + state := make(map[ModuleID]int) // 0 unvisited, 1 visiting, 2 done + var visit func(ModuleID, []ModuleID) error + visit = func(id ModuleID, path []ModuleID) error { + switch state[id] { + case 1: + return &Error{Code: ErrInvalid, Detail: fmt.Sprintf("module requirement cycle through %q", id)} + case 2: + return nil + } + state[id] = 1 + for _, req := range r.modules[id].Requires { + dep, ok := r.modules[req.Module] + if !ok { + return &Error{Code: ErrInvalid, Detail: fmt.Sprintf("module %q requires unregistered module %q", id, req.Module)} + } + for typ, versions := range req.Events { + entry, ok := r.events[typ] + if !ok || entry.module != dep.ID { + return &Error{Code: ErrInvalid, Type: typ, Detail: fmt.Sprintf("module %q requires event not owned by %q", id, req.Module)} + } + if !containsVersion(versions, entry.def.Current) { + return &Error{Code: ErrInvalid, Type: typ, Detail: fmt.Sprintf("module %q handles versions %v but %q currently writes v%d", id, versions, req.Module, entry.def.Current)} + } + } + if err := visit(req.Module, append(path, id)); err != nil { + return err + } + } + state[id] = 2 + return nil + } + for id := range r.modules { + if err := visit(id, nil); err != nil { + return err + } + } + // 2. projections stay inside module + Requires. + for k, p := range r.projections { + scope := map[ModuleID]struct{}{p.module: {}} + for _, req := range r.modules[p.module].Requires { + scope[req.Module] = struct{}{} + } + for _, typ := range append(append([]session.EventType(nil), p.def.Consumes...), p.def.Ignores...) { + owner, ok := r.ModuleForEvent(typ) + if !ok { + return &Error{Code: ErrInvalid, Type: typ, Detail: fmt.Sprintf("projection %q consumes unregistered event", k.id)} + } + if _, inScope := scope[owner]; !inScope { + return &Error{Code: ErrInvalid, Type: typ, Detail: fmt.Sprintf("projection %q consumes event of module %q outside its Requires", k.id, owner)} + } + } + for _, m := range p.def.RequireComplete { + if _, inScope := scope[m]; !inScope { + return &Error{Code: ErrInvalid, Detail: fmt.Sprintf("projection %q requires completeness of module %q outside its scope", k.id, m)} + } + } + for _, c := range p.def.Consumes { + for _, i := range p.def.Ignores { + if c == i { + return &Error{Code: ErrInvalid, Type: c, Detail: fmt.Sprintf("projection %q both consumes and ignores", k.id)} + } + } + } + } + return nil +} + +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) (ModuleID, EventDefinition, bool) { + e, ok := r.events[typ] + return e.module, e.def, ok +} + +func (r *Registry) ModuleForEvent(typ session.EventType) (ModuleID, bool) { + e, ok := r.events[typ] + if ok { + return e.module, true + } + // An unregistered type still belongs to a module namespace by prefix. + parts := strings.SplitN(string(typ), "/", 3) + if len(parts) == 3 && parts[0] == string(SourceTwilight) { + if _, registered := r.modules[ModuleID(parts[1])]; registered { + return ModuleID(parts[1]), false + } + } + return "", false +} + +func (r *Registry) LookupProjection(id ProjectionID, v ProjectionVersion) (ProjectionDefinition, bool) { + e, ok := r.projections[projectionKey{id, v}] + return e.def, ok +} + +// ModulePrefix is the EventType prefix of one module. +func ModulePrefix(id ModuleID) session.EventType { + return session.EventType(fmt.Sprintf("%s/%s/", SourceTwilight, 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()} + } + // Round trip: the persisted bytes must decode to the same canonical form. + back, err := codec.Decode(body) + if err != nil { + return jsonstable.Value{}, 0, &Error{Code: ErrCodec, Type: typ, Detail: "decode round trip: " + err.Error()} + } + again, err := codec.Encode(back) + if err != nil || !again.Equal(body) { + return jsonstable.Value{}, 0, &Error{Code: ErrCodec, Type: typ, Detail: "encode/decode/encode is not canonical-stable"} + } + 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.ModuleID, _ = r.ModuleForEvent(e.Type) + out.Unknown = true + return out, nil + } + out.ModuleID = 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" + ErrStale ErrorCode = "stale" + ErrUnsupportedProfile ErrorCode = "unsupported_profile" +) + +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/memory.go b/agent/session/memory.go new file mode 100644 index 0000000..61a0d56 --- /dev/null +++ b/agent/session/memory.go @@ -0,0 +1,644 @@ +package session + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + + "github.com/memohai/twilight/agent/es" +) + +// MemoryStore is the in-process reference Store. Each Session has its own +// critical section; the control-plane KV and snapshot tables share the +// Session's lock so a CommitIn transaction is atomic with respect to every +// other operation on the same Session. +type MemoryStore struct { + profile ProtocolProfile + mu sync.RWMutex // guards sessions map + sessions map[SessionID]*memorySession +} + +type memorySession struct { + mu sync.Mutex + header SessionHeader + commits []SessionCommit + byID map[CommitID]int + snapshots map[snapshotKey]Snapshot + control map[ControlNamespace]map[string]ControlEntry + eventIDs map[EventID]struct{} +} + +type snapshotKey struct { + key ProjectionKey + version uint16 +} + +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, 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, + byID: make(map[CommitID]int), + snapshots: make(map[snapshotKey]Snapshot), + control: make(map[ControlNamespace]map[string]ControlEntry), + eventIDs: make(map[EventID]struct{}), + } + 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 (m *MemoryStore) Head(ctx context.Context, sid SessionID) (Head, error) { + if err := ctx.Err(); err != nil { + return Head{}, err + } + s, err := m.session(sid, "head") + if err != nil { + return Head{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + return s.head(), nil +} + +func (s *memorySession) head() Head { + if len(s.commits) == 0 { + return Head{Revision: 0, Digest: s.header.HeaderDigest} + } + last := s.commits[len(s.commits)-1] + return Head{Revision: last.Revision, Digest: last.CommitDigest} +} + +func (m *MemoryStore) LookupCommit(ctx context.Context, sid SessionID, id CommitID) (SessionCommit, bool, error) { + if err := ctx.Err(); err != nil { + return SessionCommit{}, false, err + } + s, err := m.session(sid, "lookup_commit") + if err != nil { + return SessionCommit{}, false, err + } + s.mu.Lock() + defer s.mu.Unlock() + return s.lookup(id) +} + +func (s *memorySession) lookup(id CommitID) (SessionCommit, bool, error) { + i, ok := s.byID[id] + if !ok { + return SessionCommit{}, false, nil + } + return cloneCommit(&s.commits[i]), true, nil +} + +// --- transactions ----------------------------------------------------------- + +// memoryTx stages snapshot and KV writes; they are merged only when the +// transaction completes, so a failing fn leaves nothing behind. +type memoryTx struct { + s *memorySession + snapshots map[snapshotKey]Snapshot + control map[ControlNamespace]map[string]*ControlEntry // nil entry = delete + used bool +} + +func (s *memorySession) begin() *memoryTx { + return &memoryTx{s: s, snapshots: make(map[snapshotKey]Snapshot), control: make(map[ControlNamespace]map[string]*ControlEntry)} +} + +func (tx *memoryTx) Head() Head { return tx.s.head() } + +func (tx *memoryTx) LookupCommit(id CommitID) (SessionCommit, bool, error) { return tx.s.lookup(id) } + +func (tx *memoryTx) Tail(after Head, types []EventType) ([]SessionCommit, error) { + return tx.s.tail(after, types) +} + +func (s *memorySession) tail(after Head, types []EventType) ([]SessionCommit, error) { + if after.Revision > 0 { + if int(after.Revision) > len(s.commits) { + return nil, newError(ErrInvalid, "tail", s.header.SessionID, "after is beyond head") + } + if s.commits[after.Revision-1].CommitDigest != after.Digest { + return nil, newError(ErrInvalid, "tail", s.header.SessionID, "after digest is not on this stream") + } + } else if after.Digest != "" && after.Digest != s.header.HeaderDigest { + return nil, newError(ErrInvalid, "tail", s.header.SessionID, "after digest is not this header") + } + var out []SessionCommit + for i := int(after.Revision); i < len(s.commits); i++ { + if CommitMatchesTypes(&s.commits[i], types) { + out = append(out, cloneCommit(&s.commits[i])) + } + } + return out, nil +} + +func (tx *memoryTx) LoadSnapshot(key ProjectionKey, version uint16) (SnapshotResult, error) { + k := snapshotKey{key, version} + if snap, ok := tx.snapshots[k]; ok { + return SnapshotResult{Snapshot: &snap, Found: true}, nil + } + return tx.s.loadSnapshot(k), nil +} + +func (s *memorySession) loadSnapshot(k snapshotKey) SnapshotResult { + snap, ok := s.snapshots[k] + if !ok { + return SnapshotResult{} + } + return SnapshotResult{Snapshot: &snap, Found: true} +} + +func (tx *memoryTx) SaveSnapshot(snap Snapshot) error { + if err := tx.s.checkSnapshot(&snap); err != nil { + return err + } + tx.snapshots[snapshotKey{snap.ProjectionKey, snap.ProjectionVersion}] = snap + return nil +} + +func (s *memorySession) checkSnapshot(snap *Snapshot) error { + if snap.SessionID != s.header.SessionID { + return newError(ErrInvalid, "save_snapshot", s.header.SessionID, "snapshot session mismatch") + } + if snap.ProjectionKey == "" || snap.State.IsZero() { + return newError(ErrInvalid, "save_snapshot", s.header.SessionID, "empty projection key or state") + } + return nil +} + +func (tx *memoryTx) ControlGet(ns ControlNamespace, key string) (ControlEntry, bool, error) { + if staged, ok := tx.control[ns][key]; ok { + if staged == nil { + return ControlEntry{}, false, nil + } + return cloneEntry(*staged), true, nil + } + return tx.s.controlGet(ns, key) +} + +func (s *memorySession) controlGet(ns ControlNamespace, key string) (ControlEntry, bool, error) { + e, ok := s.control[ns][key] + if !ok { + return ControlEntry{}, false, nil + } + return cloneEntry(e), true, nil +} + +func (tx *memoryTx) ControlPut(ns ControlNamespace, key string, value []byte, deadline int64) error { + if ns == "" || key == "" { + return newError(ErrInvalid, "control_put", tx.s.header.SessionID, "empty namespace or key") + } + if tx.control[ns] == nil { + tx.control[ns] = make(map[string]*ControlEntry) + } + e := ControlEntry{SessionID: tx.s.header.SessionID, Namespace: ns, Key: key, Value: append([]byte(nil), value...), DeadlineUnixMilli: deadline} + tx.control[ns][key] = &e + return nil +} + +func (tx *memoryTx) ControlDelete(ns ControlNamespace, key string) error { + if tx.control[ns] == nil { + tx.control[ns] = make(map[string]*ControlEntry) + } + tx.control[ns][key] = nil + return nil +} + +// merge applies the staged writes; called with the session lock held after +// the append (if any) succeeded. +func (tx *memoryTx) merge() { + for k, snap := range tx.snapshots { + tx.s.snapshots[k] = snap + } + for ns, entries := range tx.control { + if tx.s.control[ns] == nil { + tx.s.control[ns] = make(map[string]ControlEntry) + } + for key, e := range entries { + if e == nil { + delete(tx.s.control[ns], key) + } else { + tx.s.control[ns][key] = *e + } + } + } +} + +func (m *MemoryStore) CommitIn(ctx context.Context, sid SessionID, fn CommitInFn) (AppendResult, error) { + if err := ctx.Err(); err != nil { + return AppendResult{}, err + } + if fn == nil { + return AppendResult{}, newError(ErrInvalid, "commit_in", sid, "nil fn") + } + s, err := m.session(sid, "commit_in") + if err != nil { + return AppendResult{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + tx := s.begin() + req, err := fn(tx) + if err != nil { + return AppendResult{}, err + } + if req == nil { + tx.merge() + return AppendResult{Disposition: AppendApplied, ActualHead: s.head()}, nil + } + if req.SessionID != sid { + return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: "append session mismatch"}, nil + } + if req.ExpectedHead != s.head() { + return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: "ExpectedHead does not match the head inside the critical section"}, nil + } + res, err := m.append(s, req) + if err != nil { + return AppendResult{}, err + } + if res.Disposition == AppendApplied || res.Disposition == AppendAlreadyApplied { + tx.merge() + } + return res, nil +} + +func (m *MemoryStore) Commit(ctx context.Context, req AppendRequest) (AppendResult, error) { + if err := ctx.Err(); err != nil { + return AppendResult{}, err + } + s, err := m.session(req.SessionID, "commit") + if err != nil { + return AppendResult{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + // Idempotent replay is decided before the head compare so a retry after + // a lost response returns AlreadyApplied even if the head has moved. + if existing, ok, _ := s.lookup(req.CommitID); ok { + return m.replayDisposition(s, &existing, &req) + } + if req.ExpectedHead != s.head() { + return AppendResult{Disposition: AppendHeadConflict, ActualHead: s.head()}, nil + } + return m.append(s, &req) +} + +func (m *MemoryStore) replayDisposition(s *memorySession, existing *SessionCommit, req *AppendRequest) (AppendResult, error) { + have, err := fingerprintCommit(m.profile, existing) + if err != nil { + return AppendResult{}, err + } + want, err := m.profile.FingerprintAppend(*req) + if err != nil { + return AppendResult{}, err + } + if have == want { + c := cloneCommit(existing) + return AppendResult{Disposition: AppendAlreadyApplied, Commit: &c, ActualHead: s.head()}, nil + } + return AppendResult{Disposition: AppendCommitConflict, ActualHead: s.head()}, nil +} + +// append seals and stores one commit; the caller holds the session lock and +// has already checked ExpectedHead. +func (m *MemoryStore) append(s *memorySession, req *AppendRequest) (AppendResult, error) { + if existing, ok, _ := s.lookup(req.CommitID); ok { + return m.replayDisposition(s, &existing, req) + } + if req.CommitID == "" { + return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: "empty CommitID"}, nil + } + if len(req.Events) == 0 { + return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: "empty event group"}, nil + } + seen := make(map[EventID]struct{}, len(req.Events)) + for i := range req.Events { + e := &req.Events[i] + if err := validateUncommitted(e); err != nil { + return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: fmt.Sprintf("event %d: %v", i, err)}, nil + } + if _, dup := seen[e.EventID]; dup { + return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: "duplicate EventID in group"}, nil + } + if _, dup := s.eventIDs[e.EventID]; dup { + return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: "EventID already in stream"}, nil + } + seen[e.EventID] = struct{}{} + for _, src := range e.SourceEvents { + if _, ok := s.eventIDs[src]; !ok { + if _, inGroup := seen[src]; !inGroup { + return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: "SourceEvents references an unknown EventID"}, nil + } + } + } + } + head := s.head() + commit := SessionCommit{ + ProtocolVersion: m.profile.Version(), + SessionID: req.SessionID, + Revision: head.Revision + 1, + PreviousDigest: head.Digest, + CommitID: req.CommitID, + CausationID: req.CausationID, + CorrelationID: req.CorrelationID, + Events: make([]SessionEvent, len(req.Events)), + } + for i := range req.Events { + e := &req.Events[i] + ev := SessionEvent{EventID: e.EventID, Index: uint16(i), Type: e.Type, RecordedAtUnixMilli: e.RecordedAtUnixMilli, + SourceEvents: append([]EventID(nil), e.SourceEvents...), Payload: e.Payload} + d, err := m.profile.EventDigest(commit.SessionID, commit.Revision, ev) + if err != nil { + return AppendResult{}, err + } + ev.EventDigest = d + commit.Events[i] = ev + } + d, err := m.profile.CommitDigest(commit) + if err != nil { + return AppendResult{}, err + } + commit.CommitDigest = d + s.commits = append(s.commits, commit) + s.byID[commit.CommitID] = len(s.commits) - 1 + for i := range commit.Events { + s.eventIDs[commit.Events[i].EventID] = struct{}{} + } + out := cloneCommit(&commit) + return AppendResult{Disposition: AppendApplied, Commit: &out, ActualHead: s.head()}, nil +} + +// --- replay ----------------------------------------------------------------- + +func (m *MemoryStore) Replay(ctx context.Context, req ReplayRequest) (ReplayPage, error) { + if err := ctx.Err(); err != nil { + return ReplayPage{}, err + } + s, err := m.session(req.SessionID, "replay") + if err != nil { + return ReplayPage{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + start := 0 + if req.Cursor != nil && req.Cursor.After != nil { + after := req.Cursor.After + if after.Revision == 0 || int(after.Revision) > len(s.commits) { + return ReplayPage{}, newError(ErrInvalid, "replay", req.SessionID, "cursor revision is not on this stream") + } + c := &s.commits[after.Revision-1] + if int(after.Index) >= len(c.Events) || c.Events[after.Index].EventDigest != after.EventDigest { + return ReplayPage{}, newError(ErrInvalid, "replay", req.SessionID, "cursor event is not on this stream") + } + if int(after.Index) != len(c.Events)-1 { + return ReplayPage{}, newError(ErrInvalid, "replay", req.SessionID, "cursor must address the last event of a complete commit") + } + start = int(after.Revision) + } + page := ReplayPage{Header: s.header, Head: s.head()} + var prev es.Digest + if start == 0 { + prev = s.header.HeaderDigest + } else { + prev = s.commits[start-1].CommitDigest + } + for i := start; i < len(s.commits); i++ { + c := &s.commits[i] + if len(req.Types) == 0 { + if c.Revision != es.Revision(i+1) || c.PreviousDigest != prev { + return ReplayPage{}, newError(ErrCorrupt, "replay", req.SessionID, fmt.Sprintf("chain broken at revision %d", i+1)) + } + if err := m.profile.ValidateCommit(*c); err != nil { + return ReplayPage{}, err + } + prev = c.CommitDigest + } + if !CommitMatchesTypes(c, req.Types) { + continue + } + page.Commits = append(page.Commits, cloneCommit(c)) + if req.Limit > 0 && uint32(len(page.Commits)) >= req.Limit && i+1 < len(s.commits) { + last := &c.Events[len(c.Events)-1] + page.Next = &ReplayCursor{After: &EventPosition{Revision: c.Revision, Index: last.Index, EventDigest: last.EventDigest}} + break + } + } + return page, nil +} + +// --- snapshots ---------------------------------------------------------------- + +func (m *MemoryStore) LoadSnapshot(ctx context.Context, req SnapshotRequest) (SnapshotResult, error) { + if err := ctx.Err(); err != nil { + return SnapshotResult{}, err + } + s, err := m.session(req.SessionID, "load_snapshot") + if err != nil { + return SnapshotResult{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + return s.loadSnapshot(snapshotKey{req.ProjectionKey, req.ProjectionVersion}), nil +} + +func (m *MemoryStore) SaveSnapshot(ctx context.Context, req SaveSnapshotRequest) (SaveSnapshotResult, error) { + if err := ctx.Err(); err != nil { + return SaveSnapshotResult{}, err + } + s, err := m.session(req.Snapshot.SessionID, "save_snapshot") + if err != nil { + return SaveSnapshotResult{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + if err := s.checkSnapshot(&req.Snapshot); err != nil { + return SaveSnapshotResult{}, err + } + k := snapshotKey{req.Snapshot.ProjectionKey, req.Snapshot.ProjectionVersion} + _, replaced := s.snapshots[k] + s.snapshots[k] = req.Snapshot + return SaveSnapshotResult{Snapshot: req.Snapshot, Replaced: replaced}, nil +} + +// --- control-plane KV ----------------------------------------------------------- + +func (m *MemoryStore) ControlGet(ctx context.Context, sid SessionID, ns ControlNamespace, key string) (ControlEntry, bool, error) { + if err := ctx.Err(); err != nil { + return ControlEntry{}, false, err + } + s, err := m.session(sid, "control_get") + if err != nil { + return ControlEntry{}, false, err + } + s.mu.Lock() + defer s.mu.Unlock() + return s.controlGet(ns, key) +} + +func (m *MemoryStore) ControlPut(ctx context.Context, sid SessionID, ns ControlNamespace, key string, value []byte, deadline int64) error { + if err := ctx.Err(); err != nil { + return err + } + s, err := m.session(sid, "control_put") + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + tx := s.begin() + if err := tx.ControlPut(ns, key, value, deadline); err != nil { + return err + } + tx.merge() + return nil +} + +func (m *MemoryStore) ControlCompareAndPut(ctx context.Context, sid SessionID, ns ControlNamespace, key string, expected, value []byte, deadline int64) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + s, err := m.session(sid, "control_cas") + if err != nil { + return false, err + } + s.mu.Lock() + defer s.mu.Unlock() + cur, ok := s.control[ns][key] + if !ok || string(cur.Value) != string(expected) { + return false, nil + } + tx := s.begin() + if err := tx.ControlPut(ns, key, value, deadline); err != nil { + return false, err + } + tx.merge() + return true, nil +} + +func (m *MemoryStore) ControlDelete(ctx context.Context, sid SessionID, ns ControlNamespace, key string) error { + if err := ctx.Err(); err != nil { + return err + } + s, err := m.session(sid, "control_delete") + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + delete(s.control[ns], key) + return nil +} + +func (m *MemoryStore) ControlScan(ctx context.Context, ns ControlNamespace, prefix string, fn func(ControlEntry) (bool, error)) error { + return m.scan(ctx, ns, func(e ControlEntry) bool { return strings.HasPrefix(e.Key, prefix) }, fn) +} + +func (m *MemoryStore) ControlExpired(ctx context.Context, ns ControlNamespace, before int64, fn func(ControlEntry) (bool, error)) error { + return m.scan(ctx, ns, func(e ControlEntry) bool { return e.DeadlineUnixMilli != 0 && e.DeadlineUnixMilli < before }, fn) +} + +func (m *MemoryStore) scan(ctx context.Context, ns ControlNamespace, match func(ControlEntry) bool, fn func(ControlEntry) (bool, error)) error { + if err := ctx.Err(); err != nil { + return err + } + m.mu.RLock() + sids := make([]SessionID, 0, len(m.sessions)) + for sid := range m.sessions { + sids = append(sids, sid) + } + m.mu.RUnlock() + sort.Slice(sids, func(i, j int) bool { return sids[i] < sids[j] }) + for _, sid := range sids { + s, err := m.session(sid, "control_scan") + if err != nil { + continue + } + s.mu.Lock() + var matched []ControlEntry + for _, e := range s.control[ns] { + if match(e) { + matched = append(matched, cloneEntry(e)) + } + } + s.mu.Unlock() + sort.Slice(matched, func(i, j int) bool { return matched[i].Key < matched[j].Key }) + for _, e := range matched { + cont, err := fn(e) + if err != nil { + return err + } + if !cont { + return nil + } + } + } + return nil +} + +func cloneCommit(c *SessionCommit) SessionCommit { + out := *c + out.Events = make([]SessionEvent, len(c.Events)) + for i := range c.Events { + out.Events[i] = c.Events[i] + out.Events[i].SourceEvents = append([]EventID(nil), c.Events[i].SourceEvents...) + } + return out +} + +func cloneEntry(e ControlEntry) ControlEntry { + e.Value = append([]byte(nil), e.Value...) + return e +} + +var _ Store = (*MemoryStore)(nil) diff --git a/agent/session/memory_test.go b/agent/session/memory_test.go new file mode 100644 index 0000000..ee78347 --- /dev/null +++ b/agent/session/memory_test.go @@ -0,0 +1,152 @@ +package session + +import ( + "context" + "testing" + + "github.com/memohai/twilight/agent/jsonstable" +) + +func newSession(t *testing.T) (*MemoryStore, SessionID) { + t.Helper() + store := NewMemoryStore() + if _, err := store.Create(context.Background(), CreateRequest{ProtocolVersion: ProtocolVersion1, SessionID: "s"}); err != nil { + t.Fatal(err) + } + return store, "s" +} + +func ev(id, typ, payload string) UncommittedEvent { + return UncommittedEvent{EventID: EventID(id), Type: EventType(typ), Payload: jsonstable.MustParse(payload), RecordedAtUnixMilli: 1} +} + +func TestCreateIsIdempotentAndConflicts(t *testing.T) { + store, _ := newSession(t) + ctx := context.Background() + if _, err := store.Create(ctx, CreateRequest{ProtocolVersion: ProtocolVersion1, SessionID: "s"}); err != nil { + t.Fatalf("identical create: %v", err) + } + if _, err := store.Create(ctx, CreateRequest{ProtocolVersion: ProtocolVersion1, SessionID: "s", CausationID: "other"}); !IsCode(err, ErrConflict) { + t.Fatalf("different create: %v, want conflict", err) + } + if _, err := store.Create(ctx, CreateRequest{ProtocolVersion: 9, SessionID: "x"}); !IsCode(err, ErrUnsupportedProfile) { + t.Fatalf("unsupported profile: %v", err) + } +} + +// Commit is CAS; a retry of the same CommitID is AlreadyApplied even with a +// different timestamp and a moved head; a same-ID different group conflicts; +// CommitIn produces an equivalent commit. +func TestCommitIdempotencyAndConflicts(t *testing.T) { + store, sid := newSession(t) + ctx := context.Background() + head, _ := store.Head(ctx, sid) + if head.Revision != 0 || head.Digest == "" { + t.Fatalf("empty head = %+v", head) + } + first := AppendRequest{SessionID: sid, ExpectedHead: head, CommitID: "c1", Events: []UncommittedEvent{ev("e1", "twilight/x/a", `{"v":1}`)}} + res, err := store.Commit(ctx, first) + if err != nil || res.Disposition != AppendApplied || res.Commit.Revision != 1 { + t.Fatalf("first commit = %+v, %v", res, err) + } + stale, _ := store.Commit(ctx, AppendRequest{SessionID: sid, ExpectedHead: head, CommitID: "c2", Events: []UncommittedEvent{ev("e2", "twilight/x/a", `{"v":1}`)}}) + if stale.Disposition != AppendHeadConflict || stale.ActualHead.Revision != 1 { + t.Fatalf("stale commit = %+v", stale) + } + retry := first + retry.Events = []UncommittedEvent{ev("e1", "twilight/x/a", `{"v":1}`)} + retry.Events[0].RecordedAtUnixMilli = 99 + again, _ := store.Commit(ctx, retry) + if again.Disposition != AppendAlreadyApplied || again.Commit.Events[0].RecordedAtUnixMilli != 1 { + t.Fatalf("retry = %+v", again) + } + conflict, _ := store.Commit(ctx, AppendRequest{SessionID: sid, ExpectedHead: res.ActualHead, CommitID: "c1", Events: []UncommittedEvent{ev("e9", "twilight/x/a", `{"v":2}`)}}) + if conflict.Disposition != AppendCommitConflict { + t.Fatalf("same id different group = %+v", conflict) + } + // CommitIn: KV write in the same transaction; fn nil still commits KV. + inRes, err := store.CommitIn(ctx, sid, func(tx SessionTx) (*AppendRequest, error) { + if err := tx.ControlPut("ns", "k", []byte("v"), 0); err != nil { + return nil, err + } + return &AppendRequest{SessionID: sid, ExpectedHead: tx.Head(), CommitID: "c3", Events: []UncommittedEvent{ev("e3", "twilight/y/b", `{"v":1}`)}}, nil + }) + if err != nil || inRes.Disposition != AppendApplied || inRes.Commit.Revision != 2 { + t.Fatalf("commit in = %+v, %v", inRes, err) + } + if entry, ok, _ := store.ControlGet(ctx, sid, "ns", "k"); !ok || string(entry.Value) != "v" { + t.Fatal("KV write did not commit with the transaction") + } + noop, err := store.CommitIn(ctx, sid, func(tx SessionTx) (*AppendRequest, error) { + return nil, tx.ControlDelete("ns", "k") + }) + if err != nil || noop.Disposition != AppendApplied || noop.Commit != nil { + t.Fatalf("noop = %+v, %v", noop, err) + } + if _, ok, _ := store.ControlGet(ctx, sid, "ns", "k"); ok { + t.Fatal("nil fn did not commit its KV delete") + } + // Chain and replay filter. + page, err := store.Replay(ctx, ReplayRequest{SessionID: sid}) + if err != nil || len(page.Commits) != 2 || page.Commits[1].PreviousDigest != page.Commits[0].CommitDigest { + t.Fatalf("replay = %+v, %v", page, err) + } + filtered, _ := store.Replay(ctx, ReplayRequest{SessionID: sid, Types: []EventType{"twilight/y/"}}) + if len(filtered.Commits) != 1 || filtered.Commits[0].CommitID != "c3" { + t.Fatalf("filtered = %+v", filtered.Commits) + } +} + +func TestControlCompareAndPutAndExpired(t *testing.T) { + store, sid := newSession(t) + ctx := context.Background() + if ok, _ := store.ControlCompareAndPut(ctx, sid, "ns", "missing", nil, []byte("x"), 0); ok { + t.Fatal("CAS wrote a missing entry") + } + _ = store.ControlPut(ctx, sid, "ns", "k", []byte("a"), 10) + if ok, _ := store.ControlCompareAndPut(ctx, sid, "ns", "k", []byte("b"), []byte("c"), 20); ok { + t.Fatal("CAS wrote with a stale expected value") + } + if ok, _ := store.ControlCompareAndPut(ctx, sid, "ns", "k", []byte("a"), []byte("a"), 20); !ok { + t.Fatal("CAS refused a matching expected value") + } + _ = store.ControlPut(ctx, sid, "ns", "never", []byte("n"), 0) + var seen []string + _ = store.ControlExpired(ctx, "ns", 25, func(e ControlEntry) (bool, error) { seen = append(seen, e.Key); return true, nil }) + if len(seen) != 1 || seen[0] != "k" { + t.Fatalf("expired = %v, want [k]", seen) + } + seen = nil + _ = store.ControlExpired(ctx, "ns", 15, func(e ControlEntry) (bool, error) { seen = append(seen, e.Key); return true, nil }) + if len(seen) != 0 { + t.Fatalf("renewed entry reported expired: %v", seen) + } +} + +func TestSnapshotThroughMustBeAPrefix(t *testing.T) { + store, sid := newSession(t) + ctx := context.Background() + head, _ := store.Head(ctx, sid) + res, _ := store.Commit(ctx, AppendRequest{SessionID: sid, ExpectedHead: head, CommitID: "c1", Events: []UncommittedEvent{ev("e1", "twilight/x/a", `{}`)}}) + snap := Snapshot{ProtocolVersion: ProtocolVersion1, SessionID: sid, ProjectionKey: "p", ProjectionVersion: 1, Through: res.ActualHead, State: jsonstable.MustParse(`{"n":1}`)} + d, _ := ProfileV1().SnapshotDigest(snap) + snap.SnapshotDigest = d + if _, err := store.SaveSnapshot(ctx, SaveSnapshotRequest{Snapshot: snap}); err != nil { + t.Fatal(err) + } + loaded, _ := store.LoadSnapshot(ctx, SnapshotRequest{SessionID: sid, ProjectionKey: "p", ProjectionVersion: 1}) + if !loaded.Found || loaded.Snapshot.Through != res.ActualHead { + t.Fatalf("snapshot = %+v", loaded) + } + tampered := *loaded.Snapshot + tampered.Through.Digest = "sha256:bogus" + if err := ProfileV1().ValidateSnapshot(tampered); err == nil { + t.Fatal("tampered snapshot validated") + } + if _, err := store.CommitIn(ctx, sid, func(tx SessionTx) (*AppendRequest, error) { + _, err := tx.Tail(tampered.Through, nil) + return nil, err + }); !IsCode(err, ErrInvalid) { + t.Fatalf("tail from a non-prefix head: %v, want invalid", err) + } +} diff --git a/agent/session/profile.go b/agent/session/profile.go new file mode 100644 index 0000000..f1a138a --- /dev/null +++ b/agent/session/profile.go @@ -0,0 +1,267 @@ +package session + +import ( + "bytes" + "errors" + "fmt" + "sort" + "unicode/utf8" + + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/jsonstable" +) + +// ProtocolProfile freezes the kernel wire for one ProtocolVersion +// (SES-WIR-2): encodings, digest preimages and the append fingerprint. +type ProtocolProfile interface { + Version() uint16 + HeaderDigest(SessionHeader) (es.Digest, error) + EventDigest(SessionID, es.Revision, SessionEvent) (es.Digest, error) + CommitDigest(SessionCommit) (es.Digest, error) + SnapshotDigest(Snapshot) (es.Digest, error) + FingerprintAppend(AppendRequest) (es.Digest, error) + ValidateHeader(SessionHeader) error + ValidateCommit(SessionCommit) error + ValidateSnapshot(Snapshot) 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"` + 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.CausationID, h.Metadata}) +} + +type eventDigestBody struct { + SessionID SessionID `json:"sessionId"` + Revision es.Revision `json:"revision"` + Index uint16 `json:"index"` + EventID EventID `json:"eventId"` + Type EventType `json:"type"` + RecordedAtUnixMilli int64 `json:"recordedAtUnixMilli"` + SourceEvents []EventID `json:"sourceEvents,omitempty"` + Payload jsonstable.Value `json:"payload"` +} + +func (profileV1) EventDigest(sid SessionID, rev es.Revision, e SessionEvent) (es.Digest, error) { + return digestDomain("twilight/session/event", eventDigestBody{sid, rev, e.Index, e.EventID, e.Type, e.RecordedAtUnixMilli, e.SourceEvents, e.Payload}) +} + +type commitDigestBody struct { + ProtocolVersion uint16 `json:"protocolVersion"` + SessionID SessionID `json:"sessionId"` + Revision es.Revision `json:"revision"` + PreviousDigest es.Digest `json:"previousDigest"` + CommitID CommitID `json:"commitId"` + CausationID es.CausationID `json:"causationId,omitempty"` + CorrelationID string `json:"correlationId,omitempty"` + Events []es.Digest `json:"events"` +} + +func (profileV1) CommitDigest(c SessionCommit) (es.Digest, error) { + digests := make([]es.Digest, len(c.Events)) + for i := range c.Events { + digests[i] = c.Events[i].EventDigest + } + return digestDomain("twilight/session/commit", commitDigestBody{c.ProtocolVersion, c.SessionID, c.Revision, c.PreviousDigest, c.CommitID, c.CausationID, c.CorrelationID, digests}) +} + +type snapshotDigestBody struct { + ProtocolVersion uint16 `json:"protocolVersion"` + SessionID SessionID `json:"sessionId"` + ProjectionKey ProjectionKey `json:"projectionKey"` + ProjectionVersion uint16 `json:"projectionVersion"` + Through Head `json:"through"` + State jsonstable.Value `json:"state"` +} + +func (profileV1) SnapshotDigest(s Snapshot) (es.Digest, error) { + return digestDomain("twilight/session/snapshot", snapshotDigestBody{s.ProtocolVersion, s.SessionID, s.ProjectionKey, s.ProjectionVersion, s.Through, s.State}) +} + +type fingerprintEvent struct { + EventID EventID `json:"eventId"` + Type EventType `json:"type"` + SourceEvents []EventID `json:"sourceEvents,omitempty"` + Payload jsonstable.Value `json:"payload"` +} + +type fingerprintBody struct { + SessionID SessionID `json:"sessionId"` + CommitID CommitID `json:"commitId"` + CausationID es.CausationID `json:"causationId,omitempty"` + CorrelationID string `json:"correlationId,omitempty"` + Events []fingerprintEvent `json:"events"` +} + +// FingerprintAppend covers everything that makes a retry "the same append": +// not ExpectedHead and not RecordedAtUnixMilli (SES-APP-1). +func (profileV1) FingerprintAppend(r AppendRequest) (es.Digest, error) { + events := make([]fingerprintEvent, len(r.Events)) + for i, e := range r.Events { + events[i] = fingerprintEvent{e.EventID, e.Type, e.SourceEvents, e.Payload} + } + return digestDomain("twilight/session/append", fingerprintBody{r.SessionID, r.CommitID, r.CausationID, r.CorrelationID, events}) +} + +func fingerprintCommit(p ProtocolProfile, c *SessionCommit) (es.Digest, error) { + events := make([]UncommittedEvent, len(c.Events)) + for i, e := range c.Events { + events[i] = UncommittedEvent{EventID: e.EventID, Type: e.Type, SourceEvents: e.SourceEvents, Payload: e.Payload} + } + return p.FingerprintAppend(AppendRequest{SessionID: c.SessionID, CommitID: c.CommitID, CausationID: c.CausationID, CorrelationID: c.CorrelationID, Events: events}) +} + +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 +} + +func (p profileV1) ValidateCommit(c SessionCommit) error { + if c.ProtocolVersion != ProtocolVersion1 { + return &Error{Code: ErrUnsupportedProfile, Operation: "commit", SessionID: c.SessionID, CommitID: c.CommitID} + } + if c.Revision == 0 || len(c.Events) == 0 { + return &Error{Code: ErrCorrupt, Operation: "commit", SessionID: c.SessionID, CommitID: c.CommitID, Detail: "empty commit or zero revision"} + } + for i := range c.Events { + e := &c.Events[i] + if int(e.Index) != i { + return &Error{Code: ErrCorrupt, Operation: "commit", SessionID: c.SessionID, CommitID: c.CommitID, Detail: "event index gap"} + } + want, err := p.EventDigest(c.SessionID, c.Revision, *e) + if err != nil { + return err + } + if e.EventDigest != want { + return &Error{Code: ErrCorrupt, Operation: "commit", SessionID: c.SessionID, CommitID: c.CommitID, Detail: fmt.Sprintf("event %d digest mismatch", i)} + } + } + want, err := p.CommitDigest(c) + if err != nil { + return err + } + if c.CommitDigest != want { + return &Error{Code: ErrCorrupt, Operation: "commit", SessionID: c.SessionID, CommitID: c.CommitID, Detail: "commit digest mismatch"} + } + return nil +} + +func (p profileV1) ValidateSnapshot(s Snapshot) error { + if s.ProtocolVersion != ProtocolVersion1 { + return &Error{Code: ErrUnsupportedProfile, Operation: "snapshot", SessionID: s.SessionID} + } + want, err := p.SnapshotDigest(s) + if err != nil { + return err + } + if s.SnapshotDigest != want { + return newError(ErrCorrupt, "snapshot", s.SessionID, "snapshot digest mismatch") + } + return nil +} + +// validateUncommitted checks one event of an append group before it is +// sealed: identities, canonical payload and sorted-unique SourceEvents. +func validateUncommitted(e *UncommittedEvent) error { + if err := validIdentity("EventID", string(e.EventID)); err != nil { + return err + } + 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") + } + for i := range e.SourceEvents { + if err := validIdentity("SourceEvent", string(e.SourceEvents[i])); err != nil { + return err + } + if i > 0 && e.SourceEvents[i] <= e.SourceEvents[i-1] { + return errors.New("SourceEvents must be sorted and unique") + } + } + 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 +} + +// SortedUniqueEventIDs canonicalizes a SourceEvents set. +func SortedUniqueEventIDs(ids []EventID) []EventID { + if len(ids) == 0 { + return nil + } + out := append([]EventID(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++ + } + } + return out[:n] +} diff --git a/agent/session/run/module.go b/agent/session/run/module.go new file mode 100644 index 0000000..c770a4e --- /dev/null +++ b/agent/session/run/module.go @@ -0,0 +1,150 @@ +// 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/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/jsonstable" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/extension" +) + +const ( + ModuleID extension.ModuleID = "run" + // Prefix is the EventType namespace of every Run fact. + Prefix session.EventType = "twilight/run/" + // LeaseNamespace holds execution occupancy (RUN 5.1). + LeaseNamespace session.ControlNamespace = "twilight/run/lease" + // CommandNamespace is the control-plane idempotency index: CommitID -> + // command digest, written in the commit's transaction so an exact replay + // can be told from a same-ID conflict without persisting the command. + CommandNamespace session.ControlNamespace = "twilight/run/command" +) + +// 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{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..9d5436c --- /dev/null +++ b/agent/session/run/projection.go @@ -0,0 +1,169 @@ +package runmod + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/memohai/twilight/agent/jsonstable" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/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. +type Machine struct { + Active map[run.RunID]run.MachineState + Positions map[run.RunID]run.RunPosition + Schemas map[run.RunID]uint16 +} + +func newMachine() Machine { + return Machine{Active: map[run.RunID]run.MachineState{}, Positions: map[run.RunID]run.RunPosition{}, Schemas: map[run.RunID]uint16{}} +} + +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 + } + 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) + } + 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) + return out, nil + } + out.Active[ev.RunID] = next + out.Positions[ev.RunID] = run.RunPosition{Revision: e.Revision, Index: e.Event.Index} + return out, nil +} + +type machineWire struct { + Runs map[run.RunID]machineRunWire `json:"runs"` +} + +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} + } + 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 + } + return m, nil +} + +// MachineProjection consumes every twilight/run/ event (RequireComplete run). +var MachineProjection = extension.ProjectionDefinition{ + ID: MachineProjectionID, Version: 1, + Consumes: AllTypes(), + RequireComplete: []extension.ModuleID{ModuleID}, + 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..ff4d3ec --- /dev/null +++ b/agent/session/run/runtime.go @@ -0,0 +1,646 @@ +package runmod + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/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 commit (RUN-CMT-3 step 9). +type SourceDigestCarrier interface { + SourceDigest() es.Digest +} + +// SnapshotPolicy decides whether the machine projection snapshot is written +// in a commit's transaction. It sees the state 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 { + Store session.Store + Registry *extension.Registry + Appender extension.SemanticAppender + Projections extension.ProjectionReader + Frozen run.FrozenValueStore + Companion run.Companion + Snapshot SnapshotPolicy + // LeaseTTL zero means leases never expire (in-process occupancy only). + LeaseTTL time.Duration + Now func() time.Time +} + +// Runtime is the run.Runtime over a Session (RUN-CMT-1). +type Runtime struct { + cfg Config + leases extension.Leases +} + +func NewRuntime(cfg Config) (*Runtime, error) { + switch { + case cfg.Store == nil, cfg.Registry == nil, cfg.Appender == nil, cfg.Projections == nil: + return nil, errors.New("runmod: runtime requires store, registry, appender and projections") + 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, leases: extension.Leases{Store: cfg.Store}}, nil +} + +// NewMemoryFrozenValues is the in-process FrozenValueStore. +func NewMemoryFrozenValues() *run.MemoryFrozenValues { return run.NewMemoryFrozenValues() } + +func (r *Runtime) nowMilli() int64 { return r.cfg.Now().UnixMilli() } + +// --- 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 + } + state, head, err := r.cfg.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. + 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 + } + state, _, err := r.cfg.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 replays the Run's events, 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.Replay(ctx, session.ReplayRequest{SessionID: sid, Types: []session.EventType{Prefix}}) + if err != nil { + return run.RunRecord{}, err + } + commits := page.Commits + for page.Next != nil { + if page, err = r.cfg.Store.Replay(ctx, session.ReplayRequest{SessionID: sid, Types: []session.EventType{Prefix}, Cursor: page.Next}); err != nil { + return run.RunRecord{}, err + } + commits = append(commits, page.Commits...) + } + var record run.RunRecord + var position run.RunPosition + for ci := range commits { + c := &commits[ci] + for i := range c.Events { + e := &c.Events[i] + if !session.HasTypePrefix(e.Type, []session.EventType{Prefix}) { + continue + } + 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 = session.EventPosition{Revision: c.Revision, Index: e.Index, EventDigest: e.EventDigest} + } + record.Events = append(record.Events, *e) + record.Facts = append(record.Facts, ev.Fact) + position = run.RunPosition{Revision: c.Revision, Index: e.Index} + } + } + 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 + } + } + + var out run.CommitResult + var rejection error + res, err := r.cfg.Appender.AppendSemanticIn(ctx, sid, func(tx extension.SemanticTx) (*extension.SemanticGroup, error) { + group, result, reject, err := r.evaluate(tx, sid, &req) + if err != nil { + return nil, err + } + if reject != nil { + rejection = reject + return nil, errDiscard + } + out = result + return group, nil + }) + if err != nil { + if errors.Is(err, errDiscard) { + return run.CommitResult{}, rejection + } + return run.CommitResult{}, err + } + switch res.Outcome { + case extension.SemanticApplied: + out.Status = run.CommitAccepted + out.Commit = *res.Commit + out.Snapshot.Head = session.Head{Revision: res.Commit.Revision, Digest: res.Commit.CommitDigest} + out.Snapshot.Position = run.RunPosition{Revision: res.Commit.Revision, Index: uint16(out.Snapshot.Position.Index)} + return out, nil + case extension.SemanticNoop: + // evaluate found an exact replay and filled out. + return out, nil + case extension.SemanticAlreadyApplied: + out.Status = run.CommitAlreadyApplied + out.Commit = *res.Commit + return out, nil + case extension.SemanticCommitConflict: + return run.CommitResult{}, run.ErrCommandConflict + default: + return run.CommitResult{}, fmt.Errorf("runmod: commit: %s: %s", res.Outcome, res.Detail) + } +} + +var errDiscard = errors.New("runmod: discard") + +// evaluate is RUN-CMT-3 inside the transaction. 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(tx extension.SemanticTx, sid session.SessionID, req *run.CommitRequest) (*extension.SemanticGroup, run.CommitResult, error, error) { + env := &req.Command + commitID := session.CommitID(env.ID) + runID := env.RunID + + // Steps 2-3: exact replay vs same-ID conflict, via the control-plane + // command index and the stored commit. + if existing, found, err := tx.LookupCommit(commitID); err != nil { + return nil, run.CommitResult{}, nil, err + } else if found { + entry, ok, err := tx.ControlGet(CommandNamespace, string(commitID)) + if err != nil { + return nil, run.CommitResult{}, nil, err + } + if !ok || string(entry.Value) != string(env.Digest) { + return nil, run.CommitResult{}, run.ErrCommandConflict, nil + } + snapshot, err := r.snapshotIn(tx, sid, runID) + if err != nil { + return nil, run.CommitResult{}, nil, err + } + result := run.CommitResult{Status: run.CommitAlreadyApplied, Snapshot: snapshot, Commit: existing} + if run.IsStart(env.Command) { + // The start's grant is live only while the lease it minted is still + // the lease on record for this target. + key := run.GrantTarget(runID, env.Command) + if lease, has, err := extension.LookupLease(tx, LeaseNamespace, key); err != nil { + return nil, run.CommitResult{}, nil, err + } else if has && lease.Token == extension.DeriveLeaseToken(sid, LeaseNamespace, key, string(run.CommandClaim(env.Command)), commitID) { + result.Grant = run.ExecutionGrant(lease.Token) + } + } + return nil, result, nil, nil + } + + // Step 5: current state from snapshot plus filtered tail. + proj, before, err := r.loadMachine(tx) + if err != nil { + return nil, run.CommitResult{}, nil, err + } + state, active := proj.Active[runID] + if !active { + // Terminal Runs leave the projection; tell terminal from unknown. + if _, err := r.terminalState(tx, runID); err != nil { + return nil, run.CommitResult{}, err, nil + } + return nil, run.CommitResult{}, run.ErrRunTerminal, nil + } + schema := proj.Schemas[runID] + if env.SchemaVersion != schema { + return nil, run.CommitResult{}, 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, run.CommitResult{}, nil, err + } + + // Step 6: grant and recovery authority from the lease table. + key := run.GrantTarget(runID, env.Command) + var lease extension.Lease + hasLease := false + if key != "" { + lease, hasLease, err = extension.LookupLease(tx, LeaseNamespace, key) + if err != nil { + return nil, run.CommitResult{}, nil, err + } + } + claim := run.CommandClaim(env.Command) + grantValid := hasLease && req.Grant != "" && lease.Token == extension.LeaseToken(req.Grant) + if _, recovering := env.Command.(run.RecoverModelExecution); recovering && req.Grant != "" { + grantValid = grantValid && lease.Holder == string(claim) + } + // Recovery authority (RUN-CMT-6): the lease is expired and the command is + // bound to its holder, either by the carried Claim (model recovery) or by + // the derived tool-recovery CommandID (Unknown settlement). + recoveryValid := hasLease && req.Grant == "" && r.expired(lease) + if recoveryValid { + switch cmd := env.Command.(type) { + case run.RecoverModelExecution: + recoveryValid = lease.Holder == string(cmd.Claim) + case run.SubmitToolFailure: + recoveryValid = cmd.Outcome == run.ToolOutcomeUnknown && + env.ID == run.DeriveToolRecoveryCommandID(runID, cmd.StepID, cmd.CallID, run.ExecutionClaim(lease.Holder)) + default: + recoveryValid = false + } + } + + decision, err := run.EvaluateCommit(state, proj.Positions[runID], *req, grantValid, recoveryValid, proto) + if err != nil { + return nil, run.CommitResult{}, nil, err + } + switch decision.Kind { + case run.DecisionConflict: + return nil, run.CommitResult{}, run.ErrCommandConflict, nil + case run.DecisionStale: + if decision.Reject != nil && !errors.Is(decision.Reject, run.ErrStaleRuntime) { + return nil, run.CommitResult{}, fmt.Errorf("%w: %w", run.ErrStaleRuntime, decision.Reject), nil + } + return nil, run.CommitResult{}, run.ErrStaleRuntime, nil + case run.DecisionTerminal: + return nil, run.CommitResult{}, 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, run.CommitResult{}, nil, fmt.Errorf("runmod: companion: %w", err) + } + for _, me := range companion { + if session.HasTypePrefix(me.Type, []session.EventType{Prefix}) { + return nil, run.CommitResult{}, nil, errors.New("runmod: companion must not produce twilight/run/ events") + } + // A carried digest must be one a fact of this commit 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, run.CommitResult{}, nil, fmt.Errorf("runmod: companion %s SourceDigest is not recorded by a fact of this commit", 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}) + } + + // Step 10: lease changes, command index, snapshot. + var grant run.ExecutionGrant + switch { + case run.IsStart(env.Command): + acquired, err := extension.AcquireLease(tx, sid, commitID, now, extension.AcquireLeaseRequest{ + Namespace: LeaseNamespace, Key: key, Holder: string(claim), TTL: r.cfg.LeaseTTL}) + if err != nil { + var xerr *extension.Error + if errors.As(err, &xerr) && xerr.Code == extension.ErrConflict { + return nil, run.CommitResult{}, run.ErrStaleRuntime, nil + } + return nil, run.CommitResult{}, nil, err + } + grant = run.ExecutionGrant(acquired.Token) + case run.IsSettlement(env.Command) && hasLease: + if err := extension.ReleaseLease(tx, LeaseNamespace, key, lease.Token); err != nil { + return nil, run.CommitResult{}, nil, err + } + } + if decision.NewState.Status.Terminal() { + // Terminal commit revokes every grant of the Run (RUN-CMT-6): the + // Executing targets of the pre-state name the live leases. + for _, target := range executingTargets(&state) { + if l, has, err := extension.LookupLease(tx, LeaseNamespace, target); err != nil { + return nil, run.CommitResult{}, nil, err + } else if has { + if err := extension.ReleaseLease(tx, LeaseNamespace, target, l.Token); err != nil { + return nil, run.CommitResult{}, nil, err + } + } + } + } + if err := tx.ControlPut(CommandNamespace, string(commitID), []byte(env.Digest), 0); err != nil { + return nil, run.CommitResult{}, nil, err + } + // 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) + } + } + } + if r.cfg.Snapshot(&state, &decision.NewState) { + if err := extension.SaveSnapshotIn(tx, &MachineProjection, proj, before); err != nil { + return nil, run.CommitResult{}, nil, err + } + } + result := run.CommitResult{ + Snapshot: run.RuntimeSnapshot{State: decision.NewState, SchemaVersion: schema, + Position: run.RunPosition{Index: uint16(len(decision.Facts) - 1)}}, // Revision filled after append + Grant: grant, + } + return group, result, nil, nil +} + +func (r *Runtime) loadMachine(tx extension.SemanticTx) (Machine, session.Head, error) { + state, head, err := extension.LoadIn(tx, &MachineProjection) + if err != nil { + return Machine{}, session.Head{}, err + } + return state.(Machine), head, nil +} + +// snapshotIn is Load inside the transaction. +func (r *Runtime) snapshotIn(tx extension.SemanticTx, sid session.SessionID, runID run.RunID) (run.RuntimeSnapshot, error) { + proj, head, err := r.loadMachine(tx) + if err != nil { + return run.RuntimeSnapshot{}, err + } + if ms, ok := proj.Active[runID]; ok { + return run.RuntimeSnapshot{State: ms, Position: proj.Positions[runID], Head: head, SchemaVersion: proj.Schemas[runID]}, nil + } + state, err := r.terminalState(tx, runID) + if err != nil { + return run.RuntimeSnapshot{}, err + } + return run.RuntimeSnapshot{State: state.state, Position: state.position, Head: head, SchemaVersion: state.schema}, nil +} + +type foldedRun struct { + state run.MachineState + position run.RunPosition + schema uint16 +} + +// terminalState folds a Run that is no longer in the projection; it returns +// ErrRunNotFound when the Run never existed in this Session. +func (r *Runtime) terminalState(tx extension.SemanticTx, runID run.RunID) (foldedRun, error) { + commits, err := tx.Tail(session.Head{}, []session.EventType{Prefix}) + if err != nil { + return foldedRun{}, err + } + var facts []run.Fact + var position run.RunPosition + for ci := range commits { + for _, e := range commits[ci].Events { + if !session.HasTypePrefix(e.Type, []session.EventType{Prefix}) { + continue + } + decoded, err := tx.Decode(e) + if err != nil { + return foldedRun{}, err + } + if decoded.Unknown { + continue + } + if ev := decoded.Value.(Event); ev.RunID == runID { + facts = append(facts, ev.Fact) + position = run.RunPosition{Revision: commits[ci].Revision, Index: e.Index} + } + } + } + if len(facts) == 0 { + return foldedRun{}, run.ErrRunNotFound + } + state, err := run.FoldRun(facts) + if err != nil { + return foldedRun{}, err + } + return foldedRun{state: state, position: position, schema: facts[0].(run.RunCreated).SchemaVersion}, nil +} + +func (r *Runtime) expired(l extension.Lease) bool { + return l.DeadlineUnixMilli != 0 && l.DeadlineUnixMilli <= r.nowMilli() +} + +// executingTargets lists the lease keys of every Executing target in state. +func executingTargets(s *run.MachineState) []string { + switch cur := s.Current.(type) { + case run.ModelStep: + if cur.Status == run.ModelExecuting { + return []string{run.LeaseKey(s.RunID, cur.RefValue.ID, "")} + } + case run.ToolStep: + var out []string + for _, c := range cur.Calls { + if c.Status == run.ToolExecuting { + out = append(out, run.LeaseKey(s.RunID, cur.RefValue.ID, c.CallID)) + } + } + return out + } + return nil +} + +// --- frozen request, lease renewal, recovery ------------------------------------------- + +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) +} + +func (r *Runtime) RenewLease(ctx context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID, grant run.ExecutionGrant) error { + if err := run.CheckContext(ctx); err != nil { + return err + } + if grant == "" { + return run.ErrStaleRuntime + } + key := run.LeaseKey(runID, stepID, callID) + if key == "" { + return errors.New("runmod: renew requires a step") + } + err := r.leases.Renew(ctx, sid, LeaseNamespace, key, extension.LeaseToken(grant), r.cfg.LeaseTTL, r.nowMilli()) + if err != nil { + var xerr *extension.Error + if errors.As(err, &xerr) && xerr.Code == extension.ErrStale { + return run.ErrStaleRuntime + } + return err + } + return nil +} + +// RecoverExpired commits grantless recovery for every expired lease that still +// occupies an Executing target (RUN 5.1). +func (r *Runtime) RecoverExpired(ctx context.Context) (int, error) { + if r.cfg.LeaseTTL <= 0 { + return 0, nil + } + type expiredLease struct { + sid session.SessionID + lease extension.Lease + } + var expired []expiredLease + err := r.leases.Expired(ctx, LeaseNamespace, r.nowMilli(), func(sid session.SessionID, l extension.Lease) (bool, error) { + expired = append(expired, expiredLease{sid, l}) + return true, nil + }) + if err != nil { + return 0, err + } + n := 0 + for _, e := range expired { + runID := runIDOfLeaseKey(e.lease.Key) + if runID == "" { + continue + } + snapshot, err := r.Load(ctx, e.sid, runID) + if err != nil { + if errors.Is(err, run.ErrRunNotFound) { + continue + } + return n, err + } + cmd, cmdID, ok := run.RecoveryCommand(&snapshot.State, e.lease.Key, run.ExecutionClaim(e.lease.Holder)) + if !ok { + continue + } + proto, err := snapshot.Protocol() + if err != nil { + return n, err + } + env, err := proto.BuildEnvelope(e.sid, runID, cmdID, cmd) + if err != nil { + return n, err + } + res, err := r.Commit(ctx, e.sid, run.CommitRequest{Base: snapshot.Position, 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 +} + +func runIDOfLeaseKey(key string) run.RunID { + for _, sep := range []string{"/model/", "/call/"} { + if i := strings.Index(key, sep); i > 0 { + return run.RunID(key[:i]) + } + } + return "" +} + +var _ run.Runtime = (*Runtime)(nil) diff --git a/agent/session/store.go b/agent/session/store.go new file mode 100644 index 0000000..4006ae5 --- /dev/null +++ b/agent/session/store.go @@ -0,0 +1,154 @@ +package session + +import ( + "context" + + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/jsonstable" +) + +// CreateRequest establishes a stream. Field-identical repeats are idempotent; +// a different request for the same SessionID is a Conflict (SES-WIR-1). +type CreateRequest struct { + ProtocolVersion uint16 + SessionID SessionID + CausationID es.CausationID + Metadata jsonstable.Value +} + +// AppendRequest is one atomic commit to append (SES 5). +type AppendRequest struct { + SessionID SessionID + ExpectedHead Head + CommitID CommitID + CausationID es.CausationID + CorrelationID string + Events []UncommittedEvent +} + +type AppendDisposition string + +const ( + AppendApplied AppendDisposition = "applied" + AppendAlreadyApplied AppendDisposition = "already_applied" + AppendHeadConflict AppendDisposition = "head_conflict" + AppendCommitConflict AppendDisposition = "commit_conflict" + AppendInvalid AppendDisposition = "invalid" +) + +type AppendResult struct { + Disposition AppendDisposition + Commit *SessionCommit + ActualHead Head + // Detail explains an Invalid disposition. + Detail string +} + +// SessionTx is the read/write view inside CommitIn's critical section +// (SES-API-2). Every method takes effect in the same transaction as the +// commit the fn decides to append. +type SessionTx interface { + Head() Head + LookupCommit(CommitID) (SessionCommit, bool, error) + // Tail returns the commits after `after`; with a non-empty types filter + // only commits carrying at least one event whose Type has one of the + // prefixes are returned. + Tail(after Head, types []EventType) ([]SessionCommit, error) + LoadSnapshot(ProjectionKey, uint16) (SnapshotResult, error) + SaveSnapshot(Snapshot) error + ControlGet(ControlNamespace, string) (ControlEntry, bool, error) + ControlPut(ControlNamespace, string, []byte, int64) error + ControlDelete(ControlNamespace, string) error +} + +// CommitInFn decides, inside the critical section, what to append. nil means +// append nothing; snapshot and KV writes already made through tx still commit. +type CommitInFn func(SessionTx) (*AppendRequest, error) + +type ReplayCursor struct { + After *EventPosition + Token CursorToken +} + +type ReplayRequest struct { + SessionID SessionID + Types []EventType // empty = all; otherwise EventType prefix filter + Cursor *ReplayCursor + Limit uint32 +} + +type ReplayPage struct { + Header SessionHeader + Commits []SessionCommit + Next *ReplayCursor + Head Head +} + +type SnapshotRequest struct { + SessionID SessionID + ProjectionKey ProjectionKey + ProjectionVersion uint16 +} +type SnapshotResult struct { + Snapshot *Snapshot + Found bool +} +type SaveSnapshotRequest struct{ Snapshot Snapshot } +type SaveSnapshotResult struct { + Snapshot Snapshot + Replaced bool +} + +// Store is the kernel port (SES 4). Commit and CommitIn are the only append +// entries; both persist the commit, the new head and any same-call snapshot +// and control-plane writes atomically. +type Store interface { + Create(context.Context, CreateRequest) (SessionHeader, error) + Header(context.Context, SessionID) (SessionHeader, error) + Head(context.Context, SessionID) (Head, error) + LookupCommit(context.Context, SessionID, CommitID) (SessionCommit, bool, error) + Commit(context.Context, AppendRequest) (AppendResult, error) + CommitIn(context.Context, SessionID, CommitInFn) (AppendResult, error) + Replay(context.Context, ReplayRequest) (ReplayPage, error) + LoadSnapshot(context.Context, SnapshotRequest) (SnapshotResult, error) + SaveSnapshot(context.Context, SaveSnapshotRequest) (SaveSnapshotResult, error) + ControlGet(context.Context, SessionID, ControlNamespace, string) (ControlEntry, bool, error) + ControlPut(context.Context, SessionID, ControlNamespace, string, []byte, int64) error + // ControlCompareAndPut writes only when the entry exists and its current + // Value equals expected bytewise; it reports whether it wrote. + ControlCompareAndPut(context.Context, SessionID, ControlNamespace, string, []byte, []byte, int64) (bool, error) + ControlDelete(context.Context, SessionID, ControlNamespace, string) error + // ControlScan enumerates across all Sessions by key prefix; fn false stops. + ControlScan(context.Context, ControlNamespace, string, func(ControlEntry) (bool, error)) error + // ControlExpired enumerates entries whose deadline is non-zero and before + // beforeUnixMilli, across all Sessions; fn false stops. + ControlExpired(context.Context, ControlNamespace, int64, func(ControlEntry) (bool, error)) 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 +} + +// CommitMatchesTypes reports whether a commit carries at least one event +// whose Type has one of the prefixes. +func CommitMatchesTypes(c *SessionCommit, prefixes []EventType) bool { + if len(prefixes) == 0 { + return true + } + for i := range c.Events { + if HasTypePrefix(c.Events[i].Type, prefixes) { + return true + } + } + return false +} diff --git a/agent/session/types.go b/agent/session/types.go new file mode 100644 index 0000000..222a4a3 --- /dev/null +++ b/agent/session/types.go @@ -0,0 +1,182 @@ +// Package session is the Event Sourcing kernel of a Twilight Session +// (docs/design/agent-session.md). It owns the envelope, ordering, commit, +// critical section, snapshot and control-plane KV mechanics; payloads are +// opaque canonical JSON that Session modules encode and interpret. +package session + +import ( + "fmt" + + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/jsonstable" +) + +type ( + SessionID string + CommitID string + EventID string + EventType string + ProjectionKey string + CursorToken string + ControlNamespace string +) + +// ProtocolVersion1 is the current pre-release kernel wire version. It covers +// header, envelope, commit, snapshot envelope and digest profile only; +// payload versions are carried by modules (SES-VER-1). +const ProtocolVersion1 uint16 = 1 + +// SessionHeader is the immutable creation record of a stream (SES-WIR-1). +type SessionHeader struct { + ProtocolVersion uint16 `json:"protocolVersion"` + SessionID SessionID `json:"sessionId"` + 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"` + Revision es.Revision `json:"revision"` + HeadDigest es.Digest `json:"headDigest"` +} + +// SessionEvent is one committed event. Index orders events inside a commit; +// EventDigest additionally covers SessionID, Revision and Index. +type SessionEvent struct { + EventID EventID `json:"eventId"` + Index uint16 `json:"index"` + Type EventType `json:"type"` + RecordedAtUnixMilli int64 `json:"recordedAtUnixMilli"` + SourceEvents []EventID `json:"sourceEvents,omitempty"` + Payload jsonstable.Value `json:"payload"` + EventDigest es.Digest `json:"eventDigest"` +} + +// UncommittedEvent is what a producer hands to an append port. +type UncommittedEvent struct { + EventID EventID + Type EventType + RecordedAtUnixMilli int64 + SourceEvents []EventID + Payload jsonstable.Value +} + +// SessionCommit is one atomic append: the unit of ordering and of the digest +// chain. Revision 1 chains to HeaderDigest, later commits to the previous +// CommitDigest. +type SessionCommit struct { + ProtocolVersion uint16 `json:"protocolVersion"` + SessionID SessionID `json:"sessionId"` + Revision es.Revision `json:"revision"` + PreviousDigest es.Digest `json:"previousDigest"` + CommitID CommitID `json:"commitId"` + CausationID es.CausationID `json:"causationId,omitempty"` + CorrelationID string `json:"correlationId,omitempty"` + Events []SessionEvent `json:"events"` + CommitDigest es.Digest `json:"commitDigest"` +} + +// Head is the stream position after the last commit. The empty stream head +// is {0, HeaderDigest}. +type Head struct { + Revision es.Revision `json:"revision"` + Digest es.Digest `json:"digest"` +} + +// EventPosition addresses one committed event exactly. +type EventPosition struct { + Revision es.Revision `json:"revision"` + Index uint16 `json:"index"` + EventDigest es.Digest `json:"eventDigest"` +} + +// Snapshot is a discardable projection cache (SES-SNP-1). Through.Digest is +// the coverage proof: it must equal the CommitDigest at Through.Revision. +type Snapshot struct { + ProtocolVersion uint16 `json:"protocolVersion"` + SessionID SessionID `json:"sessionId"` + ProjectionKey ProjectionKey `json:"projectionKey"` + ProjectionVersion uint16 `json:"projectionVersion"` + Through Head `json:"through"` + State jsonstable.Value `json:"state"` + SnapshotDigest es.Digest `json:"snapshotDigest"` +} + +// ControlEntry is one control-plane KV row (SES-API-3). The kernel never +// interprets Value and never deletes an entry because its deadline passed. +type ControlEntry struct { + SessionID SessionID + Namespace ControlNamespace + Key string + Value []byte + DeadlineUnixMilli int64 +} + +// ErrorCode classifies kernel failures (SES 4). +type ErrorCode string + +const ( + ErrInvalid ErrorCode = "invalid" + ErrNotFound ErrorCode = "not_found" + ErrConflict ErrorCode = "conflict" + ErrCorrupt ErrorCode = "corrupt" + ErrUnsupportedProfile ErrorCode = "unsupported_profile" + ErrUnsupported ErrorCode = "unsupported" + ErrUnavailable ErrorCode = "unavailable" +) + +// 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/README.md b/agent/turn/README.md deleted file mode 100644 index 8df9cd8..0000000 --- a/agent/turn/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# agent/turn (legacy) - -This package implements the pre-single-Session-ES Turn coordinator (per-Run -store, FactMapper, MemoryLog). It no longer matches docs/design/agent-turn.md -and is excluded from the default build with the `legacy_turn` build tag until -the new Turn implementation replaces it. Build or test it with -`go test -tags legacy_turn ./agent/turn/...`. diff --git a/agent/turn/companion.go b/agent/turn/companion.go new file mode 100644 index 0000000..13d3b32 --- /dev/null +++ b/agent/turn/companion.go @@ -0,0 +1,143 @@ +package turn + +import ( + "fmt" + + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/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/context.go b/agent/turn/context.go deleted file mode 100644 index 90345b8..0000000 --- a/agent/turn/context.go +++ /dev/null @@ -1,238 +0,0 @@ -//go:build legacy_turn - -package turn - -import ( - "context" - "fmt" - - "github.com/memohai/twilight/agent/run" - "github.com/memohai/twilight/agent/run/loop" - "github.com/memohai/twilight/sdk" -) - -// EntryKind is the kind of one conversation entry in a folded Context. -type EntryKind string - -const ( - EntryInput EntryKind = "input" - EntryAssistant EntryKind = "assistant" - EntryToolResult EntryKind = "tool_result" -) - -// Entry is one element of the model-visible conversation, derived from -// committed chatlog events (CHT-CTX-1). Exactly one of the payload fields is -// set according to Kind. -type Entry struct { - Kind EntryKind - Turn TurnID - Seq uint64 - Input *InputDeliveredPayload - Assistant *AssistantPayload - ToolResult *ToolResultPayload -} - -// ContextFold projects a session log into the ordered conversation the model -// should see: delivered inputs, assistant outputs and tool results across -// every Turn of the session, in commit order. It is a pure function of the -// events; turn lifecycle events contribute nothing (CHT-CTX-1, CHT-CTX-2). -// -// This is the minimal fold: no summary, checkpoint or supersession handling -// yet; those arrive with the Chatlog module. -func ContextFold(events []Event) ([]Entry, error) { - entries := make([]Entry, 0, len(events)) - for i := range events { - ev := &events[i] - switch ev.Type { - case EventInputDelivered: - var p InputDeliveredPayload - if err := ev.Payload.Decode(&p); err != nil { - return nil, fmt.Errorf("agent: turn: fold input_delivered seq %d: %w", ev.Seq, err) - } - entries = append(entries, Entry{Kind: EntryInput, Turn: ev.Turn, Seq: ev.Seq, Input: &p}) - case EventAssistant: - var p AssistantPayload - if err := ev.Payload.Decode(&p); err != nil { - return nil, fmt.Errorf("agent: turn: fold assistant seq %d: %w", ev.Seq, err) - } - entries = append(entries, Entry{Kind: EntryAssistant, Turn: ev.Turn, Seq: ev.Seq, Assistant: &p}) - case EventToolResult: - var p ToolResultPayload - if err := ev.Payload.Decode(&p); err != nil { - return nil, fmt.Errorf("agent: turn: fold tool_result seq %d: %w", ev.Seq, err) - } - entries = append(entries, Entry{Kind: EntryToolResult, Turn: ev.Turn, Seq: ev.Seq, ToolResult: &p}) - } - } - return entries, nil -} - -// ContextPlanner is the reference RequestPlanner (REF-PLN): it assembles the -// next sdk.Request from the session's folded chatlog plus the Run boundary -// facts in PlanningHint. sdk.Message is produced here and nowhere else; it is -// never stored. -// -// Within one Loop.Run the Coordinator has not yet materialized the tool step -// that just closed, so the planner appends hint.LastModelResult and -// hint.LastToolStep itself when the fold does not already contain that step -// (REF-PLN-3). Pending inputs named by hint.Inputs are consumed by this plan -// and are already in the fold as input_delivered events. -type ContextPlanner struct { - Log Log - Session SessionID - Model run.ModelRef - Tools []run.ToolSpec - System string - // InputText extracts the user-visible 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.Log == nil || p.Session == "" || p.Model == "" { - return loop.RequestPlan{}, fmt.Errorf("agent: turn: context planner requires Log, Session and Model") - } - events, err := p.Log.Replay(ctx, p.Session) - if err != nil { - return loop.RequestPlan{}, err - } - entries, err := ContextFold(events) - if err != nil { - return loop.RequestPlan{}, err - } - var msgs []sdk.Message - if p.System != "" { - msgs = append(msgs, sdk.SystemMessage(p.System)) - } - inputText := p.InputText - if inputText == nil { - inputText = v1InputText - } - folded := make(map[run.StepID]bool) - for i := range entries { - e := &entries[i] - switch e.Kind { - case EntryInput: - text, err := inputText(e.Input.Content) - if err != nil { - return loop.RequestPlan{}, err - } - msgs = append(msgs, sdk.UserMessage(text)) - case EntryAssistant: - folded[e.Assistant.StepID] = true - msg, err := assistantMessage(e.Assistant.Text, e.Assistant.ToolCalls) - if err != nil { - return loop.RequestPlan{}, err - } - msgs = append(msgs, msg) - case EntryToolResult: - part, err := toolResultPart(e.ToolResult) - if err != nil { - return loop.RequestPlan{}, err - } - msgs = append(msgs, sdk.ToolMessage(part)) - } - } - // The step that just closed inside this Loop.Run is committed on the Run - // but not yet on the session log; add it from the boundary facts. - if hint.LastToolStep != nil && hint.LastModelResult != nil && !folded[hint.LastToolStep.Source] { - calls := make([]ToolCallPayload, 0, len(hint.LastModelResult.ToolCalls)) - for i, tc := range hint.LastModelResult.ToolCalls { - calls = append(calls, ToolCallPayload{CallID: run.DeriveCallID(hint.LastToolStep.Source, i), ProviderCallID: tc.ToolCallID, Name: tc.ToolName, Input: tc.Input}) - } - msg, err := assistantMessage(hint.LastModelResult.Text, calls) - if err != nil { - return loop.RequestPlan{}, err - } - msgs = append(msgs, msg) - names := make(map[run.CallID]string, len(calls)) - for _, c := range calls { - names[c.CallID] = c.Name - } - parts := make([]sdk.ToolResultPart, 0, len(hint.LastToolStep.Calls)) - for i := range hint.LastToolStep.Calls { - c := &hint.LastToolStep.Calls[i] - r := toolResultFromState(c) - r.Name = names[c.CallID] - part, err := toolResultPart(r) - if err != nil { - return loop.RequestPlan{}, err - } - parts = append(parts, part) - } - if len(parts) > 0 { - msgs = append(msgs, sdk.ToolMessage(parts...)) - } - } - ids := make([]run.InputID, 0, len(hint.Inputs)) - for _, in := range hint.Inputs { - ids = append(ids, in.ID) - } - defs := make([]sdk.ToolDefinition, 0, len(p.Tools)) - for _, spec := range p.Tools { - defs = append(defs, spec.Definition.SDK()) - } - return loop.RequestPlan{ - Model: p.Model, - Request: sdk.Request{Model: string(p.Model), Messages: msgs, Tools: defs}, - InputIDs: ids, - Tools: p.Tools, - }, nil -} - -func v1InputText(content run.CanonicalJSON) (string, error) { - var body struct { - Text string `json:"text"` - } - if err := content.Decode(&body); err != nil { - return "", fmt.Errorf("agent: turn: input payload: %w", err) - } - return body.Text, nil -} - -func assistantMessage(text string, calls []ToolCallPayload) (sdk.Message, error) { - var parts []sdk.MessagePart - if text != "" { - parts = append(parts, sdk.TextPart{Text: text}) - } - for _, c := range calls { - input, err := c.Input.Any() - if err != nil { - return sdk.Message{}, err - } - parts = append(parts, sdk.ToolCallPart{ToolCallID: c.ProviderCallID, ToolName: c.Name, Input: input}) - } - return sdk.Message{Role: sdk.MessageRoleAssistant, Content: parts}, nil -} - -func toolResultPart(r *ToolResultPayload) (sdk.ToolResultPart, error) { - part := sdk.ToolResultPart{ToolCallID: r.ProviderCallID, ToolName: r.Name} - switch r.Status { - case ToolSuccess: - out, err := r.Output.Any() - if err != nil { - return sdk.ToolResultPart{}, err - } - part.Result = out - case ToolError: - part.Result, part.IsError = r.Failure+": "+r.Message, true - case ToolUnknown: - part.Result, part.IsError = "tool outcome unknown: "+r.Message, true - default: - return sdk.ToolResultPart{}, fmt.Errorf("agent: turn: unknown tool result status %q", r.Status) - } - return part, nil -} - -func toolResultFromState(c *run.ToolCallState) *ToolResultPayload { - p := &ToolResultPayload{CallID: c.CallID, ProviderCallID: c.ProviderCallID} - switch { - case c.Result != nil: - p.Status, p.Output = ToolSuccess, c.Result.Output - case c.Failure != nil && c.Failure.Outcome == run.ToolOutcomeUnknown: - p.Status, p.Failure, p.Message = ToolUnknown, c.Failure.Failure.Class, c.Failure.Failure.Message - case c.Failure != nil: - p.Status, p.Failure, p.Message = ToolError, c.Failure.Failure.Class, c.Failure.Failure.Message - } - return p -} diff --git a/agent/turn/coordinator.go b/agent/turn/coordinator.go index eca64dd..8db4221 100644 --- a/agent/turn/coordinator.go +++ b/agent/turn/coordinator.go @@ -1,344 +1,475 @@ -//go:build legacy_turn - package turn import ( "context" "errors" "fmt" + "time" "github.com/memohai/twilight/agent/es" "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/chatlog" + "github.com/memohai/twilight/agent/session/extension" + runmod "github.com/memohai/twilight/agent/session/run" ) -// RunDriver executes one Run until it finishes or has nothing executable. -// The reference driver wraps loop.Loop.Run; the coordinator reads the -// resulting disposition from the Runtime rather than from the driver. +// ErrConflict reports a Turn in a state that does not admit the operation. +var ErrConflict = errors.New("turn: conflict") + +// ErrBindingUnavailable reports that the persisted binding cannot be resolved. +var ErrBindingUnavailable = errors.New("turn: binding_unavailable") + +type DriveRequest struct { + Ref TurnRef + RunID run.RunID +} + +// RunDriver drives one Run to its next quiescent point; the reference driver +// wraps loop.Run (TRN-DRV-1). type RunDriver interface { - Drive(ctx context.Context, runID run.RunID) error + Drive(context.Context, DriveRequest) error } -// Coordinator ties one Turn to one primary Run (TRN-SCP-1). It keeps no -// state of its own: every operation replays the session log and reads the -// Runtime (TRN-SCP-3). -type Coordinator struct { - Log Log - Runtime run.Runtime - Driver RunDriver +type ExecutionBindingRegistry interface { + Resolve(ExecutionBindingRef) (RunDriver, error) } -var ( - ErrTurnNotFound = errors.New("agent: turn: no started turn") - ErrTurnConflict = errors.New("agent: turn: turn already started with a different run") -) +type StartRequest struct { + Ref TurnRef + Inputs []run.AgentInput + ExecutionBinding ExecutionBindingRef + Companion CompanionVersion +} +type DeliverRequest struct { + Ref TurnRef + Inputs []run.AgentInput +} +type TurnRequest struct{ Ref TurnRef } +type RetryRequest struct { + Ref TurnRef + Reason string +} +type StopRequest struct { + Ref TurnRef + Reason string +} +type SettleRequest struct { + Ref TurnRef + FailureClass string +} -type Disposition string +type ResumeDisposition string const ( - // DispositionFinished: the Run is terminal and the Turn is settled. - DispositionFinished Disposition = "finished" - // DispositionWaitingForResponse: a tool call waits for approval or an - // external answer; Application submits it and calls Resume again. - DispositionWaitingForResponse Disposition = "waiting_for_response" - // DispositionWaitingForRecovery: an execution is in flight with no local - // owner; Application runs Runtime.RecoverExpired and calls Resume again. - DispositionWaitingForRecovery Disposition = "waiting_for_recovery" - // DispositionActive: the driver returned before an idle point (for - // example its context was cancelled); Resume continues. - DispositionActive Disposition = "active" + ResumeWaitingForResponse ResumeDisposition = "waiting_for_response" + ResumeWaitingForRecovery ResumeDisposition = "waiting_for_recovery" + ResumeFinished ResumeDisposition = "finished" ) -type StartRequest struct { - Ref Ref - RunID run.RunID - Inputs []run.AgentInput -} - -type Result struct { - Ref Ref +type TurnResponse struct { + Ref TurnRef RunID run.RunID - Disposition Disposition - Settlement Settlement + Attempt uint32 + Status TurnStatus + Disposition ResumeDisposition + End *run.RunEnd Waiting []run.ResponseRequest - Result *run.RunResult } -// linkage is the replayed view of one Turn. -type linkage struct { - started *StartedPayload - settled bool - covered uint64 // highest Run revision already materialized - inputs map[run.InputID]run.CanonicalJSON +// Service is the Turn API (TRN 3). +type Service interface { + Start(context.Context, StartRequest) (TurnResponse, error) + Deliver(context.Context, DeliverRequest) (TurnResponse, error) + Resume(context.Context, TurnRequest) (TurnResponse, error) + Retry(context.Context, RetryRequest) (TurnResponse, error) + Stop(context.Context, StopRequest) (TurnResponse, error) + Settle(context.Context, SettleRequest) (TurnResponse, error) } -func (c *Coordinator) replay(ctx context.Context, ref Ref) (linkage, error) { - events, err := c.Log.Replay(ctx, ref.Session) - if err != nil { - return linkage{}, err - } - l := linkage{inputs: make(map[run.InputID]run.CanonicalJSON)} - for i := range events { - ev := &events[i] - if ev.Turn != ref.Turn { - continue - } - switch ev.Type { - case EventTurnStarted: - var p StartedPayload - if err := ev.Payload.Decode(&p); err != nil { - return linkage{}, err - } - l.started = &p - case EventTurnCompleted, EventTurnFailed: - l.settled = true - case EventInputDelivered: - var p InputDeliveredPayload - if err := ev.Payload.Decode(&p); err != nil { - return linkage{}, err - } - l.inputs[p.InputID] = p.Content - default: - if ev.Revision > l.covered { - l.covered = ev.Revision - } - } - } - return l, nil +// Coordinator has no hidden state (TRN-SCP-3): every method reads the turn +// surface and the machine projection first. +type Coordinator struct { + Projections extension.ProjectionReader + Appender extension.SemanticAppender + Runtime run.Runtime + Bindings ExecutionBindingRegistry + // Now stamps event times; nil selects time.Now. + Now func() time.Time } -// Start appends the Turn's started group, creates the Run and resumes -// (TRN-STR-3..6). A repeated Start with the same RunID is idempotent; a -// different RunID for a started Turn is a conflict. -func (c *Coordinator) Start(ctx context.Context, req StartRequest) (Result, error) { - if req.Ref.Session == "" || req.Ref.Turn == "" || req.RunID == "" { - return Result{}, errors.New("agent: turn: start requires session, turn and run ids") +func (c *Coordinator) now() int64 { + if c.Now != nil { + return c.Now().UnixMilli() } - l, err := c.replay(ctx, req.Ref) + return time.Now().UnixMilli() +} + +func (c *Coordinator) surface(ctx context.Context, sid session.SessionID) (TurnSurface, error) { + state, _, err := c.Projections.Load(ctx, sid, SurfaceProjectionID, SurfaceProjection.Version) if err != nil { - return Result{}, err + return TurnSurface{}, err } - if l.started != nil { - if l.started.RunID != req.RunID { - return Result{}, ErrTurnConflict - } - return c.Resume(ctx, req.Ref) + return state.(TurnSurface), nil +} + +// --- Start ------------------------------------------------------------------------ + +func (c *Coordinator) Start(ctx context.Context, req StartRequest) (TurnResponse, error) { + if req.Ref.SessionID == "" || req.Ref.TurnID == "" || req.ExecutionBinding.ID == "" || req.ExecutionBinding.Digest == "" || req.Companion == "" { + return TurnResponse{}, errors.New("turn: start requires ref, binding and companion") } - ids := make([]run.InputID, len(req.Inputs)) - group := make([]Event, 0, len(req.Inputs)+1) + inputIDs := make([]chatlog.InputID, len(req.Inputs)) + seen := map[run.InputID]struct{}{} for i, in := range req.Inputs { - if in.ID == "" { - return Result{}, fmt.Errorf("agent: turn: input %d has empty id", i) + if _, dup := seen[in.ID]; dup || in.ID == "" { + return TurnResponse{}, errors.New("turn: start inputs must have unique non-empty IDs") } - ids[i] = in.ID + seen[in.ID] = struct{}{} + inputIDs[i] = chatlog.InputID(in.ID) } - started, err := run.CanonicalJSONFromValue(StartedPayload{TurnID: req.Ref.Turn, RunID: req.RunID, InputIDs: ids}) + sid, turnID := req.Ref.SessionID, req.Ref.TurnID + plan := PlanDigest(turnID, req.ExecutionBinding.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 Result{}, err + return TurnResponse{}, err } - group = append(group, Event{Type: EventTurnStarted, Turn: req.Ref.Turn, Payload: started}) - for _, in := range req.Inputs { - p, err := run.CanonicalJSONFromValue(InputDeliveredPayload{InputID: in.ID, TurnID: req.Ref.Turn, Content: in.Payload}) + facts, err := run.ProtocolV1().BuildCreateGroup(newRun, req.Inputs) + if err != nil { + return TurnResponse{}, err + } + now := c.now() + res, err := c.Appender.AppendSemanticIn(ctx, sid, func(tx extension.SemanticTx) (*extension.SemanticGroup, error) { + if _, found, err := tx.LookupCommit(commitID); err != nil { + return nil, err + } else if found { + group := c.startGroup(commitID, turnID, inputIDs, req, plan, facts, now) + return &group, nil // exact replay: the Appender compares fingerprints + } + surface, err := loadSurface(tx) if err != nil { - return Result{}, err + return nil, err + } + if _, exists := surface.Turns[turnID]; exists { + return nil, fmt.Errorf("%w: turn %s already started", ErrConflict, turnID) } - group = append(group, Event{Type: EventInputDelivered, Turn: req.Ref.Turn, Payload: p}) + if _, active := surface.Active(); active { + return nil, fmt.Errorf("%w: session already has an active turn", ErrConflict) + } + if err := checkSubmitted(tx, req.Inputs); err != nil { + return nil, err + } + group := c.startGroup(commitID, turnID, inputIDs, req, plan, facts, now) + return &group, nil + }) + if err != nil { + return TurnResponse{}, err + } + if res.Outcome != extension.SemanticApplied && res.Outcome != extension.SemanticAlreadyApplied { + return TurnResponse{}, fmt.Errorf("turn: start: %s: %s", res.Outcome, res.Detail) + } + return c.drive(ctx, req.Ref, runID) +} + +func (c *Coordinator) startGroup(commitID session.CommitID, turnID TurnID, inputIDs []chatlog.InputID, req StartRequest, plan es.Digest, 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, ExecutionBinding: req.ExecutionBinding, Companion: req.Companion, PlanDigest: plan}}) + 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)}}) } - if err := c.Log.Append(ctx, req.Ref.Session, group); err != nil { - return Result{}, err + 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 c.Resume(ctx, req.Ref) + return group } -// Resume re-derives the Turn from the log, ensures the Run exists and holds -// every delivered input, materializes what is committed, drives, then -// materializes and settles (TRN-RSM-1..5). -func (c *Coordinator) Resume(ctx context.Context, ref Ref) (Result, error) { - l, err := c.replay(ctx, ref) +func loadSurface(tx extension.SemanticTx) (TurnSurface, error) { + state, _, err := extension.LoadIn(tx, &SurfaceProjection) if err != nil { - return Result{}, err + return TurnSurface{}, err } - if l.started == nil { - return Result{}, ErrTurnNotFound + return state.(TurnSurface), nil +} + +// checkSubmitted enforces TRN-STR-1 (2): each input is a submitted chatlog +// Input whose Content equals the payload. +func checkSubmitted(tx extension.SemanticTx, inputs []run.AgentInput) error { + if len(inputs) == 0 { + return nil } - runID := l.started.RunID - if !l.settled { - if err := c.ensureRun(ctx, ref, runID, &l); err != nil { - return Result{}, err - } - if _, err := c.materialize(ctx, ref, runID, &l); err != nil { - return Result{}, err - } - snap, err := c.Runtime.Load(ctx, runID) - if err != nil { - return Result{}, err + state, _, err := extension.LoadIn(tx, &chatlog.SurfaceProjection) + 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 !snap.State.Status.Terminal() { - if err := c.Driver.Drive(ctx, runID); err != nil { - return Result{}, err - } + if !view.Input.Content.Equal(in.Payload) { + return fmt.Errorf("%w: input %s payload differs from its submitted content", ErrConflict, in.ID) } } - return c.settle(ctx, ref, runID, &l) + return nil } -// Stop cancels an active Run under a Turn-derived CommandID and settles -// (TRN-STP-1..3). -func (c *Coordinator) Stop(ctx context.Context, ref Ref) (Result, error) { - l, err := c.replay(ctx, ref) +// --- 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 Result{}, err + return TurnResponse{}, err } - if l.started == nil { - return Result{}, ErrTurnNotFound + 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) } - runID := l.started.RunID - if !l.settled { - snap, err := c.Runtime.Load(ctx, runID) + runID := view.ActiveRun + for _, in := range req.Inputs { + snapshot, err := c.Runtime.Load(ctx, sid, runID) if err != nil { - return Result{}, err + return TurnResponse{}, err } - if !snap.State.Status.Terminal() { - proto, err := snap.Protocol() - if err != nil { - return Result{}, err - } - id := run.CommandID(es.DigestBytes([]byte("twilight/turn/cancel-run:" + string(ref.Session) + ":" + string(ref.Turn) + ":" + string(runID)))) - env, err := proto.BuildEnvelope(runID, id, run.CancelRun{Reason: run.ReasonCancelled}) - if err != nil { - return Result{}, err - } - if _, err := c.Runtime.Commit(ctx, run.CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil && !errors.Is(err, run.ErrRunTerminal) { - return Result{}, err + proto, err := snapshot.Protocol() + if err != nil { + return TurnResponse{}, err + } + 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{Base: snapshot.Position, 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.settle(ctx, ref, runID, &l) + return c.drive(ctx, req.Ref, runID) } -// ensureRun creates the Run (idempotent) and accepts every delivered input -// through its derived CommandID; Accepted and AlreadyApplied both advance, -// a terminal Run absorbs the rest (TRN-RSM-2..3). -func (c *Coordinator) ensureRun(ctx context.Context, ref Ref, runID run.RunID, l *linkage) error { - newRun, err := run.BuildNewRun(runID, es.CausationID(string(ref.Session)+"/"+string(ref.Turn))) +// --- Resume / Retry / Stop / Settle ------------------------------------------------------ + +func (c *Coordinator) Resume(ctx context.Context, req TurnRequest) (TurnResponse, error) { + surface, err := c.surface(ctx, req.Ref.SessionID) if err != nil { - return err + return TurnResponse{}, err } - if _, err := c.Runtime.Create(ctx, newRun); err != nil { - return err + view, ok := surface.Turns[req.Ref.TurnID] + if !ok { + return TurnResponse{}, fmt.Errorf("%w: unknown turn %s", ErrConflict, req.Ref.TurnID) } - for _, id := range l.started.InputIDs { - content, ok := l.inputs[id] - if !ok { - return fmt.Errorf("agent: turn: input %q named by started has no input_delivered", id) - } - snap, err := c.Runtime.Load(ctx, runID) + if view.Status != TurnActive { + return c.responseFor(ctx, req.Ref, &view) + } + return c.drive(ctx, req.Ref, view.ActiveRun) +} + +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() + res, err := c.Appender.AppendSemanticIn(ctx, sid, func(tx extension.SemanticTx) (*extension.SemanticGroup, error) { + surface, err := loadSurface(tx) if err != nil { - return err + return nil, err } - if snap.State.Status.Terminal() { - return nil + view, ok := surface.Turns[turnID] + if !ok || view.Status != TurnAttemptFailed { + return nil, fmt.Errorf("%w: turn %s is not attempt_failed", ErrConflict, turnID) } - proto, err := snap.Protocol() + 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 err + return nil, err } - env, err := proto.BuildEnvelope(runID, run.DeriveInputCommandID(runID, id), run.AcceptInput{Input: run.AgentInput{ID: id, Payload: content}}) + inputs, err := deliveredInputs(tx, view.InputIDs) if err != nil { - return err + return nil, err } - if _, err := c.Runtime.Commit(ctx, run.CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { - // Not at Open (a step is in progress) means the input was already - // consumed by an earlier prepare; the derived id would have - // replayed otherwise. - if errors.Is(err, run.ErrStaleRuntime) { - continue - } - return 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 nil + if res.Outcome != extension.SemanticApplied && res.Outcome != extension.SemanticAlreadyApplied { + return TurnResponse{}, fmt.Errorf("turn: retry: %s: %s", res.Outcome, res.Detail) + } + return c.drive(ctx, req.Ref, runID) } -// materialize maps every transition above the coverage watermark into -// chatlog events and appends them as one group (TRN-MAT-1). It returns the -// verified record so callers settle from the same consistent read. -func (c *Coordinator) materialize(ctx context.Context, ref Ref, runID run.RunID, l *linkage) (run.RunRecord, error) { - record, err := c.Runtime.Record(ctx, runID) +// deliveredInputs rebuilds the AgentInputs of a Turn from the chatlog surface, +// in TurnView.InputIDs order (TRN-RTY-1). +func deliveredInputs(tx extension.SemanticTx, ids []chatlog.InputID) ([]run.AgentInput, error) { + state, _, err := extension.LoadIn(tx, &chatlog.SurfaceProjection) if err != nil { - return run.RunRecord{}, err + return nil, err } - var group []Event - for i := range record.Transitions { - if record.Transitions[i].Revision <= l.covered { - continue + 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) } - mapped, err := MapTransition(ref.Turn, record.Transitions[:i+1]) + 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) + } + runID := view.ActiveRun + snapshot, err := c.Runtime.Load(ctx, sid, runID) + if err != nil { + return TurnResponse{}, err + } + proto, err := snapshot.Protocol() + if err != nil { + return TurnResponse{}, err + } + env, err := proto.BuildEnvelope(sid, runID, CancelCommandID(sid, turnID, runID), run.CancelRun{}) + if err != nil { + return TurnResponse{}, err + } + _, err = c.Runtime.Commit(ctx, sid, run.CommitRequest{Base: snapshot.Position, 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() + res, err := c.Appender.AppendSemanticIn(ctx, sid, func(tx extension.SemanticTx) (*extension.SemanticGroup, error) { + surface, err := loadSurface(tx) if err != nil { - return run.RunRecord{}, err + return nil, err } - group = append(group, mapped...) - } - if len(group) > 0 { - if err := c.Log.Append(ctx, ref.Session, group); err != nil { - return run.RunRecord{}, err + view, ok := surface.Turns[turnID] + if !ok || view.Status != TurnAttemptFailed { + return nil, fmt.Errorf("%w: turn %s is not attempt_failed", ErrConflict, turnID) } - l.covered = group[len(group)-1].Revision + 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 + } + if res.Outcome != extension.SemanticApplied && res.Outcome != extension.SemanticAlreadyApplied { + return TurnResponse{}, fmt.Errorf("turn: settle: %s: %s", res.Outcome, res.Detail) } - return record, nil + return c.respond(ctx, req.Ref, runID) } -// settle materializes the tail and, when the Run is terminal and the Turn -// not yet settled, appends completed/failed from RunEnded (TRN-SET-3). The -// result reports the disposition read from the Runtime. -func (c *Coordinator) settle(ctx context.Context, ref Ref, runID run.RunID, l *linkage) (Result, error) { - record, err := c.materialize(ctx, ref, runID, l) +// --- Drive ---------------------------------------------------------------------------- + +func (c *Coordinator) drive(ctx context.Context, ref TurnRef, runID run.RunID) (TurnResponse, error) { + surface, err := c.surface(ctx, ref.SessionID) if err != nil { - return Result{}, err - } - state := &record.Snapshot.State - res := Result{Ref: ref, RunID: runID} - if !state.Status.Terminal() { - switch { - case run.NeedsRecovery(*state): - res.Disposition = DispositionWaitingForRecovery - case len(run.WaitingCalls(*state)) > 0: - res.Disposition = DispositionWaitingForResponse - res.Waiting = run.WaitingCalls(*state) - default: - res.Disposition = DispositionActive - } - return res, nil - } - res.Disposition = DispositionFinished - res.Result = state.Result - settlement, typ := settlementOf(state.Result) - res.Settlement = settlement - if !l.settled { - p := SettledPayload{TurnID: ref.Turn, RunID: runID, Settlement: settlement, Revision: record.Snapshot.Revision, Reason: string(state.Result.Reason)} - if state.Result.Failure != nil { - p.FailureClass = state.Result.Failure.Class - } else if settlement == SettlementStopped { - p.FailureClass = string(run.ReasonCancelled) - } - raw, err := run.CanonicalJSONFromValue(p) + return TurnResponse{}, err + } + view := surface.Turns[ref.TurnID] + if view.Status == TurnActive { + driver, err := c.Bindings.Resolve(view.ExecutionBinding) if err != nil { - return Result{}, err + return TurnResponse{}, fmt.Errorf("%w: %v", ErrBindingUnavailable, err) } - if err := c.Log.Append(ctx, ref.Session, []Event{{Type: typ, Turn: ref.Turn, Revision: record.Snapshot.Revision, Payload: raw}}); err != nil { - return Result{}, err + if err := driver.Drive(ctx, DriveRequest{Ref: ref, RunID: runID}); err != nil { + return TurnResponse{}, err } - l.settled = true } - return res, nil + return c.respond(ctx, ref, runID) } -func settlementOf(r *run.RunResult) (settlement Settlement, eventType string) { - switch r.Status { - case run.RunCompleted: - return SettlementCompleted, EventTurnCompleted - case run.RunStopped: - return SettlementStopped, EventTurnFailed +// respond reads the projections and fills the disposition (TRN-DRV-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: - return SettlementFailed, EventTurnFailed + 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 deleted file mode 100644 index 23c4655..0000000 --- a/agent/turn/coordinator_test.go +++ /dev/null @@ -1,364 +0,0 @@ -//go:build legacy_turn - -package turn_test - -import ( - "context" - "errors" - "fmt" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/memohai/twilight/agent/run" - "github.com/memohai/twilight/agent/run/loop" - "github.com/memohai/twilight/agent/turn" - "github.com/memohai/twilight/sdk" -) - -// --- application doubles ------------------------------------------------------ - -type scriptModel struct { - results []sdk.ModelResult - calls atomic.Int32 - mu sync.Mutex - requests []sdk.Request // every request the model received, in order -} - -func (m *scriptModel) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { return m, nil } -func (m *scriptModel) Generate(ctx context.Context, req sdk.Request) (sdk.ModelResult, error) { - if err := ctx.Err(); err != nil { - return sdk.ModelResult{}, err - } - m.mu.Lock() - m.requests = append(m.requests, req) - m.mu.Unlock() - n := int(m.calls.Add(1)) - 1 - if n >= len(m.results) { - return sdk.ModelResult{}, errors.New("no scripted result") - } - return m.results[n], nil -} - -type echoTool struct { - spec run.ToolSpec - def sdk.ToolDefinition - block chan struct{} // when non-nil, first execution blocks until closed - ran atomic.Int32 -} - -func newEchoTool(t testing.TB, policy run.ResponsePolicy) *echoTool { - t.Helper() - def := sdk.ToolDefinition{Name: "echo", Parameters: []byte(`{"type":"object"}`)} - frozen, err := run.FreezeToolDefinition(def) - if err != nil { - t.Fatal(err) - } - digest, err := run.ProtocolV1().DigestToolDefinition(frozen) - if err != nil { - t.Fatal(err) - } - return &echoTool{def: def, spec: run.ToolSpec{Ref: "echo", Definition: frozen, DefinitionDigest: digest, Policy: policy}} -} - -func (e *echoTool) ResolveTool(ref run.ToolRef) (loop.ExecutableTool, error) { - if ref != "echo" { - return nil, fmt.Errorf("unknown tool %q", ref) - } - return e, nil -} -func (e *echoTool) Ref() run.ToolRef { return "echo" } -func (e *echoTool) Definition() sdk.ToolDefinition { return e.def } -func (e *echoTool) ResponsePolicy() run.ResponsePolicy { return e.spec.Policy } -func (e *echoTool) ValidateArguments(run.CanonicalJSON) error { return nil } -func (e *echoTool) Execute(_ context.Context, req loop.ToolExecutionRequest) loop.ToolExecutionOutcome { - if e.ran.Add(1) == 1 && e.block != nil { - <-e.block - return loop.ToolExecutionUnknown{Failure: run.ToolFailure{Class: run.FailureEffectUnknown}} - } - return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} -} - -func toolCall(id string) sdk.ModelResult { - return sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{TotalTokens: 1}, - ToolCalls: []sdk.ToolCall{{ToolCallID: id, ToolName: "echo", Input: `{"x":1}`}}} -} - -func text(s string) sdk.ModelResult { - return sdk.ModelResult{Text: s, FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}} -} - -type harness struct { - t *testing.T - log *turn.MemoryLog - rt run.Runtime - coord *turn.Coordinator - tool *echoTool - model *scriptModel - ref turn.Ref -} - -func newHarness(t *testing.T, rt run.Runtime, policy run.ResponsePolicy, results ...sdk.ModelResult) *harness { - return newHarnessOnLog(t, rt, turn.NewMemoryLog(), policy, results...) -} - -// newHarnessOnLog builds a fresh Loop and Coordinator over an existing log, -// which is how a second process or a second Turn joins the same session. -func newHarnessOnLog(t *testing.T, rt run.Runtime, log *turn.MemoryLog, policy run.ResponsePolicy, results ...sdk.ModelResult) *harness { - t.Helper() - tool := newEchoTool(t, policy) - model := &scriptModel{results: results} - planner := &turn.ContextPlanner{Log: log, Session: "s-1", Model: "m-1", Tools: []run.ToolSpec{tool.spec}, System: "test system"} - l, err := loop.New(model, tool, planner, loop.ExecutionPolicy{}, false) - if err != nil { - t.Fatal(err) - } - return &harness{t: t, log: log, rt: rt, tool: tool, model: model, - coord: &turn.Coordinator{Log: log, Runtime: rt, Driver: turn.LoopDriver{Loop: l, Runtime: rt}}, - ref: turn.Ref{Session: "s-1", Turn: "t-1"}} -} - -// roles returns the role sequence of the i-th request the model saw. -func (h *harness) roles(i int) []string { - h.t.Helper() - h.model.mu.Lock() - defer h.model.mu.Unlock() - if i >= len(h.model.requests) { - h.t.Fatalf("model saw %d requests, want index %d", len(h.model.requests), i) - } - out := make([]string, 0, len(h.model.requests[i].Messages)) - for _, m := range h.model.requests[i].Messages { - out = append(out, string(m.Role)) - } - return out -} - -func (h *harness) events() []turn.Event { - h.t.Helper() - evs, err := h.log.Replay(context.Background(), h.ref.Session) - if err != nil { - h.t.Fatal(err) - } - return evs -} - -func (h *harness) types() []string { - evs := h.events() - out := make([]string, 0, len(evs)) - for _, e := range evs { - out = append(out, e.Type) - } - return out -} - -func requireTypes(t *testing.T, got, want []string) { - t.Helper() - if fmt.Sprint(got) != fmt.Sprint(want) { - t.Fatalf("event types\n got %v\n want %v", got, want) - } -} - -// --- tests -------------------------------------------------------------------- - -func TestStartDrivesToCompletionAndMaterializes(t *testing.T) { - rt := run.NewRuntime(run.NewMemoryStore()) - h := newHarness(t, rt, run.DirectExecution, toolCall("c1"), text("done")) - res, err := h.coord.Start(context.Background(), turn.StartRequest{Ref: h.ref, RunID: "run-1", - Inputs: []run.AgentInput{{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{"text":"hi"}`)}}}) - if err != nil { - t.Fatal(err) - } - if res.Disposition != turn.DispositionFinished || res.Settlement != turn.SettlementCompleted || res.Result.Model.Text != "done" { - t.Fatalf("res = %+v", res) - } - requireTypes(t, h.types(), []string{ - turn.EventTurnStarted, turn.EventInputDelivered, - turn.EventAssistant, turn.EventToolResult, turn.EventAssistant, - turn.EventTurnCompleted, - }) - // Materialized events carry their Run position and the coverage watermark - // equals the terminal revision. - evs := h.events() - if evs[2].Revision == 0 || evs[len(evs)-1].Revision != 9 { - t.Fatalf("positions: %+v", evs) - } - // The second model request carries the whole conversation so far, not - // only the boundary facts: system, the user input, the assistant tool - // call and its result. - requireTypes(t, h.roles(0), []string{"system", "user"}) - requireTypes(t, h.roles(1), []string{"system", "user", "assistant", "tool"}) - // Start again with the same RunID is idempotent: no new events. - again, err := h.coord.Start(context.Background(), turn.StartRequest{Ref: h.ref, RunID: "run-1"}) - if err != nil || again.Disposition != turn.DispositionFinished || len(h.events()) != len(evs) { - t.Fatalf("idempotent start: %+v %v events=%d", again, err, len(h.events())) - } - if _, err := h.coord.Start(context.Background(), turn.StartRequest{Ref: h.ref, RunID: "run-2"}); !errors.Is(err, turn.ErrTurnConflict) { - t.Fatalf("second primary run err = %v", err) - } -} - -func TestApprovalWaitsThenResumes(t *testing.T) { - rt := run.NewRuntime(run.NewMemoryStore()) - h := newHarness(t, rt, run.ApprovalRequired, toolCall("c1"), text("done")) - res, err := h.coord.Start(context.Background(), turn.StartRequest{Ref: h.ref, RunID: "run-1", - Inputs: []run.AgentInput{{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{"text":"hi"}`)}}}) - if err != nil { - t.Fatal(err) - } - if res.Disposition != turn.DispositionWaitingForResponse || len(res.Waiting) != 1 { - t.Fatalf("res = %+v", res) - } - // The assistant that asked for the tool is already materialized while we wait. - requireTypes(t, h.types(), []string{turn.EventTurnStarted, turn.EventInputDelivered, turn.EventAssistant}) - - w := res.Waiting[0] - digest, err := run.ProtocolV1().DigestToolResponseDecision(w.Kind, run.ResponseDecisionApproved, "") - if err != nil { - t.Fatal(err) - } - snap, err := rt.Load(context.Background(), "run-1") - if err != nil { - t.Fatal(err) - } - env, err := run.ProtocolV1().BuildEnvelope("run-1", run.DeriveResponseCommandID("run-1", w.StepID, w.CallID, w.ID), - run.ApproveToolCall{StepID: w.StepID, CallID: w.CallID, ResponseID: w.ID, ResponseDigest: digest}) - if err != nil { - t.Fatal(err) - } - if _, err := rt.Commit(context.Background(), run.CommitRequest{BaseRevision: snap.Revision, Command: env}); err != nil { - t.Fatal(err) - } - res, err = h.coord.Resume(context.Background(), h.ref) - if err != nil || res.Disposition != turn.DispositionFinished { - t.Fatalf("resume: %+v %v", res, err) - } - requireTypes(t, h.types(), []string{turn.EventTurnStarted, turn.EventInputDelivered, turn.EventAssistant, - turn.EventToolResult, turn.EventAssistant, turn.EventTurnCompleted}) -} - -func TestStopCancelsAndSettlesAsFailedStopped(t *testing.T) { - rt := run.NewRuntime(run.NewMemoryStore()) - h := newHarness(t, rt, run.ApprovalRequired, toolCall("c1")) - if _, err := h.coord.Start(context.Background(), turn.StartRequest{Ref: h.ref, RunID: "run-1", - Inputs: []run.AgentInput{{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{}`)}}}); err != nil { - t.Fatal(err) - } - res, err := h.coord.Stop(context.Background(), h.ref) - if err != nil || res.Disposition != turn.DispositionFinished || res.Settlement != turn.SettlementStopped { - t.Fatalf("stop: %+v %v", res, err) - } - evs := h.events() - last := evs[len(evs)-1] - var p turn.SettledPayload - if err := last.Payload.Decode(&p); err != nil { - t.Fatal(err) - } - if last.Type != turn.EventTurnFailed || p.Settlement != turn.SettlementStopped || p.FailureClass != string(run.ReasonCancelled) { - t.Fatalf("settlement event = %s %+v", last.Type, p) - } - // Stop again is idempotent. - if _, err := h.coord.Stop(context.Background(), h.ref); err != nil || len(h.events()) != len(evs) { - t.Fatalf("second stop: %v events=%d", err, len(h.events())) - } -} - -// A driver whose process dies mid tool call: the Turn is resumed by a fresh -// coordinator after lease expiry. Nothing about the Turn lives outside the -// log and the Runtime, so the new coordinator rebuilds and finishes it. -func TestResumeAfterCrashRecoversAndSettles(t *testing.T) { - clock := time.Unix(1000, 0) - rt := run.NewRuntimeWithOptions(run.NewMemoryStore(), run.RuntimeOptions{LeaseTTL: time.Second, Now: func() time.Time { return clock }}) - h := newHarness(t, rt, run.DirectExecution, toolCall("c1"), text("after crash")) - h.tool.block = make(chan struct{}) - - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { - _, err := h.coord.Start(ctx, turn.StartRequest{Ref: h.ref, RunID: "run-1", - Inputs: []run.AgentInput{{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{}`)}}}) - done <- err - }() - deadline := time.Now().Add(5 * time.Second) - for { - snap, err := rt.Load(context.Background(), "run-1") - if err == nil && len(run.ExecutingCalls(snap.State)) == 1 { - break - } - if time.Now().After(deadline) { - t.Fatal("tool never started") - } - time.Sleep(time.Millisecond) - } - // "Crash": cancel the driver's context and let the blocked worker return - // Unknown against a cancelled ctx; then drop every in-process artifact. - cancel() - close(h.tool.block) - <-done - - clock = clock.Add(2 * time.Second) - if _, err := rt.RecoverExpired(context.Background()); err != nil { - t.Fatal(err) - } - - // New process: new Loop, new Coordinator, same log and Runtime. - h2 := newHarnessOnLog(t, rt, h.log, run.DirectExecution, text("after crash")) - res, err := h2.coord.Resume(context.Background(), h.ref) - if err != nil || res.Disposition != turn.DispositionFinished || res.Settlement != turn.SettlementCompleted { - t.Fatalf("resume after crash: %+v %v", res, err) - } - types := h2.types() - // Exactly one tool_result, and it is the Unknown recovery, followed by the - // second assistant and the settlement. - unknown := 0 - for _, e := range h2.events() { - if e.Type != turn.EventToolResult { - continue - } - var p turn.ToolResultPayload - if err := e.Payload.Decode(&p); err != nil { - t.Fatal(err) - } - if p.Status == turn.ToolUnknown { - unknown++ - } - } - if unknown != 1 { - t.Fatalf("unknown tool results = %d in %v", unknown, types) - } - if types[len(types)-1] != turn.EventTurnCompleted { - t.Fatalf("types = %v", types) - } -} - -// A second Turn on the same session plans from the whole session history: the -// model sees the first Turn's input, tool exchange and answer before the new -// input. Nothing about the first Turn is held in memory by the second Loop. -func TestSecondTurnSeesFirstTurnConversation(t *testing.T) { - rt := run.NewRuntime(run.NewMemoryStore()) - h1 := newHarness(t, rt, run.DirectExecution, toolCall("c1"), text("first answer")) - if _, err := h1.coord.Start(context.Background(), turn.StartRequest{Ref: h1.ref, RunID: "run-1", - Inputs: []run.AgentInput{{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{"text":"first question"}`)}}}); err != nil { - t.Fatal(err) - } - h2 := newHarnessOnLog(t, rt, h1.log, run.DirectExecution, text("second answer")) - h2.ref = turn.Ref{Session: "s-1", Turn: "t-2"} - res, err := h2.coord.Start(context.Background(), turn.StartRequest{Ref: h2.ref, RunID: "run-2", - Inputs: []run.AgentInput{{ID: "in-2", Payload: run.MustParseCanonicalJSON(`{"text":"second question"}`)}}}) - if err != nil || res.Disposition != turn.DispositionFinished || res.Result.Model.Text != "second answer" { - t.Fatalf("second turn: %+v %v", res, err) - } - requireTypes(t, h2.roles(0), []string{"system", "user", "assistant", "tool", "assistant", "user"}) - req := h2.model.requests[0] - if got := req.Messages[1].Content[0].(sdk.TextPart).Text; got != "first question" { - t.Fatalf("first user message = %q", got) - } - if got := req.Messages[5].Content[0].(sdk.TextPart).Text; got != "second question" { - t.Fatalf("second user message = %q", got) - } - // The log now holds both Turns in order. - types := h2.types() - if types[0] != turn.EventTurnStarted || types[len(types)-1] != turn.EventTurnCompleted { - t.Fatalf("types = %v", types) - } -} diff --git a/agent/turn/driver.go b/agent/turn/driver.go deleted file mode 100644 index 50f4c7e..0000000 --- a/agent/turn/driver.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build legacy_turn - -package turn - -import ( - "context" - - "github.com/memohai/twilight/agent/run" - "github.com/memohai/twilight/agent/run/loop" -) - -// LoopDriver is the reference RunDriver: it drives a Run with one loop.Loop -// and reports nothing about the outcome, which the Coordinator reads from -// the Runtime. -type LoopDriver struct { - Loop *loop.Loop - Runtime run.Runtime - Events loop.EventSink -} - -func (d LoopDriver) Drive(ctx context.Context, runID run.RunID) error { - _, err := d.Loop.Run(ctx, d.Runtime, runID, d.Events) - return err -} diff --git a/agent/turn/live_test.go b/agent/turn/live_test.go deleted file mode 100644 index 3f3b09b..0000000 --- a/agent/turn/live_test.go +++ /dev/null @@ -1,184 +0,0 @@ -//go:build legacy_turn && live - -// Live end-to-end test: agent/turn -> agent/run/loop -> a real model through -// the sdk. Runs only with `go test -tags live` and the environment below. -// -// TWILIGHT_LIVE_BASE_URL OpenAI-compatible chat completions base URL -// TWILIGHT_LIVE_API_KEY bearer key -// TWILIGHT_LIVE_MODEL model id (default gpt-5.4) -package turn_test - -import ( - "context" - "encoding/json" - "fmt" - "os" - "strconv" - "strings" - "testing" - "time" - - "github.com/memohai/twilight/agent/run" - "github.com/memohai/twilight/agent/run/loop" - "github.com/memohai/twilight/agent/turn" - "github.com/memohai/twilight/provider/openai/completions" - "github.com/memohai/twilight/sdk" -) - -// calculator is a real tool: it evaluates "a op b" and returns the number. -type calculator struct{ spec run.ToolSpec } - -func newCalculator(t *testing.T) *calculator { - t.Helper() - def := sdk.ToolDefinition{ - Name: "calculator", - Description: "Evaluate an arithmetic expression of the form ' ' where op is + - * /.", - Parameters: []byte(`{"type":"object","properties":{"expression":{"type":"string"}},"required":["expression"]}`), - } - frozen, err := run.FreezeToolDefinition(def) - if err != nil { - t.Fatal(err) - } - digest, err := run.ProtocolV1().DigestToolDefinition(frozen) - if err != nil { - t.Fatal(err) - } - return &calculator{spec: run.ToolSpec{Ref: "calculator", Definition: frozen, DefinitionDigest: digest, Policy: run.DirectExecution}} -} - -func (c *calculator) ResolveTool(ref run.ToolRef) (loop.ExecutableTool, error) { - if ref != "calculator" { - return nil, fmt.Errorf("unknown tool %q", ref) - } - return c, nil -} -func (c *calculator) Ref() run.ToolRef { return "calculator" } -func (c *calculator) Definition() sdk.ToolDefinition { return c.spec.Definition.SDK() } -func (c *calculator) ResponsePolicy() run.ResponsePolicy { return run.DirectExecution } -func (c *calculator) ValidateArguments(run.CanonicalJSON) error { return nil } -func (c *calculator) Execute(_ context.Context, req loop.ToolExecutionRequest) loop.ToolExecutionOutcome { - var args struct { - Expression string `json:"expression"` - } - if err := req.Arguments.Decode(&args); err != nil { - return loop.ToolExecutionFailed{Failure: run.ToolFailure{Class: run.FailureInvalidArguments, Message: err.Error()}} - } - fields := strings.Fields(strings.NewReplacer("*", " * ", "+", " + ", "-", " - ", "/", " / ").Replace(args.Expression)) - if len(fields) != 3 { - return loop.ToolExecutionFailed{Failure: run.ToolFailure{Class: run.FailureInvalidArguments, Message: "expected ' '"}} - } - a, errA := strconv.Atoi(fields[0]) - b, errB := strconv.Atoi(fields[2]) - if errA != nil || errB != nil { - return loop.ToolExecutionFailed{Failure: run.ToolFailure{Class: run.FailureInvalidArguments, Message: "operands must be integers"}} - } - var v int - switch fields[1] { - case "+": - v = a + b - case "-": - v = a - b - case "*": - v = a * b - case "/": - if b == 0 { - return loop.ToolExecutionFailed{Failure: run.ToolFailure{Class: run.FailureExecution, Message: "division by zero"}} - } - v = a / b - default: - return loop.ToolExecutionFailed{Failure: run.ToolFailure{Class: run.FailureInvalidArguments, Message: "unknown operator"}} - } - out, _ := run.CanonicalJSONFromValue(map[string]int{"result": v}) - return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: out}} -} - -type liveCatalog struct{ model *sdk.Model } - -func (c liveCatalog) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { return c.model, nil } - -func TestLiveTurnWithRealModelAndTool(t *testing.T) { - baseURL, apiKey := os.Getenv("TWILIGHT_LIVE_BASE_URL"), os.Getenv("TWILIGHT_LIVE_API_KEY") - if baseURL == "" || apiKey == "" { - t.Skip("TWILIGHT_LIVE_BASE_URL / TWILIGHT_LIVE_API_KEY not set") - } - modelID := os.Getenv("TWILIGHT_LIVE_MODEL") - if modelID == "" { - modelID = "gpt-5.4" - } - provider := completions.New(completions.WithAPIKey(apiKey), completions.WithBaseURL(baseURL)) - model := provider.ChatModel(modelID) - - calc := newCalculator(t) - rt := run.NewRuntime(run.NewMemoryStore()) - log := turn.NewMemoryLog() - planner := &turn.ContextPlanner{ - Log: log, Session: "live-s1", Model: run.ModelRef(modelID), Tools: []run.ToolSpec{calc.spec}, - System: "You are a precise assistant. Use the calculator tool for any arithmetic. Reply with just the final number once you have it.", - } - l, err := loop.New(liveCatalog{model}, calc, planner, loop.ExecutionPolicy{}, false) - if err != nil { - t.Fatal(err) - } - coord := &turn.Coordinator{Log: log, Runtime: rt, Driver: turn.LoopDriver{Loop: l, Runtime: rt}} - - ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) - defer cancel() - ref := turn.Ref{Session: "live-s1", Turn: "live-t1"} - res, err := coord.Start(ctx, turn.StartRequest{Ref: ref, RunID: "live-run-1", - Inputs: []run.AgentInput{{ID: "in-1", Payload: run.MustParseCanonicalJSON(`{"text":"What is 17 * 23? Use the calculator."}`)}}}) - if err != nil { - t.Fatal(err) - } - if res.Disposition != turn.DispositionFinished || res.Settlement != turn.SettlementCompleted { - if res.Result != nil && res.Result.Failure != nil { - t.Fatalf("turn %s: reason=%s class=%s message=%s", res.Settlement, res.Result.Reason, res.Result.Failure.Class, res.Result.Failure.Message) - } - t.Fatalf("turn did not complete: disposition=%s settlement=%s result=%+v", res.Disposition, res.Settlement, res.Result) - } - if !strings.Contains(res.Result.Model.Text, "391") { - t.Fatalf("final answer %q does not contain 391", res.Result.Model.Text) - } - - events, err := log.Replay(ctx, ref.Session) - if err != nil { - t.Fatal(err) - } - var sawToolResult bool - t.Logf("=== session log: %d events", len(events)) - for _, e := range events { - t.Logf("seq=%d %-32s rev=%d idx=%d\n%s", e.Seq, e.Type, e.Revision, e.Index, e.Payload.String()) - if e.Type == turn.EventToolResult { - var p turn.ToolResultPayload - if err := e.Payload.Decode(&p); err != nil { - t.Fatal(err) - } - if p.Status != turn.ToolSuccess || !strings.Contains(p.Output.String(), "391") { - t.Fatalf("tool result = %+v", p) - } - sawToolResult = true - } - } - if !sawToolResult { - t.Fatal("model did not call the tool") - } - record, err := rt.Record(ctx, "live-run-1") - if err != nil { - t.Fatal(err) - } - t.Logf("=== run record: %d transitions, usage %+v", len(record.Transitions), record.Snapshot.State.Usage) - for _, tr := range record.Transitions { - for _, ev := range tr.Events { - t.Logf("rev=%d idx=%d %-22s command=%s", ev.Revision, ev.Index, ev.Type, ev.CommandID) - } - } - if out := os.Getenv("TWILIGHT_LIVE_RECORD_OUT"); out != "" { - raw, err := json.MarshalIndent(record, "", " ") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(out, raw, 0o644); err != nil { - t.Fatal(err) - } - t.Logf("=== full RunRecord written to %s (%d bytes)", out, len(raw)) - } -} diff --git a/agent/turn/mapper.go b/agent/turn/mapper.go deleted file mode 100644 index 9c30720..0000000 --- a/agent/turn/mapper.go +++ /dev/null @@ -1,69 +0,0 @@ -//go:build legacy_turn - -package turn - -import ( - "fmt" - - "github.com/memohai/twilight/agent/run" -) - -// MapTransition is the v1 FactMapper (TRN-MAP-1): it projects the AgentEvents -// of the last transition in prefix into chatlog events for the Turn. prefix is -// the complete record up to and including that transition (TRN-MAT-1), which -// is how a tool_result learns the ProviderCallID its ToolStepOpened carried. -// Facts with no conversation content map to nothing; RunEnded is settlement -// and is handled by the coordinator. -func MapTransition(turnID TurnID, prefix []run.TransitionRecord) ([]Event, error) { - if len(prefix) == 0 { - return nil, nil - } - type callInfo struct{ provider, name string } - calls := make(map[run.CallID]callInfo) - for i := range prefix { - for j := range prefix[i].Events { - if completed, ok := prefix[i].Events[j].Fact.(run.ModelStepCompleted); ok { - for k, tc := range completed.Result.ToolCalls { - calls[run.DeriveCallID(completed.StepID, k)] = callInfo{provider: tc.ToolCallID, name: tc.ToolName} - } - } - } - } - tr := &prefix[len(prefix)-1] - var out []Event - for i := range tr.Events { - ev := &tr.Events[i] - var payload any - var typ string - switch f := ev.Fact.(type) { - case run.ModelStepCompleted: - calls := make([]ToolCallPayload, 0, len(f.Result.ToolCalls)) - for j, tc := range f.Result.ToolCalls { - calls = append(calls, ToolCallPayload{CallID: run.DeriveCallID(f.StepID, j), ProviderCallID: tc.ToolCallID, Name: tc.ToolName, Input: tc.Input}) - } - typ = EventAssistant - payload = AssistantPayload{TurnID: turnID, StepID: f.StepID, Text: f.Result.Text, ToolCalls: calls} - case run.ToolCallCompleted: - typ = EventToolResult - payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, ProviderCallID: calls[f.CallID].provider, Name: calls[f.CallID].name, Status: ToolSuccess, Output: f.Result.Output} - case run.ToolCallAnswered: - typ = EventToolResult - payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, ProviderCallID: calls[f.CallID].provider, Name: calls[f.CallID].name, Status: ToolSuccess, Output: f.Payload} - case run.ToolCallFailed: - status := ToolError - if f.Outcome == run.ToolOutcomeUnknown || f.Failure.Class == run.FailureEffectUnknown { - status = ToolUnknown - } - typ = EventToolResult - payload = ToolResultPayload{TurnID: turnID, CallID: f.CallID, ProviderCallID: calls[f.CallID].provider, Name: calls[f.CallID].name, Status: status, Failure: f.Failure.Class, Message: f.Failure.Message} - default: - continue - } - raw, err := run.CanonicalJSONFromValue(payload) - if err != nil { - return nil, fmt.Errorf("agent: turn: map %s: %w", typ, err) - } - out = append(out, Event{Type: typ, Turn: turnID, Revision: ev.Revision, Index: ev.Index, Payload: raw}) - } - return out, nil -} diff --git a/agent/turn/projection.go b/agent/turn/projection.go new file mode 100644 index 0000000..73459ad --- /dev/null +++ b/agent/turn/projection.go @@ -0,0 +1,209 @@ +package turn + +import ( + "fmt" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/chatlog" + "github.com/memohai/twilight/agent/session/extension" + runmod "github.com/memohai/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"` + // 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"` + ExecutionBinding ExecutionBindingRef `json:"executionBinding"` + 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] +} + +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"}, + RequireComplete: []extension.ModuleID{ModuleID, runmod.ModuleID}, + 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...), + ExecutionBinding: p.ExecutionBinding, 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}) + 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 index 7b3c40a..1ef2dda 100644 --- a/agent/turn/turn.go +++ b/agent/turn/turn.go @@ -1,162 +1,160 @@ -//go:build legacy_turn - -// Package turn is the minimal Turn coordinator: one Turn owns one primary -// Run, delivers inputs into it, drives it, and materializes the committed -// Run record into chatlog events on an append-only session log. -// -// This is the vertical slice described in docs/design/agent-turn.md, not the -// full Session kernel: the Log has no fork, snapshot or import, event -// identity is a sequence number, and materialization coverage is the -// highest Run revision already mapped for the Turn. Those simplifications are -// what the slice exists to test the Run API against; the Session spec stays -// a draft until this shape has been driven from a real host. +// 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 ( - "context" "errors" - "sync" + "fmt" + "github.com/memohai/twilight/agent/es" "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/chatlog" + "github.com/memohai/twilight/agent/session/extension" + runmod "github.com/memohai/twilight/agent/session/run" ) -type SessionID string -type TurnID string +const ModuleID extension.ModuleID = "turn" -// Ref addresses one Turn. -type Ref struct { - Session SessionID - Turn TurnID +type ( + TurnID string + ExecutionBindingID string + CompanionVersion string +) + +type TurnRef struct { + SessionID session.SessionID + TurnID TurnID } -// Event types this package appends. Chatlog types follow -// agent-session-chatlog.md; turn types follow agent-turn.md. +type ExecutionBindingRef struct { + ID ExecutionBindingID `json:"id"` + Digest es.Digest `json:"digest"` +} + +type Settlement string + const ( - EventTurnStarted = "twilight/turn/started" - EventTurnCompleted = "twilight/turn/completed" - EventTurnFailed = "twilight/turn/failed" - EventInputDelivered = "twilight/chatlog/input_delivered" - EventAssistant = "twilight/chatlog/assistant" - EventToolResult = "twilight/chatlog/tool_result" + SettlementCompleted Settlement = "completed" + SettlementFailed Settlement = "failed" + SettlementStopped Settlement = "stopped" ) -// Event is one committed fact on a session log. Seq is assigned by the Log. -// Revision/Index are set on events materialized from a Run transition and -// carry the source AgentEvent position; they are the coverage watermark. -type Event struct { - Seq uint64 - Type string - Turn TurnID - Revision uint64 - Index uint16 - Payload run.CanonicalJSON -} +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" +) -// Log is an append-only per-session event log. Append assigns contiguous -// Seq values and persists the whole group or nothing. -type Log interface { - Append(ctx context.Context, session SessionID, events []Event) error - Replay(ctx context.Context, session SessionID) ([]Event, error) +type StartedPayload struct { + TurnID TurnID `json:"turnId"` + InputIDs []chatlog.InputID `json:"inputIds,omitempty"` + ExecutionBinding ExecutionBindingRef `json:"executionBinding"` + Companion CompanionVersion `json:"companion"` + PlanDigest es.Digest `json:"planDigest"` } -// MemoryLog is the in-process Log. -type MemoryLog struct { - mu sync.Mutex - logs map[SessionID][]Event +type CompletedPayload struct { + TurnID TurnID `json:"turnId"` + RunID run.RunID `json:"runId"` } -func NewMemoryLog() *MemoryLog { return &MemoryLog{logs: make(map[SessionID][]Event)} } - -func (m *MemoryLog) Append(ctx context.Context, session SessionID, events []Event) error { - if err := ctx.Err(); err != nil { - return err - } - if len(events) == 0 { - return errors.New("agent: turn: empty append") - } - m.mu.Lock() - defer m.mu.Unlock() - log := m.logs[session] - next := uint64(len(log)) + 1 - for i := range events { - events[i].Seq = next - next++ - } - m.logs[session] = append(log, events...) - return nil +type FailedPayload struct { + TurnID TurnID `json:"turnId"` + RunID run.RunID `json:"runId"` + Settlement Settlement `json:"settlement"` + FailureClass string `json:"failureClass,omitempty"` } -func (m *MemoryLog) Replay(ctx context.Context, session SessionID) ([]Event, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - m.mu.Lock() - defer m.mu.Unlock() - return append([]Event(nil), m.logs[session]...), nil +type SupersededPayload struct { + TurnID TurnID `json:"turnId"` + ReplacementTurnID TurnID `json:"replacementTurnId"` } -// --- payloads ------------------------------------------------------------- +// --- identity derivations (TRN-ID) ---------------------------------------------- -type StartedPayload struct { - TurnID TurnID `json:"turnId"` - RunID run.RunID `json:"runId"` - InputIDs []run.InputID `json:"inputIds"` +func digestOf(domain string, parts ...string) es.Digest { + raw, _ := es.EncodeTypedPayload(1, domain, parts) + return es.DigestBytes(raw) } -type InputDeliveredPayload struct { - InputID run.InputID `json:"inputId"` - TurnID TurnID `json:"turnId"` - Content run.CanonicalJSON `json:"content"` +// PlanDigest is TRN-ID-2. +func PlanDigest(turnID TurnID, binding es.Digest, companion CompanionVersion, inputs []chatlog.InputID) es.Digest { + parts := []string{string(turnID), string(binding), string(companion)} + for _, id := range inputs { + parts = append(parts, string(id)) + } + return digestOf("twilight/turn/plan", parts...) } -type Settlement string +// 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)) +} -const ( - SettlementCompleted Settlement = "completed" - SettlementFailed Settlement = "failed" - SettlementStopped Settlement = "stopped" -) +// 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))) +} -type SettledPayload struct { - TurnID TurnID `json:"turnId"` - RunID run.RunID `json:"runId"` - Settlement Settlement `json:"settlement"` - Revision uint64 `json:"revision"` - FailureClass string `json:"failureClass,omitempty"` - Reason string `json:"reason,omitempty"` +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))) } -type ToolCallPayload struct { - CallID run.CallID `json:"callId"` - ProviderCallID string `json:"providerCallId,omitempty"` - Name string `json:"name"` - Input run.CanonicalJSON `json:"input"` +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))) } -type AssistantPayload struct { - TurnID TurnID `json:"turnId"` - StepID run.StepID `json:"stepId"` - Text string `json:"text,omitempty"` - ToolCalls []ToolCallPayload `json:"toolCalls,omitempty"` +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))) } -type ToolResultStatus string +// --- module ----------------------------------------------------------------------- -const ( - ToolSuccess ToolResultStatus = "success" - ToolError ToolResultStatus = "error" - ToolUnknown ToolResultStatus = "unknown" -) +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}}} +} -type ToolResultPayload struct { - TurnID TurnID `json:"turnId"` - CallID run.CallID `json:"callId"` - ProviderCallID string `json:"providerCallId,omitempty"` - // Name is the tool name the model used for this call, echoed back with - // the result for providers that pair on it. - Name string `json:"name,omitempty"` - Status ToolResultStatus `json:"status"` - Output run.CanonicalJSON `json:"output,omitzero"` - Failure string `json:"failure,omitempty"` - Message string `json:"message,omitempty"` +// 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{ + ID: ModuleID, + Requires: []extension.ModuleRequirement{ + {Module: runmod.ModuleID, Events: map[session.EventType][]extension.PayloadVersion{ + runmod.Prefix + "run_created": {1}, + runmod.Prefix + "input_accepted": {1}, + runmod.Prefix + "run_ended": {1}, + }}, + {Module: chatlog.ModuleID}, + }, + Events: []extension.EventDefinition{ + def[StartedPayload](TypeStarted, func(p *StartedPayload) error { + if p.TurnID == "" || p.ExecutionBinding.ID == "" || p.ExecutionBinding.Digest == "" || p.Companion == "" || p.PlanDigest == "" { + return errors.New("started requires turnId, binding, companion and planDigest") + } + 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/docs/design/agent-artifact.md b/docs/design/agent-artifact.md index 3a28eae..6de90da 100644 --- a/docs/design/agent-artifact.md +++ b/docs/design/agent-artifact.md @@ -1,6 +1,6 @@ # Twilight Agent Artifact Core -状态:设计草案。无实现;wire 与 claim 状态表在 Memory reference implementation 通过 conformance 前不冻结。v1 的 claim 只有 `Active` 与 `Released` 两态,与 Session commit 同事务写入;`Prepared` 状态、reconciler、provider 迁移 fence 与 archive import/export 在附录中,不进入 v1 conformance。 +状态:设计草案。`agent/artifact` 已实现 Ref、Binding、Memory BindingStore、BindingSetBuilder 与两态 KV ledger;Resolver、Store、Promoter 与 scheme registry 未实现。wire 与 claim 状态表在 conformance 通过前不冻结。v1 的 claim 只有 `Active` 与 `Released` 两态,与 Session commit 同事务写入;`Prepared` 状态、reconciler、provider 迁移 fence 与 archive import/export 在附录中,不进入 v1 conformance。 本文定义 `agent/artifact`。文中的"必须""不得""应该"是协议约束;canonical JSON、JCS 与 domain-separated digest 使用 `agent/jsonstable` 和 `agent/es` 的通则。 diff --git a/docs/design/agent-reference-assembly.md b/docs/design/agent-reference-assembly.md index ec66581..fdff4d2 100644 --- a/docs/design/agent-reference-assembly.md +++ b/docs/design/agent-reference-assembly.md @@ -1,6 +1,8 @@ # Twilight Agent 参考组装 -状态:设计草案。与 [Run](agent-run.md)、[Turn](agent-turn.md)、[Chatlog](agent-session-chatlog.md) 冲突时以各正式规范为准。 +状态:设计草案。`agent/ref` 已实现 ExecutionBinding、ContextPlanner、Memory 组装与 SessionDriver。与 [Run](agent-run.md)、[Turn](agent-turn.md)、[Chatlog](agent-session-chatlog.md) 冲突时以各正式规范为准。 + +补充说明:ContextPlanner 把回合中途投递的输入排在其之前尚未结算的工具结果之后。原因是这类输入的 `input_delivered` 先于 `tool_result` 进入 stream,而 provider 要求工具结果紧随发出调用的 assistant 消息。fold 顺序不变,只影响请求组装。 本文规定 Memory 参考 agent 的四处组装:ExecutionBinding 公开字段、Planner、用户正文在 Chatlog Input 与 Run AgentInput 上的同一份 payload、session 作用域的输入路由(SessionDriver)。 diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index e304c53..8b8bae1 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -1,6 +1,6 @@ # Twilight Agent Run Protocol -状态:设计规范。Machine、command/fact 规则与 Loop 已有实现;第 5 节的 Runtime 与存储层无实现,实施记录见 [agent-runtime-refactor.md](agent-runtime-refactor.md)。 +状态:设计规范。Machine、command/fact 规则、Loop 与第 5 节的 Runtime(`agent/session/run`)已有 Memory 实现;RUN-CMP-2 的 Runtime conformance 尚未按本文重建,当前由 `agent/run/loop`、`agent/run/runtest` 与 `agent/ref` 的测试覆盖。实施记录见 [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` 的通则。 diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index b238435..833a73f 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -94,19 +94,25 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 | shared `agent/es` 与 RFC 8785 canonical JSON | 完成 | | Decide/Evolve/Next Run Machine | 完成 | | RunHeader、TransitionRecord、wire codec、fold/golden tests | 完成 | -| RunID-addressed `Runtime.Create/Load/Commit/Record` | 完成;待按第 6 节改为 Session module 形态 | -| multi-Run Store-backed Runtime 与 Runtime conformance | 完成;conformance 待迁移到 Session Store 之上 | -| 追加式 per-Run `Store` 合同、snapshot codec、lease 续期 | 完成;per-Run Store 将由 Session Store 的 `CommitIn` 取代,lease 表与 snapshot codec 保留 | -| SQLite per-Run Store adapter | 完成;随 per-Run Store 退役,SQLite 实现改为 Session Store adapter | -| `agent/run/loop` package extraction | 完成 | -| Turn 最小实现(`agent/turn`:Start / Resume / Stop、v1 FactMapper、append-only MemoryLog、崩溃后 Resume) | 完成;FactMapper 与 MaterializeAll 将改为 companion,Log 由 Session Store 取代 | -| 单一 Session ES 的设计文档(agent-run 第 5 节、agent-turn、session CommitIn、extension/chatlog 修订) | 完成,2026-09-04 | -| Session/Artifact/Session Module protocols | 草案,无实现;wire 在 Memory 纵向切片跑通前不冻结 | -| Chatlog protocol | 草案,payload 与 golden 尚未冻结 | -| 参考组装 | 草案 | -| PostgreSQL durable Run adapter(旧接口) | 历史 prototype,迁移未完成 | - -当前正式调用形态为 Application 组合 shared `run.Runtime` 与 `loop.Loop`。Loop 不保存 authority state;Runtime 不读取 queue 或 planner context。 +| 第 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 未实现 | +| `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`(ExecutionBinding、ContextPlanner、Memory 组装、SessionDriver、崩溃恢复 example) | 完成,2026-09-07 | +| Runtime conformance(RUN-CMP-2)与各模块 conformance | 未按新合同重建;旧 `runtimetest` 已随 per-Run Store 删除 | +| SQLite / PostgreSQL Session Store adapter、live 模型接入 | 未开始 | + +当前正式调用形态为 `agent/ref` 的 Memory 组装:`ref.New` 返回 Store、Registry、Appender、Projections、Runtime、Coordinator 与 Bindings;Application 经 `SessionDriver.Send` 投递输入。Loop 不保存 authority state;Runtime 不读取 queue 或 planner context。 + +实现与规范的两处差异,待确认: + +1. Runtime 在控制面 KV 的 `twilight/run/command` 命名空间记录每个 CommitID 的 command digest,与 commit 同事务写入,用于区分精确重放与同 CommandID 的冲突。规范第 5.1 节未列出该条目;command 本身仍不持久化。 +2. 终态 Run 从 `twilight/run/machine` 投影移除后,`Runtime.Load` 对该 Run 改为按 RunID 过滤 replay 后折叠返回终态,而不是返回 `ErrRunNotFound`;Loop 依赖这一行为在结算后读到终态。 ## 4. 后续实施工作 diff --git a/docs/design/agent-session-chatlog.md b/docs/design/agent-session-chatlog.md index 009e704..4fc09f9 100644 --- a/docs/design/agent-session-chatlog.md +++ b/docs/design/agent-session-chatlog.md @@ -1,6 +1,6 @@ # Twilight Agent Session Chatlog Module -状态:设计草案,payload 字段、输入 limits 与 golden fixtures 尚未冻结。 +状态:设计草案。`agent/session/chatlog` 已实现事件定义、parts codec、PartsExtractor、Surface 与 Context 投影;checkpoint 事件未实现。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`。 diff --git a/docs/design/agent-session-extension.md b/docs/design/agent-session-extension.md index 20661e3..6458000 100644 --- a/docs/design/agent-session-extension.md +++ b/docs/design/agent-session-extension.md @@ -1,6 +1,6 @@ # Twilight Agent Session Module Framework -状态:设计草案。无实现;Registry、SemanticAppender、Lease 与 projection 在 Memory reference implementation 通过 conformance 前不冻结。v1 为 first-party 固定注册表与单事务 append;Application module、通用 Catalog 与两阶段 journal 在附录中,不进入 v1 conformance。 +状态:设计草案。`agent/session/extension` 已实现 Registry(含 `Requires` 校验)、SemanticAppender 两个入口、Lease 与 ProjectionReader;第 8 节 conformance 部分实现。wire 在 conformance 完整通过前不冻结。v1 为 first-party 固定注册表与单事务 append;Application module、通用 Catalog 与两阶段 journal 在附录中,不进入 v1 conformance。 本文定义建立在 `agent/session` 与 `agent/artifact` 之上的 Session Module Framework。实现包路径为 `agent/session/extension`;文中的"必须""不得""应该"是协议约束;JSON canonicalization 与 digest 遵循 `agent/jsonstable`、`agent/es`。 diff --git a/docs/design/agent-session.md b/docs/design/agent-session.md index e72637c..bec9cc6 100644 --- a/docs/design/agent-session.md +++ b/docs/design/agent-session.md @@ -1,6 +1,6 @@ # Twilight Agent Session Protocol -状态:设计草案。无实现;wire、digest preimage 与 conformance 在 Memory reference implementation 与 Input → Turn → Run → Session 纵向切片跑通前不冻结。v1 为单 stream kernel;Fork、ancestry、canonical import 在附录 A 中,不进入 v1 conformance。 +状态:设计草案。`agent/session` 已有 Memory reference implementation(Create、Commit、CommitIn、Types 过滤 replay、snapshot、控制面 KV),纵向切片已在 `agent/ref` 跑通;wire、digest preimage 与 conformance 在第 8 节列出的检查全部实现前不冻结。v1 为单 stream kernel;Fork、ancestry、canonical import 在附录 A 中,不进入 v1 conformance。 本文定义 Twilight Session 的 Event Sourcing kernel。文中的"必须""不得""应该"是协议约束。 diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index 00a9de2..09d34d7 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -1,6 +1,6 @@ # Twilight Agent Turn 协议 -状态:设计草案。现有 `agent/turn` 代码不符合本文,待重写;Run 事实与 Turn、Chatlog 事件同在一条 Session stream。 +状态:设计草案。`agent/turn` 已按本文重写(Coordinator 的 Start / Deliver / Resume / Retry / Stop / Settle、CompanionV1、surface 投影);第 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)。 From 563cffec0ac50f0b766a072c2c6b9ac0c13910f5 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Mon, 7 Sep 2026 22:29:02 +0800 Subject: [PATCH 66/81] Run Runtime: idempotency is the kernel's CommitID alone Drop the twilight/run/command control-plane index that stored a command digest per CommitID to tell an exact replay from a same-ID conflict. Every Run CommandID is content-derived, so a LookupCommit hit is the same command; the two families whose identity deliberately omits content (approve/reject of one ResponseID, two settlements of one attempt) are read back from the projection instead. RUN-CMT-5 and the refactor record updated. --- agent/session/run/module.go | 4 ---- agent/session/run/runtime.go | 16 ++++------------ docs/design/agent-run.md | 6 +++--- docs/design/agent-runtime-refactor.md | 7 ++++--- 4 files changed, 11 insertions(+), 22 deletions(-) diff --git a/agent/session/run/module.go b/agent/session/run/module.go index c770a4e..1288214 100644 --- a/agent/session/run/module.go +++ b/agent/session/run/module.go @@ -21,10 +21,6 @@ const ( Prefix session.EventType = "twilight/run/" // LeaseNamespace holds execution occupancy (RUN 5.1). LeaseNamespace session.ControlNamespace = "twilight/run/lease" - // CommandNamespace is the control-plane idempotency index: CommitID -> - // command digest, written in the commit's transaction so an exact replay - // can be told from a same-ID conflict without persisting the command. - CommandNamespace session.ControlNamespace = "twilight/run/command" ) // factNames is the closed list of v1 fact discriminators. diff --git a/agent/session/run/runtime.go b/agent/session/run/runtime.go index ff4d3ec..fac1165 100644 --- a/agent/session/run/runtime.go +++ b/agent/session/run/runtime.go @@ -251,18 +251,13 @@ func (r *Runtime) evaluate(tx extension.SemanticTx, sid session.SessionID, req * commitID := session.CommitID(env.ID) runID := env.RunID - // Steps 2-3: exact replay vs same-ID conflict, via the control-plane - // command index and the stored commit. + // Steps 2-3: replay. Idempotency is the kernel's (SessionID, CommitID) + // alone (RUN-CMT-5): every Run CommandID is content-derived, so a hit is + // the same command; the caller reads the projection for the effective + // outcome. No Decide runs on replay. if existing, found, err := tx.LookupCommit(commitID); err != nil { return nil, run.CommitResult{}, nil, err } else if found { - entry, ok, err := tx.ControlGet(CommandNamespace, string(commitID)) - if err != nil { - return nil, run.CommitResult{}, nil, err - } - if !ok || string(entry.Value) != string(env.Digest) { - return nil, run.CommitResult{}, run.ErrCommandConflict, nil - } snapshot, err := r.snapshotIn(tx, sid, runID) if err != nil { return nil, run.CommitResult{}, nil, err @@ -422,9 +417,6 @@ func (r *Runtime) evaluate(tx extension.SemanticTx, sid session.SessionID, req * } } } - if err := tx.ControlPut(CommandNamespace, string(commitID), []byte(env.Digest), 0); err != nil { - return nil, run.CommitResult{}, nil, err - } // 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 { diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 8b8bae1..90ce857 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -105,7 +105,7 @@ companion 与 Attach 事件与 Run 事实一起经 Module Framework 的 admissio | model recovery CommandID | RunID、StepID、Claim | | tool recovery CommandID(RecoverExpired 的 Unknown) | RunID、StepID、CallID、Claim | -同一派生 identity 的内容变化在 Session kernel 表现为 `CommitConflict`(同 CommitID、不同 event group)。`PlanningToken` 是 Application-owned opaque freshness token,属于 prepare command identity 内容;Run 不校验它的语义(RUN-CMT-4)。 +派生 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 @@ -426,7 +426,7 @@ FrozenValueStore 的 `Put` 幂等且内容寻址,在进入事务之前完成 **RUN-CMT-4** `PrepareModelRequest` 是 hard-CAS command:`Base` 必须等于 section 内投影记录的该 Run 的 `Position`。这是有意选择:同一 Session 内其他模块的写入(用户提交新输入、summary、checkpoint、其他 Turn 的事件)不移动 Position,因此不使 Prepare 失效;Plan 与 Prepare 之间发生的 chatlog 写入不会被本次请求包含,新鲜度由 Application 经 `PlanningToken` 与 Planner 自行负责,Run 不校验 `PlanningToken` 的语义。其他 command 通过当前 target state 和 grant 做 call-local rebase;stale Base 本身不阻止无冲突的 ingress/control/settlement。相同 command 的 replay 判定先于 terminal check,因此 terminal Run 仍能返回原 commit。 -**RUN-CMT-5** 幂等键为 Session kernel 的 `(SessionID, CommitID)`,CommitID 等于 CommandID。同 CommandID 的精确重放返回 `CommitAlreadyApplied`、当前 snapshot 与原完整 commit,且不得再次 Decide 或产生外部 effect。对于 `StartModelExecution` 和 `StartToolCall`,Runtime 还必须验证 command 中的 `ExecutionClaim`:相同 CommandID、相同 digest、相同 claim 的精确重放在 grant 仍 live 时返回原 start grant;不同 claim 触发 `ErrCommandConflict`,并保持现有执行授权。非 start command 的 replay 不返回 grant。 +**RUN-CMT-5** 幂等键为 Session kernel 的 `(SessionID, CommitID)`,CommitID 等于 CommandID,Runtime 不另设幂等索引。同 CommandID 的重放返回 `CommitAlreadyApplied`、当前 snapshot 与原完整 commit,且不得再次 Decide 或产生外部 effect;command 不持久化,Runtime 不比对重放 command 的内容,同 CommandID 视为同一 command。对于 `StartModelExecution` 和 `StartToolCall`,claim 是 CommandID 的 preimage,不同 claim 即不同 command:其 start 按当前 target state 与 lease 评估,target 已被占用时返回 `ErrStaleRuntime`,并保持现有执行授权。start 的重放在 grant 仍 live 时返回原 start grant。非 start command 的 replay 不返回 grant。 **RUN-CMT-6** accepted `StartModelExecution`/`StartToolCall` 为目标签发新 grant;该 start 的 `CommitAccepted` 和在 grant 仍 live 时满足精确 replay 条件的 `CommitAlreadyApplied` 返回同一个 grant。若该 start 已 settlement 或 Run 已 terminal,精确 replay 仍返回 `CommitAlreadyApplied`,并返回空 grant。model result/failure/reject 与 executing tool result/known failure 必须携带 live target grant。settlement 接受后 grant 失效;terminal commit 撤销该 Run 全部 grant。`RecoverModelExecution` 由 live grant holder 提交,或在 Runtime 验证 lease 已过期且 command Claim 等于该 lease 的 Claim 后无 grant 提交。Executing tool 的 recovery 使用同一条 `SubmitToolFailure{Outcome:Unknown}` command:工具 owner 必须携带 live grant;`RecoverExpired` 仅在 lease 已过期且没有已接受 settlement 时无 grant 提交。该 Unknown 只结算这一 call,Run 保持 Active。 @@ -586,7 +586,7 @@ type Event struct { **RUN-CMP-2** Runtime conformance 必须覆盖: - Start group 建立 Run、重复 `created` 拒绝、missing Run、schema 与 created 不一致的 command 拒绝; -- command exact replay/conflict、prepare hard CAS、call-local rebase、terminal replay; +- command 重放(同 CommandID 返回 AlreadyApplied 且不再 Decide)、不同 claim 的 start 被拒绝、prepare hard CAS、call-local rebase、terminal replay; - 输入入队:`AcceptInput` 在 Open、Model Prepared、Model Executing、ToolStep(含 Waiting)都被接受;Prepared 期间入队后 `Next` 返回 `WithdrawPrepared`,Withdraw 后重规划的 Prepare 包含该输入;Executing 期间入队的输入在该步结算后的 Prepare 中被消费;无 tool call 且有 pending 输入的 `SubmitModelResult` 不结束 Run; - grant 签发、隔离、精确 start replay、消费、跨 Run 拒绝与 recovery authorization; - 一 command 一 commit、run facts 在 companion 与 Attach 之前、companion 的 `SourceDigest` 等于 fact 记录的 ResultDigest / OutputDigest、Attach 拒绝 `twilight/run/` 事件、companion 与 Attach 经 admission 并建立 claim; diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 833a73f..13b6029 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -109,10 +109,11 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 当前正式调用形态为 `agent/ref` 的 Memory 组装:`ref.New` 返回 Store、Registry、Appender、Projections、Runtime、Coordinator 与 Bindings;Application 经 `SessionDriver.Send` 投递输入。Loop 不保存 authority state;Runtime 不读取 queue 或 planner context。 -实现与规范的两处差异,待确认: +实现与规范的差异,待确认: -1. Runtime 在控制面 KV 的 `twilight/run/command` 命名空间记录每个 CommitID 的 command digest,与 commit 同事务写入,用于区分精确重放与同 CommandID 的冲突。规范第 5.1 节未列出该条目;command 本身仍不持久化。 -2. 终态 Run 从 `twilight/run/machine` 投影移除后,`Runtime.Load` 对该 Run 改为按 RunID 过滤 replay 后折叠返回终态,而不是返回 `ErrRunNotFound`;Loop 依赖这一行为在结算后读到终态。 +1. 终态 Run 从 `twilight/run/machine` 投影移除后,`Runtime.Load` 对该 Run 改为按 RunID 过滤 replay 后折叠返回终态,而不是返回 `ErrRunNotFound`;Loop 依赖这一行为在结算后读到终态。 + +已决定(2026-09-07):Runtime 不为 command digest 另设控制面索引;同 CommandID 一律按重放处理,幂等只由 kernel 的 `(SessionID, CommitID)` 承担(RUN-CMT-5)。曾实现过 `twilight/run/command` 索引,用于把同 ID 不同内容判为冲突;该判定只覆盖 approve/reject 撞 ID 与同一 attempt 两次结算两种情形,前者由调用方读投影覆盖,后者属于实现错误,故删除。 ## 4. 后续实施工作 From e51a2ad5f8efc1de728b483a8cbb1953a9a6171c Mon Sep 17 00:00:00 2001 From: Fodesu Date: Mon, 7 Sep 2026 23:08:58 +0800 Subject: [PATCH 67/81] Loop finishes from the terminal settlement; Coordinator builds envelopes from the surface settle returns the terminal RunResult when a model settlement ends the Run, and Loop.Run finishes from it instead of reloading a Run the machine projection no longer holds (RUN 7). AttemptView records SchemaVersion so Deliver and Stop construct envelopes without reading the machine projection; AcceptInput and CancelRun carry no Base (RUN-CMT-4). Load on a terminated Run keeps folding its events as the fallback (RUN-CMT-1). --- agent/run/loop/cache.go | 32 +++++++++++++++--------- agent/run/loop/loop.go | 30 +++++++++++++--------- agent/run/loop/model.go | 36 +++++++++++++++------------ agent/run/loop/tool.go | 5 ++-- agent/turn/coordinator.go | 36 +++++++++++++++------------ agent/turn/projection.go | 15 ++++++++++- docs/design/agent-run.md | 5 +++- docs/design/agent-runtime-refactor.md | 4 +-- docs/design/agent-turn.md | 7 +++--- 9 files changed, 104 insertions(+), 66 deletions(-) diff --git a/agent/run/loop/cache.go b/agent/run/loop/cache.go index d0dfdd3..a5624c9 100644 --- a/agent/run/loop/cache.go +++ b/agent/run/loop/cache.go @@ -141,7 +141,11 @@ func (l *Loop) forgetRunClaims(ctx context.Context, sid session.SessionID, runID // CommandID. On success or on a sentinel rejection the claim is released: // the attempt is over either way. A transport failure keeps the claim so the // next Run (in this or a replacement process) replays the same settlement. -func (l *Loop) settle(ctx context.Context, runtime boundRuntime, events EventSink, a attempt, base run.RunPosition, grant run.ExecutionGrant, cmd run.AgentCommand, proto run.Protocol) error { +// +// 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, grant run.ExecutionGrant, cmd run.AgentCommand, proto run.Protocol) (*run.RunResult, error) { id := a.settlementID() if _, recovering := cmd.(run.RecoverModelExecution); recovering { id = a.recoveryID() @@ -150,30 +154,34 @@ func (l *Loop) settle(ctx context.Context, runtime boundRuntime, events EventSin if err != nil { if retriable(err) { l.forgetClaim(ctx, a) - return nil + return nil, nil } - return err + return nil, err } l.forgetClaim(ctx, a) l.emitCommitted(ctx, events, runtime.sid, a.runID, &res.Commit) - return nil + if res.Snapshot.State.Status.Terminal() { + return res.Snapshot.State.Result, nil + } + return nil, nil } // resumeOwnedStarts re-enters every Executing target this Loop holds a claim // for. With a durable ClaimStore this is how a replacement process finishes // what its predecessor started: the derived start ID replays and returns the // live grant, then the effect runs (or re-runs) and settles. -func (l *Loop) resumeOwnedStarts(ctx context.Context, runtime boundRuntime, events EventSink, snapshot *run.RuntimeSnapshot) (bool, error) { +func (l *Loop) resumeOwnedStarts(ctx context.Context, runtime boundRuntime, events EventSink, snapshot *run.RuntimeSnapshot) (handled bool, finished *run.RunResult, err error) { runID := snapshot.State.RunID switch current := snapshot.State.Current.(type) { case run.ModelStep: if current.Status != run.ModelExecuting { - return false, nil + return false, nil, nil } if ok, err := l.hasClaim(ctx, runtime.sid, runID, current.RefValue.ID, ""); err != nil || !ok { - return false, err + return false, nil, err } - return true, l.runModelStep(ctx, runtime, events, snapshot, current.RefValue.ID) + finished, err := l.runModelStep(ctx, runtime, events, snapshot, current.RefValue.ID) + return true, finished, err case run.ToolStep: var ids []run.CallID for _, call := range current.Calls { @@ -181,17 +189,17 @@ func (l *Loop) resumeOwnedStarts(ctx context.Context, runtime boundRuntime, even continue } if ok, err := l.hasClaim(ctx, runtime.sid, runID, current.RefValue.ID, call.CallID); err != nil { - return false, err + return false, nil, err } else if ok { ids = append(ids, call.CallID) } } if len(ids) == 0 { - return false, nil + return false, nil, nil } - return true, l.runToolCalls(ctx, runtime, events, snapshot, run.StartToolCalls{StepID: current.RefValue.ID, CallIDs: ids}) + return true, nil, l.runToolCalls(ctx, runtime, events, snapshot, run.StartToolCalls{StepID: current.RefValue.ID, CallIDs: ids}) default: - return false, nil + return false, nil, nil } } diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index a7cd989..85e8139 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -105,6 +105,15 @@ func (l *Loop) Run(ctx context.Context, rt run.Runtime, sid session.SessionID, r 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 { + l.forgetRunClaims(ctx, sid, runID) + 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 { @@ -120,23 +129,16 @@ func (l *Loop) Run(ctx context.Context, rt run.Runtime, sid session.SessionID, r return LoopResult{}, fmt.Errorf("agent: loop: runtime returned RunID %q for %q", snapshot.State.RunID, runID) } if snapshot.State.Status.Terminal() { - l.forgetRunClaims(ctx, sid, runID) - if events != nil { - _ = events.Emit(ctx, Event{ - Session: sid, - RunID: snapshot.State.RunID, - Kind: EventRunFinished, - Durability: EventCommitted, - }) - } - return LoopResult{Disposition: LoopFinished, Result: snapshot.State.Result}, nil + return finish(snapshot.State.Result), nil } if l.Claims == nil { return LoopResult{}, errNoClaimStore } - if handled, err := l.resumeOwnedStarts(ctx, runtime, events, &snapshot); err != nil { + if handled, finished, err := l.resumeOwnedStarts(ctx, runtime, events, &snapshot); err != nil { return LoopResult{}, err + } else if finished != nil { + return finish(finished), nil } else if handled { continue } @@ -168,9 +170,13 @@ func (l *Loop) Run(ctx context.Context, rt run.Runtime, sid session.SessionID, r l.emitCommitted(ctx, events, sid, runID, &res.Commit) } case run.StartModelCall: - if err := l.runModelStep(ctx, runtime, events, &snapshot, eff.StepID); err != nil { + 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 diff --git a/agent/run/loop/model.go b/agent/run/loop/model.go index 4c2e2f4..129421f 100644 --- a/agent/run/loop/model.go +++ b/agent/run/loop/model.go @@ -83,35 +83,38 @@ func (l *Loop) planAndPrepare(ctx context.Context, runtime boundRuntime, events // --- StartModelCall --- -func (l *Loop) runModelStep(ctx context.Context, runtime boundRuntime, events EventSink, snapshot *run.RuntimeSnapshot, stepID run.StepID) error { +// 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 err + return nil, err } a, err := l.claimFor(ctx, runtime.sid, runID, stepID, "") if err != nil { - return err + return nil, err } 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) { l.forgetClaim(ctx, a) - return nil + return nil, nil } // The start may have committed while its response was lost. The // claim stays stored so a later Run replays the derived start ID and // recovers the grant. - return err + return nil, err } if start.Status == run.CommitAlreadyApplied && start.Grant == "" { // Settled already: by this Loop before a lost response, or by // recovery. Nothing left to own. l.forgetClaim(ctx, a) - return nil + return nil, nil } if start.Grant == "" { - return errors.New("agent: loop: start model returned no execution grant") + return nil, errors.New("agent: loop: start model returned no execution grant") } l.emitCommitted(ctx, events, runtime.sid, runID, &start.Commit) @@ -123,9 +126,9 @@ func (l *Loop) runModelStep(ctx context.Context, runtime boundRuntime, events Ev // let the next machine state decide what to do. if start.Status == run.CommitAlreadyApplied { l.forgetClaim(ctx, a) - return nil + return nil, nil } - return fmt.Errorf("agent: loop: started step %q is not current", stepID) + return nil, fmt.Errorf("agent: loop: started step %q is not current", stepID) } var completion run.AgentCommand @@ -151,10 +154,10 @@ func (l *Loop) runModelStep(ctx context.Context, runtime boundRuntime, events Ev 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, start.Grant, run.RecoverModelExecution{StepID: stepID, Claim: a.claim}, proto); err != nil { - return err + if _, err := l.settle(ctx, runtime, events, a, start.Snapshot.Position, start.Grant, run.RecoverModelExecution{StepID: stepID, Claim: a.claim}, proto); err != nil { + return nil, err } - return fetchErr + return nil, fetchErr } failure := run.StepFailure{Class: run.FailureMalformedModel, Message: fetchErr.Error()} completion = run.RejectModelResult{StepID: stepID, Failure: failure, Disposition: l.modelRejectDisposition(modelStep, failure)} @@ -184,13 +187,14 @@ func (l *Loop) runModelStep(ctx context.Context, runtime boundRuntime, events Ev } } - if err := l.settle(ctx, runtime, events, a, start.Snapshot.Position, start.Grant, completion, proto); err != nil { - return err + finished, err := l.settle(ctx, runtime, events, a, start.Snapshot.Position, start.Grant, completion, proto) + if err != nil { + return nil, err } if catalogErr != nil { - return fmt.Errorf("agent: loop: model catalog: %w", catalogErr) + return nil, fmt.Errorf("agent: loop: model catalog: %w", catalogErr) } - return nil + return finished, nil } func (l *Loop) modelRejectDisposition(step run.ModelStep, failure run.StepFailure) run.ModelRejectDisposition { diff --git a/agent/run/loop/tool.go b/agent/run/loop/tool.go index e03bdae..48aa61b 100644 --- a/agent/run/loop/tool.go +++ b/agent/run/loop/tool.go @@ -162,7 +162,7 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime boundRuntime, events Ev } failure.Class = run.FailureEffectUnknown } - if err := l.settle(ctx, runtime, events, a, start.Snapshot.Position, start.Grant, + if _, err := l.settle(ctx, runtime, events, a, start.Snapshot.Position, start.Grant, run.SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: failure, Outcome: run.ToolOutcomeUnknown}, proto); err != nil { settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, proto) if settleErr != nil { @@ -251,7 +251,8 @@ func (l *Loop) settleWorkers(ctx context.Context, runtime boundRuntime, events E // 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. - if err := l.settle(controlCtx, runtime, events, w.attempt, w.base, w.grant, cmd, proto); err != nil { + // Tool settlements never terminate a Run; the result is ignored. + if _, err := l.settle(controlCtx, runtime, events, w.attempt, w.base, w.grant, cmd, proto); err != nil { if firstErr == nil { firstErr = fmt.Errorf("agent: loop: settling call %q: %w", w.call.CallID, err) } diff --git a/agent/turn/coordinator.go b/agent/turn/coordinator.go index 8db4221..1dc51b6 100644 --- a/agent/turn/coordinator.go +++ b/agent/turn/coordinator.go @@ -231,21 +231,24 @@ func (c *Coordinator) Deliver(ctx context.Context, req DeliverRequest) (TurnResp if !ok || view.Status != TurnActive { return TurnResponse{}, fmt.Errorf("%w: turn %s is not active", ErrConflict, req.Ref.TurnID) } - runID := view.ActiveRun + // 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 { - snapshot, err := c.Runtime.Load(ctx, sid, runID) - if err != nil { - return TurnResponse{}, err - } - proto, err := snapshot.Protocol() - if err != nil { - return TurnResponse{}, err - } 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{Base: snapshot.Position, Command: env, + _, 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) { @@ -347,12 +350,12 @@ func (c *Coordinator) Stop(ctx context.Context, req StopRequest) (TurnResponse, if !ok || view.Status != TurnActive { return TurnResponse{}, fmt.Errorf("%w: turn %s is not active", ErrConflict, turnID) } - runID := view.ActiveRun - snapshot, err := c.Runtime.Load(ctx, sid, runID) - if err != nil { - return TurnResponse{}, err + att := view.ActiveAttempt() + if att == nil { + return TurnResponse{}, fmt.Errorf("%w: turn %s has no active attempt", ErrConflict, turnID) } - proto, err := snapshot.Protocol() + runID := att.RunID + proto, err := run.ProtocolFor(att.SchemaVersion) if err != nil { return TurnResponse{}, err } @@ -360,7 +363,8 @@ func (c *Coordinator) Stop(ctx context.Context, req StopRequest) (TurnResponse, if err != nil { return TurnResponse{}, err } - _, err = c.Runtime.Commit(ctx, sid, run.CommitRequest{Base: snapshot.Position, Command: env, + // 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 diff --git a/agent/turn/projection.go b/agent/turn/projection.go index 73459ad..9889c0e 100644 --- a/agent/turn/projection.go +++ b/agent/turn/projection.go @@ -26,6 +26,9 @@ const ( 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"` } @@ -58,6 +61,16 @@ func (v *TurnView) LastAttempt() *AttemptView { 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"` @@ -167,7 +180,7 @@ func (s TurnSurface) applyRun(ev runmod.Event) (any, error) { 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}) + 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 diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 90ce857..807d17c 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -387,7 +387,7 @@ type CommitResult struct { } ``` -**RUN-CMT-1** Runtime 按 `(SessionID, RunID)` 寻址。Run 由 Coordinator 的 Start commit 创建(TRN-STR-2),Runtime 没有 `Create`。缺失 Run 的 Load、Commit、Record 返回 `ErrRunNotFound`。 +**RUN-CMT-1** Runtime 按 `(SessionID, RunID)` 寻址。Run 由 Coordinator 的 Start commit 创建(TRN-STR-2),Runtime 没有 `Create`。`ErrRunNotFound` 只用于该 Session 中不存在的 RunID。已终结的 Run 不在 `twilight/run/machine` 投影中(RUN-CMT-2),`Load` 对它以 `Types=[twilight/run/]` 过滤 replay、按 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),`RequireComplete` 为 `run`,状态为: @@ -540,6 +540,9 @@ Loop.Run(ctx, runtime, sessionID, runID, sink): if resumeCachedStart: continue // 重放本进程已接受、尚未结算的 start 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 可以并行驱动。 diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 13b6029..fbc1eb6 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -109,9 +109,7 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 当前正式调用形态为 `agent/ref` 的 Memory 组装:`ref.New` 返回 Store、Registry、Appender、Projections、Runtime、Coordinator 与 Bindings;Application 经 `SessionDriver.Send` 投递输入。Loop 不保存 authority state;Runtime 不读取 queue 或 planner context。 -实现与规范的差异,待确认: - -1. 终态 Run 从 `twilight/run/machine` 投影移除后,`Runtime.Load` 对该 Run 改为按 RunID 过滤 replay 后折叠返回终态,而不是返回 `ErrRunNotFound`;Loop 依赖这一行为在结算后读到终态。 +已决定(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 两次结算两种情形,前者由调用方读投影覆盖,后者属于实现错误,故删除。 diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index 09d34d7..6bf4508 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -114,7 +114,8 @@ const ( type AttemptView struct { RunID run.RunID Attempt uint32 - End *run.RunEnd // 非终态时为 nil + SchemaVersion uint16 // created.SchemaVersion;Coordinator 据此构造该 attempt 的 command envelope + End *run.RunEnd // 非终态时为 nil } type TurnView struct { TurnID TurnID @@ -228,7 +229,7 @@ InputIDs 为空时 group 为 `started` 加 `created`。`created` 与 `input_acce **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}`。Run 接受输入与 chatlog 把输入挂到 Turn 在同一 commit 可见。`AcceptInput` 在 Run 的任意非终态都被接受(RUN-MCH-4),Deliver 不关心 Run 当前处于哪一步。CommandID 为 Run 的 input CommandID,重放幂等;多条输入中途失败时,以剩余条目重试。 +**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。提交后,若本进程没有在驱动该 Run,Deliver 进入 Drive;已在驱动时不动,运行中的 Loop 在下一次 Load 看到 `PendingInputs`。Deliver 与该 Run 的最后一步 `SubmitModelResult` 并发时由 Session 临界区定序:输入先提交,Run 回到 `Open` 继续;结果先提交,Run 已终结,Deliver 得到 `ErrRunTerminal` 并返回 `completed`,该输入未被 delivered。 @@ -238,7 +239,7 @@ InputIDs 为空时 group 为 `started` 加 `created`。`created` 与 `input_acce **TRN-RSM-1** Resume 要求投影中该 Turn 为 `active`,取 `ActiveRun` 进入 Drive。`attempt_failed` 时返回该状态,由 Application 选择 Retry 或 Settle。 -**TRN-STP-1** Stop 要求 Turn 为 `active`。Coordinator 提交 `CancelRun{Reason:ReasonCancelled}`,并在 `CommitRequest.Attach` 中附加 `twilight/turn/failed{Settlement:stopped, FailureClass:"cancelled"}`;两者在同一 commit 可见。结算 Turn 是 Turn 层的决定,由发起 Stop 的 Coordinator 声明,Run 事实与 companion 不推断它。Application 直接提交的 `CancelRun` 不附加结算事件,Turn 进入 `attempt_failed`。Stop 时仍在 `PendingInputs` 中、尚未被 Prepare 消费的输入已经 delivered 到该 Turn:随后 Retry 会把它们与其他已 delivered 输入一起重放给新 attempt;Settle 则让它们随该 Turn 一起结束,不再进入任何模型请求。 +**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 供审计。 From 359f7074a926323ed2c99eaa2b1a519f60bfa6bf Mon Sep 17 00:00:00 2001 From: Fodesu Date: Mon, 7 Sep 2026 23:47:08 +0800 Subject: [PATCH 68/81] Rebuild RUN-CMP-2 as a Store-parameterized conformance suite Revise RUN-CMP-2 to assert Run semantics only (atomicity, digest chain and snapshot equivalence are referenced from the kernel and extension suites), add the decisions of the day (terminal Load, settlement snapshot, zero Base for non-Prepare commands, holder-bound tool recovery) and bind grantless tool recovery to the lease holder in RUN 5.1 / RUN-CMT-6. TRN-MAP-3 notes that a failed call's tool_result has no SourceDigest. agent/session/run/runtimetest implements the revised list against any session.Store; it runs on the Memory store. The suite exposed one gap: the machine projection accepted a second run_created for a terminated RunID, so the projection now keeps the set of ended RunIDs (ids only). --- agent/session/run/projection.go | 24 +- agent/session/run/runtimetest/conformance.go | 613 +++++++++++++++++++ agent/session/run/runtimetest/harness.go | 382 ++++++++++++ agent/session/run/runtimetest/memory_test.go | 11 + docs/design/agent-run.md | 40 +- docs/design/agent-runtime-refactor.md | 2 +- docs/design/agent-turn.md | 2 +- 7 files changed, 1050 insertions(+), 24 deletions(-) create mode 100644 agent/session/run/runtimetest/conformance.go create mode 100644 agent/session/run/runtimetest/harness.go create mode 100644 agent/session/run/runtimetest/memory_test.go diff --git a/agent/session/run/projection.go b/agent/session/run/projection.go index 9d5436c..4b837fd 100644 --- a/agent/session/run/projection.go +++ b/agent/session/run/projection.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "sort" "github.com/memohai/twilight/agent/jsonstable" "github.com/memohai/twilight/agent/run" @@ -16,15 +17,17 @@ 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. +// 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{}} + 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 { @@ -38,6 +41,9 @@ func (m Machine) clone() Machine { for k, v := range m.Schemas { out.Schemas[k] = v } + for k := range m.Ended { + out.Ended[k] = struct{}{} + } return out } @@ -54,6 +60,9 @@ func (m Machine) Apply(e extension.DecodedEvent) (Machine, error) { 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 @@ -79,6 +88,7 @@ func (m Machine) Apply(e extension.DecodedEvent) (Machine, error) { 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 @@ -87,7 +97,8 @@ func (m Machine) Apply(e extension.DecodedEvent) (Machine, error) { } type machineWire struct { - Runs map[run.RunID]machineRunWire `json:"runs"` + Runs map[run.RunID]machineRunWire `json:"runs"` + Ended []run.RunID `json:"ended,omitempty"` } type machineRunWire struct { @@ -126,6 +137,10 @@ func (c machineCodec) Encode(value any) (jsonstable.Value, error) { } 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) } @@ -151,6 +166,9 @@ func (machineCodec) Decode(wire jsonstable.Value) (any, error) { m.Positions[id] = r.Position m.Schemas[id] = r.Schema } + for _, id := range w.Ended { + m.Ended[id] = struct{}{} + } return m, nil } diff --git a/agent/session/run/runtimetest/conformance.go b/agent/session/run/runtimetest/conformance.go new file mode 100644 index 0000000..d5a5502 --- /dev/null +++ b/agent/session/run/runtimetest/conformance.go @@ -0,0 +1,613 @@ +package runtimetest + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/memohai/twilight/agent/artifact" + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/chatlog" + "github.com/memohai/twilight/agent/session/extension" + runmod "github.com/memohai/twilight/agent/session/run" + "github.com/memohai/twilight/agent/turn" +) + +// Run executes the RUN-CMP-2 Runtime conformance suite against stores made +// by newStore. +func Run(t *testing.T, newStore Factory) { + t.Helper() + for name, fn := range map[string]func(*testing.T, Factory){ + "Creation": testCreation, + "ReplayAndBase": testReplayAndBase, + "InputQueue": testInputQueue, + "Grants": testGrants, + "CommitComposition": testCommitComposition, + "Admission": testAdmission, + "SettlementSnapshot": testSettlementSnapshot, + "PrepareCAS": testPrepareCASIgnoresOtherModules, + "LeaseAndCommit": testLeaseAndCommit, + "Projection": testProjection, + "Isolation": testIsolation, + "ExpiryRecovery": testExpiryRecovery, + "Renewal": testRenewal, + "FrozenValues": testFrozenValues, + } { + t.Run(name, func(t *testing.T) { fn(t, newStore) }) + } +} + +// --- 建立与寻址 ----------------------------------------------------------------------- + +func testCreation(t *testing.T, newStore Factory) { + h := newHarness(t, newStore(t), 0) + 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) + } + if snap.Position.Revision != h.head().Revision || snap.Position.Index != 3 { + t.Fatalf("position = %+v, want revision %d index 3 (started, delivered, created, accepted)", snap.Position, h.head().Revision) + } + // 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", run.RunPosition{}, "", 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"), run.RunPosition{}, "", run.AcceptInput{Input: input("late")}); !errors.Is(err, run.ErrRunTerminal) { + t.Fatalf("commit on terminal = %v", err) + } + // A second created for the same RunID breaks the fold. + h.mustApply(h.startGroup("t2", "r1", 1)) + if _, err := h.rt.Load(h.ctx, sid, "r1"); err == nil { + t.Fatal("duplicate created folded silently") + } +} + +// --- 重放与 Base ---------------------------------------------------------------------- + +func testReplayAndBase(t *testing.T, newStore Factory) { + h := newHarness(t, newStore(t), 0) + h.startRun("t1", "r1", input("in-1")) + first := h.mustCommit("r1", run.DeriveInputCommandID("r1", "in-2"), run.RunPosition{}, "", 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"), run.RunPosition{}, "", run.AcceptInput{Input: input("in-2")}) + if again.Status != run.CommitAlreadyApplied || again.Commit.CommitID != first.Commit.CommitID || again.Commit.CommitDigest != first.Commit.CommitDigest { + t.Fatalf("replay = %+v", again) + } + if h.head().Revision != first.Commit.Revision { + t.Fatal("replay appended a commit") + } + // Prepare is a hard CAS on the Run's own position. + snap := h.load("r1") + stale := run.RunPosition{Revision: snap.Position.Revision - 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"), run.RunPosition{}, "", 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"), run.RunPosition{}, "", 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"), run.RunPosition{}, "", 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", run.RunPosition{}, "", 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, newStore Factory) { + h := newHarness(t, newStore(t), 0) + 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"), run.RunPosition{}, "", 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. + grant, claim := h.startModel("r1", cmd.StepID) + h.mustCommit("r1", run.DeriveInputCommandID("r1", "in-3"), run.RunPosition{}, "", run.AcceptInput{Input: input("in-3")}) + res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", cmd.StepID, "", claim), run.RunPosition{}, grant, 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, newStore(t), 0) + h2.startRun("t1", "r2", input("in-1")) + h2.openToolStep("r2", 1) + res = h2.mustCommit("r2", run.DeriveInputCommandID("r2", "in-9"), run.RunPosition{}, "", 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) + } +} + +// --- grant ----------------------------------------------------------------------------- + +func testGrants(t *testing.T, newStore Factory) { + h := newHarness(t, newStore(t), 0) + h.startRun("t1", "r1", input("in-1")) + h.startRun("t2", "r2", input("in-1")) + step, grant, claim := h.executingModel("r1", false) + lease, ok := h.lease("r1", step, "") + if !ok || run.ExecutionGrant(lease.Token) != grant || lease.Holder != string(claim) { + t.Fatalf("lease = %+v ok=%v, grant %s", lease, ok, grant) + } + // Same-claim replay returns the same grant; another claim is refused. + replay := h.mustCommit("r1", run.DeriveStartCommandID("r1", step, "", claim), run.RunPosition{}, "", run.StartModelExecution{StepID: step, Claim: claim}) + if replay.Status != run.CommitAlreadyApplied || replay.Grant != grant { + t.Fatalf("start replay = %+v", replay) + } + other := h.claim() + if _, err := h.commit("r1", run.DeriveStartCommandID("r1", step, "", other), run.RunPosition{}, "", run.StartModelExecution{StepID: step, Claim: other}); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("second claim start = %v", err) + } + // Settlement needs the live grant of its own target. + settle := run.SubmitModelResult{StepID: step, Result: textResult("done")} + settleID := run.DeriveSettlementCommandID("r1", step, "", claim) + if _, err := h.commit("r1", settleID, run.RunPosition{}, "", settle); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("grantless settlement = %v", err) + } + if _, err := h.commit("r1", settleID, run.RunPosition{}, "wrong", settle); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("wrong grant = %v", err) + } + step2, grant2, _ := h.executingModel("r2", false) + if _, err := h.commit("r1", settleID, run.RunPosition{}, grant2, settle); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("cross-run grant = %v", err) + } + _ = step2 + res := h.mustCommit("r1", settleID, run.RunPosition{}, grant, settle) + if !res.Snapshot.State.Status.Terminal() { + t.Fatal("settlement did not end the run") + } + // After settlement the start replays with an empty grant and the lease is gone. + replay = h.mustCommit("r1", run.DeriveStartCommandID("r1", step, "", claim), run.RunPosition{}, "", run.StartModelExecution{StepID: step, Claim: claim}) + if replay.Status != run.CommitAlreadyApplied || replay.Grant != "" { + t.Fatalf("start replay after settlement = %+v", replay) + } + if _, ok := h.lease("r1", step, ""); ok { + t.Fatal("lease survived settlement") + } +} + +// --- commit 组成 ---------------------------------------------------------------------------- + +func testCommitComposition(t *testing.T, newStore Factory) { + h := newHarness(t, newStore(t), 0) + h.startRun("t1", "r1", input("in-1")) + step, grant, claim := h.executingModel("r1", true) + result, bindings := h.toolCallResult(step, 1) + before := h.head() + res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, "", claim), run.RunPosition{}, grant, + run.SubmitModelResult{StepID: step, Result: result, Calls: bindings}) + if h.head().Revision != before.Revision+1 { + t.Fatal("one command did not produce exactly one commit") + } + types := eventTypes(&res.Commit) + 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("commit events = %v, want %v", types, want) + } + // 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.Commit.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 + toolGrant, 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), run.RunPosition{}, toolGrant, + 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") + } + res = h.mustCommit("r1", run.DeriveSettlementCommandID("r1", ts.RefValue.ID, call, toolClaim), run.RunPosition{}, toolGrant, + 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.Commit) + if len(types) != 3 || types[0] != runmod.Prefix+"tool_call_completed" || types[1] != chatlog.TypeToolResult || types[2] != chatlog.TypeInputDelivered { + t.Fatalf("commit events = %v", types) + } + outputDigest, _ := run.ProtocolV1().DigestToolOutput(output) + decoded, _ = h.registry.Decode(res.Commit.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, newStore Factory) { + h := newHarness(t, newStore(t), 0) + 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 commit is refused and nothing is written. + if _, err := h.commit("r1", run.DeriveInputCommandID("r1", "in-2"), run.RunPosition{}, "", 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: commit and claim in the same transaction. + res := h.mustCommit("r1", run.DeriveInputCommandID("r1", "in-2"), run.RunPosition{}, "", run.AcceptInput{Input: input("in-2")}, attach("b1")) + claimID := extension.DeriveClaimID(session.ProtocolVersion1, sid, res.Commit.CommitID, mustSet(t, h, "b1").RefSetDigest) + entry, ok, err := h.store.ControlGet(h.ctx, sid, extension.ClaimNamespace, string(claimID)) + if err != nil || !ok || !strings.Contains(string(entry.Value), `"active"`) { + t.Fatalf("claim entry = %s ok=%v err=%v", entry.Value, 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, newStore Factory) { + h := newHarness(t, newStore(t), 0) + h.startRun("t1", "r1", input("in-1")) + step, grant, claim := h.executingModel("r1", false) + res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, "", claim), run.RunPosition{}, grant, 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 commit (companion). + types := eventTypes(&res.Commit) + if types[len(types)-1] != turn.TypeCompleted { + t.Fatalf("terminal commit events = %v, want turn/completed last", types) + } +} + +// --- Prepare hard CAS 对其他模块不敏感 --------------------------------------------------------------- + +func testPrepareCASIgnoresOtherModules(t *testing.T, newStore Factory) { + h := newHarness(t, newStore(t), 0) + h.startRun("t1", "r1", input("in-1")) + h.startRun("t2", "r2", input("in-1")) + 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 %+v -> %+v", 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) + } +} + +// --- 租约与 commit ------------------------------------------------------------------------------- + +func testLeaseAndCommit(t *testing.T, newStore Factory) { + h := newHarness(t, newStore(t), 0) + h.startRun("t1", "r1", input("in-1")) + step, ids := h.openToolStep("r1", 2) + g0, c0 := h.startTool("r1", step, ids[0]) + if l, ok := h.lease("r1", step, ids[0]); !ok || l.Holder != string(c0) || run.ExecutionGrant(l.Token) != g0 { + t.Fatalf("lease after start = %+v ok=%v", l, ok) + } + h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, ids[0], c0), run.RunPosition{}, g0, + run.SubmitToolResult{StepID: step, CallID: ids[0], Result: run.ToolExecutionResult{Output: run.MustParseCanonicalJSON(`1`)}}) + if _, ok := h.lease("r1", step, ids[0]); ok { + t.Fatal("lease survived settlement") + } + // Terminal commit releases every live lease of the Run. + h.startTool("r1", step, ids[1]) + h.mustCommit("r1", "cancel", run.RunPosition{}, "", run.CancelRun{}) + if _, ok := h.lease("r1", step, ids[1]); ok { + t.Fatal("lease survived the terminal commit") + } + if got := h.load("r1").State.Result.UncertainCalls; len(got) != 1 || got[0] != ids[1] { + t.Fatalf("uncertain calls = %v", got) + } +} + +// --- 投影 ------------------------------------------------------------------------------------------- + +func testProjection(t *testing.T, newStore Factory) { + h := newHarness(t, newStore(t), 0) + h.startRun("t1", "r1", input("in-1")) + snapshotOf := func() session.SnapshotResult { + res, err := h.store.LoadSnapshot(h.ctx, session.SnapshotRequest{SessionID: sid, ProjectionKey: session.ProjectionKey(runmod.MachineProjectionID), ProjectionVersion: 1}) + if err != nil { + t.Fatal(err) + } + return res + } + step, _, _ := h.executingModel("r1", true) + if snapshotOf().Found { + t.Fatal("snapshot written while the Run is mid-step") + } + toolStep, ids := func() (run.StepID, []run.CallID) { + g, _ := h.lease("r1", step, "") + result, bindings := h.toolCallResult(step, 1) + res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, "", run.ExecutionClaim(g.Holder)), run.RunPosition{}, run.ExecutionGrant(g.Token), + run.SubmitModelResult{StepID: step, Result: result, Calls: bindings}) + return res.Snapshot.State.Current.(run.ToolStep).RefValue.ID, []run.CallID{bindings[0].CallID} + }() + grant, claim := h.startTool("r1", toolStep, ids[0]) + res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", toolStep, ids[0], claim), run.RunPosition{}, grant, + run.SubmitToolResult{StepID: toolStep, CallID: ids[0], 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) + } + snap := snapshotOf() + if !snap.Found || snap.Snapshot.Through.Revision != res.Commit.Revision-1 { + t.Fatalf("snapshot after return to Open = %+v, want Through = commit revision - 1", snap.Snapshot) + } + // Projection state, Load and Record agree for the active Run. + m := h.machine() + loaded := h.load("r1") + rec := h.record("r1") + if active, ok := m.Active["r1"]; !ok || !run.StatesEquivalent(&active, &loaded.State) || !run.StatesEquivalent(&active, &rec.Snapshot.State) { + t.Fatal("projection, Load and Record disagree") + } + // Terminal Run leaves the projection; Load and Record still answer. + h.mustCommit("r1", "cancel", run.RunPosition{}, "", run.CancelRun{}) + if _, still := h.machine().Active["r1"]; still { + t.Fatal("terminal run still in the projection") + } + 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, newStore Factory) { + h := newHarness(t, newStore(t), 0) + h.startRun("t1", "r1", input("in-1")) + h.startRun("t2", "r2", input("in-1")) + 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", ExecutionBinding: turn.ExecutionBindingRef{ID: "b", Digest: "sha256:b"}, Companion: turn.CompanionV1Version, PlanDigest: "sha256:p"}}}}) + 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)) + } +} + +// --- lease 过期 recovery ---------------------------------------------------------------------------- + +func testExpiryRecovery(t *testing.T, newStore Factory) { + const ttl = time.Minute + h := newHarness(t, newStore(t), ttl) + h.startRun("t1", "r1", input("in-1")) + h.startRun("t2", "r2", input("in-1")) + // r1: executing model; r2: two executing tool calls started at different times. + h.executingModel("r1", false) + requestDigest := h.load("r1").State.Current.(run.ModelStep).RequestDigest + toolStep, ids := h.openToolStep("r2", 2) + _, c0 := h.startTool("r2", toolStep, ids[0]) + h.clock.Advance(ttl / 2) + h.startTool("r2", toolStep, ids[1]) + + // Live leases refuse grantless recovery and RecoverExpired finds nothing. + if n, err := h.rt.RecoverExpired(h.ctx); err != nil || n != 0 { + t.Fatalf("recover with live leases = %d %v", n, err) + } + unknown := run.SubmitToolFailure{StepID: toolStep, CallID: ids[0], Failure: run.ToolFailure{Class: run.FailureEffectUnknown}, Outcome: run.ToolOutcomeUnknown} + if _, err := h.commit("r2", run.DeriveToolRecoveryCommandID("r2", toolStep, ids[0], c0), run.RunPosition{}, "", unknown); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("grantless unknown on a live lease = %v", err) + } + // Expire r1's model lease and r2's first call only. + h.clock.Advance(ttl/2 + time.Second) + // A recovery CommandID derived from another holder is refused. + if _, err := h.commit("r2", run.DeriveToolRecoveryCommandID("r2", toolStep, ids[0], "intruder"), run.RunPosition{}, "", unknown); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("recovery with a foreign holder = %v", err) + } + n, err := h.rt.RecoverExpired(h.ctx) + if err != nil || n != 2 { + t.Fatalf("recovered = %d %v, want 2", n, err) + } + r1 := h.load("r1").State.Current.(run.ModelStep) + if r1.Status != run.ModelPrepared || r1.RequestDigest != requestDigest { + t.Fatalf("model after recovery = %+v", r1) + } + if _, err := h.rt.FrozenRequest(h.ctx, requestDigest); err != nil { + t.Fatalf("frozen request after recovery: %v", err) + } + ts := h.load("r2").State.Current.(run.ToolStep) + if ts.Calls[0].Status != run.ToolFailed || ts.Calls[0].Failure == nil || ts.Calls[0].Failure.Outcome != run.ToolOutcomeUnknown { + t.Fatalf("expired call = %+v", ts.Calls[0]) + } + if ts.Calls[1].Status != run.ToolExecuting { + t.Fatalf("sibling call = %+v, want still Executing", ts.Calls[1]) + } + if _, ok := h.lease("r2", toolStep, ids[0]); ok { + t.Fatal("recovered lease not deleted") + } + if _, ok := h.lease("r2", toolStep, ids[1]); !ok { + t.Fatal("sibling lease deleted") + } + if n, err := h.rt.RecoverExpired(h.ctx); err != nil || n != 0 { + t.Fatalf("second RecoverExpired = %d %v, want 0", n, err) + } + // TTL zero: recovery never runs. + h0 := newHarness(t, newStore(t), 0) + h0.startRun("t1", "r1", input("in-1")) + h0.executingModel("r1", false) + h0.clock.Advance(time.Hour) + if n, err := h0.rt.RecoverExpired(h0.ctx); err != nil || n != 0 { + t.Fatalf("RecoverExpired with TTL 0 = %d %v", n, err) + } +} + +// --- lease 续期 --------------------------------------------------------------------------------------- + +func testRenewal(t *testing.T, newStore Factory) { + const ttl = time.Minute + h := newHarness(t, newStore(t), ttl) + h.startRun("t1", "r1", input("in-1")) + step, grant, claim := h.executingModel("r1", false) + h.clock.Advance(ttl / 2) + if err := h.rt.RenewLease(h.ctx, sid, "r1", step, "", grant); err != nil { + t.Fatalf("renew: %v", err) + } + h.clock.Advance(ttl/2 + time.Second) // past the original deadline, inside the renewed one + if n, _ := h.rt.RecoverExpired(h.ctx); n != 0 { + t.Fatal("renewed lease was recovered at its original deadline") + } + if err := h.rt.RenewLease(h.ctx, sid, "r1", step, "", ""); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("renew with empty grant = %v", err) + } + if err := h.rt.RenewLease(h.ctx, sid, "r1", step, "", "wrong"); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("renew with wrong grant = %v", err) + } + h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, "", claim), run.RunPosition{}, grant, run.SubmitModelResult{StepID: step, Result: textResult("done")}) + if err := h.rt.RenewLease(h.ctx, sid, "r1", step, "", grant); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("renew after settlement = %v", err) + } + // TTL zero: renewal only validates the grant. + h0 := newHarness(t, newStore(t), 0) + h0.startRun("t1", "r1", input("in-1")) + step0, grant0, _ := h0.executingModel("r1", false) + if err := h0.rt.RenewLease(h0.ctx, sid, "r1", step0, "", grant0); err != nil { + t.Fatalf("renew with TTL 0: %v", err) + } + if l, _ := h0.lease("r1", step0, ""); l.DeadlineUnixMilli != 0 { + t.Fatalf("TTL 0 renew set a deadline: %d", l.DeadlineUnixMilli) + } +} + +// --- FrozenValueStore -------------------------------------------------------------------------------- + +func testFrozenValues(t *testing.T, newStore Factory) { + h := newHarness(t, newStore(t), 0) + h.startRun("t1", "r1", input("in-1")) + step, grant, 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), run.RunPosition{}, grant, 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..94545c3 --- /dev/null +++ b/agent/session/run/runtimetest/harness.go @@ -0,0 +1,382 @@ +// 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 transaction +// atomicity, digest chains and snapshot equivalence to the kernel and +// Module Framework suites. +package runtimetest + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "testing" + "time" + + "github.com/memohai/twilight/agent/artifact" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/chatlog" + "github.com/memohai/twilight/agent/session/extension" + runmod "github.com/memohai/twilight/agent/session/run" + "github.com/memohai/twilight/agent/turn" + "github.com/memohai/twilight/sdk" +) + +// Factory returns a fresh, empty Store for one test. +type Factory func(t testing.TB) session.Store + +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 assembled stack over a Store. +type harness struct { + t testing.TB + ctx context.Context + store session.Store + registry *extension.Registry + appender extension.SemanticAppender + reader extension.ProjectionReader + bindings *artifact.MemoryBindingStore + frozen *run.MemoryFrozenValues + clock *clock + rt *runmod.Runtime + seq int +} + +func newHarness(t testing.TB, store session.Store, ttl time.Duration) *harness { + t.Helper() + registry, err := extension.BuildRegistry(session.ProfileV1(), chatlog.Module, runmod.Module, turn.Module) + if err != nil { + t.Fatal(err) + } + bindings := artifact.NewMemoryBindingStore() + appender, err := extension.NewSemanticAppender(store, registry, artifact.SetBuilder{Resolver: bindings}, artifact.KVLedger{}) + if err != nil { + t.Fatal(err) + } + reader := extension.NewProjectionReader(store, registry) + h := &harness{t: t, ctx: context.Background(), store: store, registry: registry, appender: appender, reader: reader, + bindings: bindings, frozen: run.NewMemoryFrozenValues(), clock: &clock{now: time.Unix(1_000_000, 0)}} + h.rt, err = runmod.NewRuntime(runmod.Config{Store: store, Registry: registry, Appender: appender, Projections: reader, + Frozen: h.frozen, Companion: turn.CompanionV1{}, LeaseTTL: ttl, Now: h.clock.Now}) + if err != nil { + t.Fatal(err) + } + if _, err := store.Create(h.ctx, session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: sid}); err != nil { + t.Fatal(err) + } + return h +} + +func (h *harness) fatal(args ...any) { h.t.Helper(); h.t.Fatal(args...) } + +func (h *harness) head() session.Head { + h.t.Helper() + head, err := h.store.Head(h.ctx, sid) + if err != nil { + h.fatal(err) + } + return head +} + +// appendGroup appends a typed group by CAS at the current head. +func (h *harness) appendGroup(group extension.SemanticGroup) extension.SemanticAppendResult { + h.t.Helper() + res, err := h.appender.AppendSemantic(h.ctx, extension.SemanticAppendRequest{SessionID: sid, ExpectedHead: h.head(), Group: group}) + if err != nil { + h.fatal(err) + } + return res +} + +func (h *harness) mustApply(group extension.SemanticGroup) session.SessionCommit { + h.t.Helper() + res := h.appendGroup(group) + if res.Outcome != extension.SemanticApplied { + h.fatal(fmt.Sprintf("append %s: %s %s", group.CommitID, res.Outcome, res.Detail)) + } + return *res.Commit +} + +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, ExecutionBinding: turn.ExecutionBindingRef{ID: "b", Digest: "sha256:b"}, + Companion: turn.CompanionV1Version, PlanDigest: "sha256:plan"}}) + 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 and returns it. +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, grant run.ExecutionGrant, 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 h.rt.Commit(h.ctx, sid, run.CommitRequest{Base: base, Grant: grant, Command: env, Attach: attach}) +} + +func (h *harness) mustCommit(runID run.RunID, id run.CommandID, base run.RunPosition, grant run.ExecutionGrant, cmd run.AgentCommand, attach ...run.ModuleEvent) run.CommitResult { + h.t.Helper() + res, err := h.commit(runID, id, base, grant, 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.ExecutionGrant, run.ExecutionClaim) { + h.t.Helper() + claim := h.claim() + res := h.mustCommit(runID, run.DeriveStartCommandID(runID, step, "", claim), h.load(runID).Position, "", run.StartModelExecution{StepID: step, Claim: claim}) + if res.Grant == "" { + h.fatal("start returned no grant") + } + return res.Grant, claim +} + +// executingModel drives a fresh Run to Model Executing. +func (h *harness) executingModel(runID run.RunID, withTool bool) (run.StepID, run.ExecutionGrant, run.ExecutionClaim) { + h.t.Helper() + step := h.prepare(runID, withTool) + grant, claim := h.startModel(runID, step) + return step, grant, claim +} + +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, grant, claim := h.executingModel(runID, true) + result, bindings := h.toolCallResult(step, n) + res := h.mustCommit(runID, run.DeriveSettlementCommandID(runID, step, "", claim), h.load(runID).Position, grant, + 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.ExecutionGrant, run.ExecutionClaim) { + h.t.Helper() + claim := h.claim() + res := h.mustCommit(runID, run.DeriveStartCommandID(runID, step, call, claim), h.load(runID).Position, "", run.StartToolCall{StepID: step, CallID: call, Claim: claim}) + if res.Grant == "" { + h.fatal("tool start returned no grant") + } + return res.Grant, claim +} + +func (h *harness) lease(runID run.RunID, step run.StepID, call run.CallID) (extension.Lease, bool) { + h.t.Helper() + l, ok, err := extension.Leases{Store: h.store}.Lookup(h.ctx, sid, runmod.LeaseNamespace, run.LeaseKey(runID, step, call)) + if err != nil { + h.fatal(err) + } + return l, ok +} + +func (h *harness) machine() runmod.Machine { + h.t.Helper() + state, _, err := h.reader.Load(h.ctx, sid, runmod.MachineProjectionID, runmod.MachineProjection.Version) + if err != nil { + h.fatal(err) + } + return state.(runmod.Machine) +} + +func eventTypes(c *session.SessionCommit) []session.EventType { + out := make([]session.EventType, len(c.Events)) + for i := range c.Events { + out[i] = c.Events[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..f7a356b --- /dev/null +++ b/agent/session/run/runtimetest/memory_test.go @@ -0,0 +1,11 @@ +package runtimetest + +import ( + "testing" + + "github.com/memohai/twilight/agent/session" +) + +func TestMemoryStoreConformance(t *testing.T) { + Run(t, func(testing.TB) session.Store { return session.NewMemoryStore() }) +} diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 807d17c..614b2ef 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -398,7 +398,7 @@ type MachineProjection struct { } ``` -终态 Run 在 `RunEnded` 折叠后从投影中移除;终态结果由 `Record` 与 turn surface 提供,投影大小与活动 Run 数成正比。snapshot 是可丢弃缓存(SES-SNP-1):`Load` 经 `extension.ProjectionReader`(EXT-PRJ-4)读取,即 snapshot(若存在且 `Through` 是当前前缀)加其后类型前缀为 `twilight/run/` 的 tail;没有 snapshot 时从 stream 的过滤 replay 全量 fold。写入策略由 `agent/session/run` 的 `SnapshotPolicy` 决定,默认在 Run 的 `Current` 回到 `Open` 或 Run 终结时写入,并可按 commit 计数补充;kernel 不要求每次 commit 都写。`Record` 以 `Types=[twilight/run/]` 过滤 replay 读取该 RunID 的全部事件(SES-REP-2),FoldRun 重建;该 Run 仍在投影中时与投影状态比对,corrupt、gap 或 divergence 必须失败。 +终态 Run 在 `RunEnded` 折叠后从投影中移除,投影只保留其 RunID 用于拒绝同一 RunID 的第二条 `created`(RUN-NEW-1);终态结果由 `Record` 与 turn surface 提供,投影大小与活动 Run 数成正比,加上已终结 RunID 的集合。snapshot 是可丢弃缓存(SES-SNP-1):`Load` 经 `extension.ProjectionReader`(EXT-PRJ-4)读取,即 snapshot(若存在且 `Through` 是当前前缀)加其后类型前缀为 `twilight/run/` 的 tail;没有 snapshot 时从 stream 的过滤 replay 全量 fold。写入策略由 `agent/session/run` 的 `SnapshotPolicy` 决定,默认在 Run 的 `Current` 回到 `Open` 或 Run 终结时写入,并可按 commit 计数补充;kernel 不要求每次 commit 都写。`Record` 以 `Types=[twilight/run/]` 过滤 replay 读取该 RunID 的全部事件(SES-REP-2),FoldRun 重建;该 Run 仍在投影中时与投影状态比对,corrupt、gap 或 divergence 必须失败。 **RUN-CMT-3** Commit 经 `extension.SemanticAppender.AppendSemanticIn` 在 Session Store 的一个事务内完成(SES-API-2、EXT-APP-3)。所有 Runtime implementation 在 fn 内调用同一个 pure `EvaluateCommit`,顺序固定为: @@ -412,7 +412,7 @@ AppendSemanticIn(sessionID, func(tx): 5 state = fold(tx.LoadSnapshot(twilight/run/machine) + tx.Tail(after, [twilight/run/])) 缺少 created -> ErrRunNotFound;schema 不等于 created.SchemaVersion -> 不可重试错误;terminal check 6 validate hard CAS(prepare 的 Base == Positions[RunID])/ target state / execution grant / recovery authority - grant 经 LookupLease 校验 Token;recovery authority 要求条目 deadline 已过且 command Claim 等于 Holder + grant 经 LookupLease 校验 Token;recovery authority 要求条目 deadline 已过且 command 与 Holder 绑定(5.1 节) 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/ 事件) @@ -428,7 +428,7 @@ FrozenValueStore 的 `Put` 幂等且内容寻址,在进入事务之前完成 **RUN-CMT-5** 幂等键为 Session kernel 的 `(SessionID, CommitID)`,CommitID 等于 CommandID,Runtime 不另设幂等索引。同 CommandID 的重放返回 `CommitAlreadyApplied`、当前 snapshot 与原完整 commit,且不得再次 Decide 或产生外部 effect;command 不持久化,Runtime 不比对重放 command 的内容,同 CommandID 视为同一 command。对于 `StartModelExecution` 和 `StartToolCall`,claim 是 CommandID 的 preimage,不同 claim 即不同 command:其 start 按当前 target state 与 lease 评估,target 已被占用时返回 `ErrStaleRuntime`,并保持现有执行授权。start 的重放在 grant 仍 live 时返回原 start grant。非 start command 的 replay 不返回 grant。 -**RUN-CMT-6** accepted `StartModelExecution`/`StartToolCall` 为目标签发新 grant;该 start 的 `CommitAccepted` 和在 grant 仍 live 时满足精确 replay 条件的 `CommitAlreadyApplied` 返回同一个 grant。若该 start 已 settlement 或 Run 已 terminal,精确 replay 仍返回 `CommitAlreadyApplied`,并返回空 grant。model result/failure/reject 与 executing tool result/known failure 必须携带 live target grant。settlement 接受后 grant 失效;terminal commit 撤销该 Run 全部 grant。`RecoverModelExecution` 由 live grant holder 提交,或在 Runtime 验证 lease 已过期且 command Claim 等于该 lease 的 Claim 后无 grant 提交。Executing tool 的 recovery 使用同一条 `SubmitToolFailure{Outcome:Unknown}` command:工具 owner 必须携带 live grant;`RecoverExpired` 仅在 lease 已过期且没有已接受 settlement 时无 grant 提交。该 Unknown 只结算这一 call,Run 保持 Active。 +**RUN-CMT-6** accepted `StartModelExecution`/`StartToolCall` 为目标签发新 grant;该 start 的 `CommitAccepted` 和在 grant 仍 live 时满足精确 replay 条件的 `CommitAlreadyApplied` 返回同一个 grant。若该 start 已 settlement 或 Run 已 terminal,精确 replay 仍返回 `CommitAlreadyApplied`,并返回空 grant。model result/failure/reject 与 executing tool result/known failure 必须携带 live target grant。settlement 接受后 grant 失效;terminal commit 撤销该 Run 全部 grant。`RecoverModelExecution` 由 live grant holder 提交,或在 Runtime 验证 lease 已过期且 command Claim 等于该 lease 的 Holder 后无 grant 提交。Executing tool 的 recovery 使用同一条 `SubmitToolFailure{Outcome:Unknown}` command:工具 owner 必须携带 live grant;`RecoverExpired` 仅在 lease 已过期且没有已接受 settlement 时无 grant 提交。该 Unknown 只结算这一 call,Run 保持 Active。 **RUN-CMT-7** commit、lease 变更、claim 与(若写入)snapshot 在同一 Session Store 事务内生效:lease 经 `extension.AcquireLease` / `ReleaseLease` 在 `SemanticTx` 内写入,claim 由 Appender 写入,三者与 commit 同时可见或同时不可见。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 @@ -445,7 +445,7 @@ grant、lease、ExecutionClaim、ClaimStore 与投影 snapshot 都不进入 stre | target | `Namespace = twilight/run/lease`,`Key = /model/` 或 `/call//`;三段都是定长 hex digest,恢复时由 Key 解析 target,`Attrs` 为空 | | `LeaseTTL` | `TTL`;零表示不超时(进程内占用) | -一个 target 至多一条 live lease。start 在提交 `ModelStepStarted` / `ToolCallStarted` 的事务内 `AcquireLease`;settlement 与 recovery 在提交对应 fact 的事务内 `ReleaseLease`。因此"日志中该 target 为 Executing"与"存在其 lease"同时成立或同时不成立。过期且无 settlement 时 Runtime 允许 grantless Recover:recovery authority 的判定为条目 deadline 已过、且 command 的 Claim 等于条目的 Holder。durable `loop.ClaimStore` 使用控制面 KV namespace `twilight/run/claim`。 +一个 target 至多一条 live lease。start 在提交 `ModelStepStarted` / `ToolCallStarted` 的事务内 `AcquireLease`;settlement 与 recovery 在提交对应 fact 的事务内 `ReleaseLease`。因此"日志中该 target 为 Executing"与"存在其 lease"同时成立或同时不成立。过期且无 settlement 时 Runtime 允许 grantless Recover:recovery authority 的判定为条目 deadline 已过、且 command 与条目的 Holder 绑定。`RecoverModelExecution` 携带 Claim,要求 Claim 等于 Holder;`SubmitToolFailure{Outcome:Unknown}` 不携带 Claim,要求其 CommandID 等于以 Holder 为 Claim 派生的 tool recovery CommandID(第 2 节 identity 表)。两者都使同一 Holder 的重复恢复幂等,并拒绝其他 Holder 的恢复。durable `loop.ClaimStore` 使用控制面 KV namespace `twilight/run/claim`。 `RecoverExpired` 以 `Leases.Expired(twilight/run/lease, now)` 枚举过期条目,由 Key 解析 target:Executing tool call 无 grant 提交 `SubmitToolFailure{Unknown}`,Executing model 提交 `RecoverModelExecution`;command 的 Claim 取自 `Holder`,因此 recovery CommandID 确定,重复扫描幂等。该 Run 保持 Active,同一 RunID 继续。进程内宿主使用 Memory 实现,lease 不超时,grantless recover 被拒绝。生产崩溃恢复使用带 TTL 的 Runtime。 @@ -586,21 +586,22 @@ type Event struct { **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 必须覆盖: - -- Start group 建立 Run、重复 `created` 拒绝、missing Run、schema 与 created 不一致的 command 拒绝; -- command 重放(同 CommandID 返回 AlreadyApplied 且不再 Decide)、不同 claim 的 start 被拒绝、prepare hard CAS、call-local rebase、terminal replay; -- 输入入队:`AcceptInput` 在 Open、Model Prepared、Model Executing、ToolStep(含 Waiting)都被接受;Prepared 期间入队后 `Next` 返回 `WithdrawPrepared`,Withdraw 后重规划的 Prepare 包含该输入;Executing 期间入队的输入在该步结算后的 Prepare 中被消费;无 tool call 且有 pending 输入的 `SubmitModelResult` 不结束 Run; -- grant 签发、隔离、精确 start replay、消费、跨 Run 拒绝与 recovery authorization; -- 一 command 一 commit、run facts 在 companion 与 Attach 之前、companion 的 `SourceDigest` 等于 fact 记录的 ResultDigest / OutputDigest、Attach 拒绝 `twilight/run/` 事件、companion 与 Attach 经 admission 并建立 claim; -- Prepare hard CAS 只对该 Run 自己的事件敏感:同一 Session 内其他模块的写入不使 Prepare 失效; -- commit、lease、claim,以及该 commit 若写入的 snapshot 同事务:在任一写入点注入崩溃后它们同时存在或同时缺失; -- 投影 snapshot 加 tail 与全量 fold 等价;删除 snapshot 后 Load 结果不变;终态 Run 不再出现在投影中;Record 单一一致点、FoldRun 等价、gap/tamper/corrupt failure; -- 同一 Session 内多 Run 隔离、不同 SchemaVersion 的 Run 共存;同一 Session 的 chatlog/turn 事件不影响 Run fold; -- lease 过期 recovery:live lease 拒绝 grantless、过期 model 回到 Prepared、过期 tool 记 Unknown 且 sibling 不受影响、RecoverExpired 幂等、恢复事务删除 lease 条目; -- lease 续期:续期后原 deadline 不触发 recovery、错误/空 grant 与 settlement 后续期被拒、续期与结算并发时条件写失败且不写回已删除条目; -- FrozenValueStore:Put 幂等、Recovered 后按 RequestDigest 取回同一请求、本体缺失的错误分类、step 终结后删除本体不影响 Record; -- MachineState codec:每个 Current variant 与终态 round-trip、拒绝 unknown field / 非法判别式 / trailing data。 +**RUN-CMP-2** Runtime conformance 只断言 Run 模块自己的语义;事务原子性、digest chain、snapshot 加 tail 的等价性由 Session kernel 与 Module Framework 的 conformance 覆盖(SES 第 8 节、EXT 第 8 节),本清单以引用代替重复。conformance 以 `session.Store` 为参数(`agent/session/run/runtimetest`),Memory 与 durable adapter 跑同一套。必须覆盖: + +- 建立与寻址:Start group 建立 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` 与原 commit 且不再 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 而不结束; +- grant:start 签发 grant 且返回的 lease Token 与之相同;同 claim 的 start 重放返回同一 grant,settlement 后重放返回空 grant;不同 claim 的 start 在 target 已被占用时返回 `ErrStaleRuntime`;空 grant、错误 grant、另一 Run 的 grant 的 settlement 返回 `ErrStaleRuntime`; +- commit 组成:一 command 一 commit;commit 内 run 事实在 companion 与 Attach 之前;companion 中非空 `SourceDigest` 等于同 commit fact 记录的 ResultDigest / OutputDigest / ResponseDigest;Attach 携带 `twilight/run/` 事件被拒绝;companion 与 Attach 中的 ReferencePart 经 admission,未注册 Binding 使 commit 失败且无写入,合法 Binding 在同 commit 建立 `twilight/artifact/claim` 条目; +- 结算返回值:`CommitResult.Snapshot` 是 Evolve 后状态;终结 Run 的结算其 `Snapshot.Status` 为终态且 `Result` 非空,与 Record 一致; +- Prepare hard CAS 只对该 Run 自己的事件敏感:同一 Session 内 chatlog、turn 或其他 Run 的写入不改变该 Run 的 Position,也不使 Prepare 失效; +- 租约与 commit:start 接受后 `twilight/run/lease` 存在该 target 的条目,Holder 为 claim;settlement、recovery 与 terminal commit 之后条目不存在(同事务性由 SES-API-1 保证,本层观察结果); +- 投影:`SnapshotPolicy` 在 Run 回到 Open 或终结时写入 snapshot;终态 Run 不出现在 `twilight/run/machine`;Record 对活动 Run 的 fold 与投影一致;非法 fact 序列使 FoldRun 报错(篡改与缺口的检测属于 SES-REP-3); +- 隔离:同一 Session 内多 Run 互不影响 Position 与 Record;chatlog 与 turn 事件不影响 Run fold。不同 SchemaVersion 的 Run 共存在第二个 SchemaVersion 发布后启用; +- lease 过期 recovery:live lease 拒绝 grantless recovery;过期 model 回到 Prepared 且 `FrozenRequest` 返回同一 RequestDigest 的请求;过期 tool 记 Unknown 且同 step 的 sibling 不受影响;grantless Unknown 的 CommandID 必须等于以 lease Holder 为 Claim 派生的 tool recovery CommandID,否则返回 `ErrStaleRuntime`;`RecoverExpired` 幂等;恢复 commit 删除 lease 条目;`LeaseTTL` 为零时 `RecoverExpired` 不做任何事; +- lease 续期:续期后原 deadline 不触发 recovery;空 grant、错误 grant 与 settlement 后的续期返回 `ErrStaleRuntime`;`LeaseTTL` 为零时续期只校验 grant(续期与 Release 并发的条件写由 EXT-LSE-3 覆盖); +- FrozenValueStore:`Put` 幂等;未知 digest 的 `FrozenRequest` 返回 `ErrFrozenValueMissing`;step 终结后删除本体不影响 Record; +- MachineState codec:每个 Current variant 与终态 round-trip、拒绝 unknown field / 非法判别式 / trailing data(`agent/run` 单元测试)。 Loop conformance 必须覆盖: @@ -612,6 +613,7 @@ Loop conformance 必须覆盖: - Cancel 将 Executing tool/model 投影到 `UncertainCalls` / `UncertainModel`;ExternalResponse reject 为 `response_rejected`; - streaming delta 与 nil result、EventSink committed observation; - stale/unknown commit response、prepare no-progress rejection 与无 livelock; +- 模型结算终结 Run 时 Loop 不再 Load,返回 `LoopFinished` 且 `Result` 等于 Record 的终态; - 超过 LeaseTTL 的工具调用在续期下不被记为 Unknown,其结果被接受; - 共享 ClaimStore 的第二个 Loop 实例重放前一实例的 start 并完成 settlement,不等待 lease 过期。 diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index fbc1eb6..247b473 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -104,7 +104,7 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 | `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`(ExecutionBinding、ContextPlanner、Memory 组装、SessionDriver、崩溃恢复 example) | 完成,2026-09-07 | -| Runtime conformance(RUN-CMP-2)与各模块 conformance | 未按新合同重建;旧 `runtimetest` 已随 per-Run Store 删除 | +| Runtime conformance(RUN-CMP-2,`agent/session/run/runtimetest`,以 `session.Store` 为参数) | 完成,2026-09-07;对 Memory Store 通过。kernel 与 extension 的 conformance 部分实现 | | SQLite / PostgreSQL Session Store adapter、live 模型接入 | 未开始 | 当前正式调用形态为 `agent/ref` 的 Memory 组装:`ref.New` 返回 Store、Registry、Appender、Projections、Runtime、Coordinator 与 Bindings;Application 经 `SessionDriver.Send` 投递输入。Loop 不保存 authority state;Runtime 不读取 queue 或 planner context。 diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index 6bf4508..8810d78 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -265,7 +265,7 @@ Run 事实只保存执行状态与内容 digest(RUN-WIR-4)。模型文本、 **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`;chatlog 条目自身的 `Digest` 仍按 CHT-COD-3 覆盖 parts。Runtime 在写入前校验这一等式(RUN-CMT-3 第 9 步)。 +**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`。 From bc438fd4737856aef5944f99e2f09270f7bcbed1 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 8 Sep 2026 17:34:19 +0800 Subject: [PATCH 69/81] Specs: Session-level single writer and flat event rows (edition 2) Revise all eight design docs to the 2026-09-08 decisions, code untouched: - session: kernel shrinks to Create/Header/Open/Append/Read; one row per event with global Seq, CommitID + Index/Last group markers, per-row digest; Session-level ownership with Epoch fencing. Drops CommitIn, CAS, control-plane KV, kernel snapshot, EventID, replay cursor. - extension: in-process Writer is the sole write entry (serial commits, in-memory idempotency index, claim before Append); Writers registry; optional ProjectionCache; Ignorable replaces RequireComplete. - run: no lease/grant/RenewLease/RecoverExpired; RunPosition = Seq; takeover disposition RecoverInterrupted with TakeoverClaim(Epoch); RUN-CMP-2 rewritten. turn/chatlog/artifact/ref updated to match. - runtime-refactor: section 8 records rationale, table of changes, persistence levels and implementation order. --- docs/design/agent-artifact.md | 39 ++-- docs/design/agent-reference-assembly.md | 43 ++-- docs/design/agent-run.md | 199 +++++++---------- docs/design/agent-runtime-refactor.md | 108 +++++++-- docs/design/agent-session-chatlog.md | 18 +- docs/design/agent-session-extension.md | 266 ++++++++-------------- docs/design/agent-session.md | 282 ++++++++---------------- docs/design/agent-turn.md | 44 ++-- 8 files changed, 419 insertions(+), 580 deletions(-) diff --git a/docs/design/agent-artifact.md b/docs/design/agent-artifact.md index 6de90da..0d33d31 100644 --- a/docs/design/agent-artifact.md +++ b/docs/design/agent-artifact.md @@ -1,6 +1,6 @@ # Twilight Agent Artifact Core -状态:设计草案。`agent/artifact` 已实现 Ref、Binding、Memory BindingStore、BindingSetBuilder 与两态 KV ledger;Resolver、Store、Promoter 与 scheme registry 未实现。wire 与 claim 状态表在 conformance 通过前不冻结。v1 的 claim 只有 `Active` 与 `Released` 两态,与 Session commit 同事务写入;`Prepared` 状态、reconciler、provider 迁移 fence 与 archive import/export 在附录中,不进入 v1 conformance。 +状态:设计草案,第二版(2026-09-08)。`agent/artifact` 已实现 Ref、Binding、Memory BindingStore、BindingSetBuilder 与第一版的两态 KV ledger(在 Session 控制面 KV 内、与 commit 同事务);Resolver、Store、Promoter 与 scheme registry 未实现。本版随 kernel 第二版把 ledger 改为自持久化、在 owner fact Append 之前建立 claim,尚未实现。wire 与 claim 状态表在 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` 的通则。 @@ -14,7 +14,7 @@ Binding:稳定 BindingID 到 immutable Ref 的映射 RetentionClaim:owner 对一个 BindingSet 的 durable 保留事实 ``` -`BindingSet` 是 claim 的内容集合。`Active` claim 是已确认 owner fact 的 retention root;`Released` claim 不再保留任何内容。v1 中 claim 由 Session Module Framework 在写入 owner fact 的同一事务内以 `Active` 状态建立(EXT-APP-3),因此不需要 in-flight 保护状态;`Prepared` 保留给无法同事务写入的部署(附录)。Core 不依赖 Session、Event、Chatlog 或 Application,且不解释 owner 的领域语义。Attachment 等 owner module 可以关联 `AttachmentID`、subject 与 `BindingID`,但该边界只使用 BindingID,不引入 Event 依赖。 +`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 实现。第一版只要求 Memory reference implementation 和 conformance suite。 @@ -128,38 +128,37 @@ type ClaimCursor struct { Watermark ClaimID; After ClaimID } type ClaimPage struct { Items []RetentionClaim; Next *ClaimCursor } type ClaimOwnerQuery struct { Kind, Authority string; Identities []string } -// ClaimKV 是宿主提供的、与 owner fact 同事务的 KV 视图;Session 部署中由 -// session.SessionTx 的控制面 KV(namespace twilight/artifact/claim)适配。 -type ClaimKV interface { - Get(key string) ([]byte, bool, error) - Put(key string, value []byte) error - Delete(key string) error -} - +// RetentionLedger 自行持久化(Memory、文件或数据库),不依赖宿主事务。 type RetentionLedger interface { - // ActivateIn 在 kv 所属事务内建立或幂等确认一个 Active claim。 - ActivateIn(kv ClaimKV, ClaimID, ClaimOwner, BindingSet) (RetentionClaim, error) + // 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:`ActivateIn` 对同 ID、同 owner、同 set 幂等,对任何其他组合 conflict。`Active` claim 是 GC root;GC 只忽略 `Released` claim。未知 scheme 必须保守保留。 +**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 必须保守保留。 | 操作 | 前置状态 | 结果 | |---|---|---| -| ActivateIn(new ID, owner, set) | 不存在 | Active | -| ActivateIn(existing ID, exact owner/set) | Active | 幂等成功 | -| ActivateIn(existing ID, exact owner/set) | Released | conflict | -| ActivateIn(existing ID, other owner/set) | 任意 | conflict | -| ReleaseActive | Active,且 owner retention 已结束 | Released | +| 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 不匹配。v1 没有 in-flight claim,因此没有 reconciler;`ReleaseActive` 的授权(owner retention 已结束)由 Application 的 GC policy 提供。 +**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 @@ -226,6 +225,6 @@ 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、两态状态表、`ActivateIn` 在宿主事务内生效与回滚、cursor pagination、Active GC protection; +- **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 index fdff4d2..a09c11e 100644 --- a/docs/design/agent-reference-assembly.md +++ b/docs/design/agent-reference-assembly.md @@ -40,7 +40,7 @@ Planner ID:`twilight/turn/planner/context-v1`。 func Plan(ctx context.Context, hint run.PlanningHint, fold []chatlog.Entry, pub BindingPublic) (loop.RequestPlan, error) ``` -**REF-PLN-1** `fold` 为 `ContextFold` 对该 Session chatlog 事件的输出(含已应用的 checkpoint)。Planner 在每次 Plan 时经 `extension.ProjectionReader` 读取 `twilight/chatlog/context` 投影(snapshot 加 tail)。 +**REF-PLN-1** `fold` 为 `ContextFold` 对该 Session chatlog 事件的输出(含已应用的 checkpoint)。Planner 在每次 Plan 时经该 Session Writer 的 `Projections()` 读取 `twilight/chatlog/context` 投影(EXT-PRJ-4)。 **REF-PLN-2** `sdk.Messages` 顺序: @@ -79,7 +79,7 @@ Coordinator 是 Turn 作用域的:Turn 结束即返回。参考组装提供一 ```go type SessionDriver struct { Coordinator turn.Service - Projections extension.ProjectionReader + Writers extension.Writers // 读投影经 Writer.Projections() Binding turn.ExecutionBindingRef // 新 Turn 使用的执行绑定 Companion turn.CompanionVersion NewTurnID func() turn.TurnID @@ -94,37 +94,40 @@ func (d *SessionDriver) OnTurnSettled(ctx, sid session.SessionID) (turn.TurnResp **REF-DRV-3** Turn 为 `attempt_failed` 时 `Send` 返回 conflict,不自动 Retry 或 Settle;这两者是 Application 的决定。`Deliver` 与最后一步结果并发失败(TRN-DLV-3)时,`Send` 得到 `completed`,输入仍为 `submitted`,随后的 `OnTurnSettled` 会把它带入下一个 Turn。 -**REF-DRV-4** 崩溃恢复:`SessionDriver` 从两个投影重建。对每个 session,先按 TRN-REC-1 处理 `active` 与 `attempt_failed` 的 Turn;没有未结算 Turn 时调用 `OnTurnSettled` 消费积压的输入。 +**REF-DRV-4** 崩溃恢复:`SessionDriver` 从两个投影重建。对每个 session,先经 `Writers` 取得 Writer(新 Epoch),调用 `Runtime.RecoverInterrupted` 处置全部 Executing 目标(RUN-CMT-7),再按 TRN-REC-1 处理 `active` 与 `attempt_failed` 的 Turn;没有未结算 Turn 时调用 `OnTurnSettled` 消费积压的输入。 ## 5. Memory 组成 ```text -sessionStore = session.NewMemoryStore() // commit、CommitIn、snapshot、控制面 KV -registry = extension.BuildRegistry(profile, chatlog.Module, turn.Module, runmod.Module) -projections = extension.NewProjectionReader(sessionStore, registry) +sessionStore = session.NewMemoryStore() // Create、Header、Open、Read(SES 第 4 至 6 节) +registry = extension.BuildRegistry(protocolVersion, chatlog.Module, turn.Module, runmod.Module) bindingStore = artifact.NewMemoryBindingStore() -ledger = extension.NewKVLedger(sessionStore, bindingStore) // claim 存于控制面 KV twilight/artifact/claim -appender = extension.NewSemanticAppender(sessionStore, registry, bindingStore, ledger) -runtime = runmod.NewRuntime(appender, projections, extension.Leases{Store: sessionStore}, runmod.NewMemoryFrozenValues(), turn.CompanionV1(registry), runmod.DefaultSnapshotPolicy) +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), runmod.DefaultSnapshotPolicy, projectionCache) drivers = Resolve(ExecutionBindingRef) -> loop.New(models, tools, contextPlanner, policy, pub.Streaming) -coordinator = turn.Coordinator{Projections: projections, Appender: appender, Runtime: runtime, Bindings: drivers} -session = SessionDriver{Coordinator: coordinator, Projections: projections, Binding: bindingRef, Companion: turn.CompanionV1Version, NewTurnID: ...} +coordinator = turn.Coordinator{Writers: writers, Runtime: runtime, Bindings: drivers} +session = SessionDriver{Coordinator: coordinator, Writers: writers, Binding: bindingRef, Companion: turn.CompanionV1Version, NewTurnID: ...} input_submitted session.Send // 无 active Turn → coordinator.Start - commit 1: twilight/turn/started + twilight/chatlog/input_delivered* + twilight/run/created + twilight/run/input_accepted* + 组 1: twilight/turn/started + twilight/chatlog/input_delivered* + twilight/run/created + twilight/run/input_accepted* Loop.Run - commit: twilight/run/model_step_prepared (请求本体 → FrozenValueStore) - commit: twilight/run/model_step_started (lease → 控制面 KV,同事务) - commit: twilight/run/model_step_completed + twilight/run/tool_step_opened + twilight/chatlog/assistant - commit: twilight/run/tool_call_started + 组: 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; session.Send // 有 active Turn → coordinator.Deliver - commit: twilight/run/input_accepted + twilight/chatlog/input_delivered - commit: twilight/run/tool_call_completed + twilight/chatlog/tool_result - commit: twilight/run/model_step_prepared (PlanningHint.Inputs 含中途输入) + 组: twilight/run/input_accepted + twilight/chatlog/input_delivered + 组: twilight/run/tool_call_completed + twilight/chatlog/tool_result + 组: twilight/run/model_step_prepared (PlanningHint.Inputs 含中途输入) ... - commit: twilight/run/model_step_completed + twilight/run/ended + twilight/chatlog/assistant + twilight/turn/completed + 组: twilight/run/model_step_completed + twilight/run/ended + twilight/chatlog/assistant + twilight/turn/completed session.OnTurnSettled // 有积压的 submitted 输入 → 开下一个 Turn + +进程重启:writers.Writer(sid) 以新 Epoch 打开 → runtime.RecoverInterrupted(sid) → 各 Turn Resume(REF-DRV-4) ``` +每一行"组"是一次 `Writer.Commit`,落为 stream 中 CommitID 相同、Index 连续的若干行(SES-APP-1)。 + 参考 agent 的工具 ResponsePolicy 为 `DirectExecution`。ContextFold 在无 checkpoint 时输出全部有效条目。 diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 614b2ef..f1b9508 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -1,39 +1,38 @@ # Twilight Agent Run Protocol -状态:设计规范。Machine、command/fact 规则、Loop 与第 5 节的 Runtime(`agent/session/run`)已有 Memory 实现;RUN-CMP-2 的 Runtime conformance 尚未按本文重建,当前由 `agent/run/loop`、`agent/run/runtest` 与 `agent/ref` 的测试覆盖。实施记录见 [agent-runtime-refactor.md](agent-runtime-refactor.md)。 +状态:设计规范,第二版(2026-09-08)。Machine、command/fact 规则与 Loop 的执行逻辑已有实现并在第一版栈上通过测试;第 5 节的 Runtime 与 Loop 的执行授权部分按第一版(lease、grant、控制面 KV)实现于 `agent/session/run` 与 `agent/run/loop`,尚未按本版收缩。本版依据 [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) 第 8 节。 本文定义 `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 -MachineState Run 的语义状态投影(twilight/run/machine);snapshot 为可丢弃的派生缓存 -Runtime Run 的 command 入口:在 Session 临界区内 Decide、Evolve,经 Module Framework 追加 +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 存取 -extension.Lease 占用与续期:grant 是 Lease Token,ExecutionClaim 是 Holder;建立在 Session 控制面 KV 上,与 commit 同事务 ``` -`MachineState` 决定 Run 当前可执行动作。每次接受的 command 产生一个 SessionCommit,其中的 `twilight/run/` 事件经该 Run 版本的 `Protocol.Evolve` 从 `twilight/run/created` 重放后必须得到同一 `MachineState`。 +`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 到 SessionCommit 的原子提交边界、grant/lease、recovery +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,Machine 只接受带有效 grant 的 settlement。Attempt 的执行控制信息由 start command 的 `ExecutionClaim` 和 Runtime 返回的 opaque `ExecutionGrant` 表达;它们不进入 stream。 +`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 与 Store 类型,不依赖 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.SemanticAppender.AppendSemanticIn` 写入,经 `extension.Lease` 占用与续期)、FrozenValueStore adapter。 +**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、commit、projection、snapshot 与控制面 KV 机制由 [agent-session.md](agent-session.md) 与 [agent-session-extension.md](agent-session-extension.md) 定义。Artifact、queue、provider registry、权限与产品 policy 分别由其 package 或 Application 拥有。 +**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 @@ -49,15 +48,14 @@ type ToolRef string type ModelRef string type PlanningToken string type ExecutionClaim string -type ExecutionGrant string type Digest = es.Digest ``` -**RUN-WIR-1** identity 必须非空、稳定且为有效 UTF-8。`ExecutionClaim` 由 Loop 为一次 start command 生成并在该 command 的重试中保持不变,用于绑定 start command 与执行尝试。`ExecutionGrant` 是 Runtime 签发的 opaque capability,交还给签发它的 Runtime 完成 settlement。两者服务于执行授权,不进入 fact。Run 跨 domain causation 记录在 `twilight/run/created` 的 `CausationID`。 +**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。envelope、revision、index、digest chain 与 idempotency 由 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。 +**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 { @@ -71,9 +69,9 @@ type CommandEnvelope struct { } ``` -command 不持久化。`CommandEnvelope.ID` 就是该 command 产生的 SessionCommit 的 `CommitID`;`Digest` 只用于 Runtime 在临界区内比对精确重放。 +command 不持久化。`CommandEnvelope.ID` 就是该 command 产生的 event 组的 `CommitID`;`Digest` 只用于 Runtime 校验 envelope 构造完整(不匹配为不可重试错误),不参与重放判定。 -**RUN-WIR-3** 一个 command 恰产生一个 SessionCommit;其 `twilight/run/` 事件 Index 从 0 连续递增,companion 事件(TRN-CMP)与调用方附加事件(`CommitRequest.Attach`)依次紧随其后。所有事件的 EventID 由 Appender 按 `Digest(EventType, CommitID, index)` 统一赋值(EXT-APP-5);Runtime 提交的 commit 其 CommitID 等于 CommandID,Coordinator 写入的 Start 与 Retry group 使用该 group 的 CommitID。`RecordedAtUnixMilli` 由写入方的时钟填入,是 metadata,不参与 Run 的任何派生,也不进入 append fingerprint(SES-APP-1)。构造 command 必须使用该 Run 版本的 `Protocol.BuildEnvelope`(Loop 通过 `RuntimeSnapshot.Protocol()` 取得)。`agent/run` 不提供隐式选择版本的包级 `BuildEnvelope`、`Decide`、`Evolve` 或 `Digest*` 函数;新 Run 与测试显式使用 `ProtocolV1()`。 +**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,内容本体落在两处: @@ -81,17 +79,17 @@ command 不持久化。`CommandEnvelope.ID` 就是该 command 产生的 SessionC |---|---|---| | 冻结模型请求 `ModelRequest`(含工具定义) | `ModelStepPrepared.RequestDigest` | `FrozenValueStore`,key 为 RequestDigest | | 工具定义 `ToolDefinition` | `ToolSpec.DefinitionDigest`,只用于执行前校验 | 请求本体内;不另设存储 | -| 模型输出文本、reasoning、tool call 列表 | `ModelStepCompleted.ResultDigest` | 同 commit 的 `twilight/chatlog/assistant`,其 `SourceDigest` 等于 ResultDigest | -| 工具输出 | `ToolCallCompleted.OutputDigest` / `ToolCallAnswered.ResponseDigest` | 同 commit 的 `twilight/chatlog/tool_result`,其 `SourceDigest` 等于该 digest | +| 模型输出文本、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-APP-1):它们可以携带 `ReferencePart`,其 Binding 在同一事务建立 claim。`FrozenValueStore` 是内容寻址存储:`Put(digest, bytes)` 幂等,`Get(digest)`。请求本体的有效期是该 ModelStep 从 Prepared 到终结;step 终结后 adapter 可按保留策略删除或归档,Record 校验不依赖本体。 +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 校验不依赖本体。 下列 identity 稳定派生并由 Commit 验证: | identity | preimage | |---|---| -| PrepareModelRequest CommandID | RunID、loaded `RunPosition`(该 RunID 最后一条事件的 (revision, index)) | +| 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` 中的位置 | @@ -102,8 +100,8 @@ companion 与 Attach 事件与 Run 事实一起经 Module Framework 的 admissio | 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 | RunID、StepID、Claim | -| tool recovery CommandID(RecoverExpired 的 Unknown) | 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)。 @@ -125,15 +123,15 @@ type RunCreated struct { CausationID es.CausationID } type RunRecord struct { - Created session.EventPosition + Created session.Seq Snapshot RuntimeSnapshot - Events []session.SessionEvent // 该 RunID 的全部 twilight/run/ 事件,按 stream 顺序 + 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` 进入同一 commit(TRN-STR-2)。`Protocol.BuildCreateGroup(NewRun, []AgentInput)` 返回 `created` 与 `input_accepted` 的 facts,编码为 Session event 由 `agent/session/run` 完成,Coordinator 不自行编码。同一 RunID 第二条 `created` 为 Evolve 错误。 +**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)` 按 stream 顺序折叠该 RunID 的完整事件序列,第一条必须是 `created`,并按其 `SchemaVersion` 绑定 `Protocol`。Fold 过程执行纯状态重建。import、诊断与 `Runtime.Record` integrity verification 都经 FoldRun;snapshot 通过 FoldRun 结果校验。 +**RUN-NEW-2** `FoldRun(events)` 按 Seq 顺序折叠该 RunID 的完整事件序列,第一条必须是 `created`,并按其 `SchemaVersion` 绑定 `Protocol`。Fold 过程执行纯状态重建。import、诊断与 `Runtime.Record` integrity verification 都经 FoldRun;投影缓存通过 FoldRun 结果校验。 ## 4. Machine @@ -223,13 +221,13 @@ func (RunFailedEnd) runEnd() {} type RunEnded struct { End RunEnd } ``` -`RunEnded.End` 必须恰好是上述三个 variant 之一;`RunStoppedEnd.Reason` 必须非空,`RunFailedEnd.Reason` 必须是失败原因,`RunFailedEnd.Failure.Class` 必须非空。`RunEnded` 是 terminal commit 中最后一个 `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`。 +`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,或同 commit 失败 Run) + | +-> Rejected (retry 回到 Prepared,或同组失败 Run) +-> Withdrawn -> Open (Prepared 期间有 pending input,放弃该请求并重规划) ToolCall: @@ -245,7 +243,7 @@ Recovered 回到 Prepared 后,下一次 Start 重发同一 `RequestDigest` 的 **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、lease key、派生 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。 +**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 规则为: @@ -255,10 +253,10 @@ Recovered 回到 Prepared 后,下一次 Start 重发同一 `RequestDigest` 的 | `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`。恢复 durable attempt 时携带该 attempt 的 `Claim` | +| `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 或在同一 commit 追加 `RunEnded(failed/malformed_model_result)` | +| `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 | @@ -270,7 +268,7 @@ Recovered 回到 Prepared 后,下一次 Start 重发同一 `RequestDigest` 的 没有独立的 `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,一次返回该 commit 的完整 ordered fact group;验证成功后返回完整 facts。`Protocol.Evolve(state, fact)` 机械折叠 fact,依赖 fact 携带的完整执行状态数据。accepted facts 必须 self-contained;若 commit terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 +**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 的最小公共形状为: @@ -290,7 +288,7 @@ type RecoverModelExecution struct { } ``` -一次执行 attempt 的全部 command identity 都从其 `Claim` 派生:start、owner settlement、model recovery、tool recovery 的 CommandID 分别按上表计算,Commit 对 start 强制校验该派生。因此 Loop 只需保留 `Claim` 一个值(`loop.ClaimStore`):提交响应丢失时,以同一 Claim 重放得到同一 CommandID,Runtime 对精确重放返回原 `ExecutionGrant`;settlement 重放同理。Claim 存在进程内时,恢复只覆盖响应丢失;宿主注入 durable ClaimStore 时,替代进程可以直接重放前一进程已开始 attempt 的 start 并完成 settlement,不必等 lease 过期。Loop 已丢失 Claim 时,由 Runtime 的 recovery authority(lease 过期)处理该 execution。 +一次执行 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`: @@ -306,7 +304,7 @@ type RecoverModelExecution struct { 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`。执行恢复由 Runtime 的 recovery authority 根据 `NeedsRecovery` 提供。 +**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 @@ -316,12 +314,12 @@ type Runtime interface { Commit(context.Context, session.SessionID, CommitRequest) (CommitResult, error) Record(context.Context, session.SessionID, RunID) (RunRecord, error) FrozenRequest(context.Context, Digest) (ModelRequest, error) - RenewLease(ctx context.Context, sessionID session.SessionID, runID RunID, stepID StepID, callID CallID, grant ExecutionGrant) error - RecoverExpired(context.Context) (int, error) + // RecoverInterrupted 是接管处置:对该 Session 投影中全部 Executing 目标各提交一个 recovery command, + // 返回提交数。宿主在 OpenWriter 之后、驱动任何 Run 之前调用一次(RUN-CMT-7)。 + RecoverInterrupted(context.Context, session.SessionID) (int, error) } -// RunPosition 是该 RunID 最后一条 twilight/run/ 事件在 stream 中的位置; -// 只有这个 Run 自己的事件会移动它。 -type RunPosition struct { Revision es.Revision; Index uint16 } +// RunPosition 是该 RunID 最后一条 twilight/run/ 事件的 Seq;只有这个 Run 自己的事件会移动它。 +type RunPosition = session.Seq type RuntimeSnapshot struct { State MachineState // detached in-process view Position RunPosition @@ -329,12 +327,12 @@ type RuntimeSnapshot struct { SchemaVersion uint16 // created.SchemaVersion } -// ModuleEvent 是其他模块的 typed event,由 agent/session/run 经 Registry 编码;EventID 由 Appender 按位置赋值(EXT-APP-5)。 +// ModuleEvent 是其他模块的 typed event,由 agent/session/run 经 Registry 编码。 type ModuleEvent struct { Type session.EventType Value any } -// Companion 把一个 commit 的 Run facts 与 command 携带的 transient 内容映射为 +// Companion 把一组 Run facts 与 command 携带的 transient 内容映射为 // 其他模块的事件(对话内容、Turn completed)。实现由 agent/turn 提供(TRN-CMP)。 type CompanionRequest struct { Session session.SessionID @@ -374,84 +372,69 @@ func (Protocol) DecodeMachineState(jsonstable.Value) (MachineState, error) func ProtocolV1() Protocol type CommitRequest struct { - Base RunPosition // Load 时的 Position;PrepareModelRequest 为 hard CAS - Grant ExecutionGrant + 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 - Commit session.SessionCommit // 完整 commit:run facts、companion、Attach - Grant ExecutionGrant + Events []session.SessionEvent // 本次 command 的完整组:run facts、companion、Attach } ``` -**RUN-CMT-1** Runtime 按 `(SessionID, RunID)` 寻址。Run 由 Coordinator 的 Start commit 创建(TRN-STR-2),Runtime 没有 `Create`。`ErrRunNotFound` 只用于该 Session 中不存在的 RunID。已终结的 Run 不在 `twilight/run/machine` 投影中(RUN-CMT-2),`Load` 对它以 `Types=[twilight/run/]` 过滤 replay、按 RunID 筛出全部事实后 FoldRun,返回终态 snapshot;`Commit` 对它返回 `ErrRunTerminal`。这条路径是兜底:正常流程中 Loop 从结算返回的 snapshot 读到终态(第 7 节),Coordinator 从 turn surface 的 `AttemptView` 取终态与 SchemaVersion(TRN-PRJ-1),都不依赖它。 +Runtime 由组装代码以 `extension.Writers`(EXT-WRT-6)、`FrozenValueStore`、`Companion` 与 `SnapshotPolicy` 构造;它按 SessionID 取得该 Session 的 `Writer`,全部读写经该 Writer。 -**RUN-CMT-2** 投影 `twilight/run/machine` 消费全部 `twilight/run/` 事件,忽略其他模块事件(EXT-PRJ-2),`RequireComplete` 为 `run`,状态为: +**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` 折叠后从投影中移除,投影只保留其 RunID 用于拒绝同一 RunID 的第二条 `created`(RUN-NEW-1);终态结果由 `Record` 与 turn surface 提供,投影大小与活动 Run 数成正比,加上已终结 RunID 的集合。snapshot 是可丢弃缓存(SES-SNP-1):`Load` 经 `extension.ProjectionReader`(EXT-PRJ-4)读取,即 snapshot(若存在且 `Through` 是当前前缀)加其后类型前缀为 `twilight/run/` 的 tail;没有 snapshot 时从 stream 的过滤 replay 全量 fold。写入策略由 `agent/session/run` 的 `SnapshotPolicy` 决定,默认在 Run 的 `Current` 回到 `Open` 或 Run 终结时写入,并可按 commit 计数补充;kernel 不要求每次 commit 都写。`Record` 以 `Types=[twilight/run/]` 过滤 replay 读取该 RunID 的全部事件(SES-REP-2),FoldRun 重建;该 Run 仍在投影中时与投影状态比对,corrupt、gap 或 divergence 必须失败。 +终态 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.SemanticAppender.AppendSemanticIn` 在 Session Store 的一个事务内完成(SES-API-2、EXT-APP-3)。所有 Runtime implementation 在 fn 内调用同一个 pure `EvaluateCommit`,顺序固定为: +**RUN-CMT-3** Commit 经 `extension.Writer.Commit` 在该 Session 的 Writer 互斥区内完成(EXT-WRT-1)。所有 Runtime implementation 在 fn 内调用同一个 pure `EvaluateCommit`,顺序固定为: ```text -AppendSemanticIn(sessionID, func(tx): +writer.Commit(func(view): 1 validate envelope SessionID/RunID/schema/type/digest(digest 不匹配为不可重试错误) - 2 tx.LookupCommit(CommitID = CommandID) - 3 found -> fn 返回 nil(Appender 记 Noop);Runtime 以查到的 commit 构造 CommitAlreadyApplied, - start 精确重放时 LookupLease,Token 仍 live 则返回原 grant + 2 view.LookupCommit(CommitID = CommandID) + 3 found -> fn 返回 nil(Writer 记 Noop);Runtime 以查到的行与当前投影构造 CommitAlreadyApplied 4 derived CommandID check - 5 state = fold(tx.LoadSnapshot(twilight/run/machine) + tx.Tail(after, [twilight/run/])) - 缺少 created -> ErrRunNotFound;schema 不等于 created.SchemaVersion -> 不可重试错误;terminal check - 6 validate hard CAS(prepare 的 Base == Positions[RunID])/ target state / execution grant / recovery authority - grant 经 LookupLease 校验 Token;recovery authority 要求条目 deadline 已过且 command 与 Holder 绑定(5.1 节) + 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 start -> AcquireLease;settlement / recovery -> ReleaseLease;按 SnapshotPolicy tx.SaveSnapshot - 11 return SemanticGroup{CommitID: CommandID, Events: run ++ companion ++ attach} + 10 return SemanticGroup{CommitID: CommandID, Events: run ++ companion ++ attach} ) -// Appender 在同一事务内完成 codec、Binding admission、claim 写入与 append。 +// Writer 完成 codec、Binding admission、claim(先于 Append)、Append 与投影折叠(EXT-WRT-1/3)。 +// Runtime 在 Commit 返回后按 SnapshotPolicy 写投影缓存;缓存写入失败不影响 commit 结果。 ``` -FrozenValueStore 的 `Put` 幂等且内容寻址,在进入事务之前完成;事务失败时留下的本体无害,可由保留策略回收。 +FrozenValueStore 的 `Put` 幂等且内容寻址,在进入 Writer 之前完成;Commit 失败时留下的本体无害,可由保留策略回收。 -**RUN-CMT-4** `PrepareModelRequest` 是 hard-CAS command:`Base` 必须等于 section 内投影记录的该 Run 的 `Position`。这是有意选择:同一 Session 内其他模块的写入(用户提交新输入、summary、checkpoint、其他 Turn 的事件)不移动 Position,因此不使 Prepare 失效;Plan 与 Prepare 之间发生的 chatlog 写入不会被本次请求包含,新鲜度由 Application 经 `PlanningToken` 与 Planner 自行负责,Run 不校验 `PlanningToken` 的语义。其他 command 通过当前 target state 和 grant 做 call-local rebase;stale Base 本身不阻止无冲突的 ingress/control/settlement。相同 command 的 replay 判定先于 terminal check,因此 terminal Run 仍能返回原 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** 幂等键为 Session kernel 的 `(SessionID, CommitID)`,CommitID 等于 CommandID,Runtime 不另设幂等索引。同 CommandID 的重放返回 `CommitAlreadyApplied`、当前 snapshot 与原完整 commit,且不得再次 Decide 或产生外部 effect;command 不持久化,Runtime 不比对重放 command 的内容,同 CommandID 视为同一 command。对于 `StartModelExecution` 和 `StartToolCall`,claim 是 CommandID 的 preimage,不同 claim 即不同 command:其 start 按当前 target state 与 lease 评估,target 已被占用时返回 `ErrStaleRuntime`,并保持现有执行授权。start 的重放在 grant 仍 live 时返回原 start grant。非 start command 的 replay 不返回 grant。 +**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** accepted `StartModelExecution`/`StartToolCall` 为目标签发新 grant;该 start 的 `CommitAccepted` 和在 grant 仍 live 时满足精确 replay 条件的 `CommitAlreadyApplied` 返回同一个 grant。若该 start 已 settlement 或 Run 已 terminal,精确 replay 仍返回 `CommitAlreadyApplied`,并返回空 grant。model result/failure/reject 与 executing tool result/known failure 必须携带 live target grant。settlement 接受后 grant 失效;terminal commit 撤销该 Run 全部 grant。`RecoverModelExecution` 由 live grant holder 提交,或在 Runtime 验证 lease 已过期且 command Claim 等于该 lease 的 Holder 后无 grant 提交。Executing tool 的 recovery 使用同一条 `SubmitToolFailure{Outcome:Unknown}` command:工具 owner 必须携带 live grant;`RecoverExpired` 仅在 lease 已过期且没有已接受 settlement 时无 grant 提交。该 Unknown 只结算这一 call,Run 保持 Active。 +**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** commit、lease 变更、claim 与(若写入)snapshot 在同一 Session Store 事务内生效:lease 经 `extension.AcquireLease` / `ReleaseLease` 在 `SemanticTx` 内写入,claim 由 Appender 写入,三者与 commit 同时可见或同时不可见。`CommitResult`、Load 与 Record 返回 detached values。预期拒绝映射为 `ErrCommandConflict`、`ErrStaleRuntime`、`ErrRunTerminal`;transport/storage failure 保持可判别且不得伪装为 rejection。 +**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 控制面 - -grant、lease、ExecutionClaim、ClaimStore 与投影 snapshot 都不进入 stream。占用与续期使用 Module Framework 的 `extension.Lease`(EXT-LSE),它建立在 Session 控制面 KV 上,与 commit 同事务;durable claim 直接使用控制面 KV;snapshot 使用 Session snapshot(SES-SNP)。Run 对这些设施的映射为: - -| Run 概念 | Lease 字段 | -|---|---| -| `ExecutionGrant` | `Token`,由 Acquire 所在的 start CommitID 派生,因此 start 的精确重放得到同一 grant | -| `ExecutionClaim` | `Holder` | -| target | `Namespace = twilight/run/lease`,`Key = /model/` 或 `/call//`;三段都是定长 hex digest,恢复时由 Key 解析 target,`Attrs` 为空 | -| `LeaseTTL` | `TTL`;零表示不超时(进程内占用) | - -一个 target 至多一条 live lease。start 在提交 `ModelStepStarted` / `ToolCallStarted` 的事务内 `AcquireLease`;settlement 与 recovery 在提交对应 fact 的事务内 `ReleaseLease`。因此"日志中该 target 为 Executing"与"存在其 lease"同时成立或同时不成立。过期且无 settlement 时 Runtime 允许 grantless Recover:recovery authority 的判定为条目 deadline 已过、且 command 与条目的 Holder 绑定。`RecoverModelExecution` 携带 Claim,要求 Claim 等于 Holder;`SubmitToolFailure{Outcome:Unknown}` 不携带 Claim,要求其 CommandID 等于以 Holder 为 Claim 派生的 tool recovery CommandID(第 2 节 identity 表)。两者都使同一 Holder 的重复恢复幂等,并拒绝其他 Holder 的恢复。durable `loop.ClaimStore` 使用控制面 KV namespace `twilight/run/claim`。 - -`RecoverExpired` 以 `Leases.Expired(twilight/run/lease, now)` 枚举过期条目,由 Key 解析 target:Executing tool call 无 grant 提交 `SubmitToolFailure{Unknown}`,Executing model 提交 `RecoverModelExecution`;command 的 Claim 取自 `Holder`,因此 recovery CommandID 确定,重复扫描幂等。该 Run 保持 Active,同一 RunID 继续。进程内宿主使用 Memory 实现,lease 不超时,grantless recover 被拒绝。生产崩溃恢复使用带 TTL 的 Runtime。 +### 5.1 不进入 stream 的数据 -**RUN-CMT-9** lease 续期。`Runtime.RenewLease` 调用 `Leases.Renew`(EXT-LSE-3):Token 等于该 target 当前 lease 的 Token 时把 deadline 推后一个 `LeaseTTL`;条目不存在、Token 不匹配或条目已被 Release 时返回 `ErrStaleRuntime`。续期在临界区之外,由 kernel 的条件写保证不会把已删除的 lease 写回。持有 grant 的 worker 在效果执行期间必须以远小于 `LeaseTTL` 的间隔续期(Loop 的 `ExecutionPolicy.LeaseRenewInterval`);续期返回 `ErrStaleRuntime` 表示该 target 已被 recovery 接管,worker 必须停止执行并放弃 settlement。`LeaseTTL` 是恢复延迟上界。`LeaseTTL` 为零时 `RenewLease` 只验证 grant,不改变 deadline。 - -持久结构与一致性等级的总表见 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 7 节。 +`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 @@ -501,16 +484,8 @@ const ( type ExecutionPolicy struct { ToolExecution ToolExecutionMode MaxParallel int - LeaseRenewInterval time.Duration - Claims ClaimStore // nil 为进程内存储 OnMalformedModelResult func(run.ModelStep, run.StepFailure) run.ModelRejectDisposition } -type ClaimStore interface { - Put(ctx, session.SessionID, run.RunID, run.StepID, run.CallID, run.ExecutionClaim) error - Get(ctx, session.SessionID, run.RunID, run.StepID, run.CallID) (run.ExecutionClaim, bool, error) - Delete(ctx, session.SessionID, run.RunID, run.StepID, run.CallID) error - DeleteRun(ctx, session.SessionID, run.RunID) error -} type LoopResult struct { Disposition LoopDisposition // LoopWaiting | LoopFinished Reason WaitReason // 仅 ExecutionRecovery 时为 execution_recovery;否则为空 @@ -521,13 +496,13 @@ func New(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy 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`。`LeaseRenewInterval` 是 worker 续期间隔(RUN-CMT-9):模型与工具 worker 在效果执行期间按该间隔调用 `Runtime.RenewLease`,续期被拒时取消该 worker 的 ctx;零值关闭续期,只对 lease 不超时的 Runtime 正确。 +**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 时由 recovery authority 唤醒,`Reason` 为 `execution_recovery`;否则 `Reason` 为空。Waiting call 不进入 `LoopResult`;Application 通过投影的 `WaitingCalls` 读取。`LoopFinished` 时 `Result` 非 nil,并等于 terminal Run 的 `RunResult`。 +`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 同 commit 提交),并使用自己注入的 memory、attachments 与 product policy 组装 `sdk.Request`。Runtime 验证并冻结 planner 返回的 request,Planner 管理 application context。 +`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 @@ -536,8 +511,6 @@ Loop.Run(ctx, runtime, sessionID, runID, sink): repeat: snapshot = Runtime.Load(sessionID, runID) if terminal: emit observational run_finished; return Finished(snapshot.Result) - if resumeSettlement: continue // 重放可能已提交、响应丢失的 settlement - if resumeCachedStart: continue // 重放本进程已接受、尚未结算的 start effect = run.Next(snapshot.State) dispatch effect // 模型结算(无 tool call 的 SubmitModelResult、SubmitModelFailure、FailRun 的 RejectModelResult) @@ -545,17 +518,17 @@ Loop.Run(ctx, runtime, sessionID, runID, sink): // return Finished(snapshot.Result),不再 Load。工具结算不会终结 Run。 ``` -每个 `Loop` 实例为每个 `(SessionID, RunID)` 分配一个本地 driver slot。同一实例对同一 Run 的并发 `Run` 调用返回 `ErrRunAlreadyRunning`;不同 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` 或使用同一 command ID、同一 `ExecutionClaim` 精确重放得到原 grant 的 Loop,才拥有该 execution。Loop 必须在 `ClaimStore` 中保留该 attempt 的 claim 直到完成 settlement,其余 identity 按需派生;缺少 grant 的 replay 进入 reload 流程。调用使用 `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-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`;只有 Accepted owner 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 grant 提交。同一 ToolStep 中 DirectExecution 的 Pending call,在外层 ctx 未取消时于本次 `Run` 内按冻结 Scheduling 分批 Start 并结算;ctx 已取消时停止再 Start,只结算已持有 grant 的 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;Executing 且本进程持有 start cache 时只重放 start 并结算,不得再提交 grantless Known。 +**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/digest 重放一次;仍未知时返回错误,由后续 Load/Record 查询 authority。stale/terminal/conflict 触发 reload/drop,旧 external effect 保持单次执行尝试。工具实现配合 context 返回;永久阻塞由 application/durable recovery 处理。 +**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。 @@ -572,13 +545,13 @@ type Event struct { Kind EventKind Durability EventDurability Payload json.RawMessage - Committed *session.SessionCommit + Committed []session.SessionEvent // EventAgentCommitted 携带本次 command 的完整组 } ``` -`Sequence` 仅用于同一临时观察流内的顺序(例如 ToolProgress),从 1 开始;committed observation 的权威顺序由 Session `(Revision, Index)` 表达,未提供临时序号时保持 0。 +`Sequence` 仅用于同一临时观察流内的顺序(例如 ToolProgress),从 1 开始;committed observation 的权威顺序由 Session `Seq` 表达,未提供临时序号时保持 0。 -**RUN-LOP-6** EventSink 提供 realtime observation,Loop 通过序列化调用向 sink 发送事件。`EventAgentCommitted` 携带 accepted commit;text/reasoning delta、tool progress、tool lifecycle 与 run-finished observation 可丢失、重复或断流。sink failure 保持 Commit 结果;恢复与审计读取 Session stream,EventSink gap 通过 stream 对账。 +**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) 定义。 @@ -586,20 +559,19 @@ type Event struct { **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、snapshot 加 tail 的等价性由 Session kernel 与 Module Framework 的 conformance 覆盖(SES 第 8 节、EXT 第 8 节),本清单以引用代替重复。conformance 以 `session.Store` 为参数(`agent/session/run/runtimetest`),Memory 与 durable adapter 跑同一套。必须覆盖: +**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 group 建立 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` 与原 commit 且不再 Decide;Run 已终结后对已接受 command 的重放仍返回 AlreadyApplied,新 command 返回 `ErrRunTerminal`;prepare 的 Base 不等于该 Run 的 Position 时返回 `ErrStaleRuntime`;非 Prepare command 接受零值或过期的 Base(call-local rebase); +- 建立与寻址: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 而不结束; -- grant:start 签发 grant 且返回的 lease Token 与之相同;同 claim 的 start 重放返回同一 grant,settlement 后重放返回空 grant;不同 claim 的 start 在 target 已被占用时返回 `ErrStaleRuntime`;空 grant、错误 grant、另一 Run 的 grant 的 settlement 返回 `ErrStaleRuntime`; -- commit 组成:一 command 一 commit;commit 内 run 事实在 companion 与 Attach 之前;companion 中非空 `SourceDigest` 等于同 commit fact 记录的 ResultDigest / OutputDigest / ResponseDigest;Attach 携带 `twilight/run/` 事件被拒绝;companion 与 Attach 中的 ReferencePart 经 admission,未注册 Binding 使 commit 失败且无写入,合法 Binding 在同 commit 建立 `twilight/artifact/claim` 条目; +- 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 失效; -- 租约与 commit:start 接受后 `twilight/run/lease` 存在该 target 的条目,Holder 为 claim;settlement、recovery 与 terminal commit 之后条目不存在(同事务性由 SES-API-1 保证,本层观察结果); -- 投影:`SnapshotPolicy` 在 Run 回到 Open 或终结时写入 snapshot;终态 Run 不出现在 `twilight/run/machine`;Record 对活动 Run 的 fold 与投影一致;非法 fact 序列使 FoldRun 报错(篡改与缺口的检测属于 SES-REP-3); +- 投影:`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 发布后启用; -- lease 过期 recovery:live lease 拒绝 grantless recovery;过期 model 回到 Prepared 且 `FrozenRequest` 返回同一 RequestDigest 的请求;过期 tool 记 Unknown 且同 step 的 sibling 不受影响;grantless Unknown 的 CommandID 必须等于以 lease Holder 为 Claim 派生的 tool recovery CommandID,否则返回 `ErrStaleRuntime`;`RecoverExpired` 幂等;恢复 commit 删除 lease 条目;`LeaseTTL` 为零时 `RecoverExpired` 不做任何事; -- lease 续期:续期后原 deadline 不触发 recovery;空 grant、错误 grant 与 settlement 后的续期返回 `ErrStaleRuntime`;`LeaseTTL` 为零时续期只校验 grant(续期与 Release 并发的条件写由 EXT-LSE-3 覆盖); +- 接管处置:关闭 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` 单元测试)。 @@ -611,10 +583,9 @@ Loop conformance 必须覆盖: - `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; -- stale/unknown commit response、prepare no-progress rejection 与无 livelock; +- streaming delta 与 nil result、EventSink committed observation 携带完整组; +- 非 sentinel commit error 的一次重放、prepare no-progress rejection 与无 livelock; - 模型结算终结 Run 时 Loop 不再 Load,返回 `LoopFinished` 且 `Result` 等于 Record 的终态; -- 超过 LeaseTTL 的工具调用在续期下不被记为 Unknown,其结果被接受; -- 共享 ClaimStore 的第二个 Loop 实例重放前一实例的 start 并完成 settlement,不等待 lease 过期。 +- 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 index 247b473..3e5fd41 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -33,14 +33,15 @@ ### 2.1 authority ```text -Session stream 唯一 authority:twilight/turn、twilight/chatlog、twilight/run 事件同在一条 stream -控制面 KV Session Store 的 control-plane KV:lease、grant、artifact claim;与 commit 同事务,不进入 stream -MachineState Run 的语义状态投影(twilight/run/machine),snapshot 为可丢弃缓存 -Runtime Run command 的提交入口:Session 临界区内 Decide、Evolve,经 SemanticAppender 追加 +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 之前的设计为两条 ES(Run 独立的 `RunHeader + TransitionRecord[]`,Turn 把 Run 事实 materialize 到 Session)。该设计已被第 6 节记录的决定取代,第 7 节记录审查后的第二次修订(多写者临界区、控制面 KV、lease),第 8 节记录 2026-09-08 的第三次修订(Session 级单写者、扁平事件)。上表为第 8 节之后的形态。 ### 2.2 package layout @@ -49,11 +50,11 @@ 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 Event-first Session kernel(Commit CAS、CommitIn 临界区、snapshot、控制面 KV) -agent/session/extension Session Module Framework:first-party Registry、payload 版本、admission、SemanticAppender、Lease、projection +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 实现(消费 SemanticAppender 与 Lease)、FrozenValueStore -agent/artifact Ref、Binding、两态 RetentionLedger +agent/session/run first-party Run module:EventDefinition、machine projection、Runtime 实现(经 Writer 写入)、接管处置、FrozenValueStore +agent/artifact Ref、Binding、自持久化的两态 RetentionLedger、回收前核对 agent/turn Turn 生命周期、attempt、CompanionV1 ``` @@ -80,11 +81,12 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 - 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)在同一 SessionCommit 写入;没有 Run→Session materialization、coverage 水位或 outbox。 -- 只有一条写入路径:`SemanticAppender`。Run 的 Runtime 经 `AppendSemanticIn` 写入,companion 与 Attach 事件与其他 producer 一样经 admission;artifact claim 与 commit 同事务。 -- lease、grant、durable claim 存放在 Session Store 的控制面 KV,与 commit 同事务;ExecutionClaim、投影 snapshot 与 FrozenValueStore 是控制面或派生数据,不进入 stream。 +- 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),不等待 TTL 按目标恢复。 - Run fact 只保存执行状态与内容 digest;请求本体(含工具定义)在 FrozenValueStore,模型输出与工具输出在 chatlog 事件。 -- kernel `ProtocolVersion` 只覆盖 envelope 与 commit;payload 版本由模块携带(`v` 字段),Run 保留自己的 `SchemaVersion`。 +- kernel `ProtocolVersion` 只覆盖行结构与 digest;payload 版本由模块携带(`v` 字段),Run 保留自己的 `SchemaVersion`。 ## 3. 已完成迁移 @@ -105,9 +107,11 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 | `agent/turn` 重写(Coordinator、CompanionV1、surface 投影) | 完成,2026-09-07;旧实现已删除 | | 参考组装 `agent/ref`(ExecutionBinding、ContextPlanner、Memory 组装、SessionDriver、崩溃恢复 example) | 完成,2026-09-07 | | Runtime conformance(RUN-CMP-2,`agent/session/run/runtimetest`,以 `session.Store` 为参数) | 完成,2026-09-07;对 Memory Store 通过。kernel 与 extension 的 conformance 部分实现 | -| SQLite / PostgreSQL Session Store adapter、live 模型接入 | 未开始 | +| 第 8 节规范修订(session、extension、run、turn、chatlog、artifact、参考组装的第二版) | 完成,2026-09-08;代码未动 | +| 第 8 节代码重构(kernel 收缩、Writer、Runtime 去 lease/grant、接管处置、conformance 重建) | 未开始 | +| 文件 adapter(`agent/session/filestore`)、live 模型接入 | 未开始 | -当前正式调用形态为 `agent/ref` 的 Memory 组装:`ref.New` 返回 Store、Registry、Appender、Projections、Runtime、Coordinator 与 Bindings;Application 经 `SessionDriver.Send` 投递输入。Loop 不保存 authority state;Runtime 不读取 queue 或 planner context。 +以上"完成"的代码行都是第一版形态(多写者临界区、控制面 KV、lease),第 8 节修订后需要按新规范重写;当前正式调用形态仍为 `agent/ref` 的第一版 Memory 组装。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 的最小记录,因投影会随历史增长而未采用。 @@ -117,18 +121,13 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 ### 4.1 Core reference implementations -- 实现 Session kernel Memory Store:Commit、`CommitIn`、Types 过滤 replay、snapshot、控制面 KV;v1 conformance 不含 Fork 与 import; -- 实现 `extension.FirstPartyRegistry`、payload 版本、admission 与 `SemanticAppender`(两个入口,claim 同事务);artifact 两态 ledger 的 Memory 实现; -- 按第 6、7 节把 `agent/run` 的存储层改为 Session module(`agent/session/run`); -- 把 `agent/turn` 改为 attempt 模型与 `CompanionV1`,`Log` 替换为 Session Store 与 `SemanticAppender`; -- 加 Chatlog Context projection 与参考 Planner;`PlanningHint` 只提供边界事实; -- 纵向切片跑通后再冻结 kernel `ProtocolVersion` 1 与各模块 payload 版本 1 的 golden fixtures。 +第一版(第 6、7 节)的全部条目已于 2026-09-07 完成;第 8 节修订后的实施顺序见 8.5。完成后再冻结 kernel `ProtocolVersion` 1 与各模块 payload 版本 1 的 golden fixtures。 ### 4.2 durable adapters -- Session Store 的 SQLite 与 PostgreSQL adapter(commit、CommitIn 事务、snapshot、控制面 KV、Types 索引); +- 文件 adapter `agent/session/filestore`:一个 Session 一个目录,`stream.jsonl` 一行一个 event,`session.lock` 为 `flock` 目标,每次 Append 一次 fsync,打开时截掉不完整尾组; +- 数据库 adapter(SQLite / PostgreSQL):sessions(header、epoch、owner deadline)、events 两张表,Append 一个事务,`Heartbeat` 推后 deadline;只在多会话服务需要时做; - 收紧 Session authority tables 的 immutable RLS policy; -- 实现 `RecoverExpired` 的定期调度; - 需要远程 Store 或跨存储 claim 时,实现 extension 附录 C 与 artifact 附录的两阶段路径。 ### 4.3 Application migration @@ -244,7 +243,7 @@ Run 从独立的 Event Sourcing 存储改为 first-party Session Module。Run 审查意见"三个 first-party module 实际是一个领域,应合为一个实现"。耦合证据成立,但它们指向的是固定的分层顺序(turn → run、turn → chatlog),可以用包依赖表达。合成一个包会失去读侧收益:投影按 EventType 命名空间筛选,Context 只读 chatlog、machine 只读 run。因此保留三个包与三个命名空间,推迟的是可插拔框架(附录 B),不是模块划分。 -### 7.3 持久结构与一致性等级 +### 7.3 持久结构与一致性等级(第一版;第 8 节之后见 8.4) | 结构 | 等级 | 写入点 | 丢失或不一致时 | |---|---|---|---| @@ -295,4 +294,65 @@ lease 的第二条出路:grant 由 `(Claim, start CommitID)` 派生,start fa 对照 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 起因 + +第一版 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,数据库 adapter 另有 deadline) | 控制 | `Open`、`Heartbeat` | 文件 adapter 随进程释放;数据库 adapter 过期后可接管 | +| 投影缓存 | 派生缓存 | `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/Heartbeat、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 index 4fc09f9..6cd70fc 100644 --- a/docs/design/agent-session-chatlog.md +++ b/docs/design/agent-session-chatlog.md @@ -1,6 +1,6 @@ # Twilight Agent Session Chatlog Module -状态:设计草案。`agent/session/chatlog` 已实现事件定义、parts codec、PartsExtractor、Surface 与 Context 投影;checkpoint 事件未实现。payload 字段、输入 limits 与 golden fixtures 尚未冻结。 +状态:设计草案。`agent/session/chatlog` 已实现事件定义、parts codec、PartsExtractor、Surface 与 Context 投影;checkpoint 事件未实现。2026-09-08 随 kernel 第二版把位置类型改为 `session.Seq`、`RequireComplete` 改为 EXT-PRJ-2 的 Ignorable 规则,尚未实现。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`。 @@ -15,7 +15,7 @@ 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/` 事实写在同一 SessionCommit(TRN-CMP)。Run 事实只记录内容 digest,内容本体只在 chatlog 事件中出现一次。companion 事件与其他 producer 的事件走同一条写入路径:`SemanticAppender` 在同一事务内执行 codec、Binding admission 并建立 claim(EXT-APP-3),因此 companion 中的 `ReferencePart` 受到与用户输入相同的保护。 +`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 权威是已提交的条目。 @@ -41,9 +41,9 @@ type CheckpointID string | Assistant | `assistant` | 无 | — | immutable;ID 单次创建 | | Tool result | `tool_result` | 无 | 可被 `tool_result_superseded` | 同一 Turn、同一 CallID 至多一条 active | | Summary | `summary` | 无 | 随 checkpoint 失效 | checkpoint 的摘要正文 | -| Checkpoint | `checkpoint_created` | 无 | invalidated | 指向已有 EventPosition | +| Checkpoint | `checkpoint_created` | 无 | invalidated | 指向已有 Seq | -**CHT-LIF-1** reducer 拒绝 identity mutation、非法状态迁移、replacement conflict 与重复 ID。模型步骤进行中走 EventSink;定稿随 `ModelStepCompleted` / `ToolCallCompleted` 等 Run 事实同 commit 写入 `assistant` 或 `tool_result`。同一 Turn 的多个 Run attempt 各自产生 assistant 与 tool_result,全部保留在 stream 中并出现在 ContextFold 的输出里;哪些条目进入模型请求由 Planner 决定(TRN-RTY-3、REF-PLN-6),本模块不作取舍。 +**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 与条目 @@ -191,7 +191,7 @@ type SummaryPayload struct { Summary Summary } type CheckpointCreatedPayload struct { CheckpointID CheckpointID - CoveredThrough session.EventPosition + CoveredThrough session.Seq BaseContextDigest es.Digest SummaryID SummaryID SummaryDigest es.Digest @@ -230,7 +230,7 @@ twilight/chatlog/checkpoint_created twilight/chatlog/checkpoint_invalidated ``` -**CHT-EVT-2** `input_submitted` 创建 Input。Delivered、Withdrawn、Rejected 各终结一次。`input_delivered` 要求 Input 仍为 submitted,并写入非空 TurnID;它与把该输入交给 Run 的事实同 commit:Start group 中与 `twilight/turn/started` 一起,回合中途与 `twilight/run/input_accepted` 一起(TRN-STR-2、TRN-DLV-2)。AssistantID、ToolResultID、SummaryID 在 stream 内单次创建。 +**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 Digest 的 domain 为 `twilight/chatlog/checkpoint_created`。`BaseContextDigest` 覆盖截至 `CoveredThrough` 的有序 active Context 序列 `(Kind, ID, Digest)`。`CoveredThrough` 早于该 checkpoint。`SummaryID` 落在 `CoveredThrough` 与 checkpoint 之间,且已由 `summary` 创建。该间隙内仅有这一条 summary。`Retained` 为 base 序列的有序子集。合法 checkpoint 下 Context 为 `[Summary] + Retained`,再 fold checkpoint 之后的 tail。checkpoint 在显式 invalidate,或 summary / Retained / base source 被 supersede 之后失效;projection 回退到更早合法 checkpoint,或从全量 events 重折。 @@ -240,7 +240,7 @@ twilight/chatlog/checkpoint_invalidated type SurfaceEntry struct { Kind EntryKind ID string - Position session.EventPosition + Seq session.Seq } type Surface struct { Inputs map[InputID]Input @@ -251,7 +251,7 @@ type Surface struct { } ``` -**CHT-SUR-1** SurfaceFold 消费 chatlog decoded events,`RequireComplete` 为 `chatlog`。`EntryOrder` 为 stream 顺序下的 delivered input、assistant、tool_result、summary,并带 Position。回合列表由 turn 投影提供,按 `TurnID` 连接。 +**CHT-SUR-1** SurfaceFold 消费 chatlog decoded events,其他事件按 EXT-PRJ-2 处理。`EntryOrder` 为 stream 顺序下的 delivered input、assistant、tool_result、summary,并带 Seq。回合列表由 turn 投影提供,按 `TurnID` 连接。 ## 7. Context projection @@ -259,7 +259,7 @@ type Surface struct { func ContextFold(events []extension.DecodedEvent) ([]Entry, error) ``` -**CHT-CTX-1** 输入为已验证、按 stream 顺序的 chatlog events,`RequireComplete` 为 `chatlog`。输出为 delivered input、assistant、tool_result、summary 经 supersession 与 checkpoint 处理后的有序 `[]Entry`。ContextFold 为纯函数。 +**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。 diff --git a/docs/design/agent-session-extension.md b/docs/design/agent-session-extension.md index 6458000..4c0ceeb 100644 --- a/docs/design/agent-session-extension.md +++ b/docs/design/agent-session-extension.md @@ -1,6 +1,6 @@ # Twilight Agent Session Module Framework -状态:设计草案。`agent/session/extension` 已实现 Registry(含 `Requires` 校验)、SemanticAppender 两个入口、Lease 与 ProjectionReader;第 8 节 conformance 部分实现。wire 在 conformance 完整通过前不冻结。v1 为 first-party 固定注册表与单事务 append;Application module、通用 Catalog 与两阶段 journal 在附录中,不进入 v1 conformance。 +状态:设计草案,第二版(2026-09-08)。第一版(双入口 Appender、Lease、事务内投影读写)已由 `agent/session/extension` 实现并验证,随后按 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 8 节收缩为本版:写入串行与幂等重放由进程内的 `Writer` 承担,kernel 只提供追加日志([agent-session.md](agent-session.md))。本版尚无实现;wire 在 conformance 通过前不冻结。 本文定义建立在 `agent/session` 与 `agent/artifact` 之上的 Session Module Framework。实现包路径为 `agent/session/extension`;文中的"必须""不得""应该"是协议约束;JSON canonicalization 与 digest 遵循 `agent/jsonstable`、`agent/es`。 @@ -12,23 +12,19 @@ agent/artifact ← Session Module Framework → agent/session first-party modules: chatlog、turn、run ``` -Framework 负责 typed event codec 与 payload 版本、Binding declaration 与 admission、claim 与 commit 的同事务写入、pure projection。first-party Source 为 `twilight`,其 Module 为 `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** v1 只有一个写入路径:`SemanticAppender`。它有两个入口,`AppendSemantic`(CAS)与 `AppendSemanticIn`(临界区),两者执行同一套 codec、admission 与 claim 规则,都在 Session Store 的一个事务内完成。Run 的 `Runtime` 经 `AppendSemanticIn` 写入;Turn 的 Start、Retry、Settle 经任一入口写入。`session.Store` 的 append port(`Commit`、`CommitIn`)只由 Appender 调用;模块读取投影经 `ProjectionReader`(第 6 节),控制面 KV 的临界区外操作经 `Lease`(第 7 节)。 +**EXT-SCP-1** 一个 Session 在一个进程内恰有一个 `Writer`,它持有 kernel 的 `session.Writer`(所有权句柄)。全部写入经 `Writer.Commit`:Run 的 Runtime、Turn 的 Coordinator、接管恢复都是它的调用方。模块读取投影经 `ProjectionReader`。 -**EXT-SCP-2** 模块集合由组装代码在启动时传入 `BuildRegistry`,运行期不变;v1 的组装恰为三个 first-party module,本层不 import 任何模块包。Application 自定义 Source 与 Module、通用 `Catalog` 构建校验与 `RuntimeRegistry` 见附录 B;远程或跨存储 adapter 的两阶段提交见附录 C。 +**EXT-SCP-2** 模块集合由组装代码在启动时传入 `BuildRegistry`,运行期不变;v1 恰为三个 first-party module,本层不 import 任何模块包。Application 自定义 Source、通用 `Catalog` 见附录 B。 -**EXT-SCP-3** 建立在 kernel 机制之上、供模块共用的类型化设施属于本层,不属于 kernel。v1 有三个:`SemanticAppender`(封装 `Store.CommitIn`)、artifact claim 的 KV 适配(封装控制面 KV)、`Lease`(封装控制面 KV 的条件写与 deadline 枚举,第 7 节)。kernel 对这些设施只提供事务、条件写与 deadline 枚举(SES-API-3),不持有其语义。run 是 `Lease` 的第一个消费者;`Lease` 的 API 不得出现 run 的概念。 - -**EXT-SCP-4** 模块间依赖单向、固定,以 `Requires` 声明并由 Registry 校验(EXT-REG-4)。v1 三个模块的声明: +**EXT-SCP-3** 模块间依赖单向、固定,以 `Requires` 声明并由 Registry 校验(EXT-REG-4)。v1 三个模块的声明: | 模块 | Requires | |---|---| -| `chatlog` | 无。事件字段 `TurnID` 是 opaque 字符串,不需要 turn 的 codec | -| `run` | 无。Run fact 只记录内容 digest,内容由 `Companion` 写成其他模块的事件;`Companion` 是 Runtime 的构造参数,不是模块依赖 | -| `turn` | `run`(`twilight/run/created` v1、`twilight/run/input_accepted` v1、`twilight/run/ended` v1)、`chatlog`(存在即可) | - -本层不提供按模块启停的机制;v1 的组装总是传入全部三个模块。 +| `chatlog` | 无 | +| `run` | 无。`Companion` 是 Runtime 的构造参数,不是模块依赖 | +| `turn` | `run`(`twilight/run/run_created`、`input_accepted`、`run_ended` v1)、`chatlog`(存在即可) | ## 2. Registry 与版本 @@ -43,9 +39,11 @@ const SourceTwilight SourceID = "twilight" type EventDefinition struct { Type session.EventType - Current PayloadVersion // Encode 使用的版本 - Codecs map[PayloadVersion]PayloadCodec // Decode 支持的全部版本 + Current PayloadVersion + Codecs map[PayloadVersion]PayloadCodec Bindings []BindingReferenceDefinition + // Ignorable 为真的事件写入时带 session.SessionEvent.Ignorable,供不认识它的 reader 跳过。 + Ignorable bool } type ModuleDescriptor struct { ID ModuleID @@ -53,42 +51,31 @@ type ModuleDescriptor struct { Events []EventDefinition Projections []ProjectionDefinition } -// ModuleRequirement 声明对另一模块的依赖:消费它的哪些事件、能处理哪些 payload 版本。 type ModuleRequirement struct { Module ModuleID - Events map[session.EventType][]PayloadVersion // 空表示只要求该模块已注册 -} -type Registry struct { - ProtocolVersion uint16 - Profile session.ProtocolProfile - // immutable indexes for modules, events and projections + Events map[session.EventType][]PayloadVersion } -func BuildRegistry(profile session.ProtocolProfile, modules ...ModuleDescriptor) (*Registry, error) +type Registry struct { ProtocolVersion uint16 /* immutable indexes */ } +func BuildRegistry(protocolVersion uint16, modules ...ModuleDescriptor) (*Registry, error) func (r *Registry) LookupEvent(session.EventType) (ModuleID, EventDefinition, bool) -func (r *Registry) ModuleForEvent(session.EventType) (ModuleID, bool) -func (r *Registry) LookupProjection(ProjectionID, ProjectionVersion) (ProjectionDefinition, bool) +func (r *Registry) ModuleOf(session.EventType) (ModuleID, bool) // 按 twilight// 前缀 +func (r *Registry) Encode(session.EventType, any) (jsonstable.Value, PayloadVersion, error) func (r *Registry) Decode(session.SessionEvent) (DecodedEvent, error) ``` -**EXT-REG-1** EventType 为 `twilight//`,例如 `twilight/chatlog/assistant`、`twilight/turn/started`、`twilight/run/model_step_prepared`。Digest domain 与 EventType 相同。一个 Registry 中 ModuleID、EventType、ProjectionID 均唯一;`BuildRegistry` 校验每个 EventDefinition 的 Type 前缀等于其模块,构建后只读,`Profile.Version()` 必须等于 `ProtocolVersion`。 - -**EXT-REG-2** payload 版本与 kernel 版本分离(SES-VER-1)。每个 payload object 的第一层携带整数字段 `v`;`Encode` 写入 `Current`,`Decode` 读取 `v` 并选择 `Codecs[v]`。同一 EventType 的旧版本 codec 永久保留在 Registry 中,旧事件不迁移。一个模块的 payload 非兼容变化只增加该 EventType 的 `Current` 与一个新 codec,不影响其他模块,不触发 kernel 版本变化。 - -**EXT-REG-3** `Decode` 对未注册的 EventType,或已注册 EventType 的未注册 `v`,返回 `DecodedEvent{Unknown:true}` 并保留原始 payload;是否接受由 projection 的 `RequireComplete` 决定(EXT-PRJ-2)。Encode 对 `Current` 之外的版本拒绝。 +**EXT-REG-1** EventType 为 `twilight//`。一个 Registry 中 ModuleID、EventType、ProjectionID 均唯一;`BuildRegistry` 校验每个 EventDefinition 的 Type 前缀等于其模块,构建后只读。 -**EXT-REG-4** 模块间依赖由 `Requires` 声明,Registry 构建时校验,任一失败拒绝构建: +**EXT-REG-2** payload 版本与 kernel 版本分离(SES-VER-1)。payload object 第一层携带整数字段 `v`;`Encode` 写入 `Current`,`Decode` 读 `v` 并选择 `Codecs[v]`。旧版本 codec 永久保留,旧事件不迁移。 -1. `Requires` 指向的模块都已注册,依赖图无环; -2. 每个 projection 的 `Consumes` 与 `Ignores` 中的 EventType 属于本模块或 `Requires` 中的模块;`RequireComplete` 是本模块加 `Requires` 的子集; -3. `Requires.Events` 声明的每个 EventType,被依赖模块该类型的 `Current` 版本必须在声明的版本列表中。被依赖模块升版本而依赖方未声明能处理时,在启动期失败并指出是哪个依赖,而不是在 Apply 时失败。 +**EXT-REG-3** `Decode` 对未注册的 EventType 或未注册的 `v` 返回 `DecodedEvent{Unknown:true}` 并保留原始 payload。投影对 Unknown 的处置见 EXT-PRJ-2。 -`Requires` 只表达事件消费依赖。一个模块需要另一模块提供的接口实现(例如 run 的 `Companion` 由 turn 实现)是普通的构造参数,由组装代码注入、为 nil 时构造失败,不进入 `Requires`,Registry 不校验。 +**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 负责加入 + Encode(value any) (jsonstable.Value, error) // 不含 v;Registry 加入 Decode(wire jsonstable.Value) (any, error) Validate(value any) error } @@ -101,218 +88,143 @@ type DecodedEvent struct { } ``` -**EXT-COD-1** codec、Validate、Binding extraction 必须纯、确定、无 IO,不读 clock/random/environment/mutable global。Decode wire-first:先验证 object、discriminator(如适用)、kind 与 limits,再构造 value。Encode/Decode 必须拒绝 nil、typed nil、kind mismatch、未知 kind 和非 canonical value;有效值须满足 `Encode → Decode → Encode` 的 canonical round-trip。 +**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。 -**EXT-COD-2** 已提交事件的 payload 保持原始 canonical bytes。`v` 字段由 Registry 在 Encode 后加入、Decode 前取出,codec 自身不读写它;payload 的其他第一层字段不得命名为 `v`。 +**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 } -// BindingExtractor 由声明它的模块提供,从 decoded typed value 中返回全部 Artifact 引用。 type BindingExtractor interface { BindingIDs(value any) ([]artifact.BindingID, error) // appearance order } type BindingReferenceDefinition struct { - JSONPointer string // 普通 JSON payload 路径;与 Extractor 二选一 - Extractor BindingExtractor // typed value 上的模块自定义提取(chatlog 的 parts) + Extractor BindingExtractor Cardinality Cardinality AllowedSchemes []artifact.Scheme RequiredDurability artifact.Durability } ``` -**EXT-REF-1** declaration 恰选一种:非空 JSONPointer,或非 nil `Extractor`。JSONPointer 路径在 canonical JSON payload 上执行;`Extractor` 接收 decoded typed value,必须返回内部全部引用,不能回退为 JSONPointer 猜测。提取保留 appearance order,随后 group 才 sorted-unique。Extractor 由模块随 EventDefinition 一起声明,本层不维护提取器注册表。 +**EXT-REF-1** 声明以 `Extractor` 提取 typed value 内的全部 Artifact 引用,保留 appearance order,随后 group 才 sorted-unique。Extractor 随 EventDefinition 声明,本层不维护提取器注册表。(第一版的 JSONPointer 路径提取无消费者,已删除。) -**EXT-REF-2** `BuildRegistry` 验证 cardinality、pointer grammar、Extractor 非 nil(若声明)与 scheme/durability declaration;所有 declaration 的最低 durability 至少为 `EventBound`。admission 解析每个 Binding,验证 Scheme、最低 durability、resolvability 与 host access policy;任何遗漏或违反均拒绝整个 group,不作任何写入。 +**EXT-REF-2** `BuildRegistry` 验证 cardinality、Extractor 非 nil 与 scheme/durability 声明;最低 durability 至少为 `EventBound`。admission 解析每个 Binding,验证 Scheme、最低 durability、resolvability;任何违反拒绝整个 group,不作任何写入。 -## 5. SemanticAppender +## 5. Writer:进程内的写入串行与幂等 ```go type TypedEvent struct { Type session.EventType - RecordedAtUnixMilli int64; SourceEvents []session.EventID; Value any + RecordedAtUnixMilli int64 + SourceSeqs []session.Seq + Value any } type SemanticGroup struct { CommitID session.CommitID - CausationID es.CausationID; CorrelationID string Events []TypedEvent } -type SemanticAppendRequest struct { - SessionID session.SessionID; ExpectedHead session.Head - Group SemanticGroup -} -// SemanticTx 是 session.SessionTx 加 typed decode;所有方法在同一事务内生效。 -type SemanticTx interface { - session.SessionTx - Decode(session.SessionEvent) (DecodedEvent, error) +// 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 SemanticCommitFn func(SemanticTx) (*SemanticGroup, error) +type CommitFn func(View) (*SemanticGroup, error) // nil 表示不写 -type SemanticAppendOutcome string +type CommitOutcome string const ( - SemanticApplied SemanticAppendOutcome = "applied" - SemanticAlreadyApplied SemanticAppendOutcome = "already_applied" - SemanticHeadConflict SemanticAppendOutcome = "head_conflict" - SemanticCommitConflict SemanticAppendOutcome = "commit_conflict" - SemanticInvalid SemanticAppendOutcome = "invalid" - SemanticNoop SemanticAppendOutcome = "noop" // fn 返回 nil + CommitApplied CommitOutcome = "applied" + CommitAlreadyApplied CommitOutcome = "already_applied" // 同 CommitID、同 fingerprint + CommitConflict CommitOutcome = "conflict" // 同 CommitID、不同 fingerprint + CommitInvalid CommitOutcome = "invalid" + CommitNoop CommitOutcome = "noop" ) -type SemanticAppendResult struct { - Outcome SemanticAppendOutcome - Commit *session.SessionCommit - Claim *artifact.RetentionClaim // group 含 Binding 时非空 +type CommitResult struct { + Outcome CommitOutcome + Events []session.SessionEvent + Claim *artifact.RetentionClaim + Detail string } -type SemanticAppender interface { - AppendSemantic(context.Context, SemanticAppendRequest) (SemanticAppendResult, error) - AppendSemanticIn(context.Context, session.SessionID, SemanticCommitFn) (SemanticAppendResult, error) + +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-APP-1** group 的 Events 是完整 group,不能为空。Appender 对每个 TypedEvent lookup EventDefinition、Validate、canonical Encode(加入 `v`)、decode round-trip 和 Binding extraction;任一失败不作写入。它将全部 occurrence 组成 sorted-unique union,并通过 `artifact.BindingSetBuilder.Build(ctx, union)` 构造完整 BindingSet,再按 declaration 执行 Scheme、最低 durability 与 host access policy。Appender 必须将 TypedEvent 的 `RecordedAtUnixMilli`、`SourceEvents` 与 canonical Payload、Type 逐字段映射为 `session.UncommittedEvent`,不得替换其中任一值。 +**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-APP-5** first-party 事件的 EventID 由 Appender 统一赋值:`EventID = Digest(EventType, CommitID, index)`,index 为该事件在 group 中的位置。TypedEvent 不携带 EventID。同一 CommitID 的重放得到同一组 EventID,因此 EventID 与 CommitID 一起构成幂等判定的一部分(SES-APP-1)。模块不得自行派生 EventID。 +**EXT-WRT-2** 幂等:fn 返回的 group 若 CommitID 已在索引中,比对 fingerprint(Type、SourceSeqs、Payload 的有序序列,不含时间),相同返回 `AlreadyApplied` 与原行,不同返回 `Conflict`;两者都不写入,也不做 admission 与 claim。fn 内可先经 `View.LookupCommit` 判断,避免为重放重新构造 group。 -**EXT-APP-2** 对 nonempty BindingSet,以已验证 Header 唯一派生: +**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)` 尽力回收,失败也只留孤儿。 -```text -ClaimID = Digest("twilight/session-extension/claim", - claim-profile-version "1", - canonical-string(Header.ProtocolVersion), - SessionID, CommitID, BindingSet.RefSetDigest) -ClaimOwner = {Kind:"twilight/session/commit", - Authority:string(SessionID), Identity:string(CommitID)} -``` - -ClaimID 不含 ExpectedHead 与时间戳。相同 CommitID 的重试若 typed event identity、source、payload 或 BindingSet 不同为 conflict。 +**EXT-WRT-4** `Append` 返回 `ErrOwnershipLost` 时 Writer 进入失效状态:本次与之后的 `Commit` 返回该错误,调用方必须放弃该 Session 的执行。这是 Session 级 fencing 在进程内的表现;Runtime 与 Loop 对它的处理见 RUN-CMT-6。 -**EXT-APP-3** 两个入口都在 `Store.CommitIn` 的一个事务内完成,顺序为: +**EXT-WRT-5** ClaimID 派生保持第一版规则:`Digest("twilight/session-extension/claim", "1", ProtocolVersion, SessionID, CommitID, RefSetDigest)`;`ClaimOwner = {Kind:"twilight/session/commit", Authority:SessionID, Identity:CommitID}`。 -```text -AppendSemanticIn(sessionID, fn): - Store.CommitIn(sessionID, func(tx): - group = fn(SemanticTx{tx}) // nil → Noop,不追加 - if existing := tx.LookupCommit(group.CommitID): - fingerprint 相同 → AlreadyApplied;ledger.ActivateIn(tx.control, claim) 幂等重放;返回 - 否则 → CommitConflict - validate/encode/extract → build BindingSet → admission - if BindingSet 非空: ledger.ActivateIn(tx.control["twilight/artifact/claim"], ClaimID, Owner, Set) - return AppendRequest{ExpectedHead: tx.Head(), ...}) - -AppendSemantic(request): - 同上,fn 固定为:tx.Head() == request.ExpectedHead ? request.Group : HeadConflict +```go +// Writers 是宿主维护的 SessionID → Writer 映射;模块(run 的 Runtime、turn 的 Coordinator)经它取得 Writer。 +type Writers interface { + Writer(context.Context, session.SessionID) (Writer, error) +} ``` -claim 在 `Active` 状态写入控制面 KV,与 commit 同一事务;没有 `Prepared` 状态,也没有 journal。事务失败时 commit 与 claim 都不可见;事务成功时两者同时可见。`HeadConflict`、`CommitConflict`、`Invalid` 分别映射为同名 Semantic outcome,且不留下任何写入。claim 的 Release 由 Application 在事件 retention 结束时经 `RetentionLedger.ReleaseActive` 执行,不属于 Appender。 +**EXT-WRT-6** 一个进程对同一 Session 只打开一个 Writer,`Writers` 负责这一唯一性:首次请求时 `OpenWriter`,之后返回同一实例;Writer 失效(EXT-WRT-4)或 Close 后再次请求返回错误,是否重新 Open 由宿主决定。模块不自行调用 `OpenWriter`。 -**EXT-APP-4** 崩溃恢复不需要扫描:没有 in-flight 状态。Appender 调用返回未知结果时,调用方以同一 CommitID 重试,得到 `AlreadyApplied` 或首次 `Applied`。 - -## 6. pure projection 与 snapshot +## 6. pure projection 与缓存 ```go type ProjectionDefinition struct { ID ProjectionID; Version ProjectionVersion - Consumes []session.EventType; Ignores []session.EventType; RequireComplete []ModuleID + Consumes []session.EventType Initial func() (any, error) Apply func(any, DecodedEvent) (any, error) StateCodec PayloadCodec } -type ProjectionRunRequest struct { - Registry *Registry - Definition ProjectionDefinition - Events []session.SessionEvent - InitialState any -} -type ProjectionRunResult struct { State any; Applied uint64; Ignored uint64 } -type ProjectionRunner interface { Run(ProjectionRunRequest) (ProjectionRunResult, error) } - -// ProjectionReader 是模块与 Coordinator 的读取入口:snapshot 加过滤 tail,返回投影状态与其覆盖到的 head。 type ProjectionReader interface { - Load(ctx context.Context, sid session.SessionID, id ProjectionID, v ProjectionVersion) (state any, through session.Head, err error) + Load(ctx, sid session.SessionID, id ProjectionID, v ProjectionVersion) (state any, through session.Seq, err error) } -``` - -**EXT-PRJ-1** Initial、Apply、StateCodec 和 runner decode 都必须 pure。runner 只接受已验证的 complete commit sequence;一个 commit 内任一 event 失败,不得发布该 commit 的 partial state。 - -**EXT-PRJ-2** `Consumes` 表示必须 decode/handle 的 EventType,`Ignores` 是显式已知跳过,二者不得重叠。出现属于 `RequireComplete` module 的 Unknown event 必须失败;其他 module 的 event 可忽略。读取时以 `Consumes` 与 `RequireComplete` module 的前缀作为 `Types` 过滤(SES-REP-2、SessionTx.Tail),使读取代价与 projection 消费的事件数成正比。 - -**EXT-PRJ-3** snapshot 使用 Session snapshot envelope,`ProjectionKey` 等于 `ProjectionID`,`ProjectionVersion` 同名。只有 ProjectionID、ProjectionVersion、StateCodec canonical validation 和 `Through` 前缀校验都通过时可复用;否则从 log 重建。写入策略由 projection 自定,可以在 `SemanticTx.SaveSnapshot` 中与 commit 同事务写入,也可以异步写入。 - -**EXT-PRJ-4** `ProjectionReader.Load` 是临界区之外读取投影的唯一入口:读 snapshot、按 EXT-PRJ-3 校验、以过滤 replay 读取其后的 tail、fold,返回状态与 `through`。同一 `ProjectionRunner` 在 `SemanticTx` 内以 `LoadSnapshot` 加 `Tail` 得到相同结果。 - -## 7. Lease:占用与续期 - -```go -type LeaseToken string -type Lease struct { - Namespace session.ControlNamespace - Key string - Holder string // 模块提供的持有者标识;同一 Holder 的重复 Acquire 幂等 - Token LeaseToken // 本层派生的凭证;Release 与 Renew 必须携带 - DeadlineUnixMilli int64 // 0 表示不超时 - Attrs jsonstable.Value // 模块自用,本层不解释 -} -type AcquireLeaseRequest struct { - Namespace session.ControlNamespace; Key string - Holder string; TTL time.Duration; Attrs jsonstable.Value +// ProjectionCache 是可选的派生缓存,随时可删;Memory 与文件实现由本层提供。 +type ProjectionCache interface { + Load(ctx, sid, id, v) (state jsonstable.Value, through session.Seq, digest es.Digest, ok bool, err error) + Save(ctx, sid, id, v, state jsonstable.Value, through session.Seq, digest es.Digest) error } - -// SemanticTx 内,与本次 commit 同事务 -func AcquireLease(tx SemanticTx, commitID session.CommitID, now int64, req AcquireLeaseRequest) (Lease, error) -func ReleaseLease(tx SemanticTx, ns session.ControlNamespace, key string, token LeaseToken) error -func LookupLease(tx SemanticTx, ns session.ControlNamespace, key string) (Lease, bool, error) - -// 临界区之外 -type Leases struct { Store session.Store } -func (Leases) Lookup(ctx, sid session.SessionID, ns session.ControlNamespace, key string) (Lease, bool, error) -func (Leases) Renew(ctx, sid session.SessionID, ns session.ControlNamespace, key string, token LeaseToken, ttl time.Duration, now int64) error -func (Leases) Expired(ctx, ns session.ControlNamespace, now int64, fn func(session.SessionID, Lease) (bool, error)) error +func NewProjectionReader(store session.Store, registry *Registry, cache ProjectionCache) ProjectionReader ``` -**EXT-LSE-1** 值编码为 canonical JSON `{holder, token, attrs}`,deadline 使用 KV 条目的 deadline 字段(now 加 TTL;TTL 为零时 deadline 为 0)。`Token = Digest("twilight/session-extension/lease", SessionID, Namespace, Key, Holder, commitID)`,由 Acquire 所在 commit 的 CommitID 派生,因此 Acquire 是纯函数,同一 commit 的重放得到同一 Token。 +**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-LSE-2** `AcquireLease`:条目不存在时写入并返回新 Lease;条目存在且 Holder 相同时返回既有 Lease(幂等,不改 deadline);Holder 不同时返回 `ErrConflict`,无论既有条目是否已过 deadline。本层不自动回收过期条目;过期的处置由消费模块经 `Expired` 枚举后在自己的 commit 中完成,通常以 `ReleaseLease` 结束。`ReleaseLease`:条目不存在或 Token 不匹配返回 `ErrStale`,否则删除。 +**EXT-PRJ-3** 缓存条目记录 `through`(已折叠到的最后一行 Seq)与该行的 `Digest`。复用条件:`Read(From: through)` 返回的首行 Seq 与 Digest 与缓存一致,且 `StateCodec.Decode` 成功;否则从头重折。写入策略由投影或其宿主决定(例如 run 的 `SnapshotPolicy`);缓存不在 kernel,也不与 append 同事务,丢失或过期只影响读取代价。 -**EXT-LSE-3** `Renew` 在临界区之外执行:`ControlGet` 读到条目并核对 Token 后,以读到的值为 `expected` 调用 `ControlCompareAndPut`,只改 deadline、不改值;条目不存在、Token 不匹配、或条件写返回 false(条目已被 Release 或改写)时返回 `ErrStale`。条件写保证续期不会把已删除的条目写回。TTL 为零时 `Renew` 只校验 Token,不改变 deadline。`Expired` 以 `ControlExpired` 枚举 deadline 已过的条目并解码为 Lease;fn 返回 false 停止。 +**EXT-PRJ-4** `Writer.Projections()` 返回的 reader 直接读 Writer 内存中的状态,不经 Store;独立进程的观察者用 `NewProjectionReader` 从 Store 读,两者对同一 head 给出相同状态。 -## 8. errors 与 conformance +## 7. errors 与 conformance ```go type ErrorCode string const ( ErrInvalid ErrorCode = "invalid"; ErrUnknownEvent ErrorCode = "unknown_event" ErrCodec ErrorCode = "codec"; ErrBinding ErrorCode = "binding" - ErrConflict ErrorCode = "conflict"; ErrStale ErrorCode = "stale" - ErrUnsupportedProfile ErrorCode = "unsupported_profile" + ErrConflict ErrorCode = "conflict"; ErrOwnershipLost ErrorCode = "ownership_lost" ) -type Error struct { Code ErrorCode; Type session.EventType; Detail string } -func (Error) Error() string ``` v1 conformance 必须验证: -- **EXT-REG-1、EXT-REG-2、EXT-REG-3、EXT-REG-4**:immutable Registry、Profile/ProtocolVersion binding、`v` 字段的写入与选择、多版本 codec 共存、Unknown 事件保留 raw payload、`Requires` 缺失或成环被拒绝、projection 消费未声明模块的事件被拒绝、被依赖事件版本不在声明范围被拒绝; -- **EXT-COD-1、EXT-COD-2**:wire-first、安全 codec、canonical round-trip、`v` 保留字段; -- **EXT-REF-1、EXT-REF-2**:pointer 与 Extractor 全量提取、cardinality、scheme/durability admission、拒绝时无写入; -- **EXT-APP-1 至 EXT-APP-5**:TypedEvent→UncommittedEvent 全字段映射、EventID 由 CommitID 与 index 统一派生、由 Header ProtocolVersion 与 RefSetDigest 派生的 stable ClaimID、claim 与 commit 同事务(崩溃点注入后两者同时存在或同时缺失)、AlreadyApplied 的 claim 幂等、两个入口产生等价 commit、Noop 不追加; -- **EXT-LSE-1、EXT-LSE-2、EXT-LSE-3**:Token 确定派生、同 Holder 幂等、异 Holder conflict、Acquire 与 commit 同事务、Release 的 Token 校验、Renew 与 Release 并发时不复活已删除条目、TTL 为零的 Renew 不改 deadline、Expired 只返回已过 deadline 的条目; -- **EXT-PRJ-1 至 EXT-PRJ-4**:pure fold、commit boundary、Consumes/Ignores/RequireComplete、Types 过滤读取与全量读取等价、snapshot equivalence、`ProjectionReader.Load` 与临界区内读取结果一致。 +- **EXT-REG-1 至 4**:immutable Registry、`v` 的写入与选择、多版本 codec 共存、Unknown 保留 raw payload、`Requires` 缺失或成环被拒绝、投影消费范围外事件被拒绝、被依赖事件版本不在声明范围被拒绝; +- **EXT-COD-1/2**:wire-first、canonical round-trip、`v` 保留字段; +- **EXT-REF-1/2**:Extractor 全量提取、cardinality、scheme/durability admission、拒绝时无写入; +- **EXT-WRT-1 至 5**:OpenWriter 后索引与投影等于全量 fold;同 CommitID 重放 AlreadyApplied、不同内容 Conflict、两者无写入;并发调用方串行且各自看到前一次的结果;claim 先于 append,append 失败后 claim 被释放或可被核对回收;`ErrOwnershipLost` 后 Writer 失效; +- **EXT-PRJ-1 至 4**:pure fold、组边界、Consumes 与范围外跳过、Ignorable 与非 Ignorable 的 Unknown、缓存复用条件、Writer 内投影与 Store 读取一致。 ## 附录 B:Application module 与通用 Catalog(不进入 v1) -Application 注册自己的 `SourceID`(例如 `acme`)与 Module 时,EventType 为 `//`;`BuildCatalog(CatalogBuildRequest)` 在启动时把多个 Source 的 ModuleDescriptor、RuntimeRegistry 与 artifact SchemeDefinition 组合为只读索引,拒绝 duplicate owner、namespace mismatch、registry requirement mismatch、非法 Binding declaration 与低于 `EventBound` 的 declaration。`RuntimeRegistry` 以 `CodecRegistryDescriptor{ID, WireManifest, WireProfile}` 描述由配置装载的自定义 codec 与提取器,Catalog 逐字段匹配 requirement 与 descriptor。v1 的模块以 Go 值直接传入 `BuildRegistry`,提取器随 EventDefinition 声明,不需要这一层间接。 - -## 附录 C:两阶段 semantic append(不进入 v1) - -当 Session Store、RetentionLedger 或 BindingStore 不在同一事务域(远程 Store、跨数据库)时,claim 与 commit 无法同事务写入,需要 `Prepared` claim 状态与 durable `SemanticAppendJournal`: - -```text -journal.Prepare(intent{ClaimID, Owner, BindingSet, Fingerprint, CanonicalRequest, Pending}) -→ ledger.Prepare(ClaimID, Owner, Set) // Prepared claim 为 in-flight 写入提供 GC 保护 -→ Store.Commit(CanonicalRequest) -Applied/AlreadyApplied → ledger.Activate;journal.MarkTerminal(Completed) -Invalid/CommitConflict → journal.MarkTerminal(Aborted);ledger.AbortPrepared -未知结果 → 保留 Prepared 与 Pending,返回 Indeterminate -``` - -恢复扫描 journal Pending 与 ledger Prepared 的并集:先以 intent 幂等重建 Prepared claim,再 `LookupCommit` 决定 Activate 或重试;只有 journal 的 durable `Aborted` 标记授权 `AbortPrepared`;claim 存在而 intent 缺失时保持 Prepared 并报告 indeterminate。支持 `AtomicSemanticCommitter` 的 adapter 可以跳过 choreography,但必须原子持久化同一完整 intent 与匹配 claim。这些规则对应 artifact 附录中的 `Prepared` 状态与 reconciler。 +Application 注册自己的 `SourceID` 与 Module 时,EventType 为 `//`;`BuildCatalog` 在启动时把多个 Source 的 ModuleDescriptor 与 artifact SchemeDefinition 组合为只读索引。v1 的模块以 Go 值直接传入 `BuildRegistry`,不需要这一层。 diff --git a/docs/design/agent-session.md b/docs/design/agent-session.md index bec9cc6..9bf3442 100644 --- a/docs/design/agent-session.md +++ b/docs/design/agent-session.md @@ -1,281 +1,175 @@ # Twilight Agent Session Protocol -状态:设计草案。`agent/session` 已有 Memory reference implementation(Create、Commit、CommitIn、Types 过滤 replay、snapshot、控制面 KV),纵向切片已在 `agent/ref` 跑通;wire、digest preimage 与 conformance 在第 8 节列出的检查全部实现前不冻结。v1 为单 stream kernel;Fork、ancestry、canonical import 在附录 A 中,不进入 v1 conformance。 +状态:设计草案,第二版(2026-09-08)。第一版(多写者临界区、commit 容器、控制面 KV、kernel 内 snapshot)已由 `agent/session` 的 Memory 实现验证过语义,随后按 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 8 节的决定收缩为本版。本版尚无实现;wire 在 Memory 与文件 adapter 通过第 7 节 conformance 前不冻结。 本文定义 Twilight Session 的 Event Sourcing kernel。文中的"必须""不得""应该"是协议约束。 -## 1. Events 与范围 +## 1. 范围 ```text -Events = committed SessionEvent stream +Events = 一条 Session 的有序 SessionEvent 日志,追加式,一行一个 event State = Fold(Events) -Persistent representation - = immutable SessionHeader - + ordered atomic SessionCommit records containing ordered SessionEvents - + control-plane KV(同事务写入,不进入 stream,不参与 digest chain) +kernel 负责:header、event 行、seq、原子的组追加、Session 级写者独占、按行 digest、顺序读 +modules 负责:event ontology、typed codec、payload 版本、投影、投影缓存、幂等重放、并发串行 ``` -Committed Events 构成 Session 的长期语义事实。进入 Fold 前,Session kernel 必须验证 Event 位于完整的 SessionCommit 中,且 Header、commit boundary、head 与 digest chain 有效。 +**SES-SCP-1** kernel 不解释 payload,不校验 payload 的 schema,不知道模块、commit 的语义、投影或 lease。它保证四件事:日志只能追加;同一时刻一个 Session 至多一个有效写者;一次 `Append` 的整组 event 同时可见或同时不存在;每行携带覆盖前一行的 digest。 -```text -Session kernel envelope、顺序、commit、临界区、snapshot、控制面 KV、integrity -Session modules event ontology、typed codec、payload 版本、payload validation、projection -``` - -Payload 对 Session kernel 是 opaque canonical JSON。snapshot 与控制面 KV 都不是语义事实:snapshot 是可重建的派生数据;控制面 KV 保存执行占用、保留声明等运行控制信息,由模块解释,kernel 只保证它与同一 commit 原子写入、支持条件写与按 deadline 枚举。 - -**SES-SCP-1** kernel 不依赖领域 payload,也不对其执行 schema validation 或解释。replay 的唯一顺序是 `(revision, index)`;时钟只作 metadata。 +**SES-SCP-2** 并发不在 kernel 解决。一个 Session 的全部写入者(Run 的 worker、Turn 的 Coordinator、恢复流程)在进程内经同一个 `extension.Writer` 串行(EXT-WRT),Writer 持有 kernel 的写者句柄。kernel 只拒绝不持有有效所有权的 `Append`。 -**SES-SCP-2** v1 的范围是一条 stream 的 kernel:header、commit、两种 append 入口、local replay、snapshot、控制面 KV。Fork、ancestry archive、canonical import 与 resolved replay 见附录 A,v1 实现返回 `ErrUnsupported`,conformance 不覆盖。header 与 digest preimage 为它们保留字段位置,日后加入不升 ProtocolVersion。 +**SES-SCP-3** v1 的范围是单条 stream:header、Open/Append/Read、所有权与 epoch、按行 digest。Fork、ancestry、canonical import 见附录 A,v1 返回 `ErrUnsupported`。 ## 2. 版本 -`ProtocolVersion` 只覆盖 kernel wire:header、event envelope、commit、snapshot envelope、digest profile 与 codec 的 canonicalization 规则。它不覆盖 payload。 +`ProtocolVersion` 覆盖 kernel wire:header 字段、event 行字段、digest preimage、组完整性规则。它不覆盖 payload。 -**SES-VER-1** payload 的版本由模块负责:每个 payload object 的第一层携带整数字段 `v`,模块按 `(EventType, v)` 选择 codec(EXT-REG-2)。同一 stream 内不同 event 可以携带不同的 `v`;kernel 不读取该字段。 +**SES-VER-1** payload 的版本由模块负责:每个 payload object 第一层携带整数字段 `v`,模块按 `(EventType, v)` 选 codec(EXT-REG-2)。kernel 不读取该字段。 -**SES-VER-2** `ProtocolVersion` 在旧 kernel reader 读取新 writer 产生的 stream 后无法保持 envelope、commit 顺序或 digest chain 语义时递增。payload 字段、EventType 与模块 codec 的变化不触发 kernel 版本变化。kernel 版本变化由外部 migration tool 生成新版本 stream。 +**SES-VER-2** `ProtocolVersion` 在旧 reader 无法保持行结构或 digest 语义时递增;payload、EventType、模块 codec 的变化不触发。kernel 版本变化由外部 migration tool 生成新版本日志,旧日志原样保留(adjacent migration)。 -第一版运行实例绑定一个 `ProtocolProfile`,其 `Version()` 是实例接受的 `ProtocolVersion`。读取 Header、Commit 或 snapshot 时先校验 `ProtocolVersion` 与绑定 profile 相等,不匹配返回 `ErrUnsupportedProfile`。 - -## 3. wire types 与 profile +## 3. wire types ```go type SessionID string type CommitID string -type EventID string type EventType string -type ProjectionKey string -type CursorToken string -type ControlNamespace string +type Seq uint64 // 行号,从 0 连续递增 +type Epoch uint64 // 写者所有权代数,从 1 递增 type SessionHeader struct { ProtocolVersion uint16 SessionID SessionID - ParentFork *ForkPoint // v1 必须为 nil;见附录 A + CreatedAtUnixMilli int64 + ParentFork *ForkPoint // v1 必须为 nil;附录 A CausationID es.CausationID Metadata jsonstable.Value HeaderDigest es.Digest } + type SessionEvent struct { - EventID EventID - Index uint16 + Seq Seq + CommitID CommitID // 同一次 Append 的行相同 + Index uint16 // 组内序号,从 0 递增 + Last bool // 组内最后一行 Type EventType RecordedAtUnixMilli int64 - SourceEvents []EventID + SourceSeqs []Seq // 可选;语义由声明它的模块解释,kernel 不校验 + Ignorable bool // 写者声明:不认识该 Type 的 reader 可以跳过它 Payload jsonstable.Value - EventDigest es.Digest + Digest es.Digest // 覆盖本行全部字段与前一行的 Digest } + type UncommittedEvent struct { - EventID EventID Type EventType RecordedAtUnixMilli int64 - SourceEvents []EventID + SourceSeqs []Seq + Ignorable bool Payload jsonstable.Value } -type SessionCommit struct { - ProtocolVersion uint16 - SessionID SessionID - Revision es.Revision - PreviousDigest es.Digest +type Group struct { CommitID CommitID - CausationID es.CausationID - CorrelationID string - Events []SessionEvent - CommitDigest es.Digest + Events []UncommittedEvent // 非空 } -type Head struct { Revision es.Revision; Digest es.Digest } -type EventPosition struct { Revision es.Revision; Index uint16; EventDigest es.Digest } +type Head struct { Next Seq; Digest es.Digest } // 空日志为 {0, HeaderDigest} ``` -**SES-WIR-1** identity 非空且稳定;EventID 在同一 stream 内唯一。header 不可变,revision 从 1 连续递增,commit 至少有一个 event,event Index 从 0 连续递增。revision 1 的 `PreviousDigest=HeaderDigest`,其后为前一 CommitDigest。空 stream head 是 `{0, HeaderDigest}`。`Create` 对逐字段相同的 `CreateRequest` 幂等返回既有 header,同 SessionID 的不同请求为 `Conflict`。commit 与 header 的 `CausationID`、`CorrelationID` 是 producer 自行填写的 opaque metadata,kernel 不解释;它们进入 digest 与 append fingerprint。 +**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** `ProtocolProfile` 冻结 kernel wire:envelope、commit、snapshot envelope、null/omission、精确 integer encoding、unknown-field policy、array order 与下列 digest preimage;所有 digest 依 `agent/es` 的 versioned domain separator。 +**SES-WIR-2** digest preimage: -```go -type ProtocolProfile interface { - Version() uint16 - EncodeHeader(SessionHeader) (jsonstable.Value, error) - DecodeHeader(jsonstable.Value) (SessionHeader, error) - EncodeEvent(SessionID, es.Revision, SessionEvent) (jsonstable.Value, error) - DecodeEvent(SessionID, es.Revision, jsonstable.Value) (SessionEvent, error) - EncodeCommit(SessionCommit) (jsonstable.Value, error) - DecodeCommit(jsonstable.Value) (SessionCommit, error) - EncodeSnapshot(Snapshot) (jsonstable.Value, error) - DecodeSnapshot(jsonstable.Value) (Snapshot, error) - ValidateCanonicalHeader(jsonstable.Value) (SessionHeader, error) - ValidateCanonicalEvent(SessionID, es.Revision, jsonstable.Value) (SessionEvent, error) - ValidateCanonicalCommit(jsonstable.Value) (SessionCommit, error) - ValidateCanonicalSnapshot(jsonstable.Value) (Snapshot, error) - FingerprintAppend(AppendRequest) (es.Digest, error) -} +```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 ``` -**SES-WIR-3** 同一个 Session 的 `SessionHeader.ProtocolVersion`、所有 `SessionCommit.ProtocolVersion`、`Snapshot.ProtocolVersion` 与所选 `ProtocolProfile.Version()` 必须相等。Store 从已验证 Header 派生后续操作使用的版本,调用方提交的版本字段只能通过一致性校验。 +digest 依 `agent/es` 的 versioned domain separator。链条按行连接;任何行被改写、删除或重排都使其后所有行的 digest 失效。 -Header、event、commit 分别覆盖自身以外的全部持久字段。event digest 额外覆盖 SessionID、revision 和 index。每个 `ValidateCanonical*` 必须执行 decode → encode 并要求 canonical-equivalent wire,同时验证对应 digest。`SourceEvents` 是 bytewise sorted-unique set,且只可引用同一 stream 内已存在的 EventID。opaque Payload 必须已经 canonical,完整 value 进入 event digest 和 append fingerprint。 +**SES-WIR-3** 同一 Session 的 header 与每一行使用同一 `ProtocolVersion`;Store 从 header 派生版本,调用方不传版本。 -## 4. Store API 与 errors +## 4. 所有权 ```go -type CreateRequest struct { - ProtocolVersion uint16; SessionID SessionID; CausationID es.CausationID; Metadata jsonstable.Value +type OpenOptions struct { + // TTL 为零表示所有权只随进程或连接存活(文件锁语义);非零表示写者必须在 TTL 内 Heartbeat, + // 否则其他 Open 可以接管。 + TTL time.Duration } -type AppendRequest struct { - SessionID SessionID; ExpectedHead Head; CommitID CommitID - CausationID es.CausationID; CorrelationID string; Events []UncommittedEvent -} -type AppendDisposition string -const ( - AppendApplied AppendDisposition = "applied" - AppendAlreadyApplied AppendDisposition = "already_applied" - AppendHeadConflict AppendDisposition = "head_conflict" - AppendCommitConflict AppendDisposition = "commit_conflict" - AppendInvalid AppendDisposition = "invalid" -) -type AppendResult struct { Disposition AppendDisposition; Commit *SessionCommit; ActualHead Head } - -// 临界区内的读写视图。所有方法在同一事务内生效。 -type SessionTx interface { +type Writer interface { // kernel 的写者句柄,由 Store.Open 返回 + SessionID() SessionID + Epoch() Epoch Head() Head - LookupCommit(CommitID) (SessionCommit, bool, error) - // Tail 返回 after 之后的 local commits;types 非空时只返回含至少一个匹配 EventType 前缀的完整 commit。 - Tail(after Head, types []EventType) ([]SessionCommit, error) - LoadSnapshot(ProjectionKey, ProjectionVersion uint16) (SnapshotResult, error) - SaveSnapshot(Snapshot) error - ControlGet(ControlNamespace, key string) (ControlEntry, bool, error) - ControlPut(ControlNamespace, key string, value []byte, deadlineUnixMilli int64) error - ControlDelete(ControlNamespace, key string) error -} -type CommitInFn func(SessionTx) (*AppendRequest, error) - -type ReplayCursor struct { After *EventPosition; Token CursorToken } -type ReplayRequest struct { - SessionID SessionID - Types []EventType // 空为全部;非空为 EventType 前缀过滤 - Cursor *ReplayCursor - Limit uint32 + Append(context.Context, Group) ([]SessionEvent, error) + Heartbeat(context.Context) error + Close(context.Context) error } -type ReplayPage struct { Header SessionHeader; Commits []SessionCommit; Next *ReplayCursor; Head Head } - -type SnapshotRequest struct { SessionID SessionID; ProjectionKey ProjectionKey; ProjectionVersion uint16 } -type SnapshotResult struct { Snapshot *Snapshot; Found bool } -type SaveSnapshotRequest struct { Snapshot Snapshot } -type SaveSnapshotResult struct { Snapshot Snapshot; Replaced bool } - -type ControlEntry struct { - SessionID SessionID; Namespace ControlNamespace; Key string - Value []byte - DeadlineUnixMilli int64 // 0 表示无 deadline;kernel 只用于枚举,不据此删除 -} - type Store interface { Create(context.Context, CreateRequest) (SessionHeader, error) Header(context.Context, SessionID) (SessionHeader, error) - Head(context.Context, SessionID) (Head, error) - LookupCommit(context.Context, SessionID, CommitID) (SessionCommit, bool, error) - Commit(context.Context, AppendRequest) (AppendResult, error) - CommitIn(context.Context, SessionID, CommitInFn) (AppendResult, error) - Replay(context.Context, ReplayRequest) (ReplayPage, error) - LoadSnapshot(context.Context, SnapshotRequest) (SnapshotResult, error) - SaveSnapshot(context.Context, SaveSnapshotRequest) (SaveSnapshotResult, error) - // 控制面 KV,临界区之外的读写;与 SessionTx 内的同名方法作用于同一存储。 - ControlGet(context.Context, SessionID, ControlNamespace, key string) (ControlEntry, bool, error) - ControlPut(context.Context, SessionID, ControlNamespace, key string, value []byte, deadlineUnixMilli int64) error - // 条件写:仅当条目存在且当前 Value 逐字节等于 expected 时写入;返回是否写入。 - ControlCompareAndPut(context.Context, SessionID, ControlNamespace, key string, expected, value []byte, deadlineUnixMilli int64) (bool, error) - ControlDelete(context.Context, SessionID, ControlNamespace, key string) error - ControlScan(context.Context, ControlNamespace, keyPrefix string, fn func(ControlEntry) (bool, error)) error // 跨全部 Session,按前缀 - ControlExpired(context.Context, ControlNamespace, beforeUnixMilli int64, fn func(ControlEntry) (bool, error)) error // 跨全部 Session,deadline 非 0 且早于 before + Open(context.Context, SessionID, OpenOptions) (Writer, error) + Read(context.Context, ReadRequest) (ReadPage, error) } ``` -```go -type ErrorCode string -const ( - ErrInvalid ErrorCode = "invalid"; ErrNotFound ErrorCode = "not_found" - ErrConflict ErrorCode = "conflict"; ErrCorrupt ErrorCode = "corrupt" - ErrUnsupportedProfile ErrorCode = "unsupported_profile"; ErrUnsupported ErrorCode = "unsupported" - ErrUnavailable ErrorCode = "unavailable" -) -type Error struct { Code ErrorCode; Operation string; SessionID SessionID; CommitID CommitID; Detail string } -func (Error) Error() string -``` - -**SES-API-1** `Commit` 与 `CommitIn` 是仅有的两个 append port。它们必须原子持久化完整 commit、新 head,以及同一调用内的 snapshot 与控制面 KV 写入。普通 producer 的 capability exposure 由 Session Module Framework 提供(EXT-APP)。 +**SES-OWN-1** 同一 Session 同一时刻至多一个有效 Writer。`Open` 在已有有效 Writer 时返回 `ErrOwned`;有效性由 adapter 的锁机制决定:文件 adapter 用进程内独占加 `flock`,进程死亡即释放;数据库 adapter 用带 deadline 的所有权行,deadline 由 `Heartbeat` 推后,过期后可被接管。 -**SES-API-2** `CommitIn` 是同一 Session 的 append 临界区:Store 在 per-Session 锁或数据库事务内向 fn 提供 `SessionTx`,fn 在其中读 head、按 CommitID 查 commit、读 snapshot 与 tail、读写控制面 KV、决定要追加的事件;返回的 `AppendRequest` 由 Store 在同一事务内按第 5 节规则追加。fn 返回 nil 表示不追加,此时 fn 已执行的 snapshot 与 KV 写入仍提交。section 内 head 不移动,`ExpectedHead` 与 head 不一致只可能是 fn 的实现错误,返回 `Invalid`。fn 必须纯且除 SessionTx 外无副作用;Store 不保证 fn 只被调用一次。 +**SES-OWN-2** 每次成功的 Open 使该 Session 的 `Epoch` 加一并持久化。`Append` 与 `Heartbeat` 携带 Writer 的 Epoch;Store 对落后于当前持久化 Epoch 的调用返回 `ErrOwnershipLost`,不写入任何内容。这是 fencing:被接管的旧写者的迟到写入不可能进入日志。 -`CommitIn` 与 `Commit` 产生的 commit 逐字段等价,同一 CommitID 的幂等与 conflict 判定相同。两者都是 adapter 必须实现的 port:`Commit`(CAS)一次请求完成比对与写入,适用于 Store 实现为远程客户端、不能在持锁期间回调调用方的部署;`CommitIn` 适用于需要在写入前基于当前状态做决定的模块(例如 Run 的 Decide),以及需要把控制面 KV 写入与 commit 放在同一事务的 producer。进程内 adapter 可以用 `CommitIn` 加 head 比对函数实现 `Commit`,但 `Commit` 不因此从合同中移除。 - -**SES-API-3** 控制面 KV 以 `(SessionID, Namespace, Key)` 寻址,值为 opaque bytes 加一个可选 deadline,kernel 不解释值,也不因 deadline 到期而删除或修改条目。它用于需要与 commit 原子写入、但不属于语义事实的运行控制信息:执行占用、保留声明等。kernel 提供且只提供三种模块自己无法提供的保证:`SessionTx` 内的写入与该 commit 同事务;`ControlCompareAndPut` 是临界区之外的原子条件写,条目缺失或当前值不等于 `expected` 时不写入并返回 false;`ControlScan` 与 `ControlExpired` 跨 Session 枚举,前者按 key 前缀,后者按 deadline 早于给定时刻,fn 返回 false 停止。占用、凭证、续期、过期后的处置等语义由使用它的模块或 Module Framework 定义(EXT-SCP-3),不进入 kernel。Namespace 由模块以 `//` 命名。KV 与 stream 在同一存储与事务域,不会单独丢失;它不进入 digest chain,因此不能通过 chain 校验,模块不得把语义事实放入 KV。 - -## 5. append 与 idempotency - -**SES-APP-1** CommitID 的幂等键为 `(SessionID, CommitID)`。append fingerprint 覆盖 SessionID、CommitID、causation、correlation 和有序 UncommittedEvent group 的 `EventID`、`Type`、`SourceEvents`、`Payload`;**不**覆盖 ExpectedHead,也**不**覆盖 `RecordedAtUnixMilli`。时间是 metadata,重试可以携带新的时间戳而得到 `AlreadyApplied`,已提交 commit 中的时间保持首次写入值。 - -```text -append(request): - if existing := lookup(SessionID, CommitID): - if fingerprint(existing) == fingerprint(request): return AlreadyApplied(existing) - return CommitConflict - validate profile, identities, payloads and ExpectedHead - assign next revision and contiguous indexes; calculate event/commit digests - atomically write complete commit, new head, and pending snapshot / KV writes - return Applied(commit) -``` +**SES-OWN-3** 所有权是 Session 级的,不是执行目标级的。一个进程取得 Session 的所有权即拥有其中全部执行;接管者读日志后对所有仍在执行中的目标做一次性处置(RUN-CMT-7)。kernel 不知道"执行中"是什么,这一步由 run 模块在 Writer 上完成。 -**SES-APP-2** CAS 不匹配返回 `HeadConflict` 和 actual head,不得 last-write-wins。任何 invalid envelope、重复 EventID、非 canonical payload 或 digest-chain violation 返回 `Invalid` 或 `Corrupt`;不得部分写入。Store 必须拒绝同一 stream 内的 EventID duplicate。 +**SES-OWN-4** `Read` 不需要所有权,任何进程可以随时读;读到的是完整组构成的前缀(SES-APP-2)。 -## 6. replay +## 5. append -**SES-REP-1** Replay 只返回请求 `SessionID` 自己的 commits,按 `(Revision, Index)` 递增,page 只返回完整 commit。`After=nil` 表示起点,非 nil `After` 表示该精确 digest 的最后已返回 event,续页严格从它之后开始。 +**SES-APP-1** `Append(group)` 原子:整组 event 同时可见或同时不存在。Store 为组内每行赋 `Seq`(从当前 `Head.Next` 起连续)、`Index`、`Last`,计算 `Digest`,持久化,然后返回带完整字段的行。返回即持久(文件 adapter 每次 Append 一次 `fsync`;数据库 adapter 一个事务)。 -**SES-REP-2** `Types` 非空时返回至少含一个匹配 EventType 前缀的完整 commit,跳过其他 commit。过滤 replay 可以验证每个返回 commit 自身的 event digest 与 commit digest,不能验证 revision 连续性与 `PreviousDigest` 链;链完整性只由无过滤 replay 验证。adapter 应维护 `(SessionID, EventType 前缀)` 到 revision 的索引,使过滤 replay 的代价与匹配 commit 数成正比。 +**SES-APP-2** 崩溃只可能留下一个不完整的尾组:文件 adapter 打开时把末尾 `Last=false` 且没有后续行的整组截掉;数据库 adapter 由事务保证不会出现。reader 在任何时刻都不会看到不完整的组。 -**SES-REP-3** 无过滤 replay 必须验证 header、revision gap、previous/commit/event digest、index 和 profile。损坏、缺口或不支持版本必须 fail loudly。cursor 的 `After` 必须是当前请求中存在且 digest 匹配的完整 `EventPosition`。Token 是 adapter 的 opaque continuation,必须绑定 `SessionID`、Types、完整 After 和 Limit,且不得替代这些可验证字段。 +**SES-APP-3** kernel 拒绝:空组、重复 `CommitID`、非 canonical 或非 object 的 payload、无效 identity、落后的 Epoch。拒绝不写入任何内容,返回 `ErrInvalid`(重复 CommitID 为 `ErrConflict`)。kernel 不比对重复 CommitID 的内容,不返回"已应用":幂等重放由 `extension.Writer` 以内存索引完成(EXT-WRT-2)。 -## 7. snapshot +## 6. read ```go -type Snapshot struct { - ProtocolVersion uint16; SessionID SessionID - ProjectionKey ProjectionKey; ProjectionVersion uint16 - Through Head - State jsonstable.Value; SnapshotDigest es.Digest +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-SNP-1** snapshot 是派生 cache,任何时刻可以删除并从 stream 重建。`Through` 是该 snapshot 覆盖到的 head;因为 commit 经 `PreviousDigest` 链式覆盖全部前缀,`Through.Digest` 就是 coverage 的证明,不另设 coverage digest。SnapshotDigest 覆盖全部 snapshot field(自身除外)。 - -**SES-SNP-2** Load 时 `Through` 必须是当前 stream 的前缀(该 revision 的 CommitDigest 等于 `Through.Digest`),否则视为缺失并全量重折。写入频率由 projection 自己的策略决定;kernel 不要求每次 commit 都写 snapshot。同一 `(SessionID, ProjectionKey, ProjectionVersion)` 只保留最新一份。 +**SES-REP-1** `Read` 按 `Seq` 递增返回 `From` 起的行,只返回完整组内的行;`Limit` 截断只发生在组边界。无过滤时 Store 校验每行的 `Digest` 链;有过滤时只校验返回行自身的 digest,链完整性由无过滤读取验证。损坏必须 fail loudly(`ErrCorrupt`)。 -## 8. boundaries 与 conformance +**SES-REP-2** `Types` 过滤是读取代价的优化:文件 adapter 全量扫描后过滤,数据库 adapter 用 `(SessionID, Type 前缀)` 索引。过滤与不过滤读到的事件集合对匹配类型完全一致。 -Session modules 对 opaque payload 作 typed encode/decode 并负责 payload 版本;Store 负责 commit、CommitID、digest、replay、snapshot 与控制面 KV 的机制。unknown event 保留原始 payload 并支持原样 replay。first-party module 为 `chatlog`、`turn` 与 `run`:Run 的执行事实以 `twilight/run/` 事件进入同一 stream,其状态机与投影由 [agent-run.md](agent-run.md) 定义。 +## 7. errors 与 conformance -一条 Session stream 承载多个模块的事件;每个 projection 只消费自己声明的 EventType(EXT-PRJ-2),并以 snapshot 加过滤 tail 的方式读取。读取代价由 projection 消费的事件数与 snapshot 之后的 tail 长度决定,与 stream 总长度无关。 +```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 必须验证: +v1 conformance 以 `Store` 为参数,Memory 与文件 adapter 跑同一套,必须验证: -- **SES-SCP-2**:附录 A 的能力返回 `ErrUnsupported`,`ParentFork` 非 nil 的 header 被拒绝; -- **SES-VER-1、SES-VER-2**:kernel 不读取 payload `v`;不同 `v` 的 event 在同一 stream 共存; -- **SES-WIR-1、SES-WIR-2、SES-WIR-3**:wire/profile freeze、版本一致性、所有 Encode/Decode 与 `ValidateCanonical*` round-trip、digest preimage、same-stream SourceEvents、EventID uniqueness、complete commit; -- **SES-API-1、SES-API-2、SES-API-3、SES-APP-1、SES-APP-2**:atomic CAS、`CommitIn` 与 `Commit` 的 commit 等价、fn 返回 nil 时 KV 与 snapshot 仍提交、concurrent writer、CommitID exact idempotency(含不同时间戳的重试得到 AlreadyApplied)、failure classification、KV 与 commit 同事务、`ControlCompareAndPut` 在条目被并发删除或改写后返回 false、`ControlScan` 前缀与提前停止、`ControlExpired` 只返回 deadline 非 0 且已过的条目; -- **SES-REP-1、SES-REP-2、SES-REP-3**:顺序、完整 EventPosition After 语义、Types 过滤只返回匹配 commit、cursor/token binding、tamper/gap failure; -- **SES-SNP-1、SES-SNP-2**:Through 前缀校验、snapshot 加 tail 与全量 fold 等价、过期 snapshot 被忽略。 +- **SES-WIR-1/2/3**:Seq 连续、组内 Index/Last、CommitID 唯一、payload canonical、digest 链与 header 根、版本一致; +- **SES-OWN-1/2**:第二个 Open 返回 `ErrOwned`;Close 后可再 Open 且 Epoch 加一;旧 Writer 的 Append 与 Heartbeat 返回 `ErrOwnershipLost` 且不写入;TTL 过期后接管; +- **SES-APP-1/2/3**:整组可见性;在组中途注入崩溃后打开,尾组不出现;拒绝项无写入; +- **SES-REP-1/2**:顺序、From、Limit 在组边界截断、过滤与全量对匹配类型一致、篡改任一行后无过滤读取报 `ErrCorrupt`; +- **SES-SCP-3**:附录 A 入口返回 `ErrUnsupported`,`ParentFork` 非 nil 的 header 被拒绝。 -第一版包含 MemoryStore 与上述 Store conformance;不承诺特定 durable adapter。 +第一版实现为 MemoryStore 与文件 adapter(一个 Session 一个目录,`stream.jsonl` 一行一个 event,`session.lock` 为 `flock` 目标)。 ## 附录 A:预留能力(不进入 v1) -以下能力保留设计,供后续版本在不升 kernel `ProtocolVersion` 的前提下加入。v1 实现对这些入口返回 `ErrUnsupported`。 - -**Fork。** `ForkPoint{ParentSessionID, Revision, HeadDigest}`;`Fork(ForkRequest)` 创建 immutable child header,`ParentFork` 精确绑定 parent 已验证 boundary,child local revision 从 1 开始。同 ForkID 逐字段相同的请求幂等,其他为 conflict。保留 child 时必须保留 ancestor header 与 boundary 内 commit(RetainClosure)。目前没有规范内的消费者:subagent 使用独立 Session。 - -**Resolved replay 与 AncestryDigest。** 有 Fork 后 replay 分 Local 与 Resolved 两种模式;Resolved 按 root-to-target segment order 返回。`AncestryDigest` 的 preimage 为 profile、target SessionID、root 到 target 的有序 HeaderDigest、每条 ForkPoint、每级可见 CommitDigest 与 target local Head。cursor 与 snapshot 绑定该 digest。v1 只有一个 segment,`EventPosition` 不携带 origin SessionID;加入 Fork 时以 optional 字段扩展,旧 reader 可忽略。 +**Fork。** `ForkPoint{ParentSessionID, Seq, Digest}`;子 Session 复制父的前缀作为 seed,header 记 `ParentFork`,seed 之后第一行的 prev digest 为 `ForkPoint.Digest`。目前没有规范内的消费者:subagent 使用独立 Session。 -**Canonical import 与 ancestry archive。** `ImportCanonical` 只处理 Session records,不解释 payload,经 `ValidateCanonical*` 验证完整 chain 后导入完整 stream 或已有可验证 predecessor 的 contiguous tail。`ImmutableAncestryArchive` 的 preimage 为 ProtocolVersion、按 segment order 的 Headers 与 Commits。同 `(SessionID, CommitID)` 仅在 canonical commit 逐字段相同才幂等。 +**Canonical import。** 按行校验 digest 链后导入完整日志或已有可验证前缀的连续尾部;同 `(SessionID, Seq)` 仅在行逐字节相同时幂等。 -**Snapshot coverage 与 ancestry。** 有 Fork 后 snapshot 的 `Through` 扩展为 `ResolvedHead{SessionID, LocalHead, AncestryDigest}`,同 local head 而 ancestry 不同不等价。 +**投影缓存与 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 index 8810d78..a30a4b6 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -1,6 +1,6 @@ # Twilight Agent Turn 协议 -状态:设计草案。`agent/turn` 已按本文重写(Coordinator 的 Start / Deliver / Resume / Retry / Stop / Settle、CompanionV1、surface 投影);第 8 节 conformance 尚未完整实现,当前由 `agent/ref` 的测试覆盖 Start、Deliver、Stop 与新 Turn 的开启。Run 事实与 Turn、Chatlog 事件同在一条 Session stream。 +状态:设计草案,第二版(2026-09-08)。`agent/turn` 已按第一版实现(Coordinator 的 Start / Deliver / Resume / Retry / Stop / Settle、CompanionV1、surface 投影),写入经 `SemanticAppender`;本版随 [agent-session.md](agent-session.md) 与 [agent-session-extension.md](agent-session-extension.md) 第二版改为经 `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)。 @@ -32,7 +32,7 @@ subagent 使用独立 Session 与独立 Turn。 **TRN-SCP-3** Coordinator 没有隐藏状态。它从 `twilight/turn/surface` 投影与 `twilight/run/machine` 投影重建。 -**TRN-SCP-4** Turn 自己的写入经 `extension.SemanticAppender`;Run 事实的写入经 `run.Runtime`,后者同样经 `SemanticAppender.AppendSemanticIn` 落在同一 `session.Store`(EXT-SCP-1)。Artifact 由其 owner 管理。 +**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 决策与并发。Coordinator 按 persisted binding 解析 driver。参考 Planner 每次 Plan 使用 Binding 的 `ModelRef`。 @@ -95,11 +95,11 @@ twilight/turn/superseded unsettled Turn 是尚未 completed、failed 或 superseded 的 `started`。 -**TRN-EVT-2** 本模块产生的事件(含 companion 与 Attach 产生的)的 EventID 由 Appender 按 `Digest(EventType, CommitID, index)` 统一赋值(EXT-APP-5)。Start 的 CommitID 由 StartOperationDigest 派生;Retry、Settle、Stop 的 CommitID 见各自条目。相同 canonical payload 幂等;差异为 conflict。事件时间戳不参与幂等判定(SES-APP-1)。 +**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`,`RequireComplete` 为 `turn` 与 `run`(EXT-PRJ-2): +**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 @@ -138,8 +138,7 @@ UI 按 `TurnID` 连接 `twilight/chatlog/surface` 的条目,按 `RunID` 连接 ```go type Coordinator struct { - Projections extension.ProjectionReader - Appender extension.SemanticAppender + Writers extension.Writers // 每个方法按 Ref.SessionID 取 Writer:写入经 Commit,读取经 Projections() Runtime run.Runtime Bindings ExecutionBindingRegistry } @@ -183,11 +182,11 @@ const ( ) ``` -**TRN-API-1** Coordinator 经 `ProjectionReader` 读取 `twilight/turn/surface` 与 `twilight/run/machine` 两个投影接续;每个方法先读投影再决定动作。Coordinator 不持有 `session.Store`。 +**TRN-API-1** Coordinator 经 Writer 的 `Projections()` 读取 `twilight/turn/surface` 与 `twilight/run/machine` 两个投影(EXT-PRJ-4);每个方法先读投影再决定动作。Coordinator 不持有 `session.Store`。 **TRN-API-2** Registry 用同一 `run.Runtime` 组装 driver。Run 的写入只经 `run.Runtime`。 -**TRN-API-3** DTO 为值语义。`Waiting` 为 `twilight/run/machine` 的 `WaitingCalls`。`NeedsRecovery` 为 true 时返回 `ResumeWaitingForRecovery`;Application 调用 `Runtime.RecoverExpired` 后再 Resume。 +**TRN-API-3** DTO 为值语义。`Waiting` 为 `twilight/run/machine` 的 `WaitingCalls`。`NeedsRecovery` 为 true 时返回 `ResumeWaitingForRecovery`;这只出现在接管处置之前,宿主调用 `Runtime.RecoverInterrupted`(RUN-CMT-7)后再 Resume。 **TRN-API-4** `twilight/turn/superseded` 由 Application 追加。Coordinator 的方法不写该事件。superseded 的 Turn 若仍有非终态 Run,Application 必须先 Stop。 @@ -215,7 +214,7 @@ 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,再 `AppendSemantic`。相同 identity 为 applied / already-applied。head conflict 时 CAS rebase,identity 与 payload 保持不变。 +**TRN-STR-3** 派生 PlanDigest、StartOperationDigest、RunID 与 group identity,再经 `Writer.Commit` 写入一组。相同 identity 为 applied / already-applied;Writer 串行执行全部写入,不存在 head conflict。 **TRN-STR-4** append 成功后进入 Drive。 @@ -231,7 +230,7 @@ InputIDs 为空时 group 为 `started` 加 `created`。`created` 与 `input_acce **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。提交后,若本进程没有在驱动该 Run,Deliver 进入 Drive;已在驱动时不动,运行中的 Loop 在下一次 Load 看到 `PendingInputs`。Deliver 与该 Run 的最后一步 `SubmitModelResult` 并发时由 Session 临界区定序:输入先提交,Run 回到 `Open` 继续;结果先提交,Run 已终结,Deliver 得到 `ErrRunTerminal` 并返回 `completed`,该输入未被 delivered。 +**TRN-DLV-3** Deliver 不取消正在进行的模型调用或工具调用;要打断用 Stop。提交后,若本进程没有在驱动该 Run,Deliver 进入 Drive;已在驱动时不动,运行中的 Loop 在下一次 Load 看到 `PendingInputs`。Deliver 与该 Run 的最后一步 `SubmitModelResult` 并发时由 Writer 串行定序:输入先提交,Run 回到 `Open` 继续;结果先提交,Run 已终结,Deliver 得到 `ErrRunTerminal` 并返回 `completed`,该输入未被 delivered。 **TRN-DRV-1** Drive 解析 binding 得到 driver,调用 `driver.Drive(ctx, {Ref, RunID})`。driver 内部为 `loop.Run(ctx, runtime, SessionID, RunID, sink)`。Drive 返回后读投影设置 `Disposition` 与 `End`:Run 终态为 `ResumeFinished`,`End` 取 surface 中该 attempt 的 `AttemptView.End`;`NeedsRecovery` 为 true 为 `ResumeWaitingForRecovery`;仅有 WaitingCalls 为 `ResumeWaitingForResponse`。 @@ -247,9 +246,9 @@ InputIDs 为空时 group 为 `started` 加 `created`。`created` 与 `input_acce ## 6. companion:Run 事实到对话内容 -Run 事实只保存执行状态与内容 digest(RUN-WIR-4)。模型文本、工具调用与工具输出以 chatlog 事件形式与产生它们的 Run 事实写在同一 SessionCommit。`run.Runtime.Commit` 在 Decide 之后、写入之前调用注入的 `run.Companion`,把本 commit 的 facts 与 command 携带的 transient 内容映射为 `run.ModuleEvent`,追加在 Run facts 之后;随后整个 group 经 `SemanticAppender` 的 codec、Binding admission 与 claim 写入(EXT-APP-3)。接口定义在 `agent/run`(第 5 节);本模块提供实现 `CompanionV1`,它把 `CompanionRequest.Owner` 解释为 TurnID。 +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`。输出事件不携带 EventID,由 Appender 按所在 commit 的 CommitID 与位置赋值(EXT-APP-5);条目自身的 identity(AssistantID、ToolResultID)按 TRN-MAP-2 派生。同一 commit 重放得到同一 group。companion 事件可以携带 `ReferencePart`;其 Binding 由 Appender 在同一事务 admission 并建立 claim,Runtime 不另行处理。 +**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 映射: @@ -277,17 +276,18 @@ Run 事实只保存执行状态与内容 digest(RUN-WIR-4)。模型文本、 | 情形 | 动作 | |---|---| -| `started` 已提交、进程在 Drive 前退出 | Resume | -| Loop 提交响应丢失 | Loop 以 ClaimStore 中的 Claim 重放(RUN-LOP-3);Runtime 按 `(SessionID, CommitID)` 幂等 | -| 模型 Executing、lease 过期 | `RecoverExpired` 提交 `RecoverModelExecution`;Run 保持 Active,同一 RunID 以同一冻结请求继续 | -| 工具效果未知 | lease 过期后 `RecoverExpired` 提交该 call 的 Unknown,companion 写 status=`unknown`;Run 保持 Active | +| `started` 已提交、进程在 Drive 前退出 | 新 owner 的 `RecoverInterrupted` 无事可做(Run 在 Open);Resume | +| 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 响应丢失 | 以同一 Cancel CommandID 重放 | -| Deliver 中某条输入的 commit 响应丢失 | 以同一 input CommandID 重放,得到 already-applied 后继续剩余条目 | -| Start 或 Retry 响应丢失 | 以同一 CommitID 重放,得到 already-applied | +| Stop 的 Commit 返回非 sentinel 错误 | 以同一 Cancel CommandID 重放 | +| Deliver 中某条输入的 Commit 返回非 sentinel 错误 | 以同一 input CommandID 重放,得到 already-applied 后继续剩余条目 | +| Start 或 Retry 的 Commit 返回非 sentinel 错误 | 以同一 CommitID 重放,得到 already-applied | | binding 缺失 | 返回 `binding_unavailable`;Turn 状态不变 | -**TRN-REC-3** 没有跨存储的对账:Run 事实、companion 内容、claim 与 Turn 结算在同一事务,要么全部可见要么全部不可见。 +**TRN-REC-3** 没有跨存储的对账:Run 事实、companion 内容与 Turn 结算在同一组,`Append` 原子,要么全部可见要么全部不可见。claim 在 Append 之前建立,崩溃只可能留下孤儿 claim,由 artifact 的回收前核对释放(EXT-WRT-3、ART-RET-3)。 ## 8. conformance @@ -297,5 +297,5 @@ Run 事实只保存执行状态与内容 digest(RUN-WIR-4)。模型文本、 - **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-DRV-1 至 TRN-STL-1**:Drive disposition、Stop 以 Attach 单 commit 结算、Application 的 Cancel 进入 `attempt_failed`、Settle 前置条件; -- **TRN-CMP-1 至 TRN-MAP-4**:companion 纯函数、v1 映射表、`SourceDigest` 等于 Run fact 记录值、companion 中的 ReferencePart 经 admission 并建立 claim、同 commit 可见性; -- **TRN-REC-1 至 TRN-REC-3**:上表恢复情形、崩溃后 Resume、无跨存储对账。 +- **TRN-CMP-1 至 TRN-MAP-4**:companion 纯函数、v1 映射表、`SourceDigest` 等于 Run fact 记录值、companion 中的 ReferencePart 经 admission 并建立 claim、同组可见性; +- **TRN-REC-1 至 TRN-REC-3**:上表恢复情形、新 Writer 接管后 `RecoverInterrupted` 再 Resume、`ErrOwnershipLost` 后本进程放弃、无跨存储对账。 From 426801badcaee675cabd8181e32d24189c4bcb3b Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 8 Sep 2026 21:57:14 +0800 Subject: [PATCH 70/81] Rebuild the agent stack on the edition-2 kernel Implement agent-runtime-refactor.md section 8 across the whole stack: - session: kernel shrinks to Create/Header/Open/Append/Read; one row per event (Seq, CommitID, Index/Last, per-row chained digest); Session ownership with Epoch fencing and TTL takeover; conformance suite in sessiontest, Store-parameterized. - extension: Writer is the sole write entry (serial commits, in-memory idempotency index rebuilt on open, claim before Append, projections folded in memory, ErrOwnershipLost fails closed); Writers registry; optional ProjectionCache; Ignorable replaces RequireComplete. - artifact: self-persisting MemoryLedger.Activate, OwnerVerifier and Reconcile release orphan claims before collection. - run/session-run/loop: RunPosition = Seq; no lease, grant, RenewLease, RecoverExpired or ClaimStore; RecoverInterrupted disposes every Executing target under TakeoverClaim(SessionID, Epoch); settlements fence with ErrOwnershipLost; CommitResult carries the sealed rows. - turn/ref: Coordinator commits through Writers; Memory assembly opens ownership per Session (Memory.Open = take over + dispose); the crash example now shows Epoch fencing instead of lease expiry. - RUN-CMP-2 conformance rebuilt in runtimetest (13 subtests incl. Takeover and OwnershipLost); all 9 agent test packages pass. --- agent/artifact/artifact.go | 160 +++-- agent/ref/assembly.go | 91 ++- agent/ref/example_test.go | 76 ++- agent/ref/planner.go | 2 +- agent/ref/turn_test.go | 2 +- agent/run/canonical_test.go | 10 +- agent/run/commit.go | 214 ++---- agent/run/ids.go | 22 +- agent/run/loop/attempt.go | 74 +++ agent/run/loop/bound.go | 4 - agent/run/loop/cache.go | 214 ------ agent/run/loop/contract.go | 20 +- agent/run/loop/events.go | 7 +- agent/run/loop/helpers_test.go | 47 +- agent/run/loop/lease.go | 59 -- agent/run/loop/lease_test.go | 160 ----- agent/run/loop/loop.go | 44 +- agent/run/loop/loop_test.go | 22 +- agent/run/loop/model.go | 45 +- agent/run/loop/takeover_test.go | 94 +++ agent/run/loop/tool.go | 86 +-- agent/run/machine_test.go | 2 +- agent/run/recover.go | 36 - agent/run/runtest/feature.go | 42 +- agent/run/runtime.go | 60 +- agent/session/chatlog/chatlog_test.go | 2 +- agent/session/chatlog/projection.go | 22 +- agent/session/extension/appender.go | 383 ----------- agent/session/extension/binding.go | 50 +- agent/session/extension/extension_test.go | 374 ++++++++--- agent/session/extension/lease.go | 168 ----- agent/session/extension/projection.go | 337 +++++----- agent/session/extension/registry.go | 109 +-- agent/session/extension/writer.go | 508 ++++++++++++++ agent/session/memory.go | 661 +++++-------------- agent/session/memory_test.go | 153 +---- agent/session/profile.go | 182 +---- agent/session/run/module.go | 2 - agent/session/run/projection.go | 9 +- agent/session/run/runtime.go | 518 +++++---------- agent/session/run/runtimetest/conformance.go | 482 +++++++------- agent/session/run/runtimetest/harness.go | 156 +++-- agent/session/run/runtimetest/memory_test.go | 7 +- agent/session/sessiontest/conformance.go | 294 +++++++++ agent/session/store.go | 148 ++--- agent/session/types.go | 127 ++-- agent/turn/coordinator.go | 93 ++- agent/turn/projection.go | 1 - docs/design/agent-artifact.md | 2 +- docs/design/agent-run.md | 2 +- docs/design/agent-runtime-refactor.md | 4 +- docs/design/agent-session-chatlog.md | 2 +- docs/design/agent-session-extension.md | 13 +- docs/design/agent-session.md | 2 +- docs/design/agent-turn.md | 2 +- 55 files changed, 2755 insertions(+), 3651 deletions(-) create mode 100644 agent/run/loop/attempt.go delete mode 100644 agent/run/loop/cache.go delete mode 100644 agent/run/loop/lease.go delete mode 100644 agent/run/loop/lease_test.go create mode 100644 agent/run/loop/takeover_test.go delete mode 100644 agent/run/recover.go delete mode 100644 agent/session/extension/appender.go delete mode 100644 agent/session/extension/lease.go create mode 100644 agent/session/extension/writer.go create mode 100644 agent/session/sessiontest/conformance.go diff --git a/agent/artifact/artifact.go b/agent/artifact/artifact.go index 1c8c151..a312351 100644 --- a/agent/artifact/artifact.go +++ b/agent/artifact/artifact.go @@ -1,19 +1,17 @@ -// Package artifact is the Artifact Core (docs/design/agent-artifact.md): Ref, -// Binding and the two-state RetentionLedger. v1 keeps the Memory reference -// implementation only; claims are activated inside the host's transaction -// through ClaimKV. +// Package artifact is the Artifact Core (docs/design/agent-artifact.md, +// edition 2): 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" - "encoding/json" "errors" "fmt" "sort" "sync" "github.com/memohai/twilight/agent/es" - "github.com/memohai/twilight/agent/jsonstable" ) type ( @@ -204,6 +202,12 @@ type ClaimOwner struct { 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 ( @@ -275,94 +279,128 @@ func SortedUniqueBindingIDs(ids []BindingID) []BindingID { return out[:n] } -// ClaimKV is the host's same-transaction KV view. In Session deployments it -// adapts session.SessionTx's control-plane KV (namespace twilight/artifact/claim). -type ClaimKV interface { - Get(key string) ([]byte, bool, error) - Put(key string, value []byte) error - Delete(key string) error -} - -// RetentionLedger keeps Active/Released claims (ART-RET-2). +// RetentionLedger keeps Active/Released claims (ART-RET-2). It persists +// itself; Activate returns only once the claim is durable. type RetentionLedger interface { - ActivateIn(kv ClaimKV, id ClaimID, owner ClaimOwner, set BindingSet) (RetentionClaim, error) + 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 } -// KVLedger stores claims as canonical JSON under the ClaimID key. Reads and -// releases outside a transaction go through the ClaimKVProvider the host -// supplies (for Session: a store-backed adapter). -type KVLedger struct { +// MemoryLedger is the in-process RetentionLedger. Builder, when set, rebuilds +// and verifies every incoming set (ART-RET-1). +type MemoryLedger struct { Builder BindingSetBuilder - // Outside is the non-transactional KV view for LookupClaim/ReleaseActive. - Outside func(context.Context) ClaimKV + mu sync.Mutex + claims map[ClaimID]RetentionClaim } -func (l KVLedger) ActivateIn(kv ClaimKV, id ClaimID, owner ClaimOwner, set BindingSet) (RetentionClaim, error) { +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"} } - claim := RetentionClaim{ID: id, Owner: owner, BindingSet: set, State: ClaimActive} - raw, ok, err := kv.Get(string(id)) - if err != nil { - return RetentionClaim{}, err - } - if ok { - var existing RetentionClaim - if err := json.Unmarshal(raw, &existing); err != nil { - return RetentionClaim{}, &Error{Code: ErrCorrupt, Operation: "activate", Identity: string(id), Detail: err.Error()} + 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 } - encoded, err := jsonstable.MarshalCanonical(claim) - if err != nil { - return RetentionClaim{}, err - } - if err := kv.Put(string(id), encoded); err != nil { - return RetentionClaim{}, err - } + claim := RetentionClaim{ID: id, Owner: owner, BindingSet: set, State: ClaimActive} + l.claims[id] = claim return claim, nil } -func (l KVLedger) LookupClaim(ctx context.Context, id ClaimID) (RetentionClaim, bool, error) { - if l.Outside == nil { - return RetentionClaim{}, false, errors.New("artifact: ledger: no outside KV") - } - raw, ok, err := l.Outside(ctx).Get(string(id)) - if err != nil || !ok { +func (l *MemoryLedger) LookupClaim(ctx context.Context, id ClaimID) (RetentionClaim, bool, error) { + if err := ctx.Err(); err != nil { return RetentionClaim{}, false, err } - var claim RetentionClaim - if err := json.Unmarshal(raw, &claim); err != nil { - return RetentionClaim{}, false, &Error{Code: ErrCorrupt, Operation: "lookup_claim", Identity: string(id), Detail: err.Error()} - } - return claim, true, nil + l.mu.Lock() + defer l.mu.Unlock() + c, ok := l.claims[id] + return c, ok, nil } -func (l KVLedger) ReleaseActive(ctx context.Context, id ClaimID) error { - claim, ok, err := l.LookupClaim(ctx, id) - if err != 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)} } - if claim.State == ClaimReleased { - return nil - } - claim.State = ClaimReleased - encoded, err := jsonstable.MarshalCanonical(claim) - if err != nil { - return err + 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) + } } - return l.Outside(ctx).Put(string(id), encoded) + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil } func sameSet(a, b BindingSet) bool { diff --git a/agent/ref/assembly.go b/agent/ref/assembly.go index 3dbea76..9433a34 100644 --- a/agent/ref/assembly.go +++ b/agent/ref/assembly.go @@ -18,95 +18,122 @@ import ( // Options tunes the Memory assembly. type Options struct { - // LeaseTTL zero means execution leases never expire. - LeaseTTL time.Duration - Now func() time.Time + // Ownership configures the Session Writer: TTL zero is process-lifetime + // ownership, non-zero lets another assembly take over after the TTL. + 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 } -// Memory is the fully wired in-process agent (REF 5). +// 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 - Projections extension.ProjectionReader + Writers extension.Writers Bindings *Bindings - Appender extension.SemanticAppender Runtime *runmod.Runtime Coordinator *turn.Coordinator BindingStore *artifact.MemoryBindingStore + Ledger artifact.RetentionLedger now func() time.Time } -// New assembles store, registry, appender, projections, runtime and -// coordinator over the three first-party modules. +// 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() } - registry, err := extension.BuildRegistry(session.ProfileV1(), chatlog.Module, runmod.Module, turn.Module) + registry, err := extension.BuildRegistry(session.ProtocolVersion1, chatlog.Module, runmod.Module, turn.Module) if err != nil { return nil, err } - bindings := artifact.NewMemoryBindingStore() - ledger := artifact.KVLedger{Builder: artifact.SetBuilder{Resolver: bindings}} - appender, err := extension.NewSemanticAppender(store, registry, artifact.SetBuilder{Resolver: bindings}, ledger) - 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}) } - projections := extension.NewProjectionReader(store, registry) + 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{ - Store: store, Registry: registry, Appender: appender, Projections: projections, - Frozen: opts.Frozen, Companion: turn.CompanionV1{}, LeaseTTL: opts.LeaseTTL, Now: now, + Writers: writers, Registry: registry, Store: store, + Frozen: opts.Frozen, Companion: turn.CompanionV1{}, Cache: extension.NewMemoryProjectionCache(), Now: now, }) if err != nil { return nil, err } - m := &Memory{Store: store, Registry: registry, Projections: projections, Appender: appender, Runtime: runtime, BindingStore: bindings, now: now} - m.Bindings = NewBindings(runtime, projections, opts.Sink) - m.Coordinator = &turn.Coordinator{Projections: projections, Appender: appender, Runtime: runtime, Bindings: m.Bindings, Now: now} + m := &Memory{Store: store, Registry: registry, Writers: writers, Runtime: runtime, BindingStore: bindings, Ledger: ledger, now: now} + m.Bindings = NewBindings(runtime, writersProjections{writers}, opts.Sink) + m.Coordinator = &turn.Coordinator{Writers: writers, Runtime: runtime, Bindings: m.Bindings, Now: now} return m, nil } +// 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}) + _, err := m.Store.Create(ctx, session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: sid, CreatedAtUnixMilli: m.now().UnixMilli()}) return err } +// Open takes ownership of the Session and runs the takeover disposition +// (REF-DRV-4, 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) - head, err := m.Store.Head(ctx, sid) + w, err := m.Writers.Writer(ctx, sid) if err != nil { return run.AgentInput{}, err } - res, err := m.Appender.AppendSemantic(ctx, extension.SemanticAppendRequest{ - SessionID: sid, ExpectedHead: head, - Group: extension.SemanticGroup{CommitID: session.CommitID("input-submitted/" + string(id)), Events: []extension.TypedEvent{{ + 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.SemanticApplied, extension.SemanticAlreadyApplied: + case extension.CommitApplied, extension.CommitAlreadyApplied: return run.AgentInput{ID: id, Payload: content}, nil - case extension.SemanticHeadConflict: - // Another writer moved the head; the caller retries with a fresh head. - return m.SubmitInput(ctx, sid, id, text) default: return run.AgentInput{}, fmt.Errorf("ref: submit input: %s: %s", res.Outcome, res.Detail) } @@ -114,7 +141,7 @@ func (m *Memory) SubmitInput(ctx context.Context, sid session.SessionID, id run. // ChatlogSurface reads the chatlog surface projection. func (m *Memory) ChatlogSurface(ctx context.Context, sid session.SessionID) (chatlog.Surface, error) { - state, _, err := m.Projections.Load(ctx, sid, chatlog.SurfaceProjectionID, chatlog.SurfaceProjection.Version) + state, _, err := writersProjections{m.Writers}.Load(ctx, sid, chatlog.SurfaceProjectionID, chatlog.SurfaceProjection.Version) if err != nil { return chatlog.Surface{}, err } @@ -123,7 +150,7 @@ func (m *Memory) ChatlogSurface(ctx context.Context, sid session.SessionID) (cha // TurnSurface reads the turn surface projection. func (m *Memory) TurnSurface(ctx context.Context, sid session.SessionID) (turn.TurnSurface, error) { - state, _, err := m.Projections.Load(ctx, sid, turn.SurfaceProjectionID, turn.SurfaceProjection.Version) + state, _, err := writersProjections{m.Writers}.Load(ctx, sid, turn.SurfaceProjectionID, turn.SurfaceProjection.Version) if err != nil { return turn.TurnSurface{}, err } diff --git a/agent/ref/example_test.go b/agent/ref/example_test.go index 7973d28..80a5aa0 100644 --- a/agent/ref/example_test.go +++ b/agent/ref/example_test.go @@ -19,35 +19,39 @@ import ( // Example_recoverableTurn drives one Turn through a process crash on the // single Session stream. // -// Process 1 creates the Session, 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 with a live lease. Nothing is written on the way down. +// 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. The lease has expired, so -// RecoverExpired settles the abandoned call as Unknown in the same commit 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 turn surface and the chatlog then agree on what -// happened without any cross-store reconciliation. +// Process 2 reopens the same Session store after the ownership TTL passed 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" - const leaseTTL = 30 * time.Second + const ownership = 30 * time.Second clock := &fakeClock{now: time.Unix(1_000_000, 0)} // Shared "durable" state: the Session store and the frozen request bodies. - store := session.NewMemoryStore() + store := session.NewMemoryStoreWithClock(clock.Now) frozen := run.NewMemoryFrozenValues() tool := &lookupTool{block: make(chan struct{})} // ---- process 1 ---------------------------------------------------------- - p1, err := ref.New(ref.Options{Store: store, Frozen: frozen, LeaseTTL: leaseTTL, Now: clock.Now}) + p1, err := ref.New(ref.Options{Store: store, Frozen: frozen, Ownership: session.OpenOptions{TTL: ownership}, 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) + } binding1, err := p1.Bindings.Register("weather-agent", newBinding(tool)) if err != nil { panic(err) @@ -67,8 +71,8 @@ func Example_recoverableTurn() { fmt.Println("process 1: tool call is Executing; process crashes") // ---- process 2 ---------------------------------------------------------- - clock.Advance(2 * leaseTTL) - p2, err := ref.New(ref.Options{Store: store, Frozen: frozen, LeaseTTL: leaseTTL, Now: clock.Now}) + clock.Advance(2 * ownership) + p2, err := ref.New(ref.Options{Store: store, Frozen: frozen, Ownership: session.OpenOptions{TTL: ownership}, Now: clock.Now}) if err != nil { panic(err) } @@ -77,13 +81,7 @@ func Example_recoverableTurn() { if _, err := p2.Bindings.Register("weather-agent", newBinding(tool)); err != nil { panic(err) } - snap, err := p2.Runtime.Load(ctx, sid, runID) - if err != nil { - panic(err) - } - fmt.Printf("process 2: run active = %v, needs recovery = %v\n", snap.State.Status == run.RunActive, run.NeedsRecovery(snap.State)) - - recovered, err := p2.Runtime.RecoverExpired(ctx) + recovered, err := p2.Open(ctx, sid) if err != nil { panic(err) } @@ -91,7 +89,7 @@ func Example_recoverableTurn() { if err != nil { panic(err) } - fmt.Printf("recovered %d lease; chatlog has %d tool_result(s) with status %s\n", recovered, len(chat.ToolResults), toolResultStatus(&chat)) + 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.Coordinator.Resume(ctx, turn.TurnRequest{Ref: ref1}) if err != nil { @@ -106,17 +104,38 @@ func Example_recoverableTurn() { 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 rejected because the - // lease it held was released by recovery. + // Let the abandoned worker exit; its settlement is fenced because process + // 1's Epoch was superseded. close(tool.block) - <-startDone + 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: run active = true, needs recovery = true - // recovered 1 lease; chatlog has 1 tool_result(s) with status unknown + // 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 { @@ -154,7 +173,6 @@ func newBinding(tool *lookupTool) ref.Binding { Public: ref.BindingPublic{Model: "m-1", Tools: []ref.PublicTool{{Ref: tool.Ref(), Definition: def, Policy: run.DirectExecution}}}, Models: modelCatalog{&scriptedModel{}}, Tools: toolCatalog{tool}, - Policy: loop.ExecutionPolicy{LeaseRenewInterval: 5 * time.Second}, } } @@ -218,7 +236,7 @@ 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.ToolExecutionUnknown{Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: "process died"}} + return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} } return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} } diff --git a/agent/ref/planner.go b/agent/ref/planner.go index 634a957..669f6e6 100644 --- a/agent/ref/planner.go +++ b/agent/ref/planner.go @@ -58,7 +58,7 @@ func (p *ContextPlanner) Plan(ctx context.Context, hint run.PlanningHint) (loop. Model: p.Public.Model, Request: sdk.Request{Model: string(p.Public.Model), Messages: msgs, Tools: defs}, InputIDs: ids, - PlanningToken: run.PlanningToken(fmt.Sprintf("%d:%s", head.Revision, head.Digest)), + PlanningToken: run.PlanningToken(fmt.Sprintf("%d:%s", head.Next, head.Digest)), Tools: specs, }, nil } diff --git a/agent/ref/turn_test.go b/agent/ref/turn_test.go index d4166a3..92a8d15 100644 --- a/agent/ref/turn_test.go +++ b/agent/ref/turn_test.go @@ -61,7 +61,7 @@ func toolCallAnswer() sdk.ModelResult { func setup(t *testing.T, model loop.ModelInvoker, tool *gateTool) (*ref.Memory, turn.ExecutionBindingRef, session.SessionID) { t.Helper() - m, err := ref.New(ref.Options{LeaseTTL: time.Hour}) + m, err := ref.New(ref.Options{}) if err != nil { t.Fatal(err) } diff --git a/agent/run/canonical_test.go b/agent/run/canonical_test.go index 6582a3f..3ca501d 100644 --- a/agent/run/canonical_test.go +++ b/agent/run/canonical_test.go @@ -136,18 +136,18 @@ func TestDigestCommandIdentity(t *testing.T) { func TestDeriveStability(t *testing.T) { // Fixed inputs must produce fixed outputs across processes; freeze a few. - id1 := DeriveModelRequestCommandID("run-1", RunPosition{Revision: 7}) - id2 := DeriveModelRequestCommandID("run-1", RunPosition{Revision: 7}) + id1 := DeriveModelRequestCommandID("run-1", 7) + id2 := DeriveModelRequestCommandID("run-1", 7) if id1 != id2 { t.Fatal("derive is not deterministic") } - if id1 == DeriveModelRequestCommandID("run-1", RunPosition{Revision: 8}) { + if id1 == DeriveModelRequestCommandID("run-1", 8) { t.Fatal("revision does not separate command IDs") } - if id1 == DeriveModelRequestCommandID("run-1", RunPosition{Revision: 7, Index: 1}) { + if id1 == DeriveModelRequestCommandID("run-1", 70) { t.Fatal("index does not separate command IDs") } - if id1 == DeriveModelRequestCommandID("run-2", RunPosition{Revision: 7}) { + if id1 == DeriveModelRequestCommandID("run-2", 7) { t.Fatal("run does not separate command IDs") } // Namespaces must not collide even with aligned parts. diff --git a/agent/run/commit.go b/agent/run/commit.go index d4932bf..49b015a 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -16,7 +16,7 @@ const ( // CommitDecision is EvaluateCommit's verdict. The Runtime maps rejections // onto the sentinel errors: Conflict -> ErrCommandConflict, Stale -> -// ErrStaleRuntime, Terminal -> ErrRunTerminal (RUN-CMT-7). +// ErrStaleRuntime, Terminal -> ErrRunTerminal. type CommitDecision struct { Kind DecisionKind NewState MachineState @@ -25,69 +25,6 @@ type CommitDecision struct { Reject error } -// commandCategory classifies a command for Base handling (RUN-CMT-4). -// PrepareModelRequest is the only hard-CAS command. -type commandCategory uint8 - -const ( - catPlan commandCategory = iota - catStart - catOwnerSettle - catIngress - catRunControl - catRecovery -) - -func categorize(c AgentCommand) commandCategory { - switch cmd := c.(type) { - case PrepareModelRequest: - return catPlan - case StartModelExecution, StartToolCall: - return catStart - case SubmitModelResult, SubmitModelFailure, RejectModelResult, SubmitToolResult: - return catOwnerSettle - case SubmitToolFailure: - if cmd.Outcome == ToolOutcomeUnknown { - return catRecovery // scanner path when grantless; owner path with grant - } - return catIngress // known failure on Pending uses empty grant; Executing path checks grant below - case ApproveToolCall, RejectToolCall, SubmitToolResponse, AcceptInput, WithdrawPreparedStep: - return catIngress - case CancelRun: - return catRunControl - case RecoverModelExecution: - return catRecovery - default: - return catPlan - } -} - -// requiresGrant reports whether this command must carry the start grant of -// its target, given the current state (RUN-CMT-6). -func requiresGrant(s *MachineState, c AgentCommand) bool { - switch cmd := c.(type) { - case SubmitModelResult, SubmitModelFailure, RejectModelResult: - return true - case SubmitToolResult: - return true - case SubmitToolFailure: - // Known failure on a Pending call uses an empty grant; anything - // touching an Executing call needs the owner grant. Unknown from the - // scanner is validated via recoveryValid instead. - if ts, ok := s.Current.(ToolStep); ok { - if i := ts.callIndex(cmd.CallID); i >= 0 { - return ts.Calls[i].Status == ToolExecuting && cmd.Outcome == ToolOutcomeKnown - } - } - return false - case RecoverModelExecution: - // Grant-holder release path; the grantless path is recovery-validated. - return false - default: - return false - } -} - // ValidateEnvelope is step 1 of RUN-CMT-3: identity, schema and digest. A // digest that does not cover the command is a construction fault and is // returned as a hard error, never as a retriable rejection. @@ -111,20 +48,13 @@ func ValidateEnvelope(env *CommandEnvelope, proto Protocol) error { return nil } -// EvaluateCommit is the pure evaluation every Runtime runs inside the -// Session critical section after exact-replay lookup (RUN-CMT-3 steps 4-8). -// grantValid and recoveryValid are the control-plane verdicts the Runtime -// supplies: whether req.Grant is the live grant for the command's target, and -// whether a grantless recovery command matches an expired lease. +// 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, - grantValid bool, - recoveryValid bool, - proto Protocol, -) (CommitDecision, error) { +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) @@ -132,8 +62,7 @@ func EvaluateCommit( if err := ValidateEnvelope(&env, proto); err != nil { return CommitDecision{}, err } - // A start claim is part of the command identity. Rejecting an empty claim - // here prevents an unbound worker from acquiring execution ownership. + // A start claim is part of the command identity (RUN-WIR-1). switch cmd := env.Command.(type) { case StartModelExecution: if cmd.Claim == "" { @@ -149,8 +78,8 @@ func EvaluateCommit( } } // Derived-identity families must use their derived CommandID (RUN-WIR-3): - // the derivation is the idempotency index for inputs/responses/planning, so - // a caller-minted random ID cannot bypass duplicate detection. + // 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 } @@ -160,15 +89,8 @@ func EvaluateCommit( return CommitDecision{Kind: DecisionTerminal, Reject: ErrRunTerminal}, nil } - // Base and authorization. - cat := categorize(env.Command) - if cat == catPlan && !samePosition(req.Base, position) { - return CommitDecision{Kind: DecisionStale, Reject: ErrStaleRuntime}, nil - } - if requiresGrant(&cur, env.Command) && !grantValid { - return CommitDecision{Kind: DecisionStale, Reject: ErrStaleRuntime}, nil - } - if cat == catRecovery && !grantValid && !recoveryValid { + // 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 } @@ -221,8 +143,6 @@ func EvaluateCommit( return CommitDecision{Kind: DecisionApply, NewState: state, Facts: detached}, nil } -func samePosition(a, b RunPosition) bool { return a.Revision == b.Revision && a.Index == b.Index } - // checkDerivedCommandID enforces the derived-identity rules of RUN-WIR-3. func checkDerivedCommandID(env *CommandEnvelope, base RunPosition) error { var want CommandID @@ -244,9 +164,7 @@ func checkDerivedCommandID(env *CommandEnvelope, base RunPosition) error { case StartToolCall: want = DeriveStartCommandID(env.RunID, cmd.StepID, cmd.CallID, cmd.Claim) case RecoverModelExecution: - if cmd.Claim != "" { - want = DeriveModelRecoveryCommandID(env.RunID, cmd.StepID, cmd.Claim) - } + want = DeriveModelRecoveryCommandID(env.RunID, cmd.StepID, cmd.Claim) default: return nil } @@ -256,44 +174,7 @@ func checkDerivedCommandID(env *CommandEnvelope, base RunPosition) error { return nil } -// LeaseKey addresses the execution target of a start inside the Run's lease -// namespace: /model/ or /call// (RUN 5.1). -func LeaseKey(runID RunID, stepID StepID, callID CallID) string { - switch { - case stepID == "": - return "" - case callID == "": - return string(runID) + "/model/" + string(stepID) - default: - return string(runID) + "/call/" + string(stepID) + "/" + string(callID) - } -} - -// GrantTarget returns the lease key a command's grant or recovery refers to. -func GrantTarget(runID RunID, c AgentCommand) string { - switch cmd := c.(type) { - case StartModelExecution: - return LeaseKey(runID, cmd.StepID, "") - case SubmitModelResult: - return LeaseKey(runID, cmd.StepID, "") - case SubmitModelFailure: - return LeaseKey(runID, cmd.StepID, "") - case RejectModelResult: - return LeaseKey(runID, cmd.StepID, "") - case RecoverModelExecution: - return LeaseKey(runID, cmd.StepID, "") - case StartToolCall: - return LeaseKey(runID, cmd.StepID, cmd.CallID) - case SubmitToolResult: - return LeaseKey(runID, cmd.StepID, cmd.CallID) - case SubmitToolFailure: - return LeaseKey(runID, cmd.StepID, cmd.CallID) - default: - return "" - } -} - -// IsStart reports whether c acquires an execution lease. +// IsStart reports whether c begins an execution attempt. func IsStart(c AgentCommand) bool { switch c.(type) { case StartModelExecution, StartToolCall: @@ -302,15 +183,6 @@ func IsStart(c AgentCommand) bool { return false } -// IsSettlement reports whether c releases the lease of its target. -func IsSettlement(c AgentCommand) bool { - switch c.(type) { - case SubmitModelResult, SubmitModelFailure, RejectModelResult, RecoverModelExecution, SubmitToolResult, SubmitToolFailure: - return true - } - return false -} - // CommandClaim returns the ExecutionClaim a start or recovery command carries. func CommandClaim(c AgentCommand) ExecutionClaim { switch cmd := c.(type) { @@ -324,35 +196,47 @@ func CommandClaim(c AgentCommand) ExecutionClaim { return "" } -// RecoveryCommand builds the grantless recovery command for an expired lease -// on target (RUN 5.1): an Executing model step recovers to Prepared; an -// Executing tool call settles as Unknown. ok is false when the target is no -// longer Executing. -func RecoveryCommand(state *MachineState, target string, claim ExecutionClaim) (AgentCommand, CommandID, bool) { +// 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 || target != LeaseKey(state.RunID, cur.RefValue.ID, "") { - return nil, "", false + if cur.Status != ModelExecuting { + return nil } - return RecoverModelExecution{StepID: cur.RefValue.ID, Claim: claim}, - DeriveModelRecoveryCommandID(state.RunID, cur.RefValue.ID, claim), true + return []Recovery{{ + Command: RecoverModelExecution{StepID: cur.RefValue.ID, Claim: claim}, + ID: DeriveModelRecoveryCommandID(state.RunID, cur.RefValue.ID, claim), + }} case ToolStep: - prefix := LeaseKey(state.RunID, cur.RefValue.ID, "x") - prefix = prefix[:len(prefix)-1] - if len(target) <= len(prefix) || target[:len(prefix)] != prefix { - return nil, "", false - } - callID := CallID(target[len(prefix):]) - if i := cur.callIndex(callID); i < 0 || cur.Calls[i].Status != ToolExecuting { - return nil, "", false + 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 SubmitToolFailure{ - StepID: cur.RefValue.ID, - CallID: callID, - Failure: ToolFailure{Class: FailureEffectUnknown, Message: "lease expired"}, - Outcome: ToolOutcomeUnknown, - }, DeriveToolRecoveryCommandID(state.RunID, cur.RefValue.ID, callID, claim), true + return out default: - return nil, "", false + return nil } } diff --git a/agent/run/ids.go b/agent/run/ids.go index 154b3ee..9223709 100644 --- a/agent/run/ids.go +++ b/agent/run/ids.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/session" ) type RunID string @@ -34,11 +35,6 @@ type PlanningToken string // accidentally acquiring a second execution grant. type ExecutionClaim string -// ExecutionGrant is an opaque capability minted by the Runtime for one -// accepted start command. Callers only pass it back; its representation is -// implementation-defined. -type ExecutionGrant string - func sha256Digest(data []byte) Digest { return es.DigestBytes(data) } // namespacedHash derives a stable identifier from a namespace and ordered @@ -56,7 +52,14 @@ func namespacedHash(namespace string, parts ...string) string { // 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.Revision), fmt.Sprintf("%d", position.Index))) + 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 @@ -107,8 +110,7 @@ func DeriveWithdrawCommandID(run RunID, step StepID) CommandID { } // DeriveStartCommandID derives the CommandID of StartModelExecution (empty -// call) or StartToolCall from the target and the attempt's ExecutionClaim. A -// Loop that kept only the claim can replay its start and recover the grant; +// 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 { @@ -131,8 +133,8 @@ func DeriveModelRecoveryCommandID(run RunID, step StepID, claim ExecutionClaim) return CommandID(namespacedHash("twilight/model-recovery", string(run), string(step), string(claim))) } -// DeriveToolRecoveryCommandID derives the identity of the grantless Unknown -// settlement RecoverExpired commits for one abandoned tool attempt. +// 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..2c37439 --- /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/memohai/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 index 2945046..a771b3b 100644 --- a/agent/run/loop/bound.go +++ b/agent/run/loop/bound.go @@ -25,7 +25,3 @@ func (b boundRuntime) Commit(ctx context.Context, req run.CommitRequest) (run.Co func (b boundRuntime) FrozenRequest(ctx context.Context, digest run.Digest) (run.ModelRequest, error) { return b.rt.FrozenRequest(ctx, digest) } - -func (b boundRuntime) RenewLease(ctx context.Context, runID run.RunID, stepID run.StepID, callID run.CallID, grant run.ExecutionGrant) error { - return b.rt.RenewLease(ctx, b.sid, runID, stepID, callID, grant) -} diff --git a/agent/run/loop/cache.go b/agent/run/loop/cache.go deleted file mode 100644 index a5624c9..0000000 --- a/agent/run/loop/cache.go +++ /dev/null @@ -1,214 +0,0 @@ -package loop - -import ( - "context" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "sync" - - run "github.com/memohai/twilight/agent/run" - "github.com/memohai/twilight/agent/session" -) - -// ClaimStore is the host-injected record of this Loop's live execution -// claims. A claim is the only local state a Loop needs to reclaim an accepted -// start after its own process died: with the claim, the start CommandID, the -// settlement CommandID and the recovery CommandID all derive (RUN-WIR-3). -// -// The in-process default (memoryClaims) gives response-loss recovery within -// one process. A durable ClaimStore lets a replacement process replay the -// settlement of a tool call the dead process had finished but not reported, -// instead of waiting for lease expiry. -// -// Implementations must be safe for concurrent use. Put replaces any existing -// claim for the key; Delete of a missing key is a no-op. -type ClaimStore interface { - Put(ctx context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID, claim run.ExecutionClaim) error - Get(ctx context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID) (run.ExecutionClaim, bool, error) - Delete(ctx context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID) error - // DeleteRun forgets every claim of a finished Run. - DeleteRun(ctx context.Context, sid session.SessionID, runID run.RunID) error -} - -type claimKey struct { - sid session.SessionID - runID run.RunID - stepID run.StepID - callID run.CallID -} - -// memoryClaims is the default in-process ClaimStore. -type memoryClaims struct { - mu sync.Mutex - claims map[claimKey]run.ExecutionClaim -} - -func newMemoryClaims() *memoryClaims { - return &memoryClaims{claims: make(map[claimKey]run.ExecutionClaim)} -} - -func (m *memoryClaims) Put(_ context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID, claim run.ExecutionClaim) error { - m.mu.Lock() - m.claims[claimKey{sid, runID, stepID, callID}] = claim - m.mu.Unlock() - return nil -} - -func (m *memoryClaims) Get(_ context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID) (run.ExecutionClaim, bool, error) { - m.mu.Lock() - defer m.mu.Unlock() - c, ok := m.claims[claimKey{sid, runID, stepID, callID}] - return c, ok, nil -} - -func (m *memoryClaims) Delete(_ context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID) error { - m.mu.Lock() - delete(m.claims, claimKey{sid, runID, stepID, callID}) - m.mu.Unlock() - return nil -} - -func (m *memoryClaims) DeleteRun(_ context.Context, sid session.SessionID, runID run.RunID) error { - m.mu.Lock() - for k := range m.claims { - if k.sid == sid && k.runID == runID { - delete(m.claims, k) - } - } - m.mu.Unlock() - return nil -} - -// attempt is one execution attempt this Loop owns or is trying to own. Every -// command identity of the attempt derives from the claim. -type attempt struct { - sid session.SessionID - runID run.RunID - stepID run.StepID - callID run.CallID - claim run.ExecutionClaim -} - -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) -} - -// claimFor returns the attempt for key, reusing a stored claim when the Loop -// (or a predecessor process sharing the ClaimStore) already started it, and -// minting and storing a fresh claim otherwise. -func (l *Loop) claimFor(ctx context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID) (attempt, error) { - claim, ok, err := l.Claims.Get(ctx, sid, runID, stepID, callID) - if err != nil { - return attempt{}, fmt.Errorf("agent: loop: claim store: %w", err) - } - if !ok { - claim = freshExecutionClaim() - if err := l.Claims.Put(ctx, sid, runID, stepID, callID, claim); err != nil { - return attempt{}, fmt.Errorf("agent: loop: claim store: %w", err) - } - } - return attempt{sid: sid, runID: runID, stepID: stepID, callID: callID, claim: claim}, nil -} - -// hasClaim reports whether a claim for the target is already stored. -func (l *Loop) hasClaim(ctx context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID) (bool, error) { - _, ok, err := l.Claims.Get(ctx, sid, runID, stepID, callID) - if err != nil { - return false, fmt.Errorf("agent: loop: claim store: %w", err) - } - return ok, nil -} - -func (l *Loop) forgetClaim(ctx context.Context, a attempt) { - _ = l.Claims.Delete(context.WithoutCancel(ctx), a.sid, a.runID, a.stepID, a.callID) -} - -func (l *Loop) forgetRunClaims(ctx context.Context, sid session.SessionID, runID run.RunID) { - _ = l.Claims.DeleteRun(context.WithoutCancel(ctx), sid, runID) -} - -// settle commits the owner settlement of an attempt under its derived -// CommandID. On success or on a sentinel rejection the claim is released: -// the attempt is over either way. A transport failure keeps the claim so the -// next Run (in this or a replacement process) replays the same settlement. -// -// 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, grant run.ExecutionGrant, 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, grant, cmd, proto) - if err != nil { - if retriable(err) { - l.forgetClaim(ctx, a) - return nil, nil - } - return nil, err - } - l.forgetClaim(ctx, a) - l.emitCommitted(ctx, events, runtime.sid, a.runID, &res.Commit) - if res.Snapshot.State.Status.Terminal() { - return res.Snapshot.State.Result, nil - } - return nil, nil -} - -// resumeOwnedStarts re-enters every Executing target this Loop holds a claim -// for. With a durable ClaimStore this is how a replacement process finishes -// what its predecessor started: the derived start ID replays and returns the -// live grant, then the effect runs (or re-runs) and settles. -func (l *Loop) resumeOwnedStarts(ctx context.Context, runtime boundRuntime, events EventSink, snapshot *run.RuntimeSnapshot) (handled bool, finished *run.RunResult, err error) { - runID := snapshot.State.RunID - switch current := snapshot.State.Current.(type) { - case run.ModelStep: - if current.Status != run.ModelExecuting { - return false, nil, nil - } - if ok, err := l.hasClaim(ctx, runtime.sid, runID, current.RefValue.ID, ""); err != nil || !ok { - return false, nil, err - } - finished, err := l.runModelStep(ctx, runtime, events, snapshot, current.RefValue.ID) - return true, finished, err - case run.ToolStep: - var ids []run.CallID - for _, call := range current.Calls { - if call.Status != run.ToolExecuting { - continue - } - if ok, err := l.hasClaim(ctx, runtime.sid, runID, current.RefValue.ID, call.CallID); err != nil { - return false, nil, err - } else if ok { - ids = append(ids, call.CallID) - } - } - if len(ids) == 0 { - return false, nil, nil - } - return true, nil, l.runToolCalls(ctx, runtime, events, snapshot, run.StartToolCalls{StepID: current.RefValue.ID, CallIDs: ids}) - default: - return false, nil, nil - } -} - -var errNoClaimStore = errors.New("agent: loop: nil claim store") - -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[:])) -} diff --git a/agent/run/loop/contract.go b/agent/run/loop/contract.go index 133fb01..47985e7 100644 --- a/agent/run/loop/contract.go +++ b/agent/run/loop/contract.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "time" run "github.com/memohai/twilight/agent/run" "github.com/memohai/twilight/agent/session" @@ -14,7 +13,7 @@ import ( // ErrRunAlreadyRunning identifies a second local driver for the same Run. // A Loop permits concurrent execution of different Runs and serializes each -// Run locally so one execution grant has one in-process consumer. +// 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 @@ -140,14 +139,14 @@ type Event struct { StepID run.StepID CallID run.CallID // Sequence orders provisional observations within one stream. Committed - // observations use the Session (Revision, Index) for authority ordering. + // 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 - // SessionCommit (run facts, companion, attach); nil for provisional. - Committed *session.SessionCommit + // group (run facts, companion, attach); nil for provisional. + Committed []session.SessionEvent } // ExecutionPolicy is host-owned loop policy. ToolExecution and MaxParallel @@ -161,14 +160,6 @@ type ExecutionPolicy struct { // MaxParallel bounds local tool workers. Zero means all eligible calls in // the current batch may run concurrently. MaxParallel int - // LeaseRenewInterval is how often a running model or tool worker renews - // its execution lease through Runtime.RenewLease. It must be well below - // the Runtime's LeaseTTL (RUN-CMT-8); zero disables renewal and is only - // correct for a Runtime whose leases do not expire. - LeaseRenewInterval time.Duration - // Claims is the ClaimStore this Loop records live execution claims in. - // nil selects an in-process store. - Claims ClaimStore } type LoopDisposition uint8 @@ -184,7 +175,8 @@ type LoopResult struct { 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. + // 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 } diff --git a/agent/run/loop/events.go b/agent/run/loop/events.go index 74c9296..369ed2a 100644 --- a/agent/run/loop/events.go +++ b/agent/run/loop/events.go @@ -23,17 +23,16 @@ func (s *serializedEventSink) Emit(ctx context.Context, event Event) error { return s.sink.Emit(ctx, event) } -func (l *Loop) emitCommitted(ctx context.Context, events EventSink, sid session.SessionID, runID run.RunID, committed *session.SessionCommit) { - if events == nil || committed == nil { +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 } - c := *committed _ = events.Emit(ctx, Event{ Session: sid, RunID: runID, Kind: EventAgentCommitted, Durability: EventCommitted, - Committed: &c, + Committed: append([]session.SessionEvent(nil), committed...), }) } diff --git a/agent/run/loop/helpers_test.go b/agent/run/loop/helpers_test.go index 83c8cdd..b221c62 100644 --- a/agent/run/loop/helpers_test.go +++ b/agent/run/loop/helpers_test.go @@ -26,33 +26,45 @@ func (nopCompanion) Version() string { return "test/ 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, and a Runtime with a no-op companion. +// Store, the run module, one owner process (Writers) and a Runtime with a +// no-op companion. type testStack struct { store *session.MemoryStore - appender extension.SemanticAppender + registry *extension.Registry + writers extension.Writers runtime *runmod.Runtime + now func() time.Time + ttl time.Duration } func newTestStack(t testing.TB, ttl time.Duration, now func() time.Time) *testStack { t.Helper() - store := session.NewMemoryStore() - registry, err := extension.BuildRegistry(session.ProfileV1(), runmod.Module) - if err != nil { - t.Fatal(err) + if now == nil { + now = time.Now } - appender, err := extension.NewSemanticAppender(store, registry, nil, nil) + store := session.NewMemoryStoreWithClock(now) + registry, err := extension.BuildRegistry(session.ProtocolVersion1, runmod.Module) if err != nil { t.Fatal(err) } - rt, err := runmod.NewRuntime(runmod.Config{Store: store, Registry: registry, Appender: appender, - Projections: extension.NewProjectionReader(store, registry), Companion: nopCompanion{}, LeaseTTL: ttl, Now: now}) - if err != nil { + if _, err := store.Create(context.Background(), session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: testSession}); err != nil { t.Fatal(err) } - if _, err := store.Create(context.Background(), session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: testSession}); err != nil { + s := &testStack{store: store, registry: registry, now: now, ttl: ttl} + s.open(t) + return s +} + +// open starts a new owner process over the same store (a takeover when a +// previous one is still open and its TTL has passed). +func (s *testStack) open(t testing.TB) { + t.Helper() + s.writers = extension.NewWriters(s.store, s.registry, extension.Admission{}, session.OpenOptions{TTL: s.ttl}) + 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) } - return &testStack{store: store, appender: appender, runtime: rt} + s.runtime = rt } // createRun appends the Start group of one Run with a seed input (RUN-NEW-1). @@ -66,25 +78,24 @@ func (s *testStack) createRun(t testing.TB, runID RunID, inputs ...AgentInput) { if err != nil { t.Fatal(err) } - group := extension.SemanticGroup{CommitID: session.CommitID("create/" + string(runID))} + 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}}) } - head, err := s.store.Head(context.Background(), testSession) + w, err := s.writers.Writer(context.Background(), testSession) if err != nil { t.Fatal(err) } - res, err := s.appender.AppendSemantic(context.Background(), extension.SemanticAppendRequest{SessionID: testSession, ExpectedHead: head, Group: group}) + 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.SemanticApplied { + 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, leases -// never expiring. +// newTestRuntime is a Runtime holding "run-1" seeded with one input. func newTestRuntime(t testing.TB) Runtime { t.Helper() stack := newTestStack(t, 0, nil) diff --git a/agent/run/loop/lease.go b/agent/run/loop/lease.go deleted file mode 100644 index 730d14c..0000000 --- a/agent/run/loop/lease.go +++ /dev/null @@ -1,59 +0,0 @@ -package loop - -import ( - "context" - "errors" - "time" - - run "github.com/memohai/twilight/agent/run" -) - -// keepLease renews the execution lease behind grant every interval until the -// returned stop function is called or the renewal is rejected. The worker -// context it returns is cancelled when the Runtime reports the lease is no -// longer ours (ErrStaleRuntime): the target was recovered under us, so -// continuing the effect can only produce a result nobody will accept. -// -// With interval <= 0 renewal is disabled and the worker context is ctx itself. -func (l *Loop) keepLease(ctx context.Context, runtime boundRuntime, runID run.RunID, stepID run.StepID, callID run.CallID, grant run.ExecutionGrant) (workerCtx context.Context, stop func()) { - interval := l.Execution.LeaseRenewInterval - if interval <= 0 { - return ctx, func() {} - } - workerCtx, cancel := context.WithCancel(ctx) - done := make(chan struct{}) - control := context.WithoutCancel(ctx) - go func() { - defer close(done) - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-done: - return - case <-workerCtx.Done(): - return - case <-ticker.C: - } - err := runtime.RenewLease(control, runID, stepID, callID, grant) - if err == nil { - continue - } - if errors.Is(err, run.ErrStaleRuntime) || errors.Is(err, run.ErrRunTerminal) || errors.Is(err, run.ErrRunNotFound) { - cancel() - return - } - // Transport failure: keep the worker running and retry on the - // next tick; the lease still has TTL minus one interval left. - } - }() - var once bool - return workerCtx, func() { - if once { - return - } - once = true - cancel() - <-done - } -} diff --git a/agent/run/loop/lease_test.go b/agent/run/loop/lease_test.go deleted file mode 100644 index b505210..0000000 --- a/agent/run/loop/lease_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package loop - -import ( - "context" - "sync" - "sync/atomic" - "testing" - "time" - - . "github.com/memohai/twilight/agent/run" - "github.com/memohai/twilight/agent/session" - "github.com/memohai/twilight/sdk" -) - -type renewCountingRuntime struct { - Runtime - renewals atomic.Int32 -} - -func (r *renewCountingRuntime) RenewLease(ctx context.Context, sid session.SessionID, runID RunID, stepID StepID, callID CallID, grant ExecutionGrant) error { - r.renewals.Add(1) - return r.Runtime.RenewLease(ctx, sid, runID, stepID, callID, grant) -} - -// A tool that runs longer than the lease TTL keeps its lease alive through -// the Loop heartbeat, so the scanner does not settle it as Unknown and the -// worker's own result is accepted. -func TestLoopRenewsLeaseDuringLongTool(t *testing.T) { - var mu sync.Mutex - clock := time.Unix(1000, 0) - now := func() time.Time { mu.Lock(); defer mu.Unlock(); return clock } - advance := func(d time.Duration) { mu.Lock(); defer mu.Unlock(); clock = clock.Add(d) } - - stack := newTestStack(t, 200*time.Millisecond, now) - stack.createRun(t, "run-1", AgentInput{ID: "seed", Payload: cj(`{}`)}) - rt := &renewCountingRuntime{Runtime: stack.runtime} - - spec := toolSpec(t, "slow", DirectExecution) - slow := &fakeTool{ref: "slow", def: toolDef(spec.Name), policy: DirectExecution, - execute: func(ctx context.Context, req ToolExecutionRequest) ToolExecutionOutcome { - // Simulate a tool that outlives the TTL: advance the clock past - // several deadlines while the heartbeat keeps renewing. - for i := 0; i < 4; i++ { - time.Sleep(30 * time.Millisecond) - advance(150 * time.Millisecond) - if _, err := rt.RecoverExpired(context.Background()); err != nil { - return ToolExecutionFailed{Failure: ToolFailure{Class: FailureExecution, Message: err.Error()}} - } - } - return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: req.Arguments}} - }} - slowCall := sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{TotalTokens: 2}, - ToolCalls: []sdk.ToolCall{{ToolCallID: "c1", ToolName: "slow", Input: `{"x":1}`}}} - invoker := &fakeInvoker{results: []sdk.ModelResult{slowCall, textResult("done")}} - interpreter, err := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"slow": slow}}, - staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{LeaseRenewInterval: 10 * time.Millisecond}, false) - if err != nil { - t.Fatal(err) - } - res, err := interpreter.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("res = %+v", res) - } - if rt.renewals.Load() == 0 { - t.Fatal("lease was never renewed") - } - for _, fact := range recordFacts(t, rt, "run-1") { - if f, ok := fact.(ToolCallFailed); ok && f.Outcome == ToolOutcomeUnknown { - t.Fatalf("tool call settled Unknown despite heartbeat: %+v", f) - } - } -} - -func TestNewRejectsNegativeLeaseRenewInterval(t *testing.T) { - _, err := New(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{LeaseRenewInterval: -1}, false) - if err == nil { - t.Fatal("negative LeaseRenewInterval accepted") - } -} - -// A replacement Loop that shares the dead Loop's ClaimStore replays the -// derived start under the same claim, gets the live grant back, executes the -// tool and settles, without waiting for the lease to expire. -func TestLoopReplacementFinishesInheritedClaim(t *testing.T) { - stack := newTestStack(t, time.Hour, nil) - stack.createRun(t, "run-1", AgentInput{ID: "seed", Payload: cj(`{}`)}) - rt := stack.runtime - - shared := newMemoryClaims() - spec := toolSpec(t, "echo", DirectExecution) - block := make(chan struct{}) - var executions atomic.Int32 - tool := &fakeTool{ref: "echo", def: toolDef(spec.Name), policy: DirectExecution, - execute: func(ctx context.Context, req ToolExecutionRequest) ToolExecutionOutcome { - if executions.Add(1) == 1 { - <-block // first process "dies" here - return ToolExecutionUnknown{Failure: ToolFailure{Class: FailureEffectUnknown}} - } - return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: req.Arguments}} - }} - invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("done")}} - catalog := fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": tool}} - - first, err := New(fakeCatalog{invoker}, catalog, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{Claims: shared}, false) - if err != nil { - t.Fatal(err) - } - firstDone := make(chan struct{}) - go func() { defer close(firstDone); _, _ = first.Run(context.Background(), rt, testSession, "run-1", nil) }() - deadline := time.Now().Add(5 * time.Second) - for { - snap, err := rt.Load(context.Background(), testSession, "run-1") - if err != nil { - t.Fatal(err) - } - if len(ExecutingCalls(snap.State)) == 1 { - break - } - if time.Now().After(deadline) { - t.Fatal("tool never started") - } - time.Sleep(time.Millisecond) - } - - // Lease is nowhere near expiry (1h). A second Loop with the same claims - // takes over immediately. - second, err := New(fakeCatalog{invoker}, catalog, staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{Claims: shared}, false) - if err != nil { - t.Fatal(err) - } - res, err := second.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("second loop result = %+v", res) - } - starts := 0 - for _, fact := range recordFacts(t, rt, "run-1") { - switch f := fact.(type) { - case ToolCallStarted: - starts++ - case ToolCallFailed: - if f.Outcome == ToolOutcomeUnknown { - t.Fatalf("call settled Unknown; replacement did not inherit the claim: %+v", f) - } - } - } - if starts != 1 { - t.Fatalf("ToolCallStarted facts = %d, want 1 (one attempt, replayed)", starts) - } - // The first worker eventually returns; its settlement replays the same - // derived id with a different outcome and is rejected as a conflict, which - // the Loop treats as "already settled by someone else". - close(block) - <-firstDone -} diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go index 85e8139..d70dfbd 100644 --- a/agent/run/loop/loop.go +++ b/agent/run/loop/loop.go @@ -18,13 +18,6 @@ type Loop struct { Planner RequestPlanner Execution ExecutionPolicy Streaming bool - // Claims records the ExecutionClaim of every start this Loop has issued - // and not yet settled. Every command identity of an attempt derives from - // its claim, so this is the only local state a Loop needs to replay a - // start or settlement whose response was lost. New installs an in-process - // store; hosts that want a replacement process to finish a dead process's - // attempts inject a durable one through ExecutionPolicy.Claims. - Claims ClaimStore runsMu sync.Mutex runs map[run.RunID]struct{} @@ -48,15 +41,8 @@ func New(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy if policy.MaxParallel < 0 { return nil, errors.New("agent: loop: negative MaxParallel") } - if policy.LeaseRenewInterval < 0 { - return nil, errors.New("agent: loop: negative LeaseRenewInterval") - } - claims := policy.Claims - if claims == nil { - claims = newMemoryClaims() - } return &Loop{Models: models, Tools: tools, Planner: planner, Execution: policy, Streaming: streaming, - Claims: claims, runs: make(map[run.RunID]struct{})}, nil + runs: make(map[run.RunID]struct{})}, nil } func (l *Loop) toolScheduling() run.ToolScheduling { @@ -108,7 +94,6 @@ func (l *Loop) Run(ctx context.Context, rt run.Runtime, sid session.SessionID, r // 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 { - l.forgetRunClaims(ctx, sid, runID) if events != nil { _ = events.Emit(ctx, Event{Session: sid, RunID: runID, Kind: EventRunFinished, Durability: EventCommitted}) } @@ -132,17 +117,6 @@ func (l *Loop) Run(ctx context.Context, rt run.Runtime, sid session.SessionID, r return finish(snapshot.State.Result), nil } - if l.Claims == nil { - return LoopResult{}, errNoClaimStore - } - if handled, finished, err := l.resumeOwnedStarts(ctx, runtime, events, &snapshot); err != nil { - return LoopResult{}, err - } else if finished != nil { - return finish(finished), nil - } else if handled { - continue - } - effect, err := run.Next(snapshot.State) if err != nil { return LoopResult{}, err @@ -161,13 +135,13 @@ func (l *Loop) Run(ctx context.Context, rt run.Runtime, sid session.SessionID, r if err != nil { return LoopResult{}, err } - res, err := l.commit(ctx, runtime, runID, run.DeriveWithdrawCommandID(runID, eff.StepID), snapshot.Position, "", + 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.Commit) + l.emitCommitted(ctx, events, sid, runID, res.Events) } case run.StartModelCall: finished, err := l.runModelStep(ctx, runtime, events, &snapshot, eff.StepID) @@ -196,10 +170,10 @@ func (l *Loop) Run(ctx context.Context, rt run.Runtime, sid session.SessionID, r // 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 abandoning a live grant or re-executing an expensive step. -func (l *Loop) commit(ctx context.Context, runtime boundRuntime, runID run.RunID, id run.CommandID, base run.RunPosition, grant run.ExecutionGrant, cmd run.AgentCommand, proto run.Protocol) (run.CommitResult, error) { +// 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") } @@ -207,9 +181,9 @@ func (l *Loop) commit(ctx context.Context, runtime boundRuntime, runID run.RunID if err != nil { return run.CommitResult{}, err } - req := run.CommitRequest{Base: base, Grant: grant, Command: env} + req := run.CommitRequest{Base: base, Command: env} res, err := runtime.Commit(ctx, req) - if err != nil && !retriable(err) { + if err != nil && !retriable(err) && !ownershipLost(err) { res, err = runtime.Commit(ctx, req) } return res, err diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go index 238d9d8..a5d3eb8 100644 --- a/agent/run/loop/loop_test.go +++ b/agent/run/loop/loop_test.go @@ -270,7 +270,9 @@ func (staleCommitRuntime) Commit(context.Context, session.SessionID, CommitReque return CommitResult{}, ErrStaleRuntime } -func TestToolStartStaleDropsLocalClaim(t *testing.T) { +// 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) @@ -298,15 +300,12 @@ func TestToolStartStaleDropsLocalClaim(t *testing.T) { BindingDigest: bindingDigest, Arguments: args, Policy: DirectExecution, Status: ToolPending, }}, }, - }, Position: RunPosition{Revision: 1}, SchemaVersion: SchemaVersion1} + }, 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) } - if _, ok, _ := loop.Claims.Get(context.Background(), testSession, "run-1", stepID, callID); ok { - t.Fatal("stale tool start retained a local execution claim") - } } type responseLossRuntime struct { @@ -357,10 +356,21 @@ func TestLoopReplaysStartAfterTwoLostResponses(t *testing.T) { t.Fatal(err) } // The first Loop reaches the start barrier; both start responses are lost, - // so the authority remains Executing while the local claim is retained. + // 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) } diff --git a/agent/run/loop/model.go b/agent/run/loop/model.go index 129421f..a8203a4 100644 --- a/agent/run/loop/model.go +++ b/agent/run/loop/model.go @@ -48,7 +48,7 @@ func (l *Loop) planAndPrepare(ctx context.Context, runtime boundRuntime, events } 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{ + res, err := l.commit(ctx, runtime, snapshot.State.RunID, cmdID, snapshot.Position, run.PrepareModelRequest{ StepID: stepID, Model: model, Request: frozenRequest, @@ -62,7 +62,7 @@ func (l *Loop) planAndPrepare(ctx context.Context, runtime boundRuntime, events // 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.Commit) + l.emitCommitted(ctx, events, runtime.sid, snapshot.State.RunID, res.Events) return nil } if !retriable(err) { @@ -92,40 +92,21 @@ func (l *Loop) runModelStep(ctx context.Context, runtime boundRuntime, events Ev if err != nil { return nil, err } - a, err := l.claimFor(ctx, runtime.sid, runID, stepID, "") - if err != nil { - return nil, err - } - start, err := l.commit(ctx, runtime, runID, a.startID(), snapshot.Position, "", run.StartModelExecution{StepID: stepID, Claim: a.claim}, proto) + 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) { - l.forgetClaim(ctx, a) - return nil, nil + return nil, nil // another actor moved the step; reload decides } - // The start may have committed while its response was lost. The - // claim stays stored so a later Run replays the derived start ID and - // recovers the grant. return nil, err } - if start.Status == run.CommitAlreadyApplied && start.Grant == "" { - // Settled already: by this Loop before a lost response, or by - // recovery. Nothing left to own. - l.forgetClaim(ctx, a) - return nil, nil - } - if start.Grant == "" { - return nil, errors.New("agent: loop: start model returned no execution grant") - } - l.emitCommitted(ctx, events, runtime.sid, runID, &start.Commit) + 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 { - // An exact start replay can race with another owner that already - // settled the step. The Runtime returns the original grant for replay, - // but executing again would duplicate the provider effect; reload and - // let the next machine state decide what to do. + // 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 { - l.forgetClaim(ctx, a) return nil, nil } return nil, fmt.Errorf("agent: loop: started step %q is not current", stepID) @@ -154,7 +135,7 @@ func (l *Loop) runModelStep(ctx context.Context, runtime boundRuntime, events Ev 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, start.Grant, run.RecoverModelExecution{StepID: stepID, Claim: a.claim}, proto); err != nil { + 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 @@ -162,11 +143,9 @@ func (l *Loop) runModelStep(ctx context.Context, runtime boundRuntime, events Ev failure := run.StepFailure{Class: run.FailureMalformedModel, Message: fetchErr.Error()} completion = run.RejectModelResult{StepID: stepID, Failure: failure, Disposition: l.modelRejectDisposition(modelStep, failure)} } else { - workerCtx, stopLease := l.keepLease(ctx, runtime, runID, stepID, "", start.Grant) - result, invokeErr := l.invokeModel(workerCtx, invoker, &sdkRequest, runID, stepID, events) - stopLease() + result, invokeErr := l.invokeModel(ctx, invoker, &sdkRequest, runID, stepID, events) switch { - case invokeErr != nil && workerCtx.Err() != nil: + 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()}} @@ -187,7 +166,7 @@ func (l *Loop) runModelStep(ctx context.Context, runtime boundRuntime, events Ev } } - finished, err := l.settle(ctx, runtime, events, a, start.Snapshot.Position, start.Grant, completion, proto) + finished, err := l.settle(ctx, runtime, events, a, start.Snapshot.Position, completion, proto) if err != nil { return nil, err } diff --git a/agent/run/loop/takeover_test.go b/agent/run/loop/takeover_test.go new file mode 100644 index 0000000..35763e2 --- /dev/null +++ b/agent/run/loop/takeover_test.go @@ -0,0 +1,94 @@ +package loop + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + . "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/sdk" +) + +// The owner process dies while a tool call is Executing. A new owner takes +// the Session over after the TTL, 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) { + var mu sync.Mutex + clock := time.Unix(1000, 0) + now := func() time.Time { mu.Lock(); defer mu.Unlock(); return clock } + advance := func(d time.Duration) { mu.Lock(); defer mu.Unlock(); clock = clock.Add(d) } + + stack := newTestStack(t, time.Minute, now) + 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 stops heartbeating; the TTL passes; a new owner opens. + advance(2 * time.Minute) + 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 index 48aa61b..a6b285a 100644 --- a/agent/run/loop/tool.go +++ b/agent/run/loop/tool.go @@ -2,7 +2,6 @@ package loop import ( "context" - "errors" "fmt" "sync" @@ -11,7 +10,6 @@ import ( type startedWorker struct { call run.ToolCallState - grant run.ExecutionGrant base run.RunPosition tool ExecutableTool attempt attempt @@ -86,26 +84,18 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime boundRuntime, events Ev continue } call := ts.Calls[i] - resuming := false - switch call.Status { - case run.ToolPending: - case run.ToolExecuting: - if ok, err := l.hasClaim(ctx, runtime.sid, runID, eff.StepID, callID); err != nil { - return err - } else if !ok { - continue - } - resuming = true - default: + 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 && !resuming { + 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, "", + 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) @@ -114,65 +104,31 @@ func (l *Loop) runToolCalls(ctx context.Context, runtime boundRuntime, events Ev } return settleErr } - l.emitCommitted(ctx, events, runtime.sid, runID, &res.Commit) + l.emitCommitted(ctx, events, runtime.sid, runID, res.Events) continue } - a, err := l.claimFor(ctx, runtime.sid, runID, eff.StepID, callID) - if err != nil { - return err - } - start, err := l.commit(ctx, runtime, runID, a.startID(), snapshot.Position, "", + 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) { - // A sentinel rejection proves this start did not acquire the - // call. Drop the claim so a later snapshot can mint a fresh - // attempt or observe the other owner. - l.forgetClaim(ctx, a) - return settleErr + return settleErr // another actor moved the call; reload decides } return err } - if start.Status == run.CommitAlreadyApplied && start.Grant == "" { - l.forgetClaim(ctx, a) - continue // settled already; nothing left to own - } - if start.Grant == "" { - l.forgetClaim(ctx, a) - return errors.New("agent: loop: start tool returned no execution grant") - } if startedCall, ok := toolCallFromSnapshot(start.Snapshot.State, eff.StepID, callID); !ok || startedCall.Status != run.ToolExecuting { - // A replay may arrive after another worker has settled this call. - // Never invoke an effect for a call that is no longer Executing. - l.forgetClaim(ctx, a) + // 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.Commit) + 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}) } - if resuming && known != nil { - failure := *known - if failure.Class != run.FailureEffectUnknown { - if failure.Message == "" { - failure.Message = "tool reported " + failure.Class - } - failure.Class = run.FailureEffectUnknown - } - if _, err := l.settle(ctx, runtime, events, a, start.Snapshot.Position, start.Grant, - run.SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: failure, Outcome: run.ToolOutcomeUnknown}, proto); err != nil { - settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, proto) - if settleErr != nil { - return settleErr - } - return err - } - continue - } - started = append(started, startedWorker{call: call, grant: start.Grant, base: start.Snapshot.Position, tool: tool, attempt: a}) + 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) @@ -219,9 +175,7 @@ func (l *Loop) settleWorkers(ctx context.Context, runtime boundRuntime, events E Arguments: w.call.Arguments, Progress: &progressSink{events: events, run: runID, step: stepID, call: w.call.CallID}, } - workerCtx, stopLease := l.keepLease(ctx, runtime, runID, stepID, w.call.CallID, w.grant) - outcome := executeToolSafely(workerCtx, w.tool, &req) - stopLease() + outcome := executeToolSafely(ctx, w.tool, &req) var cmd run.AgentCommand switch o := outcome.(type) { @@ -247,12 +201,12 @@ func (l *Loop) settleWorkers(ctx context.Context, runtime boundRuntime, events E mu.Lock() defer mu.Unlock() - // Commit with the worker's own grant on its 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, w.grant, cmd, proto); err != nil { + // 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) } diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go index 0aaf560..9502d1b 100644 --- a/agent/run/machine_test.go +++ b/agent/run/machine_test.go @@ -75,7 +75,7 @@ func buildPrepare(t *testing.T, s MachineState, req sdk.Request, specs []ToolSpe if err != nil { t.Fatal(err) } - cmdID := DeriveModelRequestCommandID(s.RunID, RunPosition{}) + cmdID := DeriveModelRequestCommandID(s.RunID, 0) stepID := DeriveModelStepID(s.RunID, cmdID, binding) ids := make([]InputID, len(s.PendingInputs)) for i, in := range s.PendingInputs { diff --git a/agent/run/recover.go b/agent/run/recover.go deleted file mode 100644 index dae8364..0000000 --- a/agent/run/recover.go +++ /dev/null @@ -1,36 +0,0 @@ -package run - -import ( - "context" - "errors" - "time" -) - -// RunExpiredRecovery calls RecoverExpired immediately and then every -// interval until ctx is cancelled. Hosts own this loop; Loop does not. -func RunExpiredRecovery(ctx context.Context, rt Runtime, interval time.Duration) error { - if err := checkContext(ctx); err != nil { - return err - } - if rt == nil { - return errors.New("agent: recover: nil runtime") - } - if interval <= 0 { - return errors.New("agent: recover: interval must be positive") - } - if _, err := rt.RecoverExpired(ctx); err != nil { - return err - } - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-ticker.C: - if _, err := rt.RecoverExpired(ctx); err != nil { - return err - } - } - } -} diff --git a/agent/run/runtest/feature.go b/agent/run/runtest/feature.go index 70ff627..749571b 100644 --- a/agent/run/runtest/feature.go +++ b/agent/run/runtest/feature.go @@ -38,23 +38,19 @@ func (nopCompanion) Map(run.CompanionRequest) ([]run.ModuleEvent, error) { retur func newRuntime(t testing.TB, inputs ...run.AgentInput) run.Runtime { t.Helper() store := session.NewMemoryStore() - registry, err := extension.BuildRegistry(session.ProfileV1(), runmod.Module) + registry, err := extension.BuildRegistry(session.ProtocolVersion1, runmod.Module) if err != nil { t.Fatal(err) } - appender, err := extension.NewSemanticAppender(store, registry, nil, nil) - if err != nil { + ctx := context.Background() + if _, err := store.Create(ctx, session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: defaultSession}); err != nil { t.Fatal(err) } - rt, err := runmod.NewRuntime(runmod.Config{Store: store, Registry: registry, Appender: appender, - Projections: extension.NewProjectionReader(store, registry), Companion: nopCompanion{}}) + 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) } - ctx := context.Background() - if _, err := store.Create(ctx, session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: defaultSession}); err != nil { - t.Fatal(err) - } newRun, err := run.BuildNewRun(defaultRunID, "") if err != nil { t.Fatal(err) @@ -63,19 +59,19 @@ func newRuntime(t testing.TB, inputs ...run.AgentInput) run.Runtime { if err != nil { t.Fatal(err) } - group := extension.SemanticGroup{CommitID: "create/" + defaultRunID} + 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}}) } - head, err := store.Head(ctx, defaultSession) + w, err := writers.Writer(ctx, defaultSession) if err != nil { t.Fatal(err) } - res, err := appender.AppendSemantic(ctx, extension.SemanticAppendRequest{SessionID: defaultSession, ExpectedHead: head, Group: group}) + res, err := w.Commit(ctx, func(extension.View) (*extension.SemanticGroup, error) { return group, nil }) if err != nil { t.Fatal(err) } - if res.Outcome != extension.SemanticApplied { + if res.Outcome != extension.CommitApplied { t.Fatalf("create run: %s %s", res.Outcome, res.Detail) } return rt @@ -100,7 +96,6 @@ type Feature struct { seq int modelStepID run.StepID - modelGrant run.ExecutionGrant last loop.LoopResult resolveErr error } @@ -246,7 +241,7 @@ func (f *Feature) Approve() *Feature { } f.commit(run.ApproveToolCall{ StepID: w.StepID, CallID: w.CallID, ResponseID: w.ID, ResponseDigest: digest, - }, "") + }) return f } @@ -261,14 +256,14 @@ func (f *Feature) Reject(reason string) *Feature { 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{}, "") + f.commit(run.CancelRun{}) return f } @@ -276,8 +271,7 @@ func (f *Feature) Cancel() *Feature { func (f *Feature) ExecutingModel() *Feature { f.t.Helper() f.commitPrepare() - res := f.commit(run.StartModelExecution{StepID: f.modelStepID}, "") - f.modelGrant = res.Grant + f.commit(run.StartModelExecution{StepID: f.modelStepID}) return f } @@ -341,12 +335,12 @@ func (f *Feature) ExecutingTool(name string, callID run.CallID) *Feature { CallID: callID, ProviderCallID: providerID, ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, BindingDigest: binding, Arguments: args, Policy: spec.Policy, }}, - }, f.modelGrant) + }) 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}, "") + f.commit(run.StartToolCall{StepID: ts.Ref().ID, CallID: callID}) return f } @@ -398,7 +392,7 @@ func (f *Feature) waiting() run.ResponseRequest { return reqs[0] } -func (f *Feature) commit(cmd run.AgentCommand, grant run.ExecutionGrant) run.CommitResult { +func (f *Feature) commit(cmd run.AgentCommand) run.CommitResult { f.t.Helper() snap := f.load() proto, err := snap.Protocol() @@ -413,7 +407,7 @@ func (f *Feature) commit(cmd run.AgentCommand, grant run.ExecutionGrant) run.Com f.t.Fatal(err) } res, err := f.rt.Commit(f.ctx, defaultSession, run.CommitRequest{ - Base: snap.Position, Grant: grant, Command: env, + Base: snap.Position, Command: env, }) if err != nil { f.t.Fatalf("commit %T: %v", cmd, err) @@ -482,7 +476,7 @@ func (f *Feature) commitPrepare() { 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. diff --git a/agent/run/runtime.go b/agent/run/runtime.go index 6f50b56..5be9daf 100644 --- a/agent/run/runtime.go +++ b/agent/run/runtime.go @@ -2,24 +2,26 @@ package run import ( "context" + "errors" - "github.com/memohai/twilight/agent/es" "github.com/memohai/twilight/agent/session" ) -// RunPosition is the stream position of a Run's last twilight/run/ event. -// Only the Run's own events move it; other modules' commits 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 struct { - Revision es.Revision `json:"revision"` - Index uint16 `json:"index"` -} +// 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 critical section and -// appends facts, companion content and attached events as one SessionCommit. -// Runs are created by the Coordinator's Start group; there is no Create. +// 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) @@ -27,18 +29,17 @@ type Runtime interface { // 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) - // RenewLease extends the lease behind grant on the Executing target - // (stepID alone for a ModelStep, stepID+callID for a tool call). - RenewLease(ctx context.Context, sessionID session.SessionID, runID RunID, stepID StepID, callID CallID, grant ExecutionGrant) error - // RecoverExpired grantless-commits recovery for expired execution leases. - // Hosts call it on a timer; Loop does not. - RecoverExpired(context.Context) (int, 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 position at read time. + // Position is the Run's last event Seq at read time. Position RunPosition // Head is the Session head at read time. Head session.Head @@ -53,7 +54,7 @@ func (s RuntimeSnapshot) Protocol() (Protocol, error) { } // ModuleEvent is a typed event of another module (chatlog, turn) that the -// Runtime appends after the Run facts in the same commit. The module +// Runtime appends after the Run facts in the same group. The module // implementation encodes it through the Registry. type ModuleEvent struct { Type session.EventType @@ -73,8 +74,8 @@ type CompanionRequest struct { } // Companion maps Run facts and the command's transient content to the -// conversation events that travel in the same commit (TRN-CMP). Map must be -// a deterministic pure function. +// 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) @@ -82,9 +83,9 @@ type Companion interface { type CommitRequest struct { // Base is the Position the caller loaded. PrepareModelRequest treats it - // as a hard CAS; other commands rebase call-locally (RUN-CMT-4). + // as a hard CAS; other commands rebase call-locally and may pass zero + // (RUN-CMT-4). Base RunPosition - Grant ExecutionGrant Command CommandEnvelope // Attach are caller events appended after the companion events; they must // not be twilight/run/ events. @@ -101,17 +102,14 @@ const ( type CommitResult struct { Status CommitStatus Snapshot RuntimeSnapshot - // Commit is the complete SessionCommit: run facts, companion, attach. - Commit session.SessionCommit - // Grant is returned for an Accepted start and for an exact replay while - // that start is still live; otherwise empty. - Grant ExecutionGrant + // 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 stream order, folded and compared with the projection. +// RunID in Seq order, folded and compared with the projection. type RunRecord struct { - Created session.EventPosition + Created session.Seq Snapshot RuntimeSnapshot Events []session.SessionEvent Facts []Fact diff --git a/agent/session/chatlog/chatlog_test.go b/agent/session/chatlog/chatlog_test.go index 96f50db..38fd650 100644 --- a/agent/session/chatlog/chatlog_test.go +++ b/agent/session/chatlog/chatlog_test.go @@ -10,7 +10,7 @@ import ( func registry(t *testing.T) *extension.Registry { t.Helper() - r, err := extension.BuildRegistry(session.ProfileV1(), Module) + r, err := extension.BuildRegistry(session.ProtocolVersion1, Module) if err != nil { t.Fatal(err) } diff --git a/agent/session/chatlog/projection.go b/agent/session/chatlog/projection.go index 886f4a6..13a9eec 100644 --- a/agent/session/chatlog/projection.go +++ b/agent/session/chatlog/projection.go @@ -40,9 +40,9 @@ const ( ) type SurfaceEntry struct { - Kind EntryKind `json:"kind"` - ID string `json:"id"` - Position session.EventPosition `json:"position"` + Kind EntryKind `json:"kind"` + ID string `json:"id"` + Seq session.Seq `json:"seq"` } // Surface is the UI-facing read model (CHT-SUR-1). @@ -85,8 +85,7 @@ var chatlogConsumes = []session.EventType{TypeInputSubmitted, TypeInputDelivered var SurfaceProjection = extension.ProjectionDefinition{ ID: SurfaceProjectionID, Version: 1, - Consumes: chatlogConsumes, - RequireComplete: []extension.ModuleID{ModuleID}, + 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{}}, nil }, @@ -97,7 +96,7 @@ var SurfaceProjection = extension.ProjectionDefinition{ func applySurface(state any, e extension.DecodedEvent) (any, error) { s := state.(Surface) s = cloneSurface(s) - pos := session.EventPosition{Revision: e.Revision, Index: e.Event.Index, EventDigest: e.Event.EventDigest} + pos := e.Event.Seq switch p := e.Value.(type) { case InputSubmittedPayload: if _, dup := s.Inputs[p.InputID]; dup { @@ -117,7 +116,7 @@ func applySurface(state any, e extension.DecodedEvent) (any, error) { 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), Position: pos}) + 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 @@ -131,13 +130,13 @@ func applySurface(state any, e extension.DecodedEvent) (any, error) { 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), Position: pos}) + 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), Position: pos}) + 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) @@ -151,7 +150,7 @@ func applySurface(state any, e extension.DecodedEvent) (any, error) { 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), Position: pos}) + s.EntryOrder = append(s.EntryOrder, SurfaceEntry{Kind: EntrySummary, ID: string(p.Summary.ID), Seq: pos}) default: return nil, fmt.Errorf("chatlog surface: unexpected %T", e.Value) } @@ -222,8 +221,7 @@ type Context struct { var ContextProjection = extension.ProjectionDefinition{ ID: ContextProjectionID, Version: 1, - Consumes: chatlogConsumes, - RequireComplete: []extension.ModuleID{ModuleID}, + Consumes: chatlogConsumes, Initial: func() (any, error) { return Context{Pending: map[InputID]Input{}, Superseded: map[ToolResultID]ToolResultID{}}, nil }, diff --git a/agent/session/extension/appender.go b/agent/session/extension/appender.go deleted file mode 100644 index 0e59ebd..0000000 --- a/agent/session/extension/appender.go +++ /dev/null @@ -1,383 +0,0 @@ -package extension - -import ( - "context" - "errors" - "fmt" - - "github.com/memohai/twilight/agent/artifact" - "github.com/memohai/twilight/agent/es" - "github.com/memohai/twilight/agent/session" -) - -// ClaimNamespace is the control-plane namespace artifact claims live in. -const ClaimNamespace session.ControlNamespace = "twilight/artifact/claim" - -// TypedEvent is a module value plus event metadata; it carries no EventID -// (EXT-APP-5). -type TypedEvent struct { - Type session.EventType - RecordedAtUnixMilli int64 - SourceEvents []session.EventID - Value any -} - -type SemanticGroup struct { - CommitID session.CommitID - CausationID es.CausationID - CorrelationID string - Events []TypedEvent -} - -type SemanticAppendRequest struct { - SessionID session.SessionID - ExpectedHead session.Head - Group SemanticGroup -} - -// SemanticTx is the kernel transaction plus typed decode. -type SemanticTx interface { - session.SessionTx - Decode(session.SessionEvent) (DecodedEvent, error) - SessionID() session.SessionID - Registry() *Registry -} - -type SemanticCommitFn func(SemanticTx) (*SemanticGroup, error) - -type SemanticAppendOutcome string - -const ( - SemanticApplied SemanticAppendOutcome = "applied" - SemanticAlreadyApplied SemanticAppendOutcome = "already_applied" - SemanticHeadConflict SemanticAppendOutcome = "head_conflict" - SemanticCommitConflict SemanticAppendOutcome = "commit_conflict" - SemanticInvalid SemanticAppendOutcome = "invalid" - SemanticNoop SemanticAppendOutcome = "noop" -) - -type SemanticAppendResult struct { - Outcome SemanticAppendOutcome - Commit *session.SessionCommit - Claim *artifact.RetentionClaim - Detail string -} - -// SemanticAppender is the only write path (EXT-SCP-1). -type SemanticAppender interface { - AppendSemantic(context.Context, SemanticAppendRequest) (SemanticAppendResult, error) - AppendSemanticIn(context.Context, session.SessionID, SemanticCommitFn) (SemanticAppendResult, error) -} - -// DeriveEventID is the Appender's EventID rule (EXT-APP-5). -func DeriveEventID(typ session.EventType, commitID session.CommitID, index int) session.EventID { - raw, _ := es.EncodeTypedPayload(session.ProtocolVersion1, "twilight/session-extension/event-id", []string{string(typ), string(commitID), fmt.Sprintf("%d", index)}) - return session.EventID(es.DigestBytes(raw)) -} - -// DeriveClaimID is EXT-APP-2. -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)) -} - -type appender struct { - store session.Store - registry *Registry - builder artifact.BindingSetBuilder - ledger artifact.RetentionLedger -} - -// NewSemanticAppender assembles the write path. builder and ledger may be nil -// only when no registered event declares Bindings. -func NewSemanticAppender(store session.Store, registry *Registry, builder artifact.BindingSetBuilder, ledger artifact.RetentionLedger) (SemanticAppender, error) { - if store == nil || registry == nil { - return nil, errors.New("extension: appender: nil store or registry") - } - return &appender{store: store, registry: registry, builder: builder, ledger: ledger}, nil -} - -type semanticTx struct { - session.SessionTx - sid session.SessionID - registry *Registry -} - -func (t *semanticTx) Decode(e session.SessionEvent) (DecodedEvent, error) { - return t.registry.Decode(e) -} -func (t *semanticTx) SessionID() session.SessionID { return t.sid } -func (t *semanticTx) Registry() *Registry { return t.registry } - -type txClaimKV struct { - tx session.SessionTx -} - -func (k txClaimKV) Get(key string) ([]byte, bool, error) { - e, ok, err := k.tx.ControlGet(ClaimNamespace, key) - if err != nil || !ok { - return nil, false, err - } - return e.Value, true, nil -} -func (k txClaimKV) Put(key string, value []byte) error { - return k.tx.ControlPut(ClaimNamespace, key, value, 0) -} -func (k txClaimKV) Delete(key string) error { return k.tx.ControlDelete(ClaimNamespace, key) } - -// StoreClaimKV is the outside-the-transaction ClaimKV over a Store, for the -// ledger's LookupClaim and ReleaseActive. -func StoreClaimKV(store session.Store, sid session.SessionID) func(context.Context) artifact.ClaimKV { - return func(ctx context.Context) artifact.ClaimKV { return storeClaimKV{store, sid, ctx} } -} - -type storeClaimKV struct { - store session.Store - sid session.SessionID - ctx context.Context -} - -func (k storeClaimKV) Get(key string) ([]byte, bool, error) { - e, ok, err := k.store.ControlGet(k.ctx, k.sid, ClaimNamespace, key) - if err != nil || !ok { - return nil, false, err - } - return e.Value, true, nil -} -func (k storeClaimKV) Put(key string, value []byte) error { - return k.store.ControlPut(k.ctx, k.sid, ClaimNamespace, key, value, 0) -} -func (k storeClaimKV) Delete(key string) error { - return k.store.ControlDelete(k.ctx, k.sid, ClaimNamespace, key) -} - -func (a *appender) AppendSemantic(ctx context.Context, req SemanticAppendRequest) (SemanticAppendResult, error) { - return a.AppendSemanticIn(ctx, req.SessionID, func(tx SemanticTx) (*SemanticGroup, error) { - group := req.Group - // A retry of an already committed group is judged by fingerprint, not - // by the head it was first attempted against (SES-APP-1). - if _, found, err := tx.LookupCommit(group.CommitID); err != nil { - return nil, err - } else if found { - return &group, nil - } - if tx.Head() != req.ExpectedHead { - return nil, errHeadConflict - } - return &group, nil - }) -} - -var errHeadConflict = errors.New("extension: head conflict") - -// errDiscard aborts the kernel transaction so a rejected group leaves no -// snapshot or KV write behind; the semantic outcome is carried separately. -var errDiscard = errors.New("extension: discard transaction") - -// AppendSemanticIn is EXT-APP-3: fn decides the group inside the kernel's -// critical section; codec, admission, claim and append happen in the same -// transaction. -func (a *appender) AppendSemanticIn(ctx context.Context, sid session.SessionID, fn SemanticCommitFn) (SemanticAppendResult, error) { - if fn == nil { - return SemanticAppendResult{}, errors.New("extension: appender: nil fn") - } - var result SemanticAppendResult - header, err := a.store.Header(ctx, sid) - if err != nil { - return SemanticAppendResult{}, err - } - appendRes, err := a.store.CommitIn(ctx, sid, func(tx session.SessionTx) (*session.AppendRequest, error) { - stx := &semanticTx{SessionTx: tx, sid: sid, registry: a.registry} - group, err := fn(stx) - if err != nil { - if errors.Is(err, errHeadConflict) { - result = SemanticAppendResult{Outcome: SemanticHeadConflict} - return nil, errDiscard - } - return nil, err - } - if group == nil { - result = SemanticAppendResult{Outcome: SemanticNoop} - return nil, nil - } - if group.CommitID == "" || len(group.Events) == 0 { - result = SemanticAppendResult{Outcome: SemanticInvalid, Detail: "empty CommitID or event group"} - return nil, errDiscard - } - req, claim, invalid, err := a.prepare(ctx, tx, &header, sid, group) - if err != nil { - return nil, err - } - if invalid != "" { - result = SemanticAppendResult{Outcome: SemanticInvalid, Detail: invalid} - return nil, errDiscard - } - // Exact replay short-circuits before admission writes anything new; - // the claim of a replayed commit is re-activated idempotently. - if existing, ok, err := tx.LookupCommit(group.CommitID); err != nil { - return nil, err - } else if ok { - have, err := a.registry.Profile.FingerprintAppend(*req) - if err != nil { - return nil, err - } - want, err := fingerprintOf(a.registry.Profile, &existing) - if err != nil { - return nil, err - } - if have != want { - result = SemanticAppendResult{Outcome: SemanticCommitConflict} - return nil, errDiscard - } - if claim != nil { - if _, err := a.ledger.ActivateIn(txClaimKV{tx}, claim.ID, claim.Owner, claim.BindingSet); err != nil { - return nil, err - } - } - c := existing - result = SemanticAppendResult{Outcome: SemanticAlreadyApplied, Commit: &c, Claim: claim} - return nil, nil - } - if claim != nil { - if _, err := a.ledger.ActivateIn(txClaimKV{tx}, claim.ID, claim.Owner, claim.BindingSet); err != nil { - return nil, err - } - } - result = SemanticAppendResult{Claim: claim} - req.ExpectedHead = tx.Head() - return req, nil - }) - if err != nil { - if errors.Is(err, errDiscard) { - return result, nil - } - return SemanticAppendResult{}, err - } - if result.Outcome != "" { - return result, nil - } - switch appendRes.Disposition { - case session.AppendApplied: - result.Outcome = SemanticApplied - result.Commit = appendRes.Commit - case session.AppendAlreadyApplied: - result.Outcome = SemanticAlreadyApplied - result.Commit = appendRes.Commit - case session.AppendCommitConflict: - result.Outcome = SemanticCommitConflict - case session.AppendHeadConflict: - result.Outcome = SemanticHeadConflict - default: - result.Outcome = SemanticInvalid - result.Detail = appendRes.Detail - } - return result, nil -} - -// prepare validates, encodes and extracts bindings for the group. It returns -// the kernel AppendRequest (ExpectedHead unset) and the claim to activate. -func (a *appender) prepare(ctx context.Context, tx session.SessionTx, header *session.SessionHeader, sid session.SessionID, group *SemanticGroup) (*session.AppendRequest, *artifact.RetentionClaim, string, error) { - req := &session.AppendRequest{SessionID: sid, CommitID: group.CommitID, CausationID: group.CausationID, CorrelationID: group.CorrelationID, - Events: make([]session.UncommittedEvent, len(group.Events))} - var refs []artifact.BindingID - for i, te := range group.Events { - _, def, ok := a.registry.LookupEvent(te.Type) - if !ok { - return nil, nil, fmt.Sprintf("event %d: unknown type %s", i, te.Type), nil - } - payload, _, err := a.registry.Encode(te.Type, te.Value) - if err != nil { - return nil, nil, fmt.Sprintf("event %d: %v", i, err), nil - } - for _, decl := range def.Bindings { - ids, err := decl.extract(te.Value, payload) - if err != nil { - return 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, fmt.Sprintf("event %d: binding cardinality violated", i), nil - } - for _, id := range ids { - if invalid, err := a.admit(ctx, id, &decl); err != nil { - return nil, nil, "", err - } else if invalid != "" { - return nil, nil, fmt.Sprintf("event %d: %s", i, invalid), nil - } - } - refs = append(refs, ids...) - } - req.Events[i] = session.UncommittedEvent{ - EventID: DeriveEventID(te.Type, group.CommitID, i), - Type: te.Type, - RecordedAtUnixMilli: te.RecordedAtUnixMilli, - SourceEvents: session.SortedUniqueEventIDs(te.SourceEvents), - Payload: payload, - } - } - if len(refs) == 0 { - return req, nil, "", nil - } - if a.builder == nil || a.ledger == nil { - return nil, nil, "group references artifacts but no ledger is configured", nil - } - set, err := a.builder.Build(ctx, refs) - if err != nil { - var aerr *artifact.Error - if errors.As(err, &aerr) { - return nil, nil, "binding set: " + aerr.Error(), nil - } - return nil, nil, "", err - } - claim := &artifact.RetentionClaim{ - ID: DeriveClaimID(header.ProtocolVersion, sid, group.CommitID, set.RefSetDigest), - Owner: artifact.ClaimOwner{Kind: "twilight/session/commit", Authority: string(sid), Identity: string(group.CommitID)}, - BindingSet: set, - State: artifact.ClaimActive, - } - return req, claim, "", nil -} - -func (a *appender) admit(ctx context.Context, id artifact.BindingID, decl *BindingReferenceDefinition) (string, error) { - resolver, ok := a.builder.(interface { - ResolveBinding(context.Context, artifact.BindingID) (artifact.Binding, error) - }) - if !ok { - if sb, isSet := a.builder.(artifact.SetBuilder); isSet { - resolver = sb.Resolver - } - } - if resolver == nil { - return "no binding resolver configured", nil - } - binding, err := resolver.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 -} - -func fingerprintOf(p session.ProtocolProfile, c *session.SessionCommit) (es.Digest, error) { - events := make([]session.UncommittedEvent, len(c.Events)) - for i, e := range c.Events { - events[i] = session.UncommittedEvent{EventID: e.EventID, Type: e.Type, SourceEvents: e.SourceEvents, Payload: e.Payload} - } - return p.FingerprintAppend(session.AppendRequest{SessionID: c.SessionID, CommitID: c.CommitID, CausationID: c.CausationID, CorrelationID: c.CorrelationID, Events: events}) -} diff --git a/agent/session/extension/binding.go b/agent/session/extension/binding.go index 13f3cf1..0c19c3a 100644 --- a/agent/session/extension/binding.go +++ b/agent/session/extension/binding.go @@ -2,10 +2,8 @@ package extension import ( "errors" - "strings" "github.com/memohai/twilight/agent/artifact" - "github.com/memohai/twilight/agent/jsonstable" ) type Cardinality struct { @@ -25,9 +23,8 @@ 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. +// and what admission requires of them (EXT-REF-2). type BindingReferenceDefinition struct { - JSONPointer string Extractor BindingExtractor Cardinality Cardinality AllowedSchemes []artifact.Scheme @@ -35,11 +32,8 @@ type BindingReferenceDefinition struct { } func (d *BindingReferenceDefinition) validate() error { - if (d.JSONPointer == "") == (d.Extractor == nil) { - return errors.New("binding declaration needs exactly one of JSONPointer or Extractor") - } - if d.JSONPointer != "" && !strings.HasPrefix(d.JSONPointer, "/") { - return errors.New("JSONPointer must start with /") + 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") @@ -49,41 +43,3 @@ func (d *BindingReferenceDefinition) validate() error { } return nil } - -// extract returns the references one declaration finds in value/payload. -func (d *BindingReferenceDefinition) extract(value any, payload jsonstable.Value) ([]artifact.BindingID, error) { - if d.Extractor != nil { - return d.Extractor.BindingIDs(value) - } - node, err := payload.Any() - if err != nil { - return nil, err - } - for _, seg := range strings.Split(strings.TrimPrefix(d.JSONPointer, "/"), "/") { - seg = strings.ReplaceAll(strings.ReplaceAll(seg, "~1", "/"), "~0", "~") - obj, ok := node.(map[string]any) - if !ok { - return nil, nil - } - node, ok = obj[seg] - if !ok { - return nil, nil - } - } - switch v := node.(type) { - case string: - return []artifact.BindingID{artifact.BindingID(v)}, nil - case []any: - out := make([]artifact.BindingID, 0, len(v)) - for _, item := range v { - s, ok := item.(string) - if !ok { - return nil, errors.New("binding pointer array holds a non-string") - } - out = append(out, artifact.BindingID(s)) - } - return out, nil - default: - return nil, errors.New("binding pointer does not address a string or string array") - } -} diff --git a/agent/session/extension/extension_test.go b/agent/session/extension/extension_test.go index a99f004..a588701 100644 --- a/agent/session/extension/extension_test.go +++ b/agent/session/extension/extension_test.go @@ -6,28 +6,46 @@ import ( "testing" "time" + "github.com/memohai/twilight/agent/artifact" "github.com/memohai/twilight/agent/jsonstable" "github.com/memohai/twilight/agent/session" ) type notePayload struct { - Text string `json:"text"` + 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 +}) + func noteModule(id ModuleID, requires ...ModuleRequirement) ModuleDescriptor { typ := ModulePrefix(id) + "note" return ModuleDescriptor{ID: id, Requires: requires, - Events: []EventDefinition{{Type: typ, Current: 1, Codecs: map[PayloadVersion]PayloadCodec{1: JSONCodec[notePayload]{}}}}, + Events: []EventDefinition{ + {Type: typ, Current: 1, Codecs: map[PayloadVersion]PayloadCodec{1: JSONCodec[notePayload]{}}, + Bindings: []BindingReferenceDefinition{{Extractor: refsExtractor, RequiredDurability: artifact.EventBound}}}, + {Type: ModulePrefix(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}, RequireComplete: []ModuleID{id}, + 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) - s.Notes = append(append([]string(nil), s.Notes...), e.Value.(notePayload).Text) + 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]{}, @@ -42,13 +60,15 @@ func TestBuildRegistryValidatesRequires(t *testing.T) { "unhandled version": {noteModule("a"), noteModule("b", ModuleRequirement{Module: "a", Events: map[session.EventType][]PayloadVersion{ModulePrefix("a") + "note": {2}}})}, "event outside module": {{ID: "a", Events: []EventDefinition{{Type: "twilight/b/x", Current: 1, Codecs: map[PayloadVersion]PayloadCodec{1: JSONCodec[notePayload]{}}}}}}, + "projection outside scope": {noteModule("a"), {ID: "b", Projections: []ProjectionDefinition{{ID: "p", Version: 1, Consumes: []session.EventType{ModulePrefix("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.ProfileV1(), modules...); err == nil { + if _, err := BuildRegistry(session.ProtocolVersion1, modules...); err == nil { t.Errorf("%s: registry built", name) } } - if _, err := BuildRegistry(session.ProfileV1(), noteModule("a"), noteModule("b", ModuleRequirement{Module: "a", + if _, err := BuildRegistry(session.ProtocolVersion1, noteModule("a"), noteModule("b", ModuleRequirement{Module: "a", Events: map[session.EventType][]PayloadVersion{ModulePrefix("a") + "note": {1}}})); err != nil { t.Fatalf("valid registry: %v", err) } @@ -56,7 +76,7 @@ func TestBuildRegistryValidatesRequires(t *testing.T) { // Encode adds v; Decode selects the codec by v and keeps unknown versions raw. func TestRegistryPayloadVersion(t *testing.T) { - r, err := BuildRegistry(session.ProfileV1(), noteModule("a")) + r, err := BuildRegistry(session.ProtocolVersion1, noteModule("a")) if err != nil { t.Fatal(err) } @@ -73,153 +93,287 @@ func TestRegistryPayloadVersion(t *testing.T) { if err != nil || !future.Unknown || future.Version != 2 { t.Fatalf("future version = %+v %v", future, err) } - if _, _, err := r.Encode(typ, notePayload{}); err != nil { - t.Fatalf("encode zero value: %v", err) - } if _, _, err := r.Encode("twilight/a/other", notePayload{}); err == nil { t.Fatal("unknown type encoded") } } -func newAppender(t *testing.T) (session.Store, *Registry, SemanticAppender, session.SessionID) { +type fixture struct { + store *session.MemoryStore + registry *Registry + bindings *artifact.MemoryBindingStore + ledger *artifact.MemoryLedger + now time.Time +} + +func newFixture(t *testing.T) *fixture { t.Helper() - store := session.NewMemoryStore() - r, err := BuildRegistry(session.ProfileV1(), noteModule("a")) + f := &fixture{now: time.Unix(1_700_000_000, 0)} + f.store = session.NewMemoryStoreWithClock(func() time.Time { return f.now }) + r, err := BuildRegistry(session.ProtocolVersion1, noteModule("a")) if err != nil { t.Fatal(err) } - a, err := NewSemanticAppender(store, r, nil, nil) - if err != nil { + 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) } - if _, err := store.Create(context.Background(), session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: "s"}); err != nil { + return f +} + +func (f *fixture) admission() Admission { return Admission{Bindings: f.bindings, Ledger: f.ledger} } + +func (f *fixture) open(t *testing.T, ttl time.Duration) Writer { + t.Helper() + w, err := OpenWriter(context.Background(), f.store, f.registry, f.admission(), "s", session.OpenOptions{TTL: ttl}) + 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: ModulePrefix("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(ModulePrefix("a"))+"notes"), 1) + if err != nil { t.Fatal(err) } - return store, r, a, "s" + return state.(noteState).Notes } -// Both append entries derive EventIDs from (type, CommitID, index), reject a -// same-ID different group, and the reader sees the projection with snapshot -// and tail equivalent. -func TestSemanticAppenderAndProjectionReader(t *testing.T) { - store, r, a, sid := newAppender(t) +// 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() - typ := ModulePrefix("a") + "note" - group := SemanticGroup{CommitID: "c1", Events: []TypedEvent{{Type: typ, Value: notePayload{Text: "one"}}}} - res, err := a.AppendSemanticIn(ctx, sid, func(tx SemanticTx) (*SemanticGroup, error) { return &group, nil }) - if err != nil || res.Outcome != SemanticApplied { - t.Fatalf("append in = %+v %v", res, err) - } - if res.Commit.Events[0].EventID != DeriveEventID(typ, "c1", 0) { - t.Fatal("EventID not derived from (type, CommitID, index)") - } - head, _ := store.Head(ctx, sid) - res2, err := a.AppendSemantic(ctx, SemanticAppendRequest{SessionID: sid, ExpectedHead: head, - Group: SemanticGroup{CommitID: "c2", Events: []TypedEvent{{Type: typ, Value: notePayload{Text: "two"}}}}}) - if err != nil || res2.Outcome != SemanticApplied { - t.Fatalf("append = %+v %v", res2, err) - } - replay, _ := a.AppendSemantic(ctx, SemanticAppendRequest{SessionID: sid, ExpectedHead: head, Group: group}) - if replay.Outcome != SemanticAlreadyApplied { + w := f.open(t, 0) + 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, _ := a.AppendSemantic(ctx, SemanticAppendRequest{SessionID: sid, ExpectedHead: head, - Group: SemanticGroup{CommitID: "c1", Events: []TypedEvent{{Type: typ, Value: notePayload{Text: "changed"}}}}}) - if conflict.Outcome != SemanticCommitConflict { + conflict, _ := w.Commit(ctx, noteGroup("c1", "changed")) + if conflict.Outcome != CommitConflict { t.Fatalf("conflict = %+v", conflict) } - stale, _ := a.AppendSemantic(ctx, SemanticAppendRequest{SessionID: sid, ExpectedHead: head, - Group: SemanticGroup{CommitID: "c3", Events: []TypedEvent{{Type: typ, Value: notePayload{Text: "three"}}}}}) - if stale.Outcome != SemanticHeadConflict { - t.Fatalf("stale = %+v", stale) + noop, _ := w.Commit(ctx, func(View) (*SemanticGroup, error) { return nil, nil }) + if noop.Outcome != CommitNoop { + t.Fatalf("noop = %+v", noop) } - invalid, _ := a.AppendSemantic(ctx, SemanticAppendRequest{SessionID: sid, ExpectedHead: session.Head{Revision: res2.Commit.Revision, Digest: res2.Commit.CommitDigest}, - Group: SemanticGroup{CommitID: "c4", Events: []TypedEvent{{Type: "twilight/a/unknown", Value: notePayload{}}}}}) - if invalid.Outcome != SemanticInvalid { + 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) } - - reader := NewProjectionReader(store, r) - def, _ := r.LookupProjection(ProjectionID(string(typ)+"s"), 1) - state, through, err := reader.Load(ctx, sid, def.ID, def.Version) - if err != nil || through.Revision != 2 { - t.Fatalf("load = %+v %+v %v", state, through, err) + rejected, _ := w.Commit(ctx, noteGroup("c3", "fine", "reject")) + if rejected.Outcome != CommitInvalid { + t.Fatalf("projection rejection must block the append: %+v", rejected) } - full := state.(noteState).Notes - // Snapshot after the first commit, then the tail must give the same state. - _, err = a.AppendSemanticIn(ctx, sid, func(tx SemanticTx) (*SemanticGroup, error) { - s, _, err := LoadIn(tx, &def) - if err != nil { - return nil, err + 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 len(s.(noteState).Notes) != 2 { - t.Fatalf("LoadIn = %+v", s) + if s, err := v.Projection(ProjectionID(string(ModulePrefix("a"))+"notes"), 1); err != nil || len(s.(noteState).Notes) != 2 { + t.Fatalf("view projection = %+v %v", s, err) } - return nil, SaveSnapshotIn(tx, &def, noteState{Notes: []string{"one"}}, session.Head{Revision: 1, Digest: res.Commit.CommitDigest}) + return nil, nil }) if err != nil { t.Fatal(err) } - state, _, err = reader.Load(ctx, sid, def.ID, def.Version) - if err != nil || len(state.(noteState).Notes) != len(full) || state.(noteState).Notes[1] != "two" { - t.Fatalf("snapshot+tail = %+v, full = %v, %v", state, full, 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, 0) + 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(ModulePrefix("a"))+"notes"), 1) + if err != nil || len(state.(noteState).Notes) != 2 || through.Next != 2 { + t.Fatalf("store reader = %+v %+v %v", state, through, err) } } -func TestLeaseLifecycle(t *testing.T) { - store, _, a, sid := newAppender(t) +// EXT-WRT-4: a superseded writer fails closed. +func TestWriterOwnershipLost(t *testing.T) { + f := newFixture(t) ctx := context.Background() - typ := ModulePrefix("a") + "note" - var lease Lease - _, err := a.AppendSemanticIn(ctx, sid, func(tx SemanticTx) (*SemanticGroup, error) { - l, err := AcquireLease(tx, sid, "c1", 1000, AcquireLeaseRequest{Namespace: "twilight/a/lease", Key: "k", Holder: "h1", TTL: time.Second}) - if err != nil { - return nil, err - } - lease = l - if again, err := AcquireLease(tx, sid, "c1", 1000, AcquireLeaseRequest{Namespace: "twilight/a/lease", Key: "k", Holder: "h1", TTL: time.Second}); err != nil || again.Token != l.Token { - t.Fatalf("same holder re-acquire = %+v %v", again, err) - } - if _, err := AcquireLease(tx, sid, "c1", 1000, AcquireLeaseRequest{Namespace: "twilight/a/lease", Key: "k", Holder: "h2"}); !errors.Is(err, &Error{Code: ErrConflict}) { - t.Fatalf("other holder = %v, want conflict", err) - } - return &SemanticGroup{CommitID: "c1", Events: []TypedEvent{{Type: typ, Value: notePayload{Text: "start"}}}}, nil - }) - if err != nil { + w1 := f.open(t, time.Minute) + if _, err := w1.Commit(ctx, noteGroup("c1", "one")); err != nil { t.Fatal(err) } - if lease.Token != DeriveLeaseToken(sid, "twilight/a/lease", "k", "h1", "c1") || lease.DeadlineUnixMilli != 2000 { - t.Fatalf("lease = %+v", lease) + f.now = f.now.Add(2 * time.Minute) + w2 := f.open(t, time.Minute) + if _, err := w1.Commit(ctx, noteGroup("c2", "late")); !errors.Is(err, &Error{Code: ErrOwnershipLost}) { + t.Fatalf("stale writer commit = %v, want ownership_lost", err) } - leases := Leases{Store: store} - if err := leases.Renew(ctx, sid, "twilight/a/lease", "k", lease.Token, time.Second, 1500); err != nil { - t.Fatalf("renew: %v", err) + if _, err := w1.Commit(ctx, noteGroup("c3", "again")); !errors.Is(err, &Error{Code: ErrOwnershipLost}) { + t.Fatal("writer did not stay failed") } - if err := leases.Renew(ctx, sid, "twilight/a/lease", "k", "bad", time.Second, 1500); !errors.Is(err, &Error{Code: ErrStale}) { - t.Fatalf("renew with bad token = %v", err) + if got := notes(t, w2); len(got) != 1 { + t.Fatalf("fenced write leaked: %v", got) } - var expired []string - _ = leases.Expired(ctx, "twilight/a/lease", 2400, func(_ session.SessionID, l Lease) (bool, error) { expired = append(expired, l.Key); return true, nil }) - if len(expired) != 0 { - t.Fatalf("renewed lease expired early: %v", expired) + 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) } - _ = leases.Expired(ctx, "twilight/a/lease", 2600, func(_ session.SessionID, l Lease) (bool, error) { expired = append(expired, l.Key); return true, nil }) - if len(expired) != 1 { - t.Fatalf("expired = %v", expired) + _ = w2.Close(ctx) + a, err := ws.Writer(ctx, "s") + if err != nil { + t.Fatal(err) } - _, err = a.AppendSemanticIn(ctx, sid, func(tx SemanticTx) (*SemanticGroup, error) { - if err := ReleaseLease(tx, "twilight/a/lease", "k", "bad"); !errors.Is(err, &Error{Code: ErrStale}) { - t.Fatalf("release with bad token = %v", err) - } - if err := ReleaseLease(tx, "twilight/a/lease", "k", lease.Token); err != nil { - return nil, 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, 0) + 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: ModulePrefix("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) } - return &SemanticGroup{CommitID: "c2", Events: []TypedEvent{{Type: typ, Value: notePayload{Text: "settle"}}}}, nil + } + 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, 0) + 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, 0) + res, err := w.Commit(ctx, func(View) (*SemanticGroup, error) { + return &SemanticGroup{CommitID: "c1", Events: []TypedEvent{{Type: ModulePrefix("a") + "note", Value: notePayload{Text: "file", Refs: []string{"b1"}}}}}, nil }) - if err != 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: ModulePrefix("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) } - if err := leases.Renew(ctx, sid, "twilight/a/lease", "k", lease.Token, time.Second, 1500); !errors.Is(err, &Error{Code: ErrStale}) { - t.Fatalf("renew after release = %v", err) + _ = w.Close(ctx) + w = f.open(t, 0) + 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, 0) + defer w.Close(ctx) + id := ProjectionID(string(ModulePrefix("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/lease.go b/agent/session/extension/lease.go deleted file mode 100644 index adef9c6..0000000 --- a/agent/session/extension/lease.go +++ /dev/null @@ -1,168 +0,0 @@ -package extension - -import ( - "context" - "encoding/json" - "errors" - "time" - - "github.com/memohai/twilight/agent/es" - "github.com/memohai/twilight/agent/jsonstable" - "github.com/memohai/twilight/agent/session" -) - -type LeaseToken string - -// Lease is one occupancy record in the control-plane KV (EXT-LSE). -type Lease struct { - Namespace session.ControlNamespace - Key string - Holder string - Token LeaseToken - DeadlineUnixMilli int64 - Attrs jsonstable.Value -} - -type AcquireLeaseRequest struct { - Namespace session.ControlNamespace - Key string - Holder string - TTL time.Duration - Attrs jsonstable.Value -} - -type leaseValue struct { - Holder string `json:"holder"` - Token LeaseToken `json:"token"` - Attrs jsonstable.Value `json:"attrs,omitempty"` -} - -// DeriveLeaseToken is the pure Token derivation of EXT-LSE-1. -func DeriveLeaseToken(sid session.SessionID, ns session.ControlNamespace, key, holder string, commitID session.CommitID) LeaseToken { - raw, _ := es.EncodeTypedPayload(session.ProtocolVersion1, "twilight/session-extension/lease", []string{string(sid), string(ns), key, holder, string(commitID)}) - return LeaseToken(es.DigestBytes(raw)) -} - -// AcquireLease writes or idempotently confirms a lease inside the commit -// transaction (EXT-LSE-2). sid is the Session the tx belongs to. -func AcquireLease(tx session.SessionTx, sid session.SessionID, commitID session.CommitID, now int64, req AcquireLeaseRequest) (Lease, error) { - if req.Namespace == "" || req.Key == "" || req.Holder == "" { - return Lease{}, &Error{Code: ErrInvalid, Detail: "lease requires namespace, key and holder"} - } - existing, ok, err := LookupLease(tx, req.Namespace, req.Key) - if err != nil { - return Lease{}, err - } - if ok { - if existing.Holder == req.Holder { - return existing, nil - } - return Lease{}, &Error{Code: ErrConflict, Detail: "lease held by another holder"} - } - lease := Lease{Namespace: req.Namespace, Key: req.Key, Holder: req.Holder, - Token: DeriveLeaseToken(sid, req.Namespace, req.Key, req.Holder, commitID), Attrs: req.Attrs} - if req.TTL > 0 { - lease.DeadlineUnixMilli = now + req.TTL.Milliseconds() - } - value, err := encodeLease(&lease) - if err != nil { - return Lease{}, err - } - if err := tx.ControlPut(req.Namespace, req.Key, value, lease.DeadlineUnixMilli); err != nil { - return Lease{}, err - } - return lease, nil -} - -// ReleaseLease deletes the lease when token matches; otherwise ErrStale. -func ReleaseLease(tx session.SessionTx, ns session.ControlNamespace, key string, token LeaseToken) error { - existing, ok, err := LookupLease(tx, ns, key) - if err != nil { - return err - } - if !ok || existing.Token != token { - return &Error{Code: ErrStale, Detail: "lease missing or token mismatch"} - } - return tx.ControlDelete(ns, key) -} - -func LookupLease(tx session.SessionTx, ns session.ControlNamespace, key string) (Lease, bool, error) { - entry, ok, err := tx.ControlGet(ns, key) - if err != nil || !ok { - return Lease{}, false, err - } - lease, err := decodeLease(entry) - if err != nil { - return Lease{}, false, err - } - return lease, true, nil -} - -// Leases is the outside-the-critical-section facade (EXT-LSE-3). -type Leases struct{ Store session.Store } - -func (l Leases) Lookup(ctx context.Context, sid session.SessionID, ns session.ControlNamespace, key string) (Lease, bool, error) { - entry, ok, err := l.Store.ControlGet(ctx, sid, ns, key) - if err != nil || !ok { - return Lease{}, false, err - } - lease, err := decodeLease(entry) - if err != nil { - return Lease{}, false, err - } - return lease, true, nil -} - -// Renew pushes the deadline by ttl with a conditional write; a missing entry, -// a token mismatch or a lost race with Release returns ErrStale. ttl zero only -// validates the token. -func (l Leases) Renew(ctx context.Context, sid session.SessionID, ns session.ControlNamespace, key string, token LeaseToken, ttl time.Duration, now int64) error { - entry, ok, err := l.Store.ControlGet(ctx, sid, ns, key) - if err != nil { - return err - } - if !ok { - return &Error{Code: ErrStale, Detail: "lease missing"} - } - lease, err := decodeLease(entry) - if err != nil { - return err - } - if lease.Token != token { - return &Error{Code: ErrStale, Detail: "lease token mismatch"} - } - if ttl <= 0 { - return nil - } - written, err := l.Store.ControlCompareAndPut(ctx, sid, ns, key, entry.Value, entry.Value, now+ttl.Milliseconds()) - if err != nil { - return err - } - if !written { - return &Error{Code: ErrStale, Detail: "lease released or rewritten during renew"} - } - return nil -} - -// Expired enumerates leases whose deadline passed before now. -func (l Leases) Expired(ctx context.Context, ns session.ControlNamespace, now int64, fn func(session.SessionID, Lease) (bool, error)) error { - return l.Store.ControlExpired(ctx, ns, now, func(entry session.ControlEntry) (bool, error) { - lease, err := decodeLease(entry) - if err != nil { - return false, err - } - return fn(entry.SessionID, lease) - }) -} - -func encodeLease(l *Lease) ([]byte, error) { - return jsonstable.MarshalCanonical(leaseValue{Holder: l.Holder, Token: l.Token, Attrs: l.Attrs}) -} - -func decodeLease(entry session.ControlEntry) (Lease, error) { - var v leaseValue - if err := json.Unmarshal(entry.Value, &v); err != nil { - return Lease{}, errors.New("extension: lease: corrupt value") - } - return Lease{Namespace: entry.Namespace, Key: entry.Key, Holder: v.Holder, Token: v.Token, DeadlineUnixMilli: entry.DeadlineUnixMilli, Attrs: v.Attrs}, nil -} diff --git a/agent/session/extension/projection.go b/agent/session/extension/projection.go index 4dba065..3c23c84 100644 --- a/agent/session/extension/projection.go +++ b/agent/session/extension/projection.go @@ -4,236 +4,229 @@ import ( "context" "errors" "fmt" + "sync" + "github.com/memohai/twilight/agent/es" "github.com/memohai/twilight/agent/jsonstable" "github.com/memohai/twilight/agent/session" ) // ProjectionDefinition is a pure fold over decoded events (EXT-PRJ-1). type ProjectionDefinition struct { - ID ProjectionID - Version ProjectionVersion - Consumes []session.EventType - Ignores []session.EventType - RequireComplete []ModuleID - Initial func() (any, error) - Apply func(any, DecodedEvent) (any, error) - StateCodec PayloadCodec + ID ProjectionID + Version ProjectionVersion + Consumes []session.EventType + Initial func() (any, error) + Apply func(any, DecodedEvent) (any, error) + StateCodec PayloadCodec } -// TypeFilter is the Types filter a reader uses for this projection: the -// consumed types plus the full prefixes of RequireComplete modules. -func (d *ProjectionDefinition) TypeFilter() []session.EventType { - var out []session.EventType - for _, m := range d.RequireComplete { - out = append(out, ModulePrefix(m)) - } - out = append(out, d.Consumes...) - return out +// 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[ModuleID]struct{} + types []session.EventType } -// Fold applies the commits to state. It fails on an Unknown event of a -// RequireComplete module and never publishes a partial commit. -func Fold(registry *Registry, def *ProjectionDefinition, state any, commits []session.SessionCommit) (any, error) { - consumes := make(map[session.EventType]struct{}, len(def.Consumes)) - for _, t := range def.Consumes { - consumes[t] = struct{}{} +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)} } - ignores := make(map[session.EventType]struct{}, len(def.Ignores)) - for _, t := range def.Ignores { - ignores[t] = struct{}{} + 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{}{} } - required := make(map[ModuleID]struct{}, len(def.RequireComplete)) - for _, m := range def.RequireComplete { - required[m] = struct{}{} + for m := range s.modules { + s.types = append(s.types, ModulePrefix(m)) } - for ci := range commits { + 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 _, e := range commits[ci].Events { - if _, skip := ignores[e.Type]; skip { - continue - } - if _, want := consumes[e.Type]; !want { - // Unknown event of a required module: refuse rather than skip. - module, registered := registry.ModuleForEvent(e.Type) - if _, mustBeComplete := required[module]; mustBeComplete && !registered { - return nil, &Error{Code: ErrUnknownEvent, Type: e.Type, Detail: fmt.Sprintf("projection %q requires complete module %q", def.ID, module)} - } - continue - } - decoded, err := registry.Decode(e) + for j := i; j <= end; j++ { + var err error + next, err = r.applyRow(s, next, &rows[j]) if err != nil { return nil, err } - decoded.Revision = commits[ci].Revision - if decoded.Unknown { - if _, mustBeComplete := required[decoded.ModuleID]; mustBeComplete { - return nil, &Error{Code: ErrUnknownEvent, Type: e.Type, Detail: fmt.Sprintf("projection %q cannot decode v%d", def.ID, decoded.Version)} - } - continue - } - next, err = def.Apply(next, decoded) - if err != nil { - return nil, fmt.Errorf("projection %s: revision %d: %w", def.ID, commits[ci].Revision, err) - } } state = next + i = end + 1 } return state, nil } -// ProjectionReader loads a projection outside the critical section -// (EXT-PRJ-4): snapshot (if it is a prefix of the stream) plus filtered tail. +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 %q at seq %d", s.def.ID, module, 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) } -type reader struct { - store session.Store - registry *Registry +// 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 } -func NewProjectionReader(store session.Store, registry *Registry) ProjectionReader { - return &reader{store: store, registry: registry} +// MemoryProjectionCache is the in-process ProjectionCache. +type MemoryProjectionCache struct { + mu sync.Mutex + entries map[cacheKey]cacheEntry } -func (r *reader) Load(ctx context.Context, sid session.SessionID, id ProjectionID, v ProjectionVersion) (any, session.Head, error) { - def, ok := r.registry.LookupProjection(id, v) - if !ok { - return nil, session.Head{}, &Error{Code: ErrInvalid, Detail: fmt.Sprintf("unknown projection %q v%d", id, v)} - } - state, after, err := r.startState(ctx, sid, &def) - if err != nil { - return nil, session.Head{}, err - } - page, err := r.store.Replay(ctx, session.ReplayRequest{SessionID: sid, Types: def.TypeFilter()}) - if err != nil { - return nil, session.Head{}, err - } - // Filtered replay pages by commit; a Types filter cannot use the digest - // chain, so we page until Next is nil. - commits := page.Commits - for page.Next != nil { - page, err = r.store.Replay(ctx, session.ReplayRequest{SessionID: sid, Types: def.TypeFilter(), Cursor: page.Next}) - if err != nil { - return nil, session.Head{}, err - } - commits = append(commits, page.Commits...) - } - // Commits at or before the snapshot head are already folded. - var tail []session.SessionCommit - for i := range commits { - if commits[i].Revision > after.Revision { - tail = append(tail, commits[i]) - } - } - state, err = Fold(r.registry, &def, state, tail) - if err != nil { - return nil, session.Head{}, err - } - return state, page.Head, nil +type cacheKey struct { + sid session.SessionID + id ProjectionID + v ProjectionVersion +} +type cacheEntry struct { + state jsonstable.Value + through session.Head } -// startState returns the snapshot state when the snapshot is a valid prefix -// of the stream; otherwise the projection's initial state and the empty head. -func (r *reader) startState(ctx context.Context, sid session.SessionID, def *ProjectionDefinition) (any, session.Head, error) { - res, err := r.store.LoadSnapshot(ctx, session.SnapshotRequest{SessionID: sid, ProjectionKey: session.ProjectionKey(def.ID), ProjectionVersion: uint16(def.Version)}) - if err != nil { - return nil, session.Head{}, err - } - if res.Found { - if state, ok := r.snapshotState(ctx, sid, def, res.Snapshot); ok { - return state, res.Snapshot.Through, nil - } - } - state, err := def.Initial() - return state, session.Head{}, err +func NewMemoryProjectionCache() *MemoryProjectionCache { + return &MemoryProjectionCache{entries: make(map[cacheKey]cacheEntry)} } -func (r *reader) snapshotState(ctx context.Context, sid session.SessionID, def *ProjectionDefinition, snap *session.Snapshot) (any, bool) { - if err := r.registry.Profile.ValidateSnapshot(*snap); err != nil { - return nil, false +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 } - if !r.isPrefix(ctx, sid, snap.Through) { - return nil, false + def, _, ok := registry.LookupProjection(id, v) + if !ok { + return &Error{Code: ErrInvalid, Detail: fmt.Sprintf("unknown projection %q v%d", id, v)} } - state, err := def.StateCodec.Decode(snap.State) + encoded, err := def.StateCodec.Encode(state) if err != nil { - return nil, false + return err } - return state, true + return cache.Save(ctx, sid, id, v, encoded, through) } -func (r *reader) isPrefix(ctx context.Context, sid session.SessionID, through session.Head) bool { - if through.Revision == 0 { - return false - } - // Replay one unfiltered page up to Through; the kernel verifies the chain. - page, err := r.store.Replay(ctx, session.ReplayRequest{SessionID: sid, Limit: uint32(through.Revision)}) - if err != nil || len(page.Commits) < int(through.Revision) { - return false - } - return page.Commits[through.Revision-1].CommitDigest == through.Digest +type storeReader struct { + store session.Store + registry *Registry + cache ProjectionCache } -// LoadIn folds a projection inside a transaction from snapshot plus tail. It -// returns the state and the head it covers. -func LoadIn(tx SemanticTx, def *ProjectionDefinition) (any, session.Head, error) { - registry := tx.Registry() - res, err := tx.LoadSnapshot(session.ProjectionKey(def.ID), uint16(def.Version)) +// 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 } - var state any - var after session.Head - if res.Found && registry.Profile.ValidateSnapshot(*res.Snapshot) == nil { - if s, err := def.StateCodec.Decode(res.Snapshot.State); err == nil { - // Tail validates Through against the stream; a stale snapshot makes - // Tail fail and we fall back to a full fold. - if commits, terr := tx.Tail(res.Snapshot.Through, def.TypeFilter()); terr == nil { - state = s - after = res.Snapshot.Through - folded, err := Fold(registry, def, state, commits) - if err != nil { - return nil, session.Head{}, err - } - return folded, tx.Head(), nil - } - } - } - state, err = def.Initial() + state, from, err := r.startState(ctx, sid, scope) if err != nil { return nil, session.Head{}, err } - commits, err := tx.Tail(after, def.TypeFilter()) + page, err := r.store.Read(ctx, session.ReadRequest{SessionID: sid, From: from.Next, Types: scope.types}) if err != nil { return nil, session.Head{}, err } - folded, err := Fold(registry, def, state, commits) + state, err = r.registry.fold(scope, state, page.Events) if err != nil { return nil, session.Head{}, err } - return folded, tx.Head(), nil + return state, page.Head, nil } -// SaveSnapshotIn encodes state and stores it as the projection's snapshot -// covering through, in the same transaction. -func SaveSnapshotIn(tx SemanticTx, def *ProjectionDefinition, state any, through session.Head) error { - if through.Revision == 0 { - return nil - } - encoded, err := def.StateCodec.Encode(state) - if err != nil { - return err +// 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 + } + } } - snap := session.Snapshot{ProtocolVersion: tx.Registry().ProtocolVersion, SessionID: tx.SessionID(), - ProjectionKey: session.ProjectionKey(def.ID), ProjectionVersion: uint16(def.Version), Through: through, State: encoded} - d, err := tx.Registry().Profile.SnapshotDigest(snap) - if err != nil { - return err + 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 } - snap.SnapshotDigest = d - return tx.SaveSnapshot(snap) + 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. @@ -255,10 +248,12 @@ func (c JSONStateCodec[T]) Encode(value any) (jsonstable.Value, error) { func (JSONStateCodec[T]) Decode(wire jsonstable.Value) (any, error) { var v T if wire.IsZero() { - return nil, errors.New("empty snapshot state") + 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 index 2da9eae..d937b20 100644 --- a/agent/session/extension/registry.go +++ b/agent/session/extension/registry.go @@ -1,7 +1,8 @@ // Package extension is the Session Module Framework -// (docs/design/agent-session-extension.md): typed event codecs with payload -// versions, Binding admission, the single write path (SemanticAppender), -// pure projections and the Lease facility built on the control-plane KV. +// (docs/design/agent-session-extension.md, edition 2): 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 ( @@ -10,7 +11,6 @@ import ( "fmt" "strings" - "github.com/memohai/twilight/agent/es" "github.com/memohai/twilight/agent/jsonstable" "github.com/memohai/twilight/agent/session" ) @@ -38,6 +38,10 @@ type EventDefinition struct { 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 @@ -56,7 +60,6 @@ type ModuleDescriptor struct { type DecodedEvent struct { Event session.SessionEvent - Revision es.Revision // set by Fold; zero when decoded outside a commit ModuleID ModuleID Version PayloadVersion Value any @@ -66,7 +69,6 @@ type DecodedEvent struct { // Registry is the immutable index built once at startup (EXT-REG-1). type Registry struct { ProtocolVersion uint16 - Profile session.ProtocolProfile modules map[ModuleID]ModuleDescriptor events map[session.EventType]eventEntry @@ -89,11 +91,11 @@ type projectionEntry struct { } // BuildRegistry validates the module set and freezes the indexes. -func BuildRegistry(profile session.ProtocolProfile, modules ...ModuleDescriptor) (*Registry, error) { - if profile == nil { - return nil, errors.New("extension: registry: nil profile") +func BuildRegistry(protocolVersion uint16, modules ...ModuleDescriptor) (*Registry, error) { + if protocolVersion == 0 { + return nil, errors.New("extension: registry: zero protocol version") } - r := &Registry{ProtocolVersion: profile.Version(), Profile: profile, + r := &Registry{ProtocolVersion: protocolVersion, modules: make(map[ModuleID]ModuleDescriptor), events: make(map[session.EventType]eventEntry), projections: make(map[projectionKey]projectionEntry)} for _, m := range modules { if m.ID == "" || strings.Contains(string(m.ID), "/") { @@ -103,7 +105,7 @@ func BuildRegistry(profile session.ProtocolProfile, modules ...ModuleDescriptor) return nil, &Error{Code: ErrInvalid, Detail: fmt.Sprintf("duplicate module %q", m.ID)} } r.modules[m.ID] = m - prefix := session.EventType(fmt.Sprintf("%s/%s/", SourceTwilight, m.ID)) + prefix := ModulePrefix(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 %q", m.ID)} @@ -141,10 +143,9 @@ func BuildRegistry(profile session.ProtocolProfile, modules ...ModuleDescriptor) // checkRequirements enforces EXT-REG-4: registered dependencies, no cycles, // projection consumption within scope, and handled payload versions. func (r *Registry) checkRequirements() error { - // 1. registered and acyclic. state := make(map[ModuleID]int) // 0 unvisited, 1 visiting, 2 done - var visit func(ModuleID, []ModuleID) error - visit = func(id ModuleID, path []ModuleID) error { + var visit func(ModuleID) error + visit = func(id ModuleID) error { switch state[id] { case 1: return &Error{Code: ErrInvalid, Detail: fmt.Sprintf("module requirement cycle through %q", id)} @@ -166,7 +167,7 @@ func (r *Registry) checkRequirements() error { return &Error{Code: ErrInvalid, Type: typ, Detail: fmt.Sprintf("module %q handles versions %v but %q currently writes v%d", id, versions, req.Module, entry.def.Current)} } } - if err := visit(req.Module, append(path, id)); err != nil { + if err := visit(req.Module); err != nil { return err } } @@ -174,41 +175,35 @@ func (r *Registry) checkRequirements() error { return nil } for id := range r.modules { - if err := visit(id, nil); err != nil { + if err := visit(id); err != nil { return err } } - // 2. projections stay inside module + Requires. for k, p := range r.projections { - scope := map[ModuleID]struct{}{p.module: {}} - for _, req := range r.modules[p.module].Requires { - scope[req.Module] = struct{}{} - } - for _, typ := range append(append([]session.EventType(nil), p.def.Consumes...), p.def.Ignores...) { - owner, ok := r.ModuleForEvent(typ) + 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[owner]; !inScope { - return &Error{Code: ErrInvalid, Type: typ, Detail: fmt.Sprintf("projection %q consumes event of module %q outside its Requires", k.id, owner)} - } - } - for _, m := range p.def.RequireComplete { - if _, inScope := scope[m]; !inScope { - return &Error{Code: ErrInvalid, Detail: fmt.Sprintf("projection %q requires completeness of module %q outside its scope", k.id, m)} - } - } - for _, c := range p.def.Consumes { - for _, i := range p.def.Ignores { - if c == i { - return &Error{Code: ErrInvalid, Type: c, Detail: fmt.Sprintf("projection %q both consumes and ignores", k.id)} - } + if _, inScope := scope[entry.module]; !inScope { + return &Error{Code: ErrInvalid, Type: typ, Detail: fmt.Sprintf("projection %q consumes event of module %q outside its Requires", k.id, entry.module)} } } } 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(id ModuleID) map[ModuleID]struct{} { + scope := map[ModuleID]struct{}{id: {}} + for _, req := range r.modules[id].Requires { + scope[req.Module] = struct{}{} + } + return scope +} + func containsVersion(vs []PayloadVersion, v PayloadVersion) bool { for _, x := range vs { if x == v { @@ -223,24 +218,31 @@ func (r *Registry) LookupEvent(typ session.EventType) (ModuleID, EventDefinition return e.module, e.def, ok } -func (r *Registry) ModuleForEvent(typ session.EventType) (ModuleID, bool) { - e, ok := r.events[typ] - if ok { - return e.module, true - } - // An unregistered type still belongs to a module namespace by prefix. +// ModuleOf names the module an EventType belongs to by its +// twilight// prefix, registered or not; false when the prefix names no +// registered module. +func (r *Registry) ModuleOf(typ session.EventType) (ModuleID, bool) { parts := strings.SplitN(string(typ), "/", 3) if len(parts) == 3 && parts[0] == string(SourceTwilight) { if _, registered := r.modules[ModuleID(parts[1])]; registered { - return ModuleID(parts[1]), false + return ModuleID(parts[1]), true } } return "", false } -func (r *Registry) LookupProjection(id ProjectionID, v ProjectionVersion) (ProjectionDefinition, bool) { +func (r *Registry) LookupProjection(id ProjectionID, v ProjectionVersion) (ProjectionDefinition, ModuleID, bool) { e, ok := r.projections[projectionKey{id, v}] - return e.def, ok + 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. @@ -284,7 +286,7 @@ func (r *Registry) Decode(e session.SessionEvent) (DecodedEvent, error) { out := DecodedEvent{Event: e} module, def, ok := r.LookupEvent(e.Type) if !ok { - out.ModuleID, _ = r.ModuleForEvent(e.Type) + out.ModuleID, _ = r.ModuleOf(e.Type) out.Unknown = true return out, nil } @@ -409,13 +411,12 @@ func StrictDecode(wire jsonstable.Value, dst any) error { type ErrorCode string const ( - ErrInvalid ErrorCode = "invalid" - ErrUnknownEvent ErrorCode = "unknown_event" - ErrCodec ErrorCode = "codec" - ErrBinding ErrorCode = "binding" - ErrConflict ErrorCode = "conflict" - ErrStale ErrorCode = "stale" - ErrUnsupportedProfile ErrorCode = "unsupported_profile" + ErrInvalid ErrorCode = "invalid" + ErrUnknownEvent ErrorCode = "unknown_event" + ErrCodec ErrorCode = "codec" + ErrBinding ErrorCode = "binding" + ErrConflict ErrorCode = "conflict" + ErrOwnershipLost ErrorCode = "ownership_lost" ) type Error struct { diff --git a/agent/session/extension/writer.go b/agent/session/extension/writer.go new file mode 100644 index 0000000..390aedb --- /dev/null +++ b/agent/session/extension/writer.go @@ -0,0 +1,508 @@ +package extension + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/memohai/twilight/agent/artifact" + "github.com/memohai/twilight/agent/es" + "github.com/memohai/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/memory.go b/agent/session/memory.go index 61a0d56..877b293 100644 --- a/agent/session/memory.go +++ b/agent/session/memory.go @@ -3,40 +3,36 @@ package session import ( "context" "fmt" - "sort" - "strings" "sync" - - "github.com/memohai/twilight/agent/es" + "time" ) -// MemoryStore is the in-process reference Store. Each Session has its own -// critical section; the control-plane KV and snapshot tables share the -// Session's lock so a CommitIn transaction is atomic with respect to every -// other operation on the same Session. +// MemoryStore is the in-process reference Store. Ownership follows the +// database-adapter shape: an Open with TTL records a deadline that Heartbeat +// pushes and that a later Open may pass; TTL zero ownership lasts until Close. type MemoryStore struct { profile ProtocolProfile + now func() time.Time mu sync.RWMutex // guards sessions map sessions map[SessionID]*memorySession } type memorySession struct { - mu sync.Mutex - header SessionHeader - commits []SessionCommit - byID map[CommitID]int - snapshots map[snapshotKey]Snapshot - control map[ControlNamespace]map[string]ControlEntry - eventIDs map[EventID]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 + deadline time.Time // zero when the owner has no TTL } -type snapshotKey struct { - key ProjectionKey - version uint16 -} +// NewMemoryStore returns a MemoryStore using the wall clock. +func NewMemoryStore() *MemoryStore { return NewMemoryStoreWithClock(time.Now) } -func NewMemoryStore() *MemoryStore { - return &MemoryStore{profile: ProfileV1(), sessions: make(map[SessionID]*memorySession)} +// NewMemoryStoreWithClock lets tests drive ownership deadlines. +func NewMemoryStoreWithClock(now func() time.Time) *MemoryStore { + return &MemoryStore{profile: ProfileV1(), now: now, sessions: make(map[SessionID]*memorySession)} } func (m *MemoryStore) Profile() ProtocolProfile { return m.profile } @@ -58,7 +54,7 @@ func (m *MemoryStore) Create(ctx context.Context, req CreateRequest) (SessionHea if req.ProtocolVersion != m.profile.Version() { return SessionHeader{}, &Error{Code: ErrUnsupportedProfile, Operation: "create", SessionID: req.SessionID} } - header := SessionHeader{ProtocolVersion: req.ProtocolVersion, SessionID: req.SessionID, CausationID: req.CausationID, Metadata: req.Metadata} + 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 @@ -75,13 +71,7 @@ func (m *MemoryStore) Create(ctx context.Context, req CreateRequest) (SessionHea } return SessionHeader{}, newError(ErrConflict, "create", req.SessionID, "session exists with a different header") } - m.sessions[req.SessionID] = &memorySession{ - header: header, - byID: make(map[CommitID]int), - snapshots: make(map[snapshotKey]Snapshot), - control: make(map[ControlNamespace]map[string]ControlEntry), - eventIDs: make(map[EventID]struct{}), - } + m.sessions[req.SessionID] = &memorySession{header: header, byCommit: make(map[CommitID][2]int)} return header, nil } @@ -98,547 +88,222 @@ func (m *MemoryStore) Header(ctx context.Context, sid SessionID) (SessionHeader, return s.header, nil } -func (m *MemoryStore) Head(ctx context.Context, sid SessionID) (Head, error) { - if err := ctx.Err(); err != nil { - return Head{}, err - } - s, err := m.session(sid, "head") - if err != nil { - return Head{}, err +func (s *memorySession) head() Head { + if len(s.rows) == 0 { + return Head{Next: 0, Digest: s.header.HeaderDigest} } - s.mu.Lock() - defer s.mu.Unlock() - return s.head(), nil + last := &s.rows[len(s.rows)-1] + return Head{Next: last.Seq + 1, Digest: last.Digest} } -func (s *memorySession) head() Head { - if len(s.commits) == 0 { - return Head{Revision: 0, Digest: s.header.HeaderDigest} - } - last := s.commits[len(s.commits)-1] - return Head{Revision: last.Revision, Digest: last.CommitDigest} +// --- ownership ------------------------------------------------------------------ + +type memoryWriter struct { + store *MemoryStore + s *memorySession + epoch Epoch + ttl time.Duration } -func (m *MemoryStore) LookupCommit(ctx context.Context, sid SessionID, id CommitID) (SessionCommit, bool, error) { +func (m *MemoryStore) Open(ctx context.Context, sid SessionID, opts OpenOptions) (Writer, error) { if err := ctx.Err(); err != nil { - return SessionCommit{}, false, err + return nil, err + } + if opts.TTL < 0 { + return nil, newError(ErrInvalid, "open", sid, "negative TTL") } - s, err := m.session(sid, "lookup_commit") + s, err := m.session(sid, "open") if err != nil { - return SessionCommit{}, false, err + return nil, err } s.mu.Lock() defer s.mu.Unlock() - return s.lookup(id) -} - -func (s *memorySession) lookup(id CommitID) (SessionCommit, bool, error) { - i, ok := s.byID[id] - if !ok { - return SessionCommit{}, false, nil - } - return cloneCommit(&s.commits[i]), true, nil -} - -// --- transactions ----------------------------------------------------------- - -// memoryTx stages snapshot and KV writes; they are merged only when the -// transaction completes, so a failing fn leaves nothing behind. -type memoryTx struct { - s *memorySession - snapshots map[snapshotKey]Snapshot - control map[ControlNamespace]map[string]*ControlEntry // nil entry = delete - used bool -} - -func (s *memorySession) begin() *memoryTx { - return &memoryTx{s: s, snapshots: make(map[snapshotKey]Snapshot), control: make(map[ControlNamespace]map[string]*ControlEntry)} -} - -func (tx *memoryTx) Head() Head { return tx.s.head() } - -func (tx *memoryTx) LookupCommit(id CommitID) (SessionCommit, bool, error) { return tx.s.lookup(id) } - -func (tx *memoryTx) Tail(after Head, types []EventType) ([]SessionCommit, error) { - return tx.s.tail(after, types) -} - -func (s *memorySession) tail(after Head, types []EventType) ([]SessionCommit, error) { - if after.Revision > 0 { - if int(after.Revision) > len(s.commits) { - return nil, newError(ErrInvalid, "tail", s.header.SessionID, "after is beyond head") - } - if s.commits[after.Revision-1].CommitDigest != after.Digest { - return nil, newError(ErrInvalid, "tail", s.header.SessionID, "after digest is not on this stream") - } - } else if after.Digest != "" && after.Digest != s.header.HeaderDigest { - return nil, newError(ErrInvalid, "tail", s.header.SessionID, "after digest is not this header") - } - var out []SessionCommit - for i := int(after.Revision); i < len(s.commits); i++ { - if CommitMatchesTypes(&s.commits[i], types) { - out = append(out, cloneCommit(&s.commits[i])) + if s.owner != nil { + if s.deadline.IsZero() || m.now().Before(s.deadline) { + return nil, newError(ErrOwned, "open", sid, fmt.Sprintf("owned by epoch %d", s.epoch)) } } - return out, nil -} - -func (tx *memoryTx) LoadSnapshot(key ProjectionKey, version uint16) (SnapshotResult, error) { - k := snapshotKey{key, version} - if snap, ok := tx.snapshots[k]; ok { - return SnapshotResult{Snapshot: &snap, Found: true}, nil + s.epoch++ + w := &memoryWriter{store: m, s: s, epoch: s.epoch, ttl: opts.TTL} + s.owner = w + if opts.TTL > 0 { + s.deadline = m.now().Add(opts.TTL) + } else { + s.deadline = time.Time{} } - return tx.s.loadSnapshot(k), nil + return w, nil } -func (s *memorySession) loadSnapshot(k snapshotKey) SnapshotResult { - snap, ok := s.snapshots[k] - if !ok { - return SnapshotResult{} - } - return SnapshotResult{Snapshot: &snap, Found: true} -} +func (w *memoryWriter) SessionID() SessionID { return w.s.header.SessionID } +func (w *memoryWriter) Epoch() Epoch { return w.epoch } -func (tx *memoryTx) SaveSnapshot(snap Snapshot) error { - if err := tx.s.checkSnapshot(&snap); err != nil { - return err - } - tx.snapshots[snapshotKey{snap.ProjectionKey, snap.ProjectionVersion}] = snap - return nil +func (w *memoryWriter) Head() Head { + w.s.mu.Lock() + defer w.s.mu.Unlock() + return w.s.head() } -func (s *memorySession) checkSnapshot(snap *Snapshot) error { - if snap.SessionID != s.header.SessionID { - return newError(ErrInvalid, "save_snapshot", s.header.SessionID, "snapshot session mismatch") - } - if snap.ProjectionKey == "" || snap.State.IsZero() { - return newError(ErrInvalid, "save_snapshot", s.header.SessionID, "empty projection key or state") +// 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 (tx *memoryTx) ControlGet(ns ControlNamespace, key string) (ControlEntry, bool, error) { - if staged, ok := tx.control[ns][key]; ok { - if staged == nil { - return ControlEntry{}, false, nil - } - return cloneEntry(*staged), true, nil - } - return tx.s.controlGet(ns, key) -} - -func (s *memorySession) controlGet(ns ControlNamespace, key string) (ControlEntry, bool, error) { - e, ok := s.control[ns][key] - if !ok { - return ControlEntry{}, false, nil +func (w *memoryWriter) Heartbeat(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err } - return cloneEntry(e), true, nil -} - -func (tx *memoryTx) ControlPut(ns ControlNamespace, key string, value []byte, deadline int64) error { - if ns == "" || key == "" { - return newError(ErrInvalid, "control_put", tx.s.header.SessionID, "empty namespace or key") + w.s.mu.Lock() + defer w.s.mu.Unlock() + if err := w.current("heartbeat"); err != nil { + return err } - if tx.control[ns] == nil { - tx.control[ns] = make(map[string]*ControlEntry) + if w.ttl > 0 { + w.s.deadline = w.store.now().Add(w.ttl) } - e := ControlEntry{SessionID: tx.s.header.SessionID, Namespace: ns, Key: key, Value: append([]byte(nil), value...), DeadlineUnixMilli: deadline} - tx.control[ns][key] = &e return nil } -func (tx *memoryTx) ControlDelete(ns ControlNamespace, key string) error { - if tx.control[ns] == nil { - tx.control[ns] = make(map[string]*ControlEntry) +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 + w.s.deadline = time.Time{} } - tx.control[ns][key] = nil return nil } -// merge applies the staged writes; called with the session lock held after -// the append (if any) succeeded. -func (tx *memoryTx) merge() { - for k, snap := range tx.snapshots { - tx.s.snapshots[k] = snap - } - for ns, entries := range tx.control { - if tx.s.control[ns] == nil { - tx.s.control[ns] = make(map[string]ControlEntry) - } - for key, e := range entries { - if e == nil { - delete(tx.s.control[ns], key) - } else { - tx.s.control[ns][key] = *e - } - } - } -} - -func (m *MemoryStore) CommitIn(ctx context.Context, sid SessionID, fn CommitInFn) (AppendResult, error) { - if err := ctx.Err(); err != nil { - return AppendResult{}, err - } - if fn == nil { - return AppendResult{}, newError(ErrInvalid, "commit_in", sid, "nil fn") - } - s, err := m.session(sid, "commit_in") - if err != nil { - return AppendResult{}, err - } - s.mu.Lock() - defer s.mu.Unlock() - tx := s.begin() - req, err := fn(tx) - if err != nil { - return AppendResult{}, err - } - if req == nil { - tx.merge() - return AppendResult{Disposition: AppendApplied, ActualHead: s.head()}, nil - } - if req.SessionID != sid { - return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: "append session mismatch"}, nil - } - if req.ExpectedHead != s.head() { - return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: "ExpectedHead does not match the head inside the critical section"}, nil - } - res, err := m.append(s, req) - if err != nil { - return AppendResult{}, err - } - if res.Disposition == AppendApplied || res.Disposition == AppendAlreadyApplied { - tx.merge() - } - return res, nil -} +// --- append ----------------------------------------------------------------------- -func (m *MemoryStore) Commit(ctx context.Context, req AppendRequest) (AppendResult, error) { +func (w *memoryWriter) Append(ctx context.Context, g Group) ([]SessionEvent, error) { if err := ctx.Err(); err != nil { - return AppendResult{}, err - } - s, err := m.session(req.SessionID, "commit") - if err != nil { - return AppendResult{}, err - } - s.mu.Lock() - defer s.mu.Unlock() - // Idempotent replay is decided before the head compare so a retry after - // a lost response returns AlreadyApplied even if the head has moved. - if existing, ok, _ := s.lookup(req.CommitID); ok { - return m.replayDisposition(s, &existing, &req) + return nil, err } - if req.ExpectedHead != s.head() { - return AppendResult{Disposition: AppendHeadConflict, ActualHead: s.head()}, nil + sid := w.s.header.SessionID + if g.CommitID == "" { + return nil, newError(ErrInvalid, "append", sid, "empty CommitID") } - return m.append(s, &req) -} - -func (m *MemoryStore) replayDisposition(s *memorySession, existing *SessionCommit, req *AppendRequest) (AppendResult, error) { - have, err := fingerprintCommit(m.profile, existing) - if err != nil { - return AppendResult{}, err + if err := validIdentity("CommitID", string(g.CommitID)); err != nil { + return nil, newError(ErrInvalid, "append", sid, err.Error()) } - want, err := m.profile.FingerprintAppend(*req) - if err != nil { - return AppendResult{}, err + if len(g.Events) == 0 { + return nil, newError(ErrInvalid, "append", sid, "empty group") } - if have == want { - c := cloneCommit(existing) - return AppendResult{Disposition: AppendAlreadyApplied, Commit: &c, ActualHead: s.head()}, nil + if len(g.Events) > int(^uint16(0)) { + return nil, newError(ErrInvalid, "append", sid, "group too large") } - return AppendResult{Disposition: AppendCommitConflict, ActualHead: s.head()}, nil -} - -// append seals and stores one commit; the caller holds the session lock and -// has already checked ExpectedHead. -func (m *MemoryStore) append(s *memorySession, req *AppendRequest) (AppendResult, error) { - if existing, ok, _ := s.lookup(req.CommitID); ok { - return m.replayDisposition(s, &existing, req) - } - if req.CommitID == "" { - return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: "empty CommitID"}, nil - } - if len(req.Events) == 0 { - return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: "empty event group"}, nil - } - seen := make(map[EventID]struct{}, len(req.Events)) - for i := range req.Events { - e := &req.Events[i] - if err := validateUncommitted(e); err != nil { - return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: fmt.Sprintf("event %d: %v", i, err)}, nil - } - if _, dup := seen[e.EventID]; dup { - return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: "duplicate EventID in group"}, nil - } - if _, dup := s.eventIDs[e.EventID]; dup { - return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: "EventID already in stream"}, nil - } - seen[e.EventID] = struct{}{} - for _, src := range e.SourceEvents { - if _, ok := s.eventIDs[src]; !ok { - if _, inGroup := seen[src]; !inGroup { - return AppendResult{Disposition: AppendInvalid, ActualHead: s.head(), Detail: "SourceEvents references an unknown EventID"}, nil - } - } + 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)) } } - head := s.head() - commit := SessionCommit{ - ProtocolVersion: m.profile.Version(), - SessionID: req.SessionID, - Revision: head.Revision + 1, - PreviousDigest: head.Digest, - CommitID: req.CommitID, - CausationID: req.CausationID, - CorrelationID: req.CorrelationID, - Events: make([]SessionEvent, len(req.Events)), - } - for i := range req.Events { - e := &req.Events[i] - ev := SessionEvent{EventID: e.EventID, Index: uint16(i), Type: e.Type, RecordedAtUnixMilli: e.RecordedAtUnixMilli, - SourceEvents: append([]EventID(nil), e.SourceEvents...), Payload: e.Payload} - d, err := m.profile.EventDigest(commit.SessionID, commit.Revision, ev) + 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 AppendResult{}, err + return nil, err } - ev.EventDigest = d - commit.Events[i] = ev + row.Digest = d + prev = d + rows[i] = row } - d, err := m.profile.CommitDigest(commit) - if err != nil { - return AppendResult{}, err - } - commit.CommitDigest = d - s.commits = append(s.commits, commit) - s.byID[commit.CommitID] = len(s.commits) - 1 - for i := range commit.Events { - s.eventIDs[commit.Events[i].EventID] = struct{}{} - } - out := cloneCommit(&commit) - return AppendResult{Disposition: AppendApplied, Commit: &out, ActualHead: s.head()}, nil + 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 } -// --- replay ----------------------------------------------------------------- +// --- read -------------------------------------------------------------------------- -func (m *MemoryStore) Replay(ctx context.Context, req ReplayRequest) (ReplayPage, error) { +func (m *MemoryStore) Read(ctx context.Context, req ReadRequest) (ReadPage, error) { if err := ctx.Err(); err != nil { - return ReplayPage{}, err + return ReadPage{}, err } - s, err := m.session(req.SessionID, "replay") + s, err := m.session(req.SessionID, "read") if err != nil { - return ReplayPage{}, err + return ReadPage{}, err } s.mu.Lock() defer s.mu.Unlock() - start := 0 - if req.Cursor != nil && req.Cursor.After != nil { - after := req.Cursor.After - if after.Revision == 0 || int(after.Revision) > len(s.commits) { - return ReplayPage{}, newError(ErrInvalid, "replay", req.SessionID, "cursor revision is not on this stream") + // The Memory adapter verifies the whole chain on every read; a durable + // adapter verifies the chain only on unfiltered reads (SES-REP-1). + if err := ValidateChain(m.profile, s.header, s.rows); err != nil { + return ReadPage{}, err + } + 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++ } - c := &s.commits[after.Revision-1] - if int(after.Index) >= len(c.Events) || c.Events[after.Index].EventDigest != after.EventDigest { - return ReplayPage{}, newError(ErrInvalid, "replay", req.SessionID, "cursor event is not on this stream") + if end >= len(s.rows) { + break // incomplete tail group is never exposed (SES-APP-2) } - if int(after.Index) != len(c.Events)-1 { - return ReplayPage{}, newError(ErrInvalid, "replay", req.SessionID, "cursor must address the last event of a complete commit") - } - start = int(after.Revision) - } - page := ReplayPage{Header: s.header, Head: s.head()} - var prev es.Digest - if start == 0 { - prev = s.header.HeaderDigest - } else { - prev = s.commits[start-1].CommitDigest - } - for i := start; i < len(s.commits); i++ { - c := &s.commits[i] - if len(req.Types) == 0 { - if c.Revision != es.Revision(i+1) || c.PreviousDigest != prev { - return ReplayPage{}, newError(ErrCorrupt, "replay", req.SessionID, fmt.Sprintf("chain broken at revision %d", i+1)) - } - if err := m.profile.ValidateCommit(*c); err != nil { - return ReplayPage{}, err + 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]) } - prev = c.CommitDigest - } - if !CommitMatchesTypes(c, req.Types) { - continue } - page.Commits = append(page.Commits, cloneCommit(c)) - if req.Limit > 0 && uint32(len(page.Commits)) >= req.Limit && i+1 < len(s.commits) { - last := &c.Events[len(c.Events)-1] - page.Next = &ReplayCursor{After: &EventPosition{Revision: c.Revision, Index: last.Index, EventDigest: last.EventDigest}} - break + 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 } -// --- snapshots ---------------------------------------------------------------- - -func (m *MemoryStore) LoadSnapshot(ctx context.Context, req SnapshotRequest) (SnapshotResult, error) { - if err := ctx.Err(); err != nil { - return SnapshotResult{}, err - } - s, err := m.session(req.SessionID, "load_snapshot") - if err != nil { - return SnapshotResult{}, err - } - s.mu.Lock() - defer s.mu.Unlock() - return s.loadSnapshot(snapshotKey{req.ProjectionKey, req.ProjectionVersion}), nil -} - -func (m *MemoryStore) SaveSnapshot(ctx context.Context, req SaveSnapshotRequest) (SaveSnapshotResult, error) { - if err := ctx.Err(); err != nil { - return SaveSnapshotResult{}, err - } - s, err := m.session(req.Snapshot.SessionID, "save_snapshot") - if err != nil { - return SaveSnapshotResult{}, err - } - s.mu.Lock() - defer s.mu.Unlock() - if err := s.checkSnapshot(&req.Snapshot); err != nil { - return SaveSnapshotResult{}, err - } - k := snapshotKey{req.Snapshot.ProjectionKey, req.Snapshot.ProjectionVersion} - _, replaced := s.snapshots[k] - s.snapshots[k] = req.Snapshot - return SaveSnapshotResult{Snapshot: req.Snapshot, Replaced: replaced}, nil -} - -// --- control-plane KV ----------------------------------------------------------- - -func (m *MemoryStore) ControlGet(ctx context.Context, sid SessionID, ns ControlNamespace, key string) (ControlEntry, bool, error) { - if err := ctx.Err(); err != nil { - return ControlEntry{}, false, err - } - s, err := m.session(sid, "control_get") - if err != nil { - return ControlEntry{}, false, err - } - s.mu.Lock() - defer s.mu.Unlock() - return s.controlGet(ns, key) -} - -func (m *MemoryStore) ControlPut(ctx context.Context, sid SessionID, ns ControlNamespace, key string, value []byte, deadline int64) error { - if err := ctx.Err(); err != nil { - return err - } - s, err := m.session(sid, "control_put") +// Tamper mutates one stored row in place. It exists so conformance can prove +// that the chain check 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 err + return } s.mu.Lock() defer s.mu.Unlock() - tx := s.begin() - if err := tx.ControlPut(ns, key, value, deadline); err != nil { - return err + if int(seq) < len(s.rows) { + mutate(&s.rows[seq]) } - tx.merge() - return nil } -func (m *MemoryStore) ControlCompareAndPut(ctx context.Context, sid SessionID, ns ControlNamespace, key string, expected, value []byte, deadline int64) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - s, err := m.session(sid, "control_cas") - if err != nil { - return false, err - } - s.mu.Lock() - defer s.mu.Unlock() - cur, ok := s.control[ns][key] - if !ok || string(cur.Value) != string(expected) { - return false, nil - } - tx := s.begin() - if err := tx.ControlPut(ns, key, value, deadline); err != nil { - return false, err - } - tx.merge() - return true, nil -} - -func (m *MemoryStore) ControlDelete(ctx context.Context, sid SessionID, ns ControlNamespace, key string) error { - if err := ctx.Err(); err != nil { - return err - } - s, err := m.session(sid, "control_delete") - if err != nil { - return err - } - s.mu.Lock() - defer s.mu.Unlock() - delete(s.control[ns], key) - return nil -} - -func (m *MemoryStore) ControlScan(ctx context.Context, ns ControlNamespace, prefix string, fn func(ControlEntry) (bool, error)) error { - return m.scan(ctx, ns, func(e ControlEntry) bool { return strings.HasPrefix(e.Key, prefix) }, fn) -} - -func (m *MemoryStore) ControlExpired(ctx context.Context, ns ControlNamespace, before int64, fn func(ControlEntry) (bool, error)) error { - return m.scan(ctx, ns, func(e ControlEntry) bool { return e.DeadlineUnixMilli != 0 && e.DeadlineUnixMilli < before }, fn) -} - -func (m *MemoryStore) scan(ctx context.Context, ns ControlNamespace, match func(ControlEntry) bool, fn func(ControlEntry) (bool, error)) error { - if err := ctx.Err(); err != nil { - return err - } - m.mu.RLock() - sids := make([]SessionID, 0, len(m.sessions)) - for sid := range m.sessions { - sids = append(sids, sid) - } - m.mu.RUnlock() - sort.Slice(sids, func(i, j int) bool { return sids[i] < sids[j] }) - for _, sid := range sids { - s, err := m.session(sid, "control_scan") - if err != nil { - continue - } - s.mu.Lock() - var matched []ControlEntry - for _, e := range s.control[ns] { - if match(e) { - matched = append(matched, cloneEntry(e)) - } - } - s.mu.Unlock() - sort.Slice(matched, func(i, j int) bool { return matched[i].Key < matched[j].Key }) - for _, e := range matched { - cont, err := fn(e) - if err != nil { - return err - } - if !cont { - return nil - } - } - } - return nil -} - -func cloneCommit(c *SessionCommit) SessionCommit { - out := *c - out.Events = make([]SessionEvent, len(c.Events)) - for i := range c.Events { - out.Events[i] = c.Events[i] - out.Events[i].SourceEvents = append([]EventID(nil), c.Events[i].SourceEvents...) +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 } -func cloneEntry(e ControlEntry) ControlEntry { - e.Value = append([]byte(nil), e.Value...) - return e -} - var _ Store = (*MemoryStore)(nil) diff --git a/agent/session/memory_test.go b/agent/session/memory_test.go index ee78347..4817cb1 100644 --- a/agent/session/memory_test.go +++ b/agent/session/memory_test.go @@ -1,152 +1,17 @@ -package session +package session_test import ( - "context" "testing" + "time" - "github.com/memohai/twilight/agent/jsonstable" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/sessiontest" ) -func newSession(t *testing.T) (*MemoryStore, SessionID) { - t.Helper() - store := NewMemoryStore() - if _, err := store.Create(context.Background(), CreateRequest{ProtocolVersion: ProtocolVersion1, SessionID: "s"}); err != nil { - t.Fatal(err) - } - return store, "s" -} - -func ev(id, typ, payload string) UncommittedEvent { - return UncommittedEvent{EventID: EventID(id), Type: EventType(typ), Payload: jsonstable.MustParse(payload), RecordedAtUnixMilli: 1} -} - -func TestCreateIsIdempotentAndConflicts(t *testing.T) { - store, _ := newSession(t) - ctx := context.Background() - if _, err := store.Create(ctx, CreateRequest{ProtocolVersion: ProtocolVersion1, SessionID: "s"}); err != nil { - t.Fatalf("identical create: %v", err) - } - if _, err := store.Create(ctx, CreateRequest{ProtocolVersion: ProtocolVersion1, SessionID: "s", CausationID: "other"}); !IsCode(err, ErrConflict) { - t.Fatalf("different create: %v, want conflict", err) - } - if _, err := store.Create(ctx, CreateRequest{ProtocolVersion: 9, SessionID: "x"}); !IsCode(err, ErrUnsupportedProfile) { - t.Fatalf("unsupported profile: %v", err) - } -} - -// Commit is CAS; a retry of the same CommitID is AlreadyApplied even with a -// different timestamp and a moved head; a same-ID different group conflicts; -// CommitIn produces an equivalent commit. -func TestCommitIdempotencyAndConflicts(t *testing.T) { - store, sid := newSession(t) - ctx := context.Background() - head, _ := store.Head(ctx, sid) - if head.Revision != 0 || head.Digest == "" { - t.Fatalf("empty head = %+v", head) - } - first := AppendRequest{SessionID: sid, ExpectedHead: head, CommitID: "c1", Events: []UncommittedEvent{ev("e1", "twilight/x/a", `{"v":1}`)}} - res, err := store.Commit(ctx, first) - if err != nil || res.Disposition != AppendApplied || res.Commit.Revision != 1 { - t.Fatalf("first commit = %+v, %v", res, err) - } - stale, _ := store.Commit(ctx, AppendRequest{SessionID: sid, ExpectedHead: head, CommitID: "c2", Events: []UncommittedEvent{ev("e2", "twilight/x/a", `{"v":1}`)}}) - if stale.Disposition != AppendHeadConflict || stale.ActualHead.Revision != 1 { - t.Fatalf("stale commit = %+v", stale) - } - retry := first - retry.Events = []UncommittedEvent{ev("e1", "twilight/x/a", `{"v":1}`)} - retry.Events[0].RecordedAtUnixMilli = 99 - again, _ := store.Commit(ctx, retry) - if again.Disposition != AppendAlreadyApplied || again.Commit.Events[0].RecordedAtUnixMilli != 1 { - t.Fatalf("retry = %+v", again) - } - conflict, _ := store.Commit(ctx, AppendRequest{SessionID: sid, ExpectedHead: res.ActualHead, CommitID: "c1", Events: []UncommittedEvent{ev("e9", "twilight/x/a", `{"v":2}`)}}) - if conflict.Disposition != AppendCommitConflict { - t.Fatalf("same id different group = %+v", conflict) - } - // CommitIn: KV write in the same transaction; fn nil still commits KV. - inRes, err := store.CommitIn(ctx, sid, func(tx SessionTx) (*AppendRequest, error) { - if err := tx.ControlPut("ns", "k", []byte("v"), 0); err != nil { - return nil, err - } - return &AppendRequest{SessionID: sid, ExpectedHead: tx.Head(), CommitID: "c3", Events: []UncommittedEvent{ev("e3", "twilight/y/b", `{"v":1}`)}}, nil +func TestMemoryStoreConformance(t *testing.T) { + sessiontest.Run(t, func(t *testing.T) sessiontest.Fixture { + now := time.Unix(1_700_000_000, 0) + store := session.NewMemoryStoreWithClock(func() time.Time { return now }) + return sessiontest.Fixture{Store: store, Advance: func(d time.Duration) { now = now.Add(d) }} }) - if err != nil || inRes.Disposition != AppendApplied || inRes.Commit.Revision != 2 { - t.Fatalf("commit in = %+v, %v", inRes, err) - } - if entry, ok, _ := store.ControlGet(ctx, sid, "ns", "k"); !ok || string(entry.Value) != "v" { - t.Fatal("KV write did not commit with the transaction") - } - noop, err := store.CommitIn(ctx, sid, func(tx SessionTx) (*AppendRequest, error) { - return nil, tx.ControlDelete("ns", "k") - }) - if err != nil || noop.Disposition != AppendApplied || noop.Commit != nil { - t.Fatalf("noop = %+v, %v", noop, err) - } - if _, ok, _ := store.ControlGet(ctx, sid, "ns", "k"); ok { - t.Fatal("nil fn did not commit its KV delete") - } - // Chain and replay filter. - page, err := store.Replay(ctx, ReplayRequest{SessionID: sid}) - if err != nil || len(page.Commits) != 2 || page.Commits[1].PreviousDigest != page.Commits[0].CommitDigest { - t.Fatalf("replay = %+v, %v", page, err) - } - filtered, _ := store.Replay(ctx, ReplayRequest{SessionID: sid, Types: []EventType{"twilight/y/"}}) - if len(filtered.Commits) != 1 || filtered.Commits[0].CommitID != "c3" { - t.Fatalf("filtered = %+v", filtered.Commits) - } -} - -func TestControlCompareAndPutAndExpired(t *testing.T) { - store, sid := newSession(t) - ctx := context.Background() - if ok, _ := store.ControlCompareAndPut(ctx, sid, "ns", "missing", nil, []byte("x"), 0); ok { - t.Fatal("CAS wrote a missing entry") - } - _ = store.ControlPut(ctx, sid, "ns", "k", []byte("a"), 10) - if ok, _ := store.ControlCompareAndPut(ctx, sid, "ns", "k", []byte("b"), []byte("c"), 20); ok { - t.Fatal("CAS wrote with a stale expected value") - } - if ok, _ := store.ControlCompareAndPut(ctx, sid, "ns", "k", []byte("a"), []byte("a"), 20); !ok { - t.Fatal("CAS refused a matching expected value") - } - _ = store.ControlPut(ctx, sid, "ns", "never", []byte("n"), 0) - var seen []string - _ = store.ControlExpired(ctx, "ns", 25, func(e ControlEntry) (bool, error) { seen = append(seen, e.Key); return true, nil }) - if len(seen) != 1 || seen[0] != "k" { - t.Fatalf("expired = %v, want [k]", seen) - } - seen = nil - _ = store.ControlExpired(ctx, "ns", 15, func(e ControlEntry) (bool, error) { seen = append(seen, e.Key); return true, nil }) - if len(seen) != 0 { - t.Fatalf("renewed entry reported expired: %v", seen) - } -} - -func TestSnapshotThroughMustBeAPrefix(t *testing.T) { - store, sid := newSession(t) - ctx := context.Background() - head, _ := store.Head(ctx, sid) - res, _ := store.Commit(ctx, AppendRequest{SessionID: sid, ExpectedHead: head, CommitID: "c1", Events: []UncommittedEvent{ev("e1", "twilight/x/a", `{}`)}}) - snap := Snapshot{ProtocolVersion: ProtocolVersion1, SessionID: sid, ProjectionKey: "p", ProjectionVersion: 1, Through: res.ActualHead, State: jsonstable.MustParse(`{"n":1}`)} - d, _ := ProfileV1().SnapshotDigest(snap) - snap.SnapshotDigest = d - if _, err := store.SaveSnapshot(ctx, SaveSnapshotRequest{Snapshot: snap}); err != nil { - t.Fatal(err) - } - loaded, _ := store.LoadSnapshot(ctx, SnapshotRequest{SessionID: sid, ProjectionKey: "p", ProjectionVersion: 1}) - if !loaded.Found || loaded.Snapshot.Through != res.ActualHead { - t.Fatalf("snapshot = %+v", loaded) - } - tampered := *loaded.Snapshot - tampered.Through.Digest = "sha256:bogus" - if err := ProfileV1().ValidateSnapshot(tampered); err == nil { - t.Fatal("tampered snapshot validated") - } - if _, err := store.CommitIn(ctx, sid, func(tx SessionTx) (*AppendRequest, error) { - _, err := tx.Tail(tampered.Through, nil) - return nil, err - }); !IsCode(err, ErrInvalid) { - t.Fatalf("tail from a non-prefix head: %v, want invalid", err) - } } diff --git a/agent/session/profile.go b/agent/session/profile.go index f1a138a..8706110 100644 --- a/agent/session/profile.go +++ b/agent/session/profile.go @@ -4,7 +4,6 @@ import ( "bytes" "errors" "fmt" - "sort" "unicode/utf8" "github.com/memohai/twilight/agent/es" @@ -12,17 +11,14 @@ import ( ) // ProtocolProfile freezes the kernel wire for one ProtocolVersion -// (SES-WIR-2): encodings, digest preimages and the append fingerprint. +// (SES-WIR-2): the header digest and the per-row chained digest. type ProtocolProfile interface { Version() uint16 HeaderDigest(SessionHeader) (es.Digest, error) - EventDigest(SessionID, es.Revision, SessionEvent) (es.Digest, error) - CommitDigest(SessionCommit) (es.Digest, error) - SnapshotDigest(Snapshot) (es.Digest, error) - FingerprintAppend(AppendRequest) (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 - ValidateCommit(SessionCommit) error - ValidateSnapshot(Snapshot) error } // ProfileV1 returns the ProtocolVersion1 profile. @@ -41,97 +37,36 @@ type profileV1 struct{} func (profileV1) Version() uint16 { return ProtocolVersion1 } type headerDigestBody struct { - ProtocolVersion uint16 `json:"protocolVersion"` - SessionID SessionID `json:"sessionId"` - CausationID es.CausationID `json:"causationId,omitempty"` - Metadata jsonstable.Value `json:"metadata,omitempty"` + 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.CausationID, h.Metadata}) + 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"` - Revision es.Revision `json:"revision"` + Seq Seq `json:"seq"` + CommitID CommitID `json:"commitId"` Index uint16 `json:"index"` - EventID EventID `json:"eventId"` + Last bool `json:"last"` Type EventType `json:"type"` RecordedAtUnixMilli int64 `json:"recordedAtUnixMilli"` - SourceEvents []EventID `json:"sourceEvents,omitempty"` + SourceSeqs []Seq `json:"sourceSeqs,omitempty"` + Ignorable bool `json:"ignorable,omitempty"` Payload jsonstable.Value `json:"payload"` } -func (profileV1) EventDigest(sid SessionID, rev es.Revision, e SessionEvent) (es.Digest, error) { - return digestDomain("twilight/session/event", eventDigestBody{sid, rev, e.Index, e.EventID, e.Type, e.RecordedAtUnixMilli, e.SourceEvents, e.Payload}) -} - -type commitDigestBody struct { - ProtocolVersion uint16 `json:"protocolVersion"` - SessionID SessionID `json:"sessionId"` - Revision es.Revision `json:"revision"` - PreviousDigest es.Digest `json:"previousDigest"` - CommitID CommitID `json:"commitId"` - CausationID es.CausationID `json:"causationId,omitempty"` - CorrelationID string `json:"correlationId,omitempty"` - Events []es.Digest `json:"events"` -} - -func (profileV1) CommitDigest(c SessionCommit) (es.Digest, error) { - digests := make([]es.Digest, len(c.Events)) - for i := range c.Events { - digests[i] = c.Events[i].EventDigest - } - return digestDomain("twilight/session/commit", commitDigestBody{c.ProtocolVersion, c.SessionID, c.Revision, c.PreviousDigest, c.CommitID, c.CausationID, c.CorrelationID, digests}) -} - -type snapshotDigestBody struct { - ProtocolVersion uint16 `json:"protocolVersion"` - SessionID SessionID `json:"sessionId"` - ProjectionKey ProjectionKey `json:"projectionKey"` - ProjectionVersion uint16 `json:"projectionVersion"` - Through Head `json:"through"` - State jsonstable.Value `json:"state"` -} - -func (profileV1) SnapshotDigest(s Snapshot) (es.Digest, error) { - return digestDomain("twilight/session/snapshot", snapshotDigestBody{s.ProtocolVersion, s.SessionID, s.ProjectionKey, s.ProjectionVersion, s.Through, s.State}) -} - -type fingerprintEvent struct { - EventID EventID `json:"eventId"` - Type EventType `json:"type"` - SourceEvents []EventID `json:"sourceEvents,omitempty"` - Payload jsonstable.Value `json:"payload"` -} - -type fingerprintBody struct { - SessionID SessionID `json:"sessionId"` - CommitID CommitID `json:"commitId"` - CausationID es.CausationID `json:"causationId,omitempty"` - CorrelationID string `json:"correlationId,omitempty"` - Events []fingerprintEvent `json:"events"` -} - -// FingerprintAppend covers everything that makes a retry "the same append": -// not ExpectedHead and not RecordedAtUnixMilli (SES-APP-1). -func (profileV1) FingerprintAppend(r AppendRequest) (es.Digest, error) { - events := make([]fingerprintEvent, len(r.Events)) - for i, e := range r.Events { - events[i] = fingerprintEvent{e.EventID, e.Type, e.SourceEvents, e.Payload} - } - return digestDomain("twilight/session/append", fingerprintBody{r.SessionID, r.CommitID, r.CausationID, r.CorrelationID, events}) -} - -func fingerprintCommit(p ProtocolProfile, c *SessionCommit) (es.Digest, error) { - events := make([]UncommittedEvent, len(c.Events)) - for i, e := range c.Events { - events[i] = UncommittedEvent{EventID: e.EventID, Type: e.Type, SourceEvents: e.SourceEvents, Payload: e.Payload} - } - return p.FingerprintAppend(AppendRequest{SessionID: c.SessionID, CommitID: c.CommitID, CausationID: c.CausationID, CorrelationID: c.CorrelationID, Events: events}) +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 { @@ -154,56 +89,30 @@ func (p profileV1) ValidateHeader(h SessionHeader) error { return nil } -func (p profileV1) ValidateCommit(c SessionCommit) error { - if c.ProtocolVersion != ProtocolVersion1 { - return &Error{Code: ErrUnsupportedProfile, Operation: "commit", SessionID: c.SessionID, CommitID: c.CommitID} - } - if c.Revision == 0 || len(c.Events) == 0 { - return &Error{Code: ErrCorrupt, Operation: "commit", SessionID: c.SessionID, CommitID: c.CommitID, Detail: "empty commit or zero revision"} - } - for i := range c.Events { - e := &c.Events[i] - if int(e.Index) != i { - return &Error{Code: ErrCorrupt, Operation: "commit", SessionID: c.SessionID, CommitID: c.CommitID, Detail: "event index gap"} +// 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(c.SessionID, c.Revision, *e) + want, err := p.EventDigest(prev, header.SessionID, *r) if err != nil { return err } - if e.EventDigest != want { - return &Error{Code: ErrCorrupt, Operation: "commit", SessionID: c.SessionID, CommitID: c.CommitID, Detail: fmt.Sprintf("event %d digest mismatch", i)} + 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)} } - } - want, err := p.CommitDigest(c) - if err != nil { - return err - } - if c.CommitDigest != want { - return &Error{Code: ErrCorrupt, Operation: "commit", SessionID: c.SessionID, CommitID: c.CommitID, Detail: "commit digest mismatch"} - } - return nil -} - -func (p profileV1) ValidateSnapshot(s Snapshot) error { - if s.ProtocolVersion != ProtocolVersion1 { - return &Error{Code: ErrUnsupportedProfile, Operation: "snapshot", SessionID: s.SessionID} - } - want, err := p.SnapshotDigest(s) - if err != nil { - return err - } - if s.SnapshotDigest != want { - return newError(ErrCorrupt, "snapshot", s.SessionID, "snapshot digest mismatch") + prev = r.Digest } return nil } -// validateUncommitted checks one event of an append group before it is -// sealed: identities, canonical payload and sorted-unique SourceEvents. -func validateUncommitted(e *UncommittedEvent) error { - if err := validIdentity("EventID", string(e.EventID)); err != nil { - return err - } +// 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 } @@ -220,14 +129,6 @@ func validateUncommitted(e *UncommittedEvent) error { if !bytes.HasPrefix(bytes.TrimSpace(e.Payload.Bytes()), []byte("{")) { return errors.New("payload is not an object") } - for i := range e.SourceEvents { - if err := validIdentity("SourceEvent", string(e.SourceEvents[i])); err != nil { - return err - } - if i > 0 && e.SourceEvents[i] <= e.SourceEvents[i-1] { - return errors.New("SourceEvents must be sorted and unique") - } - } return nil } @@ -248,20 +149,3 @@ func digestDomain(domain string, body any) (es.Digest, error) { } return es.DigestBytes(raw), nil } - -// SortedUniqueEventIDs canonicalizes a SourceEvents set. -func SortedUniqueEventIDs(ids []EventID) []EventID { - if len(ids) == 0 { - return nil - } - out := append([]EventID(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++ - } - } - return out[:n] -} diff --git a/agent/session/run/module.go b/agent/session/run/module.go index 1288214..5ef9e9c 100644 --- a/agent/session/run/module.go +++ b/agent/session/run/module.go @@ -19,8 +19,6 @@ const ( ModuleID extension.ModuleID = "run" // Prefix is the EventType namespace of every Run fact. Prefix session.EventType = "twilight/run/" - // LeaseNamespace holds execution occupancy (RUN 5.1). - LeaseNamespace session.ControlNamespace = "twilight/run/lease" ) // factNames is the closed list of v1 fact discriminators. diff --git a/agent/session/run/projection.go b/agent/session/run/projection.go index 4b837fd..37c382f 100644 --- a/agent/session/run/projection.go +++ b/agent/session/run/projection.go @@ -92,7 +92,7 @@ func (m Machine) Apply(e extension.DecodedEvent) (Machine, error) { return out, nil } out.Active[ev.RunID] = next - out.Positions[ev.RunID] = run.RunPosition{Revision: e.Revision, Index: e.Event.Index} + out.Positions[ev.RunID] = e.Event.Seq return out, nil } @@ -172,12 +172,11 @@ func (machineCodec) Decode(wire jsonstable.Value) (any, error) { return m, nil } -// MachineProjection consumes every twilight/run/ event (RequireComplete run). +// MachineProjection consumes every twilight/run/ event. var MachineProjection = extension.ProjectionDefinition{ ID: MachineProjectionID, Version: 1, - Consumes: AllTypes(), - RequireComplete: []extension.ModuleID{ModuleID}, - Initial: func() (any, error) { return newMachine(), nil }, + Consumes: AllTypes(), + Initial: func() (any, error) { return newMachine(), nil }, Apply: func(state any, e extension.DecodedEvent) (any, error) { return state.(Machine).Apply(e) }, diff --git a/agent/session/run/runtime.go b/agent/session/run/runtime.go index fac1165..723771f 100644 --- a/agent/session/run/runtime.go +++ b/agent/session/run/runtime.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strings" "time" "github.com/memohai/twilight/agent/es" @@ -15,13 +14,13 @@ import ( // 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 commit (RUN-CMT-3 step 9). +// 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 snapshot is written -// in a commit's transaction. It sees the state after Evolve. +// 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. @@ -35,28 +34,26 @@ func DefaultSnapshotPolicy(_, after *run.MachineState) bool { // Config assembles a Runtime (agent-reference-assembly.md 5). type Config struct { - Store session.Store - Registry *extension.Registry - Appender extension.SemanticAppender - Projections extension.ProjectionReader - Frozen run.FrozenValueStore - Companion run.Companion - Snapshot SnapshotPolicy - // LeaseTTL zero means leases never expire (in-process occupancy only). - LeaseTTL time.Duration - Now func() time.Time + 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 (RUN-CMT-1). +// Runtime is the run.Runtime over a Session Writer (RUN-CMT-1). type Runtime struct { - cfg Config - leases extension.Leases + cfg Config } func NewRuntime(cfg Config) (*Runtime, error) { switch { - case cfg.Store == nil, cfg.Registry == nil, cfg.Appender == nil, cfg.Projections == nil: - return nil, errors.New("runmod: runtime requires store, registry, appender and projections") + 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") } @@ -69,7 +66,7 @@ func NewRuntime(cfg Config) (*Runtime, error) { if cfg.Now == nil { cfg.Now = time.Now } - return &Runtime{cfg: cfg, leases: extension.Leases{Store: cfg.Store}}, nil + return &Runtime{cfg: cfg}, nil } // NewMemoryFrozenValues is the in-process FrozenValueStore. @@ -77,13 +74,33 @@ func NewMemoryFrozenValues() *run.MemoryFrozenValues { return run.NewMemoryFroze 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 } - state, head, err := r.cfg.Projections.Load(ctx, sid, MachineProjectionID, MachineProjection.Version) + 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 } @@ -92,7 +109,7 @@ func (r *Runtime) Load(ctx context.Context, sid session.SessionID, runID run.Run 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. + // 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 @@ -104,7 +121,11 @@ func (r *Runtime) Record(ctx context.Context, sid session.SessionID, runID run.R if err := run.CheckContext(ctx); err != nil { return run.RunRecord{}, err } - state, _, err := r.cfg.Projections.Load(ctx, sid, MachineProjectionID, MachineProjection.Version) + 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 } @@ -116,47 +137,34 @@ func (r *Runtime) Record(ctx context.Context, sid session.SessionID, runID run.R return r.record(ctx, sid, runID, expect) } -// record replays the Run's events, folds them and (when expect is given) -// compares the fold with the projection state. +// 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.Replay(ctx, session.ReplayRequest{SessionID: sid, Types: []session.EventType{Prefix}}) + page, err := r.cfg.Store.Read(ctx, session.ReadRequest{SessionID: sid, Types: []session.EventType{Prefix}}) if err != nil { return run.RunRecord{}, err } - commits := page.Commits - for page.Next != nil { - if page, err = r.cfg.Store.Replay(ctx, session.ReplayRequest{SessionID: sid, Types: []session.EventType{Prefix}, Cursor: page.Next}); err != nil { - return run.RunRecord{}, err - } - commits = append(commits, page.Commits...) - } var record run.RunRecord var position run.RunPosition - for ci := range commits { - c := &commits[ci] - for i := range c.Events { - e := &c.Events[i] - if !session.HasTypePrefix(e.Type, []session.EventType{Prefix}) { - continue - } - 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 = session.EventPosition{Revision: c.Revision, Index: e.Index, EventDigest: e.EventDigest} - } - record.Events = append(record.Events, *e) - record.Facts = append(record.Facts, ev.Fact) - position = run.RunPosition{Revision: c.Revision, Index: e.Index} + 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 @@ -199,150 +207,131 @@ func (r *Runtime) Commit(ctx context.Context, sid session.SessionID, req run.Com return run.CommitResult{}, err } } + w, err := r.writer(ctx, sid) + if err != nil { + return run.CommitResult{}, err + } - var out run.CommitResult + var out evaluated var rejection error - res, err := r.cfg.Appender.AppendSemanticIn(ctx, sid, func(tx extension.SemanticTx) (*extension.SemanticGroup, error) { - group, result, reject, err := r.evaluate(tx, sid, &req) + 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, errDiscard + return nil, nil } out = result + if group != nil { + before, after = result.before, result.Snapshot.State + } return group, nil }) if err != nil { - if errors.Is(err, errDiscard) { - return run.CommitResult{}, rejection - } - return run.CommitResult{}, err + return run.CommitResult{}, ownershipError(err) + } + if rejection != nil { + return run.CommitResult{}, rejection } switch res.Outcome { - case extension.SemanticApplied: + case extension.CommitApplied: out.Status = run.CommitAccepted - out.Commit = *res.Commit - out.Snapshot.Head = session.Head{Revision: res.Commit.Revision, Digest: res.Commit.CommitDigest} - out.Snapshot.Position = run.RunPosition{Revision: res.Commit.Revision, Index: uint16(out.Snapshot.Position.Index)} - return out, nil - case extension.SemanticNoop: + 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, nil - case extension.SemanticAlreadyApplied: - out.Status = run.CommitAlreadyApplied - out.Commit = *res.Commit - return out, nil - case extension.SemanticCommitConflict: + 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) } } -var errDiscard = errors.New("runmod: discard") +// 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 transaction. It returns either a group to +// 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(tx extension.SemanticTx, sid session.SessionID, req *run.CommitRequest) (*extension.SemanticGroup, run.CommitResult, error, 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 kernel's (SessionID, CommitID) - // alone (RUN-CMT-5): every Run CommandID is content-derived, so a hit is - // the same command; the caller reads the projection for the effective - // outcome. No Decide runs on replay. - if existing, found, err := tx.LookupCommit(commitID); err != nil { - return nil, run.CommitResult{}, nil, err - } else if found { - snapshot, err := r.snapshotIn(tx, sid, 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, run.CommitResult{}, nil, err - } - result := run.CommitResult{Status: run.CommitAlreadyApplied, Snapshot: snapshot, Commit: existing} - if run.IsStart(env.Command) { - // The start's grant is live only while the lease it minted is still - // the lease on record for this target. - key := run.GrantTarget(runID, env.Command) - if lease, has, err := extension.LookupLease(tx, LeaseNamespace, key); err != nil { - return nil, run.CommitResult{}, nil, err - } else if has && lease.Token == extension.DeriveLeaseToken(sid, LeaseNamespace, key, string(run.CommandClaim(env.Command)), commitID) { - result.Grant = run.ExecutionGrant(lease.Token) - } + return nil, evaluated{}, nil, err } - return nil, result, nil, nil + return nil, evaluated{CommitResult: run.CommitResult{Status: run.CommitAlreadyApplied, Snapshot: snapshot, Events: existing}}, nil, nil } - // Step 5: current state from snapshot plus filtered tail. - proj, before, err := r.loadMachine(tx) + // Step 5: current state from the Writer's projection. + proj, err := loadMachine(view) if err != nil { - return nil, run.CommitResult{}, nil, err + return nil, evaluated{}, nil, err } state, active := proj.Active[runID] if !active { // Terminal Runs leave the projection; tell terminal from unknown. - if _, err := r.terminalState(tx, runID); err != nil { - return nil, run.CommitResult{}, err, nil + if _, ended := proj.Ended[runID]; ended { + return nil, evaluated{}, run.ErrRunTerminal, nil } - return nil, run.CommitResult{}, 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, run.CommitResult{}, nil, fmt.Errorf("runmod: commit: command schema %d does not match run schema %d", 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, run.CommitResult{}, nil, err + return nil, evaluated{}, nil, err } - // Step 6: grant and recovery authority from the lease table. - key := run.GrantTarget(runID, env.Command) - var lease extension.Lease - hasLease := false - if key != "" { - lease, hasLease, err = extension.LookupLease(tx, LeaseNamespace, key) - if err != nil { - return nil, run.CommitResult{}, nil, err - } - } - claim := run.CommandClaim(env.Command) - grantValid := hasLease && req.Grant != "" && lease.Token == extension.LeaseToken(req.Grant) - if _, recovering := env.Command.(run.RecoverModelExecution); recovering && req.Grant != "" { - grantValid = grantValid && lease.Holder == string(claim) - } - // Recovery authority (RUN-CMT-6): the lease is expired and the command is - // bound to its holder, either by the carried Claim (model recovery) or by - // the derived tool-recovery CommandID (Unknown settlement). - recoveryValid := hasLease && req.Grant == "" && r.expired(lease) - if recoveryValid { - switch cmd := env.Command.(type) { - case run.RecoverModelExecution: - recoveryValid = lease.Holder == string(cmd.Claim) - case run.SubmitToolFailure: - recoveryValid = cmd.Outcome == run.ToolOutcomeUnknown && - env.ID == run.DeriveToolRecoveryCommandID(runID, cmd.StepID, cmd.CallID, run.ExecutionClaim(lease.Holder)) - default: - recoveryValid = false - } - } - - decision, err := run.EvaluateCommit(state, proj.Positions[runID], *req, grantValid, recoveryValid, proto) + decision, err := run.EvaluateCommit(state, proj.Positions[runID], *req, proto) if err != nil { - return nil, run.CommitResult{}, nil, err + return nil, evaluated{}, nil, err } switch decision.Kind { case run.DecisionConflict: - return nil, run.CommitResult{}, run.ErrCommandConflict, nil + return nil, evaluated{}, run.ErrCommandConflict, nil case run.DecisionStale: if decision.Reject != nil && !errors.Is(decision.Reject, run.ErrStaleRuntime) { - return nil, run.CommitResult{}, fmt.Errorf("%w: %w", run.ErrStaleRuntime, decision.Reject), nil + return nil, evaluated{}, fmt.Errorf("%w: %w", run.ErrStaleRuntime, decision.Reject), nil } - return nil, run.CommitResult{}, run.ErrStaleRuntime, nil + return nil, evaluated{}, run.ErrStaleRuntime, nil case run.DecisionTerminal: - return nil, run.CommitResult{}, run.ErrRunTerminal, nil + return nil, evaluated{}, run.ErrRunTerminal, nil } // Step 8: facts -> events. @@ -364,18 +353,18 @@ func (r *Runtime) evaluate(tx extension.SemanticTx, sid session.SessionID, req * 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, run.CommitResult{}, nil, fmt.Errorf("runmod: companion: %w", err) + return nil, evaluated{}, nil, fmt.Errorf("runmod: companion: %w", err) } for _, me := range companion { if session.HasTypePrefix(me.Type, []session.EventType{Prefix}) { - return nil, run.CommitResult{}, nil, errors.New("runmod: companion must not produce twilight/run/ events") + return nil, evaluated{}, nil, errors.New("runmod: companion must not produce twilight/run/ events") } - // A carried digest must be one a fact of this commit recorded; content + // 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, run.CommitResult{}, nil, fmt.Errorf("runmod: companion %s SourceDigest is not recorded by a fact of this commit", me.Type) + return nil, evaluated{}, nil, fmt.Errorf("runmod: companion %s SourceDigest is not recorded by a fact of this group", me.Type) } } } @@ -384,39 +373,6 @@ func (r *Runtime) evaluate(tx extension.SemanticTx, sid session.SessionID, req * for _, me := range req.Attach { group.Events = append(group.Events, extension.TypedEvent{Type: me.Type, RecordedAtUnixMilli: now, Value: me.Value}) } - - // Step 10: lease changes, command index, snapshot. - var grant run.ExecutionGrant - switch { - case run.IsStart(env.Command): - acquired, err := extension.AcquireLease(tx, sid, commitID, now, extension.AcquireLeaseRequest{ - Namespace: LeaseNamespace, Key: key, Holder: string(claim), TTL: r.cfg.LeaseTTL}) - if err != nil { - var xerr *extension.Error - if errors.As(err, &xerr) && xerr.Code == extension.ErrConflict { - return nil, run.CommitResult{}, run.ErrStaleRuntime, nil - } - return nil, run.CommitResult{}, nil, err - } - grant = run.ExecutionGrant(acquired.Token) - case run.IsSettlement(env.Command) && hasLease: - if err := extension.ReleaseLease(tx, LeaseNamespace, key, lease.Token); err != nil { - return nil, run.CommitResult{}, nil, err - } - } - if decision.NewState.Status.Terminal() { - // Terminal commit revokes every grant of the Run (RUN-CMT-6): the - // Executing targets of the pre-state name the live leases. - for _, target := range executingTargets(&state) { - if l, has, err := extension.LookupLease(tx, LeaseNamespace, target); err != nil { - return nil, run.CommitResult{}, nil, err - } else if has { - if err := extension.ReleaseLease(tx, LeaseNamespace, target, l.Token); err != nil { - return nil, run.CommitResult{}, nil, err - } - } - } - } // 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 { @@ -425,110 +381,39 @@ func (r *Runtime) evaluate(tx extension.SemanticTx, sid session.SessionID, req * } } } - if r.cfg.Snapshot(&state, &decision.NewState) { - if err := extension.SaveSnapshotIn(tx, &MachineProjection, proj, before); err != nil { - return nil, run.CommitResult{}, nil, err - } - } - result := run.CommitResult{ - Snapshot: run.RuntimeSnapshot{State: decision.NewState, SchemaVersion: schema, - Position: run.RunPosition{Index: uint16(len(decision.Facts) - 1)}}, // Revision filled after append - Grant: grant, + 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 (r *Runtime) loadMachine(tx extension.SemanticTx) (Machine, session.Head, error) { - state, head, err := extension.LoadIn(tx, &MachineProjection) +func loadMachine(view extension.View) (Machine, error) { + state, err := view.Projection(MachineProjectionID, MachineProjection.Version) if err != nil { - return Machine{}, session.Head{}, err + return Machine{}, err } - return state.(Machine), head, nil + return state.(Machine), nil } -// snapshotIn is Load inside the transaction. -func (r *Runtime) snapshotIn(tx extension.SemanticTx, sid session.SessionID, runID run.RunID) (run.RuntimeSnapshot, error) { - proj, head, err := r.loadMachine(tx) +// 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: head, SchemaVersion: proj.Schemas[runID]}, nil + return run.RuntimeSnapshot{State: ms, Position: proj.Positions[runID], Head: view.Head(), SchemaVersion: proj.Schemas[runID]}, nil } - state, err := r.terminalState(tx, runID) + record, err := r.record(ctx, sid, runID, nil) if err != nil { return run.RuntimeSnapshot{}, err } - return run.RuntimeSnapshot{State: state.state, Position: state.position, Head: head, SchemaVersion: state.schema}, nil -} - -type foldedRun struct { - state run.MachineState - position run.RunPosition - schema uint16 -} - -// terminalState folds a Run that is no longer in the projection; it returns -// ErrRunNotFound when the Run never existed in this Session. -func (r *Runtime) terminalState(tx extension.SemanticTx, runID run.RunID) (foldedRun, error) { - commits, err := tx.Tail(session.Head{}, []session.EventType{Prefix}) - if err != nil { - return foldedRun{}, err - } - var facts []run.Fact - var position run.RunPosition - for ci := range commits { - for _, e := range commits[ci].Events { - if !session.HasTypePrefix(e.Type, []session.EventType{Prefix}) { - continue - } - decoded, err := tx.Decode(e) - if err != nil { - return foldedRun{}, err - } - if decoded.Unknown { - continue - } - if ev := decoded.Value.(Event); ev.RunID == runID { - facts = append(facts, ev.Fact) - position = run.RunPosition{Revision: commits[ci].Revision, Index: e.Index} - } - } - } - if len(facts) == 0 { - return foldedRun{}, run.ErrRunNotFound - } - state, err := run.FoldRun(facts) - if err != nil { - return foldedRun{}, err - } - return foldedRun{state: state, position: position, schema: facts[0].(run.RunCreated).SchemaVersion}, nil -} - -func (r *Runtime) expired(l extension.Lease) bool { - return l.DeadlineUnixMilli != 0 && l.DeadlineUnixMilli <= r.nowMilli() -} - -// executingTargets lists the lease keys of every Executing target in state. -func executingTargets(s *run.MachineState) []string { - switch cur := s.Current.(type) { - case run.ModelStep: - if cur.Status == run.ModelExecuting { - return []string{run.LeaseKey(s.RunID, cur.RefValue.ID, "")} - } - case run.ToolStep: - var out []string - for _, c := range cur.Calls { - if c.Status == run.ToolExecuting { - out = append(out, run.LeaseKey(s.RunID, cur.RefValue.ID, c.CallID)) - } - } - return out - } - return nil + return record.Snapshot, nil } -// --- frozen request, lease renewal, recovery ------------------------------------------- +// --- frozen request, takeover ------------------------------------------------------- func (r *Runtime) FrozenRequest(ctx context.Context, digest run.Digest) (run.ModelRequest, error) { if err := run.CheckContext(ctx); err != nil { @@ -547,92 +432,45 @@ func (r *Runtime) FrozenRequest(ctx context.Context, digest run.Digest) (run.Mod return run.DecodeFrozenRequest(raw, digest) } -func (r *Runtime) RenewLease(ctx context.Context, sid session.SessionID, runID run.RunID, stepID run.StepID, callID run.CallID, grant run.ExecutionGrant) error { +// 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 err - } - if grant == "" { - return run.ErrStaleRuntime - } - key := run.LeaseKey(runID, stepID, callID) - if key == "" { - return errors.New("runmod: renew requires a step") + return 0, err } - err := r.leases.Renew(ctx, sid, LeaseNamespace, key, extension.LeaseToken(grant), r.cfg.LeaseTTL, r.nowMilli()) + w, err := r.writer(ctx, sid) if err != nil { - var xerr *extension.Error - if errors.As(err, &xerr) && xerr.Code == extension.ErrStale { - return run.ErrStaleRuntime - } - return err - } - return nil -} - -// RecoverExpired commits grantless recovery for every expired lease that still -// occupies an Executing target (RUN 5.1). -func (r *Runtime) RecoverExpired(ctx context.Context) (int, error) { - if r.cfg.LeaseTTL <= 0 { - return 0, nil - } - type expiredLease struct { - sid session.SessionID - lease extension.Lease + return 0, err } - var expired []expiredLease - err := r.leases.Expired(ctx, LeaseNamespace, r.nowMilli(), func(sid session.SessionID, l extension.Lease) (bool, error) { - expired = append(expired, expiredLease{sid, l}) - return true, nil - }) + 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 _, e := range expired { - runID := runIDOfLeaseKey(e.lease.Key) - if runID == "" { - continue - } - snapshot, err := r.Load(ctx, e.sid, runID) - if err != nil { - if errors.Is(err, run.ErrRunNotFound) { - continue - } - return n, err - } - cmd, cmdID, ok := run.RecoveryCommand(&snapshot.State, e.lease.Key, run.ExecutionClaim(e.lease.Holder)) - if !ok { - continue - } - proto, err := snapshot.Protocol() + for runID, ms := range state.(Machine).Active { + proto, err := run.ProtocolFor(state.(Machine).Schemas[runID]) if err != nil { return n, err } - env, err := proto.BuildEnvelope(e.sid, runID, cmdID, cmd) - if err != nil { - return n, err - } - res, err := r.Commit(ctx, e.sid, run.CommitRequest{Base: snapshot.Position, Command: env}) - if err != nil { - if errors.Is(err, run.ErrStaleRuntime) || errors.Is(err, run.ErrRunTerminal) || errors.Is(err, run.ErrCommandConflict) { - continue + 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, err - } - if res.Status == run.CommitAccepted { - n++ } } return n, nil } -func runIDOfLeaseKey(key string) run.RunID { - for _, sep := range []string{"/model/", "/call/"} { - if i := strings.Index(key, sep); i > 0 { - return run.RunID(key[:i]) - } - } - return "" -} - var _ run.Runtime = (*Runtime)(nil) diff --git a/agent/session/run/runtimetest/conformance.go b/agent/session/run/runtimetest/conformance.go index d5a5502..ac6bffb 100644 --- a/agent/session/run/runtimetest/conformance.go +++ b/agent/session/run/runtimetest/conformance.go @@ -16,41 +16,42 @@ import ( "github.com/memohai/twilight/agent/turn" ) -// Run executes the RUN-CMP-2 Runtime conformance suite against stores made -// by newStore. -func Run(t *testing.T, newStore Factory) { +// 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, - "Grants": testGrants, - "CommitComposition": testCommitComposition, + "StartAndClaim": testStartAndClaim, + "GroupComposition": testGroupComposition, "Admission": testAdmission, "SettlementSnapshot": testSettlementSnapshot, "PrepareCAS": testPrepareCASIgnoresOtherModules, - "LeaseAndCommit": testLeaseAndCommit, "Projection": testProjection, "Isolation": testIsolation, - "ExpiryRecovery": testExpiryRecovery, - "Renewal": testRenewal, + "Takeover": testTakeover, + "OwnershipLost": testOwnershipLost, "FrozenValues": testFrozenValues, } { - t.Run(name, func(t *testing.T) { fn(t, newStore) }) + t.Run(name, func(t *testing.T) { fn(t, factory) }) } } // --- 建立与寻址 ----------------------------------------------------------------------- -func testCreation(t *testing.T, newStore Factory) { - h := newHarness(t, newStore(t), 0) +func testCreation(t *testing.T, factory Factory) { + h := newHarness(t, factory(t), 0) 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) } - if snap.Position.Revision != h.head().Revision || snap.Position.Index != 3 { - t.Fatalf("position = %+v, want revision %d index 3 (started, delivered, created, accepted)", snap.Position, h.head().Revision) + // 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) { @@ -71,7 +72,7 @@ func testCreation(t *testing.T, newStore Factory) { 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", run.RunPosition{}, "", run.CancelRun{}) + 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) @@ -80,66 +81,68 @@ func testCreation(t *testing.T, newStore Factory) { 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"), run.RunPosition{}, "", run.AcceptInput{Input: input("late")}); !errors.Is(err, run.ErrRunTerminal) { + 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 breaks the fold. - h.mustApply(h.startGroup("t2", "r1", 1)) - if _, err := h.rt.Load(h.ctx, sid, "r1"); err == nil { - t.Fatal("duplicate created folded silently") + // 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, newStore Factory) { - h := newHarness(t, newStore(t), 0) +func testReplayAndBase(t *testing.T, factory Factory) { + h := newHarness(t, factory(t), 0) h.startRun("t1", "r1", input("in-1")) - first := h.mustCommit("r1", run.DeriveInputCommandID("r1", "in-2"), run.RunPosition{}, "", run.AcceptInput{Input: input("in-2")}) + 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"), run.RunPosition{}, "", run.AcceptInput{Input: input("in-2")}) - if again.Status != run.CommitAlreadyApplied || again.Commit.CommitID != first.Commit.CommitID || again.Commit.CommitDigest != first.Commit.CommitDigest { + 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().Revision != first.Commit.Revision { - t.Fatal("replay appended a commit") + 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 := run.RunPosition{Revision: snap.Position.Revision - 1} + 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) { + 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) + 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"), run.RunPosition{}, "", run.AcceptInput{Input: input("in-3")}) + 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"), run.RunPosition{}, "", run.AcceptInput{Input: input("in-3")}) + 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"), run.RunPosition{}, "", run.AcceptInput{Input: input("in-4")}); !errors.Is(err, run.ErrRunTerminal) { + 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", run.RunPosition{}, "", run.AcceptInput{Input: input("in-5")}); !errors.Is(err, run.ErrCommandConflict) && !errors.Is(err, run.ErrRunTerminal) { + 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, newStore Factory) { - h := newHarness(t, newStore(t), 0) +func testInputQueue(t *testing.T, factory Factory) { + h := newHarness(t, factory(t), 0) 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"), run.RunPosition{}, "", run.AcceptInput{Input: input("in-2")}) + 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 { @@ -148,7 +151,7 @@ func testInputQueue(t *testing.T, newStore Factory) { 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}) + 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) @@ -157,11 +160,11 @@ func testInputQueue(t *testing.T, newStore Factory) { 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) + h.mustCommit("r1", id, snap.Position, cmd) // Executing: the input queues; a result without calls reopens instead of ending. - grant, claim := h.startModel("r1", cmd.StepID) - h.mustCommit("r1", run.DeriveInputCommandID("r1", "in-3"), run.RunPosition{}, "", run.AcceptInput{Input: input("in-3")}) - res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", cmd.StepID, "", claim), run.RunPosition{}, grant, run.SubmitModelResult{StepID: cmd.StepID, Result: textResult("a")}) + 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") } @@ -169,80 +172,76 @@ func testInputQueue(t *testing.T, newStore Factory) { t.Fatalf("after result with pending input = %+v", res.Snapshot.State) } // ToolStep: the input queues as well. - h2 := newHarness(t, newStore(t), 0) + h2 := newHarness(t, factory(t), 0) h2.startRun("t1", "r2", input("in-1")) h2.openToolStep("r2", 1) - res = h2.mustCommit("r2", run.DeriveInputCommandID("r2", "in-9"), run.RunPosition{}, "", run.AcceptInput{Input: input("in-9")}) + 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) } } -// --- grant ----------------------------------------------------------------------------- +// --- start 与 claim ------------------------------------------------------------------------ -func testGrants(t *testing.T, newStore Factory) { - h := newHarness(t, newStore(t), 0) +func testStartAndClaim(t *testing.T, factory Factory) { + h := newHarness(t, factory(t), 0) h.startRun("t1", "r1", input("in-1")) - h.startRun("t2", "r2", input("in-1")) - step, grant, claim := h.executingModel("r1", false) - lease, ok := h.lease("r1", step, "") - if !ok || run.ExecutionGrant(lease.Token) != grant || lease.Holder != string(claim) { - t.Fatalf("lease = %+v ok=%v, grant %s", lease, ok, grant) - } - // Same-claim replay returns the same grant; another claim is refused. - replay := h.mustCommit("r1", run.DeriveStartCommandID("r1", step, "", claim), run.RunPosition{}, "", run.StartModelExecution{StepID: step, Claim: claim}) - if replay.Status != run.CommitAlreadyApplied || replay.Grant != grant { + 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), run.RunPosition{}, "", run.StartModelExecution{StepID: step, Claim: other}); !errors.Is(err, run.ErrStaleRuntime) { + 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) } - // Settlement needs the live grant of its own target. - settle := run.SubmitModelResult{StepID: step, Result: textResult("done")} - settleID := run.DeriveSettlementCommandID("r1", step, "", claim) - if _, err := h.commit("r1", settleID, run.RunPosition{}, "", settle); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("grantless settlement = %v", err) - } - if _, err := h.commit("r1", settleID, run.RunPosition{}, "wrong", settle); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("wrong grant = %v", err) - } - step2, grant2, _ := h.executingModel("r2", false) - if _, err := h.commit("r1", settleID, run.RunPosition{}, grant2, settle); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("cross-run grant = %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) } - _ = step2 - res := h.mustCommit("r1", settleID, run.RunPosition{}, grant, settle) + // 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") } - // After settlement the start replays with an empty grant and the lease is gone. - replay = h.mustCommit("r1", run.DeriveStartCommandID("r1", step, "", claim), run.RunPosition{}, "", run.StartModelExecution{StepID: step, Claim: claim}) - if replay.Status != run.CommitAlreadyApplied || replay.Grant != "" { - t.Fatalf("start replay after settlement = %+v", replay) + again := h.mustCommit("r1", settleID, 0, run.SubmitModelResult{StepID: step, Result: textResult("done")}) + if again.Status != run.CommitAlreadyApplied { + t.Fatalf("settlement replay = %+v", again) } - if _, ok := h.lease("r1", step, ""); ok { - t.Fatal("lease survived settlement") + // 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) } } -// --- commit 组成 ---------------------------------------------------------------------------- +// --- 组的组成 ---------------------------------------------------------------------------- -func testCommitComposition(t *testing.T, newStore Factory) { - h := newHarness(t, newStore(t), 0) +func testGroupComposition(t *testing.T, factory Factory) { + h := newHarness(t, factory(t), 0) h.startRun("t1", "r1", input("in-1")) - step, grant, claim := h.executingModel("r1", true) + step, claim := h.executingModel("r1", true) result, bindings := h.toolCallResult(step, 1) before := h.head() - res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, "", claim), run.RunPosition{}, grant, + res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, "", claim), 0, run.SubmitModelResult{StepID: step, Result: result, Calls: bindings}) - if h.head().Revision != before.Revision+1 { - t.Fatal("one command did not produce exactly one commit") + 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.Commit) + 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("commit events = %v, want %v", types, 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 @@ -251,7 +250,7 @@ func testCommitComposition(t *testing.T, newStore Factory) { resultDigest = c.ResultDigest } } - decoded, err := h.registry.Decode(res.Commit.Events[2]) + decoded, err := h.registry.Decode(res.Events[2]) if err != nil { t.Fatal(err) } @@ -261,22 +260,23 @@ func testCommitComposition(t *testing.T, newStore Factory) { // Attach follows the companion; twilight/run/ events are refused. ts := res.Snapshot.State.Current.(run.ToolStep) call := ts.Calls[0].CallID - toolGrant, toolClaim := h.startTool("r1", ts.RefValue.ID, call) + 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), run.RunPosition{}, toolGrant, + 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") } - res = h.mustCommit("r1", run.DeriveSettlementCommandID("r1", ts.RefValue.ID, call, toolClaim), run.RunPosition{}, toolGrant, + 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.Commit) + 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("commit events = %v", types) + t.Fatalf("group events = %v", types) } outputDigest, _ := run.ProtocolV1().DigestToolOutput(output) - decoded, _ = h.registry.Decode(res.Commit.Events[1]) + 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) } @@ -292,8 +292,8 @@ func asStrings(types []session.EventType) []string { // --- admission ------------------------------------------------------------------------------- -func testAdmission(t *testing.T, newStore Factory) { - h := newHarness(t, newStore(t), 0) +func testAdmission(t *testing.T, factory Factory) { + h := newHarness(t, factory(t), 0) 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) @@ -309,8 +309,8 @@ func testAdmission(t *testing.T, newStore Factory) { return run.ModuleEvent{Type: chatlog.TypeAssistant, Value: chatlog.AssistantPayload{Assistant: a}} } before := h.head() - // Unregistered binding: the whole commit is refused and nothing is written. - if _, err := h.commit("r1", run.DeriveInputCommandID("r1", "in-2"), run.RunPosition{}, "", run.AcceptInput{Input: input("in-2")}, attach("missing")); err == nil { + // 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 { @@ -319,12 +319,12 @@ func testAdmission(t *testing.T, newStore Factory) { if len(h.load("r1").State.PendingInputs) != 1 { t.Fatal("refused commit changed the Run") } - // Registered binding: commit and claim in the same transaction. - res := h.mustCommit("r1", run.DeriveInputCommandID("r1", "in-2"), run.RunPosition{}, "", run.AcceptInput{Input: input("in-2")}, attach("b1")) - claimID := extension.DeriveClaimID(session.ProtocolVersion1, sid, res.Commit.CommitID, mustSet(t, h, "b1").RefSetDigest) - entry, ok, err := h.store.ControlGet(h.ctx, sid, extension.ClaimNamespace, string(claimID)) - if err != nil || !ok || !strings.Contains(string(entry.Value), `"active"`) { - t.Fatalf("claim entry = %s ok=%v err=%v", entry.Value, ok, err) + // 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) } } @@ -339,11 +339,11 @@ func mustSet(t *testing.T, h *harness, ids ...artifact.BindingID) artifact.Bindi // --- 结算返回值 -------------------------------------------------------------------------------- -func testSettlementSnapshot(t *testing.T, newStore Factory) { - h := newHarness(t, newStore(t), 0) +func testSettlementSnapshot(t *testing.T, factory Factory) { + h := newHarness(t, factory(t), 0) h.startRun("t1", "r1", input("in-1")) - step, grant, claim := h.executingModel("r1", false) - res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, "", claim), run.RunPosition{}, grant, run.SubmitModelResult{StepID: step, Result: textResult("done")}) + 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) } @@ -351,19 +351,19 @@ func testSettlementSnapshot(t *testing.T, newStore Factory) { 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 commit (companion). - types := eventTypes(&res.Commit) + // The completed turn is settled in the same group (companion). + types := eventTypes(res.Events) if types[len(types)-1] != turn.TypeCompleted { - t.Fatalf("terminal commit events = %v, want turn/completed last", types) + t.Fatalf("terminal group events = %v, want turn/completed last", types) } } // --- Prepare hard CAS 对其他模块不敏感 --------------------------------------------------------------- -func testPrepareCASIgnoresOtherModules(t *testing.T, newStore Factory) { - h := newHarness(t, newStore(t), 0) +func testPrepareCASIgnoresOtherModules(t *testing.T, factory Factory) { + h := newHarness(t, factory(t), 0) h.startRun("t1", "r1", input("in-1")) - h.startRun("t2", "r2", 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. @@ -371,87 +371,71 @@ func testPrepareCASIgnoresOtherModules(t *testing.T, newStore Factory) { h.prepare("r2", false) after := h.load("r1") if after.Position != snap.Position { - t.Fatalf("foreign writes moved r1 position %+v -> %+v", snap.Position, after.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 { + if _, err := h.commit("r1", id, snap.Position, cmd); err != nil { t.Fatalf("prepare against a moved session head: %v", err) } } -// --- 租约与 commit ------------------------------------------------------------------------------- - -func testLeaseAndCommit(t *testing.T, newStore Factory) { - h := newHarness(t, newStore(t), 0) - h.startRun("t1", "r1", input("in-1")) - step, ids := h.openToolStep("r1", 2) - g0, c0 := h.startTool("r1", step, ids[0]) - if l, ok := h.lease("r1", step, ids[0]); !ok || l.Holder != string(c0) || run.ExecutionGrant(l.Token) != g0 { - t.Fatalf("lease after start = %+v ok=%v", l, ok) - } - h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, ids[0], c0), run.RunPosition{}, g0, - run.SubmitToolResult{StepID: step, CallID: ids[0], Result: run.ToolExecutionResult{Output: run.MustParseCanonicalJSON(`1`)}}) - if _, ok := h.lease("r1", step, ids[0]); ok { - t.Fatal("lease survived settlement") - } - // Terminal commit releases every live lease of the Run. - h.startTool("r1", step, ids[1]) - h.mustCommit("r1", "cancel", run.RunPosition{}, "", run.CancelRun{}) - if _, ok := h.lease("r1", step, ids[1]); ok { - t.Fatal("lease survived the terminal commit") - } - if got := h.load("r1").State.Result.UncertainCalls; len(got) != 1 || got[0] != ids[1] { - t.Fatalf("uncertain calls = %v", got) - } -} - // --- 投影 ------------------------------------------------------------------------------------------- -func testProjection(t *testing.T, newStore Factory) { - h := newHarness(t, newStore(t), 0) +func testProjection(t *testing.T, factory Factory) { + h := newHarness(t, factory(t), 0) h.startRun("t1", "r1", input("in-1")) - snapshotOf := func() session.SnapshotResult { - res, err := h.store.LoadSnapshot(h.ctx, session.SnapshotRequest{SessionID: sid, ProjectionKey: session.ProjectionKey(runmod.MachineProjectionID), ProjectionVersion: 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 res - } - step, _, _ := h.executingModel("r1", true) - if snapshotOf().Found { - t.Fatal("snapshot written while the Run is mid-step") - } - toolStep, ids := func() (run.StepID, []run.CallID) { - g, _ := h.lease("r1", step, "") - result, bindings := h.toolCallResult(step, 1) - res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, "", run.ExecutionClaim(g.Holder)), run.RunPosition{}, run.ExecutionGrant(g.Token), - run.SubmitModelResult{StepID: step, Result: result, Calls: bindings}) - return res.Snapshot.State.Current.(run.ToolStep).RefValue.ID, []run.CallID{bindings[0].CallID} - }() - grant, claim := h.startTool("r1", toolStep, ids[0]) - res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", toolStep, ids[0], claim), run.RunPosition{}, grant, - run.SubmitToolResult{StepID: toolStep, CallID: ids[0], Result: run.ToolExecutionResult{Output: run.MustParseCanonicalJSON(`1`)}}) + 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) } - snap := snapshotOf() - if !snap.Found || snap.Snapshot.Through.Revision != res.Commit.Revision-1 { - t.Fatalf("snapshot after return to Open = %+v, want Through = commit revision - 1", snap.Snapshot) + 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) } - // Projection state, Load and Record agree for the active Run. m := h.machine() loaded := h.load("r1") rec := h.record("r1") - if active, ok := m.Active["r1"]; !ok || !run.StatesEquivalent(&active, &loaded.State) || !run.StatesEquivalent(&active, &rec.Snapshot.State) { + 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") } - // Terminal Run leaves the projection; Load and Record still answer. - h.mustCommit("r1", "cancel", run.RunPosition{}, "", run.CancelRun{}) - if _, still := h.machine().Active["r1"]; still { + 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") } @@ -463,10 +447,10 @@ func testProjection(t *testing.T, newStore Factory) { // --- 隔离 -------------------------------------------------------------------------------------------- -func testIsolation(t *testing.T, newStore Factory) { - h := newHarness(t, newStore(t), 0) +func testIsolation(t *testing.T, factory Factory) { + h := newHarness(t, factory(t), 0) h.startRun("t1", "r1", input("in-1")) - h.startRun("t2", "r2", input("in-1")) + h.startRun("t2", "r2", input("in-b")) p1 := h.load("r1").Position h.prepare("r2", false) h.submitInputs(input("noise")) @@ -485,115 +469,105 @@ func testIsolation(t *testing.T, newStore Factory) { } } -// --- lease 过期 recovery ---------------------------------------------------------------------------- +// --- 接管处置 ----------------------------------------------------------------------------------------- -func testExpiryRecovery(t *testing.T, newStore Factory) { - const ttl = time.Minute - h := newHarness(t, newStore(t), ttl) +func testTakeover(t *testing.T, factory Factory) { + f := factory(t) + if f.Advance == nil { + t.Skip("fixture has no clock; TTL takeover not testable") + } + h := newHarness(t, f, time.Minute) h.startRun("t1", "r1", input("in-1")) - h.startRun("t2", "r2", input("in-1")) - // r1: executing model; r2: two executing tool calls started at different times. + 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) - _, c0 := h.startTool("r2", toolStep, ids[0]) - h.clock.Advance(ttl / 2) - h.startTool("r2", toolStep, ids[1]) + h.startTool("r2", toolStep, ids[0]) - // Live leases refuse grantless recovery and RecoverExpired finds nothing. - if n, err := h.rt.RecoverExpired(h.ctx); err != nil || n != 0 { - t.Fatalf("recover with live leases = %d %v", n, err) - } - unknown := run.SubmitToolFailure{StepID: toolStep, CallID: ids[0], Failure: run.ToolFailure{Class: run.FailureEffectUnknown}, Outcome: run.ToolOutcomeUnknown} - if _, err := h.commit("r2", run.DeriveToolRecoveryCommandID("r2", toolStep, ids[0], c0), run.RunPosition{}, "", unknown); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("grantless unknown on a live lease = %v", err) - } - // Expire r1's model lease and r2's first call only. - h.clock.Advance(ttl/2 + time.Second) - // A recovery CommandID derived from another holder is refused. - if _, err := h.commit("r2", run.DeriveToolRecoveryCommandID("r2", toolStep, ids[0], "intruder"), run.RunPosition{}, "", unknown); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("recovery with a foreign holder = %v", err) - } - n, err := h.rt.RecoverExpired(h.ctx) + h.takeover() + n, err := h.rt.RecoverInterrupted(h.ctx, sid) if err != nil || n != 2 { - t.Fatalf("recovered = %d %v, want 2", n, err) + t.Fatalf("RecoverInterrupted = %d %v, want 2", n, err) } - r1 := h.load("r1").State.Current.(run.ModelStep) - if r1.Status != run.ModelPrepared || r1.RequestDigest != requestDigest { - t.Fatalf("model after recovery = %+v", r1) + 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 recovery: %v", err) - } - ts := h.load("r2").State.Current.(run.ToolStep) - if ts.Calls[0].Status != run.ToolFailed || ts.Calls[0].Failure == nil || ts.Calls[0].Failure.Outcome != run.ToolOutcomeUnknown { - t.Fatalf("expired call = %+v", ts.Calls[0]) - } - if ts.Calls[1].Status != run.ToolExecuting { - t.Fatalf("sibling call = %+v, want still Executing", ts.Calls[1]) + 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 + } } - if _, ok := h.lease("r2", toolStep, ids[0]); ok { - t.Fatal("recovered lease not deleted") + 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) } - if _, ok := h.lease("r2", toolStep, ids[1]); !ok { - t.Fatal("sibling lease deleted") + 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) } - if n, err := h.rt.RecoverExpired(h.ctx); err != nil || n != 0 { - t.Fatalf("second RecoverExpired = %d %v, want 0", n, err) + // 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) } - // TTL zero: recovery never runs. - h0 := newHarness(t, newStore(t), 0) - h0.startRun("t1", "r1", input("in-1")) - h0.executingModel("r1", false) - h0.clock.Advance(time.Hour) - if n, err := h0.rt.RecoverExpired(h0.ctx); err != nil || n != 0 { - t.Fatalf("RecoverExpired with TTL 0 = %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) } } -// --- lease 续期 --------------------------------------------------------------------------------------- +// --- 所有权失效 ---------------------------------------------------------------------------------------- -func testRenewal(t *testing.T, newStore Factory) { - const ttl = time.Minute - h := newHarness(t, newStore(t), ttl) - h.startRun("t1", "r1", input("in-1")) - step, grant, claim := h.executingModel("r1", false) - h.clock.Advance(ttl / 2) - if err := h.rt.RenewLease(h.ctx, sid, "r1", step, "", grant); err != nil { - t.Fatalf("renew: %v", err) - } - h.clock.Advance(ttl/2 + time.Second) // past the original deadline, inside the renewed one - if n, _ := h.rt.RecoverExpired(h.ctx); n != 0 { - t.Fatal("renewed lease was recovered at its original deadline") +func testOwnershipLost(t *testing.T, factory Factory) { + f := factory(t) + if f.Advance == nil { + t.Skip("fixture has no clock; TTL takeover not testable") } - if err := h.rt.RenewLease(h.ctx, sid, "r1", step, "", ""); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("renew with empty grant = %v", err) - } - if err := h.rt.RenewLease(h.ctx, sid, "r1", step, "", "wrong"); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("renew with wrong grant = %v", err) + h := newHarness(t, f, time.Minute) + 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) } - h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, "", claim), run.RunPosition{}, grant, run.SubmitModelResult{StepID: step, Result: textResult("done")}) - if err := h.rt.RenewLease(h.ctx, sid, "r1", step, "", grant); !errors.Is(err, run.ErrStaleRuntime) { - t.Fatalf("renew after settlement = %v", err) + if h.head() != head { + t.Fatal("fenced commit reached the stream") } - // TTL zero: renewal only validates the grant. - h0 := newHarness(t, newStore(t), 0) - h0.startRun("t1", "r1", input("in-1")) - step0, grant0, _ := h0.executingModel("r1", false) - if err := h0.rt.RenewLease(h0.ctx, sid, "r1", step0, "", grant0); err != nil { - t.Fatalf("renew with TTL 0: %v", err) + if _, err := old.Load(h.ctx, sid, "r1"); !errors.Is(err, run.ErrOwnershipLost) { + t.Fatalf("old owner load = %v, want ErrOwnershipLost", err) } - if l, _ := h0.lease("r1", step0, ""); l.DeadlineUnixMilli != 0 { - t.Fatalf("TTL 0 renew set a deadline: %d", l.DeadlineUnixMilli) + // 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, newStore Factory) { - h := newHarness(t, newStore(t), 0) +func testFrozenValues(t *testing.T, factory Factory) { + h := newHarness(t, factory(t), 0) h.startRun("t1", "r1", input("in-1")) - step, grant, claim := h.executingModel("r1", false) + 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 { @@ -605,7 +579,7 @@ func testFrozenValues(t *testing.T, newStore Factory) { 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), run.RunPosition{}, grant, run.SubmitModelResult{StepID: step, Result: textResult("done")}) + 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 index 94545c3..a726cb6 100644 --- a/agent/session/run/runtimetest/harness.go +++ b/agent/session/run/runtimetest/harness.go @@ -1,7 +1,7 @@ // 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 transaction -// atomicity, digest chains and snapshot equivalence to the kernel and +// 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 @@ -23,8 +23,15 @@ import ( "github.com/memohai/twilight/sdk" ) -// Factory returns a fresh, empty Store for one test. -type Factory func(t testing.TB) session.Store +// Fixture is one adapter under test. Advance moves the adapter's clock so a +// TTL takeover can be exercised; nil skips the takeover checks. +type Fixture struct { + Store session.Store + Advance func(time.Duration) +} + +// Factory returns a fresh, empty Fixture for one test. +type Factory func(t testing.TB) Fixture const sid session.SessionID = "conformance" @@ -40,74 +47,94 @@ func (c *clock) Advance(d time.Duration) { c.mu.Unlock() } -// harness is one assembled stack over a Store. +// 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 - appender extension.SemanticAppender - reader extension.ProjectionReader bindings *artifact.MemoryBindingStore + ledger *artifact.MemoryLedger frozen *run.MemoryFrozenValues + cache *extension.MemoryProjectionCache clock *clock + ttl time.Duration + writers extension.Writers rt *runmod.Runtime seq int } -func newHarness(t testing.TB, store session.Store, ttl time.Duration) *harness { +func newHarness(t testing.TB, f Fixture, ttl time.Duration) *harness { t.Helper() - registry, err := extension.BuildRegistry(session.ProfileV1(), chatlog.Module, runmod.Module, turn.Module) + registry, err := extension.BuildRegistry(session.ProtocolVersion1, chatlog.Module, runmod.Module, turn.Module) if err != nil { t.Fatal(err) } bindings := artifact.NewMemoryBindingStore() - appender, err := extension.NewSemanticAppender(store, registry, artifact.SetBuilder{Resolver: bindings}, artifact.KVLedger{}) - if err != nil { + 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)}, ttl: ttl} + if _, err := f.Store.Create(h.ctx, session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: sid}); err != nil { t.Fatal(err) } - reader := extension.NewProjectionReader(store, registry) - h := &harness{t: t, ctx: context.Background(), store: store, registry: registry, appender: appender, reader: reader, - bindings: bindings, frozen: run.NewMemoryFrozenValues(), clock: &clock{now: time.Unix(1_000_000, 0)}} - h.rt, err = runmod.NewRuntime(runmod.Config{Store: store, Registry: registry, Appender: appender, Projections: reader, - Frozen: h.frozen, Companion: turn.CompanionV1{}, LeaseTTL: ttl, Now: h.clock.Now}) + h.open() + return h +} + +// open starts an owner process: Writers over the shared store and a Runtime. +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{TTL: h.ttl}) + 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 { - t.Fatal(err) - } - if _, err := store.Create(h.ctx, session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: sid}); err != nil { - t.Fatal(err) + h.fatal(err) } - return h + h.rt = rt +} + +// takeover lets the ownership TTL pass and opens a new owner process; the +// previous Runtime stays usable so tests can observe its fencing. +func (h *harness) takeover() *runmod.Runtime { + h.t.Helper() + old := h.rt + h.fixture.Advance(2 * h.ttl) + h.open() + return old } func (h *harness) fatal(args ...any) { h.t.Helper(); h.t.Fatal(args...) } -func (h *harness) head() session.Head { +func (h *harness) writer() extension.Writer { h.t.Helper() - head, err := h.store.Head(h.ctx, sid) + w, err := h.writers.Writer(h.ctx, sid) if err != nil { h.fatal(err) } - return head + return w } -// appendGroup appends a typed group by CAS at the current head. -func (h *harness) appendGroup(group extension.SemanticGroup) extension.SemanticAppendResult { +func (h *harness) head() session.Head { h.t.Helper() - res, err := h.appender.AppendSemantic(h.ctx, extension.SemanticAppendRequest{SessionID: sid, ExpectedHead: h.head(), Group: group}) + page, err := h.store.Read(h.ctx, session.ReadRequest{SessionID: sid, From: ^session.Seq(0) >> 1}) if err != nil { h.fatal(err) } - return res + return page.Head } -func (h *harness) mustApply(group extension.SemanticGroup) session.SessionCommit { +// 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 := h.appendGroup(group) - if res.Outcome != extension.SemanticApplied { + 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.Commit + return res.Events } func input(id string) run.AgentInput { @@ -157,7 +184,7 @@ func (h *harness) startGroup(turnID turn.TurnID, runID run.RunID, attempt uint32 return group } -// startRun creates a Run under turnID with the given inputs and returns it. +// 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...) @@ -192,18 +219,23 @@ func (h *harness) proto(runID run.RunID) run.Protocol { } // 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, grant run.ExecutionGrant, cmd run.AgentCommand, attach ...run.ModuleEvent) (run.CommitResult, error) { +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 h.rt.Commit(h.ctx, sid, run.CommitRequest{Base: base, Grant: grant, Command: env, Attach: attach}) + 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, grant run.ExecutionGrant, cmd run.AgentCommand, attach ...run.ModuleEvent) run.CommitResult { +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, grant, cmd, attach...) + res, err := h.commit(runID, id, base, cmd, attach...) if err != nil { h.fatal(fmt.Sprintf("commit %T: %v", cmd, err)) } @@ -272,27 +304,26 @@ 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) + 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.ExecutionGrant, run.ExecutionClaim) { +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), h.load(runID).Position, "", run.StartModelExecution{StepID: step, Claim: claim}) - if res.Grant == "" { - h.fatal("start returned no grant") + 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 res.Grant, claim + return claim } // executingModel drives a fresh Run to Model Executing. -func (h *harness) executingModel(runID run.RunID, withTool bool) (run.StepID, run.ExecutionGrant, run.ExecutionClaim) { +func (h *harness) executingModel(runID run.RunID, withTool bool) (run.StepID, run.ExecutionClaim) { h.t.Helper() step := h.prepare(runID, withTool) - grant, claim := h.startModel(runID, step) - return step, grant, claim + return step, h.startModel(runID, step) } func textResult(text string) run.ModelResult { @@ -330,9 +361,9 @@ func (h *harness) toolCallResult(step run.StepID, n int) (run.ModelResult, []run // 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, grant, claim := h.executingModel(runID, true) + step, claim := h.executingModel(runID, true) result, bindings := h.toolCallResult(step, n) - res := h.mustCommit(runID, run.DeriveSettlementCommandID(runID, step, "", claim), h.load(runID).Position, grant, + 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 { @@ -345,38 +376,29 @@ func (h *harness) openToolStep(runID run.RunID, n int) (run.StepID, []run.CallID return ts.RefValue.ID, ids } -func (h *harness) startTool(runID run.RunID, step run.StepID, call run.CallID) (run.ExecutionGrant, run.ExecutionClaim) { +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), h.load(runID).Position, "", run.StartToolCall{StepID: step, CallID: call, Claim: claim}) - if res.Grant == "" { - h.fatal("tool start returned no grant") - } - return res.Grant, claim -} - -func (h *harness) lease(runID run.RunID, step run.StepID, call run.CallID) (extension.Lease, bool) { - h.t.Helper() - l, ok, err := extension.Leases{Store: h.store}.Lookup(h.ctx, sid, runmod.LeaseNamespace, run.LeaseKey(runID, step, call)) - if err != nil { - h.fatal(err) + 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 l, ok + return claim } func (h *harness) machine() runmod.Machine { h.t.Helper() - state, _, err := h.reader.Load(h.ctx, sid, runmod.MachineProjectionID, runmod.MachineProjection.Version) + 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(c *session.SessionCommit) []session.EventType { - out := make([]session.EventType, len(c.Events)) - for i := range c.Events { - out[i] = c.Events[i].Type +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 index f7a356b..a985d1c 100644 --- a/agent/session/run/runtimetest/memory_test.go +++ b/agent/session/run/runtimetest/memory_test.go @@ -2,10 +2,15 @@ package runtimetest import ( "testing" + "time" "github.com/memohai/twilight/agent/session" ) func TestMemoryStoreConformance(t *testing.T) { - Run(t, func(testing.TB) session.Store { return session.NewMemoryStore() }) + Run(t, func(testing.TB) Fixture { + now := time.Unix(1_000_000, 0) + store := session.NewMemoryStoreWithClock(func() time.Time { return now }) + return Fixture{Store: store, Advance: func(d time.Duration) { now = now.Add(d) }} + }) } diff --git a/agent/session/sessiontest/conformance.go b/agent/session/sessiontest/conformance.go new file mode 100644 index 0000000..8188595 --- /dev/null +++ b/agent/session/sessiontest/conformance.go @@ -0,0 +1,294 @@ +// 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" + "time" + + "github.com/memohai/twilight/agent/jsonstable" + "github.com/memohai/twilight/agent/session" +) + +// Fixture is one adapter under test. Advance moves the adapter's clock for +// TTL takeover checks; nil skips those checks (file adapters have no TTL). +type Fixture struct { + Store session.Store + Advance func(time.Duration) +} + +// 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, ttl time.Duration) session.Writer { + t.Helper() + w, err := store.Open(context.Background(), sid, session.OpenOptions{TTL: ttl}) + 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", 0) + 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; a +// superseded Writer's Append and Heartbeat fail without writing; TTL expiry +// allows takeover. +func testOwnership(t *testing.T, f Fixture) { + ctx := context.Background() + create(t, f.Store, "s") + w1 := open(t, f.Store, "s", 0) + 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", 0) + 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) + } + if err := w1.Heartbeat(ctx); !session.IsCode(err, session.ErrOwnershipLost) { + t.Fatalf("old writer heartbeat = %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") + } + if err := w2.Close(ctx); err != nil { + t.Fatal(err) + } + // Read never needs ownership (SES-OWN-4): already exercised above while owned. + if f.Advance == nil { + return + } + w3 := open(t, f.Store, "s", time.Minute) + f.Advance(30 * time.Second) + if err := w3.Heartbeat(ctx); err != nil { + t.Fatalf("heartbeat inside TTL: %v", err) + } + f.Advance(45 * time.Second) + if _, err := f.Store.Open(ctx, "s", session.OpenOptions{TTL: time.Minute}); !session.IsCode(err, session.ErrOwned) { + t.Fatal("open succeeded while the heartbeat kept ownership alive") + } + f.Advance(time.Minute) + w4 := open(t, f.Store, "s", time.Minute) + if w4.Epoch() != w3.Epoch()+1 { + t.Fatalf("takeover epoch = %d, want %d", w4.Epoch(), w3.Epoch()+1) + } + if _, err := w3.Append(ctx, session.Group{CommitID: "late", Events: []session.UncommittedEvent{ev("twilight/x/a", `{}`)}}); !session.IsCode(err, session.ErrOwnershipLost) { + t.Fatalf("expired writer append = %v, want ownership_lost", err) + } + appendGroup(t, w4, "c3", ev("twilight/x/a", `{}`)) +} + +// 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", 0) + 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. +func testRead(t *testing.T, f Fixture) { + ctx := context.Background() + create(t, f.Store, "s") + w := open(t, f.Store, "s", 0) + 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 { + tamper.Tamper("s", 2, func(e *session.SessionEvent) { e.Payload = jsonstable.MustParse(`{"x":1}`) }) + if _, err := f.Store.Read(ctx, session.ReadRequest{SessionID: "s"}); !session.IsCode(err, session.ErrCorrupt) { + t.Fatalf("tampered read = %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 index 4006ae5..8e2db9b 100644 --- a/agent/session/store.go +++ b/agent/session/store.go @@ -2,127 +2,69 @@ package session import ( "context" + "time" "github.com/memohai/twilight/agent/es" "github.com/memohai/twilight/agent/jsonstable" ) // CreateRequest establishes a stream. Field-identical repeats are idempotent; -// a different request for the same SessionID is a Conflict (SES-WIR-1). +// a different request for the same SessionID is a Conflict. type CreateRequest struct { - ProtocolVersion uint16 - SessionID SessionID - CausationID es.CausationID - Metadata jsonstable.Value + ProtocolVersion uint16 + SessionID SessionID + CreatedAtUnixMilli int64 + CausationID es.CausationID + Metadata jsonstable.Value } -// AppendRequest is one atomic commit to append (SES 5). -type AppendRequest struct { - SessionID SessionID - ExpectedHead Head - CommitID CommitID - CausationID es.CausationID - CorrelationID string - Events []UncommittedEvent +// OpenOptions configures writer ownership (SES-OWN-1). TTL zero means the +// ownership lives only as long as the process or connection (file-lock +// semantics); non-zero means the writer must Heartbeat within TTL or another +// Open may take over. +type OpenOptions struct { + TTL time.Duration } -type AppendDisposition string - -const ( - AppendApplied AppendDisposition = "applied" - AppendAlreadyApplied AppendDisposition = "already_applied" - AppendHeadConflict AppendDisposition = "head_conflict" - AppendCommitConflict AppendDisposition = "commit_conflict" - AppendInvalid AppendDisposition = "invalid" -) - -type AppendResult struct { - Disposition AppendDisposition - Commit *SessionCommit - ActualHead Head - // Detail explains an Invalid disposition. - Detail string -} - -// SessionTx is the read/write view inside CommitIn's critical section -// (SES-API-2). Every method takes effect in the same transaction as the -// commit the fn decides to append. -type SessionTx interface { +// Writer is the kernel's ownership handle returned by Store.Open. Append and +// Heartbeat carry 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 - LookupCommit(CommitID) (SessionCommit, bool, error) - // Tail returns the commits after `after`; with a non-empty types filter - // only commits carrying at least one event whose Type has one of the - // prefixes are returned. - Tail(after Head, types []EventType) ([]SessionCommit, error) - LoadSnapshot(ProjectionKey, uint16) (SnapshotResult, error) - SaveSnapshot(Snapshot) error - ControlGet(ControlNamespace, string) (ControlEntry, bool, error) - ControlPut(ControlNamespace, string, []byte, int64) error - ControlDelete(ControlNamespace, string) error + // 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) + Heartbeat(context.Context) error + Close(context.Context) error } -// CommitInFn decides, inside the critical section, what to append. nil means -// append nothing; snapshot and KV writes already made through tx still commit. -type CommitInFn func(SessionTx) (*AppendRequest, error) - -type ReplayCursor struct { - After *EventPosition - Token CursorToken -} - -type ReplayRequest struct { +// 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 - Cursor *ReplayCursor - Limit uint32 + Limit uint32 // 0 = unlimited; truncation only at a group boundary } -type ReplayPage struct { +// 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 - Commits []SessionCommit - Next *ReplayCursor + Events []SessionEvent Head Head + HasMore bool } -type SnapshotRequest struct { - SessionID SessionID - ProjectionKey ProjectionKey - ProjectionVersion uint16 -} -type SnapshotResult struct { - Snapshot *Snapshot - Found bool -} -type SaveSnapshotRequest struct{ Snapshot Snapshot } -type SaveSnapshotResult struct { - Snapshot Snapshot - Replaced bool -} - -// Store is the kernel port (SES 4). Commit and CommitIn are the only append -// entries; both persist the commit, the new head and any same-call snapshot -// and control-plane writes atomically. +// Store is the kernel port (SES 4 to 6). type Store interface { Create(context.Context, CreateRequest) (SessionHeader, error) Header(context.Context, SessionID) (SessionHeader, error) - Head(context.Context, SessionID) (Head, error) - LookupCommit(context.Context, SessionID, CommitID) (SessionCommit, bool, error) - Commit(context.Context, AppendRequest) (AppendResult, error) - CommitIn(context.Context, SessionID, CommitInFn) (AppendResult, error) - Replay(context.Context, ReplayRequest) (ReplayPage, error) - LoadSnapshot(context.Context, SnapshotRequest) (SnapshotResult, error) - SaveSnapshot(context.Context, SaveSnapshotRequest) (SaveSnapshotResult, error) - ControlGet(context.Context, SessionID, ControlNamespace, string) (ControlEntry, bool, error) - ControlPut(context.Context, SessionID, ControlNamespace, string, []byte, int64) error - // ControlCompareAndPut writes only when the entry exists and its current - // Value equals expected bytewise; it reports whether it wrote. - ControlCompareAndPut(context.Context, SessionID, ControlNamespace, string, []byte, []byte, int64) (bool, error) - ControlDelete(context.Context, SessionID, ControlNamespace, string) error - // ControlScan enumerates across all Sessions by key prefix; fn false stops. - ControlScan(context.Context, ControlNamespace, string, func(ControlEntry) (bool, error)) error - // ControlExpired enumerates entries whose deadline is non-zero and before - // beforeUnixMilli, across all Sessions; fn false stops. - ControlExpired(context.Context, ControlNamespace, int64, func(ControlEntry) (bool, error)) 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 @@ -138,17 +80,3 @@ func HasTypePrefix(typ EventType, prefixes []EventType) bool { } return false } - -// CommitMatchesTypes reports whether a commit carries at least one event -// whose Type has one of the prefixes. -func CommitMatchesTypes(c *SessionCommit, prefixes []EventType) bool { - if len(prefixes) == 0 { - return true - } - for i := range c.Events { - if HasTypePrefix(c.Events[i].Type, prefixes) { - return true - } - } - return false -} diff --git a/agent/session/types.go b/agent/session/types.go index 222a4a3..1756800 100644 --- a/agent/session/types.go +++ b/agent/session/types.go @@ -1,7 +1,8 @@ -// Package session is the Event Sourcing kernel of a Twilight Session -// (docs/design/agent-session.md). It owns the envelope, ordering, commit, -// critical section, snapshot and control-plane KV mechanics; payloads are -// opaque canonical JSON that Session modules encode and interpret. +// Package session is the append-only log kernel of a Twilight Session +// (docs/design/agent-session.md, edition 2). 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 ( @@ -12,110 +13,77 @@ import ( ) type ( - SessionID string - CommitID string - EventID string - EventType string - ProjectionKey string - CursorToken string - ControlNamespace string + 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, envelope, commit, snapshot envelope and digest profile only; -// payload versions are carried by modules (SES-VER-1). +// 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 (SES-WIR-1). +// SessionHeader is the immutable creation record of a stream. type SessionHeader struct { - ProtocolVersion uint16 `json:"protocolVersion"` - SessionID SessionID `json:"sessionId"` - 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"` + 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"` - Revision es.Revision `json:"revision"` - HeadDigest es.Digest `json:"headDigest"` + ParentSessionID SessionID `json:"parentSessionId"` + Seq Seq `json:"seq"` + Digest es.Digest `json:"digest"` } -// SessionEvent is one committed event. Index orders events inside a commit; -// EventDigest additionally covers SessionID, Revision and Index. +// 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 { - EventID EventID `json:"eventId"` + Seq Seq `json:"seq"` + CommitID CommitID `json:"commitId"` Index uint16 `json:"index"` + Last bool `json:"last"` Type EventType `json:"type"` RecordedAtUnixMilli int64 `json:"recordedAtUnixMilli"` - SourceEvents []EventID `json:"sourceEvents,omitempty"` + SourceSeqs []Seq `json:"sourceSeqs,omitempty"` + Ignorable bool `json:"ignorable,omitempty"` Payload jsonstable.Value `json:"payload"` - EventDigest es.Digest `json:"eventDigest"` + Digest es.Digest `json:"digest"` } -// UncommittedEvent is what a producer hands to an append port. +// UncommittedEvent is what a producer hands to Append. type UncommittedEvent struct { - EventID EventID Type EventType RecordedAtUnixMilli int64 - SourceEvents []EventID + SourceSeqs []Seq + Ignorable bool Payload jsonstable.Value } -// SessionCommit is one atomic append: the unit of ordering and of the digest -// chain. Revision 1 chains to HeaderDigest, later commits to the previous -// CommitDigest. -type SessionCommit struct { - ProtocolVersion uint16 `json:"protocolVersion"` - SessionID SessionID `json:"sessionId"` - Revision es.Revision `json:"revision"` - PreviousDigest es.Digest `json:"previousDigest"` - CommitID CommitID `json:"commitId"` - CausationID es.CausationID `json:"causationId,omitempty"` - CorrelationID string `json:"correlationId,omitempty"` - Events []SessionEvent `json:"events"` - CommitDigest es.Digest `json:"commitDigest"` +// 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 commit. The empty stream head -// is {0, HeaderDigest}. +// 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 { - Revision es.Revision `json:"revision"` - Digest es.Digest `json:"digest"` + Next Seq `json:"next"` + Digest es.Digest `json:"digest"` } -// EventPosition addresses one committed event exactly. -type EventPosition struct { - Revision es.Revision `json:"revision"` - Index uint16 `json:"index"` - EventDigest es.Digest `json:"eventDigest"` -} - -// Snapshot is a discardable projection cache (SES-SNP-1). Through.Digest is -// the coverage proof: it must equal the CommitDigest at Through.Revision. -type Snapshot struct { - ProtocolVersion uint16 `json:"protocolVersion"` - SessionID SessionID `json:"sessionId"` - ProjectionKey ProjectionKey `json:"projectionKey"` - ProjectionVersion uint16 `json:"projectionVersion"` - Through Head `json:"through"` - State jsonstable.Value `json:"state"` - SnapshotDigest es.Digest `json:"snapshotDigest"` -} - -// ControlEntry is one control-plane KV row (SES-API-3). The kernel never -// interprets Value and never deletes an entry because its deadline passed. -type ControlEntry struct { - SessionID SessionID - Namespace ControlNamespace - Key string - Value []byte - DeadlineUnixMilli int64 -} - -// ErrorCode classifies kernel failures (SES 4). +// ErrorCode classifies kernel failures (SES 7). type ErrorCode string const ( @@ -123,9 +91,10 @@ const ( ErrNotFound ErrorCode = "not_found" ErrConflict ErrorCode = "conflict" ErrCorrupt ErrorCode = "corrupt" + ErrOwned ErrorCode = "owned" + ErrOwnershipLost ErrorCode = "ownership_lost" ErrUnsupportedProfile ErrorCode = "unsupported_profile" ErrUnsupported ErrorCode = "unsupported" - ErrUnavailable ErrorCode = "unavailable" ) // Error is the kernel's discriminable error value. diff --git a/agent/turn/coordinator.go b/agent/turn/coordinator.go index 1dc51b6..d4fe03e 100644 --- a/agent/turn/coordinator.go +++ b/agent/turn/coordinator.go @@ -88,12 +88,12 @@ type Service interface { } // Coordinator has no hidden state (TRN-SCP-3): every method reads the turn -// surface and the machine projection first. +// surface and the machine projection first. Writes and projection reads go +// through the Session's Writer (TRN-SCP-4, TRN-API-1). type Coordinator struct { - Projections extension.ProjectionReader - Appender extension.SemanticAppender - Runtime run.Runtime - Bindings ExecutionBindingRegistry + Writers extension.Writers + Runtime run.Runtime + Bindings ExecutionBindingRegistry // Now stamps event times; nil selects time.Now. Now func() time.Time } @@ -105,14 +105,52 @@ func (c *Coordinator) now() int64 { 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) { - state, _, err := c.Projections.Load(ctx, sid, SurfaceProjectionID, SurfaceProjection.Version) + 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) { @@ -141,14 +179,12 @@ func (c *Coordinator) Start(ctx context.Context, req StartRequest) (TurnResponse return TurnResponse{}, err } now := c.now() - res, err := c.Appender.AppendSemanticIn(ctx, sid, func(tx extension.SemanticTx) (*extension.SemanticGroup, error) { - if _, found, err := tx.LookupCommit(commitID); err != nil { - return nil, err - } else if found { + 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, plan, facts, now) - return &group, nil // exact replay: the Appender compares fingerprints + return &group, nil // exact replay: the Writer compares fingerprints } - surface, err := loadSurface(tx) + surface, err := loadSurface(view) if err != nil { return nil, err } @@ -158,7 +194,7 @@ func (c *Coordinator) Start(ctx context.Context, req StartRequest) (TurnResponse if _, active := surface.Active(); active { return nil, fmt.Errorf("%w: session already has an active turn", ErrConflict) } - if err := checkSubmitted(tx, req.Inputs); err != nil { + if err := checkSubmitted(view, req.Inputs); err != nil { return nil, err } group := c.startGroup(commitID, turnID, inputIDs, req, plan, facts, now) @@ -167,9 +203,6 @@ func (c *Coordinator) Start(ctx context.Context, req StartRequest) (TurnResponse if err != nil { return TurnResponse{}, err } - if res.Outcome != extension.SemanticApplied && res.Outcome != extension.SemanticAlreadyApplied { - return TurnResponse{}, fmt.Errorf("turn: start: %s: %s", res.Outcome, res.Detail) - } return c.drive(ctx, req.Ref, runID) } @@ -188,8 +221,8 @@ func (c *Coordinator) startGroup(commitID session.CommitID, turnID TurnID, input return group } -func loadSurface(tx extension.SemanticTx) (TurnSurface, error) { - state, _, err := extension.LoadIn(tx, &SurfaceProjection) +func loadSurface(view extension.View) (TurnSurface, error) { + state, err := view.Projection(SurfaceProjectionID, SurfaceProjection.Version) if err != nil { return TurnSurface{}, err } @@ -198,11 +231,11 @@ func loadSurface(tx extension.SemanticTx) (TurnSurface, error) { // checkSubmitted enforces TRN-STR-1 (2): each input is a submitted chatlog // Input whose Content equals the payload. -func checkSubmitted(tx extension.SemanticTx, inputs []run.AgentInput) error { +func checkSubmitted(view extension.View, inputs []run.AgentInput) error { if len(inputs) == 0 { return nil } - state, _, err := extension.LoadIn(tx, &chatlog.SurfaceProjection) + state, err := view.Projection(chatlog.SurfaceProjectionID, chatlog.SurfaceProjection.Version) if err != nil { return err } @@ -282,8 +315,8 @@ func (c *Coordinator) Retry(ctx context.Context, req RetryRequest) (TurnResponse sid, turnID := req.Ref.SessionID, req.Ref.TurnID var runID run.RunID now := c.now() - res, err := c.Appender.AppendSemanticIn(ctx, sid, func(tx extension.SemanticTx) (*extension.SemanticGroup, error) { - surface, err := loadSurface(tx) + err := c.commit(ctx, sid, "retry", func(v extension.View) (*extension.SemanticGroup, error) { + surface, err := loadSurface(v) if err != nil { return nil, err } @@ -298,7 +331,7 @@ func (c *Coordinator) Retry(ctx context.Context, req RetryRequest) (TurnResponse if err != nil { return nil, err } - inputs, err := deliveredInputs(tx, view.InputIDs) + inputs, err := deliveredInputs(v, view.InputIDs) if err != nil { return nil, err } @@ -315,16 +348,13 @@ func (c *Coordinator) Retry(ctx context.Context, req RetryRequest) (TurnResponse if err != nil { return TurnResponse{}, err } - if res.Outcome != extension.SemanticApplied && res.Outcome != extension.SemanticAlreadyApplied { - return TurnResponse{}, fmt.Errorf("turn: retry: %s: %s", res.Outcome, res.Detail) - } return c.drive(ctx, req.Ref, runID) } // deliveredInputs rebuilds the AgentInputs of a Turn from the chatlog surface, // in TurnView.InputIDs order (TRN-RTY-1). -func deliveredInputs(tx extension.SemanticTx, ids []chatlog.InputID) ([]run.AgentInput, error) { - state, _, err := extension.LoadIn(tx, &chatlog.SurfaceProjection) +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 } @@ -376,8 +406,8 @@ func (c *Coordinator) Settle(ctx context.Context, req SettleRequest) (TurnRespon sid, turnID := req.Ref.SessionID, req.Ref.TurnID var runID run.RunID now := c.now() - res, err := c.Appender.AppendSemanticIn(ctx, sid, func(tx extension.SemanticTx) (*extension.SemanticGroup, error) { - surface, err := loadSurface(tx) + err := c.commit(ctx, sid, "settle", func(v extension.View) (*extension.SemanticGroup, error) { + surface, err := loadSurface(v) if err != nil { return nil, err } @@ -393,9 +423,6 @@ func (c *Coordinator) Settle(ctx context.Context, req SettleRequest) (TurnRespon if err != nil { return TurnResponse{}, err } - if res.Outcome != extension.SemanticApplied && res.Outcome != extension.SemanticAlreadyApplied { - return TurnResponse{}, fmt.Errorf("turn: settle: %s: %s", res.Outcome, res.Detail) - } return c.respond(ctx, req.Ref, runID) } diff --git a/agent/turn/projection.go b/agent/turn/projection.go index 9889c0e..0b54869 100644 --- a/agent/turn/projection.go +++ b/agent/turn/projection.go @@ -105,7 +105,6 @@ 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"}, - RequireComplete: []extension.ModuleID{ModuleID, runmod.ModuleID}, Initial: func() (any, error) { return TurnSurface{Turns: map[TurnID]TurnView{}, RunOwner: map[run.RunID]TurnID{}}, nil }, diff --git a/docs/design/agent-artifact.md b/docs/design/agent-artifact.md index 0d33d31..810f6bf 100644 --- a/docs/design/agent-artifact.md +++ b/docs/design/agent-artifact.md @@ -1,6 +1,6 @@ # Twilight Agent Artifact Core -状态:设计草案,第二版(2026-09-08)。`agent/artifact` 已实现 Ref、Binding、Memory BindingStore、BindingSetBuilder 与第一版的两态 KV ledger(在 Session 控制面 KV 内、与 commit 同事务);Resolver、Store、Promoter 与 scheme registry 未实现。本版随 kernel 第二版把 ledger 改为自持久化、在 owner fact Append 之前建立 claim,尚未实现。wire 与 claim 状态表在 conformance 通过前不冻结。v1 的 claim 只有 `Active` 与 `Released` 两态;`Prepared` 状态、provider 迁移 fence 与 archive import/export 在附录中,不进入 v1 conformance。 +状态:设计草案,第二版(2026-09-08)。`agent/artifact` 已实现 Ref、Binding、Memory BindingStore、BindingSetBuilder 与第一版的两态 KV ledger(在 Session 控制面 KV 内、与 commit 同事务);Resolver、Store、Promoter 与 scheme registry 未实现。本版随 kernel 第二版把 ledger 改为自持久化(`MemoryLedger.Activate`)、在 owner fact Append 之前建立 claim,回收前核对由 `OwnerVerifier` 与 `Reconcile` 提供,已于 2026-09-08 实现。wire 与 claim 状态表在 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` 的通则。 diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index f1b9508..00b63a0 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -1,6 +1,6 @@ # Twilight Agent Run Protocol -状态:设计规范,第二版(2026-09-08)。Machine、command/fact 规则与 Loop 的执行逻辑已有实现并在第一版栈上通过测试;第 5 节的 Runtime 与 Loop 的执行授权部分按第一版(lease、grant、控制面 KV)实现于 `agent/session/run` 与 `agent/run/loop`,尚未按本版收缩。本版依据 [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) 第 8 节。 +状态:设计规范,第二版(2026-09-08)。Machine、command/fact 规则与 Loop 的执行逻辑已有实现并在第一版栈上通过测试;第 5 节的 Runtime(`agent/session/run`)与 Loop 已于 2026-09-08 按本版实现: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) 第 8 节。 本文定义 `agent/run`、`agent/run/loop` 与 Run 作为 Session Module 的存储形态。文中的"必须""不得""应该"是协议约束;canonical JSON、JCS 与 domain-separated digest 使用 `agent/jsonstable` 和 `agent/es` 的通则。 diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 3e5fd41..874a4d6 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -108,10 +108,10 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 | 参考组装 `agent/ref`(ExecutionBinding、ContextPlanner、Memory 组装、SessionDriver、崩溃恢复 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 重建) | 未开始 | +| 第 8 节代码重构(kernel 收缩、Writer、Runtime 去 lease/grant、接管处置、conformance 重建) | 完成,2026-09-08;`agent/` 下 9 个测试包全部通过,kernel 与 RUN-CMP-2 的 conformance 均以 Store 为参数 | | 文件 adapter(`agent/session/filestore`)、live 模型接入 | 未开始 | -以上"完成"的代码行都是第一版形态(多写者临界区、控制面 KV、lease),第 8 节修订后需要按新规范重写;当前正式调用形态仍为 `agent/ref` 的第一版 Memory 组装。Loop 不保存 authority state;Runtime 不读取 queue 或 planner context。 +2026-09-07 的代码行是第一版形态,已于 2026-09-08 按第 8 节重写为第二版。当前正式调用形态为 `agent/ref` 的 Memory 组装:`ref.New` 返回 Store、Registry、Writers、Runtime、Coordinator 与 Bindings;宿主对每个 Session 先 `Memory.Open`(取所有权并接管处置)再经 `SessionDriver.Send` 投递输入。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 的最小记录,因投影会随历史增长而未采用。 diff --git a/docs/design/agent-session-chatlog.md b/docs/design/agent-session-chatlog.md index 6cd70fc..5c5a5dc 100644 --- a/docs/design/agent-session-chatlog.md +++ b/docs/design/agent-session-chatlog.md @@ -1,6 +1,6 @@ # Twilight Agent Session Chatlog Module -状态:设计草案。`agent/session/chatlog` 已实现事件定义、parts codec、PartsExtractor、Surface 与 Context 投影;checkpoint 事件未实现。2026-09-08 随 kernel 第二版把位置类型改为 `session.Seq`、`RequireComplete` 改为 EXT-PRJ-2 的 Ignorable 规则,尚未实现。payload 字段、输入 limits 与 golden fixtures 尚未冻结。 +状态:设计草案。`agent/session/chatlog` 已实现事件定义、parts codec、PartsExtractor、Surface 与 Context 投影;checkpoint 事件未实现。2026-09-08 随 kernel 第二版把位置类型改为 `session.Seq`、`RequireComplete` 改为 EXT-PRJ-2 的 Ignorable 规则,已实现。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`。 diff --git a/docs/design/agent-session-extension.md b/docs/design/agent-session-extension.md index 4c0ceeb..d591596 100644 --- a/docs/design/agent-session-extension.md +++ b/docs/design/agent-session-extension.md @@ -1,6 +1,6 @@ # Twilight Agent Session Module Framework -状态:设计草案,第二版(2026-09-08)。第一版(双入口 Appender、Lease、事务内投影读写)已由 `agent/session/extension` 实现并验证,随后按 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 8 节收缩为本版:写入串行与幂等重放由进程内的 `Writer` 承担,kernel 只提供追加日志([agent-session.md](agent-session.md))。本版尚无实现;wire 在 conformance 通过前不冻结。 +状态:设计草案,第二版(2026-09-08)。第一版(双入口 Appender、Lease、事务内投影读写)已由 `agent/session/extension` 实现并验证,随后按 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 8 节收缩为本版:写入串行与幂等重放由进程内的 `Writer` 承担,kernel 只提供追加日志([agent-session.md](agent-session.md))。本版已由 `agent/session/extension` 实现(Writer、Writers、ProjectionReader 与 MemoryProjectionCache)并通过第 7 节的测试;wire 在文件 adapter 通过前不冻结。 本文定义建立在 `agent/session` 与 `agent/artifact` 之上的 Session Module Framework。实现包路径为 `agent/session/extension`;文中的"必须""不得""应该"是协议约束;JSON canonicalization 与 digest 遵循 `agent/jsonstable`、`agent/es`。 @@ -188,12 +188,13 @@ type ProjectionDefinition struct { StateCodec PayloadCodec } type ProjectionReader interface { - Load(ctx, sid session.SessionID, id ProjectionID, v ProjectionVersion) (state any, through session.Seq, err error) + // 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 与文件实现由本层提供。 +// ProjectionCache 是可选的派生缓存,随时可删;Memory 实现由本层提供。 type ProjectionCache interface { - Load(ctx, sid, id, v) (state jsonstable.Value, through session.Seq, digest es.Digest, ok bool, err error) - Save(ctx, sid, id, v, state jsonstable.Value, through session.Seq, digest es.Digest) error + 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 ``` @@ -202,7 +203,7 @@ func NewProjectionReader(store session.Store, registry *Registry, cache Projecti **EXT-PRJ-2** 投影只处理 `Consumes` 中的 EventType。其他 EventType 按归属处理:属于本模块或 `Requires` 模块(EXT-REG-4 的范围)且 `Decode` 为 Unknown 的事件,`Ignorable` 为真则跳过,否则 Fold 失败;范围之外的模块的事件一律跳过。写入者对纯信息性事件声明 `Ignorable`(EXT-REG),默认不可忽略:忘记声明只会导致多拒绝,不会导致静默丢失。读取时以范围内模块的前缀作为 `Types` 过滤。 -**EXT-PRJ-3** 缓存条目记录 `through`(已折叠到的最后一行 Seq)与该行的 `Digest`。复用条件:`Read(From: through)` 返回的首行 Seq 与 Digest 与缓存一致,且 `StateCodec.Decode` 成功;否则从头重折。写入策略由投影或其宿主决定(例如 run 的 `SnapshotPolicy`);缓存不在 kernel,也不与 append 同事务,丢失或过期只影响读取代价。 +**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 给出相同状态。 diff --git a/docs/design/agent-session.md b/docs/design/agent-session.md index 9bf3442..d1e88ee 100644 --- a/docs/design/agent-session.md +++ b/docs/design/agent-session.md @@ -1,6 +1,6 @@ # Twilight Agent Session Protocol -状态:设计草案,第二版(2026-09-08)。第一版(多写者临界区、commit 容器、控制面 KV、kernel 内 snapshot)已由 `agent/session` 的 Memory 实现验证过语义,随后按 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 8 节的决定收缩为本版。本版尚无实现;wire 在 Memory 与文件 adapter 通过第 7 节 conformance 前不冻结。 +状态:设计草案,第二版(2026-09-08)。第一版(多写者临界区、commit 容器、控制面 KV、kernel 内 snapshot)已由 `agent/session` 的 Memory 实现验证过语义,随后按 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 8 节的决定收缩为本版。本版已由 `agent/session` 的 MemoryStore 实现并通过第 7 节 conformance(`agent/session/sessiontest`,以 Store 为参数);wire 在文件 adapter 也通过前不冻结。 本文定义 Twilight Session 的 Event Sourcing kernel。文中的"必须""不得""应该"是协议约束。 diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index a30a4b6..0af660c 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -1,6 +1,6 @@ # Twilight Agent Turn 协议 -状态:设计草案,第二版(2026-09-08)。`agent/turn` 已按第一版实现(Coordinator 的 Start / Deliver / Resume / Retry / Stop / Settle、CompanionV1、surface 投影),写入经 `SemanticAppender`;本版随 [agent-session.md](agent-session.md) 与 [agent-session-extension.md](agent-session-extension.md) 第二版改为经 `extension.Writer` 写入、以 `Seq` 定位、恢复走接管处置,尚未实现。第 8 节 conformance 尚未完整实现,当前由 `agent/ref` 的测试覆盖 Start、Deliver、Stop 与新 Turn 的开启。Run 事实与 Turn、Chatlog 事件同在一条 Session stream。 +状态:设计草案,第二版(2026-09-08)。`agent/turn` 已按第一版实现(Coordinator 的 Start / Deliver / Resume / Retry / Stop / Settle、CompanionV1、surface 投影),写入经 `SemanticAppender`;本版随 [agent-session.md](agent-session.md) 与 [agent-session-extension.md](agent-session-extension.md) 第二版改为经 `extension.Writer` 写入、以 `Seq` 定位、恢复走接管处置,已于 2026-09-08 实现。第 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)。 From 4983a3035f2332d00505bed14c637ef6792110d5 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 9 Sep 2026 01:19:40 +0800 Subject: [PATCH 71/81] Docs: drop edition wording from the specs The seven protocol specs now describe only the current design; their status lines state what is implemented and point historical designs at agent-runtime-refactor.md. The refactor record keeps its history but dates its revisions instead of numbering editions, and the table in section 8 uses before/after columns. Go package comments follow. --- agent/artifact/artifact.go | 4 ++-- agent/session/extension/registry.go | 2 +- agent/session/types.go | 2 +- docs/design/agent-artifact.md | 4 ++-- docs/design/agent-run.md | 4 ++-- docs/design/agent-runtime-refactor.md | 30 +++++++++++++------------- docs/design/agent-session-chatlog.md | 2 +- docs/design/agent-session-extension.md | 6 +++--- docs/design/agent-session.md | 4 ++-- docs/design/agent-turn.md | 2 +- 10 files changed, 30 insertions(+), 30 deletions(-) diff --git a/agent/artifact/artifact.go b/agent/artifact/artifact.go index a312351..034badf 100644 --- a/agent/artifact/artifact.go +++ b/agent/artifact/artifact.go @@ -1,5 +1,5 @@ -// Package artifact is the Artifact Core (docs/design/agent-artifact.md, -// edition 2): Ref, Binding and the two-state RetentionLedger. The ledger +// 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 diff --git a/agent/session/extension/registry.go b/agent/session/extension/registry.go index d937b20..c6bc2d7 100644 --- a/agent/session/extension/registry.go +++ b/agent/session/extension/registry.go @@ -1,5 +1,5 @@ // Package extension is the Session Module Framework -// (docs/design/agent-session-extension.md, edition 2): typed event codecs with +// (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. diff --git a/agent/session/types.go b/agent/session/types.go index 1756800..b46b44e 100644 --- a/agent/session/types.go +++ b/agent/session/types.go @@ -1,5 +1,5 @@ // Package session is the append-only log kernel of a Twilight Session -// (docs/design/agent-session.md, edition 2). It owns the header, one row per +// (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. diff --git a/docs/design/agent-artifact.md b/docs/design/agent-artifact.md index 810f6bf..bbc21bd 100644 --- a/docs/design/agent-artifact.md +++ b/docs/design/agent-artifact.md @@ -1,6 +1,6 @@ # Twilight Agent Artifact Core -状态:设计草案,第二版(2026-09-08)。`agent/artifact` 已实现 Ref、Binding、Memory BindingStore、BindingSetBuilder 与第一版的两态 KV ledger(在 Session 控制面 KV 内、与 commit 同事务);Resolver、Store、Promoter 与 scheme registry 未实现。本版随 kernel 第二版把 ledger 改为自持久化(`MemoryLedger.Activate`)、在 owner fact Append 之前建立 claim,回收前核对由 `OwnerVerifier` 与 `Reconcile` 提供,已于 2026-09-08 实现。wire 与 claim 状态表在 conformance 通过前不冻结。v1 的 claim 只有 `Active` 与 `Released` 两态;`Prepared` 状态、provider 迁移 fence 与 archive import/export 在附录中,不进入 v1 conformance。 +状态:设计草案。`agent/artifact` 已实现 Ref、Binding、Memory BindingStore、BindingSetBuilder 与自持久化的两态 ledger(`MemoryLedger.Activate` 在 owner fact Append 之前建立 claim,回收前核对由 `OwnerVerifier` 与 `Reconcile` 提供);Resolver、Store、Promoter 与 scheme registry 未实现。wire 与 claim 状态表在 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` 的通则。 @@ -16,7 +16,7 @@ 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 实现。第一版只要求 Memory reference implementation 和 conformance suite。 +**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`。 diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index 00b63a0..c233a28 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -1,6 +1,6 @@ # Twilight Agent Run Protocol -状态:设计规范,第二版(2026-09-08)。Machine、command/fact 规则与 Loop 的执行逻辑已有实现并在第一版栈上通过测试;第 5 节的 Runtime(`agent/session/run`)与 Loop 已于 2026-09-08 按本版实现: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) 第 8 节。 +状态:设计规范。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` 的通则。 @@ -434,7 +434,7 @@ FrozenValueStore 的 `Put` 幂等且内容寻址,在进入 Writer 之前完成 ### 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 节。 +`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 diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 874a4d6..b4cc29f 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -41,7 +41,7 @@ Runtime Run command 的提交入口:Writer 内 Decide、Evolve、com FrozenValueStore 内容寻址旁存:模型请求本体(含工具定义) ``` -2026-09-04 之前的设计为两条 ES(Run 独立的 `RunHeader + TransitionRecord[]`,Turn 把 Run 事实 materialize 到 Session)。该设计已被第 6 节记录的决定取代,第 7 节记录审查后的第二次修订(多写者临界区、控制面 KV、lease),第 8 节记录 2026-09-08 的第三次修订(Session 级单写者、扁平事件)。上表为第 8 节之后的形态。 +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 @@ -107,11 +107,11 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 | `agent/turn` 重写(Coordinator、CompanionV1、surface 投影) | 完成,2026-09-07;旧实现已删除 | | 参考组装 `agent/ref`(ExecutionBinding、ContextPlanner、Memory 组装、SessionDriver、崩溃恢复 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 节规范修订(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 的代码行是第一版形态,已于 2026-09-08 按第 8 节重写为第二版。当前正式调用形态为 `agent/ref` 的 Memory 组装:`ref.New` 返回 Store、Registry、Writers、Runtime、Coordinator 与 Bindings;宿主对每个 Session 先 `Memory.Open`(取所有权并接管处置)再经 `SessionDriver.Send` 投递输入。Loop 不保存 authority state;Runtime 不读取 queue 或 planner context。 +2026-09-07 的代码行是第 8 节修订前的形态,已于 2026-09-08 按第 8 节重写。当前正式调用形态为 `agent/ref` 的 Memory 组装:`ref.New` 返回 Store、Registry、Writers、Runtime、Coordinator 与 Bindings;宿主对每个 Session 先 `Memory.Open`(取所有权并接管处置)再经 `SessionDriver.Send` 投递输入。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 的最小记录,因投影会随历史增长而未采用。 @@ -121,7 +121,7 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 ### 4.1 Core reference implementations -第一版(第 6、7 节)的全部条目已于 2026-09-07 完成;第 8 节修订后的实施顺序见 8.5。完成后再冻结 kernel `ProtocolVersion` 1 与各模块 payload 版本 1 的 golden fixtures。 +第 6、7 节的全部条目已于 2026-09-07 完成;第 8 节修订后的实施顺序见 8.5。完成后再冻结 kernel `ProtocolVersion` 1 与各模块 payload 版本 1 的 golden fixtures。 ### 4.2 durable adapters @@ -220,7 +220,7 @@ Run 从独立的 Event Sourcing 存储改为 first-party Session Module。Run 新增:`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. 2026-09-04 修订(架构审查后) ### 7.1 采纳的修正 @@ -243,7 +243,7 @@ Run 从独立的 Event Sourcing 存储改为 first-party Session Module。Run 审查意见"三个 first-party module 实际是一个领域,应合为一个实现"。耦合证据成立,但它们指向的是固定的分层顺序(turn → run、turn → chatlog),可以用包依赖表达。合成一个包会失去读侧收益:投影按 EventType 命名空间筛选,Context 只读 chatlog、machine 只读 run。因此保留三个包与三个命名空间,推迟的是可插拔框架(附录 B),不是模块划分。 -### 7.3 持久结构与一致性等级(第一版;第 8 节之后见 8.4) +### 7.3 持久结构与一致性等级(第 8 节修订前;修订后见 8.4) | 结构 | 等级 | 写入点 | 丢失或不一致时 | |---|---|---|---| @@ -270,9 +270,9 @@ lease 的第二条出路:grant 由 `(Claim, start CommitID)` 派生,start fa 5. `agent/turn` attempt 模型、CompanionV1、surface 投影; 6. 参考组装跑通 Input → Turn → Run → Session 纵向切片,再接 live 模型。 -### 7.6 租约的层次(2026-09-04 第三次修订) +### 7.6 租约的层次(2026-09-04,随后调整) -审查后的第一版把 lease 写成 run 模块对 opaque KV 的约定,续期与结算存在竞争,并补了一个投影兜底扫描。随后考虑过把类型化的 lease 原语放进 kernel,被否决:kernel 不应持有"持有者"这类模块语义。最终切法: +审查后最初把 lease 写成 run 模块对 opaque KV 的约定,续期与结算存在竞争,并补了一个投影兜底扫描。随后考虑过把类型化的 lease 原语放进 kernel,被否决:kernel 不应持有"持有者"这类模块语义。最终切法: | 层 | 提供 | |---|---| @@ -294,17 +294,17 @@ lease 的第二条出路:grant 由 `(Claim, start CommitID)` 派生,start fa 对照 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. 2026-09-08 修订:Session 级单写者与扁平事件 ### 8.1 起因 -第一版 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`,第二个写者直接被拒)说明单写者足以支撑同类需求。 +第 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) | @@ -325,11 +325,11 @@ lease 的第二条出路:grant 由 `(Claim, start CommitID)` 派生,start fa ### 8.3 失去与得到 -失去:同一 Session 的不同工具调用由不同进程并发执行(没有消费者);claim 与 commit 的同事务一致性(降为先 claim 后 append,孤儿由核对清理);第一版 conformance 中 grant 隔离、跨 Run grant、lease 续期的十几项断言。 +失去:同一 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 持久结构与一致性等级(第二版) +### 8.4 持久结构与一致性等级(修订后) | 结构 | 等级 | 写入点 | 丢失或不一致时 | |---|---|---|---| @@ -345,7 +345,7 @@ v1 只有两类恢复动作:`RecoverInterrupted`(新 owner 一次性处置 E ### 8.5 实施顺序 -1. `agent/session`:按第二版重写 Memory Store(Create、Header、Open/Epoch/Heartbeat、Append 整组、Read 过滤)与 conformance;删除 CommitIn、CAS、控制面 KV、snapshot、四套 digest、EventID、ReplayCursor; +1. `agent/session`:重写 Memory Store(Create、Header、Open/Epoch/Heartbeat、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`; @@ -353,6 +353,6 @@ v1 只有两类恢复动作:`RecoverInterrupted`(新 owner 一次性处置 E 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 验收。 +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 index 5c5a5dc..dac4185 100644 --- a/docs/design/agent-session-chatlog.md +++ b/docs/design/agent-session-chatlog.md @@ -1,6 +1,6 @@ # Twilight Agent Session Chatlog Module -状态:设计草案。`agent/session/chatlog` 已实现事件定义、parts codec、PartsExtractor、Surface 与 Context 投影;checkpoint 事件未实现。2026-09-08 随 kernel 第二版把位置类型改为 `session.Seq`、`RequireComplete` 改为 EXT-PRJ-2 的 Ignorable 规则,已实现。payload 字段、输入 limits 与 golden fixtures 尚未冻结。 +状态:设计草案。`agent/session/chatlog` 已实现事件定义、parts codec、PartsExtractor、Surface 与 Context 投影;checkpoint 事件未实现。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`。 diff --git a/docs/design/agent-session-extension.md b/docs/design/agent-session-extension.md index d591596..634a4b1 100644 --- a/docs/design/agent-session-extension.md +++ b/docs/design/agent-session-extension.md @@ -1,6 +1,6 @@ # Twilight Agent Session Module Framework -状态:设计草案,第二版(2026-09-08)。第一版(双入口 Appender、Lease、事务内投影读写)已由 `agent/session/extension` 实现并验证,随后按 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 8 节收缩为本版:写入串行与幂等重放由进程内的 `Writer` 承担,kernel 只提供追加日志([agent-session.md](agent-session.md))。本版已由 `agent/session/extension` 实现(Writer、Writers、ProjectionReader 与 MemoryProjectionCache)并通过第 7 节的测试;wire 在文件 adapter 通过前不冻结。 +状态:设计草案。已由 `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`。 @@ -107,7 +107,7 @@ type BindingReferenceDefinition struct { } ``` -**EXT-REF-1** 声明以 `Extractor` 提取 typed value 内的全部 Artifact 引用,保留 appearance order,随后 group 才 sorted-unique。Extractor 随 EventDefinition 声明,本层不维护提取器注册表。(第一版的 JSONPointer 路径提取无消费者,已删除。) +**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,不作任何写入。 @@ -166,7 +166,7 @@ func OpenWriter(ctx, store session.Store, registry *Registry, ledger artifact.Re **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}`。 +**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。 diff --git a/docs/design/agent-session.md b/docs/design/agent-session.md index d1e88ee..cd77c8e 100644 --- a/docs/design/agent-session.md +++ b/docs/design/agent-session.md @@ -1,6 +1,6 @@ # Twilight Agent Session Protocol -状态:设计草案,第二版(2026-09-08)。第一版(多写者临界区、commit 容器、控制面 KV、kernel 内 snapshot)已由 `agent/session` 的 Memory 实现验证过语义,随后按 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 8 节的决定收缩为本版。本版已由 `agent/session` 的 MemoryStore 实现并通过第 7 节 conformance(`agent/session/sessiontest`,以 Store 为参数);wire 在文件 adapter 也通过前不冻结。 +状态:设计草案。已由 `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。文中的"必须""不得""应该"是协议约束。 @@ -164,7 +164,7 @@ v1 conformance 以 `Store` 为参数,Memory 与文件 adapter 跑同一套, - **SES-REP-1/2**:顺序、From、Limit 在组边界截断、过滤与全量对匹配类型一致、篡改任一行后无过滤读取报 `ErrCorrupt`; - **SES-SCP-3**:附录 A 入口返回 `ErrUnsupported`,`ParentFork` 非 nil 的 header 被拒绝。 -第一版实现为 MemoryStore 与文件 adapter(一个 Session 一个目录,`stream.jsonl` 一行一个 event,`session.lock` 为 `flock` 目标)。 +参考实现为 MemoryStore 与文件 adapter(一个 Session 一个目录,`stream.jsonl` 一行一个 event,`session.lock` 为 `flock` 目标)。 ## 附录 A:预留能力(不进入 v1) diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index 0af660c..a617451 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -1,6 +1,6 @@ # Twilight Agent Turn 协议 -状态:设计草案,第二版(2026-09-08)。`agent/turn` 已按第一版实现(Coordinator 的 Start / Deliver / Resume / Retry / Stop / Settle、CompanionV1、surface 投影),写入经 `SemanticAppender`;本版随 [agent-session.md](agent-session.md) 与 [agent-session-extension.md](agent-session-extension.md) 第二版改为经 `extension.Writer` 写入、以 `Seq` 定位、恢复走接管处置,已于 2026-09-08 实现。第 8 节 conformance 尚未完整实现,当前由 `agent/ref` 的测试覆盖 Start、Deliver、Stop 与新 Turn 的开启。Run 事实与 Turn、Chatlog 事件同在一条 Session stream。 +状态:设计草案。`agent/turn` 已按本文实现:Coordinator 的 Start / Deliver / Resume / Retry / Stop / Settle、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)。 From 76e5a3efab6a046ee83f0b2bb2e6179bee3428b8 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 9 Sep 2026 02:16:24 +0800 Subject: [PATCH 72/81] Add the JSONL file store and the prototype example agent/session/filestore: one directory per Session (header.json, log.jsonl, owner.json). Ownership is arbitrated through owner.json, so two instances over one root behave as two processes; Open scans the log, verifies the digest chain and truncates a torn tail; Append seals a whole group in one write. Tamper and LogPath support tests and debugging. Both conformance suites run against it. agent/ref Example_jsonlPrototype drives steer (Deliver into the running turn), queue (submitted input picked up after settlement) and crash recovery (second store instance takes over, Resume finishes the turn) on one inspectable JSONL stream. --- agent/ref/example_filestore_test.go | 260 +++++++++ agent/session/filestore/conformance_test.go | 47 ++ agent/session/filestore/filestore.go | 560 ++++++++++++++++++++ 3 files changed, 867 insertions(+) create mode 100644 agent/ref/example_filestore_test.go create mode 100644 agent/session/filestore/conformance_test.go create mode 100644 agent/session/filestore/filestore.go diff --git a/agent/ref/example_filestore_test.go b/agent/ref/example_filestore_test.go new file mode 100644 index 0000000..d7125e5 --- /dev/null +++ b/agent/ref/example_filestore_test.go @@ -0,0 +1,260 @@ +package ref_test + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/memohai/twilight/agent/ref" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/filestore" + "github.com/memohai/twilight/agent/turn" + "github.com/memohai/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. +// After the ownership TTL a second Store instance over the same directory — +// a new process — takes the Session over, 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" + const ownership = 30 * time.Second + 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, filestore.Options{Now: clock.Now}) + if err != nil { + panic(err) + } + p1, err := ref.New(ref.Options{Store: store1, Frozen: frozen, Ownership: session.OpenOptions{TTL: ownership}, 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")}} + binding1, err := p1.Bindings.Register("jsonl-agent", protoBinding(model1, tool)) + if err != nil { + panic(err) + } + turnSeq := 0 + driver := &ref.SessionDriver{Coordinator: p1.Coordinator, Memory: p1, Binding: binding1, 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-1). + in2, err := p1.SubmitInput(ctx, sid, "in-2", "and tomorrow?") + if err != nil { + panic(err) + } + steerDone := make(chan struct{}) + go func() { + defer close(steerDone) + if _, err := driver.Send(ctx, sid, []run.AgentInput{in2}); err != nil && !errors.Is(err, loop.ErrRunAlreadyRunning) { + 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-2); 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 ------------- + clock.Advance(2 * ownership) + store2, err := filestore.New(root, filestore.Options{Now: clock.Now}) + if err != nil { + panic(err) + } + p2, err := ref.New(ref.Options{Store: store2, Frozen: frozen, Ownership: session.OpenOptions{TTL: ownership}, Now: clock.Now}) + if err != nil { + panic(err) + } + if _, err := p2.Bindings.Register("jsonl-agent", protoBinding(&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.Coordinator.Resume(ctx, turn.TurnRequest{Ref: 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 protoBinding(model loop.ModelInvoker, tool *stagedTool) ref.Binding { + def, err := run.FreezeToolDefinition(tool.Definition()) + if err != nil { + panic(err) + } + return ref.Binding{ + Public: ref.BindingPublic{Model: "m-1", Tools: []ref.PublicTool{{Ref: tool.Ref(), Definition: def, Policy: run.DirectExecution}}}, + Models: modelCatalog{model}, + Tools: stagedCatalog{tool}, + } +} + +// 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}} +} + +type stagedCatalog struct{ tool *stagedTool } + +func (c stagedCatalog) ResolveTool(run.ToolRef) (loop.ExecutableTool, error) { return c.tool, nil } diff --git a/agent/session/filestore/conformance_test.go b/agent/session/filestore/conformance_test.go new file mode 100644 index 0000000..fef7e4e --- /dev/null +++ b/agent/session/filestore/conformance_test.go @@ -0,0 +1,47 @@ +package filestore_test + +import ( + "sync" + "testing" + "time" + + "github.com/memohai/twilight/agent/session/filestore" + "github.com/memohai/twilight/agent/session/run/runtimetest" + "github.com/memohai/twilight/agent/session/sessiontest" +) + +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() +} + +func newFixture(t testing.TB) (*filestore.Store, *clock) { + t.Helper() + c := &clock{now: time.Unix(1_000_000, 0)} + store, err := filestore.New(t.TempDir(), filestore.Options{Now: c.Now}) + if err != nil { + t.Fatal(err) + } + return store, c +} + +func TestKernelConformance(t *testing.T) { + sessiontest.Run(t, func(t *testing.T) sessiontest.Fixture { + store, c := newFixture(t) + return sessiontest.Fixture{Store: store, Advance: c.Advance} + }) +} + +func TestRuntimeConformance(t *testing.T) { + runtimetest.Run(t, func(t testing.TB) runtimetest.Fixture { + store, c := newFixture(t) + return runtimetest.Fixture{Store: store, Advance: c.Advance} + }) +} diff --git a/agent/session/filestore/filestore.go b/agent/session/filestore/filestore.go new file mode 100644 index 0000000..516ca30 --- /dev/null +++ b/agent/session/filestore/filestore.go @@ -0,0 +1,560 @@ +// 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, owned flag, deadline). 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 or Heartbeat. 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" + "time" + "unicode/utf8" + + "github.com/memohai/twilight/agent/session" +) + +const ( + headerFile = "header.json" + logFile = "log.jsonl" + ownerFile = "owner.json" +) + +// Options tunes the store. +type Options struct { + // Now drives ownership deadlines; nil selects time.Now. + Now func() time.Time +} + +// Store is the JSONL session.Store. +type Store struct { + root string + profile session.ProtocolProfile + now func() time.Time + mu sync.Mutex // serializes every operation of this instance +} + +// New opens the store root, creating it if needed. +func New(root string, opts Options) (*Store, error) { + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, err + } + now := opts.Now + if now == nil { + now = time.Now + } + return &Store{root: root, profile: session.ProfileV1(), now: now}, 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, Heartbeat and Close checks against. +type ownerRecord struct { + Epoch session.Epoch `json:"epoch"` + Owned bool `json:"owned"` + DeadlineUnixMilli int64 `json:"deadlineUnixMilli,omitempty"` +} + +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 + } + if opts.TTL < 0 { + return nil, kerr(session.ErrInvalid, "open", sid, "negative TTL") + } + 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 + } + now := s.now() + if rec.Owned && (rec.DeadlineUnixMilli == 0 || now.UnixMilli() < rec.DeadlineUnixMilli) { + 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 opts.TTL > 0 { + rec.DeadlineUnixMilli = now.Add(opts.TTL).UnixMilli() + } else { + rec.DeadlineUnixMilli = 0 + } + if err := saveOwner(dir, rec); err != nil { + return nil, err + } + w := &fileWriter{store: s, header: header, dir: dir, logPath: logPath, epoch: rec.Epoch, ttl: opts.TTL, + 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 + ttl time.Duration + 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) Heartbeat(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + w.store.mu.Lock() + defer w.store.mu.Unlock() + if err := w.current("heartbeat"); err != nil { + return err + } + if w.ttl > 0 { + return saveOwner(w.dir, ownerRecord{Epoch: w.epoch, Owned: true, DeadlineUnixMilli: w.store.now().Add(w.ttl).UnixMilli()}) + } + 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 + } + // A durable adapter verifies the chain on unfiltered reads (SES-REP-1). + if len(req.Types) == 0 { + if err := session.ValidateChain(s.profile, header, rows); 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 read-side +// chain check 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) From 09a12f524c71593782d125a22212237617a50095 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 9 Sep 2026 02:29:01 +0800 Subject: [PATCH 73/81] Ownership by explicit takeover; verify the chain at Open OpenOptions carries Takeover instead of TTL and Writer loses Heartbeat: a second opener wins only by declaring takeover, and safety rests on epoch fencing alone, so stores need no clock. owner.json shrinks to {epoch, owned}. Digest-chain verification moves from every Read to Open (and the exported ValidateChain); Read trusts the store. Conformance drops the Advance fixture hook, the takeover subtests run on every adapter, and the tamper subtest asserts corrupt-at-Open. SES-OWN-1/2 and SES-REP-1 rewritten accordingly. --- agent/ref/assembly.go | 4 +- agent/ref/example_filestore_test.go | 18 +++-- agent/ref/example_test.go | 19 ++--- agent/run/loop/helpers_test.go | 15 ++-- agent/run/loop/takeover_test.go | 18 ++--- agent/session/extension/extension_test.go | 29 ++++---- agent/session/filestore/conformance_test.go | 27 ++------ agent/session/filestore/filestore.go | 73 ++++---------------- agent/session/memory.go | 64 +++++------------ agent/session/memory_test.go | 5 +- agent/session/run/runtimetest/conformance.go | 37 ++++------ agent/session/run/runtimetest/harness.go | 19 +++-- agent/session/run/runtimetest/memory_test.go | 5 +- agent/session/sessiontest/conformance.go | 72 ++++++++----------- agent/session/store.go | 16 ++--- docs/design/agent-runtime-refactor.md | 8 +-- docs/design/agent-session.md | 19 +++-- 17 files changed, 149 insertions(+), 299 deletions(-) diff --git a/agent/ref/assembly.go b/agent/ref/assembly.go index 9433a34..f8cd329 100644 --- a/agent/ref/assembly.go +++ b/agent/ref/assembly.go @@ -18,8 +18,8 @@ import ( // Options tunes the Memory assembly. type Options struct { - // Ownership configures the Session Writer: TTL zero is process-lifetime - // ownership, non-zero lets another assembly take over after the TTL. + // 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. diff --git a/agent/ref/example_filestore_test.go b/agent/ref/example_filestore_test.go index d7125e5..9a56aad 100644 --- a/agent/ref/example_filestore_test.go +++ b/agent/ref/example_filestore_test.go @@ -27,14 +27,13 @@ import ( // Turn 2 from the queued input. // // Turn 2 shows resume: the process "crashes" while its tool call executes. -// After the ownership TTL a second Store instance over the same directory — -// a new process — takes the Session over, 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. +// 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" - const ownership = 30 * time.Second clock := &fakeClock{now: time.Unix(1_000_000, 0)} root, err := os.MkdirTemp("", "twilight-jsonl-*") if err != nil { @@ -46,11 +45,11 @@ func Example_jsonlPrototype() { frozen := run.NewMemoryFrozenValues() // ---- process 1 ---------------------------------------------------------- - store1, err := filestore.New(root, filestore.Options{Now: clock.Now}) + store1, err := filestore.New(root) if err != nil { panic(err) } - p1, err := ref.New(ref.Options{Store: store1, Frozen: frozen, Ownership: session.OpenOptions{TTL: ownership}, Now: clock.Now}) + p1, err := ref.New(ref.Options{Store: store1, Frozen: frozen, Now: clock.Now}) if err != nil { panic(err) } @@ -131,12 +130,11 @@ func Example_jsonlPrototype() { 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 ------------- - clock.Advance(2 * ownership) - store2, err := filestore.New(root, filestore.Options{Now: clock.Now}) + store2, err := filestore.New(root) if err != nil { panic(err) } - p2, err := ref.New(ref.Options{Store: store2, Frozen: frozen, Ownership: session.OpenOptions{TTL: ownership}, Now: clock.Now}) + p2, err := ref.New(ref.Options{Store: store2, Frozen: frozen, Ownership: session.OpenOptions{Takeover: true}, Now: clock.Now}) if err != nil { panic(err) } diff --git a/agent/ref/example_test.go b/agent/ref/example_test.go index 80a5aa0..82c9925 100644 --- a/agent/ref/example_test.go +++ b/agent/ref/example_test.go @@ -23,8 +23,9 @@ import ( // 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 after the ownership TTL passed and -// takes the Session over (Epoch 2). Its takeover disposition settles the +// 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 @@ -33,16 +34,15 @@ import ( func Example_recoverableTurn() { ctx := context.Background() const sid session.SessionID = "session-1" - const ownership = 30 * time.Second clock := &fakeClock{now: time.Unix(1_000_000, 0)} // Shared "durable" state: the Session store and the frozen request bodies. - store := session.NewMemoryStoreWithClock(clock.Now) + store := session.NewMemoryStore() frozen := run.NewMemoryFrozenValues() tool := &lookupTool{block: make(chan struct{})} // ---- process 1 ---------------------------------------------------------- - p1, err := ref.New(ref.Options{Store: store, Frozen: frozen, Ownership: session.OpenOptions{TTL: ownership}, Now: clock.Now}) + p1, err := ref.New(ref.Options{Store: store, Frozen: frozen, Now: clock.Now}) if err != nil { panic(err) } @@ -71,8 +71,7 @@ func Example_recoverableTurn() { fmt.Println("process 1: tool call is Executing; process crashes") // ---- process 2 ---------------------------------------------------------- - clock.Advance(2 * ownership) - p2, err := ref.New(ref.Options{Store: store, Frozen: frozen, Ownership: session.OpenOptions{TTL: ownership}, Now: clock.Now}) + p2, err := ref.New(ref.Options{Store: store, Frozen: frozen, Ownership: session.OpenOptions{Takeover: true}, Now: clock.Now}) if err != nil { panic(err) } @@ -187,12 +186,6 @@ func (c *fakeClock) Now() time.Time { return c.now } -func (c *fakeClock) Advance(d time.Duration) { - c.mu.Lock() - c.now = c.now.Add(d) - c.mu.Unlock() -} - type modelCatalog struct{ m loop.ModelInvoker } func (c modelCatalog) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { return c.m, nil } diff --git a/agent/run/loop/helpers_test.go b/agent/run/loop/helpers_test.go index b221c62..ab5b9b6 100644 --- a/agent/run/loop/helpers_test.go +++ b/agent/run/loop/helpers_test.go @@ -34,15 +34,14 @@ type testStack struct { writers extension.Writers runtime *runmod.Runtime now func() time.Time - ttl time.Duration } -func newTestStack(t testing.TB, ttl time.Duration, now func() time.Time) *testStack { +func newTestStack(t testing.TB, now func() time.Time) *testStack { t.Helper() if now == nil { now = time.Now } - store := session.NewMemoryStoreWithClock(now) + store := session.NewMemoryStore() registry, err := extension.BuildRegistry(session.ProtocolVersion1, runmod.Module) if err != nil { t.Fatal(err) @@ -50,16 +49,16 @@ func newTestStack(t testing.TB, ttl time.Duration, now func() time.Time) *testSt 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, ttl: ttl} + s := &testStack{store: store, registry: registry, now: now} s.open(t) return s } -// open starts a new owner process over the same store (a takeover when a -// previous one is still open and its TTL has passed). +// 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{TTL: s.ttl}) + 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) @@ -98,7 +97,7 @@ func (s *testStack) createRun(t testing.TB, runID RunID, inputs ...AgentInput) { // newTestRuntime is a Runtime holding "run-1" seeded with one input. func newTestRuntime(t testing.TB) Runtime { t.Helper() - stack := newTestStack(t, 0, nil) + stack := newTestStack(t, nil) stack.createRun(t, "run-1", AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)}) return stack.runtime } diff --git a/agent/run/loop/takeover_test.go b/agent/run/loop/takeover_test.go index 35763e2..43a8f87 100644 --- a/agent/run/loop/takeover_test.go +++ b/agent/run/loop/takeover_test.go @@ -3,25 +3,18 @@ package loop import ( "context" "errors" - "sync" "testing" - "time" . "github.com/memohai/twilight/agent/run" "github.com/memohai/twilight/sdk" ) // The owner process dies while a tool call is Executing. A new owner takes -// the Session over after the TTL, 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). +// 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) { - var mu sync.Mutex - clock := time.Unix(1000, 0) - now := func() time.Time { mu.Lock(); defer mu.Unlock(); return clock } - advance := func(d time.Duration) { mu.Lock(); defer mu.Unlock(); clock = clock.Add(d) } - - stack := newTestStack(t, time.Minute, now) + stack := newTestStack(t, nil) stack.createRun(t, "run-1", AgentInput{ID: "seed", Payload: cj(`{}`)}) oldRuntime := stack.runtime @@ -45,8 +38,7 @@ func TestTakeoverDisposesExecutingCallAndFencesOldOwner(t *testing.T) { go func() { _, err := first.Run(context.Background(), oldRuntime, testSession, "run-1", nil); firstDone <- err }() <-started - // The old owner stops heartbeating; the TTL passes; a new owner opens. - advance(2 * time.Minute) + // 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 { diff --git a/agent/session/extension/extension_test.go b/agent/session/extension/extension_test.go index a588701..fb3bd44 100644 --- a/agent/session/extension/extension_test.go +++ b/agent/session/extension/extension_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "testing" - "time" "github.com/memohai/twilight/agent/artifact" "github.com/memohai/twilight/agent/jsonstable" @@ -103,13 +102,12 @@ type fixture struct { registry *Registry bindings *artifact.MemoryBindingStore ledger *artifact.MemoryLedger - now time.Time } func newFixture(t *testing.T) *fixture { t.Helper() - f := &fixture{now: time.Unix(1_700_000_000, 0)} - f.store = session.NewMemoryStoreWithClock(func() time.Time { return f.now }) + f := &fixture{} + f.store = session.NewMemoryStore() r, err := BuildRegistry(session.ProtocolVersion1, noteModule("a")) if err != nil { t.Fatal(err) @@ -125,9 +123,9 @@ func newFixture(t *testing.T) *fixture { func (f *fixture) admission() Admission { return Admission{Bindings: f.bindings, Ledger: f.ledger} } -func (f *fixture) open(t *testing.T, ttl time.Duration) Writer { +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{TTL: ttl}) + 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) } @@ -158,7 +156,7 @@ func notes(t *testing.T, w Writer) []string { func TestWriterCommitReplayAndRebuild(t *testing.T) { f := newFixture(t) ctx := context.Background() - w := f.open(t, 0) + 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) @@ -216,7 +214,7 @@ func TestWriterCommitReplayAndRebuild(t *testing.T) { if _, err := w.Commit(ctx, noteGroup("c4", "x")); err == nil { t.Fatal("closed writer accepted a commit") } - w2 := f.open(t, 0) + w2 := f.open(t, false) if w2.Epoch() != 2 { t.Fatalf("epoch = %d", w2.Epoch()) } @@ -237,12 +235,11 @@ func TestWriterCommitReplayAndRebuild(t *testing.T) { func TestWriterOwnershipLost(t *testing.T) { f := newFixture(t) ctx := context.Background() - w1 := f.open(t, time.Minute) + w1 := f.open(t, false) if _, err := w1.Commit(ctx, noteGroup("c1", "one")); err != nil { t.Fatal(err) } - f.now = f.now.Add(2 * time.Minute) - w2 := f.open(t, time.Minute) + 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) } @@ -271,7 +268,7 @@ func TestWriterOwnershipLost(t *testing.T) { func TestProjectionUnknownEvents(t *testing.T) { f := newFixture(t) ctx := context.Background() - w := f.open(t, 0) + w := f.open(t, false) if _, err := w.Commit(ctx, noteGroup("c1", "one")); err != nil { t.Fatal(err) } @@ -291,7 +288,7 @@ func TestProjectionUnknownEvents(t *testing.T) { 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, 0) + w = f.open(t, false) if got := notes(t, w); len(got) != 1 { t.Fatalf("notes = %v", got) } @@ -313,7 +310,7 @@ func TestWriterClaimsAndReconcile(t *testing.T) { if _, err := f.bindings.CreateBinding(ctx, b); err != nil { t.Fatal(err) } - w := f.open(t, 0) + w := f.open(t, false) res, err := w.Commit(ctx, func(View) (*SemanticGroup, error) { return &SemanticGroup{CommitID: "c1", Events: []TypedEvent{{Type: ModulePrefix("a") + "note", Value: notePayload{Text: "file", Refs: []string{"b1"}}}}}, nil }) @@ -334,7 +331,7 @@ func TestWriterClaimsAndReconcile(t *testing.T) { t.Fatal(err) } _ = w.Close(ctx) - w = f.open(t, 0) + 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) @@ -349,7 +346,7 @@ func TestWriterClaimsAndReconcile(t *testing.T) { func TestProjectionCache(t *testing.T) { f := newFixture(t) ctx := context.Background() - w := f.open(t, 0) + w := f.open(t, false) defer w.Close(ctx) id := ProjectionID(string(ModulePrefix("a")) + "notes") _, _ = w.Commit(ctx, noteGroup("c1", "one")) diff --git a/agent/session/filestore/conformance_test.go b/agent/session/filestore/conformance_test.go index fef7e4e..85c51b9 100644 --- a/agent/session/filestore/conformance_test.go +++ b/agent/session/filestore/conformance_test.go @@ -1,47 +1,30 @@ package filestore_test import ( - "sync" "testing" - "time" "github.com/memohai/twilight/agent/session/filestore" "github.com/memohai/twilight/agent/session/run/runtimetest" "github.com/memohai/twilight/agent/session/sessiontest" ) -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() -} - -func newFixture(t testing.TB) (*filestore.Store, *clock) { +func newStore(t testing.TB) *filestore.Store { t.Helper() - c := &clock{now: time.Unix(1_000_000, 0)} - store, err := filestore.New(t.TempDir(), filestore.Options{Now: c.Now}) + store, err := filestore.New(t.TempDir()) if err != nil { t.Fatal(err) } - return store, c + return store } func TestKernelConformance(t *testing.T) { sessiontest.Run(t, func(t *testing.T) sessiontest.Fixture { - store, c := newFixture(t) - return sessiontest.Fixture{Store: store, Advance: c.Advance} + return sessiontest.Fixture{Store: newStore(t)} }) } func TestRuntimeConformance(t *testing.T) { runtimetest.Run(t, func(t testing.TB) runtimetest.Fixture { - store, c := newFixture(t) - return runtimetest.Fixture{Store: store, Advance: c.Advance} + return runtimetest.Fixture{Store: newStore(t)} }) } diff --git a/agent/session/filestore/filestore.go b/agent/session/filestore/filestore.go index 516ca30..98db1ab 100644 --- a/agent/session/filestore/filestore.go +++ b/agent/session/filestore/filestore.go @@ -1,14 +1,13 @@ // 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, owned flag, deadline). The log is plain -// JSONL so a stream can be inspected and diffed with standard tools. +// 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 or Heartbeat. 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. +// 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 ( @@ -20,7 +19,6 @@ import ( "path/filepath" "strings" "sync" - "time" "unicode/utf8" "github.com/memohai/twilight/agent/session" @@ -32,30 +30,19 @@ const ( ownerFile = "owner.json" ) -// Options tunes the store. -type Options struct { - // Now drives ownership deadlines; nil selects time.Now. - Now func() time.Time -} - // Store is the JSONL session.Store. type Store struct { root string profile session.ProtocolProfile - now func() time.Time mu sync.Mutex // serializes every operation of this instance } // New opens the store root, creating it if needed. -func New(root string, opts Options) (*Store, error) { +func New(root string) (*Store, error) { if err := os.MkdirAll(root, 0o755); err != nil { return nil, err } - now := opts.Now - if now == nil { - now = time.Now - } - return &Store{root: root, profile: session.ProfileV1(), now: now}, nil + return &Store{root: root, profile: session.ProfileV1()}, nil } // LogPath returns the Session's JSONL log file for direct inspection. @@ -174,11 +161,10 @@ func (s *Store) Header(ctx context.Context, sid session.SessionID) (session.Sess // --- ownership ------------------------------------------------------------------ // ownerRecord is the persisted ownership state; owner.json is the authority -// that every Append, Heartbeat and Close checks against. +// that every Append and Close checks against. type ownerRecord struct { - Epoch session.Epoch `json:"epoch"` - Owned bool `json:"owned"` - DeadlineUnixMilli int64 `json:"deadlineUnixMilli,omitempty"` + Epoch session.Epoch `json:"epoch"` + Owned bool `json:"owned"` } func loadOwner(dir string) (ownerRecord, error) { @@ -208,9 +194,6 @@ func (s *Store) Open(ctx context.Context, sid session.SessionID, opts session.Op if err := ctx.Err(); err != nil { return nil, err } - if opts.TTL < 0 { - return nil, kerr(session.ErrInvalid, "open", sid, "negative TTL") - } s.mu.Lock() defer s.mu.Unlock() header, dir, err := s.loadHeader(sid, "open") @@ -221,8 +204,7 @@ func (s *Store) Open(ctx context.Context, sid session.SessionID, opts session.Op if err != nil { return nil, err } - now := s.now() - if rec.Owned && (rec.DeadlineUnixMilli == 0 || now.UnixMilli() < rec.DeadlineUnixMilli) { + 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) @@ -243,15 +225,10 @@ func (s *Store) Open(ctx context.Context, sid session.SessionID, opts session.Op } rec.Epoch++ rec.Owned = true - if opts.TTL > 0 { - rec.DeadlineUnixMilli = now.Add(opts.TTL).UnixMilli() - } else { - rec.DeadlineUnixMilli = 0 - } if err := saveOwner(dir, rec); err != nil { return nil, err } - w := &fileWriter{store: s, header: header, dir: dir, logPath: logPath, epoch: rec.Epoch, ttl: opts.TTL, + 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{}{} @@ -273,7 +250,6 @@ type fileWriter struct { dir string logPath string epoch session.Epoch - ttl time.Duration head session.Head commits map[session.CommitID]struct{} } @@ -301,21 +277,6 @@ func (w *fileWriter) current(op string) error { return nil } -func (w *fileWriter) Heartbeat(ctx context.Context) error { - if err := ctx.Err(); err != nil { - return err - } - w.store.mu.Lock() - defer w.store.mu.Unlock() - if err := w.current("heartbeat"); err != nil { - return err - } - if w.ttl > 0 { - return saveOwner(w.dir, ownerRecord{Epoch: w.epoch, Owned: true, DeadlineUnixMilli: w.store.now().Add(w.ttl).UnixMilli()}) - } - return nil -} - func (w *fileWriter) Close(ctx context.Context) error { w.store.mu.Lock() defer w.store.mu.Unlock() @@ -463,12 +424,6 @@ func (s *Store) Read(ctx context.Context, req session.ReadRequest) (session.Read if err != nil { return session.ReadPage{}, err } - // A durable adapter verifies the chain on unfiltered reads (SES-REP-1). - if len(req.Types) == 0 { - if err := session.ValidateChain(s.profile, header, rows); 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 @@ -506,8 +461,8 @@ func (s *Store) Read(ctx context.Context, req session.ReadRequest) (session.Read return page, nil } -// Tamper rewrites one row on disk so conformance can prove the read-side -// chain check detects corruption; production code never calls it. +// 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() diff --git a/agent/session/memory.go b/agent/session/memory.go index 877b293..a04d0e8 100644 --- a/agent/session/memory.go +++ b/agent/session/memory.go @@ -4,15 +4,13 @@ import ( "context" "fmt" "sync" - "time" ) -// MemoryStore is the in-process reference Store. Ownership follows the -// database-adapter shape: an Open with TTL records a deadline that Heartbeat -// pushes and that a later Open may pass; TTL zero ownership lasts until Close. +// 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 - now func() time.Time mu sync.RWMutex // guards sessions map sessions map[SessionID]*memorySession } @@ -24,15 +22,11 @@ type memorySession struct { byCommit map[CommitID][2]int // [first, last] row index of the group epoch Epoch owner *memoryWriter // nil when no live owner - deadline time.Time // zero when the owner has no TTL } -// NewMemoryStore returns a MemoryStore using the wall clock. -func NewMemoryStore() *MemoryStore { return NewMemoryStoreWithClock(time.Now) } - -// NewMemoryStoreWithClock lets tests drive ownership deadlines. -func NewMemoryStoreWithClock(now func() time.Time) *MemoryStore { - return &MemoryStore{profile: ProfileV1(), now: now, sessions: make(map[SessionID]*memorySession)} +// 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 } @@ -102,35 +96,29 @@ type memoryWriter struct { store *MemoryStore s *memorySession epoch Epoch - ttl time.Duration } func (m *MemoryStore) Open(ctx context.Context, sid SessionID, opts OpenOptions) (Writer, error) { if err := ctx.Err(); err != nil { return nil, err } - if opts.TTL < 0 { - return nil, newError(ErrInvalid, "open", sid, "negative TTL") - } s, err := m.session(sid, "open") if err != nil { return nil, err } s.mu.Lock() defer s.mu.Unlock() - if s.owner != nil { - if s.deadline.IsZero() || m.now().Before(s.deadline) { - return nil, newError(ErrOwned, "open", sid, fmt.Sprintf("owned by epoch %d", s.epoch)) - } + 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, ttl: opts.TTL} + w := &memoryWriter{store: m, s: s, epoch: s.epoch} s.owner = w - if opts.TTL > 0 { - s.deadline = m.now().Add(opts.TTL) - } else { - s.deadline = time.Time{} - } return w, nil } @@ -151,27 +139,11 @@ func (w *memoryWriter) current(op string) error { return nil } -func (w *memoryWriter) Heartbeat(ctx context.Context) error { - if err := ctx.Err(); err != nil { - return err - } - w.s.mu.Lock() - defer w.s.mu.Unlock() - if err := w.current("heartbeat"); err != nil { - return err - } - if w.ttl > 0 { - w.s.deadline = w.store.now().Add(w.ttl) - } - 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 - w.s.deadline = time.Time{} } return nil } @@ -241,11 +213,6 @@ func (m *MemoryStore) Read(ctx context.Context, req ReadRequest) (ReadPage, erro } s.mu.Lock() defer s.mu.Unlock() - // The Memory adapter verifies the whole chain on every read; a durable - // adapter verifies the chain only on unfiltered reads (SES-REP-1). - if err := ValidateChain(m.profile, s.header, s.rows); err != nil { - return ReadPage{}, err - } page := ReadPage{Header: s.header, Head: s.head()} if int(req.From) > len(s.rows) { return page, nil @@ -284,7 +251,8 @@ func (m *MemoryStore) Read(ctx context.Context, req ReadRequest) (ReadPage, erro } // Tamper mutates one stored row in place. It exists so conformance can prove -// that the chain check detects corruption; production code never calls it. +// 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 { diff --git a/agent/session/memory_test.go b/agent/session/memory_test.go index 4817cb1..ac3b15b 100644 --- a/agent/session/memory_test.go +++ b/agent/session/memory_test.go @@ -2,7 +2,6 @@ package session_test import ( "testing" - "time" "github.com/memohai/twilight/agent/session" "github.com/memohai/twilight/agent/session/sessiontest" @@ -10,8 +9,6 @@ import ( func TestMemoryStoreConformance(t *testing.T) { sessiontest.Run(t, func(t *testing.T) sessiontest.Fixture { - now := time.Unix(1_700_000_000, 0) - store := session.NewMemoryStoreWithClock(func() time.Time { return now }) - return sessiontest.Fixture{Store: store, Advance: func(d time.Duration) { now = now.Add(d) }} + return sessiontest.Fixture{Store: session.NewMemoryStore()} }) } diff --git a/agent/session/run/runtimetest/conformance.go b/agent/session/run/runtimetest/conformance.go index ac6bffb..fd0f5f7 100644 --- a/agent/session/run/runtimetest/conformance.go +++ b/agent/session/run/runtimetest/conformance.go @@ -4,7 +4,6 @@ import ( "errors" "strings" "testing" - "time" "github.com/memohai/twilight/agent/artifact" "github.com/memohai/twilight/agent/es" @@ -42,7 +41,7 @@ func Run(t *testing.T, factory Factory) { // --- 建立与寻址 ----------------------------------------------------------------------- func testCreation(t *testing.T, factory Factory) { - h := newHarness(t, factory(t), 0) + 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 { @@ -96,7 +95,7 @@ func testCreation(t *testing.T, factory Factory) { // --- 重放与 Base ---------------------------------------------------------------------- func testReplayAndBase(t *testing.T, factory Factory) { - h := newHarness(t, factory(t), 0) + 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 { @@ -138,7 +137,7 @@ func testReplayAndBase(t *testing.T, factory Factory) { // --- 输入入队 -------------------------------------------------------------------------- func testInputQueue(t *testing.T, factory Factory) { - h := newHarness(t, factory(t), 0) + 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) @@ -172,7 +171,7 @@ func testInputQueue(t *testing.T, factory Factory) { t.Fatalf("after result with pending input = %+v", res.Snapshot.State) } // ToolStep: the input queues as well. - h2 := newHarness(t, factory(t), 0) + 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")}) @@ -184,7 +183,7 @@ func testInputQueue(t *testing.T, factory Factory) { // --- start 与 claim ------------------------------------------------------------------------ func testStartAndClaim(t *testing.T, factory Factory) { - h := newHarness(t, factory(t), 0) + 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. @@ -220,7 +219,7 @@ func testStartAndClaim(t *testing.T, factory Factory) { // --- 组的组成 ---------------------------------------------------------------------------- func testGroupComposition(t *testing.T, factory Factory) { - h := newHarness(t, factory(t), 0) + h := newHarness(t, factory(t)) h.startRun("t1", "r1", input("in-1")) step, claim := h.executingModel("r1", true) result, bindings := h.toolCallResult(step, 1) @@ -293,7 +292,7 @@ func asStrings(types []session.EventType) []string { // --- admission ------------------------------------------------------------------------------- func testAdmission(t *testing.T, factory Factory) { - h := newHarness(t, factory(t), 0) + 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) @@ -340,7 +339,7 @@ func mustSet(t *testing.T, h *harness, ids ...artifact.BindingID) artifact.Bindi // --- 结算返回值 -------------------------------------------------------------------------------- func testSettlementSnapshot(t *testing.T, factory Factory) { - h := newHarness(t, factory(t), 0) + 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")}) @@ -361,7 +360,7 @@ func testSettlementSnapshot(t *testing.T, factory Factory) { // --- Prepare hard CAS 对其他模块不敏感 --------------------------------------------------------------- func testPrepareCASIgnoresOtherModules(t *testing.T, factory Factory) { - h := newHarness(t, factory(t), 0) + h := newHarness(t, factory(t)) h.startRun("t1", "r1", input("in-1")) h.startRun("t2", "r2", input("in-b")) snap := h.load("r1") @@ -384,7 +383,7 @@ func testPrepareCASIgnoresOtherModules(t *testing.T, factory Factory) { // --- 投影 ------------------------------------------------------------------------------------------- func testProjection(t *testing.T, factory Factory) { - h := newHarness(t, factory(t), 0) + 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) @@ -448,7 +447,7 @@ func testProjection(t *testing.T, factory Factory) { // --- 隔离 -------------------------------------------------------------------------------------------- func testIsolation(t *testing.T, factory Factory) { - h := newHarness(t, factory(t), 0) + h := newHarness(t, factory(t)) h.startRun("t1", "r1", input("in-1")) h.startRun("t2", "r2", input("in-b")) p1 := h.load("r1").Position @@ -472,11 +471,7 @@ func testIsolation(t *testing.T, factory Factory) { // --- 接管处置 ----------------------------------------------------------------------------------------- func testTakeover(t *testing.T, factory Factory) { - f := factory(t) - if f.Advance == nil { - t.Skip("fixture has no clock; TTL takeover not testable") - } - h := newHarness(t, f, time.Minute) + 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. @@ -537,11 +532,7 @@ func testTakeover(t *testing.T, factory Factory) { // --- 所有权失效 ---------------------------------------------------------------------------------------- func testOwnershipLost(t *testing.T, factory Factory) { - f := factory(t) - if f.Advance == nil { - t.Skip("fixture has no clock; TTL takeover not testable") - } - h := newHarness(t, f, time.Minute) + h := newHarness(t, factory(t)) h.startRun("t1", "r1", input("in-1")) step, claim := h.executingModel("r1", false) old := h.takeover() @@ -565,7 +556,7 @@ func testOwnershipLost(t *testing.T, factory Factory) { // --- FrozenValueStore -------------------------------------------------------------------------------- func testFrozenValues(t *testing.T, factory Factory) { - h := newHarness(t, factory(t), 0) + 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 diff --git a/agent/session/run/runtimetest/harness.go b/agent/session/run/runtimetest/harness.go index a726cb6..f090cf9 100644 --- a/agent/session/run/runtimetest/harness.go +++ b/agent/session/run/runtimetest/harness.go @@ -23,11 +23,9 @@ import ( "github.com/memohai/twilight/sdk" ) -// Fixture is one adapter under test. Advance moves the adapter's clock so a -// TTL takeover can be exercised; nil skips the takeover checks. +// Fixture is one adapter under test. type Fixture struct { - Store session.Store - Advance func(time.Duration) + Store session.Store } // Factory returns a fresh, empty Fixture for one test. @@ -59,13 +57,12 @@ type harness struct { frozen *run.MemoryFrozenValues cache *extension.MemoryProjectionCache clock *clock - ttl time.Duration writers extension.Writers rt *runmod.Runtime seq int } -func newHarness(t testing.TB, f Fixture, ttl time.Duration) *harness { +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 { @@ -74,7 +71,7 @@ func newHarness(t testing.TB, f Fixture, ttl time.Duration) *harness { 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)}, ttl: ttl} + 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) } @@ -83,9 +80,10 @@ func newHarness(t testing.TB, f Fixture, ttl time.Duration) *harness { } // 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{TTL: h.ttl}) + 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 { @@ -94,12 +92,11 @@ func (h *harness) open() { h.rt = rt } -// takeover lets the ownership TTL pass and opens a new owner process; the -// previous Runtime stays usable so tests can observe its fencing. +// 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.fixture.Advance(2 * h.ttl) h.open() return old } diff --git a/agent/session/run/runtimetest/memory_test.go b/agent/session/run/runtimetest/memory_test.go index a985d1c..6ba5976 100644 --- a/agent/session/run/runtimetest/memory_test.go +++ b/agent/session/run/runtimetest/memory_test.go @@ -2,15 +2,12 @@ package runtimetest import ( "testing" - "time" "github.com/memohai/twilight/agent/session" ) func TestMemoryStoreConformance(t *testing.T) { Run(t, func(testing.TB) Fixture { - now := time.Unix(1_000_000, 0) - store := session.NewMemoryStoreWithClock(func() time.Time { return now }) - return Fixture{Store: store, Advance: func(d time.Duration) { now = now.Add(d) }} + return Fixture{Store: session.NewMemoryStore()} }) } diff --git a/agent/session/sessiontest/conformance.go b/agent/session/sessiontest/conformance.go index 8188595..faf9f95 100644 --- a/agent/session/sessiontest/conformance.go +++ b/agent/session/sessiontest/conformance.go @@ -6,17 +6,14 @@ package sessiontest import ( "context" "testing" - "time" "github.com/memohai/twilight/agent/jsonstable" "github.com/memohai/twilight/agent/session" ) -// Fixture is one adapter under test. Advance moves the adapter's clock for -// TTL takeover checks; nil skips those checks (file adapters have no TTL). +// Fixture is one adapter under test. type Fixture struct { - Store session.Store - Advance func(time.Duration) + Store session.Store } // Factory builds a fresh, empty Store for one subtest. @@ -41,9 +38,9 @@ func create(t *testing.T, store session.Store, sid session.SessionID) session.Se return h } -func open(t *testing.T, store session.Store, sid session.SessionID, ttl time.Duration) session.Writer { +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{TTL: ttl}) + w, err := store.Open(context.Background(), sid, session.OpenOptions{Takeover: takeover}) if err != nil { t.Fatalf("open: %v", err) } @@ -80,7 +77,7 @@ func testWire(t *testing.T, f Fixture) { 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", 0) + w := open(t, f.Store, "s", false) if head := w.Head(); head.Next != 0 || head.Digest != h.HeaderDigest { t.Fatalf("empty head = %+v", head) } @@ -114,13 +111,13 @@ func testWire(t *testing.T, f Fixture) { } } -// SES-OWN-1/2: second Open is ErrOwned; Close then Open bumps Epoch; a -// superseded Writer's Append and Heartbeat fail without writing; TTL expiry -// allows takeover. +// 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", 0) + w1 := open(t, f.Store, "s", false) if w1.Epoch() != 1 { t.Fatalf("first epoch = %d", w1.Epoch()) } @@ -131,16 +128,13 @@ func testOwnership(t *testing.T, f Fixture) { if err := w1.Close(ctx); err != nil { t.Fatal(err) } - w2 := open(t, f.Store, "s", 0) + 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) } - if err := w1.Heartbeat(ctx); !session.IsCode(err, session.ErrOwnershipLost) { - t.Fatalf("old writer heartbeat = %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)) @@ -152,38 +146,27 @@ func testOwnership(t *testing.T, f Fixture) { if _, err := f.Store.Open(ctx, "s", session.OpenOptions{}); !session.IsCode(err, session.ErrOwned) { t.Fatal("closing a superseded writer released the current owner") } - if err := w2.Close(ctx); err != nil { - t.Fatal(err) - } - // Read never needs ownership (SES-OWN-4): already exercised above while owned. - if f.Advance == nil { - return - } - w3 := open(t, f.Store, "s", time.Minute) - f.Advance(30 * time.Second) - if err := w3.Heartbeat(ctx); err != nil { - t.Fatalf("heartbeat inside TTL: %v", err) - } - f.Advance(45 * time.Second) - if _, err := f.Store.Open(ctx, "s", session.OpenOptions{TTL: time.Minute}); !session.IsCode(err, session.ErrOwned) { - t.Fatal("open succeeded while the heartbeat kept ownership alive") + // 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) } - f.Advance(time.Minute) - w4 := open(t, f.Store, "s", time.Minute) - if w4.Epoch() != w3.Epoch()+1 { - t.Fatalf("takeover epoch = %d, want %d", w4.Epoch(), w3.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) } - if _, err := w3.Append(ctx, session.Group{CommitID: "late", Events: []session.UncommittedEvent{ev("twilight/x/a", `{}`)}}); !session.IsCode(err, session.ErrOwnershipLost) { - t.Fatalf("expired 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)) } - appendGroup(t, w4, "c3", ev("twilight/x/a", `{}`)) + // 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", 0) + w := open(t, f.Store, "s", false) rejects := []struct { name string g session.Group @@ -217,11 +200,11 @@ func testAppend(t *testing.T, f Fixture) { } // SES-REP-1/2: order, From, Limit at group boundaries, filter equivalence, -// tamper detection. +// 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", 0) + 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 @@ -271,9 +254,12 @@ func testRead(t *testing.T, f Fixture) { 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.Read(ctx, session.ReadRequest{SessionID: "s"}); !session.IsCode(err, session.ErrCorrupt) { - t.Fatalf("tampered read = %v, want corrupt", err) + 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) } } } diff --git a/agent/session/store.go b/agent/session/store.go index 8e2db9b..586723a 100644 --- a/agent/session/store.go +++ b/agent/session/store.go @@ -2,7 +2,6 @@ package session import ( "context" - "time" "github.com/memohai/twilight/agent/es" "github.com/memohai/twilight/agent/jsonstable" @@ -18,16 +17,16 @@ type CreateRequest struct { Metadata jsonstable.Value } -// OpenOptions configures writer ownership (SES-OWN-1). TTL zero means the -// ownership lives only as long as the process or connection (file-lock -// semantics); non-zero means the writer must Heartbeat within TTL or another -// Open may take over. +// 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 { - TTL time.Duration + Takeover bool } -// Writer is the kernel's ownership handle returned by Store.Open. Append and -// Heartbeat carry its Epoch; a Writer whose Epoch has been superseded gets +// 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 @@ -37,7 +36,6 @@ type Writer interface { // (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) - Heartbeat(context.Context) error Close(context.Context) error } diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index b4cc29f..6b249fb 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -84,7 +84,7 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 - 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),不等待 TTL 按目标恢复。 +- 接管者对全部 Executing 目标一次性处置(模型回 Prepared、工具记 Unknown),不逐目标等待或恢复。 - Run fact 只保存执行状态与内容 digest;请求本体(含工具定义)在 FrozenValueStore,模型输出与工具输出在 chatlog 事件。 - kernel `ProtocolVersion` 只覆盖行结构与 digest;payload 版本由模块携带(`v` 字段),Run 保留自己的 `SchemaVersion`。 @@ -126,7 +126,7 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 ### 4.2 durable adapters - 文件 adapter `agent/session/filestore`:一个 Session 一个目录,`stream.jsonl` 一行一个 event,`session.lock` 为 `flock` 目标,每次 Append 一次 fsync,打开时截掉不完整尾组; -- 数据库 adapter(SQLite / PostgreSQL):sessions(header、epoch、owner deadline)、events 两张表,Append 一个事务,`Heartbeat` 推后 deadline;只在多会话服务需要时做; +- 数据库 adapter(SQLite / PostgreSQL):sessions(header、epoch)、events 两张表,Append 一个事务;只在多会话服务需要时做; - 收紧 Session authority tables 的 immutable RLS policy; - 需要远程 Store 或跨存储 claim 时,实现 extension 附录 C 与 artifact 附录的两阶段路径。 @@ -334,7 +334,7 @@ lease 的第二条出路:grant 由 `(Claim, start CommitID)` 派生,start fa | 结构 | 等级 | 写入点 | 丢失或不一致时 | |---|---|---|---| | Session header 与 event 行 | authority | `Writer.Append` | 不可恢复;按行 digest 链使损坏可检测;不完整尾组在打开时截掉 | -| 所有权记录(Epoch,数据库 adapter 另有 deadline) | 控制 | `Open`、`Heartbeat` | 文件 adapter 随进程释放;数据库 adapter 过期后可接管 | +| 所有权记录(Epoch) | 控制 | `Open` | 接管由 Open 的 `Takeover` 声明,旧写者被 Epoch fencing | | 投影缓存 | 派生缓存 | `SnapshotPolicy` | 从 stream 重折 | | Writer 内存:幂等索引、投影状态、head | 派生 | `OpenWriter` 重建 | 随进程消失,重开时从日志重建 | | FrozenValueStore | 旁存,生命周期为 ModelStep | Commit 之前 `Put` | Executing/Prepared step 的重发失败为不可重试错误 | @@ -345,7 +345,7 @@ v1 只有两类恢复动作:`RecoverInterrupted`(新 owner 一次性处置 E ### 8.5 实施顺序 -1. `agent/session`:重写 Memory Store(Create、Header、Open/Epoch/Heartbeat、Append 整组、Read 过滤)与 conformance;删除 CommitIn、CAS、控制面 KV、snapshot、四套 digest、EventID、ReplayCursor; +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`; diff --git a/docs/design/agent-session.md b/docs/design/agent-session.md index cd77c8e..d5d709d 100644 --- a/docs/design/agent-session.md +++ b/docs/design/agent-session.md @@ -92,16 +92,15 @@ digest 依 `agent/es` 的 versioned domain separator。链条按行连接;任 ```go type OpenOptions struct { - // TTL 为零表示所有权只随进程或连接存活(文件锁语义);非零表示写者必须在 TTL 内 Heartbeat, - // 否则其他 Open 可以接管。 - TTL time.Duration + // 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) - Heartbeat(context.Context) error Close(context.Context) error } type Store interface { @@ -112,9 +111,9 @@ type Store interface { } ``` -**SES-OWN-1** 同一 Session 同一时刻至多一个有效 Writer。`Open` 在已有有效 Writer 时返回 `ErrOwned`;有效性由 adapter 的锁机制决定:文件 adapter 用进程内独占加 `flock`,进程死亡即释放;数据库 adapter 用带 deadline 的所有权行,deadline 由 `Heartbeat` 推后,过期后可被接管。 +**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` 与 `Heartbeat` 携带 Writer 的 Epoch;Store 对落后于当前持久化 Epoch 的调用返回 `ErrOwnershipLost`,不写入任何内容。这是 fencing:被接管的旧写者的迟到写入不可能进入日志。 +**SES-OWN-2** 每次成功的 Open 使该 Session 的 `Epoch` 加一并持久化。`Append` 携带 Writer 的 Epoch;Store 对落后于当前持久化 Epoch 的调用返回 `ErrOwnershipLost`,不写入任何内容。这是 fencing:被接管的旧写者的迟到写入不可能进入日志。 **SES-OWN-3** 所有权是 Session 级的,不是执行目标级的。一个进程取得 Session 的所有权即拥有其中全部执行;接管者读日志后对所有仍在执行中的目标做一次性处置(RUN-CMT-7)。kernel 不知道"执行中"是什么,这一步由 run 模块在 Writer 上完成。 @@ -140,7 +139,7 @@ type ReadRequest struct { type ReadPage struct { Header SessionHeader; Events []SessionEvent; Head Head; HasMore bool } ``` -**SES-REP-1** `Read` 按 `Seq` 递增返回 `From` 起的行,只返回完整组内的行;`Limit` 截断只发生在组边界。无过滤时 Store 校验每行的 `Digest` 链;有过滤时只校验返回行自身的 digest,链完整性由无过滤读取验证。损坏必须 fail loudly(`ErrCorrupt`)。 +**SES-REP-1** `Read` 按 `Seq` 递增返回 `From` 起的行,只返回完整组内的行;`Limit` 截断只发生在组边界。损坏检测的义务点在 `Open`:Open 在建立所有权前校验整条 `Digest` 链,损坏必须 fail loudly(`ErrCorrupt`);`ValidateChain` 同时作为显式校验入口导出。`Read` 信任存储,不逐次重算链。 **SES-REP-2** `Types` 过滤是读取代价的优化:文件 adapter 全量扫描后过滤,数据库 adapter 用 `(SessionID, Type 前缀)` 索引。过滤与不过滤读到的事件集合对匹配类型完全一致。 @@ -159,12 +158,12 @@ const ( 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 加一;旧 Writer 的 Append 与 Heartbeat 返回 `ErrOwnershipLost` 且不写入;TTL 过期后接管; +- **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 在组边界截断、过滤与全量对匹配类型一致、篡改任一行后无过滤读取报 `ErrCorrupt`; +- **SES-REP-1/2**:顺序、From、Limit 在组边界截断、过滤与全量对匹配类型一致、篡改任一行后下一次 Open 报 `ErrCorrupt`; - **SES-SCP-3**:附录 A 入口返回 `ErrUnsupported`,`ParentFork` 非 nil 的 header 被拒绝。 -参考实现为 MemoryStore 与文件 adapter(一个 Session 一个目录,`stream.jsonl` 一行一个 event,`session.lock` 为 `flock` 目标)。 +参考实现为 MemoryStore 与文件 adapter `agent/session/filestore`(一个 Session 一个目录:`header.json`、`log.jsonl` 一行一个 event、`owner.json` 记录 epoch 与 owned)。 ## 附录 A:预留能力(不进入 v1) From 4c8d9bb35b16c5cf893ef05f00c27bf78ab8d06d Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 9 Sep 2026 02:43:29 +0800 Subject: [PATCH 74/81] Drop self-checks with no failure mode and unread wire fields ref.New no longer injects the in-process projection cache: it never outlives the Writer that already holds the folded state. Encode loses the per-commit decode round-trip; canonical stability is a module test obligation, now covered by table-driven round-trip tests in chatlog and turn. CommandEnvelope loses its Digest (it only re-verified a value BuildEnvelope had just produced) and StartedPayload loses the PlanDigest field nothing read; the derivation into the Start CommitID stays. Chatlog checkpoint moves to an appendix; artifact retention conformance is deferred to the first real content store; roadmap notes the FrozenValueStore/cas merge direction. --- agent/ref/assembly.go | 2 +- agent/run/canonical_test.go | 35 +-------------- agent/run/codec.go | 21 ++------- agent/run/codec_test.go | 11 ++--- agent/run/commit.go | 13 ++---- agent/run/protocol.go | 27 ++--------- agent/session/chatlog/chatlog_test.go | 47 ++++++++++++++++++++ agent/session/extension/registry.go | 11 +---- agent/session/run/runtimetest/conformance.go | 2 +- agent/session/run/runtimetest/harness.go | 2 +- agent/turn/coordinator.go | 8 ++-- agent/turn/module_test.go | 37 +++++++++++++++ agent/turn/turn.go | 5 +-- docs/design/agent-artifact.md | 2 +- docs/design/agent-reference-assembly.md | 2 +- docs/design/agent-run.md | 6 +-- docs/design/agent-runtime-refactor.md | 7 +-- docs/design/agent-session-chatlog.md | 8 +++- docs/design/agent-session-extension.md | 6 +-- docs/design/agent-turn.md | 5 +-- 20 files changed, 128 insertions(+), 129 deletions(-) create mode 100644 agent/turn/module_test.go diff --git a/agent/ref/assembly.go b/agent/ref/assembly.go index f8cd329..66684e9 100644 --- a/agent/ref/assembly.go +++ b/agent/ref/assembly.go @@ -74,7 +74,7 @@ func New(opts Options) (*Memory, error) { } runtime, err := runmod.NewRuntime(runmod.Config{ Writers: writers, Registry: registry, Store: store, - Frozen: opts.Frozen, Companion: turn.CompanionV1{}, Cache: extension.NewMemoryProjectionCache(), Now: now, + Frozen: opts.Frozen, Companion: turn.CompanionV1{}, Now: now, }) if err != nil { return nil, err diff --git a/agent/run/canonical_test.go b/agent/run/canonical_test.go index 3ca501d..030340c 100644 --- a/agent/run/canonical_test.go +++ b/agent/run/canonical_test.go @@ -2,7 +2,6 @@ package run import ( "encoding/json" - "strings" "testing" ) @@ -97,26 +96,8 @@ func TestCanonicalDeterminism(t *testing.T) { } } -func TestDigestCommandIdentity(t *testing.T) { +func TestDigestPreimageCoversSchemaVersion(t *testing.T) { cmd := StartToolCall{StepID: "s1", CallID: "c1", Claim: "claim-1"} - d1, err := ProtocolV1().DigestCommand("start_tool_call", cmd) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(string(d1), "sha256:") || len(d1) != len("sha256:")+64 { - t.Fatalf("bad digest wire form: %s", d1) - } - // Same content, same digest. - d2, _ := ProtocolV1().DigestCommand("start_tool_call", StartToolCall{StepID: "s1", CallID: "c1", Claim: "claim-1"}) - if d1 != d2 { - t.Fatal("same command produced different digests") - } - // Different content differs. - d3, _ := ProtocolV1().DigestCommand("start_tool_call", StartToolCall{StepID: "s1", CallID: "c2", Claim: "claim-1"}) - if d1 == d3 { - t.Fatal("different commands produced the same digest") - } - // Schema version participates in the digest preimage. body1, err := encodeEnvelopeBody(SchemaVersion1, "start_tool_call", cmd) if err != nil { t.Fatal(err) @@ -128,10 +109,6 @@ func TestDigestCommandIdentity(t *testing.T) { if string(body1) == string(body2) { t.Fatal("schema version did not affect digest preimage") } - // Type mismatch is rejected. - if _, err := ProtocolV1().DigestCommand("cancel_run", cmd); err == nil { - t.Fatal("expected type/variant mismatch error") - } } func TestDeriveStability(t *testing.T) { @@ -214,16 +191,6 @@ func TestSchemaVersion1Golden(t *testing.T) { if string(body) != wantBody { t.Fatalf("golden body changed:\n got %q\nwant %q", body, wantBody) } - d, err := ProtocolV1().DigestCommand("cancel_run", cmd) - if err != nil { - t.Fatal(err) - } - // Current pre-release fixture. After publication, a mismatch is a protocol - // break that invalidates persisted digests and must not update this value. - const wantDigest = "sha256:a7770a5443f180ec1935bfa4498af75375b8d5f182f239f587917b28b78ee80c" - if string(d) != wantDigest { - t.Fatalf("golden digest changed:\n got %s\nwant %s", d, wantDigest) - } fact := InputAccepted{Input: AgentInput{ID: "in-1", Payload: cj(`{"text":"hi"}`)}} fbody, err := ProtocolV1().EncodeFact("input_accepted", fact) diff --git a/agent/run/codec.go b/agent/run/codec.go index d8b0af4..21ebecb 100644 --- a/agent/run/codec.go +++ b/agent/run/codec.go @@ -16,7 +16,6 @@ type commandEnvelopeWire struct { SessionID session.SessionID `json:"sessionId,omitempty"` RunID RunID `json:"runId"` ID CommandID `json:"id"` - Digest Digest `json:"digest"` Command json.RawMessage `json:"command"` } @@ -26,13 +25,12 @@ type commandEnvelopeMarshal struct { SessionID session.SessionID `json:"sessionId,omitempty"` RunID RunID `json:"runId"` ID CommandID `json:"id"` - Digest Digest `json:"digest"` Command AgentCommand `json:"command"` } -// DecodeCommandEnvelope decodes the persisted command wire shape and restores -// the sealed command variant from Type. The digest is verified during decode; -// malformed or unsupported wire data is rejected before it can enter Runtime. +// 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 { @@ -59,7 +57,6 @@ func (e CommandEnvelope) MarshalJSON() ([]byte, error) { SessionID: e.SessionID, RunID: e.RunID, ID: e.ID, - Digest: e.Digest, Command: e.Command, }) } @@ -77,23 +74,12 @@ func (e *CommandEnvelope) UnmarshalJSON(raw []byte) error { if err != nil { return err } - want, err := proto.DigestCommand(wire.Type, cmd) - if err != nil { - return err - } - if wire.Digest == "" { - return errors.New("agent: codec: command envelope missing digest") - } - if wire.Digest != want { - return fmt.Errorf("agent: codec: command digest mismatch: got %s want %s", wire.Digest, want) - } if err := requireCanonicalEquivalent(raw, commandEnvelopeMarshal{ SchemaVersion: wire.SchemaVersion, Type: wire.Type, SessionID: wire.SessionID, RunID: wire.RunID, ID: wire.ID, - Digest: wire.Digest, Command: cmd, }); err != nil { return err @@ -104,7 +90,6 @@ func (e *CommandEnvelope) UnmarshalJSON(raw []byte) error { SessionID: wire.SessionID, RunID: wire.RunID, ID: wire.ID, - Digest: wire.Digest, Command: cmd, } return nil diff --git a/agent/run/codec_test.go b/agent/run/codec_test.go index 3163e33..d0b4fc5 100644 --- a/agent/run/codec_test.go +++ b/agent/run/codec_test.go @@ -42,7 +42,7 @@ func TestCommandEnvelopeJSONRoundTripRestoresVariants(t *testing.T) { 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.Digest != env.Digest || decoded.ID != env.ID { + if decoded.Type != env.Type || decoded.ID != env.ID { t.Fatalf("decoded envelope = %+v, want %+v", decoded, env) } } @@ -97,7 +97,7 @@ func TestWireCodecRejectsAmbiguousJSONBeforeVariantDecode(t *testing.T) { if err != nil { t.Fatal(err) } - raw := []byte(fmt.Sprintf(`{"schemaVersion":1,"type":"accept_input","runId":"run-1","id":%q,"digest":%q,"command":{"input":{"id":"in","payload":1},"input":{"id":"in","payload":1}}}`, env.ID, env.Digest)) + 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") } @@ -112,7 +112,7 @@ func TestWireCodecRejectsAmbiguousJSONBeforeVariantDecode(t *testing.T) { } } -func TestWireCodecRejectsUnknownTypeAndDigestMismatch(t *testing.T) { +func TestWireCodecRejectsUnknownType(t *testing.T) { env, err := ProtocolV1().BuildEnvelope("s-1", "run-1", "cmd-1", CancelRun{}) if err != nil { t.Fatal(err) @@ -125,11 +125,6 @@ func TestWireCodecRejectsUnknownTypeAndDigestMismatch(t *testing.T) { if _, err := DecodeCommandEnvelope([]byte(badType)); err == nil { t.Fatal("unknown command type decoded") } - badDigest := strings.Replace(string(raw), string(env.Digest), "sha256:bad", 1) - if _, err := DecodeCommandEnvelope([]byte(badDigest)); err == nil { - t.Fatal("bad command digest decoded") - } - } func TestRunEndedTaggedUnionRejectsInvalidValues(t *testing.T) { diff --git a/agent/run/commit.go b/agent/run/commit.go index 49b015a..30ae783 100644 --- a/agent/run/commit.go +++ b/agent/run/commit.go @@ -25,9 +25,9 @@ type CommitDecision struct { Reject error } -// ValidateEnvelope is step 1 of RUN-CMT-3: identity, schema and digest. A -// digest that does not cover the command is a construction fault and is -// returned as a hard error, never as a retriable rejection. +// 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") @@ -38,13 +38,6 @@ func ValidateEnvelope(env *CommandEnvelope, proto Protocol) error { if env.SchemaVersion != proto.Version() { return fmt.Errorf("agent: commit: command schema %d does not match run schema %d", env.SchemaVersion, proto.Version()) } - wantDigest, err := proto.DigestCommand(env.Type, env.Command) - if err != nil { - return err - } - if env.Digest != wantDigest { - return errors.New("agent: commit: envelope digest mismatch") - } return nil } diff --git a/agent/run/protocol.go b/agent/run/protocol.go index 10f61a8..8f24d6e 100644 --- a/agent/run/protocol.go +++ b/agent/run/protocol.go @@ -14,21 +14,19 @@ 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 Digest lets the Runtime tell an exact replay from a conflict -// (RUN-WIR-2). +// 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"` - Digest Digest `json:"digest"` Command AgentCommand `json:"command"` } // encodeEnvelopeBody is the digest input for a command: schema version, type -// discriminator and canonical command bytes. The Digest field itself, base -// revisions and grants never enter the digest (RUN-WIR-2). +// discriminator and canonical command bytes. func encodeEnvelopeBody(schemaVersion uint16, typ string, body any) ([]byte, error) { return es.EncodeTypedPayload(schemaVersion, typ, body) } @@ -182,20 +180,6 @@ func (p Protocol) BuildCreateGroup(run NewRun, inputs []AgentInput) ([]Fact, err return p.buildCreateGroup(run, inputs) } -func (p Protocol) DigestCommand(typ string, command AgentCommand) (Digest, error) { - if err := p.ready(); err != nil { - return "", err - } - if typ == "" || typ != commandType(command) { - return "", fmt.Errorf("agent: digest: type %q does not match command variant", typ) - } - body, err := encodeEnvelopeBody(p.version, typ, command) - if err != nil { - return "", err - } - return sha256Digest(body), nil -} - func (p Protocol) EncodeFact(typ string, fact Fact) ([]byte, error) { if err := p.ready(); err != nil { return nil, err @@ -248,17 +232,12 @@ func (p Protocol) BuildEnvelope(sid session.SessionID, run RunID, id CommandID, if typ == "" { return CommandEnvelope{}, fmt.Errorf("agent: envelope: unknown command variant %T", cmd) } - d, err := p.DigestCommand(typ, cmd) - if err != nil { - return CommandEnvelope{}, err - } return CommandEnvelope{ SchemaVersion: p.version, Type: typ, SessionID: sid, RunID: run, ID: id, - Digest: d, Command: cmd, }, nil } diff --git a/agent/session/chatlog/chatlog_test.go b/agent/session/chatlog/chatlog_test.go index 38fd650..8288d45 100644 --- a/agent/session/chatlog/chatlog_test.go +++ b/agent/session/chatlog/chatlog_test.go @@ -146,3 +146,50 @@ func TestSurfaceAndContextFold(t *testing.T) { 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) + } + 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}, + } + 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/session/extension/registry.go b/agent/session/extension/registry.go index c6bc2d7..02c4192 100644 --- a/agent/session/extension/registry.go +++ b/agent/session/extension/registry.go @@ -268,15 +268,8 @@ func (r *Registry) Encode(typ session.EventType, value any) (jsonstable.Value, P if err != nil { return jsonstable.Value{}, 0, &Error{Code: ErrCodec, Type: typ, Detail: err.Error()} } - // Round trip: the persisted bytes must decode to the same canonical form. - back, err := codec.Decode(body) - if err != nil { - return jsonstable.Value{}, 0, &Error{Code: ErrCodec, Type: typ, Detail: "decode round trip: " + err.Error()} - } - again, err := codec.Encode(back) - if err != nil || !again.Equal(body) { - return jsonstable.Value{}, 0, &Error{Code: ErrCodec, Type: typ, Detail: "encode/decode/encode is not canonical-stable"} - } + // 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 } diff --git a/agent/session/run/runtimetest/conformance.go b/agent/session/run/runtimetest/conformance.go index fd0f5f7..6cc1dff 100644 --- a/agent/session/run/runtimetest/conformance.go +++ b/agent/session/run/runtimetest/conformance.go @@ -454,7 +454,7 @@ func testIsolation(t *testing.T, factory Factory) { 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", ExecutionBinding: turn.ExecutionBindingRef{ID: "b", Digest: "sha256:b"}, Companion: turn.CompanionV1Version, PlanDigest: "sha256:p"}}}}) + Value: turn.StartedPayload{TurnID: "t9", ExecutionBinding: turn.ExecutionBindingRef{ID: "b", Digest: "sha256:b"}, Companion: turn.CompanionV1Version}}}}) if h.load("r1").Position != p1 { t.Fatal("r2, chatlog or turn writes moved r1") } diff --git a/agent/session/run/runtimetest/harness.go b/agent/session/run/runtimetest/harness.go index f090cf9..c16d740 100644 --- a/agent/session/run/runtimetest/harness.go +++ b/agent/session/run/runtimetest/harness.go @@ -169,7 +169,7 @@ func (h *harness) startGroup(turnID turn.TurnID, runID run.RunID, attempt uint32 if attempt == 1 { group.Events = append(group.Events, extension.TypedEvent{Type: turn.TypeStarted, RecordedAtUnixMilli: 1, Value: turn.StartedPayload{TurnID: turnID, InputIDs: ids, ExecutionBinding: turn.ExecutionBindingRef{ID: "b", Digest: "sha256:b"}, - Companion: turn.CompanionV1Version, PlanDigest: "sha256:plan"}}) + 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)}}) diff --git a/agent/turn/coordinator.go b/agent/turn/coordinator.go index d4fe03e..3867ada 100644 --- a/agent/turn/coordinator.go +++ b/agent/turn/coordinator.go @@ -181,7 +181,7 @@ func (c *Coordinator) Start(ctx context.Context, req StartRequest) (TurnResponse 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, plan, facts, now) + group := c.startGroup(commitID, turnID, inputIDs, req, facts, now) return &group, nil // exact replay: the Writer compares fingerprints } surface, err := loadSurface(view) @@ -197,7 +197,7 @@ func (c *Coordinator) Start(ctx context.Context, req StartRequest) (TurnResponse if err := checkSubmitted(view, req.Inputs); err != nil { return nil, err } - group := c.startGroup(commitID, turnID, inputIDs, req, plan, facts, now) + group := c.startGroup(commitID, turnID, inputIDs, req, facts, now) return &group, nil }) if err != nil { @@ -206,10 +206,10 @@ func (c *Coordinator) Start(ctx context.Context, req StartRequest) (TurnResponse return c.drive(ctx, req.Ref, runID) } -func (c *Coordinator) startGroup(commitID session.CommitID, turnID TurnID, inputIDs []chatlog.InputID, req StartRequest, plan es.Digest, facts []run.Fact, now int64) extension.SemanticGroup { +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, ExecutionBinding: req.ExecutionBinding, Companion: req.Companion, PlanDigest: plan}}) + Value: StartedPayload{TurnID: turnID, InputIDs: inputIDs, ExecutionBinding: req.ExecutionBinding, 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)}}) diff --git a/agent/turn/module_test.go b/agent/turn/module_test.go new file mode 100644 index 0000000..68e12be --- /dev/null +++ b/agent/turn/module_test.go @@ -0,0 +1,37 @@ +package turn + +import ( + "testing" + + "github.com/memohai/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, ExecutionBinding: ExecutionBindingRef{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/turn.go b/agent/turn/turn.go index 1ef2dda..048733e 100644 --- a/agent/turn/turn.go +++ b/agent/turn/turn.go @@ -53,7 +53,6 @@ type StartedPayload struct { InputIDs []chatlog.InputID `json:"inputIds,omitempty"` ExecutionBinding ExecutionBindingRef `json:"executionBinding"` Companion CompanionVersion `json:"companion"` - PlanDigest es.Digest `json:"planDigest"` } type CompletedPayload struct { @@ -132,8 +131,8 @@ var Module = extension.ModuleDescriptor{ }, Events: []extension.EventDefinition{ def[StartedPayload](TypeStarted, func(p *StartedPayload) error { - if p.TurnID == "" || p.ExecutionBinding.ID == "" || p.ExecutionBinding.Digest == "" || p.Companion == "" || p.PlanDigest == "" { - return errors.New("started requires turnId, binding, companion and planDigest") + if p.TurnID == "" || p.ExecutionBinding.ID == "" || p.ExecutionBinding.Digest == "" || p.Companion == "" { + return errors.New("started requires turnId, binding and companion") } return nil }), diff --git a/docs/design/agent-artifact.md b/docs/design/agent-artifact.md index bbc21bd..73f35b4 100644 --- a/docs/design/agent-artifact.md +++ b/docs/design/agent-artifact.md @@ -1,6 +1,6 @@ # 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 通过前不冻结。v1 的 claim 只有 `Active` 与 `Released` 两态;`Prepared` 状态、provider 迁移 fence 与 archive import/export 在附录中,不进入 v1 conformance。 +状态:设计草案。`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` 的通则。 diff --git a/docs/design/agent-reference-assembly.md b/docs/design/agent-reference-assembly.md index a09c11e..fc702e1 100644 --- a/docs/design/agent-reference-assembly.md +++ b/docs/design/agent-reference-assembly.md @@ -104,7 +104,7 @@ registry = extension.BuildRegistry(protocolVersion, chatlog.Module, turn.Mod 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), runmod.DefaultSnapshotPolicy, projectionCache) +runtime = runmod.NewRuntime(writers, runmod.NewMemoryFrozenValues(), turn.CompanionV1(registry)) // ProjectionCache 可选,参考装配不注入 drivers = Resolve(ExecutionBindingRef) -> loop.New(models, tools, contextPlanner, policy, pub.Streaming) coordinator = turn.Coordinator{Writers: writers, Runtime: runtime, Bindings: drivers} session = SessionDriver{Coordinator: coordinator, Writers: writers, Binding: bindingRef, Companion: turn.CompanionV1Version, NewTurnID: ...} diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md index c233a28..10d3b35 100644 --- a/docs/design/agent-run.md +++ b/docs/design/agent-run.md @@ -64,12 +64,11 @@ type CommandEnvelope struct { SessionID session.SessionID RunID RunID ID CommandID - Digest Digest // 覆盖 schema、type 与完整 command,含 transient 内容 Command AgentCommand } ``` -command 不持久化。`CommandEnvelope.ID` 就是该 command 产生的 event 组的 `CommitID`;`Digest` 只用于 Runtime 校验 envelope 构造完整(不匹配为不可重试错误),不参与重放判定。 +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()`。 @@ -360,7 +359,6 @@ 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) DigestCommand(typ string, command AgentCommand) (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) @@ -403,7 +401,7 @@ type MachineProjection struct { ```text writer.Commit(func(view): - 1 validate envelope SessionID/RunID/schema/type/digest(digest 不匹配为不可重试错误) + 1 validate envelope SessionID/RunID/schema/type 2 view.LookupCommit(CommitID = CommandID) 3 found -> fn 返回 nil(Writer 记 Noop);Runtime 以查到的行与当前投影构造 CommitAlreadyApplied 4 derived CommandID check diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index 6b249fb..e7271a3 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -125,10 +125,11 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 ### 4.2 durable adapters -- 文件 adapter `agent/session/filestore`:一个 Session 一个目录,`stream.jsonl` 一行一个 event,`session.lock` 为 `flock` 目标,每次 Append 一次 fsync,打开时截掉不完整尾组; +- 文件 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 附录的两阶段路径。 +- 需要远程 Store 或跨存储 claim 时,实现 extension 附录 C 与 artifact 附录的两阶段路径; +- 内容寻址旁存合并:FrozenValueStore 与 artifact 的 `cas` scheme 是同一抽象的两份定义,长期把模型请求本体旁存实现为 authority 固定的 artifact `cas` 存储实例,随第一个真实内容存储(artifact Store/Resolver)一起做。 ### 4.3 Application migration @@ -139,7 +140,7 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 ## 4.3 Run 内部整理(已完成) -- envelope digest 不匹配从 `ErrCommandConflict` 改为不可重试错误,调用方不再对构造错误 reload 重试; +- envelope digest 不匹配从 `ErrCommandConflict` 改为不可重试错误,调用方不再对构造错误 reload 重试(该自校验其后整体删除:envelope 只经 `BuildEnvelope` 构造,不再携带 digest); - Evolve 对重复 `InputAccepted` 报错而非静默去重; - `decideSubmitModelResult` 拆为 binding 校验与 ToolStep 派生两步; - `RunEnded` wire 改为 tagged union,与 Go sealed union 对称; diff --git a/docs/design/agent-session-chatlog.md b/docs/design/agent-session-chatlog.md index dac4185..7bb2933 100644 --- a/docs/design/agent-session-chatlog.md +++ b/docs/design/agent-session-chatlog.md @@ -232,7 +232,7 @@ 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 Digest 的 domain 为 `twilight/chatlog/checkpoint_created`。`BaseContextDigest` 覆盖截至 `CoveredThrough` 的有序 active Context 序列 `(Kind, ID, Digest)`。`CoveredThrough` 早于该 checkpoint。`SummaryID` 落在 `CoveredThrough` 与 checkpoint 之间,且已由 `summary` 创建。该间隙内仅有这一条 summary。`Retained` 为 base 序列的有序子集。合法 checkpoint 下 Context 为 `[Summary] + Retained`,再 fold checkpoint 之后的 tail。checkpoint 在显式 invalidate,或 summary / Retained / base source 被 supersede 之后失效;projection 回退到更早合法 checkpoint,或从全量 events 重折。 +**CHT-EVT-3**(checkpoint 协议)移至附录 A:未实现,随实现需求冻结。 ## 6. Surface projection @@ -288,3 +288,9 @@ type ContextMaterializer interface { - **CHT-COD-1 至 CHT-COD-3**:codec;Digest domain 与 EventType 相同; - **CHT-SUR-1、CHT-CTX-1、CHT-CTX-2**:EntryOrder 与 checkpoint; - **CHT-MAT-1**:materializer 为 IO 边界。 + +## 附录 A:checkpoint 协议(未实现,随实现需求冻结) + +**CHT-EVT-3** checkpoint Digest 的 domain 为 `twilight/chatlog/checkpoint_created`。`BaseContextDigest` 覆盖截至 `CoveredThrough` 的有序 active Context 序列 `(Kind, ID, Digest)`。`CoveredThrough` 早于该 checkpoint。`SummaryID` 落在 `CoveredThrough` 与 checkpoint 之间,且已由 `summary` 创建。该间隙内仅有这一条 summary。`Retained` 为 base 序列的有序子集。合法 checkpoint 下 Context 为 `[Summary] + Retained`,再 fold checkpoint 之后的 tail。checkpoint 在显式 invalidate,或 summary / Retained / base source 被 supersede 之后失效;projection 回退到更早合法 checkpoint,或从全量 events 重折。 + +本条与 fork/import(agent-session.md 附录 A)同等待遇:wire 与不变量在第一个实现与消费者出现之前不冻结,实现时允许修订。CHT-EVT-1 保留 `checkpoint_created` / `checkpoint_invalidated` 两个 EventType 名与第 2 节的生命周期行;CHT-CTX-2 中"合法 checkpoint 按 CHT-EVT-3 应用"在本附录实现前为空操作。 diff --git a/docs/design/agent-session-extension.md b/docs/design/agent-session-extension.md index 634a4b1..dcc0b67 100644 --- a/docs/design/agent-session-extension.md +++ b/docs/design/agent-session-extension.md @@ -88,7 +88,7 @@ type DecodedEvent struct { } ``` -**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。 +**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`。 @@ -221,9 +221,9 @@ const ( v1 conformance 必须验证: - **EXT-REG-1 至 4**:immutable Registry、`v` 的写入与选择、多版本 codec 共存、Unknown 保留 raw payload、`Requires` 缺失或成环被拒绝、投影消费范围外事件被拒绝、被依赖事件版本不在声明范围被拒绝; -- **EXT-COD-1/2**:wire-first、canonical round-trip、`v` 保留字段; +- **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 被释放或可被核对回收;`ErrOwnershipLost` 后 Writer 失效; +- **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 读取一致。 ## 附录 B:Application module 与通用 Catalog(不进入 v1) diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index a617451..eb45a03 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -58,7 +58,6 @@ type StartedPayload struct { InputIDs []chatlog.InputID ExecutionBinding ExecutionBindingRef Companion CompanionVersion - PlanDigest es.Digest } type CompletedPayload struct { TurnID TurnID @@ -78,7 +77,7 @@ type SupersededPayload struct { **TRN-ID-1** `TurnRef`、RunID、binding ID、CompanionVersion、InputID 与 digest 非空且稳定。 -**TRN-ID-2** `PlanDigest = Digest("twilight/turn/plan", TurnID, ExecutionBinding.Digest, Companion, ordered InputIDs)`。 +**TRN-ID-2** `PlanDigest = Digest("twilight/turn/plan", TurnID, ExecutionBinding.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` 中。 @@ -202,7 +201,7 @@ const ( **TRN-STR-2** Start 是一次原子 commit,顺序为: ```text -twilight/turn/started{TurnID, InputIDs, ExecutionBinding, Companion, PlanDigest} +twilight/turn/started{TurnID, InputIDs, ExecutionBinding, Companion} twilight/chatlog/input_delivered{InputIDs[0], TurnID} ... twilight/chatlog/input_delivered{InputIDs[n-1], TurnID} From e08a9714d2ca105d01bc10afe6d85c1aa0f67156 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 9 Sep 2026 08:57:36 +0800 Subject: [PATCH 75/81] Add the twilight-agent CLI, the first real entry point cmd/twilight-agent wires the JSONL file store into the reference assembly and drives it as an interactive agent: each stdin line is submitted and routed by the session driver (Deliver into a running turn, or a new turn), settled turns print the assistant reply and drain queued inputs, and startup resumes an active turn after a takeover. A -mock mode (scripted model plus a built-in now tool) runs without an API key; the real mode speaks the OpenAI completions shape with an optional DeepSeek compat switch. --- cmd/twilight-agent/main.go | 351 +++++++++++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 cmd/twilight-agent/main.go diff --git a/cmd/twilight-agent/main.go b/cmd/twilight-agent/main.go new file mode 100644 index 0000000..a498fe4 --- /dev/null +++ b/cmd/twilight-agent/main.go @@ -0,0 +1,351 @@ +// Command twilight-agent is a line-oriented CLI agent over the agent core: +// the JSONL file store carries the Session, the reference assembly wires the +// runtime, and the REPL routes each line through SessionDriver.Send — so a +// line typed while a Turn runs steers it (Deliver), a line that cannot be +// delivered stays queued 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" + "time" + + "github.com/memohai/twilight/agent/ref" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/chatlog" + "github.com/memohai/twilight/agent/session/filestore" + "github.com/memohai/twilight/agent/turn" + "github.com/memohai/twilight/provider/openai/completions" + "github.com/memohai/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") + ) + flag.Parse() + if err := run_(*root, session.SessionID(*sid), *provider, *baseURL, *apiKey, *modelID, *compat, *system, *mock); 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) error { + ctx := context.Background() + binding, err := buildBinding(mock, provider, baseURL, apiKey, modelID, compat, system) + if err != nil { + return err + } + + store, err := filestore.New(root) + if err != nil { + return err + } + m, err := ref.New(ref.Options{Store: store, Ownership: session.OpenOptions{Takeover: true}, Sink: printSink{}}) + if err != nil { + return err + } + if _, err := store.Header(ctx, sid); err != nil { + if !session.IsCode(err, session.ErrNotFound) { + return err + } + if err := m.CreateSession(ctx, sid); err != nil { + return err + } + } + disposed, err := m.Open(ctx, sid) + if err != nil { + return err + } + bindingRef, err := m.Bindings.Register("cli", binding) + if err != nil { + return err + } + + idBase := time.Now().UnixMilli() + idSeq := 0 + driver := &ref.SessionDriver{Coordinator: m.Coordinator, Memory: m, Binding: bindingRef, Companion: turn.CompanionV1Version, + NewTurnID: func() turn.TurnID { idSeq++; return turn.TurnID(fmt.Sprintf("turn-%d-%d", idBase, idSeq)) }} + nextInputID := func() run.InputID { idSeq++; return run.InputID(fmt.Sprintf("in-%d-%d", idBase, idSeq)) } + + fmt.Printf("session %s — log at %s\n", sid, store.LogPath(sid)) + if disposed > 0 { + fmt.Printf("takeover: %d executing target disposed\n", disposed) + } + + settled := make(chan settleResult, 8) + inflight := 0 + spawn := func(fn func() (turn.TurnResponse, error)) { + inflight++ + go func() { + resp, err := fn() + settled <- settleResult{resp: resp, err: err} + }() + } + + // A restart lands here with the interrupted Turn still active: resume it. + tsurf, err := m.TurnSurface(ctx, sid) + if err != nil { + return err + } + if v, ok := tsurf.Active(); ok { + fmt.Printf("resuming turn %s\n", v.TurnID) + req := turn.TurnRequest{Ref: turn.TurnRef{SessionID: sid, TurnID: v.TurnID}} + spawn(func() (turn.TurnResponse, error) { return m.Coordinator.Resume(ctx, req) }) + } + for _, v := range tsurf.Turns { + if v.Status == turn.TurnAttemptFailed { + fmt.Printf("turn %s failed; /retry to retry it\n", v.TurnID) + } + } + + handle := func(res settleResult, allowBacklog bool) { + inflight-- + switch { + case res.skip: + case res.err == nil: + fmt.Printf("turn %s: %s (%s)\n", res.resp.Ref.TurnID, res.resp.Status, res.resp.Disposition) + if text := lastAssistantText(ctx, m, sid); text != "" { + fmt.Println(text) + } + case errors.Is(res.err, loop.ErrRunAlreadyRunning): + fmt.Println("steer: input delivered into the running turn") + default: + fmt.Fprintln(os.Stderr, "error:", res.err) + } + if !allowBacklog || inflight > 0 { + return + } + // Drain the backlog: submitted, undelivered inputs open the next Turn. + chat, err := m.ChatlogSurface(ctx, sid) + if err != nil || len(chat.SubmittedInputs()) == 0 { + return + } + inflight++ + go func() { + resp, ok, err := driver.OnTurnSettled(ctx, sid) + settled <- settleResult{resp: resp, err: err, skip: err == nil && !ok} + }() + } + + lines := make(chan string) + go func() { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + lines <- scanner.Text() + } + close(lines) + }() + + for { + select { + case res := <-settled: + handle(res, true) + case line, ok := <-lines: + if !ok { + line = "/quit" + } + switch line = strings.TrimSpace(line); { + case line == "": + case line == "/quit": + for inflight > 0 { + select { + case res := <-settled: + handle(res, false) + case <-time.After(60 * time.Second): + fmt.Fprintln(os.Stderr, "timed out waiting for the running turn") + inflight = 0 + } + } + return m.Close(ctx) + case line == "/log": + fmt.Println(store.LogPath(sid)) + case line == "/retry": + tsurf, err := m.TurnSurface(ctx, sid) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + retried := false + for id, v := range tsurf.Turns { + if v.Status == turn.TurnAttemptFailed { + req := turn.RetryRequest{Ref: turn.TurnRef{SessionID: sid, TurnID: id}, Reason: "cli retry"} + spawn(func() (turn.TurnResponse, error) { return m.Coordinator.Retry(ctx, req) }) + retried = true + break + } + } + if !retried { + fmt.Println("no failed turn to retry") + } + case strings.HasPrefix(line, "/"): + fmt.Println("commands: /quit /log /retry") + default: + in, err := m.SubmitInput(ctx, sid, nextInputID(), line) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + // Send routes by itself: Deliver into the active Turn (steer), + // or Start a new one. An input the running Turn cannot accept + // stays submitted and the backlog drain picks it up. + spawn(func() (turn.TurnResponse, error) { return driver.Send(ctx, sid, []run.AgentInput{in}) }) + } + } + } +} + +type settleResult struct { + resp turn.TurnResponse + err error + skip bool +} + +func lastAssistantText(ctx context.Context, m *ref.Memory, sid session.SessionID) string { + chat, err := m.ChatlogSurface(ctx, sid) + if err != nil { + return "" + } + for i := len(chat.EntryOrder) - 1; i >= 0; i-- { + e := chat.EntryOrder[i] + if e.Kind != chatlog.EntryAssistant { + continue + } + var b strings.Builder + for _, part := range chat.Assistants[chatlog.AssistantID(e.ID)].Parts { + if t, ok := part.(chatlog.TextPart); ok { + b.WriteString(t.Text) + } + } + return b.String() + } + return "" +} + +// 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 +} + +// --- binding ------------------------------------------------------------------- + +func buildBinding(mock bool, provider, baseURL, apiKey, modelID, compat, system string) (ref.Binding, error) { + if mock { + tool := nowTool{} + def, err := run.FreezeToolDefinition(tool.Definition()) + if err != nil { + return ref.Binding{}, err + } + return ref.Binding{ + Public: ref.BindingPublic{Model: "mock", SystemPrompt: system, + Tools: []ref.PublicTool{{Ref: tool.Ref(), Definition: def, Policy: run.DirectExecution}}}, + Models: modelCatalog{mockModel{}}, + Tools: singleTool{tool}, + }, nil + } + if provider != "openai-completions" { + return ref.Binding{}, fmt.Errorf("unsupported provider %q (only openai-completions)", provider) + } + if modelID == "" { + return ref.Binding{}, 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 ref.Binding{}, fmt.Errorf("unsupported compat %q (only deepseek)", compat) + } + invoker := providerModel{model: &sdk.Model{ID: modelID, Provider: completions.New(opts...), Type: sdk.ModelTypeChat}} + return ref.Binding{ + Public: ref.BindingPublic{Model: run.ModelRef(modelID), SystemPrompt: system}, + Models: modelCatalog{invoker}, + Tools: singleTool{}, + }, nil +} + +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) +} + +type modelCatalog struct{ m loop.ModelInvoker } + +func (c modelCatalog) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { return c.m, nil } + +// singleTool resolves the one built-in tool; the zero value resolves nothing. +type singleTool struct{ tool loop.ExecutableTool } + +func (c singleTool) ResolveTool(r run.ToolRef) (loop.ExecutableTool, error) { + if c.tool == nil || c.tool.Ref() != r { + return nil, fmt.Errorf("unknown tool %q", r) + } + return c.tool, nil +} + +// 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. +type mockModel struct{} + +func (mockModel) Generate(_ context.Context, req sdk.Request) (sdk.ModelResult, error) { + 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 +} + +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}} +} From 4f6c7cd6679fea205057099224476991cc8b05c3 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Wed, 9 Sep 2026 09:59:26 +0800 Subject: [PATCH 76/81] Agent abstraction: profiles, a session host, and a pure turn layer turn renames ExecutionBinding* to Profile* (Binding now names only the artifact concept) and stops reporting a successful mid-run delivery as an error: a second driver of a running Run settles as the new already_driving disposition. Drivers are built once at registration, so the Loop's already-running guard actually holds; before, every Resolve built a fresh Loop and two drivers could drive one Run concurrently. ref gains the missing agent layer: NewAgent(model, opts) replaces the catalog boilerplate, the profile digest covers only fields that affect replay (the system prompt is tunable), and the new Session host owns EnsureSession, id minting, Send with the reply text, and backlog draining. The CLI drops its hand-rolled bookkeeping. --- .twilight/default/header.json | 1 + .twilight/default/owner.json | 1 + agent/ref/agent.go | 249 ++++++++++++++++ agent/ref/assembly.go | 44 ++- agent/ref/binding.go | 148 ---------- agent/ref/example_filestore_test.go | 24 +- agent/ref/example_test.go | 33 +-- agent/ref/planner.go | 14 +- agent/ref/session.go | 267 ++++++++++++++++++ agent/ref/turn_test.go | 41 ++- agent/run/loop/takeover_test.go | 5 +- agent/session/extension/extension_test.go | 4 +- agent/session/run/runtimetest/conformance.go | 2 +- agent/session/run/runtimetest/harness.go | 2 +- agent/session/sessiontest/conformance.go | 4 +- agent/turn/coordinator.go | 50 ++-- agent/turn/module_test.go | 2 +- agent/turn/projection.go | 18 +- agent/turn/turn.go | 28 +- cmd/twilight-agent/main.go | 281 ++++++------------- docs/design/agent-reference-assembly.md | 86 ++++-- docs/design/agent-runtime-refactor.md | 4 +- docs/design/agent-turn.md | 33 +-- twilight-agent | Bin 0 -> 13719682 bytes 24 files changed, 828 insertions(+), 513 deletions(-) create mode 100644 .twilight/default/header.json create mode 100644 .twilight/default/owner.json create mode 100644 agent/ref/agent.go delete mode 100644 agent/ref/binding.go create mode 100644 agent/ref/session.go create mode 100755 twilight-agent diff --git a/.twilight/default/header.json b/.twilight/default/header.json new file mode 100644 index 0000000..1b9f17d --- /dev/null +++ b/.twilight/default/header.json @@ -0,0 +1 @@ +{"protocolVersion":1,"sessionId":"default","createdAtUnixMilli":1788915871627,"metadata":null,"headerDigest":"sha256:f807778724a0aa3462d4bc57a73b428af12ec7ad7c9374757115e15f4e7e60c6"} \ No newline at end of file diff --git a/.twilight/default/owner.json b/.twilight/default/owner.json new file mode 100644 index 0000000..3c00598 --- /dev/null +++ b/.twilight/default/owner.json @@ -0,0 +1 @@ +{"epoch":1,"owned":true} \ No newline at end of file diff --git a/agent/ref/agent.go b/agent/ref/agent.go new file mode 100644 index 0000000..9374487 --- /dev/null +++ b/agent/ref/agent.go @@ -0,0 +1,249 @@ +package ref + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/memohai/twilight/agent/es" + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/run/loop" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/turn" + "github.com/memohai/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 } + +// Agents is the in-process turn.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 +// turn.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 turn.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) (turn.RunDriver, error) { + r.mu.RLock() + reg, ok := r.byID[ref.ID] + r.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("ref: unknown profile %s", ref.ID) + } + p := reg.agent.Profile() + digest, err := DigestProfile(&p) + if err != nil { + return nil, err + } + if digest != ref.Digest { + return nil, fmt.Errorf("ref: profile %s digest mismatch", ref.ID) + } + return reg.driver, nil +} + +// loopDriver is TRN-DRV-1: Drive is loop.Run. A concurrent local driver of +// the same Run is reported as turn.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 turn.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", turn.ErrAlreadyDriving, err) + } + return err +} diff --git a/agent/ref/assembly.go b/agent/ref/assembly.go index 66684e9..7e0d2c6 100644 --- a/agent/ref/assembly.go +++ b/agent/ref/assembly.go @@ -2,7 +2,6 @@ package ref import ( "context" - "errors" "fmt" "time" @@ -40,7 +39,7 @@ type Memory struct { Store session.Store Registry *extension.Registry Writers extension.Writers - Bindings *Bindings + Agents *Agents Runtime *runmod.Runtime Coordinator *turn.Coordinator BindingStore *artifact.MemoryBindingStore @@ -80,8 +79,8 @@ func New(opts Options) (*Memory, error) { return nil, err } m := &Memory{Store: store, Registry: registry, Writers: writers, Runtime: runtime, BindingStore: bindings, Ledger: ledger, now: now} - m.Bindings = NewBindings(runtime, writersProjections{writers}, opts.Sink) - m.Coordinator = &turn.Coordinator{Writers: writers, Runtime: runtime, Bindings: m.Bindings, Now: now} + m.Agents = NewAgents(runtime, writersProjections{writers}, opts.Sink) + m.Coordinator = &turn.Coordinator{Writers: writers, Runtime: runtime, Profiles: m.Agents, Now: now} return m, nil } @@ -102,6 +101,24 @@ func (m *Memory) CreateSession(ctx context.Context, sid session.SessionID) error 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-4, RUN-CMT-7). It returns the number of recovery commands issued. func (m *Memory) Open(ctx context.Context, sid session.SessionID) (int, error) { @@ -139,6 +156,11 @@ func (m *Memory) SubmitInput(ctx context.Context, sid session.SessionID, id run. } } +// 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) +} + // ChatlogSurface reads the chatlog surface projection. func (m *Memory) ChatlogSurface(ctx context.Context, sid session.SessionID) (chatlog.Surface, error) { state, _, err := writersProjections{m.Writers}.Load(ctx, sid, chatlog.SurfaceProjectionID, chatlog.SurfaceProjection.Version) @@ -162,15 +184,17 @@ func (m *Memory) TurnSurface(ctx context.Context, sid session.SessionID) (turn.T type SessionDriver struct { Coordinator turn.Service Memory *Memory - Binding turn.ExecutionBindingRef + Profile turn.ProfileRef Companion turn.CompanionVersion - NewTurnID func() turn.TurnID + // NewTurnID mints the next TurnID; nil selects the random default. + NewTurnID func() turn.TurnID } // Send is REF-DRV-1: Deliver into the active Turn, or Start a new one. func (d *SessionDriver) Send(ctx context.Context, sid session.SessionID, inputs []run.AgentInput) (turn.TurnResponse, error) { - if d.NewTurnID == nil { - return turn.TurnResponse{}, errors.New("ref: session driver requires NewTurnID") + newTurnID := d.NewTurnID + if newTurnID == nil { + newTurnID = NewTurnID } surface, err := d.Memory.TurnSurface(ctx, sid) if err != nil { @@ -184,8 +208,8 @@ func (d *SessionDriver) Send(ctx context.Context, sid session.SessionID, inputs return turn.TurnResponse{}, fmt.Errorf("%w: turn %s awaits Retry or Settle", turn.ErrConflict, v.TurnID) } } - return d.Coordinator.Start(ctx, turn.StartRequest{Ref: turn.TurnRef{SessionID: sid, TurnID: d.NewTurnID()}, Inputs: inputs, - ExecutionBinding: d.Binding, Companion: d.Companion}) + return d.Coordinator.Start(ctx, turn.StartRequest{Ref: turn.TurnRef{SessionID: sid, TurnID: newTurnID()}, Inputs: inputs, + Profile: d.Profile, Companion: d.Companion}) } // OnTurnSettled is REF-DRV-2: start the next Turn from the backlog of diff --git a/agent/ref/binding.go b/agent/ref/binding.go deleted file mode 100644 index 44bb7d7..0000000 --- a/agent/ref/binding.go +++ /dev/null @@ -1,148 +0,0 @@ -// Package ref is the Memory reference assembly -// (docs/design/agent-reference-assembly.md): ExecutionBinding, the context -// Planner, the Session-scoped input router and the wiring of every layer into -// one in-process agent. -package ref - -import ( - "context" - "errors" - "fmt" - "sync" - - "github.com/memohai/twilight/agent/es" - "github.com/memohai/twilight/agent/run" - "github.com/memohai/twilight/agent/run/loop" - "github.com/memohai/twilight/agent/session" - "github.com/memohai/twilight/agent/turn" - "github.com/memohai/twilight/sdk" -) - -// PlannerID names the reference planner (REF 2). -const PlannerID = "twilight/turn/planner/context-v1" - -type PublicTool struct { - Ref run.ToolRef `json:"ref"` - Definition run.ToolDefinition `json:"definition"` - Policy run.ResponsePolicy `json:"policy"` -} - -// BindingPublic is the digest-covered public configuration of a binding -// (REF-BND-1). Credentials and clients stay in process. -type BindingPublic struct { - SchemaVersion uint16 `json:"schemaVersion"` - Model run.ModelRef `json:"model"` - Tools []PublicTool `json:"tools,omitempty"` - Streaming bool `json:"streaming,omitempty"` - PlannerID string `json:"plannerId"` - SystemPrompt string `json:"systemPrompt,omitempty"` -} - -func DigestBinding(pub *BindingPublic) (es.Digest, error) { - raw, err := es.EncodeTypedPayload(1, "twilight/turn/binding", pub) - if err != nil { - return "", err - } - return es.DigestBytes(raw), nil -} - -// ToolSpecs derives the frozen ToolSpecs and provider definitions of pub, in -// order (REF-PLN-4). -func (pub *BindingPublic) ToolSpecs() ([]run.ToolSpec, []sdk.ToolDefinition, error) { - specs := make([]run.ToolSpec, 0, len(pub.Tools)) - defs := make([]sdk.ToolDefinition, 0, len(pub.Tools)) - for _, t := range pub.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 -} - -// Binding is one resolvable execution binding: public config plus the -// in-process model and tool catalogs. -type Binding struct { - Public BindingPublic - Models loop.ModelCatalog - Tools loop.ToolCatalog - Policy loop.ExecutionPolicy -} - -// Bindings is the in-process ExecutionBindingRegistry (REF-BND-2). -type Bindings struct { - runtime run.Runtime - projections ProjectionSource - sink loop.EventSink - - mu sync.RWMutex - byID map[turn.ExecutionBindingID]Binding -} - -// 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 NewBindings(runtime run.Runtime, projections ProjectionSource, sink loop.EventSink) *Bindings { - return &Bindings{runtime: runtime, projections: projections, sink: sink, byID: map[turn.ExecutionBindingID]Binding{}} -} - -// Register stores a binding and returns the ref the Session records. -func (b *Bindings) Register(id turn.ExecutionBindingID, binding Binding) (turn.ExecutionBindingRef, error) { - if id == "" || binding.Models == nil || binding.Tools == nil || binding.Public.Model == "" { - return turn.ExecutionBindingRef{}, errors.New("ref: binding requires id, model, model catalog and tool catalog") - } - if binding.Public.SchemaVersion == 0 { - binding.Public.SchemaVersion = 1 - } - if binding.Public.PlannerID == "" { - binding.Public.PlannerID = PlannerID - } - digest, err := DigestBinding(&binding.Public) - if err != nil { - return turn.ExecutionBindingRef{}, err - } - b.mu.Lock() - b.byID[id] = binding - b.mu.Unlock() - return turn.ExecutionBindingRef{ID: id, Digest: digest}, nil -} - -// Resolve returns a RunDriver when the ref's digest matches the registered -// public configuration (REF-BND-2). -func (b *Bindings) Resolve(ref turn.ExecutionBindingRef) (turn.RunDriver, error) { - b.mu.RLock() - binding, ok := b.byID[ref.ID] - b.mu.RUnlock() - if !ok { - return nil, fmt.Errorf("ref: unknown binding %s", ref.ID) - } - digest, err := DigestBinding(&binding.Public) - if err != nil { - return nil, err - } - if digest != ref.Digest { - return nil, fmt.Errorf("ref: binding %s digest mismatch", ref.ID) - } - planner := &ContextPlanner{Projections: b.projections, Public: binding.Public} - l, err := loop.New(binding.Models, binding.Tools, planner, binding.Policy, binding.Public.Streaming) - if err != nil { - return nil, err - } - return loopDriver{loop: l, runtime: b.runtime, sink: b.sink}, nil -} - -// loopDriver is TRN-DRV-1: Drive is loop.Run. -type loopDriver struct { - loop *loop.Loop - runtime run.Runtime - sink loop.EventSink -} - -func (d loopDriver) Drive(ctx context.Context, req turn.DriveRequest) error { - _, err := d.loop.Run(ctx, d.runtime, req.Ref.SessionID, req.RunID, d.sink) - return err -} diff --git a/agent/ref/example_filestore_test.go b/agent/ref/example_filestore_test.go index 9a56aad..46132d1 100644 --- a/agent/ref/example_filestore_test.go +++ b/agent/ref/example_filestore_test.go @@ -2,7 +2,6 @@ package ref_test import ( "context" - "errors" "fmt" "os" "strings" @@ -60,12 +59,12 @@ func Example_jsonlPrototype() { panic(err) } model1 := &scriptedRequests{answers: []sdk.ModelResult{protoToolCall("call-1"), protoText("done"), protoToolCall("call-2")}} - binding1, err := p1.Bindings.Register("jsonl-agent", protoBinding(model1, tool)) + 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, Binding: binding1, Companion: turn.CompanionV1Version, + 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. @@ -92,7 +91,8 @@ func Example_jsonlPrototype() { steerDone := make(chan struct{}) go func() { defer close(steerDone) - if _, err := driver.Send(ctx, sid, []run.AgentInput{in2}); err != nil && !errors.Is(err, loop.ErrRunAlreadyRunning) { + // Deliver into the running Turn returns already_driving, not an error. + if _, err := driver.Send(ctx, sid, []run.AgentInput{in2}); err != nil { panic(err) } }() @@ -138,7 +138,7 @@ func Example_jsonlPrototype() { if err != nil { panic(err) } - if _, err := p2.Bindings.Register("jsonl-agent", protoBinding(&scriptedRequests{}, tool)); err != nil { + if _, err := p2.Agents.Register("jsonl-agent", protoAgent(&scriptedRequests{}, tool)); err != nil { panic(err) } recovered, err := p2.Open(ctx, sid) @@ -200,16 +200,12 @@ func protoText(text string) sdk.ModelResult { return sdk.ModelResult{Text: text, FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}} } -func protoBinding(model loop.ModelInvoker, tool *stagedTool) ref.Binding { - def, err := run.FreezeToolDefinition(tool.Definition()) +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 ref.Binding{ - Public: ref.BindingPublic{Model: "m-1", Tools: []ref.PublicTool{{Ref: tool.Ref(), Definition: def, Policy: run.DirectExecution}}}, - Models: modelCatalog{model}, - Tools: stagedCatalog{tool}, - } + return agent } // stagedTool blocks each staged execution until its stage is released; @@ -252,7 +248,3 @@ func (t *stagedTool) Execute(_ context.Context, req loop.ToolExecutionRequest) l } return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} } - -type stagedCatalog struct{ tool *stagedTool } - -func (c stagedCatalog) ResolveTool(run.ToolRef) (loop.ExecutableTool, error) { return c.tool, nil } diff --git a/agent/ref/example_test.go b/agent/ref/example_test.go index 82c9925..ea97a75 100644 --- a/agent/ref/example_test.go +++ b/agent/ref/example_test.go @@ -52,7 +52,7 @@ func Example_recoverableTurn() { if _, err := p1.Open(ctx, sid); err != nil { panic(err) } - binding1, err := p1.Bindings.Register("weather-agent", newBinding(tool)) + profile1, err := p1.Agents.Register("weather-agent", newAgent(tool)) if err != nil { panic(err) } @@ -64,7 +64,7 @@ func Example_recoverableTurn() { startDone := make(chan error, 1) go func() { _, err := p1.Coordinator.Start(ctx, turn.StartRequest{Ref: ref1, Inputs: []run.AgentInput{input}, - ExecutionBinding: binding1, Companion: turn.CompanionV1Version}) + Profile: profile1, Companion: turn.CompanionV1Version}) startDone <- err }() runID := waitForExecutingCall(ctx, p1, sid, ref1.TurnID) @@ -75,9 +75,9 @@ func Example_recoverableTurn() { if err != nil { panic(err) } - // The binding is re-registered from the same public configuration, so the - // ref the Session recorded still resolves. - if _, err := p2.Bindings.Register("weather-agent", newBinding(tool)); err != nil { + // 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) @@ -163,16 +163,12 @@ func waitForExecutingCall(ctx context.Context, m *ref.Memory, sid session.Sessio } } -func newBinding(tool *lookupTool) ref.Binding { - def, err := run.FreezeToolDefinition(tool.Definition()) +func newAgent(tool *lookupTool) ref.Agent { + agent, err := ref.NewAgent("m-1", &scriptedModel{}, ref.WithTool(tool)) if err != nil { panic(err) } - return ref.Binding{ - Public: ref.BindingPublic{Model: "m-1", Tools: []ref.PublicTool{{Ref: tool.Ref(), Definition: def, Policy: run.DirectExecution}}}, - Models: modelCatalog{&scriptedModel{}}, - Tools: toolCatalog{tool}, - } + return agent } type fakeClock struct { @@ -186,19 +182,6 @@ func (c *fakeClock) Now() time.Time { return c.now } -type modelCatalog struct{ m loop.ModelInvoker } - -func (c modelCatalog) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { return c.m, nil } - -type toolCatalog struct{ tool *lookupTool } - -func (c toolCatalog) ResolveTool(ref run.ToolRef) (loop.ExecutableTool, error) { - if ref != c.tool.Ref() { - return nil, fmt.Errorf("unknown tool %q", ref) - } - return c.tool, nil -} - // scriptedModel asks for the tool until a tool result is in the conversation, // then answers. type scriptedModel struct{} diff --git a/agent/ref/planner.go b/agent/ref/planner.go index 669f6e6..cd44900 100644 --- a/agent/ref/planner.go +++ b/agent/ref/planner.go @@ -24,14 +24,14 @@ type ( // those of earlier attempts of the same Turn (REF-PLN-6). type ContextPlanner struct { Projections ProjectionSource - Public BindingPublic + 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.Public.Model == "" { + if p.Projections == nil || p.Profile.Model == "" { return loop.RequestPlan{}, errors.New("ref: planner requires projections and a model") } if hint.Session == "" { @@ -46,7 +46,7 @@ func (p *ContextPlanner) Plan(ctx context.Context, hint run.PlanningHint) (loop. if err != nil { return loop.RequestPlan{}, err } - specs, defs, err := p.Public.ToolSpecs() + specs, defs, err := p.Profile.ToolSpecs() if err != nil { return loop.RequestPlan{}, err } @@ -55,8 +55,8 @@ func (p *ContextPlanner) Plan(ctx context.Context, hint run.PlanningHint) (loop. ids = append(ids, in.ID) } return loop.RequestPlan{ - Model: p.Public.Model, - Request: sdk.Request{Model: string(p.Public.Model), Messages: msgs, Tools: defs}, + 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, @@ -66,8 +66,8 @@ func (p *ContextPlanner) Plan(ctx context.Context, hint run.PlanningHint) (loop. // messages is REF-PLN-2. func (p *ContextPlanner) messages(entries []chatlog.Entry) ([]sdk.Message, error) { var msgs []sdk.Message - if p.Public.SystemPrompt != "" { - msgs = append(msgs, sdk.SystemMessage(p.Public.SystemPrompt)) + if p.Profile.SystemPrompt != "" { + msgs = append(msgs, sdk.SystemMessage(p.Profile.SystemPrompt)) } inputText := p.InputText if inputText == nil { diff --git a/agent/ref/session.go b/agent/ref/session.go new file mode 100644 index 0000000..3fb84b3 --- /dev/null +++ b/agent/ref/session.go @@ -0,0 +1,267 @@ +package ref + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "strings" + + "github.com/memohai/twilight/agent/run" + "github.com/memohai/twilight/agent/session" + "github.com/memohai/twilight/agent/session/chatlog" + "github.com/memohai/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 +} + +// 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 +} + +// 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, + 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: turn.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.Coordinator.Resume(ctx, turn.TurnRequest{Ref: 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 + } + resp, err := s.m.Coordinator.Retry(ctx, turn.RetryRequest{Ref: turn.TurnRef{SessionID: s.sid, TurnID: status.Failed[0]}, Reason: "host retry"}) + 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-2). +func (s *Session) settled(ctx context.Context, resp turn.TurnResponse) ([]Result, error) { + out := []Result{s.result(ctx, resp)} + if resp.Disposition == turn.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 { + return out, nil + } + out = append(out, s.result(ctx, next)) + if next.Disposition == turn.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 index 92a8d15..5d19a9d 100644 --- a/agent/ref/turn_test.go +++ b/agent/ref/turn_test.go @@ -2,7 +2,6 @@ package ref_test import ( "context" - "errors" "testing" "time" @@ -50,16 +49,12 @@ func (m *scriptedRequests) Generate(_ context.Context, req sdk.Request) (sdk.Mod return next, nil } -type gateCatalog struct{ tool *gateTool } - -func (c gateCatalog) ResolveTool(ref run.ToolRef) (loop.ExecutableTool, error) { return c.tool, 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.ExecutionBindingRef, session.SessionID) { +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 { @@ -69,18 +64,15 @@ func setup(t *testing.T, model loop.ModelInvoker, tool *gateTool) (*ref.Memory, if err := m.CreateSession(context.Background(), sid); err != nil { t.Fatal(err) } - def, err := run.FreezeToolDefinition(tool.Definition()) + agent, err := ref.NewAgent("m-1", model, ref.WithTool(tool), ref.WithSystemPrompt("be brief")) if err != nil { t.Fatal(err) } - binding, err := m.Bindings.Register("b1", ref.Binding{ - Public: ref.BindingPublic{Model: "m-1", SystemPrompt: "be brief", Tools: []ref.PublicTool{{Ref: tool.Ref(), Definition: def, Policy: run.DirectExecution}}}, - Models: modelCatalog{model}, Tools: gateCatalog{tool}, - }) + profile, err := m.Agents.Register("b1", agent) if err != nil { t.Fatal(err) } - return m, binding, sid + return m, profile, sid } // An input delivered while a tool call is Executing queues on the Run, is @@ -99,7 +91,7 @@ func TestDeliverMidTurnReachesNextModelRequest(t *testing.T) { 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}, ExecutionBinding: binding, Companion: turn.CompanionV1Version}) + resp, err := m.Coordinator.Start(ctx, turn.StartRequest{Ref: ref1, Inputs: []run.AgentInput{first}, Profile: binding, Companion: turn.CompanionV1Version}) if err != nil { t.Error(err) } @@ -111,14 +103,17 @@ func TestDeliverMidTurnReachesNextModelRequest(t *testing.T) { if err != nil { t.Fatal(err) } - driver := &ref.SessionDriver{Coordinator: m.Coordinator, Memory: m, Binding: binding, Companion: turn.CompanionV1Version, NewTurnID: func() turn.TurnID { return "t2" }} + 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; Drive is skipped because the Run is already being driven here, so - // route through the Coordinator directly in a goroutine. - deliverDone := make(chan error, 1) + // 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() { - _, err := driver.Send(ctx, sid, []run.AgentInput{second}) - deliverDone <- err + 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. @@ -127,8 +122,8 @@ func TestDeliverMidTurnReachesNextModelRequest(t *testing.T) { return err == nil && len(surface.Turns["t1"].InputIDs) == 2 }) close(tool.release) - if err := <-deliverDone; err != nil && !errors.Is(err, loop.ErrRunAlreadyRunning) { - t.Fatalf("deliver: %v", err) + if resp := <-deliverDone; resp.Disposition != turn.ResumeAlreadyDriving && resp.Disposition != turn.ResumeFinished { + t.Fatalf("deliver disposition = %s", resp.Disposition) } resp := <-done if resp.Status != turn.TurnCompleted { @@ -171,7 +166,7 @@ func TestStopSettlesTurnAndNextSendStartsNewTurn(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - _, _ = m.Coordinator.Start(ctx, turn.StartRequest{Ref: ref1, Inputs: []run.AgentInput{first}, ExecutionBinding: binding, Companion: turn.CompanionV1Version}) + _, _ = m.Coordinator.Start(ctx, turn.StartRequest{Ref: ref1, Inputs: []run.AgentInput{first}, Profile: binding, Companion: turn.CompanionV1Version}) }() <-tool.started @@ -198,7 +193,7 @@ func TestStopSettlesTurnAndNextSendStartsNewTurn(t *testing.T) { } second, _ := m.SubmitInput(ctx, sid, "in-2", "again") - driver := &ref.SessionDriver{Coordinator: m.Coordinator, Memory: m, Binding: binding, Companion: turn.CompanionV1Version, NewTurnID: func() turn.TurnID { return "t2" }} + 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) diff --git a/agent/run/loop/takeover_test.go b/agent/run/loop/takeover_test.go index 43a8f87..789b1f2 100644 --- a/agent/run/loop/takeover_test.go +++ b/agent/run/loop/takeover_test.go @@ -35,7 +35,10 @@ func TestTakeoverDisposesExecutingCallAndFencesOldOwner(t *testing.T) { t.Fatal(err) } firstDone := make(chan error, 1) - go func() { _, err := first.Run(context.Background(), oldRuntime, testSession, "run-1", nil); firstDone <- err }() + 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. diff --git a/agent/session/extension/extension_test.go b/agent/session/extension/extension_test.go index fb3bd44..62d8acf 100644 --- a/agent/session/extension/extension_test.go +++ b/agent/session/extension/extension_test.go @@ -285,8 +285,8 @@ func TestProjectionUnknownEvents(t *testing.T) { t.Fatal(err) } } - raw("other", "twilight/zzz/thing", false) // out of scope: skipped - raw("future", "twilight/a/future", true) // in scope, ignorable: skipped + 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 { diff --git a/agent/session/run/runtimetest/conformance.go b/agent/session/run/runtimetest/conformance.go index 6cc1dff..6484157 100644 --- a/agent/session/run/runtimetest/conformance.go +++ b/agent/session/run/runtimetest/conformance.go @@ -454,7 +454,7 @@ func testIsolation(t *testing.T, factory Factory) { 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", ExecutionBinding: turn.ExecutionBindingRef{ID: "b", Digest: "sha256:b"}, Companion: turn.CompanionV1Version}}}}) + 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") } diff --git a/agent/session/run/runtimetest/harness.go b/agent/session/run/runtimetest/harness.go index c16d740..f550de0 100644 --- a/agent/session/run/runtimetest/harness.go +++ b/agent/session/run/runtimetest/harness.go @@ -168,7 +168,7 @@ func (h *harness) startGroup(turnID turn.TurnID, runID run.RunID, attempt uint32 } if attempt == 1 { group.Events = append(group.Events, extension.TypedEvent{Type: turn.TypeStarted, RecordedAtUnixMilli: 1, - Value: turn.StartedPayload{TurnID: turnID, InputIDs: ids, ExecutionBinding: turn.ExecutionBindingRef{ID: "b", Digest: "sha256:b"}, + 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, diff --git a/agent/session/sessiontest/conformance.go b/agent/session/sessiontest/conformance.go index faf9f95..6952ef7 100644 --- a/agent/session/sessiontest/conformance.go +++ b/agent/session/sessiontest/conformance.go @@ -205,8 +205,8 @@ 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, "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 { diff --git a/agent/turn/coordinator.go b/agent/turn/coordinator.go index 3867ada..c50ea11 100644 --- a/agent/turn/coordinator.go +++ b/agent/turn/coordinator.go @@ -17,8 +17,14 @@ import ( // ErrConflict reports a Turn in a state that does not admit the operation. var ErrConflict = errors.New("turn: conflict") -// ErrBindingUnavailable reports that the persisted binding cannot be resolved. -var ErrBindingUnavailable = errors.New("turn: binding_unavailable") +// ErrProfileUnavailable reports that the persisted profile cannot be resolved. +var ErrProfileUnavailable = errors.New("turn: 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 Coordinator turns it into a successful response +// with ResumeAlreadyDriving, not an error. +var ErrAlreadyDriving = errors.New("turn: already_driving") type DriveRequest struct { Ref TurnRef @@ -26,20 +32,21 @@ type DriveRequest struct { } // RunDriver drives one Run to its next quiescent point; the reference driver -// wraps loop.Run (TRN-DRV-1). +// wraps loop.Run (TRN-DRV-1). A second local driver of the same Run reports +// ErrAlreadyDriving instead of driving. type RunDriver interface { Drive(context.Context, DriveRequest) error } -type ExecutionBindingRegistry interface { - Resolve(ExecutionBindingRef) (RunDriver, error) +type ProfileRegistry interface { + Resolve(ProfileRef) (RunDriver, error) } type StartRequest struct { - Ref TurnRef - Inputs []run.AgentInput - ExecutionBinding ExecutionBindingRef - Companion CompanionVersion + Ref TurnRef + Inputs []run.AgentInput + Profile ProfileRef + Companion CompanionVersion } type DeliverRequest struct { Ref TurnRef @@ -65,6 +72,9 @@ const ( ResumeWaitingForResponse ResumeDisposition = "waiting_for_response" ResumeWaitingForRecovery ResumeDisposition = "waiting_for_recovery" ResumeFinished ResumeDisposition = "finished" + // ResumeAlreadyDriving: the inputs (if any) are committed and another + // local driver of the same Run carries them forward. + ResumeAlreadyDriving ResumeDisposition = "already_driving" ) type TurnResponse struct { @@ -93,7 +103,7 @@ type Service interface { type Coordinator struct { Writers extension.Writers Runtime run.Runtime - Bindings ExecutionBindingRegistry + Profiles ProfileRegistry // Now stamps event times; nil selects time.Now. Now func() time.Time } @@ -154,8 +164,8 @@ func (c *Coordinator) commit(ctx context.Context, sid session.SessionID, op stri // --- Start ------------------------------------------------------------------------ func (c *Coordinator) Start(ctx context.Context, req StartRequest) (TurnResponse, error) { - if req.Ref.SessionID == "" || req.Ref.TurnID == "" || req.ExecutionBinding.ID == "" || req.ExecutionBinding.Digest == "" || req.Companion == "" { - return TurnResponse{}, errors.New("turn: start requires ref, binding and companion") + 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{}{} @@ -167,7 +177,7 @@ func (c *Coordinator) Start(ctx context.Context, req StartRequest) (TurnResponse inputIDs[i] = chatlog.InputID(in.ID) } sid, turnID := req.Ref.SessionID, req.Ref.TurnID - plan := PlanDigest(turnID, req.ExecutionBinding.Digest, req.Companion, inputIDs) + 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)) @@ -209,7 +219,7 @@ func (c *Coordinator) Start(ctx context.Context, req StartRequest) (TurnResponse 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, ExecutionBinding: req.ExecutionBinding, Companion: req.Companion}}) + 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)}}) @@ -435,11 +445,19 @@ func (c *Coordinator) drive(ctx context.Context, ref TurnRef, runID run.RunID) ( } view := surface.Turns[ref.TurnID] if view.Status == TurnActive { - driver, err := c.Bindings.Resolve(view.ExecutionBinding) + driver, err := c.Profiles.Resolve(view.Profile) if err != nil { - return TurnResponse{}, fmt.Errorf("%w: %v", ErrBindingUnavailable, err) + return TurnResponse{}, fmt.Errorf("%w: %v", ErrProfileUnavailable, err) } if err := driver.Drive(ctx, DriveRequest{Ref: ref, RunID: runID}); err != nil { + if errors.Is(err, ErrAlreadyDriving) { + resp, rerr := c.respond(ctx, ref, runID) + if rerr != nil { + return TurnResponse{}, rerr + } + resp.Disposition = ResumeAlreadyDriving + return resp, nil + } return TurnResponse{}, err } } diff --git a/agent/turn/module_test.go b/agent/turn/module_test.go index 68e12be..a6e56b1 100644 --- a/agent/turn/module_test.go +++ b/agent/turn/module_test.go @@ -10,7 +10,7 @@ import ( // 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, ExecutionBinding: ExecutionBindingRef{ID: "b", Digest: "sha256:b"}, Companion: CompanionV1Version}, + 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"}, diff --git a/agent/turn/projection.go b/agent/turn/projection.go index 0b54869..b6ad1d9 100644 --- a/agent/turn/projection.go +++ b/agent/turn/projection.go @@ -43,14 +43,14 @@ func (a *AttemptView) Ended() *run.RunEnd { } type TurnView struct { - TurnID TurnID `json:"turnId"` - Status TurnStatus `json:"status"` - InputIDs []chatlog.InputID `json:"inputIds,omitempty"` - ExecutionBinding ExecutionBindingRef `json:"executionBinding"` - Companion CompanionVersion `json:"companion"` - Attempts []AttemptView `json:"attempts,omitempty"` - ActiveRun run.RunID `json:"activeRun,omitempty"` - ReplacementTurnID TurnID `json:"replacementTurnId,omitempty"` + 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. @@ -121,7 +121,7 @@ func applySurface(state any, e extension.DecodedEvent) (any, error) { } s.Order = append(s.Order, p.TurnID) s.Turns[p.TurnID] = TurnView{TurnID: p.TurnID, Status: TurnActive, InputIDs: append([]chatlog.InputID(nil), p.InputIDs...), - ExecutionBinding: p.ExecutionBinding, Companion: p.Companion} + Profile: p.Profile, Companion: p.Companion} case CompletedPayload: v, err := s.settling(p.TurnID) if err != nil { diff --git a/agent/turn/turn.go b/agent/turn/turn.go index 048733e..430fc1a 100644 --- a/agent/turn/turn.go +++ b/agent/turn/turn.go @@ -18,9 +18,9 @@ import ( const ModuleID extension.ModuleID = "turn" type ( - TurnID string - ExecutionBindingID string - CompanionVersion string + TurnID string + ProfileID string + CompanionVersion string ) type TurnRef struct { @@ -28,9 +28,9 @@ type TurnRef struct { TurnID TurnID } -type ExecutionBindingRef struct { - ID ExecutionBindingID `json:"id"` - Digest es.Digest `json:"digest"` +type ProfileRef struct { + ID ProfileID `json:"id"` + Digest es.Digest `json:"digest"` } type Settlement string @@ -49,10 +49,10 @@ const ( ) type StartedPayload struct { - TurnID TurnID `json:"turnId"` - InputIDs []chatlog.InputID `json:"inputIds,omitempty"` - ExecutionBinding ExecutionBindingRef `json:"executionBinding"` - Companion CompanionVersion `json:"companion"` + TurnID TurnID `json:"turnId"` + InputIDs []chatlog.InputID `json:"inputIds,omitempty"` + Profile ProfileRef `json:"profile"` + Companion CompanionVersion `json:"companion"` } type CompletedPayload struct { @@ -80,8 +80,8 @@ func digestOf(domain string, parts ...string) es.Digest { } // PlanDigest is TRN-ID-2. -func PlanDigest(turnID TurnID, binding es.Digest, companion CompanionVersion, inputs []chatlog.InputID) es.Digest { - parts := []string{string(turnID), string(binding), string(companion)} +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)) } @@ -131,8 +131,8 @@ var Module = extension.ModuleDescriptor{ }, Events: []extension.EventDefinition{ def[StartedPayload](TypeStarted, func(p *StartedPayload) error { - if p.TurnID == "" || p.ExecutionBinding.ID == "" || p.ExecutionBinding.Digest == "" || p.Companion == "" { - return errors.New("started requires turnId, binding and companion") + if p.TurnID == "" || p.Profile.ID == "" || p.Profile.Digest == "" || p.Companion == "" { + return errors.New("started requires turnId, profile and companion") } return nil }), diff --git a/cmd/twilight-agent/main.go b/cmd/twilight-agent/main.go index a498fe4..23e9575 100644 --- a/cmd/twilight-agent/main.go +++ b/cmd/twilight-agent/main.go @@ -1,9 +1,9 @@ // Command twilight-agent is a line-oriented CLI agent over the agent core: -// the JSONL file store carries the Session, the reference assembly wires the -// runtime, and the REPL routes each line through SessionDriver.Send — so a -// line typed while a Turn runs steers it (Deliver), a line that cannot be -// delivered stays queued and opens the next Turn after settlement, and a -// restart over the same root takes the Session over and resumes. +// 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 ( @@ -14,13 +14,13 @@ import ( "fmt" "os" "strings" + "sync" "time" "github.com/memohai/twilight/agent/ref" "github.com/memohai/twilight/agent/run" "github.com/memohai/twilight/agent/run/loop" "github.com/memohai/twilight/agent/session" - "github.com/memohai/twilight/agent/session/chatlog" "github.com/memohai/twilight/agent/session/filestore" "github.com/memohai/twilight/agent/turn" "github.com/memohai/twilight/provider/openai/completions" @@ -48,7 +48,7 @@ func main() { func run_(root string, sid session.SessionID, provider, baseURL, apiKey, modelID, compat, system string, mock bool) error { ctx := context.Background() - binding, err := buildBinding(mock, provider, baseURL, apiKey, modelID, compat, system) + agent, err := buildAgent(mock, provider, baseURL, apiKey, modelID, compat, system) if err != nil { return err } @@ -61,181 +61,108 @@ func run_(root string, sid session.SessionID, provider, baseURL, apiKey, modelID if err != nil { return err } - if _, err := store.Header(ctx, sid); err != nil { - if !session.IsCode(err, session.ErrNotFound) { - return err - } - if err := m.CreateSession(ctx, sid); err != nil { - return err - } - } - disposed, err := m.Open(ctx, sid) + profile, err := m.Agents.Register("cli", agent) if err != nil { return err } - bindingRef, err := m.Bindings.Register("cli", binding) + s, err := m.OpenSession(ctx, sid, ref.SessionOptions{Profile: profile}) if err != nil { return err } - - idBase := time.Now().UnixMilli() - idSeq := 0 - driver := &ref.SessionDriver{Coordinator: m.Coordinator, Memory: m, Binding: bindingRef, Companion: turn.CompanionV1Version, - NewTurnID: func() turn.TurnID { idSeq++; return turn.TurnID(fmt.Sprintf("turn-%d-%d", idBase, idSeq)) }} - nextInputID := func() run.InputID { idSeq++; return run.InputID(fmt.Sprintf("in-%d-%d", idBase, idSeq)) } - fmt.Printf("session %s — log at %s\n", sid, store.LogPath(sid)) - if disposed > 0 { - fmt.Printf("takeover: %d executing target disposed\n", disposed) + if s.Recovered > 0 { + fmt.Printf("takeover: %d executing target disposed\n", s.Recovered) } - settled := make(chan settleResult, 8) - inflight := 0 - spawn := func(fn func() (turn.TurnResponse, error)) { - inflight++ - go func() { - resp, err := fn() - settled <- settleResult{resp: resp, err: err} - }() - } + // 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 - // A restart lands here with the interrupted Turn still active: resume it. - tsurf, err := m.TurnSurface(ctx, sid) + status, err := s.Status(ctx) if err != nil { return err } - if v, ok := tsurf.Active(); ok { - fmt.Printf("resuming turn %s\n", v.TurnID) - req := turn.TurnRequest{Ref: turn.TurnRef{SessionID: sid, TurnID: v.TurnID}} - spawn(func() (turn.TurnResponse, error) { return m.Coordinator.Resume(ctx, req) }) - } - for _, v := range tsurf.Turns { - if v.Status == turn.TurnAttemptFailed { - fmt.Printf("turn %s failed; /retry to retry it\n", v.TurnID) - } - } - - handle := func(res settleResult, allowBacklog bool) { - inflight-- - switch { - case res.skip: - case res.err == nil: - fmt.Printf("turn %s: %s (%s)\n", res.resp.Ref.TurnID, res.resp.Status, res.resp.Disposition) - if text := lastAssistantText(ctx, m, sid); text != "" { - fmt.Println(text) - } - case errors.Is(res.err, loop.ErrRunAlreadyRunning): - fmt.Println("steer: input delivered into the running turn") - default: - fmt.Fprintln(os.Stderr, "error:", res.err) - } - if !allowBacklog || inflight > 0 { - return - } - // Drain the backlog: submitted, undelivered inputs open the next Turn. - chat, err := m.ChatlogSurface(ctx, sid) - if err != nil || len(chat.SubmittedInputs()) == 0 { - return - } - inflight++ + if status.Active != "" { + fmt.Printf("resuming turn %s\n", status.Active) + wg.Add(1) go func() { - resp, ok, err := driver.OnTurnSettled(ctx, sid) - settled <- settleResult{resp: resp, err: err, skip: err == nil && !ok} + defer wg.Done() + results, _, err := s.Resume(driveCtx) + report(results, err) }() } - - lines := make(chan string) - go func() { - scanner := bufio.NewScanner(os.Stdin) - for scanner.Scan() { - lines <- scanner.Text() - } - close(lines) - }() - - for { - select { - case res := <-settled: - handle(res, true) - case line, ok := <-lines: - if !ok { - line = "/quit" - } - switch line = strings.TrimSpace(line); { - case line == "": - case line == "/quit": - for inflight > 0 { - select { - case res := <-settled: - handle(res, false) - case <-time.After(60 * time.Second): - fmt.Fprintln(os.Stderr, "timed out waiting for the running turn") - inflight = 0 - } - } - return m.Close(ctx) - case line == "/log": - fmt.Println(store.LogPath(sid)) - case line == "/retry": - tsurf, err := m.TurnSurface(ctx, sid) - if err != nil { - fmt.Fprintln(os.Stderr, "error:", err) - continue - } - retried := false - for id, v := range tsurf.Turns { - if v.Status == turn.TurnAttemptFailed { - req := turn.RetryRequest{Ref: turn.TurnRef{SessionID: sid, TurnID: id}, Reason: "cli retry"} - spawn(func() (turn.TurnResponse, error) { return m.Coordinator.Retry(ctx, req) }) - retried = true - break - } - } - if !retried { + 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 } - case strings.HasPrefix(line, "/"): - fmt.Println("commands: /quit /log /retry") - default: - in, err := m.SubmitInput(ctx, sid, nextInputID(), line) - if err != nil { - fmt.Fprintln(os.Stderr, "error:", err) - continue - } - // Send routes by itself: Deliver into the active Turn (steer), - // or Start a new one. An input the running Turn cannot accept - // stays submitted and the backlog drain picks it up. - spawn(func() (turn.TurnResponse, error) { return driver.Send(ctx, sid, []run.AgentInput{in}) }) - } + report(results, err) + }() + case strings.HasPrefix(line, "/"): + fmt.Println("commands: /quit /log /retry") + 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) } -type settleResult struct { - resp turn.TurnResponse - err error - skip bool -} - -func lastAssistantText(ctx context.Context, m *ref.Memory, sid session.SessionID) string { - chat, err := m.ChatlogSurface(ctx, sid) - if err != nil { - return "" - } - for i := len(chat.EntryOrder) - 1; i >= 0; i-- { - e := chat.EntryOrder[i] - if e.Kind != chatlog.EntryAssistant { +func report(results []ref.Result, err error) { + for _, r := range results { + if r.Disposition == turn.ResumeAlreadyDriving { + fmt.Println("steer: input delivered into the running turn") continue } - var b strings.Builder - for _, part := range chat.Assistants[chatlog.AssistantID(e.ID)].Parts { - if t, ok := part.(chatlog.TextPart); ok { - b.WriteString(t.Text) - } + 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 b.String() } - return "" + return s.Close(ctx) } // printSink surfaces tool activity while a Turn runs (observation only). @@ -251,27 +178,17 @@ func (printSink) Emit(_ context.Context, e loop.Event) error { return nil } -// --- binding ------------------------------------------------------------------- +// --- agent ------------------------------------------------------------------- -func buildBinding(mock bool, provider, baseURL, apiKey, modelID, compat, system string) (ref.Binding, error) { +func buildAgent(mock bool, provider, baseURL, apiKey, modelID, compat, system string) (ref.Agent, error) { if mock { - tool := nowTool{} - def, err := run.FreezeToolDefinition(tool.Definition()) - if err != nil { - return ref.Binding{}, err - } - return ref.Binding{ - Public: ref.BindingPublic{Model: "mock", SystemPrompt: system, - Tools: []ref.PublicTool{{Ref: tool.Ref(), Definition: def, Policy: run.DirectExecution}}}, - Models: modelCatalog{mockModel{}}, - Tools: singleTool{tool}, - }, nil + return ref.NewAgent("mock", mockModel{}, ref.WithTool(nowTool{}), ref.WithSystemPrompt(system)) } if provider != "openai-completions" { - return ref.Binding{}, fmt.Errorf("unsupported provider %q (only openai-completions)", provider) + return nil, fmt.Errorf("unsupported provider %q (only openai-completions)", provider) } if modelID == "" { - return ref.Binding{}, errors.New("-model is required (or use -mock)") + return nil, errors.New("-model is required (or use -mock)") } if apiKey == "" { apiKey = os.Getenv("TWILIGHT_API_KEY") @@ -291,14 +208,10 @@ func buildBinding(mock bool, provider, baseURL, apiKey, modelID, compat, system case "deepseek": opts = append(opts, completions.WithDeepSeekChatCompletionsCompat()) default: - return ref.Binding{}, fmt.Errorf("unsupported compat %q (only deepseek)", compat) + 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.Binding{ - Public: ref.BindingPublic{Model: run.ModelRef(modelID), SystemPrompt: system}, - Models: modelCatalog{invoker}, - Tools: singleTool{}, - }, nil + return ref.NewAgent(run.ModelRef(modelID), invoker, ref.WithSystemPrompt(system)) } type providerModel struct{ model *sdk.Model } @@ -307,20 +220,6 @@ func (p providerModel) Generate(ctx context.Context, req sdk.Request) (sdk.Model return sdk.Generate(ctx, p.model, req) } -type modelCatalog struct{ m loop.ModelInvoker } - -func (c modelCatalog) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { return c.m, nil } - -// singleTool resolves the one built-in tool; the zero value resolves nothing. -type singleTool struct{ tool loop.ExecutableTool } - -func (c singleTool) ResolveTool(r run.ToolRef) (loop.ExecutableTool, error) { - if c.tool == nil || c.tool.Ref() != r { - return nil, fmt.Errorf("unknown tool %q", r) - } - return c.tool, nil -} - // 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. diff --git a/docs/design/agent-reference-assembly.md b/docs/design/agent-reference-assembly.md index fc702e1..5268cf9 100644 --- a/docs/design/agent-reference-assembly.md +++ b/docs/design/agent-reference-assembly.md @@ -1,57 +1,66 @@ # Twilight Agent 参考组装 -状态:设计草案。`agent/ref` 已实现 ExecutionBinding、ContextPlanner、Memory 组装与 SessionDriver。与 [Run](agent-run.md)、[Turn](agent-turn.md)、[Chatlog](agent-session-chatlog.md) 冲突时以各正式规范为准。 +状态:设计草案。`agent/ref` 已实现 Agent 配置面(Profile)、ContextPlanner、Memory 组装、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 的四处组装:ExecutionBinding 公开字段、Planner、用户正文在 Chatlog Input 与 Run AgentInput 上的同一份 payload、session 作用域的输入路由(SessionDriver)。 +本文规定 Memory 参考 agent 的五处组装:Agent 配置面(Profile 公开字段与 digest 边界)、Planner、用户正文在 Chatlog Input 与 Run AgentInput 上的同一份 payload、session 作用域的输入路由(SessionDriver)、宿主对象(Session)。 -## 1. ExecutionBinding +## 1. Agent 与 Profile -Session 保存 `ExecutionBindingRef{ID, Digest}`。Digest 覆盖公开配置: +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 BindingPublic struct { +type Profile struct { SchemaVersion uint16 // 1 Model run.ModelRef Tools []PublicTool // ToolSpec 与 Request.Tools 都由此派生 Streaming bool - PlannerID string // "twilight/turn/planner/context-v1" - SystemPrompt string + SystemPrompt string // 在 digest 之外 } ``` -**REF-BND-1** `Digest = Digest("twilight/turn/binding", canonical(BindingPublic))`。 +**REF-BND-1** `Digest = Digest("twilight/ref/profile", canonical(Profile 去除 SystemPrompt))`。digest 只覆盖影响重放正确性的字段(SchemaVersion、Model、Tools、Streaming);SystemPrompt 是调优文本,修改它不得使可恢复的 Turn 无法 Resolve。 -**REF-BND-2** `Resolve(ref)` 在 Digest 匹配时返回 RunDriver:公开配置、ModelCatalog、ToolCatalog、Planner。同一 Run 内同一 ModelRef 的解析语义保持等价(RUN-LOP-7)。 +**REF-BND-2** `Agents.Register(id, agent)` 在注册时构建 driver 并返回 `ProfileRef`;`Resolve(ref)` 在 Digest 与注册 agent 的当前 Profile 匹配时返回该注册的 driver。同一注册的所有 drive 共享一个 Loop 实例,因此同一 Run 的第二个本地驱动者确定地得到 `turn.ErrAlreadyDriving`(TRN-DRV-1),而非与首个驱动者并发驱动。同一 Run 内同一 ModelRef 的解析语义保持等价(RUN-LOP-7)。 -**REF-BND-3** 参考 Planner 的 `RequestPlan.Model` 等于 `BindingPublic.Model`。 +**REF-BND-3** 参考 Planner 的 `RequestPlan.Model` 等于 `Profile.Model`。 ## 2. Planner -Planner ID:`twilight/turn/planner/context-v1`。 +参考 Planner 为 context-v1;装配只有这一个 Planner,Profile 不记录 Planner 标识(第二个 Planner 出现时随 Planner 注册表重新引入)。 ```go -func Plan(ctx context.Context, hint run.PlanningHint, fold []chatlog.Entry, pub BindingPublic) (loop.RequestPlan, error) +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. `pub.SystemPrompt` 非空时一条 system message; +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 = pub.Model`;`Request.Tools` 与 `Tools`(ToolSpec:Ref、DefinitionDigest、Policy)都由 `pub.Tools` 派生,顺序一致;`InputIDs` 为本次消费的 PendingInput IDs。`PlanningToken` 随 fold 的 Entry digest 序列或 Binding Digest 变化。 +**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 转换。 @@ -68,27 +77,27 @@ 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。 +**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 是 Turn 作用域的:Turn 结束即返回。参考组装提供一个 session 作用域的 `SessionDriver`,把用户输入按当前状态路由到 Deliver 或 Start,并在 Turn 结算后自动开启下一个 Turn。它只组合 Coordinator 与两个投影,没有自己的持久状态,不进入 turn 或 run 协议。 +Coordinator 是 Turn 作用域的:Turn 结束即返回。参考组装提供一个 session 作用域的 `SessionDriver`,把用户输入按当前状态路由到 Deliver 或 Start,并在 Turn 结算后开启下一个 Turn。它只组合 Coordinator 与两个投影,没有自己的持久状态,不进入 turn 或 run 协议。 ```go type SessionDriver struct { Coordinator turn.Service - Writers extension.Writers // 读投影经 Writer.Projections() - Binding turn.ExecutionBindingRef // 新 Turn 使用的执行绑定 + Writers extension.Writers // 读投影经 Writer.Projections() + Profile turn.ProfileRef // 新 Turn 使用的 Agent Profile Companion turn.CompanionVersion - NewTurnID func() turn.TurnID + 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-1** `Send` 先读 `twilight/turn/surface`:存在 `active` 的 Turn 时调用 `Deliver`,输入进入该 Run 的下一步;否则以 `NewTurnID()`、`Binding`、`Companion` 调用 `Start`。这对应 inbox 模型中"steer 在运行中注入下一步、在空闲时开启新 turn"的行为。输入在两种情形下都已由 Application 先写入 `input_submitted`。 +**REF-DRV-1** `Send` 先读 `twilight/turn/surface`:存在 `active` 的 Turn 时调用 `Deliver`,输入进入该 Run 的下一步;否则以 `NewTurnID()`、`Profile`、`Companion` 调用 `Start`。这对应 inbox 模型中"steer 在运行中注入下一步、在空闲时开启新 turn"的行为。输入在两种情形下都已由 Application 先写入 `input_submitted`。 **REF-DRV-2** `OnTurnSettled` 在 Turn 进入 `completed`、`failed`、`stopped` 或 `superseded` 后调用:读 `twilight/chatlog/surface`,若存在 `submitted` 且未 delivered 的输入,按 `input_submitted` 的 stream 顺序取全部,`Start` 新 Turn 并返回;否则返回 `false`。这对应 inbox 模型的 `next-turn` 列表:已提交而未投递的输入就是该列表,不需要另一份持久结构。 @@ -96,7 +105,27 @@ func (d *SessionDriver) OnTurnSettled(ctx, sid session.SessionID) (turn.TurnResp **REF-DRV-4** 崩溃恢复:`SessionDriver` 从两个投影重建。对每个 session,先经 `Writers` 取得 Writer(新 Epoch),调用 `Runtime.RecoverInterrupted` 处置全部 Executing 目标(RUN-CMT-7),再按 TRN-REC-1 处理 `active` 与 `attempt_failed` 的 Turn;没有未结算 Turn 时调用 `OnTurnSettled` 消费积压的输入。 -## 5. Memory 组成 +## 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-1)并阻塞到结算:首个 `Result` 是输入落入的 Turn,其后是本次调用在结算后从积压开启并结算的 Turn(REF-DRV-2 的循环,内化在宿主里)。`Disposition` 为 `already_driving` 时该输入由运行中的驱动者推进,本次调用不再排空。`Reply` 为该 Turn 最后一条 assistant 的 TextPart 拼接,仅在 `finished` 时读取。 + +**REF-SES-3** 并发 `Send` 安全:写入由该 Session 的 Writer 串行化。路由竞态(两个 Send 同时判定 Start,或投递瞬间结算)表现为 `turn.ErrConflict`,宿主重试路由;重试前发现输入已被其他驱动者投递时,返回 `already_driving` 的 `Result`(该 Turn 在取走它的调用里结算与报告)。 + +## 6. Memory 组成 ```text sessionStore = session.NewMemoryStore() // Create、Header、Open、Read(SES 第 4 至 6 节) @@ -105,28 +134,29 @@ 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 可选,参考装配不注入 -drivers = Resolve(ExecutionBindingRef) -> loop.New(models, tools, contextPlanner, policy, pub.Streaming) -coordinator = turn.Coordinator{Writers: writers, Runtime: runtime, Bindings: drivers} -session = SessionDriver{Coordinator: coordinator, Writers: writers, Binding: bindingRef, Companion: turn.CompanionV1Version, NewTurnID: ...} +agents = Agents.Register(id, agent) -> driver = loop.New(agent, agent, contextPlanner, policy, profile.Streaming) // 每注册一个 Loop +coordinator = turn.Coordinator{Writers: writers, Runtime: runtime, Profiles: agents} +driver = SessionDriver{Coordinator: coordinator, Writers: writers, Profile: profileRef, Companion: turn.CompanionV1Version} +host = Memory.OpenSession(sid, {Profile: profileRef}) // ref.Session input_submitted -session.Send // 无 active Turn → coordinator.Start +driver.Send // 无 active Turn → coordinator.Start 组 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; session.Send // 有 active Turn → coordinator.Deliver + 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 -session.OnTurnSettled // 有积压的 submitted 输入 → 开下一个 Turn +driver.OnTurnSettled // 有积压的 submitted 输入 → 开下一个 Turn(Session.Send 内化了这一步) +``` 进程重启:writers.Writer(sid) 以新 Epoch 打开 → runtime.RecoverInterrupted(sid) → 各 Turn Resume(REF-DRV-4) -``` 每一行"组"是一次 `Writer.Commit`,落为 stream 中 CommitID 相同、Index 连续的若干行(SES-APP-1)。 diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md index e7271a3..a4f4dab 100644 --- a/docs/design/agent-runtime-refactor.md +++ b/docs/design/agent-runtime-refactor.md @@ -14,7 +14,7 @@ | 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)(草案) | -| 参考组装(Binding / Planner / Input) | [agent-reference-assembly.md](agent-reference-assembly.md)(草案) | +| 参考组装(Agent/Profile / Planner / Input / Session 宿主) | [agent-reference-assembly.md](agent-reference-assembly.md)(草案) | ## 1. 背景 @@ -105,7 +105,7 @@ run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定 | `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`(ExecutionBinding、ContextPlanner、Memory 组装、SessionDriver、崩溃恢复 example) | 完成,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 为参数 | diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md index eb45a03..576df29 100644 --- a/docs/design/agent-turn.md +++ b/docs/design/agent-turn.md @@ -16,7 +16,7 @@ Run 完成一个 Turn 的一次 attempt。同一 Turn 至多一个非终态 R | 回合存在、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 | binding、driver、retry、context 策略、产品策略 | +| 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`,只要求存在。 @@ -34,16 +34,16 @@ subagent 使用独立 Session 与独立 Turn。 **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 决策与并发。Coordinator 按 persisted binding 解析 driver。参考 Planner 每次 Plan 使用 Binding 的 `ModelRef`。 +**TRN-SCP-5** Application 管理 model、provider、tool、prompt、token、approval、queue、retry 决策与并发。Coordinator 按 persisted profile 解析 driver。参考 Planner 每次 Plan 使用 Profile 的 `ModelRef`。 -**TRN-SCP-6** Start 之前建立 immutable execution binding。Session 保存 `ID` 与 `Digest`。密钥与 client 留在进程内。Resolve 失败返回 `binding_unavailable`。公开字段见 [参考组装](agent-reference-assembly.md)。 +**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 ExecutionBindingRef struct { ID ExecutionBindingID; Digest es.Digest } +type ProfileRef struct { ID ProfileID; Digest es.Digest } type CompanionVersion string type Settlement string @@ -56,7 +56,7 @@ const ( type StartedPayload struct { TurnID TurnID InputIDs []chatlog.InputID - ExecutionBinding ExecutionBindingRef + Profile ProfileRef Companion CompanionVersion } type CompletedPayload struct { @@ -75,9 +75,9 @@ type SupersededPayload struct { } ``` -**TRN-ID-1** `TurnRef`、RunID、binding ID、CompanionVersion、InputID 与 digest 非空且稳定。 +**TRN-ID-1** `TurnRef`、RunID、profile ID、CompanionVersion、InputID 与 digest 非空且稳定。 -**TRN-ID-2** `PlanDigest = Digest("twilight/turn/plan", TurnID, ExecutionBinding.Digest, Companion, ordered InputIDs)`。PlanDigest 只参与 TRN-ID-3 的派生,不落盘:`started` payload 的每个字段都是它的 preimage 成员,落盘该 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` 中。 @@ -120,7 +120,7 @@ type TurnView struct { TurnID TurnID Status TurnStatus InputIDs []chatlog.InputID // started 的初始输入,加此后经 Deliver 进入任一 attempt 的输入,按 accepted 顺序去重 - ExecutionBinding ExecutionBindingRef + Profile ProfileRef Attempts []AttemptView // 按 Attempt 递增 ActiveRun run.RunID // Status=active 时非空 ReplacementTurnID TurnID @@ -139,11 +139,11 @@ UI 按 `TurnID` 连接 `twilight/chatlog/surface` 的条目,按 `RunID` 连接 type Coordinator struct { Writers extension.Writers // 每个方法按 Ref.SessionID 取 Writer:写入经 Commit,读取经 Projections() Runtime run.Runtime - Bindings ExecutionBindingRegistry + Profiles ProfileRegistry } type DriveRequest struct { Ref TurnRef; RunID run.RunID } -type RunDriver interface { Drive(context.Context, DriveRequest) error } -type ExecutionBindingRegistry interface { Resolve(ExecutionBindingRef) (RunDriver, error) } +type RunDriver interface { Drive(context.Context, DriveRequest) error } // 同一 Run 的第二个本地驱动返回 ErrAlreadyDriving +type ProfileRegistry interface { Resolve(ProfileRef) (RunDriver, error) } type Service interface { Start(context.Context, StartRequest) (TurnResponse, error) @@ -156,7 +156,7 @@ type Service interface { type StartRequest struct { Ref TurnRef Inputs []run.AgentInput // ID 为已 submitted 的 InputID,Payload 等于其 Content - ExecutionBinding ExecutionBindingRef + Profile ProfileRef Companion CompanionVersion } type DeliverRequest struct { Ref TurnRef; Inputs []run.AgentInput } // 回合中途追加输入 @@ -178,6 +178,7 @@ const ( ResumeWaitingForResponse ResumeDisposition = "waiting_for_response" ResumeWaitingForRecovery ResumeDisposition = "waiting_for_recovery" ResumeFinished ResumeDisposition = "finished" + ResumeAlreadyDriving ResumeDisposition = "already_driving" // 输入已提交,运行中的驱动者继续推进 ) ``` @@ -193,7 +194,7 @@ const ( **TRN-STR-1** StartRequest: -1. Ref、binding ref、companion version 非空; +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` 的顺序。 @@ -201,7 +202,7 @@ const ( **TRN-STR-2** Start 是一次原子 commit,顺序为: ```text -twilight/turn/started{TurnID, InputIDs, ExecutionBinding, Companion} +twilight/turn/started{TurnID, InputIDs, Profile, Companion} twilight/chatlog/input_delivered{InputIDs[0], TurnID} ... twilight/chatlog/input_delivered{InputIDs[n-1], TurnID} @@ -231,7 +232,7 @@ InputIDs 为空时 group 为 `started` 加 `created`。`created` 与 `input_acce **TRN-DLV-3** Deliver 不取消正在进行的模型调用或工具调用;要打断用 Stop。提交后,若本进程没有在驱动该 Run,Deliver 进入 Drive;已在驱动时不动,运行中的 Loop 在下一次 Load 看到 `PendingInputs`。Deliver 与该 Run 的最后一步 `SubmitModelResult` 并发时由 Writer 串行定序:输入先提交,Run 回到 `Open` 继续;结果先提交,Run 已终结,Deliver 得到 `ErrRunTerminal` 并返回 `completed`,该输入未被 delivered。 -**TRN-DRV-1** Drive 解析 binding 得到 driver,调用 `driver.Drive(ctx, {Ref, RunID})`。driver 内部为 `loop.Run(ctx, runtime, SessionID, RunID, sink)`。Drive 返回后读投影设置 `Disposition` 与 `End`:Run 终态为 `ResumeFinished`,`End` 取 surface 中该 attempt 的 `AttemptView.End`;`NeedsRecovery` 为 true 为 `ResumeWaitingForRecovery`;仅有 WaitingCalls 为 `ResumeWaitingForResponse`。 +**TRN-DRV-1** Drive 解析 profile 得到 driver,调用 `driver.Drive(ctx, {Ref, RunID})`。driver 内部为 `loop.Run(ctx, runtime, SessionID, RunID, sink)`。同一 Run 已有本地驱动者时 driver 返回 `ErrAlreadyDriving`,Coordinator 转为成功响应并置 `ResumeAlreadyDriving`:提交的输入由运行中的驱动者继续推进,调用方不经错误通道分辨这一情形。其余情形 Drive 返回后读投影设置 `Disposition` 与 `End`:Run 终态为 `ResumeFinished`,`End` 取 surface 中该 attempt 的 `AttemptView.End`;`NeedsRecovery` 为 true 为 `ResumeWaitingForRecovery`;仅有 WaitingCalls 为 `ResumeWaitingForResponse`。 **TRN-DRV-2** EventSink 的 `text_delta` / `reasoning_delta` 为临时观察。Waiting 由 Application 提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 后再次 Resume。 @@ -284,7 +285,7 @@ Run 事实只保存执行状态与内容 digest(RUN-WIR-4)。模型文本、 | Stop 的 Commit 返回非 sentinel 错误 | 以同一 Cancel CommandID 重放 | | Deliver 中某条输入的 Commit 返回非 sentinel 错误 | 以同一 input CommandID 重放,得到 already-applied 后继续剩余条目 | | Start 或 Retry 的 Commit 返回非 sentinel 错误 | 以同一 CommitID 重放,得到 already-applied | -| binding 缺失 | 返回 `binding_unavailable`;Turn 状态不变 | +| profile 缺失 | 返回 `profile_unavailable`;Turn 状态不变 | **TRN-REC-3** 没有跨存储的对账:Run 事实、companion 内容与 Turn 结算在同一组,`Append` 原子,要么全部可见要么全部不可见。claim 在 Append 之前建立,崩溃只可能留下孤儿 claim,由 artifact 的回收前核对释放(EXT-WRT-3、ART-RET-3)。 diff --git a/twilight-agent b/twilight-agent new file mode 100755 index 0000000000000000000000000000000000000000..2e626d8537621ebebd532b5a0b84a93c31dda317 GIT binary patch literal 13719682 zcmeFa3w)K;neV^eeMxp8NUXHdg5=7zR_v%qMs42QxOkt+sZQI|T!A18;-!U(O@N?C z@C-rD(IICV?vk{2EQ77gvyq`Mh~=>wVUws$bIjl$}GZ|{pYIK=sp2SoS6 z$N!6X@KU@7Wn~X8tgd*t!hfHPU)|rk@OEbeog%tVgy(JTM#B`YS6N4DagSKj`3>@nh9)jP3{ZtMhc=pXgRL z=aB_f-lJrA_RbX!ygNs_1$6&mbQ8P;wmT2MwTE1I6FeZgmpu7@;t#)VdZvC@OaT`! zJ@NnQ9lRc>OY=UjcV3jAaj7TpIwTkRbJ|(?$h`Zi=Pj7C@Z#{yP`A7>^&f+7^B#D} z1$RDvLtXq9`{20$$bP+lVf7>TxvfubZ=DBks@GcGe>l7a3my-x)O*S*?#_vFdz2Fh?Xz<1xQ zo?ZFK+_H+w$_HnEUG2Oeysz~Gb>Srd8z2A=%gQ`>_sv@{+qoYM?`!*9{Lan6IPv#2 z>tn*3^I*mO08Ga3%M+e;+I!y@Tr|F1v)+N5@Gl8Jx4CnxE6X0LdT?GcytX+T9C*bZ zfVS=bBLS3inFP;k?|_^QqF>rv7hZYU<<7@x&wWgI_bsf_2g&%&Yx}-~-|88cIPm7B ze9t*2{JSuI4^}LgJNu#Y;Qj8By$-zp@z>Hu#{BN~A0>ctE(gIA-?@Iotut@GrL<(G zYjupzcgyd&)cby~bN27WKK(4jwXB>}w)<=tPRnPwGiCx7M$fbIBhJ15FNfz>J2!jH zoqK~E`%U@aB7RZlETjeJg;)2RA5S)B$H6vp^4*A|KJWBO-otYp`qNqbda=K{18wHe z1zxHbvN-U)%;ZNNdT{c6^C~A-FS)Opd;RJ8R1V>i_}5>S@^>wH=;7J-UsZG!-_5IZ zOMRU09-RHq!u!5{Reth!HGnv;KDUy|x6Z44u;Rfblc!0XzOnGZ`43GlUijebX$v1& zfC8PjaKTlMpy|!zFU@}NA)Y)mxiF~=@P%LbA6L&^v#TC?aNfg9E?k5E7MS`Q#i2Hg ztufC&^yO<`U6=mVBTeHkfA|WX$#Yoy(ofCV=0uL-;eY=9Ck6hK0{=;Y{~uDIFKhb3 zENA?XHM8W;vvR6u-n;0Fx0ftfeEX7xchC9DnU#-xbK&G~-u9PszkbJz1xxO~^PAIW zetE`{k~<%ne9JvwfB5dJAGz&|^Zqh_@wIn9bob})`r_n!uP^)jpUs_d$6b#sSai*z z*$c0`rEo#ckgUFb%B~%1$NQ?T?s+Y2W_>VxOz-9%uqhYzNwG~j(6K6tL^=@Z6Gd6nm(VfMOL&rN^2&)8?% zQ=2#7@sH@taG$jYGg}XyD}4G1%7!TydB5aoJe3jQOLblPbi^_*9_PCl-|71f&hM`% z>-`|LsCRF$>=zqvGH;r?l*Ua{&AZulLF2tW*80q>;LoF}Cf*So)A~gdSRYNb<3H)^ za~(9Vh5EL?v0U`GH=y>?R=qZ7 zLI;=1j6j2(^z7@AfQwJA$rnCX37=d$zcUG+=o6+zc=qGE$8 zn=H3%6#BX4624W#W}W|i@J>6wDrlwzfm2mtj?`wEGdpN+`&9F0w)te^4U4OK%Y&J} z2xeK0L>EQ`jn#-P9ubV1M*N@=LECD?FBlQb3N+%~JI`^^oHOHQ=e_zixYsn|pN;4M zw-wN-I^AB<6%4hmH0HV(&xEte&5qc+V>~Z6;fD5Lz-foxi6>MpvHgDkFxno(j}A@l zG>bNQG)d%*{e6AUM1xjq;xnHwe$r<-d~w?A2Os`xp~3yX(U@JLy^oXHDI62~Jv(F? zbKiaJSRUt4P4!fjuQJx*+(7RSSB|vTZOhufq1ISuwp`u3q0$5z>OUyBzAR+U2xluo zpDwi)MK5pOAetq^{!;2-e*U(H_s^2}$H#xBBd-Je>*Ftastt?_ojP=#*oGo-FQyG8 zgW3={yy60Fs4$k(hN6gRR9kWG8?vq5AI5@)ehVBfGgi+E+Lj1^)&Xn%T^u<{?YY-$ z&zl!%&#BbG{Sh#B{$>-IqI&%{i3feYNvuy-_A=;JQ>coT5u0B3pn$EQy^v>D3r zs}yVfKXIsiKCfnbH2Q}G8mYg}1g#?lfsCGsqeP<-{W>c;*TiEBO+3|XJz8K=dnS!E zvuZgH`IMQ}cUQQ#?^t+o-`|CL`>qKuRy`+ZXE$;p-kum;vh(!aR^!ZhmXt0~>Otu>Doi|}>By1Wx zf@!Vufc2zgYN87ZxFdAEIm68AdM=pXewT}T_Z=p#cAP9Q@e{=+ehM3N>J}5PqQ29> zECyz78Zd7#@qNIXWFieWTWJk94>OJRrgo?L?KFMgS!?B=o^IlL*P-Xb?NV{ z^&Pc^`JK-d=U1hNTHB}F@rrb_EBX}^$NuE!hRl)9VDNDLx!R}ejlHhkn1-%mJH8#7 z)S8r@%Ac*=&_TOvu?JCjJ&GO^uYD^O{r(j@E_f=x1%4?o!Jeu#Gb_)857$S`5p?0< zs&gw>R|)1Fc6<*oHos86p>wVsZ>H>C2VM|(q1Jm7;KlB;fbe8VYdR$v8tWIOciGU(HNjfr<%kDa_5 zT7!E#{SymDT5qGyj_d7s=QVcRX`j~~)!#)q!R`jPj=L#C{l)0y118jv2Ojn5Zu`2N z_Jtc{FN@*!j0c z!ApVG&fwQ~zH)_mbK8vT8c(F!J1c{AN8hWp*8fY?THlT?@_mEZr>ymbW6av@#gWF? z&~NSR;9c^!g5ATnZzO&DCB7A3%Ra~c%4+G{V)DD5b8YzzA7DeCuz6m{bDQUO{%4Zx z$&=<6o6GpF2Ml3mR))n1n^U5uWJ4Sj59w*L+GG5MQp{0Ef_45>ScV>BXgsaxj= z)ycPlPo$^+TK3HS2X`osOte-Hc>e`5IaKLox{@0m&dX7q9sZy#mi z*IH(M^m6q4C_9dvtp7uadGnjUE{uPZ{;-4ehbj0)Vt+_~IR3x-0iAKu?+4@lIrhBR zIQG0HM}2(xfIY_+iicbq6K>u23G632Nj{42pWt8iw8C?tnyRVIFEB>HPU~IJ)UDfM zdVkP?&Fi9kCkH8}5h_0+)67B-VV^=hTgI4KWASIUp_gPw>aimsbb4)uIdjXozF8^8 z-aR+{za4$;OUC%`t3d;js(Y4Qs`YvQ#LbEciT@y!;We*LMV$IX!W75MUc?zA_Z_d);t8uvZ- z)f{ks}tX%YrGwQ6h4Y#$2&4j{2zGk zzWe2ey64)9^Tn17tJD@QYXrF$mgM;2I-o=NoW zp%G~fcACcIsXc}8`zhq|H2zH56=uC{nd{r3VMi@vZI4zb@SD1!=}F+9LT=v%c1O_O z*?GMkUs3M#pVR*8%k7pEygzQjJu&3}@ewHv-M~3mZTewd!M9~Dz1Nz(H#D{nuiZIZ z?eqFAooo87pWYGfA4;J2Fuq?H3apQOGPNN%+vIn{^L_Z0r=xwd{C=nzZ@2Uz14Zzj z%AEq&w2A>dYd)*@LwY*i@0;~iV10$-%N&2naSzAc9ADy)ocj9bl5^mQ{^|T6QFjl- zKhlOs_gvAhd+ZzFy)4ssPq|s|_$lQ#G-{mplBqj-b;-W(W|S=Zu74hxYK|=K>wEEr zY3ALE;u*V1onC9hCxs*N5hu{+Q|)-^3_CvU9y>n0#*WWeY{%CH1M9O=O-t@&R?FB? zftK;P!4|c@ys0o=xQg$dwc|@DlRezD}Z)n7DIY&-F;}dKK3byz4bw zPxY?Xay`SlUeEPC-u1Iw*Lc_elk3Ia^+v8&dDq|N`dO~6x}y$WTdcU>Kb1k#LL6kh z%b&##e@e%{_y=qKcj;eGwL2$Qzq{kLQ^Q5$%lc`219I@_(BX~c!M!&**EbArtl&Dj zfAx*+D;jQ${#~H49lurMmT#Z1)|Z>!DboFtQDk#b$1j^Vd7d zxIO*EjqSy&Zj5$Y+}9lyZr}JYSWi2!h@%C3ST8)<9dKcds=KNE>Bn!3uCN?EAz0NP z2J0CQ*7)E6tXqOEtcU7vY`?tjrfB8>tiSj$SkHMh8$ZIO+3%L$xPQm0qq(bE&vwi| zbhbDG&3+f`jqV|q;L+?R51z&hejh}=J}Cdru>7p7x}#ZH8_%jw%BzCAUH-VOpFcha ze=NNKe>m~hB>ysZt=$q>Z@1K%Ku-mFpc$Q%%{h3ry(Eu=@!9-M*gg4x*&K&Pnf1P} z=GN=RCi~@|x*hqyNd5EB^NI2w#jZ%7E7lU0ox@&LVi%&=6zScl7qESwHzl`ty zO-vU(KGQF&QS3`Qc0GoDQw;V?QQ`>LkLo&mceTlwqHzh8{Xs+?A z;+l##Ord_g%ULt6rSo!NM1oC<4~TBF(ZREcxt_q!5Dv3}KPC`ZulsSAo0d5>IlYQ8 z2>)M0dmpcy_k>sJ`FLSpWAH=yNIQO`@RF?;UfjpvheW(G`td4l>BN>^5HI`yAFo~u zytuy*UgF)@aC{4N>(@M9i4C{o&m{1R-(P9OkuKC<9rz}jjd?L@<8zefHg5fzS?|Ov zq3!1wBF?cQRNRmm zDejp;45sQ_(Xr}t!yD_*-F&Pbf2lrW@2|QTw{N9f7d*c?>%fLgY_MVu^-EuF$_tqn zOMdqCV_nd0J9hEl(oj?V(zd3~fO%2AN;Ce<3iLpS_yBqro4voP0%mR<{s(ZG0e3jJ z-sIbVj(=h2;ui*5U;VAyA06;OCw);7Ik3T(+fUK=195Pd2mjdny2UxYil;hb5O`2^ zD7JGmc*fw1PUyFy&d!h0rq?58z4)&h{v&R7U5x(kq;KD$zx?Z!iz|B{rO&hA!&5vD zF^<+Z@*67nM*Sh)&^ae!#~)DHp{6A(V&Zw}idXq@eYZWM;cL;@;T7UE;+11@Xo9b! z_zZqYziyF?`}{KR6XKWA{qmhR(#{Xl4)pB&eei(8F|Dj2gB!q4)f z(M9=^?F#s(qTabaQUU*He6cCbrF$jyE~j4M_DPS{%)e1g6j~D-q#t7Prx{OVQ-@$% zz%7AZ+3-2K=~ySczA|f{!|MfxaT2j`(Lpvcws>5xO9!jxUhKs*U~D%j4XM=Ux98*s zeb=5`Jg4{fP%0jJ#Z1Uw4CL-tE}Y+0P= z@NpUAU*S;=tP&Gy2zz+Ufet6YV>_|9W8jfH%EiOg-zB}W70aNH#{RY5`7zGPymxH^ z@6DziKEF+L@)-mEb&!7EA2z%C?JPPqxXR8CTKLWyAMKdvjO9jl!OO^;Y;Fu5`|~_} zZe@6Cf@eY9(eosEq8p?44+vY}b8#eYV$=LG<;W1R3<{@ftFgD1Yt9n&eF5 z%TDBs*n6D*Uw2}^+4;)Sa8tb*duZi9nswXY9rWvJ(Y=1JwN81hE_@T>^GEb-t4Ucc zo)y1#hRm)`{F9vZrOvqS3G`_<`n3C8@v&~~N-Pp?(l`F`iU~AKz<$rbp1*?4>_X0S zuyJ38wnfm~jg^O6ZD?M8E_F%=^;EB))^dV29>@qcbs_hHwF$ne0!}f0z^0n&Uhzz< z-v@Ge>3kY`&t>yGJ+{fGA$n}5M?;@}E#zQ67(TLJe?_Mw^S405yF40RKS0ASY>DW2 z(4ph_Lr*}*6VUNM0v*$!qx=JM2M!$%nAFwL>G0hx(DE+y8a!4Hzjo43InS21bffQA zLC?sg9Pbw&J4v6t9El8~=QNYrFcCV;gq|m$=L!1ve0ruh^vn`HsZ0LU0sJ`m8twF< zau0v_7xXLdDpuj^K;vbhKvRtRMQa;9;M3Z!sem4uBQT>^@~ZJYF$$+#8@%#3I&b{O z(v~*xZi5d_pxa-A7f#X7saK>8!u=$DI0;-<2QsE~-eBV0$Y&Nfo%^*V8~3K{)iUgr zBP%!9aplnb>ye5X0%iGleeil1g>xqZ=&Ead#NwLV6hzd}4& z^sIuOE3iX_$p1sao!Eel9TKkX`>mZ;KW=Uu-yhffqmM_De9Uv=8Rz@&uK&E0F>q$9 z#?l*8i02|7;+2)u-9YToU>toP++W#zV**-GW;hBT@ z1T*L>>5aKHb9%dwg=t%-@jk8THN8U*%;UM@mEH8^m(k(UC(9fip6c*_J22&!cA$Ud zQ%UE~OBHV1gPU~AKhsA1j4s{tL}?w*EndZ=XuaI zYq-@Cf}WzU`l1k9Uxts+j*nT0e;~Wy@`3AP`Luq_qxEwa=7K-W(AKN<|T8tmOh^o#Vd`m}P5$tRZM$gXJSl&5T>{rfET&nXK% zHyiU}CUJmvzSX!v@%2jZdKq1i*rrLeN#B(7jxqYta&UHO>gxGi_+^EOIAdkmQuYqcco_=mYeWu`TOG8j$-u35VK2Tfjrq+ z+I)h(p6=UPWa}h4_9S*VbZNM$6FnfA_K#Q4bH0su1zXvLe4KA1T$>yHL+TWVpTS%C zfMR%9vRDi)_gfiL67 zy0?9J-8%G{#}BTICCi)d18n@?1A6w!g#LBMFX#7f0Dglq#$2-)Y<= zzK{&ZfG>vAr7KMuVxXk`t0|>oo=zUzU*v+l+nmX4)L|G-@_iS&80j;W#`Y=CoN8VWmLbv86dx(lQ#1Dc6_>kH@Bgd zIf`k}rtJCzgu8<>ZX6LH|Gx@dPw+;;BYDiFQ=r z=We7ebszNY`Y60R;q~R^rcru&1$-;txz^)j#VumUJTz=+M}FjEDb~2}_kGXU#A{=e z6<-#j-{fzsP@V68be%rmeB<}7j(P;X@%iNxZAtV8{)~B7IphmEWQ&o1(KTChb9$}D zcfM|U<4bQ0l!{)!wp9pjsvYmS06)6$2gcKqfvVuG`CG8MV>g-iD}mGjQ+U(dH4YIe5|b!m_#{D#`x+wp%&!0)vW z!S9Dj_+=Z@k6Yz%yQPYI;aln8w!cZ&u^Ar|H)yB6dGBA{K9^5w68`$>=gg-~@c-#= z`4LgtRf}(=_T)gXqPp?q-?Dr0r5ZDt7ci9AICOlse8p|~QE2VxCww*0wmg`gFTPP; ze-ORkmwN>hUU6am*#J!2g_&4h_#XoQpM$%6p_Ry*{2+IX9B6vZj6ZY|KPbgwemmn} z;h^!Od^CT2tmos$af~mv0e2hY)K%%F4cqXcPK+EJuUJtt?xk$v|C>U=CSq~@ z{zbqt`2n>Fng;NZH0BXZb(7yeX-+(9pnssllC5ELZ3z9ka&*yZ&F_#sI*2dW4V*4w z1)A$Iegb0)Xx5z-Zc?n$$3L>qTDJo|B_B|@7q0om7I6v+PnXk$G3l?{_pj1)|vkA`4jbj@BhR7-}|TN|C7H-=zlMc_ET(+ zpAXo35q;IdJl(|gF3uPUUG?wP4RbE88!pNlg`u>jI!^`?^_9;{(h*%jvnx?I>_E2s z_d1^#8EHaSzAzVG{t3nbPY`#={DZYVH`BDBFC2cU2t}GY(7)KEhAMQ6|9vH6l4QM+ zh=+93pnKU-kH%k5pz(JSXzc5oQ%Uh}>6`kLKTh9tGfwe!PNKf?bq#Bd2GK&}P0>R6 z(3Q}li}B?@lSj-YE{FaZ#D7+paUMSPGH4?GRgc~hT`s1JmP4xnUF6OIN!CTB7vRM! z6X>)XI{EZT&R;6e?C>CC=_X=iV~M#6pC{1A%;7upI{xP>^zXbZbL2E*t{(cP5Phv2 z^$GmFj$(X1V5t4o_$>#prGCypvB&>)saY@mp7^YkxPxNaos3uNue9T{J^h{{+c?<} zuLw3MuMjIn|Kn%M9##?KCHL95?c9uG&BV%b@Jn{!KWADg^Ruz#6nd+S=wpBUC-fyYyp9lxFS9Jhw`ECZJ{^XB#5Rx{6$#XVMfPdjs| z%J_EZ|L&W0IykgdKBw;Y0_&9#Lz=c@Uk@?{Q6DU;ncXX$zK=dQL3@;k5l!dOo{h{& zNXGBuw88O7Jz3gvc0gZUEPwf$GnZ&!Jf^XT#$uiHxzibLQjc z%p5zkX_6IgxHUZB13d51v)QEgSkPE8pB{WO<~_wv!;^0QWEivxwT^*SHhu4pfi!nP zb0M7mXDrfncvJnW<7ISh4*8>T_;*G4VMX|1Gtn<8pH6Ff6&du)=^P4s za)zF%N6&0U=e|t+xtE!iEuPNJ4l_T4I;C?f6XNjGFRF80dG?9iLBgTBQ?CB2QYklVXKe%lDgr&zA;2MaHHy zsZZ08CAE7ZywZZtC*3q2-cWAs6f_$9$&@C(9Ymu8Uf+3h1Fj9to87y_qfv0)*g?8D z`#fDd{XeXWW5nWwvoB{p{a!sYAZw!EF8ED;vi~Q3tNLAu`t49G#q26$jIx5b&tAqi zW$3$H^j$eRqKt9IM8>>jd(D{^^n$xiMRTu1t&GD*=za$0V)S()?>PK}dQS& zy8xa1Z$s$cFft~c+d)j9c+cgk_t7om8|P%&ExNAhD~YFpLz_uEl(xfenP`O%b(HVhug?S#;s}#|}27jj-brE$h&gk42g$Jof4!TuZGK2Up=x zZKd{KFS8>3*FJtuoA%PCy|f8_n82KQc0D?@3_2W4;xFI+Grt-Cb3i^5=LK{WTlsa) z{G}@zAO0`K$vs%eQ+sh?zBQ7(4LYzO%bbbf2NYhtY(s&`ICBzSj3TSK^tI*?`SXW5 z8A~r`U8-*0AM|p=8?mDRWyPoW5Otr$!XZ~NmKQl2& z-}JZVX|ts0#^PgHS;W}EO>_D+k7>-$iP3_GbYvy@DD`3D+>{06!n2qMq?l&xX?qDd zxh0)^$DFEb@jDjPAageGWCu!_yQO(NJCMsl=5nz%>Tniy^6e7oQ_)MYh&=S#@`&m0 z=T7pBC5)GeYd2PA6t-j%<2?mGUX(U_FKzq@`Pe~zU)`mR2YDv?I=bc>vqXCOdp@nM zv6uWXfd~BlZ@I{PL-}zUlPd=-m>Qc;lRpOC2K05<(br)sg4NKZ5>_Sd$J7!S-wIB=&!`V9WNiW6y}w^uaBj{hwhC9hB=)oUsc%d4j%jd^F&) zr`oQQ_!&9)2tD|Z>DYob_z7$96W)SfmZuHrr?Kj}*$i#?9C?)6$d}$bud??vKGgX+ zpCPTbDfq=kaz2`ya1yw}`}C*@tveBBy?wJ3f0h3*Xf6ijRo8iQG4L~E$bj3Y z1ODXT`7GY^gz?Q6{QhGu#?u$=zk&H=vaO{<|47?Pza`sNLyR5vdOmo z^>+r_`o;u(m3E2r??66H3y zTbO$y`PZBQ&52S!ef5}=@AqkaT?aIhOgec^?^=1z9O&Oa{{-3SC`NAa5!}8GJAFNt ze(0AQ`q!C%f_w;vI{J1Fee29Wf#15|mvZ{>#-#c+mr%Jl)$d=+{|>1Bg!w1TpI zc7*!-{P|O{JD~O5_{Qj5wOP1qA6>j!>(4bWcR=S3_K~~kPmQ|=bZ&~LbAwGMk+Toe zxsu1z&|#3yosPWR0&VV~@1Sw#-NFaGOCNVbC+YK5(!227r0pc()AWAygeDsUk6i% zuY)7j5XWXn2XFkhzFD`?h8Q>(yK!6P=9hGUdo?~7@qr`N*wy~|U7mjO=LtJD1s)X- z`PbWt)!a`$a$L~F_YkX*4k?HKL->i$W%&&r%2m1?Q>N2jsK>S5> z8%`i2-SCv+2bxFV&$;RVp9*+La|7gGsI2A^Rgbn;YyN@%oyNgC;2({HLsoFL<_R3f zA7po=Stn`xOlLeCYLYxScq0dO_}r_`l^)BylsRMAlT#~9o`EX=G>zXw;z9tGUb zOhG?rK17Hk$RQYxKY>3h8}s*X4~%_A@vR>(@a@ZMN%=Lco4-M8_3+77;FFR2i0A(v z9fXaeNAP$FY1ejl+6E*p_Fk*@4#aE4|BTId zRWn~J6g1ZV8^b>33TeTLQ9Qmv+^!PU7>-FxwLyPKJ z;}D|%LeNCdoO}A1Innt+`j_$jh;pqz-<8|AkGWu>fAZ&AxbZ~sr0=)mhqtUJPnu0% zXD&6~9%pX3%yXfZ%qvYim{HQ=#0$ayIP&{Cc4#Z>msG#<1IX2p{m9fd=(~-5KDK)? zbCs=wpP6&sK)J&~a2zBLd3O8}>K=b@l=YPUH|0;DT{}Y6lm5&JWjP-F-!-1*S zOUG=-Hcew*?>^e{oM&??h&82vPaADH9x~tGbg7wjJ9A`W8E*Vqd5a)8%1#EMkz`dm zS9Z_GeFAp#Vz{?~`*v_or4HrRBNMS*oZCi6nnp1f!q4TUg6oywKaN~p3iubZo=o^l zAD#&2I`&ud6msxWwnZL0){PzLf+xDz*I|pvb^Mq)ycceI*Jk)$coQF~%5rg6EKSc| z@#Y?graO2aw1WM9x!TxFe+IFI@|~P|u=niE&?>!Fv$eEkM^;)>8n|VXTeufG-i+$WwQ5(bP@38qkx@=Y#{;B-t7`z^5FO%g%kac{8YWm%?FRQDdkMaZ|@Y0-2;*f{y z!8ZgRqP^-@-CM!kFE={@|8Bn7W>ODhSNq$L{Ox~NyVIcsauXU+(z1;;MUKyetaSl@ zySta$qW#F5Y+ewVQ{PCR=XUp98(dq`@(Q^<#r&@WmgYD~7YQH5q_*Op7I||Zw5Ql~ zbWAmCA_BBY_Ao$O1W!76Gxj0Co(sEyCt1v6zP6PySRPe2QEs<;Ws~Jr^wO9?^OO_u zsbt>BX4Z^~#>IyHv%n{pSj@}N{blC+wvo12k{d%=ezkVvKoZ1=!;|gx5>R?HF*fp=AkUVA&7F-}SBdL3L!q2g^e4T$pOgPjmR7 zqF3>I`OFp2SMiSuj~~2Tm*#c0Tn8OBuQP9`)l&Wu{E%}Ieu&~b$bVU_`M22qdoHpc z#6*0=MElIRYst!~wa50D3};^C3i@Vc(z-BWhu%Cs*H$Ll#s#WRW0~Q|EAo--FQqy2 z)h|3A4B!B%z}D;kEB1e0h{UaN==-qi?7y|4iRAA??{ixvk_ET%Rdn?T_cz{o(XL zU#?B)OZBPm-#zKXvp?NGUs(IXv=`Iv=_X#y_%nbl4brb}Uxr%$SG?~T<$qVgXWP(! zPTUh3cN8NJ(Ain1B%g+Fitrzb=*tfDa#hyLPA-FWor@~zOW9ue%X^uBEqknSh}L^5 z4>eml1G)8WYB%dWHQ)Pga;RC@)ZaO6sasdq-}dRf%4tbIuf*>xGbuf`otNxvUW`4)^_NEPHT1MYh1&4mj3;(>Tk|5`d4&w z^6B*buV`1lEEc=@h}8*wNZBRpyngii&&}VQ-+y&B>vqboXgqqm)+W)P^qIyhnqw@w zYD~hM^lLR2LH5Qk{{`0FYuur@jS~~VCNUrRd2%h!w+|1sR6yexINSKZO>Yp_;eB*y zxJ5p^;$s@uXpQ_z=pJSr<7RBA+Ag16bk(7G@@wDedq(lHa^Q7bR^sGvCek16LrYtn zaZmbyon0G&D2cAmQkKlMw-8VUb*6&_SUYvO3 z0KGRc=OP!su8MP_?Bp_D2twbigf+s$e}O#G4-QYjC!c|5ii5Hv&=8wat~dv@E5<%< zVP0CTw?^2Pm15RR1@rcAD1sJ6`0UCzsI11?K5Z2r2@?ZVUdh=vl=`C7H9@k;wfM8M zU-yJsQ_v+ZJ7X~EYCqrN(lv|R9=~M8{ssQH%zYk2E|S+o8OC?Y5B}zN@HRfl#lAJP zRq?=Au}gl;Mq_;WmeTKuF&j4*h8~AU6~Fn=Tv+b;aal+AB4>_Xh1Z;zEHn_@35v<0 zA5JSK3$Od}*yoAG-aB#Fpcq#nF<7m?>7m{8(Fd!E!v>(|Y3y4pVO*=dOC|IDxiH`g z?+fL^cy}Rs;34$HH%#2;-Kh38^Va^A{~Xv0>Enmc3*Wdf?8%9X&oC5#IviZ^-J0o+91{M&c?o+;K+0{9P9hK`w4E@y|}Myi*RRVMBrD&uhBy2c-{ER_`2l3*?Rob*vhsHDlGeBH zfVYsBZ$cyKJ%=ZP!yLUAeJ^ziG5Qfbz`vjN{C3pmTHmmSIqi6ZcKnvH1a zeb*{p*#V8JX~zNdqjDPUcepew#O~=`rDPXKH4E7pL*P^pn0U_=&Y1+x(_CUUb*2SGoM*%)8`TIm1tqR8u^XN4<_uF?Kkea%{}%5mw&Q+|Fhy(B3*7wRwMlh3ww|)$MgLmk-uKASWieJR zXD_0d*9Nu2@nf<5B{jKIWAIQhbVK%z6hqr$H@74?Tyjylyvvcx{$(N3uAM?G5y<&Ym__8-{^8KZ>kK+%Fa@Qh!)`v67-hDBDpvBqW zVPJiR#d>P_1N+ILNRQ$dT$jrnl|1;jn&)BW;Q2Wi(UI6igJSmIgzoBlt&Lj2UX8i5 zjd?SZh`}AwbFEjJJ<4t&E_Y}P&sHuCHmzX&rmm}*3$~THV4chbJHTA9#CKYQBcDur zqQ-78yM13{73=A>H!3-o-Ojk;a^_$nm(5B0lH}oYRI@M1SoF?T_9a@D z;+Ob3Z~FV8{EU9_lsX8mb?;-fUl8gVv+Qpuz{eEvfbFZtx zX|}Of|1FhwxS+0*3X!gOrD$D_-;Jzj@LFSNhIMD~#VVEBN)y6*}z;HNg2 z-Ffh62tO4Y*)NN;)pz(Nj-T3K?-u`QZhAd@nFn98wsjIQpMJj7Tt4F6hs2xy{6SsM zPMV{b_)c{8d9f=DFQR*5)aBTJ>ef7}vEa51`#cu=EdCpd3~4Oojzt5lcQA%X#8Wxx zvG8Xpy5UOTYc8kevA8~k{M%6L0%A;#PeFZ-Pr+G!@mGPRa|{?A;IUD$DBhvV&i5(M z>xuOGwCHsW-%|d3pThN--8_kK54JY47CIU4T<}izFLIH+75MvV`xg9**@I-SWc3sH z`My7K8W{}J=3B_YYL5LJ<^ z&(=Qg$a`e14eD(A2?C`y%E#F6uK9Al1Ja&Kb7c!a_&RaN0j>^n%XZ_sgz{8On zWB%v!hrYv7hg26Euf)l8h~`4vaxSD@ov9|PMb zJ0O2F7k_jk@|^=e2jr9DOC|EplSknnd^E{`$`rx#IsHDV%Rd*gea{?8uzj9B_!fI! zIJOUc(3cQ1^zGlgq+_!l4w)kV(+r{*+Z*QwSdt01fYpdB~ zcpG!7Wq-F~Yqwx)b=}GO+mB^y;YVM;{W#>=TKMiyVQb^?!T-g!w%3<+ojqII^HH`| zGFg|c--r44*;!&+va`%xU_VyY70J$)lOOrG@Dj(>EnS{W!Ixiyw+(;&{GDW9HznFv z#z7aduTQDJur-d&WBlOT*J{tcmL%9#`Fw5gq3rD|%*{W*+DJ+Xj9+6wRg zM{VmMxxuzNYm@(Pu&p!y_h1_TAF{2ze+t{W^G*2Y-(g$#{xre1diLqL&H+7OJUt*? zpg&(9yq2U7D#n-re|xY#SnJ2uo)nIQ^?|b%RQ|SnSoz%b=m7M@3;sCpkI@M)qc7xd zE8ZfT^lA3lkWNr6*zu>STe%YHs(O5nJbaHld`^v76aDRn-b(0q0|4HQ0os9eZT~KFEqw2x#$?TX!Z1u>1VHj zX7fM)C-Jif>4MrnkuF&2`PnNI{A@jwpRG96`F?h&bsBrq^z*JqW9^NU=w}PY#r$lo zn+xD)%Qx7JUoBr-eD1G_*iH=UFUd)3O~e*rQ1a6&Dc9lo)TOXQKbDwT_ATuNd`RF#qqZXs@jw25~7UfMEoBJ042F-&5NetI%_ zCt_ds5$nIzy7JIdPK}e_e`idFIgKa$R>P@E{JX;Fyv47=29*lYu=ZL&T ze5#x>Dv$j3=fb`R948ls4R`Wqe0RTWD&>mNedp!Fs+`zEe|zs$d#jKM$`^v~hvmaM zJipI@4bI8&DnCD6LR+1D7%_fF9^ohPn2vsVgy%1q4+DSE^t0r{6vvwF<-@cmYZtuP zT}CbpeUg|9>$$}^c`7F#_7yj__1j_-f1RAz$BA#f&ivlV7l>~?KrZXDlmS_Jc-}%! zRxWd7rb$YP%{o^$grp~4<_zFi9TO?lK z#u@P&iqXA}>{y2US)iflNiTMH_h9+i@H+Byw@aHs$~f}#q`wx<%_kGPJI`;}LHGx;O^B?|-IXNE^zYCxr5`Bim>%_VrNT^%>Ly*3#s{6A> zGdgQQ=++?5{O@ytSCs%S@m|+OqjMMdu$Pq&z&!ec*YeSdSEITG4<=+=~t z_N^hltLNER&f7Roo+tJfHI# z&TBY7&-r=IZ*YEt^E;g1;rxH%d)2LaRj2AxU8+ZQM6S4U!Ne;*x8UY0u39kvipdKi zS4`smvz#X`FgN);T^Ik4)5P%G$AKd=vX9?-YqotW^AwL1otu8l%yfA zinIE@p0jAZjk7~*&Z2cUXVE%BUwssebjo}b9O1M14}C9w6s(Ve^MB%d(L!_(4OE}n z-o~LeXGs?!%Ueh1EjR)1+T0)D+UW4*)KfryJjT_R1MB@aqALx4QSZN;zt3qq^PRNM z>5CQq+MAMI>9vZh2LA@^*eH5B6&sEo@bz7eetU6*_RtFT6p~}n8cd6GfH{|WtjTO< ze(N&y)gIPk6mkw^*}HQ&nD;t=A9LFStjo#fD4?9ypStsq+&v~$M-DmTpp!Qa&UfHm zZNHSuq3;DZu*Po5<9EnZkLA_lz{O9?=C~MKAAjpy^QuH0&i$*2_lei~Ciy5Z62AKKIzHS!55io;fcp+i-&Uk%!(|x~`RR$<3B&2xaZtQJS}J#~zPf`pw8f z*5YOIdx?tIx24GtTb4O<82W_j?cP!jl~=oXek8>QC0yoMBd3Ju^HL7yd&W6}YhyDEIe{V$!F!AT9LJhx%I>D@ZpzNDV85|8);va` zsmY)$#|dl)WtLE8iOLl6TRw*05ijD{jy>UBKJW7NZXfdo3h47_YV!t@=i(C@w)aB$Cm&r`Rc9Ji-Y=`~Y5`dR-6!(Cg*}C_tqon!a9g@{ zd$vhCY;*1&J^rf56%(!!JZt0eTlG!unydvS995&oVo%22KIcbyw;!B(^@7OC?yt2& z=M?nld~(wK9__AQCbX#t`N8(?Ql2MDJ<0r3#5|e;;2Z3C8FNy^OS1XJ^mB3NH`i5R z*HcWe!CZO#);x|?YoZHU*0e7;v?jKoXHCa~30F+GeFjGj$8q3xZaqC2(hqQc)td^25Cbn z_-6uZ2lP!DVfP+KZ^1k8tQoN)oG~RBj7)(xDz|$Y`bK4EAg93*(mih3V3w{^9Qu5o z@0_yu)hc@ue{4|MtL?>p*>S4FgFQWiIRTW{I^#j*({0w|r#i4d{{qXYPi7cnb`(82k-)SFwlnUMYJX)mI%;(yrg?`JMz17bMkJ6F*cHCQTj!dt)*ZFP&dSwXT zm02zghdiKb_q!p$DAQi{!2TCt=-cVkdtaS7a&ygn?)T`GbiQx)zE9`-X7Bs-cXjQ4 zpAM{MYe-K8unr~2U$Q>ggdFsc_a9qVcQhQ>aaQs!U9l}abh~uNB-VKw+EIbr=PhF| zU=#Yzg=D|&N8@g9!xrboYmc=Nn`>hY&qTg!gWvUSb{&4Zr!!ih`<&5RZ*Tk23Fn&> zep@k7kNB`+lq+HXqk64-hX$Rgp~HeJ_$SaIZOr{dryXfyE@ydYT7xk|2lb!yrPfmw zphq?V%R+t%_L@bqvyuEfj-e*r2`%s?;#nM8ySoHir#_#7bAB}Tts2%?{?~M1Z9REb`Dmcf` z-_P*Qr=L5nbmzipT&ejSzsASXc=k2;PV_Hi&AdaKJZsSoc%tmPW>L9lvWtECp<6a~ zi1m+$tIpLvU1i`m^s(LrSnJe*jnEn@ouwnofKf=hV%%$eglJqg)ml^soU*O_UN~p} zT><(|zju-MLcQ~QcZ#!>y~OvFDd4xnY0J*s7nX0(o~iEd8_fN-AI}wEM1zCxl}~7f z{)hNJTvI)Dd&oM{#yY1b@E^OkPHF7s*>QeL;lvZo8@BP=)Z2K6ewc3~gQA!A|7fP( zLUcebztbG-d*^Jqnd|J+;LeG5?V)lQ>QB{^!WyN6)YA>D9aosOkrzMJ7@0SFY9IA< zQ%~E7)CS?8_2+&)+S70=Hl`W>rIR++U%hgJ>J`nkezTBu!FAAB_NL63Gl37@Im#Xj zM~gPD*kFTCdxBl|`7`a4imhh$uNP~-%)~RxQnu8`-$Zk*%X0kyKYm`=E4nNGQU;xGLng-6R88$>op#?+YmtpE?|aEC+6PSf z;`)@eh4Ga27V{hIomv~#hitDvCi*(fBJABP-HRVa)ulJi=C|Ls8S`Qav2uMQA4mIo zgn_ZAF8$09%IRFGb>KB)r|P--A-vXd;#h)JN4@$kd?1~DnE72hu66uOd`??jS#aAR z{8dKhm0>fbnzgARV1}rhc>Sz2e*Y?uw$+Z({JF=0Z!_Z37k`7^+)$$*ISW^9fp1J(jx5PA8B}H zuQ{T*U+U)y>QXuR<<|N_*42fZEa;~FGG!N2nU|BoVXE0LfwTD7iD?Ztlo`|d5A3Dk z#M6e`@jEGR68?OCe$vSm42%!8o)O>Y%x%yv6<+T1=j$2mcR6e6seOmDMt{xah zZ2WCt{g!g4d4Ac;=E%ci;Qev%Cv+=#*(}P2M~31Lzk!^-a{+kzJ&OV>)YD3R3;8y3 zQ=myXwGeI6_wO*?>ac>X${}3n`=9W=#uJ<9n~cDtJ1>3QI`Vno6nqMu!85@=1nl8o zvyQxTg?;Sgr4c7*=gZi|;Qj!(cchsqdo1(dVdk+qbpU7n@4t0yB8O-u7&^a>UekFZ z`&0~rhHtl`+kLJcINs&z0rA0KP&Ue1RhI{bppR40$HUkoVsQU|oHA;w>ee|M z8YX}9Q81I=&$H4FyK{n@H+(l}rifP91n2jkx@+0n^ih+4`Wq(y)aB%autV+aAyC9z za>rkJ)Xwk1SLv>0UzW?rQ{vm4{)U}DRPE)yoqN{J0E0E_lu_B_`d$3foV8%0%L!)y03X~-Co(J(x~I;nlG zuFFJjq{mfGvZFoCZ@Kc4CiR1Ull67#`3Z-w-=y2>*}GJ_?TzI51)q|A9?)m9d6oQ@ z%x1>7wf`78!}^F>wG+bM{|@y@*J)j&bl3IJB}cv=*Rsiyqr`iB%NFUUZuTjeKsn;) zhfi`J$!^<_w)a-Xqt=;j)^(r8hbv|uuI{Y14aa$w^7-ZsdanIuicHGtB6mNWGetbB zG=bJ)liFHPgV>H*bh_-3eiNE}%-Toy-`J=*zI(7~_d$y)e)CABhj!O!WVVX7bAWYe60g6e-;;yar>h?Cuit(I&?g1=VDF*1(`UsuIr5Tyk78n0 z&rJM)!o?NmOr&DWKJ4-zSkGPYP~rPoS>~Ef9^GSAcE0xP@oBF!F_!p@JiFyIZMZr6wGAOF)G#y+U1(WnZiY|Z zGD8~V7gZsL;+ZXs)9=qayrGu&&-4B^#`+qs>^H&Ilwq#jp3j)CDqP$u|8YM1+a0I> z=2P|-Vwop^A-+m1qxlE(pm{U#7Rt<;6SCLMj}#r7W2LlKohv%lfQ>xPcxMyzoCC~Z z=xX^KKh1u9Lw!19D{y%m_`aYJ*OV%1_OBFhj{QwV7=R5sI zRa@4p8&n7D#aipLUfWQ>cPfjVx9&0K!4(-RHf)8r>&;b;Z!LRKVaAWi+M6>*VXk2Cwzj}!zldRne9v;KAeB}e||y@HnJ0Kd_ZYmP*ZTk-Y|EB={tea~c%Vy?pR zLhcLWf6kic$3JbRbp6zd*9Xj$Zl1|cpTYiN+8amvH_6UQZsniqyUpNL1YVD_*Osv> z$Mmnm`DtPN{Xf*U^zFZ-QMe(SZ&nZgM*e{eGo@s7#j%aUiSceUXRgFQDHv&ef2j7X z?1T644^z(Fee7{~CK?r&VZnJ;8WO)L5-j|>K zH#z&vPncki^zqv!8oTaWT=OFn$sQx0+VzvHR!4@%7ydXaD{DZeKU?@?la)PYHgs0~ zpQav1COw#C(4_Jcf>-Il6E5QZj_Zj=gW@I*PbH~D} z1O0t{^f3K`UG6M4yQLQ|;rCx+ef)+ZebIi*iaTqErkfvk94>VB&S*zx#)|pP0m`xW ziqnR2aPETkSAtKLoi!%Pwd`Q**x>%SSU+hmCVxzj*nf5K+r{3k7(H)ix%a*TImkv1 zV#q-ea`16sM5|bPl=`imnM;@LNIdZQj#Qxov}ac4Qi~(dlF8nN9S&~>ofxyeZHE`N zKiSV&UuQ!vJ=+9tX2Y8=!Pf@-q~jRhJy;2jMc`PN(Y(ROvxsZaDiL4tpPmhBQ{CV; zIc2E#!^%i5E=1EWwOi}|3&$*uyEwkc;p<`P;34b-DBG8wq<6RgEi% z_x1D4FFiU1hYynNt&Dw2OyRLQ=7JXBZ>D2E3#!B4@4@ek!oTu2J2I_&%_rVZEaVbk z+Twe7Q#`7%q8QtPM_i!0OF7(PX`Ma~@uQ|riaW;)Ivj6x5#Oz{6!`wfPZ;?tYq&w2KLBEaUj#c+> zcnzOZ@Phb#vMVQ{iTpFIpH_@aZPfiBp5B|xOG)peAMrkk7qu=s%Gh4HztiZ)H8uB6 zeLI-my5}*MX3J^&+u+rMZ=ktmPwg{DzNeVbShMI9ZCpc)=&6;~5ygr2%;xux>BFb~ zt99hN+&5ck^NXNWCOSO;@1`$JX;L}u3)9SRsww_kOS~dR{!nv12es2Ax8tMUC($9P zo#)Xp>HT5GV^QKZinII-d5${w8W*0w7w|{HN5<6w4S6JgviOXHIdH@xS_qu>`-BadqRfpEB!%%q5R9 z{%>cVYz*C}JdzKmQ?#QVSND|mN=Nj+yMej<#+>Owp0=8LXAG|~x%O9VMi#a*&S>9b z=P$z^D-KYkaV9xGU2FX|`?VcbECfHvwQG)Eb?cBXWkPko8r)~@yE^E0vF|S4pYFGg z-t_wMHSL>*52Sv)@cws$e~+mG`WD=3&P+lMpjp21PolB%Qth-S54^~Y9o|NNZlgcL zYkwK-m;Mw# z;{zQoqWwhouYS{ z_dAfa>YL2k`npKtdxzLNYJU;siHlX)ytm)qcCPU0uIo))@wu>pJNXJk(O>)`M*I40KpYwFgwvcZ|; zHeaEfV(LNs$mu5KFn01tcQ6&Xcw#lNtcFx<^8}vvQKpXeMA2dG=*Jj7e1N!x>l3H8 zw($F@QFIJ`xbqt`s$(|o2-6OH2XG1Z(0{YqX`>rEsO_i655P$_zn!+_es1N_U`_U7 zm2vf$yEbST@Y6ZUp`m_1sA4Md75fW~zuY_ELuxPkXlmg8j{PRT^O>MC|KK0A*PHYP zK3z1mhqpK$jdD6dUPKM4easDC44>$zdpR=*RquEQ)$d?73eR`!izh^oSZv1|6PVITn+wZ;9d;AWqhwPxkJI5 zHoADb`Q#Asd)7Z6R$NedbJ@!JtbI-_O|eDIACN89_sTVN(_g9Z_grvsbTImNzlV!r z+9^)`DLs>*nd*;`hf4wXx!|eakx~AC3v)9jdobmLRsyS--^^ISyu(V$DSzRY9ReJE zLto4)qV527s~+KV7CP#TPq!-u-T~$eyv;LRE6<~O1^=3=G$v2lgI~eX+Jh(XSv=d^#9DH{vYM+$;McP9Fu&R0I|GW ze%q!h#9T_~TuQw~>{-_geNN(QZBc%K{t_>@fy;4pgZkmPY$N=Tiax91$h1=D8)CXM zc?Ql$is2`}uhk#!b!uyUu=j^4%rR)Af7{>*`ShZo)qo9hjSdxpnEjrg~sL z4sMct)lHoPaN!3Z?wKUG;sL=G4@jQ*?ZXBOUMK*k`QT*HC%V_X2j|^?SO*O7$!_LF zsLy}sonQ3Mf5&+zM+wMyc#04FKVKirq>cX{Z)XBuWp(HM=PudU6tQf$h zPE{neZEg}46~#{F9Xqx2CJ;~%m)drwP{9Ne)?no-tu3^ZumreqDKe;&u{P{m)B@?W zGjC^xd$SVa0@Qh%SZLnw?>x_w`{Wv|Z)e_@&*yS;pXV(9^FROn{LeXF|J=`5Hi19+ z>5@@z8C$aZb*~&!&QNUlQ#-p9PhzdE;0*o{Hf{bH>y_)6^BA^CEAN!ctonB0?PTrEo4=G9?0#J^D?eWLhwwj(oQ8np@UO^2 z0R9~4RPt&gbn4%Kcivv^L-xvFKe7Y9KaLFhRx@^lbcW=j=u~Yq-=?)rYl7sxa#)35 z&8@yw)+_7&Joz4<+d}3~{S=^A{ER{8N0ebh%>q~AegE7{<%x&Yzi2Wu89L*xofx#( zM{jx$y$P?PlN%CfqD|+WAsb%s;8=8;{}t=3)&*UiAluKv2bWzn3Hq)?{>Z+ACyvXG z+XUaZwx49E$FN9Lq}$0BI4 zO1QbEZkp&UCB(B3&)7rbp*NSG)8JWezJqca8o+fFTsJW80%)NYcx)cCbD4xk#SgvD z`oXmO%i)j0p7u`RDCiYOn!l{aVi+41?b&uRUw ze|$dLODsI#=WLW~uX%dm{Y{bu-(OtZwC1lv&o*2Lob@R#gsvIGI;HGqKjSdAGkKho zpcVNN9fus-IZ#MGudizVxHTt&*w6dZ%1_Kr6;ESFoAoz4Mn7HcL6`m73p~}=UX!Q2 z&0|)b*!T8}=dj2BF|nObYGbdbjnYqO<8k=^BfQ!ew)czo+%`?$_VD z&+1{FaqT0lGu}Pan$UZ-&)kF2dztTv-Oz-7*FZBF@Llf@d?v?m4g7w!e*c&}9OTGU zWasrOm!G)asyVl2@z4$L^#1U~`!Z&>4+JJwXKA%1rW<%g|Y{_H!w4d>pW%$XHG zJuzZr&ADOzVO8J1O<@$G7+mP*}JfG@2S^GrSy#Jgpd~&fB zIhkhlRD%o2P}xYz{nL3*y65r$x$PaqZ#t}`#@*z@)cR7YHhwLDA3p?HAUU9OE|coU zr&;UDE$dau2>G`N;8Dp=-F9q~T*j5lxDGB#*>LFmSDOwOSUXae1i~o`wVxrSs{ zcE^;eX5^OSj`%wYe9G0hZxDQ)QpjHRujI8NpW2=&$P?`Cl+pRa(O%YDpv6<<>2?Co zY2Z>!Rd;{4k~7#woAT}N#E13LrN$TC=MOgzu=Xe058d^#wLg{Dy%^tTeM9F{6nxEp zT+jX&>wL}Cq59&h?0GL9WzV~Q?}mruQx%hA8w1D9jG;iXo_gIn>zm)^J#htjHR{_en?{k><9Ok`(vkYf5?+2Lo+VX(z6J*|lU9HUfvO9{Zn%T$KH}CYPdH-i< zss)^uIXEqIaH`+h6Cz$H1E(9o>GjN)Xt;7?^?Jp}%D|<0Zdr})bNRw$;C3eEMPK5F z*5Pryv;l)~BAQ~|+@>Xio6t^|##({xb;)&)e5+=Ty0%rXcX`Lofwk$-ra?Q`Jd#lF z6nQW68b_WoS`@C8pB zf3|)&K9GJG-d{hI{(saDk3VRRZgb*#=3TdNcM?{slN-gS)cMb;gL z%)M@+YzqCol33KTiF{bs(z7a8^~*Y1e^%2Y+zQ5wCMA|ns4=N z#m^&ddVDjVV%C6WF^1T^(9<-ntt8jExGJG$v)TJCGC`pKEV17Wi8rS0-m)G zIFG!PL|kf|x`SKwnrSEc0P9A+fXN9j=HA;fe5IMJ8)bXuOR#uxneXCqfcY8^{4bcX zkDu6!-8PiEqTAEESCU^lsH=j!meaRcz@A?B95`_2tT3UEYdw8<$Dp;e)6DfC)}Umc zW}0;p)Wi2#a~*dclm1z>n*e4vB~t?u$Dao#AMWtyI!-E^_qjM ztsH{Jw1%g9fdy|CV{d29+YBwo`^iy1&~Q6*ufBrt@Im@WhMza{T|r;t=_`|XnzxTb z^l@-(QPUkam!CMeD0TF~MRzwHq))BqxNAl8V(U-L8};(J$kaXOI=}bfxyYg;8x%{? z8j@g=ENF+;MT?zN8NVH)Gdk;!du@7bXI?s|>MU~W_-xkJb1zJ3>Hw}3;M?cGXW%4m z=jCb9eOt)zv0raAAGe6PafUA3z&76RvWrTW8MahDi1<&tx4qAEWoC6>u-Y2ymf1- zD|*)+TJ71E-m4&n=a#o+ejGnPro76ij(9qpq@aZ>yQKWcwSF1d)ZvpSIf$k*djv01kD zcDz>hMb7qtzvh1URMRQyq`nt=R_%;S3p94*99e&w+?`b4AkHj^ok-`553Q9d*Rg~B zBM(`pUh7~_7VU*s5^GJL=`(u_*$c9E+~iKpW*kq-FJlSNYimm!~ z?2HcN_cHcef+G_z-UJ+qtG`Ph_-DG$FP!e=<11z~l=e(~+B2v5!t;D=kkX>Oc5I0b zWd5n*P`&Im<$R!6`fLEfBYQy4H=3BYWB+-0>AFv7Pj!ivJ360z`{ZM6f#*-3S=4mb z9xGf2U$wovsHu%{mHNA1o9wrCx%82!%P*lH!Q2MCg4=~zeMt5l<$DnFuLGFNn8!yw zGJa`-Z2se(I1c_)d-b#@n3g$tPS}I<+o0)`LFFfEtrbRIXVHG!{Qi2XjXv7uqdS4s zg)s^E)RyRM4)D%-g*7Ng-s_o>_X+JZsU6yA;JxB+YV+Whm>m$hPHsa=-FVH8iU6?wd2d}(- z)&o~3^DlWVUE-D3u0AnvLRWd3H`Z0=peN>_Cu*neIoF1sH~^g6&QEEoy=98gFSY2G zTJ%dFyx?#Y!Kmn z?u9#>QsI$QkFIQyPGW3kj6>LyMXZ|q@h~|r>^rsU$nR;aCf`G{IKI{w+Bu=XUF(}> zWT;3DU_rm_C>$Ki2(sr_qvpm%bh&Nefr>HIzl zj{V%NR~Yn%L(G@f>XV?kP0GieJ0Tnj>RFkym(tu35UgM6+gk1dIHfOgi7;M2xy zSqHe5b$|oh&AgR$fNkUyV_X`*mK8ZbLXwQ4x~GqyNH`>O({m( z+DH8D>F!+HecJ2(_L`A$vgA9M@_e5sregA$X(v&~3U|jEZQT76xVHG$x4vuE+WX^Y zBWt7oRs86#S}%@Dh*_}i(dY-SeAmqeXLi1HuyG7H8w1X^aR1ISaJChk9Rz0wxRboh zO6qC@UmNe3WbhU305_~B)CgDbT8Xdwwav)imKJyjoXtWeyEx2Fz@K@p`jopW;axg> z)5h9{7sp=wZut}VUEtuiE&;#ekmp|d6J7%7M^`sW-?U)Ud+asClXvWfC-Dnpv&oLy z${3C9#ol*pfsfl!~BLBmIMfu!X5BYasGWJuElV4$S^84ji^i;zK1?1Ysuu;n3 zjlKAsWe#s7!5g*kMlEt$d@bJC3U3^QHx7`8CcX0@@eVg$BH7=9pY6m;%y>p=JovxT ze|>so>YK9?Cwtih3&&KQ(;t1(#Q+mKsj zVm?3rgfZB>YxW~J^JtIF*u$QAY$12IPu?+)&p5Q@^6%LSdp_+uQZJ9&!{7+HboM&- z&uU(yoPScyUi=sN?&!6`y~wRz);N3F=h?d`5b0eT=$=eoP7AcP5?V|51y9G2i7Pqx zO*mV{vsCUl7@t~x;z8zqEZ?#xPjkL^3vm_csg=<2ywv^cSFcIiP#or*5@;!vc;;Ye zXMU1D{P4VoC#EO)Oy2Ij)YZE*-i_nkxOt`KT{E(}lQ!FcC5iV9LEl+F&mz!%Cq9H^ zM;ozLZ0-qqcS`40$?gdH!rM8!Yy0_?tH%LbwlAr%jkAtKk4u3o_5k#VKO{fNOOual z9vz>^(BuQe;V9Eg+4cmQyhHOj4Vr{5o2jFmT$yVm*M?0j$JyPQ~BHQ!y*{p_LF z9E@eG?0q^u-%49O1l+Cxw^j3q872A7?nD18u6zgY6Y)Naxfb3xL0{@u_a*9k1>fZD z>`+d40ej{$t^Dv;U-kI54z0HBLr)(<{-S3lB+zWzK6LbQv>mwyxmZ4D{(3Y^Hql#Xj($^F5}(kGaGV_FC*MQaqOLE#%&dPuWYpPJ4Z9 z+}n9e(-^Pl-Q`1#$L|?WQv&}9{v+nTmUzB)bU5^bkA;spYRkzVpU7FW<6KVjJLf7F z{^z+lCYSx|P01NQIkEK@HRlekN!oBKkTQN;*U}T66aML3>f*EwgDyPUG?;U0rv7yB z=yw+tG`&4_$%*&Ax69HF+6dxp6Gik}W2ea0f!_QGS>wL`IM=QLe{T{|c; zeDrdnt^wz_VOdN(^GGy*e6LUvd$FINAI{p~#xn=I6%+ZG* zHf078yUe}HIzFEFrvrn=>v<~g4?jG2qI|i`3}gtoky_{1d4c19&l#wkn>QF+jD1$) z$AE)2a8QZeRC)f->ovbK2D#mtyR0ox1qgk!-)seYR)N-d-5I28n<5i17o`$WL(=GNeZhU zeYW#_ta6(0?|P$y&Mqa#cV(eKUQPwO?Fbbx^UtKyqW}qTL(Dk2$!0 z8fO=EEV4F~`O?fDyeYoWPWi>Di+680Owt?r9mf!ICUSc$-96#?ceqP~_JL1d2 zn#<*1TvoM>bySms_Dy^4P97d{_KxMmU3aWpzaRS4-1!HXyGrU-eyBZ|@;QBH$7|n{ zb9T{Y^8Duz2Z(SM?D)+6>mMSoP5Wv~pw(<=KIek>PS1QQmb``X`OIe<^SPNbR*>n> zw*97Tr)z(e<{Z%e4(>=PY~tJGAaAjf8w*p5>h@YSuM4jsOZ)z99jP^@>YIk2y$=2< zviIGA>iSRrH}lh>{5-~(>(0*%|Jm)A4mdwMyu9`QZGK8W?fm@XZT9@=yb{m+ zZ1LIiQ$F+ALe47*aURIP^E3C^im&;@h2PNp#M|gUKLbD4{LJ*uk8>}x@}~_SaWAuX zez0BPpZ@z-Wg|2~N5y;>;h(kQtF++@n6nSiPigFpXu}t1#b2p`He=Xp+7lt#vd8Q0 zpR@OU>6{+L^j?A|Ho^byKCuvM2f9oBL+IreFU+!=SPwA#Pd;xS{}cN^ks!bC#D?$3 z|JIok+6NR9{2z{ZD+1S z%sJ)Zs$P}q;df^vKU8n_zpgm3g*HsRX%&q*)`;U{tP$aBubq&V~MfnAOpr=clRPgrt-TTp05~Ne&QjA-bFL6E=gS+*dTe- zO#ES0p|xWix+GV+gfp)Szqqoh5M9z7@HehR_6!=c7hSS<{TgIX895k|J(C>Svzpjr zm5&%5ITfdoHN@*1Qz>7D?9uN@zSO$yo;D;0U-Yyy*=eWlPqZ_cc66qd_8--vBfM)| zyU_>aT$(kllc|%swAZklG7bEF1Rc}LKBi?kd)MnMxejEtY*vk76Lzlj;h4pHSno<| z)HyMdmzDfoN%@oHDd;!*IO--LCzpD36#Kp#z5GJl<2bpb{hoI(s|lJt^*!tlKKr6%96YcQUI`KNTcZ18&U`s+a>#kFJxS5QWjlGkBQqlymYmWV zFDf4;*PZ8UGrLp6;;~|U4fC9{fK5BWpgBLThIL6(j)3bE#S)JbD^Dc zcHA@QId`xHzgXwgHxCZ={BPbV?jpTs zVeSn*Nu1^OP<;n$A*b>~^{nZ!Kbkdb{6F1wI+!zX%6!T5W8msK_A=yKgS(3GE%JSX zyH25lbq}fqETVnkeF1o{N&Pt92kOhcqf~P+pE=NepEBClJlu0(QqxP9uvZWLJdL^4 z^P%|u(|A6g=Vd%!!+EO3;OZ{EF9qj0R$Ai{^ci~>8Yf+-1Ruew?Z6Y|JXoDK=CgcN z8T8?um&AD*LmT+w$604>oN{6LYM+%{6`H6$fSIhbX+Mv~nN;@_aiAL3CKBskte+_T ze^S4R?Yu@l!>fFD@%aZneR2^SQ2hPyfw=v5vxkRV{^?AxUv!KL02=jrMq)vrc#) zqb+xjX*|YhzhBB2mJBeCiJoyB_sI9O1o#E>9!vQR-q@$zwmT+U2HR(xJhmPDjAXpP z)eKzi^c@|Ei~@ees|BlMz)EP%JC5#Eaa;?}BOLg=`v(=jv-jJ_&vQ2C=fN+UCwGla z`)M8`hBNo`6Nd_hw+e!}(C5ni?bOl^-PHZ+g=Y1GfnOc`BC+uAdvcu+Clsg$2ixfbARhEH{N zk>G3_iM+bPsy}I^m^;hT(J6C@mv`V3#dTSrZdn^=0J2xtoNWYcn7c!vLWAo znOec8V2R5zXD=tP6kAr;4e05yoU7x7Gt~+it`q{ z>vz3n!ALK-*E(J=XQT^fPjW`X_c$9f6bK%No3)Ung1{?FB82QgFg21yRdv% z_z&D8RdD_fE6W1u^INW{UN76k`~HFR#pa!CgM#y;S8iZ$p2`(*PqgWWGdbOU_RwZ# z$ra(u^Cu6dv&O%NGsjFB&MPbgmKy9IYvLEeI#)RaT+Ur+YpvaBVN(})qA7t$bRRlJ z@m4DPHGYOHdnK7Dg0Oe9)CedJO|u^8*-a8&piY_}y`Y|we&MMJ|g2ixbQ z6K5m#>aY5O?2n94p5D*;nXe~A>~rHEQzp3MQ(fhlVrv<=(#;yI{r5-cL${pf;UnaL z``e}od(HgB+X-u>DacRc!4SCE$IB z^66AMa zY(ae~xi|sZEemn?20ER4S$hKItZRRV?@GQG@V%1nMSQP8mQq%7KLGrG>f82RppLxk zbpc>&MYdM}Uj^^VZnE#Ti4J~hXL*jV-Ye6i&fNV}Ff(7?`|e!)bsB%7|9NxdU-s?m zl-yNZf6juOZpqnd{DOzkk*(0b*0uMdPot7O)Mdk!VW6OB$!<#tNps6rYno9p}$oWKJe`8=Kl0pYn}0zDCcS z{Bd~P#y`rrzjygA4~IHS?rG%j`AaSjKNaEr+Ecyvr1C-b*T35sx@XO@Urc|A`&ZxL z{?!k-e{~&qfxUWF?et$?wRQUMui7^K%B#0e&$~J@{p(lPO@H$07p6zqt6pANGkwTa z*FUxm$l%eGIy=IWfs_TPW~z5AzL|9Ia!U;6a;yIkJoKEAPXnKy794hsCRT(-Sq!?^|g;3q~1a5^}VBB z{9P{ZavyK0+&um9t3LPG2h{6Y_QLe1uFiez5cLjGukRi8;_q^Km-~1P;~VbOJ8k-l z!`b(adhvHQoO>AG^$uRDOn-4W``%G6{?3N8nepA@;AOkPXB^JHchrl&v*D~@e2+PJ zIb!e`pNGD8)Qi8f=fOg@M$h)XTDg47^xEaM(+@7+I=y50w&|%Wwof0oA~OB16?N0+ zuXtg4Mn?7BIefH#obAz|+ z2Ts|*l#j#kJMMDumi?VmcJKgYe;?|(!dv$DPT7$Il)a*$XNb4#6~3MgoQY7Ks{*OVeP&;Y<~ZPAKSn>W`!=df4}Z`yUT;C|}MrjgEc=kMe7%XXf9YvB4*KUMujJ7X8Af19Vi7v`dDD?AHb-;_{K z<8=Rd<(uLXseNO#^=88R`hV$b%YN^Umpyd-@I_m1rgH9l271-vJA|y4pK)vlXTTue zGc4<9=Kggj_T2T-xy)a^eQx8h=sEUo9gTf=&576(ub-Rw-oKwK7{oK;wq9M-jI6Bg zgTbDMEZW&iJ6W`|iFPS#0VG}F#3+9{-+3}WrxcHB71DRLw=ckd+3{{Z8;kjmW{ z_IOJA#`BZ^@^}{Sod4fAo?RCiPp6^hxGqyXYP40Jw^rv*VTb7cU)w(L*Kr=v$If~d zpZwHmTl&_eA9wyQ*8Yob`x)`}w_T+DWuMT#olhF~7X#30Aj#TQ${muk6>H<~*Hfco?>t2=lJ^V}?3#^=ukH_q-!9}nq|NXL_GFFnf2_3dj2Gj6 zl;+@z4vyS>(kODt)g77>KXY2hI(sJHnS68p*Ze4cj9Z5H3k$z!-Y?ALdw}=#)U8i^ z?#{Pgz5^fL_uc31zSDPRYA6yIVV{||#POrmzZ=VR;fU7>y-9oKP6{uap7$4ce?j-q z4ER16x`8{&Z?H15u8Gl4Mn?8EI=3~$@~<|11w)VW{!uG~dkW0^(tht<`aNR!Bc88e zF%Iq*j}#N*C}AA(&ozd%F_$;`$BTbWo~7i-#rzAKKjQv{McX;_L0qD*ec8wI%kRTp z9Y|Ka>GW}N{`++8laDrF`?a6czVS!m{FmXkc6}DU`ySr`_->yyc7pQVHAh~WFTx*o zWBVoe!rphHUGMK#7Q-9(ABr*Q{1o9F-Z+!_wcEqV@J3RcH`ajM<4C7;PLtp~y3aV;)&6o^a`P z75dbxo3VA)&bp$oXQ_|)eqi3tCsX&XpY@D?!&1w7)yA={uS>8at@4rKO!5r>64~tD;^xzeqMBoiQlZ9P5fp*xu_;q5}FXs zv0A#xyW!kkYgFH}^TmhtEa&{_!*39)i86kx{0rd>+LB(2(N7lrG}Di8Q%ai^v>C9P zyGvQml`k2f4gDQ(+EDpGi1lx$j^L=I&TQ&%POGUi+w|+Nn(fr_*QCj=H0^JuYzt$b z~q`miT@H1n-Q#>Wah^^ zHu5LJV=_X==ZxS^oyuU|rhtjx9h>#tNyO%?U7}ADYbKW4TV}`5nk%fxR;{NI1C7Ez zii74Omsg<^WE)A>DaSWPOfTUZAm5^Ma2=u_GdA9H_F-KK@LI(9N-ST*3!C?E z@v{E8{G4XuopJsOaOX9-TRqjG(AlM|CC!-;%4^85p4g6GxD1=PlH7(Z+;`DBwEWqi z6+PDgZpN~nvEy%hy;=eT^{GgHv;fidF99-a6%$KotS7CDsim)YV)Rfs^c;tc5CDN@MWUpl~K@R+;4!!yiU`>r6cN zk;_e7F=+G4TI*L=g!hmOZn^y8_=3OUd_~y-s~vvv`eVWs@<91a{q(O*3HJXkJ*>K3 z@;$zOTayg!vR11#+vaS4UVw3wGPX*_SdEQhL7#@N%d-t%w;yGW^O@fw<~O@AFN-*K z;Hpr4i_f}M_8Ic^)j)>U$E~w1o_BwTmx%VMtCN91DpBn19tY4?HU7pA8_XQLFl0!de{oxrSN_%?^2oTYp<{( zx!Jx*12mYFVfExg8>#fK@ywylGU_BY>yAu2}51PBN+;bIU zjxt$?aq$^NuKe|9!5R3l<%T^6=i%-2e?@0=Pgsojtz>Bu>n}8-E*m+CM1`cTg%>gsnc_)NK;eI3NcQ{cDJzVbZPd5As_f%lY+p`NtKp`P`W z&q%i-$X$}yL#D3cq2XNe=EGlz%hr^A{1d6Y}@S+}Gy_ope_$h)Ui zJ{OsFoiB17@-Kz?k5X?p@JL_vgM)fU#!ikbvipdPqfR>IcUwu3q&D`1J9OT`{BAp6 zoq7QNYk+s=pfi`V-mq!J^7SF^rm}Wl7M6}~;VdH2?p4rg8#vrr69VoKYsJZ3F=V*< zo(0TH8QUh_eU*N*7+XUPpQu02|6$1B$(3=NCxv?Mq)awtnonAJ4U>rLqa(z>@1YBu zp@kC5->ChP&(y+;^wmsmf(y6$lWbo?e?D+CfwnSfD;->j-+au?4bWATIwjoa9=FYH zx(lo=j3j}h8{h6Ie#v03-7-hngaq%O5TMR6Tb-qI#11s{qxs=>-=t#Em z*Wa||d~<1tXQ7^F6YeWsW%hT**F$1AQ}1^68zRG#pdIbgUw$am!}>;76xpr&d75vt zn8Q%dN8r`MSMtj9ZyjOmtdGQQW?pXtC%2O)%h<<^u-mfGfeXQpVAWiUZjjTrDsMtO zsc+wqP|rWI{$}PK8DQoe84yEetGx{RR$RyngKQn?c9(vbzgu;Fv)c4QliO)ezq8Qs zerPhrTo!z2IEvU^Hu!|o2%ypslIqltxm^_4itZ8C|?D~@I+m`#5}%vh3F~0&YWY{Cui-LqU;Rq z?JknN$C$)ldKO^bLg2dSD)uG94~6hU!HB?y_iw_7Q5l|7FI;q$)iVhmkbKwrdojP| zw`-kOIMGM;SM*9?C=2D;^zX!#9ou`4#+cH7J<7EYBF6uV+l#Vmm7~x~eoX*4GocHe zIidYnx_9vCt?adg#+Pc2vFUWD$rH!`uT5&!>K#8?_&4~)k8bh!pns}=^_^q-=Ggv4xi;jWeFF&FGrGKLTaT&6Y;`^tg*D$y6k00L3LMK$fTV>eoyOBSJk0uTZ zZ$wtt90uR;kFial1^ayahN^EI2ftAxfgpmF8GO@yvBUiBe;HB0=@+=>77 zU4%^841Cf{PQ^2ROcPS&ikOMB%PRU(1O&ebdSzyXQrz-D`$&81-=6u+D z%Q9q)&a291{5mK7Q_sWLKQRxy*F2Ob&coP?&BNr#qkZ#`^#5!gu%8W{gaelb6yLr} zeS_mNaICfT+2B}sodjOx>j}4ME^e=|r2lm+vdk5d0B?Kalmuk?-}2 zkJ>WF&J*$W`BVBV|B`YAjsJBlGn5xzx7FAGnUxoQhGzy&M^1iM{UaxPob!j3UpCL= zbHvv>xbv%LFYx)H@4eCk;7oc?_aWroM}2T69g&Yc6^~)sF*9GtXL5b?E{3m!o;+cX z!G13s$R_w74jd;v@-or?!|&vI}s$ix9`T-tYH zdHYoS%=HJoWyBk$7x6>yxD&Wj7>nkXK9R{iM%I7M=8@f(+2fWUT7WDIaOR_QR(8;O zwT~9jPaknF;l*J8-Hijo4qX|4R(6#aUUv+{r2Bl@%eNVycCF?3wC_XHf@3o=8~pU1 zAi4rOap6X6i)L_`1?|Ri84Ea6jLFRx9#Z4XQ!YA#$yuK-Acbc-^TvB`f&SU3(_n#gA6dzj82KzY_^;TK^2=35{_8=?Y^|~V*1?p^!0#M` zZmp@Y)JH#`)vY6+w*y=W2KlW2xd)$>--$k}%DaB(HuO;k<>ZIT2lKv@j~aE}8UGZR z<)5m|TwqMaKTTy`J1KXRcRJrH7vDM~*B2R&J=*7=GCs*(w{7DmQ}1xjq{y%A_9J7d zWBaGbV`2h_;NNZFT6x8{!@I&yJ7cMBy}0i>89DG^+;=4wkMGLdwva<8-}O;wQtb$@ z(d$CScb&=JU_)Q@x7qZ^Jv@>Xyf<^gSiOGjPk^_dUyIHvc6`tgGRcS0}yZ6Tf466*QlNA7yN;~P8eeZ<&At7km6mh2=Key!bn zygk_-(w%|2m$|>hhO>XWtxmh|JMI3KSW3yy?#W}$O-xL4Sn~Q{vv%d}?@n|7n#Ui7 z_G60oV3V~JVUrQ}QJg{eY8!0TpPmBz_&l-OvA>9gv=HagnNw`j#Uvq+5OmtQ(X|cwG_aHrX(@6d_lDkNbw}bUtGT@Sg(y z?qRlVUc;Uw3%JScUTNdmw)YeF1FUrL?Z$D)7wXF`5`O#5A@+}AM6wHoNA}Xy{|MM( z=n<{MfxBZ`=grohI$+zv?@9-E6&{~hxbwdE`o|R>|F{j@y)nt!@nq@c;nFKYktwV< z>)cVD-AIsSMj79;$cvcFv*qCDV994VJ1x6=&QFFP?^whc@#r?i1xnffvwKK@bCFWI zigv76j~`*;60^8Zas>K8u)DCTzmi`TlC$VHcPrb!{XIcouAwdSyUB0tw(36II)1`p z&XYx!2sgr={)r#l^Qz{VGfDcNSJergbhfW-*30NaG~0;}LHmz0wH} zb^18h)n|prv48Ef&pLLOFF$-2e|KJ&91*;Ia0TLUwO9DU9oQF~_fz+mf)SYVUm4yL z<@~HPXq`CPu1Cw39~i*PJkaS?J0iil-1FJ_-kiq-SpYntnV zemMcg2EF_9;f<_Ec=2|z=YJd?x51E<_2-GNECI%>HHGzs@Q?7FwZ>nsXU&1D>df!m zc9|vftsbA^IOUv`HynN$OpXXT7JV3r-UqD6Ru|U2z#1aA+OjSWR|{^+`>@?vgRjq8 z>923bj#S$jw5{)gxmM4(kH9hI(vSI_c(!eK*pkTp-<{9I{WfMCQ;Ayzxl4@s6ivDI z|5Bc>W?fx=M2zp91^#cs26i9>%J> z^h&Q(4h{E1v#;?X?hNhy(0XDI`)ADFJjUP7c-z2z{M_3>-HhM#KEGRW4P=4xVsx&h zY--tYqM;U^6{6<~=!<$r-_1I|bhY-fvmROH9k&|?@jffpn%#oy&0e{eHaT%U$HrSC zd|=}x%7e2-esf=GgmV=?ED9d~=|}mIYCrpuv)P033^Y!BW20rgnu&}m#K$ZAW^PzM zzHGlAeH&U(Z0H%rb2DQ__epm=tLHo`!oLd)CDw8~G;l8;@K)u%TU!tbrdhA*til-f zT_vzr9=kq#03W*b`9TrcZ}Nd#pC25Vq<7!&b0*nSJ6oS05{a%DYCqeO9X4yj&kT#S z-b?x0RepHH;AS&-1=8Prw*d>bVJq(>Uwq^)>VA4?l5-M)n|km6nthwhi~RK{wpY4z zH}JzhJr%$kL#M&hJ?0tXdrD)#PE(F1xIsUFgZn}~|3*6*+W(18kI_yu?d=2h($taY z_9u642NvZuc;9bk4Ykx77j7M1wzG!!=1!}kpxMK+jJmbpZ)L7K7?bYR6Rb;sBNI4wCDe6!?KI~piq{_T$bLyES3Y46d`$n^@w<3#>P+=< z_toKI^MVi4T6f{iy@A@8-Myqtl0Y(cBk}o{@gmSoBZh zIa`!u_$7^;q7REw`ti#ga>N^Qth3GIInxt)a?tXH4~|HxI(Stm;#d5ZJ^JLGo!vW* zeaP$=P4!I-CtZ?MwUzfBE8w5caX;{8XssIgangeR{e5@tHgL{q1LvGJux{0`C@Ipg zJE{AQm%o4F@Y64!8~f7R=e7@NJ$GtfYIo|EH7D+v{_?p~_q=oNjuf8VoYt*74p(so z;AwDT-?eF<4I$a4{mrKXL+f;o-NT#%xi^Qie6RE8b>;f=TFH%ii@nkBaSlYo53DE1 z_n2{y⪼Ys4V_IRblnKTjtAaSi*jA<~RuLpGH=+WheK+-3i^xu9ZC;C1wz#uV(B; z`3yPO89I0T%j9*mTvwRaeiGkj1@f>0dpWl-@7=P3y!W8NHfXUE+HEDTCt3~9ZWeD9 z=CO(K_=}t!sxwS9j$Y2k)mO@`=I3sZfXW_rH_}GTO_+wGi&HhWOzCCDU)l0sSjlF+qoh?HD z%Gc4~&EQCMyw*3oal}`x@JqCL4{e_2T+ye&<kB|49^nM}yR+!h;LQV;|`CqKp|0|#WE)rZ2h2kYs*8SydQYiFFz~&6JOtR*Y?V-^1nfYa^>Q#Qt#W~N>2tk0_~`uc9l z-pk)V>MJ|clwE=?XUbw%<*F?9&m785;_s*X%3fy5W^$$vWiMdwUbsqSb3+%7@}0+L zX&*j{Q~z`N$_}>SBfk$E)sv4>|Dnp>R$l*k+P#FbU+*h>sSO`-L*TmsyZueZ z^X4g)&kenat^H;j<%4|g>8mr+ZlAM8sPibXunXifUHGl)q=zoVvO^bsPWj*SJJeSv z#fBf90!(?)C#}3+s{E7Xc|(EeUH-ncuWYK_K5~e$=0(42h*$lsFlcG*Fu ztPj~q*@e*}YvE>>S0gE(y@2=O*|kEU;B9R zH>|TObGh5m@L_fzAIh$_guiF3yzdD1eDr@hxq84>u6s&%-$EWmXlEwB@k8!1*%AT2?wk}O*Yna-CZ-11~Rw~cGO}*^CwxEG3D{s1|tzPKB z3)dg`9jz+QTb9t)fAYM9dL!a(EyPD!c)O>qcN5w=rMB)ZU-+$rwwif<5A`lEhxQn4 zei`d&>t7Sv`e(HTFBK=W^-G?AfqK|+aeN_1F7vc?FrlrzrY+=ALR&xQc`o&S<82H1 z<@dDp4+(AkOl=|CE=y=D!t+to+aGT$4;_&AfpX9sny5)=>nCb!1^cEF+WH~SeYEvT zysd@!p$q@b)7H}oZLLyU(?SbBpq`h$DtP`r_5MEImf_zcp0>W1(AM9nEqMFi659GE z&woq3s(4#HS{r=L)7C=?ZI!AmWbKiJw*H3ahpD%~+ZM87v!|^Y32l8vZ6QZqOK9sZ zp1(>x$!3R#jOTQ?@OHC}C@mvR!? zl74xHdc;(1xRCwQDU$#5Jnel}^?^ltpp-V2@=kW584I#sx=-@|8=g9n=ju~>K>YBx zycWuJCx1Vqo2`Q#Py|U&T-g&-c!d9y+hMvijxuOSFF_?c3wC zW1pk`DQ^2|oNv8ikMpRbgN*(O1dM;)sd!c(sh@uy4TO50!*8=j5O-3HhEFqgyuFU; zuZ#XA?cHnj#F!I5e%T~otr^<=dJ^MmEmHn*QWx>88Ohv1SxxM32zpiRWa`}RQCEbs zl8L3D({8-kx9;tq`qouoyXgG7v3%<}=dUf;&brr;q-7_1t+e@>_$w)Cp}gMh#13_? z81XaRJ5y;{jY~N1UHNA^KXCu~%GKr6Qw%B#xUK=N@)Y0#j@D^5TrMm=?##%nye_<# zaxKu|U|^{OmKOO#{C;q5$%5Pi%TDO5vn7gG68oXQs&>Z5xx$T=+$Ye^8S32Awt9}0 zR5b;A;Nbad!rPd;_Ub{AcE+K-DDBmQBg@gh2WxI&-6piNy?RKbrD7=eJ`Rht5qFwR zEUI$L4dFTXTCLBI;N2y5%x=_h;&_XR<6YVpr^}*k#ir(d-nZ`LbHv-2lkzVS%OrR4 zp`T#?YrVUL{SLI5`L!FuvlO#pEE;a)uZamoU-=I=0NAEK;3 zh>KFD!e_9`FERX~xbLrOf#nL; ztm$8Cml{_yxKbQcx zV*5&-L|Ld>o1iogchxD+WTXc>N$N5 z;rV^Yu+!8t?{ku{qZjWy3Jo9N*=}N-*4m-r0`@xoty8C0GEZ#<$AvTL<8hv6zz;n< z5Apep-PR7vY3p^`A|74;3eQ8z0otD(E@1z;v1fsyOZAB3TDJ^m%!_wEpnmep>)UvK zJ)eCE?QNz#`O})$1A`*q1T#pMs<`-ms5Lua;5Eimh>%7;iI&rf?UibMDZ zns?fcrTD@voPm6dvP+=ptZ!5rA422E`bIFXfH>?P@)7l0pBnN`teTPGuMKhaiS5gj z!$$>{wd>1&>svR6&lmZqyooIgw`u)m(Yo@uhF_fC?fXgk=c}~9Z^~2A+DEX2y#>Iu zADBvkIWboyxP|?ZjB#?RbxSqlJd7WkG+XzOEZTVpnY=P&9k1kGs4bjJsl11&ChsBG z1wYMb!=~#%XY0()X4d6@sT>ge{n+iHzFe3#Y&x~o0j|_nrIk_@++yXOVy)>E?FzPe zR!ZYoE46AW-@x$1KHBbp$2ys-Q@M=8#Ojib-1?R?hIHC!L03!9$8tkGvT1A{n$oSf zc?)%8)NQ73Y{zx2l&xSaoL$RV;APKMT8o}-quvtqOe<|H<(*(}reDqXAoNoJJmjLldJX)Sz5J}`*SjpYx%VC0^`+sTh_aap5@%7MjzwzZKn@pywM3I z?sy9geoMsXFJ^`_(TA^WaB`sM+P`1ox50%YvzG`D$n1R{92k44!q#W=Zjj85=d;8f zVC@q;v~IxG(H)W8wN?!;SHsKI8CFmAh@iO(QfCQDcD57eU(WqzwTz`9$9J}hbEoIb3>S$~&@UacN zDpr1YNa)!+u}5?t@z{BDC$8j9rVid6JU^+4IK*pP;pNGn;e#A)+hFiEN`EKQ=0Vy# zsWv&wJ2$QT#3?Je@kox3x~o$?FcEI>vpN z^7GLH?;^YZH*<25`S-K_k_xS)&UJofXL6_Nl~&8+>_D z?y!pmh9Pr?*6sWRoU|kNUA&xbD9Cfie8&>{x;oVKHg@ex8-Zn^?+M}LuJFqB4ZyV* z8MvBtlAnQ_$&}ezL#`O}x0iU`9q7lE+&{H(3w*<~Q|KpG{$-O3+rs?JW}UW@v5E(D zrpFL)QvxkntmT)$Pi1TU3vAh8=N4&w7MclfnVU6HxgW)}J0BWW-B#vVGGGq8zKL~s zeb@3We_%eg!-LGN=6NvXAEb|vm0Ts6;71NT$b7;R#}Cb&HL-zbh2XGRx&c|epYxa{ z_rC|MMFH*c_0L~=(~9+l@QG~OLg=yp8^45Hh~^?_jd2N<{{Sb)S(9b6NodT^% zuFYw+pB!h|{0PovIj;87Df=$^ z>o9hM?dN>Y_&NEMOXiay8+W%I!*~swF_ULD|4r?dt)OwWBtY5uk>n!&?K_7R{ zD&H0`}jtQ+tBj|=VSk2dpF=a%|!Q%LEaqj zTd&5L(`MrQS;)h3>=7rZvSR-YL*6dzntIblQwL`X6#pdXL6i};j{Sb zlF40C&Xchh*I1@K_w0rxjOjlF1HNxLaH~(rP~n|Wy4hQ}iSytp*!x+)d)Z-mh zb*t_Ca{t*u&}bI)?PESN*{8VKoE_rtItBd>rR+vv#3nm#K?4@=)m|BKstC9XEy!e_ zU~%^!Q|y@3zt>c5mHxfn%{&>pU|l)$8p#-ILH7AF&VZKw$%dBwlr^$v zH+CuXJe#q!!4q3D_OE{s+I$Gwm#mh3n#TQxtE{xTL1`&fcOi2#eEr+E7K}fy+=}!| zZ2R;y<1w&Z0c@523+8Xm(zRClFSzjA__6b-g^Om+M%zQZ0CNXi$8$!6>L>Hzj;N#D z*EgQC$5snI$)Dx;0{i(bJ2DwveQ-z+oB8D7WcH>U;*Q3AE2S&OJn2r%jeN^C6K^YC zpl=t~`tIWwbACkJKdXSYO3#$nSEO0DzEQ-wZ|)RxCf4%vg-r*8_MH>^Be)ORamX@mc? zcSHMfOZkYmGJ#+8xPko&vjQ{o_8K|KS(rt{3^M*A{5MYve$JNRcOkz+oEs0kJA@1Q zU^-_%D*qyb7zVob$3G&6RWf|U)!`UqHI|qKoQ^s95_cW{4a?H3m{H9*^^@Bt=g5M#yn+{T@6 zQ%72_rj(GE@09BulGL>se;_KqDiFwPz8xAw{!2OUZ(xik;bueY=`i-rc*3BE8nouD$pQ z9nj)we1I-!x?*TbRSUN4OcPJ_*M0afG?0rt$5t)Snmzp#>lyyuoiB1GeLgV5GpwWa zBt7C=x5PpRQ6__2=mNcm-?Ubs^#kEkIzVzv^caKImCxA%toYbn95gZ`3%-`_P%L;B zcQG`h&rH9Jt-$G5d%+3>AJ5+6Su=c;F5LrNNoEKyf+v&y6;sIfyp!IOJdWj)Kf$@e zdawGSKctww*uR>OTw@H);L+_jn?8oomi)p}E0kACoKt+N7zSnWCuooH{3Y#`lXGg~ zC$xD0zA><-+OXb9+cEfBIBE_Q<+U(3oG*NZ{$2_%>vsvdNOY#Zqf{XYlSVQXBe|BYP}zY9=IG>k7z+%*@~{0%ZA2Z2wH1ZENsrufr%$y?HRS0nFkl z<%?%5DCr)FoQw@uE-thStu_zV{99c^&~=IZl%%*aHLiP#fM2rA3-6r?@FLlI{6l6$ zLYA)z*s5amEk0IX+B>BFv2RVT7%^w~&Z-lN)e2)GykbM?> zY$5+F^oCV#h2Q15S2qgJ!trMv9RJJ3@QWYf#V>g(J>nzXqr|!ax%!_C&m3;8ldLy( z8|TuAPDNY(A@~Z+xgUEi{k+fIuaWTu&CAg6h#wN~Wv$RHo5h)bt(47SjM69GIOy3r z_}Gbqe(e6C1bn)%g|NW_$ZVbS$cRshO?-bpZ6KF7^GC>3gaA<{mow zuJ6}3>rLwWM)f_I`=%~7zX|-h2YIJ4y<6qxw%hVaeC*QQ6T^^m;Qe;&1!J?}KQ$9) z_6c_q7i_OU7T zW2XK1MD2pQbNB~U4_2%=vHbflpIc!iA6>C%+lG$vsYhoYORn$DvA#8vcuxR*Qc1lP z)N7;sR?2N%v~|P5@+n7`QBJn`Nh|p95@b{hwnDR!+kv_Q^h!E9O1Lh?KHi7kH2Dt> zEI(Myn9;vRrV?jB7EDk+gkhwg@R{>H&DflG4-j|I^O?|kh}eUB_f?#S zCUqNKj4f~Ddf?wKU33kFF6fgO!l$B*4eNaCE**e2mN;`bkUzRAVZGMz)4~D=93LHwizn^R>pf2Hp(YAV4V zcKxvD*VwTF`Zswf8gd_`3nypb#Eg&R&m0 zjCU&j3bM|e+qDN@#hzz7UQo*1U@wKQ^#xX+q#iszW7UY{s_{Pd`ciMKkDT2kt4BKa zYm~j>G2gn&`CP_l6d(2P)pLWLdDZ&lnv3w~o#woWxc@6ZaTa+kt@s#~;Hgz}3m!DL z_~VV$L;PJu#F&gdemipo-!?M`Zcc#gvv%lxpKP+*;R*OeYmBYp9c)|qpLc-|%~uV+ zob=gC@KXurSef$Fb;~Z}@2&7|Eto46xlFt$(={H!pr$% ztZ&8O`6PILIrWaHo)3SDa;Fw;-;lcGj-!Vtx0N|a_a#3ZgXi0@ky?#U0lndawJ^pB z9^Rh~@9!7yLyyw4g8zrB;C*Ps$YFTj5APcriFbd1mP!oo`@32>_f5R-zI)Q}zI`Wd ziNpIU69rbucAE}te_y`5&V>6@&z^sj{1-m%yi5OlU-wBOk7e(>vRL1)JhtzHjK>Fq z;6uLkq+#5#Nj^d?cP}3pYQ4G)yRmbU^RWJok5}cAaAG6^~JzTXdd0Grs>( zd~JAxHmATRid%U3TIX_V-B`T68F~DF#oNGB8VppGl*jqI8XX}&5I78f$H;rqogd=w zSt-`5S=1?HJ=K*>qF?0`tb|U)=ZYa3J}-%n;bMILS`sMmH_g4aTYeohXV%_h=xs0G&moV%%lETr=LqeLOG<&??Ue_7RfH!xAkIr-M>Fgo+D@fm^pz5V#T4ICE90YV%N6)2_3Apa{eN` zg!tY@^6pGo2Zq0OU>L~0?o6NuKkMt8h^sBZ&hNFJ&FZF^Sc6+ z-G@9~O4$Hq+3(y}R%fwcmr+({iufdZ93HaqQzLjE8Lc?L&c6j-@9!sE_#N7q?9fJ& z2Zt^_&r6^W!G;fKU;~eRu<1Sv8y3!pgw}}(m~%xnrfkL%WGt>9 zWj&JdpETcI_zM!o^CfpY&U{?r%!l?mx-#D#Ph^1cD2}xoKSXl_o%hWRc_n5{f-#G6 zxbwi8Sl>KkGgjR%t#N5TcL^~O;X$#PLY_@xK6$oF^?BZtPkxAZoLdv_r1Fk4#T}P3 zPrv1?F!Au)jLRL*yhQqPcv9!lw7`=s=qB-Hz@vv{ljymZH+2_@>~G1C)(a^UHUV=n zcdqY7hiPukzu?A;Z2Bq}eXYAXT)_De|Nd-$et1Q2!jl8t*SU&Vq}RXFITuCXraBPl z!hf3~{ycB|4u9SK^y&I}&G_*}#6J7W#q_#tGp=-FUq~+AZsbk6@hP%BW2%JqRA2V; z>>0tlH;Oo`I(v%Zj`VNsx`w(IYu*2oKDE}P`O^5|(-~elz_?kPwa?9{X58{sEZR+{ zO>a5t*s~|Bku~S;qCNFBlfE)(lXK)Ek(co0kTV)nHuJR^TTkacg;;;LzIbDJCOJwg z6L9(s2d6JPH0?bvMC*Cyu^)v?;YV}7&&XqPYaIOOZUy1To^u;NTJLpzuFah7yTsT1 zn*6pb#v}SDa_}?@*b9N(PcFD{bQxoMg0>XDmY=%n7JI(spOV{NB^{zQ0KbF7jfRHY z^SNvv4m_T*7K~$kq-^IsjMdna=s?A@FE&pB$wcrP0I!M>%f9mQ+sE$!{kiWHYkaqk zJwyKP3Br8|@LRwlK8%9@JHfx~On2V?+J&F~Gc2ny9sgzxX8{>om2%=2;UyEiqT-h%{&CVT)p^!q0^wD(_)WatcCX^JG|{zLx-8P z8Kd7l^shPC#rP~AdAXkem!+K7MK^~F$hH44jE>Xqf8qXUexKp@C)M@hO*lKxnw3k_ zKTzMjy|;PKKy&5MxP*9)m%le+TZz9FzmrTUrk$wtEw-oP9`c#+>-wFKRw6kwV{~{4 zJh2a$(;c5qHrVsZ8Al$*Jo1o0Ir1eQU-9;z0S;Z7RsZ?)|Ezd87Z^-z#fD)VFhoyU zdD+O{DEAV0=K>x*#tkhdkFYuco^Lzw{JRI9BSzoE*RP-Cu7;A$e`j)b(?|HsIbJgh zi1%}kHZlAK={ccui84oV&GI)a3PIg>v*fH?;Fwp8qFpt>FVboUx8w9o_>yJi=I(kgK#D-uOSvy$O8P)tUeQ zz4vBi5v^LVAlcZ0)}<<#!D()8kkz)fGE-aakd003qV2CjMU#*OkaWpKW|)SyiLyx2 z>6A&K%Geo#U;tw)i?*HGncOTS!E^!JndVk3|M%y7zu)AWo5aSJ&ir}3F1h!+oadb9 zJllDm=R8L^7JjoCkDE`@H@`9Vv7TjJ=h#XEZL=GN5A=Zde9|YTYJU3jYs~y4l^)2L z(mVUYjl{=TTjwPC4oIenhm^MhKJ2`elGfW5XVN&ySxNF;n3(js))UCCNH%nJqa7bR zUT6IKC-OIz9car&p1_~kU!KRVAQj4&g35aJE}L4B_Gggc<~@>&zW2^Jk9&s zqwvB9pc&>&zDlDbvDFoORiDowC*(^xyB(isO)#r#8@Yvy!@Q3k(fbYGes}NxJ?|eL zXU$p(F628?Td%1Pz>|LP)s702_Y?Ks<%>$MytLi6Nkmue=nE4UreEqyr`8qpYvrq7 z+_KWL_Me58q&pVQwN7M~P7Z7Cmnt_Dy_J-vIJG?&qpsnMm4&|OLhe5>g}hx~a($++ zxM61Wku4W3>*0l07&_Ver_jkLXs;7JEuAqPz7UT{zlRp&C^l$njvG8P9*w0FS!i%g z-^CNj^evmd*|YQv11Q*bdzufo}^Q>zfEzx)?3h-;1Al`TL6u&hgQ<9 z2)SpLInyp39jNc=&~+>i4F%(~ml}p_kVd;M`Sn#MC;WuJw`)(2Mln1SoMd*Sl*f~3i?*xy8vf!OrsXI%y1p9nOefDIPWnE^@*EvAk54(YV zK;zBXY5V!DF&4l2#W%Ii+YwkW8$acKm)|wdKfM_r4D>5oKzvHKJMV;3;a_;w`J*Q91b^Zl@tQ+__dB%n zFxM>px%9V+8XDg9l*Varj=y&re&XHuiJ_Ce*pN5oqj4+lb7bB9kH1( z^<~st+C{C$6zthr=E>G?wlBoRWh$~e5Vv6VGH|8w>5Q@_=I1TwAl}hqodeb#HlE@g zJ*IoRv3Enz_%dYXGGwN7v1H~vWM)&z{fiROL)%PFX1m> zXcgVT{Q}-CpiMomwa=Zjxr;V;bG?N%e~>osq)pZ*`#KHY)pmk8D{zMuoRtp_(hLsl z7?tvv)xQ3!&2nf`XWghztQpwXAzwzk(bZ|l*L2|Irv|s=U?;dT@C64yJ_kQKGq`_U zX5$CD#;nV9@9AjbEby{V{`iS|TszC22fNRS_0xf++^CJ%A=0gljO*9}Uv&CiInkr& zEj_1x+s{j`i^lA+>U=c$ee_p$%j-e*k!A$z)Rt`Z4s6eMay&9<>jm0M=aXv%nnPAn zv-}{T3n4x^PbwZMeit9-tc%~CT8+J#0xyk2uVANs-JxaW%Cym*?8|4!T{Su*H^{YM zbg@I*7XM11i4aqM6OWe74i}6I)}=A3Ht`xjZ^R%*=azRN$c{#r9n(?cfjX$-PLxz=f{2}}LG&l(=i2t|vg z9IB4^dY>&!ZLCIrHD{B%TuSUMg};UTwV(5AExxy4x<4wr!KPE&KE9T9$0RVMod z#<%?CuMX5Vc@A!3{k5wmiQoI<6|5(!3MxObmOT^?wNkssz#Jcn`rn8D#tNP9elN8` z5f|tUbxK+N(X7AHM~A2wCsLFQ+wV zx_lhyl~nzPp7GV6)EZ0n(F@GC^5=5D5v(j^pGU?w$gjg*TzbJe{}skuPMhUARf;noDIw#VDN238(AhHPXDM`74&52w19+BA``c&ne~7+*ZtK9E|LWkH z=aUaO_o!Rj3NF9Ny`<;NwG-CuH=1+oy77s((?-zbu_lLecDyEI~o;JE@10JZd_Du;tgSB*w0(lMYt_a++h<>7+WvIIuek1Q`|r4ZJf`hB+AcBceA_>@i{C-v^44^- z?pMBz=2bH6i%t&N$n}@!xM#(+rm@8UHLk@)@K~~d=Ye= z@ujRl?yg4eYCW43e%f)X#=S ziK~Yr_dxr(@@pyY)~cUQytxaWXjwVGXQlEPW)r`p{kMX_@XB#H(LL;w&>GqotUZM- z)sSngKNZKiL%MRzjrRUw-DjP)uaox@bJ+(79*)wkU>aETMQwJg@4(l&oh`^EKe8ry z?egA*)Isf@Pc8O4U7RFV`uyuprO}S|pzaWEtPcn`=gMzN3GX7u|KG@|w6OOqo=fF9 z?e%_|=R!Q!8P9&31o{KMj@F`EsO?ya+*keiz%SRG#s=Om{wsv$+p(4HxKaQ6Kj!^f z;vstf5cv?y5qp5~8NQu+`WUk081VtM+10p7`k|*p@{zUCUUCuij%vsVE^(r8iZ8h* z1ATX#m;p5}dNO|F-D_{>rB3F(YxzLiC#(sVnf>u^lh+Xuecm+1oWUFAcb@Kl!{+7x z*8RW(4dZyPQP+Q9=c)gi>qm)M%%{&2^Mc_Oh4andm5b*`yU_2Var=6regEV0*=lb; zyZqyc&{uN(Jm{xM_{-)j7``7PhH{qmUy9Fqc={C^t7kB`7m1TguZW(NACk--%gnbw zc{&|Rw}@a{|W8Gp>fU6*gF*7%fn97lgl{xN-1 z+iCGOZOb+n4+iL8A-vateepy3SDR?RT)LZDWjzL+QOCp9hwBUH$6h`RP`2Wj6Fy z2fn*UKi=>F{akc>j>*lR0nSsv`3~;QK;G6PKNg+8BAm=vyW#63=YBVNxk+g?)tmM1 z6IX^8@%D(A_qUVyptKQWoj_Y0NTFDNs)tpA=1lKl0C#j;7l~c`m-b;0#d3urSH}N^j`+1_@ ztrLu0^bGklL3qHL@qSY$e24ODfYk=9Z@ACiZa;fh46MB|u;x0j>bZV3pMT1 zf1+z9?s?8SP_$CM4SY(RHKa8mx~;0HnDtJCvoMeg@sq6ZJZ$==lAFRWj=8gPmle#C ze29;`?BwnX^G~;Q1kOLro|@&@_e~|U!cCLztbCDYc0*$=tO?e$wwTZRUD*DuYnHE3 zAC&jl`8_LZJG9vOyN`Uqfp|_?<$7DYFROK_wbtFw zJcac~J9@qYnm2x5bcXl?*}t}}JtynTv+P}lw>q&S*5j*BtjfunkAFpcCiyv6ISR~+ zbduZ7nP>4KV-FnspvkqZT0y?uMttar;2?3OwN~;(evJbkw)J7`dmGWe*pkh>10H*y zox?n=*gl_pHr2gK>`67Un==;8`ahxmmOy_yF6?5_2=tS}yg@_8|F#o9N7>Pk@dsw$ zd!trYw1P3Y@?Yz{Ds)yYz76lS7U?|3r!i^Fwk)%A`b6tJ#I#F@Ii2NiY6^AK`75|a zZmV7`wEr!gsU^8x7|hBYLf)OFMunIDU4Nr|8EUHn`q#BH&|n2)tAPIHPub<6|7FlW zv=KgfA@}sfIRE)Jd}@Y<;epOE^D3L5Z?$#tyH?io&~Vpxi9N)T0|?Ehr1{UMQ=1mU zFFa%I%HHH_gVCA3#-A;^qVZ=-eKrk`&JV6#>>FMAAhbAkS#a$^-xx!~4?@E=4h?^m zdo8}PhK3b45DjaOY39_Is#kMQZDrm}4&}90bWFarHu+F+ZKmYqA->-hjE))`jAmwz zHZ-jF-F(yzXj%J)#edjf`!rXw`|aAziS-Y&C#M5CR*tWDQ+4dm%J!aZ>)3ASwGCQM z4CiE>g_bu$!zNabt??xD;a6%lP_4obHlaUPP_6!uwGwch=M#YdNy zoVwb>N8%yX*AoxjG6eq|H+w((^PT@x{SnW+9z=&MK*q%1!M;>@sRdptKtBzsPddi- z=@UQvHk>|b!*Av4lQu`6TpUK1gwRXxMweuxGur;bx}+7mH&&l$Uu>I4mxMVJ&XwiT zCDJ2p(5C!`vU89$@5D!knA28Z>AmIf(+2n{0bP`UE|N`P)4#v|YVi`Z z84G&@Fq$lXGw<=&=A-3(`pDj|to3x58rk0WrHlSlJ@hNi_l@PBzlNvW_6<(KnU{we zwZ7P6*OZ(2gw0EH!KrvF#9!&Ap#H*JALOs_DIF!=`X6e-iMOuz@Rr3t*FIBiO|QT9 zPvx&x*2dI>%hDeEr;y`QjV!XxE1yVqi}YIucCKV|8}_yAl+RC~?g-Zd*gOGj9nGU# z8zOL5FnSO3pAmqTu4Juz7wZA+AU`x9Kd0p2AX&=X2blXr&)omRUD&Z#N^a^sY#Hq7 zBIdoEdB@I-9&zTqhU8bhnttkEy!HuuY&KL{EfArA7$Mx`}xj1f4CMOmULGL96rsO zeZ9j+1&$uTmwU1RJ@7PoU_JI!0rpZl_SAapsR;O!&)KF+dtKwTgYI|eOm@&JXw9Xs zy4d_8?a|uyl>_aZh22VjQ<8#j=3^Jj=Oww566gMY+|uly(`u^ySY2p0Q)DgjVQH+nP6nHtyxmQfK~&ZJaD+ z{^8%-N|}G=WmX#VU#j_cU}_8n+rM&PJ#BJ-zeRn!YaVm)Ket!0D?=CK&@0HFA5*)| z*vI%YRR2q|NB>;>+CYG)o>XwQ`vM&otzhtgoJoOb|chSUKjb zHswZ#(COO4ZPoznFR;j0vmz71vw_{pvu(isC9sE@<9~Mg_j6C*y#0HEw!Qs>9vvAL zG<)UVczj?UzUu?|N41;Cc~nlj?s#MQA+S=moVBmevG;}FLiqJCbIbbZgyd2Y{?@+l z;5?3a%@yFMhP+GW!1yh-26S^5wU=D`v?XKThaB;(3qC1%*2WyRgAWtKho2??-Wzu3 z&gLEBJA19m@fnPMtl?`L`*n#M6D{z}Co!vkJZyu$F(TsP_w} z%P)*Ox$Nc7o?iakeWwdAJb1eB^2fGl?Nq`uhp3;EPJTc>YyC9N`AuJ0cp+oWvJ1f( zd6NrnFPNN`R51BbatI50f^Vl{5RopZFz8HT=@M>#cEA%l1-mI~FZ|Izn?4+O_2L;EHn<>HI`KQ?0>(>ik(f3;pr$X$O*IT%L%f*au{4?46O)58Dy->T&S%`=Z}+0YJj zvy6MYB5C0>(AjSE&NSY|UvZMSeRG%Aa#q6TB(AZilGsAd`IX>#H+-|f#9uzs@Dy@8 z4Y_T6=xfr$8N}{tQ&?L;pC!;_Eqgux){M<>``Mjmw(*ROKYNY7gLC_iA}^i+&PI1F z1KdvfvH>}^8#z{v9D~khO+k*8BgfjY7mMhBIsKQ6PJ_QSUhkL>{f|9njkg^+yOaKw z)8C~$AC#=o`V)D({U6cM!VUXTYk9`vnSbD#-s#^TWcSs`&FezcA7bweW7J<=Q|?YY zy2Ic!cm?MfQ^(rmUif>Ie8*R^Cx(&Q8wuj zJ9oE&-+8)!!QO9C>a^q40~h2^lAi=RzmxV{d6;bUX}>?X1Nuz>H;LFO*ye_=y1{RP zFJbcx=2o$qc4!^lXz;g!xK}bXC?8x>vS^T6hE~#M#Te4DJ@C(E9U~7+e&tSRQ}uOQ zt>k9ek6NpmIyA`14s6n+(ASw8*~3bE!hz;z1@qG-zY+K&MqZVcH!JMs2ueQ>xE_-) zs5UywENY`a*1&q`z{DBgfc7;bq zbR}zvqQ>A_e1qX4))Ymo8A``cBiYCkJBLGUoMHaaoyOl}=F!+u#SK4>Ufgzuxwdl# z;d5|Rgl#?-dxv<4UDN2Gl@$klmb3nhb8P4$#!XN!YDNR|pm68PG4Q_w{14@mDL`I$*B76-e(;)d57*Roe{PS5 zo*duhXTR>y5;ptiJi6TCpG!}fG5(i7Mo+uPShHPvnm5L}EtV!??VO?+=mlsZ0h(}q z>zlx(>r>zC_|!!cu|D;szR`vzNxw$ecmV zyfo{4Ei{zmlio?D9#~TS{j`~cKkF>~mzj2~It5?s41BS~t4_*~IWnH+JXY3J?;GXv zw5_+E5U!AyFVTL}g@vbk$eUQ5_EL2cpJ`(gDw88+r>6N5Dvv|=nZD~b&+sLg^N)`~ z|I3XGw+~8Ju(Dk>-v9H|KVo%MEDV2gp)s&Pri2D zU_SXTuBlDM^u4yl_2fr4^OwM6n#HR+F{*!R(8$&K-au55&>Eo#S=inQo=Nz7@ zrk0+O&+ycZ9-eaf>G-t+;{or$PbVawJ8gbCu^jol;hp*Egw_p3H`)9&?T_%&AB$1F z2fFF!r^ikw#V4VA?KstNJcW*a_dLZ}wrhLenWt7bJk!~kSk4^hwb!J*~brRp_!GDX!URHS-JlF*8*<=-6-Db<;BRn~Mef0+;O}iS2lIC)*Zx%gmOK@YD__ye<4Z z@OP7kzn71MziRSFQG5j{l)ZU4*8KB9#4kH zU48k)yVRHOn!gwPAL+{pe}cYDe7xcN_*>tJx1WjO?H0~^7%6`zUIPBUBzlu^sy<*b zdGFpe*C4%_a*6b&<@2sJhqp!P(?77jzZ87++v$V(Di>b+Q~7Ery?K#(UoKA#7vImE z{O;D9^E~>qb0qrn?!S=!O#Tb4H_5yG&)>c?j~yL?$6D6KjhN5g|FQpn=+Sn@8ppn^ zUF-qz^4S1An)wcT^gsTW_2~3Vq(_4T_`PblwWlB4RO5TN6#U*hZ7{#jM+W?<{65^; z)9>N=M8{V4`fJac{nq{Ql;QG5@X_uq!oO5bzJd9Uf9VLhTO-M>B~QSiC)=mo!8*AJ z-}Ml=CwrB9Qi0!7c^;+sEq4*4+I8-+E%K=<*TY;J!!`D@D>tEp{_MuzkwWa`#oppA z#2+eKg7(=C8}XT}VBc*m@q!JPThY?1*n7gWrB_kQ4fy$v?_7Bd`S{MC8X}Lu<^#Lm z71tB%!S_r~Tc7WFkH`0%-J1B&3o(A@naXwLdY&g1(eEeg&*k2Y-? z&d)jI_?;Dlkl$H<nH*(l- zoY<2~fACkcKiyZqp@iD-_?YE?R*uwe#T|w()1-_pb?YyU|WqtW>@lI?) z`Pb_jUS-|x#wKKsj5P15ASO2Pfg$*!lxu%1HnBjpjTGm?kE)s;uDzH!^7z(XPkd{y z{HG(0Z|wl*@`Zo5Pj3Fv_!fJFiElwq|0nUS%ZHC|DUV1rrQAH_WQv}yg`V6Pm#L>r z9-5&k=xaT3zU9O*=Ai>cU!tQ9hmMv*N6Xz97j)!_aaAeCl|YP3H9R8FqWt%wqmV;K z9q%&6RTM)`$%{{?z#$EPYE@WgjZPvq%| zp2+=p$9SoH`R^1jl`mhhFFRiP*y*mg#L7PwGi|lZ8AwJxxpK;lna0wTY8GsIb9kD1 z+My}rlcA|>{P@GtRo)2b3S9mZJ`m0QpG#MxhM=oYjDW6EJ#_VHk9=DAzd%nJ@{i!- z+ZdyVV`HtJ_`5Bi?)X2FPgjjxKBY0X|33dNXbB(Ju(VV%0$Ku(*IXi6dSuFA8P&wK zKPIETLTx)&*L}v3QLesw;(vjj3LSsjZ`g0;wU7QNJ-5|^$X^yof|fB&2GwZq7( zFE!lvZt3f;|08)dVdV1aDaLmE-0m?!e8auRukDHIkau{rG_5- z7?*;+77jsQ|H!pJCbyQ3fWD58Tz*w~?DeX5W3N-g$@n?Q!5yjmsxtQalE0ArO1i}M z72~_<9cavttt^|pX$Tt2#&0z&o#7|>V{|4T$ww~{oqacbupIjX*Zvrty{L1toVDNs zG5YbisW;WXKhVwL!lzMHh<{@T{P!u=JE;?`{q9~+bC31lVf-EPj}+dooLOuC1?A0? z3sm%T?GLh=i+*lJcUryQSMJxnx3OQx$@3b{r}7Sd5VMEscKYeIrFKT@Z+SXq zo&4jp!8|q<9&=%+ZI}K79{Pt*_KEJ}Sof8)wioRWpb^Gy)5yApe`Bquc4Bkpyfov@ zmwAmkVJ`o^Cx(ABGY7^eT>fqE@?~}8+I?6CAD@9vR|B`?J^g&#-j%~M_IQ@T&++6h zE1zx`{Jc#3%$zD`a5v*ZHa72OOr3_GUn|KjeIK|^~@lkD0Q zhEDMNyv`nq73{UYNWF+qI<*ZOt#IPn6}{}gk0!BpPiDV;Cj0H*z<)B+H>z?mKEz*; z*ILXzd-)d^vroR*$#woT_iCJd_Nw)K1$3u<^hr~XS1&So(7yA^u} zl62!=?8d*iDRtELINs5_qHX;>gIrVY`$~szjE@nT*S@jpU$MU)9G*e$nY?uThi=ZP z@=}#A-&RAd1N2Kb{8t9e*!$t*>YrEMhD*CO?63E>!#s4u7d5HY`8?`ywv(rtNgnJf z@?bNaJlJm4J&`|?{cR=(mNpD8#kMn^z53d70jxRX!OpxsShv*4gU$2>qrV_W>zCxh zeuwcUlSlUk^R+nbcy%^;Z>zu+_&eFd`}cAGFgdA(q_wU{2%Mi|Oh@$%19@%r1QIyBN!|L9#%7mmfN= z{$E*FsV&ij7ni?)7QccVx$IoosUP{qR31E(X!Ze(C7cuP`^`$hE!v z?S-e4(sECKXYAa~%2(Wj5Apr*&e*YYD-ZI!t}iDtsX2*jNj~4nu?bfA3i2Xvp7wI} zjcKn`FCs@Xlf1|iV=u3~(jSceaO_x<3z@?E%G>M?B$-^unIC{A`2B!?)aFILarLA8 zR+OS}(Inrf%}La@-s7{P+E1r`EFcG1{H9#$Bw%Fnd5QM#;`vR!8Rq$v)WB=h6^I^( zwlaNI<#A{$5*Q611?pEL?>@}P3uuHJ=vR{gvp_cNw|Fqf9QbXjbp^K;!=+ z8A-i_W^yLfcAqR=VP$o&uTT3bOQ`YZ%GCB1@E-g6zU|b0_O9>FQghQ=f5erqsuvS0 zW3>B>>muypCp+t#I!wNF!fVWhIQ#y8sR zD~Xl0s&$eFt^Flrt@iPXj+VD$Nm^f?z*yU6|(FJVrplRWoKh#*(-PS&rNJQ;%U`j=z{-=hnspml4Yvb zW6QF{o>*BnAKDqJUusLTY$W>-u*nDb_@8D^R;_GQaOwK}&vVU%b17xnM%GyBYpg8u z$~E!j8Dg-*@nyAY89HkfmoKk{pIv{;Lgc^qJhR3-*f&>_5I!`U`l}^b;cq=+w=Evu zNLxC8+RM)iu@}7WYkzIp{XxYK_g`Rd7QXmsI{?CgKEYk@0XNGZjla>Q6yiX8%9&UDI`qTutW3yaPig>&gv|+;=-n%oIgy=_tvRgTTfgyb-Y!Vx+bIdEOTzt zwmnzEi~G+zUbUgKH5HQ+Zqpg3yVtxQZz=Vw8JE_M8k-;4;g0)e#uN+3yMNl${JRBw zv42_hJp$McQ>;L9{QbcL<<#mfzmRu2XH497;WPz(cr|<4J8r>d1&(S8r-AQI@YTe- zUC70Y)Tbr(zr==V$8L3f1N8*Xu)gU)9`BmB@f@#2+nq5fibEMogZFHK<}|H#?)NvhQE^2j#j@xe&D>Z z^P05}YC85Cc^@acQk$w3OYNk7-!5y~X4MMO^Lq9rzQ@jqVyEvkXR-J1WS{IV_Q}oz z)(e(@^OQ01mEQM-TmAE%(a{bsO@q!4!UsA(_V8$H-wJ1Jh49J@bi*e0OV1x??Mq5N zTAlb<;&zLinsO_#x%{V(Z1M4~@T>1>d_OaXxq>#P@ZO44)gX@RS%5v>%3Q?Ke5+a0 z1?nFZzQ;rF(9oGVHVsLB9khHs*dt3^`p*DPERO8)&Fz(*TEU#IU`|&!YsOCI)6@}y zb|MaMeN?cpe+8=pn1a{I+^QzDYA!S}$4$sfdmfvrJ0{F!tjbmJou9@$a_&d-diKmu zVZK!p0K03+6f3^DoZl6^*U5Y~rM_6*#hiAGncK`5&cCSnW=?jc9-*$e@BB`FhpZ`^ zy?rlt-aX1QGiXcCb?}_dwGCNuoHd2>$#;kUgyo$4ZuZ$$ zvc12=@10-vnY^#^3oS|I=rQV>JOqs`*@-?#VP4Ago&8VlcN^CW44p*gBWo8UzdmBu zz9`36(*fOz<_-f>_II0+G4b{PMomG$k5(k+4Nr6$#ox#zJdQ?QH~WJRrj9gW@u}s&w}pQUl(r~AC&3~90bl0{I+Lk ze*^T@1x#vXRCYtJ6LYQ(KRP-XUUgltE|YeIb83n9q`!S}NxSNL;KPv)klyWpzdI#A zH}&gVV@sgpj9#RD=|i=zwtw2E=WSf=-t#e2_pl2c_ROC5L^sk;+4vpTab6pJ+{t;5 z;-Tk*cAu7~1nZi3R(+Cg^;5&&?UVZA_Uqtq{h=+LJu5$z>Nk7`{yM&9W$(=Qe+ZxK zs2=KOE=jRQ_xSl6utqgcNzRF)pB(*c_jwnzvy6G%Ma>V%aLH^*I(6a92d+upgXRejwFbHYCAG{5ryY+Kxxul;AjAqVT{ zTGaDa?JDZNyY|r$>P(bRBz6GpiSGVf-ic&`+g#=z+7_N;>3DPu9e^toR22`#ok zn~F2Jwg2uAEr1KfcG|Gh+M!3at6HmFw5v9C4xzViUfh4qgL~oMU5{i^`@?I?M9?<@ z>>2dh>>_M5$&6Cu#P!&B$`MOr{K$>j0c^QqY`In33vA7iOqi9yGx^X|8ujisDF*}H zq2~&N*2&_=#Pge=B_C}EScAtQgW~aJm00n%Pl@ph2h=8es4cOkdT*9XueQw-z%Lb8 zlisWIF;t`K%i1qa`&#$7HKN8*%TH^s<6Hwq9eqE~+F*(L;KIZIR+)g$>J zP-l8wgM4tB(~4uCKdm~!>yL3Bz^yzt;m+nGjG=}xbZo4t&YKk1to4PSwWYsh89N)8 zdbfyo=J9+v@3rvG0&qlqypx=P+?>Il|EcUzUd}k4856238xyEZL$7J<f!2Q`n$s!gVqKK(8M!A{DRO$5__aCg1eQpk;r^?BiGZc zgyw1B?kIJtGifIcTnb-LU^|@wU+9zOPVhAy+>U}?7EOJzdR6N2>T+z#!YMCS`^Nez zuLF09V-pR{weW7aV?P|^`<>V>!s#+!{N`O&ay@#S+BU3Bz!P&<-^y>*o7e^Ha^|6s zw$9pMA<#9MYb_Ofyw`^R9fZ;_*GLsP!AC5N$4 z@6E(H{k#+6o%Z>`wS^sD+HxnhHnQ-5sS89+cHt-oc+{c`-^sgze+-!yAoe1M9i|ey9(>9dZ2jfRo8M5+P4Y%(Qo8s)9#fyu zUBd-GL!D9l?-`Cf6un3$?xCOJi8N?G4!YO+U=H)D`{o(W8+rkGkOscekQcJEGNuq~ z;F)}WhmwL@Bo_+c|1|s;oE@<_4O@@eIuAvVqh5SQcy7RN_E{#7b0RqInEu@v)5|gR z=8i9!GX;k8H9$AfQ{S|8f)tt+*8e_U_9r-JNBvIr(vVE z;-^ig?@Q4+5oqxs&w~Fd>$mp#LZ79bpXq&kv=PZ6+VNq(N>5tgPx;slHtZ^^C?%|1 zq5{E2P89*0K2=%wQsb8Wa`)>xy7ced>znNBf2-^0*B^Y#+A8?p;xm=+KM-8(LFj7# zdfHRXrrb?&SrLu%R@M6|ZrJvD-_|WW7gU}9m7HtA8Koh$$9r>u6VzExwf4Eacj)?@ zVDy7rk4&Op&e=z^xc-!2O@i*Gq|?{p2GvAs=h=_X!e7C>tmS^HU=-bC&Th)&ovmE^ zSMHhEhI0nSV$G#f+luqE2FMTZ`qr(PsoI#!uF5^Vf$=qAbE&4z>kch;K|5!Vd1VuF z@%Pv^VmUVKQq~cwIra?mmguu?TM=j1PM4o;h3dsctnmK;`wZ{1W~TO6{N^s6ot}vA zhqb59)Z#n`)xEFmVh!pGSo>dh?BtcxgLO%cZA2X_QA*?!1*6mBEo5hW<+F_4<~e&^DcP-m-ZPPF$6EVjo5s;kWLJ+5d}0HiP)+kf z{GqPR8er{S$l5)?+C71>teA@q#`bHQFM34(Vh_6ZatHS9cIHkty=?n@O&l$*j z)fOz|uY7jI^TWo*k{ri39E#i=zN6>jwu#K2=sV8Zb4qlv2|P>2sb1^h(b&T)({DFx zZpJskvg;emCQDygaQo77zp>lBv=@{vd(b{J^+Eb<(e4IvNsRn=>RW%1T7!}qCt~D< zJDw-Z{#X1Qj;}*&0=tfGim^Er)7RQuwX{T|>6T@58M5MJ=)!{2+Fzy|h7k69JhUd* zyC(#^thnZ5%*R>m|Fkg+ znsp{Z6MA(6e)*ZS-$QM=2Ym_6i-?6UN;^{RW8EF1ZMCI!gzQfBN6+nMPUX*-!Fx$O zt7mGkQxx-FfNeL!XXC^F_JY&wAvr00kWUfb@P~{0cLB2kn9}3&ksU+cDo*b6VSnQD z(%MCBrD0=YKh?|b&~vIs`840Pmu&}Y41PDOtrFH2J9y^-`ZLNG%nGIXO`j#V)h>3G zSlr zKi$k5=Vx!pOrwsXLyKxpxe;1VbVG|e3+W@gP={vjZQJ=KKun?@r!pf?h&af_ZNzG@xhuaUk0F)n83NWG^U| z7_6}QK7nhJU@pHW$JnzM(b=_rWFIxJ(M#dTMqgco8oja~Un!>zcr%IlSKM?C@+++o zT|#~CP{|eH^fjry*ue0p|NLlhO#FLq9`Wxy%i8aw26H0sr9fx-(2?xs0KfnJ?8R+9 z>Mgg?UXIpL{Po(f>iZS+{Z*&mw)E}qe;xIGz4oePsSQ zy}<+cbN8+&n|q*(y}0Pu)uo)b+FC{q9`ajyGN3aCOD2T#QvJkV;_4@n%b$nNw@Ush zoLpvYRXMp$hykrB-_{ z>($D5@K;I=RedjY&Rmzh)d~;#YOQbw`d7KGYC~r^BWq_(v(EH#rnq@`8t>}+H0RwK z@?qURPNI)S*RY5d{ryZw{x2s3**|VseVwf!6_!&tRAABz|kt)t!d-Ql;N-y1|?S z_SEN~>q*!LTPK?}#W(qF?*G(Y7k*v$wGP<%SK(65kobFkm+1GscNo8CnBU5C_%<~Pn3m-b2aZ_*$ zzO(Z=aqFvP6QweKt&ysi*s+Q>_N2Bw&sogRbM`XptP~@3-oGV9sU!%AutS+Sx@rk}(6W)1IzZIPEERRR|sA zg9mr+Wve`$!~C;;EQbF>(2*Y+3PEq7)Ig=^sStV!<@v&eaXI)AI46NMG=CosVCNNH zUQ-?D8Iw0 zeffGB&p5d+>pZmKmCsKgtGsIots{KUdn@uO1sd0w3N$9gw-dhB2%E67xHTDL$$ZLD=Z=J?^NIH}ux&zwJTp=Og;uY12E&khhTKQQO(@SJ$&A8p@G z&vwIu2R>Sjo{+qa*glstx}Fj|u$b%L=baC^?>%Sxc;5fnemN`|>~qF?)Wc&M@A+Ko zWVZkEp5s%+W8?eQJbvt}5VReFwwH1yS-}*>_YvYI+FQoi=mo65f|EXfS~kpI-D7Q? zgRCv!ERyxaO>|8*lArm!^`;4fd)8Z1irX6pE=8M&KSvwIb$Tp zQ=21anA#i{!P~_$t8aeVJ_1drLT9;c%l*BxE0CFzJF=IW;Lq*r zf=@2H@Ybf{K-_AbFCZJ|(F;q>Z`qE^E==6C9lq1O4V)_=nwO3V@SOXdGoB9ASiXmK z-f27YwR24GZ0Qu~AkGpyptVPZS=YtSRJ^=oOvx$fQTe+X!J}lEp3&ZnNTByw>G6X@ z%xhaNatmLMPxC6eV?K@l;PY0vO6NKu%6YFT!c_Ye45;vu8~6c_#LLCii1$-sRyCG_M>T!^h~}beGm8%P$chN5I(<{5!R*?b@)l z!I$CLNy0z#;H6=mVRpslf%5HmsV&Q!hsbBoZ9H$Us-lh32O+ zXOiWm;5C;y-GRK8411ctCD>8IyL715C7Rc-zXG-LcFYcB#H-iJKQJ2*PaJAUca>lmMM_PqA0 zZ9h!z)1?zxI~V-s&I9^e*na1MV#~ieUv|0i8H^3kd zm961leGHn^`;Qi06)qSTtdo5^4mwfaM5FrKi0)NCi~IU%$6+nW{7pB6^RbzxLodW` zS;q%@Hq+*vN%;1FvFzf-Tk)YSQ9JTOX>NbPUybq265m$k8}!W|>oPAtNfr`=$IcCG z#uh%!Su2LO^2zm@1Aq9Pe6XeDgS~*SB@O?=As=T|>CExK0{fY*%eVL~e{=4Id8g+R zpHVDjC;pvcYwmgU?UJ+!A(LMye$dztA=}f5rzs~68|Cy1#AP>>FsLS?M=*+%7)C z)=nxQ?umA z&pYw52e|&4;K(lgvLBi7c!TV9bW*gEGXc%B58j4O4>lNEoxFewu9u*jT8ZKLh~Wi| z?P#Cbv-`n*nPJX}*fV?2l#Nzd*i11fRwuqO_nTgEnM>tzS{nqn$qr-Lm zlOMCTX3~}mLv!W>P8x7hfiqzK676x^sc|F*qo<#$}#-UGtl{J3tS~b-*3NFTzB#Ccys1h`)@+gf;`r{heL|DoNPVFJHYs%Z1=NB~g8cr`jlcA))3b(yN`%f~y8ruC@DB1yRbZJj2alReska))M zWj+}iOHC+h(N5A|Ge^JXz3=&>Z~r~lMf0hDZO*y>4mgPXI>fzDwBF_i&Xu=mf4A<@ z{vP=25!$;IZFEzNA?{)pz;GCMAZSg10wu|2NM4Jb%(G?8{jZ;Q4@;?Z9ic+uUpYVpRAk z?7c^Mr;}@fcf@UP=M7=XXSz81?ZlRr*)Q)h>I{V{m#0S>C$hX1sGyZ4?*Vl7h{;9pX_Y_5_gP0$N>Mv4L7OHpU~#^Q1tzLPvsmwD;NMC zH9mE3M=1I|#%JW+3A-N|T<>7)S{IOK*PHWG};r0`{cN*Mn8ydF; zA4j>5?ED~Y8u$)we?@;HN5KQ~vd8V4gWE2Ce~jn7xV=HRMIRa50$;ezwA(yqIk^1~ zwRxI0w}qlV=DQcSr*se8-XDY8m$`0mi=Gi~U*`UH+Wi2ZGuYWK9)#PmT=U}g-_$0! zmHhJJ_F1l{^XzM~)A_rR&t$#Z63nW_erYsv&^}A@!UMjozuQX8K>S;KSvZHd^fSEw z9-ddeMNUG=sc{=^T_)XijB_mHv>p5EN@O9vfOdQZio42| zQvTIJWY;M2bC#2*u4h*;mbcJbE!Z$RpIvLz&Na(>RY&`Eu6uKeFItzMe}jClB&+n) z46F9dV>)Nfmspwfc1u$7wZYS8*=yAWPj%N=SvuEVFclxoCJv}PY3=LPUi8sE&Rya8 z>#)tvLMPXe(|2gi>R#n3BI8U>d5_`}K4da$G5HR)rdG^D{6M@U+68}h*95c9LQ87* zg$oy1U*X3gmtZcr5mWK)Dt7jJ`81I0dFbN2w-aY;ZENdQ+h^weE?ekd-1GVzeY*CDOYT-G@FPn6G5fj>lemyWN%AEImWqw35_;re-S zy!+KpM(4#_`*rrXe3jxI*%AqN-IZGKIc_MnQ4crD_eV^e^ z*ADAo9t_TfN7^|$A$XvhJb{#tTIUN`e;z9#_P*}1)0y$fm7{#g@(ow5W{+JD{T;n( zRqtKj*p!bBK_9E| z%g5$wnK^-eH71SK$9S;!kFi$Wr?t5|wj=aI=U-+x=U;X>{;4^PtvKNC$!ATYn2r2X zy0;VG&m87kJY=yiQaLGt@s*$2_GK$D)AJGx?%ddp)*|z)GtVhbD4&%1jsHu(=i&R( zy5bCUVfGu)Keea+x$g(C$LCt@&$+Q7^PBhed#>}o-q9xl9sTovT->Jh`_v=$_$%&_ zY?@@^+*6NO(WzB7uS~7-9cT<7Z`_{0LkHd?MQTwsU=`cK^vmW2dMUIvwhL=qY)^)z}1=0Y1TKtHaAFzJ#87)|K__@fdZPwQuoj zpNy_1XKd+f?~6Xc-V*X(4ya~_a8v_NB@y%PMuzkt8_pt6-d{?sQf&9HT4S0QJ@(k? zMe(C5zxGk<txe~jhhcM=d~Xc&eO3=Mlr6->VHl%Cr3Y_85pZeOG>L- zsU@L4?W{@&s~tCwVuRI+O!S$YhYdy^`YJa#@=Ua&wgc4tU>{wxfzN!FGoN#LPiNb! zuXBwY^!3R><=$MJXdRHuP#q(|HTb{}uo4+<+ab0r>lkgz$GLagIFs+NZDNTX2d0fQ zXfhi=c?di`?a0ysWXasSv3ZcAJCUQ>$FLJQsHX~ZPLf0IUhLI z`}hV-Zo84$;6i_0d&)llxIaEA|4Sb|;V<>mhc@WS;0-$ZF*bs}w}L}-=qB-8p);4V z4~m$RqLPcx#ewf4=A)Q7d>x&pF%&X}We&|0LNkSop_sl&2ktmgR!vSsGrq*tg(cw?CxKX?i8GBN(!o%QLu|k7SY_Rpvy)TW>wR0Vn{L(3q)!p> zegSz_22PgIf7iYjK9(_#G6!d8kZbJGG&n2v;LB@2Z2(VA(2DFY*-y5-3G~UE*fWsJ zz)=}Ef*xlX{4h2{&(Ixm9_FXeM2>rPfYq|ITh@abYI=TJ^=t}jw zV|}3ZTEVW%k+#;(@o{Ot4VYTb&%-Wk;ym!9R%>q;GEjPaz;$5j`ccOw7Z0@I*Uh!o zpNeDL((k`Y@@+*voVTojdtYMvBfm{+z}IZYQ~dQ8)(q~GXds6^rNy36?!8W*LaY;I`MhQ4; zy@gyGp3jBu+nLKU>>kO0HqN$3Zf=&%VA@0e=b-b=ywJz>^gqJfh_0yb(p-Z7Rrgvs zSN>7mgCEtlJ!f_c&GSE|b^<=Hz19rdpZI%zM;KFH z*gRilHQIYKeyi)u#Y-{I|4P@v=Ng_r2#mkwQ!5|XFKj>ctmWid%1=g2?-1*b5AfTa zdzU`rJ-GRI4}G2wB%1g~@)_bEf#d=44@Hq!+=X22PnjtXcv|Io63J-!0+fwaeBVv^cv4KiPjL zSXa5vN@6n8aiS4MZeA;@-X{)d>7`;txjdR*6 z1P61lDH{ivBnn=4- z0-S-4Jyf1|O*k&jw(}Gdm2L#aesPJ|vDAI`-$Q7c$)4GrrI@w&corA`_dtltfU zRRB+f7EBHAU|bb~^}C^Az2t>ea_z`q{bnGn5Ha7-x+}vOzM6iB6G_0*&Si#hh!Ri?Z3tBuEy7iXujzGz&TLr6UXjp3pz)Bk#tgeBu z3?25~5e`5b*9cbE(6Ii_3oCDgu-XT~V$a!>a2c@v@b|!K9~xG!7gos#VYLi`RTcWJ z&0p<;)zSy+gqOc&^uwwuFBu^(ziwcikSq}|oB29%UAUcnxW@$R^`T*n^}?zhA*`Pa zf^{Oa$EJ<%2-Z)AhV^D~A1_yxHx37D-9Q<2dJwF0p#|5QacvQ-(?i4hi5FI6gs@&2 z1Z!{T_B+DuqaSa0M6g~N8rBcIu&m+aVe!CmH4lPyF4Sm`>wdv%9vaqDURbFkgmrWf zti7S1UKTEdmvaQ`=+Lk>cwq%c2CgJO%trcL&I9_g_SoPtQgujI1pAy zGHS-Sa4xcUv|t?^8rI!jSS2Hb_52`MdqYWAh1)sv=iF9cJwG(8+q|%9hl8cF-#vQy z*@3Y9*m$AH4dKJsbgv24vqQsrpBGkSgs{Fh5EeNd=R&XE5H1APzY5m(hK6;KxSpez ztIDm3!^ruVaea3nEXIX>GbvmKtQx`k?$EG0ys%P72CkWQ|p<#W>3#)O2uvlAS zPxt9cLx&wUZM^*?uvlX{cJBba9P+}7j1bngL9oy%HyU1U6Rd4R!$QeY_tTEE{uqFpw}>0|$%DMXZP1s!p(AsU zm+KmK4%5aP-Zn~x8*>creQk&_KWLA6v&Q_jp~w7+7gp^EVO0)<1>Kwr?X=ftpB1di zp<(Uw!fG5Ltc?R<8Ge}X*TxoEB3K)ThV^wXtjOSb2uU`T+iQlyLOs(98 z1mUf0XuK`n1O(+7ZHfbRet(jq7gh)hs!6La-hk z8rIcbSdAlu^~gY2hGvqkHtWp&g7wJIuzpYMt4{_*MhNTkL%{m74XawPK0h?97B4Jo zxb<_4eEjS{SnH)@u9{$c5XFM^*`Z+_@xn?SA*|JdV4Vo9r0xmp%p$>BJv1!Ei~DdG z93iX~gJ4yKwq6&`0MSS2>Bw+fTO z#l;qT7U1D4eNop}r5Ip0*Al6dOumDB;9)Yh&c8t{yOVd0vQM|0{QxQP*7?Ku`+9xE zl$Q?dm*J1{Tf=4WN0R!rbm)G4$lI^l5#r#!fw16@s?bku8S~39u8b=80 z;|A7V&$=F)d7N3hof52%_rcoh^&yORVC`gIOu3aZjC~%%ANLG|1rCwXiN+uJtYF({XQ^^u|b^vYkbFU3TMzxG3_{Z%>X>U=xrl!I6OYuFkt<*c!>UPvUT?|^>^{m{jK-HDj6ZH z`2%4wXM026xF&oUx?dz%^M{7@H7~5%5yHwH2rDG}=c?NbkKHI(xkJPHf)`ff2w`Op zgvGe3LO-{CxK{~Q_Rz3C;e{0$A*?wAVVQC5w8s^#0@j?NVSUgGi(G)AeeN-|F?$GD zx6U#+{4c?pJv6KgFRWA>)*xDrgI9?ayi7Yo(eit|ZR8DZgK_T-UH)F9=bs;@4WGA- zk|EnT7dm2(r+%0=&Jly^lNYsv+ejB*-u`~$YanD3A2 zZxJ~QXYuJOC*XDR6IQ%Mo*yxv-L$FeGp1X0(??s;XOQ(>scF=y z@Qm<^45v;y^(n}+JelIN&VQPm$9DGooQ(vt7G6x8-8J4vZl;g8E47Z9gR~hpgxl~R zzsNqHT~q!l+{wP4um9HC>h?i-4az0!8wa^0@Ave51u>(I#EevH@ALF^Iy%DiF>E&dYi;&A|K6-;>)j6zwLBye}0wTO=S<3 za-kGscH{XAsd40uFRT4!^K%cJxq`f^6ymrUisKTqzK%T-_yp9xKB+usQPbk9KfJh2 z@8r*B?~6tK(#wMfinsb6BJQ_D*9+%_qGcw3{0rodr__h2FQNSLFO#EW#xlpRINjdD z`JpJZWPj%!`ai98kZ0@8w(a`W~oXOfuaEX7)>b2K1lnOa9CXPH_@Ca1{2VD1j7 zU)Pbpjz6R`g&fZedv8JhN@_;^V@};h`gjfVs_Qcn*~`YWY2$r$dVcCef1Tc$mJ+C2 zxiY=CbSr18Pp1}mxrq($&Cd=gero!bpY0!n`_KOf8OB_gzR-92LazE_4QY%e%^Dj{ zzbW#Z$#wNyV^8O%0_v%|d1H1=-rj>;OWlCY^tG+a-|+O;zy4;%x)-(=UI?8oObS$H zSb^q_pjB0V;`VSE`<$l&W0!S9c*n}L+ttTx%XfY;cIMX=FNZIzqD1y3J=XBazZ)1| zck>Ufhdz~Gq8t$AKioe|{=r$3|I)u7xNmIa;yvxRZqwMcZ&f)$ZOq$Sfs~o)rrtuz zYSl~7_;sC}c4{u9tj@@sXc?diP)3`8fUk zarr@czvG6O5A8Qir;j1#Blo6OGaue-%HbqWr9Ri8EAgp4Cw3p)e4NNbHoju>aWZ4_ zgWUeO{JEQWyP*$gN%?3t-SYj)!Mxl8FYEjcj&;a38;~J|YJs3tCwZ zMUYP#vpe1gTo~}Z##)RVc*J*v zxN0f-><#34TMGEcKJD3?Q}^jl1+&2^NC@u#7=ub^i?$^OFY zXIpi5(a&ExI^;9l|0?%C93PDSJ@?-~$Ey2KycPX)8s}P)U-t4CYu_8J(IV`!-H=R9VWwfK5m{ipzWGpVuha0!XsCtK4;?(T3nZfc$$2Irav7ORwYiYTwxKgEzlnWR3UQr{7+> z>7RU~>K}aD)?u}r8$dXg`-6$|?LVN0z-6 z$f&Ql{4E2+dtJGuFOpx!9)mtTDg5k!-x+tbLvxKyb1!`3{;pVA*sE(t8SgRnU}iDK zsVP?74D7WJ^PGK76?J?hf23PpCKrhH=;8Z)TYGY`HM6lrg6|9e`c)(As+3RUqozSy zZZOJz!>Hu)yovcE&S(uGHPMmU!I zD*+$)QTp=Lkqguz933Wyx~Bx1JcJHOLmz7XCBvmZ(kA3UFLCugYSpBVxeFhT#rdQD z9%yAr3c14M_b*8wqcd6huN9KJl|Dvisq|kvES)vxBlb1+M(W*k{9@uAu8t_qQaF$y?&n2vy5?HA?JP+PNg0cxGLQC zl>>n*u_>7^dp_-bP3!*_9-(G>+oROx(R1K*DdVe6p;kXQEd-|*X)}X!LE72ls@iKc zoGbDKI4PvYRxW&Dr6SvSzu2;RXlqG?dDS=q*U|@U_al?6{l!7coTo$^SLuFxG2>*9 zR1ssZxr?!LZmV)li>Ol*H-63RB5FX@P-i}$y;Amh7uGCaa>*%;uXXfe4Mm)((1A>> z0cI^Zk`=Ub5FF+pcS@2<`Mr68lpp(2ePQjx_OTw(xe`9wQC$~b(8u`|{AG`h>>FKAaQ?EcGWP}7*GT=> zG1mT4aN*~pygv5bFS(z42iXsm!`{J)RPx8AAE5>MykC0Nwi)A$oc`)Zk<;i3`4YrK z!d)r#m-E4scq99E>)}%7LhmUzPv<}sFwO$xh2Ozf8~9oVK2_T`?e-vNoW)nBk+-Ot zh%NM~o1EXn@-K2I)#n|FkoUY45OW9oEd^BVAH;h&}sYDb~xuGAt-hRtW$Fo%>VmaYwzT6w4KZU zCrAKBb@-FH~2o}I0kvFCT z8cZkZOgd2s-=t5MW%cR1oG<#fSEDa1HdaSS0L!m)UxDwbZ!vI1j$=FawjaF_+p%H4 z&QMc+Y0-2!JgI2jxcIcQX6qJsR!deroBxOPjHjM2P|y8Y_59_3qn=k;o1FP-LHv`f zXJz)Sm^y2yQ)hG3QBN`c^ghO*a~m1gnfCAv` zSZ6CDuJbnTGv}`k-kDL=XXaS}OS&Au)8%Cc(aP5F%LC*0DJSr)QopSqx| z4nA2w;Z|41wZ0Ya9DwJy;g>L5ud#d7X8Ha33+?NBVE@&+S5A#q7oR+X`>PmV@WS!+ zwwp+N4|<_DzI{c1b7TDLjBg6>H>&;#fp`tyOyXX+<;P)5rkp0G*0+JSX2=$?aNj;h zr-FBn+8YbTMbDD2dYO2}0CwHZSsS(m&@To;pWok}e{kE*^?~i;w|;zX&fi2kR)2wc zA*Q%wa-bvli0P0#EDYz&ESzNR0V^+|;n@Ll-+h{QOVPi>ZeE-{62YHo zwoCv<>-P8k@r7gZZM$bP#wFWMXEgfb;jC`PGa64GfIbiAB7@NLkcZFIavvPao*ej< z_9q7EW6+e_+_HTWp)>F-O&|NHcl10MZ-3cz>A?$UHo#LVkORu)=jUVj-0|VZ`_RE+ z=)?6_N4x50pzBaq0Da@~Ezyp*kylCdo=VX(xE3?y)sfCNnf~xjh<@1|u6!Sy5$$3R zK~Io6Tt7p$pOR}1R<1vfH{00rR-MD4eUOVco2~mPGs26TKO9d&OON_6gm(xhRtR=^ z(KP4LjOZG|2eqR<*meaI;8o1L2Y{7$-w1s1yJY%v3Wak`HTw@@PZg1$FM|GG{!wD& z$s4$m-{kbhF6rCOe!4dP$J2QRty+14zK8AFS`qtTynIz;p7ymDf_FC&7o7;1{qIzo zeX;98mS;*AvG<{gW%_LxBri=<_0YR{&bOekUBlRZ;*HDiZ%0->v&P?(j`n-tSHu2S zzKyMeo!WRKbQsBxfAQGx!Xhz>lSFFkS{?V-OpXd7o^+~>cXTSR6uD{8(=;JQMuQz?fqmj|E zzDcgI?0liab+hcbshRYw`ML2N`2(eA?_QKnGWQW3u+XB7$F#?VTxUlsN|(x}?5Tlnig+HG6q~AgYyM%;d1CNeYeVJd%kyidMk2AP zi97uLPmZoNKS$_u_WZn*m0v*pO)|el#4JVd!H}1x>yOxLf?ul(-&>qBV!9H{o#2J8 zwG~^n>-YJUUaW81wCBEiQv7P#^6RntDtQmRZ%xdO+3j7tY94#8!shQ1qh!`MDWg8UqdHYzIeb9x>F;@HpaL4G;8{O3f$Uh5qV5oN>0bIvEp<1F<}>yw z2H9_??^Q0_$n7z8xq*d4wfIY`(G`>Qo&7gm75UOF$Wy2LT42C;0qRp7_{v@mP~T2) z?)`H?INqW8sAEh|WYNn*S@e>?=9i8nnp=KXrKQEpoCmHvl(xUmJ!tn4y6arqA3Gn) zfnewK@^~$F)o#zv@zp9$_9f*DjKh~c##SW&8?7+y8Gic zw_pmnAewpwTcmdHVyi&(6{WIbN^mkFzK^xOs7pZwDc=`WkkeSZ82a=Kw_ z?kkFrBM%xcM7AY1XMMXF8-B8b<4-;V9OYM?9PRo}h71wEJ$oojug->@QWAu*fqt9@?yb)h^*gUKE`R-+X z^CbAKJ*0J-C-%uUVaqoi!RA87pF>~P-1uwp&6LmP>@=-+DK=uq3*?Z1Pq#Og#s|h< z+|hR*<&~j$KYg@u5cIR}t0tXV%wG4~OKzU+Fp^TM~YtwFT9EfShLo@X2=eq3XQnZt=x^bKg_% z+b_F)r}lZ2D z^d>xf`!Vtap^F*xu*~^V{*)8g0iOi#R+2;N6gu8ye0=!_OXB2++Jb+gcQtuw3j+mf zl2gcYvgDTZD$dS1iTyQr7q}9-583pk{m^);)}dbOZM@Ria>{nqpC zqxa#TV6R?m$;FHTd+jFHdKlly#qK=SCvws8w7=d=vi9HC*02soeKGd_YcAIP{p5Ho zWM-mGdblJ|Hr$ZYF?^j-{yuzQCGliQfPRd_E)CoMe4hR!u5mXcan){~R3`k=-vVzxyd6{YiujLoMlSW~nd%SHR>ioBzuAr7n`j=olO0F7ogsv$i-II zmd+sAuQf8YE7@=Lh-z|hr+NPxZ*G45U{{LyjiUP*Y&bVZ|Mh&?d1q}uxtn>%ub0sL zH-P8zm0(l6K^{1@o6SGE;TwtS_t#6Z=~*xnU8)`nOZ2yY^6Et|xPTvas^0$w9kUj2 zrSkiaKR{id<}ZQo!Zd#*z5#n{&K(XOKllsQ`fUDBPYz_r&>Yc>CqqB( z+2vM#Ho5#x55ZU7y<{}Mli*_E(E47v@Nlo$s{BEP>pdDZSAg51(Y%htA^n>4L%(H? z?f{m;JFUk{ejFtChVWuJ`H=Oyf_%s)OwO8v=r9A^x8vJZ9<3bK+wDF1kd>eKB+m|$ z4_SF?^!`zg|0o}_a!*N)wbMu8F7s@EpRm7$oskciIq!!*zTEo*#wVF(`xTn3vjomG z+#0gFB{G~f;*ZAZ3;v09`v*#bW$p0F_Qr{E<+|d0+4unCU&{FHIB%`&%JK4^WaH}u zVDZn=XQtb&JtA+T`Q;#ke@J5$WL&{B$_nG0fVXqbu^BX3f=9`Z_|h@Y5n=-osBLUYYo* z^4!|G%*=R_oVJO)+>S((oA)uQi`6c~Spk zC&rdZcL-VZ;!nK!2oyL?SsPvMsk zY~fpl@WW9)jGj}7Kcawq%pY{^HZafU$a(*M^>$LP`a6SMGZy9<{60xNo$&c&OBM17 ze?GjAz3LbA!Dc`nnHnrf8X>+K*7v5j^TzS~bOZk&O9(_47%gL&<$>GN% z!{kr=uj+%A1dD$qf1POI!27`CA$vaWsQ*oKXyqyVK{hivv@CB~S2@Y*ZqkMP=j71B zR?F#ZK>zv`emvpex{?oD9-_0&YRMJae+hjU4_y&|yhA!L_?x^0e+=_EFdli2k4(AI zlU&OdmW)+yu5zwZyocRQRn$Q%eCsOTd)XyGTb5`#4eBD)?ea$WhKy8V!Cg`IeY(`)cZ7l zo&0t1_Y{Ac8_j18^O=1hZ~}lk44pOe&JO5o#<^izehuZX$gV)Q6<$2a zx_r#w7rmKzmo3ltA?k0aGWTxax}0`XwEwGz9pBQ}1sB=#c7D-kp9eov%vS~YUrBDY zI^%M(jg+Css?3ki(fDf^zx`!CM`_Z*>Gzm7;eQN%(03WU4m>Y`rr~MN${v>wI|g0< zLeIcY|JhSKyDDSO`&W^Vm$FQL=I0;j`1AMXAL)Avy;S1+kGiP^$FLme- zb&c1>`_WVT9{X@dU!C@mne>goW_YjB*~hJQ(OL2Sz2N2Ue4A6Hk6*nrn|JzN0WYXK z(T9ysohj|SN2(}L9dhE_|8h#$8Y_{ z(|xdS->yUMlaD+Y{ra(r`}V$J!ols(%$b_6R;E*T0mt2p$AqkpSmo;~r^Ml{9r&2e z6h%KSo8@RZZ584FD&|`4*6hByOEORI*HI5KAZMBzKOS#dTQrpUypnh88ZV8*@QWGRZPK-o3yBUg)mJT3uIl*uLQ-ed+n($L%x0&3|wA*e?6L`||ihoFA?CGrut} zAS*d1&X{rWuYAnvVyt;%2Qz0J9x(F0zQJzot%wp&h`g>}Uw(WOZ7470ia-48V~hUq z**DtP=WcI5_rUS?zgcPX&gon|{BrST;An_inW8_+-p=@p9I83pr0O^N)Fdxkj;Y#HbZ=?uGhl`-VM~_z#ngvi_fF z$}6+|@1~8n;5l`!4>`02jdUf<&|LhzpHBgQaEILc{HBA+w#h!z6zH)5CubzElXWLKTNFF}( zMr3{W_Qa0p`N>ns+cc*9ykVWloV)$F)+jYMZXGqJvv7tvTZ?Q}jzbQ$Ue-sf@NQy- z>-oN!v#mFQW9|7mA2z3FLCl=pIM*y|Cl2K#eR`2tVf@4|BkRvHf6I;lx50Nz*)+}p zJcX>&{YifH-LC7A@eRU=s;`@HSZ{mB6vktoI+pe5$ftG$^j5ji##CWaShdTGX z3NJj39C_TN=Q4KeyH?Ze&Q=)InJC+%>03DK%a-drKx9Hv<)^`m$2sqiYy0e!d-iiZ zt3z+$8>91P40XYGeoXlS>hBw4xQ|O-EIrz``#h&R* zJQj%8XTae<*N={G5AA=D_O(BCBebjb6Kjx5W7#_hPN%+T(xsd+)W3$d$aha0=>fiH z?Bs3c3?9b**VJLCL$sqkn!jYue!)Ay?Tndph4tG!!aHwKCcL+DtZ}xLW7ap;HruXc zz{kkvGTW}RC>B=l+OmWz>#NI&HvpRk=%;~ocfqIrKOVF^U|rv2Yj?Z|p0%$JZy#v- zP)D-mG8;F%u4D?d9qot!Bg3=A0cZ=jO%NVuz)ZHx=QH6pJ`lew18#dW;C3hDImEs8 z+BO0=^{sG%Ir~X;;KY3<9Y&{J&RJUnpCu*?ST+L7HsJR)zR85$BI@}&)k{69W8kw< zySL*J|GlV#@eRDE_VwJJ!vm}gx)6SkqvqVQ;|cKJ=?AV%uvynM#p$;^Pd;YS<@)`q zgImpZd=>BCMkn7LCQ~pQv-O?8_JAs_I-XF`GZY%@5a~oyX z@b_K*Zs9MJx8DM9y7MDu>xRk1Slr!QhA-k};b!sO;j(h{po1_0-RsZl5pCn+WCIrDwS9Ch>Y4W8%GhCfa#e;Vh+L>BJjIDm+1@P#o=7Vv)PP~`nE!W1kmYA&p^VBh|k9IJBR<0(T?7nun)04Yx z9^}zAwBa5%K3kr#_Linxwu9C<)lYP%ku}Zttl4Mm!+MS}{v&_HeVs9}V0m!EWYxN=XcUeUAa-u3pd%>`ZicHW#~#-v=wy~wi6@n|jr%*B(G3-10uyB<*K z)&piv)Ox_^`M+9^K6j1Fcby`p)AK*~X8NDk(#K-@_dL9=V%{g>#n^u46O57k<2N$K zqomV<>khzvVl)}gUH^dl4gTiC05MrwX9Xs9Z`v-tPff?y!&waEe7AYH?LGOKb+0p4o|CN*DM+*KvNif^e7(Rp zpcp?#4-Q{}M>Y+L)@+;1#~qw=+af%b9=b|={>%FxxQ)E6%tu0Fy^8hz2RuGMiF5Ff zuf_2F#8uJsH`j4K8_yo*FRHlQ*_X!;Vx!!{_o`q12GPPNfT3U(eII&Id|QEyPa5^N zx8WNP*z*blEpN?1wsY>Am4&QTNfrXLXYGA#qV>sRU-rrurx-285NWMSGE!?*^4)3O zN%`63cay!UU-@Q7{cz6rp6O>No`JJzgLW;Ul)a42vYRv368@91NI(3we!X#qy>Vur zPdYOz{z`M~?~l>CupJBbT(L=fufiC+E`C8d_O;6W{g)bBjCH(fW7ae>R@ZMA8GAI9hVO7nsJ1?5@(QjM>M@ij1)#NaPG z2#x&zoqz3tc<=oCYby?RhIOQKjPJji`wP~QfOisjcaVQR6W-D%1@8mEGZWsod+_d} zyp+G^+;{uTQ!ci5dj`Be2fX*b54@LtM6zSj(D|mqA)TXXc?SN!YJ9Q>Cp+Aay%kJb zxI-hNi$lQFKi^jHt^fOSyEnV)QuetZOY<+a>lgcK7kou{b+7cg;bYkfa{#1EAauvQ7fxc4nv1i~T zh3KlUF~{JoWCDAbztUBOUDS^pSU_LcgSSk+gc-y|if*^Vn*yE9#~R_6d6Y>u%%@Ce zoJQb{ig$UHb{fy+&Ap8G>Dx%3B&&jy$+xwGcG&~BY$59o?Nc~2VR-nN1IR>z+9ql| zHK*}=AfpU?{o;tf=D1`?vC|*byh$DqgOEf%O@8vqcxFs?Q^>P-$^E{G-zogXq<60WP&|nr<=m$@|Bq)D zUw`I!{a(Jkl=sBv&iK!sa?jd$Hil;>ct-sDm0Cw9KK@GSc!HnK`-@>yYaGysD=V4g z%FrRDqrKwS`8j7}w5YGh;;%SZwVEFjpTxe=O)5uzo~AweN34e{Z%{cp#(*n7W0ttS z-LG=s6ms?SnkqLJ_o*EGJMX{0*1i8{Do2NX%P;SB{Bfnq#}fxh`8@u9M1Ny9NN326 zZ9B44G|uzdUst66jkUxg);(=IzjphJ530_{cw6U48`07G%>I7* zUtIjJ?HBp5)GzS>=|4L?{U`JNT*j9Ot$af5#p;6A_n!H@g8F=#mVB2zk_6|(XNsV0 zy{kX$PvA8*{>DGK_iHNuv@8%XbM$RP<3+JE#!7;WT#UmaD0cSoDMJefYodBh@om`V2WiYFNwj+tM$5 zW}m@7AAnaEb`i_+#{O_p6!<=GDi+)FqA&WWV&8x@Mo=%kc7Ni+~Sy;^~X96%+%YcKv;* zh3rvnVo#UdpDNjPI_P-47gH(z6{`%TGj*#Z=WzZAT2No556uD=2Qp~d*N0vmdm%kP zho9{M^07RCUK;kD&cZ^xP8ef5Cca39?=Kj-JV8-ihq@>-aIxWiRW#VS4TepYq$glR3Pg57BGm zm-v0S#_Iz<+L1o6{(V6oxPR_<^?^G6KC=(kWc7je=F*2u+SR;WUK7~**TUa1j{h#@ z48nIWL6`FMsRXbM_da?oJihG>#jR8_2QRSp(ahS%ldOI8v-Z)n{t)zd`|(n6O|b|| zfoI*h^5e~e+mH2k{M{S<$A0)mRjvQtlH+yEeLv?436GOp_i_F;b5NEXp}q1U=@oOq zuL0Ia+F2i2vHr#FhgctxFR7t5aJB+IQfk6IS`Sf~=8zl=#FOWBHh60+2k7r& z*h3vF*B{S_}JgdmNnIIC2n*L@6U z^s#halkH>9`}VQvJ$(#k^s#ha1u(--qL1MV`Y8N*!eZ=`_!Rmnf5Q%PB3L!!>*lV${f6%?TQ}9UYjxY4D^R@4oI)- zHS;dBdfkKE+woziCM)?K{1zSDsWHOKla*1*$fMwmQ)AUXpFW&@KFZ%ilcHsZ!t^&` z;`P|$J51p8BjAMeKk35aXC=ToT-DfBi`){AB1Y5dZ%OoE@hJ7DM(gN|L)ZEf43(Fs z78nM-^YMbnu0r7S3jDm9HPj8@XeLj#diFf0Pv7j1`%O3QCH}aH)75yVcypRdCt7f9Q(zLHKCa0(tPXNFUT7EFRL9sAIg|xe|}%W_Ivk|(et7;{>-_39r%dv z77k zR_+RXwa?l0E%uY>tX$zGcF1Pl8$_qpS-Bqu<_?~VhQxbCe}`zhk=T#GxO=8*JzML^ z+QVCEa#}W^bKTB7W`3h{zGrZzl6-H;`QSD-M?d&|5m^0+Z*dGl=g}_&ozU0xzx~@Z`DHxJCENO`t{cy`OvhpZ7imUi5KaOS#@v zj;Ctk-VVao1y`SzsN2P=#q#8T!Izj3tEMvRPqeXW)}NT!)=O&J^MBXDfBS%G-*1NP z9y8DEOd-%^T@P~(5qa6!bcX|-G9$;4@T3{~Tt#w}QSq`JGtN>T=lg7E4B0=9EjIv zz+(V-Z1Uhy4?NHn;#I&SmvQd`2DKUQR`Z@Rs5kOx!#l7maa@E^w_#C<2tX$>X*l%XxgWfI9uY9xw|GjkK9C*VX{Q4?C0(~6e zT-_(FZ$H>M9lTh;oc6O$`6Rv%y|ePo+)m;BD(2xRV|a(P`aFA1dCuum$XL%7;+*oF z(>1^$4}PLpB|UozTPQ*92<79_yvtUT+#0=hDll+m80Esw9xiO@CP2c~vu{oaY{Jfdhjav5z zUly-n-Sr0WmAxBf1IUx>+3RC*`zhy_8u)_jb#oVGvgcfPxomcOZT$p(PijfI^WdiFCBJ$b`+ni9P{FrwdNmWoY=VW1K@!C zrYrpQfA%P7?j=L>&>pS=aj#^zbZ_TUV(^=Z`pS@yz19@vi4@W(HRPRXc zsNLGl*pC?F}W( zN3M-$Ik;@F8@6s&w7zRGLWHtSZ(Ij8#=ZAD&RZ`+OKS%3b%7_pNv^eunR zRi}jk>jK$zs$A;=FMDk!F2=l@Zxr8*%-&+__598eY(86GUZwJ5xa&*YwHa&T_#^ifv2G|`0Xt~F zt^2y%F8d2y&^hAv`?;>H2DlR^BVELm&-coy|AO)bUO6%UYFBO9IxBO#9R4{QSvdC= zdE-`iIOpTrYHMGP`h#sh86Rt(TUgWCm$}xt3j802KH>kpMa0^qn!xjxU`JxNiAV5# zyY`%4FIh-DEOPkW&%?6{qg|8WRhF0ilj0!1y#Jfkxkt{~M@eUpP9v)^%T}+}3scx7M}q zchz+gyI^2}tLvYsYXiFXJJ7-M#TClaCLh_oQT&_us_)$i&!TT<+D)nLTeEAsbzPs` z_ry3tYeIG$;Wf|mZZY%?CS9T2H-@#g z&G{Y4O4d4>$V*n@@{SFWU&_kM$ZVSnuF=LimDrf(vpiFuwXUT6!vRmn5^Q7_Z$!5U zVoxakB8PEdrytcdHn6oL65}h&=-V8v!K?@~etsP;-ZUhg>Pyh~-NM5|(XIqGoEH-`#KyYE;bO0O)2&gz3%;^{x2v9V@Ok7M;31WhSL9Fq z@}n-^?+wcTgu21;R`b1U;x*v&jo|aZbtb(hX6&BV_C|imV|p;0+md3;%4gJPbCij% z=X6eEZ{xsq9oz&4fqQ{746t-cN{a5gHs>FKe^4~qxU+41HJ+PlTIm+!TA-TSx49>@Q>y-M1{ z9?ouW742zlfwjP{)a~TxfG6%3?nyTqPhW43mc5p-pIYl>@?B=Gmn~QlJ}rA#u+QXo zo1Xde<7Nr5<)%D-#gcGO#jLQEucE1e(4X%=IBU~3tsBYK_`7^?1$#R4d*2_}kKL|* zJ)(YjKEZx$?qAS%lkv~z-%G81_~8-W=j*P>sa_MC75~(Ct^Dh)8E^Nx^wwVWNSE>p zr|zSiyE(TI=VhDwse&Qha>191IHK-_^Fuw zz-{c6#Lh|H#acD73i3z!eNtV5f#0|3^vz$#O16YL`le%t-ekIVVt1=gHIo;m%Wq@8 z@O!v>*Vmf)k94JOYV49dVaw;gx9`j7duCq+)49OZ`VWAkl^=NcX>JyX@P>0H%g4da?mzeK*x3Upw-Q}>;U^g8%tHS0U9pDwFK*Sd@QqOo5* z9x@w_m9TE4+*kdfFYIp~Ti#PQw!8)Z<=GN^V2N9q1K=m068VhNs;{0k)_!==g?e}3 z>}%-gEAUUSKR#c!>0;JQMDr(!D;s29g3QYxG&dY!?*nUrr?ANjICtkIQ}FOi^xc`f zKXYtO%d4iKWkEsoYzq5u>Dtvp^T|tcoJoPOjR9&w$EXgBS+fB-@+z`1l4LIna#HiW|5@e~+KWULuZE2TpNWRS zA7o!nlpLhUwg@zJ06Efz{5miNzoX)0;al1(u!}r16=tr>y}{T{vbQLKe@k`#N;IJN z$d6**3y;3{i;VaBcrSG;zD{_B|E+vh>R$rh@I&w}!E^c0Qs8YL{HY(_Exve?c)CIG zR4{y+ZyXLSwfO!Tvf}%nvwXkTOd%%~u>ucBKJcylgOUsXb}#W2{K-f63cri^`z7DB zNyn+WfxfTb|Idp8TVH#{l>LJ%Z#8FJO!G?B^-8qthkP@SzaQ~DDqq(m&LYRh@g2TX zewdeP_4~m7Blh>6{V($S0)KXVp8t70dHcja(0?u8@cBgnws9sun+(2ABWG<6`F3*g z^&HNBaocqKVeM0Lw_ia{+g$eLDlUM$X=%wK*<^orDg9(VQozff51n5i-saECTIM5@ zzm*a@B6S&>$qt*j{=JgZOwErx9HhKEpRqFF-K8xX-$c3=Olqu(61^Bfi6g(Ya zTz-6M193bzgJTtq;9$r+yYUF`1mlgiTTxyI#+*>sCIY_w{GXi5FFQ_!q>Tgu*>Ll{YUxHZ=!dvEF?L{HPgU ze!iHpO3HGn>u_v#{0QG1h(^!WJ=6U2{?O<4Z`!wM+pzqez-1VjbS1PGqRtfcMfocfcALDO zI%A&wBkIvUmYum~uJ#s-UoU5G>n3k+t9YH_=_3V!t%8^Qkm|2Lt^w&8z;Xk136_CV z!LnlBAHc^ge7s@|-)U}mredP2<=Y?E)o1p2HMy72T!{38^*7GCheHv z5hmCpeM$WeO<1a&x-G#81v-19tC({GlJL@DjcW-MjHv zHTIU)xzv{W<;QJVS;YR7di?awj6uHakuvzW%IbaFn(wsF`?ew0iKxGtemHztI3(M; zkg;dp%Z?HLgjYEI4<+54yG0 z%FC?}RAmjjbGvj_OiJSFWgq3-lC!rkpQCN#N40I|cCDZ- z=_CiSm$Jw3L12+>uj(D-ORFKb>Q3q?0`@7+2J1t&R$h*IRm>l9&&{p#>1EgZ4s(_L zj^O-Da+p2!KKh*HA2$AJ6yEB;<{DZ0OeXCC^D{-zp7vlAL33Ik4?uIm{p^1HwelW= z_mT^GCS2E^fP?5LwjT5$_B-G|G8WfI%B&yB?sc#_OXgVnnd1g<+sS|DTVMA@=#@EB zpX=yzT@Gu3=u{uYhwAsK3EP$P80P~Ym$r^C;KBbjiOek2@O298L+ zJmB+(OYrA5x%{sWeT@7uO&8{W)qYFy!(4ol=(bxn&x~0eN!Noz_dPm{e=;APc33`C z{bFY}mhy|8$rzS>2G|WxGWT{9FF8DeHB5eYgv{wgu~~Mm#iaAFffgI{Y~B=iE?-_C zujf$OrRM;sT%Hi7(UrxVsS*JSjPv&fjeHTv=+nT>&ZajDO zih0nttuM%T(sz^F(aG1brw)DTB=@;L2$o6KJosa9^l{|Cc49)-Aw!#j@oM}s)$CEP zULWKy#9z20H=;O|i#l>=aGo1DYdEt*=Kv~4lxJV>tH8d_l|R+1Pj#uzT*jyV)P2T1 zI~fYZPtXSEIrA6fFT`J%zZ}8_8Xpyoy1I?^smpKJc$Pf{^mq7b=j*ooNqbeB!Zz=% zU~^FXHWaur*<{j&97^nWNWVs#*wU?L9_t9~58L)x*N4yXYyGiNat&a%GOzfyTqH2o&!)%^X&)rW0f5--9x`;!XsJ^8MK@f0v5D8t_OzTy0i)9`H# z0FONrfG=2Wgd+*A&-buaUB?*m-zD}6RxYmFl z_c<9P+3_DL`yxD#cW%=)K7oPTrNiX(octobb;fWc5}k?txMnppbvPgUhPpYkq~(Cg zThpX6#;O=zt)Cn|Km5##_!KtrEBI(mj}SwmU)hu3_rCnV$KuKtp%|Rx8lDkz@~xS! zZ!ut@NNi;GoTo>EQ~#^)sf`osG1D`OE)sd3pUZl2QTp}UaQgZzoF4dd#t%L`$8RQ1pHMkC zy)X->|F6oy=>?Q`Qdc>DMlqUOT#QsvX509%EGjNIH@{_u&;6Y`pwg^6N>X4lFe2MAr@s0m(AMdQ7?iHLPqH$cW@%#wiZI3C-r~GIT zT!o+H2MT&talMIgwlhxUoWqV-wg{erY?_nLd3A|U0lZYcHsw;uw`=}qX)8e+)}Kkg zPyPrWW>2*2o#RGlS3RRXw$sNXcC={U@1y)DDSgZPv@q#NV_I zR>-xJ<1T(N^IGv2ilrm2WZA}X#7?$Vb}VJD+EOz#=M*$ENS_0Nyq1-Wao7Y-Z-M5t z7OHx`Odbot$A{HPYz*aJwCf@0_|g@Y{-MJg`3Ze{Lb^>Yu>$ahIlH*7V9h#;yx7gP zzxVLN^xfBEQdWLO_uJ>Zi|zA0**W>VBY8SfPK=6Y|7_8o=SFljTmQ(OU$Z9pIQfpS zebioqF>6+XPs9tx)s~;TYBG4`a#xu(imQ^1b(f8QF>7j=)6CpeFA{SlAJb_3*ZZHB zX7y+53mskWvEyT(#;%!Wj{Kh$cjG_6o?M5n6fUhwF9?76jc)KX=f|zbrm-H;j;^`E zjGOZyy8q!sX5*vz%;xu$k_+vH!3?xdxE*nqDfYQtHh>B z=g{7Q6!fKi?2GY%})*~Ts78}vA^sZh9=dH z_=WhwkqKt5rF;4yd|AmpRAlS3OQ&1D#h8$PUYi#GCej~lcAa&+7`m}%MDmM0_VOv@ zQsIAw&TeG?+#qs%khogEoxQo}jgd;^V!oYQ$&y_9kdF+1mA-0iCjb7`LwS?PTLW#Y zZ;E59yMq0F+>1v^Z-keMM~swRO4*;^?)rLX#=Dxc1onyE|6}+5v%0>DwO!G;Y?PFK zEsszhIxp`MaDKn>>O8MOy-yv2iN>Wd96{DTb&>j~y7D`BP=Atj~+{XeekCg@}d!W^fowZ%z~fT`U=6(68IJGTi-P8w7;4+M}1J)C&jxc)3y2| zfA&JFC+N(vpoMWzFrW`Vqz}Ih2Fvlyw8Ni--}q)M43+}}l`Z#tGdoTGoB(x2Ot^;} zq>pR<3K^&4&2Ejht&n&FVrG1L8=3#{^xwnxLG}eNd4c#ikLFH!Ga_>LdnKfg(^HJuT` zRyfAkQ?L=WO+Iq#r+{vWvhP0M)qBYGD(q4%fDFH61+{87C{$QAk9ki8e=+gJ@x34mW4q_6PJ zzq8J$bvTP_^hIU&|Aw>F7=Io99<3WoFWc$SpyFqLD{u5*=5i&2k9;dw3O0g+U_5}& z$ML_M&bx@?(7d|)aOava@HoCHW}d|3p8g&5o#lIdUmiEYx9HbK`G9Fl^I8JG-TkXk z+E6}L#bpP6HM~swlYRQA`+)0D3OcW0{Nfc+;-)hFG2#!`6Bhw3U;`J}HTVx@(SpA} zZ2@%{@1L)~7ufidQT;tr7wX`FeJrc+Fzo;k%RALE3}!BgT`d8)wJ$e2sW!*+Ble^56Dg>cexX&6ho@-;L;A zz)|ba{kJ-J3Ks+?!ACLN8-alj+d*uWy}xJ` zA^S$PH!AdS9sDN5ddF{Tx0^AE=LYe=?LHSfE}b+wF0?%XUL@(?9n7!M{4zgQcGK5B z=E$CrBRx~|>B)>#)|fg}_iuT&i)RA2Y7?mTbuLe5$p-0&;ld|Sr zv2Q*t>DQnC)pmVkbbU?hWmd08=e`SAg~&Tn4lhW8$Av!rGA_wf={ldDmw~^)-r}$K zjQ!`{TAy3H@qMvp$)_b>?1_v%`Tco`950#iB8BRDdWZh{a_z)9a;Xw!B=^4TRGi7){OF^n zq-PMrB>B3U7^J27oI#;$^0h2L=TLsz)v@UP^6!-B8eCF5)3l2h5`z~lYd3ihtL=7h zX!#{(_VbigP*%bH#vB)WVP!Wy?poyV^Rou$<*so%I$&!hBo=d)fE3BQB+p?(sw&QzOt(aGF!!5^yMbB>=G{J}4 z4QJKU2lV{w)&2_FhZoM-Gb=Sua&s^3pESYFJzo1WDa+*7!twsO!l_*J$-K@-IUlMq z&%vga+>X`AfscYiZ7Z$ssMXx<{Gqqq1FnPnf3Vt?pE0YP|MZh8hdyqmJYo0sy8ND1 zDhIC?Q695-7hOE-Cn)crZq+4Qtsj0^OPtU~V)l(`Bxfu4@S;lejRtfK=x-T5hGpe3 z6OXJd8H!S_HPa|Mh;YD{oqtj6A^@~8fhJ0poC>L&x$c@K? zc0Dy;H0sNZ$16G}WzcFS|9BPJ%*@x6LSFRUE`EsILxxm&_`Ab9H(mN0d|^T+jz38b zh$oSO3pqEvopaNhSB>h2;%5bd@nU!mJoX=fQ@8q$-6kK#cLmQ|qv`LhVog@>p?h?b zXDT1-S#$1k} zzg0PK-{H#7n9sO9*^jFnoILO5>5TashvRXTgX3?xa?Vq7JF84lX}_Aodc8>hAYXh#xxBOT``-&tLNxxSxzZl>-&%H*pc2WnZj8%ryv zD_P(C=hO#mp`Y*`xYBF9`0#ru|5w!ooDBG`zDU17_suV&FaBPDMuXjSJN7LyU$S4< z@&OPl@Uq}pVuEq`G&P^nw=}0asMq4p1s3_tnXZVJmu5x#~#LpnDdZd?)PgD^fYKtc3Y~nEDcoJ;{58CSY?kEcg5K z3-|tqc)yXdYW`00_a*-16A{c5vuORed@ndjhZunW5@73K<&7y!9iOEv6IMP=N}iXJ zchINDBI?M@(Ewj>twm-%{{hRFTdhBY^~i4GZl$Y)kSW@85k@Df@MNgwy@)-x)yUFS z;9K#zy5nKwsC*OaJULp846Q|<(;nQ4N6M`X4IxAIyq5bC^eENS&wMpS4oP;l${&fH zHfQ0iJ}ciAAzxlJq0WV#d{KYY2eo??KCO0h=wB{vOIAsysZRe-eF%apy5C8gcC4NZ zE%w@&W@Tuov)F4xbiSSS;#y?rUDQ{SB|{q|Ly6z4VBdkBhmfHq$WX<-lpsSFuBw|? zgbdvfIk@df6L?tVP0&Uqx}n;a{|H-ePUWnm9b*;klY70h(rbSMbFcU!(bs(HFTVMf zbQBzo+Nj z_&xRv&$hvLJbjP)O{FY3=^<}Gl}=nN=(rsA%kxn6$Fk~hEn zXi@k}#}%{E41Q$d{PJ1--;+*<-)zes_A;+vQe;!&@xDLuGN{Pm#Xw4fKwe>F6^ zlkuwFa_X(1-ahuK)kU7S_11%D_260QtUg!yt6A&_rgeh=aEQ)^ca75p;#=#{Hh z3=K~)XQv0YY+D@2>zuyoilM{s_Y-3)PlGqhj-a0pN6pzf>}mNcC6i_2r5Hc~f zXkiw3uXe@rgd3;GGbes`EDJxD+PxE_@m!W4i1&>*bAuk8Ykvgv30z%Fs(69FcS3t6 zQXj-#gwDgzxMaf(fb|^0UzzHs6SU;);xt= zNK>MZslWQmHNl=8;{s=c_{fg1#yr?KH{QRt{(CRLUmt`=ZvLk_d<=)T4a3WJU821e zf%r+qi$-yb=E5$Gh{0_khOl zqTaso__DBXqcIb&y&-yBzO5Q;gzrf2B$tC=UV{&40lI$;_=Hbz-$*`M>ZoTeZqqpO z6t7=+T(KfAMqb<|y=FChyeL21B0n2`gnNqMLz&;&@77m8rn6f%eP&_H-t`L~Yo3tT z)0{Ijx1H-3n5Sx!bNW?ycM)f5*ts8(1&;QR1zUDOd&mMydm4L;b%GH1r)$fzqD5um zhq}*(&3o_teOC^4qj7ETfB)rA2e$s|56#x~{5Iw{9(#aa@%Odf{TKKZ&iHu#F%Qp| zWbmF$ewdw)BRIBlCUKT)YQS&BT<0>5P$M}oz@ZBGdmVf|h_CbzYed=$6<`kK2cAUy zc6W}A4I7;+bim&>R}V3-nR6A(nyWhIxSqM{hX>VvhW$#+Pu+x^p539Lxy<`xhtLN! z=LLMHdQzd1PR*BOXA}7SPN=xEWZXL=e60yvwPa+@e3(1l;BeQMnIUfHQ+gi)H`^?DPeV(R)*&&+d;lZrFCG_9wv4WZ$PMqg~SL zWe<0wJLo#VcjB2vz##>H(^+0Y+Ro&Qnb+uG`^=kH#}A@&{TcAIG%Njqy_~|YsE1!k z_HiBr2EP9CQdYjL5c8^>=7MV_G^IY20Q*v4@56l)aNo_`>%A)&+X>#S^XOY^ve|Q~ zy?J>N)_92>T7}$q(Tvs@W>{PlPXlL&6SFc1UJ_t_nFH#?w+Ek# z+PpJ?)5-x_KpXxuVj;*s6F6N1T%)XKZkJxhJ9Wgf6-6B#^dS#Q*r%hI?NOM!zB%0R z`swK?2b7l#8n|Hov+^g2HwgAMoDXVY!Q7WH_f;Ov2?v&&aF3OFVfmtSdP;K4>}ky9 z0lurHzXR|Im=OJg|Re=k5Q9{0z-l_InsITEawzTEJ^d1kU z+4+-3=qoE9yT&1Vx3<7s@56cnunu}WMRiw#L+VpK_@w(L?n~f-^~}X`j|YnP=^1$$ zwj3~F%L6MVL-9eRz(+mPUmI)Gru(w^guSdUgLn9Fj!vR(?GLce5|_n4%2^T#bf(XO zvsynB@1>u;^|vloKH}cxa~E@#O^)UDt~}3{*H_bu+d^Njo*FNRz)=o#U^t^0td;|3Sd%!zlbNCP^DF2@ie@|{+B={pY zpY-V3c>jF(cUb;5x*nZi|8#cyKip%_?&~#eW${J71Qu4-nNA-*8=f4^o$A9gOrII| znVN|e_@XM0r*2~ZuL-OvnzeCTF>A==m$|1187x`2VTS37kaHlw-oqmE@%TpYsiLhY zzMGst-^M?!cQ$pKc$<|cVGs8l?E;Hdv*d%8x4kvj!@V(%ul11IEioqA^&(^U$6H|S zr3>X5IU>_xXl(<1kp3K`9~JcD?`d0O(0)(RLW23Rv?BS#Hw#$fN9RqG7t-owLGUjG z{)M9sF9pXPUe2-w^j-JW@M`@w=$CQJ4$hQQvT4PWzoEV}Z`t!1+?1T2`Zh$8jL-Qq!1)k>ioU#wx?wf&r{JCGj z+>18Yuc3B(YdxB%gC_8^^tdud54F&PY!7&#rHMLdLbAJ``N{6nUu5-3Fu4Pm{ELUj z{(P>^+7F_&u1b70RlVeYxasT1+ShizLGGfK*q%u`H8 z#oCqM6W`X_V+gpOoLDupf6T&}Cntva&6&9pUaYlg*~;SA_+q*?BCn@iWM)@U|Hdsj zlZlDzN{z1~ULv?A2A|V>$@Z${{9o3EyY^srVJjU!MH{(Ahqvv(UMk?d3hX6h>6U7f zdry?}gY9^9=9c#zJkRE0QLX{x%<0HG!^;#uHyQbT7k?%Ou71j`1(FNo@krR(60U9X z7WC8hrVQI{4>myagk17>INMEjpzOB>`ZE)qSpDe>m39jE1GmikDbQLA@gJA-`(ggtxSq)G6a4-yf5IQ1c78}6 zGttf$JzNpX6TX~n3cS1^IPP?Z-Gg}P$R5Nn{s8$)Hlm05c(@R|{KZSm+;(ho{A!P# zkUs!he=qq32C>Hn*REK*1b>poyPEOh@19ezVR)Y8Qo;G^{KE5-^Iu_{GtlJ<3&@E8 zpUs^Wef02>%Hvwg6do-yx$Jj7L8{~EqptyjrA05XUA5q0>3>VY=6cnwz0#|w`{=A! zt^eTr_z#u@3Od;*ZPypCz(*k8!xi`i^!^IY%shb|e=+vP6|tgt`;;#|rt>OaWNhG4 zT6nl;9DaEEGB7S@yYt^DAB$rAayq|_jB{}R)R1(z%d8u0Tq;OA?=&)0yTPwihzn~m_B zzIEgdWjx81(e$(lX8Uq)Ov@S50mh^?+vSYu)40!E9u#b*pCbGTWhX;-~1+T?fF4)<3-kQ(ghNFcv6;qMEjO!4Cm zzsfDHl_Y26h&%^IhKmpy~d31>eZ_Sc%{F=vdbM0*&dJQ?OTwFPL%vv?2K#QnPk! zGp6`qKR4n{=<&t8qd7akIvVu9Yydf(J23z;yUAFm-dX$j*su`+sKb*v^M+ zMsF_uLUREfXdZU*jpMH_-^NUvqj@ynhH_pc?_wo-%;g|+SioG)449THnaeTEL-oPDv(w|7w+-H8(ptZK zm9brg{gk)^c-+M}?<=Q$_yPQ7khSMi;Gg1;8rT>6E9B#8@Pv4<^r`E>mq7Q=o0-Rc z;4fKs7+H4`Txy<_(^720+f#EjFMeEfUMIM|HA&t7ZJc2}`2ct@0N%8N3n#DR%sDgd zVdB8j+Djrmvjtn9HJ+En+id)_F=zjb_|<%HEdPVuKYx{eU0HN=+v&0SEirKHYVc<} zI93=4oLzFQ^0?1<_yo9gh@4sYTqe|d_@%N{;1}yAHaFdc>q)t%uLQrep48^yS3ht` zfMeP_dZ~|N;ERuAOEYl{7-X&+y-O@*YH>7e;a(ZFd9_ZC;Fy=|1RN_>d;-74nYebH zxxa6`i(mK|InOM++FKGCA z6YOa=ftPc@t(~mjgrFg<7qm0CdiP1@LGZ?J_psJ%M5ncs5fg6LN@}Sy#J5`e5q)RA zgCFr_Vj~}x%v66IJzQn=jhR!mAN8Xz|LxlgI(7t2T<2gV4h7@bJNt^zCGNy0HLL32 zH^)_N_@@6mDbKvT7X4vXRrFiqs?4|az7KEl1Am>~_Zukw{#0qSUCZSBq2^{2>U$-kp6_Tdj|@WVkP z->b&=rZqTkt^H<|gS-2)$``2|9R8*&Z}sCkqFz0UO;Aiul6UmGi}zykA6)HX0ob&$ zb*K8kALXrPhKn1TrSGAUuVuA&rOKg~)~xnQz4wzmQ#&ZiuAQwemSGCdH`9hU57?dm zvw0AG_C38Y9RtpN;B^gfT>xC~A^uW(T@p{{5T_LV7XHF?AN(&op}b?@c3^$0uF>t` zQHpI%Yz};@4<3^^#C}cu=|$$!co2N(UB%hD=kw-T-gUbNGkBK9V*Sm~RO(j60kE!% zJ(i*mL42_v_vbI+asj7eoc~LiVprRg4~B6X){=+|y??s@?vR`7pYkqqcmItJR?OX1 z;9SH0mnpwNu-aVJkz_0h#vvJ#U@VuZ9R4~hYhGSZUG)3PtnzBk4M+S{da`jMa2-ZxY}9+>&8`D23?-a*|-nY+>R|sY3eTr2`u?J`* zR)H9)I%1?)qa3OvzMyG>d8UeW)fZU*9Fd#w-iF5L*_ZGgR-)e)n$YPD*bkw`Xnd{k z5c*9*{|V_a&*3kDwk%Bp57w<)PJ(B$LnUhm(eY}HdDh=!w8WDoOW|L|f#Ab4kR>y~ zqrvOIPhteG;0&B9V%7(!>m)dL3jIj+53-+U5S=JLz}|IqzJfqb&kkZK^N}@!;QuN3 ziQ)|uORKnJojYpdrtiYvLoN`jr@#x6)GOTm9{#U{ucz>il_B1~Qd~BN zKEAB^U>vN!_ei#%U>*lq<2c#);5O+RFKB)QQ|ThWyBK(DY^{u^j`^*k-i6dF-Jf-& z)6Zk;m*SUE%=#wm9__<0^f!o{doX6=ht^ITQn_?0?bFcNN`3HoyAFf=zH*YI6RQs~ z_P)8yEAO-s2U*EDuzh;X>n`@O=gN<-NNzi} zV#2rI*t7WH8z&!l^Nn`S32HyL@OTdOxbK`#P1p6xHK{hWhDL>yBSw8hi_=LTG zD|)^yQa^PX_z{iNO&z!c{RW!dNI#3V#0n#mVpC1CIWOOp&al(9=wta!*xSs*x2VT2 zmrqdd={E(w;&0lgxTJdW|4Iib;MwN2D~8?yKEver$>)q<#c*pLG$!e=C$L>~)|}!W zQxoB7VRN>KzEvA@R_A6)zxi0M*_vzHiSECOYqbgO_RQc~az4-&viOpk=+^u`Z8-N+ zG+hZz)8~(3Q``3vp}1m1)&4?qPz25`JD!9G>0W=@ms13;M6l&pXFPupo3$u@;^n5@4 zd5*t7;O_$a&9mA1f%m|5&M%(h(W}3QWZ=4JS5*e>X3}qg-A{7ieu4ma8|)6mL*RE9 z{LTTtiJ6gqGp+n8CxKONgx|^hLenQFnl8awdWv9Qu4`zohTo0+F~2Rlyx%1K)H^?8 zZ-V8`lxtl?Yd-z(|4lbU(>vj@HT>zlzR8MhIdOCVx@E0RKGCC!?_=L2XY!hR{e8>3 z_cd+Pe#Hgg;A`TQSF@ICa(fb@1^&#WnyKadM##;T2QA}oX^ENIsiLErG3Dp=JOEus zX2i%N1 zpF?J#Pq`Qi>`~}fdROIovmJOnQDQ?~zjMc$vljg)rJ0aH|=Edogb*5(Br4G$`7j?-g=wsSFd@>#maZ99KM!>UZqbdE+B#r zr`Uh*2j2O=yjMhDd|s7ssH>=_k63KmqgG16@6jct)}hc*h10$e?@tk zwtvQ7HP3#-ZzE$0+}&J;e)zKb9k@GO7Qs*3&H9_}_1BGzGHv7I|I+5KwK+VK4(011 zJCrj`KDA4t>EE_LV<|DPkeBJVV^p@rxJg>Vivv+ovDod zv8_eXYwZ5mqJWug?{9PWJL&Vq*9JTp;?|qL1;2y`?5k;N`u|va7x<{FGynh0B_!Mm zDy%e%`C9aDVwV&N3sQu@5)s`uyV$Jg4us3CmXIbWaHnd8hEJYqn4$VOwH6keNN zCjLNrD*A+IMmqg(4v{x9M64$=axS!xfEIGhLHxA?Twf}liq29Lskz{3V4*mV4D{t6 zKeWl&nD?hYq2?_A>8a|Ts!U@`&|fqC-409}z?bHs{fQpC4;b1?E)(K)LiYYB z)hnWXCx$1~SIfBA7aY7pO3xd~Y4R{Mpm}-YPve(NAb&J|eD;XawS@jk?7_Dx`1<+8R}w>f z;-7r-T=vRQeCU53H(X!Kz%%!_H1wO792yeu&cH7fyR}#AENyd3?@s_%|9!HO$rfJZ z@XRomf6M{1!FQeW5T(p#6YXD53_uNW$4a3k2uFPes4qW|T{ z51l9S@;GmVCYQQ-h?n~E5LXkQvDD2&99JFk5VPKr$*^f&CckC$KS!_n8lk+qtol&uwT z`y8)#$6$lQL+yBL{_^pL$@}HN%kilg@vL7tcPW15vUl>V1uh(=+bGt=weUd#T^%;xOHw}`i*ZIY$o8u9Zkn|$`1tx#=ar zKrq*x?{R#?f{o@LX70OquaP^2gqJ$TSIzG_Vl%6y7jxFZ;>*>Sgf9nIRs9;jjSx=bIgS z_9LnTkN=wU?lH5%vCl45IkdNm@)NwXn*WG+(3~;$Uh&VfpP>1AbajnOR~ucr5p>VE>FLHbW4(7s8(QXf6zXmYQIj{7sej1=dBO zn>y&G4!YS0-DIJg=zV+EEtybhbMFN3YX{MJLJ{OCZ57bg2=Gz?4l2Q+^*YqXRY${l{j&^Iqpv0weaMc4cH6GG0B(x z&;8$J109V8R-f+vDVz{ULO#1p)`MwZZGNMD9&jP)d_^|Y)4n1i;Pcoka zdHl&g2g|E=hji9-zh`?ZreZ0vlU3NFI%6dpv?Uo*yyP~;OFqLG-(sDWT01G&r<`4q zRWsr9&D>8@%Q}{>unt{8&${g$z{BkZ=~MoBdh78C<{J}Si=6%3f6#oHt6>bi%#&xw zOw8GT|C9?0Jv+p+3;F+d-e0fxzu}xm`lbsDkJj`#D9>#?`H(!%hXwP0=^55j9t_(5 zCom|(Mm*Jg@64NT)HmO)dGkHbH(&9eS9y0y-h6A_`Fgx$I{24Ozt=|t-uxfRn|}## z6bwSpgKYB6%pHDw@uSJ0aNn-{E&D9)!&@6I?!$e`Su5NxwzzlhSZ;K2uV)YD%{#++ ztZrz1YTmqa@J8`Y;qtS2^ZpM#OC)#Bqg?b+l{e=y-<-8qCc6I0`biixt3Z&MnX*)=3#9cayx#L_+!CB=RVLcrg`OuxRV0E z%`yMIE~0ViI%RL5lb|z6_I&6MjxCTymUwrc)Iw*Ku8vljr=wN+bhOIT=xE*;KET*y zd#)aDRz4k57|G&uk z>-W-5ea)5DXO2I^>Js9&l(*NfpOlh6U$RMX^p~m5QugH|$bm}!wLkHe_Q9%Wp|9~9 zzj44U`d0REaQnuufBnr8T<3(^>vSdq84?9X$}z}yo9k!~V~OvcZsnUDmvP1= z`6d~)2Oh+|nTqLeucwV>%ATi8V*@tr(c~oEPrG0Cjmm&wyP+)L%BTE#ewpR1pDDyO z_<1R3HV3k!%#&|J-|AO)@50Zg={!=7eXNfanojE{gU$ow2H#ylpXq6-&6^yWQhj*m zbe%QSxb#_q4E`DK%Ks8^_mC}IsLLFu1t%b*$))fjbxLTz6dgFOd*xyiQuXEJ(oCP9 z3O*d{4B+n&6*dUh@WcpI}Zzkl``nE7YIv zc}dcr%D$uVV7tfY$J9(nMX;eGu~XEgY=ggUlDaYKR-K|QWncBzT~FOAFMh%C$?g{K zFZuxbz~b$bFBz^gy+tlEt#`lwq7ymh$B$qy{$}a(>#P;Q7u@JhwGHSIJ=m<1K4Ayb;P=W?+#cc!@?=I_4%ucRG4*Z)t} z=Gj@U?eblBe82XMZ>06_4(0REK2SPgPIxf;g2q@O+7-;mN5g(A`ySf=X|J8gzGI~J zf=1Sue%J7u{SW6^VdaS^x*>ZZC!p68CG2NavQLz`gnS=Yk&^|z$s6wm#+#v!k5XP4 ziV>5ey_bq#TYGwPJ$BBjlg)Y;P}SxBLM0!K>PgbvPhhmY&Ip54v3?Vb_eONfK1Hu!s?Dg2Mb zEa?8E5O$mCyAAkMOv6vbd}79QMfmQ})a-tT$GE%~m}RHNPQ*-QvEcYo)<+C@7+_)8 z>(w(;R0m91tL$A`nC#|^hj8)x*oig7k=Qx2j#^z~J^k{QJGNag~AI_|u_}nnrF{EEj!B=xCdtGhi zz6PGbi|nGn=bbgD^5`JF4#mHyzmVJCQ6Ie?wdY-j^f&){>Z1L0v5!{WJU%0ZC*nsu z*(`g}PruKyM$yGjq@|Pd{(0hSF5HvuIq;9d11)WGcb{mp*5#3sRZF=8BUrR&t>heg z6PEt#$5pbAo+y@|IEvq2<&lfPA$myMn6_f>E-ZystgPAt4LLLq4>+;(A1?|4dIKAHw@>}s>p04{vcP{z%jyInv zR^J_pk4n#gf6jz|z~?wuQLg*m1$V{dWM(4EST_%Ca|?8yI>Yv8f7kw;@@b!wlm1DPZpXR~Qt+jG6nbl^uLPw6bq3CuJ7Kj;lL z>CF6ueVhOO;34R}h1lOMZ-3(8p(5^4Cbm#9QipFQu9mXHoXMM#4y2B2W{*=zo6|TeT52EtW_M2_CUr3tZ(Bh)0VrO z-@uwXiU(TQ?_Ny1-Oxq-`O4$sonawf>2mN*TZ)O$zN_{zyIG4%O4)~_e6bG>4}%Y{ ztl_z;@>cSzYF|+AbWic^B-TZYYZKqxDR;2CqL@`##YHpCV#&3`uz)7-ZA+kng_3%o;Ey0{~Q%{Yn8gM08=wGpm`#*FlTybBRaCI77*BI`l;$8Li zS>Ded=h>3)Q$613VlGf~^qq>MXP{*GCH@_1>WG$$3xrOkYmy!)5$?IP}hr zeaUxr>^yg!dh+C2dwy?7K2{KeRfLStzFG~skM_C($o1ecvnuy{vz<6J{I6zf{Fq^1 zxMb^8@S}IwN178lVpetcneEDfSWlh$BdSxnReG<(OB{WuB4&;+P}>ow?a;4y?-jG% z#u4bfUpeocuePbX1brs-BkDZmYx_q|9dy146-l?9d#SV7SLa@*j?Um$n80!NeddJv zW2?g4kuo^Oe&@btjWddUp5UyJ>ejMPq5TDPa^n1>!}d-rpN&jT)xu|gFnI{xl4UE& zl_#Efy89H5av!}vUO;P3KDrJQe6^Bud?ja^9o6)I4RLJ1klc4cd!AjiUr#;a8@hyF zV71c5JhE2O@S7|=sEqvzt(P8n&1UL){6TSN-SD(XfzpB=!xM7b@Y&p7AK!u%(h zr}O~s_gjOK_q{ad3WmQ=o1vligY2W|{VcXiej5w5pAw2}jdPwa#JR3+&Vsd?V3)?F z^L(10_JNq+jymcPa5q8sH{$EyPP;{-v93*wA3x6o(Yf>shkl$mfF5M1o-0p9F3|r& z?SaVms+a_w^XO*1i|^KGPR#vk#uL;T(Q!78vdxf>Uhj(M)K84N_d^x*ZD1cJ3C^35 z6$@DdmY<@hO2%x2b_%8?+K5wpDK{h0)o2Q=4@Y}GEvygrCAvc3M7WWkMtW@tx`xVS z*J&R$hyE-4=u>*MJ|(O4sXQ3|Iev_IftmcD_qSHAA25b|FXStK0XZ(+?ux%d{`2f3 z&U19*lGFz1@l5D(Y~DNn?!HsXJO7twllb2)o#q_wY#@h8fsckgp7E=Xy0V?~rhg01 z@c6~ZynGmnikN}G6@l{GF7|wh zj_o1cHlrMu(395lR|SVcWVCWzLO=QWFdX}E$bJ(%!s|H;0xa+&?AUc{W#2>4!lbu9 zWplkQ!#DT!DS3S=j>SFa`$Zqzbv9VKl>R*UJGm5w&bzC>An>n5ZU$X=CFr{zT?k#G zOY|k3!o!K^)j+QWjB&&@v7LhRqtw^ua>giKa`>8a&Wdx_!%N&5*jEJpi#YdqlCwua zcu3LMLMtcjeYdnRa^e3u*!ky^)A0Yy70mM6 zk4)}ZtM#C>QCbgP9hI+OzvbkuRefpJT@QNIqy0Cgn3FwcawT;&-drf$rFjkQWhhUs z6ZfV)o5|Gk$Glzb+s}#cY{{HYkbkDoU3bnr)vo>p!-PxA4K6Kvy4732(;r_anECth z$JRZ{-odHhseUu1-hR?h-|Dv+9E~RC`Q7H0-=}|W&(P-)Xy+1Q5HuH$rbW+L<+gL_ zx#<-28jl_S>yYK&4`p(TYi@K}Lq8`pZ z^d21U$0Vz`@6dP?pfzQYBft}1OIkzT08Dq{kG=j+L1r@&CS1loOr9D`Gq+fS^>P{(6u)|<>!`O*y_$x z@eRwABZ7G@cjwv7JbRdDFY`RiJcEPt3~l|MJI4mv?q!aLXukybCC7}gbL?i0oB%!xpkA^({>-b-W_Ys=NE{0cQ!h*NPgkmjb_ogH@Z5&#~dBtdE|Hc#@M3E zZ*qMIKXc+f_Np8mXbR=4dFK-T)A9)%aPoDHp5$9IZZ5*#lE2KZ8FCRmcZ&IT=EVzm z^A%4hf;Yd*T)Q+^J3r&-*r%Ic=99zcmvZO#C&nL>p5o50&7I$I%0I*Z?|E;%_Q`9` zNY!#S<6zl4=eNZ-zb@bWn8%4IXIfbQCnDn$#fEt04a~KGvh|$ltK_@N0xJ7jMtqdoM zqZc(?5YWC9`wUO>e#up}7sMiU7u51yWfACDw4K4ft9luH0<6FC_4Fe-qBXVKes^+U zzoYImc1@h{>az6gpFLMdd)}J#=s91XbND@eS>`Pq*YO#uF)4V@l_7KnOOxo^b@=*9 z@E2CGkI-=}bbJZ45ai5m9Xe03@*M%A8MbQ>w7Ety73p{izZhP^*9e^CR|1x zTi)pt6cqEc0RZFfLl^J+ajCLG(;I5OiAK3o67&)rsi~F%d3pe>` z!Sz>_dbEJQYPXLT1RxI{&VKsvc-0 zHdPuJ76U`kljn>6CHeiNE9?YTgYY@UeuZd4@uS+qEoC2J=O}Orjx+c@A|}*naf-~h zb`E6?%tL-W-Fu_^gCAs0#1eKXPuCvkZ9a4AoiL(p-}$jAIv2RhjA&{BwjDfQ0zJsy zTkrKDy7BsAe>m&)BR?DPw0-ej99T8d?xPdJZLQ#|0T{vq*mDbYZ6L>HWjMKW1AJQZ z-^ji;e9Jk1jvVvT8vfnwKPN83ut#N*A4)~pTY9n-I8pwh?;bW!CwWjXbaj&N`e@FB zr^8=|^busm4(T6>x8rR&Y~s4ND<9!5ur)K$^Q#3PV3ub7rOXAtL8^X2kh7O&K(G-H zi9#>RRgweOJ=nPQ;bmQQz}GpCPVRWx(t4CXvc&{!J}|#-HRPZ4Y;MQ@tNE=t#r!_$ zo1dS*dh?rN&$AB6OK*Q`BL1w}MCHzsa3wkWW7g)6@}1bwu6oWo*VkO2_?;EROI(~~ zzL^mB%ClxJ4>_t^jqS)QKU(e?px! z`&*(F{ns*H*uM51R^%8Pd~CwtGs;5l?^?d&SNpJN5k2XE{GD0ZZN!$$yf5}hoy{Q| zXp-%sd8(Y4q!r}dZ4z$l+&r>+-s;*Fe%e%@8UuP+Q_$_hvyJA`KCy1J6}0V(_s+C! z2W{KR9qz@t$$s#+>+yqw zZLZxW=~Jw!mp)w>PSs_0&aqQu@fhotMgRM!j0)(C4$1$E3&n$k_|} zw945Nz1eH$M^23G&6NK?`m7;0rZJJ!$sY~hgYmD^wB73o+WR@aQW8)s?lx0)UMk8N zCIUVN`8a!AJG1D?(rfEo{2^;8i)#pTN593Ekw5Th>`}qB4qEqO%c}1+ zcSYgX7LMRt>o?0`=Y&DF5h zzQ|fbr?+cdKJhQ;H!zK`{xiV5I1)U53BF1TGxl^pMUJ&a6FvWm!@J zziD@31nl#~1?6ev6Z_5W+Hb!69w(1)EQTy0PRH^Q-pPjU-(G6tbzVhmYdD>AI>ViRb38riJ9InA|^ zxvJi~&22Pud!BytVfIq$J&kpX(5%2kG9=;rVY-s-iou@p@UB;4^CD0fC zloiBU?<6IFhQwUU@7!WUq)G7i*5_3WCWXJ=$DHL;L-2v){M`dMhEOG7deI5Kk%i zS=s>~;{QvDd4<1fPwjv=$G?XEOSX7xTC&Ugj-H2I8@Nk)X66yY9pe30YJV%rnPhi7 zFKIjmx#jRGcfa4Rg|Vr(xYH<23}uk}<22?txmXjN+ivD8a0J>3+#1`74!pA&TB_kJ zNy8Y);Bb@S`==R4Mt*YQ5|*IDus1nS$vCCsYVX75^8yD17Ok#f&s^)bzw^Dq-~l*Pa0=NIu&vxo*tuf6)R4KC+SaqJY^S~a^l-| zk{@m7+t*p!y4`%%q1EpotFTjc%11{0!_G81e|mAO{C$#FqUj@!4BM!B@VFw%t;|b1 zGVdEIM;{MTe*5*@!_5C~y|>?yt=$o4>{6PS%f-@cy-EE{I7Fre&_9tEJF|CN{I(lt z=?Se1aB&7SCi>QXR~dAiUvCrj{Cb(@CVovk+s=pg0AJdWof?4e5%;npOFz=XlF&)q zt|fFb){?&saB#rfdZx|i-s0lGis51T6VyTyT->~ZPti*k;Sxw{P; z2%0@I19;m#Hf+MZRprRh9ohLtKInmZVgM|TT>td@dYm|y+uX6D_(`NMj6PATUPTSl{<=~F&$|9IxnH*0yDcZbA$U~w4T^r-3W zpAr~h<244$;7cky>hh&|G5iD9n}PWzm|93KwWDiHc^&bZS=Qv?iDNs{cf5M7g~o0@=WOU7xMkrPe%SS1 zPwWo(Jh~=UuD%s3c3mK7&wCl#+>MWAEOXu!7}Zz8yv8z)UCIZJ{`wqutf|lW0aLDZ z6V`fzX2j?9`RY{s3ZFJJ&g6GJu&O1Gr|N6WImS4jF~0eM_a4`)Q)2@foA9vy_a}E0 zUK;CcW<2%iTZ-+T2mJ|$7m@>BYhWJvs$XGE=q~10eYp$OcXi;V<9p_qIeiVEIJocL z+iZ-|D6{0{i;3OaXGXOhCTI1l^fg;KtH)g5b~rRydwtuQkb4|lsr_y!EbKN6$IyNbk;@{H2}qz@c|%wDLAz1T5zhv~K9txMAT#oFpaZtru-wf~`G`TP4 z^t$JYMh*y;wfL}V80U>zQ}CfknHea#NM|X{4&mXC38&n{$+E4Ej-Q2pA~TB`fKdm0 zIWrkP%==~V<{s8yhxF-E{BiKuM&O~}H6`dc$Qpe&d@n;5wV;2Lt*$KJ%-wfs@VPqz zza|G-GH9k{S;y6p;Bj=gIp}BYt=NZ4Bg7c-UOo0?Gd`FcJ{Ugl3gLsPw?3Fq+iRC0 z?|`$DGc;^-hDPA2>5$Q$u+0Im7y7TK&HCh-sRm+nkx7=X4wk`JRo3A0RpC(o)$nx_ zL^p|zMz0#(shqWviBmsh@xjCE{V>=hJveqHFv!wY(86G_%(fXEgn{NGdxh3BImi4tjSF_c{!F7hk};;rdSAjqs-u%JIHM%Fu4|~zt z>EM6cDd2C;AYUVKE&3eceunu>lFK#d{x+Y*PReKT|DUv%mR`Kyx#Q{I>D$g3*)2J7_-3=HZ-yE8zSA%E{KwJR@7rGQ zw69!l&Ny$5Ez&sC&|P{apU99s&X~9x&)Kv6lsndcqdX~ls5_L}z#MLJ$MgfQ{)F-L zasT$~R6a8{u#NJG{BLIr=$BC6=U58FP<;CggiPlW_Dlp1?0;`Rh^QDZy_J9 zTR88k!wcqJHQSYg%Zb8OqOs;fWZ(ot4cSsl7 zcYZ+k1hvmcH<_(GN0+k?hMi^0(EFF8_bbn`q-K>h!o4dC_nUgOhRoR9v++@@))vIR?+TQBgG zo^jFy?|fRY1I8BquC68hYo8g>){EZNyV!k({$*k8>L-Km0(1MWtB-vNeA;)>&6KZF z_RzW$C(j;TvSQV@fBw{)T??W=ijJ2rCfHYx9xXU2zkqTRpQ%_0^xT?npy#4@pERTT zg5NkfXEb^@I@k6l?19_CP#Dl@V)clZTd}qp3LY6wm-pd@lWXx8Db0N<49fs+zQ#h zO2o>iyzTS%ahBoFGy_3kLF~@}`*G{!d$~#DWUY^YC*JOj891qp zY7^+IW6fk3hn0ocXSq$}Acya;|GP2@om3$EwAYvUp|N*jevCFE!eN7RKIb*sy@fK3 zak}^HKId5v&ussbh0n@zL%R`URvOwh*cMZ$4<6dhF(>Educ=?*nB-m&A2IQdF<)6ON7jp9GW|3&<# zzt6a^8KfiD1HWeOCF$l4kR{xQDcH$>GOwA~ZRVi3bK$5NeuG|NbpneQA3np3A&x#N z+zQ9qbFK$pIq)p_DlSTGbhtb#2i)y*7w^kh_jS;ZXkaPdlY;j(+EaUgvnNAqM(bq< zdHJ+nuB4vf&+`X9M4Y_;zSOzw`)U8KNbxxAP03%W^J0sAc?~pA`E^Gy{@?u;nE{{8 zNhS#9$hi_^g7(xU4;8n#5V@jxWZ5V2@*OabmlCc|DB<%7HDf!-AF2Ab4nCnx_=Mh( z-_GqzV{rU&RsHsCoLe4e9`W60I`_AZGuBsv&c3alJy_IhN{eP6D0VMF;Fs%!F(RZ;oER}V^-Y_z&i@Osk`6q5u2))EG&Q*dlxqLG6DQ#UkWGa^u+1SbPeZc1Na2Co@0DB;6CW)aq!doNbIjaj~j~r*1qb0 zaxTr?6CGHJ{~~70lfUxH24kh17-P@3n^v4Qu4JW zh}~)C4uq!}M~LsC6{fR}&uYpt&`K73Xx&uFk0QR$nyHLM%Xerze2)-YlO65Uw`)#1 z4z!>>5cy1O>OM(P(5hM>rXKbjWs5<;og$|TE-wflzV~)x}lAy zu={JtZ&HEZqSod80q(JP)>^z@Yi&JuFl6)Yn_DZmKsW#9!bPyrpT}<&+Wm*2{kRNr zigE1FIb32Pbx*{TS#%AR=de{)X$~{+nRs)U37V5mcmG!2uD@yEeoXg$m1v%vp!4KXvzf#u`CWMp7OS1tLd!dH$IIgv+&3712g#D z4y`GjS-ywgS@@mP2D$Yuti8A>C-zXXLv28NFI9rG3gEd1dW!?g*uAx!d#&#ObD>jL zbDQPOtq}TvPyJco@12ANNswWxmKUEBvB+q2#1zF70na(X-H|~ZPyFvj-V>j%44WCMR}24J zf{tRC`weOKv=|REVVmw(tsNc1J)7Lwz#XT`Mb+@xsz7X-1!LmF*NO8ouza~l>#qfWBI~$P&aWidLJA-BU?QrjnpU*tczW!Cy z&FTbjQM+}(t^_(uB#Gf`cgm0-EA$LGyaE|)@6W~`V`J1R(f1yM<~`nhr%N;W_pRFe zl6E}|(TnID{Ok}P7w_H+f6^ML=FFyY4QpMTAzA=emNx^-5@0zNIQi!wT6j^u0UwQ^ zGdu4=E7Jc2uNrtRdc&QS&`U`kza7?|OaFi4&LuzJf!1&(Fp4r(XzxkUt-f!tK5K_> zO23fI2oxozNRCr)PQXW_lH1;z6~8ZW;a^F4l(tKNf2j+9=-I+wctGd1@MnHD{x1Uj zWurAgWceRSH?j!%0c;0&COaA+v^zZNE)2Ha8n?5vO@dLr! z!L`HFtvw1K4ieK=9f=*k2DvSIRNRJi3$-1^mWvqEmPW1>Lr)h0EAfvS!4aGF4beZJ z?9JI2ygvPgc9lB=+veStmy`0_c{Z-`Co+!OOterrDBwVRkD@_3lOv(V~OL+eF# zg_|JjMB@go#Ac04t#9@A-W}bcMt%afk0;$ey!H8zU2Jqif61|_*1Z1g z9IG5Zn0N7;kR zm&emRISgDC*G#_GN8;g@t`hJZ=HK!o-|3TS^`f9wYPeC#G}8 z`ermoODn*n6PT=%eFAS^g8k);i}9ZV7Fl?O_<#cs2S@On1132xW%a6DtehB^@-#6q z!q-1CF11$!4AR&R9&G*n|3qzKGZe#*)A(qHwO#7?)4oGH>C#yF3+@~}d_QFK8w{Q2 zn27IbVyqywI1#gVX|Ye)*9Hu9hCVZuJlElZwpQ}W>dXhZ$_B7N$v1NM_SW6Y)^@|Q zlF;MR$hm0bBdIt%AbwLtO8K{HwlPL{SZzNjQ} zN_0INx>en7_*&1zMCW2+;^%*N=-rx9aP79!I1ya)yy3fM0AqAVhWGa{jyU6Jq;Iuj z{S{M*0dnV8$G!4Z*hKOPw6L}&>iusU?DueAmNDpe&11*~>?a4N&iVRpa_`(X_+Kv` z*Kod#eZKE(hmU&s>~9jhv6HsA@D{!TZtOw@Ig#sWU$Q{=TFhlXs*V^3`2#D_i8nC* zAUSg4+`|}MP}d)%j_L*^lYOy)?2#TsZ@p7-`l^$a?t#z01X)pxFS><3O31OM@9Ix< zXMHHh1$}DWdw(81OtJUN4aGOhKO~wc_)N;)2mUBBC@_KCNZj2Sk4+*Uf!X>DaBd|J zrS8wZjQau$8lAemlaZJB+%u!^C(niBQ?hl%GlAi3O|j>J{eAgMQysy>)r@$Xz&W9@IOcdYj+7-fva! ziTAGeGu7Mw-t}Hky&t?sJ@b*&Zq?iM9`%^V4^;2vyn3gA%Xg@k0sk{=VyWhqOUq@; z%eDw}=A?|+oC{5u``x)e-k5T7Mx=JJ=X?o!Cy|Ir*>%g9B0;g>Rz5$y5Z z^A6ai=*wB3u8+&ro$ny0o%D=rS%aeQ65bua#m z!|)!pJLpfnF-E)SQu3$5Up;^7`(JVWsqie%2RIM=d6w$~oaOTY9>NDW%k=^7Qys>$ z4;l0zKEMaD?=Hj#_#kpa{6XK-ZlC_~OiYfl{C**_Q+^ZWdlK)fpIgaZp<H}C@1MGR_Rj>0UaFo})przoFM}Ta8a}7%h%MFq>Egxue@Z$Y z@bG*--S~fw;(yA7MsoM*J=^D^OZTEz731Go!1#b`m;6c4h@F>W6CYzdhb!1aB`&du z7(|W7{$@P-eZ(D)?xghh<Yx#MI zL-ZYl*7Up*|A%~T+26RdG-HUCYUC^a3;oHvU4L?tF+^N{vS>-))lb^ze@?%v|9R+s zG1(d`$WbWy=+C!wJQT3;vkN~c9eKiFPP9yuBZCBk1oN)PKRpjS^g&{kpM!UZHWq*j zablId?~3m}M4WPtIyv@_ zMg;n2r03(;TMRt!fEO?x@uptnooIMSpSz)7a``h zd*}CST`d?_IWUn}gOlh1OUb`hxM;q;r*s9m)sFJ~RrZ{&AA3#Lm}K(hOIcg5nK5ls z#$MC*F#pYCukO2+d3^ir#K9i;Q6KA5yypn?ee@RS96r^}`hA`82~SVJr3 zdq3Zw-egk$@O##oY+3kXW-h*6_*yr7?~QewMY$e*jBy5ZPJ!>7jcUux9$KU6`LTib z%ht^~ao=11!1oSE2iLO~T97leSwbA1*Y2_f^;g|!CauhN5IaX)Ty{44BW=gWFI%g3 zGEMZ)y6a(`DHcFdko>fBZ^-_F^an^E)yOv9Qa-kZzpfe|`Z|kr&H?o%V?|$+3 zcMq_{MsR&|4lSoc6YyEwy*1^hldo$;+y8}9hawhi@1UqF1k#~!;B zp&2+&?T_=u=;-7AoQrpP{nrzx=k{&;=N)^$iS2*rd_>jxv4hwh)3l!Dzw_Hp5uF)) z{6plGdSH9&1mAq?9h>T;dmbrEUyAurKi;1=-;vheIJ6(-*jLyY@PKvb9g|#}U>Cd~ zj&GoqJdM(o!qAJJx7>SSzx1PO?66L3lr7UL+xEdHF2rUoBOi+R$$Z*+i@L3})j>OZ z;1Av8ER#>pnUA}-1@8XI#o=f3@F$*J>cNgZgnWOaH=l)h_e%+G8f!N)aS3ZTKCP-N z3yr_qJb7;CsreNImRh?)_lYYf)P)VPRnJWe>P&mzku{wA352#5CD&VBOnk4xy@}<(C6{}b;#i6P=VDgmLrGP0W&>&{J#rls4wMfdK{jnz83OX zz&xabIWTklSVm_f4}2h1%em1IG$8u_pdWUkUtsq$a)e2qWudbtfqT14Z}QXo{XK%q z64rBc0elEMucUj{z*77KmOq|_d;y+O#u32Yio*NZS8W@+)6;Jqe}Uf7nyxm)1p|}P z*o;&SISxv?N4RU(^(&Ao?oIZ2wdWY6PKL4i+x-*gXEUSWEsQmGZ?wMzI+Gts{zhU# zj$hBdSC-s#YFBH?^B+~ZXAOttL!pi1d)@ncr)tjcAC%w3;tXW!nOkXya~`4cR_5Qz z{JW7=W%vV>!>|sTSXLGrn8aR_U=+X($lU1g!@pe$U%COT~fU@%UwC&QahpWzmBEBg`=f z+>Zi(^!FXYL)`t{gN@sZ%}fjs`a9?Ad8T^O=|ubf-@VA=qww(_WU%HT9vRj6XT}DS z@=G9F)V5?^miA)I)5-$I{>Yic7rA|(#dGziIi1U1aTMKkkXKWFlXx}lIJA0wzqRYp z0|Y0%lccTP7cviITZS>E8Bdnyxr9AS@}%0n1%ILJ_na$tRQ6H9QDfx2qtw;7m!n@D z#=bwyn$Z|mu(A?uT-7=7jf_Us6b(t?5Wuw z?pUk*Wh-gpx+RlR*YUi%z;8r5;bXB?t>MIWy`%p!bnB=p~0sp>I1nDbJFHl}^B44X@3BeA!% zKPVgv=fZckG{G885SwJS_SB;vxpXg^==;dW!;~FCS2=PyG{rl+u`@EfuQ98C?L`8! zE0==1IQexGoGZZhWcwd1W41XpD9qp_5 zX{}tAJ~%&C{;59?*GCsxf9H@snuf0B=T^*J?y*3*Y^fe-(}|BDKMZ;X$@+EHcQAka zPR^aK2fy9258#^(oNEaJUw!Z1M4TTn_+`X7Hcv1E%?)NzqwB-%;!iu#$y zD$8qE5Gx3+U(Fi3kaIBj9HuvNe?x}0Yk9Z%GiK+x)V0qzBXekc;CPnvBJ=Ub9yU`` zhwq(t?|kYXAr_S!lE;q#o1#<)Jd)gN@XbnWu%dIXZrfmrx(eT?{p07>s@>WcabL+9 zsp!4)epE{z-dU;u=dNqX8>I75h1yGHP7%&WMfk1!0rD3`nU8!dCr2Wmd0xYLs#@YF z>!{O!FS>GkU|lWeB{Wa1Kk2NsnX8Soe1AGskSPEi0>gPbTY{hLt7rdtD}ve+eU~s>S!bA zw&Be=aA|T-pBu)1nB%bg-}-IkL7Rr51}c0ui}d^BEsp*+@_gxU9~jc#@GCfY8*TlB zjT70kLB?tx^UsbCT=aHfs_57Etm>Rr)b^?w*>)ZJ)!|TK*I_eiGOEsSaOzYz6J1o%~81vwt!>FlZ~7iB9`0w`Rs2OR~ubid2L)p8yUtPp^cfM z_om7L@!L1GhR_$}kLcyO=;JUv#m=QEFwm1XFZlSAX=HzzF-}E~RGVtAj`rfWl4pGL zZL6H$0eg>*V4-$3?nZE9W1(A1c?}HHRAJpuH%4pD#G^96IPy9HI7| zz$v*LEKb`!oQ`&Ix=phGYbC>Qx@{1r=q|%>s^9O#scmB~ZFo49ypztPc>Mc?Q$D4O zDF=^mQ0wAtH)oZeU2<|wVE^5#O7I0PMZZrY8};sC_BPVIBi`t-X;w;HMbRgLyLTko?5t#eekPdHs66g}#>~(^`>fM}T2C z`pjCz#CC_uwyID0M6k)&ZzlwS&>Jxltee4Wy7FGC>9T6t{4({pFlNm$4 z9$iA2^k~|cW80{C_co+Y=eKc&+F))OWWQ}A`R;897Wr*>bK0Rf!S^K(#4DmJDmy2S z8=`Og$O9GVf}U?^34V$Q{tEW)k6%px@(q^YtH|+gf?S_^X7JZUxbfUoWa0zA&Bgq6%(T}-ZlV)XPVe#+w35uL?v7j_oI$BM~6P)z=T;`_p##rUU;tLLljVsPt^ zx$yj{BW=FWp?Hguho$3$`kt0QgxIJG6YN`pzEi^YAUF$wYvMaP(K*>S_C4o&kk}|a zj}#HR&G!n%4E?(J_0G@0|KN4Xoypny0h<#5zQ3RIfN9xP|K{8wcCTOv-~TPJ0~efm zbIx+zrE+NXGkn_e?>Ob3RXMo(lv94dYXdw%QG_G!lINP(?K|Lf$M2)3NY8c1V{ zu^z=o+?-=JrdoN&R3RR>bE9e96yI-fRk+ z;(QmKrX{=V=TMFr?d>IzT{<6EkF8qDb9+{fu`UBwSJU4z@P)lBJFwle0~H@z=98P+ z2Z`fToOc^pRj*TIEFh1sLWM*vp62~6gCSP3xeOI}*+||gs&De6} zld!VRu4CyUz~$Kezy;p(m+Y~n*@vv5-86F&+%)fed#;FBm89qkpV(6KjlzX)DkI>5QG0+M${g=?ntFO7V0v(@q_{5}Ee4}cEJEFCyLfe>_1YUvaOkR;P8+9I@2^i{8d?K zzcOazL*1skj9Kr~rIU15V0W@cr6VR-i&d-Ro!@0YQDqm7WBz8Bhj%0uV>{dXl=n;ieGY&qE<`E#jYE;ZKI@$uA;zUR>SI^m|Y(w>c! z+;e^h-tq3|B?r0l&qx359{qQt$LOw>Ug*@){{+#0W#=3039IbFvrMPzl${;0ce+d( z7wnwX9_YLpe{c>uV_$RNv@{LfT7QA!PT*7jjXzXl)Y{mDPq_zLeiWL23Ys4Q&2Pef z8>DUGGE4C5#?1_TT*X^wL)U+)%(D6cW6q2o(J9-+`dJvOgERL|J?YEt9VruhboUi- z=gA}IzJMY9%sX%H-64Z-{2j3V`4?S0MgAuBN#V)WM}$wyr=ZL6!Ut>=d^493{#lzRA-(T^3UVMesKbimM6!(PQD*v}VGt8NF!6S%XHiCH#@dYPtLFd;K zlAXxi26VFy6R^5jBl2}u#M~*lt1?eFtCQTNTyi%=d!oxCw_QhP@%(dM``kf6`v$%d zcH6I{%>uXmr{$w%tj*|uF}E+Zqdq0;1N0Mg`|Kq4Lh?O8d*UBDv!wZSyPsKoy64uv z=)!IXWvD%ae;&O(Z|@I$C%r|0Uqn1^w5#_3)6?q6;Qc$~c4ocXb64^`5SP}K8RyWd zbp1=AQNdqztll%up)Y5BIJTB-<0|IFS??XofYC1Ox(&ePLFRqP#evE^94yVlfoSwo zc{mV{E{QpM&rI=S_uPQ=-+aAiDYW!g>OJj$mEHp!E`z69``|B#HIprr1PA$i`PYkF z8FR>$G0zRj$i@bCvcJ*Aet51)1H|EjqCfZB5daB|+FX<kYU4&!m;4tV?Y z_5o>y{!tk{m@4fgZwP-ykPzqM=?e_kEVp}yAJs1_u|CFl+L{Advk8YPVp}M zbUV|;Q$G9mLw-kZt{;P5oH04NnZ{Jin3OY0Om=WQr?4+1XY1B{X=mM7GyMU4_Slz$ zX8=8Uoxaz#F?NSipZ>0+dwFy5>P(c}|2VjejZNLEy)I&0^7XAH#2U%ISizoV1o^G~ zj~MscNcPJ&5h6B6dl;TQ@h@-DFYyZ+Vb=Uu(Toll0CcjcyhGOn}+`l|F(41%TiZM<{{^-e**lTibtqS`ud(D&>`j+qsX-{HDEd|%+= zd&Oz+Ju;7=D5|oxmRnjC5zmcPFDiqf2T12!sK+S`afAkLYv->{dOLoW*9ua%CfI5qiOwNAWz^>5LBgN+3)?(_21 za|VQ1;Nreo;HG@GiUroQMmMffeQ&~7tUQjA^HI)AtdE(LgC9qqlV2g3SLW%ZKO={o z_8pI_$L+unX{J`eaBcb!=LzA zpzcqOe&@CG2=o61bW_0IS*Shkg;sZxcc?#RylnhXmtD*2j<~-VQ_J7G67FpwFH3DZ7jPIUE1#@8@D~Zt!l+(a*c>oF8%gA+^il zQA2Y(R_F3=ubFi1k`cBa@L)e#6T|vp4f)3_eu!=Y z-->My)R^+6e9!Oek6&={b%)#6EqQ(2;r3OZ*H``Uz9!SxZGxTfg-;}+Gn~-Lqu+cd zzJBHOd7$AbYXcl;+%$Ut|J@+I8lH0S)$rZf13kcCvx~3B?+&MtQS|wVQ_N|%+kUNM zV;ra(Zetv9ZHxo8vN4A6bfC_)F+4oI2_B}OqW#A`nElX&Sw|kse(1vN+j%hi_HdZ} zhW0;5`$6cy%gJ;uaUl6Pdf4r+?iHuM`n||HcA>Ejirzq%Lki!h%$2VTeOUmVgI zygB_lefs%aMDjmOZccLc?!(`zzHO`pI;42U*U3F-V=bV;|H_M}I;k%lXXPYKW zcfVr!3V4=)uj{)POI%>vE9hGYpO+6oz7hKAk{_auSkE~8n|truY9^Q`CAZ}l9tK-8 zB{ki{B)=l6B`$KGPlZV6JPPmAU!F zH73<1Pm^*Aww1ZDV;Xx05?j8T5~D)RQE!E+ecF^4v9Zrv`PyMbAQU`89lvGfJaM|*Isl!skd z3CCdGB!mpFbXJ_A^3@z}!zSm4Hcn8hArgzap{O@}EsG={s*61Uvdhlp}J^y#s-Z3-2 z%;xkyJc2j{<{BtX<9~rtaQ2R?A%N=jB&BpHto6(4A9WFbLCk$VcY8h50)3bNq&iE0f;6 z7yYZmg=Z~&dwuDww|GpFxxDDpIlcMh@AvDy8pgXGTSqyuymn;YWI4-OeBYAC{r_=v zmpb}>KQxoS?iUL;q0epKfG!zD*9^i3;~$$z{*u`CD>~RCp%1?=+QaD-U?`nm@fPCQ z&U_u65#4$)N66jaQ+;ZF*D$|R-3K}{@4m>n>`gTg%hNH(9Dkg+W*fV~ey-L__)I5e zHY{Fv_E6lJ>{r2I9=`7_*vk`%$(e_KqW$9%@}x*-lmA`ib?7Iaz5EEdq^-Z3J=|vs z&FY!>Rq8rsTlukAbt{~9>u48wGF^KfT6=od@MYpGXkTTM*zZtT5)j_%L)r+xnIY%(Y#m%6cYjO5m ze|s)#%01VYYl)SAHBY~vLQM0Y$DKPAD-DdYvwUnVzr+4))J0Se_rw;1< z#zc(Vx8!2DHD>vwg>8cV*&EaMy4kc%yVbW=+B@n?-1q7j`_pHc)r!3jL0_frK6%dC z@Im%Ro!C(MwmU>G)SIaG_|#ad@$XB1sjOD8fF6`9Z!a+zi-AcMXIJab54Y8DM$fSe z@7ex3@v~M(!9S<=)Q)^K)wf>V$9kPpMcl0N4AsHQw68aU7?GvKM1?$s>-|B54P|i$Gv2bU?<%}pEfrqw7UM@vVQ4- zI^+KmIPU>o8ySc66Un`rK^uK`zv8-}wRW?4B=KtZZ(mdZ&2VP=5!Qp+9Hc4M{dGQi zy2H=k(4`cw>i9>8$NQ)a{jb*5|1Xn8-2U}px_(@&lL~H`!?dcr~t1mvH8_bFJ=v>0ti3JJxHtaEd_R1P z88`&}C*iA`po#D~vpRdF6V4?M0^3)v818!z4&|tQj_%Y}( zTW|0&EwJ%L(YJ3r7`n%NYD*Bq z-%G6bJjShfm~D(Vai-Z(i|#xYxOsi6KlP=4)Ye1pyqcMJCpu**^NW+0q8U8s9Fbx; zH81b{jDJoc&55;H%o>k?+iu!j2FwoM?aZNbEcY%j$Mu|9AHwG+`n5iE?xq)i?h(cz zy;<{741e#a{vSR&%3Rqy!wfvj{GVt1Qwm159lg;ET#P^Px+NE{~N6PEBHNr=_RSq7aRM%816~6+!24h8E6g!j(?l;rVsPqL>`Pj z_>pV^#r4XE)Xmzq=P+gfALgL39la5FyZ!c6u)pQ@_r~S1!MaBy`ZuGE72F%o}}-)Kf=9C!Lfah!^8CZN&EZ#eS7igzc$kh9EaDY z51I1X;QPBa2HxMbIXJd!^PgK+Cf^rs`$mCT&3!x;cMqaZ??bnFY(Yc+k(t0hFrsfM z@b880l$$0gTj$=3`Xv*!w~#&*E8owjvz{FIe=Z`P^nU!}XKlURem`q#lRm-MLi_vf z?eqDZ$A1o*5Irm<1~pTU9m`oN@tQ339f!Vk#v!RQ529~iX6c(Tbl=Q)p*QhP(S~5E zy!C0yZ*u$iB%ehw?z=IMzgZMWzgYyWXkSJ?x9fFxhFiZ%^?5I@PsSK?-~SsvyXR^R zMf)f7UYdR~^wTp{ygIn`Br>CjacAMx)yyHt_ru6N`I4iYk+<_e21U*fw%w>XASWeX z6=$2kA1d0^{-E+7l)8OJ`4rz69#smD63=Y#@h6W@O`@!awQ=E`*s95MK5NGsj%4J|InRo)Y2{($l(C?l9iEhGo@vk(n{1`A4U(vWkM?vIv*sllCe|Y+= zuBFu1*uspZ1saonFl-#)%^SxGZyexD@g3^-mArARVI1mTzT-4>DB8`=N9I|X9ol*~ zaZbuLxSn$f&f0L#%_D0bC$2zu8feV9>)97TcG`R-_XYV3b=vvD`?A3Fe$AKfcD}Ty z{-ifF-&)m|WoIlwcZefin{;eZoj<>OHq@%R>U|z>Kq>FEKVcbLin_L*T`f&{p za$sD%^>Po!vwzd{BJaO#@7v;bq@Vetykc8tKu^F{FM7Shfj@raLu6m zjP|XSt9Z}&{yh7x)B4MY?A3|DOYKaQo`deT5r6$m_5u|55D&12L(H#YJjJUdvo;#D zW5@<`Y|bEhYzef_UEgj0dWKoO1f9>pzw;bBXIl++j=xN?0;Rq=$Yy)x1I}9iIb(O$ zw{zd?$SUr8-8=lgSK^olILmJB0Bo@X_0@57Fte>e_S7SzJNJ^~ran0#)tw`*`jIop zZ{y@g6rKyfZxMLz1rOql#1BJvBeq)op&4F>4P8CK9A8Q9s0)!#`;t@8hwJ)}K<6X5 z=dwQX+}i7qqt_LTY8%ZRm`%v1OR4wpl1oxwT2$9RYQb&&C$X6(BNywjn--eFw&uXd zzLt-hInA8Yyok?*W^~uWw;x(5xHnbA27bqR&dX`z4dn4L+Gv_|VC}VIN46CQ1IKTp zovy&BmCa_fJ*&_MFC#z0O6hva9VdI}@O*MoLGQV_X5eTWIpv`F9{5Z*YrKzf|5NlQ zYk#-g3>@S886`hi%UM+NEwRrU8nyM0e5x(Zm8Q7IvTr5%!H8#_bLFDj`oCbzld3lW zPF{tt{8zIX_+u0A!ZW+SOiTuMl;&}c&KT6?jG72-?FxQhT_lBn>R^j6!blhPcaQn zYE1tRdZ?#y7Hs}|?#n6Y8~NtRIa;&3Xt#lJ_O>|ovOb@qpOeVEYoT{N*ZVp5{ZRjH z2PI3ndnH81xx8FlUxw)+4L~LLyuxo(^y!KUg+CCW9U)skXn}+&H(MJP(u>+o%#m6B! zD1h&8#*XuNViKOHbwCW<>SFFzSsXFe~A3m`CO3q{(*@ELQ!M^2uzsl+B z3a5OVxz9Q4v|i;Cp$D)2MHk!pYgG<^HF@Q$Ren}%;J+_*+u!fh|G$(EFqU6A<2h!o z7?rBz%;^f%-vs`t|7re@InTG5R>!Y@Ab>9sI{OKD2|=&Q<#Skjz3}iJK6Bu?cP227 z>|1*X`V^mFf9iNQIQ?hSy4Ir=kA_5tqCt<=92!HvhE^-gS$z*-TWsWv%7egp1#mt@ zTfM+?-}n`48=(CoF72;iEIO;*JC#^&%Ddh2R;`Ik%)qzu;QmR?lQm)BIluH^rfopmx+PM~@BKO7?>G0GON##H|L^N{ zdvm|bInQ~{v!CZYNB8LeuycKzITr-SkF^%*`t{h;-t#W*r|UX;ZbQoT8+83@)|k1T z&gWaeu-Jp+8_xWHM9%=n_nmi7n?uh2r}4VZJUroC-)8Q1&Y#NG^;_XRu2VCwaRndY zRPweQn9-a4Ra}cGMisum#udis9r*cDkCuMr?4`O;*P*dBwDsS-^A)uPEIIIfPINMM zZ+Tw=dFuQo_Ra73gQovho+EB?$jqK*_nf>#yYV{qF4GSEvQIl~&szM0u0KdDfa`mC z=d)@bJ}pJoNvBny)AZZVXOh9Zu760lpfBhzCR;NLKLW)!Cjl?yf#+)IeJ=EFc?Viv zN)5s)=vuTdT`>=OSN$i&9l9ql=dK>u13y+l^Sk(F%!7Q}VFHcsvSxpd&pUjCOW9bm zPrj%6WW*|e!P$gf{jmcn`^RKYiN>m+F@0BqzoxrOdhcT&VjR9pi0|+ zo12J^3Xh%HJP%)F2hX0w&#z}M@(}*CIxpP2pRv!y4z;!~a^A*Mke`+KEVAONeyMA7Y0uIk zu=v{>u8)T%TiT?y{8iLhi?mJ@%^=60fn9vO9OKgA!ww&8Gw^>7vi?F{2gZw`&;RC~uke9p*h}fi`cFeMlIvfD zUcQh*Gj9+p63u)WI`f(1M)Y&Wt(UCYrGL5jvcdS8M?=Dy=%W&R34XGjK)-ch&&ijIya_Gvy8=G`H1sC_wFX;d7yet=@4M3epzq3D4d=Uh z{nVxQ!yJvLpQ~s;-}Tizc__Om7d}g)FRy;Uc=#< z!KwDY6yhK6f>y@){NoTY7YJ{xd{^PS82Ql+KUlvNn@jdXs^9A1H>>4p55Mq}?Gyfe zgFg2{%Yy%V?l}J4{r)cBf?4vCSSL8Rs^ub={yU)e5+4s(|IVw2W9ffrJ)8vgt75?7 zg})lVfo8{h@f&E@i{GqgzWwo=O3^rDmYs6~S|6lw=xhj$-|Eu%tl??g*0_MqpH7F) zu`z7C1lqeD+$c_C{e5uU!L_Ht#2dh$=>BS#?u8@GMWy7vpZ4kZZ=wA-{H(KSA57{0 z8ubs4`TPF@{VTpFUENXU(IquM2kFwO2c!8cWd6jff>pVHh3KLnIwRkuC-G1aS);WY z{y=px5SEdirtXH=&13!DY7k|ul;wV~+t0N}U37@|w1_Uk@|9SW4PX3aus{*}; z!CQc~YKiO1C#iAUQk)~55_Fn#ef_hLl^QpnkTZo_iI(FU*)NyPgR!|?P zU#q%MldfC3wv710jM~T7W>KqpS76239`IObGI~ExoI4X4_bh#%q|Z})^fR zFea_F7ZSU972c9h$$Ba_QAJ;O#^Tn^CVWuqBwMogtR-f&Z65WTIMbT^pTL>T;7z$o zdq#yDi2EI`<=k-DA>=5WE}k8Zby6qe!8PR1lP4^By$-n~duA(fpuOSrhUJD@^zhEW zde-J?dp>LU^WPbmvV6h3z86`O%+4~+KV!TOew;PU^|B$t6KvjS>0g{R!gvN_a`z)& zCR}B}yX;4P$%iZ15gxt{^k)#>p52Y64sQNq}(!l4G?gL7aTwth3xJdydyB!)FY zaj^UYYpN!CL|& z8*T|)xN;JoW9EY1mC&GSl9sUM$K)#)V{k5C9d&`NkLI=8u%N2%mts2?ETXs&03S%5xBVb0CB)>Xie*1r60Js zp?++nIg@eJ=kH&uwa+blKhE!0a}K;`OdcHX`DT3e(A*Y28ux6*dNP!GFrOMiU8BM) zD+APQ3vd>Y8L`r%Noa+e4z2{WgN(b!##XA@cXr#gW&%Hn5#U$BbM#>q5S1d+UYiE1zL5wlEj0OK#uA84kO7#%t^A?$|_A8cP;2 zq$SL^?vEo5C7(|FzPe8hY!`nIcP|bftG?MfZ05?R@%6FM64+`X{Di7#daRV%Hoy8h zYZdUT>h!5TwbxF&Nwa;3_q}U7-D~0x!6BR}S8_LT?kdHesVPxS-MV^wVbuT|yk_(2 z0-QJI)EU&f-F3=ga_X;6XoCNMAps2japtb@?eed#ye77jJf07|>&hvgjraEGq|SRYC0}(CyyHJl-rG+u`LvVV9?7R#z9pN& z|_auKss~(MZ z+!^V&bh@H2*2TGA9~Yg{zV=Q059rkHH+AWB+c0$MT>C3@`sla81G2E(5FY>sX~HQo zM}KQwmz)R7pYr!|$@@%zAK{{d>+=7oLApP|7=$Z5FCOv#)*LxC@ysC4MND9j4;`K9 z?1}$6XUctLyro&d6<}fUUQUwYuyK)4ZO%alP-1(b-Sp*#Mpob*W&=Ku%x>$dCFpaCz{Iz|Q&0#mV(N z)vVfujZ#QlAb>qwV%p!V8r}Y8n7ubk^LMZHY{+6Weq}wMAh5~yj1U*xV7zr(XWdTq z0UfxU--1&zaL6?`HujckmSOkm3_!y_!I1}_cAzO$2T~v zHK=90+ivE6+uTjnrW>#QSh|__?JXv6WgVZiCropQ@s<-i*-0EsZ3$j4o_C+!f0?vD zrz4lrF6W#%aBBSq?5nN#63_7Qv8_`o*Y|H`7_d;oY^?17n&Lc{@!McFQ9{u1xB5UFwbR*(~tvRdBe>O`8L*f zzg^q8jr{+5>`%edlw;29qK)4m$I{$bM>hE_OCNfmZ!0m3EzJ4Shd$D$I<3OtoBv7N z0v!Ww^=s{3a;8V}4E{a|&!2+Fd!b>$@*8B}DDd4Bxad^$&+Ku-=gP&Ns2YUFS$|jT zB#MuxT;vYkdmTFZTl@Gi^pnQ?Oh?bn11`xt$#VTxyioU?eH&@C{k2xF_U4>FA}94c z@sb^bwZ4iGc&>o4i4D}d!a@m=V~ zAH!8#I6lh|*O}M1Im})N@NjD%xQ33>T%T1iSRC?h&n$4xoTia?^)h{ALSLEJJ-m7g z^<XJfo-3+zIW!s_w{`YzPW<+h$_|!x@c>ih0&2M;#t`e zemOeCZ-%C%3p=4*|C-@kVAx_~$Y~9Vc__xLakK@Hn_54F&eEVgI}Utu#Rhti_a}k3 z7us34<^I*9y1ushZSpcIZWz^YD)iBoiHuKU?dCbvE;<2UoPt)RV~<1kTX}bOAamsi zXm=7h1t;-sbzWK|kmTnP@bgLdYf@iP>?Ckc0yp0QH>`q%~0 z_$Pmc?xDAn%4^`ehwtNj1fOKQ;$t&Kdl}R`@zI`@Mh%@dD9b9ynJFBuFel z@!xWEQYp5l{MM!1+i`i3t*fwuXLiA#LGm_~)3OtJ$sXO70CAKGd_3(@33Jc!iFd7) zoZW~Vm3>uYpAFk*ov}MUz1osYS4}syrLm|z|1;_2j^!x6aCvF(CUR#2%>R-XSGu|nZ17o2+d+RGTFCxeWy64=WaPbTkfy1eLMkh6iMZ%WW*vH`j-#+Dpo`jsE5 zwY7R+mu=q#oZVw0oN;=79CU<&2dRIuGKmAn4tT%0sWmw~@8R8f-aj`*+#fnO&NDNC zYX+avVQ~8nHPJlWK6f5^9AVG1O`f-8=?~ob@w^c{r_y6NaVFt+ql@37{GDsFeL6Y- zPYcJn&}0Z2tKj~5Xl14UJ;Qr`+7leP@IV=T$^HzId*JjHS?zx=%=kmxEBC<=qOBa( z%S5M|>kzaXWZvqaUBw+m_w~fwYGjYN_6GZ)Rz`en1ZhM0oBqB|UK(gT!N>BwkCvN! z_<^(?%0EdLGwm`pg;WSglu#rk|RWIG;u__JtqC&UhJ2B z@RI7;Yu=vad30Y3xxKL+Ci7H+cTNENGx__tp3(A*y`FKZoO7yrLgQL`7<&)n-NOB6 ze1GfxPUbW#kajA-T8LtB{_)A)^pDRQn`d9&XYK2xJSjwe)B>NzR}7tv;(ghl&vGqm z&|Y?U?aq|nmaZgY;04iM4f9YAjdh0dTB?9qc6!C-!3NEBfVofR**Wd`*HuRQXh(5y z!6I4-^Pb-K`dTtL7nOZG=uh!mJ=e!OP5j=#@8jTauS+|7=-->Oz2L17y3rZ#ojfxh zd8gR3=8SsSoIjA!Fq1hO;F(>tvjE&zQS&|zTfWn^<>xVvRoM65_{2NN%sHDFtLCD( z>uX0NCUj7>107pDy4w4@99X=+{o_;qo_~zHT=|m@PLG{uj6I?8gEZ1byPfdOZeTbL zocris{L;IUN2UAEv)z6KO3Q^*8o8Hp})oC8f_yw@=l2^N;Ul zYDx@OufMw_SfDkz4ty-(H%wil`eTulXGiXt=d95McrJ(Mbe=_m z{1KDYbYy*S{n=XW@q{MqUa@#Mwh6raU=@2j|ITbzJ3-zn=DmnwL&_b&p7*zZdOkFc zk6?2M@asBDu%k+f3$)&?^+lbBrZuS9_*W-%=EkJVhK#?P{i^bLYmi5`Ay?PIGsl^y zy%W%Hw@xFCE=RS4xMCY0# zv++AJ{w-r&{U~bw!+Xmao8(OozUO>unw`imvgf4K(nleCMT+=7K~4Tbot=W7>Vb#i z_#4EJ)^q^3VqOvGCv`kpv)7o8<9GMKH=;?a?-}ox=x=Etc`mVs_j%ww!F$=jWOFNc zw+lZ;xJbs4oLcinzOwwy%Zr-JOj@ttJ%&sYylKGO#5KXIJ-7KjSmW?j9G^%1`1^Rv z2j9nSf5Dan?xA2^GtIl&wk zx;oWB6DB(|mPy-%(7*Joy;hzXlg_b!M>1oAOEm*3iJb-Y%^HwsC(qq;9&*3^^mMQ2 zi9Q{kb?_s5N-|n&pt7Zw@xeanjewuw#xI*#ZTj1tsdfkb!=dfIK3uzu*U~ilPBfiL z1M}%aeuE#*a^D{3Py8t&xDT1$a zs0#*OS5Vio^&WnEXY1XwIP!Gfxp!97gn|pZsOh8l?R0zQb8w;bL!Nu)v+ix?%;zP8 z`TveSDq380wCdCU=Saz?Up$+$IJ7S3T*c9xFlU)^9lz%wIRR~)|2DapK8qu{u_EeL zB+zpnY?k(;m&Bf#>&P7GzRmm%p4!M=vv#$f)%6ZnE)C8R@BNxtsl-`vi@twHPW;R=BI%v3zXl=5CZw2p3Dp&XH| zv{etDmeUUBYE51Mt$nxVQT<@#VS*T0qK=p=c1|-j#vD3w)=d0=&ji;q z_>_9IBd$qxPV?4y(T6YocFqNC^CaU<*7{$~1v|2_H&f}`Tmfd;LA z3`c8Qgd^xN7rwDLa^=79WbI;T(a%RCys?5C(b!InGb7SJTz!bgQ}TH|8k>8enZll! z{<6!>nb*kA^>TT&$Lt)sPjwAbYhdbHH@1M5CO=FaM_zmP2 zJNrAz|1ea~if*p=_yhg=o_t<6UaQ<~f4zzhWKrj>#0Rlgy}GqI@OBO3rY^&ro#a4_ zhmMIIoOunJ(DlcWc{APphkt+%yRcWgIk!W(g_Vrek-Q zMdkXw$tYdht6~_<6$SBEE`vx>EOO%Y03k0=)hY8^6JJozr)ajYFPyN4)C$EL$c6UNBjrW^0V^L zwWpT*WTGbzZ?@~fN&mE#`N%yqvn}t|e><}2p_a3(V>VRPEk9EC)C*@zOz72;#hcbu zMN3}IJ(S*7vG}t`cS6f&z78E`cs5^ht<{0w`EB(-@N~E^c>S}N3wlfTUkGk<)>G?i zPo#iY_O!%EY-DWxGT|KAox>d00b}(8CH>>R_Vke%C$^oPd*`ptjyuzPR`uZKU9t4Y zvJY-QJO0g|oShdxcb2@iS5GY7v#w`xcwLXleDy@;uC*E83a+buAmr54ryaewl;^hc z+!mgr4&bX>Gk30ir7>Wi+d)ik6LT8Ee-=K1#05lGLHW}r*bo?u6F6h_k6U|xsU_5? zI^Z49Xc4tG{P)k-hPSpi)1AZZzPU5}^YC}l+Iz|K>yXulDzGVPsbi}(uLSx$&~6F_ z&_&X(8`)P{9QlyeG5R|=XCU!ls5wE+-wyUYB}Rsul`j&fHi*`D_92hs=qbs>oD~s# zFw^^a(Kh^1$QfhKI2@nB|MzEE@3-{m>Z?mN2I%9cZwwy%+E?QD=a(_Jl9$LzTZ>up zQr8r>%h+QIG`=dvC;wUeq~`{(n@#~&=H^KA0QsUz;OoqX=l4w{j%S}iUT5YPH)oN9 zo&_#$p^aO1UJ@(f-0(1U^ahYw`@y5|rFHL<$f(2w9K2ot zE=Q!`bEoD7e13NreBNyNDOn#zI^Wav-6i~u9#d>yw%&KBef$(3$z;u!KG@8MO!+0T z#gzkVj+Y>-GKu4$-=y1YJ|(dL`GxX-%T46n98>x2Vm>*SS6-DpvHU9JZPfpPs(R>aED0cQW^^o!b;%*KLgI zoW(>_2Dv9&zZ1H70vpufsbox4GPKwo$4`B8@0SP7%$2`BykJ$W>KWK)-a5JyqcN-M z%kyIwL7z@PPCc^qigB)=5UV9u?)KlgHiXN^c`n^4m)hgs1J=Ju)@zqvAfF*E5{zxZ zKiP4)StNUK$4AVfI6Nx9q6?a|`MNyYiA|vVXFm*Sr_(I&iypVq1~j-R8@fKmd5uwc z`+PD&&r3ecC%3T&A6_zpTECN@vA%!S!V~!SvIBJeW$XWES^xhT*Z;Tov1e5uO)<9X z!HvfksukZ2E;n-?wOv<-m&Fg-8_@-S+ZY(_zT)z?@@ij!zh8mBRSSM8@Qib7!S4ru z`dj-@qS#V@X3h`UXzX#Sf`ZHb~#-nQ!pC@%EfE!M&n;)o*!Y(YxvMs`*eJU=m+c? z#4pMB@Y+i}0)5v&-*wQpZ0xVfP9;Wg7rb#NJiL%^bloxfIi`Nlt;g8kb&Rtuk1b9^ z=ITu0AaHUfn7hAHe&CPSgD>8*vS=ag-%0y-Ve@X{`t86*UwYny>-)fT{C4y|a2)@J zX+FX8$B0v>?_9Q4ur6R;S1_-g@KFP6iniuI{$YZ)z1Zz8Vz)nJ4xKsu$3CsoP2snA zdytRN6Vl=0rIV`9=F=7O;m*fTH(Pm|v|o|qy&cQRiO0t8Jn;jIeojfvErfM z_!#>@A8Y;NnIwIv?^oTv6-)QA@1VUm$;L1I{ZW^QIaeH5`%<%@eJ_?S9Q*sN0u46p(kV`+0wpQKmtk0D*znXjHqgEqdWhjX5de5VP8Taad_knc+TkEZNJlc^H{6cjjmU|^l!igez03VzR1#K6~Aks zpZl2?`O!=Go-sPadCQV%X}!e4=V*?c@i_1J|E^)|kMZ&7TJ$v49jA28u=Hfdo1`cC zE83UvL*k(5?HA>MEPt|eG<%TPV{#mQd5miz#-VkI zUEslq1r2ROG${L>8paL7_2K2K`TH$poFyq^eJAYjtv~+SoU&e6248ATTR18)PL8%@ zmF)cv?BJ3QneEbvoL{j3O5$xNkXP)b^N4v54cQ8h+9mtN+ zv%02u$vo&Fi}|xphy=T#pndSmJ=ADeU5FC}G=Q?R; z0D9{D>GHMCxWX-e&$x)~_xs0oBd}A^(%82w_>Y%+LxnSOz(R7T0&px-ki~g zd^^#e=B|R8nv&lg%yG8nAfL0InLCg6v*}whUc6O-51fr0D<71Vj@`46HTe7a+{b4D zpZR>|@tMn~nh$l;8!P!#@G0k0#^-ZGegLi9c z9M^TxzIXj{r!G#M>$lPdw$y3!z%}-q@vBcKb)~nr;4dLpS7Y~)?=~poJpFQWO1$5$ zVS)P^=1zXBr*BxpCGUfre{!$O!E3Hd1A7-h1L6f}z{VMozdAEI4DUBVXPM}Le7y?} z^T1yov`d~vvuI=UwtD6&)Ejl@O0s(`xN+o|qmM4(w_jer>W)YC1&!~YH=YqTzdyO2 z+tOy+pqb_$&RcxCNpMOcT^vVTcH)& zcIY8_W~TJ^Y?ls}xOCvn{{cJy$@q@O(h+*NRqcnEf3;f;981rY5AN@1GHDAZA*a%z z1>N7v{bQR;dq<%AJDESN@oWVb=!eP2p%JZh^x%_cRl=c(?=J6 z$PN=&7_fPa8IyaW={Bbt`)u0+d_(DhcW7(AJ#UA#5_Db$?KWUBtEN;4Yte;fw%#ubgXPAAjig`hkJ%dcKDF$^(xYpYjVfGd|_j&NW>xZ9#{~ z_O`!?FX;DNVhgO(zqrF>E~=HhLH~LEm$`K1w!6Zk(f|AxyB6)*t?t+|;4|oAm)1%$ z&VRnv0h`~PjK_#Bf-YU?OpR)nE|7l^-jxQOBG-!u$w zRB*kP+?;W|s~F8z;>|Wz!#LZKi5<|9)_uIO3UA)uJBXopV_nNwwFcCM{_^g7cxf8s z4JS6$uJx@GAByFqM~^)F8+R|;9HfU*16 zmxcc)86!GiyI<~0_ElNgmx=8AYHRJab!$yz$q`TPO(ZYJ|Gd|KvD<&wdHR1mMc=7^ z(W>G!8{9ZekoZio30PW{Js%`4qu)VypYKNeXY_c@k;OGaWBgab5Dn{zjhYw{n#vPw=UxfvB;vUo^)iKGn+hr|v~|AS-n( z&_311?eOi-n>(YtrQqu55%96jkB3g=@4?GlTx@o5@rcHZ3{EiC+xSrT&>8b<&X{+< z?eL1m?4K|Hc%zIr+Za3E6qc;UDXo&kp4g>5Olu!2FLc%-exC zi_iB{VE(!bb1rdT@p-ZAj_4G74v~JB@VjdLlvow=PQOd}UEe;%t_RHE?=A5uigWK5 z{mJK(FR6W1T63O<{AbOyLHSaf`0(zw`u1`5U9~lf-#zhhu^!&f#BK~y_aum&sdXpz zlr{vhD}&gY#U`zRy=itGROi+NiEC)xQ2Xh%78&Hs>SFAvAZJwvuT*|kMuUCsO6vNT zm{pH#`nb(+P_C4vv%0!Aa@5uZCYt^n<-e9^*!7Td1Rdw?H`3Y(_{OmvVr_pvgcw!&v!DZ)4jt2Pqn8`e& zTKVxp^a6Hc;(GKEeG3P3cidW#1`i!aMx=2rdK%AnTU*MR!?Bj{vM6s2|6xC76E{A} zxqDjU%f(i*@n~#N<@1(}D(&qhwi9TnKcc+qHev@3FF9-V#XPTPE}%}p1lk!0N6u6c z%Tc>M=tb)56eiMveo+LwK*K9sv~9C@Ssbmg%sXVY8HxwHrQqcaf;xdu$b zy>pCr@`>BmN6cZ-UQ>I@9+83vNs=A*ZjUE|K z{<da7HPYxi4cdy*Z*B^0#brBaeI44U(KmIeAJz9|k=94-@2|E#%(rmm z*$}@_JOmr^YWj4z3#yHiYdZ&?e*&I|w@o?cbRn1OZ;RMHx@p*WW$4k4XUL1n47SvG ze~${bpk0ScTiEIP09V*GxU_}x0kAz*tYf}) zZ#Ox(srp}hE|^MyCv5xR%=E=~9K~i`7~y?A-(<4>S9bn3|lY$LXz|Ct2w>{o)W%?WRL_wZ>~q`v#JdptSg$V%C#|1n@1htyX{ zu?M|)$8_+S-Hsf?X3k;0z1-l(fJgl#u<7LAOLi6C>(D?h_PFTmE6|{xk?s6_VDRh> zUHby>>vsSh*1@wyR-YyPfR;g+hsNWd0B)_h9C2yVk3*=mt!#h5=3td=4%szG!B5*` zrEklA_1=4zb5HUyk2OJJ_|~_myu*PbB$zUzZ5kVTwJpc@Os@M(fM)_cqZk1?t}T99 zwC&w0VvBXg)``wt@A%TC;HZiG|8nNtlPBHKn&hJV-WjgnE1Hu1qU-lyZ`Xtz-*<-V z`|AEY=)Q@3iwfvjwuJoTJ@^d?`UZ}}3Gje@VC@X$2`J8;7ft&7?+3@+Vc_ul;P|Qb z%-iAM5ZqnB(Fq(?z=7S@1`h359Gi!;o_w-IGDWzMjotw+I#>gI2U(eIVF>n$$JM5; zPq(-T_G--8E)2R~<=SJSDYfNaXU0ZbRos4Ytkfi4Qq3Z@l>o1U*O4o_Uh3M)io2bc zUy-+dTUGPDL^)FC8gio8-+})SFM&_6P3ov|=C_f(9Ian-U9_vccb+`;r=l+Vfz4MbX%Q(I1+qt59l=G>_Ca)-t= z7sQTM%mdCi@LaOPyM|?*SNRLT+XvKt)gY`*?A^&D*C5Y&&o%P zV7n;i0z2@KiBPlJ>c1LlW7JUdE_&qfDbuLj3y&5=SCzo9F*;z+5!wh|gpXgMKg!^r z+01DfJQ-(P&U`uX1HtV7jX%m+iev14zDD}6{#&)PA8i%C)t}}a8ffdd)7hg^skugX zCdNd$7AaWf&bc?=Cs~j2&$sejloQiY6)5PO@0`nL+My@LtM+q0gS^BJ2w;16R23Bz zGY^5gvGcI`YD}Ov!B_^^Us8z;5<)*q?|u|JGSn3*=p1dP2H2Y!m}B}!+njyHSsl42 zo>2RC4B+lI37RQ-$K&Ol%%9b5@Nuq*d$X(G*1vD$)&B_yCwlWvo z%kI_np!+SmZwK>UE`PtVAXZDBM;LkuMjf~mXRC4H3b}B3dy(Ej7L{4JLUzqiaNT41 zI%N5}%!NzqiS@u$mdG8F4lZ}p5Zh&GH zoqXRkXs0@QUivC{6LLHH;n<`9@fX5 z@c&!?@*g9u=io<&X5TyhzaOUm+s@N}GjV&re)8fyqW>{>i?2oi{$v`R`%kugg7qK&IFZk7AK*O8h+>lyv4clG*1D7P4&xJ|ZN!BS8hnPE zImdDp&rINBb;e>xXJoj1CHno9o{demh3y5^lhHcMF)^~{IJGY z+4-bhH&Gv~pRhJW{?!$xZGd?nxSDVD&QfsJF+IwB^`V=C2OWHP^}W3{6!$kgs_|`R ze3BJ9KTcy5f1at!K3o_a-K#Ube_xk-_*;K2Y;FpY(?ZS~upFua7UbY{QRZx5Agx(v z*hoi^4?nh?*k|B-jYn$#YwOvZ69>-*K6d78VDV1&R(|$qF!FCliXVLDY;iQOPIH-P zNNWzxUU8%&u>P!KxKARd1N=_#n|03qL=}7a?+W)PW-;Cp&PxhC(ptv3zW$HwR$?wM zN`}hj@M0(LzEjgYP?u|M`ot_^4ZvLv+;P^P2EZTqYKzmRbY7fV3#>o3DL-b4=nfo` zv)0xg=$o1~+FVx-Tv?~hb>#TZ>Ab77Upn>ktcjklzkzAA{~)#n@K`?O9m#U>SvTvJ zH72e17V;dh1I|)24kzR}T#dv1!@c*uUvfgUiK3j^rFz3}() ze^_&5-|iosJ!V*UMX%Q*AH1>syK>TR$9}mh+NOHZUG%Fqy?4}B*E`O7*Le1R?Jd7B zb`1YhYi{#h5$ECp?7!Xt9*&o=&*Kui9(2fj!>M!pAIO4u32_``0S8Ys=JIJ*KHronv7C(Pk>CmL zLnl`v*WTeQF(Vt{8_t@?cX-#EgDJd^4Y*~HWlypmVEhqe%ZJIC4PENU5afhph~_8$2>p*V zZQ5(aH}oE91;!z=<6l?OA9T;$OtCai+*9_EBSYN1O0!Jc4;gz@aNjY}=8`|4@y$w( z@Ar&rI-esRj!ru`YW2nk9FjZBG(P0}YyYp~tNWjg?;rlg@zp!y+h$%OK0vPgl$7!P zkg6SSAO|ZqjmNLG7JkR+2IzJGdQ&5Y&JVb91Gfpr+f#}!*vwmzWK46-! z5bRe(kw@kR*#!psOxI_)*O{{$hP0t|uIzF4jF36N1)Z%jnIrA_PElDJK{TF4dSmW zmqfJWT~qw1a{{>^uN9MrV$M9kdUZQGq?~anze@i1RgCTT(8e8J|4v&6m3vi#u6P@H zn4q0(V-6;0D~ETNd}ig5;)UNk8~pt%XM<}_oDD86W!Ev|#T?bv3XjcDmYdJFhAt9@`20GIeR zu>gG+%I8DIS~}qwlEds;uAYCEzx}iVEYQm3u^edlKng5^$+OoFpacB*v($BLoQ@7D zUmj|^-TV8pV1M-rul=Gv#b(6o4SzO`%8MP+p0)pn=DQ3ON@qUSDtf8azLwuHH_!rh z67(KuriK1%HJ0n6ZHYo;x?RV1@+iK~I&jV6kzriL^3?F+umWAz`h3#wiGMd#daYWz_#Fowh4*ZV6 z_w`;)pm~<&1ixdraoptV|IF_^-f?Yo@TYlsxu5ru!QWzTJbt{{(#z2N?s#ZXYovfZ zso+3&oO_5G(U?`kl7b z?qb^gt*=f0`axWF47Brf3QUqS-amiLPW-y$PsdgVP5i9#n06U*0wN!$)=tpII_y|~ zcAM47fuQO~U1#h2IDcQGS|t6JE<~Tq>nnr(I&nz#`In1*GVLC0tO58ywpS->48)yZ z;vLyb(LufM=7Ax@1z%i!ltkL_2g&m!zDCU(^m1GL9rIUT&9-A`(+~0R;%EQ+PYfmTXlQ^8+FR|?m^l1!r zlC#)Ht{E;)_qp_v+KDR z4_(;;%-h9hm3$YUj*2f`yRF#aCGi1t(9;E+?%!SfEx9S)^Yq#eG!Di0&}-+-C${=E zIUdnf2QX|@-|qv%eqVpW**D$3PNm@MK1*LoeI?s5F`ayR@TJ;ZC5mODL+k6XpP;Yw zzQ3%A@8JA&@TxU|#LK3j;|Ihmzh?^4m0OU8ZOeQ3;HPu$7#S-j*XrLCWBx&;0N-h< z@^$Y)W+_ji20CND=afRj@$W?nqO2*|d*Dp>I>7e~?sZ*8?TMGEOmQ1$3IEl2X?J@?JJ$8k(>y-CUJ)GZs2eAt2mqL)d{`;_EOH>ACKiyD{v9; zN#1F0ge%q8)4cq^#g%w+kiP7EByBrnm)hEv!ye3yjP-N0bq{>>$nI<9zcojgC&}MC zfJyVO_6ulVIU;J;`{%WPGDW^(^KUbmlWb1jBTn0=AF2EBkhTkaZRgVV+qCVqH_7rt z5?*VIQop4s6ztsq?q!Rf!v=j8+fTa0t}BnV`r?rGWL81jnk&U+^0*#uENW&Gzl|Mc zUmR%Gy}U8OX5~C(lj{^BryIYAwOPJ*W5*Jgbokhbv)b`YNwepYW_dK~>Ym>b2b6x) zS?ELIPtt!D@%9IpgE+iw@mzP^5M2DR1HFlzS=Y8<` z(8q_g)yebt&}S;}p>2C5L+}4m_r=pL{JV#N-`TI0Twj!38X8^P+yx)2)@D}N6a>L- z7rI_;)e=us{7UweZJU_ukA4pC4P;D}EQvx-%1aTwD<8+ft+##_YAJ!%Lg2iFyfoFx zRE}5)`T?CdIG?H)t#u9EE9LW1=2O?kvFA2tOrSZ9xlGoV7r9j`Gu3 z1@w$9{jh8=#?&8JVVY|hYk;_F6dd?vyq`9{;nH*}e;lyuwaIviau;*i(=GiceC4vI z+g>B5t>i{>?TD*gK@MHjH8cD3U!+~ zyIz~zPs83F&n6z67v;x&$(W z=$!<5Zz;4iY>h2wW-Ih`9KEir}- zM7%QJqU%uk3btB?KKarv74h%=KNYT&e?Cl1D+_nSdP1U~W{$Lb2mc zeZ#ZbOCuWc)6pkB1zw@U>F6%edL=v{{156b=*#LZ+SdJQ?w?`ZDwjS~8zK*RmrGx| zR|TK=+nz7E;G46Rz#)3pGoH+!3C&7pv4HX(eeU_&H-n=g{x-IMs*jz;zWgk^FQ4_< zi-QGPCy^e7ej62Y3t*G}8JVALs6)Z~m$Ob*F_1CMp*zRV6c6PBkAHpkan5k|#|Bwr z!ap$uy9!zBHREC({Fbii;F=@9f-P~@er#Sdb5t@q(ya4owD)8uG=B_vSIf6(v!psu zpx9o3y^H~#m2S%$9cink=0m-?*zQ@`3f#iK+B?a!>ih5;p6@xcPrm1T>Ua0BXH0bv zpUpP05%Y4b)M{;cPbP&LY-JMI#l+2gWi*IecGJdV*lf-mIb-$c=A}P4bn|tdk&YX*cT)U4+56zv?nRKV@<-ZJTp*WgCDla* zCFy2bWI<7ZAs=$zNXGDC^h*4C(N61EFT#fBnx5CY0iLS}n`uAdTD#UZRyq4`KF@PM z=bG|FuvMEsS%kgL-+}U?f*fR{p`UEp-$^dY3wlm_cOJs_=FG~^@h&xH8f8yB!#6to zwAtgFcYhc6c5!V3pNX95xPxy87mlBFzP~*jebL1c&i7^5ZS48DD7FE+O*G@-%Q=rD zX&;_LwnLlN4(-I|&^&wRVug_br&uS=8^ak*@OZYPgpsEUl3&6x$tBOI#%_zszc@mjltu)0QZ+@%-}E!yeKXq-n)+4O3Gaw&^PVa zTAb+cpyUzw4KwB@yanjne0C^$+KCj z%(#{0P!$O05%iRJ#lex|qZE%SYL;ytWIn|=zZM*fHT5~^e#H~~@3tdXP5HQ3M6pcq z*hjMLdGcLx_xEY1He{UNSw20oUC*OOHm1mf5mt93{fB`vQy?2?r!g<;Z^7X7@7U?J zd%01lRe`nb?fyumP`(wC!E|*wMYE^(*AWH<4pt^QEv8n1_{J_%PUP zZ8I)%V#(TzqnhjTXOiP_;$h37ofFsq*cjHn&k6(=<>6EGTpn4ye{^JZPxk(`d#|KE zugPdQ_9fO!@YU|3ovj!2{U9S}@7fd4!7q(F={(@-B)*(u zB5k7g&n!kxV|!A^qpg_r@J{TRN@%YG8C(YJ=&ZJ4;P1+d5NoosWt&%9MKKNGtpZ;9 z9&45QosaCQhM$OQO_rWS)*OWIwt2Fq61snuXC-SYkTt|GT>5v${0eLJ(do!r^yP-B z=uPv9SoSgG_b}H##C7QWklFTeyRUAtN7ssTxn~}}OFZ1l8FI(&PoeLVHvXNAwMwRJ zMAnv|tE$LtEkUm2@!bJG4%C=}XaN3-j*G>A2VK|TQ>yk9_K@hiPW{vnIoEy=#?(n$ zmF0nga&(%*BhL7S^6mMr$)0+HHdPPXAAgn)DO)E>`--)m9AUJcfIs&!o39^7UbDvJ ztR3RZ%SMqN_xSKLf)n`g4{XdlaBqD9Ykr4CUx9nW1=+;W+Ofm+Tc2#^&a593+kLxd zOE|pi$!dLj@_JIrdV=BvlE(&p(T@D|>^f}i<{8*^%3(p4%xS_OlsrXu4%&74`z&M6 zk!^<@{26la1;%Q~Pw;Gv&!N{3aLvK58~OhuMP2L}(HJCex|$^OLM=DFMLs9|vg8A1ispitZJS_JEO+T6 z@Dn&6n=Rt4t*xN3Ai&wPos2iVMXTgl;_ulA* zv3;idCF+4RoMt>ju9FL^>-*eXSk;EA#Qv0SkW8W8aZ7@Fxgp=*>SK_Hr#U>_#rHB` z+R6G|9)4;aI=sY~7n|ayt*#V5c?5ab;D@-~RIPhN|DJis{%GvcyRZovV{SG&65BQx zS&#=Vr30yNbubtvrx2dcK?f=>r@p0wE1)s?H_|DctFMepg?k#WPy3+o^H>^tNZ^s%bqo>~0~ zXsN_sLNY=S05`ABV%HpAoeeM ze&oR$VihAJ_mv?p9zc#2^8IL)JOgBZ+rRR6(q?hmRgQt$snR>rjkB+} zx^WEcJZ9VRYz*gl$;v6r8T;!7ZE#Os9!IVgV#gA^jItBxU=p~-C?dW>g z4z5pj`&F%$j@u*P1fK-@0Nz#g?bEbJa)piS!MDel@B6@!>UByMYd>O-_9Lz+#l}o) znT%`*ju(sO;Qd7i&D zp04SUww_~=f)4=O8@%&r#{8DrKmHTcjC`Q*!z^4!4pINzMrp7w{ zH)lO;3dDZ#4eUtzw0o<;mGjQX0>QcgIg?X9A$G`pM%VQ5_;IbvkKTT;0QxhRK8xXR z>W;qT)g2vj9Ugb!*h+Kqkcpk>2- zE#}24WKa6no7!kUPTzs`k%B_zAi1y1>t|@Y(u1CSeLUqng?J(Ib!2990RFDXFfX2$ zP8K-$FF7&W+EcRS_8>pPyf=eubNZY(6S|{6g#Ir(k!jbd{u=((FZSJw=I_xP(W_(W zpXS`3avR6mCjYGxdg>~4*6;Mal;_j=+(dub#DH>$0Y&L=3;HPyTQ9&mLoN5)S=%w| zFNtj|z{Wr>Zc_fg85=8(65F61{Lokv{)l|m1oHwu8;ONZ?dIMV-d~Teuo1g?C%PV= z)Yh%*Mt?}Z*H35ND0*3Jez&`Br)16H3;QTu4-%0wFA4V=t|3t?3eskLaZ$+5L z_1d?CeH!8&!RyU)DfiO2_9pA0iYIEGOPJ#<#+U8N4Z|3--*-*LrBByix?bbS7`p!b zIO`>TTa~;HyNkm1(5=L(elFBYt(aBTM6vq!L=;> zm5tDyYN3ek{P0AWQ_;Q`+wJ?UGuFNCybbcB-A|Q_cd75(bm9T`Mha@~HjB!Exn=>m zy~rhlPtk#YTjJK7sG-&ba@MZPjbtpp^wp)PQ=Sw!D?WG2(c)_#U0u1LvMQ(QZp{cy&Nh}BL-=S4peJN70p<6r*aIn!849J-Ufb7flW zmPMxV<|Ek0+%xX`hk4KY+qM)L`SG#c#mA3*_&Bqh+Mev+IY4dFzg&FjV7+O4 z1LizbpgtsfKeLCqK)=L8B?ap*HS8Jk*fh~ z`q%$~UM&N+&`Ak?EIC~VMF&OqV(TFvEZoS2dv0-S5y&@!Uvn0dla0Ni{gs=7MlKs<1vl9DC7@hxb2q2s~p4s;vk$4Ya-|TtC7ZUoLg7 zO2airwNHO@_MWv>D~fDQxVh(!Wp+-(rrjMV=-L-3u(&{eZ6My5Lp)`tyT4ViN2!-% z`^Nu3mrkv?Hg0R1R8w1|)|i$Gx3)-mKt9#9SiAxMi*bnGl>a9hKJgpVsC8epr_a## zx^BFIdzG|TWWs~(UCXttz_S;4j%S!D8^R833HAec?Ic3^Eo#fFpY={Cr=@cAJ2&R= zuHak3Io_2nd{=U9fOv?7Gdh($A(B> z5xMGD{W66&+CLFX^YKQa#Muk`md1c=Oq4_l3g8h>UW{Pv!S73JO*sf%3s%u#9rj8X z`inw;VdBK~)YGZg_vuGhFXeAVbTQqA=y zu214`Zw&Yet4wxA?6#Y!qolvr2V<|)fG6}->NCrcFT#VXyP(yY)}jdKR9%d1030Rg z+FU-8^A*$|QVu{Tc&b5vga$)h=}_vpE>sXdZU9U@|44OP@1${rQr zENEgCoWHJl5kFLc=g_qY%L`$L7g|CtFK7>SIsKsHW68R_p8tP;Yd!1m8jqJ#c$4tY zT3hP+&ViKp%W!zNIOiO%;c#y7H!RNKgAL%h5}cQT^PBk_e|BtzwZwvFYis`#Q~YgOJQ-4i@0JX&7)q8pp4a%qAw=z3+jzeh{r6gEN$bfq>`6_)!;jF<{K!SB zf45@5gco|XPIR`D;@bKSyWg^<^t)#iI)=DOu~~BD;){tbQ7b(!n^+lR35_24P#!sO zHZPHWI!jEy`lzN4#c8^(Vec%uMY$&B^e-K+_Q{!QQ;pnieEe?Cfh(eI?VG;%BD;5K zRn-@tY%8I5Q=BvBcyF3y#>KoRx@l!C!0HXI5hv*V2xF8?j)Tt$|AL*yTm=^`IifXt zjY01U)^6rNHNHLE3HKdqoOxi+)YxI40ZuEg^c>^J=eZneR4RXZ&S=wEPQ2xBc~56D z>A4fU*8vVX(DA{5Y6oPt{0Cz$Px~wFh+gPlxbxuOpH^;j+=OG{U2SeL=EeJ6`~*7c zsmo>>RQq|ZhcooMaP~TL5n&vPcTQ%V_&C?^f*-7W13q2P=bgK}w!mjP`3?WbGa5%0 z^BzLxR7Ed}Y3?oU1EZr$ll$5wf5j92>*uB?c{>4(s2-;5N^=|Ygw2TVRa}kpI-Grx z|B=RT`dr1gjTK^RCq_nEx9d7K<%h!V^Wkf4O!fs`zb4W@mg_aN^+B~aJz{yxJ69*Q z&$oka8*!)4Z>bI5c`&8VHEy5pdN4LRap0KS=byNaUTVa_a@HmGII+QB{G2(Kjr2{& zM*2^FTU=wyDHignKhqz!(6PU~H(q?7)hEgIRQdC@T0dpJJ#U=b_=MOuHg*_)O>dxOS!VNr_?cnl|ap$j8*RjDVWam8Z&fh=iIy}6Y>lgFc zs`lVpjdRHS`TBg+>(iaTkE#vc38nNo!R_-&uTOXWa@;#ok{ww{Hcc8~jdwQ&wYkRqtU{6~Pyr1yyMLrzs;@%G5 zy&U#_a7}iHWSpN5{$+zht-==4*@@?k7q?+)Y zaW^F2W)J@6o(r)ZH%AKUFC0A$dC(?bUTf&Kju(B8=qBFJx?MT;N{~HK!iC_{7_^Rb z54=>z{MYDAR_fcAaOO=V&()$o^o+(UJbCNyCm;Old#TD<6CrQzOfTziif4r*(SmyX z;&HD(d1UDe&z+s|#_MOtJ@GdCuF`rt*l*T7+?!Y#bFrUxBx}lvM=5Vzxo?@o=ZcLvSat{Y7rd9SF&WR!bmUTmN6KwM%#QIqTume};VE%-TOxfi?E;?DSYL zd&Y=-QH)5jSLF2}(|)6qf9K{W-E&Iv*MZ&Q34CM?!sWyq@ktJuZ%(y7$PrwxQSCGr17@y`$9}ia#f=`<(%rn0P$5qE7_Pcr?witTexrYC&dq*9AT7Op_ zHQ0TD_q4ZZrk8&-G;Y0A<3g{!r@oRKzjV$#O~xe8jAQSom5?c7r=q>gRN*2=et(3Z}HOWw+I{Lg-{+g>U9ODw_~$zD>p9Khu`> z2*2<9<~qBdB5#=YXQsSADP?}!@s*Gj_;T{?9&5dXHWWwZ+99)fRBQw5Wf!Kji=Srw zg=;j3lkE>Y9q!YfA=93FReS<#NXQ)YuCI?rsK+<3raZNek>*G0<1Z)ZV`St<%85jc z4gM3pz4+OE7H7%$S^O?{OkNF=Z9%(^SxKBOIktyeU%q5uP4QsAgZ1z}uYTj|cX{OV z$5ZIzaod+KpQ_B)*Q!8Y4&&5VHO^etCzAbrt+k2%#t-)A=9y#!KC1rqsK1P&kEZtL z$eW};-gT67^jI5hqEJ#T`^6porggk<%kryO8^exV0S(u251Z*==niu@bgpit-isnT zmEW2+2!mr^o@CwF@`xKB*kEBHKH$T-N8cl?f0%?v@c}3o`X7;stxz0|OuvWA*9HSDLWVLx3>ds<&L z{2=(q`53(UKvZ%QY zn7sLYoPPYZ$Djq4e>*Vr7UKpeu#}3x|pxPVj;@e-}$>YxvcKtbtZyUeHKDrv% zk*|kLdAgO4qUFzVP5wv_d;wp70=r0h{zA^Ul&1#7XP2`*L>UsdZhfG~%u66Q(1o+E^U&Hl3lH-0U->N~-w8F_ZX!=sX=5^JLL>_#H+85s?mi8U$ z8xez6KRG=A3eRt)W+8d>3pW6h;;hO|(3n1;F)@y6#!9TMSLL~Y>{`S~eXKPl-id;BRl9D3o5`t!7TwcDn@zK6r_ zNq<4Ug5C4gP;7DhHE`s&r@ZyZ%iXc2!sqyx|ChNpfs?8_^ZsvD^~R#0U{E3U2C}#d zh#ibwy#Ola)r?GZG?`vmv~`Rzi3=uO(2ZzX$F4|1MrJ~@Y7-OtMVMGI39E>~BrTdT z6P=-!Za^__AZ8*ZX#c;zbMCFaT|g%9Waj<+`Fx75d+XkFp7We%KhJqie|w4*$u`%y z?n!jT#k6N^st(KZoX^Y}cR0NOn2jt5=bHJao{HH+T5s>O&Rb^gBhQzAnl`jQ!#{S( zrlpy2TiefX+pE5wXWMH(j?o7@f+j!aopIE+-H|h(zHQ%=>=)YSltq9oJ{$Xm_)pZ5=AK2^X^3#GtaMk{DNEvZh;-tkUhvH89S6hL{Dr2mt zn*4&mW5Jm2C9tP8e}S#+$>2KD1iogID~B&#OpUQZY~WRe&YWRiu{2@YZwaO&=dlM~ z^7+ZfV*zrl7D4;w`b}!(x^UFBoL?qcs%ym=7q(nv&Wd9HSem#p@Y*ww;?tGyReSWQ z>G&Jd@kxNu@Zl`v47e`DUe_5&iMLF?2>BPRi@c>gk-?qu7V<>Em&P6kXLIlGTmH-y z#BNo`CR7rw%@21H^WJ3S{$vv`=vr7+V&PB>v)taRy{S4~fj_+Y+`i@8v2QCI$3;tJ z;|v?pX>zkC;~NYc+R5IP_R5^1sBE0%u+ut|<-mv>Ne(-`b1~~4mCufjmSXEAhYh3l z-0;pIdzvddF2}Y&t_(YaycT5Ejv)1uZ7uV!%y*W3=l9s4W$2=aZ2iIwXOHh&E?g?V za5Z_EjHSJPbVd4I)^m%?@ z4|Bh_RHs*;?tX8mPH!i^s^=eESe^cGD0zb+VhHRzA9bd|VS6pl2&OeJ+jw{#Yr0y) z+71m?vS&>5TX}Yc$(gSj{`t-xbX}^-&aGmxn%i3Dw$wkjmw+#Pr&Ja46aArh_#Wn0 zxtXh|X{@m~Lp!2NeMAdE?p5w)&Fc8d0z3YS^ivc2miIvu6DCssN3@ulU6vqzs_(V? zW^2FQv=`xn+UCmikNA6QYD83HBaQ&BPm2bKMN}wHknxoQt9b6iC&OtE2c}-{jvQ0J zIhS|V5x-c+JH5<_mGSr!r4&QK%Z5sO+-cL+(j4y8a z^X%wc;@4Uy7higCm3Is~@(p4OI`3%7$9+@fgJXYmIUSdoT1pQ65Cdd??u6(nbj$JA z=tI9>%AQB)@+j9^wO8oEDQ2DU5BB%N&W|sT7E?3oFxN$Tzu+9`ZQ;iE?C;&qg=3-# z-alxsyPRume?44l9v=K1@>n&DU7SeoYzF>UeQSAgl05zM4m#0auhYy6G#v)#W)An6 z*j{^G%KoiCP?4T08qaq+ONdz*9K}aQ?Xv-UU38AUPPy04czb;y;Krycjw)|Zu>C!- z`RiL=YwXMZd|K1KGb&m=?nK|ZnRm5+&_ABrjqTpAUyh?+4BauNRhCb?>JGp^jNvNA zkU*XSJNNAV@SiGHo*z!fB@3X5ZzHRO*R4F`IQh{l!hOq;yVT>#kCu=Vs2ENO>q2@) zALSH`gZ}m1z}_)7YM$radSq7>AJO4IGGBsg?hTKdxX5RqrMubxiYCTHv&K07s zzQH}tH4$yKJ9CPn@|kXT=UVu?8XRoncYjR6quU>{7UYjdWSnEZ_@7t(AYypa5@uYS zwGB;UQ{VF*eu~%D+N`#++x4Hxr}|9VtEK!k;h~DSm$^s0zVYqq^jLD9wSOmttk>^x zgWJoZ`^xbHk>TUv^MzZ_k2a2CFA;Xv7JQ^Z#HAzD`!P6UE{TCoS;F-v5yb6V7t$IJIBDM^}>n4E*+I}qqSK8K13JY(*vj^G{Zv6AG4I0-xlrUE%thG31y?9#m82MAW zFPQ6xg*(RrujW`W+wH(CU*F~plFNsGFPXdcO4Vwa9KC-a>|Gx0tAYKKf?c`$z^-_^ zu~X4MT0blWe)x@4rY zIrnh5?4=RTv|RS6<&JPR&pVTQV~I_k!QV^Kap2*l;m+nI*fhEP)n2$I(DF%ZHZfMO zX46~H#Qf5*soS&&o{_GS>`)%hTi{B+Z3kC@T)VeH^Q-e7{rI_5_`$An=XL;oe#P3D zhaX#i3BL~*d%@8*@FThwe!TtnN%Hb{yYIx=-_YU4kN=$dOR9$?m>WifJ6{$*Wz}F( zKmHm_8|WufgNf(mXNBO4oxp|O-lVl%z2nt}s)83*@s4UR&4T`Q?uTkoCG{7*n{;J@ zOONdivC)6M+NC?%f_K{M;VG}p2CY+|o3*Z9O56Iqk>6F%szf!I_|4Q{qA&f{0N?rB z&V$xHTio1?am|T z8eJXmt_$q-N953kB~zA-G3$Y8u6dv5t#1Dht_71{K70CkVwOHM{cf`T9&q229{6#} zS@yn<{{O`L)OhDC0c?sleeXYeiubeiF}7lt6B(>M3)F3x9^Lp03%g)gK^vYv_Q#0o z*mtaF6Vwcev(K1)hOS<+KCR`~ew}Y{A$)xYAA|E)KDyd_zCS0$-@fWg>K(%aYM)Yd-G-UrTgRK z$})NIUNAQ(XG%|Vy{P7mFy)cwxeJNzJuIt+bYv522dE?5OuGXSU zqi2y5V}CccQI~4|th2UJdwokd9Rt3M57T0NulBm7`Ic4?rwf`Ix0$&j*L*v;PGAdk@Qn7tyt@PY3%|qQoojrF z-KL$TdFcRCr;&kRw>F}%Ie{801r)aXVr^mRo?!_YyC;1&aZ?(%a z9{wLS>)-wH6XgtrR9zI3GN%%!;yQeXCDPRo{ zyQoB`3YHH|OfhKcnhEAoV2(46#4!9pXkY7c#A4>048|2MjEa4F_?&3=PxRx{%XO07 zAi8LrR$*lM+~$_pT!;PR++RohvGxABM|-Hrmg4*o;Z^bRp2A2h!tW_+y%TR}`Wa(q zeqw5;&TXfjcGy$UruEiJ+A;O28Shqn_(;ytABulfw?g|b6tmFUav^y<7vR&jfQR0L zkM3Ra%rn2A@yySD-}{W}PUjw~z&6)<#dEZwXLlfz14W#J84B!aU@g1B`Pw&oXjkhe zA>fq$7tG3C^y6tcJW@@b%G9U%%V#nl;asxwdT{srN#yq+;yQlYTF=Mv0VL~cSL9k< z#+sbDela)wr6~1MuChLhe3)bKU~SdKWzNNsz2^HbbJNGMT^#ap&Gqv`VrtVNP-SQa zU#?U7Xy)n!tGKDZ;16%1w>{e$e>n@vHD(6~7|BAQ`}#V!9j~CPA#Ah*(2} zoEQTucu=kzZ68*CU&$D!t>(DX=`cTq!<{3u$&V1OGS3tZiWU!d+O+4-JoA`+rjgjh z)9|=yb29We9=X!0S5QkM5y>PKT2IjlJc7}JO7n#XTi6F+7ya)eA>>{e*RWeI?Ik;3VybP z%eLY-;j^_n&)Qmd=U&ja{C^8iVe)B!8;8R+uhhC9gt zQH)D@4J$f^L>rL9g59&bTE1A3u3|m5i2IQ*nz&R~1MO$p!%z0NcZ=G?_BHLr2cOX1 zqFXA`T0bsgJds=ab9v&>n`q)rmj>Ux+xpX*w}nE6=R0{@ds!0(?pK)iQ6aNwt?4?`a=3{N1Z zr}Mq;z>IW4`)rBl>ALnnuEDQYAKu(c%}9U18dVX#q?ym_r$z1k+_d?7^GwxJKC%ej z*LCyFc_+4U)6DdKw+-{mO?hs-!{=9iInUUX_y4?~_dk=M2B(krWe<7s^IEf>+Alwk z3H~={7+bb(-wdl0yN4P3!0a)Qj_f)ubFK3j^iJLLZ83cMrZ#Zqmr3w;?6V(0r<#ve zt1AN^a!t-|+fnl6MB58whjFHCx^MYBp5`yS90!h92|)E1LOhPIL|`jgTW0?xb=GcH1wnCz=OPrI7fe|HR_@-QxgR` zq&~G-v(|O^D)8Z)7oAKFjpU$5hkE{Bwpn?i{zSW8U8E#(QgSn}vnu@sXhzp1@KSJR zD19xmPBLm3w5>Ku@KG0|H>!YNdmO6RYxSaRs_9mDdOTj*6;5AATk?BKx_mYB9{sNI z=x^gQ?0**@;Gfq%lYyhX5qZ{4pML*C{)TAR+tbmAAJR%* z`>En0E2-T`J)SuU))bPdKv^%g!&~5_VMmqr|4uVGh3I?HO3f3@4{;l<#mDg-RBJ$M z6Ut+f?UV{I{!xnclV8R&+E1LC4({NUH@<1*r`9Hx+*oDefot$jT5b%*)OJf`sEIv_ zuA7O+wzx6C20cRz5Fc-93vtf|_@b0OV=dUhDQt>bVw8&YCsP$=2QGJ(ef3pf^*@fIt395o17~Jm3Od{5exhst@E@)mb@!#LMb-)j5Arvn7_hr9 z<-58M4d-}r*WH)$9od(i#mGQpfARN*!$3kx&M&8e@ORl4#$4W{U6iTO?>pe#&#Uu*M2Gkk4L-M1%_YnUnf{v z%k0DZwcGa(`0MAFy}CavoW7Di4DGu7@~ZA*S6t5hf1<4^>KoZ|0>AkB9OCuq^2>{A zgLk4?eQtL99PRb#^2>8>pJ#DDLR+KwL}bh4TpnG-`tt`rBVP6g)|GXyDBswHtMv{z zxu3s&oc^QkgVXQQmLI3z)_rjL9q!Mk{f}ucgHznGopS5nEyJ*+I=;HKN zx6iHIzkttn+5@LuPAmQwI9&}+jr>4|CWZtjN{@a8yH0vEF{EV(PfW{FDH*Li*G55nZZ`?0u<}&6Y(aeN%%z2Dw2`7`ou~8mv%(1ohhUxyaaO@23$9d;I``%$^ z&P1~&ee+3hHOYsoANu6AKL!yeA0yEMo|Zc7(W;$R&xbf;Qyp?OtePf~a$-ipf5Akr zp2?Kz8bpUF4*VZYtdY3S<_sSn-3eYpJF(?i zAE^Rg4UwzKJ<07%3>i8xUKKF#EXabVl=I!BCo~TH0P{V(S9B6(??rj4!Jx$S0i}gH`S(du(xY~yN>GsaF+nL*5GSg zxXXci5znaIJ!)5c59~$sDgUJqoCF%FSB9QfZ1}sEkSoUTHH~)tU+?`S>pjbR)`$0h zkE56LylfTC^Fr3hB_|cP5=|6NaE`poTqL0(YQ;R#1MRau)|6sDsC-D>*FEvV8te?! zdir#>d}5sbwfZW?+Wce3Mp3Q+puj)4X+1{ZDVtI?Qhc!&j*c-Q|hyET0hEn&q%iR z7S2VbYvGxpW-k1Z< zb6i=ay3V&D?Y#c9>tztSOBch znVnNN7ZkejU;D=!-+khEpZ_z)YwdAg{(eKCEEk$nezfq{p*p}FoOMdyW^XZn*D}^A z#6*>6yoEitLGU75vyQs3#m+?Hxw&n{oZ%9T5a*$mj<-%7WFOFZ@Juml|I~9!hmGz) z{vliA4HaeRjxwFkD)~GX*!Awh((_e^w^QG1L-We?x8ax7uAggt_n*+FnQM2S%U3jR za@qa+Ej|7JiixlF`@7Y^(CUPuJ?IVkE=y3GOSzD7caLR|wmdt!7}`=lvYmCW&>f%W zi-#Crl66G(^`+zT2N@&y>$do_`9<5oTM8fS|JJ{ZGd7c}j~5;PKPfG?*kCJs>V#v1B?R%(md#cZt7MHeZ>_PSLfVeX}_+e zB_{owgck+7-)~JI8=$4`N_z z64TM&F08e8KW6qq3f5ZucEv!B;FE`0E41)G7Gpg*mW|8DrV&fMAY$SNk5R)U&b$I| zY`fP#GAF@!IEQ`@F-%j_p%&eZJ$27E{u+CsBj2pGL@(;2^a`<1*${E%QUo_V#y$1l z0DM){+k4T)VFP(I+E*<4Qtj*m%)4-Tk;cm3V)Sq&y18=GrPR&@Uy^6UJgDbX(16?x zQCBco1#FTZE`7v##-Tp(2$w!|-@A7@_vHU+4Lw;!t_|xR{j{&RTJy%!$dA|fHQprf zq_eE$_>ZLPjl{h1^A23 zh&*)%{>XKYUmBfx#=}ny`R%RRyR+HRhu>#1zFkKHQJqPeP#lka)p2sWWCw4DHVnOQ zy(}7dqn*9>9l!tUsl*vLk5c0jPMfFanYMQ}+kdG+TLd@tTQEL!5kU#P@)kZqi~hYg^6CFp-+8}qK- z_hi6R;KJxSc;B?y)?&^S)A#1KP%NmnjExZ3u&@Xmz=L{UpKahyv8;0Bf%zW4ER(aE zM230K>37@T< zxdx|^J0&{;8-7f?+6OCGgRIL6m#^pAb86*VL>yqk1TcLUd=nR?>Qi-|Y;<@%0Ck%_=AKD!VZul|f}j!bU> zAGOH9uq)HUKABz!e!{Ly$DVvG44rhGB>sD77qG*3im|C}$y6_{mVHL2sR2< zcRL5IUw#km=zZBe&`Fujxsg30Jzq)fN@JT>g}GnBedrs<$-ESWoy=K}OG) zkmmRT_04bIeM|qufT0EbUb>I9e&6qE<5Ra#Ga0)%OkWOpH`%|d?XA391fBk}1bX0o z>Bw^Ev>873+aSiCzTf8O4|V56bC3Y;t>E18{qyi~yRonO{UZZ2YiI#%dh`9c*=4{! zO*qwc?Q=uQ%0j|%;f5d1@cOs@i~8C{U;h2mgU*#a>$l(JYkBz5*c{|t9Ggz_u0B4V zZE5K{*V}s3)T}YNJX%xS4&R13bLFznTKmGQm7T#iw%%U6@%P|4b2iZ&!S7DsZU)zC#Ndt6pxFDxSOqmK1T;lNuL z)9>ffS262i70_ed7ddAIy{vwuw@Qw$e8lkfLB^?$9)#KcOeP;A%cz87AB#C1LjjYbxGjWVy8#Ovj z9HWSJP_;?D46}Z!u?&cZbev5*Bu+b4<|jqpx@X#P+N#`k{s}rxcJjaH_G_vgVHrQvL|vbJG`>THO1e{VZcsoEo?u3H$VsU_ubY8(&Rk62(I#;eR!|VsnZ!HPcwi2fgIC4 zT`PC@Z_hWmO5L^O%!qFt^gOy^Q-Ei%joSpbcqIiLrFdr{@r@c2-w2sFjp#`CZ!@tC z;z7vTnyeU29J}E|#!`WdR&FHY=@MRCAB6AN^^?&F^r^P?0hjkof^%Yg^g!Ol#nErU z8(w>HaG*B9O_%Xul%I+2;XU-H-8b&x=UrlJMc_v?ltW*+PEMQlj%`7|B*<}0NmpS{ zT{Mo|R%k!PIW6)}=HkcnvPK{{R5$JW{BCG#dRS}6u_R}PushV)OsfAVKTLLpY#H?v zXFSPr>VM(;JcLfIp7Alv80+!V1c%y@ zEi3u@Gw?oxTEv?3b+uoKVIxc_=J#ye1))pkv|BL?^pf^`b=5&{n^?z$)=YoR&|3C; zSAui-7+RyyZ}N4;535gN7g-tM*3XbUc%vv7Wnh@?K@fVNbzV9{HQq<{RAw{c6+ifDcXc)%iWlq za>7Nky6=xGmb)~o-y7`S&8`Q*i$}BB_~>G;JR0@W@gnfy&Fe982D9JW5B@EFtj_m; zH~OENX)pQ4+c`qAsXtz&7)1h_)E-&%6mf$KqpPN!{OpMpA=X*Zr^1nBR-J=gh|GGt z$mS>3p+74;Tb?;JaKm2@cw^&t#r&=YU-7G$$E^j?KawBi(GTrSh4$03$HziH`d$JJ zA#>BgTf~Fm^tb;=t^)d}Xq2%zx;>jC0dL6W(BBg5fU|kOh`3-8c7WzmePrT>$k^ec zPsI)KIpo7uaj$_f^)Mb|UqJsaGcL8K-zAsbJ!!9LKiV2#&5p@<`1>08LpP-DT-bb$ z1onCYn{^xeyo#|)G>>MVocs}ZSihHIhX_v3o*B#ae%8}ukCb4GsFp#BHnhJ!fe)#D z@1hySppd82)aDLd7v*I9f8d-VUttb0s08P9CeiD~#23P<{}#qRN)aDr?>si2iIe_@ zyiGkLc#7#?Fss&tH%F^H9%nAM2sUz`j7%A5BN@3`4sAZnyC$Clds6gR0zI014s4a% z_{}qc2jtulGdA;u-MiS@z1{WLy(R4R)fmHCi+I-VuX~sr8}VL}^^@uBwd*4$qi5iA zv!)V%-iazNH!_!gp}p&_b(T?Eh52#Pfk;kSf>;f;pJEO)BY)|uj47ctG|f9P)5$M^m(?SYjP< zZ{l$qW8}pMW{ug?jUi}!Cv(~AToPTvxpV8Bkg>4_+($?1{t}<>ueKW(RF_q|XGI!% zLf>NNUeN=dUZTx)!!L=hcY0pfg}##P8gQRBb$`8Qw`r`@=S{!^6~r{_fm`%+rR)DH zXV{BrMv&pPuK!z`<^R_D{NLJB_`kxD!OfjHWtMkrJYF>7$!oj6Vt{|tUCa177@y(4 zt?c=Z^t~Y4Cv(l13K`E^tQ{gxrzBXb5-pf`p=2KUuEy4dr_aP^^vh6e=@@xJ$|IAl zCEu6a;XqRr{s(8Bj}lGa1zk{2B16l5cx}Doet%2-if&{puONmjTRk57^2Ybhq7P_e z2Ya0wBEe__H9bT4T8fi2M5wVe27B;A^4p-L9Q>%9B5F&v=XHj{&=l>}LrdeKBSS;z zV(4&coH-LO8(i>R{_S4Y=5$YdtMkKeb7iBq=1{@>zQQv_{2u)9Di{v95^?U^JYUVzEjO!y9e<6X4!*8%`MSc#*NXf7IeVjpi@K3j#y+nb8H&|`m-6H18of~m zZWFHVBF5NNjP61&78Db^-bM`p;Z7gVmYAn~6SUXi!s-8Q&1Dwv&B%{B_Fg2=1)9g7 z&$45he@%Z3qYqhs%&rNU{Y%;}Qm%S#tvP1C)UJ5Rqrm_;CXU`+Nv^z~1_#{FqQRxe z4e1xHCnTT^at31w;=PIHKH9&K6~=U<~ICR`?-7M+RD;%mZXi5lW~>c4g*{{Oz+ z&Fm#8!gejyS!~Q>g^w;gn8hFF9b}>(<^lJ!V2<-n95@?+&wPhhvUSKY@{Oy_p2*N4 ztv7o*1fOkpb0~DE4%?~P-4ChX*SUJzYx^^5yCz(wJV)W#yFbglzk~bzInCCt9e^Kj zv&mft^v{v_Ck2igm?M8&V!(aI>yJwWH=*0zx%TT`V)xyx=!I^sUT$FswUgwZ6kP>YYo9C>r4F_41 z6Q76fw0fYSsVWx2u2StG;<%d>BbDtNC!SmKNM)=Z{Zz~ujm5b(CS>TK3cJpk6upa_ z8s@dDZq0Qu-kD;)&zfQO4_3Ii(Y^Xvxw4nL>Z!SE9#CF0cJM0b-NXN* zCZE7(FA#e$W5gD&z>i#^d}HeVBv^;&YYukyp??C{uIyQ4k4><174`IsIbX`?CE>xj zD0Ur$krN_W%AO^xhmQ zG;yM}uFtLKI74{apJQ{{aoc%LL7TP8PoW-zXQO=zo>V@>pPyghwuP;6;B{x2|GYfq z17+undi05YYga7l2N#<-KzrQ}+oUfQr{(*Q*v?N{8q)LLvj^Z4og*W8Zuwx6XKN7a zv3ooHwEq^*d%Q8y% zLD}zLqW5{Wkn@ubUv4dpI>T2!rPzdgn49E3(LeB+bp?OlZ$sa*ZBMqAdh$IA)(&&q zGjRrgdqV}wf1y3iX8;=0Sk+z-`jO5o5WUgfpCA9U@X24R3g;=l^GNlLdC?{OomqQ+ zbQd`f5qZLF8nzz{CTH&H0C%V}?TpFN-6cYFqHAG3ZS`$zEY&@;ZcS2MA3<;k|7GuNQ|8j1>qv)ya?-s%)8 zCu6tbe9+Du^LzZfOw24rj6iMbV}9%Sw0T$YajgOH`zGlJ#dtn}Z1c+l@s8wW!)%)q zaPi-<-{LIzgZVcw-grq=d*Fz}w66cU zZ3S{wzt45o*GxaPmqeRcpOH)vobSOaemK4Hd9qY;_joP$YB zpuG|tsA5CwuJI)| z^`1+f4}Hnc`PtbJw%d z?_Z$(1kamyGy2`e?Uk$b5A35hG3Qt99;<8L1r}oI&mCkfLhDV9SDE@fRowpp&&p=j z8nQ#*an^Dr^Nu4IPIcbbBHqD{==RQtyG-q~HkQxdh-9<7m-7PMNAE@H*F5j;qZ+6C z;IWPSf26I)1p~C8eoxhBqt~aqk7}ga;GK8h8QACPZl7QF`gHeE6}Wx=9ryp0w!X** zJ<{!5KP6hs+T4vlF?xhJz1?GUJ=dZ+(MK`#@z5#sMRq-!AT%Jm2A)3D0Z&&kUn%mE zlJFT;(>H&^+?n}k^)0`HTtz=!17o}MYpd(V0~6PWowdbLttXbc zFnV6I z7wK;`w76t=IHnpyONKi!_0yPtec4!S4D-G1@z_dai|Dj*0Wo97G$V3d*<9MmAwDNt z<>6nEb4LG?RYj4(WvW9i81!2;&+g>esGi~XLi4+0et!|WrEv}G_QRF)ozv8Tz7d^y zb5}h)VCooU=Pib4_gl@xs_7%3oOI+6iH&ByWRrR^-dgnLUy{41_G{y;C87h?Q7g;x zoNH5aFF_unc)!HuDcOzp`DB3TLhn(L5uQL~G zWY=M@po>keGkf)>vR}f;`zmY|boP`y)?Va&($M0ps7Z3+BbB?XEmi!rSo;!e9 zae}qTHvL}rep?J3IaT8t&RpAjGYnlU!saQt(XF5Mey+|q+pOQ4KghN9KX_OEobJ`X zKO~m;UcY?t`uTwtf`9jjm`4%*)85FbUSuFLuQH@95M83*j!5g&ManAWit zvL9N1k$j1VjUAh7&gj*;iucq*p^~Gtfw*_?Paoa z7~a?bE%;%=wr_7ODl)cxE3|H|@h#*RH9`wLz@m7GxgW9j4ZPTm>mRCla(^gq(z^R+ zwyk@p5?9{2N3g%tz2qs)N6+|`PsuI^FW8Pn(O3U!VBg0ofVFgHx)m7$4{z$}b2^`9 zt;r8xmagA~3+)4#8L>XE zV%s^~yG8cV4%TJisj&~*d6u2Ln*OqB=PYO^9(rKoo+99T6W$y$v@`X(T{qXW-rV1y zxqlOT^{WHtUe6GRZm+!2)(J|2TXRj0x@@c(aHqJpV3qGtf_z*Jjj48%?B_~g()gtl zw}VrSfjK$c8owyIB$9j~-@NC$2aPOt*Kaj1sz+dCGVh5lB;(YEzV{$=70Xz#7ucH_ zTPP3{ysK!xnD$w-dL+?|tv9cKOnTSQH27PB-j=-XcRe=1E_^GE zIdhNk>Yld-uXhdp@abxRA3j^wQR8fermxVPzlfcUT-p>zwru5Zv*R3ULq7=rx~DjO z56>x1ue})>t7MtvkeLVMRy{IAbH(~M>j|L^kvqU2>vDejRU3Ln^y$gAj~_=Ks}I_= zza#gM6D67l@Z;2;75%sKu~%;b->y8o?C<-Q>sj&g)&GZn_AyUSI_cySP8yy+yf9Q? z_UN%cJe_#iz1QO2tK#1C_Fml(@>AN}@6^su%sNqi-HLr<=HBPm%CAfSZwWdg=v!m- z=sqg`0KU24)yl>h>FvnBB=@SATl5NkuiejNbOrOGc#QlC<$K{Hn|PG$j0)e}``6{n zdx`}(3G!zHW*tY*XYLytFp$Zg&CoD?iH3Epj|X$33-gu0{14$_W6P(T2aLIm;?}JvKgzlbz#$JZTmSQ)yVT-{75oG-T`GmD) zggecd;%#b2bnVBt;UDmwy$+hW5A6Q@0kvW1#>IE?BY2EHi_U?L{V+W*T?kCGIFB7Z z_$to`m#OlKGLQe%meH5UOXpU5O%v>MoJ1Ti+ z@ULrhgLH!OS@yBUsB`C~1GG;}ed;guzYPr(HM3E44>i#$lqnsSa5cUZZub#Eg(wp5yR&LU^!Q$t>Zj&5Sj z{XTf~eXCDCybW5Uy@X452H(Jg95ZngbIq98bGX}UZ##66ywB7u+x(&OV8Q2N@GtoE z@o1?5T9T{@h^~KlB3-Y9_v1fA&Wv?7H9{u|+AJQ;SectzbduWopQf8Rz%BUyioc%i zY0g)u$~R|Dc(#oHUi>XvpUuCwRB~5yy!s*NOLOuTIGv8nd>UN4^{&Rt9-8i}cf~W3 zQ}L(Chm2T0>EZi-SG_9(1MMf_y#esQ2uypNbiR)_MNcP_g(==OGzxF}*V+f%hc~nH z+&sD4j4Vvy|Mt8{oSEZi-8_rDMHg-G!XIp>^n{3{B(DEdnaa0AP z%D+v{X5ZXJ&^Grv$tCk>`+pMeg5zW~t-ekF_+s1OW%(dUWJ2=&i19a)$cFfuP;@%7 zQqRS2ie&aZzUA%@ke{_f-;s}vF$%w`@bw?-pj%5b9_?$ z^Zt3wd%17k`OVDxbK&%7ym{yQf7`s9zE3jm;2|CA$3qeEBjH4Nkp2RHS8U_EfB)yD z;30DlJXC@kZ~x~+ej_%pX%RXFd3{g9wZk<}6|P-^Uh6JK1{3GsUB|OLcjzwGAN>8I z6Ntxz~jn@*M3X?lTSh`E7=40byr64{ePNP z1gl?WByaA=Rl?=9Qg|)Z>_E%r{1nxgj>S`LWw772gT zPJ+KR;q;e0{PF#N8h@7mL(vM+P6P5%cF&t9ryUz-2%0rMKhBBwcRLSFGWD;P|Ioy^ zGuKaYU8LOf?*yX(_N_l*fA4axqGm7qQQEjR@#cdzj=D7K{g$lvo4F>=df2)2TvMlO zA=e#>v0s>P&Vag)>qgd6WmC>uRuPjA8YlKRuQo6GH2IUe?w^13Y2}c>!wZllyWvH} zVwSqGn1k@AWU4;~v()8X{WgJmI6dsO7u_8?#`%f-)%SyZS1j+6JY7+AE!4Q-BQ^3$ngw(#u1RgxvMvvHAq zEnL4rt+l7K;-%(1_N@BuimR^!$GylfQ!fJe5AxACi77@4&v1?mW{>ZqJU8jJnXwCI z|HIzz-M``YcLWBvHMK0=+xGWA`2Fm_ke^L@Eibl$xm&pAyy)$d!=2ZC?RzV9-Lz&_ z?0ch~Dc3E!?q{DFwCCInokKAViN3B~H)QlXlbzT*7tEMg6ByJKN%zg^1g43rE{jh5XkWp^PljKgILw)9;8qRY z@!`&QscUyAGY#k>2b&%Dd zUOonI_L;b0-XZa;;xflf+%V6Kug{Ge>Nm~5=wL!Yf86jk?j@Ip%hbog!f;yS`0dJY z*?wX~2NbKm1iS6YaGAjiF&Ph^9&S9m+)k|8kC);jeRB?sXZ$OivUn-FfLa}K?AkE7 z%JEX$ruy8!B3yQ0{J_2+WIn7eXWfHyeI$SSdC=B682CGWKjX5p#P>V>q-Y!4z0CjH zO!8LNBWGSlHavsO>~ji?%zOr!`3^F(;DOtZzT?WwJCK<_gC>t5GhZ<>Gr#Fwa4BAX z3m$F`<(s(mE3VAcZ#cbNRc9za4xe@4U6i?pM$a`PA=q*VsMz`=~iDqkn&l@~TqIfnX2~5brnj5iTND zKVx|Kn=>Dp@p?YzuN(xn+~Svrb>|&Y4&Jpt4I+de@PRXNGut?M~j#Kj|6Tu{gHR>|_nv;8Quds*kZe zXwI#EPoo2sifBkIf3eFfY_|<$1zx$!va`G4+cX2!m92bD& zcfj#DgX8=|TIZf$kl%Iy{Plt34)pfu05vPgImr*?whbodUizz%Fo#YZD>x!%XVJe z++*Eqq(12&E2sSQaG=!AiKCFE zBam&ZpJ&t8D3>m>Vc1H$id$d8`JNr3JLWh0-2&cC$exj3K_3Ig;P&~;ll1waXxfGG zZeTna4zm0Dt;+}5aGva4&D|@zId9fCR|DqD|~<4Hf_Pd_(W|+l;KVIocJjtkiSpJ^6-R@(suQyl=VW)1=Mb+Og$RPu?wKOpGT1;S#tr`_hzuLF}lj!NlGN)~?9h2VQ^v#aid+w47|XWQV(U z`L{L!oAM#9D%SqMjZv=EujXPNaA(eguCn#z@OOc^zdl5WHNz14J${4nPrLka49RWT zOKb0Q{nHzuXLvL@-AU^`+2qpumj3J>`JQroUi`qgc@}*;9bE_=r^=;^L-3rfk3afd z=nuY*eB8Im*e1MZVjw&}6n|L1X|M4jVAFWKTmj|SRk{1Ys(kywh{Y8sS6k1TXHKzC zI;im$L}JMiIi0DS@L}1bU0C3lGd{!Et`4yF_2qWG-rr>_NWYx~4d)x)J_)&^x`@;i;yG@)Dyd z#-sK28saji5pPLhqwH{Tt7k)uyODY8L0?xp&gMRBs2;}C$~!$S92!p#wo@x(>1BK^ zTKY`uu3}GADf#%K(dphjcw!-T*TSLU zXmi!5GOgeAAUl;$P<^_yc_H=~bpj`@M6Yf;&6#tN>UK*XHnV4NAvQSe253Wjf)+%M#{V0svVf)&WGmMyVr-l zG-GhrNSBk7plk6{L+Wm=n@*F?lKpJ-GWJlA^PP|fd$lgDXT$hUYST|U@6fK{N!q|~ zqYdjrdTku}JK`$rfz&$*bZ@!K^Y!^N(~H5i?ClEf*_?rZ*|%)k3*{m2f|=`vd{Yyr zt8Rp;C8P7@_}+iELx%sM55f8rG>{Ez(ETmGFl_S2zWO{g&9j0v#2hpn>Dx^HL7C1M zu=M5HJ&N-jwslJayc3arsb*gZwTD02=*E2>v1fOuxmLf@FTdF3EUNfI>~u1It!@>*(ChTC4XuOb0Qj+u997! z{tESO@V$~+rty?a&U8YY4FHu7g5 z%nx^dnD1<=3^C7)C#;%8vN_m?U(dR({7})J{x0PCM$V-^{Bvj7*ilZWuA7^k&N%hv zg-^fk7q7@BS_eK8@UwFF48C0beSvTQzVOdB{ra!Kk8lT_6`MRia0)Cb@O2Kj(>Qw2 z=U$9;H9BRntHT%jbU5p>1&duBE?u?Qt;usjtrN`~wDhp#82yHU;fKKR(@akI%%j@J zXZXfFlrc{MPks~cUsHu~_o61<*l4`KEhLdtb+fc7*-Zw!slp6+}1 z8lk;7$4K+Z9y0(<^67eUj48@l6#^Uw&0y5p`onOu_gLbaC5SeyK7VT&cfcM@i<`ElV zl5Ah^45a7ZGP&x9y}bE`e`j8Ry^O!L%2#&t=D(o(;BUv~9?M%NC=oA>qkZLd!^Z+@oQ;GHp9eTLmW*L!`sdGnug`|RNU5I(DId*q~3izol< z8yEj?%d6s>gqt(_*DqP#Nsu#pc^3an%#!z#!xJ-mk_P%vRcP4v-B;?&AaW$m)}PU}WSjnOmrjQ-9XEgYTRRyq{H3w{@ncU<4jjvHcPtrya2Wed*kAjK*)KV; zUa#ge`~0z$k_SULyP~_&>|=6@sOL%kf9+su)28FHu7xh|t_2i5AH$W{8+MWVWy%uogOTZ^z z%8@UB&R)mQW%BxMtTdPtiJAN`WMJuMz&rj*0=|2jJ>RT#bveT?Fl$|psqWEa@Bytq z%6IIO!_F6N-L)lLALe=Pbva{-%-+}TE@QKh|7_Q)UgcWn#(Ml*=kYUkf#GL(Y>RCp zvk&HO^$A{P@E3m0?1Q;o_u<=LTHfjQ&J$|TePr<~+@Hc{kM^zO2c5vrzCKrbeY*Vo zS89WICS>(_z1!zTuTPhsXS#hp%l%P&en5M`df4OVzh~SUv!@3K@3p*qs{N9Wn*AmH z{zKf_S?zwiKs=+Ez{-2wm;iI4*q3x$MsGro=r>~z^lo zv*kB3bJ<-P91N6suhWijFhG7A`)MG(NanB?!rD*Jftm08=8Y>QSHN{>c(}8{$=M_S zuL4`KeM|Rp*36+&;%mYcy1L7&ak~TCwxMG(c{#SO zlWeifJ=UN6HJ!?7b2z_yZFo7~#>b?MY9~c)sF2C+$=ow-gxXX?h8h8zr3dbBV*QBy zRQ`67*bPA&&uedRluLU;JkQ?Gisxx8o^3ALlh2~R+Ao;Lns!OZNta`DX^%-cb*;n~ z&D6OnrQmtLG5`t&i&u@aHIUum%zs9|sd|iHSE9U`n2KoEQPvm;+Z;+uo`Rz77rme>6vJS>m ztG$-bekvLm6xn;_bI^lwF{Xr~t2no93fF?wld}abuUz5so!=)Ge>4K0&hjlH-qA2t z-`NX}O%p*kEJ7EpY|HIjjNV=BIGg?cvid;2cR5q+nS`I9->tg7MfF#{zVS>KjuXxd zr~<#UoV4m^n*On|@Ds%R3S%a!TadO9>%CMzgxbD9#9Pvep6gnbEUd*%2&?4ikQflaAz~? zM#N_3G&}i+ZU+a&riRSmXrd*zGu}WgL}c(B^})MdJ9( zU)VAR-U(SaiKqsNd6#yePk+1Y>1f}@{s7i53wE&vJd>K&w%yzl+C4L?UHsWowws46 zSdZPDhkY#Go)2H<`wE-%iLqmhLpD_4-b=yE19P8pm`=Di)8oL!*x4dL+ zPw7GLpQjHWB`)sgRq>lX_>Jv~^Y0&H_H!6mO5FG)dSHt8oa|ux?B&r*M)&))-uvgC z=h+ulx5~EoNU)zmKX;n-^sN1>=+-!LLcHwt>$U$=;1fO9agIwlzWDc9FHS*sjo@K1 ze5@LvGpOxYgG^nCZs~yz){?_4Iky#FN+^zj4Z9QC*ZxesTg$r+wg1q6CdMgRQq9Xa zGBJ+KFmp5fl4xmGO|Z;;^dJ1)RtgGg}X{ADdgo6!abJMEJkSQ4) zd;3QM*chw9OC>n=?;ja(-{3f)n*0XGmd;�P}!rXvbT_QcUOjjNRsKRhP~1_3y7o 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∨huCLd=C-u5KPL7+$Q$ed z#y^tOyX-&ypzoIxJ=*y@O!9FfPM^3Aajg;AG&w}I$7<0f6$&U{)X?HDQo2CowKf`JoZ1-#r$}+{`bsTSL&Wz z``;&?^q=${u+?W_6QhkX%}>zcec&JgJg$v)ndUFg^94M&_u`ciQw;6?9ABafn=_Z4 zPdka913$Wk=CbpdyS~Xgsy7BL_>i}8XtEH#tApnn7`uFYqMD}}lZrSzQ%d;@=!(I{ zP&7^(oNdmTV8}J((G0u2cdfTF3tl??1TxE@|9>_70+qyBY7ZSzV2<=nQi2*bQYC+%kb5joKWvzmrP171H*Rm?qb(IJfHJj zyEWJ7f6jMZ$^QmU<_@Aa6vY@=?p5*bUFU4ljsTu;09-{P?nC zi05R3QB3{`cHr$bM?>J zGl&iBKkhfN3g~s$L|!4U*>a6bF?mQBm(PL!cX%!sAvuHi5tta62D@< zf0dYpJBG18*yj@rtap;1qWzVb#8PENJ7!y{jp~bRc(;%%kiA5=s;}_JF8T@0?y{zl z;}RQNIpyEeyDMizjT}PON}fqiPUd2}h7Qd71i7PiFM_K=u`<>Jb9<#%Ud3J!R|jYA z-;?24BbYj)>!VG@G9fk zF>+?KiS-I~86WELv#u^YunskOQK(6@RMIfThNlv(lj&`KAQ=BFt0fT5O zt$rQ=&^WE?J=Day1 zw)3BVo+rl0||ohI%XyKLeqdaPY|cA95ekG3svXx5SU>r^M-h(0Cn*HQ0z-g%9G(Z}Do zdhLDUwZA%N^%*^JpgnHS;}bIDHZaur@6MQMyw23v@e5gIjeJ>m{6h16jM*%{EeEG1 ztm~V4i6wJlzchwE>`3;xp*C(?(DfuIC;gRv@ojt-bXP(@%8~o`u@32U;lAyx@%yLv zK^D94I&n;|0<%kBcKm5F|HX3nr=9sOJIh*7jt!$28Qr#F2618;9ir=cE7Qc@#TX+S zSGHf!#K{BWW!>v8$A2gp)z3eQzUMbz!hEpV%5U9n>)NvfS4N2EMLb7my9Bw9&fU5_ zl>a*qFNh^vc&D>Z%zM5~Hu&1c`4)%Joe!}$+`y0SEL+h$bmvyCqS-e3bOhbGi}|_@ ze75GSc={AEzqhmBWiIw_`OOy7->v-KGtyd+!*9ii#OU)L{AL@tCjF=OnzEd=P&w~c z-EJ+Y#;#WlpSXIeaYVN=?QO-cUBLiCpa zHl-G9N@>`XC^I&s05+wA*pzgi3rqoQN>$jDm_KH2!RL}32OB0MyO6Ih65ICFqVwxQ zoXH?LRghzyd6w_xhwsa-RciVeY7oA17)N`~3Yok{seMmv_kw%xxYuyL$8vkj|3rSa z9%Ej^Jh?oSzoL)vj`nh%j>cU5>7YM-^~J}tA;ef=8$4Fd`Sj(SPd{`h{&8&DwhVJ^ z<%MU(Gm6VRz<#`Kl@$|D{RCRTPQC@(Z9Vd38e_}49$$HS$9`Yn zst8|YeSh{nXnZTnCsu7Bc8l@c9AvDXp)P*cQ2%B<+hQ&7abMeCumv61s@BY*4#{T4 zPHB$OdEaIY&`Vh^u>pnn0Skh@1^ek|L8WiO3a%BO2Fs6DQTW;r9(>U5X zNYDQN3?Kdr{0p~|b(){PqOoFr`mQ4{&g{44Tl6OJ;HBO2Z6o}b3;!u!+7jq14F5GV zKLF1-%@e|R5&V}2|CPXhigPZ^sd-xa2K?A2)FydkR)n!nR9$cT#+tjnd$PG}&%4D| zaMc|2#jV!h$QJUa)Z)VzUACZu)S{2n7LSiq5g!5XnRDUGz^(4nIiE&$;tbBx?E0Ct zV85PIJ_r5n0C6_8?_3kfqMl~nS<2p%t=7l7>(_h2k*+c1wd`(9%(JX1J6o6!Dcni#gB9XU0U|w=!?Jv9%K(a^i>{vEy*sG=EMCb>6`|br;V}8_pVK2>2Cl zHQqheD9oW+qtr1cc%X+v#y54T{oPLMIBQ1m zeo)_`|K154_B?Obw@2l7V;|)@l5eD6q`Pen9&U7jAMq@gGRv*=3VWTnQ{NUa7oZC~ z&+jXEP8*HcBkh=lY{o+4^)5MGY4aUxtQ~{6iFXIM@A6LkI`$U&O2@a`?~yyqJ|pql zych7iXK;4_TL$*ILU7}A&NG_*32WwypHhC>iIGCiH7ZjaA21aRwa$!TtPS4-XV~)9 z@%-%U9L|Yi4hSrL{N(0;!cTDfFW;?s{*8AF@Ds$*RZHM^#Z_}=%PM5jgcpHpgW|k4 z*kk%-;CfAYY8eOlp9;`%Uxt@|V#B}Fa{SPhycgs7yEfk5vHr`Bo0?7e%g*<{N@$Mx zI?DZCwUP6YNFnt8FT8WWeMf!fog8dV(((S&eYetn_n*`bG`5R&e$2Cf*LTv{HjwWh z*)oOaKXB{X3C!q~@gnP;+|}a^{}(W)s^8)t&4qDn`4;af4sWs6dtvwo-L;xJ7{{H~ zL!29dJh+~E*Q?$zb@H9oonLd``JGK8BY0;GWwzci!^rJs^ttM4E3y&!+%!V?V;?Q{ zTcabRD+d+x!`h^I2KxL}h0(U_(7&eIy0YYsbp8_N-TjNbj>3Aobt-W@*P$z+`$|{SIzl#k2S z_UmQ&LJb8!w-$u>o&A_^fyMO}^_{xo)SU)jeU5v{&GDXc>9haL{SEvZ+=Xns#3{d> zGWAFQlJW3CX95^)Ss&aXn}z(N;mXp9Rp)H~XplaNr;Po^_|O77Zlj<2PG#nNdg_@| z>pQWh!dh^Ye#{Blztz7vd#w|DhQgQnjV`nYIlF8Ha3+j;k+&bxY#Ir{Rqc*kBhTx96;KY1sM zca#$_h7OcY43NPI`iSPVU&xn|5$e3%vo^N)bM?zn%=KRX<0j^|BIY*DPc_VK70hk& zJ7Omvr)mo}MNQh12u*H4cF4ELdY$zLa|-jx9$=pW ztgopL@b(C7-GbGUZ{5%pzJAK35Bv@t-^fS!Bf+}PF83wv@WioB=A4(Up{ua}2BS?G ztO?6m8_L$eTG!^87o&ie9>ez9RpQTeWsP?I#&8#gLA(<=Rf` zs*f7~=vb9Q6W7}1#G=~rez?lPiRKq0mvZd&Vvfp@(?jg{S6hz#@#B;)6@FP;SXS^v zoN+;aihdaP@m^z|{oFYQ-F@G?`>J->ZLY3sqnitFVXKY{19d>5f0@)2TsMu?w`xg_Ia^MdzW|+ z`{U|zY>}*QWs6*0jx934?*x9W68u^v__a##YjHlJnTv)%@0yd0Pa8T2tT97MDtn>)-#QaA0*V z6)xaAtwYCyr!2_;%A_BDgR7AT2YkryMGfNj|Dfy<^9?$TV=EHBKczC-@CA|m(otzc zH1;j09kri}?OpNZmF9c9&5vnpq{D=4Ss{H^W3>=Ce#3i@@qaJ>%&D9aX2%fr*E`6c zk4QfA>|y>*+kPRcxpQXwW?*vXw)#N8z*Gaxmgm?oncu*)L^QyeSn)c4 zbhZOuDRJ%bax3ybJz%r(brta&+0tDc+m+yaMQ}Ry(NueoH>N}JbIp1F{8se67UrUZ z#5r!wOsm^6){4v)A2BZ}enNATY|v_FkbG#2?;L2U6$q>Q8oI14v*etw85&&nwDbQ8{9w-f4$VE7^N-nl|-r0l9%H z(Iv>=L>n`QZmXq^AfUwn8`CkusaOn*<@cv z9p}rNI%NlFyOTDUmo)D4Xh$*GUFfX?pr3iPKYElE>G$ZIvueA9wntK4;Jc{f2(o-M zZNK5P{b2mjhCAkso%lFy&;MZREA_O!3jM9hXSY3rXRA1io%pjzt9TpU*ZN0upG`}4 zjM+~7RPX$*j=_nhw z_-W<>=ukFm=gK%rmnj7o!5mvx52E|EtS^pkC5N4K+-=|}0~{$=6)^-ew~Ow;PkZI? ziF$SjI@m@%mqHf@xfd*Ye-H0(!d7~O_eZ5ypUj#!Y@(iQ4{4)uZ3?49KOLuXZWU>XG5u5 zwmRWNITDxB$8FSoin>{=!DHziE4VJB?rq2k`2$t=Na{WUFXInHH=6oN*GQWu(M?R< z`*_xhjqV`Nm!{Oc%)MvdrJ=DWi=A^BZTf0qpR0JKN&8)$-!ZPIoZqiG*FQMd-#gde za@BZxW#TuXH&-U!?UCQ!IYjg=8<~8uMf4??YZd*uA6Za^9b+_lnDme!FcnzAjs)dt z;A09n*jAZ4@m=cmKHtRqdcKKg`bNIkC6;?Wv#qD)$aiz#>c~F!jy_Rn_jkL~kF8EW z#AoiGo1b$YYtkP4hJ2Qs4g?GFS^1959%-E*NANylas^#^>aZL0`T}_|$(K9`cc2%PR+5yXR}pekSkv!mfOn zXV!;Feft0?E&)um5O?R@RIF6BsE zUAk^+=YPAuw@>Y?dCMt(cB;`s_x#8Cb$??nRvz#nK5Lx5DdU;03u|s6#wZkh8lPe= z=MLrdvrKu_vl-pbtBA`o?NImQ)NS|O9>0+Eqd#Xy3RyE>&G-pk&B5=I*UeoYp4mUh z#6M*XC;n;wM|;FS#Y+60i5m9wpu?3Ad*uCx4o{R5dxWiiO=5<>b3gEwSKksT#TTq` zh|}j*_K|8WUCX<%z#w#(4D2eG5Zin44qvvltAPAV$FR+1pi2}WAIkaNMOhx#a>kx9 z-~ATfZ@Jpq#UAqL=no2C8RHAB>A$NmT1DKA;?-}vxbA7yb2OW82U};#xxO{nI&mle zZwCe*d;5c`rv}mgw|#?F4YUTOZJ%1lI-D#dY&KGbSjG_l-&Z&b9)7=X+s)=R0@%%^uP- zclybbVs*Yb!Qc5hI(HUuDu3*s!HeY5UmpFpvwsDzuYP^p*|q6b;x)X=|BL)VYm)gojFRjqGPHp!CytNs;l%*CPLOuq`BREMm>-KeGj!oc2g- zjMLy}&Al&@=Y0-5-b!B5ZIweO>N#T((Y|JVv*J>G+Eam*cIqupZY?LL%U0UnTAgj@ zbTM@BXv4D?F?UZbGBQ|gX|9+2O|W-pIc>G~(>yk8qGa?lw6%h^qF((;IX$F5am_*> zlKvD|3^=ktxar4S@{pbS1$fSlXE*wdZXG z*EHJDxV|}|$ke}s`gb_<&GXdn&Nq9U`k&|7F!JWQuzTx0!u+UuE4jM$zRvnYaL=IL zXPtU)^VD17)T=i8s`nY>kLs0A%B}Z}NjAKzJn%m5skh##_qh+LcLj1-^^WAKdgY4{ ztQpJ++tD?oXIRW7r<{2)+nEK~ZOrxn!>uI%#5v_J7pG6@3gDrup)6u9rE#2Xno|`F%0hLC)`wa2??M)_H^a zE{kg{@MuGZbDzdF$bW!;jk~)Jdf{%~LsvVBEwEd@GZ%X2BkUDxHbL7(@KqseZXfHK zB9nV@OeB$kj>%dRo3_z2r&3>!`NUaoZFJgc^UT+3&&_vm)Z|1><~wMbWy|il%qz>3 zFOl;QhgbKH9A+MC!`HCgGml9ZJmTbuJc3-!j0Yl_ADlYEc$!=Z^5Z1#!8R90=PS2} zjY2OfLI0+WwtK8b;p1iYO9&55<;h%EKJ-_sy|6BR59gaBUq@NhjiVl`dTJE5>(j`o z0JL!$Tc>`ma-_}{! z=Sos_6Z^tBXv zk$v_kayWJEn^g%71LsAPmYf{zyQr??JzvB8&wT6MF^i6^f1tB!O*JR)t?|lDo?6dX>Un~Co=dyEZmiY+*zH;U>og7zG7fLD zPJ5Ga5dNQV@c$NdXM%r~ulL~n2?y_b{t$+5ov2G{tFe(*MN%kQKue)?j6 zXifd2H=LdS*zs=o`}uA7gec$+ zo8a;L&~*Ky*MY~EyYcue`}0Hx!sGWCgQ0gYC$aV%ak?nqijsv;(pB-8+4^bA%pXQ-; ztr1*#e3Z3X>V6~TO4Irz6Iez8OD3`3nFihF7I39 z#n!lQ0`>r~+b~Z)EBpD^Q{lbDAJv|I@#l43m@g@!{KEZ4wmq+BzBk)W>E96shrU7D z4@-O(HVgS83doNaCr>Fh^e_9+rE5X6XDm=CARJ zw>@CBovmekFTLN~lZR9H2gt$s0`l4T4)(~#`Tz?A?VOExh z%#I3^=QF#b6*(iGS!nWmW_L*TS2+1S^=w(dUb6|-!|WN6&17R|*UZB?Va^)L>?q{7 za!e_gVGQ}9eYvy0d|vspCa7H(9<4(KPb+%q&%wQXLm}+MnlHR~sy1bA+?>rL-Z5N9 zem*1r8N<~d$`3v5v$EAUG4Cp0fcU}+1tS_q`Ce`YZ^owt&UF696vgxat1qkiuc1}~ zn)TO7Hy&!})vt4<4ZYS=cO|wG`8g)h&n2u6)!rs>kW1Y;eCMSHou^)iE}2U{VR+2W z-`y`5jn$w}%3itO)+>L2A5i%4erwT}J2`JLM|PKI?3`WK-O784srZQF#{>eW4QCnx-w zzD8iXY02TD{Mo=bWJ~p$CD?OZ*wiQAbG}Yp{da|x`#a*lE70hBuF#YTUafJMQ_jSg zTlAjpeZ0GvxCQKiCw#mw8ukIV3r7KP_#8OQJHW9KII4l86gYB#qZ&9=pUdAzjs32t ze>G7@+&D7H&1d>)_8n`>%(hD@gWZ&o^h$WYdOh%Io2eG<4UcR$5&vvVZ#{bI;0dy#0~kXS=F`(OT9@ z&Dp_dkzfoYbJ!m1hA}Do1>eUU`2OIL`7V4}Mt&ycz2;XN9&~PE?Pr@d?EO0O6E$?V z;gQ>pj?hI;F?W7x_p~K?aoZc!i@xQKqxLg&F(1AF9aq$WBYr2?BVzZ_UKg#QefPQS zNA_>akzGl>Mg9JR15ex|kMKJok9yLRmzI734BfQ!kI)cfCmMPuh!2RdabasFM#iP3 zP2`sIj#Jr(_901M>cbAF56AyM;FEmv7vgiE&OH*%*!cX^hr-empUZo}=V|6TZ+;`~ z?@E;W^SwBo2OQ1iMfvr`)|nyTUwo=H{A9-2OrwgZjJD#oQ5eTg{SsMh%6?IMi_Hbj@V zKL9?Px9$8z5&Y?rSw<((nu2kd=F&Fn3D;gNeV|wVp>4_KG1NQTjD3=S65VYj`A79% z@6<2fhnHWn48J7(0n)Yn$d6gbh*`nRNI7fLu=GXR(|u`ILH?|B1(Ag#G9$9b+x@a_ z@Ow3P2bs&s&wtCLWFOW3Ah&-h?XNWB+ml};za*p9)7~7~Ph-FO(%^J_-RyB<{F^0< zB}W`QoYwjXc`d(-%7vwn_FWK()eFuG) zTrj>uU}^yewVBh6O@0WzEU#RcrT$3%Q&$yz5q@>`>SbmOGl_+Vu8!T@a}4X9{wVjF zD{rTp`Lied-mzJtu>s!y?bz%hr-tJ2T>t%S^~KDWwp|G|q&+ltC*ySw>yBB_XOlnQ#1KQT;<4h=^hgW# zt~uDdhAld{e%slqH3x%}>xNlr$7+cI(Vpz3r;;ZZwi>C?^GzWjeoU$JG{XTZJWR~4~j#DsP>--9m&+o)t;^W~Cr zjEmOQ&{871Fu&QcDeeb0o0gpXtxhaV^JZJd4RrZIYp%_q{2zLF!lftMKbhPwD0we> zQci(d>{nVN*0N`yRyvM!8}K7p*sgp4z-8!+_21GB>5*38C5Oct#dY@3ak|o?>Q8e$ zF{b$YWH)wY^&0x1^|1JFY0mV{$=Dkdd$Ek)+nEE}_TCz4r{1=~nRTb|#cg7})QbM3 zoYhADOOA+!JaXi*ZoJs?|6<~TvK{#^*?9C0TTblh1s@+qug}uwA;ibKdmE)c&F*fi zC%x`;+VIk=<`-N3_QZGU{2Fm_OCL4Qy83Tz%3SK6%ObrhnDnc8^kMnGvUmKP`~+Q9 z=}|MMa&AitIMewtqA&7$7@RJ{2iWSFQ)jE6(4;#~BfI-)@bdgBixbJ6J8hn+h40jtt&WH%k<0TJz0_@go{t}54EE;-@IyS1Zry}!W%UEtR^DKJ`37=GHtz=amyB&%nlhf7)$zt9%z7 z8-Z5C(r|~)ck=Qo7#S%?OV3vH0-p>Nh=$8Q2BNKK^yXE&slhG{PzYA{(j)0 zpR8W^NBUbDHhIa=bk>>6h*N4sHV>C9hMtz5%k1W>uWMfb<0%<0-J}H?+eR*5mu|Lk zrd1DpJtKLatULd zK*!a*Bm1nYuL|F$Z)%e{F~Pj6wJvtpb($x=a{6rdp6BEmDB-LHCcC&sZv80*p5D$E zOH2iS@R;Z}10UK{Y|!n{ZY8ukYSEGPqoJSaY18XQSy{(UXJ*yic5Yhd>&%_u_j!j6=(BIJB(tNgi4r*MsHy(2rfz@O zHyUEfH_?kfXEyq@@#i2fjZQz6+#Ag2L3D?q*z#(}NzRgIpK(#1E!pn1=S6zHd}Z!$ zI~PrG$H}wk1Ju!NFF^)Lr|{|%O?gRO$=FWn(Z^|1dDasd-)L~<)57jCx7P{-y?i#% zmeq%5IC5%WsFQrw7sNNuGxn68@=CcAT$#L0@Iw2VgEw{S|F+INtGj&<%{lEp<7v0W z!{5E>_pUx~bQt8HReeKbZ5Dff81D`ED4Hp&rA%j?7n2KGc0aE!@4~uJunJG`{)>VY zJZc=|pT52u)}H$PHyk+o((mW>!mr|sDai9Sc;Y^IV#K1u>)X+@Gt#D-7^g#-8N^YB z8V*B;vJXu`rfSZ@mJg37Vd~cn(}no^oCkki|CPP}yXX4o|Kf*zy$?TZs()L&{D1Lp z|0>lV3$Hxy@yR~>SkgD|^~t`)yd+sJ8gYHIseW0_O~kA>xVdcpRqJqgG9CZs2zcud zXV=QVxda_(zsEOQWb~ip+H5=Y<6XN>BL2Y8q~kAm?>3v4zU!gq)4lj}WJ8Ykv;BTr zPYl71j9+7QuEl=gnq(Yy74@lp(?&mR{=8?m-_yVIs@ly6bvE_FztP;D_N=9>YhI}C zXYvDXex%UEp-7MSBa1d7vl7z9pv5-iwa#dlPO7o3ta+j_lQT2p_<2S;d(Z_Ju`?^C z1xhDq-PFz=y%uC@E_;Jx_>uR3XY#F|D8j~H!amo`tJx=otf&vzX9vp8A)C6c+h{wR z__Ex~c?bDte1+&?O~^lUW;=Xa#+XgEtlh(y?+r`~pJ*gL?WTj2KSN%@Y~nQfDz8w$=xH9ea_}W#k_m${9{ukjq^`>&!~>>>niVb?7bZYXHs`ViLC! z$FtR1w1yb_x;DzB%RB*$djIGk&Rj;%IG}GH2nT=yBhifZ=44ZYWM`tE6lq=r=O3ld69lLnKFFC#;={W<3EUX zO+$}P9Rub1u_#x3D)|aHQ?Kf&T433S-7-PhZ>L&od;8Al;lHhaTIaG!w|X9#Jq($* z2b@CpGX;M=e!ook&zx06jsfizy|3HXWUskOz{MB%PiN1-aP)hN`FShwnEkKR6T%0+ z#erL~7?SG=Xeb6>B&f3)|F`yEt>pXYBO~!k+Ig=O(|;S^7xR5E3m+oi{rT^`H^7Sg zm3M9io_zlQUY#Aht9XcO`3J9!1=m|=W@F1TxeJh8A=dh!H=g?&A z=*Q0ajfRHV(qBWJE4Y?wU;o4QKF{Y=jz0U*GN=5{_I~$mD%Uz{1UVD={Z;B4pgMI< z%){E>_4S4~Z?e{!cnIqKJ#dh zS@@M-+4jCGLme6B*leT|Z+6>a&D58_C2H)KJ@t_|GADF1w&3cE>CdAL+Fu^}6nq1Z zRNy;206dxlw5AnYiqq%^vb8QY{(G)1&_*lYDhGzfU+>V_waUvu-jN;GzXu+XGm7U? zOw3{@CME&hcUXeGq=7Bk)+me67uLtlb^R)=_63BPV_4v20&f$7bTPjm-~wX`63<+j<}C8)SDA zxX~VJ_na@CYu3aW2F;v3CjMw(4bwDVvNzB%4fz}|XAV%D2sHCP{g>ZE_P?QwU2P`$ zL_P<9grO&%yRi?izDvGBWMO3{_2dLz%_62$aFM%w=Dcdo#X#R3J#XYhJy)K&O5|kf zy9x5V-_N@p%+oWRcl&A274P=HhWx42w}QE$p0nKYxaw?xCoTJm z^B$jf-Nd)~PVvWG?mjUmp7&**wG)5b4<1*&l}=op9}me}5p%)4gd^VF|BFMFK&-90Zpo;~C1SLed5ym~>tdx*9&7qfQ_IVV{-fE+#j zZXz%1t%cEtz{$mD##bZ=CoQV%oJ2t!lKr z-=@mx+ZdkB_YbOj2-wtKKc~(r^^N^X+5UbVN7OELo^k5@A$5Lb-VGCPpiUF7?bPYU zYd^@dF@4lIl6}~!^BLw_)hT=lM<=LrfrF!80jGn-r>w-ZLd04z~kOJ z-=a=^C%nJy)Or73x@LAcb$-cH=T>-2^Q`n4(OUM!?AK!sTsa`KPVuSpy1I6?BX7}T zcC`z$zeAp(Pm;^R$I|v%!;6+9iPMA zn>oZp#Mj%r@KgM9`ki};6^-3Pj0Jj%@|pP9J04^Yxsk~wwq8g~Ft$MD&P!ul&I0c2^s-|f++OYR{4 z@Siv<^v>w%F?U5f>KOBN#KdkXV&*yCby zd78dKPk*b5cGU#!GkZL9;-Ao|Vor?nQ_!B}{B_%lj6EzlAIAHo)7K38nn_=?=<7w% z4)B&3I5ny>Sema?o`yl@9HTkbAo4k&{{_CN7bZvcXv%IAdIYoKH13$`^)ixN9yt-zn?{F?3DQhMsL= zd=lh6D?*^SnUn0-Mjc{&82&x zBl(SWev{%s$HA+@zi`)6cDQzxOPb5(4wjT7<*@a-CKVb0#F zDLH#S!yZe?gTGOJgJKcLjd>l{Yx&Pkk=fD>o2MlCTr_^WL)Vt`=kj}&(SLi!PecpI z1S^IXK~do`o=4@4-?;6S@9FesB+eAC7hv^z?Mo( z+zIG@m7le7k#a@@yW(LJzjF0;JLV#Gxy!eG+-JaR^k=8~laY)+^y;_bIqj)A${1x~ zQ)n9z$e+gk?dEmC=n=}=q3xl>1GHeDv+XwNJC46i9t`1pU3ygCo!*=gZO&NK&;nh_ z=ODfPFz+;v2$Q=#_<0l&qG=(HGeD;$H%?tllJ(f#(=JOY~joQjxAhs;{DjXU&rRHbAjZ)8SU7- zwZBGmu5z!h@$L2lFm$Z2=4~7~u0_69@ z7PA-{&%lmSZ)J4E;g<~TFZVI_ZH{fH4O?m(`31X_vk%^BK`&Q67ugX+SGtOCbk(@% zy+Z2LbusV5zvJ~xe4~7<%dxR54n#KgX4W9ch92il*kfY*kPW`aev0+(qJ}Bm`0g3A z(+mwg106g=zQWe!{(Svz&P&6#!+3`^-c@G2i$oL4i)@;Je?=3`0`mqup97u3FjXa zRZp;U?tyE)D_qB*1sj(Re?qet69-W$c#A(4Da+tIt{adS$|*JWR&x&Po7_t#dExJt zH!1Np@kz}2PiB}o|3SuCxAAKAAcGfop=9>Yy>3q2w@GPCgXX+e|(O^-cQiGw6$5=qb~%bpUz&Z_$LKW?L&E6mmxwbq)&x1Z-?f5a?QYFR?d8*bZ-uPLN_fHM$)wFo?YHSY=-Ksux%pz}VK?#Si`7~yUd}(dl4wzW z-7V?WCqGvqrFSoAU*sitp~T_YAv`Msr$ZfB-Fnq8)xCoK z>#AG%UfjC1Hi*GHs$cugM>zFc;NGo2+o?Z?XQk91r2YfIT`jn&w-Wh|O}+!)4*3?V z+3%26CmK_o0qV@F95RtP%j{*>yd{{Ebq<*r^3<8b-W;p?X5%-|83`s{96pXM&x}f^ zYOTq#Wj;FMk+3`e+j&V94tAW+ z&z}1c(y&QywPcf)zt^@$+j|9N=g^u+u>yAN+Q-q8Y#X%J-L@_Ii8ud_EgG780Gq5= zry7)k58+?1de475r&45<-rEM9DnFBQ96f`~lI)tsRqwao zL;l!HL!CRAXF4339gD5-_IYC_&Swp+d`eZWjn=UhPUqP?WY934OCCn4zW^CmiQRXP zmDQ2S_4HI?II#UaOTBI$uxqIIK_?HG>a94i%#EhGxlHEOH{A={}#NRwEx=YM@Uvp1fB`}>-SAu$2q@0 z$@K>3_jO#ab$*ZKI>z}u%DIltMjtl?&s-J~_bpvWX=E#%Mf zMLr6R`7K{$kghQ+BLA~Lv8Ln_yrSy#nY>A8F^{=M!w%Go6w)|Z9mGX1=^v_LsrPSCeA8SZp zqtHIpZ1VGpe)5>t%R{$BnpMXofvEJgrv2z}(7b(gX?)yA1))9to(Iy$=u z)%g>ShH|d9Yy{hK4m?f#vcqzE?f^-p_Z< zz*5V%RhI8e4d;nB5#vx~1y-SB?$9^dC+WiP@|&G6AQ{iDxlwCm(V*%s>OY0OTNyio z;5yik*bM0ZV0zAY#VZjbX!=@Du42(*Fb8{#Vj{5diQe90jOsZ-M+wokFm*om`t$hUY6{J(;HUKd>xiq2;*K|ki^lC&8e!c7bEQ*(A3I=te|iuz9n zw#*&)qm908=ISeva}%%;BTG+9?y>%D1&-6mLY*b77^A73UAx7LoVC)~1T9NfxtjYl zXu1Y^>b4ystCvx?O%rwuhxd22^2bgb5{VP{_RDL~bKr-B^atecGkjEIc6MwAwqBu_U-3+szGRD`v%_-;xhA`KDCkdj7GbwI}?8A-~W=xwz!_tsCMf>_bxl*ds_&OqM{=Y3Ppt2SHfyRI7ke@t}@xFdl|(9J>6f%Aw0g+;Mg2@*&FqKpmynK}DCW zlLq&I9a&;%5*{%$3Eh03_YDm=G+Cm$q2)5SO@}7`Mdi@UEXqei7yI~5c1aIS?zDD& z($M68j>UdO8-iVQpTn5Q|F+id-)hV0U$N?h&ZpRI;Y+vu4WDw_3Q-Ph$6flI=)iU} zo^cTCdIa~H*@F?9P=YuZJJI#vN z__XJ4$vA(Cj0+q33>Xc%(j>4kQ{r~94cq3|c#xz)w(TAOy>1V=IIcpE&K@pWjYP34T+Y09gGJBN1Sl;2Jr zF70G@(~hgR_;(CF;?7%68T@7F33+Yk3I4m1I!t>GJsnef(CW*ySE}|LdOE0bc<(Uf z|IRnp@GqUI2R%K&S((VVi+UN?*YNMTa_5`QxVrLY6zemueKLk`Q@YG1YtVS%-N+2a zMLM#vXLrwCwr($bl-4s9=zeqfomM$#qQ0xO0>^^sobPo7@zLl|*q+WDKqsoTe06iq z7fg^&Rgp=KTPxGFqp?@)hWr;@BdkV0b|KULQ|M;cnM#p=e*BKu_!{FQn19HxIWXkv z-8L-J=;fJz6OzA8mp07;CBdn`;|9eI-ardIel-+Bga0nS2|H*7BPv)UF~VhW$hLF3T1iydz_b0fIj+5 z)*|HGVjsdc8mh-|U9IZ;7OY85duP5XV?8-RSzj5IyT8P@1=8zkdH6C*#!qy7@! zL6$R~oRb#lKEthf7CIStX{uq~ME0uA=6jgafH^S<-3nc=^(&!H`RYv!N??GAL20|2 zxG2V>sRn)U9^PZ^YwV3B%zx;C1?V;xVJ9-NionvU{?XU_$obfIr^Puxc8ule8Js>+z`5cv$9@reWLL&&$LFt{wRL_Io}g+bW`&}+lCvUUtgvk-4Esb zbLgvcCopRLfbz9gvCLuclJ{QM?)X%zGggIsbnqkoyAC>#Y+V@0ij-f%`BC&s_p><% zS^ZnW+A>zocgDU!o+7@j1-DW9Xus!ET)pYnR{Eu~^1Q;%JAf$%cywQyCmO_FQclh@ z?#noj-HVSo^rsa#HIE1{`h6IfL#hM(jOYi<;Aih(A8^=qX7#fPIw=KSz27vIJV)rt zPe2dYc{-b_pzB9U&oaM=vrrHjAB+DNv^QtFC* zjd#Ntk!<#yD>qU$v1hToAU@|%LpFTq{g-_NdfCl9L5?Y318a&-y{j|BZ$?jyz0Li7 z(D&CuoeAnh=fe)f8N1wz{`4&NCE%lP($TudjPgV1^F3(cOQBAkJtA6o8-Hgr_o9U# ziw?kp9Yd48;|lJ*{Q7P1n{DL%Cw6?A*ykgjSU=QLqo1A$aK4s*P{(xg37;Q0xI4d7 z47}=F{qJS`E$gz#U~*^2)hF<;7&gZGc-h?!>^V0^-hrO|$ic;|F`9U<{M+D}{Z19^ zcPd|WVEt_D2?=PF)67;|@R8)rAMlgw-HNsU99@<}E;000oe>zH3f_3GwS|$L$PXWU zDOs8d*C){*&#~iYj)>$P?a`O+Eb^}u%qG|jqbxALfE5I;hnL~Ht!e&WWLPXBc8@ui9@Rg7tD`JiaE z*0`4BJT_Q(>yf+9uk($Bw${;y_4M;8&pY+N!aLbB&##l8rite<-jmFT4a2^JT}5M7 z;oAJH;QWn4nX8`U>~Hjs-T%|IPPmWN@XxnD;9h-`jEoQAJ;}G4Q0I+bIKQrR2r&%Q z9l&mbte&9xUTcj2b(a@gCyd?6wqJ5i)JL&jmYZ0P0QSp4dJp@hv6Wz-)EpbYJ}LWL zAjfa)mI2CTx6~CmXr4=E1oo1v3)q&@E@L)9^O@RRO1oQWcMrOQV>cxq-X*j<$F#fD zX?Lm9?oy}SrB1s`opx2Oc6C*|daibt(k}hnJ%=@BSTT6~Huerrd}<5yT88cOhvY=~ z&s*WM5#SN|CORslTsVD>XX|(-|7QXE-$r8g4PKFFul?)!b<70@uj*rb1ar97$sudO zGtAL3>~P+A*rq3m?_k39x|IJaD{X!mG9*wMdQ|W& z<0{$FMfpbNnoP#768o)>+>ydOWq7aMy=@O-Q}7~c~AB}_uUcb z)vehU@yhUC9=1GWrDSWN&(`m~a0(~mH9iilEABdnvKurm4!svbyDq(djd9rv-V)#~ z489EgaiyOf=)E(WR1a;P0Pa+pNWJ%t#|P;8r8@tLZ>1NAr%SJbr-7%8t8n~h-bsbs z9nUvEF-E^7rq1(tT=u@^eW?5^RD!n?Bx;-!E@S6TgF6HwA-rE#P+Bcf!$1t;5iF zHxYNFxmI@P_W96G6*@cmQfXzy+2@5H<}1m=cmZ`U#9qW%t=okoem7B8%o;*56?@>J zLi94lZwhBRcUiKs{oeEIs@W^tG+!|CO$lSmRb%@YW0XVOqN%&euKPeAZT>=S(vEo5 z2XDFKa$NP}H;Plg)?NwfFXF2A3=J1hM%_ig-2}{KT#Hx-=ki}-UH#>R$r*h0m*ZTq z=fAY^FX(5`qnGCX@Ao~&??z${KFNOzx{7pB@s(Kvtk>8P#|<7Qe_Lah-r;8*zl`zP z+TSwcHCwvxb?|vPd(|mR%(6N+O16UU&oEz^xocLa(?{8-b^kSuU#RnD;Z@`O75={l z{|lesMtIui;*<5>BJddsB7^znO=PwChVj=o%5Ua=v(Y0TzQi{cC?lh9`kJZq!1zEX!#)Jvze13 z$J3DYI@7a!Vrb1~c%%H5&?}PTr-0!IKF`Be;Mie&+vQgJv908KUgp$)%Bf#+U49eQ zznuE-qkhYoCt}PK%c)=fmP6FvYNgj5qW++G*_Yn&4C9#LOFx$J!SYpytjuGz)Zco! z)!B@`ExT|FI-zJ;vbGH!Ii_{cmEdbCI_eDYb-6=-;A%T-A<rbw}S$4&&NbXJ8Xz)=EAD&zX4Wtg8 z&E)E%%0Km?-*d*!WuEW5$VZp@{iu}o!3Af~xUoz{XSEIy{O|Cs{C8?wf3CgmsQEtW zpWla_Ft_~%@;X;FbQNN!^!8`(Es=MJIk<6j?f<4;>Dq62>h{+EkHUB4AB4|$C4Tvx zqasVNP5w3&KI_)VODXU*^Z{QC_%$cbgC_%=8?Bh8G|OK%-14(0Jg`8%i2CZl0zdNR z6h6?Gk%wD?())t=HT?_1-0wN(Uy#YQd=UBc2Zz>FUlG`m&DqQkA}g7bjNPh!Z(zX^ z=KWKd>@$ZCRYxmzoT83A?oUxiJO0$InJP~+<>lO~d?eTM{tv&Ry34N!?%2YWm^s?W zUT_CK*?_hFWB|Ud;{GT+Sct7r<6R#MEWma-^Ax$n25|MZStD4edm2~HpUb!PL;r&P z^s|fd6l?c2Pp1BM>F+f7^X7Jr!EZX>x}7@qads|xX1?KXvyY|F(6jpc4DjG9II|KQ zmMxm{N^bMl**a|HmjPEUaX`MHzN>O#&+YFLz~T3Ac(=;(&wqyc{orpl<6T01f_XE~ zwf4|^dS@hkFJRoJgTt~#w?CEJzR+%C(UhlhJ?(V2nMs@2YwR_t-DUw}Fp7H07X9-p zxo<48zc0T16&J>`vA`~P`L?^B{({|&o44yJCUzste-ZzM{AV*abo-L?Y=7YU`AHeo zlvmxj-NTbEzCTpnSQ8`vGc+l$ny4>T-khc{Ui*F_ysube!DC=Dc>@MSo>_Z--3Ib> zSk;-4_$8sv&t2|0-`shp>03SR?UGa5Xw!{9Hu?Edy{lviyDaxY@RU!BW#p|5yGUc)r&QJPSSqp404=_r~HB){$!= z&oXfbITrR1>^c@crV#s&;^-AQ#2LQ;UQ4mXRI^6aJ88rbPQZD6T2_A zuPDwO9AiHAqZ64ia_mB{?BiT#@;=-S4;75H&Xge&{M1+T+~X(1&wcORrU#C^oA>ye z@0OrLl+o|tAl8`=F4K8q^Y_6o!I7c-`XknYQtWqnM{7!53%U36Zq3WfPI5ngALoDh zdAH=a#c^uZ%vHRnc6}=EqrJH5&kQv#y=K|T$?4nQRh!tFOgr7r?pStm>!EGtS&04V z22QTn&>L%>Uv@HY$~M71zZ4%MdqV~njI+)xrmy~?)%hjB{oIH2cd`2Gw4X*j-F?2E zKGToA%nxeM?ej+NO}oGpSZ25DQyK4<#H5QPW#nrKWFR( z?MueoKM`HcbADMCx`S69OkQ6YZGm?cA7x}iHhw(CzT{lSS&g>N{^>{1XW{V;lPIqq z7h}<21~zAN?!!W4s@|U#hMyfIH8~zJfO2b2;OZvO3n#` zsPk>loZ!xfN8uUoc_Z-=R_Eub^QYJvwZEhYeL7?n{6;x$Xs1(nK6Aabmi6;4$!W!; z#6}SRx{7r!IL$@I#=y%W=t;H>&5fp9bVm6nRnB-`|4*zrv0agwa%L@v!S7w{Ro=igsc$;Cgbw6qZDBmc zhbHEWaTmYbxBiJn)iVHD6rW^u{`3yUQFQ(18zYN}+qz%l3hg}Lj@j?*F{_~b*Ss5_ zgiI;Gp3jo3Jak2*sUJBfG+)A zIYi;DLbC?w8RK}t7|{E)Jy~B;`~~~qA2iqd8|HI0bmm$2 z`yKvKjOP4a=xdar_vC(>$>@{Ype@#W3##e!DQIdV*R7@4Ny(W?T+_}XYXY)cc{#`n z$vSIT^Z9jV-61~@_fP!MrSIaihL@eS_9@OB)>=Cb9IpIY2~NHAT|_*7G5hrulh^`{ ziq^GHLA1L8ob2Pj@lxzy*v&*+bD*s`oJFjh#d_Ytm~BMXE8cc1_nR3L%@u<6*O&E_ zF_vW1USFh)wYZg+TsQ0n&c5cL znm{($W1W;o-=@vB-p0qsw`6_-%@=8%E`F2pV+G&$W*-^V%lC^d7Z1trAL--!EHggI z_%<^hj7tmS^Wx*7=sxDI1JJ4LV^7Yq_jVwIOf19PC&T1D#Fj{(Gpvkbhpdb`#W>7; zGSIn=aWTJZ0@z_0JI2zou8R!+joUX{_tzZoX<+-K2R9afF8!QN>7OgFwRXPm^Xyee zE?2{Ui&t3D8qSVNomX$B?l(R3m}SN{c~+CwS64c^h0eWRf^OkI5bFFRKH6N)x%M4! z>$KOof_E5o_GM49^GziAP2(9uUOs*RIg5TKKfUlW`@Ln2O*|{J$Wge+zuuHR8}2N! zdAj4n@BsZSRGcFHd6oPIG5Q_HCZgQA|3Td1 z1^iDtz48A%Lyt*(7Y!_m7Lr%Gn6@UP=V)xJmS2&t>)g7}O1x8XbPa<&lr?#vcL>T)C&` ztW{?gJLQ^>Hp1Jvg9bzj(1pxVkZbx-Iw{=wKPS6& zD`(u3GHV8X`ISSbt~}^X-u>l-D^rr+-om&4sJ#5l@e98n+sF6QvB!GXKcOelMa2uq zOwq(9>>z(({KW%Wvs7>{MLl-F7;>y0Tb-pVHn%d?QQ`sZb3nIj7#TaiZUVgImX(p0 zBagCAddgzRv^aA{mJM})~?yyOTSXt?Dx{lvx@ia>+JXH8YPSLtn9sh zja^;7C}(dNn~k@vOCxq$G3ILFj}yf6+H`Br0cM_lz;EX1%W{+R^s#Z) z+D$3re;OUon@dLX!4PMzDeo7?_wMf)0FDE{E6UHqHy=VSAyazDKrhe4&^>%1fA_vq zbjcO$V;(IX*LOQGBkwHgiq%{B9k)5_n$`B6i1pH?M}#^Ta1Ch<9UO`dRn_nEM~{86ijMN|o2+@1gwj{GXv6p6|3)Ty6IHoXBzX zK1a`53SOjVafa!I{*sjSx#+W&HfxbvlaW#Nmemo5FV{nx%jh@zI#);+)On?{70J)0 z^`BxTTEIawvaj`PmO1-WIWG*2lZ!+9Nz})mP9ff(b55K1T>+kmZJdy(vhw2#t$efg zgvKP-N4^HlLJwia3OcI`GbRaSgT9;nJ3sG&>xIN2RfY185{Df7S}1=5K0e7;mu|u- zbR(Tvb50K5d+EvG&dM|Uca}J~)4VqUy!|MJo-U9l=mT^9aG^XH$+x}9lMm4+c6#-R zBly<((kCva?!NSi=_&Jr*1yH{S#yKNXDj%uvC@ux?)~%Yn!)o@?ze*9r71M8{=ZM$ zYU=qiqf+!g=|iqPc4`yre0XR&S0j6naZC7(3>$BFn=zA(s^w)pXPmhIhTa7Z=~R2Z z3E%VE=mpSK#vuFI2goAtcnP-Sz}DOOgi!-uQXkj66-I~J!Wz65+dclAjyO1y&h5&) znAsCHAo6|MNAKL}+V}@vOn-F0*S7i3;y(48)^6Br-F>h8uFVt7TKVnlq-@#(yua;* z|7?0l!fSLTU~^$|X?a>d=7>IQ7f(=kU-V$>XGwj13HS}8n>4Y$EF*~C7$Y|D4%bz}s5I=+}W27B8C^n~MO z1x1~lUAQ}S?8<<*Fa6B6wFOR+@dLsT^Hq|EjoW}g7y|CDb zJLvz+&|r%re>p>h@>;7N+30R}>k1Z_y3SFD>N;=FNyn}0 z(<6n{^;eZcS7#|d%r{@)zgqjGDlRhX`%gJE?AqVn#eWlHOn%~_WsAQ)^)24Lse7HB z)VE^PlPVwG`03Ne29b=PF2-i8IN>SvZVc-ndtFMN#JjO)9;bX3`<=RJ!H#j3Y-t3xKG&B;f6jd&YdC=n&@ZE-es4bXL7w~1&S>%n6V-xweu|4$}pL4dN+J2h0z5c?Z z)Z?`;h#zJvFOqe0BvxldWox)YHdy?lHeDrc2+OHU`4rsoaq)W;KaMv(y;$}`c&rT^ zuNWNatVFNNOtWL_wa-NRS%mlYJE7Aia&CBH`n@>cx1z}4U3Ru{;Jq1L=Ri7oCI9?U!{5pI*yuO#SPYr^K6)l{_#LZ%USuP( zWjiwSqtn3kCA8029;-UP?k(Q=H_H0+pURJ$X-l-~@}t_zGO{k&ULo~}r%k)G`O!~x zt^0jSyDMq;&(e9FcAux+*t$z|XuV{Ki<@+`ox;oNs=}H#Tg^dG`yz z_L>*}DgLX_^1^6b@UZSOurXf>wh*#acCz0H2E|M=ukU4zs&)l$896{%w~QC>Y~sGC zdO##>#eY)Hyet~*QGTvuh04R{u3W(yY4_GicE7Z~QN6*z@arV&okM#kl0ziDLw1VI z$SOTopN+ghClU@mPXC*ko56vz#+YJgRJ4{yUk^FsP>3EOyP@Jn6*Dh7*0{KT-Q$xY z|IL^{7e(mvfpPc}76sP-r3jr2S_pNwnVbXCY13=3(!Aug?|AE}p&omDlJoRm@eQ8w zjL*uH@6`|Svj6AHjD7A4tnX6mXrYe2;#X{VJoZ)UvycPyWqiMf-MW&t)RQUj*yq(H z^GO){DKvuJ)SU4l{nkRJOE=7bH_*>_T3fC#d%S-vy$KoF2;V#+eqyYD#BX?fwKa5@ zS(|@P?=ZGIDKAy|_1G=Rd+-e9m++1JN2SauTIc#!1oO+P!_nDeZU4eR^6b41J{Is^ zCjaL9%51YnZ@(%jgT70je%S|q+W1Mz2kd>Lg!3F|XYe9gQd|emj4aZ(=MfJP<*Fk4aP14|JsI$-YjiE=6 zem<};T1Xqk@Pp>WFMh_wQPQ4WNPT_LV=8@X{aC~rS^xNMqQBYh)|Z6unh&XOR0_XH zhE4zCg>_s-9jW@T=7-+C{}yZJR37W!$M>hrdN_$6@rUmg=oQ&%dDaUdmlm$bH#uwH zVXU>Lc$e|c(R$zt+t2&D%8|WqP+m>!s+FTNZpf#na&X@2zMnIO@~VbEQ9hlzp{L_k zCjNiySLDCzo`3&zowZhb^}6Lb~#5+d%o%De!)9WfI;72VUV(bB*t=FgYEqak;GZe0K#JiBTD>^T+f||Jb-QeVLq{ z#c%UYrcZkgZ>m0AlwXRB%h^)BrW(6?U-I9UZ$0N{=^{DT+cGVOvD7?>ztLX%A@5$E z?8v!C21g3<*+1s=wTixeioQvnE#mhi{br0Gb#dU}Y=MKbuTbtC z<;Q8W)X2x-#*VhrgR?o{tS|Z2Xa5|Tox|9t_U~J6|Ex<*|0X;An+ja%PXCmjAmH?` z(1q9OU%u17NtAp0ccat4o80%E{#`@)w^RD}h^K$od`SON<%#&e5S@5SqHEog#5w&t zzMxcI{uuT3C7(_k|BRO&3ZtTj5OJ0X*8bhNV!l&6&uuBV`XBnHF+T@xUf`K{xs%^N z(Qo+oul)A%yYxxX%R7{Nan-JJXhHLvdEdcRg7Q03aCMCbSBYz_wJ&@4{Q~(0U;TW< zg*f>yzUx&!8hx>+e4G3rHb>$--v658>)apChes!9{h>KQdfWAXVl6~WhwSyj*#_=~ z7u)t2YIvCOaqnM_*>*qacVYM)J$PIx{2p@nJ>=o{Z1_Fo@VljV;P(x^@Vhw&69B!;v%p|(0Y+c2`KY)I!e{(#2nVZtj zn?28-a{8%$|NkY|Qu{fZejfFVe>8P|B=?i_n>aEtdnu79^(NrF}i+XyC*GgT# zNnaMv8JhukxtY0lDgE18NgJ$#6U<$TQMq@qt&`08^^E+<$mRBryK9xCZgnGVrmo+P zroivQK8yS&rSIR-_|}%b`;bk2Ui*w$f13I{`X0EDty;RrkA)9-v;_N03As0IoFw7v z*IRw5>sjiPytsV_wsz`T1$=w)@hT4O)C%?hD#o1{HuSpAAMatWrt|D7;sI0Jdz;)0 zsrazzH3`4)A$lAHE#`$LM9SHtzn(TK#!T(hb|iL=7b$n)~- z3}O^GW21QrYo$O3vDU=l`p<0spljx()LGD+PD~W~D}8e3To>jS=#Toik3Qt)42X>Q zMAClmzVp|WJ^K~ojJm6d*HB$rv;BxZdg--SeMSE@@o#^e2Uq` zEtPQQ#A)cVgmV(mS!R|DB?gSEkrT)u^qPpT+m33>G%w#9|EO?+e^j)$l|EFf4-Z{N z9AEXx&!~@#(+l*EzVEa~j*OIHU%Au4$!^MX`2R8Axno>v=Xm_vIg%XXJxt zzC*UTF>*J4)15by^GZ5(dDrhB_I+M2-~W|-Sv|ft^2F0VdPji05CQr47-QLstdPBz zC0qT4rZsOCj>P`^;)U@2iTb_qCpLV^@d!Kedp7#+_q)GO%Di8tz<1Q_ze>U<`IF0e zlH$qP*yxK&KM^V9Y)SEJVK|f@$4~E$4>Wb$vOW=cemOBFz|41@8_BmOUwa4}QNpmFHy>(vQFbfx#6JH6lhf^Z>9KHU?0lDLrz)J?4R@K5Z$ZV=Wa?bc4bsDl zZ!zn*UtNK%TXET$Ln8Z#5q-(MzdqO8AL3pxNp>s*m)~>v#N`j$KABv<3!i3g<{PIk zJB71SaHf03J%eZ9&Q`4R5tVu0d%C;N$@TNU(r%yjN#m_}M8Gq!sGUR{xmcmY*Ppeo3*%Zq-Ym{~Unp|wpijz-; zb&Tj>glxxqZ;jO~xkdAs+SGi+{A*%>n>JgKQsq=8*R^eP{E(}9989 zZ(ev0Y)Fp;Id{EOde+1u8W;Bt(IR^oML^A%Hz#qE~+b=Op+N-|d3Sv+hpH0|jWm|m_c`3dfdMp0_ z4IhsjBECcKZ9;z%p67z+W#D-;xSJ1-*^?DTU+>(A+|I*y_y2f17x*ZvGyk8N$&HH; zD=G?_Ndj__dIQ0Tn#o1LODnswy)4}l2ndR{^|GxBnjwjT1}lSf*@bSoD?_!VR9Cax zEunb9(ry83+pTt)T)0Uu1Z|fXB>(U4yze{n&X9nY{x_e`d?s_Q&-0w;Jm)#j^PF?4 zfwg)ydwUaVPM!*Fa?R_1lrvtci(g({a|T z^ifS8)fLwNUxEC)0)Jrv@%tCEud;6~Oof*CkyS3gGVS?(FyqXSl=CVJ0~-Ul*dcB&J0=HkP~gk>8vV zD??8iNO~P*6Yye?cI3-eKZ3CreaMdFPs<`r_e427`xPv$K06!|Igrm$+;Q)U(5f~geN*5X}fDlv*XJc ztKM3t?C!NTJ+ei5Wq0;0&mVe&wek7=q4@V1ucUt;I!@p{)%~Bx*U$J!_J|fe`JJ`5 z(;H~ZUx7xVBQ$M+=B?06_cJcofq#LK-is+9)=qmKe;aQ`x|V+l-g%dA)wz~fhE~3-n9tHhOYwmk ztg_xplZG6p$d{gG$E!vj>G@uEJwvFXdyeZ;H#C8zF{ z)LreX+e$sf>J++l{)RgDJ9T!MzT9ER`Zk<8Pm$)}>*(8FjLZn(iwt_rHwQibI+U>= zxB3)#ini#5R+l>C5!Mo52=`@7es{!{vJZ~FGfk35!PKeGL? zPWbz4uctrwM*T(I{(eM%N<$yrJOjOR25}ih$nY|FR%z4GQPqaOonqk0clpGhegWwT z^x19rcM8#G%Ne)w`*V5U;NdNMzUTFAY~Ox*R%v|O3hjBy!RMs()ZHVvf6+%@&+Ss)F3(<@825D-K z^&IdddVuG48~c%3zZH)uSX$t7ty9gcWk%{{KGGjY{y4$eDcr?=b{5|1M|AmyXIx4gX(Uxp6McAUloRj1H2>LVpla#N{ z8lExcN6L2$ixso>{T*X29RyzMh_WvVfRFG}Y-kaCh6Qe4g|zLSm+Mek3-s{sDFol6 zz~_vI(`QsXGk-{|47nXkeDZ#TogY4VkDSupK;=Ci7@@I1 z)+z2W2#pF^7faFqZ-m#h@1Kh7uV61x&Aw8)S(UqAkbG2m;s9f-R9_)*8JXt8(g3dvC$whv$YQ&Z#(xzTnlZ{h0Cg$G?{ouO_}*M+{>_ zYr6arecm6QzTbEDf2sYr)0kC0^}V>8zB?JmOnLcZ`u5Aq8!Ww2_=;9q>)zh)(JBS^_o?gMCz3#i z^6NeN?bm@k`Q+%XDSp#_|2F&+=wjACp}wr{hn|r88+b#pTCDAo4>{v&_vCO_)dBWj zv)O+QoB!I%gY2tvvnMtT(|NkQT)n%r=+PkiDfm!)IRU;LhTMBKKeu5!bEu8>wDz-q z!uDOT=_1+xk@<#l%<-iL9-aoT7Qx#g_O6<98p8;F59aF_^H=eEIDESupS9Zd^n`iP zQ+k5-sY!ZSd7iRA`1omw><;COxtAtA*2?K{mTV7sN}tYJRhS6LHsZHM*t*|yDHk`E*QU z8p2LfZO)6$z}`^K-M-QVrQ>Li1c0N z-eI*_&>qlE%>k##)kT%*$-(~*!QVUBf>7OM{?Hc?mhIWnH?r^2k?rpSt zJ^!oSVyFHRdj z&w$iAfS%dCKhesYRDF-PqB@USbyn=@+7!QeX=_N!u+*pykJwF_Je|L zZI3=qM_$SnrS+v&W7j9z*podzbNq~KmtPwkD`a21@~qVOeJx}BqPMWW^Tp74^YyU| zc^=kXBTW~f!)t9OHjq2>GS`GJ(sob&_uDi0!^Yb~>i~CxR9xfmcwk3)$#nFx@iV&Y zIe5`YvZ;8w*Owz_Jbu1bG=ZPHJMZbNM=5^vVkxUQ_m;_X7t@Zn9tnT%y~*2c%s}eB z$q}RB6Jy+S8O|Gfx%fEp;{UQI?@z0bcA313o1t6o)n>_oXiiM`n@;EVP;6_7b%EF# zbgaU>3wXP8-j-tKS9N40?<>@7`ToYP|^~= zTydf})y^8~4e^aFmx?dj<(oT(KW{uU>7n@$M%El0noe8s+5y_}(?kB%c!odB)St`&Uxe1eogy-;q$ZSnM=$G%`ZDP*zcQ%u?K+WviBT6h|F`xWt&@8bd-(e z32+LS^zo==Y|0Zu)99H@%b=&$S!Zs!`+7UR-hCZzS}tohJoZ`P2%MSmO~PRTd$hu) zD=oj!7iTuc@;33_9lFEA_8lE+f6Go?!Thr2tHyfeca^VBUL*W!!F?&bc*&R)wHWS_w}P`;LZcP-}@vd?sX<1;|V zjuqqw+HX4EHO0_J*qDZL?j-TQnW6u+_Ri3|8SZ%;@u0V+Yy@}lWu`xGgw>~0^z_@G z#Its;AqVe_I(xbC=(l0wE4E3GW)A*}yH-@rUv>jBGjk1{L;s3N&|KG_$B&uv+w1SX z1ly|JJ4J+dka<_&sLV+7x^N_5?*!ct;aIq~a&$ zz<+}IJYX&|ku5g;X2)J!I@vkzqI}tL{QVbr_+dBj;MV&UWnEok0>J%};D&eq0$c^) zCt8>j-IRq6&-u!Fcnx;(`Y#tRZ@irNh?M-0JU(Lxwn1dAc*DDwV*uxgZQA;gu^C-8 zB{T4s+xf+Mm_VjTeps1zna*J(IEP{EIgGS)dk%xM-tIY!@w8RbRhqUp^WZ(g{c7TG z|NGkZJWYnYdHjzaZ>Ho;Gj%iNjo=8&9wj|LlFxhWdv}y`&a|9&+9b2H7>^3ZrF=7V zW&U%|zzgW?NqMWYu|?FU2Kh6-vy#}J>3bs|n1RzTq}&IdTo$kBFIj%sM=bx(AF=$m zKVtdS$?}^&6#Sb%V)^wSvHZr5SpM0MSpKDtSpNABTR!6O!*9yv3r_NhGx{2TH~*KX z&L0#R5Uc7FiQ7AReC;IbFFtjBv*lyYK z*;J-7Z|Bto?HgTf*jHqe9cp-QlJwiAV*5shZPlGB$l{@_&987DZS!dCF!+%)hpRa2 zWlW#grNm|+!!}FKQufN$(X7+R0e#P;-ZXrzVcjRmn%;`7rHy^H?ta%EM>df41A}qZ z_hJm5M)v+Kb&3a>_&Q|tI`jne1I~!^ejhrk?l-eOt`hnu)b*yQ!iC-rea!ou6-0D;JdCMlXhVxGqv^@>|U3=DG7+vZ9;$w=<(%x5VW+lE1 zYs>B%X#Xj+Sc^?6%iirDI$X>dO36HD4RY)+zoxHW@zMI@`MSQzJ!#VMzn!rjCi!zp z_v4q$|5Eq){}(mv|={+2o)Wqkj5?y1N3Rl)FK#`mf+XM7Fk_CMzM zil4unF}~-VZhXJc{rDxvx9T+c_1)*Et;ee`x9hQAj;7^DZT=#|d19}Omv42Dnh7V# z;3RyLyYD}lkA2i@)05r$_S#2Zz;1Av;9HaknUP*ukAE}u-747`!k5J^`$tbNN#9TX zdyzArGVM=Zf5eq9QCzv#j|YZt^y6V&G z&!B6cDqniH`pAUAt3QahwNAOd-FIsE21QO&|ISm_4~jy`emW#1V3S~))7V8fuM^mO$nf2$u>jfck{l4r6DWacOFdHdAx zn1iRuPn;&d?KJtnKTZCT?(wXNQorYMkMKUQ z$M$6J9%0`SO~sFdCS$kY&b_QTocW^ff;s%p8nvnk_{O1!5>J9pbIDA6%H*f{lLL-+4m$jG@N4o!CX$L|0>55$w(Ax<&YdWml06 zs>nSDR}@X1d!2^=wL2e?d`#%lNgtE!_k~{)Oa=D7b+uo_y(~)i(u%(1@R9Q1r6qcg z{$t_w2G5?%mZy*nxtYwg6eMz*XI-O$@3I%mk9poKiyt`(!U%#Bab z9XXFc%SWK)T4-78(y}&#mbE^ATdnJF%e2=zedCLo?D<06I2@PP))D(GUb`J#-J36& z@d(;KWx-=@@P+tnDsyxeI0IiAFD1B3rVT!@yu`l4@)G+O@si~uV!K-5o%6*@Op*@Rib)Rsl~X z(;)MvWErGw}kWH%et1YjF?>yUOJ(pEQC%(OsCUk zy05R?ZBzC|t=rz82O~a!C58F;({)F=?!v(*oFL9R(|(ls+kZ#Y&qsP|p<};RAO3v9 z-tgs&G9Eu1%J7ec*kiU5TR#&$ZV2&bGkHs`GB|u}7BO2xUtYH7l^dTsI`o%6Kf0k` z;%Je{{;A$g(0lh|4EvgIEZ)<4_lrlX|M0t`6}=NjtLB@P4ow^%N*1&`RU5_Kv|+gK z#NUSeQt7`#o6i0nen;*mZ)MKuP9oX+r^{#ga#rk)HC@Zu4Al)9?B#g}<{8!#^0iA&`RUp!-x6zS7Pu_P3av!xJJNNqHzRYAl0php(ZTah~ZZ&m_-7|>ZS|r}$zNKUvT3>8gd=lH?kxu4D z@ObBrno!_yD{=Ai(GS8Gtl!h&=^(MQ#2CGxJju26{!?R+20J_?KHR8&=})?n;&nEb zdhr_0IQivqclG}{qyGGp>Zg3>Y4|fQ6jR*7oDlwxsvUS7oGW8>yrr@Z`y}6`{ zab91H?#(%u1#>H;PmWv7XANT-<@*Wpf{f)VmATZr7tz6y*tyN-nxXc5@j~WaM8BIx z#ikRhSLe<}(Lp>(o7R^|`|LrS*k8~8{*m)p>jaZmU$lwN4wZQ8_$uH3>OVTWBAKV( z-u#c-^(PfSDw#a#aai& z+t=I;tvKr{y!64Y#oJ5yv3KsrMoOKkVG}xyM zQmT=a(&ei0p;twbqrkKl8->ob?j=se8&}t+eWluOqn_p>wxjqr;Sc>Dg5OqoMg8&b zGrzL(OuT@z*wWuN2zKV#FupV4KkKb??a(?V$;I(rB`}+)H4g`;I3}Tm;Y|-7+!NmdAPqQgZ{EUg75&LbPS@uP6@X8i( zzEHM&@ad3Dp3eKiC9D}kEy^@bTB@D%V=FFj z*Pd$h8F%eT<3HA4zwF{{PWxJ9N^)72K{>cekdbeLG3PJ9O2Jh#(XL^fW9Pell{G`{^r|#>kri* zd=X+D(zuDos`2A!tYm8lFsJ{BFIc~eu(2~g)8RrsX+8csmnLs@LyyNY;aeDrOE-!7 zV9cj4ON;*nHuTaKcy_U1d#WP74_TPC%=&efcyE;nt{uQt3`{4{3Yfh0{ZenN`t$yX z54N=N%CLusR)TL0dwJOkT3DMjHV02QwgK5iL@U8E6I#i(p*=MERs6faBwvKm3i|}& zRmpo<+;L=Vytz_EOo(D{#=Gt5Zgb|}mAnhu!JD#abL32Tb`fnChD^sX@bJfw{e`>2 zq>C$tth@6K@ehgL<`042y&nO;;U59N|IRhbCij5fk9)u`s<>C?lea#2^Wk>he)P}3 za35?km4WtJ_;3v}xtMjPns4H_#)h!FaaTd?LF`=>A>s4o#0`aYX98p2%-m}!E_3p4s!)vCm=oNW z?#~N#J-Md<9aHb$mbvhU_zq@shp5SGSG>&c9y+mHJow#*op+_q<2Q44Kn?ev+P-PK zf_q8|si!m1)$IGXvv$vX8(9Jz+%MBmZvs1K5+CQm3M{-8AowF4CBUq6BYyn+|5t&Z ziS~*4(^gAwsJtS!fV0;B2><$j-_7p=)=TF{v#>h9<%m@lrE{gmDUf9 zmDc52^f!OMo^9eXwTG+i5PuJd8dT4L($87JU{f8^d^Ll9S_Kf@axi7%(BZc?O z`GJ+y^S7<6!yjL461=+^I;=N~1Hof2rf8D2>Dmk&GHG^YMtZROzxr%re{t(ooZFN<_4-8j)f+GDkGk!%4c%F^&-V4(hK}sl?;gvbm&VM` zLl3iuI|(0m9R9=|ZB{pcCo}D-NqjQe7d~Gs>84GWM!)siWE}kW%T=+SXb-6M*xSQ- zwA1(L@;!K*@l45s#KIu=Hj0jpo$f;BjP_MZlgvxmsM6)lW*1KHeHOn?pm_2y`heED znb<{MX1^m_Ydv#FW9jLDDLU&cVAiIKm-yCZ^is)H4=&m7rVRw6B8pZH!?y=VfZ=XRp*a%w`;T+hDA0GqPLiuFgu%TGtXkt}~gQ%xq;GUPni= z_8NFVv1VRgr(#3UZydTi_6*Ix^xxi|Ki#I|;eW`^@09IG@U)G?Pc}FbKLC%b|2FPJ zmqvWJ$|&zW^hI=0HT;rDrD zv2&*zcZuX}z%R9xvlPv=^(bp|fyp^s$NvjgPvBjgyu-!NbSrm_gyA*ObJnd-G-jip zYfi}bnzB1(ZF)84yhA0KD!-A|_Dp{6EnsL19jl{s(Rl`Ro(i4Ih_74&orBQ196A?rZbWoeS=Q0#)t78v(}BAT zxXXaM%)%Y`Zo=mW^yYJ~jQeU@n9H&Q${r|~!q=e_eQg|eH{KqmZSn1T?pA#1Va{=I zXBBpd&C@&aQD22#*>qt{He$(~%~zh_4kFed_F;-kOC!7ba2b0-Vkw5NWq8|&y4)U$gt&+Z_fjcha_)~!d>9>0fR zJ5yW2D;+myY^K$CGRv{!iI&?qe_gm_XsnhP4B6`R7R~J|xQ{xZ?c` zdFbx1HoNsR<>83@bo+p0iDD@fi$SbL@+`5^fbYnR(A1K($hr>`tC0&VRwqG*2Z`DF z3HJtln0guqzf5fD1CO%zmTq&!A5JWf&`ttBlG>i3*c$fQrL3t@R_}`PhPr9L6S=P6y1SC!hyQ>~e+1rn z`p$}uVGlmFXX3%lM`z#q_R(SOhmZ1}^Um3W9@(?-oac`Y`|U4|&Q2UZ`kKl8=??Cx zcx`^}$^$0vrw5KjmUia7ykhpMtd&pS8Dj0oJ-qK3=WL_yrPzS)qDcN$t7p_~xw@Y> zr(GMPyWc)S+_dKEPu+Q%#Lv#3)cou{(5vq||3lr(^FLnw$GSOBfc=LvK?Sjf^W{r) z@9Jp+M%J_7YQ2N!$RzR8ckXd~5Yh{ZplLa0yryB}DCEvM`AkFjKqR|nl2=dOD)P25 zCh|SY&msAi9Wk+|$$!*1{+RvH|7qP{k>CzLXe2rQ?74!4_QKY`5(|YL7_=uOMrKFB z6w?vUi8fPebo2lsC3Hb?`55%f1Tml2A)x^i`-xA;jZ+zsM9hm+9d2`^1{O<4jvAij*i)+IKBMKVuh>`w7rbq zo4Kvk#_c`qz|DIw*|F)s{a^;%pLOB>r3<&G69~3JtoOmHB%F(8U~`mRPyT??9f7;c zm|JU2{7@mH&$m;q$g(`yw= zDOgi`ThD)h>;~3C=ImVZY`Dgh18q`v<5=-3@hib3_$Q82+{mX4xH z^my0vep#O4>2z81CU=iX*8I>n#-gA1p5E<4wdSVYD@@F0eVfZ3@2ifiFio6C9`DS- zf4ZA_Hk&(F;NvxCn{k0D@)?2$vi1qaO?!968)&Sht0}gHH=5aF1WMZG+V^CB4C?FyU$T}7%+CRz8!F3I{<74E)A4)VJpkyOhR6BajX~LBLYKW zp)(&_F}y8OqPlZ;1lMv~v)c4zUg>?{##`5P0f;rH??CjmS2!5y-NigJU=x zh;?=Kk{-2W_PJ#pL!()-1Iv5G+ir<;Ttr#PA@R%Q+#ex$r7x<^KR)C9E^RX<2QDou zQQK#c_i5yV(`N6CHhah0KA+y^VA?zYpB<##+hO?Q zr;Eo0!i&149$p7c$w1PE(|-Z&D<*1Uvi-b__VeOx6TSBH(%NsOy(Er@Oo@2zp!fw` zyKXU!8vk;0?BD)|bj^)CzP&k_7>~c5wa%G8?wg40J&J%yb&4pf^+0hP76Rob#sMj> z{)61HHQbLi9bVS_6iu3eS@9~(&@7AJN*68d{H5)!$c>_?vkv_KgrfyG6p!7~4IHu! z6I0~)R?(GZk66Y1c;0^JXD7Pf@4SAdWLwH!q}U&=5mu(5|7e`1e~ob+=E}7GiW4RV z8C@m79JO&l%;y07ZU1LZ{2g})baBU#t>e-~x{~xHjZe@WpXy|sR&Z0P9iLz_PHVg! zpP(I|QeT`_9pj_>%QQZc4R(CEU&)RSeQJD^uJO@#z~ztP)9{DWe~Lex{vH1C`tOlH zGWz%PhwOk_4+F?c^pvp%`E~u%$S>$A`6ZoFwqUJ+0p!ja_|+f_uJOts3p57$NRKPm z`a*ecPK`yD%eU$MzCyo&=dUxz9NQpz-V&2lVtt`k5Z`EFardb?;;~%rC-beHhCfgL z$h7ZDN4OjPGi_b=`Js+-hnL5*nfBdw_x|$R&lb<7?k`VFV?D0szD@j!)y(_q=vc)+ zEkYjL-2|VQapo3!$Ty{F|F%Bvo+PfMyUp3w2juR^zJ$mnYH5BR?MR_FfhnfRJl zg|ZH>A?A1Hk!a)0Gt8AUqr|P|(HDE}>Kx7ux0+ZDcXZTnuX)YX0trtPIyiCq;;Y3ay3r9bV;yq4|Uht3>l?sb$EIQLEkjP4gE z773rf-i3XN^yyu>2;05gzJ{Vn!B)db`5q|$*y zdzA>~zRT~&`J72x8~Kh%uirH&)=aG5BH*bb&G_&cO`Y$M=HYV|usq1`0QLWwZ{Xk^ zJO`iqsef-q{bK5W(^o%4{X6*G&Sw!H`d?xeW!ra3=1||x6L&v*jmy`cb>~U?{7CJ8 zwEvtH_U1>rjr?M_4gY%`&iqQR|93sqAM_#XU*AK0-LI#)dAk0u?4f?Ay*n(uzTSNH z$NCEY^D=1n2e)3g?}J%;Ksx?s_t5`azWy`q@jvP6T2dYv6-(N#SVI#P&OJkSsUCZc z&SbmJ{y}oi`|D{u#RDa`czTpOp04bCQSBl-{q%J1b57x7Z869h-P^9Vp5|hlR z1ma4o!jAb9&g`s<^etImALv*^UusY5=^FZCJ+k)(N|qG5_XZ{uqYxbu%LRT%emHnB ze~y^-VLOkpuUh*$DQROJlwR_HI}RKiZO~U(i;<%vZo{6U_hEdn1i5>#LNQmsI@7egRL}WG!IG8^OiG{W z%J|CcW63?S*T;mfIyTSl?3S5tO#1OiKx@J3hj%n>+52;$hjgQ@rK|(}9ehk&D3#9UJtE8`C4OWbkRxP}PJd44T5kNoTTadglq z^wztYR;PpyWfO?*4VHx6_cU!jXBr9zL>dDFLX83bFXDf2h!qVmmWR!pO1o2nDX>hUn0c((l&o<7NSzVX7iNF|ahUmq? ztN6Z*?`!xb&RP3@)`ThYAJ0H$A#XO9H=PqxOmR#9ym+(TJYkK)W@7I)oBSzc13DsW z`4X+=3*b}FHmE)Iv2D70(QJ~PUG_=l&t?-nJCY5@ddZM+S_d7!y# zz|)a^=UCbvI1~67ztGE^XF~VX+bZHS?K`s+3jn=k>l@F#6*H_%3e43WLh;jH-aIRp z0&^voX1H=mW!7uF-TJ*WX7m#T*NU&<<6`b=zE*zCbdV9 zue)4cQ5*7QY-fzCqZh{7>P)QlEXKGGJcIqp8|NRmc*(cvjL)E0qJNfsqeDCuck=_l zQ%L`o2N}eSY!(qPa>{W&4tLBaHV_bBe zKz9}fUoICe_P)X(amB(#JXa2H>36}~!4ive>sM`m=*C-KZN#<+{-Tc^tEPdmf1po? zFD4z6om6nUGTzENTW<9^v0Eu8-Le!|iYO!6R`_5nr9Xc=P0|;K^pCBBE&hI^r-AMd6ePLZ=DSM$a_-*A|#GXH0 zR1UwCvp!)QS+$KI4>~SA03| zlSf9V9c0+tUfhjT1P!3ct{FG=Z7j@>ESa30XI;~pcpW^o zn>EqD9(uZj=opS7OK7h*aCv%%c;!xD^VVi>UDnz?-VBP(=ud_p-N37_ew!6)7gKK}WX=wljp5jX6gN8b3Kbe;v;o2!^R;j3eZxg!Lg zU*g4kh?bf!=P@Vk8JZ9>8k>Q~`|`ON&H$N6uULIMW5C${bgs8|b>@I%zQ$m_Y!K{q z{>L2$51t#?r}*cF)_Mn?k+Ii$falNnHs~gX8aAI1(BBFo;-*d38DL zhaz$PKjSSY7Bo}-IQP+|_Gi)wOSx-8-cXsl4H+8BiSAd)C2AxB-2R?d(HJ&nQ-F7s3!BDqFZZfNe*V=y zQhfAIfA|O+MCN^T;-~iQ?)z~zM&`_nCI}eY_*=;TGtQ1 z1^R9ax?k(1*b~t;YInoe(~wW4q*stW+WOpgw}wJHtB=<_Rc#(xD&Las<7%Dl`1sK) zcemYYxT~*#yU_8Y@j1xn5T7%kS7OdZ$KJudbt68c#5CkqDRQfVy9JSD2Z@U~gq&=h zjh=Tae!WodO4rp z^$tv>{=6ISJlwxM6niIRVy~LsJ6l7!n-1{ZWpWPJ0%KIRTl#e1 z=(TB2UDxtM^m`EBV5|0%KKixcU)zUo%i)__j!9QpOFxoV;+?7R&et>KkFy@8=JS93 z+>;wATMg?ydxQ%&-6Y;ahBfnsd1kz@C(kn5yYatzZg1SjXzzbc(O&V#Xm9B$@a_LG z+Us`;_&WQYo_=u~@0f~L(0ZWp^FFLMN6?OAKf0Z>#r!}0a79NTWL_BcZdY>cT7$jI z%litx{V+Bz{jNG3>L}`_T=kzSI%cXI@rSnDpF-%n(WUyodS`B^M`ug~7xrO`@^4*GawEB$2B<=^i>4<*05wQr?M{|(Fmk6#~Sj`{t6j*Y>23v)g4 z{i6Fk{&nNT-SvEt+g9fJ7>EB;<99S!Kcx>eSC|qnCiCkaPgL-3D7L7~_HNIBPxOBo z`@Z63y6!z0HhZDsoJQulYK_~VXl+VGFz zAGue}%G?b87{uPm>J8XhnD1koS)YQYnb?7VeIw8Qwl=&#L(sLaMwpY{{@UaJ5!U~m z+Rr3d_nWItquMuN)@S6;nmOniZr%XM;f`eac6D6W8=O%yvyEiaGZUMg@yGE z{bG#bswd+Yz2;mK-^acr+try>Mt23@hE4qd?U6npr!VhwQdYl*ac^)g_Xh804b06q zHf~>aWW!%byU(nSbX-l@aDj=-2Dy4QItK7$UxgeP=)f@R^W`10tS;unrhD??$V&7W z)~*0@O?J65%I~MoX04&jsaJqwDE7Ft{;=s?==QgoxJB7Wl|Ku39s!O<$vu?>wWf+p?wrHa^_ltK^np?vLOG!_A1I7EIS`MNh>yc>YF3C7Nm zk~TXeY=cttzK%=qEf zJ#|ZEuO^T2dtNqLf7^<^U#qsEjruKMjI{>M#GW~Xw*?+~jhHfY&nDuPH4mz8#zu>+ z;?u?WJLChnYE*0uF_XXJZ64VsDtI>ld&?0sKhOF=e@%KhWfQWKZN?7R=bz`eq;4QSmbR4EEr9##wuYDEq^R#uz`>^F?-ycX?wxQ)Ape z((zNqlQG`qjq#UBf1R?7@n&y~Um(6yeTx5%-tEmZhkkzAKAAyVj|c8{d4T)5l4mr$ z@qgXg3sU-eLDaM_U@V<+admdayxxI-v#Gz<#;uGI{LpBH`0+e=bjl_6O}k5!&Kp+C zNbjP~Kk)&-C1&H5Hm>38$6PWBM=Tc@n7txO2fBgD+njfkAM2|CXUm@P6Y9Rhm=3PZ1(#V}|cW;A^ z- zyNG*_l`GN}QI@68)FJwPcLH{-2SWPUp#WCBA6HNZ|Wa}6MtxcMH%-!b;mz9-l!+yOd z1Lj)6%si|I=39aH9rFD!{}h;CAuXygTz{Q&e}!A;8`POVo!^l!-Ww$Q_9@P-*)uA@ zq`e*wD|R>x)~GZedd__jyM_rm^=Zk#Ap7tUZyR}XBl*@6TO$`8ap zQRnT=u-2JxUtnW>o&e_=^LfK5E7;Hq|5clzG2N{VjpO_Opjanjq^_nscR_xI_Ew9p zj$h!!|GQ(XIe9Dd3xBqiIi9>BUOCM^Y1ha3!IiV-|77JW$mXHkQ?SZpAD+Y9DGMBX zQ9jT<{I5P+&`EWE_pv{EGSJxr&2aC*Xl0!3d~;#jru!?R<73wYV=?WbFC5NeEy0)a z;z{M2#>d9HbhI%R@QrXN(!Ffh$F!IFg^P=S{TwkWT|N|JQ{Oi7*o%*_9!UQy%?chS zmWw#`toGg`%#;bxQT|H3=_man!g)a1_I1Y7kwp`ZNv9Tk3$xG{-19Ek-21W@y6~{h zlxM(mugPn_opxVAr)FO}U?1m;wbJYs2NT~Y-4|*vDz@d0j94%>HvEEA{i34o z>r0Q_*HyRY6_ec{7zLN$5nLN~+`Du*vQ; z$}9pubo1x=zx@W<*#Lg0!>j0mbhzSub9QD2dhI+X1FyA%y2oqn`Q6vgq5eVe+Gz3` z3eLzmTmd}Uuiv}08hD5Ei65_cqqlh(+S4P?I~`}GFX^xF;jO~2g{ zeq}{9pAmd|>mDEC;JxuI2sq=}yU-cWcW7r?vooGIU0`CP7|+FwXTupcHOyr^Yc!q% z%oLl>cs4vR_x?VCJ`HmMy)E81W%t{CC*!$F<9U`XcVsc+*>FLsei3)$byr__zXRUA z1HBsr<0SB&1l}VV#~X*fvLcU9cHt{4((&%axaMstq3#aO7_Z~r**Np^>G`j&{A=LK zyKiZ*&JE;si))be0-R35HORX0QE<)c1&$A8uiVR7i~XD<7T@Z;fpE;pHRCRV7F)5Y zZiIgm#5^#JZSEJa*ee_C)Zxa~3r1J zZydVgL~Om>D{zs?+1Y}fSF)~|HvS>XeuQ%qsxLe8KK44=8#?Vf_V!BJS6i*@W!hNB zxxaO%!Poa$Vm?$)^_6e&ps(i@3!*mByo?_~R3-EvN zt=|Ri_XTeHAourq?)SNF`q}R9v)u19-E_sHsGT$TZb4p5Fnwba@*Zs{g|3T`)1v*) ze%&=ivhqj7+h@Y{3*eHR^zIDs?oWAvd-|-*)*TAa;#<^Bu0O~ZzdmQ{uM z|A>-^G@|Wo84`~8y@QpZ|TSuUwGp;;s@rL z%KFTr+&&_ z$iYB&59HdD@e6vkOS51XFUJl4d^u|`ltMZb}x)ywB{Ofd10ZMQi=bxEo`o= z91tm4ukV48l2yFzSQaL4F#ofE;yr*Vi5m9NeJ90dVpo)}&(Ul0tiSH3$nOb#vm0I= z92v0@JHriq^BP75B8}4mIdfZx*$6J45buKDYS9HQ%8oQ1K+oFC@0P*nBFyiDQ;F#! zZ529(^f4O?b{_Yt4j60CFC4hnbd+U9t^NOu80Wn1W$tR;*Dz+|8OPbCqnZ31@>)o% z$e$FK9FRR{EqlbG-Tuj&s8!?VY_h>NmJF1KULYKcBKc(76TZyMQ@eeFke4a-zv+S>+vWnw$>!$J;l_9?a#MGV?}N{`LUZYK1<+-I_N7a{9G52dy9)XK2l%;aKw12@d}8U4sk6h8xW-E3q4l+l{I*+|-(S^S%HDKz_=)AK zKgb;?dF1fDCw-ThayEPEhqfG0I%~oqul&!P^36)WG=l!nwR{VG+`uQS{pJ@&$F^b99YNof z#937EvFrXcY}M$WRdcFdVr&{OaNt^E=3GoaHCuk}^xyak+WV&Rrhzwd=QYw-@cVM5 zjfP&#qZW9$zyuHf9$Qr_G@Q9?Lj1PDlek|Y_izzuuW~=_tJvwK*Vi%@iP4%zSq+*i zlK|J#9Wm)SKwwg%W5!Pzg@pWv=N$>#6-&JZToBkQkC#nvXE!{2d!!(b0> zhW2*e0XLiPWo@~~#!bb3Ur#)&+FxZV+smziDh{?zX{y3pBqO!^fTkC+4(tLu3STD9##hZJ*MEMn@Z8ri|pH-f__# zI;*c?t|i9BzBl*8a`{98hIW}70c@kq1$<*$Mep6^`Vp>dhVNRCS8DUW@o#y*3-#Wx zd>_n{U{)~RJTBVUa%r?Nke4&UneR@_spd=C@8mk|=JWQ@H}JRn>3*q8clm2i^iRd? z1-Ub&obv}-Z_2r!q=hxPBL7C_0C6Aap^|0gd@EnEE#>U%E4~ovs9g3rdloGReNp@L zPcuKBVeL|E%H@so0jvWjDmDP4>=N$8Ub4dQ@cT2I6}*2tW8bvTJ_D_}w|5lk)F`iFp=3 zwQdiTG{b|^akGgTcKiZ5zn;BmjrbFumfs^dFk<;k@j7kW%Su*=FX7K=S>|%Xcwn2q zya}A;=N3N>2|ID=vMEJ)bJ$d6BQu@z8xB1j{}p>W+x~@G@8Lmz-YM&O`cs{AKloDa zPW5!C*1AXu=M>WHVNU#Q3jge9BPG1GYTvz<4y5>|>}O5vqG$0(4Mwk-|L_|J#+cZK zIkR6n04=yfrUBl&d>=9H8{j=`Fpp>BgAff9xgq?e(v5@cVKj$pwmb?x`*JJd@_AXB zNynibKYl8oCBG(h{ONwX*q3~G{T!SYycV%{G9=tPx4iLn?C&XkvyO7phcNe{dGS-8 z?&|8Exq|h=P1mdKT-HyQR$>{iy%CI?U;iyc20zl~`IO3!`E}sYZaaQ^e%#jY4xWNd zT}zSE%;N;Kbns4xtH5nf_l=(DpBl4PM~)`%5jz`sFZtArEYKkh&@1N;+o^V2LJN?~g$HI1I`%C#?sJFzJVX@|QCiXb>obt}t zrOKzqh_mkW;E~&J(Wvp`U1a7T=WdkDJ$)zWp&dT~ldm8tcQS9HaQjC5G6^>FNiW(n3ASf)?sJ{WyCaKX`QJ{K}Q}$G`Mc^myIQtjNFYsl4}@qYD$Y$)w?jFtgtvNDZvC%!wkJQ?B4t2!`4!o)QcT&GQf7zjo`K5Wi20jY|zki%I zGj}yd{b_>hbF{1Z(UUyrW$%kh`O^y61Dt`*gS_oHjvlh`MdTc3|00TONQ{ZCz!zVD z9@Bg+=M;Gdv1KeiE8cSqEDFXwePX)eKj1s<34VhAPX7-JVTT*czVGEo#~k24h`h-g zWlB^>ag&RPWjOeJq~qTyA0}=x@jQDD$wKDqLF)Hqqd;NJ$ZA zt7?I<1(*}(qRVRUb#sCBXZ+*elJziA?CCyEo`YQqKj(hOl&oHQf;#YB;w;vv zv!duwrf~!95TD0yt#|3|Y0Pc_*6!}3Zr>i-T^h_kd}zFx^4|9B-T(T2X?#`C#6BoC zyA!S1C7O?hyMMIr@b)O8(Ry@(pWW{8$576{Y)FLcSt|63=l8L;b#?WPcVSOh)78cO z_N<%B2gI9;L$T)X19LHFWn>R%y}qPL=l*U&ugNk$Xd(6@lC@yPtKW9owf(L>x!>=( z{XWvwb!3D2bZp;B)4?8W&%V~Il2(YagtdwlJldegh13tSF^%bnQ^S3D7jEG>QD)5i|I8`yID-+k-oryu&+%I|g5 zEd6=*XBrj<&e&O>*SBF6u{_o6H)^oO%q4Egf7VlQ=)9-WTC1J2MzianA+$sH9;d$A z=&L?!;Ru`tc@_|`T?Adm~>Z5P`xNA9bY4Z%u-C3K*ut>)Q$_vhxY1r&|H?$6U1wOkgKIB`O z+gV%cc!QOCV~aT}UDrpkK4yUa&xBch&*Xm1Rh$j1V$ZRPJAIq6FL#HF)~dI*2p9Pk zz-Ow1i}qztcrZ`>{|mFu?20xzU!iea@8VkR!&Uj2yuRDzapi>>C!O6N#k+`-E7jx6 zI$9eP_r`rw>jUWXflbA%9n&{+_F7}Zn!#Rr%#8JA9dB`_O8fo4AfH5|>7ysuHN1#= z&9qUtxXhkuZfS7pDu%C^y2aG}8Fh<@>HafyHAh?E;TiC7A#nsn$7+_AWtBC^h9nsM za88(ZV!567(O|5l0-IFBnQ_%`UXC2D<~MWl6}@-aaC3Yg>8%s7Te81zr7f-9-uV;l znS^8EY7^6W!fEI+KWD7NS&`?4vmTDX|DVXseeNLhVSFH3GJJYYEU#CnA@75|i{_l@ zx)NQrqxpx(fs1K(p<+kT8CVArp|Zy2vnopl{3q|lF8^%&z`2zr2Wx=uVsO-Y$(pcV zddk8`$1Ok7S{X#2(76!CQ_ zi7Z`^A843HOvnt*`4rz@bWCl$_Z(&T9{GLz8+;d0t_2zh&ldK0eBTqiD7vSdcOQgH zXn1r_HUGDwKZF$LA1#PgVQ(MH`QZS+3-LJ#rZ8!0?``I)lRhHW!rJepzY5;Y_+Xo) zT(i#KuM605nZ~$i&Q+mTX-q9&9ywVCe`ZeC@_OJGPtAI*LT7Tu>i?S8LM1k>u2Op7 z=2@f1mSh9lhRFr7{m^Q4UJiB<$JXVoBUjTF_M*)jBI9HGfoI>!Uh#dvuy0+kWM6gA z%J32wuGOsT-O0y6^j-Zf_qp*s6WRU<@>2TXAowS>emORX_vueKMrbR+e9}JYO=69^ z!}m{=^~?O*IH&p+GJmfb^1xfiOJnnf<;7}hOdS4kL?_U*+ zAABYhf9LyI@!!9g9slFjocOzM08*Z8uQCnJZGG|H8hEo?|7S(ew)YtAy;qTyrk|eNW)^K` zCEHv~o0;~l_KF-|dwHj6FOT-}lI`8!ZF~KE?d6}Qy?okp-cJ+GGrMi?LSK8~)3g_+ zy>Jq~$=$YhU3)R|!|M3Za8R&z&wJJ5cl!1GOgat<)b9D9dVGV*cJZybA$YH%{mk=7 zgO1kiX|EoCIK7Yh579@x+edv`AKV?f%zyvIoTZ*21KF4L3+9Fk+$JNV07Z~XKTO$=>0ve&WE?L)3VjnCy})}t@7=IUNT z{~GZf)(ou^t>C%{**-Xce-|4}jTwBn44MA3Gt7W!fwPvWeferxw~h(^N@x*f&%cv0 zf=z#Zc=Wp|MXTtkdt)UOoLjf-Qe5eXhpNf#1shAL0K;xle7c(t!m%`ABus z5cI*)U+u{?1In-w97lg%3A}Z{Si^ej*&(Dq&8O}%@;vze6}@*AYvU@`Mn8>I=R|JV zG1&vEd~31b_rojNEf9^+zrI~}N$eroUZ62&Y!*Q0!YJzkFhs;7z!=9*s5bn6qHz{9 z&K!d}#$W|H{3>vr4z9KE%fEnQ)=*$}(-x4njI>ZMXPr0Mrep9Kd}#0QeRyh|y6{WJ zDev!F|HE&w_qmJByYC^I!rx#?feNFU**|6@k_C^!AS zjPy(0^n26O>#lIq?@3SJex;j!cY1n7iJShl^z_iRZu(dK>6_<;0&e33wLpY5jKo{@f@O&=6qy~j6>!0C+Fph#wVp;JDZkzO~{ zNjGaU+8ODluT4*9?h3C#W_@~kXtbNYF+II*oK0u?95K-_ayg~1C-)EbJVMGh)rvf7 ztB5q>H;p~|`}s>>WuN@C$!VA!-nQ|X@PdtsgJFNFcbrmr10q*FGr(Ns<-LdQKB}v0 z`TNL>DU!9p(qAdO8v>ap|v@p$_FMY>IZdYSJfq{E-GzqSqo z{#!pG{K(tm$msWreejdkO4tp{nPKN)RN%$R~gWzA&1N`28qL<~N)PAD%^Nhg{KOqj) zk&czZ;Z`3Gq$&RRz%Ah5_ObuPlX3WmBn~&&eVlOk8ui{_-|Vd`&VI<*zvxU(=KiI1 zIXH|i`(*QAU8Lg<;qa6X2h#pVI6MgsZXf%{IsN$O!6@M%d$w=SR7$-)E)EX-&VI>( zKYhQ{%KHaxqsu=T{KaJ*R}22L%2IqpTA|?YkDb%)WB)j(A3yxZJ3aVQd$j&1!SC$P z9Qf1sXRYU!1ApZwgulF_w}rpl2Y-1-`v-!*9Qd6+_K$P=@x%YKB>bs8;a=*c)8E+- zI`F6O2V2jr1pY~%5Pp1A^!ff-l|J|@JGNQ)D}mqXWB)j(A3yxxPQstsgMR-c_^)eU z2k&eDc{|_T?LS-3odh0}KOr8IIyPE7Ci(D~)bXUnV-k2cee56S^yA0l4i}Hi{qB6~ zW}Zj$_PgmgoxI;|J$EuVReeI7CU@LxahmMIX>!NkTbw3?lheokaZW#eoUTmbl)k^7 zNL}LDlkrjB{yIb)vBv0iJ4Pq(uUpTp>garfyGOffJ|Rw39oJc$s(d(AbquvQRe@8g zpUzejyUp#-kK2j&oq0bfn7+5qr;VK%@^{ctXMexl#ZSKZLBW&v_nq5Kth%eFqq8^? zn>qTEtt*|_#_pr7Ea2%}XJQZXzqdwud54g_opmNQTYYBqEf{Vack-Ikl*BvT=CFsh zUIA~(>p@2ydqb;>FM;8#J15&4IRSb zc7LZ&f8Q$89lrHRe4~5Do7G4Cwy1B>-pjtR)|%_#ud=d9okWA$bel_Ool670KQRqx zLwGI}p8TIi12^wZH_y_5G)n`%)wk+&M}rC<4W?$$U<&PR_tC(^e_l`c2g}lIPMx7L z2L`@BG5)k6{3i-W{!hc-&6B>NJc~cy7Jt4i{(N_bf5?ab$_)Gm^nib7kNB5o;9u^- zQ2tlIzue-VYjG^kz~9OHJu%t%KY#2JM_f%G}>OWNyh{5vVv zE&m5AGw`o;@vrVDDz~9Lm<>oo~r}y0%n%ph^AwT|k>G%)m3IF~*;a`=3f0c`W)n5Vs zDrf#%9IG<$ck+IZEb9zax${50Z>4uT|GCR0Isfz1@&6_5bvyqH9Q;F^U$r`&)p?K~ z;nhboeLtODHPB#EhrVZiLLFRf+*drQ<5l6w{|}y((#5^J@49)j`#XL5`&OCTy3x;T z{WJgvzaH=u?fK&mrT=u6_JDunWc;V*n1QWx=N%i*ciY@~FMSf5)c7nj@IBgkR5*sr z!1wvz)BC3KTX}QI8_)j-_3b^Q>U$;M`&}6P`fj-&e{k^Qf2)iCaASg(@rm#W z^BKrz3?JkB^YnwxnjYvA{lB5lb*G?D@yDRg``m|;oFm}ir%#_0eX^iW7WB!2K3UKw z3;GZumikN5r>h70m^CNOpZ_2H^A}`ZPyF*1c^{R3w)p7-4u1MbztH@FfAXLYVaFyf z%m@19K_9{hQ-3M?tg-sVN%F<4{Xe14_fA2dr#=RK?ntB0+BEviOVKAE`s7FWg!w?9 zeCU&(qE2f6(b=`G2l}l4KcUZUr=ZVGAA>$cY4lm2MxW6s`h=lR82W^vPZ;`yp%4G2 z{!;w&Ob_(g_>yFqi-jUkbhM!;XsQ&R#q~lN2 zeFT5;em<;$J5_fpe&fW&-gzL$SMKls55#At+Sd6G)1ep#wX2x;X{t}0*s1V64t;jf zx4%rn#umt5?fl`F-t;bKZ@k|foHwueOUmBgfiHHR|2KE$e#8G8pX|N9-{)}#;j6yi z9l7(j%=Q1~?#bEy-=PP$RQrB^(0Pe$_q*kr@4UBvykl;^`2SX)5&!elzVUZ&?GryT zv3LBBrM=?6AD0(@=hEEx!C^V^{RP?F*TOw?{dwOeHyA$woxTE2HXZ}MQrv}N1Qjpx z9{4eZfB9u%C=+$WIYhZD20wGczT5O^8&f!!x}PJTJM>d|kxOjeO!98AdBIekxzy(U z19_8dUREkEa+%H3`KB_PmzTOQ;E$RN*;^DfP#oG zkN8&6_d@b|kr&q4*rrd}v?%*~Kg|Q=zXv~w{+;*U!q|S6vHc8VdoyEu6JvWLV|xQ* zJBhKK$kGBYSG2(T)E1b0{ch}PDtM_WH0@Ul;7@+@21DN1 zu0+B8N-N-Op}v{(JL~&Kj#eR?lvZe*yO}6IpcLLLqL1RN`9~A*Zw>eHRq;KSPr>{J z8@*2<^{3N^0hWUKrMBGyH@02hNbB8&Ca}AJJ8cViGqr#>Qw!$T(^sJV4*b=n10qK$ z6WeyYYl>hhJa`Lhp zb-u~#^HAu*D`xdIpBh^D%K2V?oIbX7a$X*IO7W9d^Z(Pt8C(2K&O1%48O7jUBbZiS z|33STDLS(%ns{*6lPB20Gj=0zqXst=a!x*cU!OM(=ivGO@LJLfX|M1v4lWw6wcN)$ zjWK(IKBj@w4)&MRkO_Iv{37nus)TNf$JE4g2AlZx#xy)T#-X9o^?O>_v~ywBl#7UA zS{R*nEQhgv#vSXQFxI3uE^M0i66uZ4xMThk#+vj87AB_MPx=GTxZ_S*3BOB3XRm$1 zFnmZ^{P9r_#{~!X8n*ACKf#kXBoN;;>bkfG(-FcokulBWw_glRG)A|*cvH!v#=M|&jtAZI3kNtm zpf@u5nZV9(zTfry-aznh9W)y?CKCT6F%E^M-_8)9a?`KjqQPbHzGFD!H>5m1a#Tfp z)R>p!<&j{!;%RRi9EdA_&XAzZzhhL$mbr6Gy;BZ+55i~PykC$EWuwXD*A^^mih3=CQ8rq1_+Uan_bKE_tUH=MeX=3L%5CkU`nVpd4gSZv4n= zdBi~XA_lrQG0=U8f$keO<>TK>d=*`wlJ9#Jhsb(PjP{`ad~}a7gBnbD)y-La^7!=S zlh0=WpMiV^^Z5nmro1}ly{R20yqEHODZiKUdnvz{@_Q-2m-2gW);%TR%0cu=Kjnko z>^OWgu-pVpHv-!Yz&Ht5Cj#>XV80$5D#4`!xms?|Q!4)c#5^b7yc2z>E0jfijT1*- z|L2shA^PYJjJ$x6elkG6{ra2qyg**GG02@H=xB|onjHi8_O-E;ejP{kygz^a0Qy-l zKT5r5;~CV`eIupln8@HMefh0)^r4;UZABaN$cwCq?kRgHx+m<$Wp>y8S-@i`+Z;M; zQ-JUEcAOf1c;00`RRy`%ASco><5SVb>T`J4`uu3)tP7)!@BNS3(Op*2rPEv(TS)_E zVB~GDPjNr(Ij7eDgs*7SMm^Sy_6T@xBqqoYmmi)KeIgydrH;Q`cldhk@9Cke>iFB& zc{N}k`T8l#r|wh}eYR1yD#%$Vl8oWBg+Kf)2dln9(KUNVJ zoJo@t{Rbq^1H5}{5M3sOPLqXhlO6B8HwPUi_pWy?&AaRO<9glo$I{++p_kZrX5E$6 zOx$)K?n0QmfcQh&e)raL>Q_*|lKR(Ee**O{nj_a!$#L7_#9H3>lxvDH@PQl`yLf+FhFPG!cPv7ICY1R5Qy zwntm62?PX9=g8KcgFl&>a1k|e+ER+6jO`Fm5z4gX(&=UFl)bZa0nr|@ooS*#e(%p( z-qc;6*wC-?ggq~-Gn>mFYVVY|chBa!xZe`|c+JMek$z~{XK zpZAUnD6a;&yp6rXetRUw_f>pf&G$8YKNs764c}kO_w)Gvx;tcxY@PP(`XIf9+uO7& zh*R*rvma`E@Ef(${TuH4)O-A1^bdi9_S}ykC$bSg2X;8R4p*Oe{Nt>;hQnuH);F#! z{RY1mJvy_#&3^yl2=5)ep3C|sUw!WTzk46wOP_@2<~%Uj_-@GhF8E%u|G#)&7tZac z9L~Y-9lqblD${+d-{HP*d7s~Z?LB@k*}j`^zx*D*mv184HIDdwI%oK|H;39qqu(Ky zlGWQs(Z}Em$WD{%uRW+vZq(S1HMTMRa=sxlyr7hG37hrH*@I>J#ty(wC2OHfd|iLoyG@_|YGrJu;+s^V%R^e0Niw(4$WB*R+ z@AK1t-;RINOy8#c7qDvs*tLQ0{>s|V-`-s1uNzFSK@9R&G@-rhfyDYl@hdB;t8>HP09@QqSlI2M~7nH!s}wcoox z>Ea$XCwpsF`OEzBv)9k5eD>zZz_*$GtHZJ@j3bkyW7|8SiTB3r_;~#M+KppRZbFW9 zPKkMbdsu6uChYP0pTAXSUXYL9z&W&Sp4}5)dt&n4HgCb$xYp7y92ahlWzphmS-5`6 z$5rdv#aH*w@o&H3UwZ!|kC-+1vW7X_(e!?{xb(M`_^Ci6bXiD_h2xCWa1qUggh{ z#M8^zdoid-8ly@!f5hHh8p{pGZ!4c^+g454*540zEWg&tk=1i2>pN8*-v#9}JDzv@ z9QH70KRSy~+DCCv`{>xr_jAW(-rsm$=KYsHnt7jH{~vvP z1pKBm-eIgW{?nP?I9lts?xyVW(>5eAC$=+L0ezwK1GTOUYdc&SU%n#~e#-I8`_6ka z?=$$Pb^VN+Qsbl3*#BZ@FKh9>j0{?5T9L+5>H?N1$X*c9}w=XYgE^Wh(y>txsB==3z=>#6oTn9Lcwsz>`c z?8e4uFV7YHhR0O3PhtOL+0+pCX8&dS1kNlTsNibid6n)z!x_q4wVpjgIm|wjoI)NA__=dPQ@X#k* z#(GPxO7I%4UdPiRdYk?yZI3E6R^(Eip z>bu%MWMA+jE4YkYsQur--h9*A8?|3_sS+J7L4LUOo%qAPyaVN2lAqz^2Kk$f{+M!i zi~iq;QLgn3#ScSo^E^&JXrXPo~^zGJZO$`^(_JL9tJ%9P(v`E?x&)TKTnD8J&rv8R&D_h`o)z9rBwgZ}~hyovHe z_j-PV_F3&;tgx542;9-;OT79j<<@rOS|yfISf3BlIy-xZ1>5?t^l& z8(K$@lZ%iOqnF$t&Ek7X{b7Lbe+!=Z_`W`;4Zh#+k0W0D-`18Kq77xy*d5?W;}{s_uxD)m^w7y6O1}e!XZl&#TS%A^-Vq=oqexeYKn`pI;(g z>t6=HsmJx7*yo+-l(7S$h2PaR~-w62jzJIFyQe?-}XDqA> zaV|4<0H3tBGkmfdI(5{dVV>BICmbedz>a&Ww9sF8d6 zZdjdR4E#ghjbtm&_Gxo4%YHe2+4GLm{_etJ@*0>oTt+@a9kGlW&XKDwd+CWN*J4kx zzprj*&-VitPkH5uQt~dQ@_ZNb+pk>F_QWc$pyQfTbw?JHFZW>aD^DDYgfDGhSGa-P zgb8KEd!7ib3U3JdxtDsKhjpggX5{d|{lyzDTk+~+ZK2WTe%gv^&H-Vc6wZTtiFy20 z&96Q-m$DwB+`dTBrCZmPY|uNwPXBuGhBulES{>}x=IU1L+M!*P%^m^^%2pJ%b~i^` ztAVMS@{+&Ty0tBj{`;Br#EQ0MpLzPzU$VESp0}{aL{Y3A*;L$Y;Kw{yyXEVeF(vVh z=YC`D5^=hLj(~kVS>x~5b#d-`_rxRkS&uPpBYu`xGqyd^L>xu2zt7YEe}?lDsiV!C zbayvnrG*znJ4ik@XFWEV^OR2MZ$0Z`3|KQR_Nd^RKF=AgWyHm9rTkmGf&Zy< zl|D?q5OHCT^NJGaop&Mi-Vz(w&OUYcG6UMzG)5dp@VbbbwrlVBF!E4IT$tF-0^!zF z#~!0&Ip5$`&Te6R*fSM5Ucz}j*!a|RjYF4jU-1dzmSukwC%(G<@SmMoPppmo8H*Kj zvwJxZONopNwG%sCr#&_s*atMZ#O|m4n}1{fNB%A+ej8FeDYz!z)pmot7v6Q%A|nm#vyJTDuR8r_+LKk!D`XSt7eed2_V71C?ZfiEhHsYp zZNE9#_Nc*OhHHCgp>2B&ZNGV#wlBAB|E%!f@8Q}$MDI%Kyw|VureK}y=?ag}cXfIN zw$4iGylI#^@3nPatvdO8xH>m#ANtO5@nGA{vyGY066Di{*fYJH^8$o}_orvpr#Kh8 z;Y(%lmx(t=#*yO|^JXWRC)zlJm^ov)wqL-vEV#E9oLRp`aKD%t?^bi8;Yc2h9UR*Cn{h6Trq+B39<`K7u1Mwc#{C71>; ze4vH%{Z^mHc{TJiP4qKc=x6Hj5mue4oLODWnK69F(w^3G&hBMCiSsAI2k_-iw9qdE z%fDj#;QGtC&ipQO-sD=ffqpb!|2Qq{Jj4X;2{S(1tb9!B*1oN>!LrYp?Xs~o_B~~< z@My;(+P0X#3zjZ6ZCm9<4nzz4pMQ$B?Jaxxi8k8yvgt!Zt&3@!&g@x4`!XZz_J)@(dZCMV$*JTX{hG79hw?2fE~2r{_gnem|PdWV{@anInR?bbQY9(h3QXC|LF-+an(6Kg6 z$8F`!A?Uc!&@o(Q=@|Ab9m75y!_Y}|4EuD{bJ0=1qNCo6K4G7ZqD2?&sUpwf6UfmI z-Z-5xcay1ye!GAXJ%(%RHGW%%jVEOJ`)aRr|2k+l895bCb8VWf6MZ|h8J&tQT{x@l z1iJuHBL5!xwtYEt7kfVGKCUxTH1}S~_=en1?S1$11 z6X%k=SAH)2z4%m#->G)#dn=^uI5Je#@N=EC^NpJlf3mnW>v&fec7JwKh+2i6;{S6;-XPkP(RJ45ku_6!em z38(a#WSO$R+b@6E`N}MPlg!sbw;1h~+-*cIy(~R*?N}kZ(pet=K~{UV`t|h%Y?jmS zWy96C=zY}ZO^$EMs_&r@>U#(uH#pySVw2-vb~p#dS+@#rV#yk3<=fw_ z9UpZ#{L2~3Ccxd`=HqQ+-LZi`CU32dJ=+ubQL-~y^P~A}I*|N$cTSGqFbs|#58$YM z&0T*JGBF!`=)iepCu>4xf8*m*g!A~Jd^;mkz73bHlP<}((O9eo*{Ug`E%^V*d*B7W zX$3N<^Xl4w@vzTRuu*fd$H6}B<*U4H_#E*KexFuRW^HkWXKitXZ;LCiwX($(zAe^s z*<$@l$Ms$`s_<>G>|aWKT5-Bhb7u}Q-ZOpLV)`?k@zc#5Sr20uccuY8n(>*eZ2WP* z`Ztv)c=fa5NAR8WD|p|7jBy5CT>0f1Go|oty5VgX{`W=DNin)Fa{kjbkJm6?Sa@PH zf2WXL&ZQjKJSN)Obu(vu(ucIahCKqlhS;{sRIFyQ&WK`+qjP9ej5+H0PV)Y@e5)}~ z*@@HZlUFhp&={Ms%LUwbU8(hTA>)gQZ^}`T9ZN9IsT*zQQ1o0mDqW0AsyHh%LA`&Y zGH-#GH~Y_M&*Q?0@p&n}T^BI``jQ!{$M~LMZ|3LNPicimP7G}=!`_~H{WRw%AQPqF zQyUxjjC)V{djC7#ca_?>;R@-g@M0YL3GniWJ1Mt8{$Yyp7D2l<>K*fMgRDg&X2&zV ze-e3if3qj$#6QHJ?=hdeo$DAjp^)<(hxjyy@zKN>vcUN(KEIYCKb$9dq5~dX_hb!c zE0>(;;_qJUPv3On5N8#&b}{x6zdkA2P^RWoiI<*O>GLW@dCIA&Dvm9aA8h(X+W#@1 zXWxScL7sg_G`TX9XX^JH&we{9%`@qQcqQN3lqY`Ol;+oES^QcV;8z#?dV{@ig8W)1 zTqeP*d6r+@yx$E!yP%EQsJbGQ`Jix|l*PAs>^BK+hHukz`Sy+91^CAMjgD{p{WtG{ zZ`Z)Hxm<>RevI|hacg@IXPw889q+9qb{M12GQKr_5;*{~w|L`b%Ez9<)#ODR=_41U zb=i(hG=>E>!``OlD>F~wmp(4Rc(3a(kDLB0?ceN)kPCs%Cy}96bRvljN{H^zVh#6Q zMutLWuFCKoT7L!FHlq{j!-ThTe;n@ch`hzpjtN7!TAwD}9(yl3XdcqqEB_nyWexUx zi)Z{cbT!0gYd*u_PoR;$AblRM%4wc#V{fwY7lDuX!aP=ERk^od5$6lF#k{+gmEli% zP1_sji<_2ucaayeK(dv@CUiA~XSdzx-BnE7u1&tmS^VPLmBA-H4_!$4ierX7sLT;s*+e60IZ!CfC$f?d=>1wE+ ztv;%WI#t#bd%puK-qcsGReG*Sp{|crron zG)CTquWH^=rk=^>?!sRUey@IQ7d~t7nffe!uXf5OZld0L>Mf^U*#^B!@?7`wiJN?% zxSV?A6F2!jvEHk{R4mrPJD>PZp^yH~re#{*Eo~#%;VH%zM@*lh@74E;1}eXXa)P{Q z1DCbLE93`k0>?10UD%XzemC)(U4C}`uH27MZf7AmEAVd-{Ar`jqJ?BfZK7>LhnV3HlG%{ckNFhAH&sApH+wMgLTwr)uH#nI&|;oE&1zb%;Dx8Js(cLl9wRw%6ZmHIT`Sa&>w7$;}!a2=EVg+@i;af+mpceksg%s>|CBH z&rNK3RMd9Q?izx@Gg}58pe#$(>D|sk7N~kQz_CkG@}Z z3pefEFTdl5W9)o?DQya4Gop$aMWd}cr|Kzk0M>euR-M}(=DS|}x|IB(@tm!Lt?j1Y zu3NgO|7XXbfqW9tfIhp6K03CvzW*T4O&`wvT*~ReH`xVEHgTS~{4>!-XD=t9%{kD9 z_|A;fbo?mhU{d%Z_56yj+Ml=?SxxYF9p^NeI_T3?$B8_3_o!~)7b=H$jt9RlKC^x! zezVGw%`az8!}vw~8h&9fUHR3NFMp$+-)iE4J zrvHETK9j%7hxi;~0hrE}6WZb{5bVFGj`+$N`it^suLb`jV}l*SZ-V zKFQeNqG7(dm@@h}uV@YND%ob8Cn`H>VMDD)@xdO#2TRefZlVn3B3>ZAyc?XuwX&#w zM0zU!F9%oppVhNskIHW!h3y{>-&MRX^znI_80e}$iX9RDgS7EF#y$^vMF+aQ=z;E2 zs~&z5A76PY3yFDLMA@srO*rqG<=wpkJLb+sl`XrNcHahV3Vqu2LYt}7!@1`jTdA|h zD`?$HU3-a#>YQ@L35!An2Z~Owd3dW=d|>Yw&c%if`_btXKE`gJ4%$z6V}C9kvUTtn z^R#_jy<9z9-CQXy;cMm^eZS#}K7S01-|*Yh>G7rWAFThA-rwAv_c=HiZkJD%@-CUN z(wjA7vWINLFO9S0hsw5AhiA=L7>dnEpu5u9B?qVreKxv?z9i7!@Q--LZxWkf{8#x? zYuI0*rMCY}k=?_bvHUv4ZHRYlE00ygR~!zsV=Q)p_~KI6jjc5D1+C|dZe3CyW7zLa_!cpWHTv6MKe3yf z`|^z!#3wT!vX#95t$(EX>j$>C^Y^sXljE-!HEsVWXA4@~=k;s+IUMXOtU1{Fi`nsB z&cyw-&OkG5wcjfr!tkibw(H|+R}ua>KAju;>(}vTK5`Sxw@GkD`oCR$X8m6uMNWtv zefv&Vezn2JtZNweiFlH_ZN2unXgHC&vh(%loA_+{yyH&d9;MOcZ%f`S{I#ZB&cj+i zIn;dE#o!iy$UC@o+lBE5nbVWK+9cXtGBv)L_O9MP6pu4}Jqv!)KHIlp>oup8{Dfj# zb}s4*pXLl-`VsoYfk~{P)f`|bM64T|irpD-^HR;6t?ZR~aRgxfmzYD>`^gb+K<6{M zc>b?@Ej{<;q31ICi!)=>?M<*RgW9_ZIyZZ<8NoKUWwp70nB&&(e<*%!B(u$nY4dei zZ9WK&k7U8;quHMVKPwBq6ZobPXg2PB((LajuObi4;y%ru575lXUkP?PgI@I8N&4n4 z^u5OA58yvGLqFroB2U%W3;H*6Hm3ZVn&|RJqxgjlUbJIj^sByHTDg+!xcQ18?albB z_w@Q^{807d4XnE!5WATFaKwzkXBg)_6Y#ePW6wLla9!JBD=cg3neA`K10hGpn6nrJWJZ zm~QxdNqiBq9-JF??K^Ln_6_!=+n3j#8F@;Nw};wCdxqqRal%^36XS)b7>Fehb({ysC*!;RzogK7o^6-g+ZPvJJ1!cK1HsRlJu53mb5&EwT7<~N%c2GGi z<-nF@(Kh=&+pbNu^0Kbsca`xc{W7fmruF#JcZ=2GAlw>{2;9 zsYcpg$uq@Y8Q*;L-NE%<5wVhcXe;r=cUX56D`pIGFJm2SrJfhimngQYxVCgkHlqsp zP2Nn-4DF2QTzA1^6IUXaR_5)xdVQ0i++T^-vh_9K!ncgi`iUkGi zwEB_F*lOV##9es*hW!?eOwmUP_I2*N?)2s1o8+MkAQ8XGTUKB|&m{F((R%GS7eD?AHNDBK|b`yzgOCgKy}=etYe zZ{>_kMMe|Uo16rlC&D9l&)*t5mk!CW^}Fk4>)a{hcdsjNE{|^sv`=kn^xG8lx9$95 zI$x)nGgV6&$1Fs?<}TehtCGLFu$eVWH_fV9S~hF$((+lBM^*jum;V9T*vTc_U0%?i z`1RzU&&*TbeH-r^s_(u{_to52cHi9l%I^!-_qWvbH(B*v{U5LIg+P5TP~QvG_d>9~ zmDIH&tG@I9!}Znmr|YZlr@nsbW8P0=Cri)UsB3XneWz1a7Kfwfely>hOV2&@x3O_v zYm^*K)j65_D$dxI^FCH34>hIVewiKbD#A_OR+^ zeELSfmZ`qX^3(ZRZvMYhz9{wlyY{~|@@nT~zU|5n_{Dem?duwgz4q%d?ZfV8v@dW! zR6lYN3>R%3Oaj4Bg4IXn^hyI{f_c`SpJL^R1_UD2u++^QElz{83i9UY_>k zqtAU=4}>CZ-Au7f%= za7Cuh{J<|OxZi*)?;*qAwEdO8{J3okv7_k7Rp#0HxdRuxjxQYX+I3&Q zKAMpSC|kFR`iARY9y0bjJ>JkbUSqrjepmS?i0^IufXP1|GoP{abbQf3-_oG`M*Mn> z*ZYs-xAC{)JL+5Hht-jj<&~BAdsX>k7VL=dsq@?K#v6=-%^swl|4;Nk*<$_gFLuGk6Bgk1;MtPOXQR?e~Zz-NB&&JF%XbeEPyD0;C-_gZ;_!P&@r` z#c=tH51p&_1>%_+vmq1R72(+_c&xFbiGRWK6uj!LV6IB~$k^g#auyt}YG;X$>xaQr z<7vBfKe-c0!r1<>na_NCcNKHjURj_xaFP2+Lw)Spv^zHR24sF5X`H`xVW zn$e|Lfj2X!Y|1TQ4cJqZv6XUTlVSx5KcC(aB$r(E?vpLJ$^Cf?LfTd*M$%Kj%dfxUw_`*_8K&+iAH(_DXI zD0j@oCEc8vyLWSN!{3);3zFpI2Fq3qp$C22hz|EuFsF{5RU$Lr0%wyOiH^!n1@T3m z*VmoV9N@_Hj|!DMeEpmEAGyAqwlDo+|Mj6FLl5Y_pqSjL(Sw7`-xo42Ea>>i%whNm z&DWzl*A|dhCEoJwW8AAhF>#G8Ht%FqK>oV7kazFvThN{^Gcx!=#wGM?73`&j{nT2r zu3B`g!5fH;M#sjX>tl=CO7%S$>m~PP>ji}!y~v5^reDdV%kR^7 zTd~b+fE#3_d&X@Pgwe(v?W6Mejvas@k0 z&xq}*e_eun4Z@Q?^jPvgxRf{{@x{g7tQmSIx#ge9{Pb^Mxw;Wm;=srp*w=w2_BQOgA_wrV${US1qQ%g^De^9 z6=3@wMBeVA9{D=@Mr(r;@I48BUEn9#C?oc#Tmxd@J0=68JzZjy<9TPtBb2j8amG+$ z$ky5OpmXn?T)X#~_s;eP@mIg=VRz-*$mYIDpIvfEtbN(w;12mc3HnH#kCBXw>KKXt zw_%T)wLcQNC;v-h@IQp#(_Z1jeQ(@z;C#3r0?<;Sh zR5T%MT~FeLHBR)t+j{YUuJyZf3Vp%W=|OY!tc!0NspnSxLISiAR} z-Z}plUhfO?#POrujL*{RU7Q@$Q&*C@lIY^Q?42Dt_3qlHNNJ;T$)&@3PMzkN>ICn# zs#EVJKbC$r){H9Ref4g4##ari9zpyVSR4_Ch#A)nc;2~hr!WS|>) z>Dj>g)b-wM<#?yqm+fh<`S4P&=;32C$=#-}uLCc|dQ9#${>&Wsnp{r~3;WG!op8^F z7_hMc!5y1v_DJr~oDsSEGn>2%?>p zk;d1l+vh7ZwmGyVK8^M6i`XF1%9@~v!``1)yJbx#EGK=`_~hr2lcRJnYKpSL|Bzhn`0PAM2S zQ#4CapVnX5oUYh*W3Tw;{{yG3mqJNgeJJDu!-ABHBeP_yBabYcbH)O(2+%0186#(H!> zf7koA(aOD@PjPboNng(AftS_(K)f_79^>>+c6c*&g~1`@bx~iqwGdp)y{R+YDxG@~ z8dVXeLB@yGu?zjW^TjK(?QsQkr4OGed(2#)mFuz@My`kEk7}BCR)xmR5U!Fd*#gN_ z5i%9Dn<02p0XkyQ{D@f~hF;4k_1M^00_@zDzskAo~#`51~fwS&^3gP-8v( zi}>=OcXLk{&9L=qU2D_!ka-b9i{pr6K8*Prg^Y` z3U_+(2)?-D6{bIeUzxU;Ia6bcONVUn24jm$jV)dou*EItto9kx+L#rTBN}@#Fga>bh1%c=~&D={8?OJh5K%KM>r_+@$*Y-eU4H8H+}`Bc*%(SEi4ul=^~ z_1o>*yPv#=V7&als=d46q1yUChGfL{1=)B%n1%OW`grGCulIi&|H40j|DR>y|EWCq z2m4~lU-rG@&;0*_{<#`CoWgv*m7mN$`CK30L>~Rfo{#vC+N(sK`rrPQ)zN(RYCdD- z40%0u6yMalI>-L`ao}I8JmO_<td6BKjzc2)u-imp{0}0hm8Hmo$sqB zmsWW?ifjFVz9|HLyU7fzD529_Ph>XEjh`Kh1I4% z$JT!hSUp1?CS>x&775zeVfkS3 z4cDnJ<7bOCd`SjIX(@N;O8Tvi|eKp6k3;HJU*@I;& z-z5d^S_iDymi6m{`!d+Ndqxvu1y9osXquueS})K<9R9CpOJcN#42D|gjCqN6L^iDO z+i?r+D9dU`E#oHDr7=ek7dOwgS8GGDFB|jNm;G7xyP3HK?cvt1{9bUH?DIrEHvTF5 zHLxD@0+0PWH1-esb^10MSz@h0hM!0Oxy_I19W0McjQ{=K%zodjH!dQ7YZ(08{MY=f zcf zjjZx*q`qsBjid~Y0FFO!ihMSYHg!&lCwW%RvqX{3L=4B3!W{>vP8+_eEd`a|CZVc_ZY72Xp{0P`` z@?tjKH@RQ3^py(MBV8%^)?8Ppqmy}1kFzdQ^6{?3PLhY3sI>1D_x-~6v5EXvBP%K1 z*C3N!)Z2v2$UjI_V%skWw(IIx&9)%|8CYCt~VxLw#V6YE9>%E|M$P-=FgOv zeEsx%Ileu)ZP+txP4t_w_S;xzvxGeah3BAG*w~4Ee+r(w)Lip$DiXO=YlAlPtYsYg zBLOov>OEJT_H1OGxv-M{21CkjoBo}mUjNU{g8bb{{AiMQ$4}{?P=&^ z<^r+ZF9c|f{tS(0j+=N}nqDR*?9+#JU5yE7u?qUIpU|buv0SP;UTH4qP4T zZP$OK{g-U|)G;1pu07rpDY*2>=BS~|V(9XUPnW1qm!4th67}h#ch^9dFnbBFhAs_W zfiH{f6M_E}Ll)(4IJ&6M6X)E%e^;Kzl z*_fxJahhKC`G3oz*Ev4DhFkw}oOOQL^UEe58CxQsq6U3RaQ1T59Bg;lKg0? zzM!>pR}e4wI`M)~$J68^rT9CE&rvZZ)ULL<{9)x4OU@?KM*XdMj27A|8(cm<)Lw?( zAvXhc*hIyw7r_r-=Un~!v;1xOr4e|jI^`G3SJs#+S5|#L zcw)?~5f)qs8kGYlAAAh+G7E`0CU{>5?pFTsq3HL&1r1}&p?C2vMjNFkYkBr2xaF%S z-QG<9`+C1!`PQ>&E;%?p)qbXjh*?@brTey7l%u`6WUKYh(MP^=5MJ#J$Go`F4eZVq z><%%{N2|Ql3+2eEVuI%0!~`R)@~do2uz$wrEML*ZBhUBSkQ%1`Wm)673UA@|rl|L^ zB=(5-)By3*x4a33%%e3Q{>jVm7+E(w{EWp>@w_L&F$#{0z3vybGS?*>%{@5kezA|^ zR>poFytg(yw*Ahmaa}NW_lDVfQg*F*=RbqDqgQ*@e3EdHJ#h3&fr}fr+xTLsi7&cw zdvFdT7-M9-XJVlLGGN;n@p<6d*S%)p_F((WeOCJ-*oZCgsDk)+)TfX7j@96_8vbqN zT>-RNZQvrUtJ8G3Doq#m%@~F*@{gYOY4U>rJ#2rU&TlfjvA7>}xXX40aX0r_xXWG( z|6SxAjz*??*bm3V&es;fujSi6Bi>`DdZxy9ZXtFny%isg&yPNOS2(_wb`0RFYb=Sc zWX6|T^Hj;)+yU}q#4Dd)2j6eZ`=)oCUS;ZNy-%0n`umdc!;g*^;JsQ)$VQR|UiwO_Jr-j!Z6a76QcJS!u1p=Wia1p{H;>)A2N zZ>B%j-=fo0o)bejnbdfMXX2B})*N4p(eZ7EqgSN*t$y=2{^M%axX9O%es^OlU-tTE zHcKDBKRI4i+>ufKN2r5YCe2)=zE)&=5P}(n%7@+ zf87z`n3j?YGk$me9YfIKC46hGwPW)&{~w_Bi`&FUaz=%9$~J9a(j@4C-FhM z3;Jh1bzkh!rx+(OHa~d>xXLGO#s=Ys-}O((-BTgf7hvP%x0lm*8F^v+Bi`s7Cf#p^ zw!*y^xl&BP+FC0Uy^`A@8&lfxXOcx^_BQ-5!4wV-ULE|N@$@=>JPqF`#=G{Ix!#Ot z|C;@*d1Cw*JQFT1F3|aNa3U@c%#YIkg4z8;2s`=T=(~dbgR|4$Q5i+#sRs0WzF9As z_E%+-HE&)6JyTKdD*P|?6-L)zMvmSkhJfAM*Axk#2+XUPdt_MiDrT-&?_S17U5HFc zR_Hq>%(OC#9kenlSu}0r{&T!DF$(?`eLwHV2Q=@f7*jQVply@&F>CL$^FNa#{WH4o zWv>Ii6CMgzjhjM>CE&BFF3oS38k|FB?=t0Kxp&xWaJTc^r9SSOb6!YWh1+Dx4c42% zPw%|=UphYdV}n2QX`H?OHU{0LM=nRg^*7o5v%Yh>`+n_LDSb#6MP`*NXc-^lOtlZv3`VeD+X^oJ@Ef z8)L>Z2VJa$wTS~O^)0cIs&T|u$sr;x=;snxIXbi3>Y4C*6}()2_kRU{p6}1)Pls}t z1N=E|_IFN?ud(~vmPbNn&wu&GQDnEN*c)h~t>}cw$G#rB79lRQ+_(7}?{=RVyox;# z2R=pnHJ+1iXv!jvRaZn#s4ENl;g1cK^+oy*m8J2K=&raG{>4@71w3%M%3|!ii24^X zj#}i?TlKwsn)-m5?ANDtHo^Mzw_;}ISw)DvL{H;0w-jR*;@q{_lTAzvd0YzfWZI@Bd(Z(}$_=!vDhh z#tu_o_`k5e-~K*VAA0{eQy;89p0-!=Ba@7)6_-pA^VEEUd?I2_@wS+E@^$EcD|&EE zofqHAemh&~J1!z8PciY4>Rs&-S4perx|~z?d6mmjK7)ZyNKKr_2q88+zRS{zL0Y_=ns?r+*dT_ z#aDrMIWRgOR{1`ghkOS=#v@#a6A70#hYRtbeDk@}&X+%&-Y>_*oonFJP2^YIOs?#$ z#JX>xyc^)*Jl1%_t77mdK7DlG@yOSkYt7KiAuFdnh*hM#kjF@daLo*UHzv>>2ootn`*f+GRI4 zP~IKnvp);W4Zz$nyNaB?vrg3Ex4a3jujZQ*$X6nA7V-SEp+}@+&xAAUi4Dz2+-&CA zSOXB-uDyv8FM08?|9pD=J3OzVPgIPhx|w*oKhL5(CY>uL9vi=g?-W<>ng^|KK*nyu zZ{=C-87S7)_gHYj4Ld zdINcXgEnYx<@VDy2k%Y%9gSc0eEI2g4&LpjZO)Y5pG_He5D)nccK8Ikmw-M~{~g(U zKX{>Es^d4v-&yBay}1cqeCzblwSUF<=O@flya^xwD(cNFMW=+b^1Ge^=VJWYWuap) zObZp8@xQqTSKTiIPmN*P-yFP3cqPfZ{ndNGO=Sq5T3^=%*G0ar;-gmY?Vd_nuatf_ zHtU_@R>Q+(_=?Tv#Z$Y|KE2!9p|>q!^nBk>(|Yc5Yo~*k=l^s5$U|k)uiX7DdeAHF zZ_)KgN&DorG0k7BC022D6?(t30KG@!q@^45dA=^V#}oWYZ52*9nV1zbRs!5HgG+%DcdZ& zF1axJguQx}Zlm+G6$9>1BrC{)<+4>bE_*$8CC_;B!AXCbY)57@`9XHH&zQMBVL*sDkZ<9Fmr{$5zN0bWn? zZNs;A&_-lRb0RmvclXZPhH%GL-b?1P<@-tGMsef!Y8P%0kI^g5p{L{bc8!SE%!;>* zkSWQfVp>}%zku>rcs6AGH_ozYCwx*bDj?>E#60FFBre zfuFG#ysNImUSRw4+KbBJ?Zp&iAZRaSW3~Qjg+I?BdolFP@Fz0FA7d}zukiXIcf>J1I|hsvKs|McB4AF@`}pZYWmuzlO0H`zRx5 zALPG(hg^JT8&3IlA?RN?`%p42(>@HstKgb9v)*k9{A(a~)D)U6yD_*tG<$NLwH+0V zJ>++6h+sR=f7yrbTI8z%e|HJ~Zlmviux?Yf<&%epw=Kr5P^Pgfl(QP2rI&L0DCd~* zv$b9T-;5leGUn(?;1_X+x31sKUYb*j+K;~!iXR8}Kc`nT}UGx77d09y#CT{_kOU3&n5WLdH`u2* z|FkS1JI+772f53TpB8(5$TZG$JTvy(q4m0hjmXcv-av9WGSiC9UxVCKN^Zus4-OW! z56(e$uFLFq#z=Njk@(*qLN@Llrr&{odVX=t`Vi_{`~2}^FaG$FiQbOapm*Om?+_Eg z@scU8n(-rR6i-fL{5XyAWARxfttE^z(CSKx^_w$VnrQ@q;FBjcqyJH z;H~KTapufj`40mevm?W`GeeKj?N5`-m+_4LV?NJR$8Y}y-KNho zaRX>(ZIY!|P{-9bT*5qu>l>usVmztb(>n1E+9@}+jyRp>8-!;WXLfpZMe$nJtBM~(dEDqh6ngy?yy)U@ zQsN7q`enGQ~5Zyy?!nWvL-lvC!a!d#?@wQVe?|o114Dk zp3~uHDf0%bA+5%CXr6h|ls!*qPrs-5t;K$@y-lSeG4rebm|5?&Pk{CdSVmdT&_5i%1>IQ# zP3gP-)U*+D|@uvfJ z9Q$tUI5_not3!4i+V`M$@>LV?Kz6+Ij6Xk3+s{J(GVD0dilvj3our;bk(GaA&s)=e zp|R&h^X)mpEz!98t#^^9(Zp7l($_DgKOOzCD)y2uIZ=gwf2>jRFdEq#W8#vfoUOhQ zS&?icTZWMjwc{Sz-iSP3jxSNm-|#5K{UYWaf^g!)F~-|{jJJ;)T}sQDbWHKR<6O`D zc5p}E)Drq~^5k7#{0G_;^Y;a*(!L<{QCpQS7kow^wUK9v?;k+^Z=>xGqQ?iAA8~E( zz0FCWyeL8)N!6K zaW%!ywqpC(L+a60oEN7#p1J7aYHZ&{*gnm1HL!0?PwTbqS_7jTn^c8o&I`)TU$kqs zWcMC~S1(gmH)S=$t5R}kB&S*rejhyQlz%q~n^l1<(WfXcNBdPOe^tCtU#|F$tD{Nd zSMua-KW6Lsho7Cvk(Cc1E9wIrKh%evA}`kQViR>Z`Y+4!PX@6egKg>cclZ~^2Bxrq z@&#qL-_QCxjT^PjE{Yy%y@Ti{-$v(S7@vL)awHmH7aP1w;wko{z^=r*z{AKPxR777 z7T#T5mXK}XYy8zqr(@jnTD)qeb>ZSs>oaHZX+!}yTU_V{!_{dJ_S9`rw{><*D%K13vyiDz9B6_%W2$~a^!8hg1&4tlb?>4j*lVoSsWi1LW{C29%R%1Ea=}2zM}DF z+R==yk!}ZZmF;pokPUm1{^mi$1NPg?;6b6`!Q5fwM?BD8F!|_K1l{y5M*Wh9Y#fYl z%eY1JizChXzoPwT{C;M;uMU=ifA<6SDU|GmG)_n6v;Dg+a8=A&wr$iuLv#OrGI?H_R}ZfXwMVd7TGJ-^ zR{mg^KFj2r`|_@{mN$GGIwPNHIk$G{p@6JR!8duCxr>L4tdv+;nM$8Ye<|Ns`nq$B zFQe4wXdrnE($nng1TDisTJlXkS{j)jp}e0X7sJu+FQJ{}F7h5|_u@}8X{T{(2K|H| zwD5eIIUDiE(9P6c0qy7q=8KNtnoY;Fy-3^p;Jo)S=qUSN5|DQnThzW%HNNb7{B!c( zZer7!@~_x~WM4X<7(}73*Hy-jSbhEsJgSo&3DR^PJ_^2HA?+*j+o(9=^gMDoF+f|* zB}8rfXq~^l?CNghP~}N37eQNhKZTsRUh0ZI9%?T@E)^$|E!sp~p)9%l_itI56c6>? zieh(`m8Ic6;9m7{EkrhNW1qHtroF{RHpx#O=rgieY-ICX+U@0OcP}(np3`u0^ebW( z*>d!nzn-cE-@@pe-_~hPj<}Z&8abl9LwZO(!^zQ$v^6M4&vP%kSw)-0r?maFZMXK* zu3roK9Kn7_^7Iw$=@anN@1l)Pex_yROI5HpZ z)vkIQ#xYJ}9sCOL)!r{U*H!V7bf1!m$H#m;hTDJsA+vreoj(?4j8MVFTHr>-v?k%} zE*u+cpTqB>i80Qt4xgw;uUN-%qMYCGgxJmsE`7f#K))`|{865DHcqMWzPj&&$P%5Ve5}r@7sB%Xr}Vi6GQ!YW}8lFZ%BCjW4;O6hxs|{jbgK- zrahb^OM4=$nY`$6_ErY}dhT89&e2_dvc_qa|B;Sr=wAl?4d3Ngi2leYW0w>4;H~qj zN22{igs1!GaPq()4gVr7 z4?+Df{&kjoUP}9$$-9?)P99U$-VJ?LpH~Ha!zXrw=TYdqg5Swws@qqeS8bk${O7x& zUpf01t>hxE`UmB>$;UgmUTePbC)4=4JwilB58J9Wtp%)|+3H;pR}8R-JuUZnv4Iam zTjevs+hO++w)VxBId~y|L4EySeA=$ZBsYcp8hjhtYvY4a@}l$&d8K@(7-fQZcd(q} z+Gm(Ob*B;U`Qg%K{rkO=*4j|?#75}R$Gq++a@Z8}vgZ+LPl0I1pJ}gT>M;J~bn1l8 zN06n93jF%1?lpO-5cGSul6?^(mzh0g)3RsB@deOliC1`_g=;QzPTHeZ?df8Svk|(e9S@@K%K1vo zp)Hlru?GK>?~Xx7lXF2Iu*)kv!8wWxbPmoM&wH+k`^l#V7nD)I_S`&53_-sJmzvAs z4eWP2h|JZ=7J;X1&@t?JpBbkYfkSb}*EvsHG4DQn^<&UBK`g+)w_FxqQen?-RQyZr zw0j)eJytC*ZTrf|vndV4Bs2z+&HtJ9WC`>=HJFNRHhhHbup&W+2z`kzPHvRhmtrDwqqXlr5O8CyyA+u z=8~_+zDy6rI8#Y6)Nsc_a+f^5E9ScrzMB^MSo~7H8_jn&@?8txbzw&&r*qL6#h{b2 zE7*|cA^Tx-2ib$>Ic&~zQ?!RXyd1=5Q6Hplkqi1-*`3l3*;lPgZz_wO6fYHL>!u%V zIoM+SgjD3N?OI=H=~vA9%LsZI>i9W1Wl4NE&CzLm)yG&$Yv5$(da!3^k7H!D8(EcZ zDK9y_2fK}3WaF%Ix{*t>=P~7Juaq8SPCjCv>1S;$Q+CtYOp~*YE-+p_c{}*`VE1IN z6}K!!x1T|WWZ%2NlQ}rMmR&Rhchz6#_5Z!>i}XUjrO@d|L79GO`jE|1+3>1$1$1qr zPnVBi>@B)h{?6bIrvnY+?YTu&yzjxsw)V{C%rE0S=#AKr9(-4)7sb@=%F%P-Vr`78 zyTFg7Sl*67fA0v5dH7rO58tAH`1GZ~czGkdH0M1a0|o4DNSvE?Mu+;9)qD?653I)i1^Zve zOWCt@f0gzt-I|NV0et_($1_pncy4p#IwN+UV~77AdhgmdE$e((vu6&nUWRS(ybs5J z^9K2;oS{-3`$)VLA1Z{bD^EiBO2;+cmT#JXE*c+7{x4z-gP+Ep(~>{Cu0(#=D#hNE}#-Q|y1+WKRA zo;leMK9}FLlDydJ<`2i;{cw7JY4s1IeLC-y+|WM3JGy4yJNg~Z+V3HIJy0EUXhS|< z^T#rLzW3yR$=|`oY2BmU*D2cZZx^C#wA;mzr~PW*$(pFyZ@zsB^X)Z-8T0LlpMcYmpv{&X-n;#a#OPC7qH*nK z({z7`E6f$)igFdSC;3KWiSEdK+j}@`I_SfVq8&O$_?{D*0>O#JH05 zfnD^0iRJWr^ns~+=mY0rznenZ6L*09kq5eeVrB6fFLdCVQ>z~?@CsThyzqg<5i67O zokbtz`_kvN%3if0V-a%2)W6n`_MT%s`hi{J=AIaZkE*#n)&<0s}d(-aFJbrQscr-vGe1*hvas`citDi0Hm}~ZfDvng1Yu;P3%|3|}613Hh$s+!kEQ)VYfGo5BpXPvT3eDWReDBxiP-mk@ z9;fP4%&f%Z$E2DVztj=up)PF7p^W+~scQ~(H0sy-DH?+$z`w5veV7t!{~=?$HgD94 zu2X{xj^D(yd)0o8={OGny-HD!YyY=szsfUn*!W1=2TuK)&I?tXmvIzj6`fcqc{gRq z*NP6o{|)e>i@^z4jiChJ=eJGmH0^^f(!;Narr_@O07^mIZg?1^&Fymr*Ee$YBIl*P z?ULR4Th1(%-TG-@?puAJlPTA?IydKuC>B@1SuVTKZ^dU;v$x|^ z{N28dq;1>~_`p?`%v?wp}`2 z(D4tnkA1cd2g`~mUQB!47+m9X#{=d3xU!v3Y|FM|?KJJ89a|{d;hU1J1^<^c?@rk| z-=l#sowF&yailBX$x`rLHGHiK$fWbx`q`(_^{0!$(aGC>WGJ|Qsp;G0Pfay_Df@6} z{BxJtKWzPrjDMy>U+@#Y-8^R>yAI8TGRNzWJ#F7^{M@nhh3LdDUEO1WrEbK1NZzNJ ze1y#PuwC=8IrMq5k6IU-qE9X2e6?ejd&Y(-??!7(Idfp(1U9^}%8Or%e{O8hJaEId zyY-{T!1cJ%`OzH-?9x%M_{104!$|W0$7!c>kzc`;ATP7Z4g_;>HQ`im)x~ zGv#3v_ELU;`hEEUqdPuv%AV<#P`|`jwvRew-(H6%wd5&vp)bYfjN<$e6PGx+3|*N2 z&frxg1&q6p-D&88VzipaTZF!9&f%z6aH5zv;}UdXpI3AMxj#T&@`1(Z!YilNKD=sN zfzbn}&x_H6ag-%}UW6W4d6?HP8&i79>b~3aL3_r9__l(2(Fts>#!J>`55jHZo9}Qr z+vet_#LvZ!pZ;0P8S~d>d=i+yCQeh1Z&8kKA-`kKNAQ!eEitq2i)8B_BU>J_mEv#t zkrm&_#94c^{(?EMGV(6Uz}dSRzY*Wu;%nticvXT|_Wl&}v&KuocrER>+^{2;2!r?-nHhI^7kATnaVeol^`n;j}4Qtz~k&E{d4}SL* ztMkG6mE+VA-0w_&yz+99-yP02{S7j0+h+GqD`$=OkR13r-R+m%g-(+LpSCBa{Pg}D z$?>jiVwM?lX7}A36)f9d)Bht^Hu9xhHO$43z1aUg%YS=(F7D5{^1(g3{D-p2?=*C= zV=qe+i}$Zwxkc8-ZB&^>Hvb`rZ;7$5>G=E5`~~($F>d@uLRDQYFN9TzRrl zZhZU6N%Yf!eoy_hY(NAXu6d5SA)XD5YdN3oqw!lmbY}f$9bE(aM0~)t8TtF+ACPZ! zUokc_9?9(QtsH(LSiY}6r?pp{A7js!?`0#{pOAcXY(a2-_iNGr{QT-KeSC(CCqI;> z9~I;9Z}A5;*HR8&+L+Hs;+sXVd6T*9d_$yT0eFpMhrjRF>GZ?tL%Z=e()KBVJuAm& z)x56u_;^yW?;k-NIsbBMiy9&NZ_?vd5J--_S5B^qg!Kio*XN@MMI zd{_G6*@e;A>^{a(gI+vmyq>Vk}?!ZT&eX|#P;X_+5 zh#lE=%Dd|l`v0~u-pL<9AL1zNVVHiGudM3gzdZ8FgxI6iR}^$K==Z!xM+?7oo^0;} z8TD3=`SKCQ2YeGX-^SSMF3A}NLEMJJx0U9L~Hvi%72@}({=sT!H@;4hdwl=*gJ%ucCuk( z+Y`z;)qGD{->jczW!lM^ea2n`hZJi&K{?XtW$0tU(nYhjM!)tb^YnLlRPA#5)V1rSMYjz)@f=Ej2cGw6u9h@MyhqLDIC8eSYt> z{4#XBCqPTd<8+^n--U**|H$5dPW?yIsnWYx%VYZi`h)uWuRT)U{WX)HS&ywOPkeQI zDC*5lWZBEGx8X2!YTN?97tV?u>K<%4TIJ(Rap<9ouc_#+Erd`_xO|jO+nu&{#olApGO{AI6Ds+CN|W18$8(yPYRb# znWet5fwpdeCsv>A98H1YM}gtTV))SuKgh3WEQW6Q2xc!qQxCLu^*re68RH!)K!0z8 zUwh$~>RAN8R#Dd~>Uxs8hU$gJ!Fm_ND{^7CtFG%*7rA5_-`O@jFjQBgs|($M?5%+< z?QiM~y}g3JCr|MXwUj}hn0>eUappI@!j4_MXO3jS$~yKaU*sM7Jauj1jNk@*xrNw| zx%h9@)_*ho`{ro4^;-P3YWy?#V7sNi{7xq}#QfN7{LaRg&_C%<3%|(OA#BTmYVfP} znjWo2rhY^l<)bMMEB#`l-XYiu{uXQs+s57?vukN*3xC(13g2}dztH~>d!QXyMR}{D zu}4=@p62=5#(@hmP|5EN{5o7x)S>dW@axK}(KE^`)-!xW)v<+k=-EuyF_l|z}yoP2rCK~O~Iu!ZF{rpxpL+@tJ_-$tXnL621w7tP2_CBGi z{YifHw|Ljd-qUsuy-7(`Pf#qW03GLIO*whsjNcRZP?gVcHaL2txYm=buUzHLimw_MYQ+w>YaO5F`pxs^ zkC^8xIR_+3eAw0Vm%l!}UU8Ku^AgN8Y*X$!G1aSkm{-s~w8U5jo?~uW_ZN-#+7mp} zcUqIbi8+qtoGV{{XVt*p@l9Eg*RK1zJ41d<*5+l&x0t}bx-S7M+o*r8{q3L5jK_l) zv4nB)-R%Fb_AgZXX`67~!`|Z6oZYTD4foxr!2dO39}VlT9m0PVcnR+{;8X$55oqSV zSG={F^Wv*tm`*uO+dt~VFRHQdF2<}mqkDmOb4EGv$1MDX4t`!P{F0I2zjT?Y|7-_; zLoWO+Bf)>h!k_t9+x|s2<-*S!34W=C|BZvcITwCr9(cotnB~K-E&RbnOAP$FApNEW z;G1W%k34+!>3^_#qov=E9eiyr{KS#qn=Sl5I{15X;fqFsA7|VDZ3o|&3;%oOHXZ+S z@V~^u|E+_+GZ(&ZB>1%!|Nr6OkL1Gtd?fgF7XAST|5`5mt9jrzv!{P`U7^vVzjW~T z=E6Up2Oj-7SY2n^{}&E^c`p3ZBf@Y(AIyUgTFNwe#uDi{TBXg2frp4{+5y8udwt#^DCqO2N$*G!p|EC ze$1tY|G#nY&*#F=90`7##sAk1enT$&)REx#S@<72__kd5i6g;3W8weN!EechFB%Cx zVd1~+;GfQg|2=bCdG-G-3;(wc{@GmkzLDT(S^oVG2mhU1_@9piUuNMSaPZ&Dg?}{< zywU%dum2AIC%N#?=Yco!-)Y8ulf3) z%7vdd68z^Z{BIn5UmAX~*7M;fDQ6jf?qGdm6=PZU8&Y}9N$cI@qL9g@(ffss&fiPV zO+QZV_?pwh_X6n;IheY=E9%I z18>?9of=OSHEn;?!T&xN{t3(v>z{pkn6|LfaVI{0^U;Sc12H}yaHN%o$IZNJ;W z59Y$Zmys5w2^6|3{z9<)dLmqfj|F5oySA?1mS3CI8 zx$v!d;7$Ggw*HSh__4Y0%SVFmwDgeahW}St{P#Hc>ACQu^S~SY$BZ|2WV?gEJQx05=GyY;|F-HI=foSN{|^4jT=)}t z;7$Ek`TFnRXXe7cmIr>NEP$&!XL;3Z|c9%)_=EyugryiaU}TLt^Hi+ z;A?W>pUnfmQ1)wq<=Rj;%9Q|;hy$c1ms18>^@9ZS;2U${M~?*G&stFI?{)`&XDtE2aMvro>yM{|>MJb$?{@G_x$rNJ1b?H2U+Cal za^auN5C2y64qyKr{F+?&4f){@R$pc5U+v&qbKzU_!@pJiQ(OPX9sDD?@XPbS8~ICE z`J3$EAIpVr%mZ)ef4{XK=Q{ZHx$w7+1i#eMKkVQ)fibL@8GxO!cWfw5B;`Pe`acY3;K81!9Se~KPeA9^n0s%0_UaR|L$?{&*s99 z&I51izs}mf?GFAsx$y5Yrzv=h?4*vOE_}B8l zga5(mbyMQ0v9awd9sG;A@c%z`?*bopRp$SH=aQMEDGLIMQZ-30w51@(MOv$7GAXpB zsJqf#cJ1yfZPQCi@KVvAV9}&aDQyGV0K3)JHN9A90%*k|c5$~iC@s<57I1afOOs5l z9gvHtiL~VR{+#nY$u~2ZfWG$k`tP6D>q|N_-{)MO`+3fDp3{;89{e3ouJz^L!SBz8 ze_}NFhwb z^7Fp@JNR@qd|__*%V&) z{2#L6e~|+o{@IlLx?O*)aqzEa!?)yshyKTtbFBURoP!_8hJRu-_}6^-xA4c8M7WzV z!~c6IH~h=VIoALEgo7t{JQIHFXz;gK_@sj`%!aSe4ZkOO)avgm96UKVnfLnef%4!H=c(C-I+i9XxxkneYonga54UKkVR%!DYhF%MFkJ<;(wny7hlH z{LIndpSSSs4xYW|%>Jk4hCiOX+sfaw4xV#AnegLAgP&;ecgVq$pPmU{I2!ziEc~Mm z{<3WN*AI*$|F8M-@8CIymDztfH~jJBkF5T?*THkHH52|9x#3?$pO*uEjqta`t{-3Jj!N<+jxVXmhM$=O-q7!xR({(Ze5HXO zcE)zvaQHoe_5Ysa!@m4mc-H^f@Z)mBA5ZSJ{C~*7S7*Z)=7uMJZuS484*oOQ@UL?p zW5E6oAOAU}CVsKQ!QYwo3bcNe5q_4PT!dep9m5xBm|Q zYuWIta>Jv4eEaX<@5_d-&H-=a5C22{`?(H&b2j{f9Pr5Bp5)hk`|selX2Z|R4Szg& z{=01apSwMgf7bum@H2D6?@7+F{M+u}cV@#+%K^Vw{_6|=`rpAnm<>NJ2R!qCIr+EN z{v2}f4`ss_=76XF<4N|5CxHJ)9sGB*;a_LZGKc(Aug3Pj!@++)8$LZ6{PULo?{)Bx zXT$$uGVIRIp7Wccl-9=!5_|se`qxL;}-rC z4*uzE_^r9&u^*QIlMcQm8@_%t_yZRH3J3om+3>4IgI{9#f4qZ#E*ri&2R!)Sl-zCo zhjShL&$8hc@)FBGuX1mvk^hs~@H2D66Mwb(qus%$ zvf-!YhJQKPXX*c}gYV9UAD06j{Ow8pc%q4a9dhvLZ1}<)@ZfJxa=!2XJNTEf;a{iz zP7e9slYHLt&khIw+iduBZusNL;w#Ml?Y$2E583d)$PNE;vUseCAFOfkuV=%zvE;D2c0KjGlx+3;IOgMZ$_CmnoYHhg_< zcMUQzHIv+@8HkNhOf>Ie>}-~8s%S{>)>{|6TjWz z;BU=_Pv?eb|ION;dma22v*CY{8-7#rmlpqP9Q>+m_?F!8_zzRe{_E!)d`&j|6S?8> ze|-6O@O9bn59NR#ul3stcK!Vc2Y**K{MH=sMt{F%`%gOf`fT|6-0+){mst8;;o!fP z4ZkWk{PE--OTY0B{=RJZ>fG>~lCy08=Q{Y!+3*W;z#IJCX7hE!4t{Gk{Jb3Si{<~$ zvhl-LxoZ>o-?XH02tJtayFll;;BDpmma4ssd|&;(_}1)v-*0k%b^LeP`My&*7aFX$ zR&+YDaw_L0TjzQE7{~Ed<)!9637v=6-Dx`Kb{2WZA?kvx;T&eNHr7y9wP<>&_pZ;i z4&DuC^Nh=M25w&MzV>m9m9y{H({3AQ-jcU1>eG2K-NW`5+%ejI0q3pHQw=HVz;oYS z>x1r&IonQap*^2n%^X?_YxkYtoSHkIHkY%j!TM1e|8CBo7S(zyU*Ifpi1WFeU0qo~ z-}>$V->HUL(fQ$(3;B0ZZFnW;>Im2xQ#y-k&llUzqRHZ&W#*il#(fUs4*u@O znDob(>;8zj=KT2ZdM~AOyjSjF42wUMc&>u+ulN70`VN;fR$Ko)WNJM;!hF|xq57v! zmp!*XdR_V$pO5s_+6KS-FVcga_egv6tV%8RV;yjA=ps!3!7FhsKrg?^4fOsryUp z+-+TyAzazdKlzt8HqU}LsbkSlN_~nKIsbSkXVG^>^Xg+&OK(#>nq8bP9-!8M&dfi^ z81?Tu&g5<%aV)Wxvhbfbmdvp|z}SY>rzrPs*ZIfixlefT?0e>+PdNQ4=rfBkt&K+OsWb9J&S%zZ9220;+OKPD(dymMhMJgzJYxIKYl%UZ z@%cwCJaorf>pPPk$xM3q=l4Gwq{oEBzHPba0X~EGpF~xUl=`&~czN}bi6if%W+(D+ z1Rg8p4E+>jg7fPAs>!XIwyIhCt^rqH&ejl#-e1;qCv`rk=ebWX(*$Gg#2$jVBLik= zUbYR@7ogVMKJnIvHJ5mT&$auW_2=RA*9Ok=XO2I{{VFHDy!6G;?eQFTcK)&u*^2e&bJ{YuTI3buPR# z$KClEnD>D0*!AcAWDffEn)B5|^3;xfD57=&^#|r#y%#~BdFKzWlVkM^e3`xn9$n`} z2KAxrSr@!~BtKR^r)t>`uVe0|UVe2UI64*;JqxN;7eL=ts}EnkQs_d-mul56gMSWD z(;<1=wjZXb5fuqx5w}W~D)Q_qUGsXmR-Zp4QoNg44Ia^}C zJG0NPk>lekSWoZK0M2wDjLv>P#<(266hPzQ_Byn3Eb|Gi4V@@m;;n4Et8oj~xy9;d zYd?)0KWyzd|9)AyA=dsDHGgZ6w!>}hCXEAIn^_+{u7UWB}f0C&C&nV zztH@L_@xc{grHB67pgXOC+j!Udx=LE`}~_k?-yZ*w=?fU`9tp>kAOC!Z>-UF# zKa2L;eu8ug(XOMDdw-BlexCGhH_pRD<8*X-g_=Q*PC@vj_Idm~(XTuBr-$IP=E)jf z^Xyc6Bh7PF_BtyGPk%JP&-T8gA%1QpM>;e^`sZv@fB1v%eq%Fn4s!?ePR=XFw;fQexj>)N$!h}r zEuug9O6#u}?uQf;gYY(uZP?CPQac{&M_rLIo>Sko<58WH;CMpRSf%Dre=#~uwStPM zmx3SB&zf(BKD+U*ID5GLq@Izfd#gFG11Cp3@3CFf*wyD?8}h62RY%(^Xwh$_+!dhv zEK0aHV89EdD_Pf3Q>|b1tAn=B-FL!$6pxj9p;sPJt#9<#uITJ~>Jhb+LYEuBk?t`p z4ROB)HfCZd(y|o4UbSG?9N4g>lKVNLp-4UTn)d2$Al>uvS@2@+e4a+lU38f4Tbk&_ zDf#sBy=r%Wc3;45=*|2of15gJ zz<2QPPWs!)-5rkq>)`+G^cV8`+h+Q6zLRb1JN)(tkK|%i3NJ7HW9%ihP`T3~&(vDX zgU;rz62CUBxvPhmz;b*U)lFha(Ii}_CbADZvK zzR7&w&+nH9=j+#A?ZzK)YxZ38ood$W!|xx~y~AxYEuB^KF@o$EdR+tECi5G2@Vv%4 zN%v8gL$e}}JIs*V1azlv8=s@;d_Kz$>6{5okui9;CF(_6qUhA?;NgSPbET^P7%?@p z4@CPO*L{H0?d|VGUePgzpXhh~Qrb2A%pEVf*FrV-5AeHmgM6`eYD$|sFyPIh`rLWV z*Zk&;y=xtwGxs(iSL$PX|G-`=f3}WbtA1ymic18)|HTh{IVvA6N4vf;u=#m@dydD+ zk*Sl2ENu+nJhMLHD|N)$!sn!qnHs6@=sQz~pFY7m(R!2DKQNDHz?(5J5NjF$|C3b1 zZQ(f$x}T_%HB%?QQD3wEI^=ABG@L$+OjO4D9*^L+iY98KZGq;m+UNFOo+x@{EqZ0{ zPWWIpzh6m=@-o^3f5AIx_A!svo4~;W=rJ1{U5Wm=%-+c%oQMbHf0sh%347|c-~*=@ zg7*&9@-FvOA9676Cwjh3{8~;s;I#vsw|_0xq&r(Qzu%AX?r**8x~6t$Z*hHnpX^U` zyv5}oALqUSXw(MZWWHl+82UA{FL!uGmYa}eC%?zcdU?p7aP`5Zv!rWc?=EZFjeh5T z2Xhyn=>AUlTQP*4tc_)ldf?kh@N6%7=rHego!+n!zW}?eI;Q!CUsc0Qe0~f3nF60n zcwcd$CFuM`Vehfk;K!~ZBh5iOY56nAYgTU?{;sok=>=^=@ZZ)>#2P}>Nh?VdH$>pW zD@NeKUfsE^dg5m%`v1k(a}GaKO+4tmgS;oagzrvp4@J18k=n6Z`^;j^R>R!t(8*C` zPjxg}i956%_GYxfi@!UA9zUFzVeZYlfP0(pXDWEV9oR)xg3Y^R4+`>2|he13umdD&UhUD7X z2d7Jh*JroCRYK3TULNbdJZz8ri#6DuNZNy*s$um*^EH{ z3h=14Z^Wy;Hv)ch*{Q=B_j?K-UvjjAuSdFH)Y5NgeQ$h89}ilO_kxScs-@Gv4Gp7= zCCV70W(=yW9OOH@4$Z{d$hfiZZg%5^ej4k?ecJt3K>jvl(96l(0CYM9?kc` zt~fj~_?0Qfdk7g@>&5H0^LGt+>o7iC;!(x5gmd{+cl1zu+4mVl_eszvkNJhn+;k6b z@E&zb%MKq0x+~P;K)zvu`%ALl&4q`>+!gNl$jYK^dwvEk#`?JUH=l>YwY4*n>;WZ- zJsTNg%#JUJE1w86cGs@-pOt@WXM+CtTxc4MXGuS$(BlhPp9OV;(~a)l-kyhj9iSK) z_rof7C%JO}NxqytnoAwlH_jezk5ZLhKXnuzlkT4e9U7^be*oI(Zn2K#jDg=33w#^D z5hIx~Yj8YPpX*+oeCV0a{Wm4pe&n&GdtQP&tclB_+q!xYx8tyrS>w^7Y6MD z-El0N*Sb>ep$o8GCYFp1Ro&D!>?rZjdfn@gNu!z!8f^{G$mzeiMxKW1Yr6Zzkng>s z$!f;382p#OQ`g^oh1#^u+72u?eCH1 z0%*I9d#=QaY*K4riL`NgZX|LHKe<#Asr`P zdeVzm?*@5bGDPV(lIbjhn~Oq))gHF0 z3m;5(VsR&V%OsvP^vPX$E!+9~6g>KE{B6lk3YgE(R{}q~8=tJ3?<9j7>)_bsui?vd z!@KIMhq;Pg`P|i2#M=9M#`FyPo?6>C@$WHJH&3tTb9J6~V%m!8={2D-)!Wzy)7|e! zdB&WXb>H=GqxLg*@?PIRpgo23S^U-hX(k>rKR=DEc2g_76u)c&c0=>5@>Qo~e%}utE{3n-g}I-yz}!!1#vS-3)&kBWeLraD->pSu{l}oS zdH-;J!wJ1_<`hmJgkEM{0^EJT)P}ul9E@~q_ch^t$ds{%7Y1mm`%^2SYi-EygSqbE z{Z{4^#QU3l&ZJ8_dI>tFFJ*o!sQX@e#@g5Gkw4uT=y)psv&6-G+6aeT%(45LHT!zN zZ7;ac-J%YkrJ;Cr8GeH9?Ff!dcZ}*jqBV@WgFWYN=FkJr#aCQ39lSTN{xSEeFRNNO zNb3~(Oy75XKibgEyM6HI%d-wXe0YdiV#3z`EyBkQ_91nxw2_O=PT8uUkl_fIND@Gb6@^diJ^(78qENBDHUY|5R? zeLr$i&YW}~<+b>O>(I&A;}+>i`Q>JAti44e-Q`)+$#__2>wUE$9UcXz`gaIi=zGOg zcN)A$eI04n)3-q1YMw#8=*JDl2j?S`)w5$h8@{|L_(d)!q^KkSu0v*Y#nHKMJxYknMj z&|kHmUuTayNWYGqHfHd>08Tn~CT9FAbaeFFVB*!;_;9!oUUZkTSzAJj;QTwG9kTk0 zZ2Cpm9ONi{jDM>(eGEVBBy+zRnrrS_gB*S<^A-PsbNLeswV z)2}x$Mk8a`aLuKXJ((1Cy8|2Wu- zv=$HEW8&erY2AXn>VCo*JMrsMZ^W+=UzD8FxFyNs@nrS|E(iM}CRM$Vwa!~*0 zZbes*)_$g*er5NNPEh^}>ng+lulV%uWj+7r(EqyspV0sP8MMwtL(xPu?10vF(DCq_ z&@op#ju-wMTCK~V)jtK~b)fR{w7s8d`2I_Zct{6pR_Q2ob<0!iZ?cC-3?d?UWci`eNZe2*gEA0scb;cWemEp$HF&QH-kI@Gn_d08#(VPojChkknL z&-GOl%BwEHCsW&srJTfWPh~Brby3@hv0A$~V|AqccRM*m$|d>&d+x*Rg73df%fNNk z{);akQ`vvXB_Cy@=*KG=>s!%}vWr3e@c%*&iZ>=?$bod;!GIo;j&nLMI3B$x*>Jki z>8s#7nL6kB2Z!sNhkZV_-qVo()I?s_8)py2s}vtg&4g!I`)Us;!nj38<&`dij}_0K zgucyVeI+|2`|9{NE&ja*{+bE@5~mRF+I18B3y=L-{A=RL)_?x(Ewqmu47Tt1mp%vi z7yn3o1^HLLykac__)9zBVbQ|!sg1K~ey71t3;WF@@#*ReTn6u}n>)h&iSjwM*3oqxE>#5J+TsxdvtaG zAu*a0YLEENiG1QD1yxnkPa*&0!>^Rz-dnX|`qY7etF=GYCZC)+nfA~PCBR?HvyL{` zDp$gb*2`y~@L_Uw78W*K!XA;uiH(;J^ASw$gZ+LDI0_M83cjn)uAK`r+W9|0oGRAA zy5&-b&$EZ$pXlP-1s4yGZ+CqLop&pKU^P6soHi=)?<>%!3*gh6y#5vFqaJWB-PF6v z>%aPs#P=59$5p^9mGI(nc&{3L`WbZHt;EV!S$@$T>Kx)F8;E_BhVs+$S9-u_4}6~j zSBs<4Y8n3EX~b}FUs+&kzNjd7F4IGZstuFQShC*i*? z=F8q{z4i%+7oKR16rvNf?u$29;A{N^Ip|^zC%s5|C;qJV22$9%4)nzlkJun{Yv=EB z?Bnv&cWvxpOxjyeOyAH*@&b+2=C9C`UD&**J+Hn8Sr*QULgDmL{G@_VxTWCChK<(~ z$4rCMsiDI97ihbYnB`95Kg5C4bNH(mYY}nJ*q)7B+L%{qC}j4)wO^rlkK!XYGH12j z#hevGRbOgT?Hysx6|5%?o7hpTlBkez$UL_hjnX zqgOt}2A=gick!&_xszwD9*;bH!>?nBB|>xPq5jK>b#othzhb`XUoq!rPH)^eEi|Tn zEOFl#y;#dnuwNIixOuvCm3*F4_&n#r!|8I3RlIZ-;~m@l={74XX1tOU#w$4yKLlkY zUC!O|;&Ei^2J*gy!yagL4>-K%^gSCt1`Zzyk^dVSn~w1PNpEa=H+uC2>{>;4unQh(C5|Q@d~~F~=)=qze#z{s5uQl__c{I+0sCH__wc-%XB^{;fSbne=G zyAIt3K4n)9V9%!Uf9@>aZYR|tk!CD<2T_*o`W@yH8?tz>FzO-M_gS_@K zuil%yecd;D`%aNOx)3jGh}4`+DKYQ#U2{-9me%@V$JLG&bw3s#~TPh0dy8#27vm zk`6d4U5@`(6FSSt&I{<`rRdkCXYOPSdE`-A+wi-m&TMYY^RD?*`ZhWaz55f!{M9qF zp8I$9l}^&n3&=nt_-W?vWachgGKatNX~;2X*??{@L2f*9X1v;BaxCNEG!I`YicO2) zSLHYVL)g2Y{fZxC(x?sHT>_qp=`%>CfrBK1-OA{9?$hWr=Vj6`u%!C!!hYz z{z^}cW&f-+G}h4Y6mq=Q8{^Y(%peV~9Y(`g^H-o@Z9(7TvH?dJ+cTrlaAw(zZs^y8 zEy54jCmnZ#Peb|Xr+gaj55)})@1cLua3}Iu0dD8;S2Vm98eW@0!?n=xKy(xuX7qgu zoT>j8pdtH(4fjC9N1&l}=9oVW;Gd3ZISB2p9ZWHOFF)Pr3r!uLp_-=d*7K6bpN2mP--&K_)T1RqEGMr$PHI$J)p@zP;2 zU37{3I>mmaw}S8OVvgU=G0#tC%<~!Sg60<@e^Od7SY?|E~h$QG7bcZyHApzSLOyZ>8Sm_8H!u zl5KA{XDL>Vd1G^mvn-W&T-5L+_p>r?bC+uChq$w~xWLzOmX{VIgAwKyzB`nW|K#(U zKc;{8$Mj2|Pb~F^=wE;i$vVGD4(5JrxXxTz;JJ z@q#Z`gR%6$83EQ7D*jI}zNEO)1mZ|L3;Q0Qct>RGggXvzW!?5z zgmWH^=ohWs%5*M-_uB9Y8}k#dya*23Bey=9%s;p#c}Hlg)|=zKHCt@_6Tjd*Xmo*< z*?Y;cZ9C8EJ+0kZS&wLss|G!|c+cuBDWk(9=~lG~UF74OP~HXpz2Mr%;a6Rd=syR) zv6gx-9(5Rp)7Qp~M*EFejagrffd?v>(-QQe^zaluPr^3DJe|D=rPqUdVtMJchwqx+MG{L@%-^9W<>D8ko+hT1>2bh2X_$GaEuyh|`azBo|a&~Z=3clcH(`Oe6M z_{86DJ+i~+6V?d2hdfh1Og(3~{*rviPO2Rf$3R~yzP6rt)`}}?pDiff_Uh!4_&9Px z8uoj!^bPE@$QD-dTRy*OovMA8Ce9u|b4UKT{4eK^D<#+ay0@*{vP(4~p!Frd$Sx|z zqqe6Kd%PL=F~HB6ls`^;RYli{WyWdrqpdI|Q^K|AkQlEnN6zkL2 zhSmgD?|O_4eofzl&Ya4gdT`w2qqIyTW>>X6Hcn$=9X9_V&#SKvc`exNmb>wZUxzhl?+`aJr41mI$(N$ZK{YaJcx?jwGY6XG<8xQ?w;X?H=jpnQ6X1&?^5@vUo!<$KddGSD+&+vslW>UK zz6|~{G=dIY@D=<1`+CPE_G#ZblQuhzttXGy%qg$Ak{rQF_%2oS?`V4M+Y823(0>6o zdkXW-`$zFuUiBh)%|d>?6E#h7X{#w1;6##NNq=f>swXcgb^`)GWl ze2CxiyvCD@cbc{j=^V#9pAYbkbddAsti2k_r}x$p&tUxRQYyL{kBCE8#qC(H(k1 zO~L+EH$(FA*^K_&e43z7&>vLZgE{vZ4cDLCj4fjgF@dvt6~tR2@SJqkLdE$c_wc9e zo?E9EDOX1N3Yd1$4PKgsPO7IRi4NW1y~jWp(RpDsTIN5jV( zIFEwf*O}rq;8|; zw>MmMH**Wxw1ou?m+_tBDUJ7n5ysn_vA-psLNL02^-eS9JTvBN{4od5m*1x`U*Hjr z2iN!`+8fhZhS)a~O|0?I#rf$l^4%?)O}omQQErWizcW_FNC> z161Gxw1cx&aM^Y^G(+}ZG5-`kL5GjC4dAO2-0WZ-CO%rm+}9y%JDBtPd_LO7e6_dO zcDQVY)nAGcg_~pfUccfI+q2F!`DX{G>+Ei<0eZb$0uO!5;Q~2r^UpZlDO{i%$myNo z&WAYuKW6x8bbR={G^|#E7r=++E}SS{F1(bIFIWXmo;LW2r*|MjZMVu^2sd$uo8#iA zB)BoLaON(b!_fTJ+I=ZA=Lyr5M<6&`A1Btl?pN9G$I%HXFV+HXHx1rr3~qnMzT710 zN#u!_um{8a-jVV5u^|4ikCMZy9sbU%-S?=kUmgDbM0p&B!|yV_w<3>{Mag7_JR1B- z9$%rBLoRug96FhlJU#>6Mv%u%Bgx}kjPEVV<1ijPvoRo#_;tczN24!~_;HfQ(E3D^ z>>GX|aRM95akT-A{epfY*{>1fIoP_acn-4mr^IvCf|tQ~4(*wgXaHjH-FY4ZyL{G?2*8nvf?>rT+s}Ew*JA$+ zI?Q}i*k8@ruKkotA8lU$9CO4D7lEh2xD0bOXNH;UHriHP#>{EtxXh4@d+%t7BI8%O z`32%Kr@l2L=TAFd6oHn$e5ITkRGdF=dar1B-ryPFNHp10n$Ot|=(a?0S#m?q;@^w$ zsdRSAo?8*W=QaPDvw5u*&@KMuN6S8_IxUk-Y;iigDE;sIg;$$giP_e-`0EGQ7q3Y4 zr}#V`--5G`O-soA4W(MQ=7D&QCC3@4)1SUcPIsWbF7{mxB-I2k`EU`vwdycMw^`8iWFk2xIVojKU3o}0Y>xmC;ih+RI`b|ZdKDK@M^z7=`C6(K|K)?(}!V_tQ- zv|&}e*wkfMoPUGzPw>g|j8E1!6TEz!2mTlD_&ynSGuAK>9CZS}gE@$AoKLnB935j{ zxfnj#rg;LVd~I{~_v7GcW}<&-bgap9S$ht-koXpYcYgMr_~H8mkDpzOpWSvNesZa3 zFF!WcJidf+-$c#=bzB4|yG!1})xy7U`dxTMfAx7{Rb}4;Y6~AmH3kfyE_|2C+p+5$ z%d39{O+!^B(@P8=MqeSXe||f3NkWGd_!n)~f^*?LuWI6SeLtQx%aU`#^=py0UDO1+ z7TBfCM|67Nje*A$!y4q%1H<|BYWlm1=SrR{c#34}e4BFWpRG-a24r9M-1$pWk)h!F zQ1LdcK___Lp2`CsY)I9GnzZA!Ca0dLJb##>#5|2D6fXL=6Bz=qHlS`d#o27?G@BMyM3m& zsbt{{!^^w!v7_+FlJm<=ZIP4k?;7GT;*aI<$6c%y?z(MR-vseO5qx#qMSaGfg-_bi zX`*olyx9q_Dd(UI{?d4qPkf3v!u4lr?z=vexVMD;RgGs2Hm8;GaK3-@`xtwe{f1y) zad4VXUnTSv!UkoI_jbnXcvovUdVn!90ED&rkx3Kk1a0`yX@BX_ROTExBYhmW8K8_HJ+*3U)EkXdYO8< zX~o)45tCG0vO;H8kq!N>SiAl%^53hYt{ob-f3{c9GKJXJlruUvo=;9;y!i`^wFBPi zfp*HnV*mWHqCD&sZO4JpIkik2T`ro!tJ0m?pEG(A857>LMuBIpdPwt&Xzz~S4gC{? zx@0N8@8tK1{2m35IlEb zju@Tebk$whq_2ur;;DCf{dd9BQ_k}C9)*9q;ota*<+{8vy{0j@P44T4Cwqa-U$KJR3}D&+=#||y`4Hijsqo@dcyS|rw|NEi zj_;P!ZYwr9pLS>8Ue&h&9;^qqpQe3%uQ5)&EYy^^eM(>B=}=Q6I0^F9SZK5g8XbkV zdnaV$>Bn(P53OgY3EJXlQb){CV-QY+^Xur((Zum{VD49agx}fs=+CGBo^zDzMtl{W z*5S)g73~nSyGn9!4evTR=mrM^_+EAJn`(SfAGYN#;_snPek6i4XN)Upe!QeZ!X`7qOSP{WffUA+lXce#JA)8GZhNA}^k9 zLuS&v^MbLff)o5$b2dFTCDEk37@hl8%zUKx#*?#EaCv#tRPy9sgiqfGuRXHj)6;(g zkMAcBu9*1Fesbgv@i&3&F{YN}>6-hJ?8i*K{ZoBq`1U)HL)G~$*i+W@Fgja!tRSyw z9%~EPoKvi|70YNZM1R3cFW^siUI4vIy%~yArm-#7w^rUIdlf%2=U6{r*42gh2hevZ zd5>KeB>E4*UtRFrb}v>>J+T()gca~cDw3ovd;?@hxfea9i5aJ$Q~GRkF7E>OoBJLe zc)rC|9ynH9PWg7KckFU7Y&_#*LB5a;tg~mgm)(EHKd*}z|Ga=2 ze6Q8z4L^_lIJkI%nBE83pG{0|nB!T$wEYR%dmLYy@s;2wYhUmu(7OqpvE-bP@h`NG zCVIx9m-f+mp>J>1lm1f5{~VV4>&{_`Ck^lMuJX_0 z(7p@&#wL=(M4OFX^p$C}*}?Df-PG3Qw6%#dUG8tNEsam(cd)V@-N5$HHnMzmKJD+u zZ|Y-=5Aavx);W;YhlqFa8#;H7)48AAgwB1)>fFQV+f|&Q_I>+E1#k1}OA3hB zGamME(nnow65!2Qin=^=?)?~j9_4wON4y^NkG`V(Z{I(v3GjsTk2aX~>Ckx!tx*PJ z$H;f=-NR$Y`hA30)gBi+z7G7jSe4?&igCKwaTl`IgX}54K(VSKcsYekXx(aJ$J8Qp z_>|uvT`oJc6JOz%?APhDi5)AxjZ8V;!}R(SffpfQm2DFvLoJ-Gi1^rx&F7GZ!Hs$+5|AoZ% zW!nomFESVZp%A@&3potcD{h%S7JXQQ{cXk0kG-nANx8UTa%SppukJH#5o6LCy%&5; zpk2;TvDcQT7+s5Ug;!%Q#T(Lp(BlIY#2!1i&qDfHdjBx{xoyyH5^b3LU1Z*zEvaPh z6PfRYUS0AbSpUiw)x7_mzGQc$Lu#1M9QHg^v$5l(H=}hXI6&^CJJ!Op%NWZJ?6m5C zDn78t%QJN%YvI)t?Js0cdNu1f?Rlht5uckHHZvIqx=L$N_g)+HP=1cibf{oX|c*k?smv{~7s+;>xv0khpkD!(Q z$SG(jBnBkDv~?ckgXA@bUp`}FYyR9i7bMWTmcRa27@2iqP5VFyB9O`_24&Z2Dnp z^C-7F1a%Bq#Iv)QUng_h??uxS&eb|O znm+nAoqLS5T*usWMx%{+$yextuOA@J(L)<2S?g>=uD>E2^I7Li^nKTCWZ=QsO*g}b zC%rLdzfb2E+K>_Jr^cGu3pGB#b>!J;49e-3JuqipkSo#b)F90YSa(ymG6&sk{4CL= z@pUl1Z!^A5#v%Pu%lQ6^@#%9X<1=GxoNe~EHU9B04a{$YFWQm$ZjBMT`TN_xj}mKs z4`b~1$7lAo;Tg&H&EWY-j~psv&s;ryD^D7Wo=(QIi}7^Rzj7)yo+*LxC|=XLd3IAt zNcA1b;X}5_rRncNw@MePZj{5->*TXZXLX^=$ernL<9F#T%hx*3pz~@4%|9hiK(z$b@S2M94{q1oBAicoU2<^$!D^U*)m((d;1~*B@?63*jYl{M`kU=v_vQU;VpP7Yd;B-t-yE{09O+)DPTHhyZ2ekn8F)O=4gSOLj&5zmerX>1y`Aq`@i}z& zS*P!Vujk)oBk*b;xms>*@OQl9egK6vDuX#Y6u;4=1P z#6wZukAdIAw@vJGYxa89`%9R&Che?ZE=yEL9Gt}AvmR*L?d4MwIxn4iePF)oJZRrpFvRdpOl1r* zp}LA|iDg0edqry(x6r(FE>n2|;@6ACugqKXc@TXzhkwieSdYIkfL^Rcu2rwG1{v=~ zPTaey)llQb)5O;1?*^_>Iusv6xL-1X{X6D{&S0GJbT7I$NU&(}(V;h5d1Uf#< z^AJxnkJE$l_ZItnbqg@k`|dx-Uj-(=ekdMk)+pF<`P*v8={Dg;c$aN^k@@DK`(2$f z*#*@`m5(Vu;v0VtKZ=f}$fal?xdQjvmtDiTF8Mzy;>RxdRO3?;&a%inyvVQb0 zpH&}>kv`-fnV5&-gYZi!eoa2@HA34R=!`h~&uz$(>S9gdT-H(cDacu9k!&eO-}L&x zRazHrW-tCC;`0B+JhhJcD0qsn7rS)09A34AckkA_>@zN79;yovq~{E1I6Xke_Om^3 z_}=E1{)sXBv7=2n=(!;yU(v;m4tuPj(0z&*xg3kh;H8E=^=;5XxrwUVAw0{55x<@B z@yFeHRU6C6W6pV|HjUS!80!IKq7(dm8#*W#u?_mSidN86yz@2g?kw`_`br-y^r-6# zKa_f2dWVah0dMDS`}iYx5kHlJ`$xcEl$tc`KbRW4l1=eh3Oak@G5HnaprQB+pY2tC zTgW@X_T2hM_-l0dW9_py2mYp-e8p^ksE#uw!WB6i^MxDseossk{_rVf`ySzYFC*vO zo;TGxQSlh;5IKXMSJd#+v99i}^xAU~WYdo&@Em8l z34RJ`cL>LG`VK_6*9Jb@H^`^xXA9OaDIaasnEX$z&9C<8;-#I@a9q=vkUyTkO zSG!O9eL;KZ>Zwi5QBSX(da@CGV(Ebc@UN?5+X4PmgSe4Ap5U_!exf$$V`jaBTuf(9 z<>-zY{Ef)>o(+29;8F~gjw@nKX6Sej4)W7s|jCNWlQwMe$HEgM6Yii<+ zQw>}0mIap`7lit`w{*}xuLGa*`4q2u^pjeTSs3O2DhIl29DRU8dk!G4ImDQ}GXsye zUf|by_3Qg(j;#wm?`G~@Rg+9T)f&&%Q@xgRO0GVc>iy~a&Yjfz0)o%E986QzvfW_(xaQu$+@ z$dGiT%>~E5Dn(zFv%ki@e6{%3ZO9UKtiBVwydVBK46JIpX}l+q&GCHhM!)rtJBFWb zV3+W|^La<@!8}jggn#c;y(_UGad4`QYpz17(rcw2D~*)!Rlm>=ty^I>mK{^}1><8t}*4c_F9+$-BWJ`}E(?pCg=@c%G8 zzw_OR=OpKAn49*r)owdJ=mGx>ihRCl@P@H*&`y3-zIcJXk0kZ9{$91-0(G<0KKjn& z*F3;jW;2fN+7Ft#U+9pl74sbYPUFUxl{`z9dd%8WK8W>|si`qv_1t&UP7k(-IK$Oy zKlq;R?$li7upc?aix|ImH-7I5^t$+BF?=xwo|p?yzzcii2XA0MXD&SOQ}+3$;0Mov z{%TjY(2f5dWZd;z#kj@48mHDY%K6mVq7ogp(_=5&?@RoVM_>4vrZ0Eq;M@4os#i4u z{@6LpkGAK(m%|gwc$V@k;i=?tJR;s0=kvyw>0A2XmBaaFKfd>T_Tvwa8MYtaz8oD{ z5$bQNq@H*k`#juRxIahTa?v!3FKG99Rz2Fy``YIbeOoKah)YD!yZ$+zN_6;g^fCIN z1KTW|pG7WO_l@X$^pI-(Xdg)Vm<7vfg9z9 z{E>c&*`rA0;ZtJIbss<{wthSC^4S#=t%D}rH!_BbvKi7x$_>5&oa=Lh{hv;$%pHY8J%#oNR@ z)`G`I#&I(|yPQ3W{rGr0`8VtS^c?;cuy#B}tRbJ;9#fE&Zt6Nc?Zu@(_b7LX9D=K+ zAT#;!@nmxNbe_-dZvmx&?X z==Hn3CheyPCojDMpWjIS;@I4K5nrVpw-+IQymjmBChnHttX(|a4zFmBMDYaW8|iG6 zZ10o)xv|&pvU?>%=cDk6kP-FS&HJq%M-H*4v!Hti<5R5~$=?ZRlfsrM=9>CAJdK^x zepd=O@gF>L;%$t}+*=2{+7YaLZowZEykH!>+7kRc)*9-|Z=(_XUCR1lDLKR91wFc3 z=Rsl~+7}YfZ9|sl(3kc@y3qq>zX;pY34cq!q~>9VgctT`dOw3NPcEEvKp5O;j@l1# z_;?(A$oA{K;bW&JO|`~e(0p%>vAgj}uc4dwiYD3%u4R1V0yHvXfkw)^;2sV$mP4wA z$5`Ar{!w({chN=p+O2oZ&T(ef@?3)c*55R+c{9(TJYT6ig@T4hznuNuBO`pbJM+85 zI~sP5@ZGogPIGnsrrT#-dvCz+z}I>ADDqiQk}aR@%i+&T$v{5)tp#SkH4nVCR>-d7 z$1R6P(2*6`i;uE@p*(-`*9T)IOVLZl-k|%eZj3a4AAAh1p=hV0!rRx0jR|2Z{wV!O zyRsFWU)a}HQM<3m3so(+mZS>nwndtwu^pwVce8^^Lec|3)P0TF#+hO){ z1#>6k)4D)+rmTn7=xM-mlUyk@kVF4-J`_j zvrdQ^8NlX}LlSQ2EJrp!EF zib-bjXYrYVYl`3!b+&3`G7V{GhwxwZGlCQ6@NFl6tU6X&S*N_NN}n;)Lg_xJ?t z`oud9ZH{DYeWTV=kag=yPW39LHD6-Nx(Oa1Sclzw{_Y2^- zaj&;>n|?dstz4+TuX!um*i(SsuTXo=&$e}{PYs8i*zjv$>3KABRxGv@2pJE;wtMrBI&%2PEw$FdC;ZR_#nf*!!y7z{+KN2oNBjDnqhUUTcL!;m# zGz=HE&zTKdbKt`LJ~A#Y^4HWBKQ(V!FNvCt_)h~h+*?a7P-~rhvqUm$f*XC7A0nO5y7@ZeA4#`JCfmWe_GXah=zm+SP{30FThV$Z75Nw!(&gKI39)`m_TAr^FUiMKMJ@3haR zwR{ow@M7#?6~5bbp4Sq`9}ue5CJ0(cDAGC?iV0i#*ZjyGJZckgXZ7Kx?N-LV$9;1Jm2l)UC~Qx75Nei$jg=tmVh(kKm1S1U(}bssyC58 z>f`9;0-FTMC@$Y1n-hx~Qa{b};|BWU&ih5Y>s<9v(q*YLkV{=O=_z6JTK%7M?f zB7a}Fk@{aenKIRQ`@rUZXa79&`)}}l=6Cn=-C^Dh%HJyTUW4*y>W8AYg6FlBH>L9* zvR&2e3ACz?@HBKN_TwnNvCdm?$5=~*{c7b>--)g(L+2etZ|Dvr{8+Qb)_ORGuG5<5 zJo9Fm*X+N8q-M&82 ze0t4!rlEX_K_82GX-+1l!5G{bbLDd=o}hh9<&j1+Vj7x*e7{=gAwBEj89KvbW8TrB znD?3ZH9Ai+*!BfBFD%0Poju&|t!94Oi<2Ff@2-9)V3(G7p_V0_jVNLLt6J*c&>n4| zHtS!FF#r6({Izc>eX9A}^D{&HOWM;;m2+PoKD73PbkByt0e+-%;!UiEF)LR3DR9sz zI`FscJg@(Y(5(%8|B>I55n}noYP8Rj0_RD_nkp}w!M)edd|UIz9@#P5y%4i^8`w`O z&YXjPZt*|KCv|>VigSxP%i-~#jVH&<{>TRMC|+Z)*24xVdlRExG?Vr=cIPG2ij#$<_@_xYpa6yJn|`n zZMoQ^%su8cL#9o@NM+p2>LvQpDsP9*q4j7tww&8gILRY*yPLOt1lw|Kyoh~uQ-g( zYVY^;d8e<>n~z#OXkvM+^^_l@v(rQQ9MR@`evdsO?=pq{(>Z6!&s_3MjXj{xHogn$ zw|D*Hkezb%{f;Zg*xyG#=Iggt4qJUN_0Qxd<=y^o(FejsQ3gLrMh^yLG${K)9?B)l z!FPf(`sDyFzZ1}};+G8>{31S)4 znq0PA>wCq86+gceT(~`hlgulOzjM)ty_b)W_wW?wAD=S!okh|Se2huR&vNXx?uODC zv_4`4B5f_Dtx9rTbw8-^ zOkTzwG0nv{~1~PegKET_1WAj zsS1ubCvRMBIyuwvid;z)sg0ghspg>j_GpfTFtq3 zaNz#k{u%s{^_2T+B^)s08oQo2i??-vZ{$yQ%xwYhz6C z;0e)I=NlbvEIlm_)Rtm7ZM19la{V?xrZ&}%Yg6q9+r;Ot!MD@hSDoNwzvrbZ$u~)X zH_3v#Ke`**^bj||ei>h0_xni(7K3N`^72=8uffGngG+ozjalsp?jp})A1Riu)BY2G zr|_3^buIb2Q-mBI;kb96xhHP#Dd?d+_-(ZNCE8Scsh3!YzSkbC!S#c4o2GHru)-^} zwMFq=dx(kjGJoyU5DzgpSKR4h@ZLiVq!%10zSM?KD?FtK@vpOn+Are#W)56AKG6QR z_M1d^w|9*1Hk=QL*NDz;?>PBe_KxY#jVb2yfzA!cA96TXY{=o7K3Y1#+d*t^F8-U6 z*)ROZ*=2ANORr(gpjd1hF&~|QR9wsT-&F%YOolel$HcEn`72uc{G)ibc<}(a9FA{V z7r?W;-+Gt!eMdS|Xn8OJZvOq_1Dk)v!+LX%AHOwc3j4l5J_&7Re*Y@pXMXo0-@Ti6 z$MIY^0!$~rONPvtGx+^p-XBSZB;Sh7htZ$n%|*zQS<}Gx9q=i3Dt(xBg!V9%Gr8Ie zx6H!s%wpXoT9DsZZ*&p!*BbPWC(mqFP2)S^?JnR?dU@u)zDE9<`}&BvoBBS$s=klt zsB>Wtf+O*?&Z>#WS%;jjIAJ$DuJ6rxu$zz@;;HjE_a+`U@l?%s9JHJ*dKMD*^9E_E ze521oSDk-Xyx+u8$3e5%L%iM%eN)6!6@ORlusOmL?`P_T(RlsbOrG<3UHimZpD)hf z^?Bm8Rqz-6+z+oy=g;DACcRgVzyr7N-BxlIw(xv|N8@rnOR(0_$EZW;{6JT0{qOwv zz5JJBoNo@+cT-I_)pC<dzyb@=b@-d5@GZgjZv4mzi4A6oSiBh8OLg)fi3 z9BgY^?LNhn%^60&y#dBph3@LXN9jSIS9|iFoGvE0=``tlalz;Sya;p8c z$YT2O_|N&5b4R!zLcX)wxDy?uGkl`;a`?^NArgB}Om~1NSN{j{o0oI9L)_dg5;bw5 z^_>4}x+6ZWo2Q7oMJB!N&Mj+Cmp#|W_%8t;#5Gztw{b!_o@#q4zNWk5K=+Ht2OWUd z>%n6M`y=_l{0;97#=_t&jj@yaMY_0Ognhv3gV2k$e!7yiRon~L_j=)$+0a@#ZnB4d zL!WI2_Z??ri>aG!b0Br!UP1G_nzQeQFzZ9&M4j9{(&gVhV%8IUXK7^51}Rq!oz~KQ zfoIN8>hrhB8J)`7VhlRj-Fs2Ryego|`wanHqC#;UU? z;%%!3qs^~2<+vB+yrHpmUx4q!{S~gSlj0?Q4~}yvo@K{<3w%<|b8`m2xO}YQ0MBTR zlk0wn%=$Zuz19-Xis+oM=mcKGqhrS+^R)Bl)ZMxIXPk?3`W5<#r~Ak?H9QSHe#Lwp zKa-$}J{`I*ArVjXw-ypRz=v)xBnC*G#sz-OEBu(N5_gw1-Ok=>NyKZKzQMYxYvJhq$tKC( zJ&ZL*J*<4{Zs;s;CBB7hN`$|=Jl%ht--5p5Ub(xF*Y(u8iIG#T+5)lWCGd%2!)i-s zDT+3Qo1)YU%VQjxO;^aJ!1Xp)q z!@_u?DFJ?Tw_nw*?Ek|@_zs*iH+cbjN?P+97V~`r-*4hEJRGiB5| zv(8% zR=cRjvW+#8_IfQHY~SkVEZT`|Dz~|}K7L(o&phyZANZXVk*#4*hB_F*_QglCeDM~;p$H}TL@+!^hKRC{FjcrFM3m+`!h=Tb)te_ywg zd|By|#(<1DdNi5y!9)7wAhD4)$%Ic6?Ttk4jR-Y zzxQ>VblG;~CwhN*QwRC^Benl~_ML+5n^+ZoTu_HNKSMgJ0)164{Z(7mr21R6=p*SO zr)L~L*D>}KZI7o0g!D}9S&H+Je=gmRE>vFDesq5mYq(DQ-kt1;NS^DE=hfh%F#{KB zuf-p0V}}0G-3vw7s2F29!kF9~4vaFk9|gvCAl~=*Hh65LKED&_bI2xHJlCOrD?P8i z&eVp)2WK9Nt(vZG}(mCn^7wS7iM-I`$%AxpEINv}UYrzG$X%L=Nk0mlu?`xeD#EFG_ zkv7e|d|R|jbHWz=#|ZN|1|5UM{0Lm_vlN%c)-)hNj{5qN$blV0-_TG4}uCkNx?; z*yR&BeWbI=!TVJeGt?ON5>r+jQ2xw1^3i4EZiaR-WGl|OB>5@Sy*9kO6P>aS+p-Q@ zcMSW}3{P)luSqe+$;8*YYl;2w@0R?;y*0!JFJ&!qC>Q|{}=Y$hV*<8pEmZc`D(6`SK|xvH;&w@wyfg+_+c%F zcsj5#+6!32SXAe?4H}yD5Ipt|2kkit;WH0;orI5;2d(96YMnSh`%@XC_8{`simr;Q zQ*)ii($f5cF%_2k#kQhIjXS@$_N*ssY}wj?u<_}iF3=sgR^JuE{Iq$dj-(H4vneDcjtV*;;6P}q5S`gpyN8^{sH(rF5dg5 z)ww~t`7_3%e2Aa&JkRqS&$B#^hHd_RUmJWnwsGIrSADs4^18wJzeDwWTZy-ng=(K& zi!bExxOAM>SFEd$SH+py<%_X~Zk<4U2Kl!BjCd^8{04E*M1;K?*24$z2|~=Hi5)p{F8$BO?{m~{`G73>OJ}~ z?_j$We{I7b6YgT#kC@`E+>RWSlItOzc@gy`%=e6A5ua0>6E(3S+E*@(Vrk@t@cqzT zV#AuWGqH|lKZoeiOb2U)cDL^F>uO_S3G$F?S(d#p|f;eHkz|t*?Gag zH_(pS&=@pl^{Kw(56WL-T=%>2m9X{-jxoBnyvgu7_jfI2z0e3;8GInUc|H7$@Ad)h zk`8ytCIuOZ@2W`Nn1@8loVy8OtS&!u>o5IA}6{ng&%v5?d%Ka`{Ga-A^DN5dJjp@VAb8Cm!%T)_R=dz^>H*dl+3^N4rbG z1AIfBK=2H%wPzGue?G&!TcNAY-n64n)qfrRSJGY`_@jTt@AkMgV;wSlC;R_q-9^0v zyN+akA+ccDVxE{RlW5 z1K;7NK9&-nfr`u_z7*UafNr9vV%?t;ey?G_8Tko; z*OE}EJ_LSoZOCBA3)QznH}*i% z$NC0zmf-vdcV)XH83NLF>{>_bM^{JMe*Y+A{2tF9o+5lGw?`+Nw_G&z_eFBmhnow|gYh`6Sq?ED z{+e+qS3_%@7ldPQ-*(uW!TEsA)Hr+0#NG}kX6Wu+t#h>ATZ4R*X^q2Y)U40L)ic0-PCxK{jc=xzc~w|{T=u_Mcq-| zVbl%mVPJQHcllupW#4BjpT+6}XUmq*=4$p_$5Z2EF?^|gyZnh|P4az@vJW^0-SmHX zI}`Y-s%!tBn|m`cMnOSQlMs+WTeVst#Wptyqu4^9^x4+72?Qh*ZROckEnotKL4r@N z*wT{sZVm4L+-gJHwf~0d_I?(bM85N z@3r=t_u6ZZg7&B#UZvU+)U_-XO-_a;Ti_+dEiHagt%Ib>?cg^|KP~X6_{C@Q3-QwR z>Oai=26#FET`I;S890Ug_$KY+pNsb8vstv?8`>}3kn2mX%7yk>i;U1d{4U(Cw)?At z?$z$8Y2T{f6`_46j%D5d06etwY2zbXQ8jdx|7+D&O|0C3?4l-iS50;4;9l7Ojt*8l zff(eojmR%+y)2>fiNB(Qp#|x9jZgVP_0qxMLhB>B@!aFvt%Dy}YV%qDV8;(U^3pn6 zAetVvr&zI#mzZH%M)nR13_LI5WEL!VpBc?FJ;iWvz{%XM&lI^H_n>J~JrKgb><+Kq*KhHeT`ON9`X~3r%uj=UcLJsMSn)^TQd}gr5ZSt9I zM+3X~O!izc`dj+f&2QUSD|GnHl1&!#?85urd>MI!xOJ8iSWc% z`Azmi+cIlE=d0tSbI$MD&lzaQ&fKa!+jlbu_=bV7FH`I0!htpBZycBHCpi!pIKlK6 z=7XbztI2KaZ#ItE@2Qc3z4&f=;%?(-bSpj@To5~v!9gKh;Ub;G3=D&0uv;hsO?%B)N2}h}w zSE~=+TRwOKb%EonQn5vk5ZkhB*CC-f#1K}-7-tr1V(xJ^DWxm8Ry?v_q&|4TUIbmodcq=D%PNw&2r*-*1h1at?9<~`_{x&xOwd* zbk}RVpAMXRInQm=Wy}F|8(^-sUP@g8bM85t7Jf?jk6bgpBv{G=lUnb%P$tshv8G)|-J9TOh%KRr>g5Wc^!R zutxQHg0)JA@1CPivu*znbNxIX+zxlc3U1$Fp2R!yH3X~b1vT6%IS^lYcsKRp#{lE4 z*aVVGj6WT@utYrE@RF`R%#CYmxMHj;+ajz2g_!(=IvSF*~2*s$`g?P zCf=F3%9K581q-_W%XL%XdHS7+45|PoUCZ~Ig&eA795WkynR)Pf7=7U$Q$sFvI2XGZ zno_&@;CdtQIdglPiR%bmsgIEG%d^hAY2bFY2e&i9?MlWmoH5Lafm`H@S)XB(>N9Li z4jJ=V|A+3r$ma`uuHEwEY+M@OFe=0Ik`7uFo6i3`xagfW& zp?rrJ@m}U$HQ?(+bI2yiC^JsN^+e=`?mKbmh&^NX$=XMlISwNedum&I^{ux3QEh8n zPP=AqrEPaw^K7?&8gReCe3^J&X~(qeJ(SCZZ~5Whv+3#co^zj)J>3wUAFn zZN^k;Gp0hnYQu>q`SDwiu%?*dhrh6O_H*`nLE6X=xb6u17wuZYx##I=Uo+b|+IVt3 z_m34Amzuvj?G%V#;3dgq#_;CMTeCaDw5fYT$wNx-&Dtlvsea49)knMc?b5sXZ^w0{ zv2>|(D$MKpxQ-PCo$K|t_)svGkw-G~|UQ%?8G(vCX`NI-p~q7iiuXL!J2W*FvRbC)dw}4-Mv! zXO1o0FB(w}htWC@-#;zIC{+zctqp3QiUX_Q{wKQ9fwk_%U}iWm*Uka7;z-W3ZvM9D z-}+s#9Ie;9hMlE-uSL{#!sef$+TZE$F>8ladM1ZF2<>$ZC;E~7!Chf+?d@NEYYgu8 zo&LSg#v1EK(1zye$E$R{AUX;e;%!gbM1S`=~_G1^)rnhS*=)CjSB{6{P&yVcdaMF!|9y+l3tu{^6=2s zlk3CCVN;J&qxOjeCDy0C*~CE(_hfd6_yPiIi_b-9RhS+@O@KViUi6Al|v9+&b?A zxUk0}flrQ`9In??-*Y1ng`mm;IVV$ zY<{n_#~6$L+V=M|>lKVbR(`lYCAwY#H3I74om6T#yFcO$VjRzg`MuWvGm{SXLi2lx zd%Evwjt1HAHt<|rO34PSe>Q~CAO?J(Pw=cz6;&Y0t z;|Ck}mYkSe6Buh`>fOK%d?y882&5P<1gi8;s!68v=LI@13#xRXla~MqiuuxHJ-{+}?+J*g+#xwIUM6>zf>X zbYf39*7y@A8|L}gE%sSs@{#7F;{w#&^#x=1>%h#q$6 z>*8kskD^nk{;SP#?(WPyo=96o3oRGnREICGqV^Rr|Z zKaDPoHHO)P!7XFRFNV(c6yM+(%UUBXx?KZ%8=LZ2IyzGA#yjnTFJQ`KjfOchzwhPy z-r1Vp6s|SD1Nqkc4(40)dx`m6ht`I8mb2En;bN_^(H{jDGAX2WZP9uWG>`6?F^n@_ zN(#4()qDon$8N=X;wqQncj;Vs>4^rvwU7T5=3lnIaHzeeM?U@3+^;s1{>QO*q$~Cf z-#4+=X2z13E9M1U1dxMThjiz+Su%K9cIWzU@G<%pSFd3$UvkLN%j2nGDm&(1ZFwYm z?K*D}dWkxJ85x;{PR_!A$yyZ0$ImCgC%z&JKRGLyaC{kk)UP*QsJl5X^nJhauz^1* z-_EQP%h*iWXy$rbX9G%BkLh&k_SpBZvm_@kwa50h%Vu$pPv-==#}_6p^eb$jj9%z~ zN$5J_G}3#W=si&;Wwdh7O_e6T%)kkK_`j6bHRo5Rs*(#Vv>j9d&QlHlS8`= z-;KDanKPq%dPe<}Lr=?~Bhkb>=plN3Eqt94zc$W4|9h;vB0nuzV)ge4wD(Uw9ehk$ zvUU7Z)Q^-uz1}llrax`2XGi=$RiNe!@$SrsNv30*|U5MW-c-J28apOaXUT}C^ zK0+Ecp<+)<4QzOuCKM--{?$6>o!Sa(w)dzmwS4=?c-~yNEVXMqRz73ooq@cw(SdcWuPq-~v!3yc>sK+x z)g?nL9GJM`JeF>_L54Vabn_hgA>Q~)d(Mahw!tGaQuC>oukY_g`c@n-bxq`2_)k4% z_2Jm{pY^-LKZi~JjL?Ha3yKM+vyLX3Of$0nvd^Z^NWPB+Uzh9zw+|w>E&8?R-v#5< zE*NWp@m%!3h%-eVZX8qmLf2e$<(Wi$ZHLxgS6_B)JJ$Z;3J*P`-ou%h@RQ_r z!3R?_Gt78q@k5SmM9&_}>9XUkd8e*?k96>atN;2K@@bxF4@BTqTmc*XSV?go{QbDm z5f}rN4<;H9=dkZ35B_=u9!Y2H$eA}6`p264eiT#9`?gib(7eBYjCo({{dw>YGS_;3 znRQ?5YNGeHTRYdwrzl4L8`u)!FUNmz%V$#u*yj@7X7si+$-A{=zKf13RK+Vtn zz~j)Lshf#2X^tE{-ffQhTYf`-2PWifPxZ|zJn)JB>IT^~xTkNZk-h|@;I-l@@s*rY zdQRwY+L_Fo4dY+#iSTz-4E{PLd`7>R@O?xK`dskYpw9u{Z$1OQ!>0Z1im&7A)y+3v z*z-@$k27_cbw1@xJ10)-hy}!iwNFQTcf#00(Da)n#o%Io5SgldoYvkW&G)~>#>LJa zWDfiJW6GSJ3q3ODKfl{0bKdNdIifZ6szuLEKDeLpIR1!r@BdIED^J5+F>8@>Ar9C%{L zBVyG3yTKCxo*v|pId?JSkvVrU7@xmYvj`q5A)D}laVT*L>DC4|P<6XfQ>TMmRbx85itATST>qdj8 zn6i%bU*&2=>nh20cl^xKS8c5McHv8QONMo}_YMbV%Dc%3zTmB&YWhRuBDvQ~6vv%K z3@1Gs`5V5_T!%E}OYppIHYxE^O9y~Mk4<{kI-ud(9=w2$Q`)329~Jj8 zW!Rqc$Y}xZrVWq&cW{MW^goTOUwd%{-I%z7-|C3ZSTy90L%M0mx}Wc+A#mx`3A@mR zPt^(Y)@Pn#=F3InQ@9jP$9V9$iCDga&t-|M9e~R)I21mw^gNsH!u>MshZF9>opOGa zD`c+g$*wfc51H!%E}Qc2tcS#~ug_YSXMyKh`oWLw!KQR@?6xU;zUK$$aUQ%&C%izu zv=jgI?mw)EVUPd)<7m3gcFDP1N6xuz@gO+!*r#jH5zg9g`7E6EY=^(d`n@C9gqOQ) zy!^psf5)^}i_h;Ss|tbdT-mEH0AoxUZrdM?H zu6<_MNjr_;z>wCER7=H*c?K%KaE@S`_L;ERy2!Q8@)znmI`REj@jowp$}V9Y@7Xl= zP#UrcffM_AXUU%KbNDpp?3^q59C)VmX~#P^xZsUtKlh-IpwSDu(da_piy@ECTIT%y zTQOu#6pcp5ALiIForykL_0ea@T;^4KcV)xMPgJc2i=Hnq=~;4j+&O~(|HOhH`J?>M zXn)adhg!6K+ro&lfA@k=*IQ%w_DYaNC8!lI)5% z$dQmfl&$+K`i!}r=<#3w0iUy$xYOA`@-{gKv)MZ__UBgKny&M2@oB#Vx-HqEs4 zjN;5V@S^b*un%SnbLFQdTDDRDTk3X|Dp$?CX5Wjhv+aE^+3b6{{@2c$w3VZ;xg1X4 zJ>}!y?}_J{y29ww%|E*bUNLnAwB?B(oV6`;{|t{U1CGu30d%0eCO4frIWZ~X32^1a z+ubp^TJ}lQOV6{GUPsQA^m+^X^HKxGHL7LimQlz|^WSH^|5xk%r+R)r5`(BMjjO0% z=dWli3sflI+{*Wft323k43=-M0l0I_dV2r)Zewu2AF**_!M~?l-)KyD`y?c)T zg)M^rMc#NlYeKSD8m-to`)>z^nzc9WD*R66^qMjH#L6GXpeY9j`H4YFWk&L!931Gr zJO9bSf$lr`PfvjZJ8rzTD@KCsjjYjIXX`jQu!@d+_VPRc#iynueXlxTQCS zog-Y`77JI{eTwm0JYnOCb+Aa@hIQ|%dn|je3y=6`qN7G?F1mHdQ{eB5-{~{Z(Y^Zq zg6H~5&-E3a>(QR;QJ(9Op6d~w>&rdYuY5atJn5d_(>&Lyp6kmz*Oz*(FY#Ot@myc* zxxUDAJ=k-d?-}m^&+q4ZuFvyar+BXWd#?L=uKRkf`*^NNSj$DZqdc&%??c%pp8MxHadL3J=GwMTe&78}r7hIfaBPQP^mYqMJ<;kDV z^5oBF+4=Lz2Lb>YqGtv;Wg*e~YL6xpw=9UHa1Ew+KBd zFW;m|@F$$sLo>6AjgYgqACe!A1Y)IA^X>eKdjA9jI;G#WZ%v;w0EqB`I~3*TO=O!BlM|a(6T=^v(F`G z`e=NGqFrXJCVy1u2c_(FW!yeuDb78`5zS}6Z9V%fp7AX%u8#T4Z?6m0gHnzv?FQDgwyRO!A!CzNr-?bz5b4k{7m+LvBw7NEFcl##a zo^H>^vBx|oe~j7Y5cAoGt9^sEx9=^qo;Bxy{Z+`?FAFb<{k%<1e1281Bk!l!b=*s* z=8NvltTODmvf|H@wFmCt{7B}?jGI71vY%xC?m>>L7FZTBu_NTotL~1@r6{L<+ZF7& zKFo89fthk*f4W&COlQ0~`9I20YM?fp_NR=Z_CIGrw%&*g`C;L_v8{aGqu$;T&gs-S z6b9|x=v%mJzCN5Mc@v*aFr(ZRB$leslXzNFHm<#NvZx&5XI(NGxqmN-Q1i zOD?@JxmRgvb6It3#Z&FA)qifEI_p^boW-;U&PPzgwvZaOf@?GQ(;2?a*HS|hyS(uh z!FOL5eCG+iTYzs2^$D+~CMJGOMv4t5H4MD4)(hq&Bh$Z^?>^D67ZGD>}%1w z16Lt%_<=(-t9lTPlfd)MM#oC(_DODzWd07b_cFO~_e}AC*N-Q4Z)ffC#J7cdk3xzKC~O%xR)_Z#8z9z+M`9w zmQTdKrj90PM)R=p#P`;9GVX)$*(yHG#;Og?d8+H?YZY$gGurdBQ~bFQ+}4Ay=Dh4o z@n<5uIgCA&K4MF%fx3H`Vb+`2!ko<b?{)CckiN|BXE~!#ys^G3?uC zGH5rz+CuprV?jPNvIH0!>95>~pO3zsVdc;HE8D=!^Q<+;!r>hr9Lm2l<v>dS0`cHaS?2ez*G+YazqD4gyDr_+3y2ay#?Pd{9}>Mz^d zN48Y8Cw;N4y?N0qWrt6EucrCL_iOZb68dX0`s?yvCzY8pi{IvMO)A^Z9Gd%y6Blz1 z347BRgXpxGxtARt;@IH=bc8yc?x8o_)1sCmsGF(;^Xa!h3UT?@%RJPJN~q-{ovKcZ*P^K z8lAm)IO*_C2tcXZ%sP+8GSa=PZML7 z46S_-xr)AT=ACdUHYBoB=kiR4SL@~mJ2a+nskv4+upSvK9JH}-b2$23unl#?X6bCR zpYN$38>SV&lTes`j|qRC)kgR<+L+o?8_0i+{ygM)lN&=AJel zKaDm9$7rKzS+HaOPlDKlvISEs%TA-6gc$AAt~1Yncoti8=WT-~GUo}Eq5;;Xxct@j<~!bl zE{7i=-jm5Oj`JFyR9bC!%EQ;^vU={cX718 zjP|$C{(jo8r+q)|*Wa0I#vPunwB!tj*E}=u`DU z7{iQ4X!JbBQY-$4?nSeTIXLi`v}nrF_{wJ)kL0R&*{rt$57fdBYPW$g)ibt6#<&Ih zBO*Kfm8EyVuRJpoT`b;^zI6Il3^}XVI%h)izcJTu=^f-uoqypj#dtI(w|-tHyf6lx zKQ#imy@R?=um77%NB0L8_(02l7>PS9wQH`L`gy;lpITrfHl(m?eIE$%?vzVGVi&@|G|9sZ~U~QQM%^*9?Ix)y>z1#3( z&gkr_C!=-Fq{;!nQ47!2A&2VcGgm)GZcei5%7>>S)0p38=DeO-^5Q4je%K9b{dcDB+a)A}wpdY_3as@II>{=|dreO5HJ7jeIp`#1V}ub!H? zqWaN^kGA)2I?z5fv8g?kvkzMMEo-l_;4t4KeMze)^Ld4{(-hC&dZDw&%CZ|fvEOR3 z#{PKQ=&0Kbyo0#j4^FU0 zhGCDqggx>S_DBi7Ln`)o2{uW?$A)>9a{~Ul&eC(vqIefPxAJ)}H@o9VR(8kx)UrJ~ zHM`^3r0kCKq2oG#e`v24wAY(8mgLIj9r$woKAb1fukziRU`Ol0V8?s#`w@8jePrR$ zCxacw`e!qyY{mpFzY*+sa-;Fgdmk7bM~)gD?`QZrTAt^uiz=hz-5P`UnX@;bwMWs{ zJ2ZDG*pmG#4_ybHKMkz?f%{2nmLq?{A25Fz*&U5Xb>1WA8u7gync2+!PGq&@Rtvvt zZ+UnCIC+uwxK^ET$wWuqNS4q00dk7>TNt}!cr&syJb*F3$h}l>-pY8Ii5d80J2-pZ z{gqkBlDem%A7E^FGT0GrW=yG!sTCXj*Z2wb^!X07IfORA)#bz-mKwn^&ETm8d`YH; zKMr<0dlB#f z*sKp=vwj_$_3_1jDtr9o)S7fj4IasBh3AK(8K%C!(nJ(8h&Kw@Ic$`|H2uSeFJSbeTDA_ zjJ7`4@cn(G&A7~%acrh>DD`rqZPmrbjHA2p(W#}Bm(OqL>gh(&uGAk{bye~Tt-30C zcUo~9#R*ce@9HE&z_nX%SuuTV+3%wd?Rkm99r$*}FPH?I;8OpV9+nLqSGgITG`)9m z_0@$#$6f=k9|KocgR8d1)63d?=apXj>4bH6efnTk4QE(5<5@~fp$&TLX-t3o9AoP0 zw>5fvqK^+x7Oi`haXyFcyuR?uW7l06s%rE1ugYNV+++G+OV4Ba^;wMRgDu_1^bF(q zDdYKa;dNsfOXUaPqwOuD6q zagYJKu~|0in|aUIcL{Yspbf{CkX>lc{aVEoZUYv*FPmi&esm%>OB}e+-}u>W#k{*4 zS_<;r2)}Q~FMByDzN!h_?kA?BvDE(4YQua7otXxW?WV3uSntxmIS$4n{ngVr#xoAR zFP~R&jmEd&U+Cv@=x6()iDldIhlGa%Cze+oWDHG737qAYVD`V4{?CSP@u>%O-fy&B zQ9xYp6U&B=)P0xy<7__gd^UaDK_7MKgniUHdlEY|)#z{fc8U`qo6Wh<+*H<(Y!?}>~}yxhe%9)5lL)ck6W z-J!?o@16Nry889Kqcx$Qe zDqCP$z!-RpxbU$z{F$@3H-c+?Nb-tChwhxj`A~8FTa!zS8U4@iUwSk-Sn-jcy6wr) z?W;Dc_nlFT?RT<+6)dO>JbH5=X8*7Ao7sPP!0f+R?W60mCpmg4(X{m)dU}mL<7Z0S zZk_a?@N*G5m6&7ad4aZk_NRAV5NJaV&G>O&BXs9OCDm6?+10*w*sJZ+A8Ke%x$lj# zqrQ}?{t44c?@XRndh}#YP5*xVtB&$)=S4=_JYa`jL#g+1p20rj_>aMD2t0mGFoV~l zSH+odUC6bGqh%wz@OpS=1g{No#=y_he;4lE?S3bM`w;COdpfS$_@3jwIlfM7e1%+F zI#>9P=#w9vCBCl}zAp{56_Xd=nI33cLfkBMlwqE==IGab(KbjFzwW#B%;wuY*H1$K z$MDUsF1!k#ST;^l6fKQ8OW01FK!$g<8^KxnS+?60)voqVS~{oqS+@Ho?RI`SrS0}h zn2S+KZGSqC@2>~i_VnZXoA3gA#dUw?71aI<`PxRau68Unxnt7a_GZ>2o3ULE8muv%n7FR_ z#EPm_eW(-stE4i~k(=+rJy3Pv!~1Fu1QJTQS0%hOC*HTK-dM50f;mFp zH=pI$26w^SHwu?uJIFJJ$3h4wtIudote@WCO+KxRrmP&MUDUaXW7rtuJNng z$oMCnWxIW7_w&iD$QUj^%Q3Y6vvW|Rzb`tz4}N7o;^2omNG>!&Ijr$CvSz+*gKmrSbj1@FBEfA)jqV5Vf#wY`i?@k%2x(cgeG#YW3mx8A_#BXU44_~A8UkNO8z zK1j~OH3`O{56BsLnw*gj7uS?M{Aq5@FX9H4KHKYp(xbi$sl2yVB3{sR;K^D1wp9nl4G+t_qb;Omht9&0>vx=`%bO&8CDx34gUv&Fym zeGBg+Yo#`LyT#LoK+;o1==YZ$5=)8B&bYAxD$UL4lo%cR{I&bK( z=)9J_g<(D)lEd`jysO7H`v+96gw9i-^ET-GDd_xzMTg4ng3f;uH=y+4UIR;;je%9E zHl4F)b)aJUb@0q1CazN||D-m-`D=Vf(RvxQp6Kh5)_(xZCaouW;3!kM)n z@K?LeEHl^3{8oNWat7;V{^rn`WDA92!?EXV9hd;TaOTvc@M1QL9 zp2paEGi#@k`HZb$4C^-FNaw>Io@>02bw4?Cw4JrUw_q>hOCS$ec@%YwPdP?r9tHh5 zI%Ore7p?{W;km^xsBNvsDVNKE|21M7Ygkjcy>Rqc=`a1=L|j8P0@SwjRjjt}jM29B zZgl&v(EdDfMDK@2edc(r+(RoTDl*pZ+3jg;!+2*7xs}R4v(^Xwl?L+Iv|s#I-oe-7 zjYEZnUl^-g=oZ#c%bAlq$b~;V4mw%r%j}h`@%L(-#rRvMLAxe?dI5V;7k;wnvtf*j zP8LEZ)1Z-Yq6=tu-1aGIuXA+OA}t5`D&)cUf<(-xAlASEhWAX68-xysdHe zHP_FadAz_g&X%c>F=|Zj-<_T5^nJvh-)3NO=9rBRhknHT@=b1Q;n<|U!G~Jr6*9(N zt@}ReoYBJgn(y*uMsO6F7uB1H!qHvei2fWLsej?fO;gH$cj%}$e@`G!VmzN*K8}3S z8vZBbATq{^TGj{L@&~@_CVwP*;)ZcP5Oo=iKz7A{l1mqc=ar{W2R{YL^?5`0kQ236 zIQMcZUemq1xHrPNms9aF_t@`nENA+~p)J_Ab^Q}6>W~fEA6nNxv4Y>$=G?CH@yNUE zpH$I+EYaE94TF*^UgEcs>js4y`*U{bpk5UXck$Z@{nnfFDEd@{6Z@KHK-a?SvMFTa z9jc3_U5$S$W6-!K3?5&xnK1}|;(O6NHp#Jc#&%16^n2k%Nnvw zi+IQb_)j`_ZQCAl>@Nw{yfcim(4a-_nff#GBZ2d}4)EJSe(U77dGJUadyj}GQ!^yp zJkukxE^qAt*v7L%;mam}T-AXSMe7b2!H0avVeejnZI=|)Y)c|fpXWIry%c@ndZ#Gn zJE!W`u>T1@)vu9^(z!{hD^8BmGvk4?yqGiIfNKe{a-H+0>ug{WKGJx8Cb{Io2j|$& z_i=wVaIiPJY8!BD0}f*KjM3Kx*92^JgRwV3yUy5V^$%7k2grNvod2ZX3TTJg163uo zTSB`zMxb;VV{*o*eHj{)^6b?o^WF8V!L#o1>si$?XoP-VVjSWR)o*>(tOpa>?-Pcu zMep_3f**Jxi}le>*zXC{bx9(xDA~>{x{;isUgQ*2l2bIAoT7K&{U+rOkvCLH-cT>{ zhHfx8c&oANJ80x~9XLwypofX!BW|K>}@mlJ}P@~y87somWtKmz=!~3_xHihwNHUp6Ar|9NISfAg7UB2|M2?QX5KjCd5B~Cnr*Jr;fa>N^mO@v*b^* znJ+YDybt-)J1SqO;coJT7GlTVMefUdZ1^8zo6aQ_Zv*x%+8(U4C>mq z$%*M?4m3Vwb6qF;u!ub@j~LI)y#bjtnDw;p69a;#*065Z$=d!+DSWdBaWCs^b^D?7 zK1Le{D$YnWg7-eMdUbWDku=|@YtCEj?BiP?9vIWdICR`UsA_O$(YnEj!H1xYH-83g z3^oRrO4c@m*Os~LYubW7J&1icAH9!n{NAJBe;GL_w`OM^WpBch;a&gj8~?S6$9QU=5zA^^8 z^hu!Tp?GZa1oowsu%B%u@ww(ZM~7ZSey~5m!UcIWd!^^$jdP8Qn>9Z8t4rSng68-F z=rLz(D;VPe#`uuN2oIa{#r)0Gd<{GIV;t?8FLdjSM9v}o-LS9wXdNGom`78A)V#u2$#R8IrQCpE4D-#Hh_)q{A)QgtawOh=T*kTrf+p?uwyxP1$hJ5=H)0)X|ZI(~;TVsJw_wbWC_ZSPdFb|y##)3k= zwNK!OiHhr-zWw0szb8HWDLFHpN!UKf$Id{YEr>kO^~{N8J{0HXj57Cyk7M7?tZ@^} z_=Lt`@j*gm!LiQu=P`HA{0cXxvTM4J%hLDez8l-V`ESOg{*=EpXGpNZ?0ahWzQ><~ zzJDitqy*aT5IzRl_`o)-{i=-*zY8B%bRSbHV{+r-v+TUn!N)Hge2^d2g^wZK`!>!& z-#?@8BJfd%?-e*<$-4$*P9t+AKPeF&>AccttAI|oB1e}KN7(jeU-SGu=eaK15ZQJn zv|-W&zKP_Ad{W62;r5%FqpMTe{zUG0=S_jOHOl=N7HE4wbH??OtKqQ}qm9kk7LO5k zFnNr)gKCdRu97UjyR4fSAs)A?O$YX0OOv`e)&iY;J$~NkSB^)G~;7w z-@=*Fie;Bu_|bj{@wj{h;V4P-mPSq>u?KRcyJ8gh1Tz%tjEn<4Ozc5*#KhOy>!nzM z^SAVX^LG(A79Ci;A29QWyU{xDpRPT~Qu=5anRF;Mp0#*rL2Dt>gV<-4tMMnrS4%Eb-fC)P z68Yh>GtB+rjCWny@(m^McgZ5^WH8=eaP5q*n@*%XuTSg9I?L}9KMyeX>eTu#FnL_| z!*@0RLGp-(!poES9?16$@-k$4j`$1c9( zvV6Vf%dW4-PuDt2Z}_7Zxkp|1xz~XI9?yN`j5$v8S?={Xhs4~D!n+QBy6h-yDy@rj z`TfX1H@-zf{e-V?|L@_a9k_bL54k~IcyQx~9Hp-N|I7F}V9T%5jk|Q81J$tY9-r7r zyG78aGvd)akt21piOL=M2{%H{ZaPUP(-N9R3k8 zL&XyR>vHO@6IaVIg1hptdy$!+4MR;N{?!dXD{YxMj`r!>l$-3?`i42b_+$KD%WvZt zlY5M5p6g4v?i`!ab|rRh5#O4>*U3pvz1k(`icLB9W|y4fx<~wfPC1t*`a#aA|0mQx z^W4B3HAdv@n>stfwEFNjf7@Ph*~{(sx3;w> zFHSD&_13cL$~*tiKDyUi?XPVKw@;xSk=7!5J+ZR-#@gSt|8U^z?Uj>i+mQ{ehgrLR zm-VeHmaN{uKH_(m;0p;SlC|=sH~WjLWuvsdRlcFO(VKcvz44W$yR2GQ&fmSUZHk_X zZ%c-z!*^wOFy?3FKdB~`*2JCXCVY)p@XhG=9~0-}xj&GrP=B-H-C7&)S2c2PH~#44 z@3Kd19KL@mbtifiSEr9-A9Asub++KHV=tdvUq3&)!%v<0PlEW$)bLtH4X?(z_=&AS z_UE!6LUohu>)}I;3hs3@j${AiKwm{FwLMa)?a@d+(6)htLKA^8elYdKZMbqd%eo+K zKuEgTtOKW9I_eB+-Di{Ec=pQllIk@no7-3tY7Pr zmrFNXv8Zp^{IB~D&F2}#^!o67Y4X(4IN^K?Yo3Y6pJ%Kv4Vb)wWUS>RdI4{_d6>mI6Ew~xs0%)+G@eFq4 z&bh21g|+wRQGDTL=wEd3^z)2|KiQHL%1@-8J$Y+;jN{J*j{l%0{@8=n!~gNS_HDjB z?Mv|O0^#3O4{N860M{9;jfEGYL$$s^`=!(!*h_r|{2cf@DU{24l4P_#b%U}ya@dnT zf%-2KX*ZE}N6_vL+R<8@o>BjEkZX;!c^l(AU|bc_+TG@q@(mi3H9qUS56NLGpT?45 z_4HqajNvS%84ZkQId*mvHa73im;7JOy4vpPz6I(0y@a^zEMouo)STTrBy=fjg?{$0 z=PxqK)J}Mvm5X5IlURD;PdC^y$cA-1G!aI|EAK%vzLxhCb9{ugow{|w4r71?dp&UH z0{0x?zU?#Mo&(I=fLZ&{<4yx+?;JMYo!#;OQiumu9I8pOek&Xitat@_i0k-4jh}hTVBWScZ&UDVug@ekRL^Yi0dyhUg`HFZE)f6^A|=sl_k>JZ%S0+dO#M=FkUYx@}Mo^ugFI zIAYU>#-K52@3#J4iY}LKlrA)DLf{WMy7HTzxqemoDlz7I4t56XC+K zYs?xHE?o3;aADgvE5Jo8S@_8L$iivJ!cLoah#NWhJUPzhom*ue1v`Ffw_({v?9GQx zRw1LjHqoQ|qh#otQ8rPhB|}H6K9p=CuDfLDw1^DleB_S!OH7;UP5bPgFE=umP1CYX zo9be{Z(p1555}f?TJJ~MPH*;PJ9+V^noO$o;K2Sz2X-5O*Y|+GLfHl2rZ)2@xvv~KO%c*Xb9S(DnMUwgCB!JY)u_VkT2jybmHH)G&= zzhiqQW_L8_vL5fx?$DakUsR7t_~yRw<@`1x!)Mu}w^+Due2{ggfpHa$HxoY`=*Koa zJ$&}Mv~ztjpCmqsd=mJ?^9k_r^NHi*n?nMK>owE;QJ_e1E!xl_~yQeZ~M0{eCOKuUTEVx>&)=o_+z7^iF!D~qvF1*>#$8W zN6E#Z?X3IabnR+Ewu1$c?GUjVHUy4KLjr4zBDuxThPgET1Tee6a9T=>AFQ zek%C-gtmWd$!8}w&D-|juh{Zr0P=)qXL{S_o@7cAd#e@aS58vh$HW#Ub)SoeV$4Oo zL;IiDbMY~DD`V6e#l_Bi@LSh>@Vn+i&x{&iNUi{jXI>^*^U?^M$)_n>CSB6Gej=X< ze8%(XTKm`%V_gh=&{I4}F?RQw$A@MfmSoAm%JbOESz_i&2U?X|IlOT8*lomVlrKrW z)CHrI$5&2H{I#49$-1U;7w;X(+U82;t6cTy;#;?1Q|-VO+`;~RE(t3c5>bnYZl+j&ac2O9hID1nneAv;nXoU+r<_fO}m}cta}Z(k_zXJr8e%N zWS+?yPR=O%opxZ86kVCrR?F|5Jin1|(@tWYT5XH}^!_URlNQ5Q)pBCh>U1NKr6Bs? zcfl7Gs|L?Ku4(64IapR4#mR#*T=7_Ps*o#>vj=Y!xRFeu7H7vYdo5cvw+~}G#Z!BB zH*2~DT<>n61~zciSHUxi`-5j^o>DYNcQHp*)*LDB_u=!@J!K!v!|ap!$Um^^*%QC8 z*P$=?Tre3cgwH?ubnuZ~=ibMmyu~NU#dh)*bv{nbN74H?lplJS zJ-?s!kDTWa9%SXp&4-t@4%f<@#u>pE3X&eEE?8aJj*U5A^R}g8P1p5auEVUE)#8I% zIhWQM5WIJ9JwAcn$p#;B_yl?Q1hur8jz0hnt8!Sg$srd=wV3fGpCwOummfOFCm++m zZ-~)mX5M4Q0*xW>8Eb!opbZ6CdR8+tLQ=3+0yZh(>u02@Z|%}ES}M`apZmp-aWJ}9M+G% zzk1o|M@$^1L*ub-I$3Dvwi^6%@`vY=} zOCG8&z2rziF8PeKFIl2ITiMpJ?kg5q#GF%;e8zCY-<2zJ8+jtEIhbS2@EOnU=9#_p z+mMKU=}nA$2p?qm13$CQ@9@G~J0yr5i45qC3?TPZF?a7X>`SY1DpVd zCmF|bbPTl_GxhsW+6xm)+nXty>T71)_jS&t*#Zs3TNKjN^QSBmd_%e z2U-8+`dh4;#P5C&Tb92w{Ol#5#&`A&tifzNpSJ9{?y|h&pC;Nht$D7NwqygD=Lukw zG-PFGHV!nNF<~H2Xd^HPmU`tUqfZ{;Te?kr5yyDh6A&tbro|Jlz!S;nP}TXA4i!%v zh9~ZaCpy``B%W~23uu;(geQ{V338jec;YsALhWl`nCeU_jxL_?-q&2H-;viDGYX;6 zLUeBhyg`loQt{H|1C3|zXO0udVS5KSmeBX*>=Sy|pHOupYppjD?@RLaGUG~49ZSJv zr2OczS*&xaW~$oJwSHH8-Fr{{XM>;716j-9nFv0TTUXOx{Jes(@1pbbUa;hj+irRV zSzQE9^p3vkhZ)aI9*G@+pV52;w0$i)o3()ORmRCCcxJYdTs8Yd$?6x`!!i?{9qMn1~j`hV2tq_#~wmQx84tZdHasD`d-@uzaVp?`Q@_ieedj% zUtE5>o0swDs~jIN`u@UQn=zP6oV zUwhA@uhnPR*Y>mM>&Iu@L2k9 zFAkY|=}b9I-YDzq+giR7YN9?+$$xQ<0X`)0Q>_n1(Ye-DbCCt(*)R1Y#+cS09U>hu zz*iB(7i~ZXIZi7cBMfEKmIpGZO^7C6iUEjv@ zmR?C{{UJQw2RZrxdgXES%5L<^BiIzZI}^toLAN}BZpriYHDy#Qwu5}XZy?uSPwrRx zCtsh^HN4kPc9*YT*E5HC#NDq4n>jqq=y=Lco*25K z4&6~pY(p_rE3Qi&tNGOWZR~zd#`v*T4U+38TjykKrzWFwPKIj6{Qea0jPkw%E$f{p zPw~zr_B$(B*H%B~IXTmaNl`m^1hs=3A5JKnNbTSQ)DB)j?cj;j4%S&j`#JAIXI;$U zcdchSHBGlII>1>N6KZrml2cPv@gdEF#qkc2`Of_5`9DFp% z!ofu9rqFPcYee>^;>c?>&ahGY;z0i6&llQ)`N#}2H4k{_#M5w zAA6nH1$(%=kLM6=XJCJhXRo^I(_6e~)o5>|rmN2SxNXq{>Sp>&51yDnU9@FYb6w|C z;~Gjk%~wUYQ%O5c8_ic) zb#DuKKZ&}xdbW{qX+4IyS-0iH%Bp(CA-s${1ulQ#!KGqj&YU*Uhf^na5N)_|;f^0P z)f<`Oz2E43ID7A-VhBz3!OV2{+wl)yCD+sKkJLdw_3%vtd?w%H!cO8&$kAr@A!Qkf zt;xh6>qa1huYk8Qh}{t<+KrE(oF(}P@r857?q;vp9{FbMXUf7Zn!@_A?z`>1`@!XK z_JQd92=h0-&3(k=J-w{IgLX_UpV$rZEsU-2%aknUyjsR(yl`@T20l{3i9w;Q(2`_s zZ3etP0@;nsE=WtZayPDU&Xu+NB*%ZX@Mil?p0*GFmUuI|q+o4YsG)z6DbG)*?UmH` zTER#3?VbzG!wsH!*vK3>vQXd7d`tn4?)i`$laKhx*Q~v?+FwrG#f)7QoBL|}haN~n zF2=E!ww`zkG+l>Hy7NELfA~0xQSSj3WAd<2I_v!JqF>>~XS(}gJ>w@P&fMq!II#7% zVwG-q@Hv1da{i&>JC7l6*52gMZuW8WtcCtYM=AGeX~QslBpw!*7R*GxU4P}*o&K!YN)UN(6eomcg2VKUy+SQF*z;z$ z|EAskyPo!^K-Zu4AJTP}1^!4!P`*ZSz>IbM{2^=<_#zCS?uK_{2hSpxD5tnjD3|97 zc&-6DRlfK^;2mNVXUaaAg&jT%zqO9v_Pi1ZF`*C?Q#CK z2YPzd#7E>jOyL86?)q@5>?mL5FU>J3XP&j))L_S_UGrj{Ga7{_>piJ#?=7+f$)s=vQk`?1_9hoanEp6F$)= zwfo6K_?2_!t%V1j5xse=u`{Rm($KW+t}{{Xz285u$EUsDZ@X~h@Y^U8Kau_Q+FSok z_q+w2c}og4AWIaR`c4;)B74n+i(I=e*;7vY_j~X!yi0Ccw2%b+{#Gmg=d1i+trOF+ zaIZ7e9M}fOfNg{eww~~x3~t@=zt{d6%?GNpps^2BTf~iIFS_|@o>_k&n%*@}nlIU5 zgnp7*#X* z)I;yH@Tau~vyr{WwcyK~{~?wwBY9@k5g>j8k4^j=e9IUfN2Vx#mx}!`Me#)X1h2?B z>v>Dw+q~nE_jf#ha{V$>-W#Futl^ou1lbuxj~M8Y=y%<5TC2Te_%ur953+E&d%8>a z4hnWW8iW2Uc^|>AU{RizSFX$sMdV7wr^ppx?@6vaHML8wtc@jCMp1*SC%FQ@Bg+y( z+3d?p58_{qi6kdq-PXBKij~yu;H(^Qt+fWu{Cy4D8{@}UlyCR|v?Sf*VKZ0u2Tgu>eb8r$DgkyCuk^Rcjt)ZVC(2z zo#(Cb@8_90Jd*%ymjl~&VB2cOo#I-5$hgATK8p20+cVOEaTXtJ=J{dZagU+gh-=k7 zXUykfJa?RGJm9t4c(~Woc;Zh3KRw|l54z}v56_&K_~4$?KWjV->PM7sXi%)+>hcXs zpbK=)X!age1^)U#^{~yG+Dnr5w9i2n-S+e1>b;A6L(>1_3x{g`zR}xGbU!E7@xNA3 zk9o_Qgfh&oi!I>&HPeA&!_*_>PI8=VeqrzedsB~Sj4 zGcM7N{)ra+)IFZf8rt%og{p@y+S)$n2m9NXKYM~UUgliGZ`KT_%?Y%*Y)yPw0nZf# zg5)pxM-RWfta^F;M$S--FKgubKzn(0Q_XXnmzaQ$_|uy0jAt949le)SH!VsSGGa?{ z^<3IfUg5z-(`#P2Ay{+x8=>kcWB<^eL@xP(8}e&(W^F?JL~1h6ui1y6R&XM}MzAjH zlVF~Ur15$`yI%>kH9TLwp%EN!+gQFqJnF`6z7eRZHR7uTyWWih-dx})0p4N2I~#aQ zfVY5i9A~@WUAij1EQjYX~&v3x@j-}PMQ82-woM%?NgV+5c0 z(j4%XeatZP#;uvP{&(zyQTRrm_K(29OSr`|UXE@V{s~XRKs1h?*GSFI#O;SI==m=a*42*bp{NM={ zyV0BP(Z@S{OP4MoHkQtOL8DcYH)-Ui>g;sjEA^AZm_ZKXeq>WN{1R8ZqI&ZKTiW+^ zzSCaUd9*zZy0+xIWyd(OM|Q?PzhUb{?0oFwE+5Fj$E&Bn$5!xBV&Wsv4IejvkB0TR z6)%C05|8|RnfY_);ckNNCV~&;%cdF$CO#U$M*{e`9DF3EO{nMu4}Ss=oKM#(eXxgm zWBb5~bj#oA%iUfhc-aQbtZP>3ckS7fJzdKhbH049$F2*-gQIQW=w)!!2#%IP8$IFZ zDHo1@XXD7hPqN3J6s_EWz3M$b)o;hc-p5wxsh{<(ep*~}AqsW+o5K3Kn?BW_>|<|# z&Uvi`J;*HUEY>rZSuexSl3C*4STe_vF|lMzPjG+71@{3Deu@8dgZF|{!0Vh(yNvT` z#}lu6iFjQJ@w)NE>t-Xjiiy_+h}SJ!W0W-zs~SeEO81u#s~bjbiIOB=751*Zrre!h zzUUh3Tdp~B<|o|@C;^u>&V ze#O-pi;T?8e&diJV>xm2MJ^a)e89y;Xv*DqV zdq#)KxhFZ8``ceuXT0gVXAAL+26#;V@)mbnT0f^fo%gtd-|9F=WhniG>Bpi8E0*Al z$&7Cd2%7ImzADzPvu%o~S+g2`Qy#;1YUEBL#;P;kThNm_KU}hXj^diwWO`4!pb=dV z|7qg&Qw^V`KTbuFEgeb_U`5(PwSB38qZL(tpsyk4RY1T5$g!jo@g?Tx!nTn8HH&k z?9yrOxv7O-pC*^#5#SgJ9F5F}U|S9>`)R|OlO3Kp*~Xj*FS)>?^9WQUcG!u|XL5Ka zkA62ZpMKE_;}d-=o+f;)q@NMo)0*zqe_GEsGVj}%_Z8S%oJCP{(4byGQetTd@QOZF z1AMjeEg0)6zE|>DVPBWk02{%DZc_dYVWi|L-)f|4h<0x8uTs3ILX9^&Vo(o zq8|qz6FvCI0UzQ`(U17R%L+m6gzhGF>w^dj*i&%Kou{VLSDB! zUQw^u&&oLV*ccs4zq7hJXa3XeaUZ_i9=E=web%C!vK-F5A5XkL9(gkXd9!UzpezBO zNVOdjxHpk|yN!hTrNFoZzTOP~$exi;wc9WjG~pwa>|DO#dKw^b* zw42H&uouO@x&{6V8-c2gjJe!MG<`_}A6NEDs_4*!A+h!Ad~?6r;zdgzH1Ws)dBEP$VYy}0dSkd+)QR} z5-v(CYtkG;lZqdy&Zo{JUnHHoZbC&PaipJu`!F>p>jzA#2(O#~e8k~$gB?@3zY%)( zj#qHG#~)_CqONV+HgLYIY|?MJ@T)bF3UKV`SLFe^(k7K8wYn#X# zGBMS=b&f3S526p%k6jN8R=-wky@u!B4jRX1Cgu>+V!aug(P#Lo$g4Bw1wTIKyy$-P zyjbnI?do9;4A|9&T;unyC&U{6UdC0-_$O)n#J#MVBb?z1EVYcY4p?S;VA<$_MR66u zf`0CT}jyc+8kB&0eMT!H$*O*IL$GbQS9<=Dt3&wleUEUJnyfdv_Z9saY?7 z_hxECLff3RT-wU|=WRcwPL>-t$nRQmqUxxz^>x;pt$ChwT)wP1FYvBIUw?4m(Yevk z*PmVUGtac=BkL8~N9c!_-2U85{1>;+g)&~vtF>8(ET6kUONNYYc z7t$M?kG;Mgn>PcSH+NBPSuQfOfOXJ<6HBXRo3^jNY63Dz@tY=M$f}8bAZ6u-qO{;s zFvX0bfbmo@-_4y5Ru|Fd9pFmqfVHgMIO~9)K4q>0zF9E7PiQ>4&WznMhjq+fhPj8^ zUI(1&$N+1O)aN$#4C>r!OZFglct&f~&08i_9Aq92GY`FxX`_*8mB_S_&_(SnzRde? z;2aa=_--S)b*c0>G}Z#GHQnsX3_oeyYqclZpiTDGv#!2V=bNMkJJ&beoSi9|*>V+o z67Um6Q`&0~ezN%9+FO=nj{d4~=r(G1|Ip}F+WX`^>!!vVnY~Zmxo&E*z6;k)?W^zn zbyLsN_qW$gy+Gg7*G;`h-;>u(z0}B@oNi>c{N&p;lZWejdd=if`kq`f`3pv7(hZZ> zDMz;%Sv=_)p8c|s8O$^?-$}A+D3cQyLSMFGGlFZ$z_lv$vw#9TI8|T-H2cBG{Ci$f3|4X zzcbca_$_*UX&?Ed z8RW!5-wT4!PZ78`V$k*^i?&(IzAQA%p>60T4Z4o3OIT}AYD4+3jnG?(v7ES6pmh`e z#jrPn_ml+oe0TW`!F44!{CwTzDZ%3<_z`-(7T(Ia4!fA#_+<8A4ZR_lnR7{S-FW1U z)+L9o4gOa8%HBtKouBx}g{<{v_*sh~mv(=jJ%7td+VkBBi-POoiMy8{GoIQ69O4aq zv_Dn(9yyEHBXA-wN~Wn+ww@s;w?chrononBT*n@Rj+3lK4Ym82gj|uXZNN@ygeRT0 z+%fcA>?+BoeCk2F_tz@!^>4}Bpj3do9u*=yj9A1{4<>luM zU+bBlClTu^!j?@p6o)O}VDKqmzDoGG`Pywa{lyt0JREa<7&+*Uf63-cMs8T`x#Bgp zt-jA`4>@+!1+(N=TQ2!9aVf2bO`CmC1m;`Nm$|@=-5y4lxMAMRoP8gdb2WFIp$N@}X6@HYE0;hkwa8ellZj?$!XGoWFOW6h z06dWd-Rke*@O3=vaTb51Rz~UQF8MpCWa#a`NTiK7cy#Nz><%*fzK7lo9)P(7g#49 z%3k8l$Qw(~25h}$%K(?}c$tGE=f zG#*}fzMtRE?MHI%m4rUL`5&`Qd^T=x%RiA%BHu(likH{t!H@GkWv}fkZ^E@#7I1wR zJb0wj3e5zb2z<7oA104IHC+6DRpT}n9{A#S&YZjBwd2L{^(M(PHy6k~pW%z5J?6`y zy<=Y5yJAS%J69G~xp08{h==yP_S|A>LU{d=@mE;;v(Y2$?Rq_pSc~?(xQy919cMf z+r_(!c{lyxqgyAQEjcmAdDhCBUB{l6=9}p!9Y1Cw=gVe3_l@IqKi~Dvq|WAdGC$h$ z&K&AG%{}|Y6OI2or#Xw5#Vy1v>MqaRI)gqk_&tZ;cjFg`j@5?yjC)V#ZU}}9+FeJx z6KOYtc7M3+ho;?~|2(H|)weUZuEY+R=u2+y#$TF*zhw728qiRjNfpglu$J1^d?UvB)eA>l^ zGdOeGU6;^qCGFPIZVm0`lN;{NOa8+@1h>;q%pNhjc}~y6W-jEzjG^6<3MO*UaNjK+F}tqP%4m)efAis!jCvQ{g&ilqu_lc*de&6h$f?

`l=JqmcZmq1j;YT+|M?&v+QMb)!ed8tK+mf-n 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 SWirt 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

;O>dIT z%lU26Ti*RjVmdvn>wCXf@%wW0RUQ7JeoL|DlG3=*edx|@W#W5LL`_E+EW)O(Vha_GPEb8Gg`T>7K;Lce?Iz0kVu z_r~5c^YN9(KL6;-V=MOF=Qxc8Jbxw6zqm5=cmweye4oRePc^My(7JMcvs19QjI&gO z@qgZ5{@(ZB+Zkwkua;x+H=RUJGpHtm$D-^hNH9+)X^> z5$5-84r@OpXGd<|4O}IKk=q}@PAg$8NONqdvv>p$OR3zj-Jnddd%lw9cbx5V7joL@5C zu2YpdH=BEcx!$?{GRCsaalYWY_gVMeExvnKx%X~0_p*7(Tc>SY?&^f$tc|;T>bQTN z{*RgS*V6efrLyZMIDNImY?z*+``LeK;v^Hu?}Z00M?Ui6s)JzpWkxrAog-(**rG$7 z=o)d z8bA8tC7-^CW#!Xqp3{8ijrX54UUa(nO>;&Zovra2ee3G@^M*2Jl&vhSLXY7CQr5b%^9ViE3*6!4kv;qz8sEZ{@%dHtUVpHF@Sd~V8t&(-EUg7o^b*0Qw5taTOf{a0U( z^nDe1t7e|AC4LoQZqo+uW9XK=CM`GV~eQO`F++nOYs?MnOmiM zj18Jof4cYqhaRoNe%|ALrYxU`&!7*Vx#~k}eYFU>`|U?)?b0W04<07J_IHEOW3|a= zPx}|_&)Da5pA+3c4Y$1kGdDb|b)=N+XzL^H^J03Hoy1ACS7XtL=t|CZF?tdG=f?Dm zUX&kU)>GiY`QT2U2V#2gB)$ZCFxyYHdMy(__MU9JpEI89{m$Ned5ghE+J5>6*|I@r zauwrw{D;Rf>{+$dLR*L3`NumC>K2n9l|BzDzMSzlBIm1|K5PdQN7Q^{;`qlnlbxK# zMbwkP4&nU6uW#Z0KIi|k=Xq6x_ac4q+u&v55MD0kcc2k@BwLg7X?M0`Y9T#=Jg>qp z>b{M9s9P3K@5e7HV=OkW*yZg8t(h0Lf2@2tnp)KfKTifYx4*vOP~iewJ%Fm+qyKN z2eil=Oyfh~Clwz#V&JX^dPJ@Ev1QH?~|;Km*TPWd<0oI@y| zZs6Hbk-l&7tZFgNWDQGwlILRZX6Fp|jH5dA$aT3AFv*E!7_}=s7j|R)!T2OXvEK@eEt{D&i$|j?o zH=e}5H*Gxa+`hu~?+b{f9H4GkJ?*QX2K4dErq6J1kk1qGErQHx-NE4Au450Z$#cSu z@(<-Jil^15;w$nSwJu`uWBWb^4&UdJTzdZ6k6e805H3QIjCu%qE`Tkcu_o&o-}(;? z{p+(1&-%XysUwodTq$2w_XpEIRbl24=JZn8<~i!tegPOg{Ud#zt-t*5Ps#EA-(}chl`qxs8jf`(8TeA#4jAMh9YR%1?&ql*@_?o}`wx>7UTvqH&Z~vA0 zH*t-@^5so_Y+4_eujRaeZ`ysJnva{{gBAP_g5R-RO-^uWfba5o<@G zydZgRW4VB6X=r}m->b~j=}7gIl$=O&u-{b7>PBBAHkmoudOp5Z25#A9@Q)o zJlM7#Jb51i≶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

O(~*y4>Iu-B%YHQ%-O!4`Zh7oNX+ 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

W@1MMZ^2dnUH>iYox*|L)jZ#%wdKyI>s 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 z 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

;OK8Y3LApyGEHh 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

u+QTTQ(g@4YyW*ca_Y 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ŎHyk;466CD0UUezeyjbCGc`a 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 z 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


t_B{G?4(q2i>@jtU{YA!lY#4np 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

W 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%0DURC9-xwoN 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

sS}K-f(^3 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

lEm_H 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

1T$vCC@|TH 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

i#rLkOQor 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

X8tF=(^@{GQBP?ED1H3ItdSX&;x}r3 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�CgHYT8C+4qCNvT2$0vTmcg1rDALl{DE**Yl5XYsS!h`i!`o7C5$s}NRR zAC=QV9`jJV!}_3S~kvLOlC{@mKG%zU&}^66OHhH?60tp9MVeKc0n1=f6OjI9EN z+JCn6-)Vuu4`88Y*g63r6=3KdP!s_R=Xg)RcL{u%JZiF= zT1&JhYLWTZjYd^by-Uf*rViZYne!$j{~AX>t{Uer8)r+#@pP7TD@{4KPp=q zWW)QtxpyZ3Dqk`hma8<$)TrBeh;2m?dvXnhn?c7oJFq5?*rqq29c zh%$qH@bT&^mIUDSia*)_$opm=hcPNz_h03U-{uiNKg<_{ z%fQ=Pl|I-iWRwzhQ)9GOXC+S12|iPW!n*VPg8l5`W+UX3mB7IzQhfeptKuzNVM4no z(yCZF)|QRkc=BE*<3I19QaFM;`Z$()6i?GsM&~mt$e1`!hh)i&nOH)S6yWkgcq*Jn zDc?6(|3UJUb}XWm+jKtr$a|}V+)>E9Igw683Csw8cqn>rS8N%bxh1146RP6L*N_`x zHHyEQ1#>l|HuS;bpkn4hZ?f36|%Xg3p)Da zC)hh%`L8Cz4xT*`SB4xe1>x)k6MQFk<%CvOP2fFU0A#M@Tor5T^a+rMV}C*b`FB=Y zl!c zPpL@M7mPid3^-L_T&Y!oTqGF0)JGh;UA-jyMHHC~G1OVyWvW^%pE-_8?S7;YxxUsI zft@zw08=3<59gp&CWw)xGrb}TLR6|nsmr{%BM|i9wsxbhS}q3ggYEpkx3jl28g!7+ zxo=z2n3ula0TTe$nQb>k&3upCW3j!rt(}yPNO9E|;ifybwJW#X7&qNjVfSvYVPA77 zx1HL+^~X=PQzkQ}=kCB9Jh?q@UA8@Qa0z40``g&}4gZT1jMhtCXZ3B#4UL`6L7G{nol&JspBQ43n z!Yd^fsFg~nNXF|3Tpf~!pt^gu57XKc_XbjyW(RG|{IO1OvWW*X@;Jk&$_%-?0??9U z3ZRv0Pod{+GRN$~J2~dWyJOu6ni1c~0^lR#Nkg~NR=gg@6a9rIo8SGJG)%CZ|0 z!WO_&m_`+MR3Mye%;V*EDzrG&waYwBV~DRjagv`m$xffdL-|~`qusM(m=XpH=JSoo zEqv<-lj*l4EC`6W4hdBPyBMb1U%Im`-r4Tk34mC#Grum|rS$49)@(O#YoATBdT{_G z$vS-}Tdt1{73lLS>lKaz9p+(LwYAxH%ChaFV72Mw<*3J1RZXSFif*oJuE&pXE&Wfv zFL6wGnczNwtIP6SfvhRZa|eb!^;Mw)1O&XWn?f;wO&E0s2%!u0x)PPcJ>+RT`PF!{ z#weXXEF{DiGl#@hKxqO@tM&E=Y4jVZ6V=JJ2TzhANowRWe^@5-=gA@8mh1k5*<82R z53mp+(@QOo+ks8;g_tyvqjkrrJ4<7#B+e|@Jvn`xJi^Y~g`q&SQTzn0F50V2_DYlX zxWoU>Zu*^H-4q~=h+Mp8K54QKn~Xi3^Pe`k<&AcCqutYJ7tgjuv+dH^wsU>C<*yh3~dnLxyyNgEWvA@Fe6l`wG#1U5KVrPx0OY*t`L ztq9VGZxB_PK69N_EF}Vkko!r@Z7;SSnGbdc-aFcdp@moN?iV+?%bG0RJG*apEOPFp z-`hD&Hvh-*yt>Ih)a339e%5p{ez3`wH@UmDSEu9qFap~(x?W>^eUn|ii`}$KmTLBR z0GCH?<87jzbYDQ^?GP~o1!bj<0OAcXts?+`k7-mP%RLTMnBT z8`MvUof}TBvLgcw8=cF83*>BFQ$ZL%xgo>!qs=oyb6jTCo+2x(hA5th3U(X$UE6pu zgDMXZ@`++Ph%#^PsS%cp)H&{y&D`-zc-VASSpNdcROF&D6rbkDyV&kwTh4Yro6D{# z6jm2(O=0J--!tsHtu7G%E4Q((Fe6j=oR)d=m$}P->6iXe0n;d;cZl$cB%<{4U%0>i zBAddqV45jah8e^mBzWow`gv5bRJ}AbMuMy;es(Ya>Rzl2IwyCzSe%N?lv+DjfGGNj zjDQF0FxLhb&o%%Ja*IME{2=RseX;`qVoDbU3d6>~A+~xQ-FYUnt=j_yJMML)f#K&| zQ~?)3bypaN<@VRettvL0Q;<0f(3L;#6oOf%F@Vi`ASmTV19^vJPMbG)wO<2t=ex#w~kvuIyxa{ z1a1JFPR@{>Gnpwj^HL7hClNgy+UD#y>>VrXBu+k}|G~K`(5gbWIbNkbs2j1!)4Xi&Dcye5jk%5K@yx zvxHcqrc?)#N7M5o=Z+jtCZKjLzip}?sSiZ$$gpqX8O#uo&1D>|k7Nk*l@diCj3b1X zaIVBQx#hc#aipG>gKz@_FHV8$xnPPvYlymC=T5UX_eSbY?BglIX(voW>P7bg20F9GPOgbhsgWKZKfB{=>=@KRc6trDV@{*G z(+1~G)wxsFpUV>ua`RrofYQVLc{_U=XCK|mR_=u_&=XVXK(s;Dm=-kOVchdTEm5kA?Q#3{wOjkO_r$`VB!HsI$k7g>zu%Z*FZ{tCt>+;?#zj+X#T0vc zioG)h-r|`l_S_WvPk2Z(+{XLiXgJct+f!VF&6{ecPi?uo%6g#;H(f@OAuf{Tr{Yv{%!&V2~w0{+HGMj8+(xI@u6lV{VPV ztj2oAN@g!)qwbr{59F*CX}z^=7WMLfChci82&jj(j_T2-Q0b|PG8aQ&BrEEwv4w&6 zakNd8E&Igeru)n;BDG=GtsZHowX*Ab*gad=q8fWNw&jsK1BE{3BVH4bTj9Udl8UFt zyPjBD;I^t4418QugI}ntt!Z5j>2_^u+LpJeYR9kjD!|b|4jw9}c8Fl+qb8o);JX;M z21VI#%+6=~wj0Fn{V~e+k2c%nw-NP01T7~Ol#O*`YQ~n0vGHzP&G@o$RZ@CQY%{5B z8%JVFzk}A9?Nqia``w~n*dF#v$_})A`dw`oFBC2Ro~-SbpU%?SCz)aUM!R$M?uFfK zcPiXfcHq;oCA$?5;A-X3Y-PJu9>UeX=8D~-@eGjJ=vd{K$X0>dio!)b+Q3b*VOi$! zU(uOMr?UoMp5~vOW-m<8d$uiQ?AAFwq9g4uwcLb9Ud#8hRO zN{Z=mzi>@1vvfvMN^ac8FW-k!(BQ`CseSy@`>^7b6Z7subWb6=yI{)|eviDBbfMwj zOb^YTff~<6BV&w;%rTjI?6i=KEfA@q_;yBAY3GN-4`xKYB5*ftcp{uwx*uPu40sROO1m)W;iXGv#1mn6m=Tzm7kRA$ z);qh-wx9e3l&v6-u9?)KF$4j9DPx&$jhn7RO+dIOoUv6_A|ql532sS*O90){t#%83 zwyE^}cb5v@VTdRrw6%>$<2l*}HfLQnHi{E7*aY3d`UIW_&9u%`lfEhCfl~J$lFziW zJC&ws!`KiQC+_1}Xq54ZV(1CNhTE(NZkft;T`s+4XoH?0%UJ66QJ0xN6YzX$Ujfsb z`vubc)PC7j-oD(|I`|j&)zDtGzbnsS(ZR|IfOfG9XWGJiFYl7Ej{2oW_?)|Li-VSsYBy+m|J#D)3NCw|a(OLzpPpx#frYpZ)01b^8Gj z%l1eHQyB`m@fRd{%Dvt|cBkbKUKo9Is@#0jf%;HeGUhw`qpryrpH?AbY zdRmTf>iB+=Fba;@lT;gnm9}cMQkAOfQs5hW222@e>O6$18bc@PVw*VEm68smAEgQx z^>b~2>z_wA>IX+d*$)FwMxgc@kzmu~`{>-*;+Tr@Xj!)^ZS5zPZ^!et!QSosj;J1; z-zD0e4j4Hce#y7Jq(@z22L%)`d^PQDK|86xX2%NbU<$boFr?tp-kDntz!I2cyfjK@ zA0QXKoD^r}EMHb1>q>e0SpG03huY<;V_8j=sTzBKhGu-#0pL4D0)K>IuPpmPnY}BU z-39VVw)32O!^F@td$%9R)7=mI*){vy6Z`4br3XfTMflS{@aVtF?7{<+1qW81#Ed)A zFX+||q(`{Mp?W1^$UO`XV-X%1F=PD~%|#i!NE7lp;vxXai4<230{zGii=P4;`9IJM zxw0%?LYgF+&uWhfWqB#DeH&IhnqL=^co|#;c@ye|XXjd<)CCiZXjVu6wL=J6r#y~X zi#ueN9E2sOJ&gg)*u(p`sB;0mf*)b8?Pn*=L_1L(=Ripa^RChH_HFGd+a+aa{5<|rb)Q_(8LIW?J+4$hZjt9s-hTX~RsRC^7OFAE~O z7GtfXb(u3claK0!6ooC*(k63lM|CYe3Ppt679zNeO=HqzDr%j%=HNVu_6~sf-gW?a zEw4Vn{(6809gMCZzvR%#glT* zcJ6^%s_z}*`}yt+zH=8JXqOy_i&}ULmxj+V;WJzC9qsLK!}4GIWnx~$ReJjhwm`Z{ zNZxQa9Bo_$iM{1@I}lS7^|o}S%)sA*XVU~FLOmXjN<8wZq{Zx3Yy)nkv9%R7G_SG> z9J?%4=Ha$pe_tHW!%Km?R_%XVE$(mt)?1BTtrkgG4JRdv?3mFa^abaXYz9 zz=f&fWMGGaHJNPNDV+sd$_ck7^8k;dazQ|4zt&`|oW)?icCd!$&ROxj;as4T-yVW+ z`R*Y$%{~5WJLiDhf&*anE)KunGRq!5*d99AUpUKO!u>4N`j8N-o!d(I3wt=dgK?(w zSiDp60a;<(k%MxzXzI{jV`{4xNl}3GT$`i?>{0{;D5rt{DIme4gx34n?#Pxi>p2LS zgMZ{8(!27R(+)+Rcr>(i?JRpL9K1CP==tto`}$!2*et(l7AyC=S#Iv3cKRXe{MuR3 z&A)-zzV~a;z&GKJGcje_KYtCmz2`ST%5(Ijh1@=0V9>4)_nmMk*>I}t=^c zd2G81S_*FRdUJeBv)HN4En`T=Jg-ynw z-$%w{(pK-$B&s^ zd8ohUPaAS%`{3?qjoI4>zrB3B_8JkS^h|^ zS<7WrKtCTyW4@ZJr*~s4v^iy;=)*|J5FoF0%Elg^qsU42gRFZ$3ySylNmhNw#PzAZ zS7sl|ws?i#p7m?9?zOC~MIqmE&AVA!%D0U?!mrN8RjvX_Q{L#9z+lP&3EZ!7Ek&#- z=dl1i?o(*lIju|r%}0jrutHnI5kKsrb|Yw@T%E11S|odXcAP<%j`?kZw*ycXxQNTJ zOWN6?_}!yNxcgl+?=S{@f4EX!hX>FB(I3)d;^}_xKb>V?%(BmCxi4qg^+%Mx_U^V> zcIzxeKkMeUB`3<=W;e`=d~_78!g1Ap84|X%cKfGUWD-nvksXoA%z1L843w-4y+`Ig zk{aad@E)pJ&wp5v#4o8mN**6{*4mIC2z;D!8`Dxk%o3K$ITZtjLweg~?)ojr*x6-= z+R{TAw&Q>6PWf$oz1|W!ReqgCm$iH|SJY_OYe$q`J_30y%3eu(fFg4Xgk{UoS_Bu2 z86W)$osBYCh&sfi(TsAfgSS%=SR_ZPrXXPoa>{5d!J|C$8SUWxCCI|Fheaxkx6HdY$Xm%fC5D&xvrK4Ey+p5-*(; zFt;?!sjGae%uTa#KvC0)D$e2ERh?iFYZFl*z@eM4Yk#XHzI?Xb9Q{P70fiHuJzU7{ zy4le%Y>p{m)8Lu?#<704kGiIzNx!AqnYstcqBHzl2|Ff1*;i1hMvCg1%rU>SFv*}!kX+eJ0e?_e#vy9sID*vS6|qfrYJN7NEVuwp!t zM$NS@T9khMESAs%}gU{uv}C@%IxY`^;|3QM|aY!K|&mL-m}Z2 z#(N`_p!}!H=U0;vS4Obm~lpS*v1K$*YrYm>ghB?Q+ zpM#w1?AW8+aYsQVQ1G@Po36ioj=P`qFaGayxD2oM0tFKMb#sd3j+vv;UVrX}IaI^a zIQ?Y~OuzK!r_6~Os5H6Zy7+_^klmBu<+do1vJ@^RlObC_wjMzzQ<3EcDUjRo^&vQ#FD zFHvxAEu1>QMP;T=_O;oA<{nbD0`&Tr2n3jdU*?Y0N%b0Efw-UyVLgQnQ+lj1gL?8x zg^P6Vz5WFSUeKJm06^j)$f}*Bx%tW*B?C)#vU8^Z@A9qT;YIj11|7nt7nS=4c$N~` z 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®2lS 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