diff --git a/ROADMAP.en.md b/ROADMAP.en.md index 66cdb4e..7c059f1 100644 --- a/ROADMAP.en.md +++ b/ROADMAP.en.md @@ -60,6 +60,10 @@ authorization always belong to the game Host. offers, cancellation, acknowledgements, progress, and outcomes. - MCP and Host APIs share stable Operation IDs, Epoch/Observation bindings, and the exact Host-authored Offer. +- Hosts publish one `decision_authority` that hands an Actor between internal + control and one exact external Principal; revisions fence stale turns. +- External controllers can long-poll redacted Actor state and submit Actor + speech plus an exact Offer selection under one `turn_id`. - An exclusive state lock, stale timeline rejection, orphan expiry, and bounded recovery. - A dedicated [`api/control-openapi.json`](api/control-openapi.json) contract and @@ -124,10 +128,16 @@ through cross-repository tests. exists, and cooldown permits; there is no unbounded background polling. - [x] A character can initiate contextual dialogue, propose one reviewable small goal, and refuse unsafe action. -- [x] One actor has one write-control lease at a time; in-game, internal AI, and - MCP contention returns an explicit busy or handoff result. -- [x] Autonomous and external paths share the control service, Capability, and - exact offers; world action is proposed before acceptance. +- [x] One Actor has one decision source: the internal runtime or an external + agent bound to one exact Principal. Authority revisions stale unaccepted old + turns. +- [x] An external agent can wait for state changes, speak as the Actor, and + select a Host Offer; dialogue and action may share one `turn_id`. +- [x] `character-bound` and `agent-avatar` distinguish the Host character from + an external agent persona; Rin does not copy private memory across sources. +- [x] Autonomous and external paths share Capability, exact Offers, and final + Host authorization while semantic decisions remain separate from per-tick + execution. - [ ] Quiet hours, daily limits, and richer autonomous goals wait for human evidence that the current initiative adds value. diff --git a/ROADMAP.md b/ROADMAP.md index f485568..a5800fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,6 +49,10 @@ Rin 不是游戏引擎、通用自动化平台或模型代理服务。渲染、 ACK、进度和 Outcome。 - MCP 和 Host API 共用稳定 Operation ID、Epoch/Observation Binding 与精确 Host Offer。 +- Host 发布单一 `decision_authority`,在内部控制与一个精确外部 Principal 之间 + 转交;修订号会隔离旧回合。 +- 外部控制器可以长轮询脱敏 Actor 状态,并以同一 `turn_id` 提交角色对白和精确 + Offer 选择。 - 状态目录独占锁、旧时间线失效、孤儿操作过期和有界恢复。 - 独立 [`api/control-openapi.json`](api/control-openapi.json) 契约及官方 MCP Conformance 的能力匹配门禁。 @@ -95,9 +99,14 @@ Rin 不是游戏引擎、通用自动化平台或模型代理服务。渲染、 - [x] 主动对话默认关闭,并可配置主动级别、冷却和连续主动回合上限。 - [x] Dormant Actor 仅在主人在线、存在可继续话题且满足冷却时唤醒,不做无限后台轮询。 - [x] 角色可以发起有上下文对白、提出一个可审核小目标,并拒绝不安全动作。 -- [x] 同一 Actor 同一时刻只有一个写控制租约;游戏内、内部 AI 和 MCP 竞争时给出 - 明确的忙碌或转交结果。 -- [x] 自动行为与外部入口共用控制服务、Capability 和精确 Offer;世界动作先提议后确认。 +- [x] 同一 Actor 只有一个决策控制源:内部 Runtime,或绑定一个精确 Principal 的 + 外部 Agent;控制权修订会使尚未接受的旧回合失效。 +- [x] 外部 Agent 可以等待状态变化、作为角色说话并选择 Host Offer;对白和动作可用 + 同一个 `turn_id` 关联。 +- [x] `character-bound` 与 `agent-avatar` 明确区分角色人格和外部 Agent 人格; + Rin 不在控制源之间复制私有记忆。 +- [x] 自动行为与外部入口共用 Capability、精确 Offer 和 Host 最终授权;语义决策 + 与逐 Tick 执行保持分层。 - [ ] 安静时段、每日上限和更丰富的自主目标等待真人确认当前主动性有价值后再做。 ### 5. 有界世界任务 diff --git a/api/control-openapi.json b/api/control-openapi.json index 6ffd886..6e63294 100644 --- a/api/control-openapi.json +++ b/api/control-openapi.json @@ -397,6 +397,36 @@ } } }, + "/control/v1/client/wait-actor": { + "post": { + "operationId": "control_client_wait_actor", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WaitActorInput" + } + } + } + }, + "responses": { + "200": { + "description": "Latest principal-visible actor state after a bounded wait", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActorUpdate" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/control/v1/client/offers": { "post": { "operationId": "control_client_list_offers", @@ -490,6 +520,36 @@ } } }, + "/control/v1/client/utterance": { + "post": { + "operationId": "control_client_submit_utterance", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActorUtteranceInput" + } + } + } + }, + "responses": { + "200": { + "description": "Queued externally authored Actor utterance", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationView" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/control/v1/client/execute-offer": { "post": { "operationId": "control_client_execute_offer", @@ -1067,6 +1127,44 @@ } } }, + "DecisionAuthority": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "revision", + "persona_mode" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "internal", + "external" + ] + }, + "controller_principal_id": { + "$ref": "#/components/schemas/Identifier" + }, + "revision": { + "allOf": [ + { + "$ref": "#/components/schemas/JSONSafeUnsignedInteger" + }, + { + "minimum": 1 + } + ] + }, + "persona_mode": { + "type": "string", + "enum": [ + "character-bound", + "agent-avatar" + ] + } + } + }, "ActorPublication": { "type": "object", "additionalProperties": false, @@ -1103,6 +1201,9 @@ "epoch": { "$ref": "#/components/schemas/Epoch" }, + "decision_authority": { + "$ref": "#/components/schemas/DecisionAuthority" + }, "state": { "type": "object" }, @@ -1205,7 +1306,8 @@ "additionalProperties": false, "required": [ "epoch", - "observation_seq" + "observation_seq", + "authority_revision" ], "properties": { "epoch": { @@ -1220,6 +1322,16 @@ "minimum": 1 } ] + }, + "authority_revision": { + "allOf": [ + { + "$ref": "#/components/schemas/JSONSafeUnsignedInteger" + }, + { + "minimum": 1 + } + ] } } }, @@ -1261,9 +1373,13 @@ "enum": [ "message", "directive", + "utterance", "offer" ] }, + "turn_id": { + "$ref": "#/components/schemas/Identifier" + }, "text": { "type": "string", "minLength": 1, @@ -1549,6 +1665,56 @@ } } }, + "WaitActorInput": { + "type": "object", + "additionalProperties": false, + "required": [ + "host_id", + "world_id", + "actor_id", + "after_observation_seq", + "after_authority_revision", + "wait_millis" + ], + "properties": { + "host_id": { + "$ref": "#/components/schemas/Identifier" + }, + "world_id": { + "$ref": "#/components/schemas/Identifier" + }, + "actor_id": { + "$ref": "#/components/schemas/Identifier" + }, + "after_observation_seq": { + "$ref": "#/components/schemas/JSONSafeUnsignedInteger" + }, + "after_authority_revision": { + "$ref": "#/components/schemas/JSONSafeUnsignedInteger" + }, + "wait_millis": { + "type": "integer", + "minimum": 0, + "maximum": 25000 + } + } + }, + "ActorUpdate": { + "type": "object", + "additionalProperties": false, + "required": [ + "actor", + "changed" + ], + "properties": { + "actor": { + "$ref": "#/components/schemas/ActorView" + }, + "changed": { + "type": "boolean" + } + } + }, "ActorTextInput": { "type": "object", "additionalProperties": false, @@ -1579,6 +1745,40 @@ } } }, + "ActorUtteranceInput": { + "type": "object", + "additionalProperties": false, + "required": [ + "request_id", + "host_id", + "world_id", + "actor_id", + "turn_id", + "text" + ], + "properties": { + "request_id": { + "$ref": "#/components/schemas/Identifier" + }, + "host_id": { + "$ref": "#/components/schemas/Identifier" + }, + "world_id": { + "$ref": "#/components/schemas/Identifier" + }, + "actor_id": { + "$ref": "#/components/schemas/Identifier" + }, + "turn_id": { + "$ref": "#/components/schemas/Identifier" + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 300 + } + } + }, "ExecuteOfferInput": { "type": "object", "additionalProperties": false, @@ -1604,6 +1804,9 @@ }, "offer_id": { "$ref": "#/components/schemas/Identifier" + }, + "turn_id": { + "$ref": "#/components/schemas/Identifier" } } }, @@ -1662,6 +1865,7 @@ "display_name", "observation_seq", "epoch", + "decision_authority", "state", "online", "lease_expires_at_unix_millis" @@ -1688,6 +1892,9 @@ "epoch": { "$ref": "#/components/schemas/Epoch" }, + "decision_authority": { + "$ref": "#/components/schemas/DecisionAuthority" + }, "state": { "type": "object" }, @@ -1765,9 +1972,13 @@ "enum": [ "message", "directive", + "utterance", "offer" ] }, + "turn_id": { + "$ref": "#/components/schemas/Identifier" + }, "status": { "$ref": "#/components/schemas/OperationStatus" }, diff --git a/cmd/rin-control/main.go b/cmd/rin-control/main.go index ed34c2b..4ae9179 100644 --- a/cmd/rin-control/main.go +++ b/cmd/rin-control/main.go @@ -236,6 +236,7 @@ func containsAnyControlScope(scopes []string) bool { controlplane.ScopeActorRead, controlplane.ScopeActorConverse, controlplane.ScopeActorDirect, + controlplane.ScopeActorSpeak, controlplane.ScopeActorExecute, controlplane.ScopeOperationCancel, controlplane.ScopeHostAdmin, diff --git a/controlplane/authority_test.go b/controlplane/authority_test.go new file mode 100644 index 0000000..713cef3 --- /dev/null +++ b/controlplane/authority_test.go @@ -0,0 +1,310 @@ +package controlplane + +import ( + "errors" + "strings" + "testing" + + "github.com/sunrioa/rin/host" +) + +func TestDecisionAuthorityValidation(t *testing.T) { + tests := []struct { + name string + authority DecisionAuthority + }{ + { + name: "internal principal", + authority: DecisionAuthority{ + Source: DecisionInternal, + ControllerPrincipalID: "player.one", + Revision: 1, + PersonaMode: PersonaCharacterBound, + }, + }, + { + name: "internal avatar", + authority: DecisionAuthority{ + Source: DecisionInternal, + Revision: 1, + PersonaMode: PersonaAgentAvatar, + }, + }, + { + name: "external missing principal", + authority: DecisionAuthority{ + Source: DecisionExternal, + Revision: 1, + PersonaMode: PersonaCharacterBound, + }, + }, + { + name: "zero revision", + authority: DecisionAuthority{ + Source: DecisionExternal, + ControllerPrincipalID: "player.one", + PersonaMode: PersonaCharacterBound, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + publication := worldPublication(1, "ready") + publication.Actors[0].Authority = &test.authority + if err := validatePublication( + publication, + registration("instance.authority").Manifest, + ); err == nil { + t.Fatal("invalid authority was accepted") + } + }) + } +} + +func TestLegacyPublicationPreservesExternalOwnerControl(t *testing.T) { + service, _, _ := operationTestService(t, Options{}) + principal := operationPrincipal(ScopeActorRead, ScopeActorExecute) + actors, err := service.ListActors( + principal, + "test.host", + "world.one", + ) + if err != nil || len(actors) != 1 { + t.Fatalf("ListActors = %#v, %v", actors, err) + } + authority := actors[0].Authority + if authority.Source != DecisionExternal || + authority.ControllerPrincipalID != principal.ID || + authority.Revision != 1 || + authority.PersonaMode != PersonaCharacterBound { + t.Fatalf("legacy authority = %#v", authority) + } + offers, err := service.ListActorOffers( + principal, + "test.host", + "world.one", + "actor.one", + ) + if err != nil || len(offers) != 1 { + t.Fatalf("ListActorOffers = %#v, %v", offers, err) + } +} + +func TestInternalAuthorityBlocksExternalOfferSelection(t *testing.T) { + service, lease, _ := operationTestService(t, Options{}) + publication := worldPublication(2, "internal") + publication.Actors[0].Authority = &DecisionAuthority{ + Source: DecisionInternal, + Revision: 2, + PersonaMode: PersonaCharacterBound, + } + if err := service.PublishWorld( + "test.host", + lease.LeaseID, + publication, + ); err != nil { + t.Fatalf("PublishWorld: %v", err) + } + principal := operationPrincipal(ScopeActorRead, ScopeActorExecute) + if _, err := service.ListActorOffers( + principal, + "test.host", + "world.one", + "actor.one", + ); !errors.Is(err, ErrForbidden) { + t.Fatalf("ListActorOffers error = %v", err) + } + if _, err := service.ExecuteActorOffer( + principal, + ExecuteOfferInput{ + RequestID: "request.internal.offer", + HostID: "test.host", + WorldID: "world.one", + ActorID: "actor.one", + OfferID: "offer.follow", + }, + ); !errors.Is(err, ErrForbidden) { + t.Fatalf("ExecuteActorOffer error = %v", err) + } +} + +func TestAuthorityRevisionFencesUnacceptedOperations(t *testing.T) { + service, lease, _ := operationTestService(t, Options{}) + principal := operationPrincipal(ScopeActorExecute) + operation, err := service.ExecuteActorOffer( + principal, + ExecuteOfferInput{ + RequestID: "request.authority.fence", + HostID: "test.host", + WorldID: "world.one", + ActorID: "actor.one", + OfferID: "offer.follow", + }, + ) + if err != nil { + t.Fatalf("ExecuteActorOffer: %v", err) + } + + publication := worldPublication(2, "switched") + publication.Actors[0].Authority = &DecisionAuthority{ + Source: DecisionInternal, + Revision: 2, + PersonaMode: PersonaCharacterBound, + } + if err := service.PublishWorld( + "test.host", + lease.LeaseID, + publication, + ); err != nil { + t.Fatalf("PublishWorld: %v", err) + } + view, err := service.GetOperation(principal, operation.OperationID) + if err != nil || view.Status != OperationStale { + t.Fatalf("fenced operation = %#v, %v", view, err) + } +} + +func TestExternalAuthorityRequiresBoundPrincipal(t *testing.T) { + service, lease, _ := operationTestService(t, Options{}) + publication := worldPublication(2, "external") + publication.Actors[0].Authority = &DecisionAuthority{ + Source: DecisionExternal, + ControllerPrincipalID: "agent.one", + Revision: 2, + PersonaMode: PersonaAgentAvatar, + } + if err := service.PublishWorld( + "test.host", + lease.LeaseID, + publication, + ); err != nil { + t.Fatalf("PublishWorld: %v", err) + } + admin := host.Principal{ + ID: "admin.one", + GrantedScopes: []string{ScopeHostAdmin, ScopeActorExecute}, + } + if _, err := service.ListActorOffers( + admin, + "test.host", + "world.one", + "actor.one", + ); !errors.Is(err, ErrForbidden) { + t.Fatalf("unbound admin error = %v", err) + } + controller := host.Principal{ + ID: "agent.one", + GrantedScopes: []string{ + ScopeActorRead, + ScopeActorSpeak, + ScopeActorExecute, + }, + } + actors, err := service.ListActors( + controller, + "test.host", + "world.one", + ) + if err != nil || len(actors) != 1 { + t.Fatalf("bound controller actors = %#v, %v", actors, err) + } + operation, err := service.ExecuteActorOffer( + controller, + ExecuteOfferInput{ + RequestID: "request.agent.offer", + HostID: "test.host", + WorldID: "world.one", + ActorID: "actor.one", + OfferID: "offer.follow", + TurnID: "turn.agent.one", + }, + ) + if err != nil || operation.Kind != ControlOffer || + operation.TurnID != "turn.agent.one" { + t.Fatalf("bound controller offer = %#v, %v", operation, err) + } +} + +func TestExternalControllerQueuesBoundActorUtterance(t *testing.T) { + service, lease, _ := operationTestService(t, Options{}) + principal := operationPrincipal(ScopeActorSpeak) + input := ActorUtteranceInput{ + RequestID: "request.utterance.one", + HostID: "test.host", + WorldID: "world.one", + ActorID: "actor.one", + TurnID: "turn.one", + Text: "I noticed you have been building for a while.", + } + operation, err := service.SubmitActorUtterance(principal, input) + if err != nil || operation.Kind != ControlUtterance || + operation.TurnID != input.TurnID { + t.Fatalf("SubmitActorUtterance = %#v, %v", operation, err) + } + retried, err := service.SubmitActorUtterance(principal, input) + if err != nil || retried.OperationID != operation.OperationID { + t.Fatalf("idempotent utterance = %#v, %v", retried, err) + } + changed := input + changed.TurnID = "turn.two" + if _, err := service.SubmitActorUtterance( + principal, + changed, + ); !errors.Is(err, ErrConflict) { + t.Fatalf("changed turn id error = %v", err) + } + + batch := pollHost(t, service, lease, 1) + if len(batch.Requests) != 1 { + t.Fatalf("utterance batch = %#v", batch) + } + request := batch.Requests[0].Request + if request.Kind != ControlUtterance || + request.TurnID != input.TurnID || + request.Text != input.Text || + request.Binding == nil || + request.Binding.AuthorityRevision != 1 { + t.Fatalf("utterance request = %#v", request) + } +} + +func TestActorUtteranceRejectsInactiveSourceAndLongText(t *testing.T) { + service, lease, _ := operationTestService(t, Options{}) + principal := operationPrincipal(ScopeActorSpeak) + input := ActorUtteranceInput{ + RequestID: "request.utterance.invalid", + HostID: "test.host", + WorldID: "world.one", + ActorID: "actor.one", + TurnID: "turn.invalid", + Text: strings.Repeat("a", maxUtteranceRunes+1), + } + if _, err := service.SubmitActorUtterance( + principal, + input, + ); !errors.Is(err, ErrInvalid) { + t.Fatalf("long utterance error = %v", err) + } + + publication := worldPublication(2, "internal") + publication.Actors[0].Authority = &DecisionAuthority{ + Source: DecisionInternal, + Revision: 2, + PersonaMode: PersonaCharacterBound, + } + if err := service.PublishWorld( + "test.host", + lease.LeaseID, + publication, + ); err != nil { + t.Fatalf("PublishWorld: %v", err) + } + input.RequestID = "request.utterance.inactive" + input.Text = "This source is inactive." + if _, err := service.SubmitActorUtterance( + principal, + input, + ); !errors.Is(err, ErrForbidden) { + t.Fatalf("inactive source error = %v", err) + } +} diff --git a/controlplane/http.go b/controlplane/http.go index 0a26a88..b483a47 100644 --- a/controlplane/http.go +++ b/controlplane/http.go @@ -153,9 +153,11 @@ func NewHTTPHandler(service *Service, options HTTPOptions) (http.Handler, error) mux.HandleFunc("POST /control/v1/client/worlds", server.clientWorlds) mux.HandleFunc("POST /control/v1/client/actors", server.clientActors) mux.HandleFunc("POST /control/v1/client/actor", server.clientActor) + mux.HandleFunc("POST /control/v1/client/wait-actor", server.clientWaitActor) mux.HandleFunc("POST /control/v1/client/offers", server.clientOffers) mux.HandleFunc("POST /control/v1/client/message", server.clientMessage) mux.HandleFunc("POST /control/v1/client/directive", server.clientDirective) + mux.HandleFunc("POST /control/v1/client/utterance", server.clientUtterance) mux.HandleFunc("POST /control/v1/client/execute-offer", server.clientExecuteOffer) mux.HandleFunc("POST /control/v1/client/operation", server.clientOperation) mux.HandleFunc("POST /control/v1/client/cancel", server.clientCancel) @@ -453,6 +455,27 @@ func (server *hostHTTPHandler) clientActor( writeJSON(response, http.StatusOK, view) } +func (server *hostHTTPHandler) clientWaitActor( + response http.ResponseWriter, + request *http.Request, +) { + var input WaitActorInput + if err := server.decode(response, request, &input); err != nil { + writeHTTPError(response, http.StatusBadRequest, err.Error()) + return + } + update, err := server.service.WaitActor( + request.Context(), + *server.clientPrincipal, + input, + ) + if err != nil { + writeServiceError(response, err) + return + } + writeJSON(response, http.StatusOK, update) +} + func (server *hostHTTPHandler) clientOffers( response http.ResponseWriter, request *http.Request, @@ -512,6 +535,26 @@ func (server *hostHTTPHandler) clientDirective( writeJSON(response, http.StatusOK, operation) } +func (server *hostHTTPHandler) clientUtterance( + response http.ResponseWriter, + request *http.Request, +) { + var input ActorUtteranceInput + if err := server.decode(response, request, &input); err != nil { + writeHTTPError(response, http.StatusBadRequest, err.Error()) + return + } + operation, err := server.service.SubmitActorUtterance( + *server.clientPrincipal, + input, + ) + if err != nil { + writeServiceError(response, err) + return + } + writeJSON(response, http.StatusOK, operation) +} + func (server *hostHTTPHandler) clientExecuteOffer( response http.ResponseWriter, request *http.Request, @@ -606,6 +649,7 @@ func principalHasControlScope(principal host.Principal) bool { ScopeActorRead, ScopeActorConverse, ScopeActorDirect, + ScopeActorSpeak, ScopeActorExecute, ScopeOperationCancel, ScopeHostAdmin, diff --git a/controlplane/http_client.go b/controlplane/http_client.go index 7360531..9a4ab59 100644 --- a/controlplane/http_client.go +++ b/controlplane/http_client.go @@ -18,7 +18,7 @@ import ( "github.com/sunrioa/rin/internal/jsonwire" ) -const defaultControlClientTimeout = 15 * time.Second +const defaultControlClientTimeout = 30 * time.Second // HTTPClient connects a thin external-control client to one Control Daemon. type HTTPClient struct { @@ -135,6 +135,22 @@ func (client *HTTPClient) GetActor( return view, err } +// WaitActor waits for a newer actor cursor for at most 25 seconds. +func (client *HTTPClient) WaitActor( + ctx context.Context, + input WaitActorInput, +) (ActorUpdate, error) { + var update ActorUpdate + err := client.request( + ctx, + http.MethodPost, + "wait-actor", + input, + &update, + ) + return update, err +} + // ListActorOffers returns exact Host-published offers for one actor. func (client *HTTPClient) ListActorOffers( ctx context.Context, @@ -171,6 +187,22 @@ func (client *HTTPClient) SendActorDirective( return client.operation(ctx, "directive", input) } +// SubmitActorUtterance queues dialogue from the current external controller. +func (client *HTTPClient) SubmitActorUtterance( + ctx context.Context, + input ActorUtteranceInput, +) (OperationView, error) { + var operation OperationView + err := client.request( + ctx, + http.MethodPost, + "utterance", + input, + &operation, + ) + return operation, err +} + // ExecuteActorOffer selects one exact Host-published Offer. func (client *HTTPClient) ExecuteActorOffer( ctx context.Context, diff --git a/controlplane/http_client_test.go b/controlplane/http_client_test.go index f821a62..4939cb9 100644 --- a/controlplane/http_client_test.go +++ b/controlplane/http_client_test.go @@ -20,6 +20,7 @@ func TestHTTPClientUsesDaemonBoundPrincipal(t *testing.T) { ScopeActorRead, ScopeActorConverse, ScopeActorDirect, + ScopeActorSpeak, ScopeActorExecute, ScopeOperationCancel, ) @@ -73,6 +74,18 @@ func TestHTTPClientUsesDaemonBoundPrincipal(t *testing.T) { if err != nil || actor.ObservationSeq != 1 { t.Fatalf("GetActor = %#v, %v", actor, err) } + update, err := client.WaitActor(ctx, WaitActorInput{ + HostID: "test.host", + WorldID: "world.one", + ActorID: "actor.one", + AfterObservationSeq: actor.ObservationSeq, + AfterAuthorityRevision: actor.Authority.Revision, + WaitMillis: 0, + }) + if err != nil || update.Changed || + update.Actor.ObservationSeq != actor.ObservationSeq { + t.Fatalf("WaitActor = %#v, %v", update, err) + } offers, err := client.ListActorOffers( ctx, "test.host", @@ -93,6 +106,18 @@ func TestHTTPClientUsesDaemonBoundPrincipal(t *testing.T) { if err != nil || message.Status != OperationQueued { t.Fatalf("SendActorMessage = %#v, %v", message, err) } + utterance, err := client.SubmitActorUtterance(ctx, ActorUtteranceInput{ + RequestID: "request.client.utterance", + HostID: "test.host", + WorldID: "world.one", + ActorID: "actor.one", + TurnID: "turn.client.one", + Text: "I am ready.", + }) + if err != nil || utterance.Kind != ControlUtterance || + utterance.TurnID != "turn.client.one" { + t.Fatalf("SubmitActorUtterance = %#v, %v", utterance, err) + } view, err := client.GetOperation(ctx, message.OperationID) if err != nil || view.OperationID != message.OperationID { t.Fatalf("GetOperation = %#v, %v", view, err) diff --git a/controlplane/http_contract_test.go b/controlplane/http_contract_test.go index 0f68fb5..45f3805 100644 --- a/controlplane/http_contract_test.go +++ b/controlplane/http_contract_test.go @@ -26,8 +26,8 @@ func TestControlOpenAPIReferencesEveryDaemonRoute(t *testing.T) { if err != nil { t.Fatalf("ParseControlRoutes: %v", err) } - if len(routes) != 19 { - t.Fatalf("Control route count = %d, want 19", len(routes)) + if len(routes) != 21 { + t.Fatalf("Control route count = %d, want 21", len(routes)) } service := New(Options{}) @@ -35,6 +35,7 @@ func TestControlOpenAPIReferencesEveryDaemonRoute(t *testing.T) { ScopeActorRead, ScopeActorConverse, ScopeActorDirect, + ScopeActorSpeak, ScopeActorExecute, ScopeOperationCancel, ) diff --git a/controlplane/operation.go b/controlplane/operation.go index 9d968a7..d8741b3 100644 --- a/controlplane/operation.go +++ b/controlplane/operation.go @@ -10,6 +10,7 @@ import ( "reflect" "slices" "time" + "unicode/utf8" "github.com/sunrioa/rin/host" ) @@ -19,6 +20,7 @@ const ( hardMaxOperations = 65_536 defaultOperationTTL = 30 * time.Minute maxControlTextBytes = 4 << 10 + maxUtteranceRunes = 300 maxHostPollItems = 64 ) @@ -54,6 +56,71 @@ func (service *Service) SendActorDirective( return service.submitText(principal, input, ControlDirective) } +// SubmitActorUtterance queues player-visible dialogue authored by the current +// externally bound controller. +func (service *Service) SubmitActorUtterance( + principal host.Principal, + input ActorUtteranceInput, +) (OperationView, error) { + if err := validateActorUtteranceInput(input); err != nil { + return OperationView{}, err + } + if err := host.ValidatePrincipal(principal); err != nil { + return OperationView{}, fmt.Errorf("%w: principal: %v", ErrInvalid, err) + } + + service.mu.Lock() + defer service.mu.Unlock() + key := operationRequestKey(principal.ID, input.RequestID) + if existing, found, err := service.idempotentOperationLocked( + key, + principal, + input.HostID, + input.WorldID, + input.ActorID, + ControlUtterance, + input.Text, + "", + input.TurnID, + ); found || err != nil { + return existing, err + } + actor, err := service.authorizeActorLocked( + principal, + input.HostID, + input.WorldID, + input.ActorID, + ScopeActorSpeak, + ) + if err != nil { + if persistErr := service.persistOperationsLocked(); persistErr != nil { + return OperationView{}, persistErr + } + return OperationView{}, err + } + if !authorityAllowsExternal(actor, principal.ID) { + return OperationView{}, ErrForbidden + } + operationID, err := service.prepareOperationLocked() + if err != nil { + return OperationView{}, err + } + request := HostControlRequest{ + OperationID: operationID, + RequestID: input.RequestID, + Principal: clonePrincipalValue(principal), + HostID: input.HostID, + WorldID: input.WorldID, + ActorID: input.ActorID, + Kind: ControlUtterance, + TurnID: input.TurnID, + Text: input.Text, + Binding: bindingFromActor(actor), + SubmittedAt: service.now().UnixMilli(), + } + return service.queueOperationLocked(key, request) +} + // ExecuteActorOffer queues one exact Host-published Offer. func (service *Service) ExecuteActorOffer( principal host.Principal, @@ -78,6 +145,7 @@ func (service *Service) ExecuteActorOffer( ControlOffer, "", input.OfferID, + input.TurnID, ); found || err != nil { return existing, err } @@ -94,6 +162,9 @@ func (service *Service) ExecuteActorOffer( } return OperationView{}, err } + if !authorityAllowsExternal(actor, principal.ID) { + return OperationView{}, ErrForbidden + } var selected *host.ActionOffer for index := range actor.Offers { if actor.Offers[index].OfferID == input.OfferID { @@ -117,6 +188,7 @@ func (service *Service) ExecuteActorOffer( WorldID: input.WorldID, ActorID: input.ActorID, Kind: ControlOffer, + TurnID: input.TurnID, Binding: bindingFromActor(actor), Offer: &offer, SubmittedAt: service.now().UnixMilli(), @@ -152,6 +224,7 @@ func (service *Service) submitText( kind, input.Text, "", + "", ); found || err != nil { return existing, err } @@ -484,7 +557,7 @@ func (service *Service) idempotentOperationLocked( principal host.Principal, hostID, worldID, actorID string, kind ControlKind, - text, offerID string, + text, offerID, turnID string, ) (OperationView, bool, error) { operationID, exists := service.requests[key] if !exists { @@ -502,7 +575,8 @@ func (service *Service) idempotentOperationLocked( operation.request.WorldID == worldID && operation.request.ActorID == actorID && operation.request.Kind == kind && - operation.request.Text == text + operation.request.Text == text && + operation.request.TurnID == turnID if same && kind == ControlOffer { same = operation.request.Offer != nil && operation.request.Offer.OfferID == offerID @@ -534,9 +608,7 @@ func (service *Service) authorizeActorLocked( if actor.ActorID != actorID { continue } - if !hasScope(principal, ScopeHostAdmin) && - (principal.ID != actor.OwnerPrincipalID || - !hasScope(principal, requiredScope)) { + if !canAccessActor(principal, actor, requiredScope) { return ActorPublication{}, ErrForbidden } return actor, nil @@ -756,6 +828,7 @@ func operationView(operation *operationState) OperationView { WorldID: operation.request.WorldID, ActorID: operation.request.ActorID, Kind: operation.request.Kind, + TurnID: operation.request.TurnID, Status: operation.status, CancelRequested: operation.cancel, DeliveryAttempts: operation.attempts, @@ -791,8 +864,9 @@ func operationOutputView(output json.RawMessage) map[string]any { func bindingFromActor(actor ActorPublication) *ControlBinding { return &ControlBinding{ - Epoch: actor.Epoch, - ObservationSeq: actor.ObservationSeq, + Epoch: actor.Epoch, + ObservationSeq: actor.ObservationSeq, + AuthorityRevision: effectiveAuthority(actor).Revision, } } @@ -849,6 +923,32 @@ func validateActorTextInput(input ActorTextInput) error { return validateText("text", input.Text, maxControlTextBytes, true) } +func validateActorUtteranceInput(input ActorUtteranceInput) error { + if err := validateControlTarget( + input.RequestID, + input.HostID, + input.WorldID, + input.ActorID, + ); err != nil { + return err + } + if err := validateID("turn_id", input.TurnID); err != nil { + return err + } + if err := validateText( + "text", + input.Text, + maxControlTextBytes, + true, + ); err != nil { + return err + } + if utf8.RuneCountInString(input.Text) > maxUtteranceRunes { + return invalid("text", "must contain at most 300 Unicode code points") + } + return nil +} + func validateExecuteOfferInput(input ExecuteOfferInput) error { if err := validateControlTarget( input.RequestID, @@ -858,7 +958,13 @@ func validateExecuteOfferInput(input ExecuteOfferInput) error { ); err != nil { return err } - return validateID("offer_id", input.OfferID) + if err := validateID("offer_id", input.OfferID); err != nil { + return err + } + if input.TurnID != "" { + return validateID("turn_id", input.TurnID) + } + return nil } func validateControlTarget( diff --git a/controlplane/operation_file.go b/controlplane/operation_file.go index 0aa4c16..b269217 100644 --- a/controlplane/operation_file.go +++ b/controlplane/operation_file.go @@ -389,9 +389,21 @@ func validateStoredRequest(request HostControlRequest, allowLegacy bool) error { request.Binding.ObservationSeq > maxJSONSafeInteger { return errors.New("invalid binding observation_seq") } + if request.Binding.AuthorityRevision == 0 { + if !allowLegacy { + return errors.New("binding authority_revision is required") + } + } else if request.Binding.AuthorityRevision > maxJSONSafeInteger { + return errors.New("invalid binding authority_revision") + } + } + if request.TurnID != "" { + if err := validateID("turn_id", request.TurnID); err != nil { + return err + } } switch request.Kind { - case ControlMessage, ControlDirective: + case ControlMessage, ControlDirective, ControlUtterance: if request.Invocation != nil || request.Offer != nil { return errors.New("text request must not contain an offer or invocation") } @@ -403,9 +415,17 @@ func validateStoredRequest(request HostControlRequest, allowLegacy bool) error { ); err != nil { return err } + if request.Kind != ControlUtterance && request.TurnID != "" { + return errors.New("inbound text request must not contain turn_id") + } requiredScope := ScopeActorConverse if request.Kind == ControlDirective { requiredScope = ScopeActorDirect + } else if request.Kind == ControlUtterance { + requiredScope = ScopeActorSpeak + if request.TurnID == "" { + return errors.New("utterance request requires turn_id") + } } if !hasScope(request.Principal, ScopeHostAdmin) && !hasScope(request.Principal, requiredScope) { diff --git a/controlplane/service.go b/controlplane/service.go index 8dd213a..e025072 100644 --- a/controlplane/service.go +++ b/controlplane/service.go @@ -1,6 +1,7 @@ package controlplane import ( + "context" "crypto/rand" "encoding/hex" "encoding/json" @@ -213,7 +214,13 @@ func (service *Service) PublishWorld( } else if len(current.worlds) >= maxWorldsPerHost { return invalid("world_id", "host already contains 64 worlds") } + service.fenceSupersededAuthorityLocked( + hostID, + publication.WorldID, + publication, + ) current.worlds[publication.WorldID] = clonePublication(publication) + service.notifyLocked() return nil } @@ -270,7 +277,7 @@ func (service *Service) ListActors( online := current.lease.ExpiresAtUnixMillis > service.now().UnixMilli() result := make([]ActorView, 0, len(world.Actors)) for _, actor := range world.Actors { - if canRead(principal, actor.OwnerPrincipalID) { + if canAccessActor(principal, actor, ScopeActorRead) { result = append(result, actorView( hostID, worldID, current.lease, online, actor, )) @@ -292,6 +299,77 @@ func (service *Service) GetActor( } service.mu.RLock() defer service.mu.RUnlock() + return service.getActorLocked(principal, hostID, worldID, actorID) +} + +// WaitActor waits for a newer actor observation or authority revision. It uses +// the same visibility rules as GetActor and never creates a second event log. +func (service *Service) WaitActor( + ctx context.Context, + principal host.Principal, + input WaitActorInput, +) (ActorUpdate, error) { + if err := host.ValidatePrincipal(principal); err != nil { + return ActorUpdate{}, fmt.Errorf( + "%w: principal: %v", ErrInvalid, err, + ) + } + if input.WaitMillis > 25_000 { + return ActorUpdate{}, invalid( + "wait_millis", "must not exceed 25000", + ) + } + timer := time.NewTimer(time.Duration(input.WaitMillis) * time.Millisecond) + defer timer.Stop() + for { + service.mu.RLock() + if service.closed { + service.mu.RUnlock() + return ActorUpdate{}, ErrUnavailable + } + view, err := service.getActorLocked( + principal, + input.HostID, + input.WorldID, + input.ActorID, + ) + changed := service.changed + service.mu.RUnlock() + if err != nil { + return ActorUpdate{}, err + } + cursorChanged := view.ObservationSeq != input.AfterObservationSeq || + view.Authority.Revision != input.AfterAuthorityRevision + if cursorChanged || input.WaitMillis == 0 { + return ActorUpdate{Actor: view, Changed: cursorChanged}, nil + } + select { + case <-ctx.Done(): + return ActorUpdate{}, ctx.Err() + case <-timer.C: + service.mu.RLock() + view, err = service.getActorLocked( + principal, + input.HostID, + input.WorldID, + input.ActorID, + ) + service.mu.RUnlock() + if err != nil { + return ActorUpdate{}, err + } + cursorChanged = view.ObservationSeq != input.AfterObservationSeq || + view.Authority.Revision != input.AfterAuthorityRevision + return ActorUpdate{Actor: view, Changed: cursorChanged}, nil + case <-changed: + } + } +} + +func (service *Service) getActorLocked( + principal host.Principal, + hostID, worldID, actorID string, +) (ActorView, error) { current, world, err := service.findWorldLocked(hostID, worldID) if err != nil { return ActorView{}, err @@ -300,7 +378,7 @@ func (service *Service) GetActor( if actor.ActorID != actorID { continue } - if !canRead(principal, actor.OwnerPrincipalID) { + if !canAccessActor(principal, actor, ScopeActorRead) { return ActorView{}, ErrForbidden } online := current.lease.ExpiresAtUnixMillis > service.now().UnixMilli() @@ -330,7 +408,10 @@ func (service *Service) ListActorOffers( if actor.ActorID != actorID { continue } - if !canRead(principal, actor.OwnerPrincipalID) { + if !canAccessActor(principal, actor, ScopeActorRead) { + return nil, ErrForbidden + } + if !authorityAllowsExternal(actor, principal.ID) { return nil, ErrForbidden } return cloneOffers(actor.Offers), nil @@ -387,6 +468,10 @@ func clonePublication(value WorldPublication) WorldPublication { cloned.Actors = make([]ActorPublication, len(value.Actors)) for index, actor := range value.Actors { cloned.Actors[index] = actor + if actor.Authority != nil { + authority := *actor.Authority + cloned.Actors[index].Authority = &authority + } cloned.Actors[index].State = append(json.RawMessage(nil), actor.State...) cloned.Actors[index].Offers = cloneOffers(actor.Offers) @@ -420,15 +505,71 @@ func actorView( DisplayName: actor.DisplayName, ObservationSeq: actor.ObservationSeq, Epoch: actor.Epoch, + Authority: effectiveAuthority(actor), State: append(json.RawMessage(nil), actor.State...), Online: online, LeaseExpiresAtMillis: lease.ExpiresAtUnixMillis, } } +func effectiveAuthority(actor ActorPublication) DecisionAuthority { + if actor.Authority != nil { + return *actor.Authority + } + // Publications predating decision-authority support remain externally + // controllable by their owner, matching the original Control v1 behavior. + return DecisionAuthority{ + Source: DecisionExternal, + ControllerPrincipalID: actor.OwnerPrincipalID, + Revision: 1, + PersonaMode: PersonaCharacterBound, + } +} + +func authorityAllowsExternal( + actor ActorPublication, + principalID string, +) bool { + authority := effectiveAuthority(actor) + return authority.Source == DecisionExternal && + authority.ControllerPrincipalID == principalID +} + +func (service *Service) fenceSupersededAuthorityLocked( + hostID, worldID string, + publication WorldPublication, +) { + revisions := make(map[string]uint64, len(publication.Actors)) + for _, actor := range publication.Actors { + revisions[actor.ActorID] = effectiveAuthority(actor).Revision + } + now := service.now().UnixMilli() + changed := false + for _, operation := range service.operations { + if operation.request.HostID != hostID || + operation.request.WorldID != worldID || + completeOperation(operation) || + (operation.ack != nil && operation.ack.Accepted) || + operation.request.Binding == nil { + continue + } + revision, exists := revisions[operation.request.ActorID] + if exists && + revision == operation.request.Binding.AuthorityRevision { + continue + } + operation.status = OperationStale + operation.updatedAt = now + changed = true + } + if changed { + service.markOperationsDirtyLocked() + } +} + func publicationVisible(principal host.Principal, world WorldPublication) bool { for _, actor := range world.Actors { - if canRead(principal, actor.OwnerPrincipalID) { + if canAccessActor(principal, actor, ScopeActorRead) { return true } } diff --git a/controlplane/service_test.go b/controlplane/service_test.go index e84c2f2..cc4100e 100644 --- a/controlplane/service_test.go +++ b/controlplane/service_test.go @@ -2,6 +2,7 @@ package controlplane import ( "bytes" + "context" "encoding/json" "errors" "strings" @@ -164,6 +165,89 @@ func TestServicePublicationSequenceIsIdempotent(t *testing.T) { } } +func TestServiceWaitActorUsesPublishedCursor(t *testing.T) { + service := New(Options{ + Now: func() time.Time { return time.UnixMilli(1_000_000) }, + Random: bytes.NewReader(bytes.Repeat([]byte{31}, 64)), + }) + lease := mustRegister(t, service, registration("instance.one")) + first := worldPublication(1, "ready") + if err := service.PublishWorld("test.host", lease.LeaseID, first); err != nil { + t.Fatalf("PublishWorld: %v", err) + } + owner := host.Principal{ + ID: "player.one", + GrantedScopes: []string{ScopeActorRead}, + } + current, err := service.GetActor( + owner, "test.host", "world.one", "actor.one", + ) + if err != nil { + t.Fatalf("GetActor: %v", err) + } + result := make(chan ActorUpdate, 1) + failures := make(chan error, 1) + go func() { + update, waitErr := service.WaitActor( + context.Background(), + owner, + WaitActorInput{ + HostID: "test.host", + WorldID: "world.one", + ActorID: "actor.one", + AfterObservationSeq: current.ObservationSeq, + AfterAuthorityRevision: current.Authority.Revision, + WaitMillis: 1_000, + }, + ) + if waitErr != nil { + failures <- waitErr + return + } + result <- update + }() + time.Sleep(10 * time.Millisecond) + next := worldPublication(2, "working") + next.Actors[0].ObservationSeq = 2 + next.Actors[0].Offers[0].ObservationSeq = 2 + if err := service.PublishWorld("test.host", lease.LeaseID, next); err != nil { + t.Fatalf("second PublishWorld: %v", err) + } + select { + case waitErr := <-failures: + t.Fatalf("WaitActor: %v", waitErr) + case update := <-result: + if !update.Changed || update.Actor.ObservationSeq != 2 || + string(update.Actor.State) != `{"status":"working"}` { + t.Fatalf("WaitActor = %#v", update) + } + case <-time.After(time.Second): + t.Fatal("WaitActor did not wake after publication") + } + unchanged, err := service.WaitActor( + context.Background(), + owner, + WaitActorInput{ + HostID: "test.host", + WorldID: "world.one", + ActorID: "actor.one", + AfterObservationSeq: 2, + AfterAuthorityRevision: current.Authority.Revision, + WaitMillis: 1, + }, + ) + if err != nil || unchanged.Changed { + t.Fatalf("unchanged WaitActor = %#v, %v", unchanged, err) + } + if _, err := service.WaitActor( + context.Background(), + owner, + WaitActorInput{WaitMillis: 25_001}, + ); !errors.Is(err, ErrInvalid) { + t.Fatalf("oversized WaitActor error = %v", err) + } +} + func TestServiceRejectsAmbiguousOrUnboundPublication(t *testing.T) { service := New(Options{ Now: func() time.Time { return time.UnixMilli(1_000_000) }, diff --git a/controlplane/types.go b/controlplane/types.go index 7b24214..cf5eb8a 100644 --- a/controlplane/types.go +++ b/controlplane/types.go @@ -15,6 +15,7 @@ const ( ScopeActorRead = "actor.read" ScopeActorConverse = "actor.converse" ScopeActorDirect = "actor.direct" + ScopeActorSpeak = "actor.speak" ScopeActorExecute = "actor.execute" ScopeOperationCancel = "operation.cancel" ScopeHostAdmin = "host.admin" @@ -26,9 +27,40 @@ type ControlKind string const ( ControlMessage ControlKind = "message" ControlDirective ControlKind = "directive" + ControlUtterance ControlKind = "utterance" ControlOffer ControlKind = "offer" ) +// DecisionSource identifies the one deliberative controller currently allowed +// to choose autonomous Actor actions. Real-time safety reflexes remain owned by +// the game Host regardless of this value. +type DecisionSource string + +const ( + DecisionInternal DecisionSource = "internal" + DecisionExternal DecisionSource = "external" +) + +// PersonaMode controls how an external controller presents through an Actor. +// Character-bound controllers preserve the Host-authored role; agent-avatar +// controllers intentionally present the external Agent's own personality. +type PersonaMode string + +const ( + PersonaCharacterBound PersonaMode = "character-bound" + PersonaAgentAvatar PersonaMode = "agent-avatar" +) + +// DecisionAuthority is a Host-authored projection, not a second source of +// truth. The Host persists it with the game save and increments Revision on +// every controller transition. +type DecisionAuthority struct { + Source DecisionSource `json:"source"` + ControllerPrincipalID string `json:"controller_principal_id,omitempty"` + Revision uint64 `json:"revision"` + PersonaMode PersonaMode `json:"persona_mode"` +} + // OperationStatus describes delivery and authoritative Host execution state. type OperationStatus string @@ -78,6 +110,7 @@ type ActorPublication struct { DisplayName string `json:"display_name"` ObservationSeq uint64 `json:"observation_seq"` Epoch host.Epoch `json:"epoch"` + Authority *DecisionAuthority `json:"decision_authority,omitempty"` State json.RawMessage `json:"state"` Offers []host.ActionOffer `json:"offers,omitempty"` } @@ -94,16 +127,36 @@ type WorldView struct { // ActorView is a defensive copy of one principal-visible actor snapshot. type ActorView struct { - HostID string `json:"host_id"` - WorldID string `json:"world_id"` - ActorID string `json:"actor_id"` - OwnerPrincipalID string `json:"owner_principal_id"` - DisplayName string `json:"display_name"` - ObservationSeq uint64 `json:"observation_seq"` - Epoch host.Epoch `json:"epoch"` - State json.RawMessage `json:"state"` - Online bool `json:"online"` - LeaseExpiresAtMillis int64 `json:"lease_expires_at_unix_millis"` + HostID string `json:"host_id"` + WorldID string `json:"world_id"` + ActorID string `json:"actor_id"` + OwnerPrincipalID string `json:"owner_principal_id"` + DisplayName string `json:"display_name"` + ObservationSeq uint64 `json:"observation_seq"` + Epoch host.Epoch `json:"epoch"` + Authority DecisionAuthority `json:"decision_authority"` + State json.RawMessage `json:"state"` + Online bool `json:"online"` + LeaseExpiresAtMillis int64 `json:"lease_expires_at_unix_millis"` +} + +// WaitActorInput identifies the last actor cursor observed by a client. +// Waiting is bounded and returns the same principal-filtered ActorView used by +// ordinary reads. +type WaitActorInput struct { + HostID string `json:"host_id"` + WorldID string `json:"world_id"` + ActorID string `json:"actor_id"` + AfterObservationSeq uint64 `json:"after_observation_seq"` + AfterAuthorityRevision uint64 `json:"after_authority_revision"` + WaitMillis uint32 `json:"wait_millis"` +} + +// ActorUpdate reports whether the actor cursor changed before the bounded wait +// elapsed. Actor always contains the latest visible snapshot. +type ActorUpdate struct { + Actor ActorView `json:"actor"` + Changed bool `json:"changed"` } // ActorTextInput submits one message or negotiable directive to an Actor. @@ -115,6 +168,17 @@ type ActorTextInput struct { Text string `json:"text"` } +// ActorUtteranceInput submits bounded player-visible dialogue from the current +// external controller. TurnID may also be attached to an Offer selection. +type ActorUtteranceInput struct { + RequestID string `json:"request_id"` + HostID string `json:"host_id"` + WorldID string `json:"world_id"` + ActorID string `json:"actor_id"` + TurnID string `json:"turn_id"` + Text string `json:"text"` +} + // ExecuteOfferInput selects an exact Host-published Offer without adding // model-authored arguments. type ExecuteOfferInput struct { @@ -123,13 +187,15 @@ type ExecuteOfferInput struct { WorldID string `json:"world_id"` ActorID string `json:"actor_id"` OfferID string `json:"offer_id"` + TurnID string `json:"turn_id,omitempty"` } // ControlBinding records the exact Host timeline and observation that were // visible when an external request was accepted by the Control Plane. type ControlBinding struct { - Epoch host.Epoch `json:"epoch"` - ObservationSeq uint64 `json:"observation_seq"` + Epoch host.Epoch `json:"epoch"` + ObservationSeq uint64 `json:"observation_seq"` + AuthorityRevision uint64 `json:"authority_revision"` } // HostControlRequest is trusted queue data delivered to an authoritative Host. @@ -141,6 +207,7 @@ type HostControlRequest struct { WorldID string `json:"world_id"` ActorID string `json:"actor_id"` Kind ControlKind `json:"kind"` + TurnID string `json:"turn_id,omitempty"` Text string `json:"text,omitempty"` Binding *ControlBinding `json:"binding,omitempty"` Offer *host.ActionOffer `json:"offer,omitempty"` @@ -178,6 +245,7 @@ type OperationView struct { WorldID string `json:"world_id"` ActorID string `json:"actor_id"` Kind ControlKind `json:"kind"` + TurnID string `json:"turn_id,omitempty"` Status OperationStatus `json:"status"` CancelRequested bool `json:"cancel_requested"` DeliveryAttempts uint32 `json:"delivery_attempts"` diff --git a/controlplane/validate.go b/controlplane/validate.go index c04ca43..9048dc2 100644 --- a/controlplane/validate.go +++ b/controlplane/validate.go @@ -92,6 +92,14 @@ func validateActor(value ActorPublication, worldID string, index int) error { if value.Epoch.WorldID != worldID { return invalid(prefix+".epoch.world_id", "must equal publication world_id") } + if value.Authority != nil { + if err := validateDecisionAuthority( + prefix+".decision_authority", + *value.Authority, + ); err != nil { + return err + } + } if err := validateJSONObject(prefix+".state", value.State, maxActorStateBytes); err != nil { return err } @@ -121,6 +129,47 @@ func validateActor(value ActorPublication, worldID string, index int) error { return nil } +func validateDecisionAuthority( + field string, + value DecisionAuthority, +) error { + if value.Revision == 0 || value.Revision > maxJSONSafeInteger { + return invalid(field+".revision", "must be a positive JSON-safe integer") + } + switch value.Source { + case DecisionInternal: + if value.ControllerPrincipalID != "" { + return invalid( + field+".controller_principal_id", + "must be empty for internal authority", + ) + } + if value.PersonaMode != PersonaCharacterBound { + return invalid( + field+".persona_mode", + "must be character-bound for internal authority", + ) + } + case DecisionExternal: + if err := validateID( + field+".controller_principal_id", + value.ControllerPrincipalID, + ); err != nil { + return err + } + if value.PersonaMode != PersonaCharacterBound && + value.PersonaMode != PersonaAgentAvatar { + return invalid( + field+".persona_mode", + "must be character-bound or agent-avatar", + ) + } + default: + return invalid(field+".source", "must be internal or external") + } + return nil +} + func validateID(field, value string) error { if err := host.ValidatePrincipal(host.Principal{ID: value}); err != nil { return invalid(field, strings.TrimPrefix(err.Error(), "principal.id: ")) @@ -175,7 +224,19 @@ func hasScope(principal host.Principal, scope string) bool { return false } -func canRead(principal host.Principal, ownerPrincipalID string) bool { - return hasScope(principal, ScopeHostAdmin) || - (principal.ID == ownerPrincipalID && hasScope(principal, ScopeActorRead)) +func canAccessActor( + principal host.Principal, + actor ActorPublication, + requiredScope string, +) bool { + if hasScope(principal, ScopeHostAdmin) { + return true + } + if !hasScope(principal, requiredScope) { + return false + } + if principal.ID == actor.OwnerPrincipalID { + return true + } + return authorityAllowsExternal(actor, principal.ID) } diff --git a/docs/mcp-control-plane.md b/docs/mcp-control-plane.md index 9d13efa..597d6bc 100644 --- a/docs/mcp-control-plane.md +++ b/docs/mcp-control-plane.md @@ -79,13 +79,14 @@ reserved for MCP wire traffic; diagnostics go to standard error. ## Tools -`actor.read` registers four read-only tools: +`actor.read` registers five read-only tools: | Tool | Purpose | | --- | --- | | `list_worlds` | List worlds visible to the fixed Principal | | `list_actors` | List visible Actors in one world | | `get_actor_state` | Read the Host's redacted Actor publication | +| `wait_actor_update` | Long-poll by observation and authority revision for up to 25 seconds | | `list_actor_offers` | Read exact Offers from an online Host | Daemon scopes may also register: @@ -94,6 +95,7 @@ Daemon scopes may also register: | --- | --- | --- | | `send_actor_message` | `actor.converse` | Send dialogue without authorizing a world mutation | | `send_actor_directive` | `actor.direct` | Submit a goal the Actor or Host may reject | +| `speak_as_actor` | `actor.speak` | Submit Actor dialogue as the bound external controller | | `execute_actor_offer` | `actor.execute` | Select one complete current Offer | | `get_operation` | any control scope | Inspect delivery, run, and Outcome state | | `cancel_operation` | `operation.cancel` | Request cancellation; this is not rollback | @@ -101,14 +103,40 @@ Daemon scopes may also register: For conversation and exact actions, for example: ```bash -export RIN_CONTROL_SCOPES="actor.read,actor.converse,actor.execute,operation.cancel" +export RIN_CONTROL_SCOPES="actor.read,actor.speak,actor.execute,operation.cancel" ``` -Restart `rin-control` after changing scopes. `execute_actor_offer` does not accept -arbitrary action parameters, coordinates, item IDs, or method names. The -Operation stores the complete Host Offer and Epoch/Observation binding. The game -still revalidates the Offer, deadline, permissions, and current world state on -its authoritative thread. +Restart `rin-control` after changing scopes. An external character loop normally +reads `get_actor_state` once, then calls `wait_actor_update` with the returned +`observation_seq` and `decision_authority.revision`. After a change it may call +`speak_as_actor` and, when appropriate, `execute_actor_offer` with the same +`turn_id`. Both principal-safe Operation views echo that ID for correlation. + +`execute_actor_offer` does not accept arbitrary action parameters, coordinates, +item IDs, or method names. The Operation retains the complete Host Offer and its +Epoch, Observation, and Authority Revision bindings. The game still revalidates +the Offer, deadline, permissions, and current world state on its authoritative +thread. + +## Character Decision Authority + +A Host may publish `decision_authority` for an Actor: + +- With `source=internal`, external clients may observe but cannot speak as the + Actor or select its Offers. +- With `source=external`, only a daemon Principal exactly matching + `controller_principal_id` may control the Actor. `host.admin` cannot bypass + this binding. +- `persona_mode=character-bound` asks the external agent to portray the + Host-defined character. +- `persona_mode=agent-avatar` lets the external agent use its own personality + and private memory while embodying the Actor. +- Every handoff increases `revision`. Unaccepted Operations from an older + revision become stale; an already accepted bounded action may finish. + +Authority selects who makes the next semantic decision. Navigation, combat, and +building remain per-tick Host controllers. Rin neither turns model output into +frame-by-frame movement nor copies either controller's private memory. ## Host Lifecycle diff --git a/docs/mcp-control-plane.zh-CN.md b/docs/mcp-control-plane.zh-CN.md index f0f179f..2fb2994 100644 --- a/docs/mcp-control-plane.zh-CN.md +++ b/docs/mcp-control-plane.zh-CN.md @@ -73,13 +73,14 @@ Principal 和 Scope 只由 `rin-control` 启动配置决定,不能由 MCP Tool ## Tool -`actor.read` 注册四个只读 Tool: +`actor.read` 注册五个只读 Tool: | Tool | 作用 | | --- | --- | | `list_worlds` | 列出当前 Principal 可见的世界 | | `list_actors` | 列出一个世界中可见的 Actor | | `get_actor_state` | 读取 Host 已脱敏发布的 Actor 状态 | +| `wait_actor_update` | 按观察序号与控制权修订号长轮询,最长等待 25 秒 | | `list_actor_offers` | 读取在线 Host 当前发布的精确 Offer | 按 daemon Scope 还可注册: @@ -88,6 +89,7 @@ Principal 和 Scope 只由 `rin-control` 启动配置决定,不能由 MCP Tool | --- | --- | --- | | `send_actor_message` | `actor.converse` | 发送对白,不直接授权世界修改 | | `send_actor_directive` | `actor.direct` | 提交 Actor 或 Host 可以拒绝的目标 | +| `speak_as_actor` | `actor.speak` | 由当前绑定的外部控制器提交角色对白 | | `execute_actor_offer` | `actor.execute` | 选择一个完整、精确的当前 Offer | | `get_operation` | 任一控制 Scope | 查询投递、运行和 Outcome | | `cancel_operation` | `operation.cancel` | 请求取消,不表示回滚 | @@ -95,12 +97,33 @@ Principal 和 Scope 只由 `rin-control` 启动配置决定,不能由 MCP Tool 例如需要对话和精确动作时: ```bash -export RIN_CONTROL_SCOPES="actor.read,actor.converse,actor.execute,operation.cancel" +export RIN_CONTROL_SCOPES="actor.read,actor.speak,actor.execute,operation.cancel" ``` -修改 Scope 后重启 `rin-control`。`execute_actor_offer` 不接受任意动作参数、坐标、 -物品 ID 或方法名。Operation 保存 Host 发布的完整 Offer 及其 Epoch/Observation -Binding;游戏在权威线程执行前仍要复验 Offer、Deadline、权限和当前世界状态。 +修改 Scope 后重启 `rin-control`。外部角色循环通常先读取一次 +`get_actor_state`,再以返回的 `observation_seq` 和 `decision_authority.revision` +调用 `wait_actor_update`;状态变化后可在同一个 `turn_id` 中调用 +`speak_as_actor`,并按需选择一条 `execute_actor_offer`。两个 Operation 的安全视图 +都会回显 `turn_id`,便于结果关联。 + +`execute_actor_offer` 不接受任意动作参数、坐标、物品 ID 或方法名。Operation 保存 +Host 发布的完整 Offer 及其 Epoch、Observation 和 Authority Revision Binding; +游戏在权威线程执行前仍要复验 Offer、Deadline、权限和当前世界状态。 + +## 角色控制权 + +Host 可以为 Actor 发布 `decision_authority`: + +- `source=internal` 时,外部 Client 只能观察,不能代角色说话或选择 Offer; +- `source=external` 时,只有 `controller_principal_id` 精确匹配 daemon Principal + 的 Client 可以控制该 Actor,`host.admin` 也不能绕过这一绑定; +- `persona_mode=character-bound` 要求外部 Agent 扮演 Host 定义的角色; +- `persona_mode=agent-avatar` 允许外部 Agent 使用自己的性格与私有记忆来表现角色; +- 每次转交都会单调增加 `revision`。尚未被 Host 接受的旧修订 Operation 会失效, + 已接受的有界动作可以完成,避免半途破坏世界事务。 + +控制权只决定“谁做下一次语义决策”。导航、战斗和建造仍由 Host 的逐 Tick +控制器执行;Rin 不把模型输出转换成逐帧移动,也不复制任一控制器的私有记忆。 ## Host 生命周期 diff --git a/mcpbridge/client.go b/mcpbridge/client.go index da558f4..205d5eb 100644 --- a/mcpbridge/client.go +++ b/mcpbridge/client.go @@ -12,6 +12,10 @@ type ControlClient interface { ListWorlds(context.Context) ([]controlplane.WorldView, error) ListActors(context.Context, string, string) ([]controlplane.ActorView, error) GetActor(context.Context, string, string, string) (controlplane.ActorView, error) + WaitActor( + context.Context, + controlplane.WaitActorInput, + ) (controlplane.ActorUpdate, error) ListActorOffers( context.Context, string, @@ -26,6 +30,10 @@ type ControlClient interface { context.Context, controlplane.ActorTextInput, ) (controlplane.OperationView, error) + SubmitActorUtterance( + context.Context, + controlplane.ActorUtteranceInput, + ) (controlplane.OperationView, error) ExecuteActorOffer( context.Context, controlplane.ExecuteOfferInput, @@ -64,6 +72,13 @@ func (client *serviceClient) GetActor( ) } +func (client *serviceClient) WaitActor( + ctx context.Context, + input controlplane.WaitActorInput, +) (controlplane.ActorUpdate, error) { + return client.service.WaitActor(ctx, client.principal, input) +} + func (client *serviceClient) ListActorOffers( _ context.Context, hostID, worldID, actorID string, @@ -90,6 +105,13 @@ func (client *serviceClient) SendActorDirective( return client.service.SendActorDirective(client.principal, input) } +func (client *serviceClient) SubmitActorUtterance( + _ context.Context, + input controlplane.ActorUtteranceInput, +) (controlplane.OperationView, error) { + return client.service.SubmitActorUtterance(client.principal, input) +} + func (client *serviceClient) ExecuteActorOffer( _ context.Context, input controlplane.ExecuteOfferInput, diff --git a/mcpbridge/server.go b/mcpbridge/server.go index 95a4c90..b4b2a00 100644 --- a/mcpbridge/server.go +++ b/mcpbridge/server.go @@ -67,6 +67,9 @@ func NewClient( if gateway.granted(controlplane.ScopeActorDirect) { gateway.addDirectiveTool() } + if gateway.granted(controlplane.ScopeActorSpeak) { + gateway.addUtteranceTool() + } if gateway.granted(controlplane.ScopeActorExecute) { gateway.addExecuteOfferTool() } @@ -106,6 +109,11 @@ func (gateway *Gateway) addReadTools() { Description: "Read one actor's current redacted host-published state.", Annotations: annotations, }, gateway.getActorState) + mcp.AddTool(gateway.server, &mcp.Tool{ + Name: "wait_actor_update", + Description: "Wait up to 25 seconds for a newer redacted actor observation or authority revision.", + Annotations: annotations, + }, gateway.waitActorUpdate) mcp.AddTool(gateway.server, &mcp.Tool{ Name: "list_actor_offers", Description: "List exact unexpired action offers currently published for an actor.", @@ -137,6 +145,14 @@ func (gateway *Gateway) addDirectiveTool() { }, gateway.sendActorDirective) } +func (gateway *Gateway) addUtteranceTool() { + mcp.AddTool(gateway.server, &mcp.Tool{ + Name: "speak_as_actor", + Description: "Speak as an externally controlled actor; the Host records and renders the bounded utterance.", + Annotations: writeAnnotations(false), + }, gateway.speakAsActor) +} + func (gateway *Gateway) addExecuteOfferTool() { mcp.AddTool(gateway.server, &mcp.Tool{ Name: "execute_actor_offer", @@ -215,6 +231,35 @@ func (gateway *Gateway) getActorState( return nil, GetActorStateOutput{Actor: actor}, nil } +func (gateway *Gateway) waitActorUpdate( + ctx context.Context, + _ *mcp.CallToolRequest, + input WaitActorUpdateInput, +) (*mcp.CallToolResult, WaitActorUpdateOutput, error) { + update, err := gateway.client.WaitActor( + ctx, + controlplane.WaitActorInput{ + HostID: input.HostID, + WorldID: input.WorldID, + ActorID: input.ActorID, + AfterObservationSeq: input.AfterObservationSeq, + AfterAuthorityRevision: input.AfterAuthorityRevision, + WaitMillis: input.WaitMillis, + }, + ) + if err != nil { + return nil, WaitActorUpdateOutput{}, err + } + actor, err := convertActor(update.Actor) + if err != nil { + return nil, WaitActorUpdateOutput{}, err + } + return nil, WaitActorUpdateOutput{ + Actor: actor, + Changed: update.Changed, + }, nil +} + func (gateway *Gateway) listActorOffers( ctx context.Context, _ *mcp.CallToolRequest, @@ -285,6 +330,25 @@ func (gateway *Gateway) sendActorDirective( return nil, OperationOutput{Operation: operation}, err } +func (gateway *Gateway) speakAsActor( + ctx context.Context, + _ *mcp.CallToolRequest, + input SpeakAsActorInput, +) (*mcp.CallToolResult, OperationOutput, error) { + operation, err := gateway.client.SubmitActorUtterance( + ctx, + controlplane.ActorUtteranceInput{ + RequestID: input.RequestID, + HostID: input.HostID, + WorldID: input.WorldID, + ActorID: input.ActorID, + TurnID: input.TurnID, + Text: input.Text, + }, + ) + return nil, OperationOutput{Operation: operation}, err +} + func (gateway *Gateway) executeActorOffer( ctx context.Context, _ *mcp.CallToolRequest, @@ -298,6 +362,7 @@ func (gateway *Gateway) executeActorOffer( WorldID: input.WorldID, ActorID: input.ActorID, OfferID: input.OfferID, + TurnID: input.TurnID, }, ) return nil, OperationOutput{Operation: operation}, err @@ -337,6 +402,7 @@ func convertActor(view controlplane.ActorView) (Actor, error) { DisplayName: view.DisplayName, ObservationSeq: view.ObservationSeq, Epoch: view.Epoch, + DecisionAuthority: view.Authority, State: state, Online: view.Online, LeaseExpiresAtUnixMillis: view.LeaseExpiresAtMillis, @@ -377,6 +443,7 @@ func hasControlScope(principal host.Principal) bool { controlplane.ScopeActorRead, controlplane.ScopeActorConverse, controlplane.ScopeActorDirect, + controlplane.ScopeActorSpeak, controlplane.ScopeActorExecute, controlplane.ScopeOperationCancel, controlplane.ScopeHostAdmin, diff --git a/mcpbridge/server_test.go b/mcpbridge/server_test.go index 6db9d18..7a00199 100644 --- a/mcpbridge/server_test.go +++ b/mcpbridge/server_test.go @@ -47,6 +47,7 @@ func TestGatewayNegotiatesCurrentProtocolAndReadsPublishedState(t *testing.T) { "list_actor_offers", "list_actors", "list_worlds", + "wait_actor_update", } if !slices.Equal(names, expectedNames) { t.Fatalf("tool names = %#v", names) @@ -81,6 +82,30 @@ func TestGatewayNegotiatesCurrentProtocolAndReadsPublishedState(t *testing.T) { state.Actor.State["status"] != "ready" { t.Fatalf("get_actor_state = %#v", state) } + var update WaitActorUpdateOutput + callTool(t, session, "wait_actor_update", map[string]any{ + "host_id": "test.host", + "world_id": "world.one", + "actor_id": "actor.one", + "after_observation_seq": 0, + "after_authority_revision": 0, + "wait_millis": 0, + }, &update) + if !update.Changed || + update.Actor.ObservationSeq != state.Actor.ObservationSeq { + t.Fatalf("wait_actor_update = %#v", update) + } + callTool(t, session, "wait_actor_update", map[string]any{ + "host_id": "test.host", + "world_id": "world.one", + "actor_id": "actor.one", + "after_observation_seq": state.Actor.ObservationSeq, + "after_authority_revision": state.Actor.DecisionAuthority.Revision, + "wait_millis": 0, + }, &update) + if update.Changed { + t.Fatalf("unchanged actor cursor reported an update: %#v", update) + } var offers ListActorOffersOutput callTool(t, session, "list_actor_offers", map[string]any{ @@ -186,6 +211,7 @@ func TestGatewayRegistersScopedWriteToolsAndQueuesOperations(t *testing.T) { controlplane.ScopeActorRead, controlplane.ScopeActorConverse, controlplane.ScopeActorDirect, + controlplane.ScopeActorSpeak, controlplane.ScopeActorExecute, controlplane.ScopeOperationCancel, }, @@ -210,6 +236,8 @@ func TestGatewayRegistersScopedWriteToolsAndQueuesOperations(t *testing.T) { "list_worlds", "send_actor_directive", "send_actor_message", + "speak_as_actor", + "wait_actor_update", } if !slices.Equal(names, expected) { t.Fatalf("scoped tool names = %#v", names) @@ -289,6 +317,20 @@ func TestGatewayRegistersScopedWriteToolsAndQueuesOperations(t *testing.T) { t.Fatalf("directive operation = %#v", directive.Operation) } + var utterance OperationOutput + callTool(t, session, "speak_as_actor", map[string]any{ + "request_id": "request.mcp.utterance", + "host_id": "test.host", + "world_id": "world.one", + "actor_id": "actor.one", + "turn_id": "turn.mcp.one", + "text": "I can help with that.", + }, &utterance) + if utterance.Operation.Kind != controlplane.ControlUtterance || + utterance.Operation.TurnID != "turn.mcp.one" { + t.Fatalf("utterance operation = %#v", utterance.Operation) + } + var offered OperationOutput callTool(t, session, "execute_actor_offer", map[string]any{ "request_id": "request.mcp.offer", @@ -296,8 +338,10 @@ func TestGatewayRegistersScopedWriteToolsAndQueuesOperations(t *testing.T) { "world_id": "world.one", "actor_id": "actor.one", "offer_id": "offer.follow", + "turn_id": "turn.mcp.one", }, &offered) - if offered.Operation.Kind != controlplane.ControlOffer { + if offered.Operation.Kind != controlplane.ControlOffer || + offered.Operation.TurnID != "turn.mcp.one" { t.Fatalf("offer operation = %#v", offered.Operation) } diff --git a/mcpbridge/types.go b/mcpbridge/types.go index fa33211..51355a1 100644 --- a/mcpbridge/types.go +++ b/mcpbridge/types.go @@ -39,16 +39,31 @@ type GetActorStateOutput struct { Actor Actor `json:"actor"` } +type WaitActorUpdateInput struct { + HostID string `json:"host_id" jsonschema:"host identifier returned by list_worlds"` + WorldID string `json:"world_id" jsonschema:"world identifier returned by list_worlds"` + ActorID string `json:"actor_id" jsonschema:"actor identifier returned by list_actors"` + AfterObservationSeq uint64 `json:"after_observation_seq" jsonschema:"last observation_seq returned for this actor"` + AfterAuthorityRevision uint64 `json:"after_authority_revision" jsonschema:"last decision_authority revision returned for this actor"` + WaitMillis uint32 `json:"wait_millis" jsonschema:"bounded wait from 0 through 25000 milliseconds"` +} + +type WaitActorUpdateOutput struct { + Actor Actor `json:"actor"` + Changed bool `json:"changed"` +} + type Actor struct { - HostID string `json:"host_id"` - WorldID string `json:"world_id"` - ActorID string `json:"actor_id"` - DisplayName string `json:"display_name"` - ObservationSeq uint64 `json:"observation_seq"` - Epoch host.Epoch `json:"epoch"` - State map[string]any `json:"state"` - Online bool `json:"online"` - LeaseExpiresAtUnixMillis int64 `json:"lease_expires_at_unix_millis"` + HostID string `json:"host_id"` + WorldID string `json:"world_id"` + ActorID string `json:"actor_id"` + DisplayName string `json:"display_name"` + ObservationSeq uint64 `json:"observation_seq"` + Epoch host.Epoch `json:"epoch"` + DecisionAuthority controlplane.DecisionAuthority `json:"decision_authority"` + State map[string]any `json:"state"` + Online bool `json:"online"` + LeaseExpiresAtUnixMillis int64 `json:"lease_expires_at_unix_millis"` } type ListActorOffersInput struct { @@ -91,12 +106,22 @@ type SendActorDirectiveInput struct { Text string `json:"text" jsonschema:"negotiable goal that the actor may refuse"` } +type SpeakAsActorInput struct { + RequestID string `json:"request_id" jsonschema:"stable idempotency identifier chosen by the caller"` + HostID string `json:"host_id" jsonschema:"host identifier returned by list_worlds"` + WorldID string `json:"world_id" jsonschema:"world identifier returned by list_worlds"` + ActorID string `json:"actor_id" jsonschema:"actor identifier returned by list_actors"` + TurnID string `json:"turn_id" jsonschema:"identifier shared with an optional action from the same turn"` + Text string `json:"text" jsonschema:"player-visible dialogue of at most 300 Unicode code points"` +} + type ExecuteActorOfferInput struct { RequestID string `json:"request_id" jsonschema:"stable idempotency identifier chosen by the caller"` HostID string `json:"host_id" jsonschema:"host identifier returned by list_worlds"` WorldID string `json:"world_id" jsonschema:"world identifier returned by list_worlds"` ActorID string `json:"actor_id" jsonschema:"actor identifier returned by list_actors"` OfferID string `json:"offer_id" jsonschema:"exact identifier returned by list_actor_offers"` + TurnID string `json:"turn_id,omitempty" jsonschema:"optional identifier shared with dialogue from the same turn"` } type GetOperationInput struct {