From b0b4a9ea51cb71b95e3b2a241e11257ecb084b4a Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 17 Oct 2026 20:57:49 +0800 Subject: [PATCH 1/5] refactor(agentcore): make Turn the logical execution model --- agentcore/preset/ref/ref.go | 13 + agentcore/run/canonical/doc.go | 9 - agentcore/run/creation.go | 40 -- agentcore/run/creation_test.go | 32 -- agentcore/run/doc.go | 31 -- agentcore/run/fact_test.go | 28 -- agentcore/run/input_test.go | 10 - agentcore/run/regression_test.go | 157 ------- agentcore/run/sessionstore/guard.go | 23 - agentcore/run/settlement_test.go | 78 ---- agentcore/run/store/doc.go | 6 - agentcore/run/store/fold.go | 33 -- agentcore/run/wire/codec_test.go | 176 -------- .../{run => turn}/canonical/canonical_test.go | 22 +- agentcore/{run => turn}/canonical/digest.go | 32 +- agentcore/turn/canonical/doc.go | 9 + .../{run => turn}/canonical/golden_test.go | 32 +- agentcore/{run => turn}/canonical/identity.go | 70 +-- agentcore/{run => turn}/clone.go | 6 +- agentcore/{run => turn}/command.go | 33 +- agentcore/turn/creation.go | 44 ++ agentcore/turn/creation_test.go | 33 ++ agentcore/{run => turn}/decide.go | 64 +-- agentcore/turn/doc.go | 17 + agentcore/{run => turn}/effect/classify.go | 0 .../{run => turn}/effect/classify_test.go | 24 +- agentcore/{run => turn}/effect/discovery.go | 2 +- agentcore/turn/effect/owner_test.go | 44 ++ agentcore/{run => turn}/effect/progress.go | 0 agentcore/{run => turn}/effect/replay_test.go | 18 +- agentcore/{run => turn}/effect/settlement.go | 0 agentcore/{run => turn}/effect/state_test.go | 0 agentcore/{run => turn}/effect/types.go | 122 ++--- agentcore/{run => turn}/effect/watcher.go | 0 agentcore/{run => turn}/evolve.go | 80 ++-- agentcore/{run => turn}/fact.go | 158 +++---- agentcore/turn/fact_test.go | 28 ++ agentcore/{run => turn}/frozen/doc.go | 2 +- agentcore/{run => turn}/frozen/frozen.go | 54 +-- agentcore/turn/golden_test.go | 32 -- agentcore/{run => turn}/ids.go | 26 +- agentcore/turn/input_test.go | 10 + agentcore/{run => turn}/json.go | 2 +- .../loop/acknowledgesettled_test.go | 69 +-- agentcore/{run => turn}/loop/approval_test.go | 20 +- agentcore/{run => turn}/loop/cancel_test.go | 4 +- .../{run => turn}/loop/candidate_test.go | 56 +-- agentcore/{run => turn}/loop/context_test.go | 12 +- agentcore/{run => turn}/loop/contract.go | 84 ++-- agentcore/{run => turn}/loop/effectalias.go | 6 +- agentcore/{run => turn}/loop/effectref.go | 61 +-- agentcore/{run => turn}/loop/error_test.go | 2 +- agentcore/{run => turn}/loop/events.go | 12 +- agentcore/{run => turn}/loop/executor_test.go | 50 +-- agentcore/{run => turn}/loop/feature_test.go | 235 +++++----- agentcore/{run => turn}/loop/helpers_test.go | 51 +-- agentcore/{run => turn}/loop/loop.go | 205 ++++----- agentcore/{run => turn}/loop/loop_test.go | 54 +-- agentcore/{run => turn}/loop/model.go | 94 ++-- .../{run => turn}/loop/ownership_test.go | 20 +- agentcore/{run => turn}/loop/redispatch.go | 33 +- .../{run => turn}/loop/regression_test.go | 14 +- agentcore/{run => turn}/loop/require_test.go | 64 +-- .../{run => turn}/loop/retention_test.go | 8 +- agentcore/{run => turn}/loop/script_test.go | 42 +- agentcore/{run => turn}/loop/takeover_test.go | 12 +- agentcore/{run => turn}/loop/tool.go | 94 ++-- agentcore/{run => turn}/loop/tools_test.go | 10 +- .../{run => turn}/loop/unknown_model_test.go | 16 +- agentcore/{run => turn}/loop/unknown_test.go | 20 +- agentcore/{run => turn}/machine.go | 42 +- agentcore/{run => turn}/machine_test.go | 421 +++++++++--------- agentcore/{run => turn}/model/arguments.go | 0 agentcore/{run => turn}/model/doc.go | 2 +- agentcore/{run => turn}/model/model.go | 0 agentcore/turn/module_test.go | 39 -- agentcore/{run => turn}/plan/doc.go | 8 +- agentcore/{run => turn}/plan/next.go | 46 +- agentcore/{run => turn}/plan/recovery.go | 46 +- agentcore/turn/projection.go | 207 --------- agentcore/turn/projection_test.go | 63 --- agentcore/{run => turn}/query.go | 2 +- agentcore/turn/read.go | 20 - .../{run => turn}/reconcile/reconcile.go | 40 +- .../{run => turn}/reconcile/reconcile_test.go | 30 +- .../reconcile/redispatch_test.go | 8 +- .../{run => turn}/redispatch/redispatch.go | 6 +- .../redispatch/redispatch_test.go | 8 +- .../redispatch/redispatchtest/conformance.go | 8 +- .../redispatch/redispatchtest/map.go | 4 +- .../redispatch/redispatchtest/map_test.go | 2 +- agentcore/turn/regression_test.go | 157 +++++++ agentcore/{run => turn}/schema/doc.go | 2 +- agentcore/{run => turn}/schema/schema.go | 20 +- .../sessionstore/cachepolicy_test.go | 0 .../sessionstore/codec_history_test.go | 49 +- .../{run => turn}/sessionstore/frozen.go | 52 +-- .../{run => turn}/sessionstore/frozen_test.go | 10 +- agentcore/turn/sessionstore/guard.go | 24 + .../{run => turn}/sessionstore/guard_test.go | 8 +- agentcore/turn/sessionstore/identity.go | 28 ++ .../{run => turn}/sessionstore/module.go | 111 ++--- .../{run => turn}/sessionstore/projection.go | 91 ++-- .../{run => turn}/sessionstore/recover.go | 16 +- .../sessionstoretest/sessionstoretest.go | 8 +- .../sessionstore/settlementproof.go | 28 +- .../sessionstore/settlementproof_test.go | 69 +-- agentcore/{run => turn}/sessionstore/store.go | 267 ++++++----- .../sessionstore/storetest/conformance.go | 335 +++++++------- .../sessionstore/storetest/harness.go | 190 ++++---- agentcore/turn/sessionstore/surface.go | 125 ++++++ agentcore/turn/settlement_test.go | 78 ++++ agentcore/{run => turn}/state.go | 109 ++--- agentcore/{run => turn}/store/commit.go | 116 ++--- agentcore/turn/store/doc.go | 6 + agentcore/turn/store/fold.go | 33 ++ .../{run => turn}/store/settlementproof.go | 32 +- agentcore/{run => turn}/store/store.go | 46 +- agentcore/turn/turn.go | 172 ------- agentcore/{run => turn}/wire/codec.go | 50 +-- agentcore/turn/wire/codec_test.go | 177 ++++++++ agentcore/{run => turn}/wire/doc.go | 4 +- agentcore/{run => turn}/wire/helpers_test.go | 78 ++-- agentcore/{run => turn}/wire/registry.go | 94 ++-- agentcore/{run => turn}/wire/snapshot.go | 74 +-- agentcore/{run => turn}/wire/snapshot_test.go | 24 +- 126 files changed, 3208 insertions(+), 3425 deletions(-) create mode 100644 agentcore/preset/ref/ref.go delete mode 100644 agentcore/run/canonical/doc.go delete mode 100644 agentcore/run/creation.go delete mode 100644 agentcore/run/creation_test.go delete mode 100644 agentcore/run/doc.go delete mode 100644 agentcore/run/fact_test.go delete mode 100644 agentcore/run/input_test.go delete mode 100644 agentcore/run/regression_test.go delete mode 100644 agentcore/run/sessionstore/guard.go delete mode 100644 agentcore/run/settlement_test.go delete mode 100644 agentcore/run/store/doc.go delete mode 100644 agentcore/run/store/fold.go delete mode 100644 agentcore/run/wire/codec_test.go rename agentcore/{run => turn}/canonical/canonical_test.go (89%) rename agentcore/{run => turn}/canonical/digest.go (65%) create mode 100644 agentcore/turn/canonical/doc.go rename agentcore/{run => turn}/canonical/golden_test.go (51%) rename agentcore/{run => turn}/canonical/identity.go (56%) rename agentcore/{run => turn}/clone.go (96%) rename agentcore/{run => turn}/command.go (90%) create mode 100644 agentcore/turn/creation.go create mode 100644 agentcore/turn/creation_test.go rename agentcore/{run => turn}/decide.go (92%) create mode 100644 agentcore/turn/doc.go rename agentcore/{run => turn}/effect/classify.go (100%) rename agentcore/{run => turn}/effect/classify_test.go (76%) rename agentcore/{run => turn}/effect/discovery.go (96%) create mode 100644 agentcore/turn/effect/owner_test.go rename agentcore/{run => turn}/effect/progress.go (100%) rename agentcore/{run => turn}/effect/replay_test.go (60%) rename agentcore/{run => turn}/effect/settlement.go (100%) rename agentcore/{run => turn}/effect/state_test.go (100%) rename agentcore/{run => turn}/effect/types.go (86%) rename agentcore/{run => turn}/effect/watcher.go (100%) rename agentcore/{run => turn}/evolve.go (87%) rename agentcore/{run => turn}/fact.go (65%) create mode 100644 agentcore/turn/fact_test.go rename agentcore/{run => turn}/frozen/doc.go (83%) rename agentcore/{run => turn}/frozen/frozen.go (72%) delete mode 100644 agentcore/turn/golden_test.go rename agentcore/{run => turn}/ids.go (73%) create mode 100644 agentcore/turn/input_test.go rename agentcore/{run => turn}/json.go (97%) rename agentcore/{run => turn}/loop/acknowledgesettled_test.go (68%) rename agentcore/{run => turn}/loop/approval_test.go (70%) rename agentcore/{run => turn}/loop/cancel_test.go (85%) rename agentcore/{run => turn}/loop/candidate_test.go (83%) rename agentcore/{run => turn}/loop/context_test.go (87%) rename agentcore/{run => turn}/loop/contract.go (76%) rename agentcore/{run => turn}/loop/effectalias.go (94%) rename agentcore/{run => turn}/loop/effectref.go (64%) rename agentcore/{run => turn}/loop/error_test.go (93%) rename agentcore/{run => turn}/loop/events.go (82%) rename agentcore/{run => turn}/loop/executor_test.go (94%) rename agentcore/{run => turn}/loop/feature_test.go (65%) rename agentcore/{run => turn}/loop/helpers_test.go (69%) rename agentcore/{run => turn}/loop/loop.go (69%) rename agentcore/{run => turn}/loop/loop_test.go (92%) rename agentcore/{run => turn}/loop/model.go (67%) rename agentcore/{run => turn}/loop/ownership_test.go (91%) rename agentcore/{run => turn}/loop/redispatch.go (55%) rename agentcore/{run => turn}/loop/regression_test.go (93%) rename agentcore/{run => turn}/loop/require_test.go (77%) rename agentcore/{run => turn}/loop/retention_test.go (93%) rename agentcore/{run => turn}/loop/script_test.go (71%) rename agentcore/{run => turn}/loop/takeover_test.go (90%) rename agentcore/{run => turn}/loop/tool.go (55%) rename agentcore/{run => turn}/loop/tools_test.go (77%) rename agentcore/{run => turn}/loop/unknown_model_test.go (51%) rename agentcore/{run => turn}/loop/unknown_test.go (84%) rename agentcore/{run => turn}/machine.go (68%) rename agentcore/{run => turn}/machine_test.go (56%) rename agentcore/{run => turn}/model/arguments.go (100%) rename agentcore/{run => turn}/model/doc.go (96%) rename agentcore/{run => turn}/model/model.go (100%) delete mode 100644 agentcore/turn/module_test.go rename agentcore/{run => turn}/plan/doc.go (60%) rename agentcore/{run => turn}/plan/next.go (68%) rename agentcore/{run => turn}/plan/recovery.go (58%) delete mode 100644 agentcore/turn/projection.go delete mode 100644 agentcore/turn/projection_test.go rename agentcore/{run => turn}/query.go (99%) delete mode 100644 agentcore/turn/read.go rename agentcore/{run => turn}/reconcile/reconcile.go (80%) rename agentcore/{run => turn}/reconcile/reconcile_test.go (91%) rename agentcore/{run => turn}/reconcile/redispatch_test.go (96%) rename agentcore/{run => turn}/redispatch/redispatch.go (98%) rename agentcore/{run => turn}/redispatch/redispatch_test.go (94%) rename agentcore/{run => turn}/redispatch/redispatchtest/conformance.go (91%) rename agentcore/{run => turn}/redispatch/redispatchtest/map.go (96%) rename agentcore/{run => turn}/redispatch/redispatchtest/map_test.go (72%) create mode 100644 agentcore/turn/regression_test.go rename agentcore/{run => turn}/schema/doc.go (81%) rename agentcore/{run => turn}/schema/schema.go (58%) rename agentcore/{run => turn}/sessionstore/cachepolicy_test.go (100%) rename agentcore/{run => turn}/sessionstore/codec_history_test.go (60%) rename agentcore/{run => turn}/sessionstore/frozen.go (79%) rename agentcore/{run => turn}/sessionstore/frozen_test.go (93%) create mode 100644 agentcore/turn/sessionstore/guard.go rename agentcore/{run => turn}/sessionstore/guard_test.go (92%) create mode 100644 agentcore/turn/sessionstore/identity.go rename agentcore/{run => turn}/sessionstore/module.go (66%) rename agentcore/{run => turn}/sessionstore/projection.go (51%) rename agentcore/{run => turn}/sessionstore/recover.go (71%) rename agentcore/{run => turn}/sessionstore/sessionstoretest/sessionstoretest.go (64%) rename agentcore/{run => turn}/sessionstore/settlementproof.go (60%) rename agentcore/{run => turn}/sessionstore/settlementproof_test.go (64%) rename agentcore/{run => turn}/sessionstore/store.go (56%) rename agentcore/{run => turn}/sessionstore/storetest/conformance.go (70%) rename agentcore/{run => turn}/sessionstore/storetest/harness.go (64%) create mode 100644 agentcore/turn/sessionstore/surface.go create mode 100644 agentcore/turn/settlement_test.go rename agentcore/{run => turn}/state.go (87%) rename agentcore/{run => turn}/store/commit.go (61%) create mode 100644 agentcore/turn/store/doc.go create mode 100644 agentcore/turn/store/fold.go rename agentcore/{run => turn}/store/settlementproof.go (51%) rename agentcore/{run => turn}/store/store.go (56%) delete mode 100644 agentcore/turn/turn.go rename agentcore/{run => turn}/wire/codec.go (66%) create mode 100644 agentcore/turn/wire/codec_test.go rename agentcore/{run => turn}/wire/doc.go (65%) rename agentcore/{run => turn}/wire/helpers_test.go (62%) rename agentcore/{run => turn}/wire/registry.go (61%) rename agentcore/{run => turn}/wire/snapshot.go (52%) rename agentcore/{run => turn}/wire/snapshot_test.go (69%) diff --git a/agentcore/preset/ref/ref.go b/agentcore/preset/ref/ref.go new file mode 100644 index 00000000..f727699b --- /dev/null +++ b/agentcore/preset/ref/ref.go @@ -0,0 +1,13 @@ +// Package ref defines immutable preset identities without depending on the +// agent state machine or the preset catalog. +package ref + +import "github.com/felinics/twilight/agentcore/jsonstable" + +type ID string + +// Ref names the registered preset and the exact decision configuration. +type Ref struct { + ID ID `json:"id"` + Digest jsonstable.Digest `json:"digest"` +} diff --git a/agentcore/run/canonical/doc.go b/agentcore/run/canonical/doc.go deleted file mode 100644 index 0f33c663..00000000 --- a/agentcore/run/canonical/doc.go +++ /dev/null @@ -1,9 +0,0 @@ -// Package canonical is the canonical identity of a Run: the digest rules -// (Digests) for every body a fact names or a derived identity covers, and the -// derivation (Identity) of every RunID-scoped identity -- step, call, -// response, effect and command ids. Both are persisted -// semantics: their preimages never change. The state machine of -// agentcore/run is composed with them in agentcore/run/schema. The package -// also declares the envelope types under which agentcore/run/frozen stores -// the bodies these digests name. -package canonical diff --git a/agentcore/run/creation.go b/agentcore/run/creation.go deleted file mode 100644 index e871a4e1..00000000 --- a/agentcore/run/creation.go +++ /dev/null @@ -1,40 +0,0 @@ -package run - -import ( - "errors" - "unicode/utf8" - - "github.com/felinics/twilight/agentcore/ledger" -) - -// NewRun is the immutable creation data for a Run. RunID is caller-supplied -// so retries retain a stable identity. Which Turn the Run serves, and as -// which attempt, is the attempt module's fact (twilight/attempt/started), -// written in the same commit; the Run itself is one execution. -type NewRun struct { - RunID RunID `json:"runId"` - CausationID ledger.CausationID `json:"causationId,omitempty"` -} - -// BuildNewRun constructs a Run creation value. -func BuildNewRun(runID RunID, causationID ledger.CausationID) (NewRun, error) { - run := NewRun{RunID: runID, CausationID: causationID} - if err := ValidateNewRun(run); err != nil { - return NewRun{}, err - } - return run, nil -} - -// ValidateNewRun verifies 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") - } - return nil -} diff --git a/agentcore/run/creation_test.go b/agentcore/run/creation_test.go deleted file mode 100644 index 47813723..00000000 --- a/agentcore/run/creation_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package run - -import ( - "testing" - - "github.com/felinics/twilight/agentcore/ledger" -) - -func mustNewRun(t testing.TB, id RunID, cause ledger.CausationID) NewRun { - t.Helper() - run, err := BuildNewRun(id, cause) - if err != nil { - t.Fatal(err) - } - return run -} - -func TestNewRunValidation(t *testing.T) { - created := mustNewRun(t, "run-1", "session-1") - if created.RunID != "run-1" { - t.Fatalf("run id = %q", created.RunID) - } - for _, candidate := range []NewRun{ - {}, - {RunID: RunID(string([]byte{0xff}))}, - {RunID: "run-1", CausationID: ledger.CausationID(string([]byte{0xff}))}, - } { - if err := ValidateNewRun(candidate); err == nil { - t.Fatalf("invalid NewRun accepted: %+v", candidate) - } - } -} diff --git a/agentcore/run/doc.go b/agentcore/run/doc.go deleted file mode 100644 index b57be004..00000000 --- a/agentcore/run/doc.go +++ /dev/null @@ -1,31 +0,0 @@ -// Package run defines a Run: one execution attempt with a unique identity that -// is recoverable and never rewritten. It holds the Run's identifiers and value -// types, the MachineState, the sealed command and fact variants, and the legal -// state transitions (Decide, Evolve, CreateGroup) of each schema version. -// A Run is a logical execution history. Its vocabulary keeps four things -// apart: a Step (ModelStep, ToolStep) is logical progress; an effect -// (EffectID) is one request the Run makes of the outside world, the model -// call of a step or one tool call; an attempt is one physical execution of -// an effect and belongs to the executor, which the Run reaches only through -// the EffectID; a Wait (ResponseRequest) is the external input a tool call -// lacks. The Run records that an effect was requested, settled or recovered -// as unknown, never who executed it or how. -// The Machine computes no digest and derives no identity itself: it is bound -// to the Canonical and Identity rules of the schema version a Run was created -// under, so a Run replays under its own rules whatever the current version is. -// Everything around a Run lives in the subpackages, in two tiers. -// The protocol tier changes with the Run schema or is a port the core defines: -// agent/run/model holds the durable model data that facts name by digest, -// agent/run/canonical the digest and identity rules, agent/run/wire the -// persisted fact and command protocol, agent/run/frozen the frozen-body -// protocol, agent/run/schema the version binding, agent/run/plan the next -// action and the takeover dispositions, and agent/run/store the RunStore -// port and commit evaluation. The protocol tier depends -// only on agent/es, agent/jsonstable and itself. -// The execution tier uses the protocol tier, and neither the core -// nor the protocol tier imports it: agent/run/effect is the process-independent -// port between the Loop and the Executor, agent/run/loop the in-process -// execution interpreter with its model/tool ports, and agent/run/reconcile the -// takeover reconciliation against the execution store. The transport encoding -// of the effect port belongs to the Executor, in agent/executor/protocol. -package run diff --git a/agentcore/run/fact_test.go b/agentcore/run/fact_test.go deleted file mode 100644 index 548e51d4..00000000 --- a/agentcore/run/fact_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package run - -import ( - "encoding/json" - "testing" -) - -// RunEnded validates its tagged terminal value on the way to the wire: no -// end, an incomplete end or an end variant the schema does not know is -// refused by MarshalJSON, so no fact encoder can persist it. -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 := json.Marshal(fact); err == nil { - t.Fatal("invalid tagged terminal value was accepted") - } - }) - } -} - -type fakeRunEnd struct{} - -func (fakeRunEnd) runEnd() {} diff --git a/agentcore/run/input_test.go b/agentcore/run/input_test.go deleted file mode 100644 index 924e8046..00000000 --- a/agentcore/run/input_test.go +++ /dev/null @@ -1,10 +0,0 @@ -package run_test - -import ( - "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/run" -) - -// inputDigest names an input body in tests: the Run stores only the digest, -// so any content-derived digest stands in for the chatlog's. -func inputDigest(raw string) run.Digest { return jsonstable.DigestBytes([]byte(raw)) } diff --git a/agentcore/run/regression_test.go b/agentcore/run/regression_test.go deleted file mode 100644 index 7b5d60c1..00000000 --- a/agentcore/run/regression_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package run_test - -import ( - "testing" - - "github.com/felinics/twilight/agentcore/testutil/sdkconv" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/sdk" -) - -func TestRegressionZeroBindingsWithToolCallsRejected(t *testing.T) { - s := newRun(t) - s, stepID := advanceToExecuting(t, s, testRequest(), nil) - result := modelResultWithCalls("c1") - if _, err := schema.Machine().Decide(s, settling(s, run.SubmitModelResult{StepID: stepID, Result: result, Calls: nil})); err == nil { - t.Fatal("result with tool calls and no bindings completed the run") - } -} - -func TestRegressionBindingMustMatchModelResult(t *testing.T) { - safe := testToolDef("safe") - danger := testToolDef("danger") - specSafe := makeSpec(t, safe, run.DirectExecution) - specDanger := makeSpec(t, danger, run.DirectExecution) - s := newRun(t) - s, stepID := advanceToExecuting(t, s, testRequest(safe, danger), []run.ToolSpec{specSafe, specDanger}) - - evil := makeBinding(t, stepID, 0, "c1", specDanger, `{"rm":"-rf"}`) - result := modelResultWithNamedCalls("safe", `{"a":1}`, "c1") - if _, err := schema.Machine().Decide(s, settling(s, run.SubmitModelResult{StepID: stepID, Result: result, Calls: []run.ToolCallBinding{evil}})); err == nil { - t.Fatal("binding for a tool the model never called was accepted") - } - - tampered := makeBinding(t, stepID, 0, "c1", specSafe, `{"a":999}`) - if _, err := schema.Machine().Decide(s, settling(s, run.SubmitModelResult{StepID: stepID, Result: result, Calls: []run.ToolCallBinding{tampered}})); err == nil { - t.Fatal("binding with tampered arguments was accepted") - } -} - -func TestRegressionToolStepIDReproducible(t *testing.T) { - def := testToolDef("t") - spec := makeSpec(t, def, run.ApprovalRequired) - s := newRun(t) - s, stepID := advanceToExecuting(t, s, testRequest(def), []run.ToolSpec{spec}) - b := makeBinding(t, stepID, 0, "c1", spec, `{}`) - facts := mustDecide(t, s, run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) - opened := facts[1].(run.ToolStepOpened) - if schema.Identity().DeriveToolStepID(opened.Source) != opened.StepID { - t.Fatal("ToolStepOpened source does not reproduce its StepID") - } - s = fold(t, s, facts) - ts := s.Current.(run.ToolStep) - if schema.Identity().DeriveToolStepID(ts.Source) != ts.RefValue.ID { - t.Fatal("persisted ToolStep source does not reproduce the step ID") - } -} - -func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { - def := testToolDef("t") - spec := makeSpec(t, def, run.DirectExecution) - s := newRun(t) - s, stepID := advanceToExecuting(t, s, testRequest(def), []run.ToolSpec{spec}) - b := makeBinding(t, stepID, 0, "c1", spec, `{}`) - facts := mustDecide(t, s, run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) - opened := facts[1].(run.ToolStepOpened) - s = fold(t, s, facts) - s = fold(t, s, mustDecide(t, s, startTool(s, opened.StepID, cid(stepID, 0)))) - - _, err := schema.Machine().Evolve(s, run.ToolCallFailed{ - StepID: opened.StepID, - CallID: cid(stepID, 0), - Failure: run.ToolFailure{Class: run.FailureExecution}, - Outcome: run.ToolOutcomeUnknown, - }) - if err == nil { - t.Fatal("Evolve accepted an illegal unknown-outcome class") - } -} - -func TestRegressionCancelReasonFixed(t *testing.T) { - s := newRun(t) - if _, err := schema.Machine().Decide(s, run.CancelRun{Reason: run.RunReason("other")}); err == nil { - t.Fatal("CancelRun accepted a non-cancellation reason") - } - facts := mustDecide(t, s, run.CancelRun{}) - end, ok := facts[0].(run.RunEnded).End.(run.RunStoppedEnd) - if !ok || end.Reason != run.ReasonCancelled { - t.Fatal("cancel reason not fixed to cancelled") - } -} - -func TestCancelSettlesEveryUnfinishedToolCall(t *testing.T) { - for _, kind := range []run.ResponseKind{run.ResponseApproval, run.ResponseExternal} { - t.Run(string(kind), func(t *testing.T) { - policy := run.ApprovalRequired - if kind == run.ResponseExternal { - policy = run.ExternalResponse - } - def, waitDef := testToolDef("t"), testToolDef("wait") - spec, waitSpec := makeSpec(t, def, run.DirectExecution), makeSpec(t, waitDef, policy) - current := newRun(t) - current, modelStep := advanceToExecuting(t, current, testRequest(def, waitDef), []run.ToolSpec{spec, waitSpec}) - bindings := make([]run.ToolCallBinding, 5) - result := sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls} - for i := range bindings { - callSpec := spec - if i == 2 { - callSpec = waitSpec - } - providerID := "c" + string(rune('1'+i)) - bindings[i] = makeBinding(t, modelStep, i, providerID, callSpec, `{}`) - result.ToolCalls = append(result.ToolCalls, sdk.ToolCall{ToolCallID: providerID, ToolName: callSpec.Name, Input: sdk.ParseToolArguments(`{}`)}) - } - frozen, err := sdkconv.FreezeModelResult(result) - if err != nil { - t.Fatal(err) - } - current = fold(t, current, mustDecide(t, current, run.SubmitModelResult{StepID: modelStep, Result: frozen, Calls: bindings})) - stepID := current.Current.(run.ToolStep).RefValue.ID - // Retain one executing call, one completed result and one known failure. - for _, i := range []int{0, 3} { - current = fold(t, current, mustDecide(t, current, startTool(current, stepID, bindings[i].CallID))) - } - current = fold(t, current, mustDecide(t, current, run.SubmitToolResult{StepID: stepID, CallID: bindings[3].CallID, - Result: run.ToolExecutionResult{Output: cj(`"done"`)}})) - current = fold(t, current, mustDecide(t, current, run.DeclineToolCall{StepID: stepID, CallID: bindings[4].CallID, - Failure: run.ToolFailure{Class: run.FailureExecution, Message: "failed"}})) - facts := mustDecide(t, current, run.CancelRun{}) - if len(facts) != 4 { - t.Fatalf("cancel facts = %d, want three failures and RunEnded", len(facts)) - } - for i := 0; i < 3; i++ { - failure, ok := facts[i].(run.ToolCallFailed) - if !ok || failure.CallID != bindings[i].CallID { - t.Fatalf("fact %d = %+v", i, facts[i]) - } - outcome, class := run.ToolOutcomeKnown, run.FailureCancelled - if i == 0 { - outcome, class = run.ToolOutcomeUnknown, run.FailureEffectUnknown - } - if failure.Outcome != outcome || failure.Failure.Class != class { - t.Fatalf("call %d failure = %+v", i, failure) - } - } - ended := fold(t, current, facts) - if ended.Status != run.RunStopped || ended.LastToolStep == nil || - len(ended.Result.UncertainCalls) != 1 || ended.Result.UncertainCalls[0] != bindings[0].CallID { - t.Fatalf("cancel result = %+v", ended) - } - calls := ended.LastToolStep.Calls - if calls[3].Status != run.ToolCompleted || calls[4].Failure.Failure.Class != run.FailureExecution { - t.Fatalf("settled calls changed: %+v", calls[3:]) - } - }) - } -} diff --git a/agentcore/run/sessionstore/guard.go b/agentcore/run/sessionstore/guard.go deleted file mode 100644 index d821e316..00000000 --- a/agentcore/run/sessionstore/guard.go +++ /dev/null @@ -1,23 +0,0 @@ -package sessionstore - -import ( - "fmt" - - "github.com/felinics/twilight/agentcore/session/writer" -) - -// RequireNoActiveRun is the Run module's quiescence precondition for a -// Session-level operation evaluated inside the Writer's critical section: -// an error while any Run of the Session is active. Terminal Runs leave the machine projection, so an -// empty Active set is the whole answer: no Run is executing, awaiting an -// effect or holding an unresolved outcome. -func RequireNoActiveRun(view writer.View) error { - m, err := loadMachine(view) - if err != nil { - return err - } - for id := range m.Active { - return fmt.Errorf("sessionstore: run %s is active", id) - } - return nil -} diff --git a/agentcore/run/settlement_test.go b/agentcore/run/settlement_test.go deleted file mode 100644 index 9e5a356c..00000000 --- a/agentcore/run/settlement_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package run_test - -import ( - "strings" - "testing" - - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/schema" -) - -// every fact that closes an effect names it, and RunEnded closes -// none. A model failure and a cancellation settle the executing model effect -// with ModelStepFailed before the Run ends; a rejection names the effect it -// settles; Evolve refuses a RunEnded over an Executing target and a -// settlement naming another effect. -func TestEveryEffectEndsUnderItsOwnSettlement(t *testing.T) { - s := newRun(t) - model, stepID := advanceToExecuting(t, s, testRequest(), nil) - eff := model.Current.(run.ModelStep).Effect - other := schema.Identity().DeriveEffectID(model.RunID, stepID, "", 7) - failure := run.StepFailure{Class: run.FailureProvider, Message: "down"} - - t.Run("model failure", func(t *testing.T) { - facts := mustDecide(t, model, run.SubmitModelFailure{StepID: stepID, Effect: eff, Failure: failure}) - if len(facts) != 2 { - t.Fatalf("facts = %d, want [ModelStepFailed, RunEnded]", len(facts)) - } - failed, ok := facts[0].(run.ModelStepFailed) - if !ok || failed.Effect != eff || failed.Failure != failure { - t.Fatalf("facts[0] = %+v", facts[0]) - } - if end, ok := facts[1].(run.RunEnded); !ok || end.End.(run.RunFailedEnd).Reason != run.ReasonProviderFailure { - t.Fatalf("facts[1] = %+v", facts[1]) - } - if after := fold(t, model, facts); after.Status != run.RunFailed || after.ModelSteps != 1 { - t.Fatalf("after = %+v", after) - } - }) - t.Run("cancel while executing", func(t *testing.T) { - facts := mustDecide(t, model, run.CancelRun{}) - if len(facts) != 2 { - t.Fatalf("facts = %d, want [ModelStepFailed, RunEnded]", len(facts)) - } - failed, ok := facts[0].(run.ModelStepFailed) - if !ok || failed.Effect != eff || failed.Failure.Class != run.FailureEffectUnknown { - t.Fatalf("facts[0] = %+v", facts[0]) - } - if end := facts[1].(run.RunEnded).End.(run.RunStoppedEnd); end.UncertainModel != stepID { - t.Fatalf("end = %+v", end) - } - fold(t, model, facts) - }) - t.Run("rejection names the effect", func(t *testing.T) { - facts := mustDecide(t, model, run.RejectModelResult{StepID: stepID, Effect: eff, Failure: failure, Disposition: run.ModelRejectRetry}) - if rejected, ok := facts[0].(run.ModelStepRejected); !ok || rejected.Effect != eff { - t.Fatalf("facts[0] = %+v", facts[0]) - } - }) - - evolveRejects := []struct { - name string - fact run.Fact - want string - }{ - {"run ended over an executing model step", run.RunEnded{End: run.RunFailedEnd{Reason: run.ReasonProviderFailure, Failure: run.RunFailure{Class: run.FailureProvider}}}, "executes effect"}, - {"failure of another effect", run.ModelStepFailed{StepID: stepID, Effect: other, Failure: failure}, "settles effect"}, - {"failure without effect", run.ModelStepFailed{StepID: stepID, Failure: failure}, "without effect"}, - {"rejection of another effect", run.ModelStepRejected{StepID: stepID, Effect: other, Failure: failure}, "settles effect"}, - } - for _, tc := range evolveRejects { - t.Run(tc.name, func(t *testing.T) { - _, err := schema.Machine().Evolve(model, tc.fact) - if err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("Evolve(%T) = %v, want error containing %q", tc.fact, err, tc.want) - } - }) - } -} diff --git a/agentcore/run/store/doc.go b/agentcore/run/store/doc.go deleted file mode 100644 index 46c34dfa..00000000 --- a/agentcore/run/store/doc.go +++ /dev/null @@ -1,6 +0,0 @@ -// Package store is the transactional boundary of a Run: the -// RunStore port a Loop drives, the Snapshot a command sees, the CommitRequest -// and CommitResult of one command, the pure EvaluateCommit every adapter runs -// inside its own commit, and FoldRun, the rebuild of a MachineState from a -// Run's complete fact sequence. -package store diff --git a/agentcore/run/store/fold.go b/agentcore/run/store/fold.go deleted file mode 100644 index a90f0eff..00000000 --- a/agentcore/run/store/fold.go +++ /dev/null @@ -1,33 +0,0 @@ -package store - -import ( - "errors" - "fmt" - - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/schema" -) - -// FoldRun rebuilds a MachineState from the complete fact sequence of one Run -// in stream order: the first fact must be RunCreated. No Decide, -// no effects, no replay. -func FoldRun(facts []run.Fact) (run.MachineState, error) { - if len(facts) == 0 { - return run.MachineState{}, errors.New("agent: fold: no facts") - } - if _, ok := facts[0].(run.RunCreated); !ok { - return run.MachineState{}, fmt.Errorf("agent: fold: first fact is %T, want RunCreated", facts[0]) - } - var state run.MachineState - for i, f := range facts { - f, err := run.SnapshotFact(f) - if err != nil { - return run.MachineState{}, err - } - state, err = schema.Machine().Evolve(state, f) - if err != nil { - return run.MachineState{}, fmt.Errorf("agent: fold: fact %d (%s): %w", i, schema.Wire().FactType(f), err) - } - } - return state, nil -} diff --git a/agentcore/run/wire/codec_test.go b/agentcore/run/wire/codec_test.go deleted file mode 100644 index 04aef41b..00000000 --- a/agentcore/run/wire/codec_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package wire_test - -import ( - "encoding/json" - "fmt" - "reflect" - "strings" - "testing" - - "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/canonical" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/wire" -) - -func TestCommandEnvelopeJSONRoundTripRestoresVariants(t *testing.T) { - commands := []run.AgentCommand{ - run.PrepareModelRequest{StepID: "s", Model: "m", Request: model.ModelRequest{Model: "m"}, RequestDigest: "sha256:req"}, - run.WithdrawPreparedStep{StepID: "s"}, - run.StartModelExecution{StepID: "s", Effect: "effect-s"}, - run.RecoverModelExecution{StepID: "s", Effect: "effect-s"}, - run.SubmitModelResult{StepID: "s", Effect: "effect-s", Result: model.ModelResult{Text: "ok"}}, - run.SubmitModelFailure{StepID: "s", Effect: "effect-s", Failure: run.StepFailure{Class: run.FailureProvider, Message: "down"}}, - run.RejectModelResult{StepID: "s", Effect: "effect-s", Usage: model.Usage{TotalTokens: 1}, Failure: run.StepFailure{Class: run.FailureMalformedModel}}, - run.StartToolCall{StepID: "ts", CallID: "c", Effect: "effect-c"}, - run.SubmitToolResult{StepID: "ts", CallID: "c", Effect: "effect-c", Result: run.ToolExecutionResult{Output: cj(`{"ok":true}`)}}, - run.SubmitToolFailure{StepID: "ts", CallID: "c", Effect: "effect-c", Failure: run.ToolFailure{Class: run.FailureExecution}, Outcome: run.ToolOutcomeKnown}, - run.DeclineToolCall{StepID: "ts", CallID: "c", Failure: run.ToolFailure{Class: run.FailureToolLookup, Message: "no such tool"}}, - run.ApproveToolCall{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp"}, - run.RejectToolCall{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp", Reason: "no"}, - run.SubmitToolResponse{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp", Payload: cj(`{"answer":1}`)}, - run.CancelRun{}, - run.NextStep(run.AgentInput{ID: "in", Digest: inputDigest(`{"q":"hi"}`)}), - } - for _, cmd := range commands { - env, err := (wire.Facts{}).Envelope("run-1", run.CommandID("cmd-"+(wire.Facts{}).CommandType(cmd)), cmd) - if err != nil { - t.Fatalf("ProtocolV1().BuildEnvelope(%T): %v", cmd, err) - } - raw, err := json.Marshal(env) - if err != nil { - t.Fatalf("Marshal(%T): %v", cmd, err) - } - decoded, err := wire.DecodeCommandEnvelope(raw) - if err != nil { - t.Fatalf("DecodeCommandEnvelope(%T): %v\n%s", cmd, err, raw) - } - if reflect.TypeOf(decoded.Command) != reflect.TypeOf(cmd) { - t.Fatalf("decoded command type = %T, want %T", decoded.Command, cmd) - } - if decoded.Type != env.Type || decoded.ID != env.ID { - t.Fatalf("decoded envelope = %+v, want %+v", decoded, env) - } - } -} - -// Every fact variant round-trips through the v1 fact codec: canonical bytes -// decode back to the same variant and re-encode to the same bytes. -func TestFactCodecRoundTripRestoresVariants(t *testing.T) { - facts := []run.Fact{ - run.RunCreated{RunID: "run-1", CausationID: "cause"}, - run.ModelStepPrepared{StepID: "s", Model: "m", RequestDigest: "sha256:req"}, - run.ModelStepWithdrawn{StepID: "s"}, - run.ModelStepStarted{StepID: "s", Effect: "effect-m"}, - run.ModelStepRecovered{StepID: "s"}, - run.ModelStepRejected{StepID: "s", Effect: "effect-m", Usage: model.Usage{TotalTokens: 1}, Failure: run.StepFailure{Class: run.FailureMalformedModel}}, - run.ModelStepFailed{StepID: "s", Effect: "effect-m", Failure: run.StepFailure{Class: run.FailureProvider, Message: "down"}}, - run.ModelStepCompleted{StepID: "s", Usage: model.Usage{TotalTokens: 1}, FinishReason: model.FinishReasonStop, ResultDigest: "sha256:result"}, - run.ToolStepOpened{StepID: "ts", Source: "s", Calls: []run.ToolCallBinding{{CallID: "c", ToolRef: "t", Arguments: cj(`{}`), Policy: run.DirectExecution}}}, - run.ToolCallStarted{StepID: "ts", CallID: "c", Effect: "effect-t"}, - run.ToolCallApproved{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp"}, - run.ToolCallCompleted{StepID: "ts", CallID: "c", OutputDigest: "sha256:output"}, - run.ToolCallAnswered{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp"}, - run.ToolCallFailed{StepID: "ts", CallID: "c", Failure: run.ToolFailure{Class: run.FailureExecution}, Outcome: run.ToolOutcomeKnown}, - run.InputAccepted{Input: run.AgentInput{ID: "in", Digest: inputDigest(`{"q":"hi"}`)}}, - run.RunEnded{End: run.RunCompletedEnd{}}, - } - for _, fact := range facts { - typ := wire.FactType(fact) - raw, err := jsonstable.MarshalCanonical(fact) - if err != nil { - t.Fatalf("marshal(%T): %v", fact, err) - } - decoded, err := (wire.Facts{}).DecodeFact(typ, raw) - if err != nil { - t.Fatalf("DecodeFact(%T): %v\n%s", fact, err, raw) - } - if reflect.TypeOf(decoded) != reflect.TypeOf(fact) { - t.Fatalf("decoded fact type = %T, want %T", decoded, fact) - } - again, err := jsonstable.MarshalCanonical(decoded) - if err != nil || string(again) != string(raw) { - t.Fatalf("re-encode of %T differs:\n%s\n%s", fact, raw, again) - } - if _, err := (wire.Facts{}).DecodeFact("unknown", raw); err == nil { - t.Fatalf("unknown fact type decoded for %T", fact) - } - } -} - -func TestWireCodecRejectsAmbiguousJSONBeforeVariantDecode(t *testing.T) { - cmd := run.NextStep(run.AgentInput{ID: "in", Digest: inputDigest(`1`)}) - env, err := (wire.Facts{}).Envelope("run-1", (canonical.Identity{}).DeriveInputCommandID("run-1", "in"), cmd) - if err != nil { - t.Fatal(err) - } - raw := []byte(fmt.Sprintf(`{"schemaVersion":1,"type":"accept_input","runId":"run-1","id":%q,"command":{"inputs":[{"id":"in","payload":1}],"inputs":[{"id":"in","payload":1}]}}`, env.ID)) - if _, err := wire.DecodeCommandEnvelope(raw); err == nil { - t.Fatal("duplicate key command decoded") - } - - canon, err := json.Marshal(env) - if err != nil { - t.Fatal(err) - } - wrongCase := strings.Replace(string(canon), `"command":`, `"Command":`, 1) - if _, err := wire.DecodeCommandEnvelope([]byte(wrongCase)); err == nil { - t.Fatal("case-insensitive command field decoded") - } -} - -func TestWireCodecRejectsUnknownType(t *testing.T) { - env, err := (wire.Facts{}).Envelope("run-1", "cmd-1", run.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 := wire.DecodeCommandEnvelope([]byte(badType)); err == nil { - t.Fatal("unknown command type decoded") - } -} - -// RunEnded wire is a tagged union: exactly one variant key. -func TestRunEndedWireIsTaggedUnion(t *testing.T) { - cases := map[string]run.RunEnded{ - "completed": {End: run.RunCompletedEnd{}}, - "stopped": {End: run.RunStoppedEnd{Reason: run.ReasonCancelled, UncertainCalls: []run.CallID{"c1"}}}, - "failed": {End: run.RunFailedEnd{Reason: run.ReasonProviderFailure, Failure: run.RunFailure{Class: run.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 run.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 run.RunEnded - if err := json.Unmarshal([]byte(raw), &back); err == nil { - t.Fatalf("%s accepted: %s", name, raw) - } - } -} diff --git a/agentcore/run/canonical/canonical_test.go b/agentcore/turn/canonical/canonical_test.go similarity index 89% rename from agentcore/run/canonical/canonical_test.go rename to agentcore/turn/canonical/canonical_test.go index 3a2d9a6f..eece398a 100644 --- a/agentcore/run/canonical/canonical_test.go +++ b/agentcore/turn/canonical/canonical_test.go @@ -3,10 +3,10 @@ package canonical import ( "testing" - "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/wire" + "github.com/felinics/twilight/agentcore/testutil/sdkconv" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/wire" "github.com/felinics/twilight/sdk" ) @@ -114,7 +114,7 @@ func TestCanonicalDeterminism(t *testing.T) { } func TestDigestPreimageCoversVersionPrefix(t *testing.T) { - cmd := run.StartToolCall{StepID: "s1", CallID: "c1", Effect: "effect-1"} + cmd := turn.StartToolCall{StepID: "s1", CallID: "c1", Effect: "effect-1"} body1, err := jsonstable.EncodeTypedPayload(1, "start_tool_call", cmd) if err != nil { t.Fatal(err) @@ -159,8 +159,8 @@ func TestDeriveStability(t *testing.T) { } func TestDeriveResponseIDPerKind(t *testing.T) { - a := (Identity{}).DeriveResponseID("r", "s", "c", run.ResponseApproval) - b := (Identity{}).DeriveResponseID("r", "s", "c", run.ResponseExternal) + a := (Identity{}).DeriveResponseID("r", "s", "c", turn.ResponseApproval) + b := (Identity{}).DeriveResponseID("r", "s", "c", turn.ResponseExternal) if a == b { t.Fatal("response kind does not separate response IDs") } @@ -169,22 +169,22 @@ func TestDeriveResponseIDPerKind(t *testing.T) { // Golden vectors for the canonical encoding. They guard the persisted // preimages; a change here is a change to every digest ever written. func TestCanonicalGolden(t *testing.T) { - cmd := run.CancelRun{Reason: run.ReasonCancelled} - body, err := jsonstable.EncodeTypedPayload(1, "cancel_run", cmd) + cmd := turn.CancelTurn{Reason: turn.ReasonCancelled} + body, err := jsonstable.EncodeTypedPayload(1, "cancel_turn", cmd) if err != nil { t.Fatal(err) } - wantBody := `v1:10:cancel_run:{"reason":"cancelled"}` + wantBody := `v1:11:cancel_turn:{"reason":"cancelled"}` if string(body) != wantBody { t.Fatalf("golden body changed:\n got %q\nwant %q", body, wantBody) } - fact := run.InputAccepted{Input: run.AgentInput{ID: "in-1", Digest: "sha256:e7b995efa755c5ff3b84d2188b58cb4ae916a59470eb3761df8a814f11763500"}} + fact := turn.InputAccepted{Input: turn.AgentInput{ID: "in-1", Digest: "sha256:e7b995efa755c5ff3b84d2188b58cb4ae916a59470eb3761df8a814f11763500"}} fbody, err := (wire.Facts{}).EncodeFact("input_accepted", fact) if err != nil { t.Fatal(err) } - // The input body is not in the fact: the Run records the + // The input body is not in the fact: the Turn records the // input's identity and content digest, the chatlog holds the body. wantFact := `v1:14:input_accepted:{"input":{"digest":"sha256:e7b995efa755c5ff3b84d2188b58cb4ae916a59470eb3761df8a814f11763500","id":"in-1"}}` if string(fbody) != wantFact { diff --git a/agentcore/run/canonical/digest.go b/agentcore/turn/canonical/digest.go similarity index 65% rename from agentcore/run/canonical/digest.go rename to agentcore/turn/canonical/digest.go index fe9fcf84..0508eab9 100644 --- a/agentcore/run/canonical/digest.go +++ b/agentcore/turn/canonical/digest.go @@ -5,12 +5,12 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" ) // Envelope types of the bodies facts name by digest only. The -// frozen store (agent/run/frozen) keeps each body as the typed envelope its +// frozen store (agentcore/turn/frozen) keeps each body as the typed envelope its // digest was computed from, so it encodes and decodes under the same names. const ( RequestType = "model_request" @@ -27,7 +27,7 @@ const preimageVersion uint16 = 1 // Digests is the digest rules of every body a fact names. type Digests struct{} -func (Digests) DigestRequest(req model.ModelRequest) (run.Digest, error) { //nolint:gocritic // hugeParam: digest covers the complete immutable ModelRequest value. +func (Digests) DigestRequest(req model.ModelRequest) (turn.Digest, error) { //nolint:gocritic // hugeParam: digest covers the complete immutable ModelRequest value. body, err := jsonstable.EncodeTypedPayload(preimageVersion, RequestType, req) if err != nil { return "", err @@ -35,7 +35,7 @@ func (Digests) DigestRequest(req model.ModelRequest) (run.Digest, error) { //nol return jsonstable.DigestBytes(body), nil } -func (Digests) DigestToolDefinition(def model.ToolDefinition) (run.Digest, error) { +func (Digests) DigestToolDefinition(def model.ToolDefinition) (turn.Digest, error) { body, err := jsonstable.EncodeTypedPayload(preimageVersion, "tool_definition", def) if err != nil { return "", err @@ -44,24 +44,24 @@ func (Digests) DigestToolDefinition(def model.ToolDefinition) (run.Digest, error } type toolResponseDecisionDigestBody struct { - Kind run.ResponseKind `json:"kind"` - Decision run.ResponseDecision `json:"decision"` - Reason string `json:"reason,omitempty"` + Kind turn.ResponseKind `json:"kind"` + Decision turn.ResponseDecision `json:"decision"` + Reason string `json:"reason,omitempty"` } type ToolResponsePayloadBody struct { - Payload run.CanonicalJSON `json:"payload"` + Payload turn.CanonicalJSON `json:"payload"` } type ToolOutputBody struct { - Output run.CanonicalJSON `json:"output"` + Output turn.CanonicalJSON `json:"output"` } -func (Digests) DigestToolResponseDecision(kind run.ResponseKind, decision run.ResponseDecision, reason string) (run.Digest, error) { - if kind != run.ResponseApproval && kind != run.ResponseExternal { +func (Digests) DigestToolResponseDecision(kind turn.ResponseKind, decision turn.ResponseDecision, reason string) (turn.Digest, error) { + if kind != turn.ResponseApproval && kind != turn.ResponseExternal { return "", fmt.Errorf("agent: response decision: unsupported kind %q", kind) } - if decision != run.ResponseDecisionApproved && decision != run.ResponseDecisionRejected { + if decision != turn.ResponseDecisionApproved && decision != turn.ResponseDecisionRejected { return "", fmt.Errorf("agent: response decision: unsupported decision %q", decision) } body, err := jsonstable.EncodeTypedPayload(preimageVersion, "tool_response_decision", toolResponseDecisionDigestBody{ @@ -73,7 +73,7 @@ func (Digests) DigestToolResponseDecision(kind run.ResponseKind, decision run.Re return jsonstable.DigestBytes(body), nil } -func (Digests) DigestToolResponsePayload(payload run.CanonicalJSON) (run.Digest, error) { +func (Digests) DigestToolResponsePayload(payload turn.CanonicalJSON) (turn.Digest, error) { body, err := jsonstable.EncodeTypedPayload(preimageVersion, ToolResponseType, ToolResponsePayloadBody{Payload: payload}) if err != nil { return "", err @@ -83,7 +83,7 @@ func (Digests) DigestToolResponsePayload(payload run.CanonicalJSON) (run.Digest, // DigestModelResult names a frozen model result; ModelStepCompleted carries // this digest and the frozen store holds the body. -func (Digests) DigestModelResult(result model.ModelResult) (run.Digest, error) { //nolint:gocritic // hugeParam: digest covers the complete immutable ModelResult value. +func (Digests) DigestModelResult(result model.ModelResult) (turn.Digest, error) { //nolint:gocritic // hugeParam: digest covers the complete immutable ModelResult value. body, err := jsonstable.EncodeTypedPayload(preimageVersion, ModelResultType, result) if err != nil { return "", err @@ -92,7 +92,7 @@ func (Digests) DigestModelResult(result model.ModelResult) (run.Digest, error) { } // DigestToolOutput names one tool output; ToolCallCompleted carries it. -func (Digests) DigestToolOutput(output run.CanonicalJSON) (run.Digest, error) { +func (Digests) DigestToolOutput(output turn.CanonicalJSON) (turn.Digest, error) { if output.IsZero() { return "", errors.New("agent: tool output: empty output") } diff --git a/agentcore/turn/canonical/doc.go b/agentcore/turn/canonical/doc.go new file mode 100644 index 00000000..8e39619d --- /dev/null +++ b/agentcore/turn/canonical/doc.go @@ -0,0 +1,9 @@ +// Package canonical is the canonical identity of a Turn: the digest rules +// (Digests) for every body a fact names or a derived identity covers, and the +// derivation (Identity) of every TurnID-scoped identity -- step, call, +// response, effect and command ids. Both are persisted +// semantics: their preimages never change. The state machine of +// agentcore/turn is composed with them in agentcore/turn/schema. The package +// also declares the envelope types under which agentcore/turn/frozen stores +// the bodies these digests name. +package canonical diff --git a/agentcore/run/canonical/golden_test.go b/agentcore/turn/canonical/golden_test.go similarity index 51% rename from agentcore/run/canonical/golden_test.go rename to agentcore/turn/canonical/golden_test.go index 7bed96c6..e50b97d9 100644 --- a/agentcore/run/canonical/golden_test.go +++ b/agentcore/turn/canonical/golden_test.go @@ -3,39 +3,39 @@ package canonical import ( "testing" - "github.com/felinics/twilight/agentcore/run" + "github.com/felinics/twilight/agentcore/turn" ) // TestDerivedIdentityGolden freezes every derived identity for // one fixed input set. A drift with a non-empty want means the derivation // preimage changed: intentional protocol changes must update this fixture and -// agent-run.md; anything else is accidental drift. +// agent-turn.md; anything else is accidental drift. func TestDerivedIdentityGolden(t *testing.T) { const ( - runID run.RunID = "run-1" - step run.StepID = "step-1" - call run.CallID = "call-1" - effect run.EffectID = "effect-1" + turnID turn.TurnID = "run-1" + step turn.StepID = "step-1" + call turn.CallID = "call-1" + effect turn.EffectID = "effect-1" ) cases := []struct { name string got string want string }{ - {"model request command", string((Identity{}).DeriveModelRequestCommandID(runID, 7)), "7e42f75006e3271ce7420647345f238ee59e9603d596e7907cb2ccadc281763d"}, - {"model step", string((Identity{}).DeriveModelStepID(runID, "cmd-1")), "883c5ef2aae151247361c311c41884dab6621d880aac6e3c285dcc2ad5ffa8e6"}, + {"model request command", string((Identity{}).DeriveModelRequestCommandID(turnID, 7)), "7e42f75006e3271ce7420647345f238ee59e9603d596e7907cb2ccadc281763d"}, + {"model step", string((Identity{}).DeriveModelStepID(turnID, "cmd-1")), "883c5ef2aae151247361c311c41884dab6621d880aac6e3c285dcc2ad5ffa8e6"}, {"tool call", string((Identity{}).DeriveCallID(step, 0)), "cca7fd2e5a02a565155747fd8c126ac497a425d2f627eb2c77605dc0551aea11"}, {"tool step", string((Identity{}).DeriveToolStepID(step)), "6f62a482a83239b9c324a73c21b0b671ef2cad0af94a30177ed62929467d419d"}, - {"response id", string((Identity{}).DeriveResponseID(runID, step, call, run.ResponseApproval)), "819924b49cc24df66e97f5821ac80bf286b983e600f20e25f045142c4bc0e77e"}, - {"response command", string((Identity{}).DeriveResponseCommandID(runID, step, call, "resp-1")), "aacf7d247a13e4a696bff6f881ec517a6ad95ae262ec862d7dcf84b80c6bb32b"}, - {"input command", string((Identity{}).DeriveInputCommandID(runID, "in-1")), "ce57abe29d2da34e02cf8fbfb7e4a25086b352096977094e9bed7eb66dd16aad"}, - {"withdraw command", string((Identity{}).DeriveWithdrawCommandID(runID, step)), "582ffed8e0d762e83d68e0eef5c90f123e03787aad8462766667133d15961a8c"}, - {"model effect", string((Identity{}).DeriveEffectID(runID, step, "", 1)), "553f030a642d4f74a09c2b11620e464f56f98711a97b522f82cc15771ccc3394"}, - {"tool effect", string((Identity{}).DeriveEffectID(runID, step, call, 0)), "20fd10fde6bbb31634d80836d50906afc234e3676cfefb00a9abdb0fb996be78"}, + {"response id", string((Identity{}).DeriveResponseID(turnID, step, call, turn.ResponseApproval)), "819924b49cc24df66e97f5821ac80bf286b983e600f20e25f045142c4bc0e77e"}, + {"response command", string((Identity{}).DeriveResponseCommandID(turnID, step, call, "resp-1")), "aacf7d247a13e4a696bff6f881ec517a6ad95ae262ec862d7dcf84b80c6bb32b"}, + {"input command", string((Identity{}).DeriveInputCommandID(turnID, "in-1")), "ce57abe29d2da34e02cf8fbfb7e4a25086b352096977094e9bed7eb66dd16aad"}, + {"withdraw command", string((Identity{}).DeriveWithdrawCommandID(turnID, step)), "582ffed8e0d762e83d68e0eef5c90f123e03787aad8462766667133d15961a8c"}, + {"model effect", string((Identity{}).DeriveEffectID(turnID, step, "", 1)), "553f030a642d4f74a09c2b11620e464f56f98711a97b522f82cc15771ccc3394"}, + {"tool effect", string((Identity{}).DeriveEffectID(turnID, step, call, 0)), "20fd10fde6bbb31634d80836d50906afc234e3676cfefb00a9abdb0fb996be78"}, {"start command", string((Identity{}).DeriveStartCommandID(effect)), "5fbd4523f88a2e24369724c7829e378883a83851f4164b78d0cd5d07c790fd3b"}, {"settlement command", string((Identity{}).DeriveSettlementCommandID(effect)), "6e05abc3045f404dfe76216a9bd84ec8b6ca0294177d60e9695ebd354446a01d"}, {"recovery command", string((Identity{}).DeriveRecoveryCommandID(effect)), "3059f690e0e819d6f1f08bdfe09f22c4012d1d284ee90c02c3e50175102d6735"}, - {"decline command", string((Identity{}).DeriveDeclineCommandID(runID, step, call)), "c5eab65b0a9a2b828d18aed6ecc3655eff37cf55b275eb5fd15f26367637d49a"}, + {"decline command", string((Identity{}).DeriveDeclineCommandID(turnID, step, call)), "c5eab65b0a9a2b828d18aed6ecc3655eff37cf55b275eb5fd15f26367637d49a"}, } for _, c := range cases { if c.got == c.want { @@ -45,6 +45,6 @@ func TestDerivedIdentityGolden(t *testing.T) { t.Errorf("UNSET %s = %s", c.name, c.got) continue } - t.Errorf("golden %s drifted — an intentional derivation change must update this fixture and agent-run.md:\n got: %s\nwant: %s", c.name, c.got, c.want) + t.Errorf("golden %s drifted — an intentional derivation change must update this fixture and agent-turn.md:\n got: %s\nwant: %s", c.name, c.got, c.want) } } diff --git a/agentcore/run/canonical/identity.go b/agentcore/turn/canonical/identity.go similarity index 56% rename from agentcore/run/canonical/identity.go rename to agentcore/turn/canonical/identity.go index 2187f962..0ea655f5 100644 --- a/agentcore/run/canonical/identity.go +++ b/agentcore/turn/canonical/identity.go @@ -5,7 +5,7 @@ import ( "encoding/hex" "fmt" - "github.com/felinics/twilight/agentcore/run" + "github.com/felinics/twilight/agentcore/turn" ) // namespacedHash derives a stable identifier from a namespace and ordered @@ -19,70 +19,70 @@ func namespacedHash(namespace string, parts ...string) string { return hex.EncodeToString(h.Sum(nil)) } -// Identity is the identity derivation: every RunID-scoped identity (steps, +// Identity is the identity derivation: every TurnID-scoped identity (steps, // calls, responses, effects, command ids) is a namespaced hash of its // preimage. The preimages are persisted semantics and never change: a // different derivation would give a replayed command another CommitID type Identity struct{} // DeriveModelRequestCommandID derives the CommandID for PrepareModelRequest -// from the Run and the RunPosition the prompt builder loaded: concurrent +// from the Turn and the TurnPosition the prompt builder loaded: concurrent // prompt builders on the same position converge on one command identity. -func (Identity) DeriveModelRequestCommandID(runID run.RunID, position run.RunPosition) run.CommandID { - return run.CommandID(namespacedHash("twilight/model-request", string(runID), fmt.Sprintf("%d", position))) +func (Identity) DeriveModelRequestCommandID(turnID turn.TurnID, position turn.TurnPosition) turn.CommandID { + return turn.CommandID(namespacedHash("twilight/model-request", string(turnID), fmt.Sprintf("%d", position))) } -// DeriveModelStepID derives the ModelStep identity from the Run and the +// DeriveModelStepID derives the ModelStep identity from the Turn and the // preparing command: one accepted Prepare opens one step. -func (Identity) DeriveModelStepID(runID run.RunID, cmd run.CommandID) run.StepID { - return run.StepID(namespacedHash("twilight/model-step", string(runID), string(cmd))) +func (Identity) DeriveModelStepID(turnID turn.TurnID, cmd turn.CommandID) turn.StepID { + return turn.StepID(namespacedHash("twilight/model-step", string(turnID), string(cmd))) } -// DeriveCallID derives the Run-owned identity of one tool call from the +// DeriveCallID derives the Turn-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 (Identity) DeriveCallID(source run.StepID, index int) run.CallID { - return run.CallID(namespacedHash("twilight/tool-call", string(source), fmt.Sprintf("%d", index))) +func (Identity) DeriveCallID(source turn.StepID, index int) turn.CallID { + return turn.CallID(namespacedHash("twilight/tool-call", string(source), fmt.Sprintf("%d", index))) } // DeriveToolStepID derives the ToolStep identity from its source ModelStep: // a ModelStep completes once and opens at most one ToolStep. -func (Identity) DeriveToolStepID(source run.StepID) run.StepID { - return run.StepID(namespacedHash("twilight/tool-step", string(source))) +func (Identity) DeriveToolStepID(source turn.StepID) turn.StepID { + return turn.StepID(namespacedHash("twilight/tool-step", string(source))) } // 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 (Identity) DeriveResponseID(runID run.RunID, step run.StepID, call run.CallID, kind run.ResponseKind) run.ResponseID { - return run.ResponseID(namespacedHash("twilight/response", string(runID), string(step), string(call), string(kind))) +// (turn, step, call, kind) identifies it. +func (Identity) DeriveResponseID(turnID turn.TurnID, step turn.StepID, call turn.CallID, kind turn.ResponseKind) turn.ResponseID { + return turn.ResponseID(namespacedHash("twilight/response", string(turnID), 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 (Identity) DeriveResponseCommandID(runID run.RunID, step run.StepID, call run.CallID, resp run.ResponseID) run.CommandID { - return run.CommandID(namespacedHash("twilight/response-command", string(runID), string(step), string(call), string(resp))) +func (Identity) DeriveResponseCommandID(turnID turn.TurnID, step turn.StepID, call turn.CallID, resp turn.ResponseID) turn.CommandID { + return turn.CommandID(namespacedHash("twilight/response-command", string(turnID), string(step), string(call), string(resp))) } -// DeriveInputCommandID derives the CommandID for AcceptInput from the Run and +// DeriveInputCommandID derives the CommandID for AcceptInput from the Turn and // the ordered InputIDs of the batch: the same inputs in the same order replay // to the same command, a different batch is a different command. Queue-claim // references stay private to the host. -func (Identity) DeriveInputCommandID(runID run.RunID, inputs ...run.InputID) run.CommandID { +func (Identity) DeriveInputCommandID(turnID turn.TurnID, inputs ...turn.InputID) turn.CommandID { parts := make([]string, 0, len(inputs)+1) - parts = append(parts, string(runID)) + parts = append(parts, string(turnID)) for _, in := range inputs { parts = append(parts, string(in)) } - return run.CommandID(namespacedHash("twilight/input-command", parts...)) + return turn.CommandID(namespacedHash("twilight/input-command", parts...)) } // DeriveWithdrawCommandID derives the CommandID of WithdrawPreparedStep: one // Prepared step is withdrawn at most once, so the identity needs no content. -func (Identity) DeriveWithdrawCommandID(runID run.RunID, step run.StepID) run.CommandID { - return run.CommandID(namespacedHash("twilight/withdraw-command", string(runID), string(step))) +func (Identity) DeriveWithdrawCommandID(turnID turn.TurnID, step turn.StepID) turn.CommandID { + return turn.CommandID(namespacedHash("twilight/withdraw-command", string(turnID), string(step))) } // DeriveEffectID derives the identity of one request for an external effect @@ -90,26 +90,26 @@ func (Identity) DeriveWithdrawCommandID(runID run.RunID, step run.StepID) run.Co // a ToolStep. sequence distinguishes the requests one step makes: a ModelStep // whose result was rejected requests its model call again, so its sequence // is the count of rejections before the request; a tool call starts at most -// once, so its sequence is 0. The EffectID is the one handle the Run and the +// once, so its sequence is 0. The EffectID is the one handle the Turn and the // execution plane share: the start, settlement and recovery commands of the // effect derive from it, and the executor keys its attempts by it. -func (Identity) DeriveEffectID(runID run.RunID, step run.StepID, call run.CallID, sequence int) run.EffectID { - return run.EffectID(namespacedHash("twilight/effect", string(runID), string(step), string(call), fmt.Sprintf("%d", sequence))) +func (Identity) DeriveEffectID(turnID turn.TurnID, step turn.StepID, call turn.CallID, sequence int) turn.EffectID { + return turn.EffectID(namespacedHash("twilight/effect", string(turnID), string(step), string(call), fmt.Sprintf("%d", sequence))) } // DeriveStartCommandID derives the CommandID of StartModelExecution or // StartToolCall from the effect it requests. Commit enforces this derivation // so a caller-minted ID cannot bypass the idempotency index. -func (Identity) DeriveStartCommandID(effect run.EffectID) run.CommandID { - return run.CommandID(namespacedHash("twilight/start-command", string(effect))) +func (Identity) DeriveStartCommandID(effect turn.EffectID) turn.CommandID { + return turn.CommandID(namespacedHash("twilight/start-command", string(effect))) } // DeriveSettlementCommandID derives the CommandID of the settlement of one // effect (model result/failure/reject, tool result/failure). One effect // settles once, so the identity needs no content: a replay with the same // outcome is idempotent, a different outcome is a conflict. -func (Identity) DeriveSettlementCommandID(effect run.EffectID) run.CommandID { - return run.CommandID(namespacedHash("twilight/settlement-command", string(effect))) +func (Identity) DeriveSettlementCommandID(effect turn.EffectID) turn.CommandID { + return turn.CommandID(namespacedHash("twilight/settlement-command", string(effect))) } // DeriveRecoveryCommandID derives the CommandID of the recovery of one effect @@ -117,8 +117,8 @@ func (Identity) DeriveSettlementCommandID(effect run.EffectID) run.CommandID { // model effect, the Unknown SubmitToolFailure for a tool effect. The identity // is a function of the effect alone, so whichever owner repeats the recovery // replays the same command. -func (Identity) DeriveRecoveryCommandID(effect run.EffectID) run.CommandID { - return run.CommandID(namespacedHash("twilight/recovery-command", string(effect))) +func (Identity) DeriveRecoveryCommandID(effect turn.EffectID) turn.CommandID { + return turn.CommandID(namespacedHash("twilight/recovery-command", string(effect))) } // DeriveDeclineCommandID derives the CommandID of DeclineToolCall: the @@ -126,6 +126,6 @@ func (Identity) DeriveRecoveryCommandID(effect run.EffectID) run.CommandID { // declined at most once, so the identity is the call's coordinates alone; a // replay of the same decline is idempotent, a different failure is a // conflict. -func (Identity) DeriveDeclineCommandID(runID run.RunID, step run.StepID, call run.CallID) run.CommandID { - return run.CommandID(namespacedHash("twilight/decline-command", string(runID), string(step), string(call))) +func (Identity) DeriveDeclineCommandID(turnID turn.TurnID, step turn.StepID, call turn.CallID) turn.CommandID { + return turn.CommandID(namespacedHash("twilight/decline-command", string(turnID), string(step), string(call))) } diff --git a/agentcore/run/clone.go b/agentcore/turn/clone.go similarity index 96% rename from agentcore/run/clone.go rename to agentcore/turn/clone.go index 9e6447e9..2cbf89a9 100644 --- a/agentcore/run/clone.go +++ b/agentcore/turn/clone.go @@ -1,4 +1,4 @@ -package run +package turn import ( "encoding/json" @@ -91,8 +91,8 @@ func cloneFact(f Fact) Fact { case InputAccepted: fact.Input = cloneAgentInput(fact.Input) return fact - case RunEnded: - if stopped, ok := fact.End.(RunStoppedEnd); ok { + case TurnEnded: + if stopped, ok := fact.End.(TurnStoppedEnd); ok { stopped.UncertainCalls = append([]CallID(nil), stopped.UncertainCalls...) fact.End = stopped } diff --git a/agentcore/run/command.go b/agentcore/turn/command.go similarity index 90% rename from agentcore/run/command.go rename to agentcore/turn/command.go index fffef312..322718b0 100644 --- a/agentcore/run/command.go +++ b/agentcore/turn/command.go @@ -1,11 +1,11 @@ -package run +package turn import ( - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/turn/model" ) // AgentCommand is the intent submitted through Runtime.Commit for an existing -// Run. Accepting one command constitutes one transition. The +// Turn. Accepting one command constitutes one transition. The // interface is sealed: only the variants below exist. Commands may carry // transient content bodies (frozen request, model result, tool output); the // facts they produce keep only digests. @@ -13,10 +13,10 @@ 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. -// AgentInput is one input as the Run knows it: its identity and the digest of +// AgentInput is one input as the Turn knows it: its identity and the digest of // its content. The content itself lives with the module that owns inputs -// (the chatlog's input_submitted); the Run only records that this input -// entered this logical run, so an input has one body in the +// (the chatlog's input_submitted); the Turn only records that this input +// entered this logical Turn, so an input has one body in the // ledger and editing or forking it never touches a second copy. type AgentInput struct { ID InputID `json:"id"` @@ -43,7 +43,7 @@ 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 +// predates inputs that have since been accepted; the Turn returns to Open so // the next Prepare includes them. Legal only while PendingInputs is non-empty. type WithdrawPreparedStep struct { StepID StepID `json:"stepId"` @@ -64,7 +64,7 @@ type StartModelExecution struct { func (StartModelExecution) agentCommand() {} // RecoverModelExecution withdraws an Executing ModelStep whose model effect -// is lost: no provider result was accepted and none can be reached. The Run +// is lost: no provider result was accepted and none can be reached. The Turn // returns to Open and the next Prepare plans from the current state (the // frozen request is a transfer copy for the Executor, not a replay target). // Sources: the Loop when a dispatched effect ends without a result, or the @@ -111,14 +111,14 @@ 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 + // ModelRejectFailTurn records the malformed result and fails the Turn in the // same transition. - ModelRejectFailRun + ModelRejectFailTurn ) // RejectModelResult records a structurally malformed model result: usage is // accumulated, the step's reject counter is incremented, and Disposition -// decides whether the same frozen request retries or the Run fails. It +// decides whether the same frozen request retries or the Turn fails. It // settles the step's executing model effect. type RejectModelResult struct { StepID StepID `json:"stepId"` @@ -157,7 +157,7 @@ func (SubmitToolResult) agentCommand() {} // SubmitToolFailure submits the known or unknown failure of one Executing // call's tool effect. Known outcome records the failure the execution // reported; Unknown outcome records ToolCallFailed(Unknown) for the call and -// leaves the Run active. A call that fails before its effect is requested is +// leaves the Turn active. A call that fails before its effect is requested is // declined instead (DeclineToolCall). type SubmitToolFailure struct { StepID StepID `json:"stepId"` @@ -222,13 +222,14 @@ type SubmitToolResponse struct { func (SubmitToolResponse) agentCommand() {} -// CancelRun stops a non-terminal Run as a business cancellation. Hosts must +// CancelTurn stops a non-terminal Turn as a business cancellation. Hosts must // commit this before cancelling the Loop's context. -type CancelRun struct { - Reason RunReason `json:"reason,omitempty"` +type CancelTurn struct { + Message string `json:"message,omitempty"` + Reason TurnReason `json:"reason,omitempty"` } -func (CancelRun) agentCommand() {} +func (CancelTurn) agentCommand() {} // AcceptInput appends an ordered, non-empty list of inputs to PendingInputs as // one command: every input is accepted or none is. Legal in every non-terminal diff --git a/agentcore/turn/creation.go b/agentcore/turn/creation.go new file mode 100644 index 00000000..12aa34af --- /dev/null +++ b/agentcore/turn/creation.go @@ -0,0 +1,44 @@ +package turn + +import ( + "errors" + "github.com/felinics/twilight/agentcore/preset/ref" + "unicode/utf8" + + "github.com/felinics/twilight/agentcore/ledger" +) + +// NewTurn is immutable Turn creation data. The caller supplies TurnID so +// retries retain a stable identity; Preset fixes the configuration throughout +// the Turn, including recovery and ownership changes. +type NewTurn struct { + TurnID TurnID `json:"turnId"` + Preset ref.Ref `json:"preset"` + CausationID ledger.CausationID `json:"causationId,omitempty"` +} + +// BuildNewTurn constructs a Turn creation value. +func BuildNewTurn(turnID TurnID, causationID ledger.CausationID, preset ref.Ref) (NewTurn, error) { + value := NewTurn{TurnID: turnID, CausationID: causationID, Preset: preset} + if err := ValidateNewTurn(value); err != nil { + return NewTurn{}, err + } + return value, nil +} + +// ValidateNewTurn verifies the preset reference and textual identity encoding. +func ValidateNewTurn(value NewTurn) error { + if value.Preset.ID == "" || value.Preset.Digest == "" { + return errors.New("turn: preset identity and digest are required") + } + if value.TurnID == "" { + return errors.New("agent: new turn: empty TurnID") + } + if !utf8.ValidString(string(value.TurnID)) { + return errors.New("agent: new turn: TurnID is not valid UTF-8") + } + if !utf8.ValidString(string(value.CausationID)) { + return errors.New("agent: new turn: CausationID is not valid UTF-8") + } + return nil +} diff --git a/agentcore/turn/creation_test.go b/agentcore/turn/creation_test.go new file mode 100644 index 00000000..aa851ae7 --- /dev/null +++ b/agentcore/turn/creation_test.go @@ -0,0 +1,33 @@ +package turn + +import ( + "github.com/felinics/twilight/agentcore/preset/ref" + "testing" + + "github.com/felinics/twilight/agentcore/ledger" +) + +func mustNewTurn(t testing.TB, id TurnID, cause ledger.CausationID) NewTurn { + t.Helper() + run, err := BuildNewTurn(id, cause, ref.Ref{ID: "test", Digest: "sha256:test"}) + if err != nil { + t.Fatal(err) + } + return run +} + +func TestNewRunValidation(t *testing.T) { + created := mustNewTurn(t, "run-1", "session-1") + if created.TurnID != "run-1" { + t.Fatalf("run id = %q", created.TurnID) + } + for _, candidate := range []NewTurn{ + {}, + {TurnID: TurnID(string([]byte{0xff}))}, + {TurnID: "run-1", CausationID: ledger.CausationID(string([]byte{0xff}))}, + } { + if err := ValidateNewTurn(candidate); err == nil { + t.Fatalf("invalid NewTurn accepted: %+v", candidate) + } + } +} diff --git a/agentcore/run/decide.go b/agentcore/turn/decide.go similarity index 92% rename from agentcore/run/decide.go rename to agentcore/turn/decide.go index 31415175..d05a7e58 100644 --- a/agentcore/run/decide.go +++ b/agentcore/turn/decide.go @@ -1,10 +1,10 @@ -package run +package turn import ( "errors" "fmt" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/turn/model" ) // Machine errors. EvaluateCommit maps rejection reasons onto these; Decide @@ -13,7 +13,7 @@ import ( var ( ErrCommandConflict = errors.New("agent: command identity conflict") ErrStaleRuntime = errors.New("agent: stale runtime revision or grant") - ErrRunTerminal = errors.New("agent: run is terminal") + ErrTurnTerminal = errors.New("agent: turn is terminal") ) // rejectionf wraps a precondition failure that is not one of the sentinel @@ -24,14 +24,13 @@ func rejectionf(format string, args ...any) error { // Decide produces the facts a command yields against s, or the rejection // -// //nolint:gocritic // hugeParam: v1 Decide is value-based. func (m StateMachine) Decide(s MachineState, c AgentCommand) ([]Fact, error) { if err := m.bound(); err != nil { return nil, err } if s.Status.Terminal() { - return nil, ErrRunTerminal + return nil, ErrTurnTerminal } switch cmd := c.(type) { case PrepareModelRequest: @@ -62,8 +61,8 @@ func (m StateMachine) Decide(s MachineState, c AgentCommand) ([]Fact, error) { return m.decideRejectToolCall(&s, &cmd) case SubmitToolResponse: return m.decideSubmitToolResponse(&s, &cmd) - case CancelRun: - return decideCancelRun(&s, cmd) + case CancelTurn: + return decideCancelTurn(&s, cmd) case AcceptInput: return decideAcceptInput(&s, cmd) default: @@ -75,7 +74,7 @@ func (m StateMachine) Decide(s MachineState, c AgentCommand) ([]Fact, error) { func (m StateMachine) decidePrepareModelRequest(s *MachineState, cmd *PrepareModelRequest) ([]Fact, error) { if !atOpen(s.Current) { - return nil, rejectionf("prepare: run is not at Open") + return nil, rejectionf("prepare: turn is not at Open") } if cmd.StepID == "" { return nil, rejectionf("prepare: empty StepID") @@ -173,7 +172,7 @@ func (m StateMachine) decideStartModelExecution(s *MachineState, cmd StartModelE if cmd.Effect == "" { return nil, rejectionf("start model: missing effect identity") } - if want := m.Identity.DeriveEffectID(s.RunID, cmd.StepID, "", ms.Rejects); cmd.Effect != want { + if want := m.Identity.DeriveEffectID(s.TurnID, cmd.StepID, "", ms.Rejects); cmd.Effect != want { return nil, rejectionf("start model: effect %q is not the step's next model effect", cmd.Effect) } return []Fact{ModelStepStarted(cmd)}, nil @@ -230,23 +229,23 @@ func (m StateMachine) decideSubmitModelResult(s *MachineState, cmd *SubmitModelR // 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. + // a Turn whose model asked for tools. if len(cmd.Result.ToolCalls) == 0 { if len(cmd.Calls) != 0 { return nil, rejectionf("model result: %d bindings for a result with no tool calls", len(cmd.Calls)) } - // Inputs that arrived during this step keep the Run alive: the next - // Prepare consumes them. Only an empty queue ends the Run. + // Inputs that arrived during this step keep the Turn alive: the next + // Prepare consumes them. Only an empty queue ends the Turn. if len(s.PendingInputs) > 0 { return []Fact{completed}, nil } - return []Fact{completed, RunEnded{End: RunCompletedEnd{}}}, nil + return []Fact{completed, TurnEnded{End: TurnCompletedEnd{}}}, nil } bindings, err := m.checkToolCallBindings(ms, cmd) if err != nil { return nil, err } - opened, err := m.openToolStep(s.RunID, cmd.StepID, bindings, cmd.Scheduling) + opened, err := m.openToolStep(s.TurnID, cmd.StepID, bindings, cmd.Scheduling) if err != nil { return nil, err } @@ -320,7 +319,7 @@ func (m StateMachine) checkBindingAgainstResult(b *ToolCallBinding, rc *model.Mo // openToolStep derives the ToolStep identity from its source, attaches a // ResponseRequest to every call whose policy waits, and freezes the // scheduling. -func (m StateMachine) openToolStep(runID RunID, source StepID, bindings []ToolCallBinding, scheduling ToolScheduling) (ToolStepOpened, error) { +func (m StateMachine) openToolStep(turnID TurnID, source StepID, bindings []ToolCallBinding, scheduling ToolScheduling) (ToolStepOpened, error) { toolStepID := m.Identity.DeriveToolStepID(source) for i := range bindings { kind, waits := responseKindForPolicy(bindings[i].Policy) @@ -328,10 +327,10 @@ func (m StateMachine) openToolStep(runID RunID, source StepID, bindings []ToolCa continue } bindings[i].Response = &ResponseRequest{ - RunID: runID, + TurnID: turnID, StepID: toolStepID, CallID: bindings[i].CallID, - ID: m.Identity.DeriveResponseID(runID, toolStepID, bindings[i].CallID, kind), + ID: m.Identity.DeriveResponseID(turnID, toolStepID, bindings[i].CallID, kind), Kind: kind, Payload: bindings[i].Arguments, } @@ -370,9 +369,9 @@ func decideSubmitModelFailure(s *MachineState, cmd SubmitModelFailure) ([]Fact, } return []Fact{ ModelStepFailed(cmd), - RunEnded{End: RunFailedEnd{ + TurnEnded{End: TurnFailedEnd{ Reason: reason, - Failure: RunFailure{Class: cmd.Failure.Class, Message: cmd.Failure.Message}, + Failure: TurnFailure{Class: cmd.Failure.Class, Message: cmd.Failure.Message}, }}, }, nil } @@ -394,10 +393,10 @@ func decideRejectModelResult(s *MachineState, cmd *RejectModelResult) ([]Fact, e switch cmd.Disposition { case ModelRejectRetry: return []Fact{rejected}, nil - case ModelRejectFailRun: - return []Fact{rejected, RunEnded{End: RunFailedEnd{ + case ModelRejectFailTurn: + return []Fact{rejected, TurnEnded{End: TurnFailedEnd{ Reason: ReasonMalformedModel, - Failure: RunFailure{Class: FailureMalformedModel, Message: cmd.Failure.Message}, + Failure: TurnFailure{Class: FailureMalformedModel, Message: cmd.Failure.Message}, }}}, nil default: return nil, rejectionf("reject model result: unknown disposition %d", cmd.Disposition) @@ -434,7 +433,7 @@ func (m StateMachine) decideStartToolCall(s *MachineState, cmd StartToolCall) ([ if cmd.Effect == "" { return nil, rejectionf("start tool: missing effect identity") } - if want := m.Identity.DeriveEffectID(s.RunID, cmd.StepID, cmd.CallID, 0); cmd.Effect != want { + if want := m.Identity.DeriveEffectID(s.TurnID, cmd.StepID, cmd.CallID, 0); cmd.Effect != want { return nil, rejectionf("start tool: effect %q is not the call's tool effect", cmd.Effect) } return []Fact{ToolCallStarted(cmd)}, nil @@ -579,8 +578,8 @@ func (m StateMachine) decideRejectToolCall(s *MachineState, cmd *RejectToolCall) // Reject closes a Waiting call of either kind as a Known failure: // approval rejection and external-response abandonment ("the answer is // never coming") share one exit. Waiting -> Failed(Known) is legal; - // without this, an abandoned ask-user call would strand the run with - // CancelRun as the only escape. + // without this, an abandoned ask-user call would strand the Turn with + // CancelTurn as the only escape. ts, err := currentToolStep(s, cmd.StepID) if err != nil { return nil, err @@ -632,8 +631,8 @@ func (m StateMachine) decideSubmitToolResponse(s *MachineState, cmd *SubmitToolR // --- rules 12-13: cancel and input --- -func decideCancelRun(s *MachineState, cmd CancelRun) ([]Fact, error) { - // CancelRun always records RunStopped(cancelled). +func decideCancelTurn(s *MachineState, cmd CancelTurn) ([]Fact, error) { + // CancelTurn always records TurnStopped(cancelled). if cmd.Reason != "" && cmd.Reason != ReasonCancelled { return nil, rejectionf("cancel: reason must be empty or %q", ReasonCancelled) } @@ -645,22 +644,23 @@ func decideCancelRun(s *MachineState, cmd CancelRun) ([]Fact, error) { } } // A model call still executing is settled as unknown under its effect - // before the Run stops: RunEnded closes no effect by itself. + // before the Turn stops: TurnEnded closes no effect by itself. var uncertainModel StepID if ms, ok := s.Current.(ModelStep); ok && ms.Status == ModelExecuting { uncertainModel = ms.RefValue.ID facts = append(facts, ModelStepFailed{StepID: ms.RefValue.ID, Effect: ms.Effect, Failure: StepFailure{Class: FailureEffectUnknown, Message: "cancelled while the model call was executing"}}) } - facts = append(facts, RunEnded{End: RunStoppedEnd{ + facts = append(facts, TurnEnded{End: TurnStoppedEnd{ Reason: ReasonCancelled, + Message: cmd.Message, UncertainCalls: uncertain, UncertainModel: uncertainModel, }}) return facts, nil } -// cancelledToolCalls settles every unfinished call before the Run stops. +// cancelledToolCalls settles every unfinished call before the Turn stops. func cancelledToolCalls(s *MachineState) []Fact { ts, ok := s.Current.(ToolStep) if !ok { @@ -668,7 +668,7 @@ func cancelledToolCalls(s *MachineState) []Fact { } facts := make([]Fact, 0, len(ts.Calls)) for i := range ts.Calls { - failure := ToolFailure{Class: FailureCancelled, Message: "run cancelled before tool execution"} + failure := ToolFailure{Class: FailureCancelled, Message: "turn cancelled before tool execution"} outcome := ToolOutcomeKnown switch ts.Calls[i].Status { case ToolPending, ToolWaiting: @@ -684,7 +684,7 @@ func cancelledToolCalls(s *MachineState) []Fact { } // decideAcceptInput queues a batch of inputs in any non-terminal state -// PendingInputs is the durable mid-run input queue, consumed by +// PendingInputs is the durable mid-Turn input queue, consumed by // the next Prepare. A Prepared step with a non-empty queue is withdrawn by // Next. The batch is all-or-nothing: an empty list or an empty InputID is a // rejection, and an input already pending, or repeated inside the batch, is diff --git a/agentcore/turn/doc.go b/agentcore/turn/doc.go new file mode 100644 index 00000000..97e119f5 --- /dev/null +++ b/agentcore/turn/doc.go @@ -0,0 +1,17 @@ +// Package turn defines one logical agent execution. A Turn owns an immutable +// preset reference, accepted inputs, a deterministic state machine, and its +// complete fact history. It starts once and ends once; process recovery and +// ownership changes continue the same identity. +// +// Decide and Evolve express legal state transitions under schema-bound +// identity and canonicalization rules. A Step is logical progress, an Effect +// is one external request, and a Wait is an external response a tool lacks. +// Physical execution attempts, workers, leases, and backend handles belong to +// the executor. The Turn addresses external work through EffectID. +// +// Subpackages model, canonical, wire, frozen, schema, plan, and store define +// durable values, protocols, and ports. Package effect defines the executor +// boundary; loop interprets decisions; reconcile handles execution recovery. +// Package sessionstore binds these protocols to the Session ledger and owns +// both the inherited history surface and the own-only active machine. +package turn diff --git a/agentcore/run/effect/classify.go b/agentcore/turn/effect/classify.go similarity index 100% rename from agentcore/run/effect/classify.go rename to agentcore/turn/effect/classify.go diff --git a/agentcore/run/effect/classify_test.go b/agentcore/turn/effect/classify_test.go similarity index 76% rename from agentcore/run/effect/classify_test.go rename to agentcore/turn/effect/classify_test.go index 6192adc8..38245c6f 100644 --- a/agentcore/run/effect/classify_test.go +++ b/agentcore/turn/effect/classify_test.go @@ -7,8 +7,8 @@ import ( "net" "testing" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" "github.com/felinics/twilight/sdk" ) @@ -29,17 +29,17 @@ func TestClassifyModelError(t *testing.T) { name string err error want effect.FailureCode - retry run.RetryDisposition + retry turn.RetryDisposition }{ - {"429", &sdk.APIError{StatusCode: 429}, effect.FailureRateLimited, run.RetryAllowed}, - {"503 wrapped", errors.Join(errors.New("openai"), &sdk.APIError{StatusCode: 503}), effect.FailureProviderUnavailable, run.RetryAllowed}, - {"401", &sdk.APIError{StatusCode: 401}, effect.FailureAuthentication, run.RetryNever}, - {"402", &sdk.APIError{StatusCode: 402}, effect.FailureBilling, run.RetryNever}, - {"400", &sdk.APIError{StatusCode: 400}, effect.FailureBadRequest, run.RetryNever}, - {"connection reset", timeoutError{}, effect.FailureConnection, run.RetryAllowed}, - {"unexpected eof", io.ErrUnexpectedEOF, effect.FailureConnection, run.RetryAllowed}, - {"plain error", errors.New("boom"), effect.FailureExecutor, run.RetryNever}, - {"deadline as net error", &net.OpError{Err: context.DeadlineExceeded}, effect.FailureDeadline, run.RetryNever}, + {"429", &sdk.APIError{StatusCode: 429}, effect.FailureRateLimited, turn.RetryAllowed}, + {"503 wrapped", errors.Join(errors.New("openai"), &sdk.APIError{StatusCode: 503}), effect.FailureProviderUnavailable, turn.RetryAllowed}, + {"401", &sdk.APIError{StatusCode: 401}, effect.FailureAuthentication, turn.RetryNever}, + {"402", &sdk.APIError{StatusCode: 402}, effect.FailureBilling, turn.RetryNever}, + {"400", &sdk.APIError{StatusCode: 400}, effect.FailureBadRequest, turn.RetryNever}, + {"connection reset", timeoutError{}, effect.FailureConnection, turn.RetryAllowed}, + {"unexpected eof", io.ErrUnexpectedEOF, effect.FailureConnection, turn.RetryAllowed}, + {"plain error", errors.New("boom"), effect.FailureExecutor, turn.RetryNever}, + {"deadline as net error", &net.OpError{Err: context.DeadlineExceeded}, effect.FailureDeadline, turn.RetryNever}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/agentcore/run/effect/discovery.go b/agentcore/turn/effect/discovery.go similarity index 96% rename from agentcore/run/effect/discovery.go rename to agentcore/turn/effect/discovery.go index 3cbfbca6..d58ef1e0 100644 --- a/agentcore/run/effect/discovery.go +++ b/agentcore/turn/effect/discovery.go @@ -44,7 +44,7 @@ type OutstandingPage struct { // acknowledged executions are excluded. Outcomes must remain readable until // safe acknowledgement; time or an expired lease is not settlement proof. // -// Keys are sorted lexicographically by the tuple (Session, RunID, Effect), +// Keys are sorted lexicographically by the tuple (Session, OwnerKind, OwnerID, Effect), // not by a concatenated encoding. after is exclusive; its zero value starts // a scan. limit must be positive. Each page reflects durable state at its // read, not a snapshot spanning calls: concurrent new keys behind the cursor diff --git a/agentcore/turn/effect/owner_test.go b/agentcore/turn/effect/owner_test.go new file mode 100644 index 00000000..89b1f1d1 --- /dev/null +++ b/agentcore/turn/effect/owner_test.go @@ -0,0 +1,44 @@ +package effect + +import ( + "encoding/json" + "testing" +) + +func TestAssignmentOwnerNamespaces(t *testing.T) { + a := Assignment{Session: "s", OwnerKind: OwnerTurn, OwnerID: "same", Effect: "same", Body: ModelAssignment{Model: "test", RequestDigest: "sha256:test"}} + b := a + b.OwnerKind = OwnerCompaction + if a.Key() == b.Key() { + t.Fatal("execution owners collided") + } + ad, err := a.Digest() + if err != nil { + t.Fatal(err) + } + bd, err := b.Digest() + if err != nil || ad == bd { + t.Fatalf("owner not part of immutable assignment: %s %s %v", ad, bd, err) + } + for _, want := range []Assignment{a, b} { + raw, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + var got Assignment + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatal(err) + } + if got.Key() != want.Key() || got.Kind() != want.Kind() { + t.Fatalf("roundtrip = %+v, want %+v", got, want) + } + if err := got.Key().Validate(); err != nil { + t.Fatal(err) + } + } + for _, key := range []AssignmentKey{{}, {Session: "s", OwnerKind: OwnerTurn, OwnerID: "same"}, {Session: "s", OwnerKind: "unknown", OwnerID: "same", Effect: "same"}} { + if key.Validate() == nil { + t.Fatalf("invalid key accepted: %+v", key) + } + } +} diff --git a/agentcore/run/effect/progress.go b/agentcore/turn/effect/progress.go similarity index 100% rename from agentcore/run/effect/progress.go rename to agentcore/turn/effect/progress.go diff --git a/agentcore/run/effect/replay_test.go b/agentcore/turn/effect/replay_test.go similarity index 60% rename from agentcore/run/effect/replay_test.go rename to agentcore/turn/effect/replay_test.go index fb6b246f..3afeb41a 100644 --- a/agentcore/run/effect/replay_test.go +++ b/agentcore/turn/effect/replay_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" ) // The tool's Replay policy travels with the Assignment across the wire and @@ -15,18 +15,18 @@ import ( func TestToolAssignmentCarriesReplayPolicy(t *testing.T) { cases := []struct { name string - policy run.ReplayPolicy + policy turn.ReplayPolicy onWire bool - decoded run.ReplayPolicy + decoded turn.ReplayPolicy }{ - {"allowed", run.ReplayAllowed, true, run.ReplayAllowed}, - {"forbidden", run.ReplayForbidden, true, run.ReplayForbidden}, - {"unknown", run.ReplayUnknown, false, run.ReplayUnknown}, + {"allowed", turn.ReplayAllowed, true, turn.ReplayAllowed}, + {"forbidden", turn.ReplayForbidden, true, turn.ReplayForbidden}, + {"unknown", turn.ReplayUnknown, false, turn.ReplayUnknown}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - a := effect.Assignment{Session: "s", RunID: "r", StepID: "step", CallID: "c1", Effect: "e", - Body: effect.ToolAssignment{ToolRef: "lookup", Arguments: run.MustParseCanonicalJSON(`{}`), Policy: run.DirectExecution, Replay: tc.policy}} + a := effect.Assignment{Session: "s", OwnerKind: effect.OwnerTurn, OwnerID: string("r"), StepID: "step", CallID: "c1", Effect: "e", + Body: effect.ToolAssignment{ToolRef: "lookup", Arguments: turn.MustParseCanonicalJSON(`{}`), Policy: turn.DirectExecution, Replay: tc.policy}} raw, err := json.Marshal(a) if err != nil { t.Fatal(err) diff --git a/agentcore/run/effect/settlement.go b/agentcore/turn/effect/settlement.go similarity index 100% rename from agentcore/run/effect/settlement.go rename to agentcore/turn/effect/settlement.go diff --git a/agentcore/run/effect/state_test.go b/agentcore/turn/effect/state_test.go similarity index 100% rename from agentcore/run/effect/state_test.go rename to agentcore/turn/effect/state_test.go diff --git a/agentcore/run/effect/types.go b/agentcore/turn/effect/types.go similarity index 86% rename from agentcore/run/effect/types.go rename to agentcore/turn/effect/types.go index ebb25d2a..76c63de6 100644 --- a/agentcore/run/effect/types.go +++ b/agentcore/turn/effect/types.go @@ -1,4 +1,4 @@ -// Package effect defines the process-independent protocol between the Run +// Package effect defines the process-independent protocol between the Turn // Loop and the component that performs model/tool effects. It contains no // transport binding and no persistence implementation. package effect @@ -10,8 +10,8 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" ) // AssignmentKind names the effect requested by an Assignment. @@ -22,16 +22,34 @@ const ( AssignmentTool AssignmentKind = "tool" ) -// AssignmentKey identifies one effect as the execution plane addresses it. -// Session is the Run's Scope (its Session, in Twilight) and part of the -// identity, so a shared executor cannot collide two stores that happen to use -// the same Run and effect identifiers. Effect is the Run's own name for the -// effect and the only handle the two planes share; which step or -// call requested it travels in the Assignment for the backend's use. +// OwnerKind identifies the protocol that owns an external effect. +type OwnerKind string + +const ( + OwnerTurn OwnerKind = "turn" + OwnerCompaction OwnerKind = "compaction" +) + +// AssignmentKey identifies an external request within one Session and owner +// namespace. Step and call coordinates travel separately in the Assignment. type AssignmentKey struct { - Session run.Scope - RunID run.RunID - Effect run.EffectID + Session turn.Scope + OwnerKind OwnerKind + OwnerID string + Effect turn.EffectID +} + +// Validate rejects incomplete keys and unknown execution owner namespaces. +func (k AssignmentKey) Validate() error { + if k.Session == "" || k.OwnerID == "" || k.Effect == "" { + return errors.New("effect: session, owner and effect are required") + } + switch k.OwnerKind { + case OwnerTurn, OwnerCompaction: + return nil + default: + return fmt.Errorf("effect: unknown owner kind %q", k.OwnerKind) + } } // AssignmentBody is the sealed effect an Assignment asks for: a model call @@ -43,26 +61,26 @@ type AssignmentBody interface { } type ModelAssignment struct { - Model run.ModelRef `json:"model"` + Model turn.ModelRef `json:"model"` Request *model.ModelRequest `json:"request,omitempty"` - RequestDigest run.Digest `json:"requestDigest"` + RequestDigest turn.Digest `json:"requestDigest"` } func (ModelAssignment) Kind() AssignmentKind { return AssignmentModel } func (ModelAssignment) assignmentBody() {} type ToolAssignment struct { - ToolRef run.ToolRef - DefinitionDigest run.Digest - Arguments run.CanonicalJSON - Policy run.ResponsePolicy + ToolRef turn.ToolRef + DefinitionDigest turn.Digest + Arguments turn.CanonicalJSON + Policy turn.ResponsePolicy // Replay is the tool's declared replay policy, copied from the frozen // call: the Worker that adopts a lost execution decides from it alone // Omitted on the wire when unknown. - Replay run.ReplayPolicy `json:",omitempty"` + Replay turn.ReplayPolicy `json:",omitempty"` // Placement is the tool's declared placement, copied from the frozen // call: the Worker's routes select the backend by it. - Placement run.ToolPlacement `json:",omitempty"` + Placement turn.ToolPlacement `json:",omitempty"` } func (ToolAssignment) Kind() AssignmentKind { return AssignmentTool } @@ -70,18 +88,19 @@ func (ToolAssignment) assignmentBody() {} // Assignment is the complete immutable description of one external effect. type Assignment struct { - Session run.Scope - RunID run.RunID - StepID run.StepID - CallID run.CallID - Effect run.EffectID - Target *run.TargetRef + Session turn.Scope + OwnerKind OwnerKind + OwnerID string + StepID turn.StepID + CallID turn.CallID + Effect turn.EffectID + Target *turn.TargetRef // Body is the effect: exactly one of ModelAssignment or ToolAssignment. Body AssignmentBody } func (a Assignment) Key() AssignmentKey { - return AssignmentKey{Session: a.Session, RunID: a.RunID, Effect: a.Effect} + return AssignmentKey{Session: a.Session, OwnerKind: a.OwnerKind, OwnerID: a.OwnerID, Effect: a.Effect} } // Kind is the body's kind; empty for an Assignment without a body. @@ -104,7 +123,7 @@ func (a Assignment) Tool() (ToolAssignment, bool) { return t, ok } -func (a Assignment) Digest() (run.Digest, error) { +func (a Assignment) Digest() (turn.Digest, error) { return jsonstable.DigestCanonical(a) } @@ -112,19 +131,20 @@ func (a Assignment) Digest() (run.Digest, error) { // object. It is what the execution store persists and the HTTP protocol // carries; decoding refuses a shape that names one kind and carries another. type assignmentWire struct { - Session run.Scope - RunID run.RunID - StepID run.StepID - CallID run.CallID - Effect run.EffectID - Target *run.TargetRef - Kind AssignmentKind - Model *ModelAssignment - Tool *ToolAssignment + Session turn.Scope + OwnerKind OwnerKind + OwnerID string + StepID turn.StepID + CallID turn.CallID + Effect turn.EffectID + Target *turn.TargetRef + Kind AssignmentKind + Model *ModelAssignment + Tool *ToolAssignment } func (a Assignment) MarshalJSON() ([]byte, error) { - w := assignmentWire{Session: a.Session, RunID: a.RunID, StepID: a.StepID, CallID: a.CallID, Effect: a.Effect, Target: a.Target, Kind: a.Kind()} + w := assignmentWire{Session: a.Session, OwnerKind: a.OwnerKind, OwnerID: a.OwnerID, StepID: a.StepID, CallID: a.CallID, Effect: a.Effect, Target: a.Target, Kind: a.Kind()} switch b := a.Body.(type) { case ModelAssignment: w.Model = &b @@ -142,7 +162,7 @@ func (a *Assignment) UnmarshalJSON(raw []byte) error { if err := json.Unmarshal(raw, &w); err != nil { return err } - out := Assignment{Session: w.Session, RunID: w.RunID, StepID: w.StepID, CallID: w.CallID, Effect: w.Effect, Target: w.Target} + out := Assignment{Session: w.Session, OwnerKind: w.OwnerKind, OwnerID: w.OwnerID, StepID: w.StepID, CallID: w.CallID, Effect: w.Effect, Target: w.Target} switch { case w.Kind == AssignmentModel && w.Model != nil && w.Tool == nil: out.Body = *w.Model @@ -210,12 +230,12 @@ const ( // answer. The model call is the template every effect follows: classify the // failure first, then read the disposition off the class; the disposition // is never stored beside the code. -func (c FailureCode) Retry() run.RetryDisposition { +func (c FailureCode) Retry() turn.RetryDisposition { switch c { case FailureRateLimited, FailureProviderUnavailable, FailureConnection: - return run.RetryAllowed + return turn.RetryAllowed default: - return run.RetryNever + return turn.RetryNever } } @@ -234,7 +254,7 @@ type ModelFailed struct { } // Retry is the disposition of this failure, derived from its code. -func (f ModelFailed) Retry() run.RetryDisposition { return f.Code.Retry() } +func (f ModelFailed) Retry() turn.RetryDisposition { return f.Code.Retry() } // ToolExecutionOutcome is the sealed result a tool implementation returns: // succeeded, failed-known, or unknown. Each is also an OutcomeResult. @@ -243,23 +263,23 @@ type ToolExecutionOutcome interface { toolExecutionOutcome() } -type ToolExecutionSucceeded struct{ Result run.ToolExecutionResult } +type ToolExecutionSucceeded struct{ Result turn.ToolExecutionResult } // ToolExecutionFailed is a Known failure the tool reports: the effect did // not complete. Failure.Class says what went wrong; Retry is the tool's // answer for this failure alone. RetryAllowed asserts more // than "this looks transient": that this Known failure suffices to confirm // the attempt produced no external effect that cannot safely be repeated, -// so the next attempt of the same Assignment may run. A failure that cannot +// so the next attempt of the same Assignment may turn. A failure that cannot // confirm that (a payment call that timed out) is not a ToolExecutionFailed // at all but a ToolExecutionUnknown, which enters replay and reconciliation // A tool that does not judge leaves RetryUnknown, never retried. type ToolExecutionFailed struct { - Failure run.ToolFailure - Retry run.RetryDisposition + Failure turn.ToolFailure + Retry turn.RetryDisposition } -type ToolExecutionUnknown struct{ Failure run.ToolFailure } +type ToolExecutionUnknown struct{ Failure turn.ToolFailure } // Cancelled: the executor stopped the effect as requested; Message says why. type Cancelled struct{ Message string } @@ -282,7 +302,7 @@ func (ToolExecutionFailed) toolExecutionOutcome() {} func (ToolExecutionUnknown) toolExecutionOutcome() {} // Outcome is the process-independent result after an Assignment has been -// accepted. The wire protocol (agent/executor/protocol) encodes Result as a tagged +// accepted. The wire protocol (agentcore/executor/protocol) encodes Result as a tagged // envelope; there is no Go error in it. type Outcome struct { Key AssignmentKey @@ -312,7 +332,7 @@ func (o Outcome) Status() ExecutionStatus { } // ExecutionStatus is the lifecycle state of an accepted effect, independent -// of the Run state machine. +// of the Turn state machine. type ExecutionStatus string const ( @@ -447,7 +467,7 @@ type Recoverer interface { // It is intentionally message-shaped: none of its methods accepts a // process-local callback. type ExecutionPort interface { - Validate(context.Context, Assignment) (*run.ToolFailure, error) + Validate(context.Context, Assignment) (*turn.ToolFailure, error) Dispatch(context.Context, Assignment) error Attach(context.Context, AssignmentKey) (Attachment, error) // Abort closes key when nothing has been accepted for it, so that no diff --git a/agentcore/run/effect/watcher.go b/agentcore/turn/effect/watcher.go similarity index 100% rename from agentcore/run/effect/watcher.go rename to agentcore/turn/effect/watcher.go diff --git a/agentcore/run/evolve.go b/agentcore/turn/evolve.go similarity index 87% rename from agentcore/run/evolve.go rename to agentcore/turn/evolve.go index ede0493d..755b94ea 100644 --- a/agentcore/run/evolve.go +++ b/agentcore/turn/evolve.go @@ -1,10 +1,10 @@ -package run +package turn import ( "errors" "fmt" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/turn/model" ) // Evolve is the fold semantics. It first @@ -20,8 +20,8 @@ func (m StateMachine) Evolve(s MachineState, f Fact) (MachineState, error) { return s, err } switch fact := f.(type) { - case RunCreated: - return applyRunCreated(&fact), nil + case TurnStarted: + return applyTurnStarted(&fact), nil case ModelStepPrepared: return applyModelStepPrepared(s, &fact), nil case ModelStepWithdrawn: @@ -56,8 +56,8 @@ func (m StateMachine) Evolve(s MachineState, f Fact) (MachineState, error) { }), nil case InputAccepted: return applyInputAccepted(s, &fact), nil - case RunEnded: - return applyRunEnded(s, &fact), nil + case TurnEnded: + return applyTurnEnded(s, &fact), nil default: return s, fmt.Errorf("agent: evolve: unknown fact variant %T", f) } @@ -65,13 +65,13 @@ func (m StateMachine) Evolve(s MachineState, f Fact) (MachineState, error) { // --- apply: mechanical folds; guardFact has established every precondition --- -func applyRunCreated(fact *RunCreated) MachineState { - return MachineState{RunID: fact.RunID, Status: RunActive, Current: Open{}} +func applyTurnStarted(fact *TurnStarted) MachineState { + return MachineState{TurnID: fact.TurnID, Preset: fact.Preset, Status: TurnActive, Current: Open{}} } func applyModelStepPrepared(s MachineState, fact *ModelStepPrepared) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state s.Current = ModelStep{ - RefValue: StepRef{RunID: s.RunID, ID: fact.StepID}, + RefValue: StepRef{TurnID: s.TurnID, ID: fact.StepID}, RequestDigest: fact.RequestDigest, Model: fact.Model, Tools: fact.Tools, @@ -83,7 +83,7 @@ func applyModelStepPrepared(s MachineState, fact *ModelStepPrepared) MachineStat } // applyModelStepWithdrawn discards the current step: Withdrawn (Prepared, -// never sent) and Recovered (Executing, attempt lost) both return the Run to +// never sent) and Recovered (Executing, attempt lost) both return the Turn to // Open without counting a model step. PendingInputs are untouched, so the // next Prepare consumes them. func applyModelStepWithdrawn(s MachineState) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state @@ -113,8 +113,8 @@ func applyModelEffect(s MachineState, effect EffectID) MachineState { //nolint:g return s } -// applyModelStepFailed settles the step's effect and leaves the Run Open for -// the RunEnded that follows in the same group. The step ran, so ModelSteps +// applyModelStepFailed settles the step's effect and leaves the Turn Open for +// the TurnEnded that follows in the same group. The step ran, so ModelSteps // keeps counting it. func applyModelStepFailed(s MachineState) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state s.Current = Open{} @@ -147,7 +147,7 @@ func applyToolStepOpened(s MachineState, fact *ToolStepOpened) MachineState { // } } s.Current = ToolStep{ - RefValue: StepRef{RunID: s.RunID, ID: fact.StepID}, + RefValue: StepRef{TurnID: s.TurnID, ID: fact.StepID}, Source: fact.Source, Calls: calls, Scheduling: fact.Scheduling, @@ -176,12 +176,13 @@ func applyInputAccepted(s MachineState, fact *InputAccepted) MachineState { //no return s } -func applyRunEnded(s MachineState, fact *RunEnded) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state +func applyTurnEnded(s MachineState, fact *TurnEnded) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state status, reason, failure := endProjection(fact.End) s.Status = status s.Current = nil - result := &RunResult{Status: status, Reason: reason, Failure: failure, Usage: s.Usage} - if stopped, ok := fact.End.(RunStoppedEnd); ok { + result := &TurnResult{Status: status, Reason: reason, Failure: failure, Usage: s.Usage} + if stopped, ok := fact.End.(TurnStoppedEnd); ok { + result.Message = stopped.Message result.UncertainCalls = append([]CallID(nil), stopped.UncertainCalls...) result.UncertainModel = stopped.UncertainModel } @@ -193,11 +194,11 @@ func applyRunEnded(s MachineState, fact *RunEnded) MachineState { //nolint:gocri // self-consistency the fact must carry. --- func (m StateMachine) guardFact(s *MachineState, f Fact) error { - if created, ok := f.(RunCreated); ok { - return guardRunCreated(s, &created) + if created, ok := f.(TurnStarted); ok { + return guardTurnStarted(s, &created) } - if s.RunID == "" { - return errors.New("agent: evolve: fact before RunCreated") + if s.TurnID == "" { + return errors.New("agent: evolve: fact before TurnStarted") } if s.Status.Terminal() { return errors.New("agent: evolve: fact after terminal state") @@ -257,8 +258,8 @@ func (m StateMachine) guardFact(s *MachineState, f Fact) error { return guardToolCallFailed(s, &fact) case InputAccepted: return guardInputAccepted(s, &fact) - case RunEnded: - if err := validateRunEnd(fact.End); err != nil { + case TurnEnded: + if err := validateTurnEnd(fact.End); err != nil { return err } return requireNoExecutingEffect(s) @@ -267,20 +268,20 @@ func (m StateMachine) guardFact(s *MachineState, f Fact) error { } } -// requireNoExecutingEffect: a Run ends only after every effect it requested -// was settled by a fact naming it. RunEnded closes no effect, so +// requireNoExecutingEffect: a Turn ends only after every effect it requested +// was settled by a fact naming it. TurnEnded closes no effect, so // an Executing model step or tool call at that point would be an effect no // settlement ever names. func requireNoExecutingEffect(s *MachineState) error { switch cur := s.Current.(type) { case ModelStep: if cur.Status == ModelExecuting { - return fmt.Errorf("agent: evolve: run ended while model step %q executes effect %q", cur.RefValue.ID, cur.Effect) + return fmt.Errorf("agent: evolve: turn ended while model step %q executes effect %q", cur.RefValue.ID, cur.Effect) } case ToolStep: for i := range cur.Calls { if cur.Calls[i].Status == ToolExecuting { - return fmt.Errorf("agent: evolve: run ended while tool call %q executes effect %q", cur.Calls[i].CallID, cur.Calls[i].Effect) + return fmt.Errorf("agent: evolve: turn ended while tool call %q executes effect %q", cur.Calls[i].CallID, cur.Calls[i].Effect) } } } @@ -289,7 +290,7 @@ func requireNoExecutingEffect(s *MachineState) error { 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 fmt.Errorf("agent: evolve: %s while turn is not at Open", what) } return nil } @@ -346,12 +347,15 @@ 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 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") +func guardTurnStarted(s *MachineState, fact *TurnStarted) error { + if s.TurnID != "" || s.Current != nil || s.Status != TurnActive { + return errors.New("agent: evolve: turn started on a non-zero state") } - if fact.RunID == "" { - return errors.New("agent: evolve: run created with empty RunID") + if err := ValidateNewTurn(NewTurn(*fact)); err != nil { + return err + } + if fact.TurnID == "" { + return errors.New("agent: evolve: turn started with empty TurnID") } return nil } @@ -414,14 +418,14 @@ func (m StateMachine) guardToolStepOpened(s *MachineState, fact *ToolStepOpened) } seen := make(map[CallID]struct{}, len(fact.Calls)) for i := range fact.Calls { - if err := m.guardToolCallBinding(s.RunID, fact.StepID, &fact.Calls[i], seen); err != nil { + if err := m.guardToolCallBinding(s.TurnID, fact.StepID, &fact.Calls[i], seen); err != nil { return err } } return nil } -func (m StateMachine) guardToolCallBinding(runID RunID, stepID StepID, call *ToolCallBinding, seen map[CallID]struct{}) error { +func (m StateMachine) guardToolCallBinding(turnID TurnID, stepID StepID, call *ToolCallBinding, seen map[CallID]struct{}) error { if call.CallID == "" { return errors.New("agent: evolve: tool step contains empty CallID") } @@ -439,7 +443,7 @@ func (m StateMachine) guardToolCallBinding(runID RunID, stepID StepID, call *Too if !ok { return fmt.Errorf("agent: evolve: direct call %q cannot carry a response request", call.CallID) } - return m.validateResponseRequest(call.Response, runID, stepID, call.CallID, kind, call.Arguments) + return m.validateResponseRequest(call.Response, turnID, stepID, call.CallID, kind, call.Arguments) } func (m StateMachine) guardToolCallApproved(s *MachineState, fact *ToolCallApproved) error { @@ -508,11 +512,11 @@ func responseKindForPolicy(p ResponsePolicy) (ResponseKind, bool) { } } -func (m StateMachine) validateResponseRequest(req *ResponseRequest, runID RunID, stepID StepID, callID CallID, kind ResponseKind, payload CanonicalJSON) error { - if req.RunID != runID || req.StepID != stepID || req.CallID != callID || req.Kind != kind { +func (m StateMachine) validateResponseRequest(req *ResponseRequest, turnID TurnID, stepID StepID, callID CallID, kind ResponseKind, payload CanonicalJSON) error { + if req.TurnID != turnID || 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 != m.Identity.DeriveResponseID(runID, stepID, callID, kind) { + if req.ID == "" || req.ID != m.Identity.DeriveResponseID(turnID, stepID, callID, kind) { return fmt.Errorf("agent: evolve: response request ID mismatch for call %q", callID) } if !req.Payload.Equal(payload) { diff --git a/agentcore/run/fact.go b/agentcore/turn/fact.go similarity index 65% rename from agentcore/run/fact.go rename to agentcore/turn/fact.go index 1181e68b..2c6948a2 100644 --- a/agentcore/run/fact.go +++ b/agentcore/turn/fact.go @@ -1,4 +1,4 @@ -package run +package turn import ( "bytes" @@ -8,7 +8,8 @@ import ( "io" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/preset/ref" + "github.com/felinics/twilight/agentcore/turn/model" ) // Fact is one committed outcome produced by Machine.Decide. Facts are wrapped @@ -20,18 +21,19 @@ import ( // projections of these facts; no second copy of a body is written. type Fact interface{ fact() } -// RunCreated is the first fact of a Run. Folding it onto the zero -// MachineState yields the initial state; a second RunCreated is an error. -type RunCreated struct { - RunID RunID `json:"runId"` +// TurnStarted is the first fact of a Turn. Folding it onto the zero +// MachineState yields the initial state; a second TurnStarted is an error. +type TurnStarted struct { + TurnID TurnID `json:"turnId"` + Preset ref.Ref `json:"preset"` CausationID ledger.CausationID `json:"causationId,omitempty"` } -func (RunCreated) fact() {} +func (TurnStarted) fact() {} // ModelStepPrepared establishes the frozen ModelStep and consumes the listed // pending inputs. The request body is not in the fact: RequestDigest names it -// in the frozen.Store. StepID derives from the Run and the preparing +// in the frozen.Store. StepID derives from the Turn and the preparing // command; the fact carries the step's content beside it. type ModelStepPrepared struct { StepID StepID `json:"stepId"` @@ -89,12 +91,12 @@ type ModelStepRejected struct { func (ModelStepRejected) fact() {} // ModelStepFailed: Executing -> Open. It settles the model effect the step -// was executing with its final failure; the Run does not continue from it. -// SubmitModelFailure writes it before RunEnded(failed) in the same group; -// CancelRun writes it with class effect_unknown for a model call still -// executing, before RunEnded(stopped). RunEnded itself settles no effect: -// every effect a Run requested is closed by a fact that names it -// so a Run never ends with a step or call still Executing. +// was executing with its final failure; the Turn does not continue from it. +// SubmitModelFailure writes it before TurnEnded(failed) in the same group; +// CancelTurn writes it with class effect_unknown for a model call still +// executing, before TurnEnded(stopped). TurnEnded itself settles no effect: +// every effect a Turn requested is closed by a fact that names it +// so a Turn never ends with a step or call still Executing. type ModelStepFailed struct { StepID StepID `json:"stepId"` // Effect is the model effect this failure settles. @@ -107,7 +109,7 @@ func (ModelStepFailed) fact() {} // ModelStepCompleted accepts one model result: usage is accumulated and // Current becomes Open. The result body is not in the fact; ResultDigest is // its canonical identity in the frozen.Store. The same transition may then -// open a ToolStep or end the Run. +// open a ToolStep or end the Turn. type ModelStepCompleted struct { StepID StepID `json:"stepId"` // Effect is the model effect this settlement closes: the fact names the @@ -189,112 +191,114 @@ type ToolCallFailed struct { func (ToolCallFailed) fact() {} // InputAccepted appends one input to PendingInputs. Legal in every -// non-terminal state; PendingInputs is the durable mid-run input queue. +// non-terminal state; PendingInputs is the durable mid-Turn input queue. type InputAccepted struct { Input AgentInput `json:"input"` } func (InputAccepted) fact() {} -// RunEnd is the closed set of terminal outcomes. -type RunEnd interface{ runEnd() } +// TurnEnd is the closed set of terminal outcomes. +type TurnEnd interface{ turnEnd() } -type RunCompletedEnd struct{} -type RunStoppedEnd struct { - Reason RunReason +type TurnCompletedEnd struct{} +type TurnStoppedEnd struct { + Message string `json:"message,omitempty"` + Reason TurnReason UncertainCalls []CallID `json:"uncertainCalls,omitempty"` UncertainModel StepID `json:"uncertainModel,omitempty"` } -type RunFailedEnd struct { - Reason RunReason - Failure RunFailure +type TurnFailedEnd struct { + Reason TurnReason + Failure TurnFailure } -func (RunCompletedEnd) runEnd() {} -func (RunStoppedEnd) runEnd() {} -func (RunFailedEnd) runEnd() {} +func (TurnCompletedEnd) turnEnd() {} +func (TurnStoppedEnd) turnEnd() {} +func (TurnFailedEnd) turnEnd() {} -// RunEnded is the terminal fact. Always the last fact of its transition. -type RunEnded struct { - End RunEnd `json:"-"` +// TurnEnded is the terminal fact. Always the last fact of its transition. +type TurnEnded struct { + End TurnEnd `json:"-"` } -func (RunEnded) fact() {} +func (TurnEnded) fact() {} -func validateRunEnd(end RunEnd) error { +func validateTurnEnd(end TurnEnd) error { switch e := end.(type) { - case RunCompletedEnd: + case TurnCompletedEnd: return nil - case RunStoppedEnd: + case TurnStoppedEnd: if e.Reason == "" { - return errors.New("agent: run ended: stopped outcome requires a reason") + return errors.New("agent: turn ended: stopped outcome requires a reason") } return nil - case RunFailedEnd: + case TurnFailedEnd: if e.Reason == "" { - return errors.New("agent: run ended: failed outcome requires a reason") + return errors.New("agent: turn ended: failed outcome requires a reason") } if e.Failure.Class == "" { - return errors.New("agent: run ended: failed outcome requires a failure class") + return errors.New("agent: turn ended: failed outcome requires a failure class") } return nil default: - return fmt.Errorf("agent: run ended: unknown end variant %T", end) + return fmt.Errorf("agent: turn ended: unknown end variant %T", end) } } -func endProjection(end RunEnd) (RunStatus, RunReason, *RunFailure) { +func endProjection(end TurnEnd) (TurnStatus, TurnReason, *TurnFailure) { switch e := end.(type) { - case RunCompletedEnd: - return RunCompleted, "", nil - case RunStoppedEnd: - return RunStopped, e.Reason, nil - case RunFailedEnd: + case TurnCompletedEnd: + return TurnCompleted, "", nil + case TurnStoppedEnd: + return TurnStopped, e.Reason, nil + case TurnFailedEnd: failure := e.Failure - return RunFailed, e.Reason, &failure + return TurnFailed, e.Reason, &failure default: - return RunActive, "", nil + return TurnActive, "", nil } } -// runEndWire is the tagged-union wire of RunEnded: exactly one variant key is +// turnEndWire is the tagged-union wire of TurnEnded: 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 turnEndWire struct { + Completed *struct{} `json:"completed,omitempty"` + Stopped *turnStoppedEndWire `json:"stopped,omitempty"` + Failed *turnFailedEndWire `json:"failed,omitempty"` } -type runStoppedEndWire struct { - Reason RunReason `json:"reason"` - UncertainCalls []CallID `json:"uncertainCalls,omitempty"` - UncertainModel StepID `json:"uncertainModel,omitempty"` +type turnStoppedEndWire struct { + Message string `json:"message,omitempty"` + Reason TurnReason `json:"reason"` + UncertainCalls []CallID `json:"uncertainCalls,omitempty"` + UncertainModel StepID `json:"uncertainModel,omitempty"` } -type runFailedEndWire struct { - Reason RunReason `json:"reason"` - Failure RunFailure `json:"failure"` +type turnFailedEndWire struct { + Reason TurnReason `json:"reason"` + Failure TurnFailure `json:"failure"` } -func (r RunEnded) MarshalJSON() ([]byte, error) { - if err := validateRunEnd(r.End); err != nil { +func (r TurnEnded) MarshalJSON() ([]byte, error) { + if err := validateTurnEnd(r.End); err != nil { return nil, err } - var w runEndWire + var w turnEndWire switch e := r.End.(type) { - case RunCompletedEnd: + case TurnCompletedEnd: 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} + case TurnStoppedEnd: + w.Stopped = &turnStoppedEndWire{Reason: e.Reason, Message: e.Message, UncertainCalls: e.UncertainCalls, UncertainModel: e.UncertainModel} + case TurnFailedEnd: + w.Failed = &turnFailedEndWire{Reason: e.Reason, Failure: e.Failure} } return json.Marshal(w) } -func (r *RunEnded) UnmarshalJSON(raw []byte) error { - var w runEndWire +func (r *TurnEnded) UnmarshalJSON(raw []byte) error { + var w turnEndWire dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&w); err != nil { @@ -302,30 +306,30 @@ func (r *RunEnded) UnmarshalJSON(raw []byte) error { } if err := dec.Decode(&struct{}{}); err != io.EOF { if err == nil { - return errors.New("agent: run ended: trailing JSON") + return errors.New("agent: turn ended: trailing JSON") } return err } variants := 0 - var end RunEnd + var end TurnEnd if w.Completed != nil { variants++ - end = RunCompletedEnd{} + end = TurnCompletedEnd{} } if w.Stopped != nil { variants++ - end = RunStoppedEnd{Reason: w.Stopped.Reason, UncertainCalls: w.Stopped.UncertainCalls, UncertainModel: w.Stopped.UncertainModel} + end = TurnStoppedEnd{Reason: w.Stopped.Reason, Message: w.Stopped.Message, UncertainCalls: w.Stopped.UncertainCalls, UncertainModel: w.Stopped.UncertainModel} } if w.Failed != nil { variants++ - end = RunFailedEnd{Reason: w.Failed.Reason, Failure: w.Failed.Failure} + end = TurnFailedEnd{Reason: w.Failed.Reason, Failure: w.Failed.Failure} } if variants != 1 { - return fmt.Errorf("agent: run ended: exactly one outcome required, got %d", variants) + return fmt.Errorf("agent: turn ended: exactly one outcome required, got %d", variants) } - if err := validateRunEnd(end); err != nil { + if err := validateTurnEnd(end); err != nil { return err } - *r = RunEnded{End: end} + *r = TurnEnded{End: end} return nil } diff --git a/agentcore/turn/fact_test.go b/agentcore/turn/fact_test.go new file mode 100644 index 00000000..0bf3b25d --- /dev/null +++ b/agentcore/turn/fact_test.go @@ -0,0 +1,28 @@ +package turn + +import ( + "encoding/json" + "testing" +) + +// TurnEnded validates its tagged terminal value on the way to the wire: no +// end, an incomplete end or an end variant the schema does not know is +// refused by MarshalJSON, so no fact encoder can persist it. +func TestTurnEndedTaggedUnionRejectsInvalidValues(t *testing.T) { + for name, fact := range map[string]TurnEnded{ + "nil end": {}, + "stopped without reason": {End: TurnStoppedEnd{}}, + "failed without class": {End: TurnFailedEnd{Reason: ReasonProviderFailure}}, + "unknown end variant": {End: fakeTurnEnd{}}, + } { + t.Run(name, func(t *testing.T) { + if _, err := json.Marshal(fact); err == nil { + t.Fatal("invalid tagged terminal value was accepted") + } + }) + } +} + +type fakeTurnEnd struct{} + +func (fakeTurnEnd) turnEnd() {} diff --git a/agentcore/run/frozen/doc.go b/agentcore/turn/frozen/doc.go similarity index 83% rename from agentcore/run/frozen/doc.go rename to agentcore/turn/frozen/doc.go index 6d9dbd6e..d98e5e11 100644 --- a/agentcore/run/frozen/doc.go +++ b/agentcore/turn/frozen/doc.go @@ -1,4 +1,4 @@ -// Package frozen is the frozen-body protocol of a Run. Facts name +// Package frozen is the frozen-body protocol of a Turn. Facts name // the model request, model result, tool output and external tool response by // digest only; Store is the port to the content-addressed side store that holds // the bodies, and Codec stores each body as the typed envelope its digest was diff --git a/agentcore/run/frozen/frozen.go b/agentcore/turn/frozen/frozen.go similarity index 72% rename from agentcore/run/frozen/frozen.go rename to agentcore/turn/frozen/frozen.go index f74d8a0d..41ff7bd4 100644 --- a/agentcore/run/frozen/frozen.go +++ b/agentcore/turn/frozen/frozen.go @@ -7,29 +7,29 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/canonical" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/canonical" + "github.com/felinics/twilight/agentcore/turn/model" ) -// Store is the run layer's port to the content-addressed side store for +// Store is the Turn layer's port to the content-addressed side store for // the bodies that facts name by digest only: the // ModelRequest of a Prepared step, the ModelResult of a completed step, a // tool output and an external tool response. The digest is the SHA-256 of // the stored bytes, so a body is one cas entry whose key is its digest; the // adapter that realizes this port over a cas ContentStore lives in -// run/sessionstore. Put is idempotent. The fact that names a body is its +// turn/sessionstore. Put is idempotent. The fact that names a body is its // retention root: the body stays readable as long as the Session ledger // holds the fact. type Store interface { - Put(ctx context.Context, digest run.Digest, val []byte) error - Get(ctx context.Context, digest run.Digest) ([]byte, bool, error) + Put(ctx context.Context, digest turn.Digest, val []byte) error + Get(ctx context.Context, digest turn.Digest) ([]byte, bool, error) } // Authority is the cas Authority under which frozen bodies are stored. -// It is a string, not an artifact type, because run does not depend on +// It is a string, not an artifact type, because Turn does not depend on // artifact; the adapter converts it. -const Authority = "twilight/run/frozen" +const Authority = "twilight/turn/frozen" // ErrMissing reports that a body named by a fact is not in the Store. For a // request, recovery cannot resend it and the caller @@ -47,45 +47,45 @@ const envelopeVersion uint16 = 1 // envelope type canonical declares for it, so sha256(bytes) == digest. type Bodies struct{} -func (Bodies) EncodeRequest(req *model.ModelRequest, want run.Digest) ([]byte, error) { +func (Bodies) EncodeRequest(req *model.ModelRequest, want turn.Digest) ([]byte, error) { return encodeFrozen(envelopeVersion, canonical.RequestType, *req, want) } -func (Bodies) EncodeModelResult(result *model.ModelResult, want run.Digest) ([]byte, error) { +func (Bodies) EncodeModelResult(result *model.ModelResult, want turn.Digest) ([]byte, error) { return encodeFrozen(envelopeVersion, canonical.ModelResultType, *result, want) } -func (Bodies) EncodeToolOutput(output run.CanonicalJSON, want run.Digest) ([]byte, error) { +func (Bodies) EncodeToolOutput(output turn.CanonicalJSON, want turn.Digest) ([]byte, error) { if output.IsZero() { return nil, errors.New("agent: frozen tool output: empty output") } return encodeFrozen(envelopeVersion, canonical.ToolOutputType, canonical.ToolOutputBody{Output: output}, want) } -func (Bodies) EncodeToolResponse(payload run.CanonicalJSON, want run.Digest) ([]byte, error) { +func (Bodies) EncodeToolResponse(payload turn.CanonicalJSON, want turn.Digest) ([]byte, error) { return encodeFrozen(envelopeVersion, canonical.ToolResponseType, canonical.ToolResponsePayloadBody{Payload: payload}, want) } -func (Bodies) DecodeRequest(raw []byte, want run.Digest) (model.ModelRequest, error) { +func (Bodies) DecodeRequest(raw []byte, want turn.Digest) (model.ModelRequest, error) { return decodeFrozen[model.ModelRequest](raw, envelopeVersion, canonical.RequestType, want) } -func (Bodies) DecodeModelResult(raw []byte, want run.Digest) (model.ModelResult, error) { +func (Bodies) DecodeModelResult(raw []byte, want turn.Digest) (model.ModelResult, error) { return decodeFrozen[model.ModelResult](raw, envelopeVersion, canonical.ModelResultType, want) } -func (Bodies) DecodeToolOutput(raw []byte, want run.Digest) (run.CanonicalJSON, error) { +func (Bodies) DecodeToolOutput(raw []byte, want turn.Digest) (turn.CanonicalJSON, error) { body, err := decodeFrozen[canonical.ToolOutputBody](raw, envelopeVersion, canonical.ToolOutputType, want) if err != nil { - return run.CanonicalJSON{}, err + return turn.CanonicalJSON{}, err } return body.Output, nil } -func (Bodies) DecodeToolResponse(raw []byte, want run.Digest) (run.CanonicalJSON, error) { +func (Bodies) DecodeToolResponse(raw []byte, want turn.Digest) (turn.CanonicalJSON, error) { body, err := decodeFrozen[canonical.ToolResponsePayloadBody](raw, envelopeVersion, canonical.ToolResponseType, want) if err != nil { - return run.CanonicalJSON{}, err + return turn.CanonicalJSON{}, err } return body.Payload, nil } @@ -105,7 +105,7 @@ func checkEnvelope(raw []byte) error { // DecodeRequest restores a request body and checks it still digests to // the name it was stored under. The version comes from the body itself. -func DecodeRequest(raw []byte, want run.Digest) (model.ModelRequest, error) { +func DecodeRequest(raw []byte, want turn.Digest) (model.ModelRequest, error) { if err := checkEnvelope(raw); err != nil { return model.ModelRequest{}, err } @@ -114,7 +114,7 @@ func DecodeRequest(raw []byte, want run.Digest) (model.ModelRequest, error) { } // DecodeModelResult restores a model result named by ResultDigest. -func DecodeModelResult(raw []byte, want run.Digest) (model.ModelResult, error) { +func DecodeModelResult(raw []byte, want turn.Digest) (model.ModelResult, error) { if err := checkEnvelope(raw); err != nil { return model.ModelResult{}, err } @@ -123,9 +123,9 @@ func DecodeModelResult(raw []byte, want run.Digest) (model.ModelResult, error) { } // DecodeToolOutput restores a tool output named by OutputDigest. -func DecodeToolOutput(raw []byte, want run.Digest) (run.CanonicalJSON, error) { +func DecodeToolOutput(raw []byte, want turn.Digest) (turn.CanonicalJSON, error) { if err := checkEnvelope(raw); err != nil { - return run.CanonicalJSON{}, err + return turn.CanonicalJSON{}, err } codec := Bodies{} return codec.DecodeToolOutput(raw, want) @@ -133,15 +133,15 @@ func DecodeToolOutput(raw []byte, want run.Digest) (run.CanonicalJSON, error) { // DecodeToolResponse restores an external tool response named by // ResponseDigest. -func DecodeToolResponse(raw []byte, want run.Digest) (run.CanonicalJSON, error) { +func DecodeToolResponse(raw []byte, want turn.Digest) (turn.CanonicalJSON, error) { if err := checkEnvelope(raw); err != nil { - return run.CanonicalJSON{}, err + return turn.CanonicalJSON{}, err } codec := Bodies{} return codec.DecodeToolResponse(raw, want) } -func encodeFrozen(version uint16, typ string, body any, want run.Digest) ([]byte, error) { //nolint:unparam // version is the caller schema's; only v1 exists today. +func encodeFrozen(version uint16, typ string, body any, want turn.Digest) ([]byte, error) { //nolint:unparam // version is the caller schema's; only v1 exists today. raw, err := jsonstable.EncodeTypedPayload(version, typ, body) if err != nil { return nil, err @@ -152,7 +152,7 @@ func encodeFrozen(version uint16, typ string, body any, want run.Digest) ([]byte return raw, nil } -func decodeFrozen[T any](raw []byte, version uint16, typ string, want run.Digest) (T, error) { +func decodeFrozen[T any](raw []byte, version uint16, typ string, want turn.Digest) (T, error) { var zero T if got := jsonstable.DigestBytes(raw); got != want { return zero, fmt.Errorf("agent: frozen %s: stored body digest %s does not match %s", typ, got, want) diff --git a/agentcore/turn/golden_test.go b/agentcore/turn/golden_test.go deleted file mode 100644 index 8600bcc3..00000000 --- a/agentcore/turn/golden_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package turn_test - -import ( - "testing" - - "github.com/felinics/twilight/agentcore/chatlog" - "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/turn" -) - -func freezeTurn(t *testing.T, name, got, want string) { - t.Helper() - if got == want { - return - } - if want == "" { - t.Errorf("UNSET %s = %s", name, got) - return - } - t.Errorf("golden %s drifted — an intentional wire change must update this fixture and agent-turn.md:\n got: %s\nwant: %s", name, got, want) -} - -// TestTurnDerivationGolden freezes the Turn's derived identities. -func TestTurnDerivationGolden(t *testing.T) { - plan := turn.PlanDigest("turn-1", jsonstable.Digest("sha256:aa"), []chatlog.InputID{"in-1", "in-2"}) - freezeTurn(t, "plan digest", string(plan), "sha256:5194f3d5319f06e2362c816ca4dded69e8b9c30298c067c4508248f33a8d94a4") - - op := turn.StartOperationDigest("sess-1", "turn-1", plan) - freezeTurn(t, "start operation digest", string(op), "sha256:26e6b4f4a9c416dac8a6dcb4d24eb40b3c707d1f0b5a6e0fd9de3a1bbbf4ea8f") - - freezeTurn(t, "run id", string(turn.DeriveRunID("sess-1", "turn-1")), "sha256:46a41c9b32ce837aa0d4910bbc47a86150a0c27fd120a7432ed5558a3f854601") -} diff --git a/agentcore/run/ids.go b/agentcore/turn/ids.go similarity index 73% rename from agentcore/run/ids.go rename to agentcore/turn/ids.go index 80e97690..99e14ff5 100644 --- a/agentcore/run/ids.go +++ b/agentcore/turn/ids.go @@ -1,10 +1,10 @@ -package run +package turn import ( "github.com/felinics/twilight/agentcore/jsonstable" ) -type RunID string +type TurnID string type StepID string type CallID string type CommandID string @@ -22,7 +22,7 @@ type TargetRef struct { } // Digest is "sha256:<64 lowercase hex>" over canonical protocol bytes. -// It remains an alias while Run protocol types live in this package. +// It remains an alias while Turn protocol types live in this package. type Digest = jsonstable.Digest // PromptToken is opaque to agent; the application uses it to identify the @@ -33,24 +33,24 @@ type PromptToken string // the model call of one ModelStep or one tool call of a ToolStep // (Identity.DeriveEffectID). The requester fixes the effect's kind and // binding -- a ModelStep's effect is its model call under RequestDigest, a -// tool call's effect is the call with its frozen arguments -- so the Run carries -// no effect record beside the requester. It is the only handle the Run and -// the execution plane share: the Run records it in the start fact and +// tool call's effect is the call with its frozen arguments -- so the Turn carries +// no effect record beside the requester. It is the only handle the Turn and +// the execution plane share: the Turn records it in the start fact and // settles or recovers the effect under CommandIDs derived from it; the // executor keys the attempts it makes for the effect by it. Which worker // runs an attempt, under what lease and with what backend handle never -// enters the Run. +// enters the Turn. type EffectID string -// Scope is the opaque identity of the store one Run lives in: a Session in -// Twilight. Run never interprets it. It only scopes what has to stay distinct +// Scope is the opaque identity of the store one Turn lives in: a Session in +// Twilight. Turn never interprets it. It only scopes what has to stay distinct // across stores -- execution keys handed to a shared executor, the prompt // builder's read of the surrounding conversation -- and the adapter that -// realizes RunStore converts it to and from its own identity type. +// realizes TurnStore converts it to and from its own identity type. type Scope string -// RunPosition is the index of a Run's last fact in the Run's own stream. -// Only the Run's own facts move it; whatever else the surrounding store +// TurnPosition is the index of a Turn's last fact in the Turn's own stream. +// Only the Turn's own facts move it; whatever else the surrounding store // appends leaves it untouched, which is what makes Prepare's hard CAS // insensitive to concurrent writes of other modules. -type RunPosition uint64 +type TurnPosition uint64 diff --git a/agentcore/turn/input_test.go b/agentcore/turn/input_test.go new file mode 100644 index 00000000..db48ab09 --- /dev/null +++ b/agentcore/turn/input_test.go @@ -0,0 +1,10 @@ +package turn_test + +import ( + "github.com/felinics/twilight/agentcore/jsonstable" + "github.com/felinics/twilight/agentcore/turn" +) + +// inputDigest names an input body in tests: the Turn stores only the digest, +// so any content-derived digest stands in for the chatlog's. +func inputDigest(raw string) turn.Digest { return jsonstable.DigestBytes([]byte(raw)) } diff --git a/agentcore/run/json.go b/agentcore/turn/json.go similarity index 97% rename from agentcore/run/json.go rename to agentcore/turn/json.go index c82020db..a0fd7e74 100644 --- a/agentcore/run/json.go +++ b/agentcore/turn/json.go @@ -1,4 +1,4 @@ -package run +package turn import ( "github.com/felinics/twilight/agentcore/jsonstable" diff --git a/agentcore/run/loop/acknowledgesettled_test.go b/agentcore/turn/loop/acknowledgesettled_test.go similarity index 68% rename from agentcore/run/loop/acknowledgesettled_test.go rename to agentcore/turn/loop/acknowledgesettled_test.go index fca447f7..5b60567c 100644 --- a/agentcore/run/loop/acknowledgesettled_test.go +++ b/agentcore/turn/loop/acknowledgesettled_test.go @@ -5,9 +5,9 @@ import ( "errors" "testing" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/store" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/store" ) // These fakes expose only the operations ACK repair is allowed to use. An @@ -37,25 +37,25 @@ func (e *ackRepairExecutor) Acknowledge(context.Context, effect.AssignmentKey) e } type ackRepairStore struct { - store.RunStore - effects map[run.EffectID][]run.Fact + store.TurnStore + effects map[turn.EffectID][]turn.Fact proofErr error lookups int committed store.CommitResult } -func (*ackRepairStore) Scope() run.Scope { return "session" } -func (*ackRepairStore) Load(context.Context, run.RunID) (store.Snapshot, error) { - return store.Snapshot{State: run.MachineState{Status: run.RunCompleted}}, nil +func (*ackRepairStore) Scope() turn.Scope { return "session" } +func (*ackRepairStore) Load(context.Context, turn.TurnID) (store.Snapshot, error) { + return store.Snapshot{State: turn.MachineState{Status: turn.TurnCompleted}}, nil } -func (s *ackRepairStore) EffectDisposition(_ context.Context, _ run.RunID, id run.EffectID) ([]run.Fact, bool, error) { +func (s *ackRepairStore) EffectDisposition(_ context.Context, _ turn.TurnID, id turn.EffectID) ([]turn.Fact, bool, error) { s.lookups++ facts, found := s.effects[id] return facts, found, s.proofErr } func (s *ackRepairStore) Commit(_ context.Context, _ store.CommitRequest) (store.CommitResult, error) { if s.effects == nil { - s.effects = map[run.EffectID][]run.Fact{} + s.effects = map[turn.EffectID][]turn.Fact{} } for _, fact := range s.committed.Facts { id := store.DispositionEffect(fact) @@ -67,7 +67,7 @@ func (s *ackRepairStore) Commit(_ context.Context, _ store.CommitRequest) (store } func ackKey() effect.AssignmentKey { - return effect.AssignmentKey{Session: "session", RunID: "run", Effect: "effect"} + return effect.AssignmentKey{Session: "session", OwnerKind: effect.OwnerTurn, OwnerID: string("run"), Effect: "effect"} } func TestAcknowledgeSettledRepairsDroppedDelivery(t *testing.T) { @@ -75,19 +75,19 @@ func TestAcknowledgeSettledRepairsDroppedDelivery(t *testing.T) { key := ackKey() boom := errors.New("ACK response lost") exec := &ackRepairExecutor{attachment: effect.Attachment{State: effect.AttachmentTerminal, Execution: effect.ExecutionCompleted}, ackErr: boom, readable: true} - st := &ackRepairStore{committed: store.CommitResult{Facts: []run.Fact{run.ModelStepCompleted{Effect: key.Effect}}}} + st := &ackRepairStore{committed: store.CommitResult{Facts: []turn.Fact{turn.ModelStepCompleted{Effect: key.Effect}}}} l := &Loop{Executor: exec} - ref := effectRef{runID: key.RunID, id: key.Effect} - if _, err := l.settle(ctx, st, nil, &ref, 0, run.SubmitModelResult{}); err != nil { + ref := effectRef{turnID: turn.TurnID(key.OwnerID), id: key.Effect} + if _, err := l.settle(ctx, st, nil, &ref, 0, turn.SubmitModelResult{}); err != nil { t.Fatal(err) } if exec.acks != 1 || !exec.readable { t.Fatalf("failed best-effort ACK: calls=%d readable=%v", exec.acks, exec.readable) } // A process restart loses the CommitResult and in-memory Loop. Only the - // durable effect map survives. Deliver sees the terminal Run and drops. + // durable effect map survives. Deliver sees the terminal Turn and drops. st = &ackRepairStore{effects: st.effects} - l = &Loop{Executor: exec, slots: map[run.RunID]*runSlot{}} + l = &Loop{Executor: exec, slots: map[turn.TurnID]*turnSlot{}} res, err := l.Deliver(ctx, st, effect.Outcome{Key: key}, nil) if err != nil || res.Disposition != LoopDropped || exec.acks != 1 { t.Fatalf("late delivery=%+v err=%v ACKs=%d", res, err, exec.acks) @@ -107,12 +107,12 @@ func TestAcknowledgeSettledRepairsDroppedDelivery(t *testing.T) { func TestAcknowledgeSettledRequiresEffectProof(t *testing.T) { ctx := context.Background() key := ackKey() - cases := map[string]map[run.EffectID][]run.Fact{ + cases := map[string]map[turn.EffectID][]turn.Fact{ "absent": {}, - "start is not settlement": {key.Effect: {run.ModelStepStarted{Effect: key.Effect}}}, - "RunEnded alone": {key.Effect: {run.RunEnded{End: run.RunCompletedEnd{}}}}, - "another effect": {key.Effect: {run.ToolCallCompleted{Effect: "other"}}}, - "empty effect": {key.Effect: {run.ToolCallFailed{}}}, + "start is not settlement": {key.Effect: {turn.ModelStepStarted{Effect: key.Effect}}}, + "TurnEnded alone": {key.Effect: {turn.TurnEnded{End: turn.TurnCompletedEnd{}}}}, + "another effect": {key.Effect: {turn.ToolCallCompleted{Effect: "other"}}}, + "empty effect": {key.Effect: {turn.ToolCallFailed{}}}, } for name, effects := range cases { t.Run(name, func(t *testing.T) { @@ -128,12 +128,12 @@ func TestAcknowledgeSettledRequiresEffectProof(t *testing.T) { func TestAcknowledgeSettledDispositionVariants(t *testing.T) { key := ackKey() - for _, fact := range []run.Fact{ - run.ModelStepCompleted{Effect: key.Effect}, run.ModelStepRejected{Effect: key.Effect}, run.ModelStepFailed{Effect: key.Effect}, - run.ToolCallCompleted{Effect: key.Effect}, run.ToolCallFailed{Effect: key.Effect, Outcome: run.ToolOutcomeUnknown}, - run.ModelStepRecovered{Effect: key.Effect}, + for _, fact := range []turn.Fact{ + turn.ModelStepCompleted{Effect: key.Effect}, turn.ModelStepRejected{Effect: key.Effect}, turn.ModelStepFailed{Effect: key.Effect}, + turn.ToolCallCompleted{Effect: key.Effect}, turn.ToolCallFailed{Effect: key.Effect, Outcome: turn.ToolOutcomeUnknown}, + turn.ModelStepRecovered{Effect: key.Effect}, } { - st := &ackRepairStore{effects: map[run.EffectID][]run.Fact{key.Effect: {fact}}} + st := &ackRepairStore{effects: map[turn.EffectID][]turn.Fact{key.Effect: {fact}}} exec := &ackRepairExecutor{attachment: effect.Attachment{State: effect.AttachmentTerminal, Execution: effect.ExecutionUnknown}, readable: true} acked, err := (&Loop{Executor: exec}).AcknowledgeSettled(context.Background(), st, key) if err != nil || !acked || exec.acks != 1 { @@ -158,7 +158,7 @@ func TestRecoveryACKRequiresClosedExecution(t *testing.T) { {"unknown", effect.Attachment{State: effect.AttachmentTerminal, Execution: effect.ExecutionUnknown}, true}, } { t.Run(tc.name, func(t *testing.T) { - st := &ackRepairStore{effects: map[run.EffectID][]run.Fact{key.Effect: {run.ModelStepRecovered{Effect: key.Effect}}}} + st := &ackRepairStore{effects: map[turn.EffectID][]turn.Fact{key.Effect: {turn.ModelStepRecovered{Effect: key.Effect}}}} exec := &ackRepairExecutor{attachment: tc.att, readable: true} acked, err := (&Loop{Executor: exec}).AcknowledgeSettled(context.Background(), st, key) if err != nil || acked != tc.ack || exec.probes != 1 || (exec.acks == 1) != tc.ack || exec.readable == tc.ack { @@ -174,7 +174,7 @@ func TestAcknowledgeSettledErrorsDoNotAuthorizeCollection(t *testing.T) { boom := errors.New("storage unavailable") for _, where := range []string{"proof", "probe", "ACK"} { t.Run(where, func(t *testing.T) { - st := &ackRepairStore{effects: map[run.EffectID][]run.Fact{key.Effect: {run.ToolCallCompleted{Effect: key.Effect}}}} + st := &ackRepairStore{effects: map[turn.EffectID][]turn.Fact{key.Effect: {turn.ToolCallCompleted{Effect: key.Effect}}}} exec := &ackRepairExecutor{attachment: effect.Attachment{State: effect.AttachmentTerminal, Execution: effect.ExecutionCompleted}, readable: true} switch where { case "proof": @@ -193,7 +193,7 @@ func TestAcknowledgeSettledErrorsDoNotAuthorizeCollection(t *testing.T) { st := &ackRepairStore{} exec := &ackRepairExecutor{} l := &Loop{Executor: exec} - if _, err := l.AcknowledgeSettled(ctx, struct{ store.RunStore }{st}, key); !errors.Is(err, store.ErrEffectProofUnsupported) { + if _, err := l.AcknowledgeSettled(ctx, struct{ store.TurnStore }{st}, key); !errors.Is(err, store.ErrEffectProofUnsupported) { t.Fatalf("missing proof port=%v", err) } wrong := key @@ -202,3 +202,14 @@ func TestAcknowledgeSettledErrorsDoNotAuthorizeCollection(t *testing.T) { t.Fatalf("foreign scope ACK=%v err=%v lookups=%d", acked, err, st.lookups) } } + +func TestAcknowledgeSettledRejectsCompactionOwner(t *testing.T) { + key := ackKey() + key.OwnerKind = effect.OwnerCompaction + st := &ackRepairStore{effects: map[turn.EffectID][]turn.Fact{key.Effect: {turn.ToolCallCompleted{Effect: key.Effect}}}} + exec := &ackRepairExecutor{readable: true} + acked, err := (&Loop{Executor: exec}).AcknowledgeSettled(context.Background(), st, key) + if err == nil || acked || st.lookups != 0 || exec.probes != 0 || exec.acks != 0 || !exec.readable { + t.Fatalf("foreign owner: ACK=%v err=%v lookups=%d probes=%d ACKs=%d", acked, err, st.lookups, exec.probes, exec.acks) + } +} diff --git a/agentcore/run/loop/approval_test.go b/agentcore/turn/loop/approval_test.go similarity index 70% rename from agentcore/run/loop/approval_test.go rename to agentcore/turn/loop/approval_test.go index e3e87534..9d3dbbd0 100644 --- a/agentcore/run/loop/approval_test.go +++ b/agentcore/turn/loop/approval_test.go @@ -3,18 +3,18 @@ package loop_test import ( "testing" - "github.com/felinics/twilight/agentcore/run" + "github.com/felinics/twilight/agentcore/turn" ) func TestApprovalWaitsThenResumes(t *testing.T) { f := newFeature(t) - f.Tool("echo", run.ApprovalRequired) + f.Tool("echo", turn.ApprovalRequired) f.Model(ToolCalls("echo", "c1"), Text("after")) f.Run() - f.RequireWaiting(run.ResponseApproval) + f.RequireWaiting(turn.ResponseApproval) f.RequireNotRan("echo") w := f.Waiting() - if err := f.TryCommit(run.ApproveToolCall{ + if err := f.TryCommit(turn.ApproveToolCall{ StepID: w.StepID, CallID: w.CallID, ResponseID: w.ID, ResponseDigest: "sha256:bad", }); err == nil { t.Fatal("approval with bad response digest accepted") @@ -28,24 +28,24 @@ func TestApprovalWaitsThenResumes(t *testing.T) { func TestApprovalRejectIsPermissionDenied(t *testing.T) { f := newFeature(t) - f.Tool("echo", run.ApprovalRequired) + f.Tool("echo", turn.ApprovalRequired) f.Model(ToolCalls("echo", "c1")) f.Run() - f.RequireWaiting(run.ResponseApproval) + f.RequireWaiting(turn.ResponseApproval) f.Reject("no") f.RequireActive() f.RequireOpen() - f.RequireFailureClass(run.FailurePermissionDenied) + f.RequireFailureClass(turn.FailurePermissionDenied) f.RequireNotRan("echo") } func TestApprovalYieldsAfterDirectExecution(t *testing.T) { f := newFeature(t) - f.Tool("ask", run.ApprovalRequired) - f.Tool("work", run.DirectExecution) + f.Tool("ask", turn.ApprovalRequired) + f.Tool("work", turn.DirectExecution) f.Model(Calls(Call("ask", "cA"), Call("work", "cB")), Text("after")) f.Run() - f.RequireWaiting(run.ResponseApproval) + f.RequireWaiting(turn.ResponseApproval) f.RequireWaitingProvider("cA") f.RequireRan("work") f.RequireNotRan("ask") diff --git a/agentcore/run/loop/cancel_test.go b/agentcore/turn/loop/cancel_test.go similarity index 85% rename from agentcore/run/loop/cancel_test.go rename to agentcore/turn/loop/cancel_test.go index 513d9e61..29a16f67 100644 --- a/agentcore/run/loop/cancel_test.go +++ b/agentcore/turn/loop/cancel_test.go @@ -3,7 +3,7 @@ package loop_test import ( "testing" - "github.com/felinics/twilight/agentcore/run" + "github.com/felinics/twilight/agentcore/turn" ) func TestCancelStopsIdleRun(t *testing.T) { @@ -17,7 +17,7 @@ func TestCancelStopsIdleRun(t *testing.T) { func TestCancelProjectsExecutingTool(t *testing.T) { f := newFeature(t) - f.Tool("echo", run.DirectExecution) + f.Tool("echo", turn.DirectExecution) f.ExecutingTool("echo", "c1") f.Cancel() f.RequireStopped() diff --git a/agentcore/run/loop/candidate_test.go b/agentcore/turn/loop/candidate_test.go similarity index 83% rename from agentcore/run/loop/candidate_test.go rename to agentcore/turn/loop/candidate_test.go index 8381576f..368eb6ff 100644 --- a/agentcore/run/loop/candidate_test.go +++ b/agentcore/turn/loop/candidate_test.go @@ -8,11 +8,11 @@ import ( "testing" "github.com/felinics/twilight/agentcore/prompt" - run "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/store" "github.com/felinics/twilight/agentcore/testutil/local" + turn "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/store" "github.com/felinics/twilight/sdk" ) @@ -24,10 +24,10 @@ func (f candidateBuilderFunc) Build(ctx context.Context, input prompt.Input) (pr func TestCandidateChecksCompleteRequestBeforeEveryModelCall(t *testing.T) { rt, w := loopRuntime(t) - spec := toolSpec(t, "echo", run.DirectExecution) - tool := &fakeTool{ref: "echo", def: toolDef("echo"), policy: run.DirectExecution, + spec := toolSpec(t, "echo", turn.DirectExecution) + tool := &fakeTool{ref: "echo", def: toolDef("echo"), policy: turn.DirectExecution, execute: func(context.Context, local.ToolExecutionRequest) ToolExecutionOutcome { - return ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: cj(`"ok"`)}} + return ToolExecutionSucceeded{Result: turn.ToolExecutionResult{Output: cj(`"ok"`)}} }} invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("done")}} var built prompt.Prompt @@ -36,23 +36,23 @@ func TestCandidateChecksCompleteRequestBeforeEveryModelCall(t *testing.T) { builder := candidateBuilderFunc(func(ctx context.Context, input prompt.Input) (prompt.Prompt, error) { order = append(order, "build") builds++ - p, err := (staticBuilder{specs: []run.ToolSpec{spec}}).Build(ctx, input) + p, err := (staticBuilder{specs: []turn.ToolSpec{spec}}).Build(ctx, input) p.Request.System = "system policy" p.Request.Messages = append([]model.Message{{Role: model.MessageRoleSystem, Content: []model.MessagePart{{Type: model.MessagePartTypeText, Text: "system message"}}}}, p.Request.Messages...) - p.Token = run.PromptToken(fmt.Sprintf("opaque-%d", builds)) + p.Token = turn.PromptToken(fmt.Sprintf("opaque-%d", builds)) built = p return p, err }) settings := Settings{ - BeforePrepare: func(context.Context, store.RunStore, prompt.Input) error { + BeforePrepare: func(context.Context, store.TurnStore, prompt.Input) error { order = append(order, "before") return nil }, - CheckCandidate: func(_ context.Context, st store.RunStore, input prompt.Input, p *prompt.Prompt) (CandidateDecision, error) { + CheckCandidate: func(_ context.Context, st store.TurnStore, input prompt.Input, p *prompt.Prompt) (CandidateDecision, error) { order = append(order, "check") checks++ - if input.Scope != st.Scope() || input.RunID != "run-1" || !reflect.DeepEqual(p, &built) { + if input.Scope != st.Scope() || input.TurnID != "run-1" || !reflect.DeepEqual(p, &built) { t.Fatalf("candidate or input not passed intact: input=%+v candidate=%+v", input, p) } if p.Request.System != "system policy" || p.Request.Messages[0].Role != model.MessageRoleSystem || len(p.Request.Tools) != 1 || len(p.Tools) != 1 || p.Token == "" { @@ -61,7 +61,7 @@ func TestCandidateChecksCompleteRequestBeforeEveryModelCall(t *testing.T) { return CandidateReady, nil }, } - l, err := newLoop(t, nil, fakeCatalog{invoker}, fakeToolCatalog{map[run.ToolRef]local.ExecutableTool{"echo": tool}}, builder, settings, false) + l, err := newLoop(t, nil, fakeCatalog{invoker}, fakeToolCatalog{map[turn.ToolRef]local.ExecutableTool{"echo": tool}}, builder, settings, false) if err != nil { t.Fatal(err) } @@ -105,7 +105,7 @@ func TestCandidateMaintenanceAndErrorsDoNotPrepareOrDispatch(t *testing.T) { } return (staticBuilder{}).Build(ctx, input) }) - l, err := New(exec, builder, Settings{CheckCandidate: func(context.Context, store.RunStore, prompt.Input, *prompt.Prompt) (CandidateDecision, error) { + l, err := New(exec, builder, Settings{CheckCandidate: func(context.Context, store.TurnStore, prompt.Input, *prompt.Prompt) (CandidateDecision, error) { checks++ if test.budget != nil { exceeded, err := test.budget.Exceeded(71) @@ -153,16 +153,16 @@ func TestCandidateRebuildRefreshesHintAndPreparePosition(t *testing.T) { st := rt.Bind(w) before := loadState(t, rt, w, "run-1") exec := newRecordingExecutor() - var hints [][]run.InputID + var hints [][]turn.InputID builder := candidateBuilderFunc(func(ctx context.Context, input prompt.Input) (prompt.Prompt, error) { p, err := (staticBuilder{}).Build(ctx, input) - hints = append(hints, append([]run.InputID(nil), p.InputIDs...)) - p.Token = run.PromptToken(fmt.Sprintf("candidate-%d", len(hints))) + hints = append(hints, append([]turn.InputID(nil), p.InputIDs...)) + p.Token = turn.PromptToken(fmt.Sprintf("candidate-%d", len(hints))) return p, err }) checks := 0 - var freshPosition run.RunPosition - l, err := New(exec, builder, Settings{CheckCandidate: func(ctx context.Context, st store.RunStore, input prompt.Input, p *prompt.Prompt) (CandidateDecision, error) { + var freshPosition turn.TurnPosition + l, err := New(exec, builder, Settings{CheckCandidate: func(ctx context.Context, st store.TurnStore, input prompt.Input, p *prompt.Prompt) (CandidateDecision, error) { checks++ if checks == 1 { freshPosition = candidateAcceptInput(t, ctx, st, "new") @@ -180,20 +180,20 @@ func TestCandidateRebuildRefreshesHintAndPreparePosition(t *testing.T) { if err != nil || res.Disposition != LoopDispatched || checks != 2 || len(exec.dispatched) != 1 { t.Fatalf("Advance=%+v, %v checks=%d dispatch=%d", res, err, checks, len(exec.dispatched)) } - if !reflect.DeepEqual(hints, [][]run.InputID{{"seed"}, {"seed", "new"}}) || freshPosition == before.Position { + if !reflect.DeepEqual(hints, [][]turn.InputID{{"seed"}, {"seed", "new"}}) || freshPosition == before.Position { t.Fatalf("hints=%v positions=%v -> %v", hints, before.Position, freshPosition) } // A stale Prepare position would be rejected and cause a third Build. - step := loadState(t, rt, w, "run-1").State.Current.(run.ModelStep) + step := loadState(t, rt, w, "run-1").State.Current.(turn.ModelStep) wantStep := schema.Identity().DeriveModelStepID("run-1", schema.Identity().DeriveModelRequestCommandID("run-1", freshPosition)) if step.RefValue.ID != wantStep { t.Fatalf("prepared with stale position: step=%+v want=%s", step, wantStep) } prepared := 0 for _, fact := range recordFacts(t, rt, "run-1") { - if f, ok := fact.(run.ModelStepPrepared); ok { + if f, ok := fact.(turn.ModelStepPrepared); ok { prepared++ - if !reflect.DeepEqual(f.InputIDs, []run.InputID{"seed", "new"}) { + if !reflect.DeepEqual(f.InputIDs, []turn.InputID{"seed", "new"}) { t.Fatalf("prepared stale input IDs: %v", f.InputIDs) } } @@ -215,11 +215,11 @@ func TestCandidateRebuildRefreshesContextWithoutRunProgress(t *testing.T) { return p, err }) l, err := New(exec, builder, Settings{ - BeforePrepare: func(context.Context, store.RunStore, prompt.Input) error { + BeforePrepare: func(context.Context, store.TurnStore, prompt.Input) error { befores++ return nil }, - CheckCandidate: func(ctx context.Context, st store.RunStore, _ prompt.Input, p *prompt.Prompt) (CandidateDecision, error) { + CheckCandidate: func(ctx context.Context, st store.TurnStore, _ prompt.Input, p *prompt.Prompt) (CandidateDecision, error) { checks++ if checks == 1 { contextText = "new context" @@ -244,13 +244,13 @@ func TestCandidateRebuildRefreshesContextWithoutRunProgress(t *testing.T) { } } -func candidateAcceptInput(t *testing.T, ctx context.Context, st store.RunStore, id run.InputID) run.RunPosition { +func candidateAcceptInput(t *testing.T, ctx context.Context, st store.TurnStore, id turn.InputID) turn.TurnPosition { t.Helper() snap, err := st.Load(ctx, "run-1") if err != nil { t.Fatal(err) } - cmd := run.NextStep(run.AgentInput{ID: id, Digest: inputDigest(string(id))}) + cmd := turn.NextStep(turn.AgentInput{ID: id, Digest: inputDigest(string(id))}) env, err := schema.Wire().Envelope("run-1", schema.Identity().DeriveInputCommandID("run-1", id), cmd) if err != nil { t.Fatal(err) @@ -274,7 +274,7 @@ func TestCandidateRebuildBoundAndAuthorityProgress(t *testing.T) { if max == 0 { max = 4 } - l, err := New(exec, staticBuilder{}, Settings{MaxCandidateRebuilds: limit, CheckCandidate: func(ctx context.Context, st store.RunStore, _ prompt.Input, _ *prompt.Prompt) (CandidateDecision, error) { + l, err := New(exec, staticBuilder{}, Settings{MaxCandidateRebuilds: limit, CheckCandidate: func(ctx context.Context, st store.TurnStore, _ prompt.Input, _ *prompt.Prompt) (CandidateDecision, error) { checks++ if progress && !progressed && checks == max+1 { candidateAcceptInput(t, ctx, st, "progress") diff --git a/agentcore/run/loop/context_test.go b/agentcore/turn/loop/context_test.go similarity index 87% rename from agentcore/run/loop/context_test.go rename to agentcore/turn/loop/context_test.go index 9d1657eb..8d89cc1a 100644 --- a/agentcore/run/loop/context_test.go +++ b/agentcore/turn/loop/context_test.go @@ -6,13 +6,13 @@ import ( "testing" "time" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/store" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/store" "github.com/felinics/twilight/sdk" ) type blockedSettlement struct { - store.RunStore + store.TurnStore release <-chan struct{} } @@ -26,13 +26,13 @@ func (s blockedSettlement) Commit(ctx context.Context, _ store.CommitRequest) (s } func TestSettlementDeadlinePreservesOutcomeForRetry(t *testing.T) { - runs, w := loopRuntime(t) + turns, w := loopRuntime(t) lp, err := newLoop(t, nil, fakeCatalog{&fakeInvoker{results: []sdk.ModelResult{textResult("durable result")}}}, fakeToolCatalog{}, staticBuilder{}, Settings{}, false) if err != nil { t.Fatal(err) } - bound := runs.Bind(w) + bound := turns.Bind(w) step, err := lp.Advance(context.Background(), bound, "run-1", nil) if err != nil || len(step.Dispatched) != 1 { t.Fatalf("advance=%+v error=%v", step, err) @@ -51,7 +51,7 @@ func TestSettlementDeadlinePreservesOutcomeForRetry(t *testing.T) { defer cancel() done := make(chan error, 1) go func() { - _, err := lp.Deliver(ctx, blockedSettlement{RunStore: bound, release: release}, out, nil) + _, err := lp.Deliver(ctx, blockedSettlement{TurnStore: bound, release: release}, out, nil) done <- err }() select { diff --git a/agentcore/run/loop/contract.go b/agentcore/turn/loop/contract.go similarity index 76% rename from agentcore/run/loop/contract.go rename to agentcore/turn/loop/contract.go index 97cfbf65..7fbe4883 100644 --- a/agentcore/run/loop/contract.go +++ b/agentcore/turn/loop/contract.go @@ -7,55 +7,55 @@ import ( "time" "github.com/felinics/twilight/agentcore/prompt" - run "github.com/felinics/twilight/agentcore/run" - effect "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/store" + turn "github.com/felinics/twilight/agentcore/turn" + effect "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/store" ) -// ErrRunAlreadyRunning identifies a second local driver for the same Run. -// A Loop permits concurrent execution of different Runs and serializes each -// Run locally so every Executing target has one in-process owner. -var ErrRunAlreadyRunning = errors.New("agent: loop: run already running") +// ErrTurnAlreadyRunning identifies a second local driver for the same Turn. +// A Loop permits concurrent execution of different Turns and serializes each +// Turn locally so every Executing target has one in-process owner. +var ErrTurnAlreadyRunning = errors.New("agent: loop: turn already running") -// ErrCandidateContended reports repeated candidate rebuilds without Run +// ErrCandidateContended reports repeated candidate rebuilds without Turn // authority progress within one Advance. var ErrCandidateContended = errors.New("agent: loop: candidate contended") -// EffectContext identifies the effect a target is resolved for: the Run's +// EffectContext identifies the effect a target is resolved for: the Turn's // Scope and identity, the Step, the call of a tool effect, the EffectID the // effect is about to be started under, the kind of the effect and, for a tool // effect, the tool's ref. type EffectContext struct { - Session run.Scope - RunID run.RunID - StepID run.StepID - CallID run.CallID - Effect run.EffectID + Session turn.Scope + TurnID turn.TurnID + StepID turn.StepID + CallID turn.CallID + Effect turn.EffectID Kind AssignmentKind - Tool run.ToolRef + Tool turn.ToolRef // Placement is the tool's declared placement for a tool effect; a // resolver supplies a workspace target only for PlacementWorkspace. - Placement run.ToolPlacement + Placement turn.ToolPlacement } // TargetResolver supplies an opaque target for one effect. It // belongs to the application/resource layer: the Loop asks it once for every // effect it is about to start, before the start barrier, and only copies the // returned reference into that effect's Assignment. A nil target means the -// effect has no resource target. The mapping must be durable when a Run can +// effect has no resource target. The mapping must be durable when a Turn can // outlive the process that started it. type TargetResolver interface { - ResolveTarget(context.Context, EffectContext) (*run.TargetRef, error) + ResolveTarget(context.Context, EffectContext) (*turn.TargetRef, error) } // Settings are the execution parameters the Loop takes from the AgentPreset // Scheduling is frozen onto each ToolStep; MalformedRetries // bounds the retries of one model step after malformed results. type Settings struct { - Scheduling run.ToolScheduling + Scheduling turn.ToolScheduling MalformedRetries uint8 TargetResolver TargetResolver - // BeforePrepare runs each time the Run is Open and about to plan a model + // BeforePrepare runs each time the Turn is Open and about to plan a model // request, before the PromptBuilder reads the context. It // is the application's seam for reshaping that context between steps, // such as an in-turn checkpoint; it commits through the @@ -67,7 +67,7 @@ type Settings struct { // Nil accepts the candidate. Maintenance must be performed by the host // after the Loop returns, not synchronously inside this hook. CheckCandidate CandidateHook - // MaxCandidateRebuilds bounds consecutive rebuilds without Run authority + // MaxCandidateRebuilds bounds consecutive rebuilds without Turn authority // progress in one Advance. After this many rebuilds, another Rebuild // decision returns ErrCandidateContended. Zero selects 4; negative values // are invalid. @@ -75,33 +75,33 @@ type Settings struct { // Dispatch bounds the re-offers of an Assignment the Executor refused // as retryable; the zero value selects the defaults. Dispatch DispatchPolicy - // Watcher is where a blocking Run waits for the Outcomes it dispatched: + // Watcher is where a blocking Turn drive waits for the Outcomes it dispatched: // one per (owner, executor), shared with the Reconciler, so waiting on // N effects costs one settlement subscription. Its Port must be the // Loop's Executor. Nil builds a call-scoped watcher over the Executor, - // closed when Run returns. Advance and Deliver never require one. + // closed when the drive returns. Advance and Deliver never require one. Watcher *effect.Watcher } // PrepareHook is Settings.BeforePrepare: the store is the Loop's own bound -// RunStore and input the PromptInput the plan is about to hand the +// TurnStore and input the PromptInput the plan is about to hand the // PromptBuilder. -type PrepareHook func(ctx context.Context, store store.RunStore, input prompt.Input) error +type PrepareHook func(ctx context.Context, store store.TurnStore, input prompt.Input) error // CandidateHook checks the complete candidate, including system messages and // tool definitions. The candidate is borrowed for the call and should be treated // as read-only. An error discards it without preparing or dispatching it. -type CandidateHook func(ctx context.Context, st store.RunStore, input prompt.Input, candidate *prompt.Prompt) (CandidateDecision, error) +type CandidateHook func(ctx context.Context, st store.TurnStore, input prompt.Input, candidate *prompt.Prompt) (CandidateDecision, error) // CandidateDecision controls whether a built request may be prepared. type CandidateDecision uint8 const ( CandidateReady CandidateDecision = iota - // CandidateRebuild discards the candidate and reloads the Run and hint. + // CandidateRebuild discards the candidate and reloads the Turn and hint. CandidateRebuild // CandidateMaintenance yields to host-owned context maintenance without - // writing a normal Run ModelStep. + // writing a normal Turn ModelStep. CandidateMaintenance ) @@ -127,7 +127,7 @@ const ( EventToolProgress EventKind = "tool_progress" EventToolStarted EventKind = "tool_started" EventToolCompleted EventKind = "tool_completed" - EventRunFinished EventKind = "run_finished" + EventTurnFinished EventKind = "turn_finished" // EventProgressReset voids the provisional observations of an effect // received so far: the Worker re-dispatched it and a new Generation of // deltas begins. @@ -135,15 +135,15 @@ const ( ) type Event struct { - // Session is the Run's Scope. - Session run.Scope - RunID run.RunID - StepID run.StepID - CallID run.CallID + // Session is the Turn's Scope. + Session turn.Scope + TurnID turn.TurnID + StepID turn.StepID + CallID turn.CallID // Effect names the effect a provisional observation belongs to and // Generation its attempt under the Worker; both are zero for committed // observations. - Effect run.EffectID + Effect turn.EffectID Generation int // Sequence orders provisional observations within one stream. Committed // observations never set it: the commit order is the authority. @@ -151,25 +151,25 @@ type Event struct { Kind EventKind Durability EventDurability Payload json.RawMessage - // Committed is set for an EventAgentCommitted observation: the Run facts + // Committed is set for an EventAgentCommitted observation: the Turn facts // the accepted command produced, in stream order; nil for provisional. - Committed []run.Fact + Committed []turn.Fact } type LoopDisposition uint8 const ( - // LoopWaiting: no executable action; the Run waits for a response, + // LoopWaiting: no executable action; the Turn waits for a response, // recovery, context maintenance, or an Outcome of an effect this Loop // did not dispatch. LoopWaiting LoopDisposition = iota - // LoopFinished: the Run is terminal; Result is set. + // LoopFinished: the Turn is terminal; Result is set. LoopFinished // LoopDispatched: Advance handed at least one Assignment to the Executor - // and returned; Dispatched lists them. The Run moves again when their + // and returned; Dispatched lists them. The Turn moves again when their // Outcomes are read by key and then settled. LoopDispatched - // LoopDelivered: Deliver settled an Outcome and the Run is not terminal; + // LoopDelivered: Deliver settled an Outcome and the Turn is not terminal; // the host advances it next. LoopDelivered // LoopDropped: Deliver found no Executing target under the Outcome's @@ -188,7 +188,7 @@ type LoopResult struct { // dispatched by this Loop. Under Session-level ownership this only happens // before the owner's takeover disposition. ExecutionRecovery bool - Result *run.RunResult + Result *turn.TurnResult // Dispatched lists the assignments an Advance handed to the Executor. Dispatched []AssignmentKey } diff --git a/agentcore/run/loop/effectalias.go b/agentcore/turn/loop/effectalias.go similarity index 94% rename from agentcore/run/loop/effectalias.go rename to agentcore/turn/loop/effectalias.go index 9655c8a5..ae859ae8 100644 --- a/agentcore/run/loop/effectalias.go +++ b/agentcore/turn/loop/effectalias.go @@ -3,11 +3,11 @@ package loop import ( "errors" - effect "github.com/felinics/twilight/agentcore/run/effect" + effect "github.com/felinics/twilight/agentcore/turn/effect" ) // Aliases keep the protocol types the Loop speaks source-compatible with -// run/effect; the definitions live there. +// turn/effect; the definitions live there. type AssignmentKind = effect.AssignmentKind const ( @@ -86,3 +86,5 @@ var ( ErrOutcomeNotReady = effect.ErrOutcomeNotReady ErrDispatchUnknown = effect.ErrDispatchUnknown ) + +const OwnerTurn = effect.OwnerTurn diff --git a/agentcore/run/loop/effectref.go b/agentcore/turn/loop/effectref.go similarity index 64% rename from agentcore/run/loop/effectref.go rename to agentcore/turn/loop/effectref.go index 0c067cd7..4a9ecce1 100644 --- a/agentcore/run/loop/effectref.go +++ b/agentcore/turn/loop/effectref.go @@ -5,68 +5,68 @@ import ( "errors" "fmt" - run "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/store" + turn "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/store" ) -// effectRef is one effect of a Run as this Loop addresses it: the derived +// effectRef is one effect of a Turn as this Loop addresses it: the derived // EffectID the start fact records, with the schema whose Identity derives the // start, settlement and recovery CommandIDs from it. It holds // nothing of the physical execution -- worker, lease and backend handle stay // in the executor -- so any owner that reads the same state derives the same // identities and a repeated command replays instead of granting twice. type effectRef struct { - runID run.RunID - id run.EffectID + turnID turn.TurnID + id turn.EffectID } // modelEffect is the next model effect of a Prepared ModelStep: its sequence // is the count of results the step rejected so far, so a step started again // after a rejection requests a new effect. -func modelEffect(runID run.RunID, step *run.ModelStep) effectRef { - return effectRef{runID: runID, id: schema.Identity().DeriveEffectID(runID, step.RefValue.ID, "", step.Rejects)} +func modelEffect(turnID turn.TurnID, step *turn.ModelStep) effectRef { + return effectRef{turnID: turnID, id: schema.Identity().DeriveEffectID(turnID, step.RefValue.ID, "", step.Rejects)} } // toolEffect is the one tool effect of a call: a call starts at most once. -func toolEffect(runID run.RunID, stepID run.StepID, callID run.CallID) effectRef { - return effectRef{runID: runID, id: schema.Identity().DeriveEffectID(runID, stepID, callID, 0)} +func toolEffect(turnID turn.TurnID, stepID turn.StepID, callID turn.CallID) effectRef { + return effectRef{turnID: turnID, id: schema.Identity().DeriveEffectID(turnID, stepID, callID, 0)} } -func (e *effectRef) startID() run.CommandID { return schema.Identity().DeriveStartCommandID(e.id) } -func (e *effectRef) settlementID() run.CommandID { +func (e *effectRef) startID() turn.CommandID { return schema.Identity().DeriveStartCommandID(e.id) } +func (e *effectRef) settlementID() turn.CommandID { return schema.Identity().DeriveSettlementCommandID(e.id) } -func (e *effectRef) recoveryID() run.CommandID { +func (e *effectRef) recoveryID() turn.CommandID { return schema.Identity().DeriveRecoveryCommandID(e.id) } // settle commits the owner settlement of an effect under its derived // CommandID. A sentinel rejection means the effect 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 RunStore 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, rt store.RunStore, events EventSink, e *effectRef, base run.RunPosition, cmd run.AgentCommand) (*run.RunResult, error) { +// When the accepted settlement terminates the Turn, the terminal TurnResult is +// returned: the TurnStore already handed back the folded state, so the Loop +// finishes from it instead of reloading a Turn the projection no longer holds. +func (l *Loop) settle(ctx context.Context, rt store.TurnStore, events EventSink, e *effectRef, base turn.TurnPosition, cmd turn.AgentCommand) (*turn.TurnResult, error) { id := e.settlementID() - _, recovering := cmd.(run.RecoverModelExecution) + _, recovering := cmd.(turn.RecoverModelExecution) if recovering { id = e.recoveryID() } - res, err := l.commit(ctx, rt, e.runID, id, base, cmd) + res, err := l.commit(ctx, rt, e.turnID, id, base, cmd) if err != nil { if retriable(err) { return nil, nil } return nil, err } - l.emitCommitted(ctx, events, rt.Scope(), e.runID, res.Facts) + l.emitCommitted(ctx, events, rt.Scope(), e.turnID, res.Facts) // These accepted facts are durable proof, not merely a dropped delivery - // or a Run that left the projection. ACK is best effort; a crash/error + // or a Turn that left the projection. ACK is best effort; a crash/error // here is repaired by AcknowledgeSettled from the durable effect ledger. // Recovery can also precede dispatch: close a missing key, and never // collect an accepted execution while it is still nonterminal. - key := AssignmentKey{Session: rt.Scope(), RunID: e.runID, Effect: e.id} + key := AssignmentKey{Session: rt.Scope(), OwnerKind: OwnerTurn, OwnerID: string(e.turnID), Effect: e.id} if effectDisposition(res.Facts, e.id) { _, _ = l.acknowledgeProven(ctx, key) } @@ -79,8 +79,8 @@ func (l *Loop) settle(ctx context.Context, rt store.RunStore, events EventSink, // AcknowledgeSettled repairs the window between durable owner settlement and // executor ACK. It is safe after a dropped Deliver, but LoopDropped itself is // never proof. The optional EffectDispositionReader must resolve the effect's -// closing facts from durable storage, including CancelRun dispositions and -// Runs no longer held in an active projection. No command is replayed or decided. +// closing facts from durable storage, including CancelTurn dispositions and +// TurnStore no longer held in an active projection. No command is replayed or decided. // // Only facts naming this effect's disposition qualify. Even a recovery fact // is not proof execution ended: Abort must first prove a terminal execution @@ -88,8 +88,11 @@ func (l *Loop) settle(ctx context.Context, rt store.RunStore, events EventSink, // left readable and untouched. Unknown external effects are never replayed. // true means ACK succeeded (including an idempotent repeat); false means no // proof, no Acknowledger, an aborted key, or execution still nonterminal. -func (l *Loop) AcknowledgeSettled(ctx context.Context, st store.RunStore, key AssignmentKey) (bool, error) { - if err := l.checkArgs(ctx, st, key.RunID); err != nil { +func (l *Loop) AcknowledgeSettled(ctx context.Context, st store.TurnStore, key AssignmentKey) (bool, error) { + if key.OwnerKind != OwnerTurn { + return false, errors.New("loop: settlement proof requires a Turn owner") + } + if err := l.checkArgs(ctx, st, turn.TurnID(key.OwnerID)); err != nil { return false, err } if err := ctx.Err(); err != nil { @@ -102,7 +105,7 @@ func (l *Loop) AcknowledgeSettled(ctx context.Context, st store.RunStore, key As if !ok { return false, store.ErrEffectProofUnsupported } - facts, found, err := proof.EffectDisposition(ctx, key.RunID, key.Effect) + facts, found, err := proof.EffectDisposition(ctx, turn.TurnID(key.OwnerID), key.Effect) if err != nil { return false, err } @@ -113,8 +116,8 @@ func (l *Loop) AcknowledgeSettled(ctx context.Context, st store.RunStore, key As } // effectDisposition requires an effect-naming closing fact, not merely a -// commit-index hit, a start, RunEnded, or a settlement of another effect. -func effectDisposition(facts []run.Fact, id run.EffectID) bool { +// commit-index hit, a start, TurnEnded, or a settlement of another effect. +func effectDisposition(facts []turn.Fact, id turn.EffectID) bool { if id == "" { return false } diff --git a/agentcore/run/loop/error_test.go b/agentcore/turn/loop/error_test.go similarity index 93% rename from agentcore/run/loop/error_test.go rename to agentcore/turn/loop/error_test.go index 6762c962..27fa9447 100644 --- a/agentcore/run/loop/error_test.go +++ b/agentcore/turn/loop/error_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - "github.com/felinics/twilight/agentcore/run/loop" + "github.com/felinics/twilight/agentcore/turn/loop" ) func TestCatalogResolveErrorLeavesRunActive(t *testing.T) { diff --git a/agentcore/run/loop/events.go b/agentcore/turn/loop/events.go similarity index 82% rename from agentcore/run/loop/events.go rename to agentcore/turn/loop/events.go index 89ac3cfa..3a84fa46 100644 --- a/agentcore/run/loop/events.go +++ b/agentcore/turn/loop/events.go @@ -4,8 +4,8 @@ import ( "context" "sync" - run "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" + turn "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" ) type serializedEventSink struct { @@ -22,16 +22,16 @@ func (s *serializedEventSink) Emit(ctx context.Context, event Event) error { //n return s.sink.Emit(ctx, event) } -func (l *Loop) emitCommitted(ctx context.Context, events EventSink, scope run.Scope, runID run.RunID, committed []run.Fact) { +func (l *Loop) emitCommitted(ctx context.Context, events EventSink, scope turn.Scope, turnID turn.TurnID, committed []turn.Fact) { if events == nil || len(committed) == 0 { return } _ = events.Emit(ctx, Event{ Session: scope, - RunID: runID, + TurnID: turnID, Kind: EventAgentCommitted, Durability: EventCommitted, - Committed: append([]run.Fact(nil), committed...), + Committed: append([]turn.Fact(nil), committed...), }) } @@ -46,7 +46,7 @@ func (l *Loop) forwardProgress(ctx context.Context, port effect.ProgressPort, ke if !ok { return true } - _ = events.Emit(ctx, Event{Session: key.Session, RunID: key.RunID, Effect: key.Effect, + _ = events.Emit(ctx, Event{Session: key.Session, TurnID: turn.TurnID(key.OwnerID), Effect: key.Effect, Generation: f.Generation, Sequence: f.Sequence, Kind: kind, Durability: EventProvisional, Payload: f.Payload}) return true }) diff --git a/agentcore/run/loop/executor_test.go b/agentcore/turn/loop/executor_test.go similarity index 94% rename from agentcore/run/loop/executor_test.go rename to agentcore/turn/loop/executor_test.go index bbe32db4..708dfd08 100644 --- a/agentcore/run/loop/executor_test.go +++ b/agentcore/turn/loop/executor_test.go @@ -8,15 +8,15 @@ import ( "testing" "time" - . "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/reconcile" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/store" "github.com/felinics/twilight/agentcore/testutil/local" "github.com/felinics/twilight/agentcore/testutil/sdkconv" + . "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/reconcile" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/store" "github.com/felinics/twilight/sdk" ) @@ -253,7 +253,7 @@ func TestTargetResolvedPerEffect(t *testing.T) { t.Fatalf("tool effects share coordinates: %+v %+v", dispatched[1], dispatched[2]) } for i, a := range dispatched { - want := EffectContext{Session: a.Session, RunID: a.RunID, StepID: a.StepID, CallID: a.CallID, Effect: a.Effect, Kind: a.Kind()} + want := EffectContext{Session: a.Session, TurnID: TurnID(a.OwnerID), StepID: a.StepID, CallID: a.CallID, Effect: a.Effect, Kind: a.Kind()} var wantTarget *TargetRef if tool, ok := a.Tool(); ok { want.Tool = tool.ToolRef @@ -286,7 +286,7 @@ func TestAdvanceCopiesOpaqueTargetIntoAssignment(t *testing.T) { // Advance records the start barrier and hands the model call to the executor // without waiting for it; Deliver settles the Outcome and the next Advance -// finishes the Run. +// finishes the Turn. func TestAdvanceDispatchesAndDeliverSettles(t *testing.T) { rt, w := loopRuntime(t) exec := newRecordingExecutor() @@ -323,7 +323,7 @@ func TestAdvanceDispatchesAndDeliverSettles(t *testing.T) { exec.settled = map[AssignmentKey]Outcome{a.Key(): out} exec.mu.Unlock() delivered, err := l.Deliver(ctx, rt.Bind(w), out, nil) - if err != nil || delivered.Disposition != LoopFinished || delivered.Result == nil || delivered.Result.Status != RunCompleted { + if err != nil || delivered.Disposition != LoopFinished || delivered.Result == nil || delivered.Result.Status != TurnCompleted { t.Fatalf("deliver = %+v %v", delivered, err) } // The settlement is a fact: the executor is told it may collect the @@ -356,7 +356,7 @@ func (e *failingOutcomeReader) GetOutcome(ctx context.Context, key AssignmentKey // A transport failure reading an Outcome changes nothing: the step stays // Executing, the Loop keeps waiting (the Watcher reads again), and the real -// Outcome settles the Run when it arrives. +// Outcome settles the Turn when it arrives. func TestRunOutcomeReadErrorPreservesExecutingStep(t *testing.T) { rt, w := loopRuntime(t) exec := &failingOutcomeReader{recordingExecutor: newRecordingExecutor(), readErr: errors.New("temporary transport error"), failed: make(chan struct{}), ready: make(chan struct{})} @@ -377,7 +377,7 @@ func TestRunOutcomeReadErrorPreservesExecutingStep(t *testing.T) { <-exec.failed snapshot := loadState(t, rt, w, "run-1") step, ok := snapshot.State.Current.(ModelStep) - if !ok || step.Status != ModelExecuting || snapshot.State.Status != RunActive { + if !ok || step.Status != ModelExecuting || snapshot.State.Status != TurnActive { t.Fatalf("read error changed Run: %+v", snapshot.State) } close(exec.ready) @@ -390,7 +390,7 @@ func TestRunOutcomeReadErrorPreservesExecutingStep(t *testing.T) { case <-ctx.Done(): t.Fatal("Run did not finish after the read recovered") } - if got := loadState(t, rt, w, "run-1").State.Status; got != RunCompleted { + if got := loadState(t, rt, w, "run-1").State.Status; got != TurnCompleted { t.Fatalf("Run status after actual outcome = %v", got) } } @@ -439,7 +439,7 @@ func TestDeliverDropsStaleOutcome(t *testing.T) { // Executing under its original effect and that Outcome settles it. func TestTakeoverReattachesRunningAttempt(t *testing.T) { stack := newTestStack(t, nil) - stack.createRun(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{}`)}) + stack.createTurn(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{}`)}) exec := newRecordingExecutor() l, err := New(exec, staticBuilder{}, Settings{}) if err != nil { @@ -505,8 +505,8 @@ func TestTakeoverReattachesRunningAttempt(t *testing.T) { } } record, err := stack.runtime.Record(ctx, testSession, "run-1") - if err != nil || record.Snapshot.State.Status != RunCompleted { - t.Fatalf("run after reattached outcome = %+v %v", record.Snapshot.State.Status, err) + if err != nil || record.Snapshot.State.Status != TurnCompleted { + t.Fatalf("Turn after reattached outcome = %+v %v", record.Snapshot.State.Status, err) } for _, f := range record.Facts { if _, recovered := f.(ModelStepRecovered); recovered { @@ -518,7 +518,7 @@ func TestTakeoverReattachesRunningAttempt(t *testing.T) { // Takeover without a reachable executor keeps today's disposition. func TestTakeoverDisposesWhenAttachIsFalse(t *testing.T) { stack := newTestStack(t, nil) - stack.createRun(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{}`)}) + stack.createTurn(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{}`)}) exec := newRecordingExecutor() l, err := New(exec, staticBuilder{}, Settings{}) if err != nil { @@ -534,7 +534,7 @@ func TestTakeoverDisposesWhenAttachIsFalse(t *testing.T) { if err != nil || n != 1 || len(exec.attached) != 1 { t.Fatalf("RecoverInterrupted = %d %v attached=%d, want one disposition after one refused attach", n, err, len(exec.attached)) } - // The unreachable attempt is withdrawn: the Run is Open, the step is not + // The unreachable attempt is withdrawn: the Turn is Open, the step is not // counted, and the next Advance plans again. state := loadState(t, stack.runtime, stack.writer(t), "run-1").State if _, open := state.Current.(Open); !open || state.ModelSteps != 0 { @@ -571,7 +571,7 @@ func TestLocalExecutorAttachAndCancel(t *testing.T) { } spec := toolSpec(t, "echo", DirectExecution) target := TargetRef{Kind: "workspace", ID: "ws-1"} - a := Assignment{Session: testScope, RunID: "run-1", StepID: "step-1", CallID: "call-1", Effect: "effect-1", Target: &target, + a := Assignment{Session: testScope, OwnerKind: OwnerTurn, OwnerID: string("run-1"), StepID: "step-1", CallID: "call-1", Effect: "effect-1", Target: &target, Body: ToolAssignment{ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, Arguments: cj(`{}`), Policy: DirectExecution}} ref, err := exec.Prepare(context.Background(), a) if err != nil { @@ -614,7 +614,7 @@ func TestLocalExecutorAttachAndCancel(t *testing.T) { } // A model outcome that reports cancellation withdraws the step to Open for -// replanning rather than failing the Run. +// replanning rather than failing the Turn. func TestDeliverCancelledModelRecovers(t *testing.T) { rt, w := loopRuntime(t) exec := newRecordingExecutor() @@ -632,14 +632,14 @@ func TestDeliverCancelledModelRecovers(t *testing.T) { t.Fatalf("deliver cancelled = %+v %v", res, err) } state := loadState(t, rt, w, "run-1").State - if _, open := state.Current.(Open); !open || state.Status != RunActive { + if _, open := state.Current.(Open); !open || state.Status != TurnActive { t.Fatalf("state = %+v, want Open and active", state) } var _ sdk.ModelResult // keep sdk imported for result helpers above } // A frozen body a remote executor reports missing withdraws the step (the -// settlement lands, the Run is Open) and the condition comes back as an error, +// settlement lands, the Turn is Open) and the condition comes back as an error, // so the drive stops instead of prompt building again against the same missing // store. A later Advance -- the host's decision -- plans afresh. func TestDeliverMissingFrozenBodyWithdrawsAndReturnsTheError(t *testing.T) { @@ -694,9 +694,9 @@ func (e *missingBodyExecutor) Dispatch(ctx context.Context, a Assignment) error func TestRunStopsAfterOneMissingBodyRecovery(t *testing.T) { cases := []struct { name string - exec func(t *testing.T, rt store.RunStore) Executor + exec func(t *testing.T, rt store.TurnStore) Executor }{ - {"remote outcome", func(*testing.T, store.RunStore) Executor { + {"remote outcome", func(*testing.T, store.TurnStore) Executor { return &missingBodyExecutor{recordingExecutor: *newRecordingExecutor()} }}, } @@ -778,7 +778,7 @@ func TestLocalExecutorValidateChecksToolDeclarations(t *testing.T) { if err != nil { t.Fatal(err) } - a := Assignment{Session: "s", RunID: "r", StepID: "step", CallID: "c1", Effect: "e", + a := Assignment{Session: "s", OwnerKind: OwnerTurn, OwnerID: string("r"), StepID: "step", CallID: "c1", Effect: "e", Body: ToolAssignment{ToolRef: "echo", DefinitionDigest: digest, Arguments: cj(`{}`), Policy: DirectExecution, Replay: tc.assigned, Placement: tc.assignedPlacement}} failure, err := exec.Validate(context.Background(), a) if err != nil { diff --git a/agentcore/run/loop/feature_test.go b/agentcore/turn/loop/feature_test.go similarity index 65% rename from agentcore/run/loop/feature_test.go rename to agentcore/turn/loop/feature_test.go index 124058fb..aa8d4822 100644 --- a/agentcore/run/loop/feature_test.go +++ b/agentcore/turn/loop/feature_test.go @@ -1,6 +1,6 @@ -// The feature tests drive the Loop end to end against the reference RunStore. -// A Feature owns one in-process Runtime (a Memory Session with the run module -// and its SessionRunStore) and, when Run is called, one Loop. Tests name +// The feature tests drive the Loop end to end against the reference TurnStore. +// A Feature owns one in-process Runtime (a Memory Session with the Turn module +// and its SessionTurnStore) and, when Run is called, one Loop. Tests name // protocol features and speak in Tool/Model/Run/RunError/Approve/Require*. // Digest, envelope, revision, and derived effects stay inside the driver. @@ -14,33 +14,34 @@ import ( "github.com/felinics/twilight/agentcore/executor" "github.com/felinics/twilight/agentcore/executor/store/storetest" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/loop" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/sessionstore" - "github.com/felinics/twilight/agentcore/run/sessionstore/sessionstoretest" - "github.com/felinics/twilight/agentcore/run/store" + "github.com/felinics/twilight/agentcore/preset/ref" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/filestore/filestoretest" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/session/writer/writertest" "github.com/felinics/twilight/agentcore/testutil/local" "github.com/felinics/twilight/agentcore/testutil/sdkconv" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/loop" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/sessionstore" + "github.com/felinics/twilight/agentcore/turn/sessionstore/sessionstoretest" + "github.com/felinics/twilight/agentcore/turn/store" "github.com/felinics/twilight/sdk" "github.com/google/jsonschema-go/jsonschema" "testing" ) const ( - defaultRunID = "run-1" + defaultTurnID = "run-1" defaultModel = "m-1" defaultSession session.SessionID = "s-1" ) -// 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) (*sessionstore.SessionRunStore, writer.Writer) { +// newRuntime assembles the Memory Session stack with only the Turn module and +// creates the Turn with its seed input through a Start-like group. +func newRuntime(t testing.TB, inputs ...turn.AgentInput) (*sessionstore.SessionTurnStore, writer.Writer) { t.Helper() store := filestoretest.Store(t) registry, err := module.BuildRegistry(sessionstore.Module) @@ -53,24 +54,24 @@ func newRuntime(t testing.TB, inputs ...run.AgentInput) (*sessionstore.SessionRu } bindings, ledger := artifacttest.Stores(t) factory := &writer.Factory{Store: store, Registry: registry, Admission: writer.Admission{Bindings: bindings, Ledger: ledger}} - rt, err := sessionstore.NewSessionRunStore(sessionstore.Config{Registry: registry, Store: store, Frozen: sessionstoretest.Frozen(t, bindings)}) + rt, err := sessionstore.NewSessionTurnStore(sessionstore.Config{Registry: registry, Store: store, Frozen: sessionstoretest.Frozen(t, bindings)}) if err != nil { t.Fatal(err) } - newRun, err := run.BuildNewRun(defaultRunID, "") + newTurn, err := turn.BuildNewTurn(defaultTurnID, "", ref.Ref{ID: "test", Digest: "sha256:test"}) if err != nil { t.Fatal(err) } - facts, err := schema.Machine().CreateGroup(newRun, inputs) + facts, err := schema.Machine().CreateGroup(newTurn, inputs) if err != nil { t.Fatal(err) } runEvents := make([]writer.TypedEvent, 0, len(facts)) for _, f := range facts { - runEvents = append(runEvents, writer.TypedEvent{Type: sessionstore.EventType(f), Value: sessionstore.Event{RunID: defaultRunID, Fact: f}}) + runEvents = append(runEvents, writer.TypedEvent{Type: sessionstore.EventType(f), Value: sessionstore.Event{TurnID: defaultTurnID, Fact: f}}) } - group := &writer.SemanticGroup{CommitID: "create/" + defaultRunID, - Batches: []writer.TypedBatch{{Domain: sessionstore.Stream(defaultRunID), Events: runEvents}}} + group := &writer.SemanticGroup{CommitID: "create/" + defaultTurnID, + Batches: []writer.TypedBatch{{Domain: sessionstore.Stream(defaultTurnID), Events: runEvents}}} w, err := writertest.Open(t, ctx, factory, defaultSession, session.OpenOptions{}) if err != nil { t.Fatal(err) @@ -90,16 +91,16 @@ type Feature struct { t testing.TB ctx context.Context runCtx context.Context - runID run.RunID - runs *sessionstore.SessionRunStore - rt store.RunStore // runs bound to w - w writer.Writer // the owner's capability over defaultSession + turnID turn.TurnID + turns *sessionstore.SessionTurnStore + rt store.TurnStore // Turn bound to w + w writer.Writer // the owner's capability over defaultSession - model run.ModelRef + model turn.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 + specs []turn.ToolSpec + defs map[turn.ToolRef]sdk.ToolDefinition // provider bodies behind specs; ToolSpec keeps only the digest + tools map[turn.ToolRef]*scriptTool invoker *scriptInvoker // wrapPort decorates the ExecutionPort the Loop drives, for tests that // inject dispatch answers between the Loop and the Worker. @@ -108,33 +109,33 @@ type Feature struct { loop *loop.Loop seq int - modelStepID run.StepID + modelStepID turn.StepID last loop.LoopResult resolveErr error } -// newFeature creates a Runtime, a Run, and the seed input. Configure tools and +// newFeature creates a Runtime, a Turn, and the seed input. Configure tools and // model results before Run or Executing*. func newFeature(t testing.TB) *Feature { t.Helper() - runs, w := newRuntime(t, run.AgentInput{ID: "seed", Digest: "sha256:seed"}) + turns, w := newRuntime(t, turn.AgentInput{ID: "seed", Digest: "sha256:seed"}) f := &Feature{ t: t, ctx: context.Background(), runCtx: context.Background(), - runID: defaultRunID, - runs: runs, - rt: runs.Bind(w), + turnID: defaultTurnID, + turns: turns, + rt: turns.Bind(w), w: w, model: defaultModel, - defs: make(map[run.ToolRef]sdk.ToolDefinition), - tools: make(map[run.ToolRef]*scriptTool), + defs: make(map[turn.ToolRef]sdk.ToolDefinition), + tools: make(map[turn.ToolRef]*scriptTool), } return f } // Tool registers a catalog tool. Default execution echoes the call arguments. -func (f *Feature) Tool(name string, policy run.ResponsePolicy) *Feature { +func (f *Feature) Tool(name string, policy turn.ResponsePolicy) *Feature { f.t.Helper() f.guardConfig() spec, def := f.mustSpec(name, policy) @@ -151,16 +152,16 @@ func (f *Feature) Tool(name string, policy run.ResponsePolicy) *Feature { // 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 + f.Tool(name, turn.DirectExecution) + f.tools[turn.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 + f.Tool(name, turn.DirectExecution) + f.tools[turn.ToolRef(name)].fail = class return f } @@ -214,26 +215,26 @@ 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, f.turnID, 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 { +func (f *Feature) Waiting() turn.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 { +func (f *Feature) TryCommit(cmd turn.AgentCommand) error { f.t.Helper() snap := f.load() cmd = f.withEffect(cmd, snap) id := f.commandID(cmd, snap) - env, err := schema.Wire().Envelope(f.runID, id, cmd) + env, err := schema.Wire().Envelope(f.turnID, id, cmd) if err != nil { return err } @@ -245,11 +246,11 @@ func (f *Feature) TryCommit(cmd run.AgentCommand) error { func (f *Feature) Approve() *Feature { f.t.Helper() w := f.waiting() - digest, err := schema.Canonical().DigestToolResponseDecision(w.Kind, run.ResponseDecisionApproved, "") + digest, err := schema.Canonical().DigestToolResponseDecision(w.Kind, turn.ResponseDecisionApproved, "") if err != nil { f.t.Fatal(err) } - f.commit(run.ApproveToolCall{ + f.commit(turn.ApproveToolCall{ StepID: w.StepID, CallID: w.CallID, ResponseID: w.ID, ResponseDigest: digest, }) return f @@ -259,38 +260,38 @@ func (f *Feature) Approve() *Feature { func (f *Feature) Reject(reason string) *Feature { f.t.Helper() w := f.waiting() - digest, err := schema.Canonical().DigestToolResponseDecision(w.Kind, run.ResponseDecisionRejected, reason) + digest, err := schema.Canonical().DigestToolResponseDecision(w.Kind, turn.ResponseDecisionRejected, reason) if err != nil { f.t.Fatal(err) } - f.commit(run.RejectToolCall{ + f.commit(turn.RejectToolCall{ StepID: w.StepID, CallID: w.CallID, ResponseID: w.ID, ResponseDigest: digest, Reason: reason, }) return f } -// Cancel commits CancelRun. +// Cancel commits CancelTurn. func (f *Feature) Cancel() *Feature { f.t.Helper() - f.commit(run.CancelRun{}) + f.commit(turn.CancelTurn{}) return f } -// ExecutingModel leaves the Run on an Executing ModelStep (no Loop). +// ExecutingModel leaves the Turn on an Executing ModelStep (no Loop). func (f *Feature) ExecutingModel() *Feature { f.t.Helper() f.commitPrepare() - f.commit(run.StartModelExecution{StepID: f.modelStepID}) + f.commit(turn.StartModelExecution{StepID: f.modelStepID}) return f } -// callByProvider resolves a provider tool_call_id to the Run's derived CallID +// callByProvider resolves a provider tool_call_id to the Turn's derived CallID // by scanning every ToolStepOpened committed so far. -func (f *Feature) callByProvider(providerID string) run.CallID { +func (f *Feature) callByProvider(providerID string) turn.CallID { f.t.Helper() for _, fact := range f.facts() { - opened, ok := fact.(run.ToolStepOpened) + opened, ok := fact.(turn.ToolStepOpened) if !ok { continue } @@ -306,12 +307,12 @@ func (f *Feature) callByProvider(providerID string) run.CallID { // 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 { +func (f *Feature) ExecutingTool(name string, callID turn.CallID) *Feature { f.t.Helper() - var spec run.ToolSpec + var spec turn.ToolSpec found := false for _, candidate := range f.specs { - if candidate.Ref == run.ToolRef(name) { + if candidate.Ref == turn.ToolRef(name) { spec = candidate found = true break @@ -323,7 +324,7 @@ func (f *Feature) ExecutingTool(name string, callID run.CallID) *Feature { f.ExecutingModel() providerID := string(callID) callID = schema.Identity().DeriveCallID(f.modelStepID, 0) - args := run.MustParseCanonicalJSON(`{"x":1}`) + args := turn.MustParseCanonicalJSON(`{"x":1}`) frozen, err := sdkconv.FreezeModelResult(sdk.ModelResult{ FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{TotalTokens: 2}, @@ -334,19 +335,19 @@ func (f *Feature) ExecutingTool(name string, callID run.CallID) *Feature { if err != nil { f.t.Fatal(err) } - res := f.commit(run.SubmitModelResult{ + res := f.commit(turn.SubmitModelResult{ StepID: f.modelStepID, Result: frozen, - Calls: []run.ToolCallBinding{{ + Calls: []turn.ToolCallBinding{{ CallID: callID, ProviderCallID: providerID, ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, Arguments: args, Policy: spec.Policy, }}, }) - ts, ok := res.Snapshot.State.Current.(run.ToolStep) + ts, ok := res.Snapshot.State.Current.(turn.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(turn.StartToolCall{StepID: ts.Ref().ID, CallID: callID}) return f } @@ -364,7 +365,7 @@ func (f *Feature) ensureLoop() { } f.invoker = &scriptInvoker{results: f.results} f.builder = &scriptBuilder{model: f.model, specs: f.specs, defs: f.defs} - tools := make(map[run.ToolRef]local.ExecutableTool, len(f.tools)) + tools := make(map[turn.ToolRef]local.ExecutableTool, len(f.tools)) for ref, tool := range f.tools { tools[ref] = tool } @@ -389,33 +390,33 @@ func (f *Feature) ensureLoop() { func (f *Feature) load() store.Snapshot { f.t.Helper() - snap, err := f.rt.Load(f.ctx, f.runID) + snap, err := f.rt.Load(f.ctx, f.turnID) if err != nil { f.t.Fatal(err) } return snap } -func (f *Feature) state() run.MachineState { +func (f *Feature) state() turn.MachineState { f.t.Helper() return f.load().State } -func (f *Feature) waiting() run.ResponseRequest { +func (f *Feature) waiting() turn.ResponseRequest { f.t.Helper() - reqs := run.WaitingCalls(f.state()) + reqs := turn.WaitingCalls(f.state()) if len(reqs) == 0 { f.t.Fatal("no waiting call") } return reqs[0] } -func (f *Feature) commit(cmd run.AgentCommand) store.CommitResult { +func (f *Feature) commit(cmd turn.AgentCommand) store.CommitResult { f.t.Helper() snap := f.load() cmd = f.withEffect(cmd, snap) id := f.commandID(cmd, snap) - env, err := schema.Wire().Envelope(f.runID, id, cmd) + env, err := schema.Wire().Envelope(f.turnID, id, cmd) if err != nil { f.t.Fatal(err) } @@ -428,39 +429,39 @@ func (f *Feature) commit(cmd run.AgentCommand) store.CommitResult { return res } -func (f *Feature) commandID(cmd run.AgentCommand, snap store.Snapshot) run.CommandID { +func (f *Feature) commandID(cmd turn.AgentCommand, snap store.Snapshot) turn.CommandID { switch c := cmd.(type) { - case run.AcceptInput: - return schema.Identity().DeriveInputCommandID(f.runID, c.InputIDs()...) - case run.ApproveToolCall: - return schema.Identity().DeriveResponseCommandID(f.runID, c.StepID, c.CallID, c.ResponseID) - case run.RejectToolCall: - return schema.Identity().DeriveResponseCommandID(f.runID, c.StepID, c.CallID, c.ResponseID) - case run.SubmitToolResponse: - return schema.Identity().DeriveResponseCommandID(f.runID, c.StepID, c.CallID, c.ResponseID) - case run.PrepareModelRequest: - return schema.Identity().DeriveModelRequestCommandID(f.runID, snap.Position) - case run.RecoverModelExecution: + case turn.AcceptInput: + return schema.Identity().DeriveInputCommandID(f.turnID, c.InputIDs()...) + case turn.ApproveToolCall: + return schema.Identity().DeriveResponseCommandID(f.turnID, c.StepID, c.CallID, c.ResponseID) + case turn.RejectToolCall: + return schema.Identity().DeriveResponseCommandID(f.turnID, c.StepID, c.CallID, c.ResponseID) + case turn.SubmitToolResponse: + return schema.Identity().DeriveResponseCommandID(f.turnID, c.StepID, c.CallID, c.ResponseID) + case turn.PrepareModelRequest: + return schema.Identity().DeriveModelRequestCommandID(f.turnID, snap.Position) + case turn.RecoverModelExecution: return schema.Identity().DeriveRecoveryCommandID(c.Effect) - case run.StartModelExecution: + case turn.StartModelExecution: return schema.Identity().DeriveStartCommandID(c.Effect) - case run.StartToolCall: + case turn.StartToolCall: return schema.Identity().DeriveStartCommandID(c.Effect) - case run.SubmitModelResult: + case turn.SubmitModelResult: return schema.Identity().DeriveSettlementCommandID(c.Effect) - case run.SubmitModelFailure: + case turn.SubmitModelFailure: return schema.Identity().DeriveSettlementCommandID(c.Effect) - case run.RejectModelResult: + case turn.RejectModelResult: return schema.Identity().DeriveSettlementCommandID(c.Effect) - case run.SubmitToolResult: + case turn.SubmitToolResult: return schema.Identity().DeriveSettlementCommandID(c.Effect) - case run.SubmitToolFailure: + case turn.SubmitToolFailure: return schema.Identity().DeriveSettlementCommandID(c.Effect) - case run.DeclineToolCall: - return schema.Identity().DeriveDeclineCommandID(f.runID, c.StepID, c.CallID) + case turn.DeclineToolCall: + return schema.Identity().DeriveDeclineCommandID(f.turnID, c.StepID, c.CallID) default: f.seq++ - return run.CommandID(fmt.Sprintf("cmd-%d", f.seq)) + return turn.CommandID(fmt.Sprintf("cmd-%d", f.seq)) } } @@ -479,21 +480,21 @@ func (f *Feature) commitPrepare() { if err != nil { f.t.Fatal(err) } - cmdID := schema.Identity().DeriveModelRequestCommandID(f.runID, snap.Position) - stepID := schema.Identity().DeriveModelStepID(f.runID, cmdID) - ids := make([]run.InputID, len(snap.State.PendingInputs)) + cmdID := schema.Identity().DeriveModelRequestCommandID(f.turnID, snap.Position) + stepID := schema.Identity().DeriveModelStepID(f.turnID, cmdID) + ids := make([]turn.InputID, len(snap.State.PendingInputs)) for i, in := range snap.State.PendingInputs { ids[i] = in.ID } f.modelStepID = stepID - f.commit(run.PrepareModelRequest{ + f.commit(turn.PrepareModelRequest{ StepID: stepID, Model: f.model, Request: frozen, RequestDigest: reqDigest, InputIDs: ids, Tools: f.specs, }) } // 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) { +func (f *Feature) mustSpec(name string, policy turn.ResponsePolicy) (turn.ToolSpec, sdk.ToolDefinition) { f.t.Helper() def := sdk.ToolDefinition{Name: name, Parameters: &jsonschema.Schema{Type: "object"}} frozen, err := sdkconv.FreezeToolDefinition(def) @@ -504,12 +505,12 @@ 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), Name: name, DefinitionDigest: d, Policy: policy}, def + return turn.ToolSpec{Ref: turn.ToolRef(name), Name: name, DefinitionDigest: d, Policy: policy}, def } -func (f *Feature) facts() []run.Fact { +func (f *Feature) facts() []turn.Fact { f.t.Helper() - record, err := f.runs.Record(f.ctx, defaultSession, f.runID) + record, err := f.turns.Record(f.ctx, defaultSession, f.turnID) if err != nil { f.t.Fatal(err) } @@ -519,45 +520,45 @@ func (f *Feature) facts() []run.Fact { // withEffect fills the derived effect identity of a start or recovery that // names none: the model effect from the current step's rejected // count, the tool effect from the call, the recovered effect from the step. -func (f *Feature) withEffect(cmd run.AgentCommand, snap store.Snapshot) run.AgentCommand { +func (f *Feature) withEffect(cmd turn.AgentCommand, snap store.Snapshot) turn.AgentCommand { switch c := cmd.(type) { - case run.StartModelExecution: + case turn.StartModelExecution: if c.Effect == "" { - ms, _ := snap.State.Current.(run.ModelStep) - c.Effect = schema.Identity().DeriveEffectID(f.runID, c.StepID, "", ms.Rejects) + ms, _ := snap.State.Current.(turn.ModelStep) + c.Effect = schema.Identity().DeriveEffectID(f.turnID, c.StepID, "", ms.Rejects) } return c - case run.StartToolCall: + case turn.StartToolCall: if c.Effect == "" { - c.Effect = schema.Identity().DeriveEffectID(f.runID, c.StepID, c.CallID, 0) + c.Effect = schema.Identity().DeriveEffectID(f.turnID, c.StepID, c.CallID, 0) } return c - case run.RecoverModelExecution: + case turn.RecoverModelExecution: if c.Effect == "" { c.Effect = executingModelEffect(snap) } return c - case run.SubmitModelResult: + case turn.SubmitModelResult: if c.Effect == "" { c.Effect = executingModelEffect(snap) } return c - case run.SubmitModelFailure: + case turn.SubmitModelFailure: if c.Effect == "" { c.Effect = executingModelEffect(snap) } return c - case run.RejectModelResult: + case turn.RejectModelResult: if c.Effect == "" { c.Effect = executingModelEffect(snap) } return c - case run.SubmitToolResult: + case turn.SubmitToolResult: if c.Effect == "" { c.Effect = executingToolEffect(snap, c.CallID) } return c - case run.SubmitToolFailure: + case turn.SubmitToolFailure: if c.Effect == "" { c.Effect = executingToolEffect(snap, c.CallID) } @@ -568,15 +569,15 @@ func (f *Feature) withEffect(cmd run.AgentCommand, snap store.Snapshot) run.Agen } // executingModelEffect is the effect the current ModelStep is executing. -func executingModelEffect(snap store.Snapshot) run.EffectID { - ms, _ := snap.State.Current.(run.ModelStep) +func executingModelEffect(snap store.Snapshot) turn.EffectID { + ms, _ := snap.State.Current.(turn.ModelStep) return ms.Effect } // executingToolEffect is the effect the named call of the current ToolStep // is executing. -func executingToolEffect(snap store.Snapshot, callID run.CallID) run.EffectID { - ts, _ := snap.State.Current.(run.ToolStep) +func executingToolEffect(snap store.Snapshot, callID turn.CallID) turn.EffectID { + ts, _ := snap.State.Current.(turn.ToolStep) for _, call := range ts.Calls { if call.CallID == callID { return call.Effect diff --git a/agentcore/run/loop/helpers_test.go b/agentcore/turn/loop/helpers_test.go similarity index 69% rename from agentcore/run/loop/helpers_test.go rename to agentcore/turn/loop/helpers_test.go index ee69a933..09d48088 100644 --- a/agentcore/run/loop/helpers_test.go +++ b/agentcore/turn/loop/helpers_test.go @@ -10,17 +10,18 @@ import ( "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" + "github.com/felinics/twilight/agentcore/preset/ref" "github.com/felinics/twilight/agentcore/prompt" - . "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/sessionstore" - "github.com/felinics/twilight/agentcore/run/sessionstore/sessionstoretest" - "github.com/felinics/twilight/agentcore/run/store" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/filestore/filestoretest" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/session/writer/writertest" "github.com/felinics/twilight/agentcore/testutil/local" + . "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/sessionstore" + "github.com/felinics/twilight/agentcore/turn/sessionstore/sessionstoretest" + "github.com/felinics/twilight/agentcore/turn/store" "testing" "time" ) @@ -33,11 +34,11 @@ const ( func cj(raw string) CanonicalJSON { return MustParseCanonicalJSON(raw) } -// inputDigest names an input body: the Run stores only the digest. +// inputDigest names an input body: the Turn stores only the digest. func inputDigest(raw string) Digest { return jsonstable.DigestBytes([]byte(raw)) } // testStack is the minimal Session stack a Loop test drives: kernel Memory -// Store, the run module, one owner process (Writers) and a Runtime. +// Store, the Turn module, one owner process (Writers) and a Runtime. type testStack struct { store session.Stores registry *module.Registry @@ -45,7 +46,7 @@ type testStack struct { ledger artifact.RetentionLedger writers *writer.Factory w writer.Writer - runtime *sessionstore.SessionRunStore + runtime *sessionstore.SessionTurnStore now func() time.Time } @@ -76,7 +77,7 @@ func (s *testStack) open(t testing.TB) { } s.writers = &writer.Factory{Store: s.store, Registry: s.registry, Admission: writer.Admission{Bindings: s.bindings, Ledger: s.ledger}} s.w = nil - rt, err := sessionstore.NewSessionRunStore(sessionstore.Config{Registry: s.registry, Store: s.store, Frozen: sessionstoretest.Frozen(t, s.bindings), Now: s.now}) + rt, err := sessionstore.NewSessionTurnStore(sessionstore.Config{Registry: s.registry, Store: s.store, Frozen: sessionstoretest.Frozen(t, s.bindings), Now: s.now}) if err != nil { t.Fatal(err) } @@ -98,23 +99,23 @@ func (s *testStack) writer(t testing.TB) writer.Writer { return w } -// createRun appends the Start group of one Run with a seed input. -func (s *testStack) createRun(t testing.TB, runID RunID, inputs ...AgentInput) { +// createTurn appends the Start group of one Turn with a seed input. +func (s *testStack) createTurn(t testing.TB, turnID TurnID, inputs ...AgentInput) { t.Helper() - newRun, err := BuildNewRun(runID, "") + newTurn, err := BuildNewTurn(turnID, "", ref.Ref{ID: "test", Digest: "sha256:test"}) if err != nil { t.Fatal(err) } - facts, err := schema.Machine().CreateGroup(newRun, inputs) + facts, err := schema.Machine().CreateGroup(newTurn, inputs) if err != nil { t.Fatal(err) } runEvents := make([]writer.TypedEvent, 0, len(facts)) for _, f := range facts { - runEvents = append(runEvents, writer.TypedEvent{Type: sessionstore.EventType(f), Value: sessionstore.Event{RunID: runID, Fact: f}}) + runEvents = append(runEvents, writer.TypedEvent{Type: sessionstore.EventType(f), Value: sessionstore.Event{TurnID: turnID, Fact: f}}) } - group := &writer.SemanticGroup{CommitID: ledger.CommitID("create/" + string(runID)), - Batches: []writer.TypedBatch{{Domain: sessionstore.Stream(runID), Events: runEvents}}} + group := &writer.SemanticGroup{CommitID: ledger.CommitID("create/" + string(turnID)), + Batches: []writer.TypedBatch{{Domain: sessionstore.Stream(turnID), Events: runEvents}}} w := s.writer(t) res, err := w.Commit(context.Background(), func(writer.View) (*writer.SemanticGroup, error) { return group, nil }) if err != nil { @@ -125,32 +126,32 @@ func (s *testStack) createRun(t testing.TB, runID RunID, inputs ...AgentInput) { } } -// newTestRuntime is a run store holding "run-1" seeded with one input. -func newTestRuntime(t testing.TB) (*sessionstore.SessionRunStore, writer.Writer) { +// newTestRuntime is a Turn store holding "run-1" seeded with one input. +func newTestRuntime(t testing.TB) (*sessionstore.SessionTurnStore, writer.Writer) { t.Helper() stack := newTestStack(t, nil) - stack.createRun(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{"q":"hi"}`)}) + stack.createTurn(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{"q":"hi"}`)}) return stack.runtime, stack.writer(t) } -func loopRuntime(t *testing.T) (*sessionstore.SessionRunStore, writer.Writer) { +func loopRuntime(t *testing.T) (*sessionstore.SessionTurnStore, writer.Writer) { t.Helper() return newTestRuntime(t) } -// recordFacts returns every committed fact of runID in stream order. -func recordFacts(t testing.TB, rt *sessionstore.SessionRunStore, runID RunID) []Fact { +// recordFacts returns every committed fact of turnID in stream order. +func recordFacts(t testing.TB, rt *sessionstore.SessionTurnStore, turnID TurnID) []Fact { t.Helper() - record, err := rt.Record(context.Background(), testSession, runID) + record, err := rt.Record(context.Background(), testSession, turnID) if err != nil { t.Fatal(err) } return record.Facts } -func loadState(t testing.TB, rt *sessionstore.SessionRunStore, w writer.Writer, runID RunID) store.Snapshot { +func loadState(t testing.TB, rt *sessionstore.SessionTurnStore, w writer.Writer, turnID TurnID) store.Snapshot { t.Helper() - snap, err := rt.Bind(w).Load(context.Background(), runID) + snap, err := rt.Bind(w).Load(context.Background(), turnID) if err != nil { t.Fatal(err) } diff --git a/agentcore/run/loop/loop.go b/agentcore/turn/loop/loop.go similarity index 69% rename from agentcore/run/loop/loop.go rename to agentcore/turn/loop/loop.go index f2a15f9e..df247818 100644 --- a/agentcore/run/loop/loop.go +++ b/agentcore/turn/loop/loop.go @@ -8,15 +8,15 @@ import ( "time" "github.com/felinics/twilight/agentcore/prompt" - run "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/plan" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/store" + turn "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/plan" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/store" ) -// Loop is the decision interpreter of one Run. It holds no -// authoritative state: every step starts from RunStore.Load, derives the next +// Loop is the decision interpreter of one Turn. It holds no +// authoritative state: every step starts from TurnStore.Load, derives the next // action with plan.Next, records the protocol transition and, for a start, // hands the requested effect to the Executor as an Assignment keyed by its // EffectID. Outcomes are read by that key through the Executor port and @@ -29,16 +29,16 @@ type Loop struct { Settings Settings mu sync.Mutex - slots map[run.RunID]*runSlot + slots map[turn.TurnID]*turnSlot eventsMu sync.Mutex } -// runSlot serializes one Run: step guards a single Advance or Deliver at a -// time; driving marks a blocking Run in progress so a second driver is +// turnSlot serializes one Turn: step guards a single Advance or Deliver at a +// time; driving marks a blocking Turn drive in progress so a second driver is // reported instead of interleaved. refs counts the callers holding // the slot; the entry is dropped with the last release, so the map is bounded -// by the Runs being driven now rather than by every Run the Loop has seen. -type runSlot struct { +// by the Turns being driven now rather than by every Turn the Loop has seen. +type turnSlot struct { step sync.Mutex driving bool refs int @@ -53,7 +53,7 @@ func New(exec Executor, builder prompt.Builder, settings Settings) (*Loop, error if builder == nil { return nil, errors.New("agent: loop: nil builder") } - if m := settings.Scheduling.Mode; m != "" && m != run.ToolScheduleParallel && m != run.ToolScheduleSequential { + if m := settings.Scheduling.Mode; m != "" && m != turn.ToolScheduleParallel && m != turn.ToolScheduleSequential { return nil, fmt.Errorf("agent: loop: unknown scheduling mode %q", m) } if settings.Scheduling.MaxParallel < 0 { @@ -62,13 +62,13 @@ func New(exec Executor, builder prompt.Builder, settings Settings) (*Loop, error if settings.MaxCandidateRebuilds < 0 { return nil, errors.New("agent: loop: negative MaxCandidateRebuilds") } - return &Loop{Executor: exec, Builder: builder, Settings: settings, slots: make(map[run.RunID]*runSlot)}, nil + return &Loop{Executor: exec, Builder: builder, Settings: settings, slots: make(map[turn.TurnID]*turnSlot)}, nil } -func (l *Loop) toolScheduling() run.ToolScheduling { +func (l *Loop) toolScheduling() turn.ToolScheduling { s := l.Settings.Scheduling if s.Mode == "" { - s.Mode = run.ToolScheduleParallel + s.Mode = turn.ToolScheduleParallel } return s } @@ -110,7 +110,7 @@ func recoverableDispatch(ctx context.Context, err error) bool { errors.Is(err, effect.ErrDispatchRetryable) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) } -func (l *Loop) targetFor(ctx context.Context, ec EffectContext) (*run.TargetRef, error) { +func (l *Loop) targetFor(ctx context.Context, ec EffectContext) (*turn.TargetRef, error) { if l.Settings.TargetResolver == nil { return nil, nil } @@ -128,81 +128,81 @@ func (l *Loop) targetFor(ctx context.Context, ec EffectContext) (*run.TargetRef, return &cloned, nil } -// slotLocked returns the slot of runID, creating it; l.mu must be held. -func (l *Loop) slotLocked(runID run.RunID) *runSlot { - s, ok := l.slots[runID] +// slotLocked returns the slot of turnID, creating it; l.mu must be held. +func (l *Loop) slotLocked(turnID turn.TurnID) *turnSlot { + s, ok := l.slots[turnID] if !ok { - s = &runSlot{} - l.slots[runID] = s + s = &turnSlot{} + l.slots[turnID] = s } return s } -// releaseLocked drops one reference to the slot of runID and forgets the slot +// releaseLocked drops one reference to the slot of turnID and forgets the slot // with the last one; l.mu must be held. -func (l *Loop) releaseLocked(runID run.RunID) { - if s, ok := l.slots[runID]; ok { +func (l *Loop) releaseLocked(turnID turn.TurnID) { + if s, ok := l.slots[turnID]; ok { s.refs-- if s.refs <= 0 { - delete(l.slots, runID) + delete(l.slots, turnID) } } } -// acquire returns the slot of runID and holds a reference until release. -func (l *Loop) acquire(runID run.RunID) *runSlot { +// acquire returns the slot of turnID and holds a reference until release. +func (l *Loop) acquire(turnID turn.TurnID) *turnSlot { l.mu.Lock() defer l.mu.Unlock() - s := l.slotLocked(runID) + s := l.slotLocked(turnID) s.refs++ return s } -func (l *Loop) release(runID run.RunID) { +func (l *Loop) release(turnID turn.TurnID) { l.mu.Lock() - l.releaseLocked(runID) + l.releaseLocked(turnID) l.mu.Unlock() } -// startDriving marks runID as driven by a blocking Run and holds its slot for +// startDriving marks turnID as driven by a blocking Run and holds its slot for // the drive; stopDriving releases both. -func (l *Loop) startDriving(runID run.RunID) (*runSlot, error) { +func (l *Loop) startDriving(turnID turn.TurnID) (*turnSlot, error) { l.mu.Lock() defer l.mu.Unlock() - s := l.slotLocked(runID) + s := l.slotLocked(turnID) if s.driving { - return nil, ErrRunAlreadyRunning + return nil, ErrTurnAlreadyRunning } s.driving = true s.refs++ return s, nil } -func (l *Loop) stopDriving(runID run.RunID) { +func (l *Loop) stopDriving(turnID turn.TurnID) { l.mu.Lock() - if s, ok := l.slots[runID]; ok { + if s, ok := l.slots[turnID]; ok { s.driving = false } - l.releaseLocked(runID) + l.releaseLocked(turnID) l.mu.Unlock() } -func (l *Loop) isDriving(runID run.RunID) bool { +func (l *Loop) isDriving(turnID turn.TurnID) bool { l.mu.Lock() defer l.mu.Unlock() - s, ok := l.slots[runID] + s, ok := l.slots[turnID] return ok && s.driving } -func (l *Loop) checkArgs(ctx context.Context, st store.RunStore, runID run.RunID) error { +func (l *Loop) checkArgs(ctx context.Context, st store.TurnStore, turnID turn.TurnID) error { if ctx == nil { return errors.New("agent: loop: nil context") } if st == nil { - return errors.New("agent: loop: nil run store") + return errors.New("agent: loop: nil TurnStore") } - if st.Scope() == "" || runID == "" { - return errors.New("agent: loop: empty Scope or RunID") + if st.Scope() == "" || turnID == "" { + return errors.New("agent: loop: empty Scope or TurnID") } return nil } @@ -214,57 +214,57 @@ func (l *Loop) wrapSink(events EventSink) EventSink { return &serializedEventSink{sink: events, mu: &l.eventsMu} } -// Advance moves the Run to its next quiescent point without waiting on any +// Advance moves the Turn to its next quiescent point without waiting on any // effect: it records protocol transitions (prepare, withdraw, // start barriers) and dispatches Assignments, then returns LoopDispatched, // LoopWaiting or LoopFinished. The caller reads each returned key through // Executor.GetOutcome and passes it to Deliver. A concurrent Advance or a -// blocking Run of the same Run is reported as ErrRunAlreadyRunning. store is -// the RunStore bound to the caller's write capability: every commit of the +// blocking drive of the same Turn is reported as ErrTurnAlreadyRunning. store is +// the TurnStore bound to the caller's write capability: every commit of the // step goes through it. -func (l *Loop) Advance(ctx context.Context, st store.RunStore, runID run.RunID, events EventSink) (LoopResult, error) { - if err := l.checkArgs(ctx, st, runID); err != nil { +func (l *Loop) Advance(ctx context.Context, st store.TurnStore, turnID turn.TurnID, events EventSink) (LoopResult, error) { + if err := l.checkArgs(ctx, st, turnID); err != nil { return LoopResult{}, err } - if l.isDriving(runID) { - return LoopResult{}, ErrRunAlreadyRunning + if l.isDriving(turnID) { + return LoopResult{}, ErrTurnAlreadyRunning } - s := l.acquire(runID) - defer l.release(runID) + s := l.acquire(turnID) + defer l.release(turnID) if !s.step.TryLock() { - return LoopResult{}, ErrRunAlreadyRunning + return LoopResult{}, ErrTurnAlreadyRunning } defer s.step.Unlock() - return l.advance(ctx, st, runID, l.wrapSink(events)) + return l.advance(ctx, st, turnID, l.wrapSink(events)) } // advance is the body of Advance. It only dispatches assignments and returns // their keys; outcome retrieval is a separate message-shaped operation through // Executor.GetOutcome. This keeps the Executor boundary usable across process // boundaries. -func (l *Loop) advance(ctx context.Context, rt store.RunStore, runID run.RunID, events EventSink) (LoopResult, error) { +func (l *Loop) advance(ctx context.Context, rt store.TurnStore, turnID turn.TurnID, events EventSink) (LoopResult, error) { maxRebuilds := l.Settings.MaxCandidateRebuilds if maxRebuilds == 0 { maxRebuilds = 4 } rebuilds := 0 candidateContended := false - var lastPosition run.RunPosition + var lastPosition turn.TurnPosition for { if err := ctx.Err(); err != nil { return LoopResult{}, err } - snapshot, err := rt.Load(ctx, runID) + snapshot, err := rt.Load(ctx, turnID) if err != nil { return LoopResult{}, err } - if snapshot.State.RunID != runID { - return LoopResult{}, fmt.Errorf("agent: loop: store returned RunID %q for %q", snapshot.State.RunID, runID) + if snapshot.State.TurnID != turnID { + return LoopResult{}, fmt.Errorf("agent: loop: store returned TurnID %q for %q", snapshot.State.TurnID, turnID) } if snapshot.State.Status.Terminal() { - return l.finish(ctx, events, rt.Scope(), runID, snapshot.State.Result), nil + return l.finish(ctx, events, rt.Scope(), turnID, snapshot.State.Result), nil } - // Only authoritative Run progress resets the contention bound, not + // Only authoritative Turn progress resets the contention bound, not // another Build or a context-only change made by a hook. if snapshot.Position != lastPosition { rebuilds = 0 @@ -282,8 +282,8 @@ func (l *Loop) advance(ctx context.Context, rt store.RunStore, runID run.RunID, switch act := action.(type) { case plan.NeedModelRequest: - // The hook runs while the Run is Open, before the plan reads - // the context; what it commits moves no Run fact, + // The hook runs while the Turn is Open, before the plan reads + // the context; what it commits moves no Turn fact, // so the snapshot's Position stays valid for the Prepare. if hook := l.Settings.BeforePrepare; hook != nil { if err := hook(ctx, rt, act.Hint); err != nil { @@ -302,21 +302,21 @@ func (l *Loop) advance(ctx context.Context, rt store.RunStore, runID run.RunID, rebuilds++ } // Reload before enforcing the bound: the hook may itself - // have advanced Run authority. Replan the hint and CAS too. + // have advanced Turn authority. Replan the hint and CAS too. case CandidateMaintenance: return LoopResult{Disposition: LoopWaiting, Reason: ContextMaintenance}, nil } case plan.WithdrawPrepared: // Inputs arrived after this step was frozen: discard the unsent // request and replan with them. A retriable rejection - // means another actor moved the Run; the reload decides. - res, err := l.commit(ctx, rt, runID, schema.Identity().DeriveWithdrawCommandID(runID, act.StepID), snapshot.Position, - run.WithdrawPreparedStep(act)) + // means another actor moved the Turn; the reload decides. + res, err := l.commit(ctx, rt, turnID, schema.Identity().DeriveWithdrawCommandID(turnID, act.StepID), snapshot.Position, + turn.WithdrawPreparedStep(act)) if err != nil && !retriable(err) { return LoopResult{}, err } if err == nil { - l.emitCommitted(ctx, events, rt.Scope(), runID, res.Facts) + l.emitCommitted(ctx, events, rt.Scope(), turnID, res.Facts) } case plan.StartModelCall: dispatched, err := l.startModelStep(ctx, rt, events, &snapshot, act.StepID) @@ -335,7 +335,7 @@ func (l *Loop) advance(ctx context.Context, rt store.RunStore, runID run.RunID, return LoopResult{Disposition: LoopDispatched, Dispatched: dispatched}, nil } case plan.Idle: - recovery := run.NeedsRecovery(snapshot.State) + recovery := turn.NeedsRecovery(snapshot.State) reason := WaitReason("") if recovery { reason = ExecutionRecovery @@ -347,11 +347,11 @@ func (l *Loop) advance(ctx context.Context, rt store.RunStore, runID run.RunID, } } -// finish is the single exit for a terminal Run, whether the terminal state +// finish is the single exit for a terminal Turn, whether the terminal state // was read by Load or returned by the settlement that produced it. -func (l *Loop) finish(ctx context.Context, events EventSink, scope run.Scope, runID run.RunID, result *run.RunResult) LoopResult { +func (l *Loop) finish(ctx context.Context, events EventSink, scope turn.Scope, turnID turn.TurnID, result *turn.TurnResult) LoopResult { if events != nil { - _ = events.Emit(ctx, Event{Session: scope, RunID: runID, Kind: EventRunFinished, Durability: EventCommitted}) + _ = events.Emit(ctx, Event{Session: scope, TurnID: turnID, Kind: EventTurnFinished, Durability: EventCommitted}) } return LoopResult{Disposition: LoopFinished, Result: result} } @@ -360,28 +360,31 @@ func (l *Loop) finish(ctx context.Context, events EventSink, scope run.Scope, ru // call Executing under the effect the Outcome's key names and commits the // settlement under the effect's derived CommandID; an Outcome whose effect // is no longer Executing is dropped and nothing is written. It returns -// LoopFinished when the settlement terminated the Run, LoopDelivered when the +// LoopFinished when the settlement terminated the Turn, LoopDelivered when the // host should Advance next, LoopDropped for a stale Outcome. Ownership loss // is returned as is. -func (l *Loop) Deliver(ctx context.Context, st store.RunStore, out Outcome, events EventSink) (LoopResult, error) { - if err := l.checkArgs(ctx, st, out.Key.RunID); err != nil { +func (l *Loop) Deliver(ctx context.Context, st store.TurnStore, out Outcome, events EventSink) (LoopResult, error) { + if out.Key.OwnerKind != OwnerTurn { + return LoopResult{}, errors.New("loop: outcome requires a Turn owner") + } + if err := l.checkArgs(ctx, st, turn.TurnID(out.Key.OwnerID)); err != nil { return LoopResult{}, err } - s := l.acquire(out.Key.RunID) - defer l.release(out.Key.RunID) + s := l.acquire(turn.TurnID(out.Key.OwnerID)) + defer l.release(turn.TurnID(out.Key.OwnerID)) s.step.Lock() defer s.step.Unlock() return l.deliver(ctx, st, out, l.wrapSink(events)) } -func (l *Loop) deliver(ctx context.Context, rt store.RunStore, out Outcome, events EventSink) (LoopResult, error) { +func (l *Loop) deliver(ctx context.Context, rt store.TurnStore, out Outcome, events EventSink) (LoopResult, error) { if out.Key.Session != "" && out.Key.Session != rt.Scope() { return LoopResult{Disposition: LoopDropped}, nil } - runID := out.Key.RunID - snapshot, err := rt.Load(ctx, runID) + turnID := turn.TurnID(out.Key.OwnerID) + snapshot, err := rt.Load(ctx, turnID) if err != nil { - if errors.Is(err, store.ErrRunNotFound) { + if errors.Is(err, store.ErrTurnNotFound) { return LoopResult{Disposition: LoopDropped}, nil } return LoopResult{}, err @@ -389,22 +392,22 @@ func (l *Loop) deliver(ctx context.Context, rt store.RunStore, out Outcome, even if snapshot.State.Status.Terminal() { return LoopResult{Disposition: LoopDropped}, nil } - ref := effectRef{runID: runID, id: out.Key.Effect} + ref := effectRef{turnID: turnID, id: out.Key.Effect} // The key names an effect; the machine state says which step or call is // Executing under it. An effect nothing is Executing under is stale. - var cmd run.AgentCommand + var cmd turn.AgentCommand var settleErr error - var stepID run.StepID - var callID run.CallID + var stepID turn.StepID + var callID turn.CallID switch cur := snapshot.State.Current.(type) { - case run.ModelStep: - if cur.Status != run.ModelExecuting || out.Key.Effect == "" || cur.Effect != out.Key.Effect { + case turn.ModelStep: + if cur.Status != turn.ModelExecuting || out.Key.Effect == "" || cur.Effect != out.Key.Effect { return LoopResult{Disposition: LoopDropped}, nil } stepID = cur.RefValue.ID cmd, settleErr = l.modelCompletion(&cur, out) - case run.ToolStep: + case turn.ToolStep: call, ok := executingCall(&cur, out.Key.Effect) if !ok { return LoopResult{Disposition: LoopDropped}, nil @@ -423,7 +426,7 @@ func (l *Loop) deliver(ctx context.Context, rt store.RunStore, out Outcome, even return LoopResult{}, err } if callID != "" && events != nil { - _ = events.Emit(ctx, Event{Session: rt.Scope(), RunID: runID, StepID: stepID, CallID: callID, + _ = events.Emit(ctx, Event{Session: rt.Scope(), TurnID: turnID, StepID: stepID, CallID: callID, Kind: EventToolCompleted, Durability: EventCommitted}) } if settleErr != nil { @@ -434,26 +437,26 @@ func (l *Loop) deliver(ctx context.Context, rt store.RunStore, out Outcome, even return LoopResult{Disposition: LoopDelivered}, settleErr } if finished != nil { - return l.finish(ctx, events, rt.Scope(), runID, finished), nil + return l.finish(ctx, events, rt.Scope(), turnID, finished), nil } return LoopResult{Disposition: LoopDelivered}, nil } -// Run drives the Run until it finishes, has no executable action, or the +// Run drives the Turn until it finishes, has no executable action, or the // context is cancelled: Advance, wait for the Outcomes of what it // dispatched, GetOutcome, Deliver, repeat. This synchronous convenience uses // the same Advance/Deliver protocol; Activation hosts use those directly. // Context cancellation detaches local observation. Only a committed domain // cancellation authorizes cancelling an accepted external execution. -func (l *Loop) Run(ctx context.Context, rt store.RunStore, runID run.RunID, events EventSink) (LoopResult, error) { - if err := l.checkArgs(ctx, rt, runID); err != nil { +func (l *Loop) Run(ctx context.Context, rt store.TurnStore, turnID turn.TurnID, events EventSink) (LoopResult, error) { + if err := l.checkArgs(ctx, rt, turnID); err != nil { return LoopResult{}, err } - s, err := l.startDriving(runID) + s, err := l.startDriving(turnID) if err != nil { return LoopResult{}, err } - defer l.stopDriving(runID) + defer l.stopDriving(turnID) events = l.wrapSink(events) outcomes := make(chan outcomeRead, 64) @@ -467,7 +470,7 @@ func (l *Loop) Run(ctx context.Context, rt store.RunStore, runID run.RunID, even readCtx, stopReads := context.WithCancel(settleCtx) defer stopReads() // Registrations with the Watcher outlive this drive unless dropped: a - // key still pending when Run returns (ownership lost, a read error) + // key still pending when the drive returns (ownership lost, a read error) // belongs to whoever drives next. defer func() { for _, cancel := range pending { @@ -478,7 +481,7 @@ func (l *Loop) Run(ctx context.Context, rt store.RunStore, runID run.RunID, even for { if len(pending) == 0 { s.step.Lock() - res, err := l.advance(ctx, rt, runID, events) + res, err := l.advance(ctx, rt, turnID, events) s.step.Unlock() if err != nil { return LoopResult{}, err @@ -549,8 +552,8 @@ func awaitOutcome(ctx context.Context, watcher *effect.Watcher, key AssignmentKe // 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, rt store.RunStore, runID run.RunID, id run.CommandID, base run.RunPosition, cmd run.AgentCommand) (store.CommitResult, error) { - env, err := schema.Wire().Envelope(runID, id, cmd) +func (l *Loop) commit(ctx context.Context, rt store.TurnStore, turnID turn.TurnID, id turn.CommandID, base turn.TurnPosition, cmd turn.AgentCommand) (store.CommitResult, error) { + env, err := schema.Wire().Envelope(turnID, id, cmd) if err != nil { return store.CommitResult{}, err } @@ -564,5 +567,5 @@ func (l *Loop) commit(ctx context.Context, rt store.RunStore, runID run.RunID, i // retriable reports the commit errors that mean "reload and rederive". func retriable(err error) bool { - return errors.Is(err, run.ErrStaleRuntime) || errors.Is(err, run.ErrRunTerminal) || errors.Is(err, run.ErrCommandConflict) + return errors.Is(err, turn.ErrStaleRuntime) || errors.Is(err, turn.ErrTurnTerminal) || errors.Is(err, turn.ErrCommandConflict) } diff --git a/agentcore/run/loop/loop_test.go b/agentcore/turn/loop/loop_test.go similarity index 92% rename from agentcore/run/loop/loop_test.go rename to agentcore/turn/loop/loop_test.go index 3d5e9695..ff5e1da3 100644 --- a/agentcore/run/loop/loop_test.go +++ b/agentcore/turn/loop/loop_test.go @@ -11,16 +11,16 @@ import ( "time" "github.com/felinics/twilight/agentcore/prompt" - . "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/plan" - "github.com/felinics/twilight/agentcore/run/reconcile" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/sessionstore" - "github.com/felinics/twilight/agentcore/run/store" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/testutil/local" "github.com/felinics/twilight/agentcore/testutil/sdkconv" + . "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/plan" + "github.com/felinics/twilight/agentcore/turn/reconcile" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/sessionstore" + "github.com/felinics/twilight/agentcore/turn/store" "github.com/felinics/twilight/sdk" "github.com/google/jsonschema-go/jsonschema" @@ -174,8 +174,8 @@ func TestLoopRejectsConcurrentRunForSameID(t *testing.T) { done <- runErr }() <-invoker.started - if _, err := loop.Run(context.Background(), rt.Bind(w), "run-1", nil); !errors.Is(err, ErrRunAlreadyRunning) { - t.Fatalf("concurrent Run error = %v, want ErrRunAlreadyRunning", err) + if _, err := loop.Run(context.Background(), rt.Bind(w), "run-1", nil); !errors.Is(err, ErrTurnAlreadyRunning) { + t.Fatalf("concurrent Run error = %v, want ErrTurnAlreadyRunning", err) } close(invoker.release) if err := <-done; err != nil { @@ -202,7 +202,7 @@ func TestLoopModelCatalogErrorRecoversWithFreshLoop(t *testing.T) { if loadErr != nil { t.Fatal(loadErr) } - if snap.State.Status != RunActive { + if snap.State.Status != TurnActive { t.Fatalf("status = %v", snap.State.Status) } // Validate caught the missing model before the start barrier: the step is @@ -228,7 +228,7 @@ func TestLoopModelCatalogErrorRecoversWithFreshLoop(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Result == nil || res.Result.Status != RunCompleted || invoker.calls.Load() != 1 { + if res.Result == nil || res.Result.Status != TurnCompleted || invoker.calls.Load() != 1 { t.Fatalf("res = %+v, model calls = %d", res, invoker.calls.Load()) } final, err := rt.Bind(w).Load(context.Background(), "run-1") @@ -283,7 +283,7 @@ func TestLoopParallelBounded(t *testing.T) { if runErr != nil { t.Fatal(runErr) } - if res.Result.Status != RunCompleted { + if res.Result.Status != TurnCompleted { t.Fatalf("res = %+v", res) } if peak.Load() != 2 { @@ -291,7 +291,7 @@ func TestLoopParallelBounded(t *testing.T) { } } -type staleCommitRuntime struct{ store.RunStore } +type staleCommitRuntime struct{ store.TurnStore } func (staleCommitRuntime) Commit(context.Context, store.CommitRequest) (store.CommitResult, error) { return store.CommitResult{}, ErrStaleRuntime @@ -314,9 +314,9 @@ func TestToolStartStaleIsNotAnError(t *testing.T) { } stepID := StepID("step-1") snapshot := &store.Snapshot{State: MachineState{ - RunID: "run-1", Status: RunActive, + TurnID: "run-1", Status: TurnActive, Current: ToolStep{ - RefValue: StepRef{RunID: "run-1", ID: stepID}, + RefValue: StepRef{TurnID: "run-1", ID: stepID}, Source: "model-1", Calls: []ToolCallState{{ CallID: callID, ProviderCallID: "c1", ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, @@ -326,17 +326,17 @@ func TestToolStartStaleIsNotAnError(t *testing.T) { }, Position: 1} rt, w := loopRuntime(t) - dispatched, err := loop.startToolCalls(context.Background(), staleCommitRuntime{RunStore: rt.Bind(w)}, nil, snapshot, + dispatched, err := loop.startToolCalls(context.Background(), staleCommitRuntime{TurnStore: rt.Bind(w)}, nil, snapshot, plan.StartToolCalls{StepID: stepID, CallIDs: []CallID{callID}}) if err != nil || len(dispatched) != 0 { t.Fatalf("stale start: dispatched=%d err=%v", len(dispatched), err) } } -// responseLossRuntime is a run store whose bound ports lose the response of +// responseLossRuntime is a Turn store whose bound ports lose the response of // some accepted commits, so a test can see the Loop's one-shot replay. type responseLossRuntime struct { - *sessionstore.SessionRunStore + *sessionstore.SessionTurnStore mu sync.Mutex count map[CommandID]int loseModelStart bool @@ -345,21 +345,21 @@ type responseLossRuntime struct { func newResponseLossRuntime(t *testing.T) (*responseLossRuntime, writer.Writer) { t.Helper() rt, w := loopRuntime(t) - return &responseLossRuntime{SessionRunStore: rt, count: make(map[CommandID]int)}, w + return &responseLossRuntime{SessionTurnStore: rt, count: make(map[CommandID]int)}, w } -func (r *responseLossRuntime) Bind(w writer.Writer) store.RunStore { - return lossyStore{RunStore: r.SessionRunStore.Bind(w), r: r} +func (r *responseLossRuntime) Bind(w writer.Writer) store.TurnStore { + return lossyStore{TurnStore: r.SessionTurnStore.Bind(w), r: r} } type lossyStore struct { - store.RunStore + store.TurnStore r *responseLossRuntime } func (s lossyStore) Commit(ctx context.Context, req store.CommitRequest) (store.CommitResult, error) { r := s.r - result, err := s.RunStore.Commit(ctx, req) + result, err := s.TurnStore.Commit(ctx, req) if err != nil { return result, err } @@ -442,7 +442,7 @@ func TestLoopReplaysSettlementWithoutRepeatingTool(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Disposition != LoopFinished || res.Result == nil || res.Result.Status != RunCompleted { + if res.Disposition != LoopFinished || res.Result == nil || res.Result.Status != TurnCompleted { t.Fatalf("result = %+v", res) } if executions.Load() != 1 { @@ -472,7 +472,7 @@ func TestLoopMalformedModelResultDispositionFailsRun(t *testing.T) { 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 { + if res.Result == nil || res.Result.Status != TurnFailed || res.Result.Reason != ReasonMalformedModel { t.Fatalf("loop result = %+v", res) } } @@ -532,12 +532,12 @@ func TestLoopMidExecutionCancellationDetachesWithoutRecoveringEffect(t *testing. defer stop() observer := &effect.Watcher{Port: lp.Executor, Poll: time.Millisecond} defer observer.Close() - outcome, err := observer.Await(control, AssignmentKey{Session: rt.Bind(w).Scope(), RunID: "run-1", Effect: step.Effect}) + outcome, err := observer.Await(control, AssignmentKey{Session: rt.Bind(w).Scope(), OwnerKind: OwnerTurn, OwnerID: string("run-1"), Effect: step.Effect}) if err != nil { t.Fatal(err) } res, err := lp.Deliver(control, rt.Bind(w), outcome, nil) - if err != nil || res.Disposition != LoopFinished || res.Result.Status != RunCompleted { + if err != nil || res.Disposition != LoopFinished || res.Result.Status != TurnCompleted { t.Fatalf("accepted execution did not survive detachment: %+v %v", res, err) } } diff --git a/agentcore/run/loop/model.go b/agentcore/turn/loop/model.go similarity index 67% rename from agentcore/run/loop/model.go rename to agentcore/turn/loop/model.go index ecd5ed0f..2e11ad70 100644 --- a/agentcore/run/loop/model.go +++ b/agentcore/turn/loop/model.go @@ -5,15 +5,15 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/prompt" - run "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/store" + turn "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/store" ) -func (l *Loop) planAndPrepare(ctx context.Context, rt store.RunStore, events EventSink, snapshot *store.Snapshot, hint prompt.Input) (CandidateDecision, error) { +func (l *Loop) planAndPrepare(ctx context.Context, rt store.TurnStore, events EventSink, snapshot *store.Snapshot, hint prompt.Input) (CandidateDecision, error) { hint.Scope = rt.Scope() p, err := l.Builder.Build(ctx, hint) if err != nil { @@ -22,12 +22,12 @@ func (l *Loop) planAndPrepare(ctx context.Context, rt store.RunStore, events Eve frozenRequest := p.Request modelRef := p.Model if modelRef == "" { - modelRef = run.ModelRef(frozenRequest.Model) + modelRef = turn.ModelRef(frozenRequest.Model) } if modelRef == "" { return CandidateReady, fmt.Errorf("agent: loop: empty model") } - if run.ModelRef(frozenRequest.Model) != modelRef { + if turn.ModelRef(frozenRequest.Model) != modelRef { return CandidateReady, fmt.Errorf("agent: loop: request model %q does not match plan model %q", frozenRequest.Model, modelRef) } p.Model = modelRef @@ -48,9 +48,9 @@ func (l *Loop) planAndPrepare(ctx context.Context, rt store.RunStore, events Eve if err != nil { return CandidateReady, err } - cmdID := schema.Identity().DeriveModelRequestCommandID(snapshot.State.RunID, snapshot.Position) - stepID := schema.Identity().DeriveModelStepID(snapshot.State.RunID, cmdID) - res, err := l.commit(ctx, rt, snapshot.State.RunID, cmdID, snapshot.Position, run.PrepareModelRequest{ + cmdID := schema.Identity().DeriveModelRequestCommandID(snapshot.State.TurnID, snapshot.Position) + stepID := schema.Identity().DeriveModelStepID(snapshot.State.TurnID, cmdID) + res, err := l.commit(ctx, rt, snapshot.State.TurnID, cmdID, snapshot.Position, turn.PrepareModelRequest{ StepID: stepID, Model: modelRef, Request: frozenRequest, @@ -61,9 +61,9 @@ func (l *Loop) planAndPrepare(ctx context.Context, rt store.RunStore, events Eve }) if err == nil { // ModelStepPrepared carries the frozen request — the most informative - // fact of the run; observers must see it like every other accepted + // fact of the Turn; observers must see it like every other accepted // transition. - l.emitCommitted(ctx, events, rt.Scope(), snapshot.State.RunID, res.Facts) + l.emitCommitted(ctx, events, rt.Scope(), snapshot.State.TurnID, res.Facts) return CandidateReady, nil } if !retriable(err) { @@ -72,14 +72,14 @@ func (l *Loop) planAndPrepare(ctx context.Context, rt store.RunStore, events Eve // A retriable rejection with no authority progress means the rejection // was about THIS plan's content (InputIDs, digests), not concurrency: // retrying the same prompt builder at the same revision would spin forever. - after, loadErr := rt.Load(ctx, snapshot.State.RunID) + after, loadErr := rt.Load(ctx, snapshot.State.TurnID) if loadErr != nil { return CandidateReady, loadErr } if after.Position == snapshot.Position { return CandidateReady, fmt.Errorf("agent: loop: prepare rejected without authority progress: %w", err) } - return CandidateReady, nil // another actor advanced the run; reload decides the next action + return CandidateReady, nil // another actor advanced the Turn; reload decides the next action } // --- StartModelCall --- @@ -89,18 +89,18 @@ func (l *Loop) planAndPrepare(ctx context.Context, rt store.RunStore, events Eve // the reload should decide (another actor moved the step). A model catalog // that cannot serve the step withdraws it to Open and reports the error: no // model call has happened. -func (l *Loop) startModelStep(ctx context.Context, rt store.RunStore, events EventSink, snapshot *store.Snapshot, stepID run.StepID) (*AssignmentKey, error) { - runID := snapshot.State.RunID - prepared, ok := snapshot.State.Current.(run.ModelStep) +func (l *Loop) startModelStep(ctx context.Context, rt store.TurnStore, events EventSink, snapshot *store.Snapshot, stepID turn.StepID) (*AssignmentKey, error) { + turnID := snapshot.State.TurnID + prepared, ok := snapshot.State.Current.(turn.ModelStep) if !ok || prepared.RefValue.ID != stepID { return nil, fmt.Errorf("agent: loop: model step %q is not current", stepID) } - ref := modelEffect(runID, &prepared) - target, err := l.targetFor(ctx, EffectContext{Session: rt.Scope(), RunID: runID, StepID: stepID, Effect: ref.id, Kind: AssignmentModel}) + ref := modelEffect(turnID, &prepared) + target, err := l.targetFor(ctx, EffectContext{Session: rt.Scope(), TurnID: turnID, StepID: stepID, Effect: ref.id, Kind: AssignmentModel}) if err != nil { return nil, err } - assignment := Assignment{Session: rt.Scope(), RunID: runID, StepID: stepID, Effect: ref.id, Target: target, + assignment := Assignment{Session: rt.Scope(), OwnerKind: OwnerTurn, OwnerID: string(turnID), StepID: stepID, Effect: ref.id, Target: target, Body: ModelAssignment{Model: prepared.Model, RequestDigest: prepared.RequestDigest}} // Pre-start check: an executor that cannot serve the model // fails here, with the step still Prepared and no start or recovery fact. @@ -111,17 +111,17 @@ func (l *Loop) startModelStep(ctx context.Context, rt store.RunStore, events Eve if unavailable != nil { return nil, fmt.Errorf("%w: %s: %s: %s", ErrModelUnavailable, prepared.Model, unavailable.Class, unavailable.Message) } - start, err := l.commit(ctx, rt, runID, ref.startID(), snapshot.Position, run.StartModelExecution{StepID: stepID, Effect: ref.id}) + start, err := l.commit(ctx, rt, turnID, ref.startID(), snapshot.Position, turn.StartModelExecution{StepID: stepID, Effect: ref.id}) if err != nil { if retriable(err) { return nil, nil // another actor moved the step; reload decides } return nil, err } - l.emitCommitted(ctx, events, rt.Scope(), runID, start.Facts) + l.emitCommitted(ctx, events, rt.Scope(), turnID, start.Facts) - modelStep, ok := start.Snapshot.State.Current.(run.ModelStep) - if !ok || modelStep.RefValue.ID != stepID || modelStep.Status != run.ModelExecuting || modelStep.Effect != ref.id { + modelStep, ok := start.Snapshot.State.Current.(turn.ModelStep) + if !ok || modelStep.RefValue.ID != stepID || modelStep.Status != turn.ModelExecuting || modelStep.Effect != ref.id { // The start (or its one-shot replay) landed but the step is no longer // Executing: something settled it meanwhile. Reload decides. if start.Status == store.CommitAlreadyApplied { @@ -133,7 +133,7 @@ func (l *Loop) startModelStep(ctx context.Context, rt store.RunStore, events Eve request, err := rt.FrozenRequest(ctx, prepared.RequestDigest) if err != nil { if _, serr := l.settle(ctx, rt, events, &ref, start.Snapshot.Position, - run.RecoverModelExecution{StepID: stepID, Effect: ref.id}); serr != nil { + turn.RecoverModelExecution{StepID: stepID, Effect: ref.id}); serr != nil { return nil, serr } return nil, fmt.Errorf("agent: loop: load frozen model request: %w", err) @@ -150,7 +150,7 @@ func (l *Loop) startModelStep(ctx context.Context, rt store.RunStore, events Eve // A definite permanent refusal withdraws the step under its recovery // identity. Settlement still respects the caller's bounded context. if _, serr := l.settle(ctx, rt, events, &ref, start.Snapshot.Position, - run.RecoverModelExecution{StepID: stepID, Effect: ref.id}); serr != nil { + turn.RecoverModelExecution{StepID: stepID, Effect: ref.id}); serr != nil { return nil, serr } return nil, fmt.Errorf("agent: loop: model dispatch: %w", err) @@ -169,10 +169,10 @@ func (l *Loop) startModelStep(ctx context.Context, rt store.RunStore, events Eve // condition is returned as an error alongside the command: the settlement // lands, and the drive stops instead of prompt building, freezing and dispatching // again against the same missing store. Whether to try again is the host's -// decision, so a persistently unreadable store cannot spin the Run. -func (l *Loop) modelCompletion(step *run.ModelStep, out Outcome) (run.AgentCommand, error) { +// decision, so a persistently unreadable store cannot spin the Turn. +func (l *Loop) modelCompletion(step *turn.ModelStep, out Outcome) (turn.AgentCommand, error) { stepID := step.RefValue.ID - withdraw := run.RecoverModelExecution{StepID: stepID, Effect: step.Effect} + withdraw := turn.RecoverModelExecution{StepID: stepID, Effect: step.Effect} var result model.ModelResult switch r := out.Result.(type) { case effect.ModelSucceeded: @@ -191,55 +191,55 @@ func (l *Loop) modelCompletion(step *run.ModelStep, out Outcome) (run.AgentComma case effect.FailureFrozenValueMissing: return withdraw, fmt.Errorf("agent: loop: model dispatch: %w: %s", frozen.ErrMissing, r.Message) case effect.FailureMalformedRequest, effect.FailureMalformedResult: - failure := run.StepFailure{Class: run.FailureMalformedModel, Message: r.Message} - return run.RejectModelResult{StepID: stepID, Effect: step.Effect, Failure: failure, Disposition: l.modelRejectDisposition(step, failure)}, nil + failure := turn.StepFailure{Class: turn.FailureMalformedModel, Message: r.Message} + return turn.RejectModelResult{StepID: stepID, Effect: step.Effect, Failure: failure, Disposition: l.modelRejectDisposition(step, failure)}, nil case effect.FailureDeadline: return withdraw, nil default: - return run.SubmitModelFailure{StepID: stepID, Effect: step.Effect, Failure: run.StepFailure{Class: run.FailureProvider, Message: r.Message}}, nil + return turn.SubmitModelFailure{StepID: stepID, Effect: step.Effect, Failure: turn.StepFailure{Class: turn.FailureProvider, Message: r.Message}}, nil } default: // A tool result or no result for a model step: the executor answered // for the wrong effect. Nothing certain happened. - return run.SubmitModelFailure{StepID: stepID, Effect: step.Effect, Failure: run.StepFailure{Class: run.FailureProvider, Message: fmt.Sprintf("executor delivered %T for a model step", out.Result)}}, nil + return turn.SubmitModelFailure{StepID: stepID, Effect: step.Effect, Failure: turn.StepFailure{Class: turn.FailureProvider, Message: fmt.Sprintf("executor delivered %T for a model step", out.Result)}}, nil } bindings := l.bindToolCalls(&result, step) - return run.SubmitModelResult{StepID: stepID, Effect: step.Effect, Result: result, Calls: bindings, Scheduling: l.toolScheduling()}, nil + return turn.SubmitModelResult{StepID: stepID, Effect: step.Effect, Result: result, Calls: bindings, Scheduling: l.toolScheduling()}, nil } // modelRejectDisposition applies Settings.MalformedRetries: the step's // Rejects counts the malformed results already recorded, so the step is -// retried while that count is below the bound and fails the Run otherwise. +// retried while that count is below the bound and fails the Turn otherwise. // Zero retries fails on the first malformed result. -func (l *Loop) modelRejectDisposition(step *run.ModelStep, _ run.StepFailure) run.ModelRejectDisposition { +func (l *Loop) modelRejectDisposition(step *turn.ModelStep, _ turn.StepFailure) turn.ModelRejectDisposition { if step.Rejects < int(l.Settings.MalformedRetries) { - return run.ModelRejectRetry + return turn.ModelRejectRetry } - return run.ModelRejectFailRun + return turn.ModelRejectFailTurn } // bindToolCalls produces bindings from the frozen ToolSpecs. // It never calls local.ExecutableTool. -func (l *Loop) bindToolCalls(result *model.ModelResult, step *run.ModelStep) []run.ToolCallBinding { +func (l *Loop) bindToolCalls(result *model.ModelResult, step *turn.ModelStep) []turn.ToolCallBinding { if len(result.ToolCalls) == 0 { return nil } - specByName := make(map[string]run.ToolSpec, len(step.Tools)) + specByName := make(map[string]turn.ToolSpec, len(step.Tools)) for _, s := range step.Tools { specByName[s.Name] = s } - bindings := make([]run.ToolCallBinding, len(result.ToolCalls)) + bindings := make([]turn.ToolCallBinding, len(result.ToolCalls)) for i, tc := range result.ToolCalls { args := tc.Input.Canonical() - // The Run's CallID derives from the step and position; the provider's + // The Turn'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{ + b := turn.ToolCallBinding{ CallID: schema.Identity().DeriveCallID(step.RefValue.ID, i), ProviderCallID: tc.ToolCallID, - ToolRef: run.ToolRef(tc.ToolName), + ToolRef: turn.ToolRef(tc.ToolName), Arguments: args, - Policy: run.DirectExecution, + Policy: turn.DirectExecution, } if spec, known := specByName[tc.ToolName]; known { // The binding's ToolRef is the frozen spec's Ref — the catalog diff --git a/agentcore/run/loop/ownership_test.go b/agentcore/turn/loop/ownership_test.go similarity index 91% rename from agentcore/run/loop/ownership_test.go rename to agentcore/turn/loop/ownership_test.go index 4cd2379a..cbafdc68 100644 --- a/agentcore/run/loop/ownership_test.go +++ b/agentcore/turn/loop/ownership_test.go @@ -8,18 +8,18 @@ import ( "testing" "time" - . "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/reconcile" - "github.com/felinics/twilight/agentcore/run/store" "github.com/felinics/twilight/agentcore/testutil/local" + . "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/reconcile" + "github.com/felinics/twilight/agentcore/turn/store" "github.com/felinics/twilight/sdk" ) -// commitLog wraps a bound RunStore and records every AgentCommand it is +// commitLog wraps a bound TurnStore and records every AgentCommand it is // asked to commit, so a test can assert what a fenced Loop still tried to // write. type commitLog struct { - store.RunStore + store.TurnStore mu sync.Mutex cmds []AgentCommand } @@ -28,7 +28,7 @@ func (c *commitLog) Commit(ctx context.Context, req store.CommitRequest) (store. c.mu.Lock() c.cmds = append(c.cmds, req.Command.Command) c.mu.Unlock() - return c.RunStore.Commit(ctx, req) + return c.TurnStore.Commit(ctx, req) } func (c *commitLog) settlementsFor(callID CallID) int { @@ -56,9 +56,9 @@ func (c *commitLog) settlementsFor(callID CallID) int { // leave accepted executions alive for the successor instead of cancelling them. func TestOwnershipLossDetachesAndStopsSettling(t *testing.T) { stack := newTestStack(t, nil) - stack.createRun(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{}`)}) + stack.createTurn(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{}`)}) oldWriter := stack.writer(t) // the superseded owner's capability - oldRuntime := &commitLog{RunStore: stack.runtime.Bind(oldWriter)} + oldRuntime := &commitLog{TurnStore: stack.runtime.Bind(oldWriter)} spec := toolSpec(t, "echo", DirectExecution) started := make(chan CallID, 2) @@ -157,9 +157,9 @@ func TestOwnershipLossDetachesAndStopsSettling(t *testing.T) { // non-sentinel commit error would get. func TestOwnershipLossOnModelSettlementIsNotRetried(t *testing.T) { stack := newTestStack(t, nil) - stack.createRun(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{}`)}) + stack.createTurn(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{}`)}) oldWriter := stack.writer(t) // the superseded owner's capability - oldRuntime := &commitLog{RunStore: stack.runtime.Bind(oldWriter)} + oldRuntime := &commitLog{TurnStore: stack.runtime.Bind(oldWriter)} invoker := &blockingInvoker{started: make(chan struct{}), release: make(chan struct{})} loop, err := newLoop(t, nil, fakeCatalog{invoker}, fakeToolCatalog{nil}, staticBuilder{}, Settings{}, false) diff --git a/agentcore/run/loop/redispatch.go b/agentcore/turn/loop/redispatch.go similarity index 55% rename from agentcore/run/loop/redispatch.go rename to agentcore/turn/loop/redispatch.go index 4c5eb4bd..31542f71 100644 --- a/agentcore/run/loop/redispatch.go +++ b/agentcore/turn/loop/redispatch.go @@ -5,26 +5,29 @@ import ( "errors" "fmt" - run "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/store" + turn "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/store" ) -// ErrEffectNotExecuting reports a Redispatch of an effect the Run is not +// ErrEffectNotExecuting reports a Redispatch of an effect the Turn is not // Executing under: it settled, was recovered, or was never started. var ErrEffectNotExecuting = errors.New("agent: loop: effect is not executing") -// Redispatch rebuilds the Assignment of an Executing effect from the Run's +// Redispatch rebuilds the Assignment of an Executing effect from the Turn's // state and hands it to the Executor again. It is the effect process's -// dispatch step: the Run recorded the start barrier, so the +// dispatch step: the Turn recorded the start barrier, so the // effect is owed to the Executor, and a Dispatch the Loop's own drive did // not complete (a crash between the start fact and the call) is completed // here. The Executor recognises a replayed Assignment by its key, so a // Redispatch of an effect it already holds changes nothing. -func (l *Loop) Redispatch(ctx context.Context, rt store.RunStore, key AssignmentKey) error { - if err := l.checkArgs(ctx, rt, key.RunID); err != nil { +func (l *Loop) Redispatch(ctx context.Context, rt store.TurnStore, key AssignmentKey) error { + if key.OwnerKind != OwnerTurn || key.Session != rt.Scope() { + return errors.New("loop: redispatch key does not belong to this Turn store") + } + if err := l.checkArgs(ctx, rt, turn.TurnID(key.OwnerID)); err != nil { return err } - snapshot, err := rt.Load(ctx, key.RunID) + snapshot, err := rt.Load(ctx, turn.TurnID(key.OwnerID)) if err != nil { return err } @@ -33,12 +36,12 @@ func (l *Loop) Redispatch(ctx context.Context, rt store.RunStore, key Assignment } scope := rt.Scope() switch cur := snapshot.State.Current.(type) { - case run.ModelStep: - if cur.Status != run.ModelExecuting || key.Effect == "" || cur.Effect != key.Effect { + case turn.ModelStep: + if cur.Status != turn.ModelExecuting || key.Effect == "" || cur.Effect != key.Effect { return ErrEffectNotExecuting } stepID := cur.RefValue.ID - target, err := l.targetFor(ctx, EffectContext{Session: scope, RunID: key.RunID, StepID: stepID, Effect: key.Effect, Kind: AssignmentModel}) + target, err := l.targetFor(ctx, EffectContext{Session: scope, TurnID: turn.TurnID(key.OwnerID), StepID: stepID, Effect: key.Effect, Kind: AssignmentModel}) if err != nil { return err } @@ -46,19 +49,19 @@ func (l *Loop) Redispatch(ctx context.Context, rt store.RunStore, key Assignment if err != nil { return fmt.Errorf("agent: loop: redispatch: load frozen model request: %w", err) } - return l.dispatch(ctx, Assignment{Session: scope, RunID: key.RunID, StepID: stepID, Effect: key.Effect, Target: target, + return l.dispatch(ctx, Assignment{Session: scope, OwnerKind: OwnerTurn, OwnerID: string(turn.TurnID(key.OwnerID)), StepID: stepID, Effect: key.Effect, Target: target, Body: ModelAssignment{Model: cur.Model, RequestDigest: cur.RequestDigest, Request: &request}}) - case run.ToolStep: + case turn.ToolStep: call, ok := executingCall(&cur, key.Effect) if !ok { return ErrEffectNotExecuting } stepID := cur.RefValue.ID - target, err := l.targetFor(ctx, EffectContext{Session: scope, RunID: key.RunID, StepID: stepID, CallID: call.CallID, Effect: key.Effect, Kind: AssignmentTool, Tool: call.ToolRef, Placement: call.Placement}) + target, err := l.targetFor(ctx, EffectContext{Session: scope, TurnID: turn.TurnID(key.OwnerID), StepID: stepID, CallID: call.CallID, Effect: key.Effect, Kind: AssignmentTool, Tool: call.ToolRef, Placement: call.Placement}) if err != nil { return err } - return l.dispatch(ctx, Assignment{Session: scope, RunID: key.RunID, StepID: stepID, CallID: call.CallID, Effect: key.Effect, Target: target, + return l.dispatch(ctx, Assignment{Session: scope, OwnerKind: OwnerTurn, OwnerID: string(turn.TurnID(key.OwnerID)), StepID: stepID, CallID: call.CallID, Effect: key.Effect, Target: target, Body: ToolAssignment{ToolRef: call.ToolRef, DefinitionDigest: call.DefinitionDigest, Arguments: call.Arguments, Policy: call.Policy, Replay: call.Replay, Placement: call.Placement}}) default: return ErrEffectNotExecuting diff --git a/agentcore/run/loop/regression_test.go b/agentcore/turn/loop/regression_test.go similarity index 93% rename from agentcore/run/loop/regression_test.go rename to agentcore/turn/loop/regression_test.go index 96eba795..5aa4487f 100644 --- a/agentcore/run/loop/regression_test.go +++ b/agentcore/turn/loop/regression_test.go @@ -9,8 +9,8 @@ import ( "github.com/felinics/twilight/agentcore/testutil/local" "github.com/felinics/twilight/agentcore/testutil/sdkconv" - . "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/schema" + . "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/schema" "github.com/felinics/twilight/sdk" "github.com/google/jsonschema-go/jsonschema" ) @@ -30,7 +30,7 @@ func TestRegressionToolPanicBecomesUnknown(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Result.Status != RunCompleted { + if res.Result.Status != TurnCompleted { t.Fatalf("res = %+v", res.Result) } found := false @@ -62,11 +62,11 @@ func TestRegressionRunFinishedEmitted(t *testing.T) { t.Fatal(err) } for _, k := range kinds { - if k == EventRunFinished { + if k == EventTurnFinished { return } } - t.Fatalf("EventRunFinished never emitted; kinds = %v", kinds) + t.Fatalf("EventTurnFinished never emitted; kinds = %v", kinds) } func TestRegressionAliasedToolRefExecutes(t *testing.T) { @@ -105,7 +105,7 @@ func TestRegressionAliasedToolRefExecutes(t *testing.T) { if !executed.Load() { t.Fatal("aliased tool never executed") } - if res.Result.Status != RunCompleted { + if res.Result.Status != TurnCompleted { t.Fatalf("res = %+v", res.Result) } } @@ -117,7 +117,7 @@ func TestRegressionStreamNilResult(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Result.Status != RunFailed { + if res.Result.Status != TurnFailed { t.Fatalf("res = %+v", res.Result) } } diff --git a/agentcore/run/loop/require_test.go b/agentcore/turn/loop/require_test.go similarity index 77% rename from agentcore/run/loop/require_test.go rename to agentcore/turn/loop/require_test.go index 62f53249..ea77a25e 100644 --- a/agentcore/run/loop/require_test.go +++ b/agentcore/turn/loop/require_test.go @@ -2,13 +2,13 @@ package loop_test import ( "github.com/felinics/twilight/agentcore/testutil/sdkconv" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/loop" - "github.com/felinics/twilight/agentcore/run/schema" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/loop" + "github.com/felinics/twilight/agentcore/turn/schema" ) // RequireWaiting checks Loop yielded and a call is waiting for kind. -func (f *Feature) RequireWaiting(kind run.ResponseKind) { +func (f *Feature) RequireWaiting(kind turn.ResponseKind) { f.t.Helper() if f.last.Disposition != loop.LoopWaiting || f.last.ExecutionRecovery { f.t.Fatalf("loop = %+v, want Waiting", f.last) @@ -17,7 +17,7 @@ func (f *Feature) RequireWaiting(kind run.ResponseKind) { if w.Kind != kind { f.t.Fatalf("waiting kind = %s, want %s", w.Kind, kind) } - want := schema.Identity().DeriveResponseID(w.RunID, w.StepID, w.CallID, w.Kind) + want := schema.Identity().DeriveResponseID(w.TurnID, w.StepID, w.CallID, w.Kind) if w.ID != want { f.t.Fatalf("ResponseID = %q, want derived %q", w.ID, want) } @@ -32,13 +32,13 @@ func (f *Feature) RequireWaitingProvider(providerID string) { } } -// RequireCompleted checks the Run finished and that the last ModelStepCompleted +// RequireCompleted checks the Turn 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 { + if s.Status != turn.TurnCompleted || s.Result == nil { f.t.Fatalf("state = %+v", s) } if f.loop != nil && f.last.Disposition != loop.LoopFinished { @@ -59,9 +59,9 @@ func (f *Feature) RequireCompleted(text string) { if err != nil { f.t.Fatal(err) } - var got run.Digest + var got turn.Digest for _, fact := range f.facts() { - if c, ok := fact.(run.ModelStepCompleted); ok { + if c, ok := fact.(turn.ModelStepCompleted); ok { got = c.ResultDigest } } @@ -70,19 +70,19 @@ func (f *Feature) RequireCompleted(text string) { } } -// RequireStopped checks the Run stopped as cancelled. +// RequireStopped checks the Turn 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 { + if s.Status != turn.TurnStopped || s.Result == nil || s.Result.Reason != turn.ReasonCancelled { f.t.Fatalf("state = %+v, want stopped cancelled", s) } } -// RequireActive checks the Run is still active. +// RequireActive checks the Turn is still active. func (f *Feature) RequireActive() { f.t.Helper() - if f.state().Status != run.RunActive { + if f.state().Status != turn.TurnActive { f.t.Fatalf("status = %v, want active", f.state().Status) } } @@ -90,31 +90,31 @@ func (f *Feature) RequireActive() { // 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 { + ms, ok := f.state().Current.(turn.ModelStep) + if !ok || ms.Status != turn.ModelPrepared { f.t.Fatalf("current = %+v, want prepared model", f.state().Current) } } -// RequireOpen checks the Run is at the enterable Open interval. +// RequireOpen checks the Turn is at the enterable Open interval. func (f *Feature) RequireOpen() { f.t.Helper() - if _, ok := f.state().Current.(run.Open); !ok { + if _, ok := f.state().Current.(turn.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) { +func (f *Feature) RequireCallPending(id turn.CallID) { f.t.Helper() id = f.callByProvider(string(id)) - ts, ok := f.state().Current.(run.ToolStep) + ts, ok := f.state().Current.(turn.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 { + if call.Status != turn.ToolPending { f.t.Fatalf("call %s status = %v, want Pending", id, call.Status) } return @@ -126,7 +126,7 @@ func (f *Feature) RequireCallPending(id run.CallID) { // RequireNotRan checks the tool has not executed. func (f *Feature) RequireNotRan(name string) { f.t.Helper() - tool := f.tools[run.ToolRef(name)] + tool := f.tools[turn.ToolRef(name)] if tool == nil { f.t.Fatalf("tool %q not registered", name) return @@ -139,7 +139,7 @@ func (f *Feature) RequireNotRan(name string) { // RequireRan checks the tool executed at least once. func (f *Feature) RequireRan(name string) { f.t.Helper() - tool := f.tools[run.ToolRef(name)] + tool := f.tools[turn.ToolRef(name)] if tool == nil { f.t.Fatalf("tool %q not registered", name) return @@ -160,7 +160,7 @@ func (f *Feature) RequireModelCalls(n int) { } } -// RequireUsage checks accumulated total tokens on the Run result. +// RequireUsage checks accumulated total tokens on the Turn result. func (f *Feature) RequireUsage(total int) { f.t.Helper() s := f.state() @@ -171,8 +171,8 @@ func (f *Feature) RequireUsage(total int) { // RequireBuilderSawTool 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) RequireBuilderSawTool(callID run.CallID, output string) { +// (SourceStep); the completed call's OutputDigest is read from the Turn state. +func (f *Feature) RequireBuilderSawTool(callID turn.CallID, output string) { f.t.Helper() callID = f.callByProvider(string(callID)) if f.builder == nil || f.builder.lastHint.SourceStep == "" { @@ -182,12 +182,12 @@ func (f *Feature) RequireBuilderSawTool(callID run.CallID, output string) { if s.LastToolStep == nil || s.LastToolStep.RefValue.ID != f.builder.lastHint.SourceStep { f.t.Fatalf("hint SourceStep = %s, LastToolStep = %+v", f.builder.lastHint.SourceStep, s.LastToolStep) } - want, err := schema.Canonical().DigestToolOutput(run.MustParseCanonicalJSON(output)) + want, err := schema.Canonical().DigestToolOutput(turn.MustParseCanonicalJSON(output)) if err != nil { f.t.Fatal(err) } for _, call := range s.LastToolStep.Calls { - if call.CallID == callID && call.Status == run.ToolCompleted && call.Result != nil && call.Result.OutputDigest == want { + if call.CallID == callID && call.Status == turn.ToolCompleted && call.Result != nil && call.Result.OutputDigest == want { return } } @@ -195,11 +195,11 @@ func (f *Feature) RequireBuilderSawTool(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) { +func (f *Feature) RequireCallFailed(id turn.CallID, outcome turn.ToolFailureOutcome) { f.t.Helper() id = f.callByProvider(string(id)) for _, fact := range f.facts() { - failed, ok := fact.(run.ToolCallFailed) + failed, ok := fact.(turn.ToolCallFailed) if ok && failed.CallID == id && failed.Outcome == outcome { return } @@ -211,7 +211,7 @@ func (f *Feature) RequireCallFailed(id run.CallID, outcome run.ToolFailureOutcom func (f *Feature) RequireFailureClass(class string) { f.t.Helper() for _, fact := range f.facts() { - failed, ok := fact.(run.ToolCallFailed) + failed, ok := fact.(turn.ToolCallFailed) if ok && failed.Failure.Class == class { return } @@ -223,7 +223,7 @@ func (f *Feature) RequireFailureClass(class string) { func (f *Feature) RequireFactOpened() { f.t.Helper() for _, fact := range f.facts() { - if _, ok := fact.(run.ToolStepOpened); ok { + if _, ok := fact.(turn.ToolStepOpened); ok { return } } @@ -243,7 +243,7 @@ func (f *Feature) RequireNoUncertain() { } // RequireUncertainCall checks Cancel projected this executing tool call. -func (f *Feature) RequireUncertainCall(id run.CallID) { +func (f *Feature) RequireUncertainCall(id turn.CallID) { f.t.Helper() id = f.callByProvider(string(id)) s := f.state() diff --git a/agentcore/run/loop/retention_test.go b/agentcore/turn/loop/retention_test.go similarity index 93% rename from agentcore/run/loop/retention_test.go rename to agentcore/turn/loop/retention_test.go index 90f86abe..1550ca7c 100644 --- a/agentcore/run/loop/retention_test.go +++ b/agentcore/turn/loop/retention_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/felinics/twilight/agentcore/testutil/local" - . "github.com/felinics/twilight/agentcore/run" + . "github.com/felinics/twilight/agentcore/turn" "github.com/felinics/twilight/sdk" ) @@ -33,7 +33,7 @@ func TestLocalExecutorRetainsBoundedOutcomes(t *testing.T) { ctx := context.Background() var keys []string for _, n := range []string{"1", "2", "3"} { - a := Assignment{Session: testScope, RunID: "run-1", StepID: StepID("step-" + n), CallID: CallID("call-" + n), + a := Assignment{Session: testScope, OwnerKind: OwnerTurn, OwnerID: string("run-1"), StepID: StepID("step-" + n), CallID: CallID("call-" + n), Effect: EffectID("effect-" + n), Body: ToolAssignment{ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, Arguments: cj(`{}`), Policy: DirectExecution}} ref, err := exec.Prepare(ctx, a) @@ -90,7 +90,7 @@ func TestLocalExecutorRetainsBoundedOutcomes(t *testing.T) { } // Every entry point releases its slot on return, so the slot table only holds -// the Runs being driven at that moment. +// the TurnStore being driven at that moment. func TestLoopReleasesSlots(t *testing.T) { ctx := context.Background() cases := []struct { @@ -132,7 +132,7 @@ func TestLoopReleasesSlots(t *testing.T) { } rt, w := loopRuntime(t) res, err := l.Run(ctx, rt.Bind(w), "run-1", nil) - if err != nil || res.Result == nil || res.Result.Status != RunCompleted { + if err != nil || res.Result == nil || res.Result.Status != TurnCompleted { t.Fatalf("run = %+v %v", res, err) } return l diff --git a/agentcore/run/loop/script_test.go b/agentcore/turn/loop/script_test.go similarity index 71% rename from agentcore/run/loop/script_test.go rename to agentcore/turn/loop/script_test.go index 528fcd92..d8d9e44a 100644 --- a/agentcore/run/loop/script_test.go +++ b/agentcore/turn/loop/script_test.go @@ -7,11 +7,11 @@ import ( "sync" "sync/atomic" + "github.com/felinics/twilight/agentcore/prompt" "github.com/felinics/twilight/agentcore/testutil/local" "github.com/felinics/twilight/agentcore/testutil/sdkconv" - "github.com/felinics/twilight/agentcore/prompt" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/loop" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/loop" "github.com/felinics/twilight/sdk" ) @@ -75,7 +75,7 @@ type scriptCatalog struct { err error } -func (c scriptCatalog) ResolveModel(run.ModelRef) (local.ModelInvoker, error) { +func (c scriptCatalog) ResolveModel(turn.ModelRef) (local.ModelInvoker, error) { if c.err != nil { return nil, c.err } @@ -83,39 +83,39 @@ func (c scriptCatalog) ResolveModel(run.ModelRef) (local.ModelInvoker, error) { } type scriptTool struct { - ref run.ToolRef + ref turn.ToolRef def sdk.ToolDefinition - policy run.ResponsePolicy + policy turn.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) Replay() run.ReplayPolicy { return run.ReplayUnknown } -func (s *scriptTool) Placement() run.ToolPlacement { return run.PlacementProcess } -func (s *scriptTool) ValidateArguments(run.CanonicalJSON) error { +func (s *scriptTool) Ref() turn.ToolRef { return s.ref } +func (s *scriptTool) Definition() sdk.ToolDefinition { return s.def } +func (s *scriptTool) ResponsePolicy() turn.ResponsePolicy { return s.policy } +func (s *scriptTool) Replay() turn.ReplayPolicy { return turn.ReplayUnknown } +func (s *scriptTool) Placement() turn.ToolPlacement { return turn.PlacementProcess } +func (s *scriptTool) ValidateArguments(turn.CanonicalJSON) error { return nil } func (s *scriptTool) Execute(_ context.Context, req local.ToolExecutionRequest) loop.ToolExecutionOutcome { s.ran.Add(1) if s.unknown { - return loop.ToolExecutionUnknown{Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: "lost"}} + return loop.ToolExecutionUnknown{Failure: turn.ToolFailure{Class: turn.FailureEffectUnknown, Message: "lost"}} } if s.fail != "" { - return loop.ToolExecutionFailed{Failure: run.ToolFailure{Class: s.fail, Message: "boom"}} + return loop.ToolExecutionFailed{Failure: turn.ToolFailure{Class: s.fail, Message: "boom"}} } - return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} + return loop.ToolExecutionSucceeded{Result: turn.ToolExecutionResult{Output: req.Arguments}} } type scriptToolCatalog struct { - tools map[run.ToolRef]local.ExecutableTool + tools map[turn.ToolRef]local.ExecutableTool } -func (c scriptToolCatalog) ResolveTool(ref run.ToolRef) (local.ExecutableTool, error) { +func (c scriptToolCatalog) ResolveTool(ref turn.ToolRef) (local.ExecutableTool, error) { tool, ok := c.tools[ref] if !ok { return nil, fmt.Errorf("unknown tool %q", ref) @@ -124,9 +124,9 @@ func (c scriptToolCatalog) ResolveTool(ref run.ToolRef) (local.ExecutableTool, e } type scriptBuilder struct { - model run.ModelRef - specs []run.ToolSpec - defs map[run.ToolRef]sdk.ToolDefinition + model turn.ModelRef + specs []turn.ToolSpec + defs map[turn.ToolRef]sdk.ToolDefinition lastHint prompt.Input } @@ -140,7 +140,7 @@ func (p *scriptBuilder) Build(_ context.Context, hint prompt.Input) (prompt.Prom for _, spec := range p.specs { req.Tools = append(req.Tools, p.defs[spec.Ref]) } - ids := make([]run.InputID, len(hint.Inputs)) + ids := make([]turn.InputID, len(hint.Inputs)) for i, in := range hint.Inputs { ids[i] = in.ID } diff --git a/agentcore/run/loop/takeover_test.go b/agentcore/turn/loop/takeover_test.go similarity index 90% rename from agentcore/run/loop/takeover_test.go rename to agentcore/turn/loop/takeover_test.go index 61be22a5..ea6c66d3 100644 --- a/agentcore/run/loop/takeover_test.go +++ b/agentcore/turn/loop/takeover_test.go @@ -6,19 +6,19 @@ import ( "testing" "github.com/felinics/twilight/agentcore/testutil/local" - . "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/reconcile" - "github.com/felinics/twilight/agentcore/run/store" + . "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/reconcile" + "github.com/felinics/twilight/agentcore/turn/store" "github.com/felinics/twilight/sdk" ) // The owner process dies while a tool call is Executing. A new owner takes // the Session over, RecoverInterrupted settles the call as Unknown, a fresh -// Loop finishes the Run, and the dead owner's late settlement is fenced with +// Loop finishes the Turn, and the dead owner's late settlement is fenced with // ErrOwnershipLost. func TestTakeoverDisposesExecutingCallAndFencesOldOwner(t *testing.T) { stack := newTestStack(t, nil) - stack.createRun(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{}`)}) + stack.createTurn(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{}`)}) oldRuntime := stack.runtime oldWriter := stack.writer(t) // the superseded owner's capability @@ -66,7 +66,7 @@ func TestTakeoverDisposesExecutingCallAndFencesOldOwner(t *testing.T) { t.Fatal(err) } res, err := second.Run(context.Background(), stack.runtime.Bind(stack.writer(t)), "run-1", nil) - if err != nil || res.Disposition != LoopFinished || res.Result.Status != RunCompleted { + if err != nil || res.Disposition != LoopFinished || res.Result.Status != TurnCompleted { t.Fatalf("second loop = %+v %v", res, err) } unknown := 0 diff --git a/agentcore/run/loop/tool.go b/agentcore/turn/loop/tool.go similarity index 55% rename from agentcore/run/loop/tool.go rename to agentcore/turn/loop/tool.go index 0e3d622d..22e8a245 100644 --- a/agentcore/run/loop/tool.go +++ b/agentcore/turn/loop/tool.go @@ -4,14 +4,14 @@ import ( "context" "fmt" - run "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/plan" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/store" + turn "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/plan" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/store" ) -func toolCallIndex(step run.ToolStep, callID run.CallID) int { +func toolCallIndex(step turn.ToolStep, callID turn.CallID) int { for i := range step.Calls { if step.Calls[i].CallID == callID { return i @@ -27,14 +27,14 @@ func toolCallIndex(step run.ToolStep, callID run.CallID) int { // effect and handed to the Executor. It returns the dispatched keys; an empty // list with no error means nothing is executing on this Loop's behalf and the // reload decides. -func (l *Loop) startToolCalls(ctx context.Context, rt store.RunStore, events EventSink, snapshot *store.Snapshot, act plan.StartToolCalls) ([]AssignmentKey, error) { - runID := snapshot.State.RunID - ts, ok := snapshot.State.Current.(run.ToolStep) +func (l *Loop) startToolCalls(ctx context.Context, rt store.TurnStore, events EventSink, snapshot *store.Snapshot, act plan.StartToolCalls) ([]AssignmentKey, error) { + turnID := snapshot.State.TurnID + ts, ok := snapshot.State.Current.(turn.ToolStep) if !ok || ts.RefValue.ID != act.StepID { return nil, fmt.Errorf("agent: loop: tool step %q is not current", act.StepID) } limit := len(act.CallIDs) - if ts.Scheduling.Mode == run.ToolScheduleSequential { + if ts.Scheduling.Mode == turn.ToolScheduleSequential { limit = 1 } if ts.Scheduling.MaxParallel > 0 && ts.Scheduling.MaxParallel < limit { @@ -55,22 +55,22 @@ func (l *Loop) startToolCalls(ctx context.Context, rt store.RunStore, events Eve continue } call := ts.Calls[i] - if call.Status != run.ToolPending { + if call.Status != turn.ToolPending { // Executing calls settle through the Outcome of the effect they // started or the owner's takeover disposition; never re-run continue } - ref := toolEffect(runID, act.StepID, callID) + ref := toolEffect(turnID, act.StepID, callID) // The target is resolved per tool effect before the // pre-start check, so the Validate probe carries what the Assignment // will carry. - target, err := l.targetFor(ctx, EffectContext{Session: rt.Scope(), RunID: runID, StepID: act.StepID, CallID: callID, + target, err := l.targetFor(ctx, EffectContext{Session: rt.Scope(), TurnID: turnID, StepID: act.StepID, CallID: callID, Effect: ref.id, Kind: AssignmentTool, Tool: call.ToolRef, Placement: call.Placement}) if err != nil { return dispatched, err } binding := ToolAssignment{ToolRef: call.ToolRef, DefinitionDigest: call.DefinitionDigest, Arguments: call.Arguments, Policy: call.Policy, Replay: call.Replay, Placement: call.Placement} - probe := Assignment{Session: rt.Scope(), RunID: runID, StepID: act.StepID, CallID: callID, Target: target, Body: binding} + probe := Assignment{Session: rt.Scope(), OwnerKind: OwnerTurn, OwnerID: string(turnID), StepID: act.StepID, CallID: callID, Target: target, Body: binding} known, err := l.Executor.Validate(ctx, probe) if err != nil { return dispatched, err @@ -80,35 +80,35 @@ func (l *Loop) startToolCalls(ctx context.Context, rt store.RunStore, events Eve // barrier, no effect, no attempt. A call is declined at most // once, so the decline is identified by the call alone and a // retry of the same rejection is idempotent. - res, err := l.commit(ctx, rt, runID, schema.Identity().DeriveDeclineCommandID(runID, act.StepID, callID), snapshot.Position, - run.DeclineToolCall{StepID: act.StepID, CallID: callID, Failure: *known}) + res, err := l.commit(ctx, rt, turnID, schema.Identity().DeriveDeclineCommandID(turnID, act.StepID, callID), snapshot.Position, + turn.DeclineToolCall{StepID: act.StepID, CallID: callID, Failure: *known}) if err != nil { if retriable(err) { return dispatched, nil // another actor moved the call; reload decides } return dispatched, err } - l.emitCommitted(ctx, events, rt.Scope(), runID, res.Facts) + l.emitCommitted(ctx, events, rt.Scope(), turnID, res.Facts) continue } - start, err := l.commit(ctx, rt, runID, ref.startID(), snapshot.Position, - run.StartToolCall{StepID: act.StepID, CallID: callID, Effect: ref.id}) + start, err := l.commit(ctx, rt, turnID, ref.startID(), snapshot.Position, + turn.StartToolCall{StepID: act.StepID, CallID: callID, Effect: ref.id}) if err != nil { if retriable(err) { return dispatched, nil // another actor moved the call; reload decides } return dispatched, err } - if startedCall, ok := toolCallFromSnapshot(&start.Snapshot.State, act.StepID, callID); !ok || startedCall.Status != run.ToolExecuting || startedCall.Effect != ref.id { + if startedCall, ok := toolCallFromSnapshot(&start.Snapshot.State, act.StepID, callID); !ok || startedCall.Status != turn.ToolExecuting || startedCall.Effect != ref.id { // The one-shot replay may land after the call was settled, or the // call is Executing under another effect. Never invoke a call the - // Run did not start under this effect. + // The Turn did not start under this effect. continue } - l.emitCommitted(ctx, events, rt.Scope(), runID, start.Facts) + l.emitCommitted(ctx, events, rt.Scope(), turnID, start.Facts) if events != nil { - _ = events.Emit(ctx, Event{Session: rt.Scope(), RunID: runID, StepID: act.StepID, CallID: callID, + _ = events.Emit(ctx, Event{Session: rt.Scope(), TurnID: turnID, StepID: act.StepID, CallID: callID, Kind: EventToolStarted, Durability: EventCommitted}) } assignment := probe @@ -122,9 +122,9 @@ func (l *Loop) startToolCalls(ctx context.Context, rt store.RunStore, events Eve } // The effect never started: settle it as a Known execution // failure so the call does not stay Executing. - failure := run.ToolFailure{Class: run.FailureExecution, Message: "dispatch: " + err.Error()} + failure := turn.ToolFailure{Class: turn.FailureExecution, Message: "dispatch: " + err.Error()} if _, serr := l.settle(ctx, rt, events, &ref, start.Snapshot.Position, - run.SubmitToolFailure{StepID: act.StepID, CallID: callID, Effect: ref.id, Failure: failure, Outcome: run.ToolOutcomeKnown}); serr != nil { + turn.SubmitToolFailure{StepID: act.StepID, CallID: callID, Effect: ref.id, Failure: failure, Outcome: turn.ToolOutcomeKnown}); serr != nil { return dispatched, serr } continue @@ -134,65 +134,65 @@ func (l *Loop) startToolCalls(ctx context.Context, rt store.RunStore, events Eve return dispatched, nil } -func toolCallFromSnapshot(state *run.MachineState, stepID run.StepID, callID run.CallID) (run.ToolCallState, bool) { - step, ok := state.Current.(run.ToolStep) +func toolCallFromSnapshot(state *turn.MachineState, stepID turn.StepID, callID turn.CallID) (turn.ToolCallState, bool) { + step, ok := state.Current.(turn.ToolStep) if !ok || step.RefValue.ID != stepID { - return run.ToolCallState{}, false + return turn.ToolCallState{}, false } for i := range step.Calls { if step.Calls[i].CallID == callID { return step.Calls[i], true } } - return run.ToolCallState{}, false + return turn.ToolCallState{}, false } // executingCall finds the call of step that is Executing under the effect id. -func executingCall(step *run.ToolStep, id run.EffectID) (run.ToolCallState, bool) { +func executingCall(step *turn.ToolStep, id turn.EffectID) (turn.ToolCallState, bool) { if id == "" { - return run.ToolCallState{}, false + return turn.ToolCallState{}, false } for i := range step.Calls { - if step.Calls[i].Status == run.ToolExecuting && step.Calls[i].Effect == id { + if step.Calls[i].Status == turn.ToolExecuting && step.Calls[i].Effect == id { return step.Calls[i], true } } - return run.ToolCallState{}, false + return turn.ToolCallState{}, false } // toolCompletion maps a tool Outcome to the settlement of the call's tool // effect. A sealed outcome maps directly; a missing outcome or a transport // error is Unknown, because the effect may have happened. -func toolCompletion(stepID run.StepID, callID run.CallID, eff run.EffectID, out Outcome) run.AgentCommand { +func toolCompletion(stepID turn.StepID, callID turn.CallID, eff turn.EffectID, out Outcome) turn.AgentCommand { switch o := out.Result.(type) { case ToolExecutionSucceeded: - return run.SubmitToolResult{StepID: stepID, CallID: callID, Effect: eff, Result: o.Result} + return turn.SubmitToolResult{StepID: stepID, CallID: callID, Effect: eff, Result: o.Result} case ToolExecutionFailed: failure := o.Failure - if failure.Class == "" || failure.Class == run.FailureEffectUnknown { - failure.Class = run.FailureExecution + if failure.Class == "" || failure.Class == turn.FailureEffectUnknown { + failure.Class = turn.FailureExecution } - return run.SubmitToolFailure{StepID: stepID, CallID: callID, Effect: eff, Failure: failure, Outcome: run.ToolOutcomeKnown} + return turn.SubmitToolFailure{StepID: stepID, CallID: callID, Effect: eff, Failure: failure, Outcome: turn.ToolOutcomeKnown} case ToolExecutionUnknown: failure := o.Failure - if failure.Class != "" && failure.Class != run.FailureEffectUnknown && failure.Message == "" { + if failure.Class != "" && failure.Class != turn.FailureEffectUnknown && failure.Message == "" { failure.Message = "tool reported " + failure.Class } - failure.Class = run.FailureEffectUnknown - return run.SubmitToolFailure{StepID: stepID, CallID: callID, Effect: eff, Failure: failure, Outcome: run.ToolOutcomeUnknown} + failure.Class = turn.FailureEffectUnknown + return turn.SubmitToolFailure{StepID: stepID, CallID: callID, Effect: eff, Failure: failure, Outcome: turn.ToolOutcomeUnknown} case effect.Cancelled: - return run.SubmitToolFailure{StepID: stepID, CallID: callID, Effect: eff, - Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: "cancelled: " + o.Message}, Outcome: run.ToolOutcomeUnknown} + return turn.SubmitToolFailure{StepID: stepID, CallID: callID, Effect: eff, + Failure: turn.ToolFailure{Class: turn.FailureEffectUnknown, Message: "cancelled: " + o.Message}, Outcome: turn.ToolOutcomeUnknown} case effect.Unknown: msg := "tool returned no outcome" if o.Message != "" { msg = "executor: " + o.Message } - return run.SubmitToolFailure{StepID: stepID, CallID: callID, Effect: eff, - Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: msg}, Outcome: run.ToolOutcomeUnknown} + return turn.SubmitToolFailure{StepID: stepID, CallID: callID, Effect: eff, + Failure: turn.ToolFailure{Class: turn.FailureEffectUnknown, Message: msg}, Outcome: turn.ToolOutcomeUnknown} } // A model result or no result for a tool call: the effect may have // happened, so it is Unknown. - return run.SubmitToolFailure{StepID: stepID, CallID: callID, Effect: eff, - Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: fmt.Sprintf("executor delivered %T for a tool call", out.Result)}, Outcome: run.ToolOutcomeUnknown} + return turn.SubmitToolFailure{StepID: stepID, CallID: callID, Effect: eff, + Failure: turn.ToolFailure{Class: turn.FailureEffectUnknown, Message: fmt.Sprintf("executor delivered %T for a tool call", out.Result)}, Outcome: turn.ToolOutcomeUnknown} } diff --git a/agentcore/run/loop/tools_test.go b/agentcore/turn/loop/tools_test.go similarity index 77% rename from agentcore/run/loop/tools_test.go rename to agentcore/turn/loop/tools_test.go index e0936808..38d08680 100644 --- a/agentcore/run/loop/tools_test.go +++ b/agentcore/turn/loop/tools_test.go @@ -3,7 +3,7 @@ package loop_test import ( "testing" - "github.com/felinics/twilight/agentcore/run" + "github.com/felinics/twilight/agentcore/turn" ) func TestModelCallCompletes(t *testing.T) { @@ -15,7 +15,7 @@ func TestModelCallCompletes(t *testing.T) { func TestToolRoundTripCompletes(t *testing.T) { f := newFeature(t) - f.Tool("echo", run.DirectExecution) + f.Tool("echo", turn.DirectExecution) f.Model(ToolCalls("echo", "c1"), Text("done")) f.Run() f.RequireCompleted("done") @@ -28,11 +28,11 @@ func TestToolRoundTripCompletes(t *testing.T) { func TestKnownToolFailureContinues(t *testing.T) { f := newFeature(t) - f.KnownFailure("echo", run.FailureExecution) + f.KnownFailure("echo", turn.FailureExecution) f.Model(ToolCalls("echo", "c1"), Text("recovered")) f.Run() f.RequireCompleted("recovered") - f.RequireFailureClass(run.FailureExecution) + f.RequireFailureClass(turn.FailureExecution) } func TestUnknownToolRefContinues(t *testing.T) { @@ -40,5 +40,5 @@ func TestUnknownToolRefContinues(t *testing.T) { f.Model(ToolCalls("ghost", "c1"), Text("moved on")) f.Run() f.RequireCompleted("moved on") - f.RequireFailureClass(run.FailureToolLookup) + f.RequireFailureClass(turn.FailureToolLookup) } diff --git a/agentcore/run/loop/unknown_model_test.go b/agentcore/turn/loop/unknown_model_test.go similarity index 51% rename from agentcore/run/loop/unknown_model_test.go rename to agentcore/turn/loop/unknown_model_test.go index 22b85262..d18fe5a8 100644 --- a/agentcore/run/loop/unknown_model_test.go +++ b/agentcore/turn/loop/unknown_model_test.go @@ -3,24 +3,24 @@ package loop import ( "testing" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" ) // A model outcome without an answer withdraws the step to Open, the same // disposition as a cancelled effect and as the recovery path's missing -// execution; only a provider failure ends the Run. +// execution; only a provider failure ends the Turn. func TestModelCompletionWithdrawsOnUnknown(t *testing.T) { - step := &run.ModelStep{RefValue: run.StepRef{RunID: "r", ID: "s1"}, Status: run.ModelExecuting, Effect: "e1"} + step := &turn.ModelStep{RefValue: turn.StepRef{TurnID: "r", ID: "s1"}, Status: turn.ModelExecuting, Effect: "e1"} cases := []struct { name string out OutcomeResult - want run.AgentCommand + want turn.AgentCommand }{ - {"unknown", effect.Unknown{Message: "disposed"}, run.RecoverModelExecution{StepID: "s1", Effect: "e1"}}, - {"cancelled", effect.Cancelled{}, run.RecoverModelExecution{StepID: "s1", Effect: "e1"}}, + {"unknown", effect.Unknown{Message: "disposed"}, turn.RecoverModelExecution{StepID: "s1", Effect: "e1"}}, + {"cancelled", effect.Cancelled{}, turn.RecoverModelExecution{StepID: "s1", Effect: "e1"}}, {"provider failure", effect.ModelFailed{Code: effect.FailureExecutor, Message: "boom"}, - run.SubmitModelFailure{StepID: "s1", Effect: "e1", Failure: run.StepFailure{Class: run.FailureProvider, Message: "boom"}}}, + turn.SubmitModelFailure{StepID: "s1", Effect: "e1", Failure: turn.StepFailure{Class: turn.FailureProvider, Message: "boom"}}}, } l := &Loop{} for _, tc := range cases { diff --git a/agentcore/run/loop/unknown_test.go b/agentcore/turn/loop/unknown_test.go similarity index 84% rename from agentcore/run/loop/unknown_test.go rename to agentcore/turn/loop/unknown_test.go index 22badc22..d081f958 100644 --- a/agentcore/run/loop/unknown_test.go +++ b/agentcore/turn/loop/unknown_test.go @@ -7,10 +7,10 @@ import ( "testing" "time" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/loop" - "github.com/felinics/twilight/agentcore/run/plan" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/loop" + "github.com/felinics/twilight/agentcore/turn/plan" ) func TestUnknownToolOutcomeContinues(t *testing.T) { @@ -19,20 +19,20 @@ func TestUnknownToolOutcomeContinues(t *testing.T) { f.Model(ToolCalls("echo", "c1"), Text("recovered")) f.Run() f.RequireCompleted("recovered") - f.RequireCallFailed("c1", run.ToolOutcomeUnknown) - f.RequireFailureClass(run.FailureEffectUnknown) + f.RequireCallFailed("c1", turn.ToolOutcomeUnknown) + f.RequireFailureClass(turn.FailureEffectUnknown) } func TestUnknownToolOutcomeLeavesSiblingRunning(t *testing.T) { f := newFeature(t) f.Unknown("lost") - f.Tool("echo", run.DirectExecution) + f.Tool("echo", turn.DirectExecution) f.Model(Calls(Call("lost", "c1"), Call("echo", "c2")), Text("done")) f.Run() f.RequireCompleted("done") f.RequireRan("lost") f.RequireRan("echo") - f.RequireCallFailed("c1", run.ToolOutcomeUnknown) + f.RequireCallFailed("c1", turn.ToolOutcomeUnknown) f.RequireBuilderSawTool("c2", `{"x":1}`) } @@ -79,7 +79,7 @@ func TestRetryableDispatchRefusal(t *testing.T) { f := newFeature(t) port := &refusingPort{refuse: tc.refuse} f.wrapPort = func(inner effect.ExecutionPort) effect.ExecutionPort { port.ExecutionPort = inner; return port } - f.Tool("echo", run.DirectExecution) + f.Tool("echo", turn.DirectExecution) f.Model(ToolCalls("echo", "c1"), Text("done")) if tc.ran { f.Run() @@ -93,7 +93,7 @@ func TestRetryableDispatchRefusal(t *testing.T) { if len(targets) != 1 { t.Fatalf("temporary refusal lost durable intent: %+v", snapshot.State) } - key := effect.AssignmentKey{Session: f.rt.Scope(), RunID: f.runID, Effect: targets[0].Effect} + key := effect.AssignmentKey{Session: f.rt.Scope(), OwnerKind: effect.OwnerTurn, OwnerID: string(f.turnID), Effect: targets[0].Effect} if err := f.loop.Redispatch(f.ctx, f.rt, key); err != nil { t.Fatal(err) } diff --git a/agentcore/run/machine.go b/agentcore/turn/machine.go similarity index 68% rename from agentcore/run/machine.go rename to agentcore/turn/machine.go index 447d7102..13312367 100644 --- a/agentcore/run/machine.go +++ b/agentcore/turn/machine.go @@ -1,21 +1,21 @@ -package run +package turn import ( "errors" "fmt" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/turn/model" ) // Canonical and Identity are the two rule sets the Machine interprets under. // Decide and Evolve never compute a digest or derive an identity themselves: -// every persisted digest and every RunID-scoped identity comes from the bound -// rules, so a Run replays under the rules of the schema version it was written +// every persisted digest and every TurnID-scoped identity comes from the bound +// rules, so a Turn replays under the rules of the schema version it was written // under whatever the current version is. // Canonical is the digest rules the state machine is composed with; the -// implementation lives in agentcore/run/canonical, which imports this -// package, so the seam is an interface (composed in agentcore/run/schema). +// implementation lives in agentcore/turn/canonical, which imports this +// package, so the seam is an interface (composed in agentcore/turn/schema). // It is the digest rules for every body a fact names. type Canonical interface { DigestRequest(model.ModelRequest) (Digest, error) @@ -28,23 +28,23 @@ type Canonical interface { DigestToolOutput(CanonicalJSON) (Digest, error) } -// Identity is the identity derivation. Everything a Run persists that names +// Identity is the identity derivation. Everything a Turn persists that names // a step, call, response, effect or command is derived here; the -// implementation lives in agentcore/run/canonical. +// implementation lives in agentcore/turn/canonical. type Identity interface { - DeriveModelRequestCommandID(run RunID, position RunPosition) CommandID - DeriveModelStepID(run RunID, cmd CommandID) StepID + DeriveModelRequestCommandID(turnID TurnID, position TurnPosition) CommandID + DeriveModelStepID(turnID TurnID, cmd CommandID) StepID DeriveCallID(source StepID, index int) CallID DeriveToolStepID(source StepID) StepID - DeriveResponseID(run RunID, step StepID, call CallID, kind ResponseKind) ResponseID - DeriveResponseCommandID(run RunID, step StepID, call CallID, resp ResponseID) CommandID - DeriveInputCommandID(run RunID, inputs ...InputID) CommandID - DeriveWithdrawCommandID(run RunID, step StepID) CommandID + DeriveResponseID(turnID TurnID, step StepID, call CallID, kind ResponseKind) ResponseID + DeriveResponseCommandID(turnID TurnID, step StepID, call CallID, resp ResponseID) CommandID + DeriveInputCommandID(turnID TurnID, inputs ...InputID) CommandID + DeriveWithdrawCommandID(turnID TurnID, step StepID) CommandID // DeriveEffectID names one request for an external effect: the model // call of a ModelStep (empty call; sequence counts the results the step // rejected before this request) or one tool call of a ToolStep // (sequence 0: a call starts at most once). - DeriveEffectID(run RunID, step StepID, call CallID, sequence int) EffectID + DeriveEffectID(turnID TurnID, step StepID, call CallID, sequence int) EffectID // The start, settlement and recovery of an effect are identified by the // effect alone: each happens at most once per effect. DeriveStartCommandID(effect EffectID) CommandID @@ -53,7 +53,7 @@ type Identity interface { // DeriveDeclineCommandID identifies the decline of one Pending call // (DeclineToolCall). The call has no effect yet and is declined at most // once, so the identity is the call's coordinates. - DeriveDeclineCommandID(run RunID, step StepID, call CallID) CommandID + DeriveDeclineCommandID(turnID TurnID, step StepID, call CallID) CommandID } // StateMachine is the state machine: the Decide (decide.go) and @@ -74,18 +74,18 @@ func (m StateMachine) bound() error { return nil } -// CreateGroup produces the facts that establish a Run and queue its initial +// CreateGroup produces the facts that establish a Turn and queue its initial // inputs. It is pure: the owning module places these facts in -// its creation commit, the RunStore never sees a Create command. -func (m StateMachine) CreateGroup(run NewRun, inputs []AgentInput) ([]Fact, error) { +// its creation commit, the TurnStore never sees a Create command. +func (m StateMachine) CreateGroup(creation NewTurn, inputs []AgentInput) ([]Fact, error) { if err := m.bound(); err != nil { return nil, err } - if err := ValidateNewRun(run); err != nil { + if err := ValidateNewTurn(creation); err != nil { return nil, err } facts := make([]Fact, 0, 1+len(inputs)) - facts = append(facts, RunCreated(run)) + facts = append(facts, TurnStarted(creation)) seen := make(map[InputID]struct{}, len(inputs)) for _, in := range inputs { if in.ID == "" { diff --git a/agentcore/run/machine_test.go b/agentcore/turn/machine_test.go similarity index 56% rename from agentcore/run/machine_test.go rename to agentcore/turn/machine_test.go index 90d2600e..bc04456b 100644 --- a/agentcore/run/machine_test.go +++ b/agentcore/turn/machine_test.go @@ -1,38 +1,39 @@ -package run_test +package turn_test import ( "errors" + "github.com/felinics/twilight/agentcore/preset/ref" "strings" "testing" "github.com/felinics/twilight/agentcore/testutil/sdkconv" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/plan" - "github.com/felinics/twilight/agentcore/run/schema" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/plan" + "github.com/felinics/twilight/agentcore/turn/schema" "github.com/felinics/twilight/sdk" "github.com/google/jsonschema-go/jsonschema" ) // --- helpers --- -const testModel run.ModelRef = "m-1" +const testModel turn.ModelRef = "m-1" -// isOpen reports whether the Run is at Open, the position between steps. -func isOpen(c run.Current) bool { _, ok := c.(run.Open); return ok } +// isOpen reports whether the Turn is at Open, the position between steps. +func isOpen(c turn.Current) bool { _, ok := c.(turn.Open); return ok } -func cj(raw string) run.CanonicalJSON { return run.MustParseCanonicalJSON(raw) } +func cj(raw string) turn.CanonicalJSON { return turn.MustParseCanonicalJSON(raw) } -func newRun(t *testing.T) run.MachineState { +func newTurn(t *testing.T) turn.MachineState { t.Helper() - s, err := run.InitializeRun("run-1") + s, err := turn.InitializeTurn("run-1") if err != nil { t.Fatal(err) } - return fold(t, s, mustDecide(t, s, run.NextStep(run.AgentInput{ID: "seed", Digest: inputDigest(`{"q":"hi"}`)}))) + return fold(t, s, mustDecide(t, s, turn.NextStep(turn.AgentInput{ID: "seed", Digest: inputDigest(`{"q":"hi"}`)}))) } -func mustDecide(t *testing.T, s run.MachineState, c run.AgentCommand) []run.Fact { +func mustDecide(t *testing.T, s turn.MachineState, c turn.AgentCommand) []turn.Fact { t.Helper() facts, err := schema.Machine().Decide(s, settling(s, c)) if err != nil { @@ -41,7 +42,7 @@ func mustDecide(t *testing.T, s run.MachineState, c run.AgentCommand) []run.Fact return facts } -func fold(t *testing.T, s run.MachineState, facts []run.Fact) run.MachineState { +func fold(t *testing.T, s turn.MachineState, facts []turn.Fact) turn.MachineState { t.Helper() for _, f := range facts { var err error @@ -65,7 +66,7 @@ func testToolDef(name string) sdk.ToolDefinition { return sdk.ToolDefinition{Name: name, Parameters: &jsonschema.Schema{Type: "object"}} } -func buildPrepare(t *testing.T, s run.MachineState, req sdk.Request, specs []run.ToolSpec) (run.PrepareModelRequest, run.CommandID) { +func buildPrepare(t *testing.T, s turn.MachineState, req sdk.Request, specs []turn.ToolSpec) (turn.PrepareModelRequest, turn.CommandID) { t.Helper() frozenReq, err := sdkconv.FreezeModelRequest(req) if err != nil { @@ -75,14 +76,14 @@ func buildPrepare(t *testing.T, s run.MachineState, req sdk.Request, specs []run if err != nil { t.Fatal(err) } - modelRef := run.ModelRef(frozenReq.Model) - cmdID := schema.Identity().DeriveModelRequestCommandID(s.RunID, 0) - stepID := schema.Identity().DeriveModelStepID(s.RunID, cmdID) - ids := make([]run.InputID, len(s.PendingInputs)) + modelRef := turn.ModelRef(frozenReq.Model) + cmdID := schema.Identity().DeriveModelRequestCommandID(s.TurnID, 0) + stepID := schema.Identity().DeriveModelStepID(s.TurnID, cmdID) + ids := make([]turn.InputID, len(s.PendingInputs)) for i, in := range s.PendingInputs { ids[i] = in.ID } - return run.PrepareModelRequest{ + return turn.PrepareModelRequest{ StepID: stepID, Model: modelRef, Request: frozenReq, @@ -92,7 +93,7 @@ func buildPrepare(t *testing.T, s run.MachineState, req sdk.Request, specs []run }, cmdID } -func makeSpec(t *testing.T, def sdk.ToolDefinition, policy run.ResponsePolicy) run.ToolSpec { +func makeSpec(t *testing.T, def sdk.ToolDefinition, policy turn.ResponsePolicy) turn.ToolSpec { t.Helper() frozen, err := sdkconv.FreezeToolDefinition(def) if err != nil { @@ -102,10 +103,10 @@ func makeSpec(t *testing.T, def sdk.ToolDefinition, policy run.ResponsePolicy) r if err != nil { t.Fatal(err) } - return run.ToolSpec{Ref: run.ToolRef(def.Name), Name: def.Name, DefinitionDigest: d, Policy: policy} + return turn.ToolSpec{Ref: turn.ToolRef(def.Name), Name: def.Name, DefinitionDigest: d, Policy: policy} } -func responseDecisionDigest(t *testing.T, kind run.ResponseKind, decision run.ResponseDecision, reason string) run.Digest { +func responseDecisionDigest(t *testing.T, kind turn.ResponseKind, decision turn.ResponseDecision, reason string) turn.Digest { t.Helper() d, err := schema.Canonical().DigestToolResponseDecision(kind, decision, reason) if err != nil { @@ -114,7 +115,7 @@ func responseDecisionDigest(t *testing.T, kind run.ResponseKind, decision run.Re return d } -func responsePayloadDigest(t *testing.T, payload run.CanonicalJSON) run.Digest { +func responsePayloadDigest(t *testing.T, payload turn.CanonicalJSON) turn.Digest { t.Helper() d, err := schema.Canonical().DigestToolResponsePayload(payload) if err != nil { @@ -125,11 +126,11 @@ func responsePayloadDigest(t *testing.T, payload run.CanonicalJSON) run.Digest { // 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 run.StepID, index int, providerID string, spec run.ToolSpec, args string) run.ToolCallBinding { +func makeBinding(t *testing.T, source turn.StepID, index int, providerID string, spec turn.ToolSpec, args string) turn.ToolCallBinding { t.Helper() parsedArgs := cj(args) callID := schema.Identity().DeriveCallID(source, index) - return run.ToolCallBinding{ + return turn.ToolCallBinding{ CallID: callID, ProviderCallID: providerID, ToolRef: spec.Ref, @@ -140,7 +141,7 @@ func makeBinding(t *testing.T, source run.StepID, index int, providerID string, } // cid is the derived CallID of the index-th call of a step. -func cid(step run.StepID, index int) run.CallID { +func cid(step turn.StepID, index int) turn.CallID { return schema.Identity().DeriveCallID(step, index) } @@ -168,42 +169,42 @@ func modelResultWithNamedCalls(toolName, args string, callIDs ...string) model.M // startModel is the start of the current Prepared step's next model effect // the step's rejected count is the effect's sequence. -func startModel(s run.MachineState, stepID run.StepID) run.StartModelExecution { - ms, _ := s.Current.(run.ModelStep) - return run.StartModelExecution{StepID: stepID, Effect: schema.Identity().DeriveEffectID(s.RunID, stepID, "", ms.Rejects)} +func startModel(s turn.MachineState, stepID turn.StepID) turn.StartModelExecution { + ms, _ := s.Current.(turn.ModelStep) + return turn.StartModelExecution{StepID: stepID, Effect: schema.Identity().DeriveEffectID(s.TurnID, stepID, "", ms.Rejects)} } // startTool is the start of a call's one tool effect. -func startTool(s run.MachineState, stepID run.StepID, callID run.CallID) run.StartToolCall { - return run.StartToolCall{StepID: stepID, CallID: callID, Effect: schema.Identity().DeriveEffectID(s.RunID, stepID, callID, 0)} +func startTool(s turn.MachineState, stepID turn.StepID, callID turn.CallID) turn.StartToolCall { + return turn.StartToolCall{StepID: stepID, CallID: callID, Effect: schema.Identity().DeriveEffectID(s.TurnID, stepID, callID, 0)} } // settling fills the Effect of a settlement command from the state it // settles: the executing model effect, or the executing effect of the named // call. Tests that probe the effect check pass it explicitly. -func settling(s run.MachineState, c run.AgentCommand) run.AgentCommand { +func settling(s turn.MachineState, c turn.AgentCommand) turn.AgentCommand { switch cmd := c.(type) { - case run.SubmitModelResult: + case turn.SubmitModelResult: if cmd.Effect == "" { cmd.Effect = modelEffectOf(s) } return cmd - case run.SubmitModelFailure: + case turn.SubmitModelFailure: if cmd.Effect == "" { cmd.Effect = modelEffectOf(s) } return cmd - case run.RejectModelResult: + case turn.RejectModelResult: if cmd.Effect == "" { cmd.Effect = modelEffectOf(s) } return cmd - case run.SubmitToolResult: + case turn.SubmitToolResult: if cmd.Effect == "" { cmd.Effect = toolEffectOf(s, cmd.CallID) } return cmd - case run.SubmitToolFailure: + case turn.SubmitToolFailure: if cmd.Effect == "" { cmd.Effect = toolEffectOf(s, cmd.CallID) } @@ -214,15 +215,15 @@ func settling(s run.MachineState, c run.AgentCommand) run.AgentCommand { } // modelEffectOf is the effect the current ModelStep is executing. -func modelEffectOf(s run.MachineState) run.EffectID { - ms, _ := s.Current.(run.ModelStep) +func modelEffectOf(s turn.MachineState) turn.EffectID { + ms, _ := s.Current.(turn.ModelStep) return ms.Effect } // toolEffectOf is the effect the named call of the current ToolStep is // executing. -func toolEffectOf(s run.MachineState, callID run.CallID) run.EffectID { - ts, _ := s.Current.(run.ToolStep) +func toolEffectOf(s turn.MachineState, callID turn.CallID) turn.EffectID { + ts, _ := s.Current.(turn.ToolStep) for _, call := range ts.Calls { if call.CallID == callID { return call.Effect @@ -232,7 +233,7 @@ func toolEffectOf(s run.MachineState, callID run.CallID) run.EffectID { } // advance runs prepare+start and returns the state in Executing plus stepID. -func advanceToExecuting(t *testing.T, s run.MachineState, req sdk.Request, specs []run.ToolSpec) (run.MachineState, run.StepID) { +func advanceToExecuting(t *testing.T, s turn.MachineState, req sdk.Request, specs []turn.ToolSpec) (turn.MachineState, turn.StepID) { t.Helper() prep, _ := buildPrepare(t, s, req, specs) s = fold(t, s, mustDecide(t, s, prep)) @@ -242,42 +243,42 @@ func advanceToExecuting(t *testing.T, s run.MachineState, req sdk.Request, specs // --- tests --- -func TestInitializeRunIsMinimal(t *testing.T) { - s, err := run.InitializeRun("r") +func TestInitializeTurnIsMinimal(t *testing.T) { + s, err := turn.InitializeTurn("r") if err != nil { t.Fatal(err) } - if s.RunID != "r" || s.Status != run.RunActive || !isOpen(s.Current) || len(s.PendingInputs) != 0 { + if s.TurnID != "r" || s.Status != turn.TurnActive || !isOpen(s.Current) || len(s.PendingInputs) != 0 { t.Fatalf("initial state = %+v", s) } } -func TestRunCreatedFoldsOntoZeroState(t *testing.T) { - newRun, err := run.BuildNewRun("r", "cause") +func TestTurnStartedFoldsOntoZeroState(t *testing.T) { + newTurn, err := turn.BuildNewTurn("r", "cause", ref.Ref{ID: "test", Digest: "sha256:test"}) if err != nil { t.Fatal(err) } - facts, err := schema.Machine().CreateGroup(newRun, []run.AgentInput{{ID: "in-1", Digest: inputDigest(`1`)}}) + facts, err := schema.Machine().CreateGroup(newTurn, []turn.AgentInput{{ID: "in-1", Digest: inputDigest(`1`)}}) if err != nil { t.Fatal(err) } if len(facts) != 2 { t.Fatalf("facts = %d, want [created, input_accepted]", len(facts)) } - s := fold(t, run.MachineState{}, facts) - if s.RunID != "r" || !isOpen(s.Current) || len(s.PendingInputs) != 1 { + s := fold(t, turn.MachineState{}, facts) + if s.TurnID != "r" || !isOpen(s.Current) || len(s.PendingInputs) != 1 { t.Fatalf("state after create group = %+v", s) } if _, err := schema.Machine().Evolve(s, facts[0]); err == nil { - t.Fatal("second RunCreated folded") + t.Fatal("second TurnStarted folded") } - if _, err := schema.Machine().Evolve(run.MachineState{}, facts[1]); err == nil { - t.Fatal("InputAccepted folded before RunCreated") + if _, err := schema.Machine().Evolve(turn.MachineState{}, facts[1]); err == nil { + t.Fatal("InputAccepted folded before TurnStarted") } } func TestNextOnFreshRunNeedsModelRequest(t *testing.T) { - s := newRun(t) + s := newTurn(t) eff, err := plan.Next(s) if err != nil { t.Fatal(err) @@ -292,7 +293,7 @@ func TestNextOnFreshRunNeedsModelRequest(t *testing.T) { } func TestPrepareConsumesInputsAndCounts(t *testing.T) { - s := newRun(t) + s := newTurn(t) prep, _ := buildPrepare(t, s, testRequest(), nil) facts := mustDecide(t, s, prep) if len(facts) != 1 { @@ -305,13 +306,13 @@ func TestPrepareConsumesInputsAndCounts(t *testing.T) { if s.ModelSteps != 1 { t.Fatalf("ModelSteps = %d", s.ModelSteps) } - if ms, ok := s.Current.(run.ModelStep); !ok || ms.Status != run.ModelPrepared { + if ms, ok := s.Current.(turn.ModelStep); !ok || ms.Status != turn.ModelPrepared { t.Fatalf("current = %#v", s.Current) } } func TestPrepareRejectsIncompleteInputIDs(t *testing.T) { - s := newRun(t) + s := newTurn(t) prep, _ := buildPrepare(t, s, testRequest(), nil) prep.InputIDs = nil if _, err := schema.Machine().Decide(s, prep); err == nil { @@ -321,16 +322,16 @@ func TestPrepareRejectsIncompleteInputIDs(t *testing.T) { func TestModelCompleteWithToolsOpensToolStep(t *testing.T) { def := testToolDef("t") - spec := makeSpec(t, def, run.DirectExecution) - s := newRun(t) - s, stepID := advanceToExecuting(t, s, testRequest(def), []run.ToolSpec{spec}) + spec := makeSpec(t, def, turn.DirectExecution) + s := newTurn(t) + s, stepID := advanceToExecuting(t, s, testRequest(def), []turn.ToolSpec{spec}) b := makeBinding(t, stepID, 0, "c1", spec, `{"x":1}`) - facts := mustDecide(t, s, run.SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("t", `{"x":1}`, "c1"), Calls: []run.ToolCallBinding{b}}) + facts := mustDecide(t, s, turn.SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("t", `{"x":1}`, "c1"), Calls: []turn.ToolCallBinding{b}}) if len(facts) != 2 { t.Fatalf("facts = %d, want [completed, opened]", len(facts)) } - opened, ok := facts[1].(run.ToolStepOpened) + opened, ok := facts[1].(turn.ToolStepOpened) if !ok { t.Fatalf("facts[1] = %T", facts[1]) } @@ -338,86 +339,86 @@ func TestModelCompleteWithToolsOpensToolStep(t *testing.T) { t.Fatal("tool step source mismatch") } s = fold(t, s, facts) - ts, ok := s.Current.(run.ToolStep) + ts, ok := s.Current.(turn.ToolStep) if !ok { t.Fatalf("current = %T", s.Current) } - if len(ts.Calls) != 1 || ts.Calls[0].Status != run.ToolPending { + if len(ts.Calls) != 1 || ts.Calls[0].Status != turn.ToolPending { t.Fatalf("calls = %+v", ts.Calls) } - if err := run.ValidateToolCallState(ts.Calls[0]); err != nil { + if err := turn.ValidateToolCallState(ts.Calls[0]); err != nil { t.Fatal(err) } } func TestExternalResponseRequiresPayloadDigest(t *testing.T) { def := testToolDef("ask") - spec := makeSpec(t, def, run.ExternalResponse) - s := newRun(t) - s, stepID := advanceToExecuting(t, s, testRequest(def), []run.ToolSpec{spec}) + spec := makeSpec(t, def, turn.ExternalResponse) + s := newTurn(t) + s, stepID := advanceToExecuting(t, s, testRequest(def), []turn.ToolSpec{spec}) b := makeBinding(t, stepID, 0, "c1", spec, `{}`) - facts := mustDecide(t, s, run.SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("ask", `{}`, "c1"), Calls: []run.ToolCallBinding{b}}) - opened := facts[1].(run.ToolStepOpened) + facts := mustDecide(t, s, turn.SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("ask", `{}`, "c1"), Calls: []turn.ToolCallBinding{b}}) + opened := facts[1].(turn.ToolStepOpened) s = fold(t, s, facts) respID := opened.Calls[0].Response.ID payload := cj(`{"answer":"ok"}`) - if _, err := schema.Machine().Decide(s, run.SubmitToolResponse{StepID: opened.StepID, CallID: cid(stepID, 0), ResponseID: respID, ResponseDigest: "sha256:bad", Payload: payload}); err == nil { + if _, err := schema.Machine().Decide(s, turn.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, run.SubmitToolResponse{StepID: opened.StepID, CallID: cid(stepID, 0), ResponseID: respID, + facts = mustDecide(t, s, turn.SubmitToolResponse{StepID: opened.StepID, CallID: cid(stepID, 0), ResponseID: respID, ResponseDigest: responsePayloadDigest(t, payload), Payload: payload}) if len(facts) != 1 { t.Fatalf("facts = %d, want [answered]", len(facts)) } - s = newRun(t) - s, stepID = advanceToExecuting(t, s, testRequest(def), []run.ToolSpec{spec}) + s = newTurn(t) + s, stepID = advanceToExecuting(t, s, testRequest(def), []turn.ToolSpec{spec}) b = makeBinding(t, stepID, 0, "c1", spec, `{}`) - facts = mustDecide(t, s, run.SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("ask", `{}`, "c1"), Calls: []run.ToolCallBinding{b}}) - opened = facts[1].(run.ToolStepOpened) + facts = mustDecide(t, s, turn.SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("ask", `{}`, "c1"), Calls: []turn.ToolCallBinding{b}}) + opened = facts[1].(turn.ToolStepOpened) s = fold(t, s, facts) respID = opened.Calls[0].Response.ID - facts, err := schema.Machine().Decide(s, run.RejectToolCall{StepID: opened.StepID, CallID: cid(stepID, 0), ResponseID: respID, - ResponseDigest: responseDecisionDigest(t, run.ResponseExternal, run.ResponseDecisionRejected, "user dismissed"), Reason: "user dismissed"}) + facts, err := schema.Machine().Decide(s, turn.RejectToolCall{StepID: opened.StepID, CallID: cid(stepID, 0), ResponseID: respID, + ResponseDigest: responseDecisionDigest(t, turn.ResponseExternal, turn.ResponseDecisionRejected, "user dismissed"), Reason: "user dismissed"}) if err != nil { t.Fatal(err) } - failed := facts[0].(run.ToolCallFailed) - if failed.Failure.Class != run.FailureResponseRejected || failed.Outcome != run.ToolOutcomeKnown { + failed := facts[0].(turn.ToolCallFailed) + if failed.Failure.Class != turn.FailureResponseRejected || failed.Outcome != turn.ToolOutcomeKnown { t.Fatalf("failed = %+v", failed) } s = fold(t, s, facts) - if s.Status != run.RunActive || !isOpen(s.Current) { + if s.Status != turn.TurnActive || !isOpen(s.Current) { t.Fatal("run should continue after rejecting the external response") } } func TestToolSchedulingFrozenOnToolStepOpened(t *testing.T) { def := testToolDef("t") - spec := makeSpec(t, def, run.DirectExecution) - s := newRun(t) - s, stepID := advanceToExecuting(t, s, testRequest(def), []run.ToolSpec{spec}) + spec := makeSpec(t, def, turn.DirectExecution) + s := newTurn(t) + s, stepID := advanceToExecuting(t, s, testRequest(def), []turn.ToolSpec{spec}) b := makeBinding(t, stepID, 0, "c1", spec, `{}`) - facts := mustDecide(t, s, run.SubmitModelResult{ - StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}, - Scheduling: run.ToolScheduling{Mode: run.ToolScheduleSequential, MaxParallel: 1}, + facts := mustDecide(t, s, turn.SubmitModelResult{ + StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []turn.ToolCallBinding{b}, + Scheduling: turn.ToolScheduling{Mode: turn.ToolScheduleSequential, MaxParallel: 1}, }) s = fold(t, s, facts) - ts := s.Current.(run.ToolStep) - if ts.Scheduling.Mode != run.ToolScheduleSequential || ts.Scheduling.MaxParallel != 1 { + ts := s.Current.(turn.ToolStep) + if ts.Scheduling.Mode != turn.ToolScheduleSequential || ts.Scheduling.MaxParallel != 1 { t.Fatalf("scheduling = %+v", ts.Scheduling) } } func TestToolSchedulingRejectsUnknownMode(t *testing.T) { def := testToolDef("t") - spec := makeSpec(t, def, run.DirectExecution) - s := newRun(t) - s, stepID := advanceToExecuting(t, s, testRequest(def), []run.ToolSpec{spec}) + spec := makeSpec(t, def, turn.DirectExecution) + s := newTurn(t) + s, stepID := advanceToExecuting(t, s, testRequest(def), []turn.ToolSpec{spec}) b := makeBinding(t, stepID, 0, "c1", spec, `{}`) - _, err := schema.Machine().Decide(s, settling(s, run.SubmitModelResult{ - StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}, - Scheduling: run.ToolScheduling{Mode: "round-robin"}, + _, err := schema.Machine().Decide(s, settling(s, turn.SubmitModelResult{ + StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []turn.ToolCallBinding{b}, + Scheduling: turn.ToolScheduling{Mode: "round-robin"}, })) if err == nil { t.Fatal("unknown scheduling mode accepted") @@ -426,10 +427,10 @@ func TestToolSchedulingRejectsUnknownMode(t *testing.T) { func TestParallelWaitingDoesNotBlockPending(t *testing.T) { defA, defB := testToolDef("a"), testToolDef("b") - specA := makeSpec(t, defA, run.ApprovalRequired) - specB := makeSpec(t, defB, run.DirectExecution) - s := newRun(t) - s, stepID := advanceToExecuting(t, s, testRequest(defA, defB), []run.ToolSpec{specA, specB}) + specA := makeSpec(t, defA, turn.ApprovalRequired) + specB := makeSpec(t, defB, turn.DirectExecution) + s := newTurn(t) + s, stepID := advanceToExecuting(t, s, testRequest(defA, defB), []turn.ToolSpec{specA, specB}) bA := makeBinding(t, stepID, 0, "cA", specA, `{}`) bB := makeBinding(t, stepID, 1, "cB", specB, `{}`) @@ -444,8 +445,8 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { if err != nil { t.Fatal(err) } - facts := mustDecide(t, s, run.SubmitModelResult{StepID: stepID, Result: r, Calls: []run.ToolCallBinding{bA, bB}}) - opened := facts[1].(run.ToolStepOpened) + facts := mustDecide(t, s, turn.SubmitModelResult{StepID: stepID, Result: r, Calls: []turn.ToolCallBinding{bA, bB}}) + opened := facts[1].(turn.ToolStepOpened) s = fold(t, s, facts) eff, err := plan.Next(s) @@ -459,7 +460,7 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { // Complete B; step must stay open because A is Waiting. s = fold(t, s, mustDecide(t, s, startTool(s, opened.StepID, cid(stepID, 1)))) - facts = mustDecide(t, s, run.SubmitToolResult{StepID: opened.StepID, CallID: cid(stepID, 1), Result: run.ToolExecutionResult{Output: cj(`"ok"`)}}) + facts = mustDecide(t, s, turn.SubmitToolResult{StepID: opened.StepID, CallID: cid(stepID, 1), Result: turn.ToolExecutionResult{Output: cj(`"ok"`)}}) if len(facts) != 1 { t.Fatalf("facts = %d, step must not close with A waiting", len(facts)) } @@ -472,17 +473,17 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { if _, ok := eff.(plan.Idle); !ok { t.Fatalf("action after B completed = %#v, want Idle", eff) } - if reqs := run.WaitingCalls(s); len(reqs) != 1 || reqs[0].CallID != cid(stepID, 0) { - t.Fatalf("WaitingCalls = %#v", run.WaitingCalls(s)) + if reqs := turn.WaitingCalls(s); len(reqs) != 1 || reqs[0].CallID != cid(stepID, 0) { + t.Fatalf("WaitingCalls = %#v", turn.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, run.ApproveToolCall{StepID: opened.StepID, CallID: cid(stepID, 0), ResponseID: respID, - ResponseDigest: responseDecisionDigest(t, run.ResponseApproval, run.ResponseDecisionApproved, "")})) + s = fold(t, s, mustDecide(t, s, turn.ApproveToolCall{StepID: opened.StepID, CallID: cid(stepID, 0), ResponseID: respID, + ResponseDigest: responseDecisionDigest(t, turn.ResponseApproval, turn.ResponseDecisionApproved, "")})) s = fold(t, s, mustDecide(t, s, startTool(s, opened.StepID, cid(stepID, 0)))) - facts = mustDecide(t, s, run.SubmitToolResult{StepID: opened.StepID, CallID: cid(stepID, 0), Result: run.ToolExecutionResult{Output: cj(`"done"`)}}) + facts = mustDecide(t, s, turn.SubmitToolResult{StepID: opened.StepID, CallID: cid(stepID, 0), Result: turn.ToolExecutionResult{Output: cj(`"done"`)}}) if len(facts) != 1 { t.Fatalf("facts = %d, want [completed]", len(facts)) } @@ -494,10 +495,10 @@ func TestParallelWaitingDoesNotBlockPending(t *testing.T) { func TestUnknownToolFailureSettlesOnlyThatCall(t *testing.T) { defA, defB := testToolDef("a"), testToolDef("b") - specA := makeSpec(t, defA, run.DirectExecution) - specB := makeSpec(t, defB, run.DirectExecution) - s := newRun(t) - s, stepID := advanceToExecuting(t, s, testRequest(defA, defB), []run.ToolSpec{specA, specB}) + specA := makeSpec(t, defA, turn.DirectExecution) + specB := makeSpec(t, defB, turn.DirectExecution) + s := newTurn(t) + s, stepID := advanceToExecuting(t, s, testRequest(defA, defB), []turn.ToolSpec{specA, specB}) bA := makeBinding(t, stepID, 0, "cA", specA, `{}`) bB := makeBinding(t, stepID, 1, "cB", specB, `{}`) @@ -512,60 +513,60 @@ func TestUnknownToolFailureSettlesOnlyThatCall(t *testing.T) { if err != nil { t.Fatal(err) } - facts := mustDecide(t, s, run.SubmitModelResult{StepID: stepID, Result: r, Calls: []run.ToolCallBinding{bA, bB}}) - opened := facts[1].(run.ToolStepOpened) + facts := mustDecide(t, s, turn.SubmitModelResult{StepID: stepID, Result: r, Calls: []turn.ToolCallBinding{bA, bB}}) + opened := facts[1].(turn.ToolStepOpened) s = fold(t, s, facts) s = fold(t, s, mustDecide(t, s, startTool(s, opened.StepID, cid(stepID, 0)))) s = fold(t, s, mustDecide(t, s, startTool(s, opened.StepID, cid(stepID, 1)))) - facts = mustDecide(t, s, run.SubmitToolFailure{ + facts = mustDecide(t, s, turn.SubmitToolFailure{ StepID: opened.StepID, CallID: cid(stepID, 0), - Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: "lost"}, - Outcome: run.ToolOutcomeUnknown, + Failure: turn.ToolFailure{Class: turn.FailureEffectUnknown, Message: "lost"}, + Outcome: turn.ToolOutcomeUnknown, }) if len(facts) != 1 { t.Fatalf("facts = %d, want [ToolCallFailed]", len(facts)) } - failed := facts[0].(run.ToolCallFailed) - if failed.CallID != cid(stepID, 0) || failed.Outcome != run.ToolOutcomeUnknown { + failed := facts[0].(turn.ToolCallFailed) + if failed.CallID != cid(stepID, 0) || failed.Outcome != turn.ToolOutcomeUnknown { t.Fatalf("failed = %+v", failed) } s = fold(t, s, facts) - if s.Status != run.RunActive { + if s.Status != turn.TurnActive { t.Fatalf("status = %v, want active", s.Status) } - ts, ok := s.Current.(run.ToolStep) + ts, ok := s.Current.(turn.ToolStep) if !ok { t.Fatalf("current = %T, want ToolStep", s.Current) } - if ts.Calls[0].Status != run.ToolFailed || ts.Calls[1].Status != run.ToolExecuting { + if ts.Calls[0].Status != turn.ToolFailed || ts.Calls[1].Status != turn.ToolExecuting { t.Fatalf("calls = %+v", ts.Calls) } - s = fold(t, s, mustDecide(t, s, run.SubmitToolResult{ - StepID: opened.StepID, CallID: cid(stepID, 1), Result: run.ToolExecutionResult{Output: cj(`"ok"`)}, + s = fold(t, s, mustDecide(t, s, turn.SubmitToolResult{ + StepID: opened.StepID, CallID: cid(stepID, 1), Result: turn.ToolExecutionResult{Output: cj(`"ok"`)}, })) - if s.Status != run.RunActive || !isOpen(s.Current) { + if s.Status != turn.TurnActive || !isOpen(s.Current) { t.Fatalf("after sibling complete: status=%v current=%T", s.Status, s.Current) } - if s.LastToolStep == nil || s.LastToolStep.Calls[0].Status != run.ToolFailed || s.LastToolStep.Calls[1].Status != run.ToolCompleted { + if s.LastToolStep == nil || s.LastToolStep.Calls[0].Status != turn.ToolFailed || s.LastToolStep.Calls[1].Status != turn.ToolCompleted { t.Fatalf("LastToolStep = %+v", s.LastToolStep) } } func TestRejectModelResultDispositionRetriesThenFails(t *testing.T) { - s := newRun(t) + s := newTurn(t) s, stepID := advanceToExecuting(t, s, testRequest(), nil) usage := model.Usage{TotalTokens: 3} // Reject 1: back to Prepared. - facts := mustDecide(t, s, run.RejectModelResult{StepID: stepID, Usage: usage, Failure: run.StepFailure{Class: run.FailureMalformedModel}}) + facts := mustDecide(t, s, turn.RejectModelResult{StepID: stepID, Usage: usage, Failure: turn.StepFailure{Class: turn.FailureMalformedModel}}) if len(facts) != 1 { t.Fatalf("facts = %d", len(facts)) } s = fold(t, s, facts) - if ms := s.Current.(run.ModelStep); ms.Status != run.ModelPrepared || ms.Rejects != 1 { + if ms := s.Current.(turn.ModelStep); ms.Status != turn.ModelPrepared || ms.Rejects != 1 { t.Fatalf("model step = %+v", ms) } if s.Usage.TotalTokens != 3 { @@ -574,19 +575,19 @@ func TestRejectModelResultDispositionRetriesThenFails(t *testing.T) { // Start again, reject 2: host policy still chooses retry. s = fold(t, s, mustDecide(t, s, startModel(s, stepID))) - s = fold(t, s, mustDecide(t, s, run.RejectModelResult{StepID: stepID, Usage: usage, Failure: run.StepFailure{Class: run.FailureMalformedModel}})) - if ms := s.Current.(run.ModelStep); ms.Rejects != 2 { + s = fold(t, s, mustDecide(t, s, turn.RejectModelResult{StepID: stepID, Usage: usage, Failure: turn.StepFailure{Class: turn.FailureMalformedModel}})) + if ms := s.Current.(turn.ModelStep); ms.Rejects != 2 { t.Fatalf("rejects = %d", ms.Rejects) } // Third reject: host policy chooses fail-run disposition. s = fold(t, s, mustDecide(t, s, startModel(s, stepID))) - facts = mustDecide(t, s, run.RejectModelResult{StepID: stepID, Usage: usage, Failure: run.StepFailure{Class: run.FailureMalformedModel}, Disposition: run.ModelRejectFailRun}) + facts = mustDecide(t, s, turn.RejectModelResult{StepID: stepID, Usage: usage, Failure: turn.StepFailure{Class: turn.FailureMalformedModel}, Disposition: turn.ModelRejectFailTurn}) if len(facts) != 2 { t.Fatalf("facts = %d, want [rejected, ended]", len(facts)) } s = fold(t, s, facts) - if s.Status != run.RunFailed || s.Result.Reason != run.ReasonMalformedModel { + if s.Status != turn.TurnFailed || s.Result.Reason != turn.ReasonMalformedModel { t.Fatalf("result = %+v", s.Result) } if s.Usage.TotalTokens != 9 { @@ -595,8 +596,8 @@ func TestRejectModelResultDispositionRetriesThenFails(t *testing.T) { } func TestAcceptInputDuplicateIsGuarded(t *testing.T) { - s := newRun(t) - facts := mustDecide(t, s, run.NextStep(run.AgentInput{ID: "in-2", Digest: inputDigest(`1`)})) + s := newTurn(t) + facts := mustDecide(t, s, turn.NextStep(turn.AgentInput{ID: "in-2", Digest: inputDigest(`1`)})) s = fold(t, s, facts) if len(s.PendingInputs) != 2 { t.Fatalf("pending = %d", len(s.PendingInputs)) @@ -613,12 +614,12 @@ func TestAcceptInputDuplicateIsGuarded(t *testing.T) { // 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) + s := newTurn(t) prep, _ := buildPrepare(t, s, testRequest(), nil) s = fold(t, s, mustDecide(t, s, prep)) // Prepared: input queues, Next withdraws, withdraw reopens with the input. - s = fold(t, s, mustDecide(t, s, run.NextStep(run.AgentInput{ID: "in-3", Digest: inputDigest(`3`)}))) + s = fold(t, s, mustDecide(t, s, turn.NextStep(turn.AgentInput{ID: "in-3", Digest: inputDigest(`3`)}))) if len(s.PendingInputs) != 1 || s.PendingInputs[0].ID != "in-3" { t.Fatalf("pending after accept while Prepared = %+v", s.PendingInputs) } @@ -630,7 +631,7 @@ func TestAcceptInputQueuesInAnyActiveState(t *testing.T) { if !ok || withdraw.StepID != prep.StepID { t.Fatalf("action = %#v, want WithdrawPrepared", eff) } - facts := mustDecide(t, s, run.WithdrawPreparedStep{StepID: prep.StepID}) + facts := mustDecide(t, s, turn.WithdrawPreparedStep{StepID: prep.StepID}) if len(facts) != 1 { t.Fatalf("facts = %d, want [withdrawn]", len(facts)) } @@ -641,14 +642,14 @@ func TestAcceptInputQueuesInAnyActiveState(t *testing.T) { // 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 := schema.Machine().Decide(s, run.WithdrawPreparedStep{StepID: prep2.StepID}); err == nil { + if _, err := schema.Machine().Decide(s, turn.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. + // returns to Open instead of ending the Turn. s = fold(t, s, mustDecide(t, s, startModel(s, prep2.StepID))) - s = fold(t, s, mustDecide(t, s, run.NextStep(run.AgentInput{ID: "in-4", Digest: inputDigest(`4`)}))) + s = fold(t, s, mustDecide(t, s, turn.NextStep(turn.AgentInput{ID: "in-4", Digest: inputDigest(`4`)}))) if eff, _ := plan.Next(s); eff != (plan.Idle{}) { t.Fatalf("action while Executing with pending input = %#v, want Idle", eff) } @@ -656,11 +657,11 @@ func TestAcceptInputQueuesInAnyActiveState(t *testing.T) { if err != nil { t.Fatal(err) } - facts = mustDecide(t, s, run.SubmitModelResult{StepID: prep2.StepID, Result: result}) + facts = mustDecide(t, s, turn.SubmitModelResult{StepID: prep2.StepID, Result: result}) if len(facts) != 1 { - t.Fatalf("facts = %d, want [completed] without RunEnded while inputs are pending", len(facts)) + t.Fatalf("facts = %d, want [completed] without TurnEnded while inputs are pending", len(facts)) } - completed := facts[0].(run.ModelStepCompleted) + completed := facts[0].(turn.ModelStepCompleted) wantDigest, err := schema.Canonical().DigestModelResult(result) if err != nil { t.Fatal(err) @@ -669,7 +670,7 @@ func TestAcceptInputQueuesInAnyActiveState(t *testing.T) { t.Fatalf("completed = %+v", completed) } s = fold(t, s, facts) - if s.Status != run.RunActive || !isOpen(s.Current) || len(s.PendingInputs) != 1 || s.PendingInputs[0].ID != "in-4" { + if s.Status != turn.TurnActive || !isOpen(s.Current) || len(s.PendingInputs) != 1 || s.PendingInputs[0].ID != "in-4" { t.Fatalf("state after completed with pending input = %+v", s) } if eff, _ := plan.Next(s); eff == nil { @@ -680,37 +681,37 @@ func TestAcceptInputQueuesInAnyActiveState(t *testing.T) { } func TestAcceptInputRejectsSeedDuplicateID(t *testing.T) { - s := newRun(t) - _, err := schema.Machine().Decide(s, run.NextStep(run.AgentInput{ID: "seed", Digest: inputDigest(`{"q":"other"}`)})) - if !errors.Is(err, run.ErrCommandConflict) { + s := newTurn(t) + _, err := schema.Machine().Decide(s, turn.NextStep(turn.AgentInput{ID: "seed", Digest: inputDigest(`{"q":"other"}`)})) + if !errors.Is(err, turn.ErrCommandConflict) { t.Fatalf("duplicate seed input err = %v, want ErrCommandConflict", err) } } func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { - minimal, err := run.InitializeRun("run-1") + minimal, err := turn.InitializeTurn("run-1") if err != nil { t.Fatal(err) } - withInputs := func(ids ...run.InputID) run.MachineState { + withInputs := func(ids ...turn.InputID) turn.MachineState { t.Helper() s := minimal for _, id := range ids { var foldErr error - s, foldErr = schema.Machine().Evolve(s, run.InputAccepted{Input: run.AgentInput{ID: id, Digest: inputDigest(`null`)}}) + s, foldErr = schema.Machine().Evolve(s, turn.InputAccepted{Input: turn.AgentInput{ID: id, Digest: inputDigest(`null`)}}) if foldErr != nil { t.Fatal(foldErr) } } return s } - prepared := func(ids ...run.InputID) run.ModelStepPrepared { + prepared := func(ids ...turn.InputID) turn.ModelStepPrepared { request := model.ModelRequest{Model: string(testModel)} requestDigest, err := schema.Canonical().DigestRequest(request) if err != nil { t.Fatal(err) } - return run.ModelStepPrepared{StepID: "step-1", Model: testModel, RequestDigest: requestDigest, InputIDs: ids} + return turn.ModelStepPrepared{StepID: "step-1", Model: testModel, RequestDigest: requestDigest, InputIDs: ids} } t.Run("nonexistent input", func(t *testing.T) { @@ -737,16 +738,16 @@ func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { if err != nil { t.Fatal(err) } - if _, ok := next.Current.(run.ModelStep); !ok || len(next.PendingInputs) != 0 { + if _, ok := next.Current.(turn.ModelStep); !ok || len(next.PendingInputs) != 0 { t.Fatalf("prepared state = %+v", next) } }) } func TestEvolveRejectsModelPrepareOverCurrentStep(t *testing.T) { - s := newRun(t) + s := newTurn(t) s, _ = advanceToExecuting(t, s, testRequest(), nil) - _, err := schema.Machine().Evolve(s, run.ModelStepPrepared{ + _, err := schema.Machine().Evolve(s, turn.ModelStepPrepared{ StepID: "other", Model: testModel, RequestDigest: "sha256:req", @@ -756,23 +757,23 @@ func TestEvolveRejectsModelPrepareOverCurrentStep(t *testing.T) { } } -// A provider that repeats or omits tool_call_id cannot break Run identity: +// A provider that repeats or omits tool_call_id cannot break Turn 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, run.DirectExecution) - s := newRun(t) - s, stepID := advanceToExecuting(t, s, testRequest(def), []run.ToolSpec{spec}) + spec := makeSpec(t, def, turn.DirectExecution) + s := newTurn(t) + s, stepID := advanceToExecuting(t, s, testRequest(def), []turn.ToolSpec{spec}) result := modelResultWithNamedCalls("t", `{}`, "call_0", "call_0", "") - bindings := []run.ToolCallBinding{ + bindings := []turn.ToolCallBinding{ makeBinding(t, stepID, 0, "call_0", spec, `{}`), makeBinding(t, stepID, 1, "call_0", spec, `{}`), makeBinding(t, stepID, 2, "", spec, `{}`), } - facts := mustDecide(t, s, run.SubmitModelResult{StepID: stepID, Result: result, Calls: bindings}) - opened := facts[1].(run.ToolStepOpened) - seen := map[run.CallID]bool{} + facts := mustDecide(t, s, turn.SubmitModelResult{StepID: stepID, Result: result, Calls: bindings}) + opened := facts[1].(turn.ToolStepOpened) + seen := map[turn.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) @@ -785,7 +786,7 @@ func TestDerivedCallIDToleratesProviderIDReuse(t *testing.T) { // A binding whose CallID is not the derived one is rejected. forged := bindings forged[1].CallID = "call_0" - if _, err := schema.Machine().Decide(s, settling(s, run.SubmitModelResult{StepID: stepID, Result: result, Calls: forged})); err == nil { + if _, err := schema.Machine().Decide(s, settling(s, turn.SubmitModelResult{StepID: stepID, Result: result, Calls: forged})); err == nil { t.Fatal("non-derived CallID accepted") } } @@ -795,11 +796,11 @@ func TestDerivedCallIDToleratesProviderIDReuse(t *testing.T) { // Decide is called directly because mustDecide fills a missing effect in. func TestSettlementNamesExecutingEffect(t *testing.T) { defA, defB := testToolDef("a"), testToolDef("b") - specA, specB := makeSpec(t, defA, run.DirectExecution), makeSpec(t, defB, run.DirectExecution) - model := newRun(t) - model, modelStep := advanceToExecuting(t, model, testRequest(defA, defB), []run.ToolSpec{specA, specB}) + specA, specB := makeSpec(t, defA, turn.DirectExecution), makeSpec(t, defB, turn.DirectExecution) + model := newTurn(t) + model, modelStep := advanceToExecuting(t, model, testRequest(defA, defB), []turn.ToolSpec{specA, specB}) modelEff := modelEffectOf(model) - otherModel := schema.Identity().DeriveEffectID(model.RunID, modelStep, "", 7) + otherModel := schema.Identity().DeriveEffectID(model.TurnID, modelStep, "", 7) text, err := sdkconv.FreezeModelResult(sdk.ModelResult{Text: "done", FinishReason: sdk.FinishReasonStop}) if err != nil { t.Fatal(err) @@ -811,37 +812,37 @@ func TestSettlementNamesExecutingEffect(t *testing.T) { if err != nil { t.Fatal(err) } - facts := mustDecide(t, model, run.SubmitModelResult{StepID: modelStep, Result: calls, Calls: []run.ToolCallBinding{bA, bB}}) - toolStep := facts[1].(run.ToolStepOpened).StepID + facts := mustDecide(t, model, turn.SubmitModelResult{StepID: modelStep, Result: calls, Calls: []turn.ToolCallBinding{bA, bB}}) + toolStep := facts[1].(turn.ToolStepOpened).StepID tool := fold(t, model, facts) cA, cB := cid(modelStep, 0), cid(modelStep, 1) tool = fold(t, tool, mustDecide(t, tool, startTool(tool, toolStep, cA))) toolEff := toolEffectOf(tool, cA) - otherTool := schema.Identity().DeriveEffectID(tool.RunID, toolStep, cA, 1) - failure := run.StepFailure{Class: run.FailureProvider, Message: "down"} - toolFailure := run.ToolFailure{Class: run.FailureExecution, Message: "boom"} - output := run.ToolExecutionResult{Output: cj(`"ok"`)} + otherTool := schema.Identity().DeriveEffectID(tool.TurnID, toolStep, cA, 1) + failure := turn.StepFailure{Class: turn.FailureProvider, Message: "down"} + toolFailure := turn.ToolFailure{Class: turn.FailureExecution, Message: "boom"} + output := turn.ToolExecutionResult{Output: cj(`"ok"`)} rejected := []struct { name string - s run.MachineState - cmd run.AgentCommand + s turn.MachineState + cmd turn.AgentCommand want string }{ - {"model result without effect", model, run.SubmitModelResult{StepID: modelStep, Result: text}, "missing effect"}, - {"model result of another effect", model, run.SubmitModelResult{StepID: modelStep, Effect: otherModel, Result: text}, "not the step's executing effect"}, - {"model failure without effect", model, run.SubmitModelFailure{StepID: modelStep, Failure: failure}, "missing effect"}, - {"model failure of another effect", model, run.SubmitModelFailure{StepID: modelStep, Effect: otherModel, Failure: failure}, "not the step's executing effect"}, - {"reject without effect", model, run.RejectModelResult{StepID: modelStep, Failure: failure, Disposition: run.ModelRejectRetry}, "missing effect"}, - {"reject of another effect", model, run.RejectModelResult{StepID: modelStep, Effect: otherModel, Failure: failure, Disposition: run.ModelRejectRetry}, "not the step's executing effect"}, - {"tool result without effect", tool, run.SubmitToolResult{StepID: toolStep, CallID: cA, Result: output}, "missing effect"}, - {"tool result of another effect", tool, run.SubmitToolResult{StepID: toolStep, CallID: cA, Effect: otherTool, Result: output}, "not the executing effect"}, - {"tool failure without effect", tool, run.SubmitToolFailure{StepID: toolStep, CallID: cA, Failure: toolFailure, Outcome: run.ToolOutcomeKnown}, "missing effect"}, - {"tool failure of another effect", tool, run.SubmitToolFailure{StepID: toolStep, CallID: cA, Effect: otherTool, Failure: toolFailure, Outcome: run.ToolOutcomeKnown}, "not the executing effect"}, - {"unknown tool failure of another effect", tool, run.SubmitToolFailure{StepID: toolStep, CallID: cA, Effect: otherTool, Outcome: run.ToolOutcomeUnknown}, "not the executing effect"}, + {"model result without effect", model, turn.SubmitModelResult{StepID: modelStep, Result: text}, "missing effect"}, + {"model result of another effect", model, turn.SubmitModelResult{StepID: modelStep, Effect: otherModel, Result: text}, "not the step's executing effect"}, + {"model failure without effect", model, turn.SubmitModelFailure{StepID: modelStep, Failure: failure}, "missing effect"}, + {"model failure of another effect", model, turn.SubmitModelFailure{StepID: modelStep, Effect: otherModel, Failure: failure}, "not the step's executing effect"}, + {"reject without effect", model, turn.RejectModelResult{StepID: modelStep, Failure: failure, Disposition: turn.ModelRejectRetry}, "missing effect"}, + {"reject of another effect", model, turn.RejectModelResult{StepID: modelStep, Effect: otherModel, Failure: failure, Disposition: turn.ModelRejectRetry}, "not the step's executing effect"}, + {"tool result without effect", tool, turn.SubmitToolResult{StepID: toolStep, CallID: cA, Result: output}, "missing effect"}, + {"tool result of another effect", tool, turn.SubmitToolResult{StepID: toolStep, CallID: cA, Effect: otherTool, Result: output}, "not the executing effect"}, + {"tool failure without effect", tool, turn.SubmitToolFailure{StepID: toolStep, CallID: cA, Failure: toolFailure, Outcome: turn.ToolOutcomeKnown}, "missing effect"}, + {"tool failure of another effect", tool, turn.SubmitToolFailure{StepID: toolStep, CallID: cA, Effect: otherTool, Failure: toolFailure, Outcome: turn.ToolOutcomeKnown}, "not the executing effect"}, + {"unknown tool failure of another effect", tool, turn.SubmitToolFailure{StepID: toolStep, CallID: cA, Effect: otherTool, Outcome: turn.ToolOutcomeUnknown}, "not the executing effect"}, // A Pending call has no effect to settle: it is declined, not failed. - {"known failure of a Pending call", tool, run.SubmitToolFailure{StepID: toolStep, CallID: cB, Effect: schema.Identity().DeriveEffectID(tool.RunID, toolStep, cB, 0), - Failure: toolFailure, Outcome: run.ToolOutcomeKnown}, "not Executing"}, + {"known failure of a Pending call", tool, turn.SubmitToolFailure{StepID: toolStep, CallID: cB, Effect: schema.Identity().DeriveEffectID(tool.TurnID, toolStep, cB, 0), + Failure: toolFailure, Outcome: turn.ToolOutcomeKnown}, "not Executing"}, } for _, tc := range rejected { t.Run(tc.name, func(t *testing.T) { @@ -853,10 +854,10 @@ func TestSettlementNamesExecutingEffect(t *testing.T) { } // The executing effect settles. - if _, err := schema.Machine().Decide(model, run.SubmitModelResult{StepID: modelStep, Effect: modelEff, Result: text}); err != nil { + if _, err := schema.Machine().Decide(model, turn.SubmitModelResult{StepID: modelStep, Effect: modelEff, Result: text}); err != nil { t.Fatalf("model settlement under its effect: %v", err) } - if _, err := schema.Machine().Decide(tool, run.SubmitToolResult{StepID: toolStep, CallID: cA, Effect: toolEff, Result: output}); err != nil { + if _, err := schema.Machine().Decide(tool, turn.SubmitToolResult{StepID: toolStep, CallID: cA, Effect: toolEff, Result: output}); err != nil { t.Fatalf("tool settlement under its effect: %v", err) } } @@ -866,29 +867,29 @@ func TestSettlementNamesExecutingEffect(t *testing.T) { // any other call state is rejected. func TestDeclineToolCallFailsPendingCallWithoutStart(t *testing.T) { defA, defB := testToolDef("a"), testToolDef("b") - specA, specB := makeSpec(t, defA, run.DirectExecution), makeSpec(t, defB, run.DirectExecution) - s := newRun(t) - s, modelStep := advanceToExecuting(t, s, testRequest(defA, defB), []run.ToolSpec{specA, specB}) + specA, specB := makeSpec(t, defA, turn.DirectExecution), makeSpec(t, defB, turn.DirectExecution) + s := newTurn(t) + s, modelStep := advanceToExecuting(t, s, testRequest(defA, defB), []turn.ToolSpec{specA, specB}) bA, bB := makeBinding(t, modelStep, 0, "cA", specA, `{}`), makeBinding(t, modelStep, 1, "cB", specB, `{}`) r, err := sdkconv.FreezeModelResult(sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls, ToolCalls: []sdk.ToolCall{{ToolCallID: "cA", ToolName: "a", Input: sdk.ParseToolArguments(`{}`)}, {ToolCallID: "cB", ToolName: "b", Input: sdk.ParseToolArguments(`{}`)}}}) if err != nil { t.Fatal(err) } - facts := mustDecide(t, s, run.SubmitModelResult{StepID: modelStep, Result: r, Calls: []run.ToolCallBinding{bA, bB}}) - step := facts[1].(run.ToolStepOpened).StepID + facts := mustDecide(t, s, turn.SubmitModelResult{StepID: modelStep, Result: r, Calls: []turn.ToolCallBinding{bA, bB}}) + step := facts[1].(turn.ToolStepOpened).StepID s = fold(t, s, facts) cA, cB := cid(modelStep, 0), cid(modelStep, 1) s = fold(t, s, mustDecide(t, s, startTool(s, step, cB))) - failure := run.ToolFailure{Class: run.FailureToolLookup, Message: "no such tool"} + failure := turn.ToolFailure{Class: turn.FailureToolLookup, Message: "no such tool"} rejected := []struct { name string - cmd run.DeclineToolCall + cmd turn.DeclineToolCall }{ - {"executing call", run.DeclineToolCall{StepID: step, CallID: cB, Failure: failure}}, - {"unknown call", run.DeclineToolCall{StepID: step, CallID: "ghost", Failure: failure}}, - {"empty failure class", run.DeclineToolCall{StepID: step, CallID: cA}}, + {"executing call", turn.DeclineToolCall{StepID: step, CallID: cB, Failure: failure}}, + {"unknown call", turn.DeclineToolCall{StepID: step, CallID: "ghost", Failure: failure}}, + {"empty failure class", turn.DeclineToolCall{StepID: step, CallID: cA}}, } for _, tc := range rejected { t.Run(tc.name, func(t *testing.T) { @@ -898,26 +899,26 @@ func TestDeclineToolCallFailsPendingCallWithoutStart(t *testing.T) { }) } - facts = mustDecide(t, s, run.DeclineToolCall{StepID: step, CallID: cA, Failure: failure}) + facts = mustDecide(t, s, turn.DeclineToolCall{StepID: step, CallID: cA, Failure: failure}) if len(facts) != 1 { t.Fatalf("decline facts = %d, want [ToolCallFailed] and no ToolCallStarted", len(facts)) } - failed, ok := facts[0].(run.ToolCallFailed) - if !ok || failed.CallID != cA || failed.Outcome != run.ToolOutcomeKnown || failed.Failure != failure { + failed, ok := facts[0].(turn.ToolCallFailed) + if !ok || failed.CallID != cA || failed.Outcome != turn.ToolOutcomeKnown || failed.Failure != failure { t.Fatalf("decline fact = %+v", facts[0]) } s = fold(t, s, facts) - ts := s.Current.(run.ToolStep) - if ts.Calls[0].Status != run.ToolFailed || ts.Calls[0].Effect != "" { + ts := s.Current.(turn.ToolStep) + if ts.Calls[0].Status != turn.ToolFailed || ts.Calls[0].Effect != "" { t.Fatalf("declined call = %+v, want Failed with no effect", ts.Calls[0]) } // A declined call is terminal: it is neither declined nor settled again. - if _, err := schema.Machine().Decide(s, run.DeclineToolCall{StepID: step, CallID: cA, Failure: failure}); err == nil { + if _, err := schema.Machine().Decide(s, turn.DeclineToolCall{StepID: step, CallID: cA, Failure: failure}); err == nil { t.Fatal("second decline accepted") } // The step closes once its Executing sibling settles. - s = fold(t, s, mustDecide(t, s, run.SubmitToolResult{StepID: step, CallID: cB, Result: run.ToolExecutionResult{Output: cj(`"ok"`)}})) - if !isOpen(s.Current) || s.LastToolStep == nil || s.LastToolStep.Calls[0].Status != run.ToolFailed { + s = fold(t, s, mustDecide(t, s, turn.SubmitToolResult{StepID: step, CallID: cB, Result: turn.ToolExecutionResult{Output: cj(`"ok"`)}})) + if !isOpen(s.Current) || s.LastToolStep == nil || s.LastToolStep.Calls[0].Status != turn.ToolFailed { t.Fatalf("after sibling settled: current=%T last=%+v", s.Current, s.LastToolStep) } } diff --git a/agentcore/run/model/arguments.go b/agentcore/turn/model/arguments.go similarity index 100% rename from agentcore/run/model/arguments.go rename to agentcore/turn/model/arguments.go diff --git a/agentcore/run/model/doc.go b/agentcore/turn/model/doc.go similarity index 96% rename from agentcore/run/model/doc.go rename to agentcore/turn/model/doc.go index a3412005..331e1c33 100644 --- a/agentcore/run/model/doc.go +++ b/agentcore/turn/model/doc.go @@ -1,4 +1,4 @@ -// Package model holds the durable, provider-neutral model data a Run persists +// Package model holds the durable, provider-neutral model data a Turn persists // by digest: ModelRequest, Message, ToolDefinition, ModelResult, // Usage and their parts, plus the canonical rendering of tool arguments. // Every type is a closed, JSON-stable value with no SDK interface inside, so diff --git a/agentcore/run/model/model.go b/agentcore/turn/model/model.go similarity index 100% rename from agentcore/run/model/model.go rename to agentcore/turn/model/model.go diff --git a/agentcore/turn/module_test.go b/agentcore/turn/module_test.go deleted file mode 100644 index 6464c873..00000000 --- a/agentcore/turn/module_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package turn - -import ( - "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/preset" - "testing" -) - -// 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[ledger.EventType]any{ - TypeStarted: StartedPayload{TurnID: "t1", RunID: "r1", InputIDs: nil, Preset: preset.PresetRef{ID: "b", Digest: "sha256:b"}}, - 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) - } - if len(def.Codecs) != 1 { - t.Fatalf("%s: %d codecs, want one per schema this module writes", def.Type, len(def.Codecs)) - } - codec := def.Codecs[Version] - 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/agentcore/run/plan/doc.go b/agentcore/turn/plan/doc.go similarity index 60% rename from agentcore/run/plan/doc.go rename to agentcore/turn/plan/doc.go index edbee27f..370cf47b 100644 --- a/agentcore/run/plan/doc.go +++ b/agentcore/turn/plan/doc.go @@ -1,9 +1,9 @@ -// Package plan derives what a Run's MachineState asks for next and persists -// none of it. Next is the at-most-one pending action of a Run: +// Package plan derives what a Turn's MachineState asks for next and persists +// none of it. Next is the at-most-one pending action of a Turn: // the Loop re-derives the action after every Load. RecoveryTargets, // RecoveryCommand and RecoveryCommands derive the takeover dispositions of // the Executing targets: the recovery command of each target with -// its derived identity. agent/run/reconcile decides which dispositions a new -// owner actually issues. Read-only queries over the state live in the run +// its derived identity. agentcore/turn/reconcile decides which dispositions a new +// owner actually issues. Read-only queries over the state live in the Turn // package itself; the prompt boundary's input type is prompt.Input. package plan diff --git a/agentcore/run/plan/next.go b/agentcore/turn/plan/next.go similarity index 68% rename from agentcore/run/plan/next.go rename to agentcore/turn/plan/next.go index 9a304809..84c0973f 100644 --- a/agentcore/run/plan/next.go +++ b/agentcore/turn/plan/next.go @@ -4,13 +4,13 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/prompt" - "github.com/felinics/twilight/agentcore/run" + "github.com/felinics/twilight/agentcore/turn" ) // Action is the at-most-one pending action Next derives from the current -// state: what the Loop does next to move the Run. Actions are +// state: what the Loop does next to move the Turn. Actions are // never persisted; the Loop re-derives them after every Load. An action is -// not an effect: a start action requests an effect (run.EffectID) only once +// not an effect: a start action requests an effect (turn.EffectID) only once // its start fact is committed, and the other actions request none. type Action interface{ action() } @@ -21,70 +21,70 @@ type NeedModelRequest struct { func (NeedModelRequest) action() {} type StartModelCall struct { - StepID run.StepID + StepID turn.StepID } func (StartModelCall) action() {} // 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. +// and the Turn should replan. type WithdrawPrepared struct { - StepID run.StepID + StepID turn.StepID } func (WithdrawPrepared) action() {} type StartToolCalls struct { - StepID run.StepID - CallIDs []run.CallID + StepID turn.StepID + CallIDs []turn.CallID } func (StartToolCalls) action() {} -// Idle means the Run is still active and Next has no executable action. -// Application inspects MachineState with the run package's WaitingCalls, +// Idle means the Turn is still active and Next has no executable action. +// Application inspects MachineState with Turn's WaitingCalls, // ExecutingCalls and NeedsRecovery queries. Loop does not interpret them. type Idle struct{} func (Idle) action() {} // Next derives the pending action from the current state. -// Terminal states return ErrRunTerminal; callers check Status first. +// Terminal states return ErrTurnTerminal; callers check Status first. // //nolint:gocritic // hugeParam: Next is a pure value-state interpreter and must not mutate MachineState. -func Next(s run.MachineState) (Action, error) { +func Next(s turn.MachineState) (Action, error) { if s.Status.Terminal() { - return nil, run.ErrRunTerminal + return nil, turn.ErrTurnTerminal } switch cur := s.Current.(type) { - case run.Open: - var source run.StepID + case turn.Open: + var source turn.StepID if s.LastToolStep != nil { source = s.LastToolStep.RefValue.ID } return NeedModelRequest{Hint: prompt.Input{ - RunID: s.RunID, + TurnID: s.TurnID, SourceStep: source, - Inputs: append([]run.AgentInput(nil), s.PendingInputs...), + Inputs: append([]turn.AgentInput(nil), s.PendingInputs...), }}, nil - case run.ModelStep: - if cur.Status == run.ModelPrepared { + case turn.ModelStep: + if cur.Status == turn.ModelPrepared { if len(s.PendingInputs) > 0 { return WithdrawPrepared{StepID: cur.RefValue.ID}, nil } return StartModelCall{StepID: cur.RefValue.ID}, nil } return Idle{}, nil - case run.ToolStep: - var pending []run.CallID + case turn.ToolStep: + var pending []turn.CallID live := false for _, c := range cur.Calls { switch c.Status { - case run.ToolPending: + case turn.ToolPending: pending = append(pending, c.CallID) live = true - case run.ToolWaiting, run.ToolExecuting: + case turn.ToolWaiting, turn.ToolExecuting: live = true } } diff --git a/agentcore/run/plan/recovery.go b/agentcore/turn/plan/recovery.go similarity index 58% rename from agentcore/run/plan/recovery.go rename to agentcore/turn/plan/recovery.go index e4873fbe..668c9b08 100644 --- a/agentcore/run/plan/recovery.go +++ b/agentcore/turn/plan/recovery.go @@ -1,50 +1,50 @@ package plan import ( - "github.com/felinics/twilight/agentcore/run" + "github.com/felinics/twilight/agentcore/turn" ) // RecoveryDisposition is one takeover disposition command with its derived identity. type RecoveryDisposition struct { - Command run.AgentCommand - ID run.CommandID + Command turn.AgentCommand + ID turn.CommandID } // RecoveryTarget is one Executing target a takeover has to decide about: the // model step or tool call, and the effect it requested (from the started -// fact). The reconciler (agent/run/reconcile) asks the executor whether it +// fact). The reconciler (agentcore/turn/reconcile) asks the executor whether it // still holds an attempt for that effect; only if not is the recovery command // issued. type RecoveryTarget struct { - RunID run.RunID - StepID run.StepID - CallID run.CallID // empty for a model step - Effect run.EffectID - Model *run.ModelStep // set for a model target - Call *run.ToolCallState // set for a tool target + TurnID turn.TurnID + StepID turn.StepID + CallID turn.CallID // empty for a model step + Effect turn.EffectID + Model *turn.ModelStep // set for a model target + Call *turn.ToolCallState // set for a tool target } // RecoveryTargets lists the Executing targets of state in the order RecoveryCommands // disposes them. -func RecoveryTargets(state *run.MachineState) []RecoveryTarget { +func RecoveryTargets(state *turn.MachineState) []RecoveryTarget { if state.Status.Terminal() { return nil } switch cur := state.Current.(type) { - case run.ModelStep: - if cur.Status != run.ModelExecuting { + case turn.ModelStep: + if cur.Status != turn.ModelExecuting { return nil } ms := cur - return []RecoveryTarget{{RunID: state.RunID, StepID: cur.RefValue.ID, Effect: cur.Effect, Model: &ms}} - case run.ToolStep: + return []RecoveryTarget{{TurnID: state.TurnID, StepID: cur.RefValue.ID, Effect: cur.Effect, Model: &ms}} + case turn.ToolStep: var out []RecoveryTarget for i := range cur.Calls { call := cur.Calls[i] - if call.Status != run.ToolExecuting { + if call.Status != turn.ToolExecuting { continue } - out = append(out, RecoveryTarget{RunID: state.RunID, StepID: cur.RefValue.ID, CallID: call.CallID, Effect: call.Effect, Call: &call}) + out = append(out, RecoveryTarget{TurnID: state.TurnID, StepID: cur.RefValue.ID, CallID: call.CallID, Effect: call.Effect, Call: &call}) } return out default: @@ -57,20 +57,20 @@ func RecoveryTargets(state *run.MachineState) []RecoveryTarget { // an Executing tool call settles as Unknown. The command's identity derives // from the effect alone, so the same disposition issued by any // owner, or issued twice, replays instead of applying again. -func RecoveryCommand(id run.Identity, target RecoveryTarget) RecoveryDisposition { +func RecoveryCommand(id turn.Identity, target RecoveryTarget) RecoveryDisposition { if target.Call == nil { return RecoveryDisposition{ - Command: run.RecoverModelExecution{StepID: target.StepID, Effect: target.Effect}, + Command: turn.RecoverModelExecution{StepID: target.StepID, Effect: target.Effect}, ID: id.DeriveRecoveryCommandID(target.Effect), } } return RecoveryDisposition{ - Command: run.SubmitToolFailure{ + Command: turn.SubmitToolFailure{ StepID: target.StepID, CallID: target.CallID, Effect: target.Effect, - Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: "owner process lost before settlement"}, - Outcome: run.ToolOutcomeUnknown, + Failure: turn.ToolFailure{Class: turn.FailureEffectUnknown, Message: "owner process lost before settlement"}, + Outcome: turn.ToolOutcomeUnknown, }, ID: id.DeriveRecoveryCommandID(target.Effect), } @@ -78,7 +78,7 @@ func RecoveryCommand(id run.Identity, target RecoveryTarget) RecoveryDisposition // RecoveryCommands lists the takeover dispositions of every Executing target // in state. Pending and Waiting calls are left alone. -func RecoveryCommands(id run.Identity, state *run.MachineState) []RecoveryDisposition { +func RecoveryCommands(id turn.Identity, state *turn.MachineState) []RecoveryDisposition { targets := RecoveryTargets(state) if len(targets) == 0 { return nil diff --git a/agentcore/turn/projection.go b/agentcore/turn/projection.go deleted file mode 100644 index e6a4eeef..00000000 --- a/agentcore/turn/projection.go +++ /dev/null @@ -1,207 +0,0 @@ -package turn - -import ( - "fmt" - "github.com/felinics/twilight/agentcore/chatlog" - "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/preset" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/sessionstore" -) - -const SurfaceProjectionID module.ProjectionID = "twilight/turn/surface" - -type TurnStatus string - -const ( - TurnActive TurnStatus = "active" - TurnCompleted TurnStatus = "completed" - TurnFailed TurnStatus = "failed" - TurnStopped TurnStatus = "stopped" - TurnSuperseded TurnStatus = "superseded" -) - -type TurnView struct { - TurnID TurnID `json:"turnId"` - Status TurnStatus `json:"status"` - InputIDs []chatlog.InputID `json:"inputIds,omitempty"` - Preset preset.PresetRef `json:"preset"` - // RunID is the one Run that executes this Turn; End is its terminal - // result from twilight/run/run_ended, nil while the Run is open. - RunID run.RunID `json:"runId"` - End *run.RunEnded `json:"end,omitempty"` - // Settlement is the Turn's own settlement fact, when one was written - // (Stop writes failed{stopped} beside the Run's cancellation); a Turn - // settled by its Run's end alone has none. - Settlement Settlement `json:"settlement,omitempty"` - FailureClass string `json:"failureClass,omitempty"` - Reason string `json:"reason,omitempty"` - ReplacementTurnID TurnID `json:"replacementTurnId,omitempty"` -} - -// Ended is the Run's terminal end, nil while it is open. -func (v *TurnView) Ended() run.RunEnd { - if v == nil || v.End == nil { - return nil - } - return v.End.End -} - -type TurnSurface struct { - Order []TurnID `json:"order"` - Turns map[TurnID]TurnView `json:"turns"` - // RunOwner routes a Run to the Turn it executes, from twilight/turn/started - RunOwner map[run.RunID]TurnID `json:"runOwner"` -} - -// OwnerOf is the Turn a Run executes. -func (s *TurnSurface) OwnerOf(runID run.RunID) (TurnID, bool) { - turnID, ok := s.RunOwner[runID] - return turnID, ok -} - -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 := range s.Turns { - v := s.Turns[k] - v.InputIDs = append([]chatlog.InputID(nil), v.InputIDs...) - 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. -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 = module.ProjectionDefinition{ - ID: SurfaceProjectionID, Version: 1, - Consumes: []ledger.EventType{TypeStarted, TypeFailed, TypeSuperseded, - chatlog.TypeInputDelivered, sessionstore.Prefix + "run_ended"}, - // Attempt settlement is folded from run_ended, so inherited Turns settle - // from the parent's run streams, whose domain is of segment lineage and - // would otherwise be skipped; a fork point inside a Turn is - // refused by the Owner. - Inherits: module.InheritAll, - Authoritative: true, - Initial: func() (any, error) { - return TurnSurface{Turns: map[TurnID]TurnView{}, RunOwner: map[run.RunID]TurnID{}}, nil - }, - Apply: applySurface, - StateCodec: module.JSONStateCodec[TurnSurface]{}, -} - -//nolint:gocritic // hugeParam: DecodedEvent is the extension Apply shape -func applySurface(state any, e module.DecodedEvent) (any, error) { - prev, ok := state.(TurnSurface) - if !ok { - return nil, fmt.Errorf("turn surface: state is %T", state) - } - s := prev.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) - } - if owner, dup := s.RunOwner[p.RunID]; dup { - return nil, fmt.Errorf("run %s already executes turn %s", p.RunID, owner) - } - s.Order = append(s.Order, p.TurnID) - s.Turns[p.TurnID] = TurnView{TurnID: p.TurnID, Status: TurnActive, InputIDs: append([]chatlog.InputID(nil), p.InputIDs...), - Preset: p.Preset, RunID: p.RunID} - s.RunOwner[p.RunID] = p.TurnID - case sessionstore.Event: - return s.applyRun(p) - case FailedPayload: - v, err := s.settling(p.TurnID) - if err != nil { - return nil, err - } - v.Settlement, v.FailureClass, v.Reason = p.Settlement, p.FailureClass, p.Reason - 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 - } - if v.Status != TurnActive { - return nil, fmt.Errorf("turn %s superseded while %s", p.TurnID, v.Status) - } - v.Settlement, v.Status, v.ReplacementTurnID = "", TurnSuperseded, p.ReplacementTurnID - s.Turns[p.TurnID] = v - case chatlog.InputDeliveredPayload: - v, ok := s.Turns[TurnID(p.TurnID)] - if !ok { - return s, nil - } - for _, have := range v.InputIDs { - if have == p.InputID { - return s, nil - } - } - v.InputIDs = append(v.InputIDs, p.InputID) - s.Turns[TurnID(p.TurnID)] = v - default: - return nil, fmt.Errorf("turn surface: unexpected %T", e.Value) - } - return s, nil -} - -// applyRun settles the Turn a Run executes: completed when the Run -// completed, failed otherwise; a Stop already settled it as stopped in the -// same commit and the end is only recorded. A Run owned by no Turn of this -// Session is not ours. -func (s TurnSurface) applyRun(ev sessionstore.Event) (any, error) { - ended, ok := ev.Fact.(run.RunEnded) - if !ok { - return nil, fmt.Errorf("turn surface: unexpected run fact %T", ev.Fact) - } - turnID, ok := s.OwnerOf(ev.RunID) - if !ok { - return s, nil - } - v := s.Turns[turnID] - if v.End != nil { - return nil, fmt.Errorf("turn %s run %s ended twice", turnID, ev.RunID) - } - v.End = &ended - if _, completed := ended.End.(run.RunCompletedEnd); completed { - if v.Status != TurnActive { - return nil, fmt.Errorf("turn %s completed while %s", turnID, v.Status) - } - v.Status = TurnCompleted - } else if v.Status == TurnActive { - v.Status = TurnFailed - } - s.Turns[turnID] = v - 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) - } - // A Turn is settled once: while active, or right after its Run ended - // without completing, when the unit that ended the Run (Stop) writes - // the Turn's own settlement beside it. - if v.Status == TurnActive || (v.Status == TurnFailed && v.Settlement == "") { - return v, nil - } - return TurnView{}, fmt.Errorf("turn %s settled twice", id) -} diff --git a/agentcore/turn/projection_test.go b/agentcore/turn/projection_test.go deleted file mode 100644 index 363d2a35..00000000 --- a/agentcore/turn/projection_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package turn - -import ( - "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/sessionstore" - "strings" - "testing" -) - -// run_ended settles the Turn whose started named the Run. A -// completed Run completes the Turn, any other end fails it; a Run no Turn of -// the Session names is ignored; a second end of the Run is a fold error -// rather than a silent no-op. -func TestSurfaceSettlesFromRunEnded(t *testing.T) { - started := StartedPayload{TurnID: "t1", RunID: "r1"} - ended := func(runID run.RunID, end run.RunEnd) sessionstore.Event { - return sessionstore.Event{RunID: runID, Fact: run.RunEnded{End: end}} - } - completed, failed := run.RunCompletedEnd{}, run.RunFailedEnd{Reason: "provider"} - cases := []struct { - name string - events []any - wantStatus TurnStatus - wantEnded bool - wantErr string - }{ - {"completed completes the turn", []any{started, ended("r1", completed)}, TurnCompleted, true, ""}, - {"failure fails the turn", []any{started, ended("r1", failed)}, TurnFailed, true, ""}, - {"a foreign run is ignored", []any{started, ended("r9", completed)}, TurnActive, false, ""}, - {"failure twice", []any{started, ended("r1", failed), ended("r1", failed)}, "", false, "ended twice"}, - {"completed after failure", []any{started, ended("r1", failed), ended("r1", completed)}, "", false, "ended twice"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - state, err := SurfaceProjection.Initial() - if err != nil { - t.Fatal(err) - } - for i, ev := range tc.events { - if state, err = applySurface(state, module.DecodedEvent{Value: ev}); err != nil { - if i != len(tc.events)-1 { - t.Fatalf("event %d: %v", i, err) - } - break - } - } - if tc.wantErr != "" { - if err == nil || !strings.Contains(err.Error(), tc.wantErr) { - t.Fatalf("fold error = %v, want %q", err, tc.wantErr) - } - return - } - if err != nil { - t.Fatal(err) - } - v := state.(TurnSurface).Turns["t1"] - if v.Status != tc.wantStatus || v.RunID != "r1" || (v.End != nil) != tc.wantEnded { - t.Fatalf("view = %+v, want status %s ended=%v", v, tc.wantStatus, tc.wantEnded) - } - }) - } -} diff --git a/agentcore/run/query.go b/agentcore/turn/query.go similarity index 99% rename from agentcore/run/query.go rename to agentcore/turn/query.go index d1fddc84..d9cbdfa2 100644 --- a/agentcore/run/query.go +++ b/agentcore/turn/query.go @@ -1,4 +1,4 @@ -package run +package turn // WaitingCalls returns the ResponseRequests of the current ToolStep's // Waiting calls, in call order. The application inspects them when Next has diff --git a/agentcore/turn/read.go b/agentcore/turn/read.go deleted file mode 100644 index 7fb7cf5e..00000000 --- a/agentcore/turn/read.go +++ /dev/null @@ -1,20 +0,0 @@ -package turn - -import ( - "context" - "fmt" - "github.com/felinics/twilight/agentcore/session" -) - -// ReadSurface loads the turn surface of one Session through r. -func ReadSurface(ctx context.Context, r session.ProjectionReader, sid session.SessionID) (TurnSurface, error) { - state, _, err := r.Load(ctx, sid, SurfaceProjectionID, SurfaceProjection.Version) - if err != nil { - return TurnSurface{}, err - } - surface, ok := state.(TurnSurface) - if !ok { - return TurnSurface{}, fmt.Errorf("turn: surface projection is %T", state) - } - return surface, nil -} diff --git a/agentcore/run/reconcile/reconcile.go b/agentcore/turn/reconcile/reconcile.go similarity index 80% rename from agentcore/run/reconcile/reconcile.go rename to agentcore/turn/reconcile/reconcile.go index aabcb46d..f2956ad1 100644 --- a/agentcore/run/reconcile/reconcile.go +++ b/agentcore/turn/reconcile/reconcile.go @@ -1,4 +1,4 @@ -// Package reconcile compares durable Run intents with Execution Authority. +// Package reconcile compares durable Turn intents with Execution Authority. // A pass is finite: it installs no listeners, timers or background work. // Hosts schedule later passes from the returned decisions and durable state. package reconcile @@ -10,12 +10,12 @@ import ( "time" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/plan" - "github.com/felinics/twilight/agentcore/run/redispatch" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/store" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/plan" + "github.com/felinics/twilight/agentcore/turn/redispatch" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/store" ) type Verdict string @@ -28,7 +28,7 @@ const ( ) // Decision is a recovery verdict, not a promise that a local goroutine will -// resume the Run. Retry means a refusal before the effect boundary. Unknown +// resume the Turn. Retry means a refusal before the effect boundary. Unknown // dispatch is never interpreted as proof that an effect did not happen. type Decision struct { Target plan.RecoveryTarget @@ -81,8 +81,8 @@ type Reconciler struct { const DefaultMaxRedispatches = 3 -func AssignmentFromTarget(scope run.Scope, t plan.RecoveryTarget) effect.Assignment { - a := effect.Assignment{Session: scope, RunID: t.RunID, StepID: t.StepID, CallID: t.CallID, Effect: t.Effect} +func AssignmentFromTarget(scope turn.Scope, t plan.RecoveryTarget) effect.Assignment { + a := effect.Assignment{Session: scope, OwnerKind: effect.OwnerTurn, OwnerID: string(t.TurnID), StepID: t.StepID, CallID: t.CallID, Effect: t.Effect} if t.Call != nil { a.Body = effect.ToolAssignment{ToolRef: t.Call.ToolRef, DefinitionDigest: t.Call.DefinitionDigest, Arguments: t.Call.Arguments, Policy: t.Call.Policy} } else if t.Model != nil { @@ -104,13 +104,13 @@ func verdictOf(state effect.AttachmentState) (Verdict, error) { } } -// Plan observes every executing target once. It writes no Run facts, but +// Plan observes every executing target once. It writes no Turn facts, but // may close a missing key, record a redispatch, or request orphan recovery. -func (r *Reconciler) Plan(ctx context.Context, scope run.Scope, snapshot *store.Snapshot) ([]Decision, error) { +func (r *Reconciler) Plan(ctx context.Context, scope turn.Scope, snapshot *store.Snapshot) ([]Decision, error) { return r.plan(ctx, scope, snapshot, nil) } -func (r *Reconciler) plan(ctx context.Context, scope run.Scope, snapshot *store.Snapshot, only *effect.AssignmentKey) ([]Decision, error) { +func (r *Reconciler) plan(ctx context.Context, scope turn.Scope, snapshot *store.Snapshot, only *effect.AssignmentKey) ([]Decision, error) { if err := ctx.Err(); err != nil { return nil, err } @@ -118,7 +118,7 @@ func (r *Reconciler) plan(ctx context.Context, scope run.Scope, snapshot *store. if only != nil { filtered := targets[:0] for _, t := range targets { - if (effect.AssignmentKey{Session: scope, RunID: t.RunID, Effect: t.Effect}) == *only { + if (effect.AssignmentKey{Session: scope, OwnerKind: effect.OwnerTurn, OwnerID: string(t.TurnID), Effect: t.Effect}) == *only { filtered = append(filtered, t) } } @@ -136,7 +136,7 @@ func (r *Reconciler) plan(ctx context.Context, scope run.Scope, snapshot *store. decisions := make([]Decision, 0, len(targets)) for _, t := range targets { if t.Effect == "" { - return nil, fmt.Errorf("%w: run %s step %s call %q", ErrTargetWithoutEffect, t.RunID, t.StepID, t.CallID) + return nil, fmt.Errorf("%w: turn %s step %s call %q", ErrTargetWithoutEffect, t.TurnID, t.StepID, t.CallID) } d := Decision{Target: t, Observed: effect.AttachmentMissing, Verdict: Dispose} if !r.Abandon { @@ -251,20 +251,20 @@ func (r *Reconciler) now() int64 { return time.Now().UnixMilli() } -func Apply(ctx context.Context, st store.RunStore, decisions []Decision) (int, error) { +func Apply(ctx context.Context, st store.TurnStore, decisions []Decision) (int, error) { n := 0 for i := range decisions { d := &decisions[i] if d.Recovery == nil { continue } - env, err := schema.Wire().Envelope(d.Target.RunID, d.Recovery.ID, d.Recovery.Command) + env, err := schema.Wire().Envelope(d.Target.TurnID, d.Recovery.ID, d.Recovery.Command) if err != nil { return n, err } res, err := st.Commit(ctx, store.CommitRequest{Command: env}) if err != nil { - if errors.Is(err, run.ErrStaleRuntime) || errors.Is(err, run.ErrRunTerminal) || errors.Is(err, run.ErrCommandConflict) { + if errors.Is(err, turn.ErrStaleRuntime) || errors.Is(err, turn.ErrTurnTerminal) || errors.Is(err, turn.ErrCommandConflict) { continue } return n, err @@ -276,7 +276,7 @@ func Apply(ctx context.Context, st store.RunStore, decisions []Decision) (int, e return n, nil } -func (r *Reconciler) Reconcile(ctx context.Context, st store.RunStore, snapshot *store.Snapshot) (int, error) { +func (r *Reconciler) Reconcile(ctx context.Context, st store.TurnStore, snapshot *store.Snapshot) (int, error) { decisions, err := r.Plan(ctx, st.Scope(), snapshot) if err != nil { return 0, err @@ -284,7 +284,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, st store.RunStore, snapshot return Apply(ctx, st, decisions) } -func (r *Reconciler) ReconcileEffect(ctx context.Context, st store.RunStore, snapshot *store.Snapshot, key effect.AssignmentKey) (Decision, bool, error) { +func (r *Reconciler) ReconcileEffect(ctx context.Context, st store.TurnStore, snapshot *store.Snapshot, key effect.AssignmentKey) (Decision, bool, error) { decisions, err := r.plan(ctx, st.Scope(), snapshot, &key) if err != nil { return Decision{}, false, err diff --git a/agentcore/run/reconcile/reconcile_test.go b/agentcore/turn/reconcile/reconcile_test.go similarity index 91% rename from agentcore/run/reconcile/reconcile_test.go rename to agentcore/turn/reconcile/reconcile_test.go index 5e1e32b0..820c1982 100644 --- a/agentcore/run/reconcile/reconcile_test.go +++ b/agentcore/turn/reconcile/reconcile_test.go @@ -6,12 +6,12 @@ import ( "sync" "testing" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/plan" - "github.com/felinics/twilight/agentcore/run/store" "github.com/felinics/twilight/agentcore/testutil/sdkconv" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/plan" + "github.com/felinics/twilight/agentcore/turn/store" "github.com/felinics/twilight/sdk" ) @@ -36,7 +36,7 @@ type fakePort struct { accepted effect.AttachmentState } -func (*fakePort) Validate(context.Context, effect.Assignment) (*run.ToolFailure, error) { +func (*fakePort) Validate(context.Context, effect.Assignment) (*turn.ToolFailure, error) { return nil, nil } func (*fakePort) Dispatch(context.Context, effect.Assignment) error { return nil } @@ -79,9 +79,9 @@ func (*fakePort) Settlements(context.Context, string, uint64, func(effect.Settle panic("a recovery pass must not subscribe or leave background work") } -func executingModel(eff run.EffectID) *store.Snapshot { - return &store.Snapshot{State: run.MachineState{RunID: "r1", Status: run.RunActive, - Current: run.ModelStep{RefValue: run.StepRef{RunID: "r1", ID: "s1"}, Model: "m", RequestDigest: "sha256:req", Status: run.ModelExecuting, Effect: eff}}} +func executingModel(eff turn.EffectID) *store.Snapshot { + return &store.Snapshot{State: turn.MachineState{TurnID: "r1", Status: turn.TurnActive, + Current: turn.ModelStep{RefValue: turn.StepRef{TurnID: "r1", ID: "s1"}, Model: "m", RequestDigest: "sha256:req", Status: turn.ModelExecuting, Effect: eff}}} } func TestPlanVerdicts(t *testing.T) { @@ -106,7 +106,7 @@ func TestPlanVerdicts(t *testing.T) { if d.Verdict != tc.want || (d.Recovery != nil) != tc.disposes || d.Observed != tc.observed || d.Retry { t.Fatalf("%s: decision=%+v want=%s disposes=%v", tc.state, d, tc.want, tc.disposes) } - if len(port.asked) != 1 || port.asked[0] != (effect.AssignmentKey{Session: "s", RunID: "r1", Effect: "c1"}) || port.reads != 0 { + if len(port.asked) != 1 || port.asked[0] != (effect.AssignmentKey{Session: "s", OwnerKind: effect.OwnerTurn, OwnerID: string("r1"), Effect: "c1"}) || port.reads != 0 { t.Fatalf("%s: asked=%+v reads=%d", tc.state, port.asked, port.reads) } } @@ -124,7 +124,7 @@ func TestPlanVerdicts(t *testing.T) { if err != nil || len(decisions) != 1 || decisions[0].Verdict != Dispose || decisions[0].Recovery == nil { t.Fatalf("Abandon: %+v %v", decisions, err) } - if _, ok := decisions[0].Recovery.Command.(run.RecoverModelExecution); !ok { + if _, ok := decisions[0].Recovery.Command.(turn.RecoverModelExecution); !ok { t.Fatalf("model disposal=%T", decisions[0].Recovery.Command) } if _, err := (&Reconciler{Executions: port}).Plan(context.Background(), "s", executingModel("")); !errors.Is(err, ErrTargetWithoutEffect) { @@ -141,7 +141,7 @@ func TestAssignmentFromTarget(t *testing.T) { if model, ok := a.Model(); !ok || model.Request != nil || model.RequestDigest != "sha256:req" { t.Fatalf("assignment=%+v", a) } - if a.Key() != (effect.AssignmentKey{Session: "s", RunID: "r1", Effect: "c1"}) { + if a.Key() != (effect.AssignmentKey{Session: "s", OwnerKind: effect.OwnerTurn, OwnerID: string("r1"), Effect: "c1"}) { t.Fatalf("key=%+v", a.Key()) } } @@ -159,7 +159,7 @@ func TestKeptOutcomeReadRetries(t *testing.T) { } return effect.Outcome{Key: key, Result: effect.ModelSucceeded{Result: result}}, nil }} - key := effect.AssignmentKey{Session: "s", RunID: "r1", Effect: "c1"} + key := effect.AssignmentKey{Session: "s", OwnerKind: effect.OwnerTurn, OwnerID: string("r1"), Effect: "c1"} for pass := 0; pass < 2; pass++ { r := &Reconciler{Executions: port} decisions, err := r.Plan(ctx, "s", executingModel("c1")) @@ -201,7 +201,7 @@ func TestRecoveryPassCanDisconnectAndResume(t *testing.T) { if ds, err := second.Plan(context.Background(), "s", executingModel("c1")); err != nil || len(ds) != 1 || ds[0].Verdict != Keep || port.reads != 0 { t.Fatalf("resumed pass=%+v %v reads=%d", ds, err, port.reads) } - out, err := port.GetOutcome(context.Background(), effect.AssignmentKey{Session: "s", RunID: "r1", Effect: "c1"}) + out, err := port.GetOutcome(context.Background(), effect.AssignmentKey{Session: "s", OwnerKind: effect.OwnerTurn, OwnerID: string("r1"), Effect: "c1"}) if m, ok := out.ModelResult(); err != nil || !ok || m.Text != "late" || port.reads != 1 { t.Fatalf("resumed explicit read=%+v %v reads=%d", out, err, port.reads) } @@ -286,7 +286,7 @@ func TestPlanClosesMissingBeforeDisposing(t *testing.T) { disposes bool }{ {"tombstone stands", "", Dispose, true}, - {"acceptance won and runs", effect.AttachmentActive, Keep, false}, + {"acceptance won and continues", effect.AttachmentActive, Keep, false}, {"acceptance won without a lease", effect.AttachmentOrphaned, Defer, false}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/agentcore/run/reconcile/redispatch_test.go b/agentcore/turn/reconcile/redispatch_test.go similarity index 96% rename from agentcore/run/reconcile/redispatch_test.go rename to agentcore/turn/reconcile/redispatch_test.go index c3bb9746..36a61e3c 100644 --- a/agentcore/run/reconcile/redispatch_test.go +++ b/agentcore/turn/reconcile/redispatch_test.go @@ -6,8 +6,8 @@ import ( "testing" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/redispatch" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/redispatch" ) // attempts is an in-memory dispatch ledger. @@ -68,7 +68,7 @@ func (a *attempts) Append(ctx context.Context, epoch ledger.Epoch, k effect.Assi // the budget's end and a definite rejection dispose. func TestPlanRedispatchesMissingWithinBudget(t *testing.T) { ctx := context.Background() - key := effect.AssignmentKey{Session: "s", RunID: "r1", Effect: "c1"} + key := effect.AssignmentKey{Session: "s", OwnerKind: effect.OwnerTurn, OwnerID: string("r1"), Effect: "c1"} cases := []struct { name string redispatch error @@ -142,7 +142,7 @@ func TestPlanRedispatchesMissingWithinBudget(t *testing.T) { // background retry. A new activation explicitly plans again and reuses it. func TestRedispatchPassCanResumePendingAttempt(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - key := effect.AssignmentKey{Session: "s", RunID: "r1", Effect: "c1"} + key := effect.AssignmentKey{Session: "s", OwnerKind: effect.OwnerTurn, OwnerID: string("r1"), Effect: "c1"} attemptStore := newAttempts() port := &fakePort{state: effect.AttachmentMissing} calls := 0 diff --git a/agentcore/run/redispatch/redispatch.go b/agentcore/turn/redispatch/redispatch.go similarity index 98% rename from agentcore/run/redispatch/redispatch.go rename to agentcore/turn/redispatch/redispatch.go index 5f6f1da7..617e760f 100644 --- a/agentcore/run/redispatch/redispatch.go +++ b/agentcore/turn/redispatch/redispatch.go @@ -1,5 +1,5 @@ // Package redispatch is the dispatch ledger of one effect: the process -// manager's own decisions. Whether an effect is outstanding is the Run's +// manager's own decisions. Whether an effect is outstanding is the Turn's // state; whether a destination holds an attempt is answered by attaching // there; neither is repeated here. What only the process manager knows, and // what must survive its crash, is which redispatch it decided to make, @@ -15,7 +15,7 @@ import ( "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" ) const ( @@ -28,7 +28,7 @@ const ( // the next attempt be planned. EventDispatched ledger.EventType = "dispatched" // EventGivenUp: the process manager stopped redispatching; the reason is - // recorded and the Run disposes the effect. + // recorded and the Turn disposes the effect. EventGivenUp ledger.EventType = "given_up" ) diff --git a/agentcore/run/redispatch/redispatch_test.go b/agentcore/turn/redispatch/redispatch_test.go similarity index 94% rename from agentcore/run/redispatch/redispatch_test.go rename to agentcore/turn/redispatch/redispatch_test.go index 10d15090..a2d04d4c 100644 --- a/agentcore/run/redispatch/redispatch_test.go +++ b/agentcore/turn/redispatch/redispatch_test.go @@ -6,11 +6,11 @@ import ( "testing" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/redispatch" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/redispatch" ) -var key = effect.AssignmentKey{Session: "s1", RunID: "r1", Effect: "sha256:e1"} +var key = effect.AssignmentKey{Session: "s1", OwnerKind: effect.OwnerTurn, OwnerID: string("r1"), Effect: "sha256:e1"} type step = struct { typ ledger.EventType @@ -145,7 +145,7 @@ func TestPlanDispatchAndGiveUp(t *testing.T) { if err := redispatch.GiveUp(ctx, s, 1, key, "again", 1); err != nil { t.Fatal(err) } - if _, err := redispatch.Plan(ctx, s, 0, effect.AssignmentKey{Session: "s1", RunID: "r1", Effect: "sha256:e2"}, 1); err != nil { + if _, err := redispatch.Plan(ctx, s, 0, effect.AssignmentKey{Session: "s1", OwnerKind: effect.OwnerTurn, OwnerID: string("r1"), Effect: "sha256:e2"}, 1); err != nil { t.Fatal(err) } if err := redispatch.MarkDispatched(ctx, s, 0, key, 2, 1); !errors.Is(err, ledger.ErrFenced) && !errors.Is(err, ledger.ErrStateConflict) { diff --git a/agentcore/run/redispatch/redispatchtest/conformance.go b/agentcore/turn/redispatch/redispatchtest/conformance.go similarity index 91% rename from agentcore/run/redispatch/redispatchtest/conformance.go rename to agentcore/turn/redispatch/redispatchtest/conformance.go index 8b3f9a2f..3ea116aa 100644 --- a/agentcore/run/redispatch/redispatchtest/conformance.go +++ b/agentcore/turn/redispatch/redispatchtest/conformance.go @@ -6,8 +6,8 @@ import ( "testing" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/redispatch" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/redispatch" ) // Factory builds a fresh, empty Store for one subtest. @@ -33,7 +33,7 @@ func commit(t *testing.T, seq ledger.CommitSeq, id ledger.CommitID, typ ledger.E // is behind the highest that has written the key. func testLedger(t *testing.T, store redispatch.Store) { ctx := context.Background() - k1 := effect.AssignmentKey{Session: "s1", RunID: "r1", Effect: "sha256:e1"} + k1 := effect.AssignmentKey{Session: "s1", OwnerKind: effect.OwnerTurn, OwnerID: string("r1"), Effect: "sha256:e1"} if _, _, ok, err := store.Load(ctx, k1); err != nil || ok { t.Fatalf("load of an unknown key = ok:%v %v", ok, err) } @@ -71,7 +71,7 @@ func testLedger(t *testing.T, store redispatch.Store) { } // Keys are independent ledgers: the fence of one is not the fence of // another. - k2 := effect.AssignmentKey{Session: "s1", RunID: "r1", Effect: "sha256:e2"} + k2 := effect.AssignmentKey{Session: "s1", OwnerKind: effect.OwnerTurn, OwnerID: string("r1"), Effect: "sha256:e2"} if err := store.Append(ctx, 1, k2, commit(t, 0, redispatch.PlannedCommitID(k2, 1), redispatch.EventDispatchPlanned, redispatch.Planned{Attempt: 1})); err != nil { t.Fatalf("append to a second key under epoch 1 = %v", err) } diff --git a/agentcore/run/redispatch/redispatchtest/map.go b/agentcore/turn/redispatch/redispatchtest/map.go similarity index 96% rename from agentcore/run/redispatch/redispatchtest/map.go rename to agentcore/turn/redispatch/redispatchtest/map.go index ad9f8113..2c316f63 100644 --- a/agentcore/run/redispatch/redispatchtest/map.go +++ b/agentcore/turn/redispatch/redispatchtest/map.go @@ -7,8 +7,8 @@ import ( "sync" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/redispatch" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/redispatch" ) // Map is redispatch.Store over Go maps; the zero value is ready. diff --git a/agentcore/run/redispatch/redispatchtest/map_test.go b/agentcore/turn/redispatch/redispatchtest/map_test.go similarity index 72% rename from agentcore/run/redispatch/redispatchtest/map_test.go rename to agentcore/turn/redispatch/redispatchtest/map_test.go index 197da7d8..336317c3 100644 --- a/agentcore/run/redispatch/redispatchtest/map_test.go +++ b/agentcore/turn/redispatch/redispatchtest/map_test.go @@ -3,7 +3,7 @@ package redispatchtest import ( "testing" - "github.com/felinics/twilight/agentcore/run/redispatch" + "github.com/felinics/twilight/agentcore/turn/redispatch" ) func TestMapConformance(t *testing.T) { diff --git a/agentcore/turn/regression_test.go b/agentcore/turn/regression_test.go new file mode 100644 index 00000000..0f56dbeb --- /dev/null +++ b/agentcore/turn/regression_test.go @@ -0,0 +1,157 @@ +package turn_test + +import ( + "testing" + + "github.com/felinics/twilight/agentcore/testutil/sdkconv" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/sdk" +) + +func TestRegressionZeroBindingsWithToolCallsRejected(t *testing.T) { + s := newTurn(t) + s, stepID := advanceToExecuting(t, s, testRequest(), nil) + result := modelResultWithCalls("c1") + if _, err := schema.Machine().Decide(s, settling(s, turn.SubmitModelResult{StepID: stepID, Result: result, Calls: nil})); err == nil { + t.Fatal("result with tool calls and no bindings completed the run") + } +} + +func TestRegressionBindingMustMatchModelResult(t *testing.T) { + safe := testToolDef("safe") + danger := testToolDef("danger") + specSafe := makeSpec(t, safe, turn.DirectExecution) + specDanger := makeSpec(t, danger, turn.DirectExecution) + s := newTurn(t) + s, stepID := advanceToExecuting(t, s, testRequest(safe, danger), []turn.ToolSpec{specSafe, specDanger}) + + evil := makeBinding(t, stepID, 0, "c1", specDanger, `{"rm":"-rf"}`) + result := modelResultWithNamedCalls("safe", `{"a":1}`, "c1") + if _, err := schema.Machine().Decide(s, settling(s, turn.SubmitModelResult{StepID: stepID, Result: result, Calls: []turn.ToolCallBinding{evil}})); err == nil { + t.Fatal("binding for a tool the model never called was accepted") + } + + tampered := makeBinding(t, stepID, 0, "c1", specSafe, `{"a":999}`) + if _, err := schema.Machine().Decide(s, settling(s, turn.SubmitModelResult{StepID: stepID, Result: result, Calls: []turn.ToolCallBinding{tampered}})); err == nil { + t.Fatal("binding with tampered arguments was accepted") + } +} + +func TestRegressionToolStepIDReproducible(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, turn.ApprovalRequired) + s := newTurn(t) + s, stepID := advanceToExecuting(t, s, testRequest(def), []turn.ToolSpec{spec}) + b := makeBinding(t, stepID, 0, "c1", spec, `{}`) + facts := mustDecide(t, s, turn.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []turn.ToolCallBinding{b}}) + opened := facts[1].(turn.ToolStepOpened) + if schema.Identity().DeriveToolStepID(opened.Source) != opened.StepID { + t.Fatal("ToolStepOpened source does not reproduce its StepID") + } + s = fold(t, s, facts) + ts := s.Current.(turn.ToolStep) + if schema.Identity().DeriveToolStepID(ts.Source) != ts.RefValue.ID { + t.Fatal("persisted ToolStep source does not reproduce the step ID") + } +} + +func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, turn.DirectExecution) + s := newTurn(t) + s, stepID := advanceToExecuting(t, s, testRequest(def), []turn.ToolSpec{spec}) + b := makeBinding(t, stepID, 0, "c1", spec, `{}`) + facts := mustDecide(t, s, turn.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []turn.ToolCallBinding{b}}) + opened := facts[1].(turn.ToolStepOpened) + s = fold(t, s, facts) + s = fold(t, s, mustDecide(t, s, startTool(s, opened.StepID, cid(stepID, 0)))) + + _, err := schema.Machine().Evolve(s, turn.ToolCallFailed{ + StepID: opened.StepID, + CallID: cid(stepID, 0), + Failure: turn.ToolFailure{Class: turn.FailureExecution}, + Outcome: turn.ToolOutcomeUnknown, + }) + if err == nil { + t.Fatal("Evolve accepted an illegal unknown-outcome class") + } +} + +func TestRegressionCancelReasonFixed(t *testing.T) { + s := newTurn(t) + if _, err := schema.Machine().Decide(s, turn.CancelTurn{Reason: turn.TurnReason("other")}); err == nil { + t.Fatal("CancelTurn accepted a non-cancellation reason") + } + facts := mustDecide(t, s, turn.CancelTurn{}) + end, ok := facts[0].(turn.TurnEnded).End.(turn.TurnStoppedEnd) + if !ok || end.Reason != turn.ReasonCancelled { + t.Fatal("cancel reason not fixed to cancelled") + } +} + +func TestCancelSettlesEveryUnfinishedToolCall(t *testing.T) { + for _, kind := range []turn.ResponseKind{turn.ResponseApproval, turn.ResponseExternal} { + t.Run(string(kind), func(t *testing.T) { + policy := turn.ApprovalRequired + if kind == turn.ResponseExternal { + policy = turn.ExternalResponse + } + def, waitDef := testToolDef("t"), testToolDef("wait") + spec, waitSpec := makeSpec(t, def, turn.DirectExecution), makeSpec(t, waitDef, policy) + current := newTurn(t) + current, modelStep := advanceToExecuting(t, current, testRequest(def, waitDef), []turn.ToolSpec{spec, waitSpec}) + bindings := make([]turn.ToolCallBinding, 5) + result := sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls} + for i := range bindings { + callSpec := spec + if i == 2 { + callSpec = waitSpec + } + providerID := "c" + string(rune('1'+i)) + bindings[i] = makeBinding(t, modelStep, i, providerID, callSpec, `{}`) + result.ToolCalls = append(result.ToolCalls, sdk.ToolCall{ToolCallID: providerID, ToolName: callSpec.Name, Input: sdk.ParseToolArguments(`{}`)}) + } + frozen, err := sdkconv.FreezeModelResult(result) + if err != nil { + t.Fatal(err) + } + current = fold(t, current, mustDecide(t, current, turn.SubmitModelResult{StepID: modelStep, Result: frozen, Calls: bindings})) + stepID := current.Current.(turn.ToolStep).RefValue.ID + // Retain one executing call, one completed result and one known failure. + for _, i := range []int{0, 3} { + current = fold(t, current, mustDecide(t, current, startTool(current, stepID, bindings[i].CallID))) + } + current = fold(t, current, mustDecide(t, current, turn.SubmitToolResult{StepID: stepID, CallID: bindings[3].CallID, + Result: turn.ToolExecutionResult{Output: cj(`"done"`)}})) + current = fold(t, current, mustDecide(t, current, turn.DeclineToolCall{StepID: stepID, CallID: bindings[4].CallID, + Failure: turn.ToolFailure{Class: turn.FailureExecution, Message: "failed"}})) + facts := mustDecide(t, current, turn.CancelTurn{}) + if len(facts) != 4 { + t.Fatalf("cancel facts = %d, want three failures and TurnEnded", len(facts)) + } + for i := 0; i < 3; i++ { + failure, ok := facts[i].(turn.ToolCallFailed) + if !ok || failure.CallID != bindings[i].CallID { + t.Fatalf("fact %d = %+v", i, facts[i]) + } + outcome, class := turn.ToolOutcomeKnown, turn.FailureCancelled + if i == 0 { + outcome, class = turn.ToolOutcomeUnknown, turn.FailureEffectUnknown + } + if failure.Outcome != outcome || failure.Failure.Class != class { + t.Fatalf("call %d failure = %+v", i, failure) + } + } + ended := fold(t, current, facts) + if ended.Status != turn.TurnStopped || ended.LastToolStep == nil || + len(ended.Result.UncertainCalls) != 1 || ended.Result.UncertainCalls[0] != bindings[0].CallID { + t.Fatalf("cancel result = %+v", ended) + } + calls := ended.LastToolStep.Calls + if calls[3].Status != turn.ToolCompleted || calls[4].Failure.Failure.Class != turn.FailureExecution { + t.Fatalf("settled calls changed: %+v", calls[3:]) + } + }) + } +} diff --git a/agentcore/run/schema/doc.go b/agentcore/turn/schema/doc.go similarity index 81% rename from agentcore/run/schema/doc.go rename to agentcore/turn/schema/doc.go index f995b861..f6d706e7 100644 --- a/agentcore/run/schema/doc.go +++ b/agentcore/turn/schema/doc.go @@ -1,4 +1,4 @@ -// Package schema composes the Run protocol's contracts: the state machine, +// Package schema composes the Turn protocol's contracts: the state machine, // the fact and command codec, the digest rules, the snapshot codec, the // identity derivation and the frozen-body codec. They have no version and // cannot be replaced: identity derivation and digests are diff --git a/agentcore/run/schema/schema.go b/agentcore/turn/schema/schema.go similarity index 58% rename from agentcore/run/schema/schema.go rename to agentcore/turn/schema/schema.go index 7744191c..b444f0c2 100644 --- a/agentcore/run/schema/schema.go +++ b/agentcore/turn/schema/schema.go @@ -1,19 +1,19 @@ package schema import ( - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/canonical" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/wire" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/canonical" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/wire" ) -// The Run protocol's contracts, each a zero-size value that nothing can -// replace: every Run in every Session is decided, folded, named and +// The Turn protocol's contracts, each a zero-size value that nothing can +// replace: every Turn in every Session is decided, folded, named and // digested by these. This package exists because the state -// machine (package run) cannot import the derivations that depend on it +// machine (package turn) cannot import the derivations that depend on it // (package canonical); it is the one place the two are composed. -// Identity derives every RunID-scoped identity: step, call, response, +// Identity derives every TurnID-scoped identity: step, call, response, // effect and command ids. Its preimages are persisted semantics. func Identity() canonical.Identity { return canonical.Identity{} } @@ -22,8 +22,8 @@ func Canonical() canonical.Digests { return canonical.Digests{} } // Machine is the state machine: Decide, Evolve and CreateGroup, // composed with the identity and digest rules above. -func Machine() run.StateMachine { - return run.StateMachine{Canonical: canonical.Digests{}, Identity: canonical.Identity{}} +func Machine() turn.StateMachine { + return turn.StateMachine{Canonical: canonical.Digests{}, Identity: canonical.Identity{}} } // Wire names and decodes the fact and command variants. diff --git a/agentcore/run/sessionstore/cachepolicy_test.go b/agentcore/turn/sessionstore/cachepolicy_test.go similarity index 100% rename from agentcore/run/sessionstore/cachepolicy_test.go rename to agentcore/turn/sessionstore/cachepolicy_test.go diff --git a/agentcore/run/sessionstore/codec_history_test.go b/agentcore/turn/sessionstore/codec_history_test.go similarity index 60% rename from agentcore/run/sessionstore/codec_history_test.go rename to agentcore/turn/sessionstore/codec_history_test.go index 2c250e34..9a7de0f9 100644 --- a/agentcore/run/sessionstore/codec_history_test.go +++ b/agentcore/turn/sessionstore/codec_history_test.go @@ -5,14 +5,16 @@ import ( "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" + "github.com/felinics/twilight/agentcore/preset/ref" + "github.com/felinics/twilight/agentcore/turn" + "reflect" "strings" "testing" ) // withdrawnV1 stands in for a superseded wire shape of model_step_withdrawn // in which the step was named "step": it decodes that shape and upcasts to -// the current run.ModelStepWithdrawn. Encode is never used for a superseded +// the current turn.ModelStepWithdrawn. Encode is never used for a superseded // version. type withdrawnV1 struct{} @@ -22,13 +24,13 @@ func (withdrawnV1) Encode(any) (jsonstable.Value, error) { } func (withdrawnV1) Decode(w jsonstable.Value) (any, error) { var body struct { - RunID run.RunID `json:"runId"` - Step run.StepID `json:"step"` + TurnID turn.TurnID `json:"turnId"` + Step turn.StepID `json:"step"` } if err := w.Decode(&body); err != nil { return nil, err } - return Event{RunID: body.RunID, Fact: run.ModelStepWithdrawn{StepID: body.Step}}, nil + return Event{TurnID: body.TurnID, Fact: turn.ModelStepWithdrawn{StepID: body.Step}}, nil } // a fact type keeps the codec of every payload version it ever @@ -47,7 +49,7 @@ func TestFactCodecHistoryDecodesEveryVersion(t *testing.T) { t.Fatal(err) } typ := Type(name) - want := Event{RunID: "r1", Fact: run.ModelStepWithdrawn{StepID: "s1"}} + want := Event{TurnID: "r1", Fact: turn.ModelStepWithdrawn{StepID: "s1"}} wire, err := reg.Encode(typ, want) if err != nil || !strings.Contains(wire.String(), `"v":2`) { @@ -58,7 +60,7 @@ func TestFactCodecHistoryDecodesEveryVersion(t *testing.T) { payload string version module.PayloadVersion }{ - {"superseded wire", `{"runId":"r1","step":"s1","v":1}`, 1}, + {"superseded wire", `{"turnId":"r1","step":"s1","v":1}`, 1}, {"current wire", wire.String(), 2}, } for _, tc := range cases { @@ -91,3 +93,36 @@ func TestFactCodecHistoryMustBeContiguous(t *testing.T) { }() factCodecs("model_step_withdrawn", map[module.PayloadVersion]module.PayloadCodec{2: withdrawnV1{}}) } + +func TestTurnStartAndTerminalSurfaceRoundtrip(t *testing.T) { + preset := ref.Ref{ID: "p", Digest: "sha256:p"} + started := turn.TurnStarted{TurnID: "t", Preset: preset} + codec := factCodec{local: "turn_started"} + raw, err := codec.Encode(Event{TurnID: "t", Fact: started}) + if err != nil { + t.Fatal(err) + } + decoded, err := codec.Decode(raw) + if err != nil || !reflect.DeepEqual(decoded, Event{TurnID: "t", Fact: started}) { + t.Fatalf("start roundtrip = %+v %v", decoded, err) + } + for _, end := range []turn.TurnEnd{turn.TurnCompletedEnd{}, turn.TurnFailedEnd{Reason: turn.ReasonProviderFailure, Failure: turn.TurnFailure{Class: turn.FailureProvider}}, turn.TurnStoppedEnd{Reason: turn.ReasonCancelled, Message: "requested"}} { + want := TurnSurface{Order: []turn.TurnID{"t"}, Turns: map[turn.TurnID]TurnView{"t": {TurnID: "t", Preset: preset, End: &turn.TurnEnded{End: end}}}} + raw, err := SurfaceProjection.StateCodec.Encode(want) + if err != nil { + t.Fatal(err) + } + got, err := SurfaceProjection.StateCodec.Decode(raw) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("surface %T roundtrip = %+v %v", end, got, err) + } + } + for _, ev := range []Event{{TurnID: "other", Fact: started}, {TurnID: "t", Fact: turn.TurnStarted{TurnID: "t"}}} { + if _, err := codec.Encode(ev); err == nil { + t.Fatalf("invalid start encoded: %+v", ev) + } + } + if _, err := codec.Decode(jsonstable.MustParse("{\"turnId\":\"t\"}")); err == nil { + t.Fatal("start without preset decoded") + } +} diff --git a/agentcore/run/sessionstore/frozen.go b/agentcore/turn/sessionstore/frozen.go similarity index 79% rename from agentcore/run/sessionstore/frozen.go rename to agentcore/turn/sessionstore/frozen.go index 8f855b76..f41a0499 100644 --- a/agentcore/run/sessionstore/frozen.go +++ b/agentcore/turn/sessionstore/frozen.go @@ -9,14 +9,14 @@ import ( "strings" "github.com/felinics/twilight/agentcore/artifact" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/store" - "github.com/felinics/twilight/agentcore/run/wire" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/store" + "github.com/felinics/twilight/agentcore/turn/wire" ) -// FrozenAuthority is the cas Authority of frozen run bodies: the run layer's +// FrozenAuthority is the cas Authority of frozen Turn bodies: the Turn layer's // frozen.Store is one Authority of the artifact ContentStore, not a // second content-addressed store. const FrozenAuthority artifact.Authority = frozen.Authority @@ -27,7 +27,7 @@ const FrozenMediaType = "application/vnd.twilight.frozen+json" // frozenValues realizes frozen.Store over a cas ContentStore and the // BindingStore the Session Writers admit against. A body's digest is the -// SHA-256 of its bytes (run.EncodeFrozen*), which is exactly the cas Key, so +// SHA-256 of its bytes (turn.EncodeFrozen*), which is exactly the cas Key, so // no index maps digests to refs: Get rebuilds the Ref from the digest alone. // Put stores the body and registers its Binding in one call, so a fact that // names the digest is admissible as soon as Put returns and no caller has to @@ -40,7 +40,7 @@ type frozenValues struct { } // FrozenValues adapts a cas ContentStore serving FrozenAuthority and the -// Writers' BindingStore to the run layer's frozen.Store port. bindings +// Writers' BindingStore to the Turn layer's frozen.Store port. bindings // must be the store the Writers' Admission resolves against. A content store // of another Authority answers every Get with ErrUnauthorized, which surfaces // as an error rather than a miss. @@ -51,7 +51,7 @@ func FrozenValues(content artifact.ContentStore, bindings artifact.BindingStore) return &frozenValues{store: content, bindings: bindings}, nil } -func (f *frozenValues) Put(ctx context.Context, digest run.Digest, val []byte) error { +func (f *frozenValues) Put(ctx context.Context, digest turn.Digest, val []byte) error { if err := ctx.Err(); err != nil { return err } @@ -75,7 +75,7 @@ func (f *frozenValues) Put(ctx context.Context, digest run.Digest, val []byte) e return err } -func (f *frozenValues) Get(ctx context.Context, digest run.Digest) (body []byte, found bool, err error) { +func (f *frozenValues) Get(ctx context.Context, digest turn.Digest) (body []byte, found bool, err error) { if err := ctx.Err(); err != nil { return nil, false, err } @@ -104,8 +104,8 @@ func (f *frozenValues) Get(ctx context.Context, digest run.Digest) (body []byte, // FrozenRef is the cas Ref of the body named by digest: Key and Integrity are // both the digest, MediaType and Durability are the adapter's constants. The -// Run protocol knows only the digest; this derivation is the adapter's -func FrozenRef(digest run.Digest) (artifact.Ref, error) { +// Turn protocol knows only the digest; this derivation is the adapter's +func FrozenRef(digest turn.Digest) (artifact.Ref, error) { algorithm, val, ok := strings.Cut(string(digest), ":") if !ok || algorithm != artifact.IntegritySHA256 || val == "" { return artifact.Ref{}, fmt.Errorf("agent: frozen values: digest %q is not sha256:", digest) @@ -117,13 +117,13 @@ func FrozenRef(digest run.Digest) (artifact.Ref, error) { // FrozenBindingID is the BindingID under which a frozen body's Ref is // registered: derived from the digest alone, so the fact that names the body // and the binding admission agree without an index. -func FrozenBindingID(digest run.Digest) artifact.BindingID { +func FrozenBindingID(digest turn.Digest) artifact.BindingID { return artifact.BindingID(frozen.Authority + "/" + string(digest)) } // FrozenBinding is the Binding a fact naming digest references. Its Ref is // FrozenRef(digest), so registering it is idempotent across replays. -func FrozenBinding(digest run.Digest) (artifact.Binding, error) { +func FrozenBinding(digest turn.Digest) (artifact.Binding, error) { ref, err := FrozenRef(digest) if err != nil { return artifact.Binding{}, err @@ -138,15 +138,15 @@ func frozenRefs(val any) ([]artifact.BindingID, error) { if !ok { return nil, fmt.Errorf("frozen refs: unexpected %T", val) } - var digest run.Digest + var digest turn.Digest switch f := ev.Fact.(type) { - case run.ModelStepPrepared: + case turn.ModelStepPrepared: digest = f.RequestDigest - case run.ModelStepCompleted: + case turn.ModelStepCompleted: digest = f.ResultDigest - case run.ToolCallCompleted: + case turn.ToolCallCompleted: digest = f.OutputDigest - case run.ToolCallAnswered: + case turn.ToolCallAnswered: digest = f.ResponseDigest default: return nil, fmt.Errorf("frozen refs: %s names no frozen body", wire.FactType(ev.Fact)) @@ -170,7 +170,7 @@ type Content struct { // NewContent builds the materializer over a frozen.Store. func NewContent(st frozen.Store) *Content { return &Content{Frozen: st} } -func (c *Content) raw(ctx context.Context, what string, digest run.Digest) ([]byte, error) { +func (c *Content) raw(ctx context.Context, what string, digest turn.Digest) ([]byte, error) { if err := store.CheckContext(ctx); err != nil { return nil, err } @@ -188,7 +188,7 @@ func (c *Content) raw(ctx context.Context, what string, digest run.Digest) ([]by } // ModelResult returns the body ModelStepCompleted.ResultDigest names. -func (c *Content) ModelResult(ctx context.Context, digest run.Digest) (model.ModelResult, error) { +func (c *Content) ModelResult(ctx context.Context, digest turn.Digest) (model.ModelResult, error) { raw, err := c.raw(ctx, "model result", digest) if err != nil { return model.ModelResult{}, err @@ -197,25 +197,25 @@ func (c *Content) ModelResult(ctx context.Context, digest run.Digest) (model.Mod } // ToolOutput returns the body ToolCallCompleted.OutputDigest names. -func (c *Content) ToolOutput(ctx context.Context, digest run.Digest) (run.CanonicalJSON, error) { +func (c *Content) ToolOutput(ctx context.Context, digest turn.Digest) (turn.CanonicalJSON, error) { raw, err := c.raw(ctx, "tool output", digest) if err != nil { - return run.CanonicalJSON{}, err + return turn.CanonicalJSON{}, err } return frozen.DecodeToolOutput(raw, digest) } // ToolResponse returns the body ToolCallAnswered.ResponseDigest names. -func (c *Content) ToolResponse(ctx context.Context, digest run.Digest) (run.CanonicalJSON, error) { +func (c *Content) ToolResponse(ctx context.Context, digest turn.Digest) (turn.CanonicalJSON, error) { raw, err := c.raw(ctx, "tool response", digest) if err != nil { - return run.CanonicalJSON{}, err + return turn.CanonicalJSON{}, err } return frozen.DecodeToolResponse(raw, digest) } // ModelRequest returns the body ModelStepPrepared.RequestDigest names. -func (c *Content) ModelRequest(ctx context.Context, digest run.Digest) (model.ModelRequest, error) { +func (c *Content) ModelRequest(ctx context.Context, digest turn.Digest) (model.ModelRequest, error) { raw, err := c.raw(ctx, "model request", digest) if err != nil { return model.ModelRequest{}, err diff --git a/agentcore/run/sessionstore/frozen_test.go b/agentcore/turn/sessionstore/frozen_test.go similarity index 93% rename from agentcore/run/sessionstore/frozen_test.go rename to agentcore/turn/sessionstore/frozen_test.go index 433425f4..ab29ee95 100644 --- a/agentcore/run/sessionstore/frozen_test.go +++ b/agentcore/turn/sessionstore/frozen_test.go @@ -7,15 +7,15 @@ import ( "github.com/felinics/twilight/agentcore/artifact" "github.com/felinics/twilight/agentcore/artifact/artifacttest" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/schema" "github.com/felinics/twilight/agentcore/session/filestore" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/schema" ) // frozenBody freezes one request and renders the bytes the store keeps. -func frozenBody(t *testing.T, text string) (run.Digest, []byte) { +func frozenBody(t *testing.T, text string) (turn.Digest, []byte) { t.Helper() req := model.ModelRequest{Model: "m-1", Messages: []model.Message{{Role: model.MessageRoleUser, Content: []model.MessagePart{{Type: model.MessagePartTypeText, Text: text}}}}} diff --git a/agentcore/turn/sessionstore/guard.go b/agentcore/turn/sessionstore/guard.go new file mode 100644 index 00000000..88af1b74 --- /dev/null +++ b/agentcore/turn/sessionstore/guard.go @@ -0,0 +1,24 @@ +package sessionstore + +import ( + "fmt" + + "github.com/felinics/twilight/agentcore/session/writer" +) + +// RequireNoActiveTurn is the Turn module's quiescence precondition for a +// Session-level operation evaluated inside the Writer's critical section: +// an error while any Turn of the Session is active. Terminal Turns leave +// the machine projection, so an +// empty Active set is the whole answer: no Turn is executing, awaiting an +// effect or holding an unresolved outcome. +func RequireNoActiveTurn(view writer.View) error { + m, err := loadMachine(view) + if err != nil { + return err + } + for id := range m.Active { + return fmt.Errorf("sessionstore: turn %s is active", id) + } + return nil +} diff --git a/agentcore/run/sessionstore/guard_test.go b/agentcore/turn/sessionstore/guard_test.go similarity index 92% rename from agentcore/run/sessionstore/guard_test.go rename to agentcore/turn/sessionstore/guard_test.go index ac07f619..2ee702ef 100644 --- a/agentcore/run/sessionstore/guard_test.go +++ b/agentcore/turn/sessionstore/guard_test.go @@ -4,8 +4,8 @@ import ( "errors" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/session" + "github.com/felinics/twilight/agentcore/turn" "strings" "testing" ) @@ -29,10 +29,10 @@ func (v guardView) Projection(module.ProjectionID, module.ProjectionVersion) (an return v.state, v.err } -func TestRequireNoActiveRun(t *testing.T) { +func TestRequireNoActiveTurn(t *testing.T) { boom := errors.New("projection unavailable") active := newMachine() - active.Active["r-1"] = run.MachineState{} + active.Active["r-1"] = turn.MachineState{} cases := map[string]struct { view guardView wantErr error // errors.Is @@ -44,7 +44,7 @@ func TestRequireNoActiveRun(t *testing.T) { "foreign state": {view: guardView{state: "nope"}, mention: "string"}, } for name, tc := range cases { - err := RequireNoActiveRun(tc.view) + err := RequireNoActiveTurn(tc.view) switch { case tc.wantErr == nil && tc.mention == "": if err != nil { diff --git a/agentcore/turn/sessionstore/identity.go b/agentcore/turn/sessionstore/identity.go new file mode 100644 index 00000000..60e17255 --- /dev/null +++ b/agentcore/turn/sessionstore/identity.go @@ -0,0 +1,28 @@ +package sessionstore + +import ( + "github.com/felinics/twilight/agentcore/jsonstable" + "github.com/felinics/twilight/agentcore/preset/ref" + "github.com/felinics/twilight/agentcore/session" + "github.com/felinics/twilight/agentcore/turn" +) + +func digestOf(domain string, parts ...string) jsonstable.Digest { + raw, _ := jsonstable.EncodeTypedPayload(1, domain, parts) + return jsonstable.DigestBytes(raw) +} + +// PlanDigest identifies the immutable Turn start intent. +func PlanDigest(id turn.TurnID, preset ref.Ref, inputs []turn.AgentInput) jsonstable.Digest { + parts := []string{string(id), string(preset.ID), string(preset.Digest)} + for _, input := range inputs { + parts = append(parts, string(input.ID), string(input.Digest)) + } + return digestOf("twilight/turn/plan", parts...) +} +func StartOperationDigest(sid session.SessionID, id turn.TurnID, plan jsonstable.Digest) jsonstable.Digest { + return digestOf("twilight/turn/start-operation", string(sid), string(id), string(plan)) +} +func CancelCommandID(sid session.SessionID, id turn.TurnID) turn.CommandID { + return turn.CommandID(digestOf("twilight/turn/cancel", string(sid), string(id))) +} diff --git a/agentcore/run/sessionstore/module.go b/agentcore/turn/sessionstore/module.go similarity index 66% rename from agentcore/run/sessionstore/module.go rename to agentcore/turn/sessionstore/module.go index efccde4a..14d3e7ea 100644 --- a/agentcore/run/sessionstore/module.go +++ b/agentcore/turn/sessionstore/module.go @@ -1,7 +1,7 @@ -// Package sessionstore is the Run domain over a Session ledger: the -// twilight/run/ event definitions, the twilight/run/machine projection, the -// frozen-body store over the content store, and the RunStore adapter -// (SessionRunStore) that commits Run commands through a Session Writer. +// Package sessionstore is the Turn domain over a Session ledger: the +// twilight/turn/ event definitions, the twilight/turn/machine projection, the +// frozen-body store over the content store, and the TurnStore adapter +// (SessionTurnStore) that commits Turn commands through a Session Writer. package sessionstore import ( @@ -12,55 +12,55 @@ import ( "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/wire" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/wire" ) const ( - ModuleID module.ModuleID = "run" - // Prefix is the EventType namespace of every Run fact. - Prefix ledger.EventType = "twilight/run/" - // StreamDomain is the stream domain of Run facts: one keyed stream per - // Run, bound by the payload's runId. - StreamDomain = "run" + ModuleID module.ModuleID = "turn" + // Prefix is the EventType namespace of every Turn fact. + Prefix ledger.EventType = "twilight/turn/" + // StreamDomain is the stream domain of Turn facts: one keyed stream per + // Turn, bound by the payload's turnId. + StreamDomain = "turn" ) -// streamDefinition declares the run domain: keyed by RunID and of segment -// lineage, so a fork never reads an ancestor's Runs as its own. -var streamDefinition = module.StreamDefinition{Domain: StreamDomain, Key: streamKey, Inheritance: module.Own} +// streamDefinition inherits Turn history by identity. Execution reads use +// module.Own explicitly; the active machine does not inherit ancestor facts. +var streamDefinition = module.StreamDefinition{Domain: StreamDomain, Key: streamKey, Inheritance: module.Inherited} -// streamKey binds a run event to its Run's stream. +// streamKey binds a turn event to its Turn's stream. func streamKey(value any) (string, error) { ev, ok := value.(Event) if !ok { return "", fmt.Errorf("value is %T, want sessionstore.Event", value) } - return string(ev.RunID), nil + return string(ev.TurnID), nil } -// Stream is the logical stream of one Run's facts. -func Stream(runID run.RunID) ledger.Domain { return streamDefinition.Ref(string(runID)) } +// Stream is the logical stream of one Turn's facts. +func Stream(turnID turn.TurnID) ledger.Domain { return streamDefinition.Ref(string(turnID)) } -// factNames is the closed list of fact discriminators, from the Run core's +// factNames is the closed list of fact discriminators, from the Turn core's // variant registry: a fact the core knows is a wire type this module // registers, with no second list to keep in step. var factNames = wire.FactTypes() // EventType returns the EventType of a fact. -func EventType(f run.Fact) ledger.EventType { +func EventType(f turn.Fact) ledger.EventType { return Prefix + ledger.EventType(wire.Facts{}.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. +// Event is the typed value of one twilight/turn/ event: the fact plus the +// TurnID that every payload carries at its first level. type Event struct { - RunID run.RunID - Fact run.Fact + TurnID turn.TurnID + Fact turn.Fact } // factCodec is the codec of one fact type at its current payload version: -// the wire the Run core defines today. The payload is the canonical fact -// object with "runId" added; `v` is the Registry's. +// the wire the Turn core defines today. The payload is the canonical fact +// object with "turnId" added; `v` is the Registry's. type factCodec struct { local string wire wire.Facts @@ -71,12 +71,20 @@ func (c factCodec) Validate(value any) error { if !ok { return fmt.Errorf("value is %T, want sessionstore.Event", value) } - if ev.RunID == "" || ev.Fact == nil { - return errors.New("event requires runId and fact") + if ev.TurnID == "" || ev.Fact == nil { + return errors.New("event requires turnId and fact") } if got := c.wire.FactType(ev.Fact); got != c.local { return fmt.Errorf("fact is %s, codec is %s", got, c.local) } + if started, ok := ev.Fact.(turn.TurnStarted); ok { + if started.TurnID != ev.TurnID { + return errors.New("fact turnId disagrees with event turnId") + } + if err := turn.ValidateNewTurn(turn.NewTurn(started)); err != nil { + return err + } + } return nil } @@ -96,14 +104,14 @@ func (c factCodec) Encode(value any) (jsonstable.Value, error) { 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") + if existing, has := m["turnId"]; has { + // TurnStarted already carries turnId; it must agree. + var id turn.TurnID + if err := json.Unmarshal(existing, &id); err != nil || id != ev.TurnID { + return jsonstable.Value{}, errors.New("fact turnId disagrees with event turnId") } } - m["runId"] = json.RawMessage(fmt.Sprintf("%q", string(ev.RunID))) + m["turnId"] = json.RawMessage(fmt.Sprintf("%q", string(ev.TurnID))) return jsonstable.FromValue(m) } @@ -112,16 +120,16 @@ func (c factCodec) Decode(w jsonstable.Value) (any, error) { if err := json.Unmarshal(w.Bytes(), &m); err != nil { return nil, err } - rawID, ok := m["runId"] + rawID, ok := m["turnId"] if !ok { - return nil, errors.New("run event has no runId") + return nil, errors.New("turn event has no turnId") } - var id run.RunID + var id turn.TurnID if err := json.Unmarshal(rawID, &id); err != nil || id == "" { - return nil, errors.New("run event runId is not a string") + return nil, errors.New("turn event turnId is not a string") } - if c.local != "run_created" { - delete(m, "runId") + if c.local != "turn_started" { + delete(m, "turnId") } body, err := jsonstable.FromValue(m) if err != nil { @@ -131,10 +139,14 @@ func (c factCodec) Decode(w jsonstable.Value) (any, error) { 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") + if created, ok := fact.(turn.TurnStarted); ok && created.TurnID != id { + return nil, errors.New("turn_started turnId disagrees with payload turnId") + } + ev := Event{TurnID: id, Fact: fact} + if err := c.Validate(ev); err != nil { + return nil, err } - return Event{RunID: id, Fact: fact}, nil + return ev, nil } // frozenBodyFacts are the fact types whose digest names a frozen body; each @@ -153,16 +165,15 @@ var frozenBinding = module.BindingReferenceDefinition{ var one uint32 = 1 -// Module is the run ModuleDescriptor (no Requires). Conversation -// and Turn projections consume its facts; nothing of theirs is written here. +// Module registers Turn facts, the history surface, and the active machine. var Module = buildModule() // factCodecs is the codec history of one fact type: // one PayloadCodec per payload version ever written, keyed by version, and // the current version. older holds the superseded versions 1..n-1, each a // codec of its own that decodes that version's wire shape and upcasts to -// the current run.Fact; the current shape is version n, served by factCodec -// for the wire the Run core defines today. Nothing is dropped: a fact type +// the current turn.Fact; the current shape is version n, served by factCodec +// for the wire the Turn core defines today. Nothing is dropped: a fact type // whose shape changes keeps its previous codec under its old version and // moves up one version by itself. A history with a gap is a programming // error in this module's own table and stops the build. @@ -206,7 +217,7 @@ func eventDefinition(name string, codecs map[module.PayloadVersion]module.Payloa func buildModule() module.ModuleDescriptor { m := module.ModuleDescriptor{Source: module.SourceTwilight, ID: ModuleID, - Streams: []module.StreamDefinition{streamDefinition}, Projections: []module.ProjectionDefinition{MachineProjection}} + Streams: []module.StreamDefinition{streamDefinition}, Projections: []module.ProjectionDefinition{MachineProjection, SurfaceProjection}} for _, name := range factNames { codecs, current := factCodecs(name, olderFactCodecs(name)) m.Events = append(m.Events, eventDefinition(name, codecs, current)) @@ -217,7 +228,7 @@ func buildModule() module.ModuleDescriptor { // Type returns the EventType of one fact discriminator. func Type(name string) ledger.EventType { return Prefix + ledger.EventType(name) } -// AllTypes lists every registered twilight/run/ EventType. +// AllTypes lists every registered twilight/turn/ EventType. func AllTypes() []ledger.EventType { out := make([]ledger.EventType, len(factNames)) for i, name := range factNames { diff --git a/agentcore/run/sessionstore/projection.go b/agentcore/turn/sessionstore/projection.go similarity index 51% rename from agentcore/run/sessionstore/projection.go rename to agentcore/turn/sessionstore/projection.go index 9c21feb5..4be157ed 100644 --- a/agentcore/run/sessionstore/projection.go +++ b/agentcore/turn/sessionstore/projection.go @@ -7,27 +7,27 @@ import ( "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/store" - "github.com/felinics/twilight/agentcore/run/wire" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/store" + "github.com/felinics/twilight/agentcore/turn/wire" ) -const MachineProjectionID module.ProjectionID = "twilight/run/machine" +const MachineProjectionID module.ProjectionID = "twilight/turn/machine" -// Machine is the twilight/run/machine projection state: every -// non-terminal Run of the Session with its last event position and schema. -// Terminal Runs leave the projection entirely; Record and the turn surface -// keep their results, and a second creation of a used RunID is refused at -// commit time from the ledger's stream index (CreateRun), so the projection -// is bounded by the Runs active now. +// Machine is the twilight/turn/machine projection state: every +// non-terminal Turn of the Session with its last event position and schema. +// Terminal Turns leave the projection entirely; Record and the turn surface +// keep their results, and a second creation of a used TurnID is refused at +// commit time from the ledger's stream index (CreateTurn), so the projection +// is bounded by the Turns active now. type Machine struct { - Active map[run.RunID]run.MachineState - Positions map[run.RunID]run.RunPosition + Active map[turn.TurnID]turn.MachineState + Positions map[turn.TurnID]turn.TurnPosition } func newMachine() Machine { - return Machine{Active: map[run.RunID]run.MachineState{}, Positions: map[run.RunID]run.RunPosition{}} + return Machine{Active: map[turn.TurnID]turn.MachineState{}, Positions: map[turn.TurnID]turn.TurnPosition{}} } func (m Machine) clone() Machine { @@ -41,34 +41,34 @@ func (m Machine) clone() Machine { return out } -// snapshot returns the store.Snapshot of an active Run. -func (m Machine) snapshot(runID run.RunID) (store.Snapshot, bool) { - ms, ok := m.Active[runID] +// snapshot returns the store.Snapshot of an active Turn. +func (m Machine) snapshot(turnID turn.TurnID) (store.Snapshot, bool) { + ms, ok := m.Active[turnID] if !ok { return store.Snapshot{}, false } - return store.Snapshot{State: ms, Position: m.Positions[runID]}, true + return store.Snapshot{State: ms, Position: m.Positions[turnID]}, true } -// Apply folds one decoded run event (via Protocol.Evolve). +// Apply folds one decoded Turn event (via Protocol.Evolve). func (m Machine) Apply(e module.DecodedEvent) (Machine, error) { //nolint:gocritic // hugeParam: DecodedEvent is the extension Apply shape ev, ok := e.Value.(Event) if !ok { - return m, fmt.Errorf("run machine: unexpected %T", e.Value) + return m, fmt.Errorf("turn machine: unexpected %T", e.Value) } - if want := Stream(ev.RunID); e.Domain != want { - return m, fmt.Errorf("run machine: fact for %s arrived via stream %s", ev.RunID, e.Domain) + if want := Stream(ev.TurnID); e.Domain != want { + return m, fmt.Errorf("turn machine: fact for %s arrived via stream %s", ev.TurnID, e.Domain) } out := m.clone() - var state run.MachineState - if _, isCreated := ev.Fact.(run.RunCreated); isCreated { - if _, dup := out.Active[ev.RunID]; dup { - return m, fmt.Errorf("run machine: %s created twice", ev.RunID) + var state turn.MachineState + if _, isCreated := ev.Fact.(turn.TurnStarted); isCreated { + if _, dup := out.Active[ev.TurnID]; dup { + return m, fmt.Errorf("turn machine: %s created twice", ev.TurnID) } } else { - cur, active := out.Active[ev.RunID] + cur, active := out.Active[ev.TurnID] if !active { - return m, fmt.Errorf("run machine: fact %s for unknown or terminal run %s", wire.FactType(ev.Fact), ev.RunID) + return m, fmt.Errorf("turn machine: fact %s for unknown or terminal turn %s", wire.FactType(ev.Fact), ev.TurnID) } state = cur } @@ -77,26 +77,26 @@ func (m Machine) Apply(e module.DecodedEvent) (Machine, error) { //nolint:gocrit return m, err } if next.Status.Terminal() { - delete(out.Active, ev.RunID) - delete(out.Positions, ev.RunID) + delete(out.Active, ev.TurnID) + delete(out.Positions, ev.TurnID) return out, nil } - if _, tracked := out.Positions[ev.RunID]; tracked { - out.Positions[ev.RunID]++ + if _, tracked := out.Positions[ev.TurnID]; tracked { + out.Positions[ev.TurnID]++ } else { - out.Positions[ev.RunID] = 0 + out.Positions[ev.TurnID] = 0 } - out.Active[ev.RunID] = next + out.Active[ev.TurnID] = next return out, nil } type machineWire struct { - Runs map[run.RunID]machineRunWire `json:"runs"` + Turns map[turn.TurnID]machineTurnWire `json:"turns"` } -type machineRunWire struct { - Position run.RunPosition `json:"position"` - State jsonstable.Value `json:"state"` +type machineTurnWire struct { + Position turn.TurnPosition `json:"position"` + State jsonstable.Value `json:"state"` } type machineCodec struct{} @@ -113,7 +113,7 @@ func (c machineCodec) Encode(value any) (jsonstable.Value, error) { return jsonstable.Value{}, err } m, _ := value.(Machine) // Validate checked the type - w := machineWire{Runs: make(map[run.RunID]machineRunWire, len(m.Active))} + w := machineWire{Turns: make(map[turn.TurnID]machineTurnWire, len(m.Active))} for id := range m.Active { state := m.Active[id] raw, err := schema.Snapshot().Encode(&state) @@ -124,7 +124,7 @@ func (c machineCodec) Encode(value any) (jsonstable.Value, error) { if err != nil { return jsonstable.Value{}, err } - w.Runs[id] = machineRunWire{Position: m.Positions[id], State: encoded} + w.Turns[id] = machineTurnWire{Position: m.Positions[id], State: encoded} } return jsonstable.FromValue(w) } @@ -138,7 +138,7 @@ func (machineCodec) Decode(wr jsonstable.Value) (any, error) { return nil, err } m := newMachine() - for id, r := range w.Runs { + for id, r := range w.Turns { state, err := schema.Snapshot().Decode(r.State.Bytes()) if err != nil { return nil, err @@ -149,15 +149,16 @@ func (machineCodec) Decode(wr jsonstable.Value) (any, error) { return m, nil } -// MachineProjection consumes every twilight/run/ event. +// MachineProjection consumes every twilight/turn/ event. var MachineProjection = module.ProjectionDefinition{ ID: MachineProjectionID, Version: 1, Consumes: AllTypes(), Authoritative: true, - Initial: func() (any, error) { return newMachine(), nil }, + Inherits: func(ledger.Domain) bool { return false }, + Initial: func() (any, error) { return newMachine(), nil }, Apply: func(state any, e module.DecodedEvent) (any, error) { m, ok := state.(Machine) if !ok { - return nil, fmt.Errorf("run machine: state is %T", state) + return nil, fmt.Errorf("turn machine: state is %T", state) } return m.Apply(e) }, @@ -168,7 +169,7 @@ var _ ledger.EventType = Prefix // WriterCachePolicy is this module's use of the projection cache for the // Writer: every projection at the deployment's interval, except the machine -// projection, whose entry the SessionRunStore refreshes itself through SnapshotPolicy +// projection, whose entry the SessionTurnStore refreshes itself through SnapshotPolicy // and which must never be cached mid-step. every is the interval in // commits; zero or less takes module.DefaultCacheEvery. func WriterCachePolicy(every ledger.CommitSeq) module.CachePolicy { diff --git a/agentcore/run/sessionstore/recover.go b/agentcore/turn/sessionstore/recover.go similarity index 71% rename from agentcore/run/sessionstore/recover.go rename to agentcore/turn/sessionstore/recover.go index bad1c36e..a31c3cba 100644 --- a/agentcore/run/sessionstore/recover.go +++ b/agentcore/turn/sessionstore/recover.go @@ -5,26 +5,26 @@ import ( "errors" "fmt" - "github.com/felinics/twilight/agentcore/run/store" "github.com/felinics/twilight/agentcore/session/writer" + "github.com/felinics/twilight/agentcore/turn/store" ) -// Reconciler decides the takeover disposition of one Run's Executing targets +// Reconciler decides the takeover disposition of one Turn's Executing targets // reconcile.Reconciler is the implementation. type Reconciler interface { - Reconcile(ctx context.Context, st store.RunStore, snapshot *store.Snapshot) (int, error) + Reconcile(ctx context.Context, st store.TurnStore, snapshot *store.Snapshot) (int, error) } // RecoverInterrupted is the Session-level takeover: every active -// Run of the Session is reconciled. Each recovery command is identified by +// Turn of the Session is reconciled. Each recovery command is identified by // the effect it disposes, so an owner that repeats the takeover, // or a later owner, replays the same commands idempotently. The host calls it -// once after opening the Writer and before driving any Run. The Reconciler is +// once after opening the Writer and before driving any Turn. The Reconciler is // required: a caller that holds no executor and wants every Executing target // disposed says so with reconcile.Reconciler{Abandon: true}; nothing is // disposed for want of an executor to ask. It returns the number // of accepted recovery commands. -func (s *SessionRunStore) RecoverInterrupted(ctx context.Context, w writer.Writer, rec Reconciler) (int, error) { +func (s *SessionTurnStore) RecoverInterrupted(ctx context.Context, w writer.Writer, rec Reconciler) (int, error) { if err := store.CheckContext(ctx); err != nil { return 0, err } @@ -45,8 +45,8 @@ func (s *SessionRunStore) RecoverInterrupted(ctx context.Context, w writer.Write } st := s.Bind(w) n := 0 - for runID := range m.Active { - snapshot, _ := m.snapshot(runID) + for turnID := range m.Active { + snapshot, _ := m.snapshot(turnID) accepted, err := rec.Reconcile(ctx, st, &snapshot) n += accepted if err != nil { diff --git a/agentcore/run/sessionstore/sessionstoretest/sessionstoretest.go b/agentcore/turn/sessionstore/sessionstoretest/sessionstoretest.go similarity index 64% rename from agentcore/run/sessionstore/sessionstoretest/sessionstoretest.go rename to agentcore/turn/sessionstore/sessionstoretest/sessionstoretest.go index c49b5524..174bd26d 100644 --- a/agentcore/run/sessionstore/sessionstoretest/sessionstoretest.go +++ b/agentcore/turn/sessionstore/sessionstoretest/sessionstoretest.go @@ -1,4 +1,4 @@ -// Package sessionstoretest builds the Run session store's ports over throwaway durable +// Package sessionstoretest builds the Turn session store's ports over throwaway durable // stores for tests. package sessionstoretest @@ -6,12 +6,12 @@ import ( "testing" "github.com/felinics/twilight/agentcore/artifact" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/sessionstore" "github.com/felinics/twilight/agentcore/session/filestore/filestoretest" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/sessionstore" ) -// Frozen is the run layer's frozen.Store over a fresh file-backed content +// Frozen is the Turn layer's frozen.Store over a fresh file-backed content // store for sessionstore.FrozenAuthority and the given binding store. func Frozen(t testing.TB, bindings artifact.BindingStore) frozen.Store { t.Helper() diff --git a/agentcore/run/sessionstore/settlementproof.go b/agentcore/turn/sessionstore/settlementproof.go similarity index 60% rename from agentcore/run/sessionstore/settlementproof.go rename to agentcore/turn/sessionstore/settlementproof.go index 25a80822..f2db8335 100644 --- a/agentcore/run/sessionstore/settlementproof.go +++ b/agentcore/turn/sessionstore/settlementproof.go @@ -6,29 +6,29 @@ import ( "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/store" "github.com/felinics/twilight/agentcore/session" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/store" ) var _ store.EffectDispositionReader = (*bound)(nil) -// EffectDisposition reads effect-naming closing facts from the durable Run -// stream. It also finds dispositions in CancelRun commits, whose CommandID is +// EffectDisposition reads effect-naming closing facts from the durable Turn +// stream. It also finds dispositions in CancelTurn commits, whose CommandID is // not an effect-derived settlement ID. Active projections and retained local -// CommitResults are never consulted; terminal Runs remain provable. -func (b *bound) EffectDisposition(ctx context.Context, runID run.RunID, effectID run.EffectID) ([]run.Fact, bool, error) { +// CommitResults are never consulted; terminal Turns remain provable. +func (b *bound) EffectDisposition(ctx context.Context, turnID turn.TurnID, effectID turn.EffectID) ([]turn.Fact, bool, error) { if err := store.CheckContext(ctx); err != nil { return nil, false, err } - if runID == "" || effectID == "" { - return nil, false, errors.New("sessionstore: effect proof requires RunID and EffectID") + if turnID == "" || effectID == "" { + return nil, false, errors.New("sessionstore: effect proof requires TurnID and EffectID") } var from ledger.StreamSeq for { - // Run streams are module.Own: inherited ancestor dispositions are - // not proof about an execution addressed by this Session's scope. - page, err := b.s.cfg.Store.ReadStream(ctx, session.StreamReadRequest{SessionID: b.w.SessionID(), Domain: Stream(runID), Inheritance: module.Own, From: from, Limit: 128}) + // This proof reads module.Own even though history is inherited: + // ancestor dispositions cannot prove this Session's execution. + page, err := b.s.cfg.Store.ReadStream(ctx, session.StreamReadRequest{SessionID: b.w.SessionID(), Domain: Stream(turnID), Inheritance: module.Own, From: from, Limit: 128}) if err != nil { return nil, false, err } @@ -38,11 +38,11 @@ func (b *bound) EffectDisposition(ctx context.Context, runID run.RunID, effectID return nil, false, err } ev, ok := decoded.Value.(Event) - if !ok || ev.RunID != runID { - return nil, false, errors.New("sessionstore: effect proof event does not belong to the Run") + if !ok || ev.TurnID != turnID { + return nil, false, errors.New("sessionstore: effect proof event does not belong to the Turn") } if store.DispositionEffect(ev.Fact) == effectID { - return []run.Fact{ev.Fact}, true, nil + return []turn.Fact{ev.Fact}, true, nil } } if !page.HasMore { diff --git a/agentcore/run/sessionstore/settlementproof_test.go b/agentcore/turn/sessionstore/settlementproof_test.go similarity index 64% rename from agentcore/run/sessionstore/settlementproof_test.go rename to agentcore/turn/sessionstore/settlementproof_test.go index ac707dee..6fd27f8d 100644 --- a/agentcore/run/sessionstore/settlementproof_test.go +++ b/agentcore/turn/sessionstore/settlementproof_test.go @@ -4,16 +4,17 @@ import ( "context" "errors" "fmt" + "github.com/felinics/twilight/agentcore/preset/ref" "testing" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/store" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/filestore/filestoretest" "github.com/felinics/twilight/agentcore/session/writer" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/store" ) // Proof uses no Writer projection, command evaluation, or ownership operation. @@ -25,17 +26,17 @@ type proofScope struct { func (w proofScope) SessionID() session.SessionID { return w.sid } -func appendProofFacts(t *testing.T, h session.Handle, reg *module.Registry, runID run.RunID, id ledger.CommitID, facts ...run.Fact) ledger.Commit { +func appendProofFacts(t *testing.T, h session.Handle, reg *module.Registry, turnID turn.TurnID, id ledger.CommitID, facts ...turn.Fact) ledger.Commit { t.Helper() events := make([]ledger.Event, len(facts)) for i, fact := range facts { - payload, err := reg.Encode(EventType(fact), Event{RunID: runID, Fact: fact}) + payload, err := reg.Encode(EventType(fact), Event{TurnID: turnID, Fact: fact}) if err != nil { t.Fatal(err) } events[i] = ledger.Event{Type: EventType(fact), Payload: payload} } - c, err := h.Append(context.Background(), ledger.Proposal{CommitID: id, Batches: []ledger.EventBatch{{Domain: Stream(runID), Events: events}}}) + c, err := h.Append(context.Background(), ledger.Proposal{CommitID: id, Batches: []ledger.EventBatch{{Domain: Stream(turnID), Events: events}}}) if err != nil { t.Fatal(err) } @@ -58,50 +59,50 @@ func TestEffectProofSurvivesTerminalRunAndFindsCancelDisposition(t *testing.T) { t.Fatal(err) } defer h.Close(ctx) - const runID run.RunID = "run" - const effectID run.EffectID = "model-effect" - prefix := []run.Fact{run.RunCreated{RunID: runID}, - run.ModelStepPrepared{StepID: "step", Model: "m", RequestDigest: "sha256:req"}, - run.ModelStepStarted{StepID: "step", Effect: effectID}} - appendProofFacts(t, h, reg, runID, "prefix", prefix...) + const turnID turn.TurnID = "run" + const effectID turn.EffectID = "model-effect" + prefix := []turn.Fact{turn.TurnStarted{Preset: ref.Ref{ID: "test", Digest: "sha256:test"}, TurnID: turnID}, + turn.ModelStepPrepared{StepID: "step", Model: "m", RequestDigest: "sha256:req"}, + turn.ModelStepStarted{StepID: "step", Effect: effectID}} + appendProofFacts(t, h, reg, turnID, "prefix", prefix...) // Cross a stream page boundary before the disposition. for i := 0; i < 130; i++ { - f := run.InputAccepted{Input: run.AgentInput{ID: run.InputID(fmt.Sprintf("input-%d", i)), Digest: "sha256:input"}} - appendProofFacts(t, h, reg, runID, ledger.CommitID(fmt.Sprintf("input-%d", i)), f) + f := turn.InputAccepted{Input: turn.AgentInput{ID: turn.InputID(fmt.Sprintf("input-%d", i)), Digest: "sha256:input"}} + appendProofFacts(t, h, reg, turnID, ledger.CommitID(fmt.Sprintf("input-%d", i)), f) prefix = append(prefix, f) } - state := run.MachineState{RunID: runID, Status: run.RunActive, Current: run.ModelStep{RefValue: run.StepRef{RunID: runID, ID: "step"}, Status: run.ModelExecuting, Effect: effectID}} - closing, err := schema.Machine().Decide(state, run.CancelRun{}) + state := turn.MachineState{TurnID: turnID, Status: turn.TurnActive, Current: turn.ModelStep{RefValue: turn.StepRef{TurnID: turnID, ID: "step"}, Status: turn.ModelExecuting, Effect: effectID}} + closing, err := schema.Machine().Decide(state, turn.CancelTurn{}) if err != nil { t.Fatal(err) } - cancelCommit := appendProofFacts(t, h, reg, runID, "cancel-run-not-an-effect-command", closing...) - // The actual machine projection removes this terminal Run. That is not + cancelCommit := appendProofFacts(t, h, reg, turnID, "cancel-run-not-an-effect-command", closing...) + // The actual machine projection removes this terminal Turn. That is not // absence of a durable disposition proof. machine := newMachine() for _, fact := range append(prefix, closing...) { - machine, err = machine.Apply(module.DecodedEvent{Domain: Stream(runID), Value: Event{RunID: runID, Fact: fact}}) + machine, err = machine.Apply(module.DecodedEvent{Domain: Stream(turnID), Value: Event{TurnID: turnID, Fact: fact}}) if err != nil { t.Fatal(err) } } - if _, active := machine.Active[runID]; active { + if _, active := machine.Active[turnID]; active { t.Fatal("terminal Run remained in projection") } // A fresh adapter has neither the command result nor a machine cache. - adapter := &SessionRunStore{cfg: Config{Store: durable, Registry: reg}} + adapter := &SessionTurnStore{cfg: Config{Store: durable, Registry: reg}} proof := adapter.Bind(proofScope{sid: sid}).(store.EffectDispositionReader) - facts, found, err := proof.EffectDisposition(ctx, runID, effectID) + facts, found, err := proof.EffectDisposition(ctx, turnID, effectID) if err != nil || !found || len(facts) != 1 || store.DispositionEffect(facts[0]) != effectID { t.Fatalf("cancel proof=%+v found=%v err=%v", facts, found, err) } - if _, ok := facts[0].(run.ModelStepFailed); !ok { + if _, ok := facts[0].(turn.ModelStepFailed); !ok { t.Fatalf("cancel proof=%T", facts[0]) } for _, query := range []struct { - run run.RunID - effect run.EffectID - }{{runID, "other"}, {"other-run", effectID}} { + run turn.TurnID + effect turn.EffectID + }{{turnID, "other"}, {"other-run", effectID}} { if facts, found, err := proof.EffectDisposition(ctx, query.run, query.effect); err != nil || found || len(facts) != 0 { t.Fatalf("foreign query=%+v proof=%+v found=%v err=%v", query, facts, found, err) } @@ -113,7 +114,7 @@ func TestEffectProofSurvivesTerminalRunAndFindsCancelDisposition(t *testing.T) { t.Fatal(err) } childProof := adapter.Bind(proofScope{sid: child}).(store.EffectDispositionReader) - if facts, found, err := childProof.EffectDisposition(ctx, runID, effectID); err != nil || found || len(facts) != 0 { + if facts, found, err := childProof.EffectDisposition(ctx, turnID, effectID); err != nil || found || len(facts) != 0 { t.Fatalf("inherited effect was accepted as local proof: %+v found=%v err=%v", facts, found, err) } } @@ -134,18 +135,18 @@ func TestEffectProofFindsEveryToolInSingleCancelCommit(t *testing.T) { t.Fatal(err) } defer h.Close(ctx) - state := run.MachineState{RunID: "run", Status: run.RunActive, Current: run.ToolStep{RefValue: run.StepRef{RunID: "run", ID: "tools"}, Calls: []run.ToolCallState{ - {CallID: "call-1", Status: run.ToolExecuting, Effect: "tool-1"}, - {CallID: "call-2", Status: run.ToolExecuting, Effect: "tool-2"}, + state := turn.MachineState{TurnID: "run", Status: turn.TurnActive, Current: turn.ToolStep{RefValue: turn.StepRef{TurnID: "run", ID: "tools"}, Calls: []turn.ToolCallState{ + {CallID: "call-1", Status: turn.ToolExecuting, Effect: "tool-1"}, + {CallID: "call-2", Status: turn.ToolExecuting, Effect: "tool-2"}, }}} - facts, err := schema.Machine().Decide(state, run.CancelRun{}) + facts, err := schema.Machine().Decide(state, turn.CancelTurn{}) if err != nil { t.Fatal(err) } appendProofFacts(t, h, reg, "run", "cancel-both-tools", facts...) - adapter := &SessionRunStore{cfg: Config{Store: durable, Registry: reg}} + adapter := &SessionTurnStore{cfg: Config{Store: durable, Registry: reg}} proof := adapter.Bind(proofScope{sid: sid}).(store.EffectDispositionReader) - for _, id := range []run.EffectID{"tool-1", "tool-2"} { + for _, id := range []turn.EffectID{"tool-1", "tool-2"} { facts, found, err := proof.EffectDisposition(ctx, "run", id) if err != nil || !found || len(facts) != 1 || store.DispositionEffect(facts[0]) != id { t.Fatalf("%s proof=%+v found=%v err=%v", id, facts, found, err) @@ -166,7 +167,7 @@ func (s failedProofStore) ReadStream(context.Context, session.StreamReadRequest) func TestEffectProofReadFailureIsNotAbsence(t *testing.T) { boom := errors.New("ledger unavailable") for _, s := range []failedProofStore{{err: boom}, {stall: true}} { - adapter := &SessionRunStore{cfg: Config{Store: s}} + adapter := &SessionTurnStore{cfg: Config{Store: s}} proof := adapter.Bind(proofScope{sid: "session"}).(store.EffectDispositionReader) _, found, err := proof.EffectDisposition(context.Background(), "run", "effect") if err == nil || found || (s.err != nil && !errors.Is(err, boom)) { diff --git a/agentcore/run/sessionstore/store.go b/agentcore/turn/sessionstore/store.go similarity index 56% rename from agentcore/run/sessionstore/store.go rename to agentcore/turn/sessionstore/store.go index 86c083a7..599bf237 100644 --- a/agentcore/run/sessionstore/store.go +++ b/agentcore/turn/sessionstore/store.go @@ -6,32 +6,32 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/store" - "github.com/felinics/twilight/agentcore/run/wire" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/unit" "github.com/felinics/twilight/agentcore/session/writer" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/store" + "github.com/felinics/twilight/agentcore/turn/wire" "time" ) // 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 +type SnapshotPolicy func(before, after *turn.MachineState) bool -// DefaultSnapshotPolicy writes when the Run returns to Open or terminates. -func DefaultSnapshotPolicy(_, after *run.MachineState) bool { +// DefaultSnapshotPolicy writes when the Turn returns to Open or terminates. +func DefaultSnapshotPolicy(_, after *turn.MachineState) bool { if after.Status.Terminal() { return true } - _, open := after.Current.(run.Open) + _, open := after.Current.(turn.Open) return open } -// Config assembles a SessionRunStore (agent-store.md 10). +// Config assembles a SessionTurnStore (agent-store.md 10). type Config struct { Registry *module.Registry // Store is the read side: Record folds from it (through Cache) without @@ -43,8 +43,8 @@ type Config struct { // admission: FrozenValues over a ContentStore and a BindingStore does. Frozen frozen.Store // PrepareGuard enforces Session consistency before a new ModelStep is - // prepared, inside the same Writer critical section as its Run command. - // Nil adds no Session-level constraint to the standalone Run adapter. + // prepared, inside the same Writer critical section as its Turn command. + // Nil adds no Session-level constraint to the standalone Turn adapter. PrepareGuard func(writer.View) error // Snapshot decides when the machine projection is cached; nil selects // DefaultSnapshotPolicy. @@ -54,17 +54,17 @@ type Config struct { Now func() time.Time } -// SessionRunStore is the Session adapter of the Run core: it realizes -// store.RunStore over a Session Writer (Bind), reads Runs by SessionID without -// ownership (Record), and contributes the Run module's Parts to a cross-module -// unit of work (Command, CreateRun). It is the only code that encodes Run -// facts as twilight/run/ events. -type SessionRunStore struct { +// SessionTurnStore is the Session adapter of the Turn core: it realizes +// store.TurnStore over a Session Writer (Bind), reads TurnStore by SessionID without +// ownership (Record), and contributes the Turn module's Parts to a cross-module +// unit of work (Command, CreateTurn). It is the only code that encodes Turn +// facts as twilight/turn/ events. +type SessionTurnStore struct { cfg Config reader session.ProjectionReader } -func NewSessionRunStore(cfg Config) (*SessionRunStore, error) { +func NewSessionTurnStore(cfg Config) (*SessionTurnStore, error) { if cfg.Registry == nil || cfg.Store == nil { return nil, errors.New("sessionstore: store requires registry and store") } @@ -77,12 +77,12 @@ func NewSessionRunStore(cfg Config) (*SessionRunStore, error) { if cfg.Now == nil { cfg.Now = time.Now } - return &SessionRunStore{cfg: cfg, reader: session.NewProjectionReader(cfg.Store, cfg.Registry, cfg.Cache)}, nil + return &SessionTurnStore{cfg: cfg, reader: session.NewProjectionReader(cfg.Store, cfg.Registry, cfg.Cache)}, nil } -func (s *SessionRunStore) nowMilli() int64 { return s.cfg.Now().UnixMilli() } +func (s *SessionTurnStore) nowMilli() int64 { return s.cfg.Now().UnixMilli() } -// ownershipError maps the Writer's ownership loss onto the Run sentinel. +// ownershipError maps the Writer's ownership loss onto the Turn sentinel. func ownershipError(err error) error { if errors.Is(err, &ledger.Error{Code: ledger.CodeOwnershipLost}) || session.IsCode(err, session.ErrOwnershipLost) { return fmt.Errorf("%w: %w", store.ErrOwnershipLost, err) @@ -104,25 +104,25 @@ func loadMachine(view writer.View) (Machine, error) { // --- bound port --------------------------------------------------------------------- -// Bind returns the store.RunStore over one Session Writer: the caller's +// Bind returns the store.TurnStore over one Session Writer: the caller's // ownership capability, so every command of a drive lands on the same Writer, // epoch and projection view. -func (s *SessionRunStore) Bind(w writer.Writer) store.RunStore { return &bound{s: s, w: w} } +func (s *SessionTurnStore) Bind(w writer.Writer) store.TurnStore { return &bound{s: s, w: w} } type bound struct { - s *SessionRunStore + s *SessionTurnStore w writer.Writer } -func (b *bound) Scope() run.Scope { return run.Scope(b.w.SessionID()) } +func (b *bound) Scope() turn.Scope { return turn.Scope(b.w.SessionID()) } // Writer is the ownership capability the store is bound to; // a Loop hook that must commit through the same Writer takes it from here. func (b *bound) Writer() writer.Writer { return b.w } -// Load reads the Writer's transactional projection; a Run the +// Load reads the Writer's transactional projection; a Turn the // projection no longer holds is folded from its own stream. -func (b *bound) Load(ctx context.Context, runID run.RunID) (store.Snapshot, error) { +func (b *bound) Load(ctx context.Context, turnID turn.TurnID) (store.Snapshot, error) { if err := store.CheckContext(ctx); err != nil { return store.Snapshot{}, err } @@ -135,17 +135,17 @@ func (b *bound) Load(ctx context.Context, runID run.RunID) (store.Snapshot, erro if !ok { return store.Snapshot{}, fmt.Errorf("sessionstore: machine projection is %T", state) } - if snap, ok := m.snapshot(runID); ok { + if snap, ok := m.snapshot(turnID); ok { return snap, nil } - record, err := b.s.record(ctx, sid, runID, nil, ledger.Head{}) + record, err := b.s.record(ctx, sid, turnID, nil, ledger.Head{}) if err != nil { return store.Snapshot{}, err } return record.Snapshot, nil } -// Commit is one Run command as a unit of work of its own: the Run module's +// Commit is one Turn command as a unit of work of its own: the Turn module's // Part alone. func (b *bound) Commit(ctx context.Context, req store.CommitRequest) (store.CommitResult, error) { if err := store.CheckContext(ctx); err != nil { @@ -155,20 +155,24 @@ func (b *bound) Commit(ctx context.Context, req store.CommitRequest) (store.Comm if err != nil { return store.CommitResult{}, err } - res, err := unit.Commit(ctx, b.w, b.s.nowMilli(), unit.Work{CommitID: ledger.CommitID(req.Command.ID), Parts: []unit.Part{cmd}}) + work := unit.Work{CommitID: ledger.CommitID(req.Command.ID), Parts: []unit.Part{cmd}} + if _, ok := req.Command.Command.(turn.AcceptInput); ok { + work.ReplayCheck = cmd.CheckInputReplay + } + res, err := unit.Commit(ctx, b.w, b.s.nowMilli(), work) if err != nil { return store.CommitResult{}, ownershipError(err) } return cmd.Result(ctx, b.w, &res) } -func (b *bound) FrozenRequest(ctx context.Context, digest run.Digest) (model.ModelRequest, error) { +func (b *bound) FrozenRequest(ctx context.Context, digest turn.Digest) (model.ModelRequest, error) { return b.s.FrozenRequest(ctx, digest) } // FrozenRequest returns the request body a Prepared or Executing ModelStep // names by RequestDigest. -func (s *SessionRunStore) FrozenRequest(ctx context.Context, digest run.Digest) (model.ModelRequest, error) { +func (s *SessionTurnStore) FrozenRequest(ctx context.Context, digest turn.Digest) (model.ModelRequest, error) { if err := store.CheckContext(ctx); err != nil { return model.ModelRequest{}, err } @@ -187,29 +191,29 @@ func (s *SessionRunStore) FrozenRequest(ctx context.Context, digest run.Digest) // --- Command part ------------------------------------------------------------------- -// Command is the Run module's Part for one command: constructing +// Command is the Turn module's Part for one command: constructing // it freezes the bodies the command's facts will name, Prepare evaluates the // command inside the Writer against the machine projection and contributes -// the facts to the Run's stream, Result reads the accepted decision back -// once the unit has committed. Its rejections are the Run sentinels: -// ErrRunTerminal, ErrStaleRuntime, ErrCommandConflict, ErrRunNotFound. +// the facts to the Turn's stream, Result reads the accepted decision back +// once the unit has committed. Its rejections are the Turn sentinels: +// ErrTurnTerminal, ErrStaleRuntime, ErrCommandConflict, ErrTurnNotFound. type Command struct { - s *SessionRunStore + s *SessionTurnStore req store.CommitRequest prepared bool - before run.MachineState - after run.MachineState - position run.RunPosition - facts []run.Fact + before turn.MachineState + after turn.MachineState + position turn.TurnPosition + facts []turn.Fact } // Command builds the Part of one command. The bodies it names by digest are // stored before the unit commits: Put is idempotent and content-addressed, // so a rejected or replayed command leaves nothing inconsistent behind. -func (s *SessionRunStore) Command(ctx context.Context, req store.CommitRequest) (*Command, error) { - if req.Command.RunID == "" || req.Command.ID == "" { - return nil, errors.New("sessionstore: command requires RunID and CommandID") +func (s *SessionTurnStore) Command(ctx context.Context, req store.CommitRequest) (*Command, error) { + if req.Command.TurnID == "" || req.Command.ID == "" { + return nil, errors.New("sessionstore: command requires TurnID and CommandID") } if err := s.freezeBodies(ctx, req.Command.Command); err != nil { return nil, err @@ -219,8 +223,8 @@ func (s *SessionRunStore) Command(ctx context.Context, req store.CommitRequest) func (c *Command) Prepare(_ context.Context, view writer.View, now int64) ([]writer.TypedBatch, error) { env := &c.req.Command - runID := env.RunID - if _, preparing := env.Command.(run.PrepareModelRequest); preparing && c.s.cfg.PrepareGuard != nil { + turnID := env.TurnID + if _, preparing := env.Command.(turn.PrepareModelRequest); preparing && c.s.cfg.PrepareGuard != nil { if err := c.s.cfg.PrepareGuard(view); err != nil { return nil, err } @@ -229,77 +233,100 @@ func (c *Command) Prepare(_ context.Context, view writer.View, now int64) ([]wri if err != nil { return nil, err } - state, active := proj.Active[runID] + state, active := proj.Active[turnID] if !active { - // Terminal Runs leave the projection: tell terminal from - // unknown by the Run's own stream. - if _, exists := view.StreamHead(Stream(runID)); exists { - return nil, run.ErrRunTerminal + // Terminal Turns leave the projection: tell terminal from + // unknown by the Turn's own stream. + if _, exists := view.StreamHead(Stream(turnID)); exists { + return nil, turn.ErrTurnTerminal } - return nil, store.ErrRunNotFound + return nil, store.ErrTurnNotFound } - decision, err := store.EvaluateCommit(state, proj.Positions[runID], c.req) + decision, err := store.EvaluateCommit(state, proj.Positions[turnID], c.req) if err != nil { return nil, err } switch decision.Kind { case store.DecisionConflict: - return nil, run.ErrCommandConflict + return nil, turn.ErrCommandConflict case store.DecisionStale: - if decision.Reject != nil && !errors.Is(decision.Reject, run.ErrStaleRuntime) { - return nil, fmt.Errorf("%w: %w", run.ErrStaleRuntime, decision.Reject) + if decision.Reject != nil && !errors.Is(decision.Reject, turn.ErrStaleRuntime) { + return nil, fmt.Errorf("%w: %w", turn.ErrStaleRuntime, decision.Reject) } - return nil, run.ErrStaleRuntime + return nil, turn.ErrStaleRuntime case store.DecisionTerminal: - return nil, run.ErrRunTerminal + return nil, turn.ErrTurnTerminal } events := make([]writer.TypedEvent, 0, len(decision.Facts)) for _, f := range decision.Facts { - events = append(events, writer.TypedEvent{Type: EventType(f), RecordedAtUnixMilli: now, Value: Event{RunID: runID, Fact: f}}) + events = append(events, writer.TypedEvent{Type: EventType(f), RecordedAtUnixMilli: now, Value: Event{TurnID: turnID, Fact: f}}) } c.prepared = true c.before, c.after = state, decision.NewState - c.position = proj.Positions[runID] + run.RunPosition(len(decision.Facts)) + c.position = proj.Positions[turnID] + turn.TurnPosition(len(decision.Facts)) c.facts = decision.Facts - return []writer.TypedBatch{{Domain: Stream(runID), Events: events}}, nil + return []writer.TypedBatch{{Domain: Stream(turnID), Events: events}}, nil +} + +// CheckInputReplay verifies accepted inputs including immutable content digests. +// Command identity contains ordered InputIDs and does not contain digests. +func (c *Command) CheckInputReplay(commit ledger.Commit) error { + cmd, ok := c.req.Command.Command.(turn.AcceptInput) + if !ok { + return errors.New("sessionstore: input replay check requires AcceptInput") + } + facts, err := c.s.factsOf(commit, c.req.Command.TurnID) + if err != nil { + return err + } + if len(facts) != len(cmd.Inputs) { + return turn.ErrCommandConflict + } + for i, f := range facts { + accepted, ok := f.(turn.InputAccepted) + if !ok || accepted.Input != cmd.Inputs[i] { + return turn.ErrCommandConflict + } + } + return nil } -// Result maps the unit's outcome onto the Run's CommitResult. w is the Writer +// Result maps the unit's outcome onto the Turn's CommitResult. w is the Writer // the unit committed through; a replay reads the current snapshot from it. func (c *Command) Result(ctx context.Context, w writer.Writer, res *writer.CommitResult) (store.CommitResult, error) { switch res.Outcome { case writer.CommitApplied: if !c.prepared { - return store.CommitResult{}, errors.New("sessionstore: commit applied without the run part") + return store.CommitResult{}, errors.New("sessionstore: commit applied without the Turn part") } c.s.afterCommit(ctx, w, &c.before, &c.after) return store.CommitResult{Status: store.CommitAccepted, Facts: c.facts, Snapshot: store.Snapshot{State: c.after, Position: c.position}}, nil case writer.CommitAlreadyApplied: - // Idempotency is the Session's (SessionID, CommitID) index alone - // every Run CommandID is content-derived, so a hit is the - // same command and nothing is re-decided. - snapshot, err := c.s.Bind(w).Load(ctx, c.req.Command.RunID) + // The Session (SessionID, CommitID) index identifies a replay. + // Command IDs are content-derived; input commands additionally verify + // immutable input digests through CheckInputReplay before reaching here. + snapshot, err := c.s.Bind(w).Load(ctx, c.req.Command.TurnID) if err != nil { return store.CommitResult{}, err } - facts, err := c.s.factsOf(res.Commit, c.req.Command.RunID) + facts, err := c.s.factsOf(res.Commit, c.req.Command.TurnID) if err != nil { return store.CommitResult{}, err } return store.CommitResult{Status: store.CommitAlreadyApplied, Snapshot: snapshot, Facts: facts}, nil case writer.CommitConflict: - return store.CommitResult{}, run.ErrCommandConflict + return store.CommitResult{}, turn.ErrCommandConflict default: return store.CommitResult{}, fmt.Errorf("sessionstore: commit: %s: %s", res.Outcome, res.Detail) } } -// factsOf decodes the Run facts of runID a stored commit holds. -func (s *SessionRunStore) factsOf(c ledger.Commit, runID run.RunID) ([]run.Fact, error) { - var out []run.Fact +// factsOf decodes the Turn facts of turnID a stored commit holds. +func (s *SessionTurnStore) factsOf(c ledger.Commit, turnID turn.TurnID) ([]turn.Fact, error) { + var out []turn.Fact for _, b := range c.Batches { - if b.Domain != Stream(runID) { + if b.Domain != Stream(turnID) { continue } for i := range b.Events { @@ -318,39 +345,39 @@ func (s *SessionRunStore) factsOf(c ledger.Commit, runID run.RunID) ([]run.Fact, // freezeBodies stores the bodies a command's facts will name by digest // the request of a Prepare, the result of a model settlement, // the output of a tool settlement and the payload of an external response. -// Each body is encoded under the Run's schema as the envelope its digest was +// Each body is encoded under the Turn's schema as the envelope its digest was // computed from (digest rules and body encoding are versioned together with Decide and Evolve). -func (s *SessionRunStore) freezeBodies(ctx context.Context, cmd run.AgentCommand) error { - var digest run.Digest +func (s *SessionTurnStore) freezeBodies(ctx context.Context, cmd turn.AgentCommand) error { + var digest turn.Digest var body []byte var err error switch c := cmd.(type) { - case run.PrepareModelRequest: + case turn.PrepareModelRequest: digest = c.RequestDigest body, err = schema.Bodies().EncodeRequest(&c.Request, digest) - case run.SubmitModelResult: + case turn.SubmitModelResult: if digest, err = schema.Canonical().DigestModelResult(c.Result); err == nil { body, err = schema.Bodies().EncodeModelResult(&c.Result, digest) } - case run.SubmitToolResult: + case turn.SubmitToolResult: if digest, err = schema.Canonical().DigestToolOutput(c.Result.Output); err == nil { body, err = schema.Bodies().EncodeToolOutput(c.Result.Output, digest) } - case run.SubmitToolResponse: + case turn.SubmitToolResponse: digest = c.ResponseDigest body, err = schema.Bodies().EncodeToolResponse(c.Payload, digest) default: return nil } if err != nil { - return fmt.Errorf("%w: %w", run.ErrStaleRuntime, err) + return fmt.Errorf("%w: %w", turn.ErrStaleRuntime, err) } return s.cfg.Frozen.Put(ctx, digest, body) } // afterCommit writes the machine projection to the cache when the policy asks // for it. Cache failures never affect the commit. -func (s *SessionRunStore) afterCommit(ctx context.Context, w writer.Writer, before, after *run.MachineState) { +func (s *SessionTurnStore) afterCommit(ctx context.Context, w writer.Writer, before, after *turn.MachineState) { if s.cfg.Cache == nil || !s.cfg.Snapshot(before, after) { return } @@ -362,52 +389,52 @@ func (s *SessionRunStore) afterCommit(ctx context.Context, w writer.Writer, befo _ = session.SaveProjection(ctx, s.cfg.Cache, s.cfg.Registry, sid, MachineProjectionID, MachineProjection.Version, state, head) } -// --- CreateRun part ------------------------------------------------------------------ +// --- CreateTurn part ------------------------------------------------------------------ -// ErrRunExists reports a creation of a RunID this Session already holds. -var ErrRunExists = errors.New("sessionstore: run already exists") +// ErrTurnExists reports a creation of a TurnID this Session already holds. +var ErrTurnExists = errors.New("sessionstore: turn already exists") -// CreateRun is the Part that establishes a Run: RunCreated and -// one InputAccepted per initial input, in the Run's own stream. The owning -// module places it in its creation unit beside its own facts. A RunID whose -// stream this Session already wrote -- active or ended -- is ErrRunExists. -func CreateRun(newRun run.NewRun, inputs []run.AgentInput) unit.Part { - return createRun{newRun: newRun, inputs: inputs} +// CreateTurn is the Part that establishes a Turn: TurnStarted and +// one InputAccepted per initial input, in the Turn's own stream. The owning +// module places it in its creation unit beside its own facts. A TurnID whose +// stream this Session already wrote -- active or ended -- is ErrTurnExists. +func CreateTurn(newTurn turn.NewTurn, inputs []turn.AgentInput) unit.Part { + return createTurn{newTurn: newTurn, inputs: inputs} } -type createRun struct { - newRun run.NewRun - inputs []run.AgentInput +type createTurn struct { + newTurn turn.NewTurn + inputs []turn.AgentInput } -func (c createRun) Prepare(_ context.Context, view writer.View, now int64) ([]writer.TypedBatch, error) { - facts, err := schema.Machine().CreateGroup(c.newRun, c.inputs) +func (c createTurn) Prepare(_ context.Context, view writer.View, now int64) ([]writer.TypedBatch, error) { + facts, err := schema.Machine().CreateGroup(c.newTurn, c.inputs) if err != nil { return nil, err } - if _, exists := view.StreamHead(Stream(c.newRun.RunID)); exists { - return nil, fmt.Errorf("%w: %s", ErrRunExists, c.newRun.RunID) + if _, exists := view.StreamHead(Stream(c.newTurn.TurnID)); exists { + return nil, fmt.Errorf("%w: %s", ErrTurnExists, c.newTurn.TurnID) } events := make([]writer.TypedEvent, 0, len(facts)) for _, f := range facts { - events = append(events, writer.TypedEvent{Type: EventType(f), RecordedAtUnixMilli: now, Value: Event{RunID: c.newRun.RunID, Fact: f}}) + events = append(events, writer.TypedEvent{Type: EventType(f), RecordedAtUnixMilli: now, Value: Event{TurnID: c.newTurn.TurnID, Fact: f}}) } - return []writer.TypedBatch{{Domain: Stream(c.newRun.RunID), Events: events}}, nil + return []writer.TypedBatch{{Domain: Stream(c.newTurn.TurnID), Events: events}}, nil } // --- Record -------------------------------------------------------------------------- -// Record is one verified read of a Run: every twilight/run/ event of the -// RunID in stream order, folded and compared with the projection. +// Record is one verified read of a Turn: every twilight/turn/ event of the +// TurnID in stream order, folded and compared with the projection. type Record struct { Created ledger.StreamSeq Snapshot store.Snapshot Events []ledger.Event - Facts []run.Fact + Facts []turn.Fact } -// Record folds a Run from the Store by SessionID; it needs no ownership -func (s *SessionRunStore) Record(ctx context.Context, sid session.SessionID, runID run.RunID) (Record, error) { +// Record folds a Turn from the Store by SessionID; it needs no ownership +func (s *SessionTurnStore) Record(ctx context.Context, sid session.SessionID, turnID turn.TurnID) (Record, error) { if err := store.CheckContext(ctx); err != nil { return Record{}, err } @@ -419,25 +446,25 @@ func (s *SessionRunStore) Record(ctx context.Context, sid session.SessionID, run if !ok { return Record{}, fmt.Errorf("sessionstore: machine projection is %T", state) } - var expect *run.MachineState - if ms, ok := m.Active[runID]; ok { + var expect *turn.MachineState + if ms, ok := m.Active[turnID]; ok { expect = &ms } - return s.record(ctx, sid, runID, expect, head) + return s.record(ctx, sid, turnID, expect, head) } -// record reads the Run's events from the Store and folds them: the ledger +// record reads the Turn's events from the Store and folds them: the ledger // fold is the authoritative read. When expect is given, the fold // is compared with the projection state, but only if both were read at the // same head: the two reads are separate round trips, and a commit landing // between them makes both correct at different points, not divergent. -func (s *SessionRunStore) record(ctx context.Context, sid session.SessionID, runID run.RunID, expect *run.MachineState, expectHead ledger.Head) (Record, error) { - page, err := s.cfg.Store.ReadStream(ctx, session.StreamReadRequest{SessionID: sid, Domain: Stream(runID), Inheritance: streamDefinition.Inheritance}) +func (s *SessionTurnStore) record(ctx context.Context, sid session.SessionID, turnID turn.TurnID, expect *turn.MachineState, expectHead ledger.Head) (Record, error) { + page, err := s.cfg.Store.ReadStream(ctx, session.StreamReadRequest{SessionID: sid, Domain: Stream(turnID), Inheritance: module.Own}) if err != nil { return Record{}, err } var record Record - var position run.RunPosition + var position turn.TurnPosition for i := range page.Events { e := &page.Events[i] decoded, err := s.cfg.Registry.Decode(*e) @@ -445,13 +472,13 @@ func (s *SessionRunStore) record(ctx context.Context, sid session.SessionID, run return Record{}, err } if decoded.Unknown { - return Record{}, fmt.Errorf("sessionstore: record: unknown run event %s v%d", e.Type, decoded.Version) + return Record{}, fmt.Errorf("sessionstore: record: unknown turn event %s v%d", e.Type, decoded.Version) } ev, ok := decoded.Value.(Event) if !ok { return Record{}, fmt.Errorf("sessionstore: record: %s decoded to %T", e.Type, decoded.Value) } - if ev.RunID != runID { + if ev.TurnID != turnID { continue } if len(record.Events) == 0 { @@ -459,12 +486,12 @@ func (s *SessionRunStore) record(ctx context.Context, sid session.SessionID, run } record.Events = append(record.Events, *e) record.Facts = append(record.Facts, ev.Fact) - position = run.RunPosition(i) + position = turn.TurnPosition(i) } if len(record.Facts) == 0 { - return Record{}, store.ErrRunNotFound + return Record{}, store.ErrTurnNotFound } - state, err := store.FoldRun(record.Facts) + state, err := store.FoldTurn(record.Facts) if err != nil { return Record{}, fmt.Errorf("sessionstore: record: %w", err) } diff --git a/agentcore/run/sessionstore/storetest/conformance.go b/agentcore/turn/sessionstore/storetest/conformance.go similarity index 70% rename from agentcore/run/sessionstore/storetest/conformance.go rename to agentcore/turn/sessionstore/storetest/conformance.go index 1684e7d7..ea39881c 100644 --- a/agentcore/run/sessionstore/storetest/conformance.go +++ b/agentcore/turn/sessionstore/storetest/conformance.go @@ -7,20 +7,19 @@ import ( "github.com/felinics/twilight/agentcore/chatlog" "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/preset" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/plan" - "github.com/felinics/twilight/agentcore/run/reconcile" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/sessionstore" - "github.com/felinics/twilight/agentcore/run/store" - "github.com/felinics/twilight/agentcore/run/wire" + "github.com/felinics/twilight/agentcore/preset/ref" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/unit" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/plan" + "github.com/felinics/twilight/agentcore/turn/reconcile" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/sessionstore" + "github.com/felinics/twilight/agentcore/turn/store" + "github.com/felinics/twilight/agentcore/turn/wire" "strings" "testing" ) @@ -55,51 +54,51 @@ func Run(t *testing.T, factory Factory) { func testCreation(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) + h.startTurn("r1", input("in-1")) snap := h.load("r1") - if snap.State.RunID != "r1" || len(snap.State.PendingInputs) != 1 { + if snap.State.TurnID != "r1" || len(snap.State.PendingInputs) != 1 { t.Fatalf("created state = %+v", snap.State) } - // Position is the StreamSeq of the Run's last event: the start group's run + // Position is the StreamSeq of the Turn's last event: the start group's Turn // batch is created (0), accepted (1). The Turn's and the chatlog's events // of the same commit do not move it. if snap.Position != 1 { - t.Fatalf("position = %d, want 1 (last run event of the start group)", snap.Position) + t.Fatalf("position = %d, want 1 (last Turn event of the start group)", snap.Position) } - // Unknown RunID. - if _, err := h.rt.Bind(h.writer()).Load(h.ctx, "nope"); !errors.Is(err, store.ErrRunNotFound) { + // Unknown TurnID. + if _, err := h.rt.Bind(h.writer()).Load(h.ctx, "nope"); !errors.Is(err, store.ErrTurnNotFound) { t.Fatalf("load unknown = %v", err) } - if _, err := h.rt.Record(h.ctx, sid, "nope"); !errors.Is(err, store.ErrRunNotFound) { + if _, err := h.rt.Record(h.ctx, sid, "nope"); !errors.Is(err, store.ErrTurnNotFound) { t.Fatalf("record unknown = %v", err) } - env, _ := schema.Wire().Envelope("nope", schema.Identity().DeriveInputCommandID("nope", "x"), run.NextStep(input("x"))) - if _, err := h.rt.Bind(h.writer()).Commit(h.ctx, store.CommitRequest{Command: env}); !errors.Is(err, store.ErrRunNotFound) { + env, _ := schema.Wire().Envelope("nope", schema.Identity().DeriveInputCommandID("nope", "x"), turn.NextStep(input("x"))) + if _, err := h.rt.Bind(h.writer()).Commit(h.ctx, store.CommitRequest{Command: env}); !errors.Is(err, store.ErrTurnNotFound) { t.Fatalf("commit unknown = %v", err) } - // Terminated Run: Load returns the terminal state, Commit is terminal. - h.mustCommit("r1", "cancel-1", 0, run.CancelRun{}) + // Terminated Turn: Load returns the terminal state, Commit is terminal. + h.mustCommit("r1", "cancel-1", 0, turn.CancelTurn{}) term := h.load("r1") - if term.State.Status != run.RunStopped || term.State.Result == nil { + if term.State.Status != turn.TurnStopped || term.State.Result == nil { t.Fatalf("terminal load = %+v", term.State) } rec := h.record("r1") if !wire.StatesEquivalent(&rec.Snapshot.State, &term.State) || rec.Snapshot.Position != term.Position { t.Fatal("terminal Load and Record disagree") } - if _, err := h.commit("r1", schema.Identity().DeriveInputCommandID("r1", "late"), 0, run.NextStep(input("late"))); !errors.Is(err, run.ErrRunTerminal) { + if _, err := h.commit("r1", schema.Identity().DeriveInputCommandID("r1", "late"), 0, turn.NextStep(input("late"))); !errors.Is(err, turn.ErrTurnTerminal) { t.Fatalf("commit on terminal = %v", err) } - // A second created for the same RunID -- active or ended -- is refused by - // the Run module's creation Part from the ledger's stream index, before + // A second created for the same TurnID -- active or ended -- is refused by + // the Turn module's creation Part from the ledger's stream index, before // anything reaches the ledger. - again, err := run.BuildNewRun("r1", "") + again, err := turn.BuildNewTurn("r1", "", ref.Ref{ID: "test", Digest: "sha256:test"}) if err != nil { t.Fatal(err) } head := h.head() - if _, err := unit.Commit(h.ctx, h.writer(), 1, unit.Work{CommitID: "start/t2/1", Parts: []unit.Part{sessionstore.CreateRun(again, nil)}}); !errors.Is(err, sessionstore.ErrRunExists) { - t.Fatalf("duplicate created = %v, want ErrRunExists", err) + if _, err := unit.Commit(h.ctx, h.writer(), 1, unit.Work{CommitID: "start/t2/1", Parts: []unit.Part{sessionstore.CreateTurn(again, nil)}}); !errors.Is(err, sessionstore.ErrTurnExists) { + t.Fatalf("duplicate created = %v, want ErrTurnExists", err) } if h.head() != head { t.Fatal("refused creation wrote to the ledger") @@ -110,40 +109,42 @@ func testCreation(t *testing.T, factory Factory) { func testReplayAndBase(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) - first := h.mustCommit("r1", schema.Identity().DeriveInputCommandID("r1", "in-2"), 0, run.NextStep(input("in-2"))) + h.startTurn("r1", input("in-1")) + h.submitInputs(input("in-2")) + first := h.mustCommit("r1", schema.Identity().DeriveInputCommandID("r1", "in-2"), 0, turn.NextStep(input("in-2"))) if first.Status != store.CommitAccepted { t.Fatal("first accept not accepted") } - again := h.mustCommit("r1", schema.Identity().DeriveInputCommandID("r1", "in-2"), 0, run.NextStep(input("in-2"))) + again := h.mustCommit("r1", schema.Identity().DeriveInputCommandID("r1", "in-2"), 0, turn.NextStep(input("in-2"))) if again.Status != store.CommitAlreadyApplied || len(again.Events) != len(first.Events) || again.Head != first.Head { t.Fatalf("replay = %+v", again) } if h.head() != first.Head { t.Fatal("replay appended commits") } - // Prepare is a hard CAS on the Run's own position. + // Prepare is a hard CAS on the Turn's own position. snap := h.load("r1") stale := snap.Position - 1 cmd, id := h.preparedCommand(store.Snapshot{State: snap.State, Position: stale}, 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, turn.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", schema.Identity().DeriveInputCommandID("r1", "in-3"), 0, run.NextStep(input("in-3"))) + h.submitInputs(input("in-3")) + h.mustCommit("r1", schema.Identity().DeriveInputCommandID("r1", "in-3"), 0, turn.NextStep(input("in-3"))) // Terminal replay: an accepted command replays after termination. - h.mustCommit("r1", "cancel", prepared.Snapshot.Position, run.CancelRun{}) - replay := h.mustCommit("r1", schema.Identity().DeriveInputCommandID("r1", "in-3"), 0, run.NextStep(input("in-3"))) + h.mustCommit("r1", "cancel", prepared.Snapshot.Position, turn.CancelTurn{}) + replay := h.mustCommit("r1", schema.Identity().DeriveInputCommandID("r1", "in-3"), 0, turn.NextStep(input("in-3"))) if replay.Status != store.CommitAlreadyApplied || !replay.Snapshot.State.Status.Terminal() { t.Fatalf("terminal replay = %+v", replay) } - if _, err := h.commit("r1", schema.Identity().DeriveInputCommandID("r1", "in-4"), 0, run.NextStep(input("in-4"))); !errors.Is(err, run.ErrRunTerminal) { + if _, err := h.commit("r1", schema.Identity().DeriveInputCommandID("r1", "in-4"), 0, turn.NextStep(input("in-4"))); !errors.Is(err, turn.ErrTurnTerminal) { t.Fatalf("new command after terminal = %v", err) } // Derived-identity families must use their derived CommandID. - if _, err := h.commit("r1", "random", 0, run.NextStep(input("in-5"))); !errors.Is(err, run.ErrCommandConflict) && !errors.Is(err, run.ErrRunTerminal) { + if _, err := h.commit("r1", "random", 0, turn.NextStep(input("in-5"))); !errors.Is(err, turn.ErrCommandConflict) && !errors.Is(err, turn.ErrTurnTerminal) { t.Fatalf("non-derived id = %v", err) } } @@ -152,10 +153,11 @@ func testReplayAndBase(t *testing.T, factory Factory) { func testInputQueue(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) + h.startTurn("r1", input("in-1")) // Prepared: the input queues and Next asks to withdraw. step := h.prepare("r1", false) - h.mustCommit("r1", schema.Identity().DeriveInputCommandID("r1", "in-2"), 0, run.NextStep(input("in-2"))) + h.submitInputs(input("in-2")) + h.mustCommit("r1", schema.Identity().DeriveInputCommandID("r1", "in-2"), 0, turn.NextStep(input("in-2"))) snap := h.load("r1") eff, err := plan.Next(snap.State) if err != nil { @@ -164,9 +166,9 @@ func testInputQueue(t *testing.T, factory Factory) { if w, ok := eff.(plan.WithdrawPrepared); !ok || w.StepID != step { t.Fatalf("action while Prepared with input = %#v", eff) } - h.mustCommit("r1", schema.Identity().DeriveWithdrawCommandID("r1", step), snap.Position, run.WithdrawPreparedStep{StepID: step}) + h.mustCommit("r1", schema.Identity().DeriveWithdrawCommandID("r1", step), snap.Position, turn.WithdrawPreparedStep{StepID: step}) snap = h.load("r1") - if _, open := snap.State.Current.(run.Open); !open || len(snap.State.PendingInputs) != 1 || snap.State.ModelSteps != 0 { + if _, open := snap.State.Current.(turn.Open); !open || len(snap.State.PendingInputs) != 1 || snap.State.ModelSteps != 0 { t.Fatalf("after withdraw = %+v", snap.State) } cmd, id := h.preparedCommand(snap, false) @@ -176,20 +178,22 @@ func testInputQueue(t *testing.T, factory Factory) { h.mustCommit("r1", id, snap.Position, cmd) // Executing: the input queues; a result without calls reopens instead of ending. modelEff := h.startModel("r1", cmd.StepID) - h.mustCommit("r1", schema.Identity().DeriveInputCommandID("r1", "in-3"), 0, run.NextStep(input("in-3"))) - res := h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(modelEff), 0, run.SubmitModelResult{StepID: cmd.StepID, Effect: modelEff, Result: textResult("a")}) - if res.Snapshot.State.Status != run.RunActive { - t.Fatal("run ended with a pending input") + h.submitInputs(input("in-3")) + h.mustCommit("r1", schema.Identity().DeriveInputCommandID("r1", "in-3"), 0, turn.NextStep(input("in-3"))) + res := h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(modelEff), 0, turn.SubmitModelResult{StepID: cmd.StepID, Effect: modelEff, Result: textResult("a")}) + if res.Snapshot.State.Status != turn.TurnActive { + t.Fatal("Turn ended with a pending input") } - if _, open := res.Snapshot.State.Current.(run.Open); !open || len(res.Snapshot.State.PendingInputs) != 1 { + if _, open := res.Snapshot.State.Current.(turn.Open); !open || len(res.Snapshot.State.PendingInputs) != 1 { t.Fatalf("after result with pending input = %+v", res.Snapshot.State) } // ToolStep: the input queues as well. h2 := newHarness(t, factory(t)) - h2.startRun("t1", "r2", input("in-1")) + h2.startTurn("r2", input("in-1")) h2.openToolStep("r2", 1) - res = h2.mustCommit("r2", schema.Identity().DeriveInputCommandID("r2", "in-9"), 0, run.NextStep(input("in-9"))) - if _, ok := res.Snapshot.State.Current.(run.ToolStep); !ok || len(res.Snapshot.State.PendingInputs) != 1 { + h2.submitInputs(input("in-9")) + res = h2.mustCommit("r2", schema.Identity().DeriveInputCommandID("r2", "in-9"), 0, turn.NextStep(input("in-9"))) + if _, ok := res.Snapshot.State.Current.(turn.ToolStep); !ok || len(res.Snapshot.State.PendingInputs) != 1 { t.Fatalf("accept on tool step = %+v", res.Snapshot.State) } } @@ -198,47 +202,47 @@ func testInputQueue(t *testing.T, factory Factory) { func testStartAndEffect(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) + h.startTurn("r1", input("in-1")) step, eff := h.executingModel("r1", false) // Same-effect replay is AlreadyApplied; a start naming another effect of // the step finds the step already Executing. - replay := h.mustCommit("r1", schema.Identity().DeriveStartCommandID(eff), 0, run.StartModelExecution{StepID: step, Effect: eff}) + replay := h.mustCommit("r1", schema.Identity().DeriveStartCommandID(eff), 0, turn.StartModelExecution{StepID: step, Effect: eff}) if replay.Status != store.CommitAlreadyApplied { t.Fatalf("start replay = %+v", replay) } other := schema.Identity().DeriveEffectID("r1", step, "", 1) - if _, err := h.commit("r1", schema.Identity().DeriveStartCommandID(other), 0, run.StartModelExecution{StepID: step, Effect: other}); !errors.Is(err, run.ErrStaleRuntime) { + if _, err := h.commit("r1", schema.Identity().DeriveStartCommandID(other), 0, turn.StartModelExecution{StepID: step, Effect: other}); !errors.Is(err, turn.ErrStaleRuntime) { t.Fatalf("second effect start = %v", err) } // Starts, settlements and recoveries without an effect identity are // conflicts. - if _, err := h.commit("r1", schema.Identity().DeriveStartCommandID(""), 0, run.StartModelExecution{StepID: step}); !errors.Is(err, run.ErrCommandConflict) { + if _, err := h.commit("r1", schema.Identity().DeriveStartCommandID(""), 0, turn.StartModelExecution{StepID: step}); !errors.Is(err, turn.ErrCommandConflict) { t.Fatalf("effectless start = %v", err) } - if _, err := h.commit("r1", schema.Identity().DeriveSettlementCommandID(""), 0, run.SubmitModelResult{StepID: step, Result: textResult("done")}); !errors.Is(err, run.ErrCommandConflict) { + if _, err := h.commit("r1", schema.Identity().DeriveSettlementCommandID(""), 0, turn.SubmitModelResult{StepID: step, Result: textResult("done")}); !errors.Is(err, turn.ErrCommandConflict) { t.Fatalf("effectless settlement = %v", err) } // A settlement of another effect of the step, under that effect's // settlement identity, finds the step executing a different effect; a // settlement under any CommandID but its effect's is a conflict. - if _, err := h.commit("r1", schema.Identity().DeriveSettlementCommandID(other), 0, run.SubmitModelResult{StepID: step, Effect: other, Result: textResult("done")}); !errors.Is(err, run.ErrStaleRuntime) { + if _, err := h.commit("r1", schema.Identity().DeriveSettlementCommandID(other), 0, turn.SubmitModelResult{StepID: step, Effect: other, Result: textResult("done")}); !errors.Is(err, turn.ErrStaleRuntime) { t.Fatalf("settlement of another effect = %v", err) } - if _, err := h.commit("r1", "random", 0, run.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("done")}); !errors.Is(err, run.ErrCommandConflict) { + if _, err := h.commit("r1", "random", 0, turn.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("done")}); !errors.Is(err, turn.ErrCommandConflict) { t.Fatalf("settlement under a foreign id = %v", err) } // Settlement under the effect; its replay is AlreadyApplied. settleID := schema.Identity().DeriveSettlementCommandID(eff) - res := h.mustCommit("r1", settleID, 0, run.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("done")}) + res := h.mustCommit("r1", settleID, 0, turn.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("done")}) if !res.Snapshot.State.Status.Terminal() { - t.Fatal("settlement did not end the run") + t.Fatal("settlement did not end the Turn") } - again := h.mustCommit("r1", settleID, 0, run.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("done")}) + again := h.mustCommit("r1", settleID, 0, turn.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("done")}) if again.Status != store.CommitAlreadyApplied { t.Fatalf("settlement replay = %+v", again) } // After settlement the start still replays; a new command is terminal. - replay = h.mustCommit("r1", schema.Identity().DeriveStartCommandID(eff), 0, run.StartModelExecution{StepID: step, Effect: eff}) + replay = h.mustCommit("r1", schema.Identity().DeriveStartCommandID(eff), 0, turn.StartModelExecution{StepID: step, Effect: eff}) if replay.Status != store.CommitAlreadyApplied { t.Fatalf("start replay after settlement = %+v", replay) } @@ -248,14 +252,14 @@ func testStartAndEffect(t *testing.T, factory Factory) { func testDeclineToolCall(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) + h.startTurn("r1", input("in-1")) step, calls := h.openToolStep("r1", 2) - failure := run.ToolFailure{Class: run.FailureToolLookup, Message: "no such tool"} - decline := run.DeclineToolCall{StepID: step, CallID: calls[0], Failure: failure} + failure := turn.ToolFailure{Class: turn.FailureToolLookup, Message: "no such tool"} + decline := turn.DeclineToolCall{StepID: step, CallID: calls[0], Failure: failure} id := schema.Identity().DeriveDeclineCommandID("r1", step, calls[0]) // The decline has no effect; its identity is the call's coordinates and // any other CommandID is a conflict. - if _, err := h.commit("r1", "random", 0, decline); !errors.Is(err, run.ErrCommandConflict) { + if _, err := h.commit("r1", "random", 0, decline); !errors.Is(err, turn.ErrCommandConflict) { t.Fatalf("decline under a foreign id = %v", err) } res := h.mustCommit("r1", id, 0, decline) @@ -265,8 +269,8 @@ func testDeclineToolCall(t *testing.T, factory Factory) { if types := eventTypes(res.Events); len(types) != 1 || types[0] != sessionstore.Prefix+"tool_call_failed" { t.Fatalf("decline events = %v, want tool_call_failed alone", types) } - ts := mustType[run.ToolStep](h.t, res.Snapshot.State.Current) - if ts.Calls[0].Status != run.ToolFailed || ts.Calls[0].Effect != "" { + ts := mustType[turn.ToolStep](h.t, res.Snapshot.State.Current) + if ts.Calls[0].Status != turn.ToolFailed || ts.Calls[0].Effect != "" { t.Fatalf("declined call = %+v, want Failed with no effect", ts.Calls[0]) } // The CommandID names the decline of this call: a replay, with the same @@ -282,18 +286,18 @@ func testDeclineToolCall(t *testing.T, factory Factory) { // A Pending call has no effect to settle: a Known failure naming the // effect it would start under finds the call not Executing. eff := toolEffect("r1", step, calls[1]) - knownFailure := run.SubmitToolFailure{StepID: step, CallID: calls[1], Effect: eff, Failure: failure, Outcome: run.ToolOutcomeKnown} - if _, err := h.commit("r1", schema.Identity().DeriveSettlementCommandID(eff), 0, knownFailure); !errors.Is(err, run.ErrStaleRuntime) { + knownFailure := turn.SubmitToolFailure{StepID: step, CallID: calls[1], Effect: eff, Failure: failure, Outcome: turn.ToolOutcomeKnown} + if _, err := h.commit("r1", schema.Identity().DeriveSettlementCommandID(eff), 0, knownFailure); !errors.Is(err, turn.ErrStaleRuntime) { t.Fatalf("known failure of a Pending call = %v", err) } // Once started, the call settles under its effect and cannot be declined. h.startTool("r1", step, calls[1]) if _, err := h.commit("r1", schema.Identity().DeriveDeclineCommandID("r1", step, calls[1]), 0, - run.DeclineToolCall{StepID: step, CallID: calls[1], Failure: failure}); !errors.Is(err, run.ErrStaleRuntime) { + turn.DeclineToolCall{StepID: step, CallID: calls[1], Failure: failure}); !errors.Is(err, turn.ErrStaleRuntime) { t.Fatalf("decline of an Executing call = %v", err) } res = h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(eff), 0, knownFailure) - if _, open := res.Snapshot.State.Current.(run.Open); !open { + if _, open := res.Snapshot.State.Current.(turn.Open); !open { t.Fatalf("after both calls failed current = %T, want Open", res.Snapshot.State.Current) } } @@ -302,12 +306,12 @@ func testDeclineToolCall(t *testing.T, factory Factory) { func testGroupComposition(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) + h.startTurn("r1", input("in-1")) step, eff := h.executingModel("r1", true) result, bindings := h.toolCallResult(step, 1) before := h.head() res := h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(eff), 0, - run.SubmitModelResult{StepID: step, Effect: eff, Result: result, Calls: bindings}) + turn.SubmitModelResult{StepID: step, Effect: eff, Result: result, Calls: bindings}) if h.head().Next != before.Next+1 { t.Fatal("one command did not produce exactly one commit") } @@ -323,35 +327,35 @@ func testGroupComposition(t *testing.T, factory Factory) { // result by the fact's ResultDigest, and the body is readable under it. var resultDigest jsonstable.Digest for _, f := range h.record("r1").Facts { - if c, ok := f.(run.ModelStepCompleted); ok { + if c, ok := f.(turn.ModelStepCompleted); ok { resultDigest = c.ResultDigest } } - ts := mustType[run.ToolStep](h.t, res.Snapshot.State.Current) + ts := mustType[turn.ToolStep](h.t, res.Snapshot.State.Current) call := ts.Calls[0].CallID entries := h.contextEntries() last := entries[len(entries)-1] - if last.Kind != chatlog.EntryAssistant || last.Assistant.ResultDigest != resultDigest || last.Assistant.TurnID != "t1" || + if last.Kind != chatlog.EntryAssistant || last.Assistant.ResultDigest != resultDigest || last.Assistant.TurnID != "r1" || len(last.Assistant.CallIDs) != 1 || last.Assistant.CallIDs[0] != chatlog.CallID(call) { t.Fatalf("assistant entry = %+v, want ResultDigest %s and call %s", last.Assistant, resultDigest, call) } if body, err := sessionstore.NewContent(h.frozen).ModelResult(h.ctx, resultDigest); err != nil || len(body.ToolCalls) != 1 { t.Fatalf("frozen result = %+v %v", body, err) } - // Attach follows the facts; twilight/run/ events are refused. + // Attach follows the facts; twilight/turn/ events are refused. toolEff := h.startTool("r1", ts.RefValue.ID, call) - output := run.MustParseCanonicalJSON(`{"ok":true}`) + output := turn.MustParseCanonicalJSON(`{"ok":true}`) if _, err := h.commit("r1", schema.Identity().DeriveSettlementCommandID(toolEff), 0, - run.SubmitToolResult{StepID: ts.RefValue.ID, CallID: call, Effect: toolEff, Result: run.ToolExecutionResult{Output: output}}, - moduleEvent{Type: sessionstore.Prefix + "input_accepted", Value: sessionstore.Event{RunID: "r1", Fact: run.InputAccepted{Input: input("x")}}}); err == nil { - t.Fatal("Attach with a twilight/run/ event accepted") + turn.SubmitToolResult{StepID: ts.RefValue.ID, CallID: call, Effect: toolEff, Result: turn.ToolExecutionResult{Output: output}}, + moduleEvent{Type: sessionstore.Prefix + "input_accepted", Value: sessionstore.Event{TurnID: "r1", Fact: turn.InputAccepted{Input: input("x")}}}); err == nil { + t.Fatal("Attach with a twilight/turn/ event accepted") } h.submitInputs(input("in-attach")) res = h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(toolEff), 0, - run.SubmitToolResult{StepID: ts.RefValue.ID, CallID: call, Effect: toolEff, Result: run.ToolExecutionResult{Output: output}}, - moduleEvent{Type: chatlog.TypeInputDelivered, Value: chatlog.InputDeliveredPayload{InputID: "in-attach", TurnID: "t1", RunID: "r1"}}) + turn.SubmitToolResult{StepID: ts.RefValue.ID, CallID: call, Effect: toolEff, Result: turn.ToolExecutionResult{Output: output}}, + moduleEvent{Type: chatlog.TypeInputRejected, Value: chatlog.InputRejectedPayload{InputID: "in-attach"}}) types = eventTypes(res.Events) - if len(types) != 2 || types[0] != sessionstore.Prefix+"tool_call_completed" || types[1] != chatlog.TypeInputDelivered { + if len(types) != 2 || types[0] != sessionstore.Prefix+"tool_call_completed" || types[1] != chatlog.TypeInputRejected { t.Fatalf("group events = %v", types) } outputDigest, _ := schema.Canonical().DigestToolOutput(output) @@ -382,13 +386,13 @@ func (h *harness) contextEntries() []chatlog.Entry { } // turnSurface reads the turn surface projection through the owner's Writer. -func (h *harness) turnSurface() turn.TurnSurface { +func (h *harness) turnSurface() sessionstore.TurnSurface { h.t.Helper() - state, _, err := h.writer().Projections().Load(h.ctx, sid, turn.SurfaceProjectionID, turn.SurfaceProjection.Version) + state, _, err := h.writer().Projections().Load(h.ctx, sid, sessionstore.SurfaceProjectionID, sessionstore.SurfaceProjection.Version) if err != nil { h.fatal(err) } - return mustType[turn.TurnSurface](h.t, state) + return mustType[sessionstore.TurnSurface](h.t, state) } func asStrings(types []ledger.EventType) []string { @@ -403,7 +407,7 @@ func asStrings(types []ledger.EventType) []string { func testAdmission(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) + h.startTurn("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 { @@ -417,20 +421,21 @@ func testAdmission(t *testing.T, factory Factory) { s.Digest, _ = chatlog.DigestSummary(&s) return moduleEvent{Type: chatlog.TypeSummary, Value: chatlog.SummaryPayload{Summary: s}} } + h.submitInputs(input("in-2")) before := h.head() // Unregistered binding: the whole group is refused and nothing is written. - if _, err := h.commit("r1", schema.Identity().DeriveInputCommandID("r1", "in-2"), 0, run.NextStep(input("in-2")), attach("missing")); err == nil { + if _, err := h.commit("r1", schema.Identity().DeriveInputCommandID("r1", "in-2"), 0, turn.NextStep(input("in-2")), attach("missing")); err == nil { t.Fatal("unregistered binding admitted") } if h.head() != before { t.Fatal("refused commit wrote to the ledger") } if len(h.load("r1").State.PendingInputs) != 1 { - t.Fatal("refused commit changed the Run") + t.Fatal("refused commit changed the Turn") } // Registered binding: the claim is Active once the commit is applied. cmdID := schema.Identity().DeriveInputCommandID("r1", "in-2") - h.mustCommit("r1", cmdID, 0, run.NextStep(input("in-2")), attach("b1")) + h.mustCommit("r1", cmdID, 0, turn.NextStep(input("in-2")), attach("b1")) commitID := ledger.CommitID(cmdID) header, err := h.store.Header(h.ctx, sid) if err != nil { @@ -457,23 +462,23 @@ func mustSet(t *testing.T, h *harness, ids ...artifact.BindingID) artifact.Bindi func testSettlementSnapshot(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) + h.startTurn("r1", input("in-1")) step, eff := h.executingModel("r1", false) - res := h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(eff), 0, run.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("done")}) - if res.Snapshot.State.Status != run.RunCompleted || res.Snapshot.State.Result == nil || res.Snapshot.State.Result.Status != run.RunCompleted { + res := h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(eff), 0, turn.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("done")}) + if res.Snapshot.State.Status != turn.TurnCompleted || res.Snapshot.State.Result == nil || res.Snapshot.State.Result.Status != turn.TurnCompleted { t.Fatalf("settlement snapshot = %+v", res.Snapshot.State) } rec := h.record("r1") if !wire.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 Turn settles from the Run's own run_ended: no turn event is written. + // The Turn settles from the Turn's own turn_ended: no turn event is written. types := eventTypes(res.Events) - if types[len(types)-1] != sessionstore.Prefix+"run_ended" { - t.Fatalf("terminal group events = %v, want run_ended last", types) + if types[len(types)-1] != sessionstore.Prefix+"turn_ended" { + t.Fatalf("terminal group events = %v, want turn_ended last", types) } - if v := h.turnSurface().Turns["t1"]; v.Status != turn.TurnCompleted || v.End == nil { - t.Fatalf("turn after run_ended = %+v, want completed", v) + if v := h.turnSurface().Turns["r1"]; v.Status != turn.TurnCompleted || v.End == nil { + t.Fatalf("turn after turn_ended = %+v, want completed", v) } } @@ -481,11 +486,11 @@ func testSettlementSnapshot(t *testing.T, factory Factory) { func testPrepareCASIgnoresOtherModules(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) - h.startRun("t2", "r2", input("in-b")) + h.startTurn("r1", input("in-1")) + h.startTurn("r2", input("in-b")) snap := h.load("r1") cmd, id := h.preparedCommand(snap, false) - // Other modules and another Run write after the prompt builder loaded. + // Other modules and another Turn write after the prompt builder loaded. head := h.head() h.submitInputs(input("late")) h.prepare("r2", false) @@ -507,24 +512,24 @@ func testPrepareCASIgnoresOtherModules(t *testing.T, factory Factory) { // without a second Decide. func testSettlementReplay(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) + h.startTurn("r1", input("in-1")) step, eff := h.executingModel("r1", false) id := schema.Identity().DeriveSettlementCommandID(eff) - first := h.mustCommit("r1", id, 0, run.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("one")}) + first := h.mustCommit("r1", id, 0, turn.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("one")}) if !first.Snapshot.State.Status.Terminal() { t.Fatalf("settlement = %+v", first.Snapshot.State.Status) } - if again, err := h.commit("r1", id, 0, run.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("one")}); err != nil || again.Status != store.CommitAlreadyApplied { + if again, err := h.commit("r1", id, 0, turn.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("one")}); err != nil || again.Status != store.CommitAlreadyApplied { t.Fatalf("same result replay = %v %v", again.Status, err) } - if again, err := h.commit("r1", id, 0, run.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("two")}); err != nil || again.Status != store.CommitAlreadyApplied { + if again, err := h.commit("r1", id, 0, turn.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("two")}); err != nil || again.Status != store.CommitAlreadyApplied { t.Fatalf("different result under the same effect = %v %v, want already applied", again.Status, err) } - if again, err := h.commit("r1", id, 0, run.SubmitModelFailure{StepID: step, Effect: eff, Failure: run.StepFailure{Class: run.FailureProvider, Message: "x"}}); err != nil || again.Status != store.CommitAlreadyApplied { + if again, err := h.commit("r1", id, 0, turn.SubmitModelFailure{StepID: step, Effect: eff, Failure: turn.StepFailure{Class: turn.FailureProvider, Message: "x"}}); err != nil || again.Status != store.CommitAlreadyApplied { t.Fatalf("failure under a settled effect = %v %v, want already applied", again.Status, err) } if after := h.load("r1"); after.Position != first.Snapshot.Position || !after.State.Status.Terminal() { - t.Fatalf("replays moved the run: %+v", after) + t.Fatalf("replays moved the Turn: %+v", after) } } @@ -532,7 +537,7 @@ func testSettlementReplay(t *testing.T, factory Factory) { func testProjection(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) + h.startTurn("r1", input("in-1")) cached := func() (ledger.Head, bool) { _, through, ok, err := h.cache.Load(h.ctx, sid, sessionstore.MachineProjectionID, sessionstore.MachineProjection.Version) if err != nil { @@ -542,17 +547,17 @@ func testProjection(t *testing.T, factory Factory) { } step, eff := h.executingModel("r1", true) if _, ok := cached(); ok { - t.Fatal("projection cached while the Run is mid-step") + t.Fatal("projection cached while the Turn is mid-step") } result, bindings := h.toolCallResult(step, 1) opened := h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(eff), 0, - run.SubmitModelResult{StepID: step, Effect: eff, Result: result, Calls: bindings}) - toolStep := mustType[run.ToolStep](h.t, opened.Snapshot.State.Current).RefValue.ID + turn.SubmitModelResult{StepID: step, Effect: eff, Result: result, Calls: bindings}) + toolStep := mustType[turn.ToolStep](h.t, opened.Snapshot.State.Current).RefValue.ID callID := bindings[0].CallID toolEff := h.startTool("r1", toolStep, callID) res := h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(toolEff), 0, - run.SubmitToolResult{StepID: toolStep, CallID: callID, Effect: toolEff, Result: run.ToolExecutionResult{Output: run.MustParseCanonicalJSON(`1`)}}) - if _, open := res.Snapshot.State.Current.(run.Open); !open { + turn.SubmitToolResult{StepID: toolStep, CallID: callID, Effect: toolEff, Result: turn.ToolExecutionResult{Output: turn.MustParseCanonicalJSON(`1`)}}) + if _, open := res.Snapshot.State.Current.(turn.Open); !open { t.Fatalf("after tool settlement current = %T", res.Snapshot.State.Current) } if through, ok := cached(); !ok || through != res.Head { @@ -574,24 +579,24 @@ func testProjection(t *testing.T, factory Factory) { if cached := mustType[sessionstore.Machine](h.t, fromCache).Active["r1"]; !wire.StatesEquivalent(&cached, &active) { t.Fatal("observer's cache+tail disagrees with the writer's projection") } - // Terminal Run leaves the projection entirely; Load and Record still - // answer from the Run's stream, and a second creation of its RunID is + // Terminal Turn leaves the projection entirely; Load and Record still + // answer from the Turn's stream, and a second creation of its TurnID is // refused from the ledger's stream index, not from projection state. - h.mustCommit("r1", "cancel", 0, run.CancelRun{}) + h.mustCommit("r1", "cancel", 0, turn.CancelTurn{}) m = h.machine() if _, still := m.Active["r1"]; still { - t.Fatal("terminal run still in the projection") + t.Fatal("terminal Turn 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") + if h.load("r1").State.Status != turn.TurnStopped || h.record("r1").Snapshot.State.Status != turn.TurnStopped { + t.Fatal("terminal Turn not readable") } - if again, err := run.BuildNewRun("r1", ""); err != nil { + if again, err := turn.BuildNewTurn("r1", "", ref.Ref{ID: "test", Digest: "sha256:test"}); err != nil { t.Fatal(err) - } else if _, err := unit.Commit(h.ctx, h.writer(), 1, unit.Work{CommitID: "recreate/r1", Parts: []unit.Part{sessionstore.CreateRun(again, nil)}}); !errors.Is(err, sessionstore.ErrRunExists) { - t.Fatalf("recreating an ended run = %v, want ErrRunExists", err) + } else if _, err := unit.Commit(h.ctx, h.writer(), 1, unit.Work{CommitID: "recreate/r1", Parts: []unit.Part{sessionstore.CreateTurn(again, nil)}}); !errors.Is(err, sessionstore.ErrTurnExists) { + t.Fatalf("recreating an ended Turn = %v, want ErrTurnExists", err) } // An illegal fact sequence does not fold. - if _, err := store.FoldRun([]run.Fact{run.InputAccepted{Input: input("x")}}); err == nil { + if _, err := store.FoldTurn([]turn.Fact{turn.InputAccepted{Input: input("x")}}); err == nil { t.Fatal("fold without created succeeded") } } @@ -600,22 +605,22 @@ func testProjection(t *testing.T, factory Factory) { func testIsolation(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) - h.startRun("t2", "r2", input("in-b")) + h.startTurn("r1", input("in-1")) + h.startTurn("r2", input("in-b")) p1 := h.load("r1").Position h.prepare("r2", false) h.submitInputs(input("noise")) h.mustApply(writer.SemanticGroup{CommitID: "turn-noise", Batches: []writer.TypedBatch{{ - Domain: turn.Stream("t9"), - Events: []writer.TypedEvent{{Type: turn.TypeStarted, RecordedAtUnixMilli: 1, - Value: turn.StartedPayload{TurnID: "t9", RunID: "r9", Preset: preset.PresetRef{ID: "b", Digest: "sha256:b"}}}}, + Domain: sessionstore.Stream("t9"), + Events: []writer.TypedEvent{{Type: sessionstore.TypeStarted, RecordedAtUnixMilli: 1, + Value: sessionstore.Event{TurnID: "t9", Fact: turn.TurnStarted{TurnID: "t9", Preset: ref.Ref{ID: "b", Digest: "sha256:b"}}}}}, }}}) 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 c, ok := f.(turn.TurnStarted); ok && c.TurnID != "r1" { + t.Fatal("record of r1 contains another Turn") } } if len(h.record("r1").Facts) != 2 || len(h.record("r2").Facts) != 3 { @@ -627,15 +632,15 @@ func testIsolation(t *testing.T, factory Factory) { func testTakeover(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) - h.startRun("t2", "r2", input("in-b")) + h.startTurn("r1", input("in-1")) + h.startTurn("r2", input("in-b")) // r1: executing model; r2: one executing and one pending tool call. h.executingModel("r1", false) before := h.load("r1") // An input delivered while the model runs waits in PendingInputs; the // recovery-time plan must include it. h.submitInputs(input("in-late")) - h.mustCommit("r1", schema.Identity().DeriveInputCommandID("r1", "in-late"), 0, run.NextStep(input("in-late"))) + h.mustCommit("r1", schema.Identity().DeriveInputCommandID("r1", "in-late"), 0, turn.NextStep(input("in-late"))) toolStep, ids := h.openToolStep("r2", 2) h.startTool("r2", toolStep, ids[0]) @@ -644,34 +649,34 @@ func testTakeover(t *testing.T, factory Factory) { if err != nil || n != 2 { t.Fatalf("RecoverInterrupted = %d %v, want 2", n, err) } - // The unreachable model attempt is withdrawn: the Run is Open again with + // The unreachable model attempt is withdrawn: the Turn is Open again with // the late input still pending and the step no longer counted, so the // next Prepare is a fresh decision, not a replay. r1 := h.load("r1") - if _, open := r1.State.Current.(run.Open); !open || r1.State.Status != run.RunActive || r1.State.ModelSteps != 0 || + if _, open := r1.State.Current.(turn.Open); !open || r1.State.Status != turn.TurnActive || r1.State.ModelSteps != 0 || len(r1.State.PendingInputs) != 1 || r1.State.PendingInputs[0].ID != "in-late" { - t.Fatalf("model run after takeover = %+v, want Open with the late input pending", r1.State) + t.Fatalf("model Turn after takeover = %+v, want Open with the late input pending", r1.State) } // The next Prepare is a new decision: a new StepID (its identity derives - // from the Run position, which the recovery moved) that consumes the late + // from the Turn position, which the recovery moved) that consumes the late // input. What the request contains is the PromptBuilder's business; the harness // plans a fixed request, so only the identities and the input flow are // asserted here. - aborted := mustType[run.ModelStep](h.t, before.State.Current).RefValue.ID + aborted := mustType[turn.ModelStep](h.t, before.State.Current).RefValue.ID replanned := h.prepare("r1", false) after := h.load("r1") - if replanned == aborted || mustType[run.ModelStep](h.t, after.State.Current).RefValue.ID != replanned { + if replanned == aborted || mustType[turn.ModelStep](h.t, after.State.Current).RefValue.ID != replanned { t.Fatalf("replan reused the aborted step %s", aborted) } if len(after.State.PendingInputs) != 0 || after.State.ModelSteps != 1 { t.Fatalf("replan left pending=%d steps=%d, want the late input consumed and one counted step", len(after.State.PendingInputs), after.State.ModelSteps) } r2 := h.load("r2") - ts := mustType[run.ToolStep](h.t, r2.State.Current) - if r2.State.Status != run.RunActive || ts.Calls[0].Status != run.ToolFailed || ts.Calls[0].Failure == nil || ts.Calls[0].Failure.Outcome != run.ToolOutcomeUnknown { + ts := mustType[turn.ToolStep](h.t, r2.State.Current) + if r2.State.Status != turn.TurnActive || ts.Calls[0].Status != turn.ToolFailed || ts.Calls[0].Failure == nil || ts.Calls[0].Failure.Outcome != turn.ToolOutcomeUnknown { t.Fatalf("executing call after takeover = %+v", ts.Calls[0]) } - if ts.Calls[1].Status != run.ToolPending { + if ts.Calls[1].Status != turn.ToolPending { t.Fatalf("pending sibling = %+v, want untouched", ts.Calls[1]) } // The Unknown is the call's terminal fact; the conversation projects it @@ -710,7 +715,7 @@ func testTakeover(t *testing.T, factory Factory) { // livePort is an execution store that still holds an attempt for the effects // it lists and records every key the takeover asked about. type livePort struct { - live map[run.EffectID]bool + live map[turn.EffectID]bool asked []effect.AssignmentKey } @@ -728,7 +733,7 @@ func (p *livePort) Abort(ctx context.Context, key effect.AssignmentKey) (effect. } return effect.Attachment{State: effect.AttachmentAborted, Execution: effect.ExecutionAborted}, nil } -func (p *livePort) Validate(context.Context, effect.Assignment) (*run.ToolFailure, error) { +func (p *livePort) Validate(context.Context, effect.Assignment) (*turn.ToolFailure, error) { return nil, nil } func (p *livePort) Dispatch(context.Context, effect.Assignment) error { return nil } @@ -746,15 +751,15 @@ func (p *livePort) Cancel(context.Context, effect.AssignmentKey) error { return // target the executor no longer holds is disposed as before. func testReattach(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) - h.startRun("t2", "r2", input("in-b")) + h.startTurn("r1", input("in-1")) + h.startTurn("r2", input("in-b")) modelStep, modelEff := h.executingModel("r1", false) // Two calls so the step stays open after one is disposed. toolStep, ids := h.openToolStep("r2", 2) toolEff := h.startTool("r2", toolStep, ids[0]) h.takeover() - port := &livePort{live: map[run.EffectID]bool{modelEff: true}} + port := &livePort{live: map[turn.EffectID]bool{modelEff: true}} n, err := h.rt.RecoverInterrupted(h.ctx, h.writer(), &reconcile.Reconciler{Executions: port}) if err != nil || n != 1 { t.Fatalf("RecoverInterrupted = %d %v, want exactly the tool disposed", n, err) @@ -763,40 +768,40 @@ func testReattach(t *testing.T, factory Factory) { t.Fatalf("takeover asked about %d targets, want 2", len(port.asked)) } for _, key := range port.asked { - if key.Session != run.Scope(sid) { + if key.Session != turn.Scope(sid) { t.Fatalf("takeover asked outside the session: %+v", key) } switch key.Effect { case modelEff: - if key.RunID != "r1" { + if key.OwnerID != "r1" { t.Fatalf("model key = %+v", key) } case toolEff: - if key.RunID != "r2" { + if key.OwnerID != "r2" { t.Fatalf("tool key = %+v", key) } default: t.Fatalf("takeover asked about an unknown effect %q", key.Effect) } } - ms := mustType[run.ModelStep](h.t, h.load("r1").State.Current) - if ms.Status != run.ModelExecuting || ms.Effect != modelEff { + ms := mustType[turn.ModelStep](h.t, h.load("r1").State.Current) + if ms.Status != turn.ModelExecuting || ms.Effect != modelEff { t.Fatalf("reattached model step = %+v, want Executing under the original effect", ms) } - ts := mustType[run.ToolStep](h.t, h.load("r2").State.Current) - if ts.Calls[0].Status != run.ToolFailed || ts.Calls[0].Failure == nil || ts.Calls[0].Failure.Outcome != run.ToolOutcomeUnknown { + ts := mustType[turn.ToolStep](h.t, h.load("r2").State.Current) + if ts.Calls[0].Status != turn.ToolFailed || ts.Calls[0].Failure == nil || ts.Calls[0].Failure.Outcome != turn.ToolOutcomeUnknown { t.Fatalf("unreachable tool call = %+v, want Unknown", ts.Calls[0]) } - if ts.Calls[1].Status != run.ToolPending { + if ts.Calls[1].Status != turn.ToolPending { t.Fatalf("pending sibling = %+v, want untouched", ts.Calls[1]) } // The Outcome of the effect the executor kept settles under that effect. - res := h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(modelEff), 0, run.SubmitModelResult{StepID: modelStep, Effect: modelEff, Result: textResult("done")}) + res := h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(modelEff), 0, turn.SubmitModelResult{StepID: modelStep, Effect: modelEff, Result: textResult("done")}) if res.Status != store.CommitAccepted || !res.Snapshot.State.Status.Terminal() { t.Fatalf("settlement after reattach = %v %v", res.Status, res.Snapshot.State.Status) } for _, f := range h.record("r1").Facts { - if _, recovered := f.(run.ModelStepRecovered); recovered { + if _, recovered := f.(turn.ModelStepRecovered); recovered { t.Fatal("an effect the executor still executes must not be recovered") } } @@ -806,11 +811,11 @@ func testReattach(t *testing.T, factory Factory) { func testOwnershipLost(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) + h.startTurn("r1", input("in-1")) step, eff := h.executingModel("r1", false) old, oldWriter := h.takeover() head := h.head() - _, err := h.commitWith(old, oldWriter, "r1", schema.Identity().DeriveSettlementCommandID(eff), 0, run.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("late")}) + _, err := h.commitWith(old, oldWriter, "r1", schema.Identity().DeriveSettlementCommandID(eff), 0, turn.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("late")}) if !errors.Is(err, store.ErrOwnershipLost) { t.Fatalf("old owner commit = %v, want ErrOwnershipLost", err) } @@ -820,11 +825,11 @@ func testOwnershipLost(t *testing.T, factory Factory) { // Reading needs no ownership: the superseded process still reads the // ledger by SessionID and sees the state as the new owner left it // only its Writer's view and commits are fenced. - if rec, err := old.Record(h.ctx, sid, "r1"); err != nil || mustType[run.ModelStep](h.t, rec.Snapshot.State.Current).Status != run.ModelExecuting { + if rec, err := old.Record(h.ctx, sid, "r1"); err != nil || mustType[turn.ModelStep](h.t, rec.Snapshot.State.Current).Status != turn.ModelExecuting { t.Fatalf("old owner record = %v, want the current state without an ownership error", err) } // The new owner is unaffected. - if mustType[run.ModelStep](h.t, h.load("r1").State.Current).Status != run.ModelExecuting { + if mustType[turn.ModelStep](h.t, h.load("r1").State.Current).Status != turn.ModelExecuting { t.Fatal("new owner's view changed") } } @@ -833,9 +838,9 @@ func testOwnershipLost(t *testing.T, factory Factory) { func testFrozenValues(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) - h.startRun("t1", "r1", input("in-1")) + h.startTurn("r1", input("in-1")) step, eff := h.executingModel("r1", false) - digest := mustType[run.ModelStep](h.t, h.load("r1").State.Current).RequestDigest + digest := mustType[turn.ModelStep](h.t, h.load("r1").State.Current).RequestDigest body, _, err := h.frozen.Get(h.ctx, digest) if err != nil { t.Fatal(err) @@ -846,7 +851,7 @@ func testFrozenValues(t *testing.T, factory Factory) { if _, err := h.rt.FrozenRequest(h.ctx, "sha256:unknown"); !errors.Is(err, frozen.ErrMissing) { t.Fatalf("unknown digest = %v", err) } - h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(eff), 0, run.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("done")}) + h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(eff), 0, turn.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("done")}) // The body is EventBound content the artifact layer retains; Record never // depends on it. if _, err := h.rt.Record(h.ctx, sid, "r1"); err != nil { diff --git a/agentcore/run/sessionstore/storetest/harness.go b/agentcore/turn/sessionstore/storetest/harness.go similarity index 64% rename from agentcore/run/sessionstore/storetest/harness.go rename to agentcore/turn/sessionstore/storetest/harness.go index 75f2802c..427017a7 100644 --- a/agentcore/run/sessionstore/storetest/harness.go +++ b/agentcore/turn/sessionstore/storetest/harness.go @@ -1,6 +1,6 @@ -// Package storetest is the RunStore conformance suite. It takes a +// Package storetest is the TurnStore conformance suite. It takes a // session.Store factory so the Memory store and every durable adapter run the -// same assertions; it asserts Run semantics only and leaves group atomicity, +// same assertions; it asserts Turn semantics only and leaves group atomicity, // ownership fencing and cache equivalence to the kernel and // Module Framework suites. package storetest @@ -14,19 +14,18 @@ import ( "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/preset" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/sessionstore" - "github.com/felinics/twilight/agentcore/run/sessionstore/sessionstoretest" - "github.com/felinics/twilight/agentcore/run/store" + "github.com/felinics/twilight/agentcore/preset/ref" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/unit" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/session/writer/writertest" "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/sessionstore" + "github.com/felinics/twilight/agentcore/turn/sessionstore/sessionstoretest" + "github.com/felinics/twilight/agentcore/turn/store" "sync" "testing" "time" @@ -77,13 +76,13 @@ type harness struct { clock *clock writers *writer.Factory w writer.Writer - rt *sessionstore.SessionRunStore + rt *sessionstore.SessionTurnStore seq int } func newHarness(t testing.TB, f Fixture) *harness { t.Helper() - registry, err := module.BuildRegistry(chatlog.Module, sessionstore.Module, turn.Module) + registry, err := module.BuildRegistry(chatlog.Module, sessionstore.Module) if err != nil { t.Fatal(err) } @@ -98,14 +97,14 @@ func newHarness(t testing.TB, f Fixture) *harness { return h } -// open starts an owner process: Writers over the shared store and a run store. +// open starts an owner process: Writers over the shared store and a Turn store. // Takeover lets it supersede the previous owner process, if any. func (h *harness) open() { h.t.Helper() h.writers = &writer.Factory{Store: h.store, Registry: h.registry, Admission: writer.Admission{Bindings: h.bindings, Ledger: h.ledger}, Config: writer.WritersConfig{Cache: h.cache, CachePolicy: sessionstore.WriterCachePolicy(0)}} h.w = nil - rt, err := sessionstore.NewSessionRunStore(sessionstore.Config{Registry: h.registry, Store: h.store, + rt, err := sessionstore.NewSessionTurnStore(sessionstore.Config{Registry: h.registry, Store: h.store, Frozen: h.frozen, Cache: h.cache, Now: h.clock.Now}) if err != nil { h.fatal(err) @@ -115,7 +114,7 @@ func (h *harness) open() { // takeover opens a new owner process over the same store; the previous // store stays usable so tests can observe its fencing. -func (h *harness) takeover() (*sessionstore.SessionRunStore, writer.Writer) { +func (h *harness) takeover() (*sessionstore.SessionTurnStore, writer.Writer) { h.t.Helper() old, oldWriter := h.rt, h.writer() h.open() @@ -171,21 +170,21 @@ func flattenCommit(c ledger.Commit) []ledger.Event { } // inputContent is the body the harness submits for id; the AgentInput the -// Run sees carries only its digest. -func inputContent(id run.InputID) run.CanonicalJSON { - return run.MustParseCanonicalJSON(fmt.Sprintf(`{"text":%q}`, id)) +// Turn sees carries only its digest. +func inputContent(id turn.InputID) turn.CanonicalJSON { + return turn.MustParseCanonicalJSON(fmt.Sprintf(`{"text":%q}`, id)) } -func input(id string) run.AgentInput { - d, err := chatlog.DigestInput(chatlog.InputID(id), inputContent(run.InputID(id))) +func input(id string) turn.AgentInput { + d, err := chatlog.DigestInput(chatlog.InputID(id), inputContent(turn.InputID(id))) if err != nil { panic(err) } - return run.AgentInput{ID: run.InputID(id), Digest: d} + return turn.AgentInput{ID: turn.InputID(id), Digest: d} } // submitInputs writes chatlog input_submitted for each input. -func (h *harness) submitInputs(inputs ...run.AgentInput) { +func (h *harness) submitInputs(inputs ...turn.AgentInput) { h.t.Helper() for _, in := range inputs { h.seq++ @@ -196,71 +195,46 @@ func (h *harness) submitInputs(inputs ...run.AgentInput) { } } -// startGroup builds the atomic turn-start group without a Coordinator: turn/started, -// attempt/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) writer.SemanticGroup { +// startGroup builds the atomic turn-start group without a Coordinator: turn/started +// and input_accepted*. Owner is the TurnID. +func (h *harness) startGroup(turnID turn.TurnID, inputs ...turn.AgentInput) writer.SemanticGroup { h.t.Helper() - newRun, err := run.BuildNewRun(runID, "") + newTurn, err := turn.BuildNewTurn(turnID, "", ref.Ref{ID: "test", Digest: "sha256:test"}) if err != nil { h.fatal(err) } - facts, err := schema.Machine().CreateGroup(newRun, inputs) + facts, err := schema.Machine().CreateGroup(newTurn, inputs) if err != nil { h.fatal(err) } - group := writer.SemanticGroup{CommitID: ledger.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) - } - var turnEvents []writer.TypedEvent - if attempt == 1 { - turnEvents = append(turnEvents, writer.TypedEvent{Type: turn.TypeStarted, RecordedAtUnixMilli: 1, - Value: turn.StartedPayload{TurnID: turnID, RunID: runID, InputIDs: ids, Preset: preset.PresetRef{ID: "b", Digest: "sha256:b"}}}) - } - var chatEvents []writer.TypedEvent - if attempt == 1 { - for _, id := range ids { - chatEvents = append(chatEvents, writer.TypedEvent{Type: chatlog.TypeInputDelivered, RecordedAtUnixMilli: 1, - Value: chatlog.InputDeliveredPayload{InputID: id, TurnID: chatlog.TurnID(turnID), RunID: runID}}) - } - } + group := writer.SemanticGroup{CommitID: ledger.CommitID(fmt.Sprintf("start/%s", turnID))} runEvents := make([]writer.TypedEvent, 0, len(facts)) for _, f := range facts { - runEvents = append(runEvents, writer.TypedEvent{Type: sessionstore.EventType(f), RecordedAtUnixMilli: 1, Value: sessionstore.Event{RunID: runID, Fact: f}}) - } - // One batch per stream: the Turn's (naming its Run), the - // chatlog's when inputs are delivered, and the Run's. - if len(turnEvents) > 0 { - group.Batches = append(group.Batches, writer.TypedBatch{Domain: turn.Stream(turnID), Events: turnEvents}) - } - if len(chatEvents) > 0 { - group.Batches = append(group.Batches, writer.TypedBatch{Domain: chatlog.Stream, Events: chatEvents}) + runEvents = append(runEvents, writer.TypedEvent{Type: sessionstore.EventType(f), RecordedAtUnixMilli: 1, Value: sessionstore.Event{TurnID: turnID, Fact: f}}) } - group.Batches = append(group.Batches, writer.TypedBatch{Domain: sessionstore.Stream(runID), Events: runEvents}) + group.Batches = append(group.Batches, writer.TypedBatch{Domain: sessionstore.Stream(turnID), Events: runEvents}) return group } -// startRun creates a Run under turnID with the given inputs. -func (h *harness) startRun(turnID turn.TurnID, runID run.RunID, inputs ...run.AgentInput) { +// startRun creates a Turn under turnID with the given inputs. +func (h *harness) startTurn(turnID turn.TurnID, inputs ...turn.AgentInput) { h.t.Helper() h.submitInputs(inputs...) - h.mustApply(h.startGroup(turnID, runID, 1, inputs...)) + h.mustApply(h.startGroup(turnID, inputs...)) } -func (h *harness) load(runID run.RunID) store.Snapshot { +func (h *harness) load(turnID turn.TurnID) store.Snapshot { h.t.Helper() - snap, err := h.rt.Bind(h.writer()).Load(h.ctx, runID) + snap, err := h.rt.Bind(h.writer()).Load(h.ctx, turnID) if err != nil { h.fatal(err) } return snap } -func (h *harness) record(runID run.RunID) sessionstore.Record { +func (h *harness) record(turnID turn.TurnID) sessionstore.Record { h.t.Helper() - rec, err := h.rt.Record(h.ctx, sid, runID) + rec, err := h.rt.Record(h.ctx, sid, turnID) if err != nil { h.fatal(err) } @@ -268,7 +242,7 @@ func (h *harness) record(runID run.RunID) sessionstore.Record { } // moduleEvent is another module's event a test commits in the same unit as -// a Run command: the chatlog contributing its Part. +// a Turn command: the chatlog contributing its Part. type moduleEvent struct { Type ledger.EventType Value any @@ -287,7 +261,7 @@ func (a attachPart) Prepare(_ context.Context, _ writer.View, now int64) ([]writ return []writer.TypedBatch{{Domain: chatlog.Stream, Events: events}}, nil } -// commitResult is a Run command's result plus the stored commit it landed in. +// commitResult is a Turn command's result plus the stored commit it landed in. type commitResult struct { store.CommitResult Events []ledger.Event @@ -296,14 +270,14 @@ type commitResult struct { // commit builds the envelope and submits it as one unit; attach events follow // the facts in the same commit. -func (h *harness) commit(runID run.RunID, id run.CommandID, base run.RunPosition, cmd run.AgentCommand, attach ...moduleEvent) (commitResult, error) { +func (h *harness) commit(turnID turn.TurnID, id turn.CommandID, base turn.TurnPosition, cmd turn.AgentCommand, attach ...moduleEvent) (commitResult, error) { h.t.Helper() - return h.commitWith(h.rt, h.writer(), runID, id, base, cmd, attach...) + return h.commitWith(h.rt, h.writer(), turnID, id, base, cmd, attach...) } -func (h *harness) commitWith(rt *sessionstore.SessionRunStore, w writer.Writer, runID run.RunID, id run.CommandID, base run.RunPosition, cmd run.AgentCommand, attach ...moduleEvent) (commitResult, error) { +func (h *harness) commitWith(rt *sessionstore.SessionTurnStore, w writer.Writer, turnID turn.TurnID, id turn.CommandID, base turn.TurnPosition, cmd turn.AgentCommand, attach ...moduleEvent) (commitResult, error) { h.t.Helper() - env, err := schema.Wire().Envelope(runID, id, cmd) + env, err := schema.Wire().Envelope(turnID, id, cmd) if err != nil { h.fatal(err) } @@ -351,9 +325,9 @@ func (h *harness) withCommit(res store.CommitResult, id ledger.CommitID) commitR return out } -func (h *harness) mustCommit(runID run.RunID, id run.CommandID, base run.RunPosition, cmd run.AgentCommand, attach ...moduleEvent) commitResult { +func (h *harness) mustCommit(turnID turn.TurnID, id turn.CommandID, base turn.TurnPosition, cmd turn.AgentCommand, attach ...moduleEvent) commitResult { h.t.Helper() - res, err := h.commit(runID, id, base, cmd, attach...) + res, err := h.commit(turnID, id, base, cmd, attach...) if err != nil { h.fatal(fmt.Sprintf("commit %T: %v", cmd, err)) } @@ -362,82 +336,82 @@ func (h *harness) mustCommit(runID run.RunID, id run.CommandID, base run.RunPosi // modelEffect derives the next model effect of the Prepared step: // the step's rejected-result count is the effect's sequence. -func (h *harness) modelEffect(runID run.RunID, step run.StepID) run.EffectID { +func (h *harness) modelEffect(turnID turn.TurnID, step turn.StepID) turn.EffectID { h.t.Helper() - ms, ok := h.load(runID).State.Current.(run.ModelStep) + ms, ok := h.load(turnID).State.Current.(turn.ModelStep) if !ok || ms.RefValue.ID != step { h.fatal(fmt.Sprintf("model effect of %s: current is not the step", step)) } - return schema.Identity().DeriveEffectID(runID, step, "", ms.Rejects) + return schema.Identity().DeriveEffectID(turnID, step, "", ms.Rejects) } // toolEffect derives the one tool effect of a call: a call starts at most once. -func toolEffect(runID run.RunID, step run.StepID, call run.CallID) run.EffectID { - return schema.Identity().DeriveEffectID(runID, step, call, 0) +func toolEffect(turnID turn.TurnID, step turn.StepID, call turn.CallID) turn.EffectID { + return schema.Identity().DeriveEffectID(turnID, step, call, 0) } // --- run building blocks --------------------------------------------------------- var toolDef = model.ToolDefinition{Name: "echo", Parameters: jsonstable.MustParse(`{"type":"object"}`)} -func (h *harness) spec() run.ToolSpec { +func (h *harness) spec() turn.ToolSpec { h.t.Helper() d, err := schema.Canonical().DigestToolDefinition(toolDef) if err != nil { h.fatal(err) } - return run.ToolSpec{Ref: "echo", Name: "echo", DefinitionDigest: d, Policy: run.DirectExecution} + return turn.ToolSpec{Ref: "echo", Name: "echo", DefinitionDigest: d, Policy: turn.DirectExecution} } // preparedCommand builds PrepareModelRequest against snap with the derived ids. -func (h *harness) preparedCommand(snap store.Snapshot, withTool bool) (run.PrepareModelRequest, run.CommandID) { +func (h *harness) preparedCommand(snap store.Snapshot, withTool bool) (turn.PrepareModelRequest, turn.CommandID) { h.t.Helper() request := model.ModelRequest{Model: "m-1", Messages: []model.Message{{Role: model.MessageRoleUser, Content: []model.MessagePart{{Type: model.MessagePartTypeText, Text: "go"}}}}} - var specs []run.ToolSpec + var specs []turn.ToolSpec if withTool { request.Tools = []model.ToolDefinition{toolDef} - specs = []run.ToolSpec{h.spec()} + specs = []turn.ToolSpec{h.spec()} } reqDigest, err := schema.Canonical().DigestRequest(request) if err != nil { h.fatal(err) } - cmdID := schema.Identity().DeriveModelRequestCommandID(snap.State.RunID, snap.Position) - ids := make([]run.InputID, len(snap.State.PendingInputs)) + cmdID := schema.Identity().DeriveModelRequestCommandID(snap.State.TurnID, snap.Position) + ids := make([]turn.InputID, len(snap.State.PendingInputs)) for i, in := range snap.State.PendingInputs { ids[i] = in.ID } - return run.PrepareModelRequest{StepID: schema.Identity().DeriveModelStepID(snap.State.RunID, cmdID), Model: "m-1", Request: request, + return turn.PrepareModelRequest{StepID: schema.Identity().DeriveModelStepID(snap.State.TurnID, cmdID), Model: "m-1", Request: request, RequestDigest: reqDigest, InputIDs: ids, Tools: specs}, 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 { +// prepare commits a Prepare at the Turn's current position and returns the step. +func (h *harness) prepare(turnID turn.TurnID, withTool bool) turn.StepID { h.t.Helper() - snap := h.load(runID) + snap := h.load(turnID) cmd, id := h.preparedCommand(snap, withTool) - h.mustCommit(runID, id, snap.Position, cmd) + h.mustCommit(turnID, id, snap.Position, cmd) return cmd.StepID } // startModel commits StartModelExecution for the step's next model effect // and returns that effect. -func (h *harness) startModel(runID run.RunID, step run.StepID) run.EffectID { +func (h *harness) startModel(turnID turn.TurnID, step turn.StepID) turn.EffectID { h.t.Helper() - eff := h.modelEffect(runID, step) - res := h.mustCommit(runID, schema.Identity().DeriveStartCommandID(eff), 0, run.StartModelExecution{StepID: step, Effect: eff}) + eff := h.modelEffect(turnID, step) + res := h.mustCommit(turnID, schema.Identity().DeriveStartCommandID(eff), 0, turn.StartModelExecution{StepID: step, Effect: eff}) if res.Status != store.CommitAccepted { h.fatal("start was not accepted") } return eff } -// executingModel drives a fresh Run to Model Executing. -func (h *harness) executingModel(runID run.RunID, withTool bool) (run.StepID, run.EffectID) { +// executingModel drives a fresh Turn to Model Executing. +func (h *harness) executingModel(turnID turn.TurnID, withTool bool) (turn.StepID, turn.EffectID) { h.t.Helper() - step := h.prepare(runID, withTool) - return step, h.startModel(runID, step) + step := h.prepare(turnID, withTool) + return step, h.startModel(turnID, step) } func textResult(text string) model.ModelResult { @@ -445,33 +419,33 @@ func textResult(text string) model.ModelResult { } // toolCallResult is a model result issuing n calls of the harness tool. -func (h *harness) toolCallResult(step run.StepID, n int) (model.ModelResult, []run.ToolCallBinding) { +func (h *harness) toolCallResult(step turn.StepID, n int) (model.ModelResult, []turn.ToolCallBinding) { h.t.Helper() spec := h.spec() calls := make([]model.ModelToolCall, n) - bindings := make([]run.ToolCallBinding, n) + bindings := make([]turn.ToolCallBinding, n) for i := range calls { - args := run.MustParseCanonicalJSON(fmt.Sprintf(`{"i":%d}`, i)) + args := turn.MustParseCanonicalJSON(fmt.Sprintf(`{"i":%d}`, i)) calls[i] = model.ModelToolCall{ToolCallID: fmt.Sprintf("c%d", i), ToolName: "echo", Input: model.ToolArguments{JSON: args}} callID := schema.Identity().DeriveCallID(step, i) - bindings[i] = run.ToolCallBinding{CallID: callID, ProviderCallID: calls[i].ToolCallID, ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, + bindings[i] = turn.ToolCallBinding{CallID: callID, ProviderCallID: calls[i].ToolCallID, ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, Arguments: args, Policy: spec.Policy, Replay: spec.Replay, Placement: spec.Placement} } return model.ModelResult{FinishReason: model.FinishReasonToolCalls, Usage: model.Usage{TotalTokens: 2}, ToolCalls: calls}, 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) { +// openToolStep drives a fresh Turn to a ToolStep with n Pending calls. +func (h *harness) openToolStep(turnID turn.TurnID, n int) (turn.StepID, []turn.CallID) { h.t.Helper() - step, eff := h.executingModel(runID, true) + step, eff := h.executingModel(turnID, true) result, bindings := h.toolCallResult(step, n) - res := h.mustCommit(runID, schema.Identity().DeriveSettlementCommandID(eff), 0, - run.SubmitModelResult{StepID: step, Effect: eff, Result: result, Calls: bindings}) - ts, ok := res.Snapshot.State.Current.(run.ToolStep) + res := h.mustCommit(turnID, schema.Identity().DeriveSettlementCommandID(eff), 0, + turn.SubmitModelResult{StepID: step, Effect: eff, Result: result, Calls: bindings}) + ts, ok := res.Snapshot.State.Current.(turn.ToolStep) if !ok { h.fatal(fmt.Sprintf("after model result current = %T", res.Snapshot.State.Current)) } - ids := make([]run.CallID, n) + ids := make([]turn.CallID, n) for i := range bindings { ids[i] = bindings[i].CallID } @@ -479,10 +453,10 @@ func (h *harness) openToolStep(runID run.RunID, n int) (run.StepID, []run.CallID } // startTool commits StartToolCall for the call's tool effect and returns it. -func (h *harness) startTool(runID run.RunID, step run.StepID, call run.CallID) run.EffectID { +func (h *harness) startTool(turnID turn.TurnID, step turn.StepID, call turn.CallID) turn.EffectID { h.t.Helper() - eff := toolEffect(runID, step, call) - res := h.mustCommit(runID, schema.Identity().DeriveStartCommandID(eff), 0, run.StartToolCall{StepID: step, CallID: call, Effect: eff}) + eff := toolEffect(turnID, step, call) + res := h.mustCommit(turnID, schema.Identity().DeriveStartCommandID(eff), 0, turn.StartToolCall{StepID: step, CallID: call, Effect: eff}) if res.Status != store.CommitAccepted { h.fatal("tool start was not accepted") } diff --git a/agentcore/turn/sessionstore/surface.go b/agentcore/turn/sessionstore/surface.go new file mode 100644 index 00000000..72536ee9 --- /dev/null +++ b/agentcore/turn/sessionstore/surface.go @@ -0,0 +1,125 @@ +package sessionstore + +import ( + "context" + "fmt" + "github.com/felinics/twilight/agentcore/ledger" + "github.com/felinics/twilight/agentcore/module" + "github.com/felinics/twilight/agentcore/preset/ref" + "github.com/felinics/twilight/agentcore/session" + "github.com/felinics/twilight/agentcore/turn" +) + +// TurnRef addresses a logical Turn within its Session history. +type TurnRef struct { + SessionID session.SessionID + TurnID turn.TurnID +} + +const SurfaceProjectionID module.ProjectionID = "twilight/turn/surface" +const TypeStarted ledger.EventType = Prefix + "turn_started" + +// TurnView is the conversation-facing fold of a Turn's authoritative facts. +type TurnView struct { + TurnID turn.TurnID `json:"turnId"` + Status turn.TurnStatus `json:"status"` + InputIDs []turn.InputID `json:"inputIds,omitempty"` + Preset ref.Ref `json:"preset"` + End *turn.TurnEnded `json:"end,omitempty"` +} + +func (v *TurnView) Ended() turn.TurnEnd { + if v == nil || v.End == nil { + return nil + } + return v.End.End +} + +type TurnSurface struct { + Order []turn.TurnID `json:"order"` + Turns map[turn.TurnID]TurnView `json:"turns"` +} + +func (s *TurnSurface) Active() (TurnView, bool) { + for _, id := range s.Order { + if v := s.Turns[id]; v.Status == turn.TurnActive { + return v, true + } + } + return TurnView{}, false +} + +// History inherits facts; execution's MachineProjection explicitly does not. +var SurfaceProjection = module.ProjectionDefinition{ + ID: SurfaceProjectionID, Version: 1, + Consumes: []ledger.EventType{TypeStarted, Prefix + "input_accepted", Prefix + "turn_ended"}, + Inherits: module.InheritAll, Authoritative: true, + Initial: func() (any, error) { return TurnSurface{Turns: map[turn.TurnID]TurnView{}}, nil }, + StateCodec: module.JSONStateCodec[TurnSurface]{}, + Apply: applySurface, +} + +func applySurface(state any, e module.DecodedEvent) (any, error) { + prev, ok := state.(TurnSurface) + if !ok { + return nil, fmt.Errorf("turn surface: state is %T", state) + } + ev, ok := e.Value.(Event) + if !ok { + return nil, fmt.Errorf("turn surface: event is %T", e.Value) + } + out := TurnSurface{Order: prev.Order, Turns: make(map[turn.TurnID]TurnView, len(prev.Turns)+1)} + for id, v := range prev.Turns { + out.Turns[id] = v + } + v, exists := out.Turns[ev.TurnID] + switch f := ev.Fact.(type) { + case turn.TurnStarted: + if exists || f.TurnID != ev.TurnID { + return nil, fmt.Errorf("turn %s started twice or with a different identity", ev.TurnID) + } + v = TurnView{TurnID: f.TurnID, Preset: f.Preset, Status: turn.TurnActive} + out.Order = append(append([]turn.TurnID(nil), prev.Order...), ev.TurnID) + case turn.InputAccepted: + if !exists || v.Status.Terminal() { + return nil, fmt.Errorf("turn %s accepted input outside active lifetime", ev.TurnID) + } + for _, id := range v.InputIDs { + if id == f.Input.ID { + return nil, fmt.Errorf("turn %s accepted input %s twice", ev.TurnID, id) + } + } + v.InputIDs = append(append([]turn.InputID(nil), v.InputIDs...), f.Input.ID) + case turn.TurnEnded: + if !exists || v.Status.Terminal() { + return nil, fmt.Errorf("turn %s ended outside active lifetime", ev.TurnID) + } + v.End = &f + switch f.End.(type) { + case turn.TurnCompletedEnd: + v.Status = turn.TurnCompleted + case turn.TurnStoppedEnd: + v.Status = turn.TurnStopped + case turn.TurnFailedEnd: + v.Status = turn.TurnFailed + default: + return nil, fmt.Errorf("turn surface: unknown end %T", f.End) + } + default: + return nil, fmt.Errorf("turn surface: unexpected fact %T", ev.Fact) + } + out.Turns[ev.TurnID] = v + return out, nil +} + +func ReadSurface(ctx context.Context, r session.ProjectionReader, sid session.SessionID) (TurnSurface, error) { + state, _, err := r.Load(ctx, sid, SurfaceProjectionID, SurfaceProjection.Version) + if err != nil { + return TurnSurface{}, err + } + surface, ok := state.(TurnSurface) + if !ok { + return TurnSurface{}, fmt.Errorf("turn surface: state is %T", state) + } + return surface, nil +} diff --git a/agentcore/turn/settlement_test.go b/agentcore/turn/settlement_test.go new file mode 100644 index 00000000..3d219fc9 --- /dev/null +++ b/agentcore/turn/settlement_test.go @@ -0,0 +1,78 @@ +package turn_test + +import ( + "strings" + "testing" + + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/schema" +) + +// every fact that closes an effect names it, and TurnEnded closes +// none. A model failure and a cancellation settle the executing model effect +// with ModelStepFailed before the Turn ends; a rejection names the effect it +// settles; Evolve refuses a TurnEnded over an Executing target and a +// settlement naming another effect. +func TestEveryEffectEndsUnderItsOwnSettlement(t *testing.T) { + s := newTurn(t) + model, stepID := advanceToExecuting(t, s, testRequest(), nil) + eff := model.Current.(turn.ModelStep).Effect + other := schema.Identity().DeriveEffectID(model.TurnID, stepID, "", 7) + failure := turn.StepFailure{Class: turn.FailureProvider, Message: "down"} + + t.Run("model failure", func(t *testing.T) { + facts := mustDecide(t, model, turn.SubmitModelFailure{StepID: stepID, Effect: eff, Failure: failure}) + if len(facts) != 2 { + t.Fatalf("facts = %d, want [ModelStepFailed, TurnEnded]", len(facts)) + } + failed, ok := facts[0].(turn.ModelStepFailed) + if !ok || failed.Effect != eff || failed.Failure != failure { + t.Fatalf("facts[0] = %+v", facts[0]) + } + if end, ok := facts[1].(turn.TurnEnded); !ok || end.End.(turn.TurnFailedEnd).Reason != turn.ReasonProviderFailure { + t.Fatalf("facts[1] = %+v", facts[1]) + } + if after := fold(t, model, facts); after.Status != turn.TurnFailed || after.ModelSteps != 1 { + t.Fatalf("after = %+v", after) + } + }) + t.Run("cancel while executing", func(t *testing.T) { + facts := mustDecide(t, model, turn.CancelTurn{}) + if len(facts) != 2 { + t.Fatalf("facts = %d, want [ModelStepFailed, TurnEnded]", len(facts)) + } + failed, ok := facts[0].(turn.ModelStepFailed) + if !ok || failed.Effect != eff || failed.Failure.Class != turn.FailureEffectUnknown { + t.Fatalf("facts[0] = %+v", facts[0]) + } + if end := facts[1].(turn.TurnEnded).End.(turn.TurnStoppedEnd); end.UncertainModel != stepID { + t.Fatalf("end = %+v", end) + } + fold(t, model, facts) + }) + t.Run("rejection names the effect", func(t *testing.T) { + facts := mustDecide(t, model, turn.RejectModelResult{StepID: stepID, Effect: eff, Failure: failure, Disposition: turn.ModelRejectRetry}) + if rejected, ok := facts[0].(turn.ModelStepRejected); !ok || rejected.Effect != eff { + t.Fatalf("facts[0] = %+v", facts[0]) + } + }) + + evolveRejects := []struct { + name string + fact turn.Fact + want string + }{ + {"Turn ended over an executing model step", turn.TurnEnded{End: turn.TurnFailedEnd{Reason: turn.ReasonProviderFailure, Failure: turn.TurnFailure{Class: turn.FailureProvider}}}, "executes effect"}, + {"failure of another effect", turn.ModelStepFailed{StepID: stepID, Effect: other, Failure: failure}, "settles effect"}, + {"failure without effect", turn.ModelStepFailed{StepID: stepID, Failure: failure}, "without effect"}, + {"rejection of another effect", turn.ModelStepRejected{StepID: stepID, Effect: other, Failure: failure}, "settles effect"}, + } + for _, tc := range evolveRejects { + t.Run(tc.name, func(t *testing.T) { + _, err := schema.Machine().Evolve(model, tc.fact) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Evolve(%T) = %v, want error containing %q", tc.fact, err, tc.want) + } + }) + } +} diff --git a/agentcore/run/state.go b/agentcore/turn/state.go similarity index 87% rename from agentcore/run/state.go rename to agentcore/turn/state.go index d5d2c01a..1f39ef65 100644 --- a/agentcore/run/state.go +++ b/agentcore/turn/state.go @@ -1,52 +1,54 @@ -package run +package turn import ( "errors" "fmt" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/preset/ref" + "github.com/felinics/twilight/agentcore/turn/model" ) -type RunStatus uint8 +type TurnStatus uint8 const ( - RunActive RunStatus = iota - RunCompleted - RunStopped - RunFailed + TurnActive TurnStatus = iota + TurnCompleted + TurnStopped + TurnFailed ) -func (s RunStatus) Terminal() bool { return s != RunActive } +func (s TurnStatus) Terminal() bool { return s != TurnActive } -type RunReason string +type TurnReason string const ( - ReasonCancelled RunReason = "cancelled" - ReasonProviderFailure RunReason = "provider_failure" - ReasonMalformedModel RunReason = "malformed_model_result" - // Unknown model outcomes end the Run with this reason; unknown tool - // outcomes use FailureEffectUnknown on ToolCallFailed and leave the Run + ReasonCancelled TurnReason = "cancelled" + ReasonProviderFailure TurnReason = "provider_failure" + ReasonMalformedModel TurnReason = "malformed_model_result" + // Unknown model outcomes end the Turn with this reason; unknown tool + // outcomes use FailureEffectUnknown on ToolCallFailed and leave the Turn // active. - ReasonEffectUnknown RunReason = "effect_unknown" + ReasonEffectUnknown TurnReason = "effect_unknown" ) -type RunFailure struct { +type TurnFailure struct { Class string `json:"class"` Message string `json:"message,omitempty"` CallID CallID `json:"callId,omitempty"` } -// RunResult is the read-side restatement of how a Run ended: the RunEnded +// TurnResult is the read-side restatement of how a Turn ended: the TurnEnded // fact's End as status, reason and failure, with the Usage accumulated up to // it. Evolve folds it for readers (the Loop's LoopResult, the host); Decide -// never reads it. The authority for the end is the RunEnded fact. -type RunResult struct { - Status RunStatus `json:"status"` - Reason RunReason `json:"reason,omitempty"` - Failure *RunFailure `json:"failure,omitempty"` - // UncertainCalls are tool calls settled as Unknown when the Run stopped. +// never reads it. The authority for the end is the TurnEnded fact. +type TurnResult struct { + Message string `json:"message,omitempty"` + Status TurnStatus `json:"status"` + Reason TurnReason `json:"reason,omitempty"` + Failure *TurnFailure `json:"failure,omitempty"` + // UncertainCalls are tool calls settled as Unknown when the Turn stopped. UncertainCalls []CallID `json:"uncertainCalls,omitempty"` - // UncertainModel is the ModelStep left Executing when the Run stopped. + // UncertainModel is the ModelStep left Executing when the Turn stopped. UncertainModel StepID `json:"uncertainModel,omitempty"` Usage model.Usage `json:"usage"` } @@ -149,7 +151,7 @@ func (p ToolPlacement) String() string { } // RetryDisposition is a Known failure's own answer to whether the next -// attempt of the same Assignment may run. RetryAllowed means +// attempt of the same Assignment may turn. RetryAllowed means // this failure suffices to confirm the attempt produced no external effect // that cannot safely be repeated; RetryNever means the failure is definite // or a second attempt is not safe to assert. It is a property of the @@ -199,7 +201,7 @@ const ( // arguments shown to whoever answers. A Wait is not an effect: nothing is // dispatched for a Waiting call and no attempt exists for it. type ResponseRequest struct { - RunID RunID `json:"runId"` + TurnID TurnID `json:"turnId"` StepID StepID `json:"stepId"` CallID CallID `json:"callId"` ID ResponseID `json:"id"` @@ -225,7 +227,7 @@ type ToolSpec struct { type ToolCallBinding struct { CallID CallID `json:"callId"` // ProviderCallID is the tool_call_id the model emitted. PromptBuilders echo it - // back when they replay the call and its result; the Run never keys on it. + // back when they replay the call and its result; the Turn never keys on it. ProviderCallID string `json:"providerCallId,omitempty"` ToolRef ToolRef `json:"toolRef"` DefinitionDigest Digest `json:"definitionDigest"` @@ -239,8 +241,8 @@ type ToolCallBinding struct { } type StepRef struct { - RunID RunID `json:"runId"` - ID StepID `json:"id"` + TurnID TurnID `json:"turnId"` + ID StepID `json:"id"` } // Step is sealed by the agent package: only ModelStep and ToolStep exist. @@ -249,7 +251,7 @@ type Step interface { Ref() StepRef } -// Current is the contents of an Active run. Open is the prompt building interval: +// Current is the contents of an Active turn. Open is the prompt building 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() } @@ -292,7 +294,7 @@ type ModelStep struct { Status ModelStepStatus `json:"status"` // Effect is the model effect the step most recently requested (from // ModelStepStarted); empty before the first start. The step fixes the - // effect's kind (a model call) and binding (RequestDigest), so the Run + // effect's kind (a model call) and binding (RequestDigest), so the Turn // keeps no effect record beside the step. A rejected result returns the // step to Prepared without clearing it, and the next start requests a new // effect. A new owner recovers an Executing step under a CommandID derived @@ -491,8 +493,8 @@ func (s *ToolStep) callIndex(id CallID) int { return -1 } -// MachineState is the complete semantic state of one Run: the -// fold of its facts along four dimensions. Progress is which Step the Run +// MachineState is the complete semantic state of one Turn: the +// fold of its facts along four dimensions. Progress is which Step the Turn // is in and what it has frozen (Current, ModelSteps, LastToolStep). Inbox is // the input accepted and not yet consumed by a prepare (PendingInputs). // Effects are the EffectID an Executing ModelStep or tool call requested and @@ -503,8 +505,9 @@ func (s *ToolStep) callIndex(id CallID) int { // the host's. Content bodies (model output, tool output) never appear // either: facts record digests and the frozen.Store holds the bodies. type MachineState struct { - RunID RunID `json:"runId"` - Status RunStatus `json:"status"` + Preset ref.Ref `json:"preset"` + TurnID TurnID `json:"turnId"` + Status TurnStatus `json:"status"` Current Current `json:"-"` PendingInputs []AgentInput `json:"pendingInputs,omitempty"` ModelSteps int `json:"modelSteps"` @@ -514,25 +517,25 @@ type MachineState struct { LastToolStep *ToolStep `json:"lastToolStep,omitempty"` // Usage and Result are projections folded from facts for readers, not // inputs to Decide: Usage accumulates the usage every model fact - // reports, Result restates RunEnded with that Usage. + // reports, Result restates TurnEnded with that Usage. Usage model.Usage `json:"usage"` - Result *RunResult `json:"result,omitempty"` + Result *TurnResult `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. +// FoldTurn 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") + if s.TurnID == "" { + return errors.New("agent: state: empty TurnID") } switch s.Status { - case RunActive, RunCompleted, RunStopped, RunFailed: + case TurnActive, TurnCompleted, TurnStopped, TurnFailed: default: - return fmt.Errorf("agent: state: unknown RunStatus %d", s.Status) + return fmt.Errorf("agent: state: unknown TurnStatus %d", s.Status) } if s.ModelSteps < 0 { return errors.New("agent: state: negative model step count") @@ -577,7 +580,7 @@ func validateLastToolStep(s *MachineState) error { if last == nil { return nil } - if last.RefValue.RunID != s.RunID || last.RefValue.ID == "" || last.Source == "" { + if last.RefValue.TurnID != s.TurnID || last.RefValue.ID == "" || last.Source == "" { return errors.New("agent: state: invalid LastToolStep projection") } if len(last.Calls) == 0 { @@ -601,7 +604,7 @@ func validateCurrent(s *MachineState) error { case nil: return errors.New("agent: state: active state has no current") case ModelStep: - if current.RefValue.RunID != s.RunID || current.RefValue.ID == "" || current.Model == "" { + if current.RefValue.TurnID != s.TurnID || current.RefValue.ID == "" || current.Model == "" { return errors.New("agent: state: invalid current ModelStep identity") } if current.Status != ModelPrepared && current.Status != ModelExecuting { @@ -609,14 +612,14 @@ func validateCurrent(s *MachineState) error { } return nil case ToolStep: - return validateCurrentToolStep(s.RunID, ¤t) + return validateCurrentToolStep(s.TurnID, ¤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.Source == "" || len(ts.Calls) == 0 { +func validateCurrentToolStep(turnID TurnID, ts *ToolStep) error { + if ts.RefValue.TurnID != turnID || ts.RefValue.ID == "" || ts.Source == "" || len(ts.Calls) == 0 { return errors.New("agent: state: invalid current ToolStep identity") } seen := make(map[CallID]struct{}, len(ts.Calls)) @@ -643,12 +646,12 @@ func validateCurrentToolStep(runID RunID, ts *ToolStep) error { 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 +// InitializeTurn builds the minimal initial MachineState (Revision 0) for a +// new Turn. 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") +func InitializeTurn(turnID TurnID) (MachineState, error) { + if turnID == "" { + return MachineState{}, errors.New("agent: initialize: empty TurnID") } - return MachineState{RunID: run, Status: RunActive, Current: Open{}}, nil + return MachineState{TurnID: turnID, Status: TurnActive, Current: Open{}}, nil } diff --git a/agentcore/run/store/commit.go b/agentcore/turn/store/commit.go similarity index 61% rename from agentcore/run/store/commit.go rename to agentcore/turn/store/commit.go index ce92673a..b6ae888e 100644 --- a/agentcore/run/store/commit.go +++ b/agentcore/turn/store/commit.go @@ -4,9 +4,9 @@ import ( "errors" "fmt" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/wire" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/wire" ) type DecisionKind uint8 @@ -18,13 +18,13 @@ const ( DecisionTerminal ) -// CommitDecision is EvaluateCommit's verdict. The RunStore maps rejections +// CommitDecision is EvaluateCommit's verdict. The TurnStore maps rejections // onto the sentinel errors: Conflict -> ErrCommandConflict, Stale -> -// ErrStaleRuntime, Terminal -> ErrRunTerminal. +// ErrStaleRuntime, Terminal -> ErrTurnTerminal. type CommitDecision struct { Kind DecisionKind - NewState run.MachineState - Facts []run.Fact + NewState turn.MachineState + Facts []turn.Fact // Reject carries the precondition failure for Conflict/Stale/Terminal. Reject error } @@ -33,22 +33,22 @@ type CommitDecision struct { // built by wire.Codec.Envelope, so there is no per-commit // self-verification of the command bytes. func ValidateEnvelope(env *wire.CommandEnvelope) error { - if env.RunID == "" || env.ID == "" { - return errors.New("agent: commit: empty RunID or CommandID") + if env.TurnID == "" || env.ID == "" { + return errors.New("agent: commit: empty TurnID or CommandID") } return nil } -// EvaluateCommit is the pure evaluation every RunStore runs inside its +// EvaluateCommit is the pure evaluation every TurnStore runs inside its // store's critical section after the replay lookup (steps 4-8). Execution ownership is // Session-level, 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 run.MachineState, position run.RunPosition, req CommitRequest) (CommitDecision, error) { +func EvaluateCommit(cur turn.MachineState, position turn.TurnPosition, req CommitRequest) (CommitDecision, error) { env := req.Command - if env.RunID != cur.RunID { - return CommitDecision{}, fmt.Errorf("agent: commit: command run %q does not match authority run %q", env.RunID, cur.RunID) + if env.TurnID != cur.TurnID { + return CommitDecision{}, fmt.Errorf("agent: commit: command turn %q does not match authority turn %q", env.TurnID, cur.TurnID) } if err := ValidateEnvelope(&env); err != nil { return CommitDecision{}, err @@ -67,23 +67,23 @@ func EvaluateCommit(cur run.MachineState, position run.RunPosition, req CommitRe // Terminal absorbs non-duplicate commands (replay was handled before). if cur.Status.Terminal() { - return CommitDecision{Kind: DecisionTerminal, Reject: run.ErrRunTerminal}, nil + return CommitDecision{Kind: DecisionTerminal, Reject: turn.ErrTurnTerminal}, nil } // Base: PrepareModelRequest is the only hard-CAS command. - if _, plan := env.Command.(run.PrepareModelRequest); plan && req.Base != position { - return CommitDecision{Kind: DecisionStale, Reject: run.ErrStaleRuntime}, nil + if _, plan := env.Command.(turn.PrepareModelRequest); plan && req.Base != position { + return CommitDecision{Kind: DecisionStale, Reject: turn.ErrStaleRuntime}, nil } // Step 7: Decide once, fold with Evolve. facts, err := schema.Machine().Decide(cur, env.Command) if err != nil { switch { - case errors.Is(err, run.ErrRunTerminal): + case errors.Is(err, turn.ErrTurnTerminal): return CommitDecision{Kind: DecisionTerminal, Reject: err}, nil - case errors.Is(err, run.ErrStaleRuntime): + case errors.Is(err, turn.ErrStaleRuntime): return CommitDecision{Kind: DecisionStale, Reject: err}, nil - case errors.Is(err, run.ErrCommandConflict): + case errors.Is(err, turn.ErrCommandConflict): return CommitDecision{Kind: DecisionConflict, Reject: err}, nil default: // Precondition failures against the current state are stale from @@ -91,26 +91,26 @@ func EvaluateCommit(cur run.MachineState, position run.RunPosition, req CommitRe return CommitDecision{Kind: DecisionStale, Reject: err}, nil } } - if cmd, ok := env.Command.(run.PrepareModelRequest); ok { + if cmd, ok := env.Command.(turn.PrepareModelRequest); ok { if len(facts) == 0 { return CommitDecision{}, errors.New("agent: commit: prepare produced no facts") } - if _, ok := facts[0].(run.ModelStepPrepared); !ok { + if _, ok := facts[0].(turn.ModelStepPrepared); !ok { return CommitDecision{}, errors.New("agent: commit: prepare did not produce ModelStepPrepared") } - wantStep := schema.Identity().DeriveModelStepID(env.RunID, env.ID) + wantStep := schema.Identity().DeriveModelStepID(env.TurnID, env.ID) if cmd.StepID != wantStep { return CommitDecision{Kind: DecisionStale, Reject: fmt.Errorf("prepare: StepID %q does not match derived StepID %q", cmd.StepID, wantStep)}, nil } } state := cur - detached := make([]run.Fact, len(facts)) + detached := make([]turn.Fact, len(facts)) for i, f := range facts { // Detach every fact before it is folded: Decide forwards fields from // the caller's command and the decision must not carry caller-owned // mutable objects across the Runtime boundary. - f, err = run.SnapshotFact(f) + f, err = turn.SnapshotFact(f) if err != nil { return CommitDecision{}, err } @@ -126,23 +126,23 @@ func EvaluateCommit(cur run.MachineState, position run.RunPosition, req CommitRe // namedEffect reports the effect a command names and the operation a // rejection reports it under; named is false for commands whose identity // does not derive from an effect. -func namedEffect(cmd run.AgentCommand) (op string, effect run.EffectID, named bool) { +func namedEffect(cmd turn.AgentCommand) (op string, effect turn.EffectID, named bool) { switch c := cmd.(type) { - case run.StartModelExecution: + case turn.StartModelExecution: return "model start", c.Effect, true - case run.StartToolCall: + case turn.StartToolCall: return "tool start", c.Effect, true - case run.RecoverModelExecution: + case turn.RecoverModelExecution: return "model recovery", c.Effect, true - case run.SubmitModelResult: + case turn.SubmitModelResult: return "model result", c.Effect, true - case run.SubmitModelFailure: + case turn.SubmitModelFailure: return "model failure", c.Effect, true - case run.RejectModelResult: + case turn.RejectModelResult: return "model reject", c.Effect, true - case run.SubmitToolResult: + case turn.SubmitToolResult: return "tool result", c.Effect, true - case run.SubmitToolFailure: + case turn.SubmitToolFailure: return "tool failure", c.Effect, true default: return "", "", false @@ -150,46 +150,46 @@ func namedEffect(cmd run.AgentCommand) (op string, effect run.EffectID, named bo } // checkDerivedCommandID enforces the derived-identity rules. -func checkDerivedCommandID(env *wire.CommandEnvelope, base run.RunPosition, id run.Identity) error { - var want run.CommandID +func checkDerivedCommandID(env *wire.CommandEnvelope, base turn.TurnPosition, id turn.Identity) error { + var want turn.CommandID switch cmd := env.Command.(type) { - case run.PrepareModelRequest: - want = id.DeriveModelRequestCommandID(env.RunID, base) - case run.AcceptInput: - want = id.DeriveInputCommandID(env.RunID, cmd.InputIDs()...) - case run.WithdrawPreparedStep: - want = id.DeriveWithdrawCommandID(env.RunID, cmd.StepID) - case run.ApproveToolCall: - want = id.DeriveResponseCommandID(env.RunID, cmd.StepID, cmd.CallID, cmd.ResponseID) - case run.RejectToolCall: - want = id.DeriveResponseCommandID(env.RunID, cmd.StepID, cmd.CallID, cmd.ResponseID) - case run.SubmitToolResponse: - want = id.DeriveResponseCommandID(env.RunID, cmd.StepID, cmd.CallID, cmd.ResponseID) - case run.StartModelExecution: + case turn.PrepareModelRequest: + want = id.DeriveModelRequestCommandID(env.TurnID, base) + case turn.AcceptInput: + want = id.DeriveInputCommandID(env.TurnID, cmd.InputIDs()...) + case turn.WithdrawPreparedStep: + want = id.DeriveWithdrawCommandID(env.TurnID, cmd.StepID) + case turn.ApproveToolCall: + want = id.DeriveResponseCommandID(env.TurnID, cmd.StepID, cmd.CallID, cmd.ResponseID) + case turn.RejectToolCall: + want = id.DeriveResponseCommandID(env.TurnID, cmd.StepID, cmd.CallID, cmd.ResponseID) + case turn.SubmitToolResponse: + want = id.DeriveResponseCommandID(env.TurnID, cmd.StepID, cmd.CallID, cmd.ResponseID) + case turn.StartModelExecution: want = id.DeriveStartCommandID(cmd.Effect) - case run.StartToolCall: + case turn.StartToolCall: want = id.DeriveStartCommandID(cmd.Effect) - case run.RecoverModelExecution: + case turn.RecoverModelExecution: want = id.DeriveRecoveryCommandID(cmd.Effect) - case run.SubmitModelResult: + case turn.SubmitModelResult: want = id.DeriveSettlementCommandID(cmd.Effect) - case run.SubmitModelFailure: + case turn.SubmitModelFailure: want = id.DeriveSettlementCommandID(cmd.Effect) - case run.RejectModelResult: + case turn.RejectModelResult: want = id.DeriveSettlementCommandID(cmd.Effect) - case run.SubmitToolResult: + case turn.SubmitToolResult: want = id.DeriveSettlementCommandID(cmd.Effect) - case run.SubmitToolFailure: + case turn.SubmitToolFailure: // An Unknown tool failure is either the owner's settlement of a // delivered Outcome or a takeover disposition. The two // are distinct operations with their own identities; either closes // the effect once. - if cmd.Outcome == run.ToolOutcomeUnknown && env.ID == id.DeriveRecoveryCommandID(cmd.Effect) { + if cmd.Outcome == turn.ToolOutcomeUnknown && env.ID == id.DeriveRecoveryCommandID(cmd.Effect) { return nil } want = id.DeriveSettlementCommandID(cmd.Effect) - case run.DeclineToolCall: - want = id.DeriveDeclineCommandID(env.RunID, cmd.StepID, cmd.CallID) + case turn.DeclineToolCall: + want = id.DeriveDeclineCommandID(env.TurnID, cmd.StepID, cmd.CallID) default: return nil } diff --git a/agentcore/turn/store/doc.go b/agentcore/turn/store/doc.go new file mode 100644 index 00000000..6fb5bfeb --- /dev/null +++ b/agentcore/turn/store/doc.go @@ -0,0 +1,6 @@ +// Package store is the transactional boundary of a Turn: the +// TurnStore port a Loop drives, the Snapshot a command sees, the CommitRequest +// and CommitResult of one command, the pure EvaluateCommit every adapter runs +// inside its own commit, and FoldTurn, the rebuild of a MachineState from a +// Turn's complete fact sequence. +package store diff --git a/agentcore/turn/store/fold.go b/agentcore/turn/store/fold.go new file mode 100644 index 00000000..c0e6b6db --- /dev/null +++ b/agentcore/turn/store/fold.go @@ -0,0 +1,33 @@ +package store + +import ( + "errors" + "fmt" + + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/schema" +) + +// FoldTurn rebuilds a MachineState from the complete fact sequence of one Turn +// in stream order: the first fact must be TurnStarted. No Decide, +// no effects, no replay. +func FoldTurn(facts []turn.Fact) (turn.MachineState, error) { + if len(facts) == 0 { + return turn.MachineState{}, errors.New("agent: fold: no facts") + } + if _, ok := facts[0].(turn.TurnStarted); !ok { + return turn.MachineState{}, fmt.Errorf("agent: fold: first fact is %T, want TurnStarted", facts[0]) + } + var state turn.MachineState + for i, f := range facts { + f, err := turn.SnapshotFact(f) + if err != nil { + return turn.MachineState{}, err + } + state, err = schema.Machine().Evolve(state, f) + if err != nil { + return turn.MachineState{}, fmt.Errorf("agent: fold: fact %d (%s): %w", i, schema.Wire().FactType(f), err) + } + } + return state, nil +} diff --git a/agentcore/run/store/settlementproof.go b/agentcore/turn/store/settlementproof.go similarity index 51% rename from agentcore/run/store/settlementproof.go rename to agentcore/turn/store/settlementproof.go index b650901e..6df4e280 100644 --- a/agentcore/run/store/settlementproof.go +++ b/agentcore/turn/store/settlementproof.go @@ -4,43 +4,43 @@ import ( "context" "errors" - "github.com/felinics/twilight/agentcore/run" + "github.com/felinics/twilight/agentcore/turn" ) -// ErrEffectProofUnsupported means a RunStore cannot prove durable effect +// ErrEffectProofUnsupported means a TurnStore cannot prove durable effect // disposition. It must not be interpreted as absence of a settlement. -var ErrEffectProofUnsupported = errors.New("agent: run store: durable effect proof unsupported") +var ErrEffectProofUnsupported = errors.New("agent: turn store: durable effect proof unsupported") // EffectDispositionReader is an optional read-only capability next to -// RunStore. It speaks only Run types. EffectDisposition returns committed -// closing facts naming exactly this Run and EffectID, from the durable Run +// TurnStore. It speaks only Turn types. EffectDisposition returns committed +// closing facts naming exactly this Turn and EffectID, from the durable Turn // ledger or an index of it, never from an active-state projection. found is // false only for proven absence; read failures are errors, not absence. Facts -// are detached and proof survives a terminal Run leaving the projection. +// are detached and proof survives a terminal Turn leaving the projection. // -// Proof is by effect fact, not only a derived settlement CommandID: CancelRun +// Proof is by effect fact, not only a derived settlement CommandID: CancelTurn // can close several effects in one differently named commit. A recovery or // cancellation disposition does not prove execution terminated, so callers // must also close/probe the execution before allowing Outcome collection. type EffectDispositionReader interface { - EffectDisposition(context.Context, run.RunID, run.EffectID) (facts []run.Fact, found bool, err error) + EffectDisposition(context.Context, turn.TurnID, turn.EffectID) (facts []turn.Fact, found bool, err error) } // DispositionEffect names the effect a closing fact settles or recovers. -// Starts, RunEnded, and facts about effects that never started return empty. -func DispositionEffect(fact run.Fact) run.EffectID { +// Starts, TurnEnded, and facts about effects that never started return empty. +func DispositionEffect(fact turn.Fact) turn.EffectID { switch f := fact.(type) { - case run.ModelStepCompleted: + case turn.ModelStepCompleted: return f.Effect - case run.ModelStepFailed: + case turn.ModelStepFailed: return f.Effect - case run.ModelStepRejected: + case turn.ModelStepRejected: return f.Effect - case run.ModelStepRecovered: + case turn.ModelStepRecovered: return f.Effect - case run.ToolCallCompleted: + case turn.ToolCallCompleted: return f.Effect - case run.ToolCallFailed: + case turn.ToolCallFailed: return f.Effect default: return "" diff --git a/agentcore/run/store/store.go b/agentcore/turn/store/store.go similarity index 56% rename from agentcore/run/store/store.go rename to agentcore/turn/store/store.go index c6c90a6e..9888fa2a 100644 --- a/agentcore/run/store/store.go +++ b/agentcore/turn/store/store.go @@ -4,14 +4,14 @@ import ( "context" "errors" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/wire" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/wire" ) var ( - // ErrRunNotFound reports an operation addressed a RunID not in the Session. - ErrRunNotFound = errors.New("agent: run not found") + // ErrTurnNotFound reports an operation addressed a TurnID not in the Session. + ErrTurnNotFound = errors.New("agent: turn not found") ) // CheckContext avoids locking when cancellation already makes an operation @@ -23,45 +23,45 @@ func CheckContext(ctx context.Context) error { return ctx.Err() } -// ErrOwnershipLost reports that the write capability behind a RunStore was +// ErrOwnershipLost reports that the write capability behind a TurnStore was // superseded. It is terminal for the caller: no further command of // this process can reach the stream. -var ErrOwnershipLost = errors.New("agent: run store ownership lost") +var ErrOwnershipLost = errors.New("agent: turn store ownership lost") -// RunStore is the transactional port of the Run core: the store -// the Runs of one Scope live in, already bound to the caller's write -// capability. It speaks only Run types; how a command reaches durable +// TurnStore is the transactional port of the Turn core: the store +// the TurnStore of one Scope live in, already bound to the caller's write +// capability. It speaks only Turn types; how a command reaches durable // storage, how facts are encoded on the wire and how the bound capability -// fences a stale owner are the adapter's business (run/sessionstore). Runs +// fences a stale owner are the adapter's business (turn/sessionstore). TurnStore // are created by the owning module's creation commit; there is no Create. -type RunStore interface { +type TurnStore interface { // Scope is the store this port is bound to. - Scope() run.Scope - // Load is the owner's view of a Run for the next command. A port whose + Scope() turn.Scope + // Load is the owner's view of a Turn for the next command. A port whose // capability was superseded still answers from its own epoch's state and // is fenced at Commit. - Load(context.Context, run.RunID) (Snapshot, error) + Load(context.Context, turn.TurnID) (Snapshot, error) // Commit evaluates one command and appends its facts as one // atomic group. Replays are answered from the store's command index // without re-deciding. Commit(context.Context, CommitRequest) (CommitResult, error) // FrozenRequest returns the request body a Prepared or Executing ModelStep // names by RequestDigest; a missing body is frozen.ErrMissing. - FrozenRequest(context.Context, run.Digest) (model.ModelRequest, error) + FrozenRequest(context.Context, turn.Digest) (model.ModelRequest, error) } -// Snapshot is one Run as a command sees it. +// Snapshot is one Turn as a command sees it. type Snapshot struct { // State is a detached in-process view. - State run.MachineState - // Position is the Run's last fact position at read time. - Position run.RunPosition + State turn.MachineState + // Position is the Turn's last fact position at read time. + Position turn.TurnPosition } type CommitRequest struct { // Base is the Position the caller loaded. PrepareModelRequest treats it // as a hard CAS; other commands rebase call-locally and may pass zero - Base run.RunPosition + Base turn.TurnPosition Command wire.CommandEnvelope } @@ -75,7 +75,7 @@ const ( type CommitResult struct { Status CommitStatus Snapshot Snapshot - // Facts are the Run facts the command produced, in stream order. On a + // Facts are the Turn facts the command produced, in stream order. On a // replay they are the facts of the original commit. - Facts []run.Fact + Facts []turn.Fact } diff --git a/agentcore/turn/turn.go b/agentcore/turn/turn.go deleted file mode 100644 index 797c8e9d..00000000 --- a/agentcore/turn/turn.go +++ /dev/null @@ -1,172 +0,0 @@ -// Package turn is the Turn module: the logical Turn's own facts -// (started, failed, superseded), the identity derivations that name them, -// the protocol vocabulary operated on them and the surface that projects -// Turn state from the Session's facts. Which Run executes a Turn and where -// an input was delivered are other modules' facts; the surface joins them -// and writes no derived copy. -package turn - -import ( - "errors" - "fmt" - "github.com/felinics/twilight/agentcore/chatlog" - "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/preset" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/sessionstore" - "github.com/felinics/twilight/agentcore/session" -) - -const ( - ModuleID module.ModuleID = "turn" - // StreamDomain is the stream domain of Turn events: one keyed stream - // per Turn, bound by the payload's turnId. - StreamDomain = "turn" -) - -// streamDefinition declares the turn domain: keyed by TurnID and of session -// lineage, so a fork continues its parent's Turns. -var streamDefinition = module.StreamDefinition{Domain: StreamDomain, Key: streamKey, Inheritance: module.Inherited} - -// streamKey binds a turn event to its Turn's stream. -func streamKey(value any) (string, error) { - switch p := value.(type) { - case StartedPayload: - return string(p.TurnID), nil - case FailedPayload: - return string(p.TurnID), nil - case SupersededPayload: - return string(p.TurnID), nil - } - return "", fmt.Errorf("value is %T, not a turn event", value) -} - -// Stream is the logical stream of one Turn's events. -func Stream(turnID TurnID) ledger.Domain { return streamDefinition.Ref(string(turnID)) } - -type TurnID string - -type TurnRef struct { - SessionID session.SessionID - TurnID TurnID -} - -type Settlement string - -const ( - SettlementCompleted Settlement = "completed" - SettlementFailed Settlement = "failed" - SettlementStopped Settlement = "stopped" -) - -// EventTypes are the Turn domain's own decisions. A Run's end -// (twilight/run/run_ended) is not among them: the surface folds it from -// the run module that owns it. -const ( - TypeStarted ledger.EventType = "twilight/turn/started" - TypeFailed ledger.EventType = "twilight/turn/failed" - TypeSuperseded ledger.EventType = "twilight/turn/superseded" -) - -type StartedPayload struct { - TurnID TurnID `json:"turnId"` - RunID run.RunID `json:"runId"` - InputIDs []chatlog.InputID `json:"inputIds,omitempty"` - Preset preset.PresetRef `json:"preset"` -} - -// FailedPayload settles a Turn as failed or stopped. Reason is the -// caller's stated reason for a stop, kept as given; FailureClass is the -// settlement's class. -type FailedPayload struct { - TurnID TurnID `json:"turnId"` - RunID run.RunID `json:"runId"` - Settlement Settlement `json:"settlement"` - FailureClass string `json:"failureClass,omitempty"` - Reason string `json:"reason,omitempty"` -} - -type SupersededPayload struct { - TurnID TurnID `json:"turnId"` - ReplacementTurnID TurnID `json:"replacementTurnId"` -} - -// --- identity derivations ------------------------------------------------------- - -func digestOf(domain string, parts ...string) jsonstable.Digest { - raw, _ := jsonstable.EncodeTypedPayload(1, domain, parts) - return jsonstable.DigestBytes(raw) -} - -// PlanDigest names the Turn plan a Start commits: the Turn, the preset -// digest and the ordered input IDs. -func PlanDigest(turnID TurnID, presetDigest jsonstable.Digest, inputs []chatlog.InputID) jsonstable.Digest { - parts := make([]string, 0, 2+len(inputs)) - parts = append(parts, string(turnID), string(presetDigest)) - for _, id := range inputs { - parts = append(parts, string(id)) - } - return digestOf("twilight/turn/plan", parts...) -} - -// StartOperationDigest is the Start commit's CommitID. -func StartOperationDigest(sid session.SessionID, turnID TurnID, plan jsonstable.Digest) jsonstable.Digest { - return digestOf("twilight/turn/start-operation", string(sid), string(turnID), string(plan)) -} - -// DeriveRunID names the one Run that executes a Turn. -func DeriveRunID(sid session.SessionID, turnID TurnID) run.RunID { - return run.RunID(digestOf("twilight/turn/run", string(sid), string(turnID))) -} - -// CancelCommandID names the Run cancellation a Stop commits beside the -// Turn's failed settlement. -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))) -} - -// --- module ----------------------------------------------------------------------- - -// Version is the payload version the turn module writes every event with. -const Version module.PayloadVersion = 1 - -func def[T any](typ ledger.EventType, check func(*T) error) module.EventDefinition { - return module.EventDefinition{Type: typ, Domain: StreamDomain, - Codecs: map[module.PayloadVersion]module.PayloadCodec{Version: module.JSONCodec[T]{Check: check}}} -} - -// Module declares the turn events, the surface projection and the Requires -// of the Turn's scope: run (run_ended, which settles the Turn's Run) and -// chatlog (input_delivered, which extends the Turn's inputs). -var Module = module.ModuleDescriptor{ - Source: module.SourceTwilight, - ID: ModuleID, - Streams: []module.StreamDefinition{streamDefinition}, - Requires: []module.ModuleRequirement{ - {Source: module.SourceTwilight, Module: sessionstore.ModuleID, Events: []ledger.EventType{sessionstore.Prefix + "run_ended"}}, - {Source: module.SourceTwilight, Module: chatlog.ModuleID, Events: []ledger.EventType{chatlog.TypeInputDelivered}}, - }, - Events: []module.EventDefinition{ - def[StartedPayload](TypeStarted, func(p *StartedPayload) error { - if p.TurnID == "" || p.RunID == "" || p.Preset.ID == "" || p.Preset.Digest == "" { - return errors.New("started requires turnId, runId and preset") - } - 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: []module.ProjectionDefinition{SurfaceProjection}, -} diff --git a/agentcore/run/wire/codec.go b/agentcore/turn/wire/codec.go similarity index 66% rename from agentcore/run/wire/codec.go rename to agentcore/turn/wire/codec.go index a8aefedf..b0d54cf8 100644 --- a/agentcore/run/wire/codec.go +++ b/agentcore/turn/wire/codec.go @@ -7,7 +7,7 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/run" + "github.com/felinics/twilight/agentcore/turn" ) // payloadVersion is the version the typed fact payload bytes carry in @@ -17,31 +17,31 @@ const payloadVersion uint16 = 1 // CommandEnvelope carries one command with its protocol identity. Commands // are not persisted: ID is the CommitID of the commit the command produces, // and replay is told from conflict by the store's command index. The envelope names no store: which store a command reaches is -// the bound RunStore's. +// the bound TurnStore's. type CommandEnvelope struct { - Type string `json:"type"` - RunID run.RunID `json:"runId"` - ID run.CommandID `json:"id"` - Command run.AgentCommand `json:"command"` + Type string `json:"type"` + TurnID turn.TurnID `json:"turnId"` + ID turn.CommandID `json:"id"` + Command turn.AgentCommand `json:"command"` } type commandEnvelopeWire struct { Type string `json:"type"` - RunID run.RunID `json:"runId"` - ID run.CommandID `json:"id"` + TurnID turn.TurnID `json:"turnId"` + ID turn.CommandID `json:"id"` Command json.RawMessage `json:"command"` } type commandEnvelopeMarshal struct { - Type string `json:"type"` - RunID run.RunID `json:"runId"` - ID run.CommandID `json:"id"` - Command run.AgentCommand `json:"command"` + Type string `json:"type"` + TurnID turn.TurnID `json:"turnId"` + ID turn.CommandID `json:"id"` + Command turn.AgentCommand `json:"command"` } // DecodeCommandEnvelope decodes the command wire shape and restores the // sealed command variant from Type; malformed or unsupported wire data is -// rejected before it can enter a RunStore. +// rejected before it can enter a TurnStore. func DecodeCommandEnvelope(raw []byte) (CommandEnvelope, error) { var env CommandEnvelope if err := jsonstable.DecodeStrict(raw, &env); err != nil { @@ -63,7 +63,7 @@ func (e CommandEnvelope) MarshalJSON() ([]byte, error) { 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{Type: typ, RunID: e.RunID, ID: e.ID, Command: e.Command}) + return json.Marshal(commandEnvelopeMarshal{Type: typ, TurnID: e.TurnID, ID: e.ID, Command: e.Command}) } func (e *CommandEnvelope) UnmarshalJSON(raw []byte) error { @@ -77,11 +77,11 @@ func (e *CommandEnvelope) UnmarshalJSON(raw []byte) error { return err } if err := requireCanonicalEquivalent(raw, commandEnvelopeMarshal{ - Type: wire.Type, RunID: wire.RunID, ID: wire.ID, Command: cmd, + Type: wire.Type, TurnID: wire.TurnID, ID: wire.ID, Command: cmd, }); err != nil { return err } - *e = CommandEnvelope{Type: wire.Type, RunID: wire.RunID, ID: wire.ID, Command: cmd} + *e = CommandEnvelope{Type: wire.Type, TurnID: wire.TurnID, ID: wire.ID, Command: cmd} return nil } @@ -105,30 +105,30 @@ func requireCanonicalEquivalent(raw []byte, canonicalShape any) error { // Facts speaks through variants, its own frozen variant table. type Facts struct{} -func (Facts) FactType(f run.Fact) string { return variants.factType(f) } -func (Facts) CommandType(c run.AgentCommand) string { return variants.commandType(c) } -func (Facts) DecodeFact(typ string, raw []byte) (run.Fact, error) { +func (Facts) FactType(f turn.Fact) string { return variants.factType(f) } +func (Facts) CommandType(c turn.AgentCommand) string { return variants.commandType(c) } +func (Facts) DecodeFact(typ string, raw []byte) (turn.Fact, error) { return variants.decodeFact(typ, raw) } -func (Facts) DecodeCommand(typ string, raw []byte) (run.AgentCommand, error) { +func (Facts) DecodeCommand(typ string, raw []byte) (turn.AgentCommand, error) { return variants.decodeCommand(typ, raw) } -func (Facts) EncodeFact(typ string, fact run.Fact) ([]byte, error) { +func (Facts) EncodeFact(typ string, fact turn.Fact) ([]byte, error) { if typ == "" || typ != variants.factType(fact) { return nil, fmt.Errorf("agent: encode: type %q does not match fact variant", typ) } return jsonstable.EncodeTypedPayload(payloadVersion, typ, fact) } -func (Facts) Envelope(runID run.RunID, id run.CommandID, cmd run.AgentCommand) (CommandEnvelope, error) { +func (Facts) Envelope(turnID turn.TurnID, id turn.CommandID, cmd turn.AgentCommand) (CommandEnvelope, error) { typ := variants.commandType(cmd) if typ == "" { return CommandEnvelope{}, fmt.Errorf("agent: envelope: unknown command variant %T", cmd) } - return CommandEnvelope{Type: typ, RunID: runID, ID: id, Command: cmd}, nil + return CommandEnvelope{Type: typ, TurnID: turnID, ID: id, Command: cmd}, nil } -func (Snapshot) Encode(s *run.MachineState) ([]byte, error) { return encodeMachineState(s) } -func (Snapshot) Decode(raw []byte) (run.MachineState, error) { return decodeMachineState(raw) } +func (Snapshot) Encode(s *turn.MachineState) ([]byte, error) { return encodeMachineState(s) } +func (Snapshot) Decode(raw []byte) (turn.MachineState, error) { return decodeMachineState(raw) } diff --git a/agentcore/turn/wire/codec_test.go b/agentcore/turn/wire/codec_test.go new file mode 100644 index 00000000..b66234a5 --- /dev/null +++ b/agentcore/turn/wire/codec_test.go @@ -0,0 +1,177 @@ +package wire_test + +import ( + "encoding/json" + "fmt" + "github.com/felinics/twilight/agentcore/preset/ref" + "reflect" + "strings" + "testing" + + "github.com/felinics/twilight/agentcore/jsonstable" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/canonical" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/wire" +) + +func TestCommandEnvelopeJSONRoundTripRestoresVariants(t *testing.T) { + commands := []turn.AgentCommand{ + turn.PrepareModelRequest{StepID: "s", Model: "m", Request: model.ModelRequest{Model: "m"}, RequestDigest: "sha256:req"}, + turn.WithdrawPreparedStep{StepID: "s"}, + turn.StartModelExecution{StepID: "s", Effect: "effect-s"}, + turn.RecoverModelExecution{StepID: "s", Effect: "effect-s"}, + turn.SubmitModelResult{StepID: "s", Effect: "effect-s", Result: model.ModelResult{Text: "ok"}}, + turn.SubmitModelFailure{StepID: "s", Effect: "effect-s", Failure: turn.StepFailure{Class: turn.FailureProvider, Message: "down"}}, + turn.RejectModelResult{StepID: "s", Effect: "effect-s", Usage: model.Usage{TotalTokens: 1}, Failure: turn.StepFailure{Class: turn.FailureMalformedModel}}, + turn.StartToolCall{StepID: "ts", CallID: "c", Effect: "effect-c"}, + turn.SubmitToolResult{StepID: "ts", CallID: "c", Effect: "effect-c", Result: turn.ToolExecutionResult{Output: cj(`{"ok":true}`)}}, + turn.SubmitToolFailure{StepID: "ts", CallID: "c", Effect: "effect-c", Failure: turn.ToolFailure{Class: turn.FailureExecution}, Outcome: turn.ToolOutcomeKnown}, + turn.DeclineToolCall{StepID: "ts", CallID: "c", Failure: turn.ToolFailure{Class: turn.FailureToolLookup, Message: "no such tool"}}, + turn.ApproveToolCall{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp"}, + turn.RejectToolCall{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp", Reason: "no"}, + turn.SubmitToolResponse{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp", Payload: cj(`{"answer":1}`)}, + turn.CancelTurn{}, + turn.NextStep(turn.AgentInput{ID: "in", Digest: inputDigest(`{"q":"hi"}`)}), + } + for _, cmd := range commands { + env, err := (wire.Facts{}).Envelope("run-1", turn.CommandID("cmd-"+(wire.Facts{}).CommandType(cmd)), cmd) + if err != nil { + t.Fatalf("ProtocolV1().BuildEnvelope(%T): %v", cmd, err) + } + raw, err := json.Marshal(env) + if err != nil { + t.Fatalf("Marshal(%T): %v", cmd, err) + } + decoded, err := wire.DecodeCommandEnvelope(raw) + if err != nil { + t.Fatalf("DecodeCommandEnvelope(%T): %v\n%s", cmd, err, raw) + } + if reflect.TypeOf(decoded.Command) != reflect.TypeOf(cmd) { + t.Fatalf("decoded command type = %T, want %T", decoded.Command, cmd) + } + if decoded.Type != env.Type || decoded.ID != env.ID { + t.Fatalf("decoded envelope = %+v, want %+v", decoded, env) + } + } +} + +// Every fact variant round-trips through the v1 fact codec: canonical bytes +// decode back to the same variant and re-encode to the same bytes. +func TestFactCodecRoundTripRestoresVariants(t *testing.T) { + facts := []turn.Fact{ + turn.TurnStarted{Preset: ref.Ref{ID: "test", Digest: "sha256:test"}, TurnID: "run-1", CausationID: "cause"}, + turn.ModelStepPrepared{StepID: "s", Model: "m", RequestDigest: "sha256:req"}, + turn.ModelStepWithdrawn{StepID: "s"}, + turn.ModelStepStarted{StepID: "s", Effect: "effect-m"}, + turn.ModelStepRecovered{StepID: "s"}, + turn.ModelStepRejected{StepID: "s", Effect: "effect-m", Usage: model.Usage{TotalTokens: 1}, Failure: turn.StepFailure{Class: turn.FailureMalformedModel}}, + turn.ModelStepFailed{StepID: "s", Effect: "effect-m", Failure: turn.StepFailure{Class: turn.FailureProvider, Message: "down"}}, + turn.ModelStepCompleted{StepID: "s", Usage: model.Usage{TotalTokens: 1}, FinishReason: model.FinishReasonStop, ResultDigest: "sha256:result"}, + turn.ToolStepOpened{StepID: "ts", Source: "s", Calls: []turn.ToolCallBinding{{CallID: "c", ToolRef: "t", Arguments: cj(`{}`), Policy: turn.DirectExecution}}}, + turn.ToolCallStarted{StepID: "ts", CallID: "c", Effect: "effect-t"}, + turn.ToolCallApproved{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp"}, + turn.ToolCallCompleted{StepID: "ts", CallID: "c", OutputDigest: "sha256:output"}, + turn.ToolCallAnswered{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp"}, + turn.ToolCallFailed{StepID: "ts", CallID: "c", Failure: turn.ToolFailure{Class: turn.FailureExecution}, Outcome: turn.ToolOutcomeKnown}, + turn.InputAccepted{Input: turn.AgentInput{ID: "in", Digest: inputDigest(`{"q":"hi"}`)}}, + turn.TurnEnded{End: turn.TurnCompletedEnd{}}, + } + for _, fact := range facts { + typ := wire.FactType(fact) + raw, err := jsonstable.MarshalCanonical(fact) + if err != nil { + t.Fatalf("marshal(%T): %v", fact, err) + } + decoded, err := (wire.Facts{}).DecodeFact(typ, raw) + if err != nil { + t.Fatalf("DecodeFact(%T): %v\n%s", fact, err, raw) + } + if reflect.TypeOf(decoded) != reflect.TypeOf(fact) { + t.Fatalf("decoded fact type = %T, want %T", decoded, fact) + } + again, err := jsonstable.MarshalCanonical(decoded) + if err != nil || string(again) != string(raw) { + t.Fatalf("re-encode of %T differs:\n%s\n%s", fact, raw, again) + } + if _, err := (wire.Facts{}).DecodeFact("unknown", raw); err == nil { + t.Fatalf("unknown fact type decoded for %T", fact) + } + } +} + +func TestWireCodecRejectsAmbiguousJSONBeforeVariantDecode(t *testing.T) { + cmd := turn.NextStep(turn.AgentInput{ID: "in", Digest: inputDigest(`1`)}) + env, err := (wire.Facts{}).Envelope("run-1", (canonical.Identity{}).DeriveInputCommandID("run-1", "in"), cmd) + if err != nil { + t.Fatal(err) + } + raw := []byte(fmt.Sprintf(`{"schemaVersion":1,"type":"accept_input","turnId":"run-1","id":%q,"command":{"inputs":[{"id":"in","payload":1}],"inputs":[{"id":"in","payload":1}]}}`, env.ID)) + if _, err := wire.DecodeCommandEnvelope(raw); err == nil { + t.Fatal("duplicate key command decoded") + } + + canon, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + wrongCase := strings.Replace(string(canon), `"command":`, `"Command":`, 1) + if _, err := wire.DecodeCommandEnvelope([]byte(wrongCase)); err == nil { + t.Fatal("case-insensitive command field decoded") + } +} + +func TestWireCodecRejectsUnknownType(t *testing.T) { + env, err := (wire.Facts{}).Envelope("run-1", "cmd-1", turn.CancelTurn{}) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + badType := strings.Replace(string(raw), `"type":"cancel_turn"`, `"type":"unknown"`, 1) + if _, err := wire.DecodeCommandEnvelope([]byte(badType)); err == nil { + t.Fatal("unknown command type decoded") + } +} + +// TurnEnded wire is a tagged union: exactly one variant key. +func TestTurnEndedWireIsTaggedUnion(t *testing.T) { + cases := map[string]turn.TurnEnded{ + "completed": {End: turn.TurnCompletedEnd{}}, + "stopped": {End: turn.TurnStoppedEnd{Reason: turn.ReasonCancelled, UncertainCalls: []turn.CallID{"c1"}}}, + "failed": {End: turn.TurnFailedEnd{Reason: turn.ReasonProviderFailure, Failure: turn.TurnFailure{Class: turn.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 turn.TurnEnded + 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 turn.TurnEnded + if err := json.Unmarshal([]byte(raw), &back); err == nil { + t.Fatalf("%s accepted: %s", name, raw) + } + } +} diff --git a/agentcore/run/wire/doc.go b/agentcore/turn/wire/doc.go similarity index 65% rename from agentcore/run/wire/doc.go rename to agentcore/turn/wire/doc.go index c6666ea4..7209b731 100644 --- a/agentcore/run/wire/doc.go +++ b/agentcore/turn/wire/doc.go @@ -1,7 +1,7 @@ -// Package wire is the persisted protocol of Run facts and commands: the sealed +// Package wire is the persisted protocol of Turn facts and commands: the sealed // variant registry of each schema version, the typed fact payloads and the // command envelope, and the codec that stores a // MachineState as a durable snapshot. // Decoding is strict: unknown fields, unknown discriminators and trailing data -// are rejected before wire data can enter a RunStore. +// are rejected before wire data can enter a TurnStore. package wire diff --git a/agentcore/run/wire/helpers_test.go b/agentcore/turn/wire/helpers_test.go similarity index 62% rename from agentcore/run/wire/helpers_test.go rename to agentcore/turn/wire/helpers_test.go index 8157fc02..0a2dc073 100644 --- a/agentcore/run/wire/helpers_test.go +++ b/agentcore/turn/wire/helpers_test.go @@ -3,29 +3,29 @@ package wire_test import ( "testing" - "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/schema" + "github.com/felinics/twilight/agentcore/testutil/sdkconv" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/schema" "github.com/felinics/twilight/sdk" "github.com/google/jsonschema-go/jsonschema" ) // startModel is the start of the current Prepared step's next model effect // the step's rejected count is the effect's sequence. -func startModel(s run.MachineState, stepID run.StepID) run.StartModelExecution { - ms, _ := s.Current.(run.ModelStep) - return run.StartModelExecution{StepID: stepID, Effect: schema.Identity().DeriveEffectID(s.RunID, stepID, "", ms.Rejects)} +func startModel(s turn.MachineState, stepID turn.StepID) turn.StartModelExecution { + ms, _ := s.Current.(turn.ModelStep) + return turn.StartModelExecution{StepID: stepID, Effect: schema.Identity().DeriveEffectID(s.TurnID, stepID, "", ms.Rejects)} } // startTool is the start of a call's one tool effect. -func startTool(s run.MachineState, stepID run.StepID, callID run.CallID) run.StartToolCall { - return run.StartToolCall{StepID: stepID, CallID: callID, Effect: schema.Identity().DeriveEffectID(s.RunID, stepID, callID, 0)} +func startTool(s turn.MachineState, stepID turn.StepID, callID turn.CallID) turn.StartToolCall { + return turn.StartToolCall{StepID: stepID, CallID: callID, Effect: schema.Identity().DeriveEffectID(s.TurnID, stepID, callID, 0)} } // advance runs prepare+start and returns the state in Executing plus stepID. -func advanceToExecuting(t *testing.T, s run.MachineState, req sdk.Request, specs []run.ToolSpec) (run.MachineState, run.StepID) { +func advanceToExecuting(t *testing.T, s turn.MachineState, req sdk.Request, specs []turn.ToolSpec) (turn.MachineState, turn.StepID) { t.Helper() prep, _ := buildPrepare(t, s, req, specs) s = fold(t, s, mustDecide(t, s, prep)) @@ -34,13 +34,13 @@ func advanceToExecuting(t *testing.T, s run.MachineState, req sdk.Request, specs } // cid is the derived CallID of the index-th call of a step. -func cid(step run.StepID, index int) run.CallID { +func cid(step turn.StepID, index int) turn.CallID { return schema.Identity().DeriveCallID(step, index) } -func cj(raw string) run.CanonicalJSON { return run.MustParseCanonicalJSON(raw) } +func cj(raw string) turn.CanonicalJSON { return turn.MustParseCanonicalJSON(raw) } -func fold(t *testing.T, s run.MachineState, facts []run.Fact) run.MachineState { +func fold(t *testing.T, s turn.MachineState, facts []turn.Fact) turn.MachineState { t.Helper() for _, f := range facts { var err error @@ -52,17 +52,17 @@ func fold(t *testing.T, s run.MachineState, facts []run.Fact) run.MachineState { return s } -// inputDigest names an input body in tests: the Run stores only the digest, +// inputDigest names an input body in tests: the Turn stores only the digest, // so any content-derived digest stands in for the chatlog's. -func inputDigest(raw string) run.Digest { return jsonstable.DigestBytes([]byte(raw)) } +func inputDigest(raw string) turn.Digest { return jsonstable.DigestBytes([]byte(raw)) } // 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 run.StepID, index int, providerID string, spec run.ToolSpec, args string) run.ToolCallBinding { +func makeBinding(t *testing.T, source turn.StepID, index int, providerID string, spec turn.ToolSpec, args string) turn.ToolCallBinding { t.Helper() parsedArgs := cj(args) callID := schema.Identity().DeriveCallID(source, index) - return run.ToolCallBinding{ + return turn.ToolCallBinding{ CallID: callID, ProviderCallID: providerID, ToolRef: spec.Ref, @@ -72,7 +72,7 @@ func makeBinding(t *testing.T, source run.StepID, index int, providerID string, } } -func makeSpec(t *testing.T, def sdk.ToolDefinition, policy run.ResponsePolicy) run.ToolSpec { +func makeSpec(t *testing.T, def sdk.ToolDefinition, policy turn.ResponsePolicy) turn.ToolSpec { t.Helper() frozen, err := sdkconv.FreezeToolDefinition(def) if err != nil { @@ -82,14 +82,14 @@ func makeSpec(t *testing.T, def sdk.ToolDefinition, policy run.ResponsePolicy) r if err != nil { t.Fatal(err) } - return run.ToolSpec{Ref: run.ToolRef(def.Name), Name: def.Name, DefinitionDigest: d, Policy: policy} + return turn.ToolSpec{Ref: turn.ToolRef(def.Name), Name: def.Name, DefinitionDigest: d, Policy: policy} } func modelResultWithCalls(callIDs ...string) model.ModelResult { return modelResultWithNamedCalls("t", `{}`, callIDs...) } -func mustDecide(t *testing.T, s run.MachineState, c run.AgentCommand) []run.Fact { +func mustDecide(t *testing.T, s turn.MachineState, c turn.AgentCommand) []turn.Fact { t.Helper() facts, err := schema.Machine().Decide(s, settling(s, c)) if err != nil { @@ -101,29 +101,29 @@ func mustDecide(t *testing.T, s run.MachineState, c run.AgentCommand) []run.Fact // settling fills the Effect of a settlement command from the state it // settles: the executing model effect, or the executing effect of the named // call. Tests that probe the effect check pass it explicitly. -func settling(s run.MachineState, c run.AgentCommand) run.AgentCommand { +func settling(s turn.MachineState, c turn.AgentCommand) turn.AgentCommand { switch cmd := c.(type) { - case run.SubmitModelResult: + case turn.SubmitModelResult: if cmd.Effect == "" { cmd.Effect = modelEffectOf(s) } return cmd - case run.SubmitModelFailure: + case turn.SubmitModelFailure: if cmd.Effect == "" { cmd.Effect = modelEffectOf(s) } return cmd - case run.RejectModelResult: + case turn.RejectModelResult: if cmd.Effect == "" { cmd.Effect = modelEffectOf(s) } return cmd - case run.SubmitToolResult: + case turn.SubmitToolResult: if cmd.Effect == "" { cmd.Effect = toolEffectOf(s, cmd.CallID) } return cmd - case run.SubmitToolFailure: + case turn.SubmitToolFailure: if cmd.Effect == "" { cmd.Effect = toolEffectOf(s, cmd.CallID) } @@ -134,15 +134,15 @@ func settling(s run.MachineState, c run.AgentCommand) run.AgentCommand { } // modelEffectOf is the effect the current ModelStep is executing. -func modelEffectOf(s run.MachineState) run.EffectID { - ms, _ := s.Current.(run.ModelStep) +func modelEffectOf(s turn.MachineState) turn.EffectID { + ms, _ := s.Current.(turn.ModelStep) return ms.Effect } // toolEffectOf is the effect the named call of the current ToolStep is // executing. -func toolEffectOf(s run.MachineState, callID run.CallID) run.EffectID { - ts, _ := s.Current.(run.ToolStep) +func toolEffectOf(s turn.MachineState, callID turn.CallID) turn.EffectID { + ts, _ := s.Current.(turn.ToolStep) for _, call := range ts.Calls { if call.CallID == callID { return call.Effect @@ -151,13 +151,13 @@ func toolEffectOf(s run.MachineState, callID run.CallID) run.EffectID { return "" } -func newRun(t *testing.T) run.MachineState { +func newTurn(t *testing.T) turn.MachineState { t.Helper() - s, err := run.InitializeRun("run-1") + s, err := turn.InitializeTurn("run-1") if err != nil { t.Fatal(err) } - return fold(t, s, mustDecide(t, s, run.NextStep(run.AgentInput{ID: "seed", Digest: inputDigest(`{"q":"hi"}`)}))) + return fold(t, s, mustDecide(t, s, turn.NextStep(turn.AgentInput{ID: "seed", Digest: inputDigest(`{"q":"hi"}`)}))) } func testRequest(tools ...sdk.ToolDefinition) sdk.Request { @@ -172,7 +172,7 @@ func testToolDef(name string) sdk.ToolDefinition { return sdk.ToolDefinition{Name: name, Parameters: &jsonschema.Schema{Type: "object"}} } -func buildPrepare(t *testing.T, s run.MachineState, req sdk.Request, specs []run.ToolSpec) (run.PrepareModelRequest, run.CommandID) { +func buildPrepare(t *testing.T, s turn.MachineState, req sdk.Request, specs []turn.ToolSpec) (turn.PrepareModelRequest, turn.CommandID) { t.Helper() frozenReq, err := sdkconv.FreezeModelRequest(req) if err != nil { @@ -182,14 +182,14 @@ func buildPrepare(t *testing.T, s run.MachineState, req sdk.Request, specs []run if err != nil { t.Fatal(err) } - modelRef := run.ModelRef(frozenReq.Model) - cmdID := schema.Identity().DeriveModelRequestCommandID(s.RunID, 0) - stepID := schema.Identity().DeriveModelStepID(s.RunID, cmdID) - ids := make([]run.InputID, len(s.PendingInputs)) + modelRef := turn.ModelRef(frozenReq.Model) + cmdID := schema.Identity().DeriveModelRequestCommandID(s.TurnID, 0) + stepID := schema.Identity().DeriveModelStepID(s.TurnID, cmdID) + ids := make([]turn.InputID, len(s.PendingInputs)) for i, in := range s.PendingInputs { ids[i] = in.ID } - return run.PrepareModelRequest{ + return turn.PrepareModelRequest{ StepID: stepID, Model: modelRef, Request: frozenReq, diff --git a/agentcore/run/wire/registry.go b/agentcore/turn/wire/registry.go similarity index 61% rename from agentcore/run/wire/registry.go rename to agentcore/turn/wire/registry.go index 9e87e341..2571489a 100644 --- a/agentcore/run/wire/registry.go +++ b/agentcore/turn/wire/registry.go @@ -6,7 +6,7 @@ import ( "reflect" "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/run" + "github.com/felinics/twilight/agentcore/turn" ) // The sealed fact and command variants of one schema version are registered @@ -21,27 +21,27 @@ import ( type factVariant struct { name string goType reflect.Type - decode func([]byte) (run.Fact, error) + decode func([]byte) (turn.Fact, error) } type commandVariant struct { name string goType reflect.Type - decode func([]byte) (run.AgentCommand, error) + decode func([]byte) (turn.AgentCommand, error) } -func factOf[T run.Fact](name string) factVariant { +func factOf[T turn.Fact](name string) factVariant { var zero T - return factVariant{name: name, goType: reflect.TypeOf(zero), decode: func(raw []byte) (run.Fact, error) { + return factVariant{name: name, goType: reflect.TypeOf(zero), decode: func(raw []byte) (turn.Fact, error) { var f T err := jsonstable.DecodeStrict(raw, &f) return f, err }} } -func commandOf[T run.AgentCommand](name string) commandVariant { +func commandOf[T turn.AgentCommand](name string) commandVariant { var zero T - return commandVariant{name: name, goType: reflect.TypeOf(zero), decode: func(raw []byte) (run.AgentCommand, error) { + return commandVariant{name: name, goType: reflect.TypeOf(zero), decode: func(raw []byte) (turn.AgentCommand, error) { var c T err := jsonstable.DecodeStrict(raw, &c) return c, err @@ -50,42 +50,42 @@ func commandOf[T run.AgentCommand](name string) commandVariant { // factVariants is the closed list of facts in wire-registration order. var factVariants = []factVariant{ - factOf[run.RunCreated]("run_created"), - factOf[run.ModelStepPrepared]("model_step_prepared"), - factOf[run.ModelStepWithdrawn]("model_step_withdrawn"), - factOf[run.ModelStepStarted]("model_step_started"), - factOf[run.ModelStepRecovered]("model_step_recovered"), - factOf[run.ModelStepRejected]("model_step_rejected"), - factOf[run.ModelStepFailed]("model_step_failed"), - factOf[run.ModelStepCompleted]("model_step_completed"), - factOf[run.ToolStepOpened]("tool_step_opened"), - factOf[run.ToolCallStarted]("tool_call_started"), - factOf[run.ToolCallApproved]("tool_call_approved"), - factOf[run.ToolCallCompleted]("tool_call_completed"), - factOf[run.ToolCallAnswered]("tool_call_answered"), - factOf[run.ToolCallFailed]("tool_call_failed"), - factOf[run.InputAccepted]("input_accepted"), - factOf[run.RunEnded]("run_ended"), + factOf[turn.TurnStarted]("turn_started"), + factOf[turn.ModelStepPrepared]("model_step_prepared"), + factOf[turn.ModelStepWithdrawn]("model_step_withdrawn"), + factOf[turn.ModelStepStarted]("model_step_started"), + factOf[turn.ModelStepRecovered]("model_step_recovered"), + factOf[turn.ModelStepRejected]("model_step_rejected"), + factOf[turn.ModelStepFailed]("model_step_failed"), + factOf[turn.ModelStepCompleted]("model_step_completed"), + factOf[turn.ToolStepOpened]("tool_step_opened"), + factOf[turn.ToolCallStarted]("tool_call_started"), + factOf[turn.ToolCallApproved]("tool_call_approved"), + factOf[turn.ToolCallCompleted]("tool_call_completed"), + factOf[turn.ToolCallAnswered]("tool_call_answered"), + factOf[turn.ToolCallFailed]("tool_call_failed"), + factOf[turn.InputAccepted]("input_accepted"), + factOf[turn.TurnEnded]("turn_ended"), } // commandVariants is the closed list of commands. var commandVariants = []commandVariant{ - commandOf[run.PrepareModelRequest]("prepare_model_request"), - commandOf[run.WithdrawPreparedStep]("withdraw_prepared_step"), - commandOf[run.StartModelExecution]("start_model_execution"), - commandOf[run.RecoverModelExecution]("recover_model_execution"), - commandOf[run.SubmitModelResult]("submit_model_result"), - commandOf[run.SubmitModelFailure]("submit_model_failure"), - commandOf[run.RejectModelResult]("reject_model_result"), - commandOf[run.StartToolCall]("start_tool_call"), - commandOf[run.SubmitToolResult]("submit_tool_result"), - commandOf[run.SubmitToolFailure]("submit_tool_failure"), - commandOf[run.DeclineToolCall]("decline_tool_call"), - commandOf[run.ApproveToolCall]("approve_tool_call"), - commandOf[run.RejectToolCall]("reject_tool_call"), - commandOf[run.SubmitToolResponse]("submit_tool_response"), - commandOf[run.CancelRun]("cancel_run"), - commandOf[run.AcceptInput]("accept_input"), + commandOf[turn.PrepareModelRequest]("prepare_model_request"), + commandOf[turn.WithdrawPreparedStep]("withdraw_prepared_step"), + commandOf[turn.StartModelExecution]("start_model_execution"), + commandOf[turn.RecoverModelExecution]("recover_model_execution"), + commandOf[turn.SubmitModelResult]("submit_model_result"), + commandOf[turn.SubmitModelFailure]("submit_model_failure"), + commandOf[turn.RejectModelResult]("reject_model_result"), + commandOf[turn.StartToolCall]("start_tool_call"), + commandOf[turn.SubmitToolResult]("submit_tool_result"), + commandOf[turn.SubmitToolFailure]("submit_tool_failure"), + commandOf[turn.DeclineToolCall]("decline_tool_call"), + commandOf[turn.ApproveToolCall]("approve_tool_call"), + commandOf[turn.RejectToolCall]("reject_tool_call"), + commandOf[turn.SubmitToolResponse]("submit_tool_response"), + commandOf[turn.CancelTurn]("cancel_turn"), + commandOf[turn.AcceptInput]("accept_input"), } // variantRegistry is one schema version's closed variant table. @@ -120,14 +120,14 @@ func newVariantRegistry(facts []factVariant, commands []commandVariant) *variant // variants is the variant registry; Facts speaks through it. var variants = newVariantRegistry(factVariants, commandVariants) -func (r *variantRegistry) factType(f run.Fact) string { +func (r *variantRegistry) factType(f turn.Fact) string { if f == nil { return "" } return r.factByType[reflect.TypeOf(f)] } -func (r *variantRegistry) commandType(c run.AgentCommand) string { +func (r *variantRegistry) commandType(c turn.AgentCommand) string { if c == nil { return "" } @@ -146,7 +146,7 @@ func emptyBody(raw []byte) bool { return len(raw) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) } -func (r *variantRegistry) decodeFact(typ string, raw []byte) (run.Fact, error) { +func (r *variantRegistry) decodeFact(typ string, raw []byte) (turn.Fact, error) { if emptyBody(raw) { return nil, fmt.Errorf("agent: codec: fact %q has empty body", typ) } @@ -157,7 +157,7 @@ func (r *variantRegistry) decodeFact(typ string, raw []byte) (run.Fact, error) { return v.decode(raw) } -func (r *variantRegistry) decodeCommand(typ string, raw []byte) (run.AgentCommand, error) { +func (r *variantRegistry) decodeCommand(typ string, raw []byte) (turn.AgentCommand, error) { if emptyBody(raw) { return nil, fmt.Errorf("agent: codec: command %q has empty body", typ) } @@ -174,7 +174,7 @@ var allVariants = []*variantRegistry{variants} // FactTypes lists every fact discriminator any schema version registers, in // registration order and without duplicates: the closed set of -// twilight/run/ wire names a Session module registers. +// twilight/turn/ wire names a Session module registers. func FactTypes() []string { var out []string seen := map[string]bool{} @@ -190,9 +190,9 @@ func FactTypes() []string { } // FactType returns the local event name of a fact (the part of the EventType -// after twilight/run/): the first schema version that knows the variant -// names it. Callers with a Run's schema in hand use Schema.Wire.FactType. -func FactType(f run.Fact) string { +// after twilight/turn/): the first schema version that knows the variant +// names it. Callers with a Turn's schema in hand use Schema.Wire.FactType. +func FactType(f turn.Fact) string { for _, r := range allVariants { if name := r.factType(f); name != "" { return name diff --git a/agentcore/run/wire/snapshot.go b/agentcore/turn/wire/snapshot.go similarity index 52% rename from agentcore/run/wire/snapshot.go rename to agentcore/turn/wire/snapshot.go index 08831b98..2097266a 100644 --- a/agentcore/run/wire/snapshot.go +++ b/agentcore/turn/wire/snapshot.go @@ -6,14 +6,15 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/preset/ref" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" ) // StatesEquivalent compares two states via their canonical snapshot encoding. -func StatesEquivalent(a, b *run.MachineState) bool { return statesEquivalent(a, b) } +func StatesEquivalent(a, b *turn.MachineState) bool { return statesEquivalent(a, b) } -func statesEquivalent(a, b *run.MachineState) bool { +func statesEquivalent(a, b *turn.MachineState) bool { ab, errA := encodeMachineState(a) bb, errB := encodeMachineState(b) if errA != nil || errB != nil { @@ -32,18 +33,19 @@ type Snapshot struct{} // canonical bytes are the InitialStateDigest preimage, so field // names and omission rules are frozen with the schema. type machineStateWire struct { - RunID run.RunID `json:"runId"` - Status run.RunStatus `json:"status"` - ModelSteps int `json:"modelSteps"` - Usage model.Usage `json:"usage"` - PendingInputs []run.AgentInput `json:"pendingInputs"` - Result *run.RunResult `json:"result"` - LastToolStep *run.ToolStep `json:"lastToolStep,omitempty"` - // Current is "open", "model" or "tool" for an active Run and absent for a + Preset ref.Ref `json:"preset"` + TurnID turn.TurnID `json:"turnId"` + Status turn.TurnStatus `json:"status"` + ModelSteps int `json:"modelSteps"` + Usage model.Usage `json:"usage"` + PendingInputs []turn.AgentInput `json:"pendingInputs"` + Result *turn.TurnResult `json:"result"` + LastToolStep *turn.ToolStep `json:"lastToolStep,omitempty"` + // Current is "open", "model" or "tool" for an active Turn and absent for a // terminal one. - Current string `json:"current,omitempty"` - ModelStep *run.ModelStep `json:"modelStep,omitempty"` - ToolStep *run.ToolStep `json:"toolStep,omitempty"` + Current string `json:"current,omitempty"` + ModelStep *turn.ModelStep `json:"modelStep,omitempty"` + ToolStep *turn.ToolStep `json:"toolStep,omitempty"` } const ( @@ -52,20 +54,20 @@ const ( currentWireTool = "tool" ) -func machineStateToWire(s *run.MachineState) (machineStateWire, error) { +func machineStateToWire(s *turn.MachineState) (machineStateWire, error) { w := machineStateWire{ - RunID: s.RunID, Status: s.Status, ModelSteps: s.ModelSteps, + Preset: s.Preset, TurnID: s.TurnID, Status: s.Status, ModelSteps: s.ModelSteps, Usage: s.Usage, PendingInputs: s.PendingInputs, Result: s.Result, LastToolStep: s.LastToolStep, } switch cur := s.Current.(type) { case nil: - case run.Open: + case turn.Open: w.Current = currentWireOpen - case run.ModelStep: + case turn.ModelStep: w.Current = currentWireModel w.ModelStep = &cur - case run.ToolStep: + case turn.ToolStep: w.Current = currentWireTool w.ToolStep = &cur default: @@ -74,40 +76,40 @@ func machineStateToWire(s *run.MachineState) (machineStateWire, error) { return w, nil } -func machineStateFromWire(w *machineStateWire) (run.MachineState, error) { - s := run.MachineState{ - RunID: w.RunID, Status: w.Status, ModelSteps: w.ModelSteps, +func machineStateFromWire(w *machineStateWire) (turn.MachineState, error) { + s := turn.MachineState{ + Preset: w.Preset, TurnID: w.TurnID, Status: w.Status, ModelSteps: w.ModelSteps, Usage: w.Usage, PendingInputs: w.PendingInputs, Result: w.Result, LastToolStep: w.LastToolStep, } switch w.Current { case "": if w.ModelStep != nil || w.ToolStep != nil { - return run.MachineState{}, errors.New("agent: snapshot: step body without current discriminator") + return turn.MachineState{}, errors.New("agent: snapshot: step body without current discriminator") } case currentWireOpen: if w.ModelStep != nil || w.ToolStep != nil { - return run.MachineState{}, errors.New("agent: snapshot: open state carries a step body") + return turn.MachineState{}, errors.New("agent: snapshot: open state carries a step body") } - s.Current = run.Open{} + s.Current = turn.Open{} case currentWireModel: if w.ModelStep == nil || w.ToolStep != nil { - return run.MachineState{}, errors.New("agent: snapshot: model current requires exactly a modelStep body") + return turn.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 run.MachineState{}, errors.New("agent: snapshot: tool current requires exactly a toolStep body") + return turn.MachineState{}, errors.New("agent: snapshot: tool current requires exactly a toolStep body") } s.Current = *w.ToolStep default: - return run.MachineState{}, fmt.Errorf("agent: snapshot: unknown current %q", w.Current) + return turn.MachineState{}, fmt.Errorf("agent: snapshot: unknown current %q", w.Current) } return s, nil } // encodeMachineState renders the canonical v1 snapshot bytes. -func encodeMachineState(s *run.MachineState) ([]byte, error) { +func encodeMachineState(s *turn.MachineState) ([]byte, error) { if s == nil { return nil, errors.New("agent: snapshot: nil state") } @@ -121,20 +123,20 @@ func encodeMachineState(s *run.MachineState) ([]byte, error) { // decodeMachineState parses v1 snapshot bytes, rejecting unknown fields, // trailing data, and non-canonical-equivalent wire, then validates the // structural invariants of the restored state. -func decodeMachineState(raw []byte) (run.MachineState, error) { +func decodeMachineState(raw []byte) (turn.MachineState, error) { var w machineStateWire if err := jsonstable.DecodeStrict(raw, &w); err != nil { - return run.MachineState{}, fmt.Errorf("agent: snapshot: %w", err) + return turn.MachineState{}, fmt.Errorf("agent: snapshot: %w", err) } s, err := machineStateFromWire(&w) if err != nil { - return run.MachineState{}, err + return turn.MachineState{}, err } if err := requireCanonicalEquivalent(raw, w); err != nil { - return run.MachineState{}, fmt.Errorf("agent: snapshot: %w", err) + return turn.MachineState{}, fmt.Errorf("agent: snapshot: %w", err) } - if err := run.ValidateMachineState(&s); err != nil { - return run.MachineState{}, err + if err := turn.ValidateMachineState(&s); err != nil { + return turn.MachineState{}, err } return s, nil } diff --git a/agentcore/run/wire/snapshot_test.go b/agentcore/turn/wire/snapshot_test.go similarity index 69% rename from agentcore/run/wire/snapshot_test.go rename to agentcore/turn/wire/snapshot_test.go index 253c85f6..10875ce2 100644 --- a/agentcore/run/wire/snapshot_test.go +++ b/agentcore/turn/wire/snapshot_test.go @@ -4,16 +4,16 @@ import ( "strings" "testing" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/wire" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/wire" ) // 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, run.DirectExecution) - check := func(name string, s run.MachineState) { + spec := makeSpec(t, def, turn.DirectExecution) + check := func(name string, s turn.MachineState) { t.Helper() raw, err := (wire.Snapshot{}).Encode(&s) if err != nil { @@ -31,25 +31,25 @@ func TestSnapshotCodecRoundTrip(t *testing.T) { } } - s := newRun(t) + s := newTurn(t) check("open", s) - s, stepID := advanceToExecuting(t, s, testRequest(def), []run.ToolSpec{spec}) + s, stepID := advanceToExecuting(t, s, testRequest(def), []turn.ToolSpec{spec}) check("model executing", s) b := makeBinding(t, stepID, 0, "c1", spec, `{}`) - facts := mustDecide(t, s, run.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []run.ToolCallBinding{b}}) + facts := mustDecide(t, s, turn.SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []turn.ToolCallBinding{b}}) s = fold(t, s, facts) check("tool step pending", s) - toolStep := facts[1].(run.ToolStepOpened).StepID + toolStep := facts[1].(turn.ToolStepOpened).StepID s = fold(t, s, mustDecide(t, s, startTool(s, toolStep, cid(stepID, 0)))) check("tool step executing", s) - s = fold(t, s, mustDecide(t, s, run.SubmitToolResult{StepID: toolStep, CallID: cid(stepID, 0), Result: run.ToolExecutionResult{Output: cj(`"ok"`)}})) + s = fold(t, s, mustDecide(t, s, turn.SubmitToolResult{StepID: toolStep, CallID: cid(stepID, 0), Result: turn.ToolExecutionResult{Output: cj(`"ok"`)}})) check("open with last tool step", s) - s = fold(t, s, mustDecide(t, s, run.CancelRun{})) + s = fold(t, s, mustDecide(t, s, turn.CancelTurn{})) check("terminal", s) } func TestSnapshotCodecRejectsMalformedWire(t *testing.T) { - initial, err := run.InitializeRun("run-1") + initial, err := turn.InitializeTurn("run-1") if err != nil { t.Fatal(err) } @@ -58,7 +58,7 @@ func TestSnapshotCodecRejectsMalformedWire(t *testing.T) { t.Fatal(err) } for name, raw := range map[string]string{ - "unknown field": strings.Replace(string(good), `"runId"`, `"extra":1,"runId"`, 1), + "unknown field": strings.Replace(string(good), `"turnId"`, `"extra":1,"turnId"`, 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), From 76d2610b84f553db43d8ab77a8abe6f17b525742 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 17 Oct 2026 20:58:53 +0800 Subject: [PATCH 2/5] refactor(agentcore): adapt runtime and execution layers to Turn --- agentcore/chatlog/chatlog.go | 80 +++++--------- agentcore/chatlog/compaction.go | 2 +- agentcore/chatlog/input.go | 4 +- agentcore/chatlog/materialize.go | 28 ++--- agentcore/chatlog/projection.go | 144 +++++++++++-------------- agentcore/chatlog/read.go | 4 +- agentcore/chatlog/service.go | 54 ++++------ agentcore/chatlog/table.go | 3 +- agentcore/compaction/compaction.go | 22 ++-- agentcore/driver/budget.go | 8 +- agentcore/driver/compaction.go | 32 +++--- agentcore/driver/driver.go | 49 +++++---- agentcore/driver/loops.go | 59 ++++------ agentcore/executor/backend.go | 10 +- agentcore/executor/discovery.go | 2 +- agentcore/executor/notice/notice.go | 2 +- agentcore/executor/notices.go | 2 +- agentcore/executor/progress.go | 2 +- agentcore/executor/protocol/types.go | 30 +++--- agentcore/executor/settlement.go | 2 +- agentcore/executor/store/discovery.go | 2 +- agentcore/executor/store/seed.go | 2 +- agentcore/executor/store/store.go | 6 +- agentcore/executor/supervise.go | 10 +- agentcore/executor/worker.go | 17 +-- agentcore/history/history.go | 13 +-- agentcore/observe/bus.go | 6 +- agentcore/preset/preset.go | 40 ++++--- agentcore/prompt/budget.go | 2 +- agentcore/prompt/builder.go | 34 +++--- agentcore/runtime/execution.go | 22 ++-- agentcore/runtime/runtime.go | 22 ++-- agentcore/runtime/work.go | 14 +-- agentcore/session/unit/unit.go | 12 ++- agentcore/sessionkernel/coordinator.go | 124 +++++++++------------ agentcore/sessionkernel/kernel.go | 27 +++-- agentcore/sessionkernel/protocol.go | 30 +++--- agentcore/sessionkernel/quiescence.go | 29 +++-- agentcore/testutil/local/contract.go | 38 +++---- agentcore/testutil/local/executor.go | 53 ++++----- agentcore/testutil/local/local.go | 14 +-- agentcore/testutil/local/progress.go | 2 +- agentcore/testutil/sdkconv/convert.go | 2 +- agentcore/testutil/sdkconv/doc.go | 2 +- 44 files changed, 486 insertions(+), 576 deletions(-) diff --git a/agentcore/chatlog/chatlog.go b/agentcore/chatlog/chatlog.go index db33222a..12270cb0 100644 --- a/agentcore/chatlog/chatlog.go +++ b/agentcore/chatlog/chatlog.go @@ -2,7 +2,7 @@ // conversation's facts -- the input lifecycle, summaries, compactions and // out-of-band result supersession -- and projects the conversation (Surface) // and the model-facing context (Context) from those facts together with the -// Run facts of agentcore/run. Assistant and tool_result entries are +// Turn facts of agentcore/turn. Assistant and tool_result entries are // projections of ModelStepCompleted, ToolCallCompleted, ToolCallAnswered and // ToolCallFailed: the module writes no second copy of a model or tool // outcome. Turn lifecycle belongs to agentcore/turn. @@ -16,15 +16,15 @@ import ( "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/sessionstore" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/sessionstore" ) const ModuleID module.ModuleID = "chatlog" type ( - TurnID string + TurnID = turn.TurnID InputID string AssistantID string ToolResultID string @@ -39,7 +39,6 @@ type ( // out-of-band verification. const ( TypeInputSubmitted ledger.EventType = "twilight/chatlog/input_submitted" - TypeInputDelivered ledger.EventType = "twilight/chatlog/input_delivered" TypeInputWithdrawn ledger.EventType = "twilight/chatlog/input_withdrawn" TypeInputRejected ledger.EventType = "twilight/chatlog/input_rejected" TypeToolResultSuperseded ledger.EventType = "twilight/chatlog/tool_result_superseded" @@ -49,7 +48,7 @@ const ( ) // Digest domains of the projected entries. They are not event -// types: an assistant or tool_result entry is folded from Run facts. +// types: an assistant or tool_result entry is folded from Turn facts. const ( assistantDomain = "twilight/chatlog/assistant" toolResultDomain = "twilight/chatlog/tool_result" @@ -198,17 +197,10 @@ type Input struct { // the frozen ModelResult, and the CallIDs of the calls the result issued, in // the result's ToolCalls order (from ToolStepOpened). The text, reasoning and // tool call arguments live in the frozen body; Materialize resolves them. -// RunOwner is what the chatlog remembers of an active Run: the Turn its -// entries belong to and the schema its identities derive under. -type RunOwner struct { - TurnID TurnID `json:"turnId,omitempty"` -} - type Assistant struct { ID AssistantID `json:"id"` TurnID TurnID `json:"turnId,omitempty"` - RunID run.RunID `json:"runId"` - StepID run.StepID `json:"stepId"` + StepID turn.StepID `json:"stepId"` FinishReason model.FinishReason `json:"finishReason"` ResultDigest jsonstable.Digest `json:"resultDigest"` CallIDs []CallID `json:"callIds,omitempty"` @@ -217,16 +209,15 @@ type Assistant struct { // ToolResult is the structural projection of one call's terminal outcome // success names the frozen output or response by digest; -// error and unknown carry the Run-recorded failure. +// error and unknown carry the Turn-recorded failure. type ToolResult struct { ID ToolResultID `json:"id"` TurnID TurnID `json:"turnId,omitempty"` - RunID run.RunID `json:"runId"` CallID CallID `json:"callId"` Status ToolResultStatus `json:"status"` Source ToolResultSource `json:"source,omitempty"` OutputDigest jsonstable.Digest `json:"outputDigest,omitempty"` - Failure *run.ToolFailure `json:"failure,omitempty"` + Failure *turn.ToolFailure `json:"failure,omitempty"` Digest jsonstable.Digest `json:"digest"` } @@ -249,12 +240,12 @@ type Summary struct { } // AssistantIDFor is the entry identity of a model step's result: the StepID -// itself, which is unique across Runs. -func AssistantIDFor(step run.StepID) AssistantID { return AssistantID(step) } +// itself, which is unique across TurnStore. +func AssistantIDFor(step turn.StepID) AssistantID { return AssistantID(step) } // ToolResultIDFor is the entry identity of a call's terminal result: the // CallID itself. -func ToolResultIDFor(call run.CallID) ToolResultID { return ToolResultID(call) } +func ToolResultIDFor(call turn.CallID) ToolResultID { return ToolResultID(call) } // SupersedingToolResultID is the identity of the result an out-of-band // verification substitutes for id. @@ -274,25 +265,23 @@ func DigestAssistant(a *Assistant) (jsonstable.Digest, error) { return digestDomain(assistantDomain, struct { ID AssistantID `json:"id"` TurnID TurnID `json:"turnId,omitempty"` - RunID run.RunID `json:"runId"` - StepID run.StepID `json:"stepId"` + StepID turn.StepID `json:"stepId"` FinishReason model.FinishReason `json:"finishReason"` ResultDigest jsonstable.Digest `json:"resultDigest"` CallIDs []CallID `json:"callIds,omitempty"` - }{a.ID, a.TurnID, a.RunID, a.StepID, a.FinishReason, a.ResultDigest, a.CallIDs}) + }{a.ID, a.TurnID, a.StepID, a.FinishReason, a.ResultDigest, a.CallIDs}) } func DigestToolResult(r *ToolResult) (jsonstable.Digest, error) { return digestDomain(toolResultDomain, struct { ID ToolResultID `json:"id"` TurnID TurnID `json:"turnId,omitempty"` - RunID run.RunID `json:"runId"` CallID CallID `json:"callId"` Status ToolResultStatus `json:"status"` Source ToolResultSource `json:"source,omitempty"` OutputDigest jsonstable.Digest `json:"outputDigest,omitempty"` - Failure *run.ToolFailure `json:"failure,omitempty"` - }{r.ID, r.TurnID, r.RunID, r.CallID, r.Status, r.Source, r.OutputDigest, r.Failure}) + Failure *turn.ToolFailure `json:"failure,omitempty"` + }{r.ID, r.TurnID, r.CallID, r.Status, r.Source, r.OutputDigest, r.Failure}) } func DigestSummary(s *Summary) (jsonstable.Digest, error) { @@ -352,15 +341,6 @@ type InputSubmittedPayload struct { SubmittedAtUnixMilli int64 `json:"submittedAtUnixMilli"` } -// InputDeliveredPayload records an input entering a Turn's Run: the Turn -// and the one Run that executes it. The chatlog learns which -// Turn a Run serves from this delivery, so the entries it folds from that -// Run's facts carry the Turn. -type InputDeliveredPayload struct { - InputID InputID `json:"inputId"` - TurnID TurnID `json:"turnId"` - RunID run.RunID `json:"runId"` -} type InputWithdrawnPayload struct { InputID InputID `json:"inputId"` Reason string `json:"reason,omitempty"` @@ -373,7 +353,7 @@ type InputRejectedPayload struct { // ToolResultSupersededPayload substitutes a verified outcome for a call's // projected result: an unknown result the Application confirmed // out of band becomes success (its output frozen under OutputDigest, written -// through the run frozen.Store) or error. The Run fact is untouched; the +// through the turn frozen.Store) or error. The Turn fact is untouched; the // substitution is a conversation decision. type ToolResultSupersededPayload struct { ToolResultID ToolResultID `json:"toolResultId"` @@ -484,7 +464,7 @@ var PartsExtractor module.BindingExtractor = module.BindingExtractorFunc(func(va }) // supersededExtractor names the frozen output a success supersession -// carries, under the run module's frozen Binding derivation. +// carries, under the turn module's frozen Binding derivation. var supersededExtractor module.BindingExtractor = module.BindingExtractorFunc(func(val any) ([]artifact.BindingID, error) { p, ok := val.(ToolResultSupersededPayload) if !ok { @@ -528,26 +508,26 @@ func def[T any](typ ledger.EventType, check func(*T) error, bindings ...module.B Bindings: bindings} } -// consumedRunFacts are the Run facts the chatlog projections fold -// run_created for the Run's Turn, the model and tool outcomes +// consumedTurnFacts are the Turn facts the chatlog projections fold +// turn_started for the Turn's Turn, the model and tool outcomes // for the entries, tool_step_opened for the CallIDs a result issued, and -// run_ended to forget the Run's Turn. -var consumedRunFacts = []string{"model_step_completed", "tool_step_opened", "tool_call_completed", "tool_call_answered", "tool_call_failed", "run_ended"} +// turn_ended to forget the Turn's Turn. +var consumedTurnFacts = []string{"input_accepted", "model_step_completed", "tool_step_opened", "tool_call_completed", "tool_call_answered", "tool_call_failed", "turn_ended"} -func runRequirement() module.ModuleRequirement { - events := make([]ledger.EventType, 0, len(consumedRunFacts)) - for _, name := range consumedRunFacts { +func turnRequirement() module.ModuleRequirement { + events := make([]ledger.EventType, 0, len(consumedTurnFacts)) + for _, name := range consumedTurnFacts { events = append(events, sessionstore.Type(name)) } return module.ModuleRequirement{Source: module.SourceTwilight, Module: sessionstore.ModuleID, Events: events} } -// Module is the chatlog ModuleDescriptor (Requires run facts). +// Module is the chatlog ModuleDescriptor (Requires Turn facts). var Module = module.ModuleDescriptor{ Source: module.SourceTwilight, ID: ModuleID, Streams: []module.StreamDefinition{streamDefinition}, - Requires: []module.ModuleRequirement{runRequirement()}, + Requires: []module.ModuleRequirement{turnRequirement()}, Events: []module.EventDefinition{ def[InputSubmittedPayload](TypeInputSubmitted, func(p *InputSubmittedPayload) error { if p.InputID == "" || p.Content.IsZero() { @@ -555,12 +535,6 @@ var Module = module.ModuleDescriptor{ } return nil }), - def[InputDeliveredPayload](TypeInputDelivered, func(p *InputDeliveredPayload) error { - if p.InputID == "" || p.TurnID == "" || p.RunID == "" { - return errors.New("input_delivered requires inputId, turnId and runId") - } - return nil - }), def[InputWithdrawnPayload](TypeInputWithdrawn, nil), def[InputRejectedPayload](TypeInputRejected, nil), def[ToolResultSupersededPayload](TypeToolResultSuperseded, checkSuperseded, supersededBinding), diff --git a/agentcore/chatlog/compaction.go b/agentcore/chatlog/compaction.go index 690070e5..5e27a467 100644 --- a/agentcore/chatlog/compaction.go +++ b/agentcore/chatlog/compaction.go @@ -118,7 +118,7 @@ func contextPairs(entries []Entry) []EntryDigestPair { // CompactContext contributes a summary and compaction to an atomic unit. It // validates the plan on the unit's Writer View, not at summary-generation time. -// Run quiescence is the caller's policy and belongs in a separate unit Part. +// Turn quiescence is the caller's policy and belongs in a separate unit Part. func CompactContext(id CompactionID, summary Summary, plan CompactionPlan) unit.Part { return &compactContext{id: id, summary: summary, plan: plan} } diff --git a/agentcore/chatlog/input.go b/agentcore/chatlog/input.go index e7324d3f..fb6ac9fa 100644 --- a/agentcore/chatlog/input.go +++ b/agentcore/chatlog/input.go @@ -4,15 +4,15 @@ import ( "context" "errors" - "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/session/unit" "github.com/felinics/twilight/agentcore/session/writer" + "github.com/felinics/twilight/agentcore/turn" ) // SubmitInput contributes input admission to a cross-module unit, for example // an agent's atomic command receipt. Its caller supplies the stable operation // identity; this module alone constructs the chatlog fact. It starts no Turn. -func SubmitInput(id run.InputID, content run.CanonicalJSON) unit.Part { +func SubmitInput(id turn.InputID, content turn.CanonicalJSON) unit.Part { return unit.PartFunc(func(_ context.Context, _ writer.View, now int64) ([]writer.TypedBatch, error) { if id == "" || content.IsZero() { return nil, errors.New("chatlog: input identity and content are required") diff --git a/agentcore/chatlog/materialize.go b/agentcore/chatlog/materialize.go index 11398dcb..793099d4 100644 --- a/agentcore/chatlog/materialize.go +++ b/agentcore/chatlog/materialize.go @@ -5,27 +5,27 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/schema" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/schema" ) // ContentResolver resolves the frozen bodies structural entries name by -// digest. run/sessionstore.Content is the first-party +// digest. turn/sessionstore.Content is the first-party // implementation; the projections never call it. type ContentResolver interface { ModelResult(context.Context, jsonstable.Digest) (model.ModelResult, error) - ToolOutput(context.Context, jsonstable.Digest) (run.CanonicalJSON, error) - ToolResponse(context.Context, jsonstable.Digest) (run.CanonicalJSON, error) + ToolOutput(context.Context, jsonstable.Digest) (turn.CanonicalJSON, error) + ToolResponse(context.Context, jsonstable.Digest) (turn.CanonicalJSON, error) } -// Call is one tool call of a materialized assistant: the Run's CallID paired +// Call is one tool call of a materialized assistant: the Turn's CallID paired // with the provider's identifiers and arguments from the frozen result. type Call struct { CallID CallID ProviderCallID string Name string - Input run.CanonicalJSON + Input turn.CanonicalJSON } // Materialized is one entry with its frozen body resolved: Result and Calls @@ -35,7 +35,7 @@ type Materialized struct { Entry *Entry Result *model.ModelResult Calls []Call - Output *run.CanonicalJSON + Output *turn.CanonicalJSON } // Text is the rendering of the entry's main content: the assistant text, the @@ -63,11 +63,11 @@ func (m *Materialized) Text() string { type Materializer struct { content ContentResolver results map[jsonstable.Digest]*model.ModelResult - outputs map[jsonstable.Digest]*run.CanonicalJSON + outputs map[jsonstable.Digest]*turn.CanonicalJSON } func NewMaterializer(content ContentResolver) *Materializer { - return &Materializer{content: content, results: map[jsonstable.Digest]*model.ModelResult{}, outputs: map[jsonstable.Digest]*run.CanonicalJSON{}} + return &Materializer{content: content, results: map[jsonstable.Digest]*model.ModelResult{}, outputs: map[jsonstable.Digest]*turn.CanonicalJSON{}} } // Entries materializes every entry in order. @@ -132,7 +132,7 @@ func (m *Materializer) result(ctx context.Context, digest jsonstable.Digest) (*m return &r, nil } -func (m *Materializer) output(ctx context.Context, r *ToolResult) (*run.CanonicalJSON, error) { +func (m *Materializer) output(ctx context.Context, r *ToolResult) (*turn.CanonicalJSON, error) { if o, ok := m.outputs[r.OutputDigest]; ok { return o, nil } @@ -140,7 +140,7 @@ func (m *Materializer) output(ctx context.Context, r *ToolResult) (*run.Canonica return nil, fmt.Errorf("chatlog: no content resolver for tool result %s", r.ID) } var ( - o run.CanonicalJSON + o turn.CanonicalJSON err error ) switch r.Source { @@ -158,7 +158,7 @@ func (m *Materializer) output(ctx context.Context, r *ToolResult) (*run.Canonica return &o, nil } -// pairCalls zips the Run-assigned CallIDs with the result's ToolCalls. A +// pairCalls zips the Turn-assigned CallIDs with the result's ToolCalls. A // result issued without a ToolStepOpened in the entry (no CallIDs) derives // them the way the protocol does (identity table). func pairCalls(a *Assistant, result *model.ModelResult) ([]Call, error) { diff --git a/agentcore/chatlog/projection.go b/agentcore/chatlog/projection.go index 4cb7ca47..d4b55c8a 100644 --- a/agentcore/chatlog/projection.go +++ b/agentcore/chatlog/projection.go @@ -6,8 +6,8 @@ import ( "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/sessionstore" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/sessionstore" ) const ( @@ -75,10 +75,6 @@ type Surface struct { EntryOrder []SurfaceEntry `json:"entryOrder"` Superseded Table[ToolResultID, ToolResultID] `json:"superseded,omitzero"` Compactions Table[CompactionID, CompactionView] `json:"compactions,omitzero"` - // Runs maps each active Run of the Session to its Turn (attempt/started) - // and schema version so the - // entries folded from Run facts carry their TurnID. - Runs Table[run.RunID, RunOwner] `json:"runs,omitzero"` } // SubmittedInputs returns inputs still awaiting delivery, in submission order. @@ -107,13 +103,13 @@ func sortViews(views []InputView) { } // chatlogConsumes is what both projections fold: the module's own facts and -// the Run facts that produce assistant and tool_result entries, and the +// the Turn facts that produce assistant and tool_result entries, and the // attempt binding that names their Turn. var chatlogConsumes = func() []ledger.EventType { - out := make([]ledger.EventType, 0, 9+len(consumedRunFacts)) - out = append(out, TypeInputSubmitted, TypeInputDelivered, TypeInputWithdrawn, TypeInputRejected, + out := make([]ledger.EventType, 0, 9+len(consumedTurnFacts)) + out = append(out, TypeInputSubmitted, TypeInputWithdrawn, TypeInputRejected, TypeToolResultSuperseded, TypeSummary, TypeCompactionCreated, TypeCompactionInvalidated) - for _, name := range consumedRunFacts { + for _, name := range consumedTurnFacts { out = append(out, sessionstore.Type(name)) } return out @@ -122,7 +118,7 @@ var chatlogConsumes = func() []ledger.EventType { var SurfaceProjection = module.ProjectionDefinition{ ID: SurfaceProjectionID, Version: 1, Consumes: chatlogConsumes, - // Assistant and tool_result entries are projected from run facts + // Assistant and tool_result entries are projected from Turn facts // a fork inherits that conversation content. Inherits: module.InheritAll, Authoritative: true, @@ -155,16 +151,6 @@ func applySurface(state any, e module.DecodedEvent) (any, error) { //nolint:gocr return nil, err } s.Inputs = s.Inputs.Set(p.InputID, InputView{Input: Input{ID: p.InputID, Content: p.Content, Digest: d}, Status: InputSubmitted, Position: pos}) - case InputDeliveredPayload: - v, ok := s.Inputs.Get(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 = s.Inputs.Set(p.InputID, v) - s.EntryOrder = append(s.EntryOrder, SurfaceEntry{Kind: EntryInput, ID: string(p.InputID), Position: pos}) - s.Runs = s.Runs.Set(p.RunID, RunOwner{TurnID: p.TurnID}) case InputWithdrawnPayload: if err := terminateInput(&s, p.InputID, InputWithdrawn); err != nil { return nil, err @@ -174,7 +160,7 @@ func applySurface(state any, e module.DecodedEvent) (any, error) { //nolint:gocr return nil, err } case sessionstore.Event: - return s.applyRun(p, e.Position) + return s.applyTurn(p, e.Position) case ToolResultSupersededPayload: old, ok := s.ToolResults.Get(p.ToolResultID) if !ok { @@ -219,18 +205,23 @@ func applySurface(state any, e module.DecodedEvent) (any, error) { //nolint:gocr return s, nil } -// applyRun folds one Run fact into the Surface. The -// Run's Turn is remembered from run_created until run_ended: no fact of the -// Run follows its end, so the table is bounded by the Runs active now. -func (s Surface) applyRun(ev sessionstore.Event, pos ledger.Position) (any, error) { //nolint:gocritic // hugeParam: projection Apply is copy-on-write over value states; DecodedEvent is the extension API shape +// applyTurn folds authoritative Turn facts into conversation entries and +// derives input delivery directly from InputAccepted. +func (s Surface) applyTurn(ev sessionstore.Event, pos ledger.Position) (any, error) { //nolint:gocritic // hugeParam: projection Apply is copy-on-write over value states; DecodedEvent is the extension API shape switch f := ev.Fact.(type) { - case run.RunCreated: - // The Run's Turn comes from the input_delivered that fed it. - case run.RunEnded: - s.Runs = s.Runs.Delete(ev.RunID) - case run.ModelStepCompleted: - owner, _ := s.Runs.Get(ev.RunID) - a, err := assistantOf(owner, ev.RunID, &f) + case turn.TurnStarted: + case turn.InputAccepted: + id := InputID(f.Input.ID) + v, ok := s.Inputs.Get(id) + if !ok || v.Status != InputSubmitted || v.Input.Digest != f.Input.Digest { + return nil, fmt.Errorf("input %s accepted without matching submission", id) + } + v.Status, v.Input.TurnID = InputDelivered, ev.TurnID + s.Inputs = s.Inputs.Set(id, v) + s.EntryOrder = append(s.EntryOrder, SurfaceEntry{Kind: EntryInput, ID: string(id), Position: pos}) + case turn.TurnEnded: + case turn.ModelStepCompleted: + a, err := assistantOf(ev.TurnID, &f) if err != nil { return nil, err } @@ -239,7 +230,7 @@ func (s Surface) applyRun(ev sessionstore.Event, pos ledger.Position) (any, erro } s.Assistants = s.Assistants.Set(a.ID, a) s.EntryOrder = append(s.EntryOrder, SurfaceEntry{Kind: EntryAssistant, ID: string(a.ID), Position: pos}) - case run.ToolStepOpened: + case turn.ToolStepOpened: a, ok := s.Assistants.Get(AssistantIDFor(f.Source)) if !ok { return nil, fmt.Errorf("tool step %s opened for unknown assistant %s", f.StepID, f.Source) @@ -248,9 +239,8 @@ func (s Surface) applyRun(ev sessionstore.Event, pos ledger.Position) (any, erro return nil, err } s.Assistants = s.Assistants.Set(a.ID, a) - case run.ToolCallCompleted, run.ToolCallAnswered, run.ToolCallFailed: - owner, _ := s.Runs.Get(ev.RunID) - r, err := toolResultOf(owner.TurnID, ev.RunID, ev.Fact) + case turn.ToolCallCompleted, turn.ToolCallAnswered, turn.ToolCallFailed: + r, err := toolResultOf(ev.TurnID, ev.Fact) if err != nil { return nil, err } @@ -260,14 +250,14 @@ func (s Surface) applyRun(ev sessionstore.Event, pos ledger.Position) (any, erro s.ToolResults = s.ToolResults.Set(r.ID, r) s.EntryOrder = append(s.EntryOrder, SurfaceEntry{Kind: EntryToolResult, ID: string(r.ID), Position: pos}) default: - return nil, fmt.Errorf("chatlog surface: unexpected run fact %T", ev.Fact) + return nil, fmt.Errorf("chatlog surface: unexpected Turn fact %T", ev.Fact) } return s, nil } // assistantOf projects ModelStepCompleted into a structural entry. -func assistantOf(owner RunOwner, runID run.RunID, f *run.ModelStepCompleted) (Assistant, error) { - a := Assistant{ID: AssistantIDFor(f.StepID), TurnID: owner.TurnID, RunID: runID, StepID: f.StepID, +func assistantOf(turnID turn.TurnID, f *turn.ModelStepCompleted) (Assistant, error) { + a := Assistant{ID: AssistantIDFor(f.StepID), TurnID: turnID, StepID: f.StepID, FinishReason: f.FinishReason, ResultDigest: f.ResultDigest} d, err := DigestAssistant(&a) if err != nil { @@ -279,7 +269,7 @@ func assistantOf(owner RunOwner, runID run.RunID, f *run.ModelStepCompleted) (As // attachCalls records the CallIDs ToolStepOpened assigned, in the result's // ToolCalls order, on the assistant that issued them. -func attachCalls(a *Assistant, f *run.ToolStepOpened) error { +func attachCalls(a *Assistant, f *turn.ToolStepOpened) error { if len(a.CallIDs) != 0 { return fmt.Errorf("assistant %s opened a tool step twice", a.ID) } @@ -297,16 +287,16 @@ func attachCalls(a *Assistant, f *run.ToolStepOpened) error { // toolResultOf projects a call's terminal fact into a structural entry // (semantics: Unknown outcome or effect_unknown class is status unknown, every other failure is error). -func toolResultOf(turnID TurnID, runID run.RunID, fact run.Fact) (ToolResult, error) { +func toolResultOf(turnID TurnID, fact turn.Fact) (ToolResult, error) { var r ToolResult switch f := fact.(type) { - case run.ToolCallCompleted: + case turn.ToolCallCompleted: r = ToolResult{CallID: CallID(f.CallID), Status: ToolSuccess, Source: SourceToolOutput, OutputDigest: f.OutputDigest} - case run.ToolCallAnswered: + case turn.ToolCallAnswered: r = ToolResult{CallID: CallID(f.CallID), Status: ToolSuccess, Source: SourceToolResponse, OutputDigest: f.ResponseDigest} - case run.ToolCallFailed: + case turn.ToolCallFailed: status := ToolError - if f.Outcome == run.ToolOutcomeUnknown || f.Failure.Class == run.FailureEffectUnknown { + if f.Outcome == turn.ToolOutcomeUnknown || f.Failure.Class == turn.FailureEffectUnknown { status = ToolUnknown } failure := f.Failure @@ -314,7 +304,7 @@ func toolResultOf(turnID TurnID, runID run.RunID, fact run.Fact) (ToolResult, er default: return ToolResult{}, fmt.Errorf("chatlog: %T is not a tool result fact", fact) } - r.ID, r.TurnID, r.RunID = ToolResultIDFor(run.CallID(r.CallID)), turnID, runID + r.ID, r.TurnID = ToolResultIDFor(turn.CallID(r.CallID)), turnID d, err := DigestToolResult(&r) if err != nil { return ToolResult{}, err @@ -326,11 +316,11 @@ func toolResultOf(turnID TurnID, runID run.RunID, fact run.Fact) (ToolResult, er // supersedingResult is the entry an out-of-band verification substitutes for // old: same call, the verified status and body. func supersedingResult(old *ToolResult, p *ToolResultSupersededPayload) (ToolResult, error) { - r := ToolResult{ID: SupersedingToolResultID(old.ID), TurnID: old.TurnID, RunID: old.RunID, CallID: old.CallID, Status: p.Status} + r := ToolResult{ID: SupersedingToolResultID(old.ID), TurnID: old.TurnID, CallID: old.CallID, Status: p.Status} if p.Status == ToolSuccess { r.Source, r.OutputDigest = SourceToolOutput, p.OutputDigest } else { - r.Failure = &run.ToolFailure{Class: "superseded", Message: p.Reason} + r.Failure = &turn.ToolFailure{Class: "superseded", Message: p.Reason} } d, err := DigestToolResult(&r) if err != nil { @@ -402,13 +392,12 @@ type AppliedCompaction struct { // Context is the projection state: the ordered entries plus the bookkeeping // ContextFold needs (submitted inputs awaiting delivery, superseded results, -// applied compactions, the Turn of each Run). +// applied compactions, the Turn of each Turn). type Context struct { Entries []Entry `json:"entries"` Pending map[InputID]Input `json:"pending,omitempty"` Superseded map[ToolResultID]ToolResultID `json:"superseded,omitempty"` Compactions []AppliedCompaction `json:"compactions,omitempty"` - Runs map[run.RunID]RunOwner `json:"runs,omitempty"` } var ContextProjection = module.ProjectionDefinition{ @@ -417,7 +406,7 @@ var ContextProjection = module.ProjectionDefinition{ Inherits: module.InheritAll, Authoritative: true, Initial: func() (any, error) { - return Context{Pending: map[InputID]Input{}, Superseded: map[ToolResultID]ToolResultID{}, Runs: map[run.RunID]RunOwner{}}, nil + return Context{Pending: map[InputID]Input{}, Superseded: map[ToolResultID]ToolResultID{}}, nil }, Apply: applyContext, StateCodec: module.JSONStateCodec[Context]{}, @@ -442,17 +431,6 @@ func applyContext(state any, e module.DecodedEvent) (any, error) { //nolint:gocr } c.Pending = cow(c.Pending) 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) - } - c.Pending = cow(c.Pending) - delete(c.Pending, p.InputID) - in.TurnID = p.TurnID - c.Entries = append(c.Entries, Entry{Kind: EntryInput, ID: string(in.ID), Digest: in.Digest, Position: pos, Input: &in}) - c.Runs = cow(c.Runs) - c.Runs[p.RunID] = RunOwner{TurnID: p.TurnID} case InputWithdrawnPayload: c.Pending = cow(c.Pending) delete(c.Pending, p.InputID) @@ -460,7 +438,7 @@ func applyContext(state any, e module.DecodedEvent) (any, error) { //nolint:gocr c.Pending = cow(c.Pending) delete(c.Pending, p.InputID) case sessionstore.Event: - return c.applyRun(p, e.Position) + return c.applyTurn(p, e.Position) case ToolResultSupersededPayload: i := c.indexOf(EntryToolResult, string(p.ToolResultID)) if i < 0 { @@ -505,22 +483,28 @@ func applyContext(state any, e module.DecodedEvent) (any, error) { //nolint:gocr return c, nil } -// applyRun folds one Run fact into the Context. Runs is bounded -// like the Surface's: the Turn is forgotten at run_ended. -func (c Context) applyRun(ev sessionstore.Event, pos ledger.Position) (any, error) { +// applyTurn folds authoritative Turn facts into request context. +func (c Context) applyTurn(ev sessionstore.Event, pos ledger.Position) (any, error) { switch f := ev.Fact.(type) { - case run.RunCreated: - // The Run's Turn comes from the input_delivered that fed it. - case run.RunEnded: - c.Runs = cow(c.Runs) - delete(c.Runs, ev.RunID) - case run.ModelStepCompleted: - a, err := assistantOf(c.Runs[ev.RunID], ev.RunID, &f) + case turn.TurnStarted: + case turn.InputAccepted: + id := InputID(f.Input.ID) + in, ok := c.Pending[id] + if !ok || in.Digest != f.Input.Digest { + return nil, fmt.Errorf("input %s accepted without matching submission", id) + } + c.Pending = cow(c.Pending) + delete(c.Pending, id) + in.TurnID = ev.TurnID + c.Entries = append(c.Entries, Entry{Kind: EntryInput, ID: string(id), Digest: in.Digest, Position: pos, Input: &in}) + case turn.TurnEnded: + case turn.ModelStepCompleted: + a, err := assistantOf(ev.TurnID, &f) if err != nil { return nil, err } c.Entries = append(c.Entries, Entry{Kind: EntryAssistant, ID: string(a.ID), Digest: a.Digest, Position: pos, Assistant: &a}) - case run.ToolStepOpened: + case turn.ToolStepOpened: i := c.indexOf(EntryAssistant, string(AssistantIDFor(f.Source))) if i < 0 { return nil, fmt.Errorf("tool step %s opened for assistant %s outside the active context", f.StepID, f.Source) @@ -532,14 +516,14 @@ func (c Context) applyRun(ev sessionstore.Event, pos ledger.Position) (any, erro entries := append([]Entry(nil), c.Entries...) entries[i].Assistant, entries[i].Digest = &a, a.Digest c.Entries = entries - case run.ToolCallCompleted, run.ToolCallAnswered, run.ToolCallFailed: - r, err := toolResultOf(c.Runs[ev.RunID].TurnID, ev.RunID, ev.Fact) + case turn.ToolCallCompleted, turn.ToolCallAnswered, turn.ToolCallFailed: + r, err := toolResultOf(ev.TurnID, ev.Fact) if err != nil { return nil, err } c.Entries = append(c.Entries, Entry{Kind: EntryToolResult, ID: string(r.ID), Digest: r.Digest, Position: pos, ToolResult: &r}) default: - return nil, fmt.Errorf("chatlog context: unexpected run fact %T", ev.Fact) + return nil, fmt.Errorf("chatlog context: unexpected Turn fact %T", ev.Fact) } return c, nil } @@ -613,13 +597,13 @@ func selectRetained(base []Entry, pairs []EntryDigestPair) ([]Entry, error) { return out, nil } -// ContextFold folds decoded chatlog and run events into entries. +// ContextFold folds decoded chatlog and Turn events into entries. func ContextFold(events []module.DecodedEvent) ([]Entry, error) { state, _ := ContextProjection.Initial() for i := range events { e := &events[i] if e.Unknown || (e.Module != module.TwilightModule(ModuleID) && e.Module != module.TwilightModule(sessionstore.ModuleID)) { - return nil, errors.New("chatlog: context fold requires decoded chatlog or run events") + return nil, errors.New("chatlog: context fold requires decoded chatlog or Turn events") } next, err := applyContext(state, *e) if err != nil { diff --git a/agentcore/chatlog/read.go b/agentcore/chatlog/read.go index f65d997e..dbec1a6c 100644 --- a/agentcore/chatlog/read.go +++ b/agentcore/chatlog/read.go @@ -5,8 +5,8 @@ import ( "crypto/rand" "encoding/hex" "fmt" - "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/session" + "github.com/felinics/twilight/agentcore/turn" ) // ReadSurface loads the chatlog surface of one Session through r. @@ -69,7 +69,7 @@ func (s *Surface) LastAssistantText(ctx context.Context, content ContentResolver // NewInputID mints a collision-free InputID; the chatlog requires // session-global uniqueness across restarts. -func NewInputID() run.InputID { return run.InputID("in-" + randomSuffix(8)) } +func NewInputID() turn.InputID { return turn.InputID("in-" + randomSuffix(8)) } func randomSuffix(n int) string { b := make([]byte, n) diff --git a/agentcore/chatlog/service.go b/agentcore/chatlog/service.go index 0349d986..93c4f8e4 100644 --- a/agentcore/chatlog/service.go +++ b/agentcore/chatlog/service.go @@ -6,10 +6,10 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/unit" "github.com/felinics/twilight/agentcore/session/writer" + "github.com/felinics/twilight/agentcore/turn" "strings" "time" ) @@ -36,13 +36,13 @@ var ErrNotSubmitted = errors.New("chatlog: input is not submitted") // Submit records one user input as submitted: content is the // input's body, opaque to this module and digested as given (the agent decides its shape). Idempotency rides on the CommitID, so a // retried submission replays. -func (s *Commands) Submit(ctx context.Context, w writer.Writer, id run.InputID, content run.CanonicalJSON) (run.AgentInput, error) { +func (s *Commands) Submit(ctx context.Context, w writer.Writer, id turn.InputID, content turn.CanonicalJSON) (turn.AgentInput, error) { if content.IsZero() { - return run.AgentInput{}, errors.New("chatlog: submit requires input content") + return turn.AgentInput{}, errors.New("chatlog: submit requires input content") } digest, err := DigestInput(InputID(id), content) if err != nil { - return run.AgentInput{}, err + return turn.AgentInput{}, err } res, err := w.Commit(ctx, func(writer.View) (*writer.SemanticGroup, error) { return &writer.SemanticGroup{CommitID: ledger.CommitID("input-submitted/" + string(id)), @@ -52,20 +52,20 @@ func (s *Commands) Submit(ctx context.Context, w writer.Writer, id run.InputID, }}}}}, nil }) if err != nil { - return run.AgentInput{}, err + return turn.AgentInput{}, err } switch res.Outcome { case writer.CommitApplied, writer.CommitAlreadyApplied: - return run.AgentInput{ID: id, Digest: digest}, nil + return turn.AgentInput{ID: id, Digest: digest}, nil default: - return run.AgentInput{}, fmt.Errorf("chatlog: submit input: %s: %s", res.Outcome, res.Detail) + return turn.AgentInput{}, fmt.Errorf("chatlog: submit input: %s: %s", res.Outcome, res.Detail) } } // Withdraw marks a submitted, undelivered input as withdrawn // for example the original input of a Turn the caller forked // before in order to edit it. -func (s *Commands) Withdraw(ctx context.Context, w writer.Writer, id run.InputID, reason string) error { +func (s *Commands) Withdraw(ctx context.Context, w writer.Writer, id turn.InputID, reason string) error { res, err := w.Commit(ctx, func(v writer.View) (*writer.SemanticGroup, error) { state, err := v.Projection(SurfaceProjectionID, SurfaceProjection.Version) if err != nil { @@ -218,27 +218,13 @@ func CheckRetainClosure(entries []Entry, retain []EntryDigestPair) error { return nil } -// DeliverInputs is the chatlog's Part of a Turn's start or delivery unit -// it checks, on the unit's own View, that every input -// is a submitted chatlog Input whose content digest is the one the Run -// accepts, and writes one input_delivered per input. The body stays here; -// the Run carries only the digest. A failed check is -// ErrNotSubmitted and refuses the whole unit, so an input withdrawn between -// the caller's read and the commit is caught inside the critical section. -func DeliverInputs(turnID TurnID, runID run.RunID, inputs []run.AgentInput) unit.Part { - return deliverInputs{turnID: turnID, runID: runID, inputs: inputs} -} +// AdmitInputs validates submitted bodies on the same writer view as acceptance. +// InputAccepted is the sole durable fact that admits input to a Turn. +func AdmitInputs(inputs []turn.AgentInput) unit.Part { return admitInputs{inputs: inputs} } -type deliverInputs struct { - turnID TurnID - runID run.RunID - inputs []run.AgentInput -} +type admitInputs struct{ inputs []turn.AgentInput } -func (d deliverInputs) Prepare(_ context.Context, view writer.View, now int64) ([]writer.TypedBatch, error) { - if len(d.inputs) == 0 { - return nil, nil - } +func (d admitInputs) Prepare(_ context.Context, view writer.View, _ int64) ([]writer.TypedBatch, error) { state, err := view.Projection(SurfaceProjectionID, SurfaceProjection.Version) if err != nil { return nil, err @@ -247,17 +233,13 @@ func (d deliverInputs) Prepare(_ context.Context, view writer.View, now int64) ( if !ok { return nil, fmt.Errorf("chatlog: surface projection is %T", state) } - events := make([]writer.TypedEvent, 0, len(d.inputs)) + seen := map[turn.InputID]bool{} for _, in := range d.inputs { v, ok := surface.Inputs.Get(InputID(in.ID)) - if !ok || v.Status != InputSubmitted { - return nil, fmt.Errorf("%w: input %s is not a submitted input", ErrNotSubmitted, in.ID) - } - if v.Input.Digest != in.Digest { - return nil, fmt.Errorf("%w: input %s digest differs from its submitted content", ErrNotSubmitted, in.ID) + if in.ID == "" || seen[in.ID] || !ok || v.Status != InputSubmitted || v.Input.Digest != in.Digest { + return nil, fmt.Errorf("%w: input %s is duplicate, unavailable or has different content", ErrNotSubmitted, in.ID) } - events = append(events, writer.TypedEvent{Type: TypeInputDelivered, RecordedAtUnixMilli: now, - Value: InputDeliveredPayload{InputID: InputID(in.ID), TurnID: d.turnID, RunID: d.runID}}) + seen[in.ID] = true } - return []writer.TypedBatch{{Domain: Stream, Events: events}}, nil + return nil, nil } diff --git a/agentcore/chatlog/table.go b/agentcore/chatlog/table.go index bf6b4991..3425dfab 100644 --- a/agentcore/chatlog/table.go +++ b/agentcore/chatlog/table.go @@ -72,8 +72,7 @@ func (t Table[K, V]) Set(k K, v V) Table[K, V] { } // Delete returns a Table without k; the receiver is unchanged. It rebuilds the -// base, so it costs O(n): callers delete from tables that stay small (the -// Runs table, bounded by the Runs active now), not from the entry tables. +// base, so it costs O(n): callers should reserve deletion for small tables. func (t Table[K, V]) Delete(k K) Table[K, V] { if !t.Has(k) { return t diff --git a/agentcore/compaction/compaction.go b/agentcore/compaction/compaction.go index 77d4c597..8661259e 100644 --- a/agentcore/compaction/compaction.go +++ b/agentcore/compaction/compaction.go @@ -1,6 +1,6 @@ // Package compaction records Session context-maintenance operations. An // operation owns a frozen summary request and its effect identity, but is not -// a Run or a conversation step. Conversation changes remain chatlog facts. +// a Turn or a conversation step. Conversation changes remain chatlog facts. package compaction import ( @@ -13,12 +13,12 @@ import ( "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/sessionstore" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/unit" "github.com/felinics/twilight/agentcore/session/writer" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/sessionstore" ) type ID string @@ -45,14 +45,14 @@ var ( type Request struct { ID ID `json:"id"` Plan chatlog.CompactionPlan `json:"plan"` - Model run.ModelRef `json:"model"` - RequestDigest run.Digest `json:"requestDigest"` - Preparation run.Digest `json:"preparation,omitempty"` - Target *run.TargetRef `json:"target,omitempty"` + Model turn.ModelRef `json:"model"` + RequestDigest turn.Digest `json:"requestDigest"` + Preparation turn.Digest `json:"preparation,omitempty"` + Target *turn.TargetRef `json:"target,omitempty"` } func (r *Request) Key(sid session.SessionID) effect.AssignmentKey { - return effect.AssignmentKey{Session: run.Scope(sid), RunID: run.RunID("compact-" + r.ID), Effect: run.EffectID("summary-" + r.ID)} + return effect.AssignmentKey{Session: turn.Scope(sid), OwnerKind: effect.OwnerCompaction, OwnerID: string(r.ID), Effect: turn.EffectID("summary-" + r.ID)} } // Operation retains the intent after settlement too: an owner can finish the @@ -237,7 +237,7 @@ func fromView(v writer.View) (State, error) { } // RequireIdle is a PrepareModelRequest consistency guard, evaluated inside -// the same Writer critical section as the Run command. No candidate can be +// the same Writer critical section as the Turn command. No candidate can be // frozen into a ModelStep while an operation may still replace its context. func RequireIdle(v writer.View) error { s, err := fromView(v) @@ -259,7 +259,7 @@ func fingerprint(r *Request) (jsonstable.Digest, error) { } // Start contributes the durable intent. The caller must additionally enforce -// its Run quiescence guard in the same unit before this Part is evaluated. +// its Turn quiescence guard in the same unit before this Part is evaluated. func Start(request Request) unit.Part { return unit.PartFunc(func(_ context.Context, v writer.View, now int64) ([]writer.TypedBatch, error) { if err := checkRequest(&RequestedPayload{Request: request}); err != nil { diff --git a/agentcore/driver/budget.go b/agentcore/driver/budget.go index d5b85372..74d42131 100644 --- a/agentcore/driver/budget.go +++ b/agentcore/driver/budget.go @@ -5,8 +5,8 @@ import ( "errors" "github.com/felinics/twilight/agentcore/prompt" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" ) // BudgetPolicy is the default candidate-budget policy. The product supplies @@ -15,7 +15,7 @@ import ( // entire candidate, including system messages and tool definitions. This is an // estimate, not a guarantee about a provider's eventual usage accounting. type BudgetPolicy struct { - Budget func(context.Context, run.ModelRef) (prompt.TokenBudget, error) + Budget func(context.Context, turn.ModelRef) (prompt.TokenBudget, error) Estimate func(context.Context, *model.ModelRequest) (int, error) RetainEntries int Summary func(context.Context, *CompactionContext) (SummaryRequest, error) @@ -30,7 +30,7 @@ func (p *BudgetPolicy) Decide(ctx context.Context, _ prompt.Input, candidate *pr } modelRef := candidate.Model if modelRef == "" { - modelRef = run.ModelRef(candidate.Request.Model) + modelRef = turn.ModelRef(candidate.Request.Model) } budget, err := p.Budget(ctx, modelRef) if err != nil { diff --git a/agentcore/driver/compaction.go b/agentcore/driver/compaction.go index a265ad57..567e8652 100644 --- a/agentcore/driver/compaction.go +++ b/agentcore/driver/compaction.go @@ -16,16 +16,16 @@ import ( "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/prompt" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/loop" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/schema" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/unit" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/sessionkernel" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/loop" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/schema" ) // DefaultCompactionAttempts bounds maintenance for one preparation even if @@ -53,9 +53,9 @@ type CompactionContext struct { } type SummaryRequest struct { - Model run.ModelRef + Model turn.ModelRef Request model.ModelRequest - Target *run.TargetRef + Target *turn.TargetRef } // ContextPolicy inspects the complete candidate request. It supplies token @@ -67,7 +67,7 @@ type ContextPolicy interface { } type summaryRequests interface { - FrozenRequest(context.Context, run.Digest) (model.ModelRequest, error) + FrozenRequest(context.Context, turn.Digest) (model.ModelRequest, error) } // MaintenanceResult describes one finite maintenance coordination. Pending @@ -139,7 +139,7 @@ func (c *Compactions) now() int64 { return time.Now().UnixMilli() } -func preparation(input *prompt.Input) (run.Digest, error) { +func preparation(input *prompt.Input) (turn.Digest, error) { body, err := jsonstable.EncodeTypedPayload(1, "twilight/compaction/preparation", input) if err != nil { return "", err @@ -240,12 +240,12 @@ func newCompactionID() (compaction.ID, error) { return compaction.ID(hex.EncodeToString(bytes[:])), nil } -func (c *Compactions) freeze(ctx context.Context, id compaction.ID, plan *chatlog.CompactionPlan, boundary run.Digest, request *SummaryRequest) (compaction.Request, error) { +func (c *Compactions) freeze(ctx context.Context, id compaction.ID, plan *chatlog.CompactionPlan, boundary turn.Digest, request *SummaryRequest) (compaction.Request, error) { if c.Frozen == nil { return compaction.Request{}, errors.New("driver: compaction requires a frozen store") } if request.Model == "" { - request.Model = run.ModelRef(request.Request.Model) + request.Model = turn.ModelRef(request.Request.Model) } if request.Model == "" || string(request.Model) != request.Request.Model || len(request.Request.Tools) != 0 { return compaction.Request{}, errors.New("driver: summary request must name its model and have no tools") @@ -310,7 +310,7 @@ func (c *Compactions) start(ctx context.Context, w writer.Writer, request *compa if len(batches) == 0 { return nil, nil } - if err := sessionkernel.RequireQuiescentRun(v); err != nil { + if err := sessionkernel.RequireQuiescentTurn(v); err != nil { return nil, err } return &writer.SemanticGroup{CommitID: ledger.CommitID(identity), Batches: batches}, nil @@ -319,7 +319,7 @@ func (c *Compactions) start(ctx context.Context, w writer.Writer, request *compa } // NeedsRecovery is the activation/idle-release predicate for hosts. Pending -// operations and unacknowledged settlements remain work even without a Run. +// operations and unacknowledged settlements remain work even without a Turn. func (c *Compactions) NeedsRecovery(ctx context.Context, w writer.Writer) (bool, error) { state, err := compaction.Read(ctx, w.Projections(), w.SessionID()) if err != nil { @@ -383,7 +383,7 @@ func (c *Compactions) Advance(ctx context.Context, w writer.Writer) (Maintenance if err != nil { return result, err } - assignment := effect.Assignment{Session: run.Scope(w.SessionID()), RunID: result.Key.RunID, + assignment := effect.Assignment{Session: turn.Scope(w.SessionID()), OwnerKind: result.Key.OwnerKind, OwnerID: result.Key.OwnerID, StepID: "summary", Effect: result.Key.Effect, Target: op.Request.Target, Body: effect.ModelAssignment{Model: op.Request.Model, Request: &request, RequestDigest: op.Request.RequestDigest}} unavailable, err := c.Executor.Validate(ctx, assignment) @@ -521,7 +521,7 @@ func (c *Compactions) finish(ctx context.Context, w writer.Writer, op *compactio return err } parts = append(parts, unit.PartFunc(func(_ context.Context, v writer.View, _ int64) ([]writer.TypedBatch, error) { - return nil, sessionkernel.RequireQuiescentRun(v) + return nil, sessionkernel.RequireQuiescentTurn(v) }), chatlog.CompactContext(result.CompactionID, summary, op.Request.Plan)) } parts = append(parts, compaction.Finish(result)) diff --git a/agentcore/driver/driver.go b/agentcore/driver/driver.go index 183018ae..2f055f89 100644 --- a/agentcore/driver/driver.go +++ b/agentcore/driver/driver.go @@ -6,16 +6,15 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/compaction" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/loop" - "github.com/felinics/twilight/agentcore/run/plan" - "github.com/felinics/twilight/agentcore/run/reconcile" - "github.com/felinics/twilight/agentcore/run/redispatch" - "github.com/felinics/twilight/agentcore/run/sessionstore" - "github.com/felinics/twilight/agentcore/run/store" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/loop" + "github.com/felinics/twilight/agentcore/turn/plan" + "github.com/felinics/twilight/agentcore/turn/reconcile" + "github.com/felinics/twilight/agentcore/turn/redispatch" + "github.com/felinics/twilight/agentcore/turn/sessionstore" + "github.com/felinics/twilight/agentcore/turn/store" ) // Disposition describes work, not ownership or an Activation's lifecycle. @@ -38,7 +37,7 @@ type StepResult struct { // awaits an external execution, starts a background continuation, acquires // ownership, or retains the caller's Writer after a method returns. type Driver struct { - Runs *sessionstore.SessionRunStore + TurnStore *sessionstore.SessionTurnStore Loops *Loops Compactions *Compactions MissingEffects reconcile.MissingPolicy @@ -71,7 +70,7 @@ func (d *Driver) Step(ctx context.Context, w writer.Writer, turnID turn.TurnID) return StepResult{Disposition: Continue}, nil } } - surface, err := turn.ReadSurface(ctx, w.Projections(), w.SessionID()) + surface, err := sessionstore.ReadSurface(ctx, w.Projections(), w.SessionID()) if err != nil { return StepResult{}, err } @@ -86,8 +85,8 @@ func (d *Driver) Step(ctx context.Context, w writer.Writer, turnID turn.TurnID) if err != nil { return StepResult{}, err } - st := d.Runs.Bind(w) - snapshot, err := st.Load(ctx, view.RunID) + st := d.TurnStore.Bind(w) + snapshot, err := st.Load(ctx, view.TurnID) if err != nil { return StepResult{}, err } @@ -99,7 +98,7 @@ func (d *Driver) Step(ctx context.Context, w writer.Writer, turnID turn.TurnID) return lp.Redispatch(ctx, st, key) }} for _, target := range plan.RecoveryTargets(&snapshot.State) { - key := effect.AssignmentKey{Session: st.Scope(), RunID: view.RunID, Effect: target.Effect} + key := effect.AssignmentKey{Session: st.Scope(), OwnerKind: effect.OwnerTurn, OwnerID: string(view.TurnID), Effect: target.Effect} decision, found, err := rec.ReconcileEffect(ctx, st, &snapshot, key) if err != nil { return StepResult{}, err @@ -125,7 +124,7 @@ func (d *Driver) Step(ctx context.Context, w writer.Writer, turnID turn.TurnID) // next effect. The host's budget can now choose to continue or yield. return StepResult{Disposition: Continue}, nil } - res, err := lp.Advance(ctx, st, view.RunID, d.Sink) + res, err := lp.Advance(ctx, st, view.TurnID, d.Sink) if err != nil { if errors.Is(err, compaction.ErrPending) { return StepResult{Disposition: Continue}, nil @@ -147,11 +146,11 @@ func (d *Driver) Step(ctx context.Context, w writer.Writer, turnID turn.TurnID) } } -// ReconcileExecution handles a discovered execution even after its Run left +// ReconcileExecution handles a discovered execution even after its Turn left // the active projection. A durable domain disposition authorizes cancellation // of still-running work and later ACK; a lost Activation alone never does. func (d *Driver) ReconcileExecution(ctx context.Context, w writer.Writer, key effect.AssignmentKey) error { - if key.Session != run.Scope(w.SessionID()) { + if key.Session != turn.Scope(w.SessionID()) { return errors.New("driver: execution belongs to another session") } if d.Compactions != nil { @@ -167,16 +166,19 @@ func (d *Driver) ReconcileExecution(ctx context.Context, w writer.Writer, key ef } } } - lp, _, err := d.Loops.ForRun(ctx, w, key.RunID) + if key.OwnerKind != effect.OwnerTurn { + return errors.New("driver: unknown execution owner") + } + lp, err := d.Loops.ForTurn(ctx, w, turn.TurnID(key.OwnerID)) if err != nil { return err } - st := d.Runs.Bind(w) + st := d.TurnStore.Bind(w) proof, ok := st.(store.EffectDispositionReader) if !ok { return store.ErrEffectProofUnsupported } - _, disposed, err := proof.EffectDisposition(ctx, key.RunID, key.Effect) + _, disposed, err := proof.EffectDisposition(ctx, turn.TurnID(key.OwnerID), key.Effect) if err != nil { return err } @@ -219,7 +221,7 @@ func (d *Driver) ReconcileExecution(ctx context.Context, w writer.Writer, key ef // Deliver admits one externally discovered Outcome under the current Writer. // A dropped result is not, by itself, proof that the Executor may collect it. func (d *Driver) Deliver(ctx context.Context, w writer.Writer, out effect.Outcome) (StepResult, error) { - if out.Key.Session != run.Scope(w.SessionID()) { + if out.Key.Session != turn.Scope(w.SessionID()) { return StepResult{}, errors.New("driver: outcome belongs to another session") } if d.Compactions != nil { @@ -235,11 +237,14 @@ func (d *Driver) Deliver(ctx context.Context, w writer.Writer, out effect.Outcom } } } - lp, _, err := d.Loops.ForRun(ctx, w, out.Key.RunID) + if out.Key.OwnerKind != effect.OwnerTurn { + return StepResult{}, errors.New("driver: unknown outcome owner") + } + lp, err := d.Loops.ForTurn(ctx, w, turn.TurnID(out.Key.OwnerID)) if err != nil { return StepResult{}, err } - st := d.Runs.Bind(w) + st := d.TurnStore.Bind(w) res, err := lp.Deliver(ctx, st, out, d.Sink) if err != nil { return StepResult{}, err diff --git a/agentcore/driver/loops.go b/agentcore/driver/loops.go index 5e9120d5..bcd81e09 100644 --- a/agentcore/driver/loops.go +++ b/agentcore/driver/loops.go @@ -7,16 +7,16 @@ package driver import ( "context" "fmt" + "github.com/felinics/twilight/agentcore/turn/sessionstore" "sync" "github.com/felinics/twilight/agentcore/preset" "github.com/felinics/twilight/agentcore/prompt" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/loop" - "github.com/felinics/twilight/agentcore/run/store" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/loop" + "github.com/felinics/twilight/agentcore/turn/store" ) // Presets resolves a PresetRef to its immutable AgentPreset. @@ -24,7 +24,7 @@ type Presets interface { Resolve(preset.PresetRef) (preset.AgentPreset, error) } -// Planner is the application's between-steps hook: it runs while a Run is +// Planner is the application's between-steps hook: it runs while a Turn is // Open and about to plan a model request, with the Writer of the Session // being driven, so what it commits (an in-turn checkpoint) is what the // PromptBuilder reads next. Errors stop the drive. @@ -32,14 +32,14 @@ type Planner interface { BeforePrepare(ctx context.Context, w writer.Writer, input prompt.Input) error } -// CandidateChecker checks a complete candidate before it becomes a Run fact. -// Maintenance returns control to the Session driver; it is not a Run step. +// CandidateChecker checks a complete candidate before it becomes a Turn fact. +// Maintenance returns control to the Session driver; it is not a Turn step. type CandidateChecker interface { CheckCandidate(context.Context, writer.Writer, prompt.Input, *prompt.Prompt) (loop.CandidateDecision, error) } // Loops builds the Loop of each AgentPreset once and hands the same Loop to -// every drive, redispatch and reattachment of a Run under that preset, so +// every drive, redispatch and reattachment of a Turn under that preset, so // all of them meet the same already-driving guard. The Loop settings are // fixed when the first Loop is built; a field changed later reaches only // presets not built yet. @@ -103,52 +103,37 @@ func (l *Loops) For(ref preset.PresetRef) (*loop.Loop, error) { return lp, nil } -// ForRun returns the Loop of the Turn that owns the Run, read from the -// Session's turn surface through w, and that Turn's ID. -func (l *Loops) ForRun(ctx context.Context, w writer.Writer, runID run.RunID) (*loop.Loop, turn.TurnID, error) { - turnID, err := ownerOf(ctx, w, runID) +// ForTurn returns the Loop selected by the Turn's immutable preset. +func (l *Loops) ForTurn(ctx context.Context, w writer.Writer, turnID turn.TurnID) (*loop.Loop, error) { + surface, err := sessionstore.ReadSurface(ctx, w.Projections(), w.SessionID()) if err != nil { - return nil, "", err + return nil, err } - surface, err := turn.ReadSurface(ctx, w.Projections(), w.SessionID()) - if err != nil { - return nil, "", err + view, ok := surface.Turns[turnID] + if !ok { + return nil, fmt.Errorf("driver: unknown turn %s", turnID) } - lp, err := l.For(surface.Turns[turnID].Preset) + lp, err := l.For(view.Preset) if err != nil { - return nil, "", err - } - return lp, turnID, nil -} - -// ownerOf is the Turn that owns the Run, read from the Session's turn -// surface through w. -func ownerOf(ctx context.Context, w writer.Writer, runID run.RunID) (turn.TurnID, error) { - surface, err := turn.ReadSurface(ctx, w.Projections(), w.SessionID()) - if err != nil { - return "", err - } - turnID, ok := surface.OwnerOf(runID) - if !ok { - return "", fmt.Errorf("driver: run %s: no owning turn", runID) + return nil, err } - return turnID, nil + return lp, nil } -func (l *Loops) checkCandidate(ctx context.Context, st store.RunStore, input prompt.Input, candidate *prompt.Prompt) (loop.CandidateDecision, error) { +func (l *Loops) checkCandidate(ctx context.Context, st store.TurnStore, input prompt.Input, candidate *prompt.Prompt) (loop.CandidateDecision, error) { owned, ok := st.(interface{ Writer() writer.Writer }) if !ok { - return loop.CandidateReady, fmt.Errorf("driver: run store %T exposes no writer for candidate checks", st) + return loop.CandidateReady, fmt.Errorf("driver: turn store %T exposes no writer for candidate checks", st) } return l.Candidates.CheckCandidate(ctx, owned.Writer(), input, candidate) } // beforePrepare hands the Loop's hook to the Planner with the Writer the // bound store commits through. -func (l *Loops) beforePrepare(ctx context.Context, st store.RunStore, input prompt.Input) error { +func (l *Loops) beforePrepare(ctx context.Context, st store.TurnStore, input prompt.Input) error { owned, ok := st.(interface{ Writer() writer.Writer }) if !ok { - return fmt.Errorf("driver: run store %T exposes no writer for the planner", st) + return fmt.Errorf("driver: turn store %T exposes no writer for the planner", st) } return l.Planner.BeforePrepare(ctx, owned.Writer(), input) } diff --git a/agentcore/executor/backend.go b/agentcore/executor/backend.go index ce68eaa3..b5591755 100644 --- a/agentcore/executor/backend.go +++ b/agentcore/executor/backend.go @@ -7,8 +7,8 @@ import ( "github.com/felinics/twilight/agentcore/executor/notice" executionstore "github.com/felinics/twilight/agentcore/executor/store" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" ) // ExecutionRef is the Executor's physical binding of the current attempt for @@ -30,7 +30,7 @@ var ErrUnknownProvider = fmt.Errorf("executor: unknown execution provider: %w", type ExecutionBackend interface { // Validate checks that this Backend can serve the Assignment; it produces // no external effect. - Validate(ctx context.Context, a effect.Assignment) (*run.ToolFailure, error) + Validate(ctx context.Context, a effect.Assignment) (*turn.ToolFailure, error) // Prepare derives the Ref of the Assignment without starting the effect // and without allocating anything outside the Backend: it runs before // the acceptance is written, and an Abort may win that write, @@ -95,7 +95,7 @@ func MatchModel(a effect.Assignment) bool { _, ok := a.Model(); return ok } //no // MatchTool is the Route predicate of tool calls declared with placement // a Worker routes by the declaration, never by whether a target // is present. -func MatchTool(placement run.ToolPlacement) func(effect.Assignment) bool { +func MatchTool(placement turn.ToolPlacement) func(effect.Assignment) bool { return func(a effect.Assignment) bool { //nolint:gocritic // hugeParam: Route.Match takes the Assignment by value t, ok := a.Tool() return ok && t.Placement == placement @@ -111,7 +111,7 @@ func PortBackend(p effect.ExecutionPort) ExecutionBackend { return portBackend{p type portBackend struct{ port effect.ExecutionPort } -func (b portBackend) Validate(ctx context.Context, a effect.Assignment) (*run.ToolFailure, error) { +func (b portBackend) Validate(ctx context.Context, a effect.Assignment) (*turn.ToolFailure, error) { return b.port.Validate(ctx, a) } diff --git a/agentcore/executor/discovery.go b/agentcore/executor/discovery.go index a1b3a8af..e31d0ff1 100644 --- a/agentcore/executor/discovery.go +++ b/agentcore/executor/discovery.go @@ -4,7 +4,7 @@ import ( "context" executionstore "github.com/felinics/twilight/agentcore/executor/store" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" ) var _ effect.OutstandingPort = (*Worker)(nil) diff --git a/agentcore/executor/notice/notice.go b/agentcore/executor/notice/notice.go index 4cd8c844..ab725e63 100644 --- a/agentcore/executor/notice/notice.go +++ b/agentcore/executor/notice/notice.go @@ -11,7 +11,7 @@ import ( "context" "sync" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" ) // DefaultWindow is the number of notices a Ring keeps for late subscribers. diff --git a/agentcore/executor/notices.go b/agentcore/executor/notices.go index b47d6272..1d6fe5a0 100644 --- a/agentcore/executor/notices.go +++ b/agentcore/executor/notices.go @@ -6,7 +6,7 @@ import ( "time" "github.com/felinics/twilight/agentcore/executor/notice" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" ) // backendNotices is the Worker's subscriber to its Backends' settled Refs diff --git a/agentcore/executor/progress.go b/agentcore/executor/progress.go index b4b42e88..cf755a80 100644 --- a/agentcore/executor/progress.go +++ b/agentcore/executor/progress.go @@ -4,7 +4,7 @@ import ( "context" "sync" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" ) // DefaultProgressWindow is the frames a ProgressHub keeps per key when diff --git a/agentcore/executor/protocol/types.go b/agentcore/executor/protocol/types.go index 32881fd0..7dcb5067 100644 --- a/agentcore/executor/protocol/types.go +++ b/agentcore/executor/protocol/types.go @@ -1,32 +1,32 @@ // Package protocol defines the wire/application protocol for the process- -// independent effect port in agent/run/effect. It owns message encoding, but -// never mutates a Session or Run; settlement remains the Owner's Loop concern. +// independent effect port in agentcore/turn/effect. It owns message encoding, but +// never mutates a Session or Turn; settlement remains the Owner's Loop concern. package protocol import ( "fmt" "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/model" ) -const ProtocolVersion uint16 = 1 +const ProtocolVersion uint16 = 2 // AssignmentEnvelope is the serializable dispatch message. type AssignmentEnvelope struct { ProtocolVersion uint16 `json:"protocolVersion"` - Session run.Scope `json:"session"` + Session turn.Scope `json:"session"` Assignment effect.Assignment `json:"assignment"` } // ToolOutcomeEnvelope is the wire representation of loop's sealed outcome. type ToolOutcomeEnvelope struct { - Kind string `json:"kind"` - Result *run.ToolExecutionResult `json:"result,omitempty"` - Failure *run.ToolFailure `json:"failure,omitempty"` - Retry run.RetryDisposition `json:"retry,omitempty"` + Kind string `json:"kind"` + Result *turn.ToolExecutionResult `json:"result,omitempty"` + Failure *turn.ToolFailure `json:"failure,omitempty"` + Retry turn.RetryDisposition `json:"retry,omitempty"` } type WireError struct { @@ -46,7 +46,7 @@ type OutcomeEnvelope struct { Unknown bool `json:"unknown,omitempty"` } -func (o *OutcomeEnvelope) Digest() (run.Digest, error) { +func (o *OutcomeEnvelope) Digest() (turn.Digest, error) { return jsonstable.DigestCanonical(o) } @@ -108,19 +108,19 @@ func DecodeOutcome(w *OutcomeEnvelope) effect.Outcome { case w.Tool != nil: switch w.Tool.Kind { case "succeeded": - var r run.ToolExecutionResult + var r turn.ToolExecutionResult if w.Tool.Result != nil { r = *w.Tool.Result } out.Result = effect.ToolExecutionSucceeded{Result: r} case "failed": - var f run.ToolFailure + var f turn.ToolFailure if w.Tool.Failure != nil { f = *w.Tool.Failure } out.Result = effect.ToolExecutionFailed{Failure: f, Retry: w.Tool.Retry} case "unknown": - var f run.ToolFailure + var f turn.ToolFailure if w.Tool.Failure != nil { f = *w.Tool.Failure } diff --git a/agentcore/executor/settlement.go b/agentcore/executor/settlement.go index e3a44e8c..77b4ce3b 100644 --- a/agentcore/executor/settlement.go +++ b/agentcore/executor/settlement.go @@ -4,7 +4,7 @@ import ( "context" "github.com/felinics/twilight/agentcore/executor/notice" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" ) // DefaultSettlementWindow is the number of settlements a Worker keeps for diff --git a/agentcore/executor/store/discovery.go b/agentcore/executor/store/discovery.go index 793e4dcd..f0c1aee9 100644 --- a/agentcore/executor/store/discovery.go +++ b/agentcore/executor/store/discovery.go @@ -3,7 +3,7 @@ package store import ( "context" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" ) // Directory is the optional durable management index of executions. It is diff --git a/agentcore/executor/store/seed.go b/agentcore/executor/store/seed.go index ed40ac4e..22e16c68 100644 --- a/agentcore/executor/store/seed.go +++ b/agentcore/executor/store/seed.go @@ -5,7 +5,7 @@ import ( "github.com/felinics/twilight/agentcore/executor/protocol" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" ) // SeedCommits plans the ledger an execution goes through to reach state: diff --git a/agentcore/executor/store/store.go b/agentcore/executor/store/store.go index 00c58f14..89aa1d6e 100644 --- a/agentcore/executor/store/store.go +++ b/agentcore/executor/store/store.go @@ -4,7 +4,7 @@ // (agentcore/ledger), so what is true of any commit ledger is true here: // the ledger is the only fact authority, ExecutionState is its fold, a // replayed command is recognised by its CommitID, and a fenced writer's -// commit is refused by its Epoch. The Run never reads this ledger; the two +// commit is refused by its Epoch. The Turn never reads this ledger; the two // authorities meet only at AssignmentKey. package store @@ -17,7 +17,7 @@ import ( "github.com/felinics/twilight/agentcore/executor/protocol" "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" ) var ( @@ -83,7 +83,7 @@ func (l Lease) IsZero() bool { return l.Owner == "" && l.Epoch == 0 } // ExecutionRef is the physical binding of the current attempt made for one // effect: the provider (backend) the execution was handed to and // that provider's opaque handle. It never leaves the execution domain: -// executions are addressed by AssignmentKey, and the Run records the +// executions are addressed by AssignmentKey, and the Turn records the // EffectID alone. type ExecutionRef struct { Provider string `json:"provider"` diff --git a/agentcore/executor/supervise.go b/agentcore/executor/supervise.go index e3b5f4da..6913d0f2 100644 --- a/agentcore/executor/supervise.go +++ b/agentcore/executor/supervise.go @@ -9,8 +9,8 @@ import ( "github.com/felinics/twilight/agentcore/executor/protocol" executionstore "github.com/felinics/twilight/agentcore/executor/store" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" ) // This file is the supervision of one execution from the moment the Worker @@ -184,7 +184,7 @@ func (w *Worker) awaitBackend(lease executionstore.Lease, claimed *executionstor // leaseDone ends the running heartbeat when nothing is left to // watch. func (w *Worker) replay(ctx context.Context, lease executionstore.Lease, claimed *executionstore.Execution, backend ExecutionBackend, ref string, leaseDone chan struct{}) error { - if tool, ok := claimed.Assignment.Tool(); ok && tool.Replay != run.ReplayAllowed { + if tool, ok := claimed.Assignment.Tool(); ok && tool.Replay != turn.ReplayAllowed { env := protocol.OutcomeEnvelope{ProtocolVersion: protocol.ProtocolVersion, Key: lease.Key, Unknown: true, Error: &protocol.WireError{Code: "adopted_without_replay", Message: fmt.Sprintf("tool %q declares replay %s; the lost execution is not re-dispatched", tool.ToolRef, tool.Replay)}} err := w.finishOwned(ctx, lease, &env, effect.ExecutionUnknown, nil) @@ -524,9 +524,9 @@ func (w *Worker) retryAfter(lease executionstore.Lease, backend ExecutionBackend func retryableFailure(result effect.OutcomeResult) bool { switch r := result.(type) { case effect.ModelFailed: - return r.Retry() == run.RetryAllowed + return r.Retry() == turn.RetryAllowed case effect.ToolExecutionFailed: - return r.Retry == run.RetryAllowed + return r.Retry == turn.RetryAllowed default: return false } diff --git a/agentcore/executor/worker.go b/agentcore/executor/worker.go index daa872f5..6d077a65 100644 --- a/agentcore/executor/worker.go +++ b/agentcore/executor/worker.go @@ -19,9 +19,9 @@ import ( "github.com/felinics/twilight/agentcore/executor/protocol" executionstore "github.com/felinics/twilight/agentcore/executor/store" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/schema" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/schema" ) // WorkerOptions configure one Worker incarnation. @@ -69,7 +69,7 @@ const defaultLeaseDuration = 30 * time.Second // live lease and continues it from the persisted payload and Ref, and // Dispose settles a record its caller has given up on. Which records to // recover, when to ask and when to give up are decisions of whoever observes -// the record as orphaned — the Owner reconciling its own Run or +// the record as orphaned — the Owner reconciling its own Turn or // an external controller — and the Worker runs no loop of its own // Neither operation is part of effect.ExecutionPort, which // stays the per-assignment data plane. @@ -212,7 +212,7 @@ func (w *Worker) backend(ref ExecutionRef) (ExecutionBackend, error) { return b, nil } -func (w *Worker) Validate(ctx context.Context, a effect.Assignment) (*run.ToolFailure, error) { +func (w *Worker) Validate(ctx context.Context, a effect.Assignment) (*turn.ToolFailure, error) { r, err := w.route(a) if err != nil { return nil, err @@ -294,6 +294,9 @@ func (w *Worker) holdsLease(s *executionstore.Execution) bool { // same Assignment is answered from the ledger; recovery of an existing // execution goes through RecoverExecution. func (w *Worker) Dispatch(ctx context.Context, a effect.Assignment) error { + if err := a.Key().Validate(); err != nil { + return err + } if a.Body == nil { return errors.New("executor: assignment without body") } @@ -382,7 +385,7 @@ func (w *Worker) Dispatch(ctx context.Context, a effect.Assignment) error { // RecoverExecution once the Owner observes it orphaned. The ledger holds one // acceptance per key and does not compare content, so telling the two // Assignments apart is the Worker's reading. -func (w *Worker) opened(ctx context.Context, state *executionstore.Execution, digest run.Digest) error { +func (w *Worker) opened(ctx context.Context, state *executionstore.Execution, digest turn.Digest) error { if state.Aborted() { return effect.ErrExecutionAborted } @@ -478,7 +481,7 @@ func (w *Worker) Acknowledge(ctx context.Context, key effect.AssignmentKey) erro // Dispose settles an unsettled execution as Unknown without re-dispatching // it. The caller has given the execution up: it will not be recovered, and -// the Owner disposes the Run target on its next read. Unlike +// the Owner disposes the Turn target on its next read. Unlike // RecoverExecution, Dispose is unconditional — it also applies to executions // whose lease holder is dead or absent — and unlike Cancel it does not // require backend reachability: the backend is cancelled best-effort after diff --git a/agentcore/history/history.go b/agentcore/history/history.go index 57a696cf..423d8e8a 100644 --- a/agentcore/history/history.go +++ b/agentcore/history/history.go @@ -11,6 +11,7 @@ import ( "github.com/felinics/twilight/agentcore/module" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/sessionstore" ) // History reads a Session's committed stream for boundary questions. @@ -50,7 +51,7 @@ func (h History) PrefixCommit(ctx context.Context, sid session.SessionID, turnID func (h History) scanBoundary(ctx context.Context, sid session.SessionID, turnID turn.TurnID, includeInputs bool) (ledger.CommitSeq, error) { var inputIDs map[chatlog.InputID]struct{} if includeInputs { - surface, err := turn.ReadSurface(ctx, h.Projections, sid) + surface, err := sessionstore.ReadSurface(ctx, h.Projections, sid) if err != nil { return 0, err } @@ -60,7 +61,7 @@ func (h History) scanBoundary(ctx context.Context, sid session.SessionID, turnID } inputIDs = make(map[chatlog.InputID]struct{}, len(view.InputIDs)) for _, id := range view.InputIDs { - inputIDs[id] = struct{}{} + inputIDs[chatlog.InputID(id)] = struct{}{} } } var from ledger.CommitSeq @@ -75,7 +76,7 @@ func (h History) scanBoundary(ctx context.Context, sid session.SessionID, turnID for _, b := range c.Batches { for _, e := range b.Events { switch e.Type { - case turn.TypeStarted: + case sessionstore.TypeStarted: if hasStarted { continue } @@ -83,7 +84,7 @@ func (h History) scanBoundary(ctx context.Context, sid session.SessionID, turnID if err != nil || decoded.Unknown { continue } - if p, ok := decoded.Value.(turn.StartedPayload); ok && p.TurnID == turnID { + if p, ok := decoded.Value.(sessionstore.Event); ok && p.TurnID == turnID { started, hasStarted = c.Seq, true } case chatlog.TypeInputSubmitted: @@ -122,7 +123,7 @@ func (h History) scanBoundary(ctx context.Context, sid session.SessionID, turnID // a point would hand the child a Turn whose execution belongs to the // parent, so callers refuse it. func (h History) ActiveAt(ctx context.Context, sid session.SessionID, at ledger.CommitSeq) (turn.TurnID, bool, error) { - scope, err := h.Registry.ScopeFor(turn.SurfaceProjectionID, turn.SurfaceProjection.Version) + scope, err := h.Registry.ScopeFor(sessionstore.SurfaceProjectionID, sessionstore.SurfaceProjection.Version) if err != nil { return "", false, err } @@ -151,7 +152,7 @@ func (h History) ActiveAt(ctx context.Context, sid session.SessionID, at ledger. } from = page.Commits[len(page.Commits)-1].Seq + 1 } - surface, ok := state.(turn.TurnSurface) + surface, ok := state.(sessionstore.TurnSurface) if !ok { return "", false, fmt.Errorf("history: surface projection is %T", state) } diff --git a/agentcore/observe/bus.go b/agentcore/observe/bus.go index 5c2094c0..79040bd6 100644 --- a/agentcore/observe/bus.go +++ b/agentcore/observe/bus.go @@ -11,8 +11,8 @@ import ( "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/session" + "github.com/felinics/twilight/agentcore/turn" ) // Event is one item of a Session's observation stream, in either of two @@ -40,8 +40,8 @@ type Event struct { // Progress is the transient part of an Event: what the executor reported of // an effect while it ran. type Progress struct { - RunID run.RunID - Effect run.EffectID + TurnID turn.TurnID + Effect turn.EffectID Generation int Sequence uint64 Kind string diff --git a/agentcore/preset/preset.go b/agentcore/preset/preset.go index 4e24d7ed..cfdf9917 100644 --- a/agentcore/preset/preset.go +++ b/agentcore/preset/preset.go @@ -5,14 +5,15 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/preset/ref" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" ) type ( // PresetID names a preset under registration; the PresetRef a Session // records pairs it with the digest of the registered AgentPreset. - PresetID string + PresetID = ref.ID // PromptBuilderRef identifies the prompt builder that builds the model // prompt for a Turn. It is part of the preset digest. PromptBuilderRef string @@ -20,10 +21,7 @@ type ( // PresetRef is the digest-checked reference a Turn's started fact records: // the decision identity the Turn runs under, addressable across processes. -type PresetRef struct { - ID PresetID `json:"id"` - Digest jsonstable.Digest `json:"digest"` -} +type PresetRef = ref.Ref // ToolContract is one tool of an AgentPreset, frozen as a unit: the // definition the model sees, the response policy the agent waits under, @@ -31,15 +29,15 @@ type PresetRef struct { // attribute answers a different layer; the contract freezes them together // because a Turn must resume under all four unchanged. type ToolContract struct { - Ref run.ToolRef `json:"ref"` + Ref turn.ToolRef `json:"ref"` Definition model.ToolDefinition `json:"definition"` - Policy run.ResponsePolicy `json:"policy"` + Policy turn.ResponsePolicy `json:"policy"` // Replay is the tool's declared replay policy; it enters the preset // digest and the frozen ToolSpec. Omitted when unknown. - Replay run.ReplayPolicy `json:"replay,omitempty"` + Replay turn.ReplayPolicy `json:"replay,omitempty"` // Placement is the tool's declared placement; it enters the preset // digest and the frozen ToolSpec. Omitted for process placement. - Placement run.ToolPlacement `json:"placement,omitempty"` + Placement turn.ToolPlacement `json:"placement,omitempty"` } // AgentPreset is the decision identity a Turn is started under: every @@ -48,7 +46,7 @@ type ToolContract struct { // clients and tool implementations never enter it. Streaming is not part of // the identity: it is an execution-side observation choice of the backend. type AgentPreset struct { - Model run.ModelRef `json:"model"` + Model turn.ModelRef `json:"model"` Tools []ToolContract `json:"tools,omitempty"` // PromptBuilder is the ref of the prompt builder resolved on the Owner // side. @@ -56,9 +54,9 @@ type AgentPreset struct { // Scheduling is how the tool calls of one step run: parallel (default) or // sequential, with an optional bound on concurrent workers. It is frozen // onto each ToolStep. - Scheduling run.ToolScheduling `json:"scheduling,omitempty"` + Scheduling turn.ToolScheduling `json:"scheduling,omitempty"` // MalformedRetries is how many times one model step is retried after a - // malformed result before the Run fails; zero fails on the first. + // malformed result before the Turn fails; zero fails on the first. MalformedRetries uint8 `json:"malformedRetries,omitempty"` // SystemPrompt is the conversation instruction frozen by the preset digest. SystemPrompt string `json:"systemPrompt,omitempty"` @@ -73,12 +71,12 @@ const DigestDomain = "twilight/turn/preset" // SystemPrompt. func DigestPreset(p *AgentPreset) (jsonstable.Digest, error) { body := struct { - Model run.ModelRef `json:"model"` - Tools []ToolContract `json:"tools,omitempty"` - PromptBuilder PromptBuilderRef `json:"promptBuilder"` - Scheduling run.ToolScheduling `json:"scheduling,omitempty"` - MalformedRetries uint8 `json:"malformedRetries,omitempty"` - SystemPrompt string `json:"systemPrompt,omitempty"` + Model turn.ModelRef `json:"model"` + Tools []ToolContract `json:"tools,omitempty"` + PromptBuilder PromptBuilderRef `json:"promptBuilder"` + Scheduling turn.ToolScheduling `json:"scheduling,omitempty"` + MalformedRetries uint8 `json:"malformedRetries,omitempty"` + SystemPrompt string `json:"systemPrompt,omitempty"` }{p.Model, p.Tools, p.PromptBuilder, p.Scheduling, p.MalformedRetries, p.SystemPrompt} raw, err := jsonstable.EncodeTypedPayload(1, DigestDomain, body) if err != nil { @@ -96,7 +94,7 @@ func ValidatePreset(p *AgentPreset) error { case p.PromptBuilder == "": return errors.New("preset: preset requires a prompt builder ref") } - if m := p.Scheduling.Mode; m != "" && m != run.ToolScheduleParallel && m != run.ToolScheduleSequential { + if m := p.Scheduling.Mode; m != "" && m != turn.ToolScheduleParallel && m != turn.ToolScheduleSequential { return fmt.Errorf("preset: preset scheduling mode %q is unknown", m) } if p.Scheduling.MaxParallel < 0 { diff --git a/agentcore/prompt/budget.go b/agentcore/prompt/budget.go index 08363241..9053a7e6 100644 --- a/agentcore/prompt/budget.go +++ b/agentcore/prompt/budget.go @@ -5,7 +5,7 @@ import "fmt" // TokenBudget compares an estimated input token count against model capacity, // reserving room for both output and a safety margin. The estimator and model // capacity are supplied by the caller; this package does not tokenize requests. -// An opaque run.PromptToken is a freshness token, not a token count. +// An opaque turn.PromptToken is a freshness token, not a token count. type TokenBudget struct { ContextWindow int OutputReserve int diff --git a/agentcore/prompt/builder.go b/agentcore/prompt/builder.go index d9c12b17..5b4b9931 100644 --- a/agentcore/prompt/builder.go +++ b/agentcore/prompt/builder.go @@ -4,9 +4,9 @@ import ( "context" "github.com/felinics/twilight/agentcore/preset" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/schema" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/schema" ) // BuilderRef names a Builder in the catalog; it is part of the preset @@ -15,7 +15,7 @@ type BuilderRef = preset.PromptBuilderRef // Builder assembles the next model input from the surrounding // conversation, which it reads by the Input's Scope. The Prompt it returns -// is already the frozen ModelRequest the Run persists; a builder that +// is already the frozen ModelRequest the Turn persists; a builder that // composes its request in a provider SDK's types converts before returning. type Builder interface { Build(context.Context, Input) (Prompt, error) @@ -25,36 +25,36 @@ type Builder interface { // (messages and tool definitions), the inputs it consumed, the freshness // token of the context it was built from, and the frozen tool specs. type Prompt struct { - Model run.ModelRef + Model turn.ModelRef Request model.ModelRequest - InputIDs []run.InputID - Token run.PromptToken - Tools []run.ToolSpec + InputIDs []turn.InputID + Token turn.PromptToken + Tools []turn.ToolSpec } -// Input is what the execution hands the Builder: the Run boundary facts +// Input is what the execution hands the Builder: the Turn boundary facts // only. Conversation content (previous assistant output, tool results) is // read from the Session by the Builder itself. type Input struct { - Scope run.Scope // filled by the caller; planning does not know it - RunID run.RunID - SourceStep run.StepID - Inputs []run.AgentInput + Scope turn.Scope // filled by the caller; planning does not know it + TurnID turn.TurnID + SourceStep turn.StepID + Inputs []turn.AgentInput } // ToolSpecs splits the preset's tool contracts into what each side of the -// boundary needs: the run.ToolSpec the Run persists (definition digest, +// boundary needs: the turn.ToolSpec the Turn persists (definition digest, // policy, replay and placement declarations) and the definitions the model // request carries, in contract order. -func ToolSpecs(tools []preset.ToolContract) ([]run.ToolSpec, []model.ToolDefinition, error) { - specs := make([]run.ToolSpec, 0, len(tools)) +func ToolSpecs(tools []preset.ToolContract) ([]turn.ToolSpec, []model.ToolDefinition, error) { + specs := make([]turn.ToolSpec, 0, len(tools)) defs := make([]model.ToolDefinition, 0, len(tools)) for _, t := range tools { d, err := schema.Canonical().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, Replay: t.Replay, Placement: t.Placement}) + specs = append(specs, turn.ToolSpec{Ref: t.Ref, Name: t.Definition.Name, DefinitionDigest: d, Policy: t.Policy, Replay: t.Replay, Placement: t.Placement}) defs = append(defs, t.Definition) } return specs, defs, nil diff --git a/agentcore/runtime/execution.go b/agentcore/runtime/execution.go index 36f7a1ce..d1afdc6b 100644 --- a/agentcore/runtime/execution.go +++ b/agentcore/runtime/execution.go @@ -7,18 +7,18 @@ import ( "github.com/felinics/twilight/agentcore/driver" "github.com/felinics/twilight/agentcore/preset" "github.com/felinics/twilight/agentcore/prompt" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/loop" - "github.com/felinics/twilight/agentcore/run/reconcile" - "github.com/felinics/twilight/agentcore/run/redispatch" - "github.com/felinics/twilight/agentcore/run/sessionstore" "github.com/felinics/twilight/agentcore/session" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/loop" + "github.com/felinics/twilight/agentcore/turn/reconcile" + "github.com/felinics/twilight/agentcore/turn/redispatch" + "github.com/felinics/twilight/agentcore/turn/sessionstore" ) // ExecutionSources are durable semantic reads, independent of ownership. type ExecutionSources struct { - Runs *sessionstore.SessionRunStore + TurnStore *sessionstore.SessionTurnStore Projections session.ProjectionReader Content chatlog.ContentResolver Frozen frozen.Store @@ -54,8 +54,8 @@ func NewExecution(cfg ExecutionConfig, src ExecutionSources) (*Execution, error) if cfg.Executor == nil || cfg.PromptBuilders == nil { return nil, errors.New("runtime: executor and prompt builder catalog are required") } - if src.Runs == nil || src.Projections == nil || src.Content == nil { - return nil, errors.New("runtime: runs, projections and content are required") + if src.TurnStore == nil || src.Projections == nil || src.Content == nil { + return nil, errors.New("runtime: turn store, projections and content are required") } if cfg.MaxRedispatches < 0 || cfg.MaxCompactionAttempts < 0 { return nil, errors.New("runtime: negative recovery attempt limit") @@ -76,12 +76,12 @@ func NewExecution(cfg ExecutionConfig, src ExecutionSources) (*Execution, error) if presets == nil { presets = preset.NewMemory() } - maintenance := &driver.Compactions{Executor: cfg.Executor, Requests: src.Runs, + maintenance := &driver.Compactions{Executor: cfg.Executor, Requests: src.TurnStore, Frozen: src.Frozen, Content: src.Content, Policy: cfg.ContextPolicy, MaxAttempts: cfg.MaxCompactionAttempts} loops := &driver.Loops{Executor: cfg.Executor, Presets: presets, PromptBuilders: cfg.PromptBuilders, Targets: cfg.TargetResolver, Sources: prompt.Sources{Projections: src.Projections, Content: src.Content}, Planner: cfg.Planner, Candidates: maintenance} return &Execution{Executor: cfg.Executor, Presets: presets, Compactions: maintenance, - Driver: &driver.Driver{Runs: src.Runs, Loops: loops, Compactions: maintenance, MissingEffects: cfg.MissingEffects, + Driver: &driver.Driver{TurnStore: src.TurnStore, Loops: loops, Compactions: maintenance, MissingEffects: cfg.MissingEffects, Redispatches: cfg.Redispatches, MaxRedispatches: cfg.MaxRedispatches, Sink: cfg.Sink}}, nil } diff --git a/agentcore/runtime/runtime.go b/agentcore/runtime/runtime.go index 2690b77d..d735476f 100644 --- a/agentcore/runtime/runtime.go +++ b/agentcore/runtime/runtime.go @@ -6,11 +6,11 @@ package runtime import ( "context" "errors" + "github.com/felinics/twilight/agentcore/turn/sessionstore" "github.com/felinics/twilight/agentcore/chatlog" "github.com/felinics/twilight/agentcore/driver" "github.com/felinics/twilight/agentcore/preset" - "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/sessionkernel" "github.com/felinics/twilight/agentcore/turn" @@ -56,22 +56,22 @@ func New(cfg Config) (*SessionRuntime, error) { chat: cfg.Chatlog, preset: cfg.Preset, newID: newID}, nil } -func (r *SessionRuntime) ref(id turn.TurnID) turn.TurnRef { - return turn.TurnRef{SessionID: r.w.SessionID(), TurnID: id} +func (r *SessionRuntime) ref(id turn.TurnID) sessionstore.TurnRef { + return sessionstore.TurnRef{SessionID: r.w.SessionID(), TurnID: id} } -func (r *SessionRuntime) QueueInput(ctx context.Context, id run.InputID, content run.CanonicalJSON) (run.AgentInput, error) { +func (r *SessionRuntime) QueueInput(ctx context.Context, id turn.InputID, content turn.CanonicalJSON) (turn.AgentInput, error) { return r.chat.Submit(ctx, r.w, id, content) } // DeliverInput explicitly targets a Turn; it never changes the placement of // a queued input implicitly or waits for the Turn to finish. -func (r *SessionRuntime) DeliverInput(ctx context.Context, id turn.TurnID, input run.InputID, content run.CanonicalJSON) (sessionkernel.TurnResult, error) { +func (r *SessionRuntime) DeliverInput(ctx context.Context, id turn.TurnID, input turn.InputID, content turn.CanonicalJSON) (sessionkernel.TurnResult, error) { in, err := r.QueueInput(ctx, input, content) if err != nil { return sessionkernel.TurnResult{}, err } - return r.turns.Deliver(ctx, r.w, sessionkernel.DeliverRequest{Ref: r.ref(id), Inputs: []run.AgentInput{in}}) + return r.turns.Deliver(ctx, r.w, sessionkernel.DeliverRequest{Ref: r.ref(id), Inputs: []turn.AgentInput{in}}) } // Step advances an active Turn or routes submitted inputs into the next Turn. @@ -81,14 +81,14 @@ func (r *SessionRuntime) Step(ctx context.Context) (driver.StepResult, error) { if err := ctx.Err(); err != nil { return driver.StepResult{}, err } - surface, err := turn.ReadSurface(ctx, r.w.Projections(), r.w.SessionID()) + surface, err := sessionstore.ReadSurface(ctx, r.w.Projections(), r.w.SessionID()) if err != nil { return driver.StepResult{}, err } if active, ok := surface.Active(); ok { return r.driver.Step(ctx, r.w, active.TurnID) } - // A Session can have outstanding maintenance without any active Run. + // A Session can have outstanding maintenance without any active Turn. if r.driver.Compactions != nil { res, err := r.driver.Compactions.Advance(ctx, r.w) if err != nil { @@ -106,9 +106,9 @@ func (r *SessionRuntime) Step(ctx context.Context) (driver.StepResult, error) { if len(pending) == 0 { return driver.StepResult{Disposition: driver.Finished}, nil } - inputs := make([]run.AgentInput, len(pending)) + inputs := make([]turn.AgentInput, len(pending)) for i, in := range pending { - inputs[i] = run.AgentInput{ID: run.InputID(in.ID), Digest: in.Digest} + inputs[i] = turn.AgentInput{ID: turn.InputID(in.ID), Digest: in.Digest} } _, err = r.turns.Start(ctx, r.w, sessionkernel.StartRequest{Ref: r.ref(r.newID()), Inputs: inputs, Preset: r.preset}) return driver.StepResult{Disposition: driver.Continue}, err @@ -117,7 +117,7 @@ func (r *SessionRuntime) Step(ctx context.Context) (driver.StepResult, error) { // Stop records the domain cancellation. Physical cancellation is recovered // from that durable disposition, never inferred from an Activation's context. func (r *SessionRuntime) Stop(ctx context.Context, reason string) (sessionkernel.TurnResult, bool, error) { - surface, err := turn.ReadSurface(ctx, r.w.Projections(), r.w.SessionID()) + surface, err := sessionstore.ReadSurface(ctx, r.w.Projections(), r.w.SessionID()) if err != nil { return sessionkernel.TurnResult{}, false, err } diff --git a/agentcore/runtime/work.go b/agentcore/runtime/work.go index 36aaa299..9b55afea 100644 --- a/agentcore/runtime/work.go +++ b/agentcore/runtime/work.go @@ -6,11 +6,11 @@ import ( "github.com/felinics/twilight/agentcore/chatlog" "github.com/felinics/twilight/agentcore/compaction" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/plan" - "github.com/felinics/twilight/agentcore/run/sessionstore" "github.com/felinics/twilight/agentcore/session" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/plan" + "github.com/felinics/twilight/agentcore/turn/sessionstore" ) // Work is a reconstructible read model, not an Activation's durable state. @@ -22,7 +22,7 @@ type Work struct { Ready bool Pending bool Executions []effect.AssignmentKey - Responses []run.ResponseRequest + Responses []turn.ResponseRequest } // Inspect uses lease-free reads. Projections can be read at different heads: @@ -59,10 +59,10 @@ func Inspect(ctx context.Context, reader session.ProjectionReader, sid session.S work.Ready = true } for _, target := range plan.RecoveryTargets(&state) { - work.Executions = append(work.Executions, effect.AssignmentKey{Session: run.Scope(sid), RunID: id, Effect: target.Effect}) + work.Executions = append(work.Executions, effect.AssignmentKey{Session: turn.Scope(sid), OwnerKind: effect.OwnerTurn, OwnerID: string(id), Effect: target.Effect}) work.Pending = true } - work.Responses = append(work.Responses, run.WaitingCalls(state)...) + work.Responses = append(work.Responses, turn.WaitingCalls(state)...) } value, head, err = reader.Load(ctx, sid, compaction.ProjectionID, compaction.Projection.Version) if err != nil { diff --git a/agentcore/session/unit/unit.go b/agentcore/session/unit/unit.go index 9c50d183..23224dec 100644 --- a/agentcore/session/unit/unit.go +++ b/agentcore/session/unit/unit.go @@ -2,8 +2,8 @@ // assembled. A Work names a CommitID and the Parts that write under // it; every Part prepares its own module's events against the same // transactional View, and the Writer appends them as one commit or nothing. -// No module builds another module's events: the Turn does not encode Run -// facts, the Run does not carry chatlog events. A Part that has to refuse +// No module builds another module's events: a Turn command does not encode +// chatlog events. A Part that has to refuse // (a precondition of its module fails) refuses the whole unit. package unit @@ -38,6 +38,9 @@ type Work struct { // operation replayed. CommitID ledger.CommitID Parts []Part + // ReplayCheck validates request content before reusing a stored commit. + // It runs under the Writer critical section without preparing any Part. + ReplayCheck func(ledger.Commit) error } // Commit appends the unit through w. A CommitID the log already holds is @@ -58,6 +61,11 @@ func Commit(ctx context.Context, w writer.Writer, now int64, work Work) (writer. if existing, found, err := view.LookupCommit(work.CommitID); err != nil { return nil, err } else if found { + if work.ReplayCheck != nil { + if err := work.ReplayCheck(existing); err != nil { + return nil, err + } + } replay = &existing return nil, nil } diff --git a/agentcore/sessionkernel/coordinator.go b/agentcore/sessionkernel/coordinator.go index 1764493d..5ef3dcbe 100644 --- a/agentcore/sessionkernel/coordinator.go +++ b/agentcore/sessionkernel/coordinator.go @@ -6,31 +6,26 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/chatlog" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/sessionstore" - "github.com/felinics/twilight/agentcore/run/store" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/unit" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/sessionstore" + "github.com/felinics/twilight/agentcore/turn/store" "time" ) -// Coordinator commits the Turn protocol's commands. Each is one unit of -// work spanning the modules a Turn's transition touches -- the Turn's own -// fact, the chatlog's deliveries and the Run's command -- prepared against -// one View and appended as one commit, so the Turn, its inputs' delivery -// state and its Run never disagree. The Coordinator has no hidden state: -// every method reads the turn surface first. It never encodes another -// module's events by hand and never drives a Run: it commits protocol -// transitions and computes dispositions. +// Coordinator commits Turn lifecycle commands. Input admission and Turn +// transitions prepare against the same Writer View and commit atomically. +// Chatlog derives delivery state from the Turn's InputAccepted facts. The +// Coordinator has no hidden state and returns after committing; the Loop +// drives execution. type Coordinator struct { // Projections is the lease-free read side for Status. Projections session.ProjectionReader - // Runs is the Run module's Session adapter: it reads Runs for Status and - // contributes the Run Parts of every Turn unit. - Runs *sessionstore.SessionRunStore + // TurnStore reads Turn state and contributes its command Parts. + TurnStore *sessionstore.SessionTurnStore // Now stamps event times; nil selects time.Now. Now func() time.Time } @@ -42,12 +37,12 @@ func (c *Coordinator) now() int64 { return time.Now().UnixMilli() } -func (c *Coordinator) surface(ctx context.Context, sid session.SessionID) (turn.TurnSurface, error) { - return turn.ReadSurface(ctx, c.Projections, sid) +func (c *Coordinator) surface(ctx context.Context, sid session.SessionID) (sessionstore.TurnSurface, error) { + return sessionstore.ReadSurface(ctx, c.Projections, sid) } // owned checks that the request addresses the Session the Writer owns. -func owned(w writer.Writer, ref turn.TurnRef) error { +func owned(w writer.Writer, ref sessionstore.TurnRef) error { if w == nil { return errors.New("sessionkernel: command requires the session's writer") } @@ -66,7 +61,7 @@ func (c *Coordinator) commit(ctx context.Context, w writer.Writer, op string, wo switch { case errors.Is(err, &ledger.Error{Code: ledger.CodeOwnershipLost}): return fmt.Errorf("%w: %w", store.ErrOwnershipLost, err) - case errors.Is(err, chatlog.ErrNotSubmitted), errors.Is(err, sessionstore.ErrRunExists): + case errors.Is(err, chatlog.ErrNotSubmitted), errors.Is(err, sessionstore.ErrTurnExists), errors.Is(err, turn.ErrCommandConflict): return fmt.Errorf("%w: %w", turn.ErrConflict, err) } return err @@ -81,17 +76,8 @@ func (c *Coordinator) commit(ctx context.Context, w writer.Writer, op string, wo } } -// turnBatch is one batch of events in the Turn's stream. -func turnBatch(turnID turn.TurnID, now int64, events ...writer.TypedEvent) []writer.TypedBatch { - for i := range events { - events[i].RecordedAtUnixMilli = now - } - return []writer.TypedBatch{{Domain: turn.Stream(turnID), Events: events}} -} - -// Start opens a new Turn: the Turn's started fact, the chatlog's deliveries -// and the Run's creation are one unit; the chatlog Part enforces, on the -// same View, that every input is still submitted. +// Start records TurnStarted and initial InputAccepted facts in one commit. +// Admission checks that every input is still submitted with matching content. func (c *Coordinator) Start(ctx context.Context, w writer.Writer, req StartRequest) (TurnResult, error) { if req.Ref.SessionID == "" || req.Ref.TurnID == "" || req.Preset.ID == "" || req.Preset.Digest == "" { return TurnResult{}, errors.New("sessionkernel: start requires ref and preset") @@ -99,20 +85,17 @@ func (c *Coordinator) Start(ctx context.Context, w writer.Writer, req StartReque if err := owned(w, req.Ref); err != nil { return TurnResult{}, err } - inputIDs := make([]chatlog.InputID, len(req.Inputs)) - seen := map[run.InputID]struct{}{} - for i, in := range req.Inputs { + seen := map[turn.InputID]struct{}{} + for _, in := range req.Inputs { if _, dup := seen[in.ID]; dup || in.ID == "" { return TurnResult{}, errors.New("sessionkernel: start inputs must have unique non-empty IDs") } seen[in.ID] = struct{}{} - inputIDs[i] = chatlog.InputID(in.ID) } sid, turnID := req.Ref.SessionID, req.Ref.TurnID - p := turn.PlanDigest(turnID, req.Preset.Digest, inputIDs) - commitID := ledger.CommitID(turn.StartOperationDigest(sid, turnID, p)) - runID := turn.DeriveRunID(sid, turnID) - newRun, err := run.BuildNewRun(runID, ledger.CausationID(commitID)) + p := sessionstore.PlanDigest(turnID, req.Preset, req.Inputs) + commitID := ledger.CommitID(sessionstore.StartOperationDigest(sid, turnID, p)) + newTurn, err := turn.BuildNewTurn(turnID, ledger.CausationID(commitID), req.Preset) if err != nil { return TurnResult{}, err } @@ -128,11 +111,10 @@ func (c *Coordinator) Start(ctx context.Context, w writer.Writer, req StartReque if _, active := surface.Active(); active { return nil, fmt.Errorf("%w: session already has an active turn", turn.ErrConflict) } - return turnBatch(turnID, now, - writer.TypedEvent{Type: turn.TypeStarted, Value: turn.StartedPayload{TurnID: turnID, RunID: runID, InputIDs: inputIDs, Preset: req.Preset}}), nil + return nil, nil }), - chatlog.DeliverInputs(chatlog.TurnID(turnID), runID, req.Inputs), - sessionstore.CreateRun(newRun, req.Inputs), + chatlog.AdmitInputs(req.Inputs), + sessionstore.CreateTurn(newTurn, req.Inputs), }} if err := c.commit(ctx, w, "start", work); err != nil { return TurnResult{}, err @@ -140,16 +122,15 @@ func (c *Coordinator) Start(ctx context.Context, w writer.Writer, req StartReque return c.respond(ctx, req.Ref) } -// Deliver carries inputs into an active Turn: the Run's AcceptInput and the -// chatlog's deliveries are one unit, so the Run accepts every input and the -// chatlog delivers every input, or nothing is written. The command derives -// from the ordered InputIDs; a replay of the same batch is AlreadyApplied. +// Deliver atomically validates submitted content and records InputAccepted. +// The command identity derives from ordered InputIDs; replay also checks the +// accepted content digests before reusing a stored commit. func (c *Coordinator) Deliver(ctx context.Context, w writer.Writer, req DeliverRequest) (TurnResult, error) { if err := owned(w, req.Ref); err != nil { return TurnResult{}, err } sid := req.Ref.SessionID - surface, err := turn.ReadSurface(ctx, w.Projections(), sid) + surface, err := sessionstore.ReadSurface(ctx, w.Projections(), sid) if err != nil { return TurnResult{}, err } @@ -158,23 +139,23 @@ func (c *Coordinator) Deliver(ctx context.Context, w writer.Writer, req DeliverR return TurnResult{}, fmt.Errorf("%w: turn %s is not active", turn.ErrConflict, req.Ref.TurnID) } // AcceptInput is not a hard-CAS command: no Base is needed; the machine - // projection is read only for the Run's protocol version. - runID := view.RunID + // projection is read only for the Turn's protocol version. + turnID := view.TurnID if len(req.Inputs) == 0 { return TurnResult{}, fmt.Errorf("%w: deliver without inputs", turn.ErrConflict) } - cmd := run.AcceptInput{Inputs: req.Inputs} - env, err := schema.Wire().Envelope(runID, schema.Identity().DeriveInputCommandID(runID, cmd.InputIDs()...), cmd) + cmd := turn.AcceptInput{Inputs: req.Inputs} + env, err := schema.Wire().Envelope(turnID, schema.Identity().DeriveInputCommandID(turnID, cmd.InputIDs()...), cmd) if err != nil { return TurnResult{}, err } - accept, err := c.Runs.Command(ctx, store.CommitRequest{Command: env}) + accept, err := c.TurnStore.Command(ctx, store.CommitRequest{Command: env}) if err != nil { return TurnResult{}, err } - work := unit.Work{CommitID: ledger.CommitID(env.ID), Parts: []unit.Part{accept, chatlog.DeliverInputs(chatlog.TurnID(req.Ref.TurnID), runID, req.Inputs)}} + work := unit.Work{CommitID: ledger.CommitID(env.ID), ReplayCheck: accept.CheckInputReplay, Parts: []unit.Part{accept, chatlog.AdmitInputs(req.Inputs)}} if err := c.commit(ctx, w, "deliver", work); err != nil { - if errors.Is(err, run.ErrRunTerminal) { + if errors.Is(err, turn.ErrTurnTerminal) { // The last step settled first: the inputs stay submitted. return c.respond(ctx, req.Ref) } @@ -183,15 +164,14 @@ func (c *Coordinator) Deliver(ctx context.Context, w writer.Writer, req DeliverR return c.respond(ctx, req.Ref) } -// Stop settles the active Turn as stopped: CancelRun rebases on the current -// state, and the Run's cancellation and the Turn's failed settlement are one -// unit. +// Stop commits cancellation dispositions and TurnEnded as one transition. +// CancelTurn rebases on the latest state inside the Writer transaction. func (c *Coordinator) Stop(ctx context.Context, w writer.Writer, req StopRequest) (TurnResult, error) { if err := owned(w, req.Ref); err != nil { return TurnResult{}, err } sid, turnID := req.Ref.SessionID, req.Ref.TurnID - surface, err := turn.ReadSurface(ctx, w.Projections(), sid) + surface, err := sessionstore.ReadSurface(ctx, w.Projections(), sid) if err != nil { return TurnResult{}, err } @@ -199,33 +179,27 @@ func (c *Coordinator) Stop(ctx context.Context, w writer.Writer, req StopRequest if !ok || view.Status != turn.TurnActive { return TurnResult{}, fmt.Errorf("%w: turn %s is not active", turn.ErrConflict, turnID) } - runID := view.RunID - env, err := schema.Wire().Envelope(runID, turn.CancelCommandID(sid, turnID, runID), run.CancelRun{}) + env, err := schema.Wire().Envelope(turnID, sessionstore.CancelCommandID(sid, turnID), turn.CancelTurn{Message: req.Reason}) if err != nil { return TurnResult{}, err } - cancel, err := c.Runs.Command(ctx, store.CommitRequest{Command: env}) + cancel, err := c.TurnStore.Command(ctx, store.CommitRequest{Command: env}) if err != nil { return TurnResult{}, err } - work := unit.Work{CommitID: ledger.CommitID(env.ID), Parts: []unit.Part{cancel, - unit.PartFunc(func(_ context.Context, _ writer.View, now int64) ([]writer.TypedBatch, error) { - return turnBatch(turnID, now, writer.TypedEvent{Type: turn.TypeFailed, - Value: turn.FailedPayload{TurnID: turnID, RunID: runID, Settlement: turn.SettlementStopped, FailureClass: "cancelled", Reason: req.Reason}}), nil - }), - }} - if err := c.commit(ctx, w, "stop", work); err != nil && !errors.Is(err, run.ErrRunTerminal) { + work := unit.Work{CommitID: ledger.CommitID(env.ID), Parts: []unit.Part{cancel}} + if err := c.commit(ctx, w, "stop", work); err != nil && !errors.Is(err, turn.ErrTurnTerminal) { return TurnResult{}, err } return c.respond(ctx, req.Ref) } -func (c *Coordinator) Status(ctx context.Context, ref turn.TurnRef) (TurnResult, error) { +func (c *Coordinator) Status(ctx context.Context, ref sessionstore.TurnRef) (TurnResult, error) { return c.respond(ctx, ref) } // respond reads the projections and fills the disposition. -func (c *Coordinator) respond(ctx context.Context, ref turn.TurnRef) (TurnResult, error) { +func (c *Coordinator) respond(ctx context.Context, ref sessionstore.TurnRef) (TurnResult, error) { surface, err := c.surface(ctx, ref.SessionID) if err != nil { return TurnResult{}, err @@ -237,13 +211,13 @@ func (c *Coordinator) respond(ctx context.Context, ref turn.TurnRef) (TurnResult return c.responseFor(ctx, ref, &view) } -func (c *Coordinator) responseFor(ctx context.Context, ref turn.TurnRef, view *turn.TurnView) (TurnResult, error) { - resp := TurnResult{Ref: ref, Status: view.Status, RunID: view.RunID, End: view.Ended()} +func (c *Coordinator) responseFor(ctx context.Context, ref sessionstore.TurnRef, view *sessionstore.TurnView) (TurnResult, error) { + resp := TurnResult{Ref: ref, Status: view.Status, End: view.Ended()} if view.End != nil { resp.Disposition = ResumeFinished return resp, nil } - record, err := c.Runs.Record(ctx, ref.SessionID, view.RunID) + record, err := c.TurnStore.Record(ctx, ref.SessionID, view.TurnID) if err != nil { return TurnResult{}, err } @@ -251,10 +225,10 @@ func (c *Coordinator) responseFor(ctx context.Context, ref turn.TurnRef, view *t switch { case snapshot.State.Status.Terminal(): resp.Disposition = ResumeFinished - case run.NeedsRecovery(snapshot.State): + case turn.NeedsRecovery(snapshot.State): resp.Disposition = ResumeWaitingForRecovery default: - resp.Waiting = run.WaitingCalls(snapshot.State) + resp.Waiting = turn.WaitingCalls(snapshot.State) if len(resp.Waiting) > 0 { resp.Disposition = ResumeWaitingForResponse } diff --git a/agentcore/sessionkernel/kernel.go b/agentcore/sessionkernel/kernel.go index 10a8d62b..cfe853e2 100644 --- a/agentcore/sessionkernel/kernel.go +++ b/agentcore/sessionkernel/kernel.go @@ -1,6 +1,6 @@ // Package sessionkernel is the Session-side semantic mechanism: the durable // fact layer one Session's meaning lives in -- the Store and its Writers, -// the module Registry and the projections folded from it, the Run module's +// the module Registry and the projections folded from it, the Turn module's // Session adapter, the Turn protocol's committer, the chatlog's commands, // the content store behind the frozen bodies and the fork/collect lifecycle // over the Store. These components read durable semantic state, write it @@ -21,11 +21,11 @@ import ( "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" "github.com/felinics/twilight/agentcore/observe" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/sessionstore" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/sessionstore" ) // Artifacts groups the artifact ports: the binding index the @@ -45,7 +45,7 @@ type Ports struct { // Store is the Session kernel (required). Store session.Stores // Content is the cas ContentStore the frozen bodies live in under - // sessionstore.FrozenAuthority. The run store writes them; + // sessionstore.FrozenAuthority. The Turn store writes them; // the materializer reads them for prompts, replies and transcripts. // Required. Content artifact.ContentStore @@ -80,9 +80,9 @@ type Kernel struct { Writers *writer.Factory Registry *module.Registry Admission writer.Admission - // Runs is the Run module's Session adapter: the Run core's store bound - // per Writer, Run reads by SessionID and the Run Parts of Turn units. - Runs *sessionstore.SessionRunStore + // TurnStore is the Turn module's Session adapter: the Turn core's store bound + // per Writer, with reads by SessionID and command Parts for atomic units. + TurnStore *sessionstore.SessionTurnStore // Turns commits the Turn protocol and reads Turn status. Turns *Coordinator // Bus is the process-local committed fan-out. Tailer is the durable @@ -151,7 +151,7 @@ func New(p Ports) (*Kernel, error) { //nolint:gocritic // hugeParam: Ports is a admission := writer.Admission{Bindings: bindings, Ledger: retention} writers := &writer.Factory{Store: store, Registry: registry, Admission: admission, Config: writer.WritersConfig{Cache: cache, CachePolicy: sessionstore.WriterCachePolicy(p.CacheEvery), Observers: observers}} - runs, err := sessionstore.NewSessionRunStore(sessionstore.Config{Registry: registry, Store: store, Frozen: fz, Cache: cache, Now: now, PrepareGuard: compaction.RequireIdle}) + turns, err := sessionstore.NewSessionTurnStore(sessionstore.Config{Registry: registry, Store: store, Frozen: fz, Cache: cache, Now: now, PrepareGuard: compaction.RequireIdle}) if err != nil { return nil, err } @@ -161,8 +161,8 @@ func New(p Ports) (*Kernel, error) { //nolint:gocritic // hugeParam: Ports is a projections := session.NewProjectionReader(store, registry, cache) content := sessionstore.NewContent(fz) return &Kernel{ - Store: store, Writers: writers, Registry: registry, Admission: admission, Runs: runs, - Turns: &Coordinator{Projections: projections, Runs: runs, Now: now}, + Store: store, Writers: writers, Registry: registry, Admission: admission, TurnStore: turns, + Turns: &Coordinator{Projections: projections, TurnStore: turns, Now: now}, Bus: bus, Tailer: tailer, Frozen: fz, Projections: projections, Content: content, Chatlog: &chatlog.Commands{Now: now}, History: history.History{Store: store, Registry: registry, Projections: projections}, @@ -175,7 +175,7 @@ func New(p Ports) (*Kernel, error) { //nolint:gocritic // hugeParam: Ports is a // authoritative projections. func NewRegistry(extensions []module.ModuleDescriptor) (*module.Registry, error) { return module.BuildRegistryWithExtensions( - []module.ModuleDescriptor{chatlog.Module, sessionstore.Module, turn.Module, compaction.Module}, extensions) + []module.ModuleDescriptor{chatlog.Module, sessionstore.Module, compaction.Module}, extensions) } // Close ends the Kernel's durable read/tailer resources. Caller-owned Writers @@ -231,9 +231,8 @@ func (a *Kernel) Fork(ctx context.Context, req ForkRequest) (session.SegmentHead if req.Parent == req.Child { return session.SegmentHeader{}, errors.New("sessionkernel: a session cannot fork itself") } - // A fork point inside a Turn would hand the child a Turn whose Run is - // the parent's execution: semantic history branches only at - // quiescent points. + // Semantic history branches at a completed Turn boundary so the child + // inherits context without inheriting an active execution obligation. if active, ok, err := a.History.ActiveAt(ctx, req.Parent, req.At); err != nil { return session.SegmentHeader{}, err } else if ok { diff --git a/agentcore/sessionkernel/protocol.go b/agentcore/sessionkernel/protocol.go index cb8cc615..f8542b49 100644 --- a/agentcore/sessionkernel/protocol.go +++ b/agentcore/sessionkernel/protocol.go @@ -2,36 +2,35 @@ package sessionkernel import ( "context" + "github.com/felinics/twilight/agentcore/turn/sessionstore" "github.com/felinics/twilight/agentcore/preset" - "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/turn" ) -// StartRequest opens a new Turn under Preset with Inputs delivered into its -// Run. +// StartRequest opens a new Turn under immutable Preset and accepts Inputs. type StartRequest struct { - Ref turn.TurnRef - Inputs []run.AgentInput + Ref sessionstore.TurnRef + Inputs []turn.AgentInput Preset preset.PresetRef } -// DeliverRequest carries Inputs into an active Turn's Run. +// DeliverRequest accepts Inputs into an active Turn. type DeliverRequest struct { - Ref turn.TurnRef - Inputs []run.AgentInput + Ref sessionstore.TurnRef + Inputs []turn.AgentInput } // StopRequest settles the active Turn as stopped with Reason. type StopRequest struct { - Ref turn.TurnRef + Ref sessionstore.TurnRef Reason string } // ResumeDisposition is where a still-active Turn stands when a caller looks // at it again. It is not a Turn's own fact: the Coordinator computes it from -// the Turn's status and its Run's state. +// the Turn's current state. type ResumeDisposition string const ( @@ -43,18 +42,17 @@ const ( // TurnResult is the Turn protocol's answer: the Turn's status, and where it // stands when it is still active. type TurnResult struct { - Ref turn.TurnRef - RunID run.RunID + Ref sessionstore.TurnRef Status turn.TurnStatus Disposition ResumeDisposition - End run.RunEnd - Waiting []run.ResponseRequest + End turn.TurnEnd + Waiting []turn.ResponseRequest } // Commands are the Turn protocol commits. Each takes the Writer of the // Session it commits to: the caller's ownership capability, so every command // lands on the same Writer, epoch and projection view as the other domains' -// commands, and a stale owner is fenced by the Writer itself. Driving a Run +// commands, and a stale owner is fenced by the Writer itself. Driving a Turn // is not among them: every method returns as soon as its commit landed. type Commands interface { Start(context.Context, writer.Writer, StartRequest) (TurnResult, error) @@ -64,5 +62,5 @@ type Commands interface { // Reader is the Turn status read; it needs no ownership. type Reader interface { - Status(context.Context, turn.TurnRef) (TurnResult, error) + Status(context.Context, sessionstore.TurnRef) (TurnResult, error) } diff --git a/agentcore/sessionkernel/quiescence.go b/agentcore/sessionkernel/quiescence.go index 69c8aaf4..0a77b645 100644 --- a/agentcore/sessionkernel/quiescence.go +++ b/agentcore/sessionkernel/quiescence.go @@ -3,21 +3,20 @@ package sessionkernel import ( "fmt" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/sessionstore" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/sessionstore" ) // surfaceOf loads the turn surface from a commit View. -func surfaceOf(v writer.View) (turn.TurnSurface, error) { - state, err := v.Projection(turn.SurfaceProjectionID, turn.SurfaceProjection.Version) +func surfaceOf(v writer.View) (sessionstore.TurnSurface, error) { + state, err := v.Projection(sessionstore.SurfaceProjectionID, sessionstore.SurfaceProjection.Version) if err != nil { - return turn.TurnSurface{}, err + return sessionstore.TurnSurface{}, err } - surface, ok := state.(turn.TurnSurface) + surface, ok := state.(sessionstore.TurnSurface) if !ok { - return turn.TurnSurface{}, fmt.Errorf("sessionkernel: surface projection is %T", state) + return sessionstore.TurnSurface{}, fmt.Errorf("sessionkernel: surface projection is %T", state) } return surface, nil } @@ -36,13 +35,13 @@ func RequireNoActiveTurn(v writer.View) error { return nil } -// RequireQuiescentRun is the guard that also admits a point between the -// steps of a Turn: no active Turn, or the active Turn's Run is Open, or it +// RequireQuiescentTurn is the guard that also admits a point between the +// steps of a Turn: no active Turn, or the active Turn's Turn is Open, or it // is in a ToolStep with no Executing call. A model step Prepared or // Executing, or a tool call Executing, is turn.ErrConflict: the context such // a step reads or settles into must not move under it. Pending and Waiting // calls are fine; the closure rule keeps their assistant. -func RequireQuiescentRun(v writer.View) error { +func RequireQuiescentTurn(v writer.View) error { surface, err := surfaceOf(v) if err != nil { return err @@ -59,15 +58,15 @@ func RequireQuiescentRun(v writer.View) error { if !ok { return fmt.Errorf("sessionkernel: machine projection is %T", mstate) } - state, ok := machine.Active[active.RunID] + state, ok := machine.Active[active.TurnID] if !ok { - return nil // no live Run: nothing is mid-step + return nil // no live Turn: nothing is mid-step } switch state.Current.(type) { - case run.Open: + case turn.Open: return nil - case run.ToolStep: - if calls := run.ExecutingCalls(state); len(calls) > 0 { + case turn.ToolStep: + if calls := turn.ExecutingCalls(state); len(calls) > 0 { return fmt.Errorf("%w: turn %s has %d executing tool call(s)", turn.ErrConflict, active.TurnID, len(calls)) } return nil diff --git a/agentcore/testutil/local/contract.go b/agentcore/testutil/local/contract.go index e9a13ba1..20cbe0dc 100644 --- a/agentcore/testutil/local/contract.go +++ b/agentcore/testutil/local/contract.go @@ -8,17 +8,17 @@ import ( "context" "encoding/json" - run "github.com/felinics/twilight/agentcore/run" - effect "github.com/felinics/twilight/agentcore/run/effect" + turn "github.com/felinics/twilight/agentcore/turn" + effect "github.com/felinics/twilight/agentcore/turn/effect" "github.com/felinics/twilight/sdk" ) -// ModelCatalog resolves a frozen run.ModelRef into an invoker at execution +// ModelCatalog resolves a frozen turn.ModelRef into an invoker at execution // time; provider binding never enters the frozen request. The same ModelRef -// must resolve to equivalent execution semantics for the life of a Run. +// must resolve to equivalent execution semantics for the life of a Turn. type ModelCatalog interface { - ResolveModel(run.ModelRef) (ModelInvoker, error) + ResolveModel(turn.ModelRef) (ModelInvoker, error) } type ModelInvoker interface { @@ -32,46 +32,46 @@ type StreamingModelInvoker interface { } type ToolCatalog interface { - ResolveTool(run.ToolRef) (ExecutableTool, error) + ResolveTool(turn.ToolRef) (ExecutableTool, error) } type ToolExecutionRequest struct { - RunID run.RunID - StepID run.StepID - CallID run.CallID + TurnID turn.TurnID + StepID turn.StepID + CallID turn.CallID // Effect is the tool effect the call was started under; it identifies the // Outcome the Executor returns through its message-shaped port. - Effect run.EffectID - ToolRef run.ToolRef - DefinitionDigest run.Digest - Arguments run.CanonicalJSON + Effect turn.EffectID + ToolRef turn.ToolRef + DefinitionDigest turn.Digest + Arguments turn.CanonicalJSON // Target is an opaque resource reference supplied by the application; the // executor does not interpret it. - Target *run.TargetRef + Target *turn.TargetRef Progress ToolProgressSink } // ExecutableTool is the tool-side execution contract of the colocated // executor. type ExecutableTool interface { - Ref() run.ToolRef + Ref() turn.ToolRef Definition() sdk.ToolDefinition - ResponsePolicy() run.ResponsePolicy + ResponsePolicy() turn.ResponsePolicy // ValidateArguments runs before the start barrier and must not produce // external effects. - ValidateArguments(run.CanonicalJSON) error + ValidateArguments(turn.CanonicalJSON) error Execute(context.Context, ToolExecutionRequest) ToolExecutionOutcome // Replay declares whether Execute may run again for the same call after // an earlier execution was lost. Every tool answers; the zero value // ReplayUnknown is the answer of a tool whose author has not judged it. // The declaration is frozen into the ToolSpec and carried on every call // and Assignment. - Replay() run.ReplayPolicy + Replay() turn.ReplayPolicy // Placement declares where the tool runs: in the executor process, or // inside the Session's workspace, in which case its calls need a target // and are routed to the workspace backend. Every tool answers; the // declaration is frozen into the ToolSpec. - Placement() run.ToolPlacement + Placement() turn.ToolPlacement } // Tool outcomes belong to the process-independent effect protocol. Aliases diff --git a/agentcore/testutil/local/executor.go b/agentcore/testutil/local/executor.go index 1190a295..baa19c29 100644 --- a/agentcore/testutil/local/executor.go +++ b/agentcore/testutil/local/executor.go @@ -12,12 +12,12 @@ import ( "sync" "github.com/felinics/twilight/agentcore/executor/notice" - run "github.com/felinics/twilight/agentcore/run" - effect "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/schema" "github.com/felinics/twilight/agentcore/testutil/sdkconv" + turn "github.com/felinics/twilight/agentcore/turn" + effect "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/schema" "github.com/felinics/twilight/sdk" ) @@ -120,8 +120,7 @@ type LocalExecutor struct { const DefaultRetainedOutcomes = 1024 type inflight struct { - runID run.RunID - digest run.Digest + digest turn.Digest cancel context.CancelFunc done chan struct{} outcome Outcome @@ -200,53 +199,53 @@ func (e *LocalExecutor) evictLocked() { // Validate checks the tool definition and policy or resolves the ModelRef // in the catalog so invalid assignments fail before any start fact is written. -func (e *LocalExecutor) Validate(_ context.Context, a Assignment) (*run.ToolFailure, error) { +func (e *LocalExecutor) Validate(_ context.Context, a Assignment) (*turn.ToolFailure, error) { switch body := a.Body.(type) { case ModelAssignment: invoker, err := e.models.ResolveModel(body.Model) if err != nil { - return &run.ToolFailure{Class: run.FailureProvider, Message: err.Error()}, nil + return &turn.ToolFailure{Class: turn.FailureProvider, Message: err.Error()}, nil } if invoker == nil { - return &run.ToolFailure{Class: run.FailureProvider, Message: "model catalog returned a nil invoker"}, nil + return &turn.ToolFailure{Class: turn.FailureProvider, Message: "model catalog returned a nil invoker"}, nil } return nil, nil case ToolAssignment: _, failure := e.resolveTool(&body) return failure, nil default: - return &run.ToolFailure{Class: run.FailureProvider, Message: "assignment without body"}, nil + return &turn.ToolFailure{Class: turn.FailureProvider, Message: "assignment without body"}, nil } } -func (e *LocalExecutor) resolveTool(t *ToolAssignment) (ExecutableTool, *run.ToolFailure) { +func (e *LocalExecutor) resolveTool(t *ToolAssignment) (ExecutableTool, *turn.ToolFailure) { tool, resolveErr := e.tools.ResolveTool(t.ToolRef) if resolveErr != nil { - return nil, &run.ToolFailure{Class: run.FailureToolLookup, Message: resolveErr.Error()} + return nil, &turn.ToolFailure{Class: turn.FailureToolLookup, Message: resolveErr.Error()} } if tool == nil { - return nil, &run.ToolFailure{Class: run.FailureToolLookup, Message: "tool catalog returned a nil tool"} + return nil, &turn.ToolFailure{Class: turn.FailureToolLookup, Message: "tool catalog returned a nil tool"} } toolDef, freezeErr := sdkconv.FreezeToolDefinition(tool.Definition()) if freezeErr != nil { - return nil, &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: freezeErr.Error()} + return nil, &turn.ToolFailure{Class: turn.FailureDefinitionMismatch, Message: freezeErr.Error()} } defDigest, digestErr := schema.Canonical().DigestToolDefinition(toolDef) if digestErr != nil { - return nil, &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: digestErr.Error()} + return nil, &turn.ToolFailure{Class: turn.FailureDefinitionMismatch, Message: digestErr.Error()} } switch { case tool.Ref() != t.ToolRef || defDigest != t.DefinitionDigest: - return nil, &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: "tool definition digest mismatch"} + return nil, &turn.ToolFailure{Class: turn.FailureDefinitionMismatch, Message: "tool definition digest mismatch"} case tool.ResponsePolicy() != t.Policy: - return nil, &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: "response policy mismatch"} + return nil, &turn.ToolFailure{Class: turn.FailureDefinitionMismatch, Message: "response policy mismatch"} case tool.Replay() != t.Replay: - return nil, &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: "replay policy mismatch"} + return nil, &turn.ToolFailure{Class: turn.FailureDefinitionMismatch, Message: "replay policy mismatch"} case tool.Placement() != t.Placement: - return nil, &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: "placement mismatch"} + return nil, &turn.ToolFailure{Class: turn.FailureDefinitionMismatch, Message: "placement mismatch"} } if argErr := tool.ValidateArguments(t.Arguments); argErr != nil { - return nil, &run.ToolFailure{Class: run.FailureInvalidArguments, Message: argErr.Error()} + return nil, &turn.ToolFailure{Class: turn.FailureInvalidArguments, Message: argErr.Error()} } return tool, nil } @@ -312,7 +311,7 @@ func (e *LocalExecutor) Start(ctx context.Context, ref string, a Assignment) err } effectCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) - entry := &inflight{runID: a.RunID, digest: digest, cancel: cancel, done: make(chan struct{})} + entry := &inflight{digest: digest, cancel: cancel, done: make(chan struct{})} e.mu.Lock() if existing, dup := e.inflight[key]; dup { e.mu.Unlock() @@ -519,7 +518,7 @@ func (e *LocalExecutor) invokeModel(ctx context.Context, invoker ModelInvoker, r func (e *LocalExecutor) runTool(ctx context.Context, a Assignment, t ToolAssignment, tool ExecutableTool) Outcome { req := ToolExecutionRequest{ - RunID: a.RunID, + TurnID: turn.TurnID(a.OwnerID), StepID: a.StepID, CallID: a.CallID, Effect: a.Effect, @@ -532,7 +531,7 @@ func (e *LocalExecutor) runTool(ctx context.Context, a Assignment, t ToolAssignm return Outcome{Result: executeToolSafely(ctx, tool, &req)} } -func cloneTarget(target *run.TargetRef) *run.TargetRef { +func cloneTarget(target *turn.TargetRef) *turn.TargetRef { if target == nil { return nil } @@ -546,11 +545,13 @@ func cloneTarget(target *run.TargetRef) *run.TargetRef { 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, + outcome = ToolExecutionUnknown{Failure: turn.ToolFailure{ + Class: turn.FailureEffectUnknown, Message: fmt.Sprintf("tool panic: %v", r), }} } }() return tool.Execute(ctx, *req) } + +const OwnerTurn = effect.OwnerTurn diff --git a/agentcore/testutil/local/local.go b/agentcore/testutil/local/local.go index 43055c30..54fdd15c 100644 --- a/agentcore/testutil/local/local.go +++ b/agentcore/testutil/local/local.go @@ -5,19 +5,19 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/executor" - "github.com/felinics/twilight/agentcore/run" + "github.com/felinics/twilight/agentcore/turn" ) // Catalog is a static effect catalog for a colocated deployment: the model // invokers and tool implementations one process serves. type Catalog struct { - models map[run.ModelRef]ModelInvoker - tools map[run.ToolRef]ExecutableTool + models map[turn.ModelRef]ModelInvoker + tools map[turn.ToolRef]ExecutableTool } // NewCatalog builds a Catalog; models maps each ModelRef to its invoker. -func NewCatalog(models map[run.ModelRef]ModelInvoker, tools ...ExecutableTool) (*Catalog, error) { - c := &Catalog{models: make(map[run.ModelRef]ModelInvoker, len(models)), tools: make(map[run.ToolRef]ExecutableTool, len(tools))} +func NewCatalog(models map[turn.ModelRef]ModelInvoker, tools ...ExecutableTool) (*Catalog, error) { + c := &Catalog{models: make(map[turn.ModelRef]ModelInvoker, len(models)), tools: make(map[turn.ToolRef]ExecutableTool, len(tools))} for ref, inv := range models { if ref == "" || inv == nil { return nil, errors.New("local: catalog requires a model ref and an invoker") @@ -36,7 +36,7 @@ func NewCatalog(models map[run.ModelRef]ModelInvoker, tools ...ExecutableTool) ( return c, nil } -func (c *Catalog) ResolveModel(ref run.ModelRef) (ModelInvoker, error) { +func (c *Catalog) ResolveModel(ref turn.ModelRef) (ModelInvoker, error) { inv, ok := c.models[ref] if !ok { return nil, fmt.Errorf("local: unknown model %q", ref) @@ -44,7 +44,7 @@ func (c *Catalog) ResolveModel(ref run.ModelRef) (ModelInvoker, error) { return inv, nil } -func (c *Catalog) ResolveTool(ref run.ToolRef) (ExecutableTool, error) { +func (c *Catalog) ResolveTool(ref turn.ToolRef) (ExecutableTool, error) { t, ok := c.tools[ref] if !ok { return nil, fmt.Errorf("local: unknown tool %q", ref) diff --git a/agentcore/testutil/local/progress.go b/agentcore/testutil/local/progress.go index abf3534d..1edd8f48 100644 --- a/agentcore/testutil/local/progress.go +++ b/agentcore/testutil/local/progress.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" - effect "github.com/felinics/twilight/agentcore/run/effect" + effect "github.com/felinics/twilight/agentcore/turn/effect" ) // progressSink is the ToolProgressSink of one tool execution: each payload diff --git a/agentcore/testutil/sdkconv/convert.go b/agentcore/testutil/sdkconv/convert.go index 7edc99e4..bc70552a 100644 --- a/agentcore/testutil/sdkconv/convert.go +++ b/agentcore/testutil/sdkconv/convert.go @@ -7,7 +7,7 @@ import ( "unicode/utf8" "github.com/felinics/twilight/agentcore/jsonstable" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/turn/model" "github.com/felinics/twilight/sdk" "github.com/google/jsonschema-go/jsonschema" ) diff --git a/agentcore/testutil/sdkconv/doc.go b/agentcore/testutil/sdkconv/doc.go index 0a6bcd21..07b0cd8e 100644 --- a/agentcore/testutil/sdkconv/doc.go +++ b/agentcore/testutil/sdkconv/doc.go @@ -1,5 +1,5 @@ // Package sdkconv provides test-side conversions between the SDK's model types and the persisted -// values of agentcore/run/model. Freeze* turns a caller-owned SDK value into +// values of agentcore/turn/model. Freeze* turns a caller-owned SDK value into // a detached run value; the function named after a value type converts that // value back into a detached SDK value. // From f26b09c584716eb655b63223c6fec01846b3a3a6 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 17 Oct 2026 20:59:13 +0800 Subject: [PATCH 3/5] test(agentcore): update Turn conformance and regression coverage --- agentcore/chatlog/chatlog_test.go | 120 +++++---- agentcore/chatlog/compaction_test.go | 17 +- agentcore/chatlog/foldbench_test.go | 8 +- agentcore/chatlog/golden_test.go | 2 +- agentcore/chatlog/table_test.go | 8 +- agentcore/driver/budget_test.go | 12 +- agentcore/executor/cancellation_test.go | 2 +- agentcore/executor/discovery_test.go | 4 +- agentcore/executor/executor_test.go | 20 +- agentcore/executor/progress_test.go | 6 +- .../executor/store/storetest/conformance.go | 8 +- .../executor/store/storetest/discovery.go | 9 +- .../store/storetest/discovery_conformance.go | 31 +-- agentcore/executor/store/storetest/map.go | 9 +- agentcore/executor/supervise_test.go | 26 +- agentcore/ledger/commit_test.go | 6 +- agentcore/preset/preset_test.go | 4 +- agentcore/runtime/compaction_boundary_test.go | 2 +- agentcore/runtime/compaction_fault_test.go | 2 +- agentcore/runtime/compaction_fork_test.go | 61 ++++- agentcore/runtime/compaction_test.go | 63 +++-- agentcore/runtime/runtimetest/conformance.go | 246 ++++++++---------- agentcore/runtime/runtimetest/doc.go | 4 +- agentcore/runtime/runtimetest/harness.go | 139 +++++----- .../session/filestore/conformance_test.go | 2 +- agentcore/session/filestore/golden_test.go | 10 +- .../filestore/testdata/golden_log.jsonl | 2 +- agentcore/session/sessiontest/conformance.go | 52 ++-- agentcore/session/sessiontest/fork.go | 18 +- agentcore/session/sessiontest/index.go | 8 +- agentcore/session/unit/unit_test.go | 44 +++- agentcore/sessionkernel/coordinator_test.go | 33 ++- agentcore/sessionkernel/lifecycle_test.go | 2 +- agentcore/sessionkernel/quiescence_test.go | 33 ++- 34 files changed, 542 insertions(+), 471 deletions(-) diff --git a/agentcore/chatlog/chatlog_test.go b/agentcore/chatlog/chatlog_test.go index 5d37ffea..80ca4e3f 100644 --- a/agentcore/chatlog/chatlog_test.go +++ b/agentcore/chatlog/chatlog_test.go @@ -6,11 +6,12 @@ import ( "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/sessionstore" + "github.com/felinics/twilight/agentcore/preset/ref" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/sessionstore" "testing" ) @@ -28,20 +29,28 @@ type step struct { value any } -func runStep(runID run.RunID, f run.Fact) step { - return step{sessionstore.EventType(f), sessionstore.Event{RunID: runID, Fact: f}} +func runStep(turnID turn.TurnID, f turn.Fact) step { + return step{sessionstore.EventType(f), sessionstore.Event{TurnID: turnID, Fact: f}} } -func completed(runID run.RunID, stepID run.StepID, digest jsonstable.Digest) step { - return runStep(runID, run.ModelStepCompleted{StepID: stepID, FinishReason: model.FinishReasonStop, ResultDigest: digest}) +func accepted(id turn.TurnID, input InputID) step { + digest, err := DigestInput(input, jsonstable.MustParse(`{"text":"hi"}`)) + if err != nil { + panic(err) + } + return runStep(id, turn.InputAccepted{Input: turn.AgentInput{ID: turn.InputID(input), Digest: digest}}) +} + +func completed(turnID turn.TurnID, stepID turn.StepID, digest jsonstable.Digest) step { + return runStep(turnID, turn.ModelStepCompleted{StepID: stepID, FinishReason: model.FinishReasonStop, ResultDigest: digest}) } -func opened(runID run.RunID, source run.StepID, calls ...run.CallID) step { - bindings := make([]run.ToolCallBinding, len(calls)) +func opened(turnID turn.TurnID, source turn.StepID, calls ...turn.CallID) step { + bindings := make([]turn.ToolCallBinding, len(calls)) for i, c := range calls { - bindings[i] = run.ToolCallBinding{CallID: c, ToolRef: "echo", DefinitionDigest: "sha256:def", Arguments: jsonstable.MustParse(`{}`)} + bindings[i] = turn.ToolCallBinding{CallID: c, ToolRef: "echo", DefinitionDigest: "sha256:def", Arguments: jsonstable.MustParse(`{}`)} } - return runStep(runID, run.ToolStepOpened{StepID: run.StepID(string(source) + "/tools"), Source: source, Calls: bindings}) + return runStep(turnID, turn.ToolStepOpened{StepID: turn.StepID(string(source) + "/tools"), Source: source, Calls: bindings}) } // foldSteps encodes, decodes and folds steps through both projections, @@ -109,19 +118,19 @@ func TestPartsCodecRoundTripAndRejects(t *testing.T) { } } -// Run facts project into structural entries. The assistant +// Turn facts project into structural entries. The assistant // names the frozen result and the calls its ToolStepOpened assigned; each // call's terminal fact projects one tool_result; a verified supersession // replaces the unknown result in place. func TestRunFactsProjectEntries(t *testing.T) { ctxState, surf, err := foldSteps(t, []step{ {TypeInputSubmitted, InputSubmittedPayload{InputID: "in-1", Content: jsonstable.MustParse(`{"text":"hi"}`), SubmittedAtUnixMilli: 1}}, - {TypeInputDelivered, InputDeliveredPayload{InputID: "in-1", TurnID: "t1", RunID: "r1"}}, - runStep("r1", run.RunCreated{RunID: "r1"}), - completed("r1", "s1", "sha256:res"), - opened("r1", "s1", "c1", "c2"), - runStep("r1", run.ToolCallCompleted{StepID: "s1/tools", CallID: "c1", OutputDigest: "sha256:out"}), - runStep("r1", run.ToolCallFailed{StepID: "s1/tools", CallID: "c2", Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: "lost"}, Outcome: run.ToolOutcomeUnknown}), + accepted("t1", "in-1"), + runStep("t1", turn.TurnStarted{Preset: ref.Ref{ID: "test", Digest: "sha256:test"}, TurnID: "t1"}), + completed("t1", "s1", "sha256:res"), + opened("t1", "s1", "c1", "c2"), + runStep("t1", turn.ToolCallCompleted{StepID: "s1/tools", CallID: "c1", OutputDigest: "sha256:out"}), + runStep("t1", turn.ToolCallFailed{StepID: "s1/tools", CallID: "c2", Failure: turn.ToolFailure{Class: turn.FailureEffectUnknown, Message: "lost"}, Outcome: turn.ToolOutcomeUnknown}), {TypeToolResultSuperseded, ToolResultSupersededPayload{ToolResultID: "c2", Status: ToolSuccess, OutputDigest: "sha256:verified"}}, }) if err != nil { @@ -132,7 +141,7 @@ func TestRunFactsProjectEntries(t *testing.T) { t.Fatalf("entries = %+v", entries) } a := entries[1].Assistant - if entries[1].Kind != EntryAssistant || a.ID != "s1" || a.TurnID != "t1" || a.RunID != "r1" || a.ResultDigest != "sha256:res" || + if entries[1].Kind != EntryAssistant || a.ID != "s1" || a.TurnID != "t1" || a.ResultDigest != "sha256:res" || len(a.CallIDs) != 2 || a.CallIDs[0] != "c1" || a.CallIDs[1] != "c2" || entries[1].Digest != a.Digest { t.Fatalf("assistant = %+v", a) } @@ -158,12 +167,10 @@ func TestRunFactsProjectEntries(t *testing.T) { if unknown, _ := surf.ToolResults.Get("c2"); unknown.Status != ToolUnknown || unknown.FailureText() != "effect_unknown: lost" { t.Fatalf("unknown result = %+v", unknown) } - if owner, _ := surf.Runs.Get("r1"); owner.TurnID != "t1" { - t.Fatalf("run owner = %+v", owner) - } + // Superseding twice, or a result outside the context, is a fold error. - if _, _, err := foldSteps(t, []step{step{TypeInputSubmitted, InputSubmittedPayload{InputID: "in-1", Content: jsonstable.MustParse(`{"text":"hi"}`), SubmittedAtUnixMilli: 1}}, step{TypeInputDelivered, InputDeliveredPayload{InputID: "in-1", TurnID: "t1", RunID: "r1"}}, runStep("r1", run.RunCreated{RunID: "r1"}), completed("r1", "s1", "sha256:res"), opened("r1", "s1", "c1"), - runStep("r1", run.ToolCallFailed{StepID: "s1/tools", CallID: "c1", Failure: run.ToolFailure{Class: "x"}}), + if _, _, err := foldSteps(t, []step{step{TypeInputSubmitted, InputSubmittedPayload{InputID: "in-1", Content: jsonstable.MustParse(`{"text":"hi"}`), SubmittedAtUnixMilli: 1}}, accepted("t1", "in-1"), runStep("t1", turn.TurnStarted{Preset: ref.Ref{ID: "test", Digest: "sha256:test"}, TurnID: "t1"}), completed("t1", "s1", "sha256:res"), opened("t1", "s1", "c1"), + runStep("t1", turn.ToolCallFailed{StepID: "s1/tools", CallID: "c1", Failure: turn.ToolFailure{Class: "x"}}), {TypeToolResultSuperseded, ToolResultSupersededPayload{ToolResultID: "c1", Status: ToolError}}, {TypeToolResultSuperseded, ToolResultSupersededPayload{ToolResultID: "c1", Status: ToolError}}}); err == nil { t.Fatal("second supersession accepted") @@ -173,10 +180,10 @@ func TestRunFactsProjectEntries(t *testing.T) { } // A tool step for an unknown assistant, and a result folded twice, are // fold errors. - if _, _, err := foldSteps(t, []step{opened("r1", "nope", "c1")}); err == nil { + if _, _, err := foldSteps(t, []step{opened("t1", "nope", "c1")}); err == nil { t.Fatal("tool step for an unknown assistant accepted") } - if _, _, err := foldSteps(t, []step{completed("r1", "s1", "sha256:a"), completed("r1", "s1", "sha256:a")}); err == nil { + if _, _, err := foldSteps(t, []step{completed("t1", "s1", "sha256:a"), completed("t1", "s1", "sha256:a")}); err == nil { t.Fatal("assistant folded twice") } } @@ -190,10 +197,10 @@ func TestSurfaceAndContextFold(t *testing.T) { {TypeInputSubmitted, InputSubmittedPayload{InputID: "in-1", Content: content, SubmittedAtUnixMilli: 1}}, {TypeInputSubmitted, InputSubmittedPayload{InputID: "in-2", Content: content, SubmittedAtUnixMilli: 2}}, {TypeInputSubmitted, InputSubmittedPayload{InputID: "in-3", Content: content, SubmittedAtUnixMilli: 3}}, - {TypeInputDelivered, InputDeliveredPayload{InputID: "in-1", TurnID: "t1", RunID: "r1"}}, + accepted("t1", "in-1"), {TypeInputWithdrawn, InputWithdrawnPayload{InputID: "in-3", Reason: "user"}}, - runStep("r1", run.RunCreated{RunID: "r1"}), - completed("r1", "s1", "sha256:res"), + runStep("t1", turn.TurnStarted{Preset: ref.Ref{ID: "test", Digest: "sha256:test"}, TurnID: "t1"}), + completed("t1", "s1", "sha256:res"), } ctxState, surface, err := foldSteps(t, steps) if err != nil { @@ -215,7 +222,7 @@ func TestSurfaceAndContextFold(t *testing.T) { if _, pending := ctxState.Pending["in-2"]; !pending || len(ctxState.Pending) != 1 { t.Fatalf("pending = %+v", ctxState.Pending) } - if _, _, err := foldSteps(t, append(steps, step{TypeInputDelivered, InputDeliveredPayload{InputID: "in-1", TurnID: "t1", RunID: "r1"}})); err == nil { + if _, _, err := foldSteps(t, append(steps, accepted("t1", "in-1"))); err == nil { t.Fatal("second delivery accepted") } } @@ -236,7 +243,6 @@ func TestEventCodecCanonicalRoundTrip(t *testing.T) { } samples := map[ledger.EventType]any{ TypeInputSubmitted: InputSubmittedPayload{InputID: "in-1", Content: jsonstable.MustParse(`{"text":"hi"}`), SubmittedAtUnixMilli: 1}, - TypeInputDelivered: InputDeliveredPayload{InputID: "in-1", TurnID: "t1", RunID: "r1"}, TypeInputWithdrawn: InputWithdrawnPayload{InputID: "in-1", Reason: "user"}, TypeInputRejected: InputRejectedPayload{InputID: "in-1"}, TypeToolResultSuperseded: ToolResultSupersededPayload{ToolResultID: "tr1", Status: ToolSuccess, OutputDigest: "sha256:out"}, @@ -286,7 +292,7 @@ func TestEventCodecCanonicalRoundTrip(t *testing.T) { type fakeContent struct { results map[jsonstable.Digest]model.ModelResult - outputs map[jsonstable.Digest]run.CanonicalJSON + outputs map[jsonstable.Digest]turn.CanonicalJSON reads int } @@ -299,30 +305,30 @@ func (c *fakeContent) ModelResult(_ context.Context, d jsonstable.Digest) (model return r, nil } -func (c *fakeContent) ToolOutput(_ context.Context, d jsonstable.Digest) (run.CanonicalJSON, error) { +func (c *fakeContent) ToolOutput(_ context.Context, d jsonstable.Digest) (turn.CanonicalJSON, error) { c.reads++ o, ok := c.outputs[d] if !ok { - return run.CanonicalJSON{}, frozen.ErrMissing + return turn.CanonicalJSON{}, frozen.ErrMissing } return o, nil } -func (c *fakeContent) ToolResponse(ctx context.Context, d jsonstable.Digest) (run.CanonicalJSON, error) { +func (c *fakeContent) ToolResponse(ctx context.Context, d jsonstable.Digest) (turn.CanonicalJSON, error) { return c.ToolOutput(ctx, d) } -// The materializer pairs the Run's CallIDs with the frozen result's tool +// The materializer pairs the Turn's CallIDs with the frozen result's tool // calls, resolves successful outputs, renders failures from the entry, reads // each digest once, and reports a lost body without touching the projection. func TestMaterialize(t *testing.T) { ctxState, _, err := foldSteps(t, []step{ - step{TypeInputSubmitted, InputSubmittedPayload{InputID: "in-1", Content: jsonstable.MustParse(`{"text":"hi"}`), SubmittedAtUnixMilli: 1}}, step{TypeInputDelivered, InputDeliveredPayload{InputID: "in-1", TurnID: "t1", RunID: "r1"}}, runStep("r1", run.RunCreated{RunID: "r1"}), - completed("r1", "s1", "sha256:res"), - opened("r1", "s1", "c1", "c2"), - runStep("r1", run.ToolCallCompleted{StepID: "s1/tools", CallID: "c1", OutputDigest: "sha256:out"}), - runStep("r1", run.ToolCallAnswered{StepID: "s1/tools", CallID: "c2", ResponseID: "resp", ResponseDigest: "sha256:ans"}), - completed("r1", "s2", "sha256:res"), + step{TypeInputSubmitted, InputSubmittedPayload{InputID: "in-1", Content: jsonstable.MustParse(`{"text":"hi"}`), SubmittedAtUnixMilli: 1}}, accepted("t1", "in-1"), runStep("t1", turn.TurnStarted{Preset: ref.Ref{ID: "test", Digest: "sha256:test"}, TurnID: "t1"}), + completed("t1", "s1", "sha256:res"), + opened("t1", "s1", "c1", "c2"), + runStep("t1", turn.ToolCallCompleted{StepID: "s1/tools", CallID: "c1", OutputDigest: "sha256:out"}), + runStep("t1", turn.ToolCallAnswered{StepID: "s1/tools", CallID: "c2", ResponseID: "resp", ResponseDigest: "sha256:ans"}), + completed("t1", "s2", "sha256:res"), }) if err != nil { t.Fatal(err) @@ -332,13 +338,13 @@ func TestMaterialize(t *testing.T) { {ToolCallID: "p1", ToolName: "echo", Input: model.ToolArguments{JSON: jsonstable.MustParse(`{"a":1}`)}}, {ToolCallID: "p2", ToolName: "ask", Input: model.ToolArguments{JSON: jsonstable.MustParse(`{}`)}}, }}}, - outputs: map[jsonstable.Digest]run.CanonicalJSON{"sha256:out": jsonstable.MustParse(`{"ok":true}`), "sha256:ans": jsonstable.MustParse(`"yes"`)}, + outputs: map[jsonstable.Digest]turn.CanonicalJSON{"sha256:out": jsonstable.MustParse(`{"ok":true}`), "sha256:ans": jsonstable.MustParse(`"yes"`)}, } entries, err := NewMaterializer(content).Entries(context.Background(), ctxState.Entries) if err != nil { t.Fatal(err) } - // entries[0] is the delivered input that named the Run; the assistant follows. + // entries[0] is the delivered input that named the Turn; the assistant follows. entries = entries[1:] first := entries[0].Calls if len(entries) != 4 || entries[0].Text() != "calling" || len(first) != 2 || @@ -354,7 +360,7 @@ func TestMaterialize(t *testing.T) { if content.reads != 3 || len(entries[3].Calls) != 2 || entries[3].Calls[0].CallID != CallID(schema.Identity().DeriveCallID("s2", 0)) { t.Fatalf("reads = %d, second assistant = %+v", content.reads, entries[3]) } - failed := Entry{Kind: EntryToolResult, ToolResult: &ToolResult{ID: "c9", CallID: "c9", Status: ToolError, Failure: &run.ToolFailure{Class: "boom", Message: "x"}}} + failed := Entry{Kind: EntryToolResult, ToolResult: &ToolResult{ID: "c9", CallID: "c9", Status: ToolError, Failure: &turn.ToolFailure{Class: "boom", Message: "x"}}} if m, err := NewMaterializer(content).Entry(context.Background(), &failed); err != nil || m.Output != nil || m.Text() != "boom: x" { t.Fatalf("failed result = %+v %v", m, err) } @@ -378,9 +384,9 @@ func mustSummary(t *testing.T, id SummaryID, text string) Summary { // entryDigest is the digest the fold assigns to the assistant of one // model_step_completed under Turn t1 in Run r1, with no tool step. -func entryDigest(t *testing.T, stepID run.StepID, result jsonstable.Digest) jsonstable.Digest { +func entryDigest(t *testing.T, stepID turn.StepID, result jsonstable.Digest) jsonstable.Digest { t.Helper() - a, err := assistantOf(RunOwner{TurnID: "t1"}, "r1", &run.ModelStepCompleted{StepID: stepID, FinishReason: model.FinishReasonStop, ResultDigest: result}) + a, err := assistantOf("t1", &turn.ModelStepCompleted{StepID: stepID, FinishReason: model.FinishReasonStop, ResultDigest: result}) if err != nil { t.Fatal(err) } @@ -417,16 +423,16 @@ func TestCompactionFold(t *testing.T) { // compaction itself lands at step 7. prefix := []step{ {TypeInputSubmitted, InputSubmittedPayload{InputID: "in-1", Content: content, SubmittedAtUnixMilli: 1}}, - {TypeInputDelivered, InputDeliveredPayload{InputID: "in-1", TurnID: "t1", RunID: "r1"}}, - runStep("r1", run.RunCreated{RunID: "r1"}), - completed("r1", "s1", "sha256:one"), + accepted("t1", "in-1"), + runStep("t1", turn.TurnStarted{Preset: ref.Ref{ID: "test", Digest: "sha256:test"}, TurnID: "t1"}), + completed("t1", "s1", "sha256:one"), {TypeInputSubmitted, InputSubmittedPayload{InputID: "in-q", Content: content, SubmittedAtUnixMilli: 2}}, {TypeSummary, SummaryPayload{Summary: sum}}, } valid := mustCompaction(t, "ck1", at(4), base, sum, base[1:]) t.Run("valid compaction replaces the base and keeps the queue", func(t *testing.T) { - ctxState, surf, err := foldSteps(t, append(prefix, step{TypeCompactionCreated, valid}, completed("r1", "s2", "sha256:after"))) + ctxState, surf, err := foldSteps(t, append(prefix, step{TypeCompactionCreated, valid}, completed("t1", "s2", "sha256:after"))) if err != nil { t.Fatal(err) } @@ -451,7 +457,7 @@ func TestCompactionFold(t *testing.T) { t.Run("invalidating the latest compaction restores base plus tail", func(t *testing.T) { ctxState, surf, err := foldSteps(t, append(prefix, step{TypeCompactionCreated, valid}, - completed("r1", "s2", "sha256:after"), + completed("t1", "s2", "sha256:after"), step{TypeCompactionInvalidated, CompactionInvalidatedPayload{CompactionID: "ck1", Reason: "host"}})) if err != nil { t.Fatal(err) @@ -480,7 +486,7 @@ func TestCompactionFold(t *testing.T) { append(prefix, step{TypeCompactionCreated, mustCompaction(t, "ck4", at(3), base, sum, []EntryDigestPair{{Kind: EntryAssistant, ID: "s1", Digest: "sha256:wrong"}})})}, {"gap holds more than the summary", - append(append([]step{}, prefix...), completed("r1", "s9", "sha256:x"), + append(append([]step{}, prefix...), completed("t1", "s9", "sha256:x"), step{TypeCompactionCreated, mustCompaction(t, "ck5", at(5), append(base, EntryDigestPair{Kind: EntryAssistant, ID: "s9"}), sum, nil)})}, {"invalidating an unknown compaction", @@ -497,8 +503,8 @@ func TestCompactionFold(t *testing.T) { t.Run("superseding a compacted result is rejected", func(t *testing.T) { // The assistant's digest changes when its tool step opens, so the base // is read back from a fold of the same prefix. - steps := []step{step{TypeInputSubmitted, InputSubmittedPayload{InputID: "in-1", Content: jsonstable.MustParse(`{"text":"hi"}`), SubmittedAtUnixMilli: 1}}, step{TypeInputDelivered, InputDeliveredPayload{InputID: "in-1", TurnID: "t1", RunID: "r1"}}, runStep("r1", run.RunCreated{RunID: "r1"}), completed("r1", "ac", "sha256:call"), opened("r1", "ac", "c1"), - runStep("r1", run.ToolCallCompleted{StepID: "ac/tools", CallID: "c1", OutputDigest: "sha256:out"})} + steps := []step{step{TypeInputSubmitted, InputSubmittedPayload{InputID: "in-1", Content: jsonstable.MustParse(`{"text":"hi"}`), SubmittedAtUnixMilli: 1}}, accepted("t1", "in-1"), runStep("t1", turn.TurnStarted{Preset: ref.Ref{ID: "test", Digest: "sha256:test"}, TurnID: "t1"}), completed("t1", "ac", "sha256:call"), opened("t1", "ac", "c1"), + runStep("t1", turn.ToolCallCompleted{StepID: "ac/tools", CallID: "c1", OutputDigest: "sha256:out"})} folded, _, err := foldSteps(t, steps) if err != nil { t.Fatal(err) diff --git a/agentcore/chatlog/compaction_test.go b/agentcore/chatlog/compaction_test.go index e5dbc065..21b3f1e3 100644 --- a/agentcore/chatlog/compaction_test.go +++ b/agentcore/chatlog/compaction_test.go @@ -11,13 +11,14 @@ import ( "github.com/felinics/twilight/agentcore/chatlog" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/sessionstore" + "github.com/felinics/twilight/agentcore/preset/ref" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/filestore/filestoretest" "github.com/felinics/twilight/agentcore/session/unit" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/session/writer/writertest" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/sessionstore" ) func TestPlanCompaction(t *testing.T) { @@ -155,16 +156,20 @@ func compactionContext(t *testing.T, w writer.Writer) chatlog.Context { return current } -func appendCompactionInput(t *testing.T, w writer.Writer, id run.InputID) { +func appendCompactionInput(t *testing.T, w writer.Writer, id turn.InputID) { t.Helper() commands := chatlog.Commands{Now: time.Now} - in, err := commands.Submit(context.Background(), w, id, run.MustParseCanonicalJSON(`{"text":"input"}`)) + in, err := commands.Submit(context.Background(), w, id, turn.MustParseCanonicalJSON(`{"text":"input"}`)) + if err != nil { + t.Fatal(err) + } + creation, err := turn.BuildNewTurn(turn.TurnID("turn-"+id), "", ref.Ref{ID: "test", Digest: "sha256:test"}) if err != nil { t.Fatal(err) } res, err := unit.Commit(context.Background(), w, 1, unit.Work{ - CommitID: ledger.CommitID("deliver/" + id), - Parts: []unit.Part{chatlog.DeliverInputs("t", "r", []run.AgentInput{in})}, + CommitID: ledger.CommitID("accept/" + id), + Parts: []unit.Part{chatlog.AdmitInputs([]turn.AgentInput{in}), sessionstore.CreateTurn(creation, []turn.AgentInput{in})}, }) if err != nil || res.Outcome != writer.CommitApplied { t.Fatalf("deliver = %+v, %v", res, err) diff --git a/agentcore/chatlog/foldbench_test.go b/agentcore/chatlog/foldbench_test.go index d19ae6f2..f4350774 100644 --- a/agentcore/chatlog/foldbench_test.go +++ b/agentcore/chatlog/foldbench_test.go @@ -4,9 +4,9 @@ import ( "fmt" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/sessionstore" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/sessionstore" "testing" ) @@ -18,7 +18,7 @@ func benchEvents(n int) []module.DecodedEvent { for i := range out { out[i] = module.DecodedEvent{ Position: ledger.Position{Commit: ledger.CommitSeq(i)}, - Value: sessionstore.Event{RunID: "r", Fact: run.ModelStepCompleted{StepID: run.StepID(fmt.Sprint(i)), FinishReason: model.FinishReasonStop, ResultDigest: "sha256:x"}}, + Value: sessionstore.Event{TurnID: "r", Fact: turn.ModelStepCompleted{StepID: turn.StepID(fmt.Sprint(i)), FinishReason: model.FinishReasonStop, ResultDigest: "sha256:x"}}, } } return out diff --git a/agentcore/chatlog/golden_test.go b/agentcore/chatlog/golden_test.go index d6a13113..088cff64 100644 --- a/agentcore/chatlog/golden_test.go +++ b/agentcore/chatlog/golden_test.go @@ -5,7 +5,7 @@ import ( "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run/sessionstore" + "github.com/felinics/twilight/agentcore/turn/sessionstore" "testing" ) diff --git a/agentcore/chatlog/table_test.go b/agentcore/chatlog/table_test.go index 5d259d68..3b9b7636 100644 --- a/agentcore/chatlog/table_test.go +++ b/agentcore/chatlog/table_test.go @@ -6,9 +6,9 @@ import ( "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/sessionstore" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/sessionstore" "testing" ) @@ -76,7 +76,7 @@ func TestSurfaceFoldIsPure(t *testing.T) { var seq ledger.CommitSeq next := func() ledger.Position { seq++; return ledger.Position{Commit: seq} } assistant := func(id string) module.DecodedEvent { - return module.DecodedEvent{Position: next(), Value: sessionstore.Event{RunID: "r", Fact: run.ModelStepCompleted{StepID: run.StepID(id), FinishReason: model.FinishReasonStop, ResultDigest: "sha256:x"}}} + return module.DecodedEvent{Position: next(), Value: sessionstore.Event{TurnID: "r", Fact: turn.ModelStepCompleted{StepID: turn.StepID(id), FinishReason: model.FinishReasonStop, ResultDigest: "sha256:x"}}} } input := func(id string) module.DecodedEvent { return module.DecodedEvent{Position: next(), Value: InputSubmittedPayload{InputID: InputID(id), Content: jsonstable.MustParse(`{"text":"x"}`)}} diff --git a/agentcore/driver/budget_test.go b/agentcore/driver/budget_test.go index bde5b6b9..85a73db4 100644 --- a/agentcore/driver/budget_test.go +++ b/agentcore/driver/budget_test.go @@ -7,8 +7,8 @@ import ( "github.com/felinics/twilight/agentcore/driver" "github.com/felinics/twilight/agentcore/prompt" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/model" ) func TestBudgetPolicyUsesCompleteCandidateAndOutputCap(t *testing.T) { @@ -20,7 +20,7 @@ func TestBudgetPolicyUsesCompleteCandidateAndOutputCap(t *testing.T) { Tools: []model.ToolDefinition{{Name: "tool"}}, }} policy := &driver.BudgetPolicy{ - Budget: func(_ context.Context, ref run.ModelRef) (prompt.TokenBudget, error) { + Budget: func(_ context.Context, ref turn.ModelRef) (prompt.TokenBudget, error) { if ref != "logical-model" { t.Fatalf("model = %q", ref) } @@ -53,7 +53,7 @@ func TestBudgetPolicyUsesCompleteCandidateAndOutputCap(t *testing.T) { t.Fatal("negative output cap accepted") } outputCap = 30 - policy.Budget = func(context.Context, run.ModelRef) (prompt.TokenBudget, error) { + policy.Budget = func(context.Context, turn.ModelRef) (prompt.TokenBudget, error) { return prompt.TokenBudget{ContextWindow: 100, OutputReserve: -1}, nil } if _, err := policy.Decide(ctx, prompt.Input{}, &candidate); err == nil { @@ -72,7 +72,7 @@ func TestBudgetPolicyPropagatesConfigurationAndEstimatorFailures(t *testing.T) { t.Fatal("missing summary builder accepted") } failure := errors.New("estimator unavailable") - policy.Budget = func(context.Context, run.ModelRef) (prompt.TokenBudget, error) { + policy.Budget = func(context.Context, turn.ModelRef) (prompt.TokenBudget, error) { return prompt.TokenBudget{ContextWindow: 100}, nil } policy.Estimate = func(context.Context, *model.ModelRequest) (int, error) { return 0, failure } @@ -85,7 +85,7 @@ func TestBudgetPolicyPropagatesConfigurationAndEstimatorFailures(t *testing.T) { if _, err := policy.BuildSummary(ctx, &driver.CompactionContext{}); !errors.Is(err, failure) { t.Fatalf("summary failure = %v", err) } - policy.Budget = func(context.Context, run.ModelRef) (prompt.TokenBudget, error) { return prompt.TokenBudget{}, failure } + policy.Budget = func(context.Context, turn.ModelRef) (prompt.TokenBudget, error) { return prompt.TokenBudget{}, failure } if _, err := policy.Decide(ctx, prompt.Input{}, candidate); !errors.Is(err, failure) { t.Fatalf("limits failure = %v", err) } diff --git a/agentcore/executor/cancellation_test.go b/agentcore/executor/cancellation_test.go index b7b539af..4e4ed82e 100644 --- a/agentcore/executor/cancellation_test.go +++ b/agentcore/executor/cancellation_test.go @@ -11,7 +11,7 @@ import ( "github.com/felinics/twilight/agentcore/executor/store" "github.com/felinics/twilight/agentcore/executor/store/storetest" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" ) // RPC hooks run outside the mutex, so a test can commit cancellation while diff --git a/agentcore/executor/discovery_test.go b/agentcore/executor/discovery_test.go index 1815b310..0198d175 100644 --- a/agentcore/executor/discovery_test.go +++ b/agentcore/executor/discovery_test.go @@ -9,7 +9,7 @@ import ( "github.com/felinics/twilight/agentcore/executor" executionstore "github.com/felinics/twilight/agentcore/executor/store" "github.com/felinics/twilight/agentcore/executor/store/storetest" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" ) // Embedding only Store deliberately hides the optional Directory. @@ -38,7 +38,7 @@ func TestWorkerOutstandingCapability(t *testing.T) { if _, err := w.ListOutstanding(ctx, effect.AssignmentKey{}, 10); !errors.Is(err, effect.ErrOutstandingUnsupported) { t.Fatalf("missing Directory returned %v, want capability error", err) } - key := effect.AssignmentKey{Session: "session", RunID: "run", Effect: "effect"} + key := effect.AssignmentKey{Session: "session", OwnerKind: effect.OwnerTurn, OwnerID: string("run"), Effect: "effect"} s := &directoryStore{Store: storetest.NewMap(nil), page: effect.OutstandingPage{Items: []effect.ExecutionSummary{{Key: key, Status: effect.ExecutionAccepted, Revision: 7}}, Next: key, More: true}} w2, err := executor.NewWorker(ctx, s, routes(newTestBackend())) if err != nil { diff --git a/agentcore/executor/executor_test.go b/agentcore/executor/executor_test.go index 8b669f66..19001b54 100644 --- a/agentcore/executor/executor_test.go +++ b/agentcore/executor/executor_test.go @@ -13,11 +13,11 @@ import ( "github.com/felinics/twilight/agentcore/executor/store" "github.com/felinics/twilight/agentcore/executor/store/storetest" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/schema" "github.com/felinics/twilight/agentcore/testutil/sdkconv" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/schema" "github.com/felinics/twilight/sdk" ) @@ -42,7 +42,7 @@ func newTestBackend() *testBackend { return &testBackend{outcomes: make(map[effect.AssignmentKey]chan effect.Outcome)} } -func (b *testBackend) Validate(context.Context, effect.Assignment) (*run.ToolFailure, error) { +func (b *testBackend) Validate(context.Context, effect.Assignment) (*turn.ToolFailure, error) { return nil, nil } func (b *testBackend) Dispatch(_ context.Context, a effect.Assignment) error { @@ -188,7 +188,7 @@ func testAssignment() effect.Assignment { if err != nil { panic(err) } - return effect.Assignment{Session: "s", RunID: "r", StepID: "step", Effect: "effect", + return effect.Assignment{Session: "s", OwnerKind: effect.OwnerTurn, OwnerID: string("r"), StepID: "step", Effect: "effect", Body: effect.ModelAssignment{Model: "m", Request: &request, RequestDigest: digest}} } @@ -601,12 +601,12 @@ func TestRecordStoreSurvivesWorkerRecreation(t *testing.T) { } func testToolAssignment() effect.Assignment { - return effect.Assignment{Session: "s", RunID: "r", StepID: "step", CallID: "call-1", Effect: "effect", - Body: effect.ToolAssignment{ToolRef: "gate", DefinitionDigest: "d", Arguments: run.MustParseCanonicalJSON(`{}`), Policy: run.DirectExecution}} + return effect.Assignment{Session: "s", OwnerKind: effect.OwnerTurn, OwnerID: string("r"), StepID: "step", CallID: "call-1", Effect: "effect", + Body: effect.ToolAssignment{ToolRef: "gate", DefinitionDigest: "d", Arguments: turn.MustParseCanonicalJSON(`{}`), Policy: turn.DirectExecution}} } // Dispose is the control plane's give-up path: the record settles Unknown -// regardless of owner or lease, so the Owner's next read disposes the Run +// regardless of owner or lease, so the Owner's next read disposes the Turn // target; a terminal record is left alone and a key with no record is not // found. func TestWorkerDisposeSettlesUnknown(t *testing.T) { @@ -728,7 +728,7 @@ func TestDispatchRefusalClassification(t *testing.T) { retryable bool }{ {"record store unavailable", failingCreateStore{storetest.NewMap(nil)}, testAssignment(), true}, - {"assignment without body", storetest.NewMap(nil), effect.Assignment{Session: "s", RunID: "r", Effect: "e"}, false}, + {"assignment without body", storetest.NewMap(nil), effect.Assignment{Session: "s", OwnerKind: effect.OwnerTurn, OwnerID: string("r"), Effect: "e"}, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/agentcore/executor/progress_test.go b/agentcore/executor/progress_test.go index 9d9b0418..0a32783f 100644 --- a/agentcore/executor/progress_test.go +++ b/agentcore/executor/progress_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" ) // the hub stamps generation and sequence, replays after a @@ -15,8 +15,8 @@ import ( // what it keeps. func TestProgressHub(t *testing.T) { ctx := context.Background() - key := effect.AssignmentKey{Session: "s", RunID: "r", Effect: "e"} - other := effect.AssignmentKey{Session: "s", RunID: "r", Effect: "x"} + key := effect.AssignmentKey{Session: "s", OwnerKind: effect.OwnerTurn, OwnerID: string("r"), Effect: "e"} + other := effect.AssignmentKey{Session: "s", OwnerKind: effect.OwnerTurn, OwnerID: string("r"), Effect: "x"} h := NewProgressHub(8) pub := func(k effect.AssignmentKey, text string) { h.Publish(ctx, effect.ProgressFrame{Key: k, Kind: effect.ProgressTextDelta, Payload: json.RawMessage(`"` + text + `"`)}) diff --git a/agentcore/executor/store/storetest/conformance.go b/agentcore/executor/store/storetest/conformance.go index 1c1a94ed..80f0007c 100644 --- a/agentcore/executor/store/storetest/conformance.go +++ b/agentcore/executor/store/storetest/conformance.go @@ -10,8 +10,8 @@ import ( "github.com/felinics/twilight/agentcore/executor/protocol" executionstore "github.com/felinics/twilight/agentcore/executor/store" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" ) // Fixture is one adapter under test. Store is a handle over a fresh, empty @@ -44,8 +44,8 @@ func Run(t *testing.T, factory Factory) { } } -func assignment(id run.EffectID) effect.Assignment { - return effect.Assignment{Session: "s", RunID: "r", StepID: "step", Effect: id, +func assignment(id turn.EffectID) effect.Assignment { + return effect.Assignment{Session: "s", OwnerKind: effect.OwnerTurn, OwnerID: string("r"), StepID: "step", Effect: id, Body: effect.ModelAssignment{Model: "m", RequestDigest: "sha256:req"}} } diff --git a/agentcore/executor/store/storetest/discovery.go b/agentcore/executor/store/storetest/discovery.go index a5c5e1f3..4960bc7b 100644 --- a/agentcore/executor/store/storetest/discovery.go +++ b/agentcore/executor/store/storetest/discovery.go @@ -5,7 +5,7 @@ import ( "sort" executionstore "github.com/felinics/twilight/agentcore/executor/store" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" ) var _ executionstore.Directory = (*Map)(nil) @@ -14,8 +14,11 @@ func keyLess(a, b effect.AssignmentKey) bool { if a.Session != b.Session { return a.Session < b.Session } - if a.RunID != b.RunID { - return a.RunID < b.RunID + if a.OwnerKind != b.OwnerKind { + return a.OwnerKind < b.OwnerKind + } + if a.OwnerID != b.OwnerID { + return a.OwnerID < b.OwnerID } return a.Effect < b.Effect } diff --git a/agentcore/executor/store/storetest/discovery_conformance.go b/agentcore/executor/store/storetest/discovery_conformance.go index 0bed950c..bbe73112 100644 --- a/agentcore/executor/store/storetest/discovery_conformance.go +++ b/agentcore/executor/store/storetest/discovery_conformance.go @@ -8,8 +8,8 @@ import ( "time" executionstore "github.com/felinics/twilight/agentcore/executor/store" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" ) // Directory is optional. Every adapter that exposes it runs this scenario @@ -30,22 +30,23 @@ func testDirectory(t *testing.T, f Fixture) { key effect.AssignmentKey status effect.ExecutionStatus }{ - {effect.AssignmentKey{Session: "a", RunID: "r", Effect: "z"}, effect.ExecutionAccepted}, - {effect.AssignmentKey{Session: "a", RunID: "r/", Effect: "a"}, effect.ExecutionDispatching}, - {effect.AssignmentKey{Session: "a/", RunID: "a", Effect: "a"}, effect.ExecutionRunning}, - {effect.AssignmentKey{Session: "b", RunID: "r", Effect: "a"}, effect.ExecutionCancelRequested}, - {effect.AssignmentKey{Session: "b", RunID: "r", Effect: "b"}, effect.ExecutionCompleted}, - {effect.AssignmentKey{Session: "b", RunID: "r", Effect: "c"}, effect.ExecutionFailed}, - {effect.AssignmentKey{Session: "b", RunID: "r", Effect: "d"}, effect.ExecutionCancelled}, - {effect.AssignmentKey{Session: "b", RunID: "r", Effect: "e"}, effect.ExecutionUnknown}, + {effect.AssignmentKey{Session: "a", OwnerKind: effect.OwnerCompaction, OwnerID: "r", Effect: "z"}, effect.ExecutionAccepted}, + {effect.AssignmentKey{Session: "a", OwnerKind: effect.OwnerTurn, OwnerID: string("r"), Effect: "z"}, effect.ExecutionAccepted}, + {effect.AssignmentKey{Session: "a", OwnerKind: effect.OwnerTurn, OwnerID: string("r/"), Effect: "a"}, effect.ExecutionDispatching}, + {effect.AssignmentKey{Session: "a/", OwnerKind: effect.OwnerTurn, OwnerID: string("a"), Effect: "a"}, effect.ExecutionRunning}, + {effect.AssignmentKey{Session: "b", OwnerKind: effect.OwnerTurn, OwnerID: string("r"), Effect: "a"}, effect.ExecutionCancelRequested}, + {effect.AssignmentKey{Session: "b", OwnerKind: effect.OwnerTurn, OwnerID: string("r"), Effect: "b"}, effect.ExecutionCompleted}, + {effect.AssignmentKey{Session: "b", OwnerKind: effect.OwnerTurn, OwnerID: string("r"), Effect: "c"}, effect.ExecutionFailed}, + {effect.AssignmentKey{Session: "b", OwnerKind: effect.OwnerTurn, OwnerID: string("r"), Effect: "d"}, effect.ExecutionCancelled}, + {effect.AssignmentKey{Session: "b", OwnerKind: effect.OwnerTurn, OwnerID: string("r"), Effect: "e"}, effect.ExecutionUnknown}, } want := make([]effect.ExecutionSummary, len(rows)) for i := len(rows) - 1; i >= 0; i-- { row := rows[i] a := assignment(row.key.Effect) - a.Session, a.RunID = row.key.Session, row.key.RunID + a.Session, a.OwnerKind, a.OwnerID = row.key.Session, row.key.OwnerKind, row.key.OwnerID state := executionstore.Execution{ExecutionState: executionstore.ExecutionState{Assignment: a, State: row.status}} - if i == 2 { + if row.status == effect.ExecutionRunning { state.Lease = executionstore.Lease{Key: row.key, Owner: "expired-worker", Epoch: 3, UntilUnixMilli: 1} } if err := s.Seed(ctx, state); err != nil { @@ -62,7 +63,7 @@ func testDirectory(t *testing.T, f Fixture) { status effect.ExecutionStatus acked bool }{{"aa-aborted", effect.ExecutionAborted, false}, {"bb-acked", effect.ExecutionCompleted, true}} { - a := assignment(run.EffectID(excluded.id)) + a := assignment(turn.EffectID(excluded.id)) a.Session = "b" if err := s.Seed(ctx, executionstore.Execution{ExecutionState: executionstore.ExecutionState{Assignment: a, State: excluded.status, Acknowledged: excluded.acked}}); err != nil { t.Fatal(err) @@ -77,9 +78,9 @@ func testDirectory(t *testing.T, f Fixture) { } checkDirectoryPagination(t, d, want) // after need not still exist (or ever have existed). - after := effect.AssignmentKey{Session: "b", RunID: "r", Effect: "c-between"} + after := effect.AssignmentKey{Session: "b", OwnerKind: effect.OwnerTurn, OwnerID: string("r"), Effect: "c-between"} page, err := d.ListOutstanding(ctx, after, 1) - if err != nil || len(page.Items) != 1 || page.Items[0].Key != rows[6].key || !page.More { + if err != nil || len(page.Items) != 1 || page.Items[0].Key != rows[7].key || !page.More { t.Fatalf("exclusive absent cursor=%+v err=%v", page, err) } for _, limit := range []int{0, -1} { diff --git a/agentcore/executor/store/storetest/map.go b/agentcore/executor/store/storetest/map.go index e7ab2b92..da9d795b 100644 --- a/agentcore/executor/store/storetest/map.go +++ b/agentcore/executor/store/storetest/map.go @@ -14,7 +14,7 @@ import ( executionstore "github.com/felinics/twilight/agentcore/executor/store" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" ) // Map is executionstore.Store over Go maps. The zero value is ready; Now is @@ -246,8 +246,11 @@ func (m *Map) ListOwned(_ context.Context, owner string) ([]effect.AssignmentKey if a.Session != b.Session { return a.Session < b.Session } - if a.RunID != b.RunID { - return a.RunID < b.RunID + if a.OwnerKind != b.OwnerKind { + return a.OwnerKind < b.OwnerKind + } + if a.OwnerID != b.OwnerID { + return a.OwnerID < b.OwnerID } return a.Effect < b.Effect }) diff --git a/agentcore/executor/supervise_test.go b/agentcore/executor/supervise_test.go index 503bc3b5..7cc7f4b9 100644 --- a/agentcore/executor/supervise_test.go +++ b/agentcore/executor/supervise_test.go @@ -13,9 +13,9 @@ import ( "github.com/felinics/twilight/agentcore/executor/store" "github.com/felinics/twilight/agentcore/executor/store/storetest" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/model" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/model" "github.com/felinics/twilight/sdk" ) @@ -433,12 +433,12 @@ func modelText(out effect.Outcome) string { func TestWorkerAdoptsToolByReplayDeclaration(t *testing.T) { cases := []struct { name string - policy run.ReplayPolicy + policy turn.ReplayPolicy replayed bool }{ - {"allowed", run.ReplayAllowed, true}, - {"forbidden", run.ReplayForbidden, false}, - {"unknown", run.ReplayUnknown, false}, + {"allowed", turn.ReplayAllowed, true}, + {"forbidden", turn.ReplayForbidden, false}, + {"unknown", turn.ReplayUnknown, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -493,7 +493,7 @@ type transientBackend struct { failures int starts []string code effect.FailureCode - toolRetry run.RetryDisposition + toolRetry turn.RetryDisposition } func (b *transientBackend) Prepare(context.Context, effect.Assignment) (string, error) { @@ -513,7 +513,7 @@ func (b *transientBackend) Start(_ context.Context, ref string, a effect.Assignm } if len(b.starts) <= b.failures { if a.Kind() == effect.AssignmentTool { - result = effect.ToolExecutionFailed{Failure: run.ToolFailure{Class: run.FailureUnavailable, Message: "try again"}, Retry: b.toolRetry} + result = effect.ToolExecutionFailed{Failure: turn.ToolFailure{Class: turn.FailureUnavailable, Message: "try again"}, Retry: b.toolRetry} } else { result = effect.ModelFailed{Code: b.code, Message: "try again"} } @@ -548,7 +548,7 @@ func TestWorkerRetriesRetryableFailures(t *testing.T) { assignment effect.Assignment failures int code effect.FailureCode - toolRetry run.RetryDisposition + toolRetry turn.RetryDisposition budget int wantStarts int wantOK bool @@ -557,9 +557,9 @@ func TestWorkerRetriesRetryableFailures(t *testing.T) { {"model budget exhausted", testAssignment(), 5, effect.FailureProviderUnavailable, 0, 2, 2, false}, {"model definite failure", testAssignment(), 1, effect.FailureAuthentication, 0, 3, 1, false}, {"model retries disabled", testAssignment(), 1, effect.FailureConnection, 0, 0, 1, false}, - {"tool failure declared retryable", testToolAssignment(), 1, "", run.RetryAllowed, 3, 2, true}, - {"tool failure declared never", testToolAssignment(), 1, "", run.RetryNever, 3, 1, false}, - {"tool failure unjudged", testToolAssignment(), 1, "", run.RetryUnknown, 3, 1, false}, + {"tool failure declared retryable", testToolAssignment(), 1, "", turn.RetryAllowed, 3, 2, true}, + {"tool failure declared never", testToolAssignment(), 1, "", turn.RetryNever, 3, 1, false}, + {"tool failure unjudged", testToolAssignment(), 1, "", turn.RetryUnknown, 3, 1, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/agentcore/ledger/commit_test.go b/agentcore/ledger/commit_test.go index 1fde73c5..8c2857a2 100644 --- a/agentcore/ledger/commit_test.go +++ b/agentcore/ledger/commit_test.go @@ -53,7 +53,7 @@ func TestValidateBatches(t *testing.T) { {"one batch", []EventBatch{oneEventBatch(chat, "twilight/x/a", `{"a":1}`)}, ""}, {"two streams in one commit", []EventBatch{ oneEventBatch(chat, "twilight/x/a", `{"a":1}`), - oneEventBatch(run, "twilight/run/created", `{"runId":"r7"}`), + oneEventBatch(run, "twilight/turn/created", `{"turnId":"r7"}`), }, ""}, {"no batches", nil, "without batches"}, {"batch without events", []EventBatch{{Domain: chat}}, "no events"}, @@ -62,7 +62,7 @@ func TestValidateBatches(t *testing.T) { oneEventBatch(chat, "twilight/x/b", `{"b":2}`), }, "appears twice"}, {"stream domain with separator", []EventBatch{ - oneEventBatch(Domain{Name: "run/r7"}, "twilight/run/created", `{"runId":"r7"}`), + oneEventBatch(Domain{Name: "run/r7"}, "twilight/turn/created", `{"turnId":"r7"}`), }, `contains "/"`}, {"empty event type", []EventBatch{{Domain: chat, Events: []Event{ {RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{}`)}, @@ -91,7 +91,7 @@ func TestValidateBatches(t *testing.T) { func TestValidateCommit(t *testing.T) { batches := []EventBatch{ oneEventBatch(Domain{Name: "chat"}, "twilight/x/a", `{"a":1}`), - oneEventBatch(Domain{Name: "run", Id: "r7"}, "twilight/run/created", `{"runId":"r7"}`), + oneEventBatch(Domain{Name: "run", Id: "r7"}, "twilight/turn/created", `{"turnId":"r7"}`), } cases := []struct { name string diff --git a/agentcore/preset/preset_test.go b/agentcore/preset/preset_test.go index d1ba442f..e7e8030b 100644 --- a/agentcore/preset/preset_test.go +++ b/agentcore/preset/preset_test.go @@ -3,7 +3,7 @@ package preset import ( "testing" - "github.com/felinics/twilight/agentcore/run" + "github.com/felinics/twilight/agentcore/turn" ) // TestPresetDigestGolden freezes the preset digest and its field boundary: @@ -27,7 +27,7 @@ func TestPresetDigestGolden(t *testing.T) { }{ {"system prompt", func(p *AgentPreset) { p.SystemPrompt = "be brief" }}, {"builder", func(p *AgentPreset) { p.PromptBuilder = "other/builder" }}, - {"scheduling mode", func(p *AgentPreset) { p.Scheduling.Mode = run.ToolScheduleSequential }}, + {"scheduling mode", func(p *AgentPreset) { p.Scheduling.Mode = turn.ToolScheduleSequential }}, {"scheduling bound", func(p *AgentPreset) { p.Scheduling.MaxParallel = 2 }}, {"malformed retries", func(p *AgentPreset) { p.MalformedRetries = 1 }}, } diff --git a/agentcore/runtime/compaction_boundary_test.go b/agentcore/runtime/compaction_boundary_test.go index 7e51f66e..eaa14b1c 100644 --- a/agentcore/runtime/compaction_boundary_test.go +++ b/agentcore/runtime/compaction_boundary_test.go @@ -10,7 +10,7 @@ import ( "github.com/felinics/twilight/agentcore/compaction" "github.com/felinics/twilight/agentcore/driver" "github.com/felinics/twilight/agentcore/executor" - "github.com/felinics/twilight/agentcore/run/effect" + "github.com/felinics/twilight/agentcore/turn/effect" "github.com/felinics/twilight/sdk" ) diff --git a/agentcore/runtime/compaction_fault_test.go b/agentcore/runtime/compaction_fault_test.go index 9378df24..21154772 100644 --- a/agentcore/runtime/compaction_fault_test.go +++ b/agentcore/runtime/compaction_fault_test.go @@ -11,8 +11,8 @@ import ( "github.com/felinics/twilight/agentcore/driver" "github.com/felinics/twilight/agentcore/executor" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run/effect" "github.com/felinics/twilight/agentcore/session/writer" + "github.com/felinics/twilight/agentcore/turn/effect" "github.com/felinics/twilight/sdk" ) diff --git a/agentcore/runtime/compaction_fork_test.go b/agentcore/runtime/compaction_fork_test.go index 46fa14a4..cc6654aa 100644 --- a/agentcore/runtime/compaction_fork_test.go +++ b/agentcore/runtime/compaction_fork_test.go @@ -11,11 +11,14 @@ import ( "github.com/felinics/twilight/agentcore/chatlog" "github.com/felinics/twilight/agentcore/compaction" - "github.com/felinics/twilight/agentcore/run/effect" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/session/writer/writertest" "github.com/felinics/twilight/agentcore/sessionkernel" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/sessionstore" + "github.com/felinics/twilight/agentcore/turn/store" "github.com/felinics/twilight/sdk" ) @@ -86,7 +89,7 @@ func TestCompactionForkSameCallerIDHasIndependentProtocol(t *testing.T) { parentOp := f.assertOperation(f.w, id, compaction.Pending, false) childOp := f.assertOperation(child, id, compaction.Pending, false) parentKey, childKey := parentOp.Request.Key(compactSession), childOp.Request.Key(child.SessionID()) - if parentKey == childKey || parentKey.RunID != childKey.RunID || parentKey.Effect != childKey.Effect { + if parentKey == childKey || parentKey.OwnerID != childKey.OwnerID || parentKey.Effect != childKey.Effect { t.Fatalf("caller identity must remain stable under different Session scopes: parent=%+v child=%+v", parentKey, childKey) } for _, w := range []writer.Writer{f.w, child} { @@ -211,3 +214,57 @@ func mustCompactSurface(f *compactFixture, w writer.Writer) chatlog.Surface { } return surface } + +func TestTurnForkInheritsHistoryWithoutExecutionState(t *testing.T) { + f := newCompactFixture(t, func(context.Context, sdk.Request) (sdk.ModelResult, error) { + return sdk.ModelResult{Text: "answer"}, nil + }) + x := f.execution(f.worker, nil, true) + parent := f.startTurn("parent-turn", false) + if _, err := f.drive(x, f.w, parent.Ref.TurnID); err != nil { + t.Fatal(err) + } + parentSurface, err := sessionstore.ReadSurface(f.ctx, f.w.Projections(), compactSession) + if err != nil { + t.Fatal(err) + } + view := parentSurface.Turns[parent.Ref.TurnID] + if view.Status != turn.TurnCompleted || view.Preset != f.preset || len(view.InputIDs) != 1 { + t.Fatalf("parent = %+v", view) + } + child := f.forkWriter("turn-child") + inherited, err := sessionstore.ReadSurface(f.ctx, child.Projections(), child.SessionID()) + if err != nil || !reflect.DeepEqual(inherited, parentSurface) { + t.Fatalf("inherited surface = %+v %v", inherited, err) + } + context, err := chatlog.ReadContext(f.ctx, child.Projections(), child.SessionID()) + if err != nil || !reflect.DeepEqual(context.Entries, f.context().Entries) { + t.Fatalf("inherited context = %+v %v", context, err) + } + state, _, err := child.Projections().Load(f.ctx, child.SessionID(), sessionstore.MachineProjectionID, sessionstore.MachineProjection.Version) + if err != nil { + t.Fatal(err) + } + machine, ok := state.(sessionstore.Machine) + if !ok || len(machine.Active) != 0 || len(machine.Positions) != 0 { + t.Fatalf("child machine = %+v", state) + } + if _, err := f.k.TurnStore.Record(f.ctx, child.SessionID(), parent.Ref.TurnID); !errors.Is(err, store.ErrTurnNotFound) { + t.Fatalf("inherited execution record = %v", err) + } + in, err := f.k.Chatlog.Submit(f.ctx, child, "child-input", turn.MustParseCanonicalJSON("{\"text\":\"child\"}")) + if err != nil { + t.Fatal(err) + } + next, err := f.k.Turns.Start(f.ctx, child, sessionkernel.StartRequest{Ref: sessionstore.TurnRef{SessionID: child.SessionID(), TurnID: "child-turn"}, Inputs: []turn.AgentInput{in}, Preset: f.preset}) + if err != nil || next.Status != turn.TurnActive { + t.Fatalf("child start = %+v %v", next, err) + } + if _, err := f.drive(x, child, next.Ref.TurnID); err != nil { + t.Fatal(err) + } + unchanged, err := sessionstore.ReadSurface(f.ctx, f.w.Projections(), compactSession) + if err != nil || !reflect.DeepEqual(unchanged, parentSurface) { + t.Fatalf("parent changed: %+v %v", unchanged, err) + } +} diff --git a/agentcore/runtime/compaction_test.go b/agentcore/runtime/compaction_test.go index f37a705c..2116f595 100644 --- a/agentcore/runtime/compaction_test.go +++ b/agentcore/runtime/compaction_test.go @@ -21,12 +21,6 @@ import ( "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/preset" "github.com/felinics/twilight/agentcore/prompt" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/effect" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/sessionstore" - "github.com/felinics/twilight/agentcore/run/store" rt "github.com/felinics/twilight/agentcore/runtime" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/filestore/filestoretest" @@ -36,12 +30,17 @@ import ( "github.com/felinics/twilight/agentcore/testutil/local" "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/effect" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/sessionstore" + "github.com/felinics/twilight/agentcore/turn/store" "github.com/felinics/twilight/sdk" "github.com/google/jsonschema-go/jsonschema" ) const compactSession session.SessionID = "compaction-integration" -const compactModel run.ModelRef = "test-model" +const compactModel turn.ModelRef = "test-model" const compactSummary = "short summary" // These tests assemble only core ports: no reference-agent builder or test API @@ -81,7 +80,7 @@ func newCompactFixture(t *testing.T, inv compactInvoker, tools ...local.Executab t.Error(err) } }) - catalog, err := local.NewCatalog(map[run.ModelRef]local.ModelInvoker{compactModel: inv}, tools...) + catalog, err := local.NewCatalog(map[turn.ModelRef]local.ModelInvoker{compactModel: inv}, tools...) if err != nil { t.Fatal(err) } @@ -118,7 +117,7 @@ func (f *compactFixture) newWorker() { func (f *compactFixture) execution(port effect.ExecutionPort, policy driver.ContextPolicy, frozen bool) *rt.Execution { f.t.Helper() - src := rt.ExecutionSources{Runs: f.k.Runs, Projections: f.k.Projections, Content: f.k.Content} + src := rt.ExecutionSources{TurnStore: f.k.TurnStore, Projections: f.k.Projections, Content: f.k.Content} if frozen { src.Frozen = f.k.Frozen } @@ -215,12 +214,12 @@ func (f *compactFixture) startTurn(id string, stop bool) sessionkernel.TurnResul if err != nil { f.t.Fatal(err) } - in, err := f.k.Chatlog.Submit(f.ctx, f.w, run.InputID(id), run.MustParseCanonicalJSON(string(body))) + in, err := f.k.Chatlog.Submit(f.ctx, f.w, turn.InputID(id), turn.MustParseCanonicalJSON(string(body))) if err != nil { f.t.Fatal(err) } - ref := turn.TurnRef{SessionID: compactSession, TurnID: turn.TurnID(id)} - result, err := f.k.Turns.Start(f.ctx, f.w, sessionkernel.StartRequest{Ref: ref, Inputs: []run.AgentInput{in}, Preset: f.preset}) + ref := sessionstore.TurnRef{SessionID: compactSession, TurnID: turn.TurnID(id)} + result, err := f.k.Turns.Start(f.ctx, f.w, sessionkernel.StartRequest{Ref: ref, Inputs: []turn.AgentInput{in}, Preset: f.preset}) if err != nil { f.t.Fatal(err) } @@ -332,7 +331,7 @@ func (b compactBuilder) Build(ctx context.Context, in prompt.Input) (prompt.Prom if err != nil { return prompt.Prompt{}, err } - ids := make([]run.InputID, len(in.Inputs)) + ids := make([]turn.InputID, len(in.Inputs)) for i := range in.Inputs { ids[i] = in.Inputs[i].ID } @@ -341,7 +340,7 @@ func (b compactBuilder) Build(ctx context.Context, in prompt.Input) (prompt.Prom return prompt.Prompt{}, err } request.Tools = definitions - return prompt.Prompt{Model: b.ap.Model, Request: request, InputIDs: ids, Token: run.PromptToken(digest), Tools: specs}, nil + return prompt.Prompt{Model: b.ap.Model, Request: request, InputIDs: ids, Token: turn.PromptToken(digest), Tools: specs}, nil } type compactPolicy struct { @@ -445,7 +444,7 @@ func TestCompactionCandidateRebuildAndBudget(t *testing.T) { if op.Status != compaction.Applied || !op.Acknowledged { t.Fatalf("operation=%+v", op) } - if _, err := f.k.Runs.Record(f.ctx, compactSession, op.Request.Key(compactSession).RunID); !errors.Is(err, store.ErrRunNotFound) { + if _, err := f.k.TurnStore.Record(f.ctx, compactSession, turn.TurnID(op.Request.Key(compactSession).OwnerID)); !errors.Is(err, store.ErrTurnNotFound) { t.Fatalf("summary created a normal Run: %v", err) } } @@ -794,8 +793,8 @@ func TestCompactionIdentityAndPendingGuard(t *testing.T) { t.Fatal("conflicting Start mutated intent or dispatched") } current := f.startTurn("guarded", false) - bound := f.k.Runs.Bind(f.w) - snap, err := bound.Load(f.ctx, current.RunID) + bound := f.k.TurnStore.Bind(f.w) + snap, err := bound.Load(f.ctx, current.Ref.TurnID) if err != nil { t.Fatal(err) } @@ -804,16 +803,16 @@ func TestCompactionIdentityAndPendingGuard(t *testing.T) { if err != nil { t.Fatal(err) } - id := schema.Identity().DeriveModelRequestCommandID(current.RunID, snap.Position) - cmd := run.PrepareModelRequest{StepID: schema.Identity().DeriveModelStepID(current.RunID, id), Model: compactModel, Request: r, RequestDigest: digest, InputIDs: []run.InputID{"guarded"}} - env, err := schema.Wire().Envelope(current.RunID, id, cmd) + id := schema.Identity().DeriveModelRequestCommandID(current.Ref.TurnID, snap.Position) + cmd := turn.PrepareModelRequest{StepID: schema.Identity().DeriveModelStepID(current.Ref.TurnID, id), Model: compactModel, Request: r, RequestDigest: digest, InputIDs: []turn.InputID{"guarded"}} + env, err := schema.Wire().Envelope(current.Ref.TurnID, id, cmd) if err != nil { t.Fatal(err) } if _, err := bound.Commit(f.ctx, store.CommitRequest{Base: snap.Position, Command: env}); !errors.Is(err, compaction.ErrPending) { t.Fatalf("Prepare while pending=%v", err) } - after, err := bound.Load(f.ctx, current.RunID) + after, err := bound.Load(f.ctx, current.Ref.TurnID) if err != nil { t.Fatal(err) } @@ -848,22 +847,22 @@ type compactGateTool struct { release chan struct{} } -func (*compactGateTool) Ref() run.ToolRef { return "lookup" } +func (*compactGateTool) Ref() turn.ToolRef { return "lookup" } func (*compactGateTool) Definition() sdk.ToolDefinition { return sdk.ToolDefinition{Name: "lookup", Parameters: &jsonschema.Schema{Type: "object"}} } -func (*compactGateTool) ResponsePolicy() run.ResponsePolicy { return run.DirectExecution } -func (*compactGateTool) Replay() run.ReplayPolicy { return run.ReplayAllowed } -func (*compactGateTool) Placement() run.ToolPlacement { return run.PlacementProcess } -func (*compactGateTool) ValidateArguments(run.CanonicalJSON) error { return nil } +func (*compactGateTool) ResponsePolicy() turn.ResponsePolicy { return turn.DirectExecution } +func (*compactGateTool) Replay() turn.ReplayPolicy { return turn.ReplayAllowed } +func (*compactGateTool) Placement() turn.ToolPlacement { return turn.PlacementProcess } +func (*compactGateTool) ValidateArguments(turn.CanonicalJSON) error { return nil } func (g *compactGateTool) Execute(ctx context.Context, _ local.ToolExecutionRequest) local.ToolExecutionOutcome { g.started <- struct{}{} select { case <-g.release: body, _ := json.Marshal(strings.Repeat("tool-result-", 100)) - return local.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: run.MustParseCanonicalJSON(string(body))}} + return local.ToolExecutionSucceeded{Result: turn.ToolExecutionResult{Output: turn.MustParseCanonicalJSON(string(body))}} case <-ctx.Done(): - return local.ToolExecutionFailed{Failure: run.ToolFailure{Class: "cancelled", Message: ctx.Err().Error()}} + return local.ToolExecutionFailed{Failure: turn.ToolFailure{Class: "cancelled", Message: ctx.Err().Error()}} } } @@ -902,8 +901,8 @@ func TestCompactionStartsAfterReattachedToolOutcome(t *testing.T) { if err != nil { t.Fatal(err) } - bound := f.k.Runs.Bind(f.w) - modelCall, err := lp.Advance(f.ctx, bound, current.RunID, nil) + bound := f.k.TurnStore.Bind(f.w) + modelCall, err := lp.Advance(f.ctx, bound, current.Ref.TurnID, nil) if err != nil || len(modelCall.Dispatched) != 1 { t.Fatalf("model dispatch=%+v, %v", modelCall, err) } @@ -911,14 +910,14 @@ func TestCompactionStartsAfterReattachedToolOutcome(t *testing.T) { if _, err := lp.Deliver(f.ctx, bound, out, nil); err != nil { t.Fatal(err) } - toolCall, err := lp.Advance(f.ctx, bound, current.RunID, nil) + toolCall, err := lp.Advance(f.ctx, bound, current.Ref.TurnID, nil) if err != nil || len(toolCall.Dispatched) != 1 { t.Fatalf("tool dispatch=%+v, %v", toolCall, err) } waitCompact(t, f.ctx, tool.started) f.reopenWriter() policy := &driver.BudgetPolicy{ - Budget: func(context.Context, run.ModelRef) (prompt.TokenBudget, error) { + Budget: func(context.Context, turn.ModelRef) (prompt.TokenBudget, error) { return prompt.TokenBudget{ContextWindow: 300, OutputReserve: 30}, nil }, Estimate: func(_ context.Context, request *model.ModelRequest) (int, error) { diff --git a/agentcore/runtime/runtimetest/conformance.go b/agentcore/runtime/runtimetest/conformance.go index 46ff25a0..60dd8734 100644 --- a/agentcore/runtime/runtimetest/conformance.go +++ b/agentcore/runtime/runtimetest/conformance.go @@ -4,18 +4,17 @@ import ( "errors" "github.com/felinics/twilight/agentcore/chatlog" "github.com/felinics/twilight/agentcore/ledger" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/reconcile" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/sessionstore" - "github.com/felinics/twilight/agentcore/run/store" - "github.com/felinics/twilight/agentcore/sessionkernel" "github.com/felinics/twilight/agentcore/session/writer" + "github.com/felinics/twilight/agentcore/sessionkernel" "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/reconcile" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/sessionstore" + "github.com/felinics/twilight/agentcore/turn/store" "testing" ) -// Run executes the Turn conformance suite (agent-turn.md section 8) against +// Run executes the Turn conformance suite (docs/architecture/core.md) against // fixtures made by factory. func Run(t *testing.T, factory Factory) { t.Helper() @@ -35,9 +34,9 @@ func Run(t *testing.T, factory Factory) { } const ( - typeCreated = sessionstore.Prefix + "run_created" + typeCreated = sessionstore.Prefix + "turn_started" typeAccepted = sessionstore.Prefix + "input_accepted" - typeEnded = sessionstore.Prefix + "run_ended" + typeEnded = sessionstore.Prefix + "turn_ended" ) // --- Start ----------------------------- @@ -76,27 +75,19 @@ func testStart(t *testing.T, factory Factory) { if err != nil { t.Fatal(err) } - runID := turn.DeriveRunID(sid, "t1") - if resp.Status != turn.TurnActive || resp.RunID != runID || resp.Disposition != "" || resp.End != nil { + turnID := turn.TurnID("t1") + if resp.Status != turn.TurnActive || resp.Ref.TurnID != turnID || resp.Disposition != "" || resp.End != nil { t.Fatalf("start response = %+v", resp) } - plan := turn.PlanDigest("t1", presetRef.Digest, []chatlog.InputID{"in-1", "in-2"}) - group := h.group(ledger.CommitID(turn.StartOperationDigest(sid, "t1", plan))) - if !sameTypes(group, turn.TypeStarted, chatlog.TypeInputDelivered, chatlog.TypeInputDelivered, typeCreated, typeAccepted, typeAccepted) { + plan := sessionstore.PlanDigest("t1", presetRef, submitted) + group := h.group(ledger.CommitID(sessionstore.StartOperationDigest(sid, "t1", plan))) + if !sameTypes(group, typeCreated, typeAccepted, typeAccepted) { t.Fatalf("start group = %v", eventTypes(group)) } - // The Turn's fact names the one Run that executes it, and - // each delivery names both. - started := decode[turn.StartedPayload](t, h.registry, &group[0]) - if started.TurnID != "t1" || started.RunID != runID || len(started.InputIDs) != 2 || started.Preset != presetRef { - t.Fatalf("started payload = %+v", started) - } - if d := decode[chatlog.InputDeliveredPayload](t, h.registry, &group[1]); d.TurnID != "t1" || d.RunID != runID { - t.Fatalf("delivered payload = %+v", d) - } - created := decode[sessionstore.Event](t, h.registry, &group[3]) - if _, ok := created.Fact.(run.RunCreated); !ok || created.RunID != runID { - t.Fatalf("created fact = %+v", created) + started := decode[sessionstore.Event](t, h.registry, &group[0]) + start, ok := started.Fact.(turn.TurnStarted) + if !ok || start.TurnID != "t1" || start.Preset != presetRef { + t.Fatalf("started = %+v", started) } chat := h.chat() for _, id := range []chatlog.InputID{"in-1", "in-2"} { @@ -104,26 +95,35 @@ func testStart(t *testing.T, factory Factory) { t.Fatalf("input %s = %+v, want delivered to t1", id, v) } } - if owner, ok := chat.Runs.Get(runID); !ok || owner.TurnID != "t1" { - t.Fatalf("chatlog run owner = %+v %v, want t1 from the delivery", owner, ok) - } view := h.surface().Turns["t1"] - if len(view.InputIDs) != 2 || view.RunID != runID || view.End != nil { + if len(view.InputIDs) != 2 || view.TurnID != turnID || view.End != nil { t.Fatalf("turn view = %+v", view) } - snap := h.load(runID) - if snap.State.Status != run.RunActive || len(snap.State.PendingInputs) != 2 { - t.Fatalf("run after start = %+v, want Open with both inputs pending", snap.State) + snap := h.load(turnID) + if snap.State.Status != turn.TurnActive || len(snap.State.PendingInputs) != 2 { + t.Fatalf("Turn after start = %+v, want Open with both inputs pending", snap.State) } // a replay at a later time is already-applied and writes nothing. after := h.head() h.now += 60_000 again, err := h.c.Start(h.ctx, h.writer(), h.startRequest("t1", submitted...)) - if err != nil || again.RunID != runID || h.head() != after { + if err != nil || again.Ref.TurnID != turnID || h.head() != after { t.Fatalf("replay = %+v %v, head moved=%v", again, err, h.head() != after) } + // A Turn identity cannot be replayed with a different immutable preset + // or different content under the same input identity. + changedPreset := h.startRequest("t1", submitted...) + changedPreset.Preset.ID = "another-preset" + changedInputs := append([]turn.AgentInput(nil), submitted...) + changedInputs[0].Digest = "sha256:changed" + for _, req := range []sessionkernel.StartRequest{changedPreset, h.startRequest("t1", changedInputs...)} { + if _, err := h.c.Start(h.ctx, h.writer(), req); !errors.Is(err, turn.ErrConflict) || h.head() != after { + t.Fatalf("changed start replay: err=%v moved=%v", err, h.head() != after) + } + } + // the same TurnID with another plan, and a second // Turn while one is active, are conflicts. extra := h.submit("in-3") @@ -147,18 +147,18 @@ func testStart(t *testing.T, factory Factory) { func testDeliver(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) resp := h.start("t1", "in-1") - runID := resp.RunID + turnID := resp.Ref.TurnID - // input_accepted and input_delivered share one commit whose - // CommitID is the Run's input CommandID; the Run queues the input. + // input_accepted is the sole durable delivery fact. Its CommitID is the + // Turn input CommandID and the machine queues the accepted input. in2 := h.submit("in-2") dresp, err := h.c.Deliver(h.ctx, h.writer(), sessionkernel.DeliverRequest{Ref: h.ref("t1"), Inputs: in2}) - if err != nil || dresp.Status != turn.TurnActive || dresp.RunID != runID { + if err != nil || dresp.Status != turn.TurnActive || dresp.Ref.TurnID != turnID { t.Fatalf("deliver = %+v %v", dresp, err) } - group := h.group(ledger.CommitID(schema.Identity().DeriveInputCommandID(runID, "in-2"))) - if !sameTypes(group, typeAccepted, chatlog.TypeInputDelivered) { - t.Fatalf("deliver group = %v, want input_accepted then input_delivered", eventTypes(group)) + group := h.group(ledger.CommitID(schema.Identity().DeriveInputCommandID(turnID, "in-2"))) + if !sameTypes(group, typeAccepted) { + t.Fatalf("deliver group = %v, want input_accepted", eventTypes(group)) } if v, _ := h.chat().Inputs.Get("in-2"); v.Status != chatlog.InputDelivered || v.Input.TurnID != "t1" { t.Fatalf("in-2 = %+v", v) @@ -166,7 +166,7 @@ func testDeliver(t *testing.T, factory Factory) { if ids := h.surface().Turns["t1"].InputIDs; len(ids) != 2 || ids[1] != "in-2" { t.Fatalf("InputIDs after deliver = %v", ids) } - if pending := h.load(runID).State.PendingInputs; len(pending) != 2 || pending[1].ID != "in-2" { + if pending := h.load(turnID).State.PendingInputs; len(pending) != 2 || pending[1].ID != "in-2" { t.Fatalf("pending after deliver = %+v", pending) } // Replay of the same delivery writes nothing. @@ -175,18 +175,24 @@ func testDeliver(t *testing.T, factory Factory) { t.Fatalf("deliver replay: err=%v moved=%v", err, h.head() != head) } + changed := append([]turn.AgentInput(nil), in2...) + changed[0].Digest = "sha256:changed" + if _, err := h.c.Deliver(h.ctx, h.writer(), sessionkernel.DeliverRequest{Ref: h.ref("t1"), Inputs: changed}); !errors.Is(err, turn.ErrConflict) || h.head() != head { + t.Fatalf("changed delivery replay: err=%v moved=%v", err, h.head() != head) + } + // an unknown Turn and a Turn that is not active are conflicts; // the input stays submitted. in3 := h.submit("in-3") if _, err := h.c.Deliver(h.ctx, h.writer(), sessionkernel.DeliverRequest{Ref: h.ref("nope"), Inputs: in3}); !errors.Is(err, turn.ErrConflict) { t.Fatalf("deliver to unknown turn = %v", err) } - h.appCancel(runID) - if st := h.status("t1"); st.Status != turn.TurnFailed { - t.Fatalf("after app cancel status = %s", st.Status) + h.appCancel(turnID) + if st := h.status("t1"); st.Status != turn.TurnStopped { + t.Fatalf("after app cancel status = %v", st.Status) } if _, err := h.c.Deliver(h.ctx, h.writer(), sessionkernel.DeliverRequest{Ref: h.ref("t1"), Inputs: in3}); !errors.Is(err, turn.ErrConflict) { - t.Fatalf("deliver to a failed turn = %v, want conflict", err) + t.Fatalf("deliver to a stopped turn = %v, want conflict", err) } if v, _ := h.chat().Inputs.Get("in-3"); v.Status != chatlog.InputSubmitted { t.Fatalf("in-3 after refused deliver = %s, want submitted", v.Status) @@ -196,16 +202,16 @@ func testDeliver(t *testing.T, factory Factory) { // payload that differs from the submitted content, is a conflict and the // batch writes nothing -- including its valid members. h.start("t2", "in-4") - run2 := h.surface().Turns["t2"].RunID + run2 := h.surface().Turns["t2"].TurnID in5 := h.submit("in-5") before := h.head() pendingBefore := len(h.load(run2).State.PendingInputs) rejects := []struct { name string - inputs []run.AgentInput + inputs []turn.AgentInput }{ - {"unsubmitted second input", []run.AgentInput{in5[0], input("never-submitted")}}, - {"digest differs", []run.AgentInput{{ID: in5[0].ID, Digest: "sha256:other"}}}, + {"unsubmitted second input", []turn.AgentInput{in5[0], input("never-submitted")}}, + {"digest differs", []turn.AgentInput{{ID: in5[0].ID, Digest: "sha256:other"}}}, } for _, tc := range rejects { if _, err := h.c.Deliver(h.ctx, h.writer(), sessionkernel.DeliverRequest{Ref: h.ref("t2"), Inputs: tc.inputs}); !errors.Is(err, turn.ErrConflict) { @@ -226,14 +232,14 @@ func testDeliver(t *testing.T, factory Factory) { } // a batch is one group under the batch CommandID -- every - // input_accepted and input_delivered together -- and its replay writes nothing. + // input_accepted facts together -- and its replay writes nothing. in6 := h.submit("in-6") - batch := []run.AgentInput{in5[0], in6[0]} + batch := []turn.AgentInput{in5[0], in6[0]} if _, err := h.c.Deliver(h.ctx, h.writer(), sessionkernel.DeliverRequest{Ref: h.ref("t2"), Inputs: batch}); err != nil { t.Fatalf("batch deliver = %v", err) } group = h.group(ledger.CommitID(schema.Identity().DeriveInputCommandID(run2, "in-5", "in-6"))) - if !sameTypes(group, typeAccepted, typeAccepted, chatlog.TypeInputDelivered, chatlog.TypeInputDelivered) { + if !sameTypes(group, typeAccepted, typeAccepted) { t.Fatalf("batch group = %v", eventTypes(group)) } for _, id := range []chatlog.InputID{"in-5", "in-6"} { @@ -260,30 +266,27 @@ func testStop(t *testing.T, factory Factory) { t.Fatalf("stop unknown = %v", err) } - // CancelRun and turn/failed{stopped} land in one commit. + // CancelTurn and turn/failed{stopped} land in one commit. resp := h.start("t1", "in-1") sresp, err := h.c.Stop(h.ctx, h.writer(), sessionkernel.StopRequest{Ref: h.ref("t1"), Reason: "user"}) if err != nil || sresp.Status != turn.TurnStopped || sresp.Disposition != sessionkernel.ResumeFinished || sresp.End == nil { t.Fatalf("stop = %+v %v", sresp, err) } - group := h.group(ledger.CommitID(turn.CancelCommandID(sid, "t1", resp.RunID))) - var failed *turn.FailedPayload - sawEnded := false - for i := range group { - switch group[i].Type { - case turn.TypeFailed: - p := decode[turn.FailedPayload](t, h.registry, &group[i]) - failed = &p - case typeEnded: - sawEnded = true - } + group := h.group(ledger.CommitID(sessionstore.CancelCommandID(sid, "t1"))) + if !sameTypes(group, typeEnded) { + t.Fatalf("stop facts = %v", eventTypes(group)) } - if !sawEnded || failed == nil || failed.Settlement != turn.SettlementStopped || failed.FailureClass != "cancelled" || failed.Reason != "user" || failed.RunID != resp.RunID { - t.Fatalf("stop group = %v, failed=%+v", eventTypes(group), failed) + ended := decode[sessionstore.Event](t, h.registry, &group[0]).Fact.(turn.TurnEnded) + stopped, ok := ended.End.(turn.TurnStoppedEnd) + if !ok || stopped.Message != "user" { + t.Fatalf("stop outcome = %+v", ended) } // A settled Turn admits nothing else. for name, call := range map[string]func() error{ - "stop": func() error { _, err := h.c.Stop(h.ctx, h.writer(), sessionkernel.StopRequest{Ref: h.ref("t1")}); return err }, + "stop": func() error { + _, err := h.c.Stop(h.ctx, h.writer(), sessionkernel.StopRequest{Ref: h.ref("t1")}) + return err + }, "deliver": func() error { _, err := h.c.Deliver(h.ctx, h.writer(), sessionkernel.DeliverRequest{Ref: h.ref("t1"), Inputs: h.submit("late")}) return err @@ -294,18 +297,18 @@ func testStop(t *testing.T, factory Factory) { } } - // A Run that ends without completing settles its Turn as failed from - // its own run_ended: the Turn and its Run are one, and the + // A Turn that ends without completing settles its Turn as failed from + // its own turn_ended: the Turn and its Turn are one, and the // failure is the Turn's outcome, shown to the user; the next input opens // a new Turn with the whole history, including this one, in view. resp = h.start("t2", "in-2") - h.appCancel(resp.RunID) + h.appCancel(resp.Ref.TurnID) st := h.status("t2") - if st.Status != turn.TurnFailed || st.Disposition != sessionkernel.ResumeFinished || st.End == nil || st.RunID != resp.RunID { + if st.Status != turn.TurnStopped || st.Disposition != sessionkernel.ResumeFinished || st.End == nil || st.Ref.TurnID != resp.Ref.TurnID { t.Fatalf("status after a non-completed end = %+v", st) } - if _, stopped := st.End.(run.RunStoppedEnd); !stopped { - t.Fatalf("end = %T, want RunStoppedEnd", st.End) + if _, stopped := st.End.(turn.TurnStoppedEnd); !stopped { + t.Fatalf("end = %T, want TurnStoppedEnd", st.End) } if _, err := h.c.Stop(h.ctx, h.writer(), sessionkernel.StopRequest{Ref: h.ref("t2")}); !errors.Is(err, turn.ErrConflict) { t.Fatalf("stop after failure = %v, want conflict", err) @@ -318,19 +321,19 @@ func testStop(t *testing.T, factory Factory) { t.Fatalf("turn after a failed one = %+v", next) } - // A completed Run settles the Turn through the run_ended of its own + // A completed Turn settles through turn_ended in its own // group; no turn event is written, and every Coordinator transition then // conflicts. - res := h.complete(next.RunID) + res := h.complete(next.Ref.TurnID) types := eventTypes(res.Events) - if types[len(types)-1] != sessionstore.Prefix+"run_ended" { - t.Fatalf("completion group = %v, want run_ended last and no turn event", types) + if types[len(types)-1] != sessionstore.Prefix+"turn_ended" { + t.Fatalf("completion group = %v, want turn_ended last and no turn event", types) } st = h.status("t3") if st.Status != turn.TurnCompleted || st.Disposition != sessionkernel.ResumeFinished || st.End == nil { t.Fatalf("completed status = %+v", st) } - if _, ok := (st.End).(run.RunCompletedEnd); !ok { + if _, ok := (st.End).(turn.TurnCompletedEnd); !ok { t.Fatalf("end = %T", st.End) } if _, err := h.c.Stop(h.ctx, h.writer(), sessionkernel.StopRequest{Ref: h.ref("t3")}); !errors.Is(err, turn.ErrConflict) { @@ -343,28 +346,28 @@ func testStop(t *testing.T, factory Factory) { func testStatus(t *testing.T, factory Factory) { cases := []struct { name string - arrange func(h *harness, runID run.RunID) + arrange func(h *harness, turnID turn.TurnID) status turn.TurnStatus disposition sessionkernel.ResumeDisposition waiting int ended bool }{ - {"open run has no disposition", func(*harness, run.RunID) {}, turn.TurnActive, "", 0, false}, - {"executing model needs recovery", func(h *harness, r run.RunID) { h.executingModel(r) }, turn.TurnActive, sessionkernel.ResumeWaitingForRecovery, 0, false}, - {"approval call waits for a response", func(h *harness, r run.RunID) { h.waitingTool(r) }, turn.TurnActive, sessionkernel.ResumeWaitingForResponse, 1, false}, - {"completed run is finished", func(h *harness, r run.RunID) { h.complete(r) }, turn.TurnCompleted, sessionkernel.ResumeFinished, 0, true}, - {"cancelled run is finished and failed", func(h *harness, r run.RunID) { h.appCancel(r) }, turn.TurnFailed, sessionkernel.ResumeFinished, 0, true}, + {"open Turn has no disposition", func(*harness, turn.TurnID) {}, turn.TurnActive, "", 0, false}, + {"executing model needs recovery", func(h *harness, r turn.TurnID) { h.executingModel(r) }, turn.TurnActive, sessionkernel.ResumeWaitingForRecovery, 0, false}, + {"approval call waits for a response", func(h *harness, r turn.TurnID) { h.waitingTool(r) }, turn.TurnActive, sessionkernel.ResumeWaitingForResponse, 1, false}, + {"completed Turn is finished", func(h *harness, r turn.TurnID) { h.complete(r) }, turn.TurnCompleted, sessionkernel.ResumeFinished, 0, true}, + {"cancelled Turn is finished and failed", func(h *harness, r turn.TurnID) { h.appCancel(r) }, turn.TurnStopped, sessionkernel.ResumeFinished, 0, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h := newHarness(t, factory(t)) resp := h.start("t1", "in-1") - tc.arrange(h, resp.RunID) + tc.arrange(h, resp.Ref.TurnID) st := h.status("t1") - if st.Status != tc.status || st.Disposition != tc.disposition || len(st.Waiting) != tc.waiting || (st.End != nil) != tc.ended || st.RunID != resp.RunID { + if st.Status != tc.status || st.Disposition != tc.disposition || len(st.Waiting) != tc.waiting || (st.End != nil) != tc.ended || st.Ref.TurnID != resp.Ref.TurnID { t.Fatalf("status = %+v", st) } - if tc.waiting > 0 && st.Waiting[0].Kind != run.ResponseApproval { + if tc.waiting > 0 && st.Waiting[0].Kind != turn.ResponseApproval { t.Fatalf("waiting = %+v", st.Waiting) } }) @@ -381,69 +384,36 @@ func testProjection(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) resp := h.start("t1", "in-1") - // A Run no Turn of this Session names is not folded. - foreign, err := run.BuildNewRun("r-foreign", "") - if err != nil { - t.Fatal(err) - } - facts, err := schema.Machine().CreateGroup(foreign, nil) - if err != nil { - t.Fatal(err) - } - runEvents := make([]writer.TypedEvent, 0, len(facts)) - for _, f := range facts { - runEvents = append(runEvents, writer.TypedEvent{Type: sessionstore.EventType(f), RecordedAtUnixMilli: h.now, Value: sessionstore.Event{RunID: "r-foreign", Fact: f}}) - } - h.mustApply(writer.SemanticGroup{CommitID: "foreign-run", Batches: []writer.TypedBatch{ - {Domain: sessionstore.Stream("r-foreign"), Events: runEvents}, - }}) - surface := h.surface() - if len(surface.Turns) != 1 || surface.RunOwner["r-foreign"] != "" { - t.Fatalf("foreign run entered the surface: %+v", surface) - } - - // a second started, a Run already named by a Turn and a - // settlement of an unknown Turn are refused by the fold before anything - // is written. (A Turn's own settlement right after its Run's end is the Stop unit's shape and stays legal,.) - failed := func(turnID turn.TurnID, s turn.Settlement) writer.TypedEvent { - return writer.TypedEvent{Type: turn.TypeFailed, RecordedAtUnixMilli: h.now, - Value: turn.FailedPayload{TurnID: turnID, RunID: resp.RunID, Settlement: s, FailureClass: "x"}} - } - h.appCancel(resp.RunID) + // Duplicate starts and terminal facts outside an active lifetime are invalid. + h.appCancel(resp.Ref.TurnID) before := h.head() rejects := []struct { - name string - turnID turn.TurnID - event writer.TypedEvent + name string + id turn.TurnID + fact turn.Fact }{ - {"started twice", "t1", writer.TypedEvent{Type: turn.TypeStarted, RecordedAtUnixMilli: h.now, - Value: turn.StartedPayload{TurnID: "t1", RunID: "r-other", Preset: presetRef}}}, - {"run named twice", "t9", writer.TypedEvent{Type: turn.TypeStarted, RecordedAtUnixMilli: h.now, - Value: turn.StartedPayload{TurnID: "t9", RunID: resp.RunID, Preset: presetRef}}}, - {"failed for an unknown turn", "ghost", failed("ghost", turn.SettlementFailed)}, + {"started twice", "t1", turn.TurnStarted{TurnID: "t1", Preset: presetRef}}, + {"ended twice", "t1", turn.TurnEnded{End: turn.TurnStoppedEnd{Reason: turn.ReasonCancelled}}}, + {"unknown turn ended", "ghost", turn.TurnEnded{End: turn.TurnStoppedEnd{Reason: turn.ReasonCancelled}}}, } for i, tc := range rejects { res := h.commit(writer.SemanticGroup{CommitID: ledger.CommitID("reject-" + string(rune('a'+i))), Batches: []writer.TypedBatch{ - {Domain: turn.Stream(tc.turnID), Events: []writer.TypedEvent{tc.event}}, + {Domain: sessionstore.Stream(tc.id), Events: []writer.TypedEvent{{Type: sessionstore.EventType(tc.fact), RecordedAtUnixMilli: h.now, Value: sessionstore.Event{TurnID: tc.id, Fact: tc.fact}}}}, }}) if res.Outcome != writer.CommitInvalid { - t.Fatalf("%s: outcome = %s, want invalid", tc.name, res.Outcome) + t.Fatalf("%s: %s", tc.name, res.Outcome) } } if h.head() != before { - t.Fatal("a refused turn event was written") - } - if v := h.surface().Turns["t1"]; v.Status != turn.TurnFailed || v.End == nil { - t.Fatalf("view after run_ended = %+v", v) + t.Fatal("invalid fact changed ledger") } v := h.surface().Turns["t1"] - if _, stopped := v.Ended().(run.RunStoppedEnd); !stopped { - t.Fatalf("end = %T", v.Ended()) + if v.Status != turn.TurnStopped || v.End == nil { + t.Fatalf("terminal view = %+v", v) } - - // A completed Run settles the Turn from its own run_ended. + // A completed Turn settles from its own turn_ended. resp2 := h.start("t2", "in-2") - h.complete(resp2.RunID) + h.complete(resp2.Ref.TurnID) if v := h.surface().Turns["t2"]; v.Status != turn.TurnCompleted || v.End == nil { t.Fatalf("completed view = %+v", v) } @@ -469,13 +439,13 @@ func testRecovery(t *testing.T, factory Factory) { if n, err := h.rt.RecoverInterrupted(h.ctx, h.writer(), &reconcile.Reconciler{Abandon: true}); err != nil || n != 0 { t.Fatalf("recover after start = %d %v", n, err) } - if st := h.status("t1"); st.Status != turn.TurnActive || st.RunID != resp.RunID || st.Disposition != "" { + if st := h.status("t1"); st.Status != turn.TurnActive || st.Ref.TurnID != resp.Ref.TurnID || st.Disposition != "" { t.Fatalf("status after takeover = %+v", st) } // Model executing when the owner dies: the takeover disposes it and the // Turn stays active; the superseded Coordinator is fenced. - h.executingModel(resp.RunID) + h.executingModel(resp.Ref.TurnID) if st := h.status("t1"); st.Disposition != sessionkernel.ResumeWaitingForRecovery { t.Fatalf("before takeover disposition = %s", st.Disposition) } diff --git a/agentcore/runtime/runtimetest/doc.go b/agentcore/runtime/runtimetest/doc.go index e253b37b..48863fe9 100644 --- a/agentcore/runtime/runtimetest/doc.go +++ b/agentcore/runtime/runtimetest/doc.go @@ -1,8 +1,8 @@ package runtimetest // The suite is Store-parameterized like session/sessiontest and -// run/sessionstore/storetest: the reference memory store and every durable adapter run -// the same assertions. Assertions on the projection of Run facts into +// turn/sessionstore/storetest: the reference memory store and every durable adapter execute +// the same assertions. Assertions on the projection of Turn facts into // conversation entries live in the suite, which observes // them through the Runtime's group composition and the chatlog projections; // this suite covers the Coordinator, the surface projection and the recovery diff --git a/agentcore/runtime/runtimetest/harness.go b/agentcore/runtime/runtimetest/harness.go index ff3a51bf..5f038b0b 100644 --- a/agentcore/runtime/runtimetest/harness.go +++ b/agentcore/runtime/runtimetest/harness.go @@ -1,6 +1,6 @@ // Package turntest is the Store-parameterized conformance suite of the Turn // module. The Coordinator is pure protocol, so the suite assembles Writers, -// a Runtime and a Coordinator over the Store under test and drives Runs step +// a Runtime and a Coordinator over the Store under test and drives TurnStore step // by step through Runtime commits: no Loop, driver, model or tool stub is // involved. package runtimetest @@ -15,18 +15,17 @@ import ( "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" "github.com/felinics/twilight/agentcore/preset" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/frozen" - "github.com/felinics/twilight/agentcore/run/model" - "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/run/sessionstore" - "github.com/felinics/twilight/agentcore/run/sessionstore/sessionstoretest" - "github.com/felinics/twilight/agentcore/run/store" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/session/writer/writertest" "github.com/felinics/twilight/agentcore/sessionkernel" "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/frozen" + "github.com/felinics/twilight/agentcore/turn/model" + "github.com/felinics/twilight/agentcore/turn/schema" + "github.com/felinics/twilight/agentcore/turn/sessionstore" + "github.com/felinics/twilight/agentcore/turn/sessionstore/sessionstoretest" + "github.com/felinics/twilight/agentcore/turn/store" "testing" "time" ) @@ -55,13 +54,13 @@ type harness struct { now int64 seq int w writer.Writer - rt *sessionstore.SessionRunStore + rt *sessionstore.SessionTurnStore c *sessionkernel.Coordinator } func newHarness(t testing.TB, f Fixture) *harness { t.Helper() - registry, err := module.BuildRegistry(chatlog.Module, sessionstore.Module, turn.Module) + registry, err := module.BuildRegistry(chatlog.Module, sessionstore.Module) if err != nil { t.Fatal(err) } @@ -85,12 +84,12 @@ func (h *harness) open() { if err != nil { h.t.Fatal(err) } - runs, err := sessionstore.NewSessionRunStore(sessionstore.Config{Registry: h.registry, Store: h.store, Frozen: h.frozen, Now: clock}) + turns, err := sessionstore.NewSessionTurnStore(sessionstore.Config{Registry: h.registry, Store: h.store, Frozen: h.frozen, Now: clock}) if err != nil { h.t.Fatal(err) } - h.rt = runs - h.c = &sessionkernel.Coordinator{Projections: session.NewProjectionReader(h.store, h.registry, nil), Runs: runs, Now: clock} + h.rt = turns + h.c = &sessionkernel.Coordinator{Projections: session.NewProjectionReader(h.store, h.registry, nil), TurnStore: turns, Now: clock} } // takeover opens a new owner process and returns the superseded Coordinator @@ -105,8 +104,8 @@ func (h *harness) takeover() (*sessionkernel.Coordinator, writer.Writer) { func (h *harness) fatal(args ...any) { h.t.Helper(); h.t.Fatal(args...) } -func (h *harness) ref(turnID turn.TurnID) turn.TurnRef { - return turn.TurnRef{SessionID: sid, TurnID: turnID} +func (h *harness) ref(turnID turn.TurnID) sessionstore.TurnRef { + return sessionstore.TurnRef{SessionID: sid, TurnID: turnID} } func (h *harness) writer() writer.Writer { @@ -142,24 +141,24 @@ func flattenCommit(c ledger.Commit) []ledger.Event { } // inputContent is the body the harness submits for id; the AgentInput the -// Run sees carries only its digest. -func inputContent(id string) run.CanonicalJSON { - return run.MustParseCanonicalJSON(fmt.Sprintf(`{"text":%q}`, id)) +// Turn sees carries only its digest. +func inputContent(id string) turn.CanonicalJSON { + return turn.MustParseCanonicalJSON(fmt.Sprintf(`{"text":%q}`, id)) } -func input(id string) run.AgentInput { +func input(id string) turn.AgentInput { d, err := chatlog.DigestInput(chatlog.InputID(id), inputContent(id)) if err != nil { panic(err) } - return run.AgentInput{ID: run.InputID(id), Digest: d} + return turn.AgentInput{ID: turn.InputID(id), Digest: d} } // submit writes twilight/chatlog/input_submitted for each id and returns the // AgentInputs a Start or Deliver hands to the Coordinator. -func (h *harness) submit(ids ...string) []run.AgentInput { +func (h *harness) submit(ids ...string) []turn.AgentInput { h.t.Helper() - out := make([]run.AgentInput, len(ids)) + out := make([]turn.AgentInput, len(ids)) for i, id := range ids { in := input(id) h.mustApply(writer.SemanticGroup{CommitID: ledger.CommitID("submitted/" + id), @@ -171,7 +170,7 @@ func (h *harness) submit(ids ...string) []run.AgentInput { return out } -func (h *harness) startRequest(turnID turn.TurnID, inputs ...run.AgentInput) sessionkernel.StartRequest { +func (h *harness) startRequest(turnID turn.TurnID, inputs ...turn.AgentInput) sessionkernel.StartRequest { return sessionkernel.StartRequest{Ref: h.ref(turnID), Inputs: inputs, Preset: presetRef} } @@ -194,13 +193,13 @@ func (h *harness) status(turnID turn.TurnID) sessionkernel.TurnResult { return resp } -func (h *harness) surface() turn.TurnSurface { +func (h *harness) surface() sessionstore.TurnSurface { h.t.Helper() - state, _, err := h.writer().Projections().Load(h.ctx, sid, turn.SurfaceProjectionID, turn.SurfaceProjection.Version) + state, _, err := h.writer().Projections().Load(h.ctx, sid, sessionstore.SurfaceProjectionID, sessionstore.SurfaceProjection.Version) if err != nil { h.fatal(err) } - surface, ok := state.(turn.TurnSurface) + surface, ok := state.(sessionstore.TurnSurface) if !ok { h.fatal(fmt.Errorf("turn surface projection is %T", state)) } @@ -292,37 +291,37 @@ func decode[T any](t testing.TB, registry *module.Registry, event *ledger.Event) return v } -func (h *harness) load(runID run.RunID) store.Snapshot { +func (h *harness) load(turnID turn.TurnID) store.Snapshot { h.t.Helper() - snap, err := h.rt.Bind(h.writer()).Load(h.ctx, runID) + snap, err := h.rt.Bind(h.writer()).Load(h.ctx, turnID) if err != nil { h.fatal(err) } return snap } -// --- driving a Run without a Loop ---------------------------------------------------- +// --- driving a Turn without a Loop ---------------------------------------------------- // modelEffect derives the next model effect of the Prepared step. -func (h *harness) modelEffect(runID run.RunID, step run.StepID) run.EffectID { +func (h *harness) modelEffect(turnID turn.TurnID, step turn.StepID) turn.EffectID { h.t.Helper() - ms, ok := h.load(runID).State.Current.(run.ModelStep) + ms, ok := h.load(turnID).State.Current.(turn.ModelStep) if !ok || ms.RefValue.ID != step { h.fatal(fmt.Sprintf("model effect of %s: current is not the step", step)) } - return schema.Identity().DeriveEffectID(runID, step, "", ms.Rejects) + return schema.Identity().DeriveEffectID(turnID, step, "", ms.Rejects) } -// commitResult is a Run command's result plus the events of the commit it +// commitResult is a Turn command's result plus the events of the commit it // landed in. type commitResult struct { store.CommitResult Events []ledger.Event } -func (h *harness) runCommit(runID run.RunID, id run.CommandID, base run.RunPosition, cmd run.AgentCommand) (commitResult, error) { +func (h *harness) runCommit(turnID turn.TurnID, id turn.CommandID, base turn.TurnPosition, cmd turn.AgentCommand) (commitResult, error) { h.t.Helper() - env, err := schema.Wire().Envelope(runID, id, cmd) + env, err := schema.Wire().Envelope(turnID, id, cmd) if err != nil { h.fatal(err) } @@ -347,9 +346,9 @@ func (h *harness) runCommit(runID run.RunID, id run.CommandID, base run.RunPosit return out, nil } -func (h *harness) mustRunCommit(runID run.RunID, id run.CommandID, base run.RunPosition, cmd run.AgentCommand) commitResult { +func (h *harness) mustTurnCommit(turnID turn.TurnID, id turn.CommandID, base turn.TurnPosition, cmd turn.AgentCommand) commitResult { h.t.Helper() - res, err := h.runCommit(runID, id, base, cmd) + res, err := h.runCommit(turnID, id, base, cmd) if err != nil { h.fatal(fmt.Sprintf("commit %T: %v", cmd, err)) } @@ -358,20 +357,20 @@ func (h *harness) mustRunCommit(runID run.RunID, id run.CommandID, base run.RunP var toolDef = model.ToolDefinition{Name: "ask", Parameters: jsonstable.MustParse(`{"type":"object"}`)} -func (h *harness) spec(policy run.ResponsePolicy) run.ToolSpec { +func (h *harness) spec(policy turn.ResponsePolicy) turn.ToolSpec { h.t.Helper() d, err := schema.Canonical().DigestToolDefinition(toolDef) if err != nil { h.fatal(err) } - return run.ToolSpec{Ref: "ask", Name: "ask", DefinitionDigest: d, Policy: policy} + return turn.ToolSpec{Ref: "ask", Name: "ask", DefinitionDigest: d, Policy: policy} } -// prepare commits PrepareModelRequest at the Run's current position; specs is +// prepare commits PrepareModelRequest at the Turn's current position; specs is // nil for a model step without tools. -func (h *harness) prepare(runID run.RunID, specs []run.ToolSpec) run.StepID { +func (h *harness) prepare(turnID turn.TurnID, specs []turn.ToolSpec) turn.StepID { h.t.Helper() - snap := h.load(runID) + snap := h.load(turnID) modelReq := userRequest("m-1", "go") if len(specs) > 0 { modelReq.Tools = []model.ToolDefinition{toolDef} @@ -381,24 +380,24 @@ func (h *harness) prepare(runID run.RunID, specs []run.ToolSpec) run.StepID { if err != nil { h.fatal(err) } - cmdID := schema.Identity().DeriveModelRequestCommandID(runID, snap.Position) - ids := make([]run.InputID, len(snap.State.PendingInputs)) + cmdID := schema.Identity().DeriveModelRequestCommandID(turnID, snap.Position) + ids := make([]turn.InputID, len(snap.State.PendingInputs)) for i, in := range snap.State.PendingInputs { ids[i] = in.ID } - cmd := run.PrepareModelRequest{StepID: schema.Identity().DeriveModelStepID(runID, cmdID), Model: "m-1", Request: modelReq, + cmd := turn.PrepareModelRequest{StepID: schema.Identity().DeriveModelStepID(turnID, cmdID), Model: "m-1", Request: modelReq, RequestDigest: reqDigest, InputIDs: ids, Tools: specs} - h.mustRunCommit(runID, cmdID, snap.Position, cmd) + h.mustTurnCommit(turnID, cmdID, snap.Position, cmd) return cmd.StepID } -// executingModel takes the Run to a model step that is Executing with no +// executingModel takes the Turn to a model step that is Executing with no // worker in this process: the state RecoverInterrupted acts on. -func (h *harness) executingModel(runID run.RunID) (run.StepID, run.EffectID) { +func (h *harness) executingModel(turnID turn.TurnID) (turn.StepID, turn.EffectID) { h.t.Helper() - step := h.prepare(runID, nil) - eff := h.modelEffect(runID, step) - res := h.mustRunCommit(runID, schema.Identity().DeriveStartCommandID(eff), 0, run.StartModelExecution{StepID: step, Effect: eff}) + step := h.prepare(turnID, nil) + eff := h.modelEffect(turnID, step) + res := h.mustTurnCommit(turnID, schema.Identity().DeriveStartCommandID(eff), 0, turn.StartModelExecution{StepID: step, Effect: eff}) if res.Status != store.CommitAccepted { h.fatal("start model was not accepted") } @@ -415,37 +414,37 @@ func textResult(text string) model.ModelResult { return model.ModelResult{Text: text, FinishReason: model.FinishReasonStop, Usage: model.Usage{TotalTokens: 1}} } -// complete finishes the Run with a text result; the surface folds the Turn to -// completed from the run_ended of the same group. -func (h *harness) complete(runID run.RunID) commitResult { +// complete finishes the Turn with a text result; the surface folds the Turn to +// completed from the turn_ended of the same group. +func (h *harness) complete(turnID turn.TurnID) commitResult { h.t.Helper() - step, eff := h.executingModel(runID) - return h.mustRunCommit(runID, schema.Identity().DeriveSettlementCommandID(eff), 0, run.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("done")}) + step, eff := h.executingModel(turnID) + return h.mustTurnCommit(turnID, schema.Identity().DeriveSettlementCommandID(eff), 0, turn.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("done")}) } -// waitingTool takes the Run to a ToolStep whose single call needs approval. -func (h *harness) waitingTool(runID run.RunID) { +// waitingTool takes the Turn to a ToolStep whose single call needs approval. +func (h *harness) waitingTool(turnID turn.TurnID) { h.t.Helper() - spec := h.spec(run.ApprovalRequired) - step := h.prepare(runID, []run.ToolSpec{spec}) - eff := h.modelEffect(runID, step) - if res := h.mustRunCommit(runID, schema.Identity().DeriveStartCommandID(eff), 0, run.StartModelExecution{StepID: step, Effect: eff}); res.Status != store.CommitAccepted { + spec := h.spec(turn.ApprovalRequired) + step := h.prepare(turnID, []turn.ToolSpec{spec}) + eff := h.modelEffect(turnID, step) + if res := h.mustTurnCommit(turnID, schema.Identity().DeriveStartCommandID(eff), 0, turn.StartModelExecution{StepID: step, Effect: eff}); res.Status != store.CommitAccepted { h.fatal("start model was not accepted") } - args := run.MustParseCanonicalJSON(`{"q":1}`) + args := turn.MustParseCanonicalJSON(`{"q":1}`) callID := schema.Identity().DeriveCallID(step, 0) result := model.ModelResult{FinishReason: model.FinishReasonToolCalls, Usage: model.Usage{TotalTokens: 2}, ToolCalls: []model.ModelToolCall{{ToolCallID: "c0", ToolName: "ask", Input: model.ToolArguments{JSON: args}}}} - binding := run.ToolCallBinding{CallID: callID, ProviderCallID: "c0", ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, + binding := turn.ToolCallBinding{CallID: callID, ProviderCallID: "c0", ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, Arguments: args, Policy: spec.Policy, Replay: spec.Replay, Placement: spec.Placement} - h.mustRunCommit(runID, schema.Identity().DeriveSettlementCommandID(eff), 0, - run.SubmitModelResult{StepID: step, Effect: eff, Result: result, Calls: []run.ToolCallBinding{binding}}) + h.mustTurnCommit(turnID, schema.Identity().DeriveSettlementCommandID(eff), 0, + turn.SubmitModelResult{StepID: step, Effect: eff, Result: result, Calls: []turn.ToolCallBinding{binding}}) } -// appCancel is the Application's own CancelRun: the Run ends stopped and -// its Turn settles as failed from the run_ended alone. -func (h *harness) appCancel(runID run.RunID) { +// appCancel is the Application's own CancelTurn: the Turn ends stopped and +// its Turn settles as failed from the turn_ended alone. +func (h *harness) appCancel(turnID turn.TurnID) { h.t.Helper() h.seq++ - h.mustRunCommit(runID, run.CommandID(fmt.Sprintf("app-cancel-%d", h.seq)), 0, run.CancelRun{}) + h.mustTurnCommit(turnID, turn.CommandID(fmt.Sprintf("app-cancel-%d", h.seq)), 0, turn.CancelTurn{}) } diff --git a/agentcore/session/filestore/conformance_test.go b/agentcore/session/filestore/conformance_test.go index 0bf3dcca..fe3fe706 100644 --- a/agentcore/session/filestore/conformance_test.go +++ b/agentcore/session/filestore/conformance_test.go @@ -5,9 +5,9 @@ import ( "testing" "time" - "github.com/felinics/twilight/agentcore/run/sessionstore/storetest" "github.com/felinics/twilight/agentcore/session/filestore" "github.com/felinics/twilight/agentcore/session/sessiontest" + "github.com/felinics/twilight/agentcore/turn/sessionstore/storetest" ) func newStore(t testing.TB) *filestore.Store { diff --git a/agentcore/session/filestore/golden_test.go b/agentcore/session/filestore/golden_test.go index 8a744560..fc351885 100644 --- a/agentcore/session/filestore/golden_test.go +++ b/agentcore/session/filestore/golden_test.go @@ -17,8 +17,8 @@ var update = flag.Bool("update", false, "rewrite the golden testdata files from // TestLogFileGolden freezes the exact on-disk bytes of a stream: header.json // and log.jsonl produced from fixed inputs. It is the end-of-line fixture for // the kernel wire — a diff here is a wire change. Regenerate deliberately -// with `go test -run TestLogFileGolden -update./agent/session/filestore` and -// record the change in agent-session.md. +// with `go test ./agentcore/session/filestore -run TestLogFileGolden -update` and +// record the change in docs/architecture/core.md. func TestLogFileGolden(t *testing.T) { ctx := context.Background() root := t.TempDir() @@ -52,8 +52,8 @@ func TestLogFileGolden(t *testing.T) { {Domain: ledger.Domain{Name: "chat"}, Events: []ledger.Event{ ev("twilight/x/c", `{}`, 3), }}, - {Domain: ledger.Domain{Name: "run", Id: "r7"}, Events: []ledger.Event{ - ev("twilight/run/created", `{"runId":"r7"}`, 3), + {Domain: ledger.Domain{Name: "turn", Id: "t7"}, Events: []ledger.Event{ + ev("twilight/turn/turn_started", `{"turnId":"t7"}`, 3), }}, }}); err != nil { t.Fatal(err) @@ -87,6 +87,6 @@ func compare(t *testing.T, gotPath, goldenName string) { t.Fatalf("missing golden file (regenerate with -update after a deliberate wire change): %v", err) } if string(got) != string(want) { - t.Fatalf("%s drifted from the on-disk golden — an intentional wire change must be regenerated with -update and recorded in agent-session.md:\n got:\n%s\nwant:\n%s", goldenName, got, want) + t.Fatalf("%s drifted from the on-disk golden — an intentional wire change must be regenerated with -update and recorded in docs/architecture/core.md:\n got:\n%s\nwant:\n%s", goldenName, got, want) } } diff --git a/agentcore/session/filestore/testdata/golden_log.jsonl b/agentcore/session/filestore/testdata/golden_log.jsonl index f2ae3e3b..a2a47076 100644 --- a/agentcore/session/filestore/testdata/golden_log.jsonl +++ b/agentcore/session/filestore/testdata/golden_log.jsonl @@ -1,2 +1,2 @@ {"seq":0,"commitId":"c1","batches":[{"domain":{"name":"chat"},"events":[{"type":"twilight/x/a","recordedAtUnixMilli":1,"payload":{"a":1}},{"type":"twilight/x/b","recordedAtUnixMilli":2,"payload":{"b":[1,2]}}]}]} -{"seq":1,"commitId":"c2","batches":[{"domain":{"name":"chat"},"events":[{"type":"twilight/x/c","recordedAtUnixMilli":3,"payload":{}}]},{"domain":{"name":"run","id":"r7"},"events":[{"type":"twilight/run/created","recordedAtUnixMilli":3,"payload":{"runId":"r7"}}]}]} +{"seq":1,"commitId":"c2","batches":[{"domain":{"name":"chat"},"events":[{"type":"twilight/x/c","recordedAtUnixMilli":3,"payload":{}}]},{"domain":{"name":"turn","id":"t7"},"events":[{"type":"twilight/turn/turn_started","recordedAtUnixMilli":3,"payload":{"turnId":"t7"}}]}]} diff --git a/agentcore/session/sessiontest/conformance.go b/agentcore/session/sessiontest/conformance.go index e35f1e33..46b7b40e 100644 --- a/agentcore/session/sessiontest/conformance.go +++ b/agentcore/session/sessiontest/conformance.go @@ -73,7 +73,7 @@ func committed(t *testing.T, h session.Handle, id ledger.CommitID) bool { func chatStream() ledger.Domain { return ledger.Domain{Name: "chat"} } -func runStream(id string) ledger.Domain { +func turnStream(id string) ledger.Domain { return ledger.Domain{Name: "run", Id: id} } @@ -154,29 +154,29 @@ func testStreams(t *testing.T, f Fixture) { appendCommit(t, w, "c1", batch(chatStream(), "twilight/chat/a", `{"n":1}`)) appendCommit(t, w, "c2", batch(chatStream(), "twilight/chat/b", `{"n":2}`), - ledger.EventBatch{Domain: runStream("r7"), Events: []ledger.Event{ - {Type: "twilight/run/run_created", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{"runId":"r7"}`)}, - {Type: "twilight/run/model_step_completed", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{"runId":"r7"}`)}, + ledger.EventBatch{Domain: turnStream("r7"), Events: []ledger.Event{ + {Type: "twilight/turn/turn_started", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{"turnId":"r7"}`)}, + {Type: "twilight/turn/model_step_completed", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{"turnId":"r7"}`)}, }}) - appendCommit(t, w, "c3", batch(runStream("r7"), "twilight/run/run_ended", `{"runId":"r7"}`)) + appendCommit(t, w, "c3", batch(turnStream("r7"), "twilight/turn/turn_ended", `{"turnId":"r7"}`)) appendCommit(t, w, "c4", batch(chatStream(), "twilight/chat/c", `{"n":3}`)) page, err := f.Store.ReadStream(ctx, session.StreamReadRequest{SessionID: "s", Domain: chatStream(), Inheritance: module.Inherited}) if err != nil || len(page.Events) != 3 { t.Fatalf("chat stream = %d events, err %v", len(page.Events), err) } - runs, err := f.Store.ReadStream(ctx, session.StreamReadRequest{SessionID: "s", Domain: runStream("r7"), Inheritance: module.Own}) - if err != nil || len(runs.Events) != 3 { - t.Fatalf("run stream = %d events, err %v", len(runs.Events), err) + turns, err := f.Store.ReadStream(ctx, session.StreamReadRequest{SessionID: "s", Domain: turnStream("r7"), Inheritance: module.Own}) + if err != nil || len(turns.Events) != 3 { + t.Fatalf("Turn stream = %d events, err %v", len(turns.Events), err) } for i, want := range []string{"twilight/chat/a", "twilight/chat/b", "twilight/chat/c"} { if page.Events[i].Type != ledger.EventType(want) { t.Fatalf("session event %d = %s, want %s", i, page.Events[i].Type, want) } } - for i, want := range []string{"twilight/run/run_created", "twilight/run/model_step_completed", "twilight/run/run_ended"} { - if runs.Events[i].Type != ledger.EventType(want) { - t.Fatalf("run event %d = %s, want %s", i, runs.Events[i].Type, want) + for i, want := range []string{"twilight/turn/turn_started", "twilight/turn/model_step_completed", "twilight/turn/turn_ended"} { + if turns.Events[i].Type != ledger.EventType(want) { + t.Fatalf("Turn event %d = %s, want %s", i, turns.Events[i].Type, want) } } // From counts inside the stream: it skips a stream's own events only. @@ -184,9 +184,9 @@ func testStreams(t *testing.T, f Fixture) { if err != nil || len(tail.Events) != 2 || tail.Events[0].Type != "twilight/chat/b" { t.Fatalf("chat stream from 1 = %+v, err %v", tail.Events, err) } - runTail, err := f.Store.ReadStream(ctx, session.StreamReadRequest{SessionID: "s", Domain: runStream("r7"), Inheritance: module.Own, From: 2}) - if err != nil || len(runTail.Events) != 1 || runTail.Events[0].Type != "twilight/run/run_ended" { - t.Fatalf("run stream from 2 = %+v, err %v", runTail.Events, err) + turnTail, err := f.Store.ReadStream(ctx, session.StreamReadRequest{SessionID: "s", Domain: turnStream("r7"), Inheritance: module.Own, From: 2}) + if err != nil || len(turnTail.Events) != 1 || turnTail.Events[0].Type != "twilight/turn/turn_ended" { + t.Fatalf("Turn stream from 2 = %+v, err %v", turnTail.Events, err) } limited, err := f.Store.ReadStream(ctx, session.StreamReadRequest{SessionID: "s", Domain: chatStream(), Inheritance: module.Inherited, Limit: 2}) if err != nil || len(limited.Events) != 2 || !limited.HasMore { @@ -278,8 +278,8 @@ func testAppend(t *testing.T, f Fixture) { {"empty type", ledger.Proposal{CommitID: "c", Batches: []ledger.EventBatch{{Domain: chatStream(), Events: []ledger.Event{{Payload: jsonstable.MustParse(`{}`), RecordedAtUnixMilli: 1}}}}}, session.ErrInvalid}, {"non-object payload", ledger.Proposal{CommitID: "c", Batches: []ledger.EventBatch{batch(chatStream(), "twilight/x/a", `[1]`)}}, session.ErrInvalid}, {"zero payload", ledger.Proposal{CommitID: "c", Batches: []ledger.EventBatch{{Domain: chatStream(), Events: []ledger.Event{{Type: "twilight/x/a", RecordedAtUnixMilli: 1}}}}}, session.ErrInvalid}, - {"empty stream domain", ledger.Proposal{CommitID: "c", Batches: []ledger.EventBatch{batch(ledger.Domain{Id: "r7"}, "twilight/run/a", `{}`)}}, session.ErrInvalid}, - {"stream domain with separator", ledger.Proposal{CommitID: "c", Batches: []ledger.EventBatch{batch(ledger.Domain{Name: "run/r7"}, "twilight/run/a", `{}`)}}, session.ErrInvalid}, + {"empty stream domain", ledger.Proposal{CommitID: "c", Batches: []ledger.EventBatch{batch(ledger.Domain{Id: "r7"}, "twilight/turn/a", `{}`)}}, session.ErrInvalid}, + {"stream domain with separator", ledger.Proposal{CommitID: "c", Batches: []ledger.EventBatch{batch(ledger.Domain{Name: "run/r7"}, "twilight/turn/a", `{}`)}}, session.ErrInvalid}, {"same stream twice", ledger.Proposal{CommitID: "c", Batches: []ledger.EventBatch{batch(chatStream(), "twilight/x/a", `{}`), batch(chatStream(), "twilight/x/b", `{}`)}}, session.ErrInvalid}, } for _, tc := range rejects { @@ -303,7 +303,7 @@ func testAppend(t *testing.T, f Fixture) { // A spanning commit lands every batch or none: both sides are visible. appendCommit(t, w, "c2", batch(chatStream(), "twilight/chat/a", `{}`), - batch(runStream("r9"), "twilight/run/run_created", `{"runId":"r9"}`)) + batch(turnStream("r9"), "twilight/turn/turn_started", `{"turnId":"r9"}`)) page, _ = f.Store.ReadCommits(ctx, session.CommitReadRequest{SessionID: "s", From: 1}) if len(page.Commits) != 1 || len(page.Commits[0].Batches) != 2 { t.Fatalf("spanning commit not whole: %+v", page.Commits) @@ -337,7 +337,7 @@ func testCrashTail(t *testing.T, f Fixture) { }}) appendCommit(t, w, "c2", batch(chatStream(), "twilight/x/c", `{"c":3}`), - batch(runStream("r7"), "twilight/run/run_created", `{"runId":"r7"}`)) + batch(turnStream("r7"), "twilight/turn/turn_started", `{"turnId":"r7"}`)) if err := w.Close(ctx); err != nil { t.Fatalf("close: %v", err) } @@ -381,15 +381,15 @@ func testRead(t *testing.T, f Fixture) { w := open(t, f.Store, "s", false) appendCommit(t, w, "c1", ledger.EventBatch{Domain: chatStream(), Events: []ledger.Event{ - {Type: "twilight/run/a", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{}`)}, + {Type: "twilight/turn/a", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{}`)}, {Type: "twilight/chat/a", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{}`)}, }}, - batch(runStream("r1"), "twilight/run/model_step", `{"runId":"r1"}`)) + batch(turnStream("r1"), "twilight/turn/model_step", `{"turnId":"r1"}`)) appendCommit(t, w, "c2", batch(chatStream(), "twilight/chat/b", `{}`)) appendCommit(t, w, "c3", ledger.EventBatch{Domain: chatStream(), Events: []ledger.Event{ - {Type: "twilight/run/c", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{}`)}, - {Type: "twilight/run/d", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{}`)}, + {Type: "twilight/turn/c", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{}`)}, + {Type: "twilight/turn/d", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{}`)}, {Type: "twilight/chat/e", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{}`)}, }}) all, err := f.Store.ReadCommits(ctx, session.CommitReadRequest{SessionID: "s"}) @@ -438,10 +438,10 @@ func testQuery(t *testing.T, f Fixture) { w := open(t, f.Store, "s", false) first := appendCommit(t, w, "c1", ledger.EventBatch{Domain: chatStream(), Events: []ledger.Event{ - {Type: "twilight/run/a", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{"n":1}`)}, - {Type: "twilight/run/b", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{"n":2}`)}, + {Type: "twilight/turn/a", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{"n":1}`)}, + {Type: "twilight/turn/b", RecordedAtUnixMilli: 1, Payload: jsonstable.MustParse(`{"n":2}`)}, }}) - appendCommit(t, w, "c2", batch(chatStream(), "twilight/run/c", `{"n":3}`)) + appendCommit(t, w, "c2", batch(chatStream(), "twilight/turn/c", `{"n":3}`)) if committed(t, w, "absent") { t.Fatal("an unknown CommitID was reported committed") @@ -477,7 +477,7 @@ func testQuery(t *testing.T, f Fixture) { if got, ok, err := w.LookupCommit("c2"); err != nil || !ok || len(got.Batches) != 1 { t.Fatalf("reopened lookup c2 = %+v, ok=%v, err=%v", got, ok, err) } - last := appendCommit(t, w, "c3", batch(chatStream(), "twilight/run/d", `{"n":4}`)) + last := appendCommit(t, w, "c3", batch(chatStream(), "twilight/turn/d", `{"n":4}`)) if got, ok, err := w.LookupCommit("c3"); err != nil || !ok || got.Seq != last.Seq { t.Fatalf("lookup after append = %+v, ok=%v, err=%v", got, ok, err) } diff --git a/agentcore/session/sessiontest/fork.go b/agentcore/session/sessiontest/fork.go index 463a7e2f..9f2e571f 100644 --- a/agentcore/session/sessiontest/fork.go +++ b/agentcore/session/sessiontest/fork.go @@ -29,8 +29,8 @@ func testFork(t *testing.T, f Fixture) { parent := create(t, store, "parent") pw := open(t, store, "parent", false) c0 := appendCommit(t, pw, "c0", batch(chatStream(), "twilight/x/a", `{"n":0}`)) - c1 := appendCommit(t, pw, "c1", batch(chatStream(), "twilight/x/a", `{"n":1}`), batch(runStream("r1"), "twilight/x/r", `{"n":1}`)) - appendCommit(t, pw, "c2", batch(runStream("r1"), "twilight/x/r", `{"n":2}`)) + c1 := appendCommit(t, pw, "c1", batch(chatStream(), "twilight/x/a", `{"n":1}`), batch(turnStream("r1"), "twilight/x/r", `{"n":1}`)) + appendCommit(t, pw, "c2", batch(turnStream("r1"), "twilight/x/r", `{"n":2}`)) // Rejections write nothing: unknown parent, a commit the parent does not // have, a Session forking itself. @@ -103,7 +103,7 @@ func testFork(t *testing.T, f Fixture) { if _, err := cw.Append(ctx, ledger.Proposal{CommitID: "c0", Batches: []ledger.EventBatch{batch(chatStream(), "twilight/x/a", `{"dup":true}`)}}); !session.IsCode(err, session.ErrConflict) { t.Fatalf("append of an inherited CommitID = %v, want conflict", err) } - c3 := appendCommit(t, cw, "c3", batch(chatStream(), "twilight/x/a", n3Payload), batch(runStream("r1"), "twilight/x/r", n3Payload)) + c3 := appendCommit(t, cw, "c3", batch(chatStream(), "twilight/x/a", n3Payload), batch(turnStream("r1"), "twilight/x/r", n3Payload)) if c3.Seq != c1.Seq+1 { t.Fatalf("first own commit = seq %d, want %d", c3.Seq, c1.Seq+1) } @@ -151,16 +151,16 @@ func testFork(t *testing.T, f Fixture) { if len(sp.Events) != 1 || sp.Events[0].Payload.String() != n3Payload { t.Fatalf("child chat stream from 2 = %+v", sp.Events) } - sp, err = store.ReadStream(ctx, session.StreamReadRequest{SessionID: "child", Domain: runStream("r1"), Inheritance: module.Own}) + sp, err = store.ReadStream(ctx, session.StreamReadRequest{SessionID: "child", Domain: turnStream("r1"), Inheritance: module.Own}) if err != nil || len(sp.Events) != 1 || sp.Events[0].Payload.String() != n3Payload { - t.Fatalf("child run stream = %+v %v, want the child's own event only", sp.Events, err) + t.Fatalf("child Turn stream = %+v %v, want the child's own event only", sp.Events, err) } - sp, err = store.ReadStream(ctx, session.StreamReadRequest{SessionID: "child", Domain: runStream("r1"), Inheritance: module.Inherited}) + sp, err = store.ReadStream(ctx, session.StreamReadRequest{SessionID: "child", Domain: turnStream("r1"), Inheritance: module.Inherited}) if err != nil || len(sp.Events) != 2 || sp.Events[1].Payload.String() != n3Payload { - t.Fatalf("child run stream stitched = %+v %v, want c1 then c3", sp.Events, err) + t.Fatalf("child Turn stream stitched = %+v %v, want c1 then c3", sp.Events, err) } - if sp, _ = store.ReadStream(ctx, session.StreamReadRequest{SessionID: "parent", Domain: runStream("r1"), Inheritance: module.Own}); len(sp.Events) != 2 { - t.Fatalf("parent run stream = %+v, want c1 and c2", sp.Events) + if sp, _ = store.ReadStream(ctx, session.StreamReadRequest{SessionID: "parent", Domain: turnStream("r1"), Inheritance: module.Own}); len(sp.Events) != 2 { + t.Fatalf("parent Turn stream = %+v, want c1 and c2", sp.Events) } // Reopen continues from the edge and keeps the prefix visible. diff --git a/agentcore/session/sessiontest/index.go b/agentcore/session/sessiontest/index.go index a8f1a049..e98aa832 100644 --- a/agentcore/session/sessiontest/index.go +++ b/agentcore/session/sessiontest/index.go @@ -30,8 +30,8 @@ func testIndex(t *testing.T, f Fixture) { create(t, f.Store, "s") w := open(t, f.Store, "s", false) appendCommit(t, w, "c1", batch(chatStream(), "twilight/x/a", `{"a":1}`)) - appendCommit(t, w, "c2", batch(chatStream(), "twilight/x/b", `{"b":2}`), batch(runStream("r7"), "twilight/run/run_created", `{"runId":"r7"}`)) - c3 := appendCommit(t, w, "c3", batch(runStream("r7"), "twilight/run/x", `{"runId":"r7"}`)) + appendCommit(t, w, "c2", batch(chatStream(), "twilight/x/b", `{"b":2}`), batch(turnStream("r7"), "twilight/turn/turn_started", `{"turnId":"r7"}`)) + c3 := appendCommit(t, w, "c3", batch(turnStream("r7"), "twilight/turn/x", `{"turnId":"r7"}`)) want := w.Head() if err := w.Close(ctx); err != nil { t.Fatal(err) @@ -66,8 +66,8 @@ func testIndex(t *testing.T, f Fixture) { if n, ok := w.StreamHead(chatStream()); !ok || n != 2 { t.Fatalf("chat stream head = %d, %v; want 2", n, ok) } - if n, ok := w.StreamHead(runStream("r7")); !ok || n != 2 { - t.Fatalf("run stream head = %d, %v; want 2", n, ok) + if n, ok := w.StreamHead(turnStream("r7")); !ok || n != 2 { + t.Fatalf("Turn stream head = %d, %v; want 2", n, ok) } if c, ok, err := w.LookupCommit("c3"); err != nil || !ok || c.Seq != c3.Seq { t.Fatalf("lookup c3 = %+v, %v, %v", c, ok, err) diff --git a/agentcore/session/unit/unit_test.go b/agentcore/session/unit/unit_test.go index 40b8bec1..3df650aa 100644 --- a/agentcore/session/unit/unit_test.go +++ b/agentcore/session/unit/unit_test.go @@ -5,14 +5,16 @@ import ( "errors" "github.com/felinics/twilight/agentcore/artifact/artifacttest" "github.com/felinics/twilight/agentcore/chatlog" + "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/sessionstore" + "github.com/felinics/twilight/agentcore/preset/ref" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/filestore/filestoretest" "github.com/felinics/twilight/agentcore/session/unit" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/session/writer/writertest" + "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/sessionstore" "testing" ) @@ -39,7 +41,7 @@ func submitted(id chatlog.InputID) unit.Part { return unit.PartFunc(func(_ context.Context, _ writer.View, now int64) ([]writer.TypedBatch, error) { return []writer.TypedBatch{{Domain: chatlog.Stream, Events: []writer.TypedEvent{{ Type: chatlog.TypeInputSubmitted, RecordedAtUnixMilli: now, - Value: chatlog.InputSubmittedPayload{InputID: id, Content: run.MustParseCanonicalJSON(`{"text":"x"}`), SubmittedAtUnixMilli: now}}}}}, nil + Value: chatlog.InputSubmittedPayload{InputID: id, Content: turn.MustParseCanonicalJSON(`{"text":"x"}`), SubmittedAtUnixMilli: now}}}}}, nil }) } @@ -50,7 +52,7 @@ func submitted(id chatlog.InputID) unit.Part { func TestCommitMergesRefusesAndReplays(t *testing.T) { ctx := context.Background() w := newWriter(t) - newRun, err := run.BuildNewRun("r1", "") + newTurn, err := turn.BuildNewTurn("r1", "", ref.Ref{ID: "test", Digest: "sha256:test"}) if err != nil { t.Fatal(err) } @@ -59,7 +61,7 @@ func TestCommitMergesRefusesAndReplays(t *testing.T) { prepared++ return submitted("b").Prepare(ctx, v, now) }) - work := unit.Work{CommitID: "u1", Parts: []unit.Part{submitted("a"), sessionstore.CreateRun(newRun, nil), counting}} + work := unit.Work{CommitID: "u1", Parts: []unit.Part{submitted("a"), sessionstore.CreateTurn(newTurn, nil), counting}} res, err := unit.Commit(ctx, w, 7, work) if err != nil || res.Outcome != writer.CommitApplied { t.Fatalf("commit = %+v %v", res, err) @@ -85,7 +87,7 @@ func TestCommitMergesRefusesAndReplays(t *testing.T) { t.Fatalf("refused unit committed = %v %v", ok, err) } if _, ok := v.StreamHead(sessionstore.Stream("r1")); !ok { - t.Fatal("run stream not indexed") + t.Fatal("Turn stream not indexed") } return nil, nil }) @@ -117,7 +119,7 @@ func TestCommitReplaysByCommitID(t *testing.T) { // A CommitID written by a plain writer.Commit is a replay for a unit too. if _, err := w.Commit(ctx, func(writer.View) (*writer.SemanticGroup, error) { return &writer.SemanticGroup{CommitID: "plain", Batches: []writer.TypedBatch{{Domain: chatlog.Stream, Events: []writer.TypedEvent{{ - Type: chatlog.TypeInputSubmitted, Value: chatlog.InputSubmittedPayload{InputID: "p", Content: run.MustParseCanonicalJSON(`{"text":"x"}`)}}}}}}, nil + Type: chatlog.TypeInputSubmitted, Value: chatlog.InputSubmittedPayload{InputID: "p", Content: turn.MustParseCanonicalJSON(`{"text":"x"}`)}}}}}}, nil }); err != nil { t.Fatal(err) } @@ -125,3 +127,31 @@ func TestCommitReplaysByCommitID(t *testing.T) { t.Fatalf("replay of a plain commit = %+v %v, want already applied", none, err) } } + +func TestCommitReplayCheckRejectsWithoutPreparingParts(t *testing.T) { + ctx := context.Background() + w := newWriter(t) + original, err := unit.Commit(ctx, w, 1, unit.Work{CommitID: "checked", Parts: []unit.Part{submitted("a")}}) + if err != nil { + t.Fatal(err) + } + conflict := errors.New("changed operation content") + checks := 0 + work := unit.Work{CommitID: "checked", ReplayCheck: func(commit ledger.Commit) error { + checks++ + if commit.CommitID != original.Commit.CommitID || commit.Seq != original.Commit.Seq { + t.Fatalf("replay commit = %+v", commit) + } + return conflict + }, Parts: []unit.Part{unit.PartFunc(func(context.Context, writer.View, int64) ([]writer.TypedBatch, error) { + t.Fatal("replay prepared a part") + return nil, nil + })}} + if _, err := unit.Commit(ctx, w, 2, work); !errors.Is(err, conflict) || checks != 1 { + t.Fatalf("replay err=%v checks=%d", err, checks) + } + again, err := unit.Commit(ctx, w, 3, unit.Work{CommitID: "checked"}) + if err != nil || again.Outcome != writer.CommitAlreadyApplied || again.Commit.Seq != original.Commit.Seq { + t.Fatalf("original changed: %+v %v", again, err) + } +} diff --git a/agentcore/sessionkernel/coordinator_test.go b/agentcore/sessionkernel/coordinator_test.go index 2d814ea8..5494f323 100644 --- a/agentcore/sessionkernel/coordinator_test.go +++ b/agentcore/sessionkernel/coordinator_test.go @@ -7,25 +7,24 @@ import ( "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" "github.com/felinics/twilight/agentcore/preset" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/sessionstore" - "github.com/felinics/twilight/agentcore/run/sessionstore/sessionstoretest" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/filestore/filestoretest" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/session/writer/writertest" "github.com/felinics/twilight/agentcore/sessionkernel" "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/sessionstore" + "github.com/felinics/twilight/agentcore/turn/sessionstore/sessionstoretest" "testing" ) // The Coordinator is pure protocol: Start, Deliver and Status commit and read -// without any driver or Loop in the assembly. The Run stays Open until a +// without any driver or Loop in the assembly. The Turn stays Open until a // host drives it. func TestCoordinatorCommitsWithoutDriver(t *testing.T) { ctx := context.Background() const sid session.SessionID = "s-protocol" - registry, err := module.BuildRegistry(chatlog.Module, sessionstore.Module, turn.Module) + registry, err := module.BuildRegistry(chatlog.Module, sessionstore.Module) if err != nil { t.Fatal(err) } @@ -35,19 +34,19 @@ func TestCoordinatorCommitsWithoutDriver(t *testing.T) { } bindings, retention := artifacttest.Stores(t) factory := &writer.Factory{Store: store, Registry: registry, Admission: writer.Admission{Bindings: bindings, Ledger: retention}} - runs, err := sessionstore.NewSessionRunStore(sessionstore.Config{Registry: registry, Store: store, Frozen: sessionstoretest.Frozen(t, bindings)}) + turns, err := sessionstore.NewSessionTurnStore(sessionstore.Config{Registry: registry, Store: store, Frozen: sessionstoretest.Frozen(t, bindings)}) if err != nil { t.Fatal(err) } - c := &sessionkernel.Coordinator{Projections: session.NewProjectionReader(store, registry, nil), Runs: runs} + c := &sessionkernel.Coordinator{Projections: session.NewProjectionReader(store, registry, nil), TurnStore: turns} w, err := writertest.Open(t, ctx, factory, sid, session.OpenOptions{}) if err != nil { t.Fatal(err) } - submit := func(id chatlog.InputID) run.AgentInput { + submit := func(id chatlog.InputID) turn.AgentInput { t.Helper() - content := run.MustParseCanonicalJSON(`{"text":"hi"}`) + content := turn.MustParseCanonicalJSON(`{"text":"hi"}`) if _, err := w.Commit(ctx, func(writer.View) (*writer.SemanticGroup, error) { return &writer.SemanticGroup{CommitID: ledger.CommitID("submit/" + string(id)), Batches: []writer.TypedBatch{{Domain: chatlog.Stream, Events: []writer.TypedEvent{{ @@ -61,30 +60,30 @@ func TestCoordinatorCommitsWithoutDriver(t *testing.T) { if err != nil { t.Fatal(err) } - return run.AgentInput{ID: run.InputID(id), Digest: d} + return turn.AgentInput{ID: turn.InputID(id), Digest: d} } - ref := turn.TurnRef{SessionID: sid, TurnID: "t1"} + ref := sessionstore.TurnRef{SessionID: sid, TurnID: "t1"} p := preset.PresetRef{ID: "p1", Digest: "sha256:p1"} - start := sessionkernel.StartRequest{Ref: ref, Inputs: []run.AgentInput{submit("in-1")}, Preset: p} + start := sessionkernel.StartRequest{Ref: ref, Inputs: []turn.AgentInput{submit("in-1")}, Preset: p} resp, err := c.Start(ctx, w, start) if err != nil { t.Fatalf("start: %v", err) } - if resp.Status != turn.TurnActive || resp.RunID == "" || resp.Disposition != "" { + if resp.Status != turn.TurnActive || resp.Ref.TurnID == "" || resp.Disposition != "" { t.Fatalf("start response = %+v, want active with no disposition", resp) } - if again, err := c.Start(ctx, w, start); err != nil || again.RunID != resp.RunID { + if again, err := c.Start(ctx, w, start); err != nil || again.Ref.TurnID != resp.Ref.TurnID { t.Fatalf("start replay = %+v %v", again, err) } - if _, err := c.Deliver(ctx, w, sessionkernel.DeliverRequest{Ref: ref, Inputs: []run.AgentInput{submit("in-2")}}); err != nil { + if _, err := c.Deliver(ctx, w, sessionkernel.DeliverRequest{Ref: ref, Inputs: []turn.AgentInput{submit("in-2")}}); err != nil { t.Fatalf("deliver: %v", err) } status, err := c.Status(ctx, ref) - if err != nil || status.Status != turn.TurnActive || status.RunID != resp.RunID { + if err != nil || status.Status != turn.TurnActive || status.Ref.TurnID != resp.Ref.TurnID { t.Fatalf("status = %+v %v", status, err) } - snap, err := runs.Bind(w).Load(ctx, resp.RunID) + snap, err := turns.Bind(w).Load(ctx, resp.Ref.TurnID) if err != nil || snap.State.Status.Terminal() { t.Fatalf("run advanced without a driver: %+v %v", snap.State.Status, err) } diff --git a/agentcore/sessionkernel/lifecycle_test.go b/agentcore/sessionkernel/lifecycle_test.go index 1f157259..668e390b 100644 --- a/agentcore/sessionkernel/lifecycle_test.go +++ b/agentcore/sessionkernel/lifecycle_test.go @@ -5,11 +5,11 @@ import ( "testing" "github.com/felinics/twilight/agentcore/artifact/artifacttest" - "github.com/felinics/twilight/agentcore/run/sessionstore" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/filestore/filestoretest" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/sessionkernel" + "github.com/felinics/twilight/agentcore/turn/sessionstore" ) func TestKernelCloseDoesNotCloseBoundWriterOrLease(t *testing.T) { diff --git a/agentcore/sessionkernel/quiescence_test.go b/agentcore/sessionkernel/quiescence_test.go index 58087f3e..5e70dda4 100644 --- a/agentcore/sessionkernel/quiescence_test.go +++ b/agentcore/sessionkernel/quiescence_test.go @@ -4,10 +4,9 @@ import ( "errors" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/sessionstore" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/turn" + "github.com/felinics/twilight/agentcore/turn/sessionstore" "strings" "testing" ) @@ -36,34 +35,34 @@ func (v guardView) Projection(id module.ProjectionID, _ module.ProjectionVersion return v.state, v.err } -func activeSurface(runID run.RunID) turn.TurnSurface { - return turn.TurnSurface{Order: []turn.TurnID{"t1"}, Turns: map[turn.TurnID]turn.TurnView{"t1": {TurnID: "t1", Status: turn.TurnActive, RunID: runID}}} +func activeSurface(turnID turn.TurnID) sessionstore.TurnSurface { + return sessionstore.TurnSurface{Order: []turn.TurnID{turnID}, Turns: map[turn.TurnID]sessionstore.TurnView{turnID: {TurnID: turnID, Status: turn.TurnActive}}} } -func machineWith(current run.Current) sessionstore.Machine { - return sessionstore.Machine{Active: map[run.RunID]run.MachineState{"r1": {RunID: "r1", Status: run.RunActive, Current: current}}} +func machineWith(current turn.Current) sessionstore.Machine { + return sessionstore.Machine{Active: map[turn.TurnID]turn.MachineState{"r1": {TurnID: "r1", Status: turn.TurnActive, Current: current}}} } // The quiescent guard admits a Session between Turns and a Turn between // steps, and refuses one inside a step. -func TestRequireQuiescentRun(t *testing.T) { - executing := run.ToolStep{Calls: []run.ToolCallState{{CallID: "c1", Status: run.ToolExecuting}}} - pending := run.ToolStep{Calls: []run.ToolCallState{{CallID: "c1", Status: run.ToolPending}, {CallID: "c2", Status: run.ToolWaiting}}} +func TestRequireQuiescentTurn(t *testing.T) { + executing := turn.ToolStep{Calls: []turn.ToolCallState{{CallID: "c1", Status: turn.ToolExecuting}}} + pending := turn.ToolStep{Calls: []turn.ToolCallState{{CallID: "c1", Status: turn.ToolPending}, {CallID: "c2", Status: turn.ToolWaiting}}} cases := map[string]struct { view guardView ok bool }{ - "no turns": {view: guardView{state: turn.TurnSurface{}}, ok: true}, + "no turns": {view: guardView{state: sessionstore.TurnSurface{}}, ok: true}, "completed turn": {view: guardView{state: surfaceWith(turn.TurnCompleted)}, ok: true}, - "active turn, run open": {view: guardView{state: activeSurface("r1"), machine: machineWith(run.Open{})}, ok: true}, + "active turn, run open": {view: guardView{state: activeSurface("r1"), machine: machineWith(turn.Open{})}, ok: true}, "active turn, no live run": {view: guardView{state: activeSurface("r1"), machine: sessionstore.Machine{}}, ok: true}, "tool step without executing calls": {view: guardView{state: activeSurface("r1"), machine: machineWith(pending)}, ok: true}, "tool step with an executing call": {view: guardView{state: activeSurface("r1"), machine: machineWith(executing)}}, - "model step prepared": {view: guardView{state: activeSurface("r1"), machine: machineWith(run.ModelStep{Status: run.ModelPrepared})}}, - "model step executing": {view: guardView{state: activeSurface("r1"), machine: machineWith(run.ModelStep{Status: run.ModelExecuting})}}, + "model step prepared": {view: guardView{state: activeSurface("r1"), machine: machineWith(turn.ModelStep{Status: turn.ModelPrepared})}}, + "model step executing": {view: guardView{state: activeSurface("r1"), machine: machineWith(turn.ModelStep{Status: turn.ModelExecuting})}}, } for name, tc := range cases { - err := RequireQuiescentRun(tc.view) + err := RequireQuiescentTurn(tc.view) if tc.ok && err != nil { t.Errorf("%s: err = %v, want nil", name, err) } @@ -73,8 +72,8 @@ func TestRequireQuiescentRun(t *testing.T) { } } -func surfaceWith(status turn.TurnStatus) turn.TurnSurface { - return turn.TurnSurface{Order: []turn.TurnID{"t1"}, Turns: map[turn.TurnID]turn.TurnView{"t1": {TurnID: "t1", Status: status}}} +func surfaceWith(status turn.TurnStatus) sessionstore.TurnSurface { + return sessionstore.TurnSurface{Order: []turn.TurnID{"t1"}, Turns: map[turn.TurnID]sessionstore.TurnView{"t1": {TurnID: "t1", Status: status}}} } func TestRequireNoActiveTurn(t *testing.T) { @@ -84,7 +83,7 @@ func TestRequireNoActiveTurn(t *testing.T) { wantErr error // errors.Is mention string // substring of the error }{ - "no turns": {view: guardView{state: turn.TurnSurface{}}}, + "no turns": {view: guardView{state: sessionstore.TurnSurface{}}}, "completed turn": {view: guardView{state: surfaceWith(turn.TurnCompleted)}}, "active turn": {view: guardView{state: surfaceWith(turn.TurnActive)}, wantErr: turn.ErrConflict, mention: "t1"}, "projection failed": {view: guardView{err: boom}, wantErr: boom}, From c97b1d44c6201d3fba64a16856aba839b46fad1e Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 17 Oct 2026 20:59:34 +0800 Subject: [PATCH 4/5] docs(architecture): describe unified Turn model --- docs/architecture/core.md | 95 +++++++++++++++++++++----------------- docs/architecture/index.md | 4 +- 2 files changed, 54 insertions(+), 45 deletions(-) diff --git a/docs/architecture/core.md b/docs/architecture/core.md index 90fa4b83..3b638fe9 100644 --- a/docs/architecture/core.md +++ b/docs/architecture/core.md @@ -1,17 +1,17 @@ # Agent Core Agent Core defines Twilight's durable execution model. It owns session history, -run state, effect lifecycle, persistence, fencing, and recovery semantics. +turn state, effect lifecycle, persistence, fencing, and recovery semantics. ## Semantic flow -An Input is committed to a Session's chatlog. A Turn associates the input -with a preset and a Run. +An Input is committed to a Session's chatlog. A Turn accepts submitted inputs +under an immutable preset and owns the complete agent loop. -The Run loop reads the current Run state, derives the next command, and commits +The Turn loop reads the current Turn state, derives the next command, and commits its protocol transition. When the transition requires external work, the execution boundary handles that work and returns an Outcome. The loop commits -the outcome and continues the Run. +the outcome and continues the Turn. Session projections expose the resulting state without requiring a write capability. @@ -44,7 +44,7 @@ and applies projection updates. It neither acquires nor renews ownership; closing a Writer flushes local state without releasing the handle. The read path uses projections and query surfaces. Reads can inspect session, -turn, run, and chatlog state without acquiring a write capability. +turn and chatlog state without acquiring a write capability. CQRS is a logical separation in Agent Core. The write and read paths may share an implementation while retaining independent responsibilities. @@ -75,47 +75,49 @@ history unless their protocol commits them as facts. ### Session A Session is the durable boundary of one interaction. Its history contains the -inputs, turns, runs, compaction operations, and other module-owned facts +inputs, turns, compaction operations, and other module-owned facts associated with that interaction. `writer.Factory.Bind` builds a Writer over an acquired Session handle. Reads use projections and do not require ownership. A Writer commits under -its handle's epoch. Session identity, Run state, and recorded effects remain +its handle's epoch. Session identity, Turn state, and recorded effects remain durable independently of any open handle. ### Input -An Input is a durable entry in the session chatlog. A Turn references the input -IDs used to construct its request. +An Input is a durable entry in the session chatlog. Its lifecycle separates +submission, acceptance, and consumption: -### Turn - -A Turn is one user-visible unit of progress. It associates inputs with a -preset and a Run. The Turn surface combines Turn facts with the state of its -Run and settles as completed, failed, stopped, or superseded. +1. Chatlog records the content as `InputSubmitted`. +2. The Turn records `InputAccepted` with the input ID and content digest. +3. `ModelStepPrepared` records which accepted inputs the request consumes. -The Turn module owns Turn lifecycle facts. The Run module owns Run lifecycle -facts. +Start and delivery validate the submitted content and accept the inputs within +one Writer transaction. Replaying a delivery with the same IDs but different +content is a conflict. Chatlog derives its delivered status from `InputAccepted`; +there is no separate durable delivery event. -### Run +### Turn -A Run is the logical execution history of a Turn. It is a versioned state -machine with stable identities and deterministic transitions. +A Turn is one logical agent execution with a stable TurnID. It owns its +immutable preset reference, accepted inputs, logical steps, effect lifecycle, +recovery state, and terminal result. Its complete history is recorded as Turn +facts and folded by the deterministic `Decide/Evolve` state machine. -A Run records: +A Turn starts once with `TurnStarted` and ends once with `TurnEnded`, whose +result is completed, failed, or stopped. The conversation-facing Turn surface +and the execution machine are projections of these same authoritative facts. -- the active logical step; -- model and tool effects; -- effect requests and settlements; -- recovery state; -- the terminal Run state. +Process restart, Session takeover, Worker replacement, and execution recovery +continue the same Turn. They do not create another logical execution identity. +Physical attempts belong to the executor's Execution model. -Run identity and canonicalization rules are bound to the schema version that -created the Run. Replays use those rules. +Turn identity and canonicalization rules are bound to the schema version that +created the Turn. Replays use those rules. ### Step -A Step represents logical progress inside a Run. The current implementation +A Step represents logical progress inside a Turn. The current implementation uses model steps and tool steps. A Step may produce one or more physical execution attempts when its effect is @@ -125,7 +127,7 @@ recovered and replay is permitted. An Effect is a request for external work. Model calls and tool calls are represented as effects. The owning protocol assigns each effect a stable -identity and records its lifecycle: a Run for conversation steps, or a Session +identity and records its lifecycle: a Turn for conversation steps, or a Session compaction operation for a context-maintenance summary. ### Execution @@ -147,8 +149,8 @@ observation. An interrupted request therefore keeps its settlement unresolved. ### Wait -A Wait records an external response required by a tool call. The Run remains -active while waiting. The response is committed before the Run continues. +A Wait records an external response required by a tool call. The Turn remains +active while waiting. The response is committed before the Turn continues. ### Artifact @@ -156,42 +158,49 @@ Facts reference large or immutable bodies through artifact identities. The content store retains those bodies, and the binding and retention stores keep the references valid across restarts and forks. -## Run loop +## Turn loop -`agentcore/run/loop` is the Run decision interpreter. The RunStore remains the -source of authority. Each operation loads the Run, derives the next plan, +`agentcore/turn/loop` is the Turn decision interpreter. The TurnStore remains the +source of authority. Each operation loads the Turn, derives the next plan, commits protocol transitions, and reloads state when another actor has advanced it. The finite protocol operations are: -- `Advance` interprets the Run until it dispatches one or more assignments or +- `Advance` interprets the Turn until it dispatches one or more assignments or reaches a waiting or finished state. It prepares and commits protocol transitions, then returns assignment keys before outcome retrieval. - `Deliver` accepts one available Outcome, verifies that the named effect is still executing, commits the settlement, and reports the next action. A stale outcome is dropped. -`Run` is a synchronous composition of these same operations: Advance, wait for -Outcomes, Deliver, and repeat. It does not define another execution protocol. +A blocking loop drive is a synchronous composition of these same operations: +Advance, wait for Outcomes, Deliver, and repeat. It does not define another +execution protocol. A tool step can have several pending effects, subject to its execution policy. -The Loop serializes operations for one Run and permits different Runs to +The Loop serializes operations for one Turn and permits different Turns to advance concurrently. Its EventSink carries committed facts and provisional model or tool observations. The ledger remains authoritative for committed state. -Context cancellation interrupts the operation; it is not a Run cancellation. -Domain cancellation is recorded as Run facts. Reconciliation uses those +Context cancellation interrupts the operation; it is not a Turn cancellation. +Domain cancellation is recorded as Turn facts. Reconciliation uses those persisted dispositions to cancel the corresponding executions. Interrupted settlement leaves the Outcome available for delivery under the same identity. Acknowledgement requires both an effect-naming durable disposition and a -terminal Execution. A terminal Run or dropped delivery alone is not proof +terminal Execution. A terminal Turn or dropped delivery alone is not proof that an Outcome can be collected. ## Execution boundary +Every assignment is addressed by `(Session, OwnerKind, OwnerID, EffectID)`. +`OwnerKind` separates Turn work from compaction work, even when their IDs +coincide. Turn assignments use the TurnID as OwnerID; compaction assignments +use the compaction operation ID. StepID and CallID describe the requested work +and travel with the assignment rather than forming another owner identity. + The executor uses the following lifecycle: ```text @@ -254,7 +263,7 @@ The Session Kernel composes the durable semantic services: - the ledger and commit observers; - the module registry; - projections and projection caches; -- the Run adapter; +- the Turn store adapter; - Turn coordination; - chatlog commands; - artifacts, frozen content, and session history. diff --git a/docs/architecture/index.md b/docs/architecture/index.md index 7172e103..41110daf 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -5,7 +5,7 @@ Twilight is a carefully engineered, highly customizable agent built on a deploym ## Goal -Building an agent involves more than invoking models and tools. A practical agent needs a consistent execution model for sessions, runs, effects, storage, recovery, and interaction with external systems. +Building an agent involves more than invoking models and tools. A practical agent needs a consistent execution model for sessions, turns, effects, storage, recovery, and interaction with external systems. These concerns are often tightly coupled to a particular runtime or deployment environment, causing local and cloud agents to diverge into separate implementations. @@ -31,7 +31,7 @@ This gives the execution model a durable history and makes recovery, replay, ins The core model is expressed through a small set of abstractions: - **Session** — the long-lived semantic boundary of an agent interaction. -- **Run** — the state machine that describes the progression of an agent execution. +- **Turn** — one logical agent execution, owning its preset, inputs, state machine, and event history. - **Effect** — an explicit description of work that must be performed outside the semantic state machine. - **Execution** — the lifecycle and physical execution of an effect. - **Outcome** — the observed result of an execution returned to the semantic layer. From 3f00a98bb591e8ef7219ab7cddde7b411c5f38e6 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Sat, 17 Oct 2026 21:56:34 +0800 Subject: [PATCH 5/5] fix(agentcore): satisfy CI lint checks --- agentcore/runtime/runtimetest/conformance.go | 19 ++++++++++++------- agentcore/turn/sessionstore/identity.go | 5 ++++- .../sessionstore/storetest/conformance.go | 4 ++-- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/agentcore/runtime/runtimetest/conformance.go b/agentcore/runtime/runtimetest/conformance.go index 60dd8734..fa111c9a 100644 --- a/agentcore/runtime/runtimetest/conformance.go +++ b/agentcore/runtime/runtimetest/conformance.go @@ -34,9 +34,10 @@ func Run(t *testing.T, factory Factory) { } const ( - typeCreated = sessionstore.Prefix + "turn_started" - typeAccepted = sessionstore.Prefix + "input_accepted" - typeEnded = sessionstore.Prefix + "turn_ended" + typeCreated = sessionstore.Prefix + "turn_started" + typeAccepted = sessionstore.Prefix + "input_accepted" + typeEnded = sessionstore.Prefix + "turn_ended" + changedDigest = "sha256:changed" ) // --- Start ----------------------------- @@ -48,7 +49,7 @@ func testStart(t *testing.T, factory Factory) { // every rejection leaves the ledger untouched. altered := input("in-1") - altered.Digest = "sha256:changed" + altered.Digest = changedDigest rejects := []struct { name string req sessionkernel.StartRequest @@ -117,7 +118,7 @@ func testStart(t *testing.T, factory Factory) { changedPreset := h.startRequest("t1", submitted...) changedPreset.Preset.ID = "another-preset" changedInputs := append([]turn.AgentInput(nil), submitted...) - changedInputs[0].Digest = "sha256:changed" + changedInputs[0].Digest = changedDigest for _, req := range []sessionkernel.StartRequest{changedPreset, h.startRequest("t1", changedInputs...)} { if _, err := h.c.Start(h.ctx, h.writer(), req); !errors.Is(err, turn.ErrConflict) || h.head() != after { t.Fatalf("changed start replay: err=%v moved=%v", err, h.head() != after) @@ -176,7 +177,7 @@ func testDeliver(t *testing.T, factory Factory) { } changed := append([]turn.AgentInput(nil), in2...) - changed[0].Digest = "sha256:changed" + changed[0].Digest = changedDigest if _, err := h.c.Deliver(h.ctx, h.writer(), sessionkernel.DeliverRequest{Ref: h.ref("t1"), Inputs: changed}); !errors.Is(err, turn.ErrConflict) || h.head() != head { t.Fatalf("changed delivery replay: err=%v moved=%v", err, h.head() != head) } @@ -276,7 +277,11 @@ func testStop(t *testing.T, factory Factory) { if !sameTypes(group, typeEnded) { t.Fatalf("stop facts = %v", eventTypes(group)) } - ended := decode[sessionstore.Event](t, h.registry, &group[0]).Fact.(turn.TurnEnded) + decoded := decode[sessionstore.Event](t, h.registry, &group[0]) + ended, ok := decoded.Fact.(turn.TurnEnded) + if !ok { + t.Fatalf("stop event = %T, want turn.TurnEnded", decoded.Fact) + } stopped, ok := ended.End.(turn.TurnStoppedEnd) if !ok || stopped.Message != "user" { t.Fatalf("stop outcome = %+v", ended) diff --git a/agentcore/turn/sessionstore/identity.go b/agentcore/turn/sessionstore/identity.go index 60e17255..cffd1c56 100644 --- a/agentcore/turn/sessionstore/identity.go +++ b/agentcore/turn/sessionstore/identity.go @@ -14,7 +14,10 @@ func digestOf(domain string, parts ...string) jsonstable.Digest { // PlanDigest identifies the immutable Turn start intent. func PlanDigest(id turn.TurnID, preset ref.Ref, inputs []turn.AgentInput) jsonstable.Digest { - parts := []string{string(id), string(preset.ID), string(preset.Digest)} + parts := make([]string, 3, 3+2*len(inputs)) + parts[0] = string(id) + parts[1] = string(preset.ID) + parts[2] = string(preset.Digest) for _, input := range inputs { parts = append(parts, string(input.ID), string(input.Digest)) } diff --git a/agentcore/turn/sessionstore/storetest/conformance.go b/agentcore/turn/sessionstore/storetest/conformance.go index ea39881c..a347a98f 100644 --- a/agentcore/turn/sessionstore/storetest/conformance.go +++ b/agentcore/turn/sessionstore/storetest/conformance.go @@ -408,8 +408,8 @@ func asStrings(types []ledger.EventType) []string { func testAdmission(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) h.startTurn("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) + artifactRef := artifact.Ref{Scheme: "cas", Authority: "local", Key: "k1", Durability: artifact.EventBound, Integrity: &artifact.Integrity{Algorithm: "sha256", Value: "x"}} + binding, err := artifact.NewBinding("b1", artifactRef) if err != nil { t.Fatal(err) }