From edc3461c7703a037a5927c84dc8d141bbb3a1df9 Mon Sep 17 00:00:00 2001 From: sunrioa Date: Wed, 22 Jul 2026 16:45:37 +0800 Subject: [PATCH 1/5] docs: plan living worlds runtime --- ROADMAP.md | 3 + docs/living-worlds-v0.5-plan.md | 316 ++++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 docs/living-worlds-v0.5-plan.md diff --git a/ROADMAP.md b/ROADMAP.md index bb3151a..3f05534 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -45,4 +45,7 @@ - [ ] 多 Agent 批处理与区域休眠 - [ ] 人工调试时间线和决定回放工具 +详细协议、兼容策略、阶段提交与验收矩阵见 +[`docs/living-worlds-v0.5-plan.md`](docs/living-worlds-v0.5-plan.md)。 + 每个阶段继续保持一个原则:模型可以提出意图和表达,游戏引擎决定现实发生了什么。 diff --git a/docs/living-worlds-v0.5-plan.md b/docs/living-worlds-v0.5-plan.md new file mode 100644 index 0000000..aa67502 --- /dev/null +++ b/docs/living-worlds-v0.5-plan.md @@ -0,0 +1,316 @@ +# Rin v0.5 Living Worlds Implementation Plan + +Status: approved implementation baseline + +## 1. Objective + +Rin v0.5 turns the current single-character-compatible runtime into a small, +engine-neutral living-world foundation without giving a model authority over +the game world. The release must support long-running character memory, +conflicting private knowledge, bounded autonomous goals, region-aware actor +scheduling, multi-actor arbitration, and inspectable replay. + +The invariant remains: + +```text +model or deterministic policy -> proposal +game rules -> apply or reject +Rin -> record the observed result +``` + +The first production consumer remains `ai-galgame`, but every new contract is +defined in engine-neutral terms and covered by Go tests before a game adapter +uses it. + +## 2. Constraints + +- Keep `rin.protocol/v1`; additions are optional fields and new endpoints. +- Existing create/observe/propose/commit/snapshot requests remain valid. +- New state fields use `omitempty` so old snapshot hashes can still verify. +- Living-world behavior is enabled through session feature flags. Old sessions + retain v0.4 retention and scheduling behavior. +- The core continues to use only the Go standard library and remains CGO-free. +- The model never invents executable actions, targets, goals, files, tools, or + game-state mutations outside a game-supplied contract. +- Player text, prompt text, provider responses, and credentials are not added + to operational logs or error messages. +- Game rendering, navigation, physics, combat, inventory, quests, consent, + purchases, and canonical story state remain engine-owned. + +## 3. Feature Negotiation + +`CreateSessionRequest.features` accepts a bounded set of identifiers: + +| Feature | Purpose | +| --- | --- | +| `memory-archive-v1` | Deterministic episodic compaction and summary recall | +| `belief-conflicts-v1` | Preserve contradictory actor-local claims | +| `goal-candidates-v1` | Allow a policy to select a bounded candidate goal | +| `actor-activity-v1` | Persist region and dormant/awake actor activity | +| `arbitration-v1` | Record deterministic multi-proposal arbitration | + +Unknown feature identifiers fail session creation. `/health` advertises the +supported list so adapters can fail closed or omit unsupported features. + +The current `ai-galgame` integration enables memory archives and belief +conflicts. It does not enable autonomous goal candidates or arbitration until +the content pack supplies explicit candidates and multi-actor scenes. + +## 4. Memory Model + +### 4.1 Episodic memory + +`ActorState.memories` remains the recent, quote-capable event stream. Existing +retrieval scoring by importance, recency, tags, quotes, and recall count stays +available. + +When `memory-archive-v1` is enabled and the episodic limit is exceeded: + +1. Select a deterministic low-salience batch from the older half of memory. +2. Preserve importance-five events until no lower-salience candidate remains. +3. Create a level-one `MemorySummary` containing a bounded joined summary, + unioned tags, source event IDs, tick range, importance, and compaction reason. +4. Remove only the source episodes represented by that summary. +5. When summary capacity is exceeded, merge the oldest summaries into a higher + level instead of silently deleting them. + +Summary identifiers are content hashes. Replay therefore produces the same +archive independent of map iteration order or wall-clock time. + +`MemorySummary.reason` explains why detail was compacted. Source event IDs and +tick ranges let a developer trace what was retained without storing unbounded +verbatim text. Policy retrieval may return episodic or summary IDs; accepted +commits update recall counters on either type. + +### 4.2 Compatibility + +Sessions without `memory-archive-v1` continue to retain the newest 128 episodic +memories exactly as v0.4 did. Old event logs therefore keep their historical +replay semantics unless a newly created session explicitly opts in. + +## 5. Actor-Local Knowledge + +Observation visibility remains the primary privacy boundary: only actors in +`observer_ids`, and in a fact's optional visibility list, receive a memory or +claim. + +With `belief-conflicts-v1`, each `(subject_id, predicate)` stores a bounded +`BeliefSet`: + +- all distinct recent claims and their source event IDs; +- confidence and observed revision; +- the currently selected claim; +- an explicit `conflicted` flag when distinct objects coexist. + +The existing `ActorState.beliefs` map remains as a compatibility projection of +the selected claim. Selection is deterministic: higher confidence wins, then +newer revision, then lexical object order. Rin does not silently convert a +rumor into world truth and never copies one actor's claims to another actor. + +Model prompts receive only the requesting actor's bounded selected beliefs and +conflict summaries. No global omniscient state is introduced. + +## 6. Bounded Autonomous Goals + +`ProposeRequest.candidate_goals` may contain zero or more complete `Goal` +templates. A policy may reference: + +- an existing active actor goal; or +- one candidate goal supplied for this request. + +When a candidate is selected, the resulting `ActionProposal.proposed_goal` +embeds the exact template. The goal is not part of actor state until the game +accepts the associated action commit. Rejected or stale proposals never create +goals. + +This supports character initiative while preserving authority. A game can +offer goals such as "ask about the damaged camera" or "finish the bridge +repair", but a model cannot create a purchase, romance escalation, quest, +target, or irreversible objective the game did not advertise. + +## 7. World Revision and Multi-Actor Arbitration + +### 7.1 World revision + +Event-log revision changes for every persisted event, including proposals. +Multi-actor work also needs a revision that changes only when observable world +state changes. `SessionState.world_revision` is therefore introduced: + +- increments on create, observe, accepted/rejected commit, actor activity, and + restore; +- does not increment merely because another actor created a proposal or an + arbitration record; +- is copied into each new proposal. + +This lets several actors propose against one stable world state. A normal +single commit remains valid after unrelated proposals, but becomes stale after +an observation, activity change, restore, or another committed outcome. + +### 7.2 Arbitration + +`POST /v1/world/arbitrate` receives pending proposal IDs and a bounded set of +exclusive target IDs. Rin ranks proposals deterministically by active-goal +priority, proposal tick, actor ID, and proposal ID. It returns: + +- `selected`: no higher-ranked proposal claimed the same exclusive target; +- `deferred`: an earlier winner claimed at least one target; +- a player-readable reason and conflicting proposal IDs. + +Arbitration is advisory and persisted for debugging. It does not execute an +action or resolve a proposal. + +`POST /v1/action/commit-batch` records outcomes for proposals produced against +the same world revision in one atomic event. The game must apply all selected +actions through its own systems before committing. If any item is invalid or +stale, the entire batch is rejected. + +## 8. Region Activity and Scheduling + +`POST /v1/session/activity` persists bounded actor updates: + +- actor ID; +- region ID; +- `awake` or `dormant` state; +- game-authored reason and tick. + +Dormant actors are excluded from `/v1/scheduler/due` and cannot propose until +the game wakes them. `DueAgentsRequest.region_ids` optionally restricts the +query to currently loaded regions. Empty region filters preserve current +behavior. + +Games update activity on region load/unload or simulation schedule changes, +not on render frames. Crowds can continue to use deterministic policy while +named nearby actors use model policy. + +## 9. Timeline and Replay + +Two read-only operations support debugging: + +- `/v1/session/timeline`: bounded event headers and safe structural metadata; +- `/v1/session/replay`: reconstruct and validate state at a requested revision. + +Timeline responses omit observation summaries, quotes, prompt text, provider +content, tokens, and credentials. Replay returns protocol state and may expose +story data already present in that authenticated session, so remote endpoints +must use the existing bearer-token boundary. + +`rin inspect` opens a data directory, verifies every hash chain through normal +runtime replay, and prints a JSON session summary. Optional revision selection +uses the same replay implementation as the HTTP endpoint. + +## 10. Engine Adapters + +### Ren'Py + +- Add plain-dictionary methods for activity, arbitration, batch commit, + timeline, and replay. +- Keep all HTTP and polling objects process-local. +- Enable only memory and belief features for `ai-galgame` v1.2 content. +- Preserve authored fallback when Rin is disabled or unavailable. + +### Godot 4 + +- Add coroutine helpers for activity, due-agent queries, arbitration, and batch + commit. +- Keep navigation, animation, combat, inventory, and scene-tree mutation in + Godot. + +### Unity + +- Add serializable request/response DTOs and coroutine methods for the same + endpoints. +- Continue to use `UnityWebRequest` and bounded downloads without packages. + +Adapters do not run an agent loop every frame. The engine owns simulation +ticks and decides when an actor deserves a proposal job. + +## 11. Implementation Phases and Commits + +### Phase A: plan and compatibility contract + +- Add this document and update the roadmap. +- Record baseline Go and game test results. +- Commit: `docs: plan living worlds runtime`. + +### Phase B: cognition + +- Add feature negotiation and optional protocol fields. +- Implement memory archive compaction, summary retrieval, snapshot validation, + and deterministic replay tests. +- Implement belief sets and conflicting-claim prompt projection. +- Commit: `feat: add long-term actor cognition`. + +### Phase C: autonomy and world coordination + +- Add candidate goals and commit-time adoption. +- Add world revision semantics. +- Add actor activity, region filtering, arbitration, and atomic batch commit. +- Commit: `feat: coordinate living world actors`. + +### Phase D: observability and adapters + +- Add timeline/replay APIs and `rin inspect`. +- Extend Ren'Py, Godot, and Unity adapters and examples. +- Update protocol, architecture, RPG, model-policy, and security docs. +- Commit: `feat: add living world tooling and adapters`. + +### Phase E: game integration + +- Enable compatible cognition features when `ai-galgame` creates a new Rin + playthrough session. +- Extend compatibility vectors and process-level integration checks. +- Keep old saves and classic mode unchanged. +- Commit in the game repository: `feat: enable Rin living memory`. + +## 12. Automated Verification + +Rin acceptance requires: + +- `go test ./...`; +- `go test -race ./...`; +- `go vet ./...`; +- deterministic replay produces identical state and summary IDs; +- old v0.4 fixtures and snapshots still validate; +- memory never exceeds episodic or archive bounds; +- private claims never appear in an unlisted actor; +- conflicting claims survive snapshot/restore; +- a candidate goal is added only by an accepted commit; +- dormant actors are neither due nor allowed to propose; +- arbitration order is deterministic under shuffled input; +- batch commit is atomic and rejects mixed revisions; +- timeline output contains no observation quote or summary; +- macOS arm64/amd64, Windows amd64, and Linux amd64 builds succeed; +- Ren'Py adapter tests and compatibility vectors pass. + +Game acceptance requires: + +- the complete Python suite; +- Rin boundary and source scans; +- key-free real-process Session -> Observation -> Proposal -> Arbitration -> + Commit -> Snapshot -> Restore check; +- Ren'Py lint and compile when the SDK is available. + +## 13. Manual Verification Deferred by Lock Screen + +- Inspect memory and relationship screens at supported desktop resolutions. +- Play online across multiple chapters and confirm recalled lines feel natural. +- Save, create a different future, reload, and confirm memory rewinds. +- Stop Rin during a request and confirm offline continuation remains responsive. +- Evaluate whether autonomous questions are varied without becoming intrusive. +- Run a small Godot or Unity scene with at least three competing NPCs. + +## 14. Release and Rollback + +- Existing sessions do not gain living-world features automatically. +- A game removes feature identifiers to return new sessions to v0.4 semantics. +- No event migration rewrites JSONL files in place. +- A failed new endpoint cannot corrupt an existing session because all writes + validate first and append one hash-chained event atomically. +- The game can disable Rin and continue with authored content at any point. + +## 15. Stop Condition + +Implementation is complete when all automated checks pass, each phase has a +local commit, `ai-galgame` can opt into cognition without changing canonical +story authority, and only the manual GUI, cross-engine scene, long-play, and +human quality checks remain. From 03b76b559991781813d79e298e43f670f458f721 Mon Sep 17 00:00:00 2001 From: sunrioa Date: Wed, 22 Jul 2026 16:52:25 +0800 Subject: [PATCH 2/5] feat: add long-term actor cognition --- httpapi/server.go | 1 + policy/deterministic.go | 27 +++- policy/model.go | 29 ++-- policy/model_test.go | 24 ++++ protocol/features.go | 42 ++++++ protocol/state_validate.go | 138 ++++++++++++++++++- protocol/types.go | 46 ++++++- protocol/validate.go | 15 +++ protocol/validate_test.go | 16 +++ runtime/engine.go | 10 +- runtime/living_cognition_test.go | 134 ++++++++++++++++++ runtime/memory.go | 225 +++++++++++++++++++++++++++++++ runtime/memory_test.go | 56 ++++++++ runtime/reducer.go | 95 ++++++++++++- 14 files changed, 834 insertions(+), 24 deletions(-) create mode 100644 protocol/features.go create mode 100644 runtime/living_cognition_test.go create mode 100644 runtime/memory.go create mode 100644 runtime/memory_test.go diff --git a/httpapi/server.go b/httpapi/server.go index 35eee7c..d60a625 100644 --- a/httpapi/server.go +++ b/httpapi/server.go @@ -88,6 +88,7 @@ func (s *Server) health(response http.ResponseWriter, _ *http.Request) { "status": "ok", "protocol_version": protocol.Version, "policy_mode": s.policyMode, "async_jobs": s.jobs != nil, "structured_generation": s.generation != nil, + "features": protocol.SupportedFeatures(), }, }) } diff --git a/policy/deterministic.go b/policy/deterministic.go index 43817a2..7890310 100644 --- a/policy/deterministic.go +++ b/policy/deterministic.go @@ -178,7 +178,7 @@ func retrieveMemories(actor protocol.ActorState, tags []string, tick int64, limi memory protocol.Memory score int64 } - values := make([]scoredMemory, 0, len(actor.Memories)) + values := make([]scoredMemory, 0, len(actor.Memories)+len(actor.MemorySummaries)) for _, memory := range actor.Memories { score := int64(memory.Importance * 10) age := tick - memory.Tick @@ -201,6 +201,31 @@ func retrieveMemories(actor protocol.ActorState, tags []string, tick int64, limi } values = append(values, scoredMemory{memory: memory, score: score}) } + for _, summary := range actor.MemorySummaries { + memory := protocol.Memory{ + ID: summary.ID, EventID: summary.ID, Tick: summary.EndTick, + Summary: summary.Summary, Tags: append([]string(nil), summary.Tags...), + Importance: summary.Importance, CreatedRevision: summary.CreatedRevision, + RecallCount: summary.RecallCount, LastRecalledTick: summary.LastRecalledTick, + } + score := int64(memory.Importance * 10) + age := tick - memory.Tick + if age < 0 { + age = 0 + } + if age < 10 { + score += 10 - age + } + if memory.RecallCount == 0 { + score += 5 + } + for _, tag := range memory.Tags { + if _, exists := query[tag]; exists { + score += 8 + } + } + values = append(values, scoredMemory{memory: memory, score: score}) + } sort.Slice(values, func(i, j int) bool { if values[i].score == values[j].score { if values[i].memory.Tick == values[j].memory.Tick { diff --git a/policy/model.go b/policy/model.go index 573f049..37c109c 100644 --- a/policy/model.go +++ b/policy/model.go @@ -63,15 +63,16 @@ type promptContract struct { } type promptGameData struct { - Actor promptActor `json:"actor"` - Intent string `json:"intent"` - Tags []string `json:"tags"` - Actions []protocol.ActionSpec `json:"actions"` - Memories []protocol.Memory `json:"memories"` - Beliefs []protocol.Fact `json:"beliefs"` - Goals []protocol.Goal `json:"goals"` - Boundaries []protocol.Boundary `json:"boundaries"` - RecentActions []protocol.ActionProposal `json:"recent_actions"` + Actor promptActor `json:"actor"` + Intent string `json:"intent"` + Tags []string `json:"tags"` + Actions []protocol.ActionSpec `json:"actions"` + Memories []protocol.Memory `json:"memories"` + Beliefs []protocol.Fact `json:"beliefs"` + BeliefConflicts []protocol.BeliefSet `json:"belief_conflicts,omitempty"` + Goals []protocol.Goal `json:"goals"` + Boundaries []protocol.Boundary `json:"boundaries"` + RecentActions []protocol.ActionProposal `json:"recent_actions"` } type promptActor struct { @@ -186,6 +187,13 @@ func (p Model) promptPacket(input rinruntime.PolicyContext) promptPacket { if len(recent) > 4 { recent = recent[len(recent)-4:] } + conflicts := make([]protocol.BeliefSet, 0) + for _, key := range beliefKeys { + set, exists := input.Actor.BeliefSets[key] + if exists && set.Conflicted { + conflicts = append(conflicts, set) + } + } return promptPacket{ Contract: promptContract{ SessionRevision: input.State.Revision, @@ -197,7 +205,8 @@ func (p Model) promptPacket(input rinruntime.PolicyContext) promptPacket { UntrustedGameData: promptGameData{ Actor: promptActor{ID: input.Actor.ID, Kind: input.Actor.Kind, DisplayName: input.Actor.DisplayName, Traits: append([]string(nil), input.Actor.Traits...)}, Intent: input.Request.Intent, Tags: append([]string(nil), input.Request.Tags...), - Actions: append([]protocol.ActionSpec(nil), input.Request.CandidateActions...), Memories: memories, Beliefs: beliefs, Goals: goals, + Actions: append([]protocol.ActionSpec(nil), input.Request.CandidateActions...), Memories: memories, Beliefs: beliefs, + BeliefConflicts: conflicts, Goals: goals, Boundaries: append([]protocol.Boundary(nil), input.Actor.Boundaries...), RecentActions: append([]protocol.ActionProposal(nil), recent...), }, } diff --git a/policy/model_test.go b/policy/model_test.go index a5d9c6f..ec0d4ea 100644 --- a/policy/model_test.go +++ b/policy/model_test.go @@ -65,6 +65,30 @@ func TestModelPolicyUsesIsolatedDataPacket(t *testing.T) { } } +func TestModelPolicyReceivesOnlyActorConflictSets(t *testing.T) { + client := &completionClient{response: validModelJSON()} + input := modelInput() + input.Actor.BeliefSets = map[string]protocol.BeliefSet{ + "relic:location": { + SubjectID: "relic", Predicate: "location", SelectedSourceEventID: "event.harbor", Conflicted: true, + Claims: []protocol.BeliefClaim{ + {Fact: protocol.Fact{SubjectID: "relic", Predicate: "location", Object: "harbor", SourceEventID: "event.harbor", Confidence: 80}, ObservedRevision: 1}, + {Fact: protocol.Fact{SubjectID: "relic", Predicate: "location", Object: "tower", SourceEventID: "event.tower", Confidence: 60}, ObservedRevision: 2}, + }, + }, + } + input.Actor.Beliefs["relic:location"] = input.Actor.BeliefSets["relic:location"].Claims[0].Fact + if _, err := (policy.Model{Client: client}).Propose(context.Background(), input); err != nil { + t.Fatal(err) + } + client.mu.Lock() + request := client.request + client.mu.Unlock() + if !strings.Contains(request.Messages[1].Content, `"belief_conflicts"`) || !strings.Contains(request.Messages[1].Content, `"tower"`) { + t.Fatalf("actor-local conflict was not included in the bounded packet: %s", request.Messages[1].Content) + } +} + func TestModelPolicyRejectsContractEscapeAndUnknownJSON(t *testing.T) { client := &completionClient{response: strings.Replace(validModelJSON(), `"action_id":"talk"`, `"action_id":"execute"`, 1)} _, err := (policy.Model{Client: client}).Propose(context.Background(), modelInput()) diff --git a/protocol/features.go b/protocol/features.go new file mode 100644 index 0000000..08aa252 --- /dev/null +++ b/protocol/features.go @@ -0,0 +1,42 @@ +package protocol + +import "sort" + +const ( + FeatureMemoryArchive = "memory-archive-v1" + FeatureBeliefConflicts = "belief-conflicts-v1" + FeatureGoalCandidates = "goal-candidates-v1" + FeatureActorActivity = "actor-activity-v1" + FeatureArbitration = "arbitration-v1" +) + +var supportedFeatures = map[string]struct{}{ + FeatureMemoryArchive: {}, + FeatureBeliefConflicts: {}, + FeatureGoalCandidates: {}, + FeatureActorActivity: {}, + FeatureArbitration: {}, +} + +func SupportedFeatures() []string { + result := make([]string, 0, len(supportedFeatures)) + for feature := range supportedFeatures { + result = append(result, feature) + } + sort.Strings(result) + return result +} + +func IsSupportedFeature(feature string) bool { + _, exists := supportedFeatures[feature] + return exists +} + +func HasFeature(features []string, wanted string) bool { + for _, feature := range features { + if feature == wanted { + return true + } + } + return false +} diff --git a/protocol/state_validate.go b/protocol/state_validate.go index ab614d3..d12fc38 100644 --- a/protocol/state_validate.go +++ b/protocol/state_validate.go @@ -2,6 +2,7 @@ package protocol import ( "fmt" + "reflect" "regexp" ) @@ -19,6 +20,9 @@ func ValidateSessionState(state SessionState) error { if err := ValidateBinding(state.Binding); err != nil { return err } + if err := validateFeatures("state.features", state.Features); err != nil { + return err + } if state.Tick < 0 { return &ValidationError{Field: "state.tick", Message: "must not be negative"} } @@ -45,7 +49,7 @@ func ValidateSessionState(state SessionState) error { if len(actor.Memories) > 128 { return &ValidationError{Field: base + ".memories", Message: "must contain at most 128 values"} } - memoryIDs := make(map[string]struct{}, len(actor.Memories)) + memoryIDs := make(map[string]struct{}, len(actor.Memories)+len(actor.MemorySummaries)) for index, memory := range actor.Memories { field := fmt.Sprintf("%s.memories[%d]", base, index) if err := validateMemory(field, memory); err != nil { @@ -56,6 +60,22 @@ func ValidateSessionState(state SessionState) error { } memoryIDs[memory.ID] = struct{}{} } + if len(actor.MemorySummaries) > 32 { + return &ValidationError{Field: base + ".memory_summaries", Message: "must contain at most 32 values"} + } + if len(actor.MemorySummaries) > 0 && !HasFeature(state.Features, FeatureMemoryArchive) { + return &ValidationError{Field: base + ".memory_summaries", Message: "requires memory-archive-v1"} + } + for index, summary := range actor.MemorySummaries { + field := fmt.Sprintf("%s.memory_summaries[%d]", base, index) + if err := validateMemorySummary(field, summary); err != nil { + return err + } + if _, exists := memoryIDs[summary.ID]; exists { + return &ValidationError{Field: base + ".memory_summaries", Message: "memory and summary ids must be unique"} + } + memoryIDs[summary.ID] = struct{}{} + } if len(actor.Beliefs) > 256 { return &ValidationError{Field: base + ".beliefs", Message: "must contain at most 256 values"} } @@ -73,6 +93,31 @@ func ValidateSessionState(state SessionState) error { } } } + if len(actor.BeliefSets) > 256 { + return &ValidationError{Field: base + ".belief_sets", Message: "must contain at most 256 values"} + } + if len(actor.BeliefSets) > 0 && !HasFeature(state.Features, FeatureBeliefConflicts) { + return &ValidationError{Field: base + ".belief_sets", Message: "requires belief-conflicts-v1"} + } + for key, set := range actor.BeliefSets { + field := base + ".belief_sets." + key + if err := validateBeliefSet(field, key, set, state.Revision); err != nil { + return err + } + selected, exists := actor.Beliefs[key] + if !exists { + return &ValidationError{Field: field, Message: "must have a selected compatibility belief"} + } + matched := false + for _, claim := range set.Claims { + if claim.Fact.SourceEventID == set.SelectedSourceEventID && reflect.DeepEqual(claim.Fact, selected) { + matched = true + } + } + if !matched { + return &ValidationError{Field: field + ".selected_source_event_id", Message: "must select the projected belief"} + } + } if len(actor.RecentActions) > 32 { return &ValidationError{Field: base + ".recent_actions", Message: "must contain at most 32 values"} } @@ -96,10 +141,13 @@ func ValidateSessionState(state SessionState) error { if !exists { return &ValidationError{Field: "state.proposals." + id + ".actor_id", Message: "references an unknown actor"} } - memoryIDs := make(map[string]struct{}, len(actor.Memories)) + memoryIDs := make(map[string]struct{}, len(actor.Memories)+len(actor.MemorySummaries)) for _, memory := range actor.Memories { memoryIDs[memory.ID] = struct{}{} } + for _, summary := range actor.MemorySummaries { + memoryIDs[summary.ID] = struct{}{} + } if err := validateProposal("state.proposals."+id, state, actor, proposal, memoryIDs); err != nil { return err } @@ -149,6 +197,92 @@ func validateMemory(field string, memory Memory) error { return nil } +func validateMemorySummary(field string, summary MemorySummary) error { + if err := validateID(field+".id", summary.ID); err != nil { + return err + } + if summary.Level < 1 || summary.Level > 16 { + return &ValidationError{Field: field + ".level", Message: "must be between 1 and 16"} + } + if err := validateText(field+".summary", summary.Summary, 1000, true); err != nil { + return err + } + if err := validateTags(field+".tags", summary.Tags, 32); err != nil { + return err + } + if err := validateTags(field+".source_memory_ids", summary.SourceMemoryIDs, 64); err != nil { + return err + } + if err := validateTags(field+".source_event_ids", summary.SourceEventIDs, 64); err != nil { + return err + } + if len(summary.SourceMemoryIDs) == 0 || len(summary.SourceEventIDs) == 0 { + return &ValidationError{Field: field, Message: "must retain source memory and event ids"} + } + if summary.StartTick < 0 || summary.EndTick < summary.StartTick || summary.LastRecalledTick < 0 { + return &ValidationError{Field: field, Message: "contains an invalid tick range"} + } + if summary.Importance < 1 || summary.Importance > 5 { + return &ValidationError{Field: field + ".importance", Message: "must be between 1 and 5"} + } + if err := validateID(field+".reason", summary.Reason); err != nil { + return err + } + if summary.CreatedRevision == 0 || summary.RecallCount < 0 || summary.RecallCount > 1_000_000 { + return &ValidationError{Field: field, Message: "contains invalid revision or recall values"} + } + return nil +} + +func validateBeliefSet(field, key string, set BeliefSet, stateRevision uint64) error { + if err := validateID(field+".subject_id", set.SubjectID); err != nil { + return err + } + if err := validateID(field+".predicate", set.Predicate); err != nil { + return err + } + if key != set.SubjectID+":"+set.Predicate { + return &ValidationError{Field: field, Message: "map key must match subject and predicate"} + } + if len(set.Claims) == 0 || len(set.Claims) > 8 { + return &ValidationError{Field: field + ".claims", Message: "must contain 1-8 claims"} + } + if err := validateID(field+".selected_source_event_id", set.SelectedSourceEventID); err != nil { + return err + } + sources := make(map[string]struct{}, len(set.Claims)) + objects := make(map[string]struct{}, len(set.Claims)) + selectedExists := false + for index, claim := range set.Claims { + claimField := fmt.Sprintf("%s.claims[%d]", field, index) + if err := validateFact(claimField+".fact", claim.Fact); err != nil { + return err + } + if claim.Fact.SubjectID != set.SubjectID || claim.Fact.Predicate != set.Predicate { + return &ValidationError{Field: claimField + ".fact", Message: "must match its belief set"} + } + if claim.Fact.SourceEventID == "" { + return &ValidationError{Field: claimField + ".fact.source_event_id", Message: "is required"} + } + if _, exists := sources[claim.Fact.SourceEventID]; exists { + return &ValidationError{Field: field + ".claims", Message: "source event ids must be unique"} + } + sources[claim.Fact.SourceEventID] = struct{}{} + objects[claim.Fact.Object] = struct{}{} + if claim.ObservedRevision == 0 || claim.ObservedRevision > stateRevision { + return &ValidationError{Field: claimField + ".observed_revision", Message: "must reference an existing revision"} + } + selectedExists = selectedExists || claim.Fact.SourceEventID == set.SelectedSourceEventID + } + if !selectedExists { + return &ValidationError{Field: field + ".selected_source_event_id", Message: "references an unknown claim"} + } + if set.Conflicted != (len(objects) > 1) { + return &ValidationError{Field: field + ".conflicted", Message: "must reflect distinct claim objects"} + } + return nil +} + func validateProposal(field string, state SessionState, actor ActorState, proposal ActionProposal, memoryIDs map[string]struct{}) error { for suffix, value := range map[string]string{ ".id": proposal.ID, ".session_id": proposal.SessionID, ".request_id": proposal.RequestID, ".actor_id": proposal.ActorID, diff --git a/protocol/types.go b/protocol/types.go index 5b03d77..21e8499 100644 --- a/protocol/types.go +++ b/protocol/types.go @@ -64,6 +64,40 @@ type Memory struct { LastRecalledTick int64 `json:"last_recalled_tick"` } +// MemorySummary retains bounded, explainable context after detailed episodic +// memories are compacted. Source lists are intentionally bounded; the event +// log remains the complete audit record. +type MemorySummary struct { + ID string `json:"id"` + Level int `json:"level"` + Summary string `json:"summary"` + Tags []string `json:"tags,omitempty"` + SourceMemoryIDs []string `json:"source_memory_ids,omitempty"` + SourceEventIDs []string `json:"source_event_ids,omitempty"` + StartTick int64 `json:"start_tick"` + EndTick int64 `json:"end_tick"` + Importance int `json:"importance"` + Reason string `json:"reason"` + CreatedRevision uint64 `json:"created_revision"` + RecallCount int `json:"recall_count"` + LastRecalledTick int64 `json:"last_recalled_tick"` +} + +type BeliefClaim struct { + Fact Fact `json:"fact"` + ObservedRevision uint64 `json:"observed_revision"` +} + +// BeliefSet preserves contradictory actor-local claims while Beliefs remains +// the compatibility projection of the currently selected claim. +type BeliefSet struct { + SubjectID string `json:"subject_id"` + Predicate string `json:"predicate"` + Claims []BeliefClaim `json:"claims"` + SelectedSourceEventID string `json:"selected_source_event_id"` + Conflicted bool `json:"conflicted"` +} + type ActionSpec struct { ID string `json:"id"` Kind string `json:"kind"` @@ -93,10 +127,12 @@ type ActionProposal struct { type ActorState struct { ActorSeed - Memories []Memory `json:"memories,omitempty"` - Beliefs map[string]Fact `json:"beliefs,omitempty"` - RecentActions []ActionProposal `json:"recent_actions,omitempty"` - NextThinkTick int64 `json:"next_think_tick"` + Memories []Memory `json:"memories,omitempty"` + MemorySummaries []MemorySummary `json:"memory_summaries,omitempty"` + Beliefs map[string]Fact `json:"beliefs,omitempty"` + BeliefSets map[string]BeliefSet `json:"belief_sets,omitempty"` + RecentActions []ActionProposal `json:"recent_actions,omitempty"` + NextThinkTick int64 `json:"next_think_tick"` } type RequestReceipt struct { @@ -110,6 +146,7 @@ type SessionState struct { SessionID string `json:"session_id"` Binding Binding `json:"binding"` Seed int64 `json:"seed"` + Features []string `json:"features,omitempty"` Tick int64 `json:"tick"` Revision uint64 `json:"revision"` HeadHash string `json:"head_hash"` @@ -124,6 +161,7 @@ type CreateSessionRequest struct { SessionID string `json:"session_id"` Binding Binding `json:"binding"` Seed int64 `json:"seed"` + Features []string `json:"features,omitempty"` Actors []ActorSeed `json:"actors"` } diff --git a/protocol/validate.go b/protocol/validate.go index caaca8b..bba1f3c 100644 --- a/protocol/validate.go +++ b/protocol/validate.go @@ -71,6 +71,18 @@ func validateTags(field string, values []string, maximum int) error { return nil } +func validateFeatures(field string, values []string) error { + if err := validateTags(field, values, len(supportedFeatures)); err != nil { + return err + } + for index, value := range values { + if !IsSupportedFeature(value) { + return &ValidationError{Field: fmt.Sprintf("%s[%d]", field, index), Message: "is not supported"} + } + } + return nil +} + func ValidateBinding(binding Binding) error { if err := validateID("binding.game_id", binding.GameID); err != nil { return err @@ -178,6 +190,9 @@ func ValidateCreateSession(request CreateSessionRequest) error { if err := ValidateBinding(request.Binding); err != nil { return err } + if err := validateFeatures("features", request.Features); err != nil { + return err + } if len(request.Actors) == 0 || len(request.Actors) > 128 { return &ValidationError{Field: "actors", Message: "must contain 1-128 actors"} } diff --git a/protocol/validate_test.go b/protocol/validate_test.go index 2f5461c..d03bb4a 100644 --- a/protocol/validate_test.go +++ b/protocol/validate_test.go @@ -32,6 +32,22 @@ func TestCreateValidationRejectsInvalidBoundaryAndProtocol(t *testing.T) { } } +func TestCreateValidationNegotiatesKnownFeatures(t *testing.T) { + request := validCreate() + request.Features = []string{protocol.FeatureMemoryArchive, protocol.FeatureBeliefConflicts} + if err := protocol.ValidateCreateSession(request); err != nil { + t.Fatalf("known features should validate: %v", err) + } + request.Features = append(request.Features, "future-untrusted-feature") + if err := protocol.ValidateCreateSession(request); err == nil { + t.Fatal("unknown feature should fail") + } + request.Features = []string{protocol.FeatureMemoryArchive, protocol.FeatureMemoryArchive} + if err := protocol.ValidateCreateSession(request); err == nil { + t.Fatal("duplicate feature should fail") + } +} + func TestProposalRequiresUniqueWhitelistedShape(t *testing.T) { request := protocol.ProposeRequest{ ProtocolVersion: protocol.Version, diff --git a/runtime/engine.go b/runtime/engine.go index a6f7cbc..c2912f6 100644 --- a/runtime/engine.go +++ b/runtime/engine.go @@ -455,6 +455,11 @@ func eventIDExists(state protocol.SessionState, eventID string) bool { return true } } + for _, summary := range actor.MemorySummaries { + if contains(summary.SourceEventIDs, eventID) { + return true + } + } } return false } @@ -496,10 +501,13 @@ func validateDraft(request protocol.ProposeRequest, actor protocol.ActorState, d if len(draft.RecalledMemoryIDs) > 8 { return protocol.ActionSpec{}, NewFieldError("invalid_policy_output", "policy recalled too many memories", "recalled_memory_ids", ErrConflict) } - memoryIDs := make(map[string]struct{}, len(actor.Memories)) + memoryIDs := make(map[string]struct{}, len(actor.Memories)+len(actor.MemorySummaries)) for _, memory := range actor.Memories { memoryIDs[memory.ID] = struct{}{} } + for _, summary := range actor.MemorySummaries { + memoryIDs[summary.ID] = struct{}{} + } seen := make(map[string]struct{}, len(draft.RecalledMemoryIDs)) for _, id := range draft.RecalledMemoryIDs { if _, exists := memoryIDs[id]; !exists { diff --git a/runtime/living_cognition_test.go b/runtime/living_cognition_test.go new file mode 100644 index 0000000..865a4c9 --- /dev/null +++ b/runtime/living_cognition_test.go @@ -0,0 +1,134 @@ +package runtime_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/sunrioa/rin/policy" + "github.com/sunrioa/rin/protocol" + rinruntime "github.com/sunrioa/rin/runtime" + "github.com/sunrioa/rin/store" +) + +func TestLivingMemoryArchivesAndReplaysDeterministically(t *testing.T) { + eventStore := store.NewMemory() + engine := newEngine(t, eventStore, policy.Deterministic{}) + create := createRequest("session.archive") + create.Features = []string{protocol.FeatureMemoryArchive} + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + for index := 1; index <= 145; index++ { + request := observeRequest("session.archive", fmt.Sprintf("observe.%d", index), fmt.Sprintf("event.%d", index), int64(index)) + request.Importance = 1 + request.Tags = []string{"recent"} + request.Summary = fmt.Sprintf("Recent event %d", index) + if index <= 64 { + request.Importance = 5 + request.Tags = []string{"foundation"} + request.Summary = fmt.Sprintf("Foundational event %d", index) + } + if index <= 16 { + request.Tags = []string{"archive"} + } + if _, err := engine.Observe(request); err != nil { + t.Fatal(err) + } + } + state, err := engine.State(sessionRequest("session.archive")) + if err != nil { + t.Fatal(err) + } + actor := state.Actors["npc.mira"] + if len(actor.Memories) > 128 || len(actor.MemorySummaries) != 2 { + t.Fatalf("unexpected archive bounds: memories=%d summaries=%d", len(actor.Memories), len(actor.MemorySummaries)) + } + for _, summary := range actor.MemorySummaries { + if summary.Reason != "episodic_capacity" || len(summary.SourceEventIDs) != 16 || !strings.HasPrefix(summary.ID, "summary.") { + t.Fatalf("unexpected memory summary: %+v", summary) + } + } + + proposalRequest := proposeRequest("session.archive", "propose.archive", 1000, []string{"archive"}) + proposal, _, err := engine.Propose(context.Background(), proposalRequest) + if err != nil { + t.Fatal(err) + } + foundSummary := false + for _, id := range proposal.RecalledMemoryIDs { + foundSummary = foundSummary || strings.HasPrefix(id, "summary.") + } + if !foundSummary { + t.Fatalf("expected policy to recall an archived summary, got %v", proposal.RecalledMemoryIDs) + } + + duplicateEvent := observeRequest("session.archive", "observe.duplicate-event", "event.1", 145) + if _, err := engine.Observe(duplicateEvent); err == nil || rinruntime.ErrorCode(err) != "event_exists" { + t.Fatalf("compacted source event should remain protected from duplication: %v", err) + } + + before, err := engine.Snapshot(sessionRequest("session.archive")) + if err != nil { + t.Fatal(err) + } + reopened := newEngine(t, eventStore, policy.Deterministic{}) + after, err := reopened.Snapshot(sessionRequest("session.archive")) + if err != nil { + t.Fatal(err) + } + if before.StateHash != after.StateHash { + t.Fatalf("archive replay changed state hash: before=%s after=%s", before.StateHash, after.StateHash) + } +} + +func TestBeliefConflictsRemainActorLocal(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + create := createRequest("session.beliefs") + create.Features = []string{protocol.FeatureBeliefConflicts} + second := create.Actors[0] + second.ID = "npc.oren" + second.DisplayName = "Oren" + second.Goals = nil + create.Actors = append(create.Actors, second) + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + + first := observeRequest("session.beliefs", "observe.rumor-a", "event.rumor-a", 1) + first.ObserverIDs = []string{"npc.mira", "npc.oren"} + first.Facts = []protocol.Fact{{ + SubjectID: "relic", Predicate: "location", Object: "harbor", Visibility: []string{"npc.mira"}, Confidence: 80, + }} + if _, err := engine.Observe(first); err != nil { + t.Fatal(err) + } + secondRumor := observeRequest("session.beliefs", "observe.rumor-b", "event.rumor-b", 2) + secondRumor.Facts = []protocol.Fact{{ + SubjectID: "relic", Predicate: "location", Object: "tower", Visibility: []string{"npc.mira"}, Confidence: 60, + }} + if _, err := engine.Observe(secondRumor); err != nil { + t.Fatal(err) + } + + state, err := engine.State(sessionRequest("session.beliefs")) + if err != nil { + t.Fatal(err) + } + mira := state.Actors["npc.mira"] + set := mira.BeliefSets["relic:location"] + if !set.Conflicted || len(set.Claims) != 2 || mira.Beliefs["relic:location"].Object != "harbor" { + t.Fatalf("unexpected conflicting belief state: set=%+v selected=%+v", set, mira.Beliefs["relic:location"]) + } + if len(state.Actors["npc.oren"].Beliefs) != 0 || len(state.Actors["npc.oren"].BeliefSets) != 0 { + t.Fatalf("private claim leaked to another observer: %+v", state.Actors["npc.oren"]) + } + snapshot, err := engine.Snapshot(sessionRequest("session.beliefs")) + if err != nil { + t.Fatal(err) + } + if err := rinruntime.ValidateSnapshot(snapshot); err != nil { + t.Fatalf("living cognition snapshot should validate: %v", err) + } +} diff --git a/runtime/memory.go b/runtime/memory.go new file mode 100644 index 0000000..75f5af8 --- /dev/null +++ b/runtime/memory.go @@ -0,0 +1,225 @@ +package runtime + +import ( + "sort" + "strings" + "unicode/utf8" + + "github.com/sunrioa/rin/protocol" +) + +const ( + memoryCompactionBatch = 16 + maxMemorySummaries = 32 + summaryMergeBatch = 4 + maxSummarySources = 64 +) + +func compactActorMemories(sessionID string, actor *protocol.ActorState, revision uint64) error { + for len(actor.Memories) > maxMemories { + window := len(actor.Memories) / 2 + if window < memoryCompactionBatch { + window = memoryCompactionBatch + } + indexes := make([]int, window) + for index := range indexes { + indexes[index] = index + } + sort.Slice(indexes, func(i, j int) bool { + left := actor.Memories[indexes[i]] + right := actor.Memories[indexes[j]] + if left.Importance != right.Importance { + return left.Importance < right.Importance + } + if left.RecallCount != right.RecallCount { + return left.RecallCount < right.RecallCount + } + if left.LastRecalledTick != right.LastRecalledTick { + return left.LastRecalledTick < right.LastRecalledTick + } + if left.Tick != right.Tick { + return left.Tick < right.Tick + } + return left.ID < right.ID + }) + selectedIndexes := indexes[:memoryCompactionBatch] + selected := make([]protocol.Memory, 0, len(selectedIndexes)) + selectedSet := make(map[int]struct{}, len(selectedIndexes)) + for _, index := range selectedIndexes { + selected = append(selected, actor.Memories[index]) + selectedSet[index] = struct{}{} + } + sort.Slice(selected, func(i, j int) bool { + if selected[i].Tick == selected[j].Tick { + return selected[i].ID < selected[j].ID + } + return selected[i].Tick < selected[j].Tick + }) + summary, err := summarizeMemories(sessionID, actor.ID, selected, revision) + if err != nil { + return err + } + retained := make([]protocol.Memory, 0, len(actor.Memories)-len(selected)) + for index, memory := range actor.Memories { + if _, compacted := selectedSet[index]; !compacted { + retained = append(retained, memory) + } + } + actor.Memories = retained + actor.MemorySummaries = append(actor.MemorySummaries, summary) + } + for len(actor.MemorySummaries) > maxMemorySummaries { + sortMemorySummaries(actor.MemorySummaries) + merged, err := mergeMemorySummaries(sessionID, actor.ID, actor.MemorySummaries[:summaryMergeBatch], revision) + if err != nil { + return err + } + actor.MemorySummaries = append([]protocol.MemorySummary{merged}, actor.MemorySummaries[summaryMergeBatch:]...) + } + sortMemorySummaries(actor.MemorySummaries) + return nil +} + +func summarizeMemories(sessionID, actorID string, memories []protocol.Memory, revision uint64) (protocol.MemorySummary, error) { + memoryIDs := make([]string, 0, len(memories)) + eventIDs := make([]string, 0, len(memories)) + tags := make([]string, 0) + texts := make([]string, 0, len(memories)) + importance := 1 + recallCount := 0 + lastRecalled := int64(0) + for _, memory := range memories { + memoryIDs = append(memoryIDs, memory.ID) + eventIDs = append(eventIDs, memory.EventID) + tags = append(tags, memory.Tags...) + texts = append(texts, memory.Summary) + if memory.Importance > importance { + importance = memory.Importance + } + recallCount += memory.RecallCount + if recallCount > 1_000_000 { + recallCount = 1_000_000 + } + if memory.LastRecalledTick > lastRecalled { + lastRecalled = memory.LastRecalledTick + } + } + id, err := memorySummaryID(sessionID, actorID, 1, memoryIDs) + if err != nil { + return protocol.MemorySummary{}, err + } + return protocol.MemorySummary{ + ID: "summary." + id[:24], Level: 1, Summary: joinSummaryText(texts), + Tags: boundedUnique(tags, 32), SourceMemoryIDs: boundedUnique(memoryIDs, maxSummarySources), + SourceEventIDs: boundedUnique(eventIDs, maxSummarySources), StartTick: memories[0].Tick, + EndTick: memories[len(memories)-1].Tick, Importance: importance, Reason: "episodic_capacity", + CreatedRevision: revision, RecallCount: recallCount, LastRecalledTick: lastRecalled, + }, nil +} + +func mergeMemorySummaries(sessionID, actorID string, summaries []protocol.MemorySummary, revision uint64) (protocol.MemorySummary, error) { + sortMemorySummaries(summaries) + sourceMemoryIDs := make([]string, 0) + sourceEventIDs := make([]string, 0) + tags := make([]string, 0) + texts := make([]string, 0, len(summaries)) + identityIDs := make([]string, 0, len(summaries)) + level := 1 + importance := 1 + recallCount := 0 + lastRecalled := int64(0) + for _, summary := range summaries { + identityIDs = append(identityIDs, summary.ID) + sourceMemoryIDs = append(sourceMemoryIDs, summary.SourceMemoryIDs...) + sourceEventIDs = append(sourceEventIDs, summary.SourceEventIDs...) + tags = append(tags, summary.Tags...) + texts = append(texts, summary.Summary) + if summary.Level >= level { + level = summary.Level + 1 + } + if summary.Importance > importance { + importance = summary.Importance + } + recallCount += summary.RecallCount + if recallCount > 1_000_000 { + recallCount = 1_000_000 + } + if summary.LastRecalledTick > lastRecalled { + lastRecalled = summary.LastRecalledTick + } + } + id, err := memorySummaryID(sessionID, actorID, level, identityIDs) + if err != nil { + return protocol.MemorySummary{}, err + } + return protocol.MemorySummary{ + ID: "summary." + id[:24], Level: level, Summary: joinSummaryText(texts), + Tags: boundedUnique(tags, 32), SourceMemoryIDs: boundedUnique(sourceMemoryIDs, maxSummarySources), + SourceEventIDs: boundedUnique(sourceEventIDs, maxSummarySources), StartTick: summaries[0].StartTick, + EndTick: summaries[len(summaries)-1].EndTick, Importance: importance, Reason: "archive_capacity", + CreatedRevision: revision, RecallCount: recallCount, LastRecalledTick: lastRecalled, + }, nil +} + +func memorySummaryID(sessionID, actorID string, level int, sourceIDs []string) (string, error) { + return hashJSON(struct { + SessionID string `json:"session_id"` + ActorID string `json:"actor_id"` + Level int `json:"level"` + SourceIDs []string `json:"source_ids"` + }{SessionID: sessionID, ActorID: actorID, Level: level, SourceIDs: sourceIDs}) +} + +func joinSummaryText(values []string) string { + var builder strings.Builder + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + separator := "" + if builder.Len() > 0 { + separator = " | " + } + candidate := builder.String() + separator + value + if utf8.RuneCountInString(candidate) > 1000 { + remaining := 1000 - utf8.RuneCountInString(builder.String()+separator) + if remaining > 0 { + builder.WriteString(separator) + builder.WriteString(string([]rune(value)[:min(remaining, utf8.RuneCountInString(value))])) + } + break + } + builder.WriteString(separator) + builder.WriteString(value) + } + return builder.String() +} + +func boundedUnique(values []string, limit int) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, min(len(values), limit)) + for _, value := range values { + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + if len(result) == limit { + break + } + } + return result +} + +func sortMemorySummaries(values []protocol.MemorySummary) { + sort.Slice(values, func(i, j int) bool { + if values[i].StartTick == values[j].StartTick { + return values[i].ID < values[j].ID + } + return values[i].StartTick < values[j].StartTick + }) +} diff --git a/runtime/memory_test.go b/runtime/memory_test.go new file mode 100644 index 0000000..368b23c --- /dev/null +++ b/runtime/memory_test.go @@ -0,0 +1,56 @@ +package runtime + +import ( + "reflect" + "testing" + + "github.com/sunrioa/rin/protocol" +) + +func TestMemoryArchiveMergesInsteadOfSilentlyDroppingSummaries(t *testing.T) { + actor := protocol.ActorState{ActorSeed: protocol.ActorSeed{ID: "npc.archive"}} + for index := 0; index < 700; index++ { + actor.Memories = append(actor.Memories, protocol.Memory{ + ID: "memory." + fixedID(index), EventID: "event." + fixedID(index), Tick: int64(index), + Summary: "A bounded event summary.", Tags: []string{"history"}, Importance: 2, + CreatedRevision: uint64(index + 1), + }) + } + copyActor := actor + copyActor.Memories = append([]protocol.Memory(nil), actor.Memories...) + if err := compactActorMemories("session.archive", &actor, 701); err != nil { + t.Fatal(err) + } + if err := compactActorMemories("session.archive", ©Actor, 701); err != nil { + t.Fatal(err) + } + if len(actor.Memories) > maxMemories || len(actor.MemorySummaries) > maxMemorySummaries { + t.Fatalf("archive exceeded bounds: memories=%d summaries=%d", len(actor.Memories), len(actor.MemorySummaries)) + } + higherLevel := false + for _, summary := range actor.MemorySummaries { + higherLevel = higherLevel || summary.Level > 1 + } + if !higherLevel { + t.Fatalf("expected archive summaries to merge: %+v", actor.MemorySummaries) + } + if !reflect.DeepEqual(actor, copyActor) { + t.Fatal("identical memory streams produced different archives") + } +} + +func fixedID(value int) string { + const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" + if value == 0 { + return "0" + } + buffer := make([]byte, 0, 8) + for value > 0 { + buffer = append(buffer, alphabet[value%len(alphabet)]) + value /= len(alphabet) + } + for left, right := 0, len(buffer)-1; left < right; left, right = left+1, right-1 { + buffer[left], buffer[right] = buffer[right], buffer[left] + } + return string(buffer) +} diff --git a/runtime/reducer.go b/runtime/reducer.go index e965d33..3b5c562 100644 --- a/runtime/reducer.go +++ b/runtime/reducer.go @@ -88,6 +88,7 @@ func applyCreated(state protocol.SessionState, event protocol.EventRecord) (prot SessionID: request.SessionID, Binding: request.Binding, Seed: request.Seed, + Features: append([]string(nil), request.Features...), Actors: actors, Proposals: make(map[string]protocol.ActionProposal), Receipts: map[string]protocol.RequestReceipt{ @@ -125,10 +126,14 @@ func applyObserved(state *protocol.SessionState, event protocol.EventRecord) err Importance: request.Importance, CreatedRevision: event.Sequence, }) - if len(actor.Memories) > maxMemories { + if protocol.HasFeature(state.Features, protocol.FeatureMemoryArchive) { + if err := compactActorMemories(state.SessionID, &actor, event.Sequence); err != nil { + return err + } + } else if len(actor.Memories) > maxMemories { actor.Memories = append([]protocol.Memory(nil), actor.Memories[len(actor.Memories)-maxMemories:]...) } - applyFacts(&actor, request.Facts, request.EventID) + applyFacts(&actor, request.Facts, request.EventID, event.Sequence, protocol.HasFeature(state.Features, protocol.FeatureBeliefConflicts)) state.Actors[actorID] = actor } if request.Tick > state.Tick { @@ -194,11 +199,15 @@ func applyCommitted(state *protocol.SessionState, event protocol.EventRecord) er Importance: 3, CreatedRevision: event.Sequence, }) - if len(actor.Memories) > maxMemories { + if protocol.HasFeature(state.Features, protocol.FeatureMemoryArchive) { + if err := compactActorMemories(state.SessionID, &actor, event.Sequence); err != nil { + return err + } + } else if len(actor.Memories) > maxMemories { actor.Memories = append([]protocol.Memory(nil), actor.Memories[len(actor.Memories)-maxMemories:]...) } } - applyFacts(&actor, request.Facts, request.EventID) + applyFacts(&actor, request.Facts, request.EventID, event.Sequence, protocol.HasFeature(state.Features, protocol.FeatureBeliefConflicts)) applyGoalProgress(&actor, proposal.GoalID, 1, "") for _, update := range request.GoalUpdates { applyGoalProgress(&actor, update.GoalID, update.ProgressDelta, update.Status) @@ -235,17 +244,85 @@ func applyRestored(current protocol.SessionState, event protocol.EventRecord) (p return restored, nil } -func applyFacts(actor *protocol.ActorState, facts []protocol.Fact, eventID string) { +func applyFacts(actor *protocol.ActorState, facts []protocol.Fact, eventID string, revision uint64, preserveConflicts bool) { if actor.Beliefs == nil { actor.Beliefs = make(map[string]protocol.Fact) } + if preserveConflicts && actor.BeliefSets == nil { + actor.BeliefSets = make(map[string]protocol.BeliefSet) + } for _, fact := range facts { if len(fact.Visibility) > 0 && !contains(fact.Visibility, actor.ID) { continue } fact.SourceEventID = eventID - actor.Beliefs[fact.SubjectID+":"+fact.Predicate] = fact + key := fact.SubjectID + ":" + fact.Predicate + if !preserveConflicts { + actor.Beliefs[key] = fact + continue + } + set := actor.BeliefSets[key] + set.SubjectID = fact.SubjectID + set.Predicate = fact.Predicate + updated := false + for index := range set.Claims { + if set.Claims[index].Fact.SourceEventID == eventID { + set.Claims[index] = protocol.BeliefClaim{Fact: fact, ObservedRevision: revision} + updated = true + break + } + } + if !updated { + set.Claims = append(set.Claims, protocol.BeliefClaim{Fact: fact, ObservedRevision: revision}) + } + trimBeliefClaims(&set) + selected := selectBeliefClaim(set.Claims) + set.SelectedSourceEventID = selected.Fact.SourceEventID + set.Conflicted = beliefObjectCount(set.Claims) > 1 + actor.BeliefSets[key] = set + actor.Beliefs[key] = selected.Fact + } +} + +func trimBeliefClaims(set *protocol.BeliefSet) { + if len(set.Claims) <= 8 { + return + } + sort.Slice(set.Claims, func(i, j int) bool { + if set.Claims[i].Fact.Confidence == set.Claims[j].Fact.Confidence { + if set.Claims[i].ObservedRevision == set.Claims[j].ObservedRevision { + return set.Claims[i].Fact.SourceEventID < set.Claims[j].Fact.SourceEventID + } + return set.Claims[i].ObservedRevision > set.Claims[j].ObservedRevision + } + return set.Claims[i].Fact.Confidence > set.Claims[j].Fact.Confidence + }) + set.Claims = append([]protocol.BeliefClaim(nil), set.Claims[:8]...) +} + +func selectBeliefClaim(claims []protocol.BeliefClaim) protocol.BeliefClaim { + values := append([]protocol.BeliefClaim(nil), claims...) + sort.Slice(values, func(i, j int) bool { + if values[i].Fact.Confidence == values[j].Fact.Confidence { + if values[i].ObservedRevision == values[j].ObservedRevision { + if values[i].Fact.Object == values[j].Fact.Object { + return values[i].Fact.SourceEventID < values[j].Fact.SourceEventID + } + return values[i].Fact.Object < values[j].Fact.Object + } + return values[i].ObservedRevision > values[j].ObservedRevision + } + return values[i].Fact.Confidence > values[j].Fact.Confidence + }) + return values[0] +} + +func beliefObjectCount(claims []protocol.BeliefClaim) int { + objects := make(map[string]struct{}, len(claims)) + for _, claim := range claims { + objects[claim.Fact.Object] = struct{}{} } + return len(objects) } func applyGoalProgress(actor *protocol.ActorState, goalID string, delta int, status string) { @@ -284,6 +361,12 @@ func markRecalled(actor *protocol.ActorState, ids []string, tick int64) { actor.Memories[index].LastRecalledTick = tick } } + for index := range actor.MemorySummaries { + if _, exists := selected[actor.MemorySummaries[index].ID]; exists { + actor.MemorySummaries[index].RecallCount++ + actor.MemorySummaries[index].LastRecalledTick = tick + } + } } func trimProposals(state *protocol.SessionState) { From 25a902aa9c3fd50ddaac195de4f8754254ddb01f Mon Sep 17 00:00:00 2001 From: sunrioa Date: Wed, 22 Jul 2026 17:02:19 +0800 Subject: [PATCH 3/5] feat: coordinate living world actors --- httpapi/server.go | 30 +++ policy/cache.go | 32 +++- policy/cache_test.go | 22 +++ policy/deterministic.go | 5 +- policy/model.go | 6 +- policy/model_test.go | 21 ++ protocol/living.go | 73 +++++++ protocol/living_validate.go | 134 +++++++++++++ protocol/state_validate.go | 120 +++++++++++- protocol/types.go | 48 +++-- protocol/validate.go | 19 +- protocol/validate_test.go | 29 +++ runtime/engine.go | 359 +++++++++++++++++++++++++++++++---- runtime/living_world_test.go | 231 ++++++++++++++++++++++ runtime/reducer.go | 190 ++++++++++++++---- runtime/runtime.go | 3 + 16 files changed, 1214 insertions(+), 108 deletions(-) create mode 100644 protocol/living.go create mode 100644 protocol/living_validate.go create mode 100644 runtime/living_world_test.go diff --git a/httpapi/server.go b/httpapi/server.go index d60a625..f6eab69 100644 --- a/httpapi/server.go +++ b/httpapi/server.go @@ -63,6 +63,9 @@ func New(engine *rinruntime.Engine, options Options) *Server { mux.HandleFunc("POST /v1/session/observe", server.observe) mux.HandleFunc("POST /v1/agent/propose", server.propose) mux.HandleFunc("POST /v1/action/commit", server.commit) + mux.HandleFunc("POST /v1/action/commit-batch", server.commitBatch) + mux.HandleFunc("POST /v1/session/activity", server.setActorActivity) + mux.HandleFunc("POST /v1/world/arbitrate", server.arbitrate) mux.HandleFunc("POST /v1/session/get", server.getSession) mux.HandleFunc("POST /v1/session/snapshot", server.snapshot) mux.HandleFunc("POST /v1/session/restore", server.restore) @@ -129,6 +132,33 @@ func (s *Server) commit(response http.ResponseWriter, request *http.Request) { s.respond(response, result, err) } +func (s *Server) commitBatch(response http.ResponseWriter, request *http.Request) { + var input protocol.BatchCommitRequest + if !s.decode(response, request, &input) { + return + } + result, err := s.engine.CommitBatch(input) + s.respond(response, result, err) +} + +func (s *Server) setActorActivity(response http.ResponseWriter, request *http.Request) { + var input protocol.SetActorActivityRequest + if !s.decode(response, request, &input) { + return + } + result, err := s.engine.SetActorActivity(input) + s.respond(response, result, err) +} + +func (s *Server) arbitrate(response http.ResponseWriter, request *http.Request) { + var input protocol.ArbitrateRequest + if !s.decode(response, request, &input) { + return + } + record, duplicate, err := s.engine.Arbitrate(input) + s.respond(response, protocol.ArbitrationResult{Record: record, Duplicate: duplicate}, err) +} + func (s *Server) getSession(response http.ResponseWriter, request *http.Request) { var input protocol.SessionRequest if !s.decode(response, request, &input) { diff --git a/policy/cache.go b/policy/cache.go index cf56981..7cbe6ca 100644 --- a/policy/cache.go +++ b/policy/cache.go @@ -107,18 +107,19 @@ func (p *Cached) Propose(ctx context.Context, input rinruntime.PolicyContext) (r func proposalCacheKey(input rinruntime.PolicyContext) (string, error) { payload, err := json.Marshal(struct { - SessionID string `json:"session_id"` - HeadHash string `json:"head_hash"` - ActorID string `json:"actor_id"` - Tick int64 `json:"tick"` - Intent string `json:"intent"` - Tags any `json:"tags"` - Actions any `json:"actions"` - Urgent bool `json:"urgent"` + SessionID string `json:"session_id"` + StateVersion any `json:"state_version"` + ActorID string `json:"actor_id"` + Tick int64 `json:"tick"` + Intent string `json:"intent"` + Tags any `json:"tags"` + Actions any `json:"actions"` + CandidateGoals any `json:"candidate_goals"` + Urgent bool `json:"urgent"` }{ - SessionID: input.State.SessionID, HeadHash: input.State.HeadHash, ActorID: input.Actor.ID, + SessionID: input.State.SessionID, StateVersion: policyStateVersion(input), ActorID: input.Actor.ID, Tick: input.Request.Tick, Intent: input.Request.Intent, Tags: input.Request.Tags, - Actions: input.Request.CandidateActions, Urgent: input.Request.Urgent, + Actions: input.Request.CandidateActions, CandidateGoals: input.Request.CandidateGoals, Urgent: input.Request.Urgent, }) if err != nil { return "", err @@ -127,6 +128,17 @@ func proposalCacheKey(input rinruntime.PolicyContext) (string, error) { return hex.EncodeToString(digest[:]), nil } +func policyStateVersion(input rinruntime.PolicyContext) any { + if input.State.WorldRevision > 0 { + return struct { + WorldRevision uint64 `json:"world_revision"` + }{WorldRevision: input.State.WorldRevision} + } + return struct { + HeadHash string `json:"head_hash"` + }{HeadHash: input.State.HeadHash} +} + func (p *Cached) removeExpired(now time.Time) { for key, entry := range p.entries { if !now.Before(entry.expiresAt) { diff --git a/policy/cache_test.go b/policy/cache_test.go index a54ed01..80ddbe4 100644 --- a/policy/cache_test.go +++ b/policy/cache_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/sunrioa/rin/policy" + "github.com/sunrioa/rin/protocol" rinruntime "github.com/sunrioa/rin/runtime" ) @@ -67,6 +68,27 @@ func TestCachedPolicyReusesSemanticRequest(t *testing.T) { } } +func TestCachedPolicySeparatesCandidateGoalContracts(t *testing.T) { + underlying := &countingPolicy{} + cached, _ := policy.NewCached(underlying, policy.CacheConfig{MaxEntries: 4, TTL: time.Minute}) + input := modelInput() + input.State.WorldRevision = 3 + input.Request.CandidateGoals = []protocol.Goal{{ + ID: "goal.first", Description: "First candidate.", Priority: 3, TargetProgress: 2, Status: "active", + }} + if _, err := cached.Propose(context.Background(), input); err != nil { + t.Fatal(err) + } + input.Request.RequestID = "request.changed-goal" + input.Request.CandidateGoals[0].ID = "goal.second" + if _, err := cached.Propose(context.Background(), input); err != nil { + t.Fatal(err) + } + if underlying.count() != 2 { + t.Fatalf("different candidate goal contracts shared a cache entry: %d", underlying.count()) + } +} + func TestCachedPolicyCollapsesConcurrentCalls(t *testing.T) { underlying := &countingPolicy{started: make(chan struct{}), release: make(chan struct{})} cached, _ := policy.NewCached(underlying, policy.CacheConfig{MaxEntries: 8, TTL: time.Minute}) diff --git a/policy/deterministic.go b/policy/deterministic.go index 7890310..5e8bf57 100644 --- a/policy/deterministic.go +++ b/policy/deterministic.go @@ -27,11 +27,14 @@ func (p Deterministic) Propose(ctx context.Context, input rinruntime.PolicyConte memoryLimit = 3 } memories := retrieveMemories(input.Actor, input.Request.Tags, input.Request.Tick, memoryLimit) - goal := selectGoal(input.Actor.Goals) + goals := append([]protocol.Goal(nil), input.Actor.Goals...) + goals = append(goals, input.Request.CandidateGoals...) + goal := selectGoal(goals) boundary, triggered := triggeredBoundary(input.Actor.Boundaries, input.Request.Tags) var selected protocol.ActionSpec if triggered { + goal = nil var found bool for _, action := range input.Request.CandidateActions { if action.Kind == boundary.Response || action.ID == boundary.Response { diff --git a/policy/model.go b/policy/model.go index 37c109c..f1e0e03 100644 --- a/policy/model.go +++ b/policy/model.go @@ -156,8 +156,10 @@ func (p Model) promptPacket(input rinruntime.PolicyContext) promptPacket { for _, key := range beliefKeys { beliefs = append(beliefs, input.Actor.Beliefs[key]) } - goals := make([]protocol.Goal, 0, len(input.Actor.Goals)) - for _, goal := range input.Actor.Goals { + availableGoals := append([]protocol.Goal(nil), input.Actor.Goals...) + availableGoals = append(availableGoals, input.Request.CandidateGoals...) + goals := make([]protocol.Goal, 0, len(availableGoals)) + for _, goal := range availableGoals { if goal.Status == "active" { goals = append(goals, goal) } diff --git a/policy/model_test.go b/policy/model_test.go index ec0d4ea..d5f8d0d 100644 --- a/policy/model_test.go +++ b/policy/model_test.go @@ -89,6 +89,27 @@ func TestModelPolicyReceivesOnlyActorConflictSets(t *testing.T) { } } +func TestModelPolicyMaySelectOnlyAdvertisedCandidateGoal(t *testing.T) { + candidateID := "goal.restore-camera" + client := &completionClient{response: strings.Replace(validModelJSON(), `"goal_id":"goal.connect"`, `"goal_id":"`+candidateID+`"`, 1)} + input := modelInput() + input.Request.CandidateGoals = []protocol.Goal{{ + ID: candidateID, Description: "Restore the camera.", Priority: 5, + PreferredActions: []string{"talk"}, TargetProgress: 3, Status: "active", + }} + draft, err := (policy.Model{Client: client}).Propose(context.Background(), input) + if err != nil { + t.Fatal(err) + } + if draft.GoalID != candidateID { + t.Fatalf("model did not select advertised candidate goal: %+v", draft) + } + client.response = strings.Replace(validModelJSON(), `"goal_id":"goal.connect"`, `"goal_id":"goal.not-advertised"`, 1) + if _, err := (policy.Model{Client: client}).Propose(context.Background(), input); err == nil { + t.Fatal("model selected an unadvertised candidate goal") + } +} + func TestModelPolicyRejectsContractEscapeAndUnknownJSON(t *testing.T) { client := &completionClient{response: strings.Replace(validModelJSON(), `"action_id":"talk"`, `"action_id":"execute"`, 1)} _, err := (policy.Model{Client: client}).Propose(context.Background(), modelInput()) diff --git a/protocol/living.go b/protocol/living.go new file mode 100644 index 0000000..550ed5c --- /dev/null +++ b/protocol/living.go @@ -0,0 +1,73 @@ +package protocol + +type ActorActivity struct { + RegionID string `json:"region_id,omitempty"` + State string `json:"state"` + Reason string `json:"reason,omitempty"` + UpdatedTick int64 `json:"updated_tick"` + UpdatedRevision uint64 `json:"updated_revision"` +} + +type ActorActivityUpdate struct { + ActorID string `json:"actor_id"` + RegionID string `json:"region_id,omitempty"` + State string `json:"state"` + Reason string `json:"reason,omitempty"` +} + +type SetActorActivityRequest struct { + ProtocolVersion string `json:"protocol_version"` + SessionID string `json:"session_id"` + RequestID string `json:"request_id"` + Tick int64 `json:"tick"` + Updates []ActorActivityUpdate `json:"updates"` +} + +type ArbitrationDecision struct { + ProposalID string `json:"proposal_id"` + ActorID string `json:"actor_id"` + Status string `json:"status"` + Reason string `json:"reason"` + ConflictingProposalIDs []string `json:"conflicting_proposal_ids,omitempty"` +} + +type ArbitrationRecord struct { + ID string `json:"id"` + RequestID string `json:"request_id"` + Tick int64 `json:"tick"` + BasedOnWorldRevision uint64 `json:"based_on_world_revision"` + CreatedRevision uint64 `json:"created_revision"` + Decisions []ArbitrationDecision `json:"decisions"` +} + +type ArbitrateRequest struct { + ProtocolVersion string `json:"protocol_version"` + SessionID string `json:"session_id"` + RequestID string `json:"request_id"` + Tick int64 `json:"tick"` + ProposalIDs []string `json:"proposal_ids"` + ExclusiveTargetIDs []string `json:"exclusive_target_ids,omitempty"` +} + +type ArbitrationResult struct { + Record ArbitrationRecord `json:"record"` + Duplicate bool `json:"duplicate"` +} + +type CommitItem struct { + ProposalID string `json:"proposal_id"` + EventID string `json:"event_id"` + Accepted bool `json:"accepted"` + Outcome string `json:"outcome,omitempty"` + Tags []string `json:"tags,omitempty"` + Facts []Fact `json:"facts,omitempty"` + GoalUpdates []GoalUpdate `json:"goal_updates,omitempty"` +} + +type BatchCommitRequest struct { + ProtocolVersion string `json:"protocol_version"` + SessionID string `json:"session_id"` + RequestID string `json:"request_id"` + Tick int64 `json:"tick"` + Items []CommitItem `json:"items"` +} diff --git a/protocol/living_validate.go b/protocol/living_validate.go new file mode 100644 index 0000000..e2de1a8 --- /dev/null +++ b/protocol/living_validate.go @@ -0,0 +1,134 @@ +package protocol + +import "fmt" + +func ValidateSetActorActivity(request SetActorActivityRequest) error { + if err := validateVersion(request.ProtocolVersion); err != nil { + return err + } + for field, value := range map[string]string{"session_id": request.SessionID, "request_id": request.RequestID} { + if err := validateID(field, value); err != nil { + return err + } + } + if request.Tick < 0 { + return &ValidationError{Field: "tick", Message: "must not be negative"} + } + if len(request.Updates) == 0 || len(request.Updates) > 128 { + return &ValidationError{Field: "updates", Message: "must contain 1-128 actor updates"} + } + seen := make(map[string]struct{}, len(request.Updates)) + for index, update := range request.Updates { + field := fmt.Sprintf("updates[%d]", index) + if err := validateID(field+".actor_id", update.ActorID); err != nil { + return err + } + if update.RegionID != "" { + if err := validateID(field+".region_id", update.RegionID); err != nil { + return err + } + } + if update.State != "awake" && update.State != "dormant" { + return &ValidationError{Field: field + ".state", Message: "must be awake or dormant"} + } + if err := validateText(field+".reason", update.Reason, 300, false); err != nil { + return err + } + if _, exists := seen[update.ActorID]; exists { + return &ValidationError{Field: "updates", Message: "actor ids must be unique"} + } + seen[update.ActorID] = struct{}{} + } + return nil +} + +func ValidateArbitrate(request ArbitrateRequest) error { + if err := validateVersion(request.ProtocolVersion); err != nil { + return err + } + for field, value := range map[string]string{"session_id": request.SessionID, "request_id": request.RequestID} { + if err := validateID(field, value); err != nil { + return err + } + } + if request.Tick < 0 { + return &ValidationError{Field: "tick", Message: "must not be negative"} + } + if len(request.ProposalIDs) == 0 || len(request.ProposalIDs) > 64 { + return &ValidationError{Field: "proposal_ids", Message: "must contain 1-64 proposal ids"} + } + if err := validateTags("proposal_ids", request.ProposalIDs, 64); err != nil { + return err + } + return validateTags("exclusive_target_ids", request.ExclusiveTargetIDs, 64) +} + +func ValidateBatchCommit(request BatchCommitRequest) error { + if err := validateVersion(request.ProtocolVersion); err != nil { + return err + } + for field, value := range map[string]string{"session_id": request.SessionID, "request_id": request.RequestID} { + if err := validateID(field, value); err != nil { + return err + } + } + if request.Tick < 0 { + return &ValidationError{Field: "tick", Message: "must not be negative"} + } + if len(request.Items) == 0 || len(request.Items) > 64 { + return &ValidationError{Field: "items", Message: "must contain 1-64 commit items"} + } + proposalIDs := make(map[string]struct{}, len(request.Items)) + eventIDs := make(map[string]struct{}, len(request.Items)) + for index, item := range request.Items { + field := fmt.Sprintf("items[%d]", index) + if err := validateCommitItem(field, item); err != nil { + return err + } + if _, exists := proposalIDs[item.ProposalID]; exists { + return &ValidationError{Field: "items", Message: "proposal ids must be unique"} + } + if _, exists := eventIDs[item.EventID]; exists { + return &ValidationError{Field: "items", Message: "event ids must be unique"} + } + proposalIDs[item.ProposalID] = struct{}{} + eventIDs[item.EventID] = struct{}{} + } + return nil +} + +func validateCommitItem(field string, item CommitItem) error { + if err := validateID(field+".proposal_id", item.ProposalID); err != nil { + return err + } + if err := validateID(field+".event_id", item.EventID); err != nil { + return err + } + if err := validateText(field+".outcome", item.Outcome, 1000, item.Accepted); err != nil { + return err + } + if err := validateTags(field+".tags", item.Tags, 32); err != nil { + return err + } + if len(item.Facts) > 64 || len(item.GoalUpdates) > 32 { + return &ValidationError{Field: field, Message: "contains too many updates"} + } + for index, fact := range item.Facts { + if err := validateFact(fmt.Sprintf("%s.facts[%d]", field, index), fact); err != nil { + return err + } + } + for index, update := range item.GoalUpdates { + base := fmt.Sprintf("%s.goal_updates[%d]", field, index) + if err := validateID(base+".goal_id", update.GoalID); err != nil { + return err + } + if update.ProgressDelta < -1000 || update.ProgressDelta > 1000 { + return &ValidationError{Field: base + ".progress_delta", Message: "must be between -1000 and 1000"} + } + if update.Status != "" && update.Status != "active" && update.Status != "completed" && update.Status != "released" { + return &ValidationError{Field: base + ".status", Message: "must be active, completed, or released"} + } + } + return nil +} diff --git a/protocol/state_validate.go b/protocol/state_validate.go index d12fc38..9ff3349 100644 --- a/protocol/state_validate.go +++ b/protocol/state_validate.go @@ -29,6 +29,9 @@ func ValidateSessionState(state SessionState) error { if state.Revision == 0 { return &ValidationError{Field: "state.revision", Message: "must be greater than zero"} } + if HasFeature(state.Features, FeatureArbitration) && state.WorldRevision == 0 { + return &ValidationError{Field: "state.world_revision", Message: "must be greater than zero when arbitration is enabled"} + } if !hashPattern.MatchString(state.HeadHash) { return &ValidationError{Field: "state.head_hash", Message: "must be a lowercase SHA-256 hash"} } @@ -46,6 +49,14 @@ func ValidateSessionState(state SessionState) error { if actor.NextThinkTick < 0 { return &ValidationError{Field: base + ".next_think_tick", Message: "must not be negative"} } + if actor.Activity != nil { + if !HasFeature(state.Features, FeatureActorActivity) { + return &ValidationError{Field: base + ".activity", Message: "requires actor-activity-v1"} + } + if err := validateActorActivity(base+".activity", *actor.Activity, state); err != nil { + return err + } + } if len(actor.Memories) > 128 { return &ValidationError{Field: base + ".memories", Message: "must contain at most 128 values"} } @@ -152,6 +163,23 @@ func ValidateSessionState(state SessionState) error { return err } } + if len(state.Arbitrations) > 32 { + return &ValidationError{Field: "state.arbitrations", Message: "must contain at most 32 values"} + } + if len(state.Arbitrations) > 0 && !HasFeature(state.Features, FeatureArbitration) { + return &ValidationError{Field: "state.arbitrations", Message: "requires arbitration-v1"} + } + arbitrationIDs := make(map[string]struct{}, len(state.Arbitrations)) + for index, record := range state.Arbitrations { + field := fmt.Sprintf("state.arbitrations[%d]", index) + if err := validateArbitrationRecord(field, record, state); err != nil { + return err + } + if _, exists := arbitrationIDs[record.ID]; exists { + return &ValidationError{Field: "state.arbitrations", Message: "record ids must be unique"} + } + arbitrationIDs[record.ID] = struct{}{} + } if len(state.Receipts) > 1024 { return &ValidationError{Field: "state.receipts", Message: "must contain at most 1024 values"} } @@ -269,8 +297,8 @@ func validateBeliefSet(field, key string, set BeliefSet, stateRevision uint64) e } sources[claim.Fact.SourceEventID] = struct{}{} objects[claim.Fact.Object] = struct{}{} - if claim.ObservedRevision == 0 || claim.ObservedRevision > stateRevision { - return &ValidationError{Field: claimField + ".observed_revision", Message: "must reference an existing revision"} + if claim.ObservedRevision == 0 { + return &ValidationError{Field: claimField + ".observed_revision", Message: "must be greater than zero"} } selectedExists = selectedExists || claim.Fact.SourceEventID == set.SelectedSourceEventID } @@ -283,6 +311,75 @@ func validateBeliefSet(field, key string, set BeliefSet, stateRevision uint64) e return nil } +func validateActorActivity(field string, activity ActorActivity, state SessionState) error { + if activity.RegionID != "" { + if err := validateID(field+".region_id", activity.RegionID); err != nil { + return err + } + } + if activity.State != "awake" && activity.State != "dormant" { + return &ValidationError{Field: field + ".state", Message: "must be awake or dormant"} + } + if err := validateText(field+".reason", activity.Reason, 300, false); err != nil { + return err + } + if activity.UpdatedTick < 0 || activity.UpdatedTick > state.Tick { + return &ValidationError{Field: field + ".updated_tick", Message: "must reference the current timeline"} + } + if activity.UpdatedRevision == 0 { + return &ValidationError{Field: field + ".updated_revision", Message: "must be greater than zero"} + } + return nil +} + +func validateArbitrationRecord(field string, record ArbitrationRecord, state SessionState) error { + if err := validateID(field+".id", record.ID); err != nil { + return err + } + if err := validateID(field+".request_id", record.RequestID); err != nil { + return err + } + if record.Tick < 0 { + return &ValidationError{Field: field + ".tick", Message: "must not be negative"} + } + if record.BasedOnWorldRevision == 0 || record.BasedOnWorldRevision > state.WorldRevision { + return &ValidationError{Field: field + ".based_on_world_revision", Message: "must reference an existing world revision"} + } + if record.CreatedRevision == 0 { + return &ValidationError{Field: field + ".created_revision", Message: "must be greater than zero"} + } + if len(record.Decisions) == 0 || len(record.Decisions) > 64 { + return &ValidationError{Field: field + ".decisions", Message: "must contain 1-64 decisions"} + } + proposalIDs := make(map[string]struct{}, len(record.Decisions)) + for index, decision := range record.Decisions { + base := fmt.Sprintf("%s.decisions[%d]", field, index) + if err := validateID(base+".proposal_id", decision.ProposalID); err != nil { + return err + } + if err := validateID(base+".actor_id", decision.ActorID); err != nil { + return err + } + if _, exists := state.Actors[decision.ActorID]; !exists { + return &ValidationError{Field: base + ".actor_id", Message: "references an unknown actor"} + } + if decision.Status != "selected" && decision.Status != "deferred" { + return &ValidationError{Field: base + ".status", Message: "must be selected or deferred"} + } + if err := validateText(base+".reason", decision.Reason, 300, true); err != nil { + return err + } + if err := validateTags(base+".conflicting_proposal_ids", decision.ConflictingProposalIDs, 64); err != nil { + return err + } + if _, exists := proposalIDs[decision.ProposalID]; exists { + return &ValidationError{Field: field + ".decisions", Message: "proposal ids must be unique"} + } + proposalIDs[decision.ProposalID] = struct{}{} + } + return nil +} + func validateProposal(field string, state SessionState, actor ActorState, proposal ActionProposal, memoryIDs map[string]struct{}) error { for suffix, value := range map[string]string{ ".id": proposal.ID, ".session_id": proposal.SessionID, ".request_id": proposal.RequestID, ".actor_id": proposal.ActorID, @@ -300,6 +397,11 @@ func validateProposal(field string, state SessionState, actor ActorState, propos if !hashPattern.MatchString(proposal.BasedOnHeadHash) { return &ValidationError{Field: field + ".based_on_head_hash", Message: "must be a lowercase SHA-256 hash"} } + if HasFeature(state.Features, FeatureArbitration) { + if proposal.BasedOnWorldRevision == 0 || proposal.BasedOnWorldRevision > state.WorldRevision { + return &ValidationError{Field: field + ".based_on_world_revision", Message: "must reference an existing world revision"} + } + } if err := validateAction(field+".action", proposal.Action); err != nil { return err } @@ -333,9 +435,23 @@ func validateProposal(field string, state SessionState, actor ActorState, propos for _, goal := range actor.Goals { found = found || goal.ID == proposal.GoalID } + if proposal.ProposedGoal != nil { + if !HasFeature(state.Features, FeatureGoalCandidates) { + return &ValidationError{Field: field + ".proposed_goal", Message: "requires goal-candidates-v1"} + } + if err := validateGoal(field+".proposed_goal", *proposal.ProposedGoal); err != nil { + return err + } + if proposal.ProposedGoal.ID != proposal.GoalID || proposal.ProposedGoal.Progress != 0 || proposal.ProposedGoal.Status != "active" { + return &ValidationError{Field: field + ".proposed_goal", Message: "must match an active zero-progress goal_id"} + } + found = true + } if !found { return &ValidationError{Field: field + ".goal_id", Message: "references an unknown goal"} } + } else if proposal.ProposedGoal != nil { + return &ValidationError{Field: field + ".proposed_goal", Message: "requires goal_id"} } if proposal.Status != "pending" && proposal.Status != "accepted" && proposal.Status != "rejected" { return &ValidationError{Field: field + ".status", Message: "must be pending, accepted, or rejected"} diff --git a/protocol/types.go b/protocol/types.go index 21e8499..eb63c93 100644 --- a/protocol/types.go +++ b/protocol/types.go @@ -107,22 +107,24 @@ type ActionSpec struct { } type ActionProposal struct { - ID string `json:"id"` - SessionID string `json:"session_id"` - RequestID string `json:"request_id"` - ActorID string `json:"actor_id"` - Tick int64 `json:"tick"` - BasedOnRevision uint64 `json:"based_on_revision"` - BasedOnHeadHash string `json:"based_on_head_hash"` - CreatedRevision uint64 `json:"created_revision"` - Action ActionSpec `json:"action"` - Stance string `json:"stance"` - Summary string `json:"summary"` - Rationale string `json:"rationale"` - PolicySource string `json:"policy_source,omitempty"` - RecalledMemoryIDs []string `json:"recalled_memory_ids,omitempty"` - GoalID string `json:"goal_id,omitempty"` - Status string `json:"status"` + ID string `json:"id"` + SessionID string `json:"session_id"` + RequestID string `json:"request_id"` + ActorID string `json:"actor_id"` + Tick int64 `json:"tick"` + BasedOnRevision uint64 `json:"based_on_revision"` + BasedOnHeadHash string `json:"based_on_head_hash"` + BasedOnWorldRevision uint64 `json:"based_on_world_revision,omitempty"` + CreatedRevision uint64 `json:"created_revision"` + Action ActionSpec `json:"action"` + Stance string `json:"stance"` + Summary string `json:"summary"` + Rationale string `json:"rationale"` + PolicySource string `json:"policy_source,omitempty"` + RecalledMemoryIDs []string `json:"recalled_memory_ids,omitempty"` + GoalID string `json:"goal_id,omitempty"` + ProposedGoal *Goal `json:"proposed_goal,omitempty"` + Status string `json:"status"` } type ActorState struct { @@ -133,6 +135,7 @@ type ActorState struct { BeliefSets map[string]BeliefSet `json:"belief_sets,omitempty"` RecentActions []ActionProposal `json:"recent_actions,omitempty"` NextThinkTick int64 `json:"next_think_tick"` + Activity *ActorActivity `json:"activity,omitempty"` } type RequestReceipt struct { @@ -149,9 +152,11 @@ type SessionState struct { Features []string `json:"features,omitempty"` Tick int64 `json:"tick"` Revision uint64 `json:"revision"` + WorldRevision uint64 `json:"world_revision,omitempty"` HeadHash string `json:"head_hash"` Actors map[string]ActorState `json:"actors"` Proposals map[string]ActionProposal `json:"proposals,omitempty"` + Arbitrations []ArbitrationRecord `json:"arbitrations,omitempty"` Receipts map[string]RequestReceipt `json:"receipts,omitempty"` } @@ -190,6 +195,7 @@ type ProposeRequest struct { Intent string `json:"intent"` Tags []string `json:"tags,omitempty"` CandidateActions []ActionSpec `json:"candidate_actions"` + CandidateGoals []Goal `json:"candidate_goals,omitempty"` Urgent bool `json:"urgent,omitempty"` } @@ -226,15 +232,17 @@ type RestoreRequest struct { } type DueAgentsRequest struct { - ProtocolVersion string `json:"protocol_version"` - SessionID string `json:"session_id"` - Tick int64 `json:"tick"` - Limit int `json:"limit"` + ProtocolVersion string `json:"protocol_version"` + SessionID string `json:"session_id"` + Tick int64 `json:"tick"` + Limit int `json:"limit"` + RegionIDs []string `json:"region_ids,omitempty"` } type DueAgent struct { ActorID string `json:"actor_id"` NextThinkTick int64 `json:"next_think_tick"` + RegionID string `json:"region_id,omitempty"` } type DueAgentsResponse struct { diff --git a/protocol/validate.go b/protocol/validate.go index bba1f3c..d7443bf 100644 --- a/protocol/validate.go +++ b/protocol/validate.go @@ -332,6 +332,23 @@ func ValidatePropose(request ProposeRequest) error { } seen[action.ID] = struct{}{} } + if len(request.CandidateGoals) > 8 { + return &ValidationError{Field: "candidate_goals", Message: "must contain at most 8 goals"} + } + goalIDs := make(map[string]struct{}, len(request.CandidateGoals)) + for index, goal := range request.CandidateGoals { + field := fmt.Sprintf("candidate_goals[%d]", index) + if err := validateGoal(field, goal); err != nil { + return err + } + if goal.Progress != 0 || goal.Status != "active" { + return &ValidationError{Field: field, Message: "candidate goals must be active with zero progress"} + } + if _, exists := goalIDs[goal.ID]; exists { + return &ValidationError{Field: "candidate_goals", Message: "goal ids must be unique"} + } + goalIDs[goal.ID] = struct{}{} + } return nil } @@ -396,7 +413,7 @@ func ValidateDueAgents(request DueAgentsRequest) error { if request.Limit < 1 || request.Limit > 128 { return &ValidationError{Field: "limit", Message: "must be between 1 and 128"} } - return nil + return validateTags("region_ids", request.RegionIDs, 32) } func ValidateRestore(request RestoreRequest) error { diff --git a/protocol/validate_test.go b/protocol/validate_test.go index d03bb4a..bb4fac1 100644 --- a/protocol/validate_test.go +++ b/protocol/validate_test.go @@ -65,6 +65,35 @@ func TestProposalRequiresUniqueWhitelistedShape(t *testing.T) { } } +func TestLivingWorldRequestValidation(t *testing.T) { + proposal := protocol.ProposeRequest{ + ProtocolVersion: protocol.Version, SessionID: "session.test", RequestID: "proposal.test", ActorID: "npc.test", + Intent: "choose", CandidateActions: []protocol.ActionSpec{{ID: "wait", Kind: "wait", Description: "wait"}}, + CandidateGoals: []protocol.Goal{{ID: "goal.new", Description: "A bounded goal", Priority: 3, TargetProgress: 2, Status: "active"}}, + } + if err := protocol.ValidatePropose(proposal); err != nil { + t.Fatalf("valid candidate goal should pass: %v", err) + } + proposal.CandidateGoals[0].Progress = 1 + if err := protocol.ValidatePropose(proposal); err == nil { + t.Fatal("candidate goal with progress should fail") + } + activity := protocol.SetActorActivityRequest{ + ProtocolVersion: protocol.Version, SessionID: "session.test", RequestID: "activity.test", Tick: 1, + Updates: []protocol.ActorActivityUpdate{{ActorID: "npc.test", RegionID: "region.test", State: "sleeping"}}, + } + if err := protocol.ValidateSetActorActivity(activity); err == nil { + t.Fatal("unknown activity state should fail") + } + batch := protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, SessionID: "session.test", RequestID: "batch.test", + Items: []protocol.CommitItem{{ProposalID: "proposal.one", EventID: "event.one", Accepted: true}}, + } + if err := protocol.ValidateBatchCommit(batch); err == nil { + t.Fatal("accepted batch item without outcome should fail") + } +} + func validCreate() protocol.CreateSessionRequest { return protocol.CreateSessionRequest{ ProtocolVersion: protocol.Version, diff --git a/runtime/engine.go b/runtime/engine.go index c2912f6..79622b1 100644 --- a/runtime/engine.go +++ b/runtime/engine.go @@ -160,6 +160,20 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( session.mu.Unlock() return protocol.ActionProposal{}, false, NewError("actor_disabled", "actor is disabled", ErrConflict) } + if len(request.CandidateGoals) > 0 && !protocol.HasFeature(session.state.Features, protocol.FeatureGoalCandidates) { + session.mu.Unlock() + return protocol.ActionProposal{}, false, NewFieldError("feature_not_enabled", "candidate goals require goal-candidates-v1", "candidate_goals", ErrConflict) + } + for index, goal := range request.CandidateGoals { + if goalExists(actor, goal.ID) { + session.mu.Unlock() + return protocol.ActionProposal{}, false, NewFieldError("goal_exists", "candidate goal is already part of actor state", fmt.Sprintf("candidate_goals[%d].id", index), ErrConflict) + } + } + if actor.Activity != nil && actor.Activity.State == "dormant" { + session.mu.Unlock() + return protocol.ActionProposal{}, false, NewError("actor_dormant", "actor is dormant and must be woken by the game", ErrNotDue) + } if request.Tick < session.state.Tick { session.mu.Unlock() return protocol.ActionProposal{}, false, NewFieldError("tick_regressed", "proposal tick is older than session state", "tick", ErrConflict) @@ -175,6 +189,8 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( } baseRevision := session.state.Revision baseHash := session.state.HeadHash + baseWorldRevision := session.state.WorldRevision + arbitrationEnabled := protocol.HasFeature(session.state.Features, protocol.FeatureArbitration) session.mu.Unlock() draft, err := e.policy.Propose(ctx, PolicyContext{State: stateCopy, Actor: actor, Request: request}) @@ -187,7 +203,7 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( if err := ctx.Err(); err != nil { return protocol.ActionProposal{}, false, NewError("proposal_canceled", "proposal request was canceled", err) } - selected, err := validateDraft(request, actor, draft) + selected, proposedGoal, err := validateDraft(request, actor, draft) if err != nil { return protocol.ActionProposal{}, false, err } @@ -204,7 +220,9 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( } return protocol.ActionProposal{}, false, requestConflict(request.RequestID) } - if session.state.Revision != baseRevision || session.state.HeadHash != baseHash { + worldChanged := arbitrationEnabled && session.state.WorldRevision != baseWorldRevision + legacyChanged := !arbitrationEnabled && (session.state.Revision != baseRevision || session.state.HeadHash != baseHash) + if worldChanged || legacyChanged { return protocol.ActionProposal{}, false, NewError("state_changed", "session changed while policy was proposing; retry with a new request id", ErrStale) } proposalHash, err := hashJSON(struct { @@ -217,22 +235,24 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( return protocol.ActionProposal{}, false, NewError("proposal_id_failed", "could not identify proposal", err) } proposal := protocol.ActionProposal{ - ID: "proposal." + proposalHash[:24], - SessionID: request.SessionID, - RequestID: request.RequestID, - ActorID: request.ActorID, - Tick: request.Tick, - BasedOnRevision: baseRevision, - BasedOnHeadHash: baseHash, - CreatedRevision: baseRevision + 1, - Action: selected, - Stance: draft.Stance, - Summary: draft.Summary, - Rationale: draft.Rationale, - PolicySource: policySource(draft.PolicySource), - RecalledMemoryIDs: append([]string(nil), draft.RecalledMemoryIDs...), - GoalID: draft.GoalID, - Status: "pending", + ID: "proposal." + proposalHash[:24], + SessionID: request.SessionID, + RequestID: request.RequestID, + ActorID: request.ActorID, + Tick: request.Tick, + BasedOnRevision: baseRevision, + BasedOnHeadHash: baseHash, + BasedOnWorldRevision: baseWorldRevision, + CreatedRevision: session.state.Revision + 1, + Action: selected, + Stance: draft.Stance, + Summary: draft.Summary, + Rationale: draft.Rationale, + PolicySource: policySource(draft.PolicySource), + RecalledMemoryIDs: append([]string(nil), draft.RecalledMemoryIDs...), + GoalID: draft.GoalID, + ProposedGoal: proposedGoal, + Status: "pending", } event, err := newEvent(session.state, EventProposed, request.RequestID, proposedPayload{Proposal: proposal}, e.now()) if err != nil { @@ -267,7 +287,9 @@ func (e *Engine) Commit(request protocol.CommitRequest) (protocol.MutationResult if proposal.Status != "pending" { return protocol.MutationResult{}, NewFieldError("proposal_resolved", "proposal was already resolved", "proposal_id", ErrConflict) } - if request.Accepted && proposal.CreatedRevision != session.state.Revision { + worldRevisionMismatch := proposal.BasedOnWorldRevision > 0 && proposal.BasedOnWorldRevision != session.state.WorldRevision + legacyRevisionMismatch := proposal.BasedOnWorldRevision == 0 && proposal.CreatedRevision != session.state.Revision + if request.Accepted && (worldRevisionMismatch || legacyRevisionMismatch) { return protocol.MutationResult{}, NewError("proposal_stale", "session changed after the proposal was created", ErrStale) } if request.Tick < session.state.Tick || request.Tick < proposal.Tick { @@ -275,7 +297,7 @@ func (e *Engine) Commit(request protocol.CommitRequest) (protocol.MutationResult } actor := session.state.Actors[proposal.ActorID] for index, update := range request.GoalUpdates { - if !goalExists(actor, update.GoalID) { + if !goalExists(actor, update.GoalID) && (proposal.ProposedGoal == nil || proposal.ProposedGoal.ID != update.GoalID) { return protocol.MutationResult{}, NewFieldError("unknown_goal", "goal update references an unknown goal", fmt.Sprintf("goal_updates[%d].goal_id", index), ErrNotFound) } } @@ -289,6 +311,174 @@ func (e *Engine) Commit(request protocol.CommitRequest) (protocol.MutationResult return mutationResult(session.state, false), nil } +func (e *Engine) CommitBatch(request protocol.BatchCommitRequest) (protocol.MutationResult, error) { + if err := protocol.ValidateBatchCommit(request); err != nil { + return protocol.MutationResult{}, validationError(err) + } + session, err := e.session(request.SessionID) + if err != nil { + return protocol.MutationResult{}, err + } + session.mu.Lock() + defer session.mu.Unlock() + if !protocol.HasFeature(session.state.Features, protocol.FeatureArbitration) { + return protocol.MutationResult{}, NewError("feature_not_enabled", "batch commit requires arbitration-v1", ErrConflict) + } + if receipt, found := session.state.Receipts[request.RequestID]; found { + if receipt.Kind == EventBatchCommitted { + return mutationResult(session.state, true), nil + } + return protocol.MutationResult{}, requestConflict(request.RequestID) + } + if request.Tick < session.state.Tick { + return protocol.MutationResult{}, NewFieldError("tick_regressed", "batch commit tick is older than session state", "tick", ErrConflict) + } + actors := make(map[string]struct{}, len(request.Items)) + for index, item := range request.Items { + proposal, exists := session.state.Proposals[item.ProposalID] + if !exists { + return protocol.MutationResult{}, NewFieldError("unknown_proposal", "batch item references an unknown proposal", fmt.Sprintf("items[%d].proposal_id", index), ErrNotFound) + } + if proposal.Status != "pending" { + return protocol.MutationResult{}, NewFieldError("proposal_resolved", "batch item references a resolved proposal", fmt.Sprintf("items[%d].proposal_id", index), ErrConflict) + } + if proposal.BasedOnWorldRevision == 0 || proposal.BasedOnWorldRevision != session.state.WorldRevision { + return protocol.MutationResult{}, NewError("proposal_stale", "batch contains a proposal from another world revision", ErrStale) + } + if request.Tick < proposal.Tick { + return protocol.MutationResult{}, NewFieldError("tick_regressed", "batch commit tick is older than a proposal", "tick", ErrConflict) + } + if _, duplicate := actors[proposal.ActorID]; duplicate { + return protocol.MutationResult{}, NewFieldError("duplicate_actor", "batch may contain at most one proposal per actor", "items", ErrConflict) + } + actors[proposal.ActorID] = struct{}{} + if eventIDExists(session.state, item.EventID) { + return protocol.MutationResult{}, NewFieldError("event_exists", "batch event id was already observed", fmt.Sprintf("items[%d].event_id", index), ErrConflict) + } + actor := session.state.Actors[proposal.ActorID] + for goalIndex, update := range item.GoalUpdates { + if !goalExists(actor, update.GoalID) && (proposal.ProposedGoal == nil || proposal.ProposedGoal.ID != update.GoalID) { + return protocol.MutationResult{}, NewFieldError("unknown_goal", "goal update references an unknown goal", fmt.Sprintf("items[%d].goal_updates[%d].goal_id", index, goalIndex), ErrNotFound) + } + } + } + event, err := newEvent(session.state, EventBatchCommitted, request.RequestID, batchCommittedPayload{Request: request}, e.now()) + if err != nil { + return protocol.MutationResult{}, NewError("event_encode_failed", "could not encode batch commit", err) + } + if err := e.appendAndApply(session, event); err != nil { + return protocol.MutationResult{}, err + } + return mutationResult(session.state, false), nil +} + +func (e *Engine) SetActorActivity(request protocol.SetActorActivityRequest) (protocol.MutationResult, error) { + if err := protocol.ValidateSetActorActivity(request); err != nil { + return protocol.MutationResult{}, validationError(err) + } + session, err := e.session(request.SessionID) + if err != nil { + return protocol.MutationResult{}, err + } + session.mu.Lock() + defer session.mu.Unlock() + if !protocol.HasFeature(session.state.Features, protocol.FeatureActorActivity) { + return protocol.MutationResult{}, NewError("feature_not_enabled", "actor activity requires actor-activity-v1", ErrConflict) + } + if receipt, found := session.state.Receipts[request.RequestID]; found { + if receipt.Kind == EventActivityUpdated { + return mutationResult(session.state, true), nil + } + return protocol.MutationResult{}, requestConflict(request.RequestID) + } + if request.Tick < session.state.Tick { + return protocol.MutationResult{}, NewFieldError("tick_regressed", "activity tick is older than session state", "tick", ErrConflict) + } + for index, update := range request.Updates { + if _, exists := session.state.Actors[update.ActorID]; !exists { + return protocol.MutationResult{}, NewFieldError("unknown_actor", "activity update references an unknown actor", fmt.Sprintf("updates[%d].actor_id", index), ErrNotFound) + } + } + event, err := newEvent(session.state, EventActivityUpdated, request.RequestID, activityUpdatedPayload{Request: request}, e.now()) + if err != nil { + return protocol.MutationResult{}, NewError("event_encode_failed", "could not encode actor activity", err) + } + if err := e.appendAndApply(session, event); err != nil { + return protocol.MutationResult{}, err + } + return mutationResult(session.state, false), nil +} + +func (e *Engine) Arbitrate(request protocol.ArbitrateRequest) (protocol.ArbitrationRecord, bool, error) { + if err := protocol.ValidateArbitrate(request); err != nil { + return protocol.ArbitrationRecord{}, false, validationError(err) + } + session, err := e.session(request.SessionID) + if err != nil { + return protocol.ArbitrationRecord{}, false, err + } + session.mu.Lock() + defer session.mu.Unlock() + if !protocol.HasFeature(session.state.Features, protocol.FeatureArbitration) { + return protocol.ArbitrationRecord{}, false, NewError("feature_not_enabled", "world arbitration requires arbitration-v1", ErrConflict) + } + if receipt, found := session.state.Receipts[request.RequestID]; found { + if receipt.Kind != EventArbitrated { + return protocol.ArbitrationRecord{}, false, requestConflict(request.RequestID) + } + for _, record := range session.state.Arbitrations { + if record.ID == receipt.EntityID { + return record, true, nil + } + } + return protocol.ArbitrationRecord{}, false, NewError("arbitration_missing", "idempotent arbitration is no longer retained", ErrNotFound) + } + if request.Tick < session.state.Tick { + return protocol.ArbitrationRecord{}, false, NewFieldError("tick_regressed", "arbitration tick is older than session state", "tick", ErrConflict) + } + proposals := make([]protocol.ActionProposal, 0, len(request.ProposalIDs)) + actors := make(map[string]struct{}, len(request.ProposalIDs)) + for index, proposalID := range request.ProposalIDs { + proposal, exists := session.state.Proposals[proposalID] + if !exists { + return protocol.ArbitrationRecord{}, false, NewFieldError("unknown_proposal", "arbitration references an unknown proposal", fmt.Sprintf("proposal_ids[%d]", index), ErrNotFound) + } + if proposal.Status != "pending" { + return protocol.ArbitrationRecord{}, false, NewFieldError("proposal_resolved", "arbitration requires pending proposals", fmt.Sprintf("proposal_ids[%d]", index), ErrConflict) + } + if proposal.BasedOnWorldRevision == 0 || proposal.BasedOnWorldRevision != session.state.WorldRevision { + return protocol.ArbitrationRecord{}, false, NewError("proposal_stale", "arbitration contains a proposal from another world revision", ErrStale) + } + if _, duplicate := actors[proposal.ActorID]; duplicate { + return protocol.ArbitrationRecord{}, false, NewFieldError("duplicate_actor", "arbitration may contain at most one proposal per actor", "proposal_ids", ErrConflict) + } + actors[proposal.ActorID] = struct{}{} + proposals = append(proposals, proposal) + } + decisions := arbitrateProposals(session.state, proposals, request.ExclusiveTargetIDs) + recordHash, err := hashJSON(struct { + SessionID string `json:"session_id"` + RequestID string `json:"request_id"` + WorldRevision uint64 `json:"world_revision"` + }{request.SessionID, request.RequestID, session.state.WorldRevision}) + if err != nil { + return protocol.ArbitrationRecord{}, false, NewError("arbitration_id_failed", "could not identify arbitration", err) + } + record := protocol.ArbitrationRecord{ + ID: "arbitration." + recordHash[:24], RequestID: request.RequestID, Tick: request.Tick, + BasedOnWorldRevision: session.state.WorldRevision, CreatedRevision: session.state.Revision + 1, + Decisions: decisions, + } + event, err := newEvent(session.state, EventArbitrated, request.RequestID, arbitratedPayload{Record: record}, e.now()) + if err != nil { + return protocol.ArbitrationRecord{}, false, NewError("event_encode_failed", "could not encode arbitration", err) + } + if err := e.appendAndApply(session, event); err != nil { + return protocol.ArbitrationRecord{}, false, err + } + return record, false, nil +} + func (e *Engine) State(request protocol.SessionRequest) (protocol.SessionState, error) { if err := protocol.ValidateSessionRequest(request); err != nil { return protocol.SessionState{}, validationError(err) @@ -387,10 +577,26 @@ func (e *Engine) DueAgents(request protocol.DueAgentsRequest) (protocol.DueAgent } session.mu.Lock() defer session.mu.Unlock() + regions := make(map[string]struct{}, len(request.RegionIDs)) + for _, regionID := range request.RegionIDs { + regions[regionID] = struct{}{} + } agents := make([]protocol.DueAgent, 0) for id, actor := range session.state.Actors { + regionID := "" + if actor.Activity != nil { + if actor.Activity.State == "dormant" { + continue + } + regionID = actor.Activity.RegionID + } + if len(regions) > 0 { + if _, included := regions[regionID]; !included { + continue + } + } if actor.Enabled && actor.NextThinkTick <= request.Tick { - agents = append(agents, protocol.DueAgent{ActorID: id, NextThinkTick: actor.NextThinkTick}) + agents = append(agents, protocol.DueAgent{ActorID: id, NextThinkTick: actor.NextThinkTick, RegionID: regionID}) } } sort.Slice(agents, func(i, j int) bool { @@ -405,6 +611,85 @@ func (e *Engine) DueAgents(request protocol.DueAgentsRequest) (protocol.DueAgent return protocol.DueAgentsResponse{SessionID: request.SessionID, Tick: request.Tick, Agents: agents}, nil } +func arbitrateProposals(state protocol.SessionState, proposals []protocol.ActionProposal, exclusiveTargetIDs []string) []protocol.ArbitrationDecision { + exclusive := make(map[string]struct{}, len(exclusiveTargetIDs)) + for _, targetID := range exclusiveTargetIDs { + exclusive[targetID] = struct{}{} + } + values := append([]protocol.ActionProposal(nil), proposals...) + sort.Slice(values, func(i, j int) bool { + leftPriority := proposalGoalPriority(state.Actors[values[i].ActorID], values[i]) + rightPriority := proposalGoalPriority(state.Actors[values[j].ActorID], values[j]) + if leftPriority != rightPriority { + return leftPriority > rightPriority + } + if values[i].Tick != values[j].Tick { + return values[i].Tick < values[j].Tick + } + if values[i].ActorID != values[j].ActorID { + return values[i].ActorID < values[j].ActorID + } + return values[i].ID < values[j].ID + }) + claimed := make(map[string]string) + decisions := make([]protocol.ArbitrationDecision, 0, len(values)) + for _, proposal := range values { + conflicts := make([]string, 0) + claims := make([]string, 0) + for _, targetID := range proposal.Action.TargetIDs { + if _, isExclusive := exclusive[targetID]; !isExclusive { + continue + } + claims = append(claims, targetID) + if winnerID, occupied := claimed[targetID]; occupied { + conflicts = append(conflicts, winnerID) + } + } + conflicts = uniqueSorted(conflicts) + decision := protocol.ArbitrationDecision{ + ProposalID: proposal.ID, ActorID: proposal.ActorID, + Status: "selected", Reason: "No higher-priority proposal claimed the same exclusive target.", + } + if len(conflicts) > 0 { + decision.Status = "deferred" + decision.Reason = "A higher-priority proposal already claimed an exclusive target." + decision.ConflictingProposalIDs = conflicts + } else { + for _, targetID := range claims { + claimed[targetID] = proposal.ID + } + } + decisions = append(decisions, decision) + } + return decisions +} + +func proposalGoalPriority(actor protocol.ActorState, proposal protocol.ActionProposal) int { + for _, goal := range actor.Goals { + if goal.ID == proposal.GoalID { + return goal.Priority + } + } + if proposal.ProposedGoal != nil && proposal.ProposedGoal.ID == proposal.GoalID { + return proposal.ProposedGoal.Priority + } + return 0 +} + +func uniqueSorted(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + sort.Strings(result) + return result +} + func (e *Engine) session(id string) (*managedSession, error) { e.mu.RLock() session, exists := e.sessions[id] @@ -473,7 +758,7 @@ func goalExists(actor protocol.ActorState, goalID string) bool { return false } -func validateDraft(request protocol.ProposeRequest, actor protocol.ActorState, draft ProposalDraft) (protocol.ActionSpec, error) { +func validateDraft(request protocol.ProposeRequest, actor protocol.ActorState, draft ProposalDraft) (protocol.ActionSpec, *protocol.Goal, error) { var selected protocol.ActionSpec found := false for _, action := range request.CandidateActions { @@ -484,22 +769,22 @@ func validateDraft(request protocol.ProposeRequest, actor protocol.ActorState, d } } if !found { - return protocol.ActionSpec{}, NewFieldError("invalid_policy_output", "policy selected an action outside the candidate list", "action_id", ErrConflict) + return protocol.ActionSpec{}, nil, NewFieldError("invalid_policy_output", "policy selected an action outside the candidate list", "action_id", ErrConflict) } if draft.Stance != "engage" && draft.Stance != "partial" && draft.Stance != "redirect" && draft.Stance != "refuse" && draft.Stance != "wait" { - return protocol.ActionSpec{}, NewFieldError("invalid_policy_output", "policy returned an unsupported stance", "stance", ErrConflict) + return protocol.ActionSpec{}, nil, NewFieldError("invalid_policy_output", "policy returned an unsupported stance", "stance", ErrConflict) } if err := validatePolicyText("summary", draft.Summary, 500, true); err != nil { - return protocol.ActionSpec{}, err + return protocol.ActionSpec{}, nil, err } if err := validatePolicyText("rationale", draft.Rationale, 500, true); err != nil { - return protocol.ActionSpec{}, err + return protocol.ActionSpec{}, nil, err } if draft.PolicySource != "" && !validPolicySource(draft.PolicySource) { - return protocol.ActionSpec{}, NewFieldError("invalid_policy_output", "policy source is invalid", "policy_source", ErrConflict) + return protocol.ActionSpec{}, nil, NewFieldError("invalid_policy_output", "policy source is invalid", "policy_source", ErrConflict) } if len(draft.RecalledMemoryIDs) > 8 { - return protocol.ActionSpec{}, NewFieldError("invalid_policy_output", "policy recalled too many memories", "recalled_memory_ids", ErrConflict) + return protocol.ActionSpec{}, nil, NewFieldError("invalid_policy_output", "policy recalled too many memories", "recalled_memory_ids", ErrConflict) } memoryIDs := make(map[string]struct{}, len(actor.Memories)+len(actor.MemorySummaries)) for _, memory := range actor.Memories { @@ -511,17 +796,27 @@ func validateDraft(request protocol.ProposeRequest, actor protocol.ActorState, d seen := make(map[string]struct{}, len(draft.RecalledMemoryIDs)) for _, id := range draft.RecalledMemoryIDs { if _, exists := memoryIDs[id]; !exists { - return protocol.ActionSpec{}, NewFieldError("invalid_policy_output", "policy referenced an unknown memory", "recalled_memory_ids", ErrConflict) + return protocol.ActionSpec{}, nil, NewFieldError("invalid_policy_output", "policy referenced an unknown memory", "recalled_memory_ids", ErrConflict) } if _, exists := seen[id]; exists { - return protocol.ActionSpec{}, NewFieldError("invalid_policy_output", "policy repeated a memory id", "recalled_memory_ids", ErrConflict) + return protocol.ActionSpec{}, nil, NewFieldError("invalid_policy_output", "policy repeated a memory id", "recalled_memory_ids", ErrConflict) } seen[id] = struct{}{} } + var proposedGoal *protocol.Goal if draft.GoalID != "" && !goalExists(actor, draft.GoalID) { - return protocol.ActionSpec{}, NewFieldError("invalid_policy_output", "policy referenced an unknown goal", "goal_id", ErrConflict) + for index := range request.CandidateGoals { + if request.CandidateGoals[index].ID == draft.GoalID { + goal := request.CandidateGoals[index] + proposedGoal = &goal + break + } + } + if proposedGoal == nil { + return protocol.ActionSpec{}, nil, NewFieldError("invalid_policy_output", "policy referenced an unknown goal", "goal_id", ErrConflict) + } } - return selected, nil + return selected, proposedGoal, nil } func policySource(value string) string { diff --git a/runtime/living_world_test.go b/runtime/living_world_test.go new file mode 100644 index 0000000..ef38046 --- /dev/null +++ b/runtime/living_world_test.go @@ -0,0 +1,231 @@ +package runtime_test + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/sunrioa/rin/policy" + "github.com/sunrioa/rin/protocol" + rinruntime "github.com/sunrioa/rin/runtime" + "github.com/sunrioa/rin/store" +) + +func TestCandidateGoalIsAdoptedOnlyAfterAcceptedCommit(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + create := createRequest("session.goals") + create.Features = []string{protocol.FeatureGoalCandidates} + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + candidate := protocol.Goal{ + ID: "goal.restore-camera", Description: "Restore the damaged camera.", Motivation: "Recover a shared creative tool.", + Priority: 5, PreferredActions: []string{"talk"}, TargetProgress: 3, Status: "active", + } + request := proposeRequest("session.goals", "propose.goal-rejected", 0, nil) + request.CandidateGoals = []protocol.Goal{candidate} + rejected, _, err := engine.Propose(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if rejected.GoalID != candidate.ID || rejected.ProposedGoal == nil { + t.Fatalf("candidate goal was not represented in the proposal: %+v", rejected) + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, SessionID: create.SessionID, RequestID: "commit.goal-rejected", + ProposalID: rejected.ID, EventID: "event.goal-rejected", Tick: 0, Accepted: false, + }); err != nil { + t.Fatal(err) + } + state, _ := engine.State(sessionRequest(create.SessionID)) + if goalInState(state.Actors["npc.mira"], candidate.ID) { + t.Fatal("rejected candidate goal entered actor state") + } + + request.RequestID = "propose.goal-accepted" + accepted, _, err := engine.Propose(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, SessionID: create.SessionID, RequestID: "commit.goal-accepted", + ProposalID: accepted.ID, EventID: "event.goal-accepted", Tick: 0, Accepted: true, + Outcome: "Mira decided to ask how they could repair the camera together.", + }); err != nil { + t.Fatal(err) + } + state, _ = engine.State(sessionRequest(create.SessionID)) + goal, found := findGoal(state.Actors["npc.mira"], candidate.ID) + if !found || goal.Progress != 1 { + t.Fatalf("accepted candidate goal was not adopted and advanced: %+v", state.Actors["npc.mira"].Goals) + } +} + +func TestDormantActorIsExcludedUntilGameWakesIt(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + create := createRequest("session.activity") + create.Features = []string{protocol.FeatureActorActivity} + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + if _, err := engine.SetActorActivity(protocol.SetActorActivityRequest{ + ProtocolVersion: protocol.Version, SessionID: create.SessionID, RequestID: "activity.sleep", Tick: 1, + Updates: []protocol.ActorActivityUpdate{{ActorID: "npc.mira", RegionID: "region.harbor", State: "dormant", Reason: "region unloaded"}}, + }); err != nil { + t.Fatal(err) + } + due, err := engine.DueAgents(protocol.DueAgentsRequest{ProtocolVersion: protocol.Version, SessionID: create.SessionID, Tick: 10, Limit: 10}) + if err != nil || len(due.Agents) != 0 { + t.Fatalf("dormant actor should not be due: %+v err=%v", due, err) + } + _, _, err = engine.Propose(context.Background(), proposeRequest(create.SessionID, "propose.sleeping", 10, nil)) + if !errors.Is(err, rinruntime.ErrNotDue) || rinruntime.ErrorCode(err) != "actor_dormant" { + t.Fatalf("dormant actor should not propose: %v", err) + } + if _, err := engine.SetActorActivity(protocol.SetActorActivityRequest{ + ProtocolVersion: protocol.Version, SessionID: create.SessionID, RequestID: "activity.wake", Tick: 2, + Updates: []protocol.ActorActivityUpdate{{ActorID: "npc.mira", RegionID: "region.harbor", State: "awake", Reason: "region loaded"}}, + }); err != nil { + t.Fatal(err) + } + due, err = engine.DueAgents(protocol.DueAgentsRequest{ + ProtocolVersion: protocol.Version, SessionID: create.SessionID, Tick: 10, Limit: 10, RegionIDs: []string{"region.market"}, + }) + if err != nil || len(due.Agents) != 0 { + t.Fatalf("region filter should exclude actor: %+v err=%v", due, err) + } + due, err = engine.DueAgents(protocol.DueAgentsRequest{ + ProtocolVersion: protocol.Version, SessionID: create.SessionID, Tick: 10, Limit: 10, RegionIDs: []string{"region.harbor"}, + }) + if err != nil || len(due.Agents) != 1 || due.Agents[0].RegionID != "region.harbor" { + t.Fatalf("awake actor should be due in its region: %+v err=%v", due, err) + } +} + +func TestArbitrationIsDeterministicAndBatchCommitIsAtomic(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + create := twoActorWorldRequest("session.arbitration") + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + miraRequest := targetedProposalRequest(create.SessionID, "propose.mira", "npc.mira") + mira, _, err := engine.Propose(context.Background(), miraRequest) + if err != nil { + t.Fatal(err) + } + orenRequest := targetedProposalRequest(create.SessionID, "propose.oren", "npc.oren") + oren, _, err := engine.Propose(context.Background(), orenRequest) + if err != nil { + t.Fatalf("another proposal should not change world revision: %v", err) + } + if mira.BasedOnWorldRevision != 1 || oren.BasedOnWorldRevision != 1 { + t.Fatalf("proposals should share world revision: mira=%d oren=%d", mira.BasedOnWorldRevision, oren.BasedOnWorldRevision) + } + + first, _, err := engine.Arbitrate(protocol.ArbitrateRequest{ + ProtocolVersion: protocol.Version, SessionID: create.SessionID, RequestID: "arbitrate.first", Tick: 0, + ProposalIDs: []string{oren.ID, mira.ID}, ExclusiveTargetIDs: []string{"object.camera"}, + }) + if err != nil { + t.Fatal(err) + } + second, _, err := engine.Arbitrate(protocol.ArbitrateRequest{ + ProtocolVersion: protocol.Version, SessionID: create.SessionID, RequestID: "arbitrate.second", Tick: 0, + ProposalIDs: []string{mira.ID, oren.ID}, ExclusiveTargetIDs: []string{"object.camera"}, + }) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(first.Decisions, second.Decisions) { + t.Fatalf("arbitration depended on input order: first=%+v second=%+v", first.Decisions, second.Decisions) + } + if len(first.Decisions) != 2 || first.Decisions[0].ProposalID != mira.ID || first.Decisions[0].Status != "selected" || first.Decisions[1].Status != "deferred" { + t.Fatalf("unexpected arbitration decisions: %+v", first.Decisions) + } + + result, err := engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, SessionID: create.SessionID, RequestID: "commit.batch", Tick: 0, + Items: []protocol.CommitItem{ + {ProposalID: mira.ID, EventID: "event.mira", Accepted: true, Outcome: "Mira reached the camera first."}, + {ProposalID: oren.ID, EventID: "event.oren", Accepted: false}, + }, + }) + if err != nil { + t.Fatal(err) + } + if result.Revision != 6 { + t.Fatalf("expected one atomic batch event at revision 6, got %+v", result) + } + state, _ := engine.State(sessionRequest(create.SessionID)) + if state.WorldRevision != 2 || state.Proposals[mira.ID].Status != "accepted" || state.Proposals[oren.ID].Status != "rejected" { + t.Fatalf("unexpected post-batch state: %+v", state) + } + if _, err := engine.Snapshot(sessionRequest(create.SessionID)); err != nil { + t.Fatalf("coordinated world snapshot should validate: %v", err) + } +} + +func TestBatchCommitRejectsStaleWorldWithoutPartialMutation(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + create := twoActorWorldRequest("session.batch-stale") + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + proposal, _, err := engine.Propose(context.Background(), targetedProposalRequest(create.SessionID, "propose.before-change", "npc.mira")) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Observe(observeRequest(create.SessionID, "observe.change", "event.change", 0)); err != nil { + t.Fatal(err) + } + _, err = engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, SessionID: create.SessionID, RequestID: "commit.stale-batch", Tick: 0, + Items: []protocol.CommitItem{{ProposalID: proposal.ID, EventID: "event.should-not-commit", Accepted: true, Outcome: "Should not happen."}}, + }) + if !errors.Is(err, rinruntime.ErrStale) { + t.Fatalf("expected stale batch rejection, got %v", err) + } + state, _ := engine.State(sessionRequest(create.SessionID)) + if state.Proposals[proposal.ID].Status != "pending" || len(state.Actors["npc.mira"].RecentActions) != 0 { + t.Fatalf("failed batch partially mutated state: %+v", state) + } +} + +func twoActorWorldRequest(sessionID string) protocol.CreateSessionRequest { + create := createRequest(sessionID) + create.Features = []string{protocol.FeatureArbitration} + oren := create.Actors[0] + oren.ID = "npc.oren" + oren.DisplayName = "Oren" + oren.Goals = []protocol.Goal{{ + ID: "goal.document", Description: "Document the damaged camera.", Priority: 2, + PreferredActions: []string{"talk"}, TargetProgress: 3, Status: "active", + }} + create.Actors = append(create.Actors, oren) + return create +} + +func targetedProposalRequest(sessionID, requestID, actorID string) protocol.ProposeRequest { + request := proposeRequest(sessionID, requestID, 0, nil) + request.ActorID = actorID + request.CandidateActions = []protocol.ActionSpec{ + {ID: "talk", Kind: "dialogue", Description: "inspect the camera", TargetIDs: []string{"object.camera"}}, + {ID: "wait", Kind: "wait", Description: "wait"}, + } + return request +} + +func goalInState(actor protocol.ActorState, goalID string) bool { + _, found := findGoal(actor, goalID) + return found +} + +func findGoal(actor protocol.ActorState, goalID string) (protocol.Goal, bool) { + for _, goal := range actor.Goals { + if goal.ID == goalID { + return goal, true + } + } + return protocol.Goal{}, false +} diff --git a/runtime/reducer.go b/runtime/reducer.go index 3b5c562..0a46312 100644 --- a/runtime/reducer.go +++ b/runtime/reducer.go @@ -13,6 +13,7 @@ const ( maxRecentActions = 32 maxProposals = 64 maxReceipts = 1024 + maxArbitrations = 32 ) type createdPayload struct { @@ -31,6 +32,18 @@ type committedPayload struct { Request protocol.CommitRequest `json:"request"` } +type batchCommittedPayload struct { + Request protocol.BatchCommitRequest `json:"request"` +} + +type activityUpdatedPayload struct { + Request protocol.SetActorActivityRequest `json:"request"` +} + +type arbitratedPayload struct { + Record protocol.ArbitrationRecord `json:"record"` +} + type restoredPayload struct { Snapshot protocol.Snapshot `json:"snapshot"` } @@ -49,6 +62,12 @@ func applyEvent(state protocol.SessionState, event protocol.EventRecord) (protoc err = applyProposed(&state, event) case EventCommitted: err = applyCommitted(&state, event) + case EventBatchCommitted: + err = applyBatchCommitted(&state, event) + case EventActivityUpdated: + err = applyActivityUpdated(&state, event) + case EventArbitrated: + err = applyArbitrated(&state, event) case EventSessionRestored: state, err = applyRestored(state, event) default: @@ -83,7 +102,7 @@ func applyCreated(state protocol.SessionState, event protocol.EventRecord) (prot NextThinkTick: 0, } } - return protocol.SessionState{ + created := protocol.SessionState{ ProtocolVersion: protocol.Version, SessionID: request.SessionID, Binding: request.Binding, @@ -94,7 +113,11 @@ func applyCreated(state protocol.SessionState, event protocol.EventRecord) (prot Receipts: map[string]protocol.RequestReceipt{ request.RequestID: {Kind: EventSessionCreated, EntityID: request.SessionID, Revision: event.Sequence}, }, - }, nil + } + if protocol.HasFeature(request.Features, protocol.FeatureArbitration) { + created.WorldRevision = 1 + } + return created, nil } func applyObserved(state *protocol.SessionState, event protocol.EventRecord) error { @@ -140,6 +163,7 @@ func applyObserved(state *protocol.SessionState, event protocol.EventRecord) err state.Tick = request.Tick } state.Receipts[request.RequestID] = protocol.RequestReceipt{Kind: EventObserved, EntityID: request.EventID, Revision: event.Sequence} + advanceWorldRevision(state) return nil } @@ -164,60 +188,133 @@ func applyCommitted(state *protocol.SessionState, event protocol.EventRecord) er return fmt.Errorf("%w: decode commit payload: %v", ErrCorruptLog, err) } request := payload.Request - proposal, exists := state.Proposals[request.ProposalID] + item := protocol.CommitItem{ + ProposalID: request.ProposalID, EventID: request.EventID, Accepted: request.Accepted, + Outcome: request.Outcome, Tags: request.Tags, Facts: request.Facts, GoalUpdates: request.GoalUpdates, + } + if err := applyCommitItem(state, item, request.Tick, event.Sequence); err != nil { + return err + } + if request.Tick > state.Tick { + state.Tick = request.Tick + } + state.Receipts[request.RequestID] = protocol.RequestReceipt{Kind: EventCommitted, EntityID: request.ProposalID, Revision: event.Sequence} + advanceWorldRevision(state) + return nil +} + +func applyBatchCommitted(state *protocol.SessionState, event protocol.EventRecord) error { + var payload batchCommittedPayload + if err := json.Unmarshal(event.Data, &payload); err != nil { + return fmt.Errorf("%w: decode batch commit payload: %v", ErrCorruptLog, err) + } + for _, item := range payload.Request.Items { + if err := applyCommitItem(state, item, payload.Request.Tick, event.Sequence); err != nil { + return err + } + } + if payload.Request.Tick > state.Tick { + state.Tick = payload.Request.Tick + } + state.Receipts[payload.Request.RequestID] = protocol.RequestReceipt{ + Kind: EventBatchCommitted, EntityID: payload.Request.SessionID, Revision: event.Sequence, + } + advanceWorldRevision(state) + return nil +} + +func applyCommitItem(state *protocol.SessionState, item protocol.CommitItem, tick int64, revision uint64) error { + proposal, exists := state.Proposals[item.ProposalID] if !exists || proposal.Status != "pending" { return fmt.Errorf("%w: committed proposal is unavailable", ErrCorruptLog) } - if request.Accepted { + if item.Accepted { proposal.Status = "accepted" } else { proposal.Status = "rejected" } state.Proposals[proposal.ID] = proposal + if !item.Accepted { + return nil + } actor := state.Actors[proposal.ActorID] - if request.Accepted { - actor.RecentActions = append(actor.RecentActions, proposal) - if len(actor.RecentActions) > maxRecentActions { - actor.RecentActions = append([]protocol.ActionProposal(nil), actor.RecentActions[len(actor.RecentActions)-maxRecentActions:]...) + if proposal.ProposedGoal != nil && !goalExists(actor, proposal.ProposedGoal.ID) { + actor.Goals = append(actor.Goals, *proposal.ProposedGoal) + } + actor.RecentActions = append(actor.RecentActions, proposal) + if len(actor.RecentActions) > maxRecentActions { + actor.RecentActions = append([]protocol.ActionProposal(nil), actor.RecentActions[len(actor.RecentActions)-maxRecentActions:]...) + } + actor.NextThinkTick = tick + actor.ThinkEveryTicks + markRecalled(&actor, proposal.RecalledMemoryIDs, tick) + if item.Outcome != "" { + memoryID, err := hashJSON(struct { + ActorID string `json:"actor_id"` + EventID string `json:"event_id"` + }{proposal.ActorID, item.EventID}) + if err != nil { + return err } - actor.NextThinkTick = request.Tick + actor.ThinkEveryTicks - markRecalled(&actor, proposal.RecalledMemoryIDs, request.Tick) - if request.Outcome != "" { - memoryID, err := hashJSON(struct { - ActorID string `json:"actor_id"` - EventID string `json:"event_id"` - }{proposal.ActorID, request.EventID}) - if err != nil { + actor.Memories = append(actor.Memories, protocol.Memory{ + ID: "memory." + memoryID[:24], EventID: item.EventID, Tick: tick, + Summary: item.Outcome, Tags: append([]string(nil), item.Tags...), + Importance: 3, CreatedRevision: revision, + }) + if protocol.HasFeature(state.Features, protocol.FeatureMemoryArchive) { + if err := compactActorMemories(state.SessionID, &actor, revision); err != nil { return err } - actor.Memories = append(actor.Memories, protocol.Memory{ - ID: "memory." + memoryID[:24], - EventID: request.EventID, - Tick: request.Tick, - Summary: request.Outcome, - Tags: append([]string(nil), request.Tags...), - Importance: 3, - CreatedRevision: event.Sequence, - }) - if protocol.HasFeature(state.Features, protocol.FeatureMemoryArchive) { - if err := compactActorMemories(state.SessionID, &actor, event.Sequence); err != nil { - return err - } - } else if len(actor.Memories) > maxMemories { - actor.Memories = append([]protocol.Memory(nil), actor.Memories[len(actor.Memories)-maxMemories:]...) - } + } else if len(actor.Memories) > maxMemories { + actor.Memories = append([]protocol.Memory(nil), actor.Memories[len(actor.Memories)-maxMemories:]...) } - applyFacts(&actor, request.Facts, request.EventID, event.Sequence, protocol.HasFeature(state.Features, protocol.FeatureBeliefConflicts)) - applyGoalProgress(&actor, proposal.GoalID, 1, "") - for _, update := range request.GoalUpdates { - applyGoalProgress(&actor, update.GoalID, update.ProgressDelta, update.Status) + } + applyFacts(&actor, item.Facts, item.EventID, revision, protocol.HasFeature(state.Features, protocol.FeatureBeliefConflicts)) + applyGoalProgress(&actor, proposal.GoalID, 1, "") + for _, update := range item.GoalUpdates { + applyGoalProgress(&actor, update.GoalID, update.ProgressDelta, update.Status) + } + state.Actors[proposal.ActorID] = actor + return nil +} + +func applyActivityUpdated(state *protocol.SessionState, event protocol.EventRecord) error { + var payload activityUpdatedPayload + if err := json.Unmarshal(event.Data, &payload); err != nil { + return fmt.Errorf("%w: decode activity payload: %v", ErrCorruptLog, err) + } + for _, update := range payload.Request.Updates { + actor, exists := state.Actors[update.ActorID] + if !exists { + return fmt.Errorf("%w: activity actor is unknown", ErrCorruptLog) + } + actor.Activity = &protocol.ActorActivity{ + RegionID: update.RegionID, State: update.State, Reason: update.Reason, + UpdatedTick: payload.Request.Tick, UpdatedRevision: event.Sequence, } - state.Actors[proposal.ActorID] = actor + state.Actors[update.ActorID] = actor } - if request.Tick > state.Tick { - state.Tick = request.Tick + if payload.Request.Tick > state.Tick { + state.Tick = payload.Request.Tick + } + state.Receipts[payload.Request.RequestID] = protocol.RequestReceipt{ + Kind: EventActivityUpdated, EntityID: payload.Request.SessionID, Revision: event.Sequence, + } + advanceWorldRevision(state) + return nil +} + +func applyArbitrated(state *protocol.SessionState, event protocol.EventRecord) error { + var payload arbitratedPayload + if err := json.Unmarshal(event.Data, &payload); err != nil { + return fmt.Errorf("%w: decode arbitration payload: %v", ErrCorruptLog, err) + } + state.Arbitrations = append(state.Arbitrations, payload.Record) + if len(state.Arbitrations) > maxArbitrations { + state.Arbitrations = append([]protocol.ArbitrationRecord(nil), state.Arbitrations[len(state.Arbitrations)-maxArbitrations:]...) + } + state.Receipts[payload.Record.RequestID] = protocol.RequestReceipt{ + Kind: EventArbitrated, EntityID: payload.Record.ID, Revision: event.Sequence, } - state.Receipts[request.RequestID] = protocol.RequestReceipt{Kind: EventCommitted, EntityID: proposal.ID, Revision: event.Sequence} return nil } @@ -237,6 +334,9 @@ func applyRestored(current protocol.SessionState, event protocol.EventRecord) (p return protocol.SessionState{}, fmt.Errorf("%w: restore binding mismatch", ErrCorruptLog) } restored.Proposals = make(map[string]protocol.ActionProposal) + if protocol.HasFeature(restored.Features, protocol.FeatureArbitration) { + advanceWorldRevision(&restored) + } if restored.Receipts == nil { restored.Receipts = make(map[string]protocol.RequestReceipt) } @@ -244,6 +344,16 @@ func applyRestored(current protocol.SessionState, event protocol.EventRecord) (p return restored, nil } +func advanceWorldRevision(state *protocol.SessionState) { + if !protocol.HasFeature(state.Features, protocol.FeatureArbitration) { + return + } + state.WorldRevision++ + if state.WorldRevision == 0 { + state.WorldRevision = 1 + } +} + func applyFacts(actor *protocol.ActorState, facts []protocol.Fact, eventID string, revision uint64, preserveConflicts bool) { if actor.Beliefs == nil { actor.Beliefs = make(map[string]protocol.Fact) diff --git a/runtime/runtime.go b/runtime/runtime.go index f7dea50..42eb001 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -22,6 +22,9 @@ const ( EventObserved = "observation.recorded" EventProposed = "proposal.created" EventCommitted = "action.committed" + EventBatchCommitted = "action.batch-committed" + EventActivityUpdated = "actor.activity-updated" + EventArbitrated = "world.arbitrated" EventSessionRestored = "session.restored" ) From 4481caf8616af6a61f5c3c6ef0468acc7a2581a8 Mon Sep 17 00:00:00 2001 From: sunrioa Date: Wed, 22 Jul 2026 17:18:16 +0800 Subject: [PATCH 4/5] feat: add living world tooling and adapters --- README.md | 18 ++- ROADMAP.md | 10 +- adapters/renpy/rin_client.py | 17 ++- adapters/renpy/test_rin_client.py | 16 +++ cmd/rin/inspect.go | 137 ++++++++++++++++++ cmd/rin/inspect_test.go | 61 ++++++++ cmd/rin/main.go | 3 + compat/adapter_examples_test.go | 9 ++ docs/architecture.md | 14 +- docs/game-adapters.md | 6 +- docs/protocol-v1.md | 81 ++++++++++- examples/godot/rin_client.gd | 24 ++++ examples/unity/RinClient.cs | 223 ++++++++++++++++++++++++++++++ httpapi/server.go | 20 +++ httpapi/server_test.go | 39 ++++++ protocol/debug.go | 36 +++++ protocol/debug_validate.go | 27 ++++ runtime/debug.go | 185 +++++++++++++++++++++++++ runtime/debug_test.go | 97 +++++++++++++ 19 files changed, 1010 insertions(+), 13 deletions(-) create mode 100644 cmd/rin/inspect.go create mode 100644 cmd/rin/inspect_test.go create mode 100644 protocol/debug.go create mode 100644 protocol/debug_validate.go create mode 100644 runtime/debug.go create mode 100644 runtime/debug_test.go diff --git a/README.md b/README.md index d505c7a..d92e0e6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Rin 是一个面向游戏角色的轻量级 Agent Runtime。它作为游戏进程旁边的 Sidecar 运行,也可以直接作为 Go 包嵌入工具链。核心只使用 Go 标准库,不绑定视觉小说、RPG 引擎或任何模型供应商。 -当前版本:`v0.4.0` +当前开发线:`v0.5.0`(Living Worlds) ## 它解决什么 @@ -18,6 +18,8 @@ Rin 将“角色思考”和“游戏世界事实”拆开: - 通用结构化 Generation Job 让剧情、任务描述和受限对白也经过 Sidecar,而不是让游戏保存供应商 Key。 - 模型不可用时自动回退确定性 Policy,并用 `policy_source` 标明来源。 - Ren'Py、Godot 4 和 Unity 适配器保持同一套 observe / propose / commit 权威边界。 +- 可选分层记忆、冲突认知、候选小目标、区域休眠和确定性多角色仲裁均由 Session feature 显式启用。 +- 脱敏 Timeline、指定 revision Replay 和 `rin inspect` 让长流程角色行为可以复现和审计。 这套边界既适用于 Ren'Py 角色,也可用于 RPG NPC、队友、经营模拟居民和其他 AI 游戏实体。 @@ -66,15 +68,27 @@ go run ./cmd/rin serve | `GET` | `/v1/generation/jobs/{job_id}` | 查询生成任务与安全元数据 | | `DELETE` | `/v1/generation/jobs/{job_id}` | 取消生成任务 | | `POST` | `/v1/action/commit` | 接受或拒绝提案并记录结果 | +| `POST` | `/v1/action/commit-batch` | 原子提交同一世界版本的多角色结果 | +| `POST` | `/v1/session/activity` | 更新角色区域与 awake/dormant 状态 | +| `POST` | `/v1/world/arbitrate` | 对并行角色提案进行确定性冲突仲裁 | | `POST` | `/v1/scheduler/due` | 查询当前 tick 应思考的角色 | | `POST` | `/v1/session/get` | 读取会话状态 | | `POST` | `/v1/session/snapshot` | 创建并原子保存快照 | | `POST` | `/v1/session/restore` | 校验并恢复快照 | +| `POST` | `/v1/session/timeline` | 读取脱敏事件时间线 | +| `POST` | `/v1/session/replay` | 重放到指定 revision 并返回 Snapshot | 所有写请求都带调用方生成的 `request_id`,重复请求返回相同结果,不重复修改状态。同一 ID 被用于不同操作时返回冲突。 完整字段和错误语义见 [协议文档](docs/protocol-v1.md),职责边界见 [架构文档](docs/architecture.md)。 +离线检查一个会话(会验证日志并只打印脱敏时间线): + +```bash +go run ./cmd/rin inspect -data ./rin-data -session playthrough-1 +go run ./cmd/rin inspect -data ./rin-data -session playthrough-1 -revision 42 +``` + ## 游戏引擎适配 - Ren'Py:纯标准库 Python 客户端、`renpy.invoke_in_thread` 桥接与 authored 离线回退。 @@ -116,6 +130,6 @@ examples/ Go、Godot 与 Unity 最小接入示例 ## 当前有意不做 -`v0.4.0` 不引入供应商 SDK、向量数据库、ORM、WebSocket、动态插件执行或任意文件访问。在线模型仍是可选能力;即使供应商或 Sidecar 不可用,游戏仍可继续使用确定性策略或自己的离线剧情。 +`v0.5.0` 不引入供应商 SDK、向量数据库、ORM、WebSocket、动态插件执行或任意文件访问。在线模型仍是可选能力;即使供应商或 Sidecar 不可用,游戏仍可继续使用确定性策略或自己的离线剧情。 后续工作记录在 [ROADMAP.md](ROADMAP.md)。 diff --git a/ROADMAP.md b/ROADMAP.md index 3f05534..8413257 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,11 +39,11 @@ ## v0.5.0 - Living worlds -- [ ] 分层记忆总结与可解释遗忘 -- [ ] 角色私有认知、传闻来源和事实冲突 -- [ ] 自主小目标与 Game Master 仲裁 -- [ ] 多 Agent 批处理与区域休眠 -- [ ] 人工调试时间线和决定回放工具 +- [x] 分层记忆总结与可解释遗忘 +- [x] 角色私有认知、传闻来源和事实冲突 +- [x] 自主小目标与 Game Master 仲裁 +- [x] 多 Agent 批处理与区域休眠 +- [x] 人工调试时间线和决定回放工具 详细协议、兼容策略、阶段提交与验收矩阵见 [`docs/living-worlds-v0.5-plan.md`](docs/living-worlds-v0.5-plan.md)。 diff --git a/adapters/renpy/rin_client.py b/adapters/renpy/rin_client.py index 50ab077..f5bdf35 100644 --- a/adapters/renpy/rin_client.py +++ b/adapters/renpy/rin_client.py @@ -215,6 +215,15 @@ def cancel_generation_job(self, job_id: str) -> Dict[str, Any]: def commit(self, request: Dict[str, Any]) -> Dict[str, Any]: return self._request("POST", "/v1/action/commit", request) + def commit_batch(self, request: Dict[str, Any]) -> Dict[str, Any]: + return self._request("POST", "/v1/action/commit-batch", request) + + def set_actor_activity(self, request: Dict[str, Any]) -> Dict[str, Any]: + return self._request("POST", "/v1/session/activity", request) + + def arbitrate(self, request: Dict[str, Any]) -> Dict[str, Any]: + return self._request("POST", "/v1/world/arbitrate", request) + def state(self, request: Dict[str, Any]) -> Dict[str, Any]: return self._request("POST", "/v1/session/get", request) @@ -224,6 +233,12 @@ def snapshot(self, request: Dict[str, Any]) -> Dict[str, Any]: def restore(self, request: Dict[str, Any]) -> Dict[str, Any]: return self._request("POST", "/v1/session/restore", request) + def timeline(self, request: Dict[str, Any]) -> Dict[str, Any]: + return self._request("POST", "/v1/session/timeline", request) + + def replay(self, request: Dict[str, Any]) -> Dict[str, Any]: + return self._request("POST", "/v1/session/replay", request) + def due_agents(self, request: Dict[str, Any]) -> Dict[str, Any]: return self._request("POST", "/v1/scheduler/due", request) @@ -419,7 +434,7 @@ def _request( expected_statuses: Sequence[int] = (200,), ) -> Dict[str, Any]: body = None - headers = {"Accept": "application/json", "User-Agent": "rin-renpy/0.4"} + headers = {"Accept": "application/json", "User-Agent": "rin-renpy/0.5"} if payload is not None: if not isinstance(payload, dict): raise RinProtocolError("invalid_request", "Rin request payload must be an object") diff --git a/adapters/renpy/test_rin_client.py b/adapters/renpy/test_rin_client.py index e99bf4d..8da533f 100644 --- a/adapters/renpy/test_rin_client.py +++ b/adapters/renpy/test_rin_client.py @@ -36,11 +36,13 @@ def __init__(self): self.generation_polls = 0 self.authorization = "" self.last_payload = None + self.last_path = "" def open(self, request, timeout): self.authorization = request.get_header("Authorization", "") path = request.full_url.split("//", 1)[-1] path = path[path.find("/"):] if "/" in path else "/" + self.last_path = path if request.data is not None: self.last_payload = json.loads(request.data.decode("utf-8")) if request.get_method() == "POST" and path == "/v1/jobs/propose": @@ -147,6 +149,20 @@ def _client_with_opener(token=""): class RinClientTests(unittest.TestCase): + def test_living_world_routes(self): + client = _client_with_opener() + cases = ( + (client.commit_batch, "/v1/action/commit-batch"), + (client.set_actor_activity, "/v1/session/activity"), + (client.arbitrate, "/v1/world/arbitrate"), + (client.timeline, "/v1/session/timeline"), + (client.replay, "/v1/session/replay"), + ) + for method, expected_path in cases: + with self.subTest(path=expected_path): + method({"protocol_version": rin_client.PROTOCOL_VERSION}) + self.assertEqual(client._opener.last_path, expected_path) + def test_async_proposal_flow_and_token(self): client = _client_with_opener("fixture-token") result = client.propose_with_fallback( diff --git a/cmd/rin/inspect.go b/cmd/rin/inspect.go new file mode 100644 index 0000000..171e436 --- /dev/null +++ b/cmd/rin/inspect.go @@ -0,0 +1,137 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + + "github.com/sunrioa/rin/policy" + "github.com/sunrioa/rin/protocol" + rintime "github.com/sunrioa/rin/runtime" + "github.com/sunrioa/rin/store" +) + +type inspectOutput struct { + ProtocolVersion string `json:"protocol_version"` + SessionID string `json:"session_id"` + Binding protocol.Binding `json:"binding"` + Revision uint64 `json:"revision"` + WorldRevision uint64 `json:"world_revision,omitempty"` + Tick int64 `json:"tick"` + Features []string `json:"features,omitempty"` + ActorCount int `json:"actor_count"` + PendingProposals int `json:"pending_proposals"` + ArbitrationCount int `json:"arbitration_count"` + StateHash string `json:"state_hash"` + Timeline []protocol.TimelineEntry `json:"timeline,omitempty"` +} + +func runInspect(arguments []string, output io.Writer) error { + flags := flag.NewFlagSet("rin inspect", flag.ContinueOnError) + flags.SetOutput(io.Discard) + dataDirectory := flags.String("data", envOr("RIN_DATA_DIR", "./rin-data"), "event and snapshot directory") + sessionID := flags.String("session", "", "session identifier") + revision := flags.Uint64("revision", 0, "event-log revision; zero selects current") + timelineLimit := flags.Int("timeline-limit", 50, "number of redacted timeline entries (0-256)") + if err := flags.Parse(arguments); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected arguments: %v", flags.Args()) + } + if *sessionID == "" { + return errors.New("-session is required") + } + if *timelineLimit < 0 || *timelineLimit > 256 { + return errors.New("-timeline-limit must be between 0 and 256") + } + fileStore, err := store.OpenFile(*dataDirectory) + if err != nil { + return err + } + engine, err := rintime.Open(fileStore, policy.Deterministic{}) + if err != nil { + return err + } + var snapshot protocol.Snapshot + if *revision == 0 { + state, stateErr := engine.State(protocol.SessionRequest{ProtocolVersion: protocol.Version, SessionID: *sessionID}) + if stateErr != nil { + return stateErr + } + snapshot, err = rintime.SnapshotOf(state) + } else { + snapshot, err = engine.Replay(protocol.ReplayRequest{ + ProtocolVersion: protocol.Version, SessionID: *sessionID, Revision: *revision, + }) + } + if err != nil { + return err + } + timeline, err := inspectTimeline(engine, *sessionID, snapshot.State.Revision, *timelineLimit) + if err != nil { + return err + } + pending := 0 + for _, proposal := range snapshot.State.Proposals { + if proposal.Status == "pending" { + pending++ + } + } + result := inspectOutput{ + ProtocolVersion: protocol.Version, SessionID: snapshot.State.SessionID, + Binding: snapshot.State.Binding, Revision: snapshot.State.Revision, + WorldRevision: snapshot.State.WorldRevision, Tick: snapshot.State.Tick, + Features: append([]string(nil), snapshot.State.Features...), + ActorCount: len(snapshot.State.Actors), PendingProposals: pending, + ArbitrationCount: len(snapshot.State.Arbitrations), StateHash: snapshot.StateHash, + Timeline: timeline, + } + encoder := json.NewEncoder(output) + encoder.SetIndent("", " ") + return encoder.Encode(result) +} + +func inspectTimeline(engine *rintime.Engine, sessionID string, revision uint64, limit int) ([]protocol.TimelineEntry, error) { + if limit == 0 { + return nil, nil + } + entries := make([]protocol.TimelineEntry, 0, limit) + after := uint64(0) + for { + pageStart := after + page, err := engine.Timeline(protocol.TimelineRequest{ + ProtocolVersion: protocol.Version, SessionID: sessionID, + AfterRevision: after, Limit: 256, + }) + if err != nil { + return nil, err + } + reachedTarget := false + for _, entry := range page.Entries { + if entry.Sequence > revision { + reachedTarget = true + break + } + entries = append(entries, entry) + if len(entries) > limit { + entries = append([]protocol.TimelineEntry(nil), entries[len(entries)-limit:]...) + } + after = entry.Sequence + if entry.Sequence == revision { + reachedTarget = true + break + } + } + if reachedTarget || !page.HasMore { + break + } + if page.NextAfterRevision <= pageStart { + return nil, errors.New("timeline pagination did not advance") + } + after = page.NextAfterRevision + } + return entries, nil +} diff --git a/cmd/rin/inspect_test.go b/cmd/rin/inspect_test.go new file mode 100644 index 0000000..bc58988 --- /dev/null +++ b/cmd/rin/inspect_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/sunrioa/rin/policy" + "github.com/sunrioa/rin/protocol" + rintime "github.com/sunrioa/rin/runtime" + "github.com/sunrioa/rin/store" +) + +func TestRunInspectPrintsVerifiedRedactedSummary(t *testing.T) { + directory := t.TempDir() + fileStore, err := store.OpenFile(directory) + if err != nil { + t.Fatal(err) + } + engine, err := rintime.Open(fileStore, policy.Deterministic{}) + if err != nil { + t.Fatal(err) + } + _, err = engine.CreateSession(protocol.CreateSessionRequest{ + ProtocolVersion: protocol.Version, RequestID: "create.inspect", SessionID: "session.inspect", + Binding: protocol.Binding{GameID: "game.inspect", ContentID: "base", ContentVersion: "1", ContentHash: "hash"}, + Actors: []protocol.ActorSeed{{ + ID: "npc.inspect", Kind: "npc", DisplayName: "Inspector", + ThinkEveryTicks: 1, Enabled: true, + }}, + }) + if err != nil { + t.Fatal(err) + } + _, err = engine.Observe(protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, SessionID: "session.inspect", RequestID: "observe.inspect", + EventID: "event.inspect", Tick: 1, ObserverIDs: []string{"npc.inspect"}, Source: "game", + Kind: "dialogue", Summary: "PRIVATE_SUMMARY", Quote: "PRIVATE_QUOTE", Importance: 3, + }) + if err != nil { + t.Fatal(err) + } + + var output bytes.Buffer + if err := runInspect([]string{ + "-data", directory, "-session", "session.inspect", "-revision", "1", "-timeline-limit", "10", + }, &output); err != nil { + t.Fatal(err) + } + if strings.Contains(output.String(), "PRIVATE_SUMMARY") || strings.Contains(output.String(), "PRIVATE_QUOTE") { + t.Fatalf("inspect output leaked story text: %s", output.String()) + } + var result inspectOutput + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.SessionID != "session.inspect" || result.Revision != 1 || result.ActorCount != 1 || len(result.Timeline) != 1 { + t.Fatalf("unexpected inspect output: %+v", result) + } +} diff --git a/cmd/rin/main.go b/cmd/rin/main.go index ab189f2..58bfa6e 100644 --- a/cmd/rin/main.go +++ b/cmd/rin/main.go @@ -34,6 +34,9 @@ func run(arguments []string) error { fmt.Println(version) return nil } + if len(arguments) > 0 && arguments[0] == "inspect" { + return runInspect(arguments[1:], os.Stdout) + } if len(arguments) > 0 && arguments[0] == "serve" { arguments = arguments[1:] } diff --git a/compat/adapter_examples_test.go b/compat/adapter_examples_test.go index f2079ac..167cf95 100644 --- a/compat/adapter_examples_test.go +++ b/compat/adapter_examples_test.go @@ -23,6 +23,9 @@ func TestEngineExamplesPreserveAsyncAuthorityBoundary(t *testing.T) { "HTTPClient.METHOD_DELETE", "\"committable\": false", "\"policy_source\": \"adapter-offline\"", + "/v1/session/activity", + "/v1/world/arbitrate", + "/v1/session/timeline", }, forbidden: []string{"OS.execute", "FileAccess.open", "Thread.wait_to_finish"}, }, @@ -36,6 +39,9 @@ func TestEngineExamplesPreserveAsyncAuthorityBoundary(t *testing.T) { "WaitForSecondsRealtime", "committable = false", "policy_source = \"adapter-offline\"", + "/v1/session/activity", + "/v1/world/arbitrate", + "/v1/session/timeline", }, forbidden: []string{"Thread.Sleep", ".Wait()", "Process.Start"}, }, @@ -47,6 +53,9 @@ func TestEngineExamplesPreserveAsyncAuthorityBoundary(t *testing.T) { "class BackgroundProposalRegistry", "committable\": False", "adapter-offline", + "/v1/session/activity", + "/v1/world/arbitrate", + "/v1/session/timeline", }, forbidden: []string{"import requests", "subprocess", "os.system"}, }, diff --git a/docs/architecture.md b/docs/architecture.md index f559c4b..07b5b3f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,7 +24,9 @@ flowchart LR ### Runtime -`runtime.Engine` 是确定性状态机。每个会话单独加锁;Policy 在锁外执行,因此远程模型变慢不会阻塞新的观察或读状态。Policy 返回后若 revision 或 head hash 已变化,提案以 `state_changed` 失败,调用方可丢弃或重试。 +`runtime.Engine` 是确定性状态机。每个会话单独加锁;Policy 在锁外执行,因此远程模型变慢不会阻塞新的观察或读状态。旧会话继续用 revision/head hash 判断过期;启用 `arbitration-v1` 的会话使用只在世界事实变化时前进的 `world_revision`,因此同一轮多个角色可以并行提出动作。 + +详细记忆保持固定窗口;`memory-archive-v1` 将最旧批次压成带来源 ID、tick 范围和原因的确定性摘要,并在摘要达到上限后继续分层合并。`belief-conflicts-v1` 为每个角色保留最多八条来源声明,同时维持旧 `beliefs` 字段作为当前选中投影。两者都完全由事件重放恢复,不依赖向量数据库。 ### Policy @@ -69,6 +71,16 @@ Ren'Py、Godot 和 Unity 适配器只转换 JSON/HTTP 与各自的异步机制 Ren'Py worker registry、Godot `HTTPRequest` 和 Unity coroutine 都只存在于进程内。游戏存档保存 Snapshot 与普通结果,不保存线程、Future、Socket、HTTP 对象或 API Token。 +### Multi-actor coordination + +候选目标仍由游戏提供上限和语义范围,Policy 只能建议采用;只有 accepted Commit 才把目标写进 Actor。Activity 状态由游戏的区域或模拟系统更新,Dormant 角色不会自行唤醒。Arbitration 对同一 world revision 的 Proposal 做稳定排序并记录冲突,但不执行动作;游戏可以调整、拒绝,再以原子 Batch Commit 汇报实际结果。 + +这使 Rin 可以服务视觉小说、RPG NPC 和模拟居民,同时不承担寻路、碰撞、任务规则或 Scene Tree 等引擎职责。 + +### Observability + +Timeline 只从事件 payload 提取 ID 和枚举状态,不返回玩家原话、剧情摘要、Commit outcome 或模型内容。Replay 则运行同一个 reducer 到指定 revision,生成完整且可验证的 Snapshot,不写回 Store。`rin inspect` 复用这两条路径输出机器可读诊断;打开数据目录时仍会验证全部事件 hash chain。 + ### Store 文件存储结构: diff --git a/docs/game-adapters.md b/docs/game-adapters.md index 9e6b754..0885690 100644 --- a/docs/game-adapters.md +++ b/docs/game-adapters.md @@ -57,7 +57,7 @@ request_id = rin_schedule_proposal({ `rin_proposal_status(request_id)` returns `pending`, `ready`, or `missing`; `rin_consume_proposal(request_id)` returns one plain JSON-compatible result. `rin_cancel_proposal` propagates cancellation to the Job API. -The Python client also exposes `submit_generation_job`, `get_generation_job`, `cancel_generation_job`, `wait_for_generation`, and `generate_json`. Generation must run in the same process-local background pattern as proposals. `generate_json` accepts only the provider-free Rin request contract and returns one decoded JSON object plus bounded operational metadata. A game that persists request records should allowlist only the fields it needs; provider model names are useful for explicit probes but should not be copied into gameplay saves. +The Python client also exposes `commit_batch`, `set_actor_activity`, `arbitrate`, `timeline`, `replay`, and the structured-generation methods. Generation must run in the same process-local background pattern as proposals. `generate_json` accepts only the provider-free Rin request contract and returns one decoded JSON object plus bounded operational metadata. A game that persists request records should allowlist only the fields it needs; provider model names are useful for explicit probes but should not be copied into gameplay saves. Threads, cancellation events, HTTP objects, and registries are process-local. Never assign them to `default`, persistent data, rollback state, or a save object. Only store accepted protocol snapshots and plain result dictionaries. @@ -67,13 +67,13 @@ Native Ren'Py tests are offline unless `RIN_LIVE_TEST_ENABLED=1`, even if a deve Add [the client](../examples/godot/rin_client.gd) as a node or autoload. `propose_with_fallback` awaits `HTTPRequest` signals and timer ticks, so it does not block rendering. The [NPC example](../examples/godot/example_npc.gd) shows the complete propose, game apply, and commit sequence. -Godot owns navigation, animation, combat, inventory, and dialogue rendering. The adapter caps response bytes, disables redirects, and accepts plaintext HTTP only for an exact loopback host and valid port. +Godot owns navigation, animation, combat, inventory, and dialogue rendering. Helpers for activity, due actors, arbitration, batch commit, timeline, and replay are coroutines; call activity on simulation/region changes, not every frame. The adapter caps response bytes, disables redirects, and accepts plaintext HTTP only for an exact loopback host and valid port. ## Unity Attach [RinClient.cs](../examples/unity/RinClient.cs) to a GameObject. It uses `UnityWebRequest` coroutines and a capped streaming download handler; no JSON or networking package is required. [RinNpcExample.cs](../examples/unity/RinNpcExample.cs) shows the same apply-before-commit flow. -Unity's `JsonUtility` adapter intentionally exposes a compact common schema. Games that use action parameter maps can extend the serializable request classes without changing the wire protocol. +Unity's `JsonUtility` adapter exposes serializable DTOs for activity, scheduling, arbitration, batch commit, and timeline. Since `JsonUtility` cannot represent actor-ID keyed maps, its Replay helper returns the verified Snapshot header; projects that need the complete replayed state should parse the same endpoint with their existing dictionary-capable JSON package. Games that use action parameter maps can likewise extend the serializable request classes without changing the wire protocol. ## ai-galgame compatibility diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index f9655d4..2066f9b 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -45,6 +45,7 @@ ID 长度为 1–96,只允许字母、数字、`.`、`_`、`-`,从源头阻 "content_hash": "sha256:..." }, "seed": 42, + "features": ["memory-archive-v1", "belief-conflicts-v1"], "actors": [ { "id": "npc.mira", @@ -79,6 +80,16 @@ ID 长度为 1–96,只允许字母、数字、`.`、`_`、`-`,从源头阻 Binding 防止另一版本剧情或 Mod 的状态被静默恢复到当前游戏。 +`features` 是新会话显式选择的兼容开关,可用值由 `/health` 的 `features` 返回: + +- `memory-archive-v1`:将超出详细窗口的记忆压缩为确定性分层摘要; +- `belief-conflicts-v1`:保留角色私有的互相矛盾说法及来源; +- `goal-candidates-v1`:允许 Policy 从本次请求给出的候选小目标中提出一个; +- `actor-activity-v1`:启用区域和 awake/dormant 生命周期; +- `arbitration-v1`:启用 world revision、多角色仲裁与原子批量 commit。 + +省略该字段的旧 Session 保持 v0.4 行为,重放 hash 和 JSON 形状不变。 + ## Observe `POST /v1/session/observe` @@ -128,6 +139,16 @@ Binding 防止另一版本剧情或 Mod 的状态被静默恢复到当前游戏 {"id":"talk","kind":"dialogue","description":"ask one honest question"}, {"id":"refuse","kind":"refuse","description":"protect a private boundary"}, {"id":"wait","kind":"wait","description":"stay silent for now"} + ], + "candidate_goals": [ + { + "id": "goal.ask-about-photo", + "description": "Find a calm moment to ask about the old photograph.", + "priority": 2, + "progress": 0, + "target_progress": 2, + "status": "active" + } ] } ``` @@ -143,6 +164,8 @@ Binding 防止另一版本剧情或 Mod 的状态被静默恢复到当前游戏 Policy 运行期间不会持有会话锁。如果新观察先到达,调用返回 `state_changed`;客户端应以新的 `request_id` 重试。 +候选目标只在启用 `goal-candidates-v1` 时允许,最多 8 个。Policy 不能凭空创建目标,只能选已有目标或本次候选目标;候选目标随 Proposal 返回,只有 Proposal 被接受后才进入 Actor 状态,拒绝或过期不会留下目标。 + 在线模型不建议由游戏主线程直接调用本端点,应使用异步 Job API。 ## Async proposal jobs @@ -234,6 +257,42 @@ DELETE /v1/generation/jobs/{job_id} 接受提案会记录行动结果、更新调度、标记记忆被召回,并让关联目标自动前进 1。拒绝提案不会修改角色记忆、事实和目标。 +## Living-world coordination + +启用 `actor-activity-v1` 后,游戏在区域载入、卸载或模拟层级变化时调用: + +`POST /v1/session/activity` + +```json +{ + "protocol_version": "rin.protocol/v1", + "session_id": "playthrough-1", + "request_id": "activity.school-day-2", + "tick": 80, + "updates": [ + {"actor_id":"npc.mira","region_id":"school.roof","state":"awake"}, + {"actor_id":"npc.teacher","region_id":"school.office","state":"dormant"} + ] +} +``` + +`state` 只能为 `awake` 或 `dormant`。Dormant 角色不会出现在 scheduler 中,也不能 propose。`/v1/scheduler/due` 可增加 `region_ids` 过滤。 + +启用 `arbitration-v1` 后,同一 world revision 可以为多个角色分别产生 Proposal,再调用 `POST /v1/world/arbitrate`: + +```json +{ + "protocol_version": "rin.protocol/v1", + "session_id": "playthrough-1", + "request_id": "arbitrate.turn-81", + "tick": 81, + "proposal_ids": ["proposal.mira", "proposal.teacher"], + "exclusive_target_ids": ["prop.camera-1"] +} +``` + +结果以目标优先级、tick、actor ID、proposal ID 确定性排序,给出 `selected` 或 `deferred`。仲裁是建议记录,不直接改变游戏世界。游戏应用选中动作后,可用 `POST /v1/action/commit-batch` 一次提交每个角色最多一个结果;任何一项失效都会拒绝整个批次,不产生部分修改。 + ## Scheduler `POST /v1/scheduler/due` @@ -243,7 +302,8 @@ DELETE /v1/generation/jobs/{job_id} "protocol_version": "rin.protocol/v1", "session_id": "playthrough-1", "tick": 24, - "limit": 16 + "limit": 16, + "region_ids": ["school.roof"] } ``` @@ -272,6 +332,24 @@ Restore 拒绝 hash 错误、Session ID 不同或 Binding 不同的快照,并 当游戏反复载入同一存档时,Restore `request_id` 应同时绑定目标 Snapshot hash 和 Sidecar 当前 head hash。这样一次网络重试仍然幂等,而从后来状态再次读档会产生新的 Restore 事件并真正回退。 +## Timeline and replay + +`POST /v1/session/timeline` 返回分页的事件类型、revision、hash、请求 ID、角色/实体 ID 和状态,不返回 Observation summary/quote、Commit outcome、Prompt 或模型正文: + +```json +{"protocol_version":"rin.protocol/v1","session_id":"playthrough-1","after_revision":0,"limit":50} +``` + +响应中的 `next_after_revision` 可用于下一页,`limit` 为 1–256。 + +`POST /v1/session/replay` 使用正常 reducer 和 hash-chain 校验重建指定 revision,并返回不落盘的 Snapshot: + +```json +{"protocol_version":"rin.protocol/v1","session_id":"playthrough-1","revision":42} +``` + +Replay 会包含该 revision 已存在的角色记忆和剧情状态,因此沿用 Session API 的鉴权边界,不能当作脱敏日志接口。 + ## Common errors | HTTP | Code | Meaning | @@ -279,6 +357,7 @@ Restore 拒绝 hash 错误、Session ID 不同或 Binding 不同的快照,并 | `400` | `invalid_json` / `invalid_request` | JSON 或字段契约错误 | | `401` | `unauthorized` | Bearer Token 缺失或错误 | | `404` | `session_not_found` / `unknown_actor` | 实体不存在 | +| `404` | `revision_not_found` | Replay revision 不存在 | | `409` | `state_changed` / `proposal_stale` | 基础状态已改变 | | `409` | `actor_not_due` | 尚未到该角色的思考 tick | | `422` | `no_safe_action` | 边界触发但游戏没提供安全动作 | diff --git a/examples/godot/rin_client.gd b/examples/godot/rin_client.gd index 66ed475..c4ce333 100644 --- a/examples/godot/rin_client.gd +++ b/examples/godot/rin_client.gd @@ -38,6 +38,30 @@ func commit(request: Dictionary) -> Dictionary: return await _json_request(HTTPClient.METHOD_POST, "/v1/action/commit", request, [200]) +func commit_batch(request: Dictionary) -> Dictionary: + return await _json_request(HTTPClient.METHOD_POST, "/v1/action/commit-batch", request, [200]) + + +func set_actor_activity(request: Dictionary) -> Dictionary: + return await _json_request(HTTPClient.METHOD_POST, "/v1/session/activity", request, [200]) + + +func due_agents(request: Dictionary) -> Dictionary: + return await _json_request(HTTPClient.METHOD_POST, "/v1/scheduler/due", request, [200]) + + +func arbitrate(request: Dictionary) -> Dictionary: + return await _json_request(HTTPClient.METHOD_POST, "/v1/world/arbitrate", request, [200]) + + +func timeline(request: Dictionary) -> Dictionary: + return await _json_request(HTTPClient.METHOD_POST, "/v1/session/timeline", request, [200]) + + +func replay(request: Dictionary) -> Dictionary: + return await _json_request(HTTPClient.METHOD_POST, "/v1/session/replay", request, [200]) + + func snapshot(session_id: String) -> Dictionary: return await _json_request( HTTPClient.METHOD_POST, diff --git a/examples/unity/RinClient.cs b/examples/unity/RinClient.cs index 4a8998f..14398eb 100644 --- a/examples/unity/RinClient.cs +++ b/examples/unity/RinClient.cs @@ -53,6 +53,51 @@ public IEnumerator Commit(CommitRequest request, Action complete completed(call.Ok ? JsonUtility.FromJson(call.Text).data : null); } + public IEnumerator CommitBatch(BatchCommitRequest request, Action completed) + { + var call = new CallResult(); + yield return Send("POST", "/v1/action/commit-batch", JsonUtility.ToJson(request), 200, call); + completed(call.Ok ? JsonUtility.FromJson(call.Text).data : null); + } + + public IEnumerator SetActorActivity(SetActorActivityRequest request, Action completed) + { + var call = new CallResult(); + yield return Send("POST", "/v1/session/activity", JsonUtility.ToJson(request), 200, call); + completed(call.Ok ? JsonUtility.FromJson(call.Text).data : null); + } + + public IEnumerator DueAgents(DueAgentsRequest request, Action completed) + { + var call = new CallResult(); + yield return Send("POST", "/v1/scheduler/due", JsonUtility.ToJson(request), 200, call); + completed(call.Ok ? JsonUtility.FromJson(call.Text).data : null); + } + + public IEnumerator Arbitrate(ArbitrateRequest request, Action completed) + { + var call = new CallResult(); + yield return Send("POST", "/v1/world/arbitrate", JsonUtility.ToJson(request), 200, call); + completed(call.Ok ? JsonUtility.FromJson(call.Text).data : null); + } + + public IEnumerator Timeline(TimelineRequest request, Action completed) + { + var call = new CallResult(); + yield return Send("POST", "/v1/session/timeline", JsonUtility.ToJson(request), 200, call); + completed(call.Ok ? JsonUtility.FromJson(call.Text).data : null); + } + + // JsonUtility cannot represent Rin's actor-id keyed state maps. Replay + // therefore exposes the verified snapshot header. A dictionary-capable + // JSON package can consume the same endpoint when full state is needed. + public IEnumerator Replay(ReplayRequest request, Action completed) + { + var call = new CallResult(); + yield return Send("POST", "/v1/session/replay", JsonUtility.ToJson(request), 200, call); + completed(call.Ok ? JsonUtility.FromJson(call.Text).data : null); + } + public IEnumerator ProposeWithFallback( ProposeRequest request, string fallbackActionId, @@ -351,6 +396,10 @@ public string GetText() [Serializable] private sealed class SubmissionEnvelope { public bool ok; public JobSubmission data; public ErrorDetail error; } [Serializable] private sealed class JobEnvelope { public bool ok; public ProposalJob data; public ErrorDetail error; } [Serializable] private sealed class MutationEnvelope { public bool ok; public MutationResult data; public ErrorDetail error; } + [Serializable] private sealed class DueAgentsEnvelope { public bool ok; public DueAgentsResponse data; public ErrorDetail error; } + [Serializable] private sealed class ArbitrationEnvelope { public bool ok; public ArbitrationResult data; public ErrorDetail error; } + [Serializable] private sealed class TimelineEnvelope { public bool ok; public TimelineResponse data; public ErrorDetail error; } + [Serializable] private sealed class ReplayEnvelope { public bool ok; public ReplaySnapshot data; public ErrorDetail error; } } [Serializable] public sealed class ActionSpec @@ -408,6 +457,7 @@ [Serializable] public sealed class CreateSessionRequest public string session_id; public Binding binding; public long seed; + public string[] features; public ActorSeed[] actors; } @@ -421,6 +471,7 @@ [Serializable] public sealed class ProposeRequest public string intent; public string[] tags; public ActionSpec[] candidate_actions; + public Goal[] candidate_goals; public bool urgent; } @@ -438,6 +489,7 @@ [Serializable] public sealed class ObserveRequest public string quote; public string[] tags; public int importance; + public Fact[] facts; } [Serializable] public sealed class CommitRequest @@ -451,6 +503,8 @@ [Serializable] public sealed class CommitRequest public bool accepted; public string outcome; public string[] tags; + public Fact[] facts; + public GoalUpdate[] goal_updates; } [Serializable] public sealed class ActionProposal @@ -462,6 +516,7 @@ [Serializable] public sealed class ActionProposal public long tick; public long based_on_revision; public string based_on_head_hash; + public long based_on_world_revision; public long created_revision; public ActionSpec action; public string stance; @@ -470,9 +525,177 @@ [Serializable] public sealed class ActionProposal public string policy_source; public string[] recalled_memory_ids; public string goal_id; + public Goal proposed_goal; + public string status; +} + +[Serializable] public sealed class Fact +{ + public string subject_id; + public string predicate; + public string @object; + public string[] visibility; + public int confidence; + public string source_event_id; +} + +[Serializable] public sealed class GoalUpdate +{ + public string goal_id; + public int progress_delta; public string status; } +[Serializable] public sealed class CommitItem +{ + public string proposal_id; + public string event_id; + public bool accepted; + public string outcome; + public string[] tags; + public Fact[] facts; + public GoalUpdate[] goal_updates; +} + +[Serializable] public sealed class BatchCommitRequest +{ + public string protocol_version = RinClient.ProtocolVersion; + public string session_id; + public string request_id; + public long tick; + public CommitItem[] items; +} + +[Serializable] public sealed class ActorActivityUpdate +{ + public string actor_id; + public string region_id; + public string state; + public string reason; +} + +[Serializable] public sealed class SetActorActivityRequest +{ + public string protocol_version = RinClient.ProtocolVersion; + public string session_id; + public string request_id; + public long tick; + public ActorActivityUpdate[] updates; +} + +[Serializable] public sealed class DueAgentsRequest +{ + public string protocol_version = RinClient.ProtocolVersion; + public string session_id; + public long tick; + public int limit; + public string[] region_ids; +} + +[Serializable] public sealed class DueAgent +{ + public string actor_id; + public long next_think_tick; + public string region_id; +} + +[Serializable] public sealed class DueAgentsResponse +{ + public string session_id; + public long tick; + public DueAgent[] agents; +} + +[Serializable] public sealed class ArbitrateRequest +{ + public string protocol_version = RinClient.ProtocolVersion; + public string session_id; + public string request_id; + public long tick; + public string[] proposal_ids; + public string[] exclusive_target_ids; +} + +[Serializable] public sealed class ArbitrationDecision +{ + public string proposal_id; + public string actor_id; + public string status; + public string reason; + public string[] conflicting_proposal_ids; +} + +[Serializable] public sealed class ArbitrationRecord +{ + public string id; + public string request_id; + public long tick; + public long based_on_world_revision; + public long created_revision; + public ArbitrationDecision[] decisions; +} + +[Serializable] public sealed class ArbitrationResult +{ + public ArbitrationRecord record; + public bool duplicate; +} + +[Serializable] public sealed class TimelineRequest +{ + public string protocol_version = RinClient.ProtocolVersion; + public string session_id; + public long after_revision; + public int limit = 50; +} + +[Serializable] public sealed class TimelineEntry +{ + public long sequence; + public string type; + public string request_id; + public string recorded_at; + public string hash; + public string prev_hash; + public string[] entity_ids; + public string[] actor_ids; + public string status; +} + +[Serializable] public sealed class TimelineResponse +{ + public string session_id; + public long current_revision; + public TimelineEntry[] entries; + public long next_after_revision; + public bool has_more; +} + +[Serializable] public sealed class ReplayRequest +{ + public string protocol_version = RinClient.ProtocolVersion; + public string session_id; + public long revision; +} + +[Serializable] public sealed class ReplayStateHeader +{ + public string protocol_version; + public string session_id; + public long tick; + public long revision; + public long world_revision; + public string head_hash; + public string[] features; +} + +[Serializable] public sealed class ReplaySnapshot +{ + public string protocol_version; + public string state_hash; + public ReplayStateHeader state; +} + [Serializable] public sealed class ErrorDetail { public string code; diff --git a/httpapi/server.go b/httpapi/server.go index f6eab69..96caa3d 100644 --- a/httpapi/server.go +++ b/httpapi/server.go @@ -69,6 +69,8 @@ func New(engine *rinruntime.Engine, options Options) *Server { mux.HandleFunc("POST /v1/session/get", server.getSession) mux.HandleFunc("POST /v1/session/snapshot", server.snapshot) mux.HandleFunc("POST /v1/session/restore", server.restore) + mux.HandleFunc("POST /v1/session/timeline", server.timeline) + mux.HandleFunc("POST /v1/session/replay", server.replay) mux.HandleFunc("POST /v1/scheduler/due", server.dueAgents) mux.HandleFunc("POST /v1/jobs/propose", server.submitProposalJob) mux.HandleFunc("GET /v1/jobs/{job_id}", server.getProposalJob) @@ -186,6 +188,24 @@ func (s *Server) restore(response http.ResponseWriter, request *http.Request) { s.respond(response, result, err) } +func (s *Server) timeline(response http.ResponseWriter, request *http.Request) { + var input protocol.TimelineRequest + if !s.decode(response, request, &input) { + return + } + result, err := s.engine.Timeline(input) + s.respond(response, result, err) +} + +func (s *Server) replay(response http.ResponseWriter, request *http.Request) { + var input protocol.ReplayRequest + if !s.decode(response, request, &input) { + return + } + result, err := s.engine.Replay(input) + s.respond(response, result, err) +} + func (s *Server) dueAgents(response http.ResponseWriter, request *http.Request) { var input protocol.DueAgentsRequest if !s.decode(response, request, &input) { diff --git a/httpapi/server_test.go b/httpapi/server_test.go index 673bacd..ec5ed75 100644 --- a/httpapi/server_test.go +++ b/httpapi/server_test.go @@ -104,6 +104,45 @@ func TestHTTPFlowAndNoSafeAction(t *testing.T) { } } +func TestTimelineAndReplayHTTPFlow(t *testing.T) { + server := newServer(t, httpapi.Options{}) + if response := perform(t, server, "/v1/session/create", apiCreateRequest()); response.Code != http.StatusOK { + t.Fatalf("create: %d %s", response.Code, response.Body.String()) + } + timelineResponse := perform(t, server, "/v1/session/timeline", protocol.TimelineRequest{ + ProtocolVersion: protocol.Version, SessionID: "session.http", Limit: 10, + }) + if timelineResponse.Code != http.StatusOK { + t.Fatalf("timeline: %d %s", timelineResponse.Code, timelineResponse.Body.String()) + } + var timeline struct { + OK bool `json:"ok"` + Data protocol.TimelineResponse `json:"data"` + } + if err := json.Unmarshal(timelineResponse.Body.Bytes(), &timeline); err != nil { + t.Fatal(err) + } + if !timeline.OK || len(timeline.Data.Entries) != 1 || timeline.Data.Entries[0].Type != rinruntime.EventSessionCreated { + t.Fatalf("unexpected timeline: %+v", timeline) + } + replayResponse := perform(t, server, "/v1/session/replay", protocol.ReplayRequest{ + ProtocolVersion: protocol.Version, SessionID: "session.http", Revision: 1, + }) + if replayResponse.Code != http.StatusOK { + t.Fatalf("replay: %d %s", replayResponse.Code, replayResponse.Body.String()) + } + var replay struct { + OK bool `json:"ok"` + Data protocol.Snapshot `json:"data"` + } + if err := json.Unmarshal(replayResponse.Body.Bytes(), &replay); err != nil { + t.Fatal(err) + } + if !replay.OK || replay.Data.State.Revision != 1 || replay.Data.StateHash == "" { + t.Fatalf("unexpected replay: %+v", replay) + } +} + func TestAsyncProposalJobHTTPFlow(t *testing.T) { engine, err := rinruntime.Open(store.NewMemory(), policy.Deterministic{}) if err != nil { diff --git a/protocol/debug.go b/protocol/debug.go new file mode 100644 index 0000000..3ce3077 --- /dev/null +++ b/protocol/debug.go @@ -0,0 +1,36 @@ +package protocol + +// TimelineRequest selects a bounded page of redacted event metadata. The +// event payload is never returned by this endpoint. +type TimelineRequest struct { + ProtocolVersion string `json:"protocol_version"` + SessionID string `json:"session_id"` + AfterRevision uint64 `json:"after_revision,omitempty"` + Limit int `json:"limit"` +} + +type TimelineEntry struct { + Sequence uint64 `json:"sequence"` + Type string `json:"type"` + RequestID string `json:"request_id"` + RecordedAt string `json:"recorded_at"` + Hash string `json:"hash"` + PrevHash string `json:"prev_hash,omitempty"` + EntityIDs []string `json:"entity_ids,omitempty"` + ActorIDs []string `json:"actor_ids,omitempty"` + Status string `json:"status,omitempty"` +} + +type TimelineResponse struct { + SessionID string `json:"session_id"` + CurrentRevision uint64 `json:"current_revision"` + Entries []TimelineEntry `json:"entries"` + NextAfterRevision uint64 `json:"next_after_revision"` + HasMore bool `json:"has_more"` +} + +type ReplayRequest struct { + ProtocolVersion string `json:"protocol_version"` + SessionID string `json:"session_id"` + Revision uint64 `json:"revision"` +} diff --git a/protocol/debug_validate.go b/protocol/debug_validate.go new file mode 100644 index 0000000..832a121 --- /dev/null +++ b/protocol/debug_validate.go @@ -0,0 +1,27 @@ +package protocol + +func ValidateTimeline(request TimelineRequest) error { + if err := validateVersion(request.ProtocolVersion); err != nil { + return err + } + if err := validateID("session_id", request.SessionID); err != nil { + return err + } + if request.Limit < 1 || request.Limit > 256 { + return &ValidationError{Field: "limit", Message: "must be between 1 and 256"} + } + return nil +} + +func ValidateReplay(request ReplayRequest) error { + if err := validateVersion(request.ProtocolVersion); err != nil { + return err + } + if err := validateID("session_id", request.SessionID); err != nil { + return err + } + if request.Revision == 0 { + return &ValidationError{Field: "revision", Message: "must be greater than zero"} + } + return nil +} diff --git a/runtime/debug.go b/runtime/debug.go new file mode 100644 index 0000000..12f5042 --- /dev/null +++ b/runtime/debug.go @@ -0,0 +1,185 @@ +package runtime + +import ( + "encoding/json" + "fmt" + + "github.com/sunrioa/rin/protocol" +) + +// Timeline returns redacted, structural event metadata. It deliberately +// decodes only identifiers and enum-like state; authored and model text stays +// inside the authenticated state/replay APIs. +func (e *Engine) Timeline(request protocol.TimelineRequest) (protocol.TimelineResponse, error) { + if err := protocol.ValidateTimeline(request); err != nil { + return protocol.TimelineResponse{}, validationError(err) + } + session, err := e.session(request.SessionID) + if err != nil { + return protocol.TimelineResponse{}, err + } + session.mu.Lock() + defer session.mu.Unlock() + + events, _, err := e.loadAndReplay(request.SessionID, 0) + if err != nil { + return protocol.TimelineResponse{}, err + } + response := protocol.TimelineResponse{ + SessionID: request.SessionID, CurrentRevision: session.state.Revision, + Entries: make([]protocol.TimelineEntry, 0, request.Limit), + NextAfterRevision: request.AfterRevision, + } + for _, event := range events { + if event.Sequence <= request.AfterRevision { + continue + } + if len(response.Entries) == request.Limit { + response.HasMore = true + break + } + entry, err := timelineEntry(event) + if err != nil { + return protocol.TimelineResponse{}, NewError("timeline_decode_failed", "could not decode event metadata", err) + } + response.Entries = append(response.Entries, entry) + response.NextAfterRevision = event.Sequence + } + return response, nil +} + +// Replay reconstructs a session at an exact event-log revision without +// mutating current state or writing a snapshot. +func (e *Engine) Replay(request protocol.ReplayRequest) (protocol.Snapshot, error) { + if err := protocol.ValidateReplay(request); err != nil { + return protocol.Snapshot{}, validationError(err) + } + session, err := e.session(request.SessionID) + if err != nil { + return protocol.Snapshot{}, err + } + session.mu.Lock() + defer session.mu.Unlock() + + _, state, err := e.loadAndReplay(request.SessionID, request.Revision) + if err != nil { + return protocol.Snapshot{}, err + } + if state.Revision != request.Revision { + return protocol.Snapshot{}, NewFieldError("revision_not_found", "requested revision does not exist", "revision", ErrNotFound) + } + snapshot, err := SnapshotOf(state) + if err != nil { + return protocol.Snapshot{}, NewError("replay_failed", "could not snapshot replayed state", err) + } + return snapshot, nil +} + +// A target revision of zero verifies and replays the complete log. +func (e *Engine) loadAndReplay(sessionID string, targetRevision uint64) ([]protocol.EventRecord, protocol.SessionState, error) { + events, err := e.store.Load(sessionID) + if err != nil { + return nil, protocol.SessionState{}, NewError("store_load_failed", "could not load session log", err) + } + var state protocol.SessionState + for _, event := range events { + if targetRevision > 0 && event.Sequence > targetRevision { + break + } + state, err = applyEvent(state, event) + if err != nil { + return nil, protocol.SessionState{}, NewError("replay_failed", "session event log is invalid", err) + } + } + return events, state, nil +} + +func timelineEntry(event protocol.EventRecord) (protocol.TimelineEntry, error) { + entry := protocol.TimelineEntry{ + Sequence: event.Sequence, Type: event.Type, RequestID: event.RequestID, + RecordedAt: event.RecordedAt, Hash: event.Hash, PrevHash: event.PrevHash, + } + switch event.Type { + case EventSessionCreated: + var payload createdPayload + if err := json.Unmarshal(event.Data, &payload); err != nil { + return protocol.TimelineEntry{}, err + } + entry.EntityIDs = []string{payload.Request.SessionID} + for _, actor := range payload.Request.Actors { + entry.ActorIDs = append(entry.ActorIDs, actor.ID) + } + entry.Status = "created" + case EventObserved: + var payload observedPayload + if err := json.Unmarshal(event.Data, &payload); err != nil { + return protocol.TimelineEntry{}, err + } + entry.EntityIDs = []string{payload.Request.EventID} + entry.ActorIDs = append([]string(nil), payload.Request.ObserverIDs...) + entry.Status = payload.Request.Kind + case EventProposed: + var payload proposedPayload + if err := json.Unmarshal(event.Data, &payload); err != nil { + return protocol.TimelineEntry{}, err + } + entry.EntityIDs = []string{payload.Proposal.ID, payload.Proposal.Action.ID} + entry.ActorIDs = []string{payload.Proposal.ActorID} + entry.Status = payload.Proposal.Status + case EventCommitted: + var payload committedPayload + if err := json.Unmarshal(event.Data, &payload); err != nil { + return protocol.TimelineEntry{}, err + } + entry.EntityIDs = []string{payload.Request.ProposalID, payload.Request.EventID} + entry.Status = acceptedStatus(payload.Request.Accepted) + case EventBatchCommitted: + var payload batchCommittedPayload + if err := json.Unmarshal(event.Data, &payload); err != nil { + return protocol.TimelineEntry{}, err + } + for _, item := range payload.Request.Items { + entry.EntityIDs = append(entry.EntityIDs, item.ProposalID, item.EventID) + } + entry.Status = "committed" + case EventActivityUpdated: + var payload activityUpdatedPayload + if err := json.Unmarshal(event.Data, &payload); err != nil { + return protocol.TimelineEntry{}, err + } + for _, update := range payload.Request.Updates { + entry.ActorIDs = append(entry.ActorIDs, update.ActorID) + } + entry.Status = "updated" + case EventArbitrated: + var payload arbitratedPayload + if err := json.Unmarshal(event.Data, &payload); err != nil { + return protocol.TimelineEntry{}, err + } + entry.EntityIDs = append(entry.EntityIDs, payload.Record.ID) + for _, decision := range payload.Record.Decisions { + entry.EntityIDs = append(entry.EntityIDs, decision.ProposalID) + entry.ActorIDs = append(entry.ActorIDs, decision.ActorID) + } + entry.Status = "resolved" + case EventSessionRestored: + var payload restoredPayload + if err := json.Unmarshal(event.Data, &payload); err != nil { + return protocol.TimelineEntry{}, err + } + entry.EntityIDs = []string{payload.Snapshot.State.SessionID} + entry.Status = "restored" + default: + return protocol.TimelineEntry{}, fmt.Errorf("%w: unknown event type %q", ErrCorruptLog, event.Type) + } + entry.EntityIDs = uniqueSorted(entry.EntityIDs) + entry.ActorIDs = uniqueSorted(entry.ActorIDs) + return entry, nil +} + +func acceptedStatus(accepted bool) string { + if accepted { + return "accepted" + } + return "rejected" +} diff --git a/runtime/debug_test.go b/runtime/debug_test.go new file mode 100644 index 0000000..f2e9f5d --- /dev/null +++ b/runtime/debug_test.go @@ -0,0 +1,97 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/sunrioa/rin/policy" + "github.com/sunrioa/rin/protocol" + rintime "github.com/sunrioa/rin/runtime" + "github.com/sunrioa/rin/store" +) + +func TestTimelineIsBoundedAndRedacted(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + _, _ = engine.CreateSession(createRequest("session.timeline")) + observation := observeRequest("session.timeline", "observe.timeline", "event.timeline", 1) + observation.Summary = "SECRET_SUMMARY player disclosed a private concern" + observation.Quote = "SECRET_QUOTE exact player words" + if _, err := engine.Observe(observation); err != nil { + t.Fatal(err) + } + proposal, _, err := engine.Propose(context.Background(), proposeRequest("session.timeline", "propose.timeline", 2, nil)) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, SessionID: "session.timeline", RequestID: "commit.timeline", + ProposalID: proposal.ID, EventID: "event.commit.timeline", Tick: 2, Accepted: true, + Outcome: "SECRET_OUTCOME model-authored response", Tags: []string{"conversation"}, + }); err != nil { + t.Fatal(err) + } + + page, err := engine.Timeline(protocol.TimelineRequest{ + ProtocolVersion: protocol.Version, SessionID: "session.timeline", Limit: 2, + }) + if err != nil { + t.Fatal(err) + } + if len(page.Entries) != 2 || !page.HasMore || page.NextAfterRevision != 2 || page.CurrentRevision != 4 { + t.Fatalf("unexpected first page: %+v", page) + } + second, err := engine.Timeline(protocol.TimelineRequest{ + ProtocolVersion: protocol.Version, SessionID: "session.timeline", + AfterRevision: page.NextAfterRevision, Limit: 2, + }) + if err != nil { + t.Fatal(err) + } + if len(second.Entries) != 2 || second.HasMore || second.Entries[1].Status != "accepted" { + t.Fatalf("unexpected second page: %+v", second) + } + payload, err := json.Marshal([]protocol.TimelineResponse{page, second}) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"SECRET_SUMMARY", "SECRET_QUOTE", "SECRET_OUTCOME"} { + if strings.Contains(string(payload), secret) { + t.Fatalf("timeline leaked %s: %s", secret, payload) + } + } +} + +func TestReplayUsesExactRevisionWithoutMutatingCurrentState(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + _, _ = engine.CreateSession(createRequest("session.replay")) + _, _ = engine.Observe(observeRequest("session.replay", "observe.replay.1", "event.replay.1", 3)) + _, _ = engine.Observe(observeRequest("session.replay", "observe.replay.2", "event.replay.2", 5)) + + snapshot, err := engine.Replay(protocol.ReplayRequest{ + ProtocolVersion: protocol.Version, SessionID: "session.replay", Revision: 2, + }) + if err != nil { + t.Fatal(err) + } + if snapshot.State.Revision != 2 || snapshot.State.Tick != 3 || len(snapshot.State.Actors["npc.mira"].Memories) != 1 { + t.Fatalf("unexpected replay state: %+v", snapshot.State) + } + if err := rintime.ValidateSnapshot(snapshot); err != nil { + t.Fatalf("replay did not return a valid snapshot: %v", err) + } + current, err := engine.State(sessionRequest("session.replay")) + if err != nil { + t.Fatal(err) + } + if current.Revision != 3 || current.Tick != 5 || len(current.Actors["npc.mira"].Memories) != 2 { + t.Fatalf("replay mutated current state: %+v", current) + } + _, err = engine.Replay(protocol.ReplayRequest{ + ProtocolVersion: protocol.Version, SessionID: "session.replay", Revision: 99, + }) + if rintime.ErrorCode(err) != "revision_not_found" { + t.Fatalf("expected revision_not_found, got %v", err) + } +} From 054a49fbb5b7263ae7d7501d10523e8f21ca6263 Mon Sep 17 00:00:00 2001 From: sunrioa Date: Wed, 22 Jul 2026 17:32:00 +0800 Subject: [PATCH 5/5] test: enable deployed game cognition features --- compat/ai-galgame/vectors.json | 2 ++ compat/ai_galgame_test.go | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/compat/ai-galgame/vectors.json b/compat/ai-galgame/vectors.json index c0e23c0..775e110 100644 --- a/compat/ai-galgame/vectors.json +++ b/compat/ai-galgame/vectors.json @@ -30,6 +30,7 @@ "content_hash": "0e6f3d8bba0360068fe640385006a1eece493a4e14d19e4e90d8363407c8be1f" }, "seed": 42021, + "features": ["memory-archive-v1", "belief-conflicts-v1"], "actors": [ { "id": "npc.lin-wanqing", @@ -193,6 +194,7 @@ "content_hash": "0e6f3d8bba0360068fe640385006a1eece493a4e14d19e4e90d8363407c8be1f" }, "seed": 42022, + "features": ["memory-archive-v1", "belief-conflicts-v1"], "actors": [ { "id": "npc.lin-wanqing", diff --git a/compat/ai_galgame_test.go b/compat/ai_galgame_test.go index c8fa42b..628119a 100644 --- a/compat/ai_galgame_test.go +++ b/compat/ai_galgame_test.go @@ -88,6 +88,10 @@ func runVectorCase(t *testing.T, source vectorSource, testCase vectorCase) { testCase.Create.Binding.ContentHash != source.Fingerprint { t.Fatal("case binding does not match source manifest") } + if !protocol.HasFeature(testCase.Create.Features, protocol.FeatureMemoryArchive) || + !protocol.HasFeature(testCase.Create.Features, protocol.FeatureBeliefConflicts) { + t.Fatal("ai-galgame vectors must enable the deployed cognition features") + } engine, err := rinruntime.Open(store.NewMemory(), policy.Deterministic{}) if err != nil { t.Fatal(err)