diff --git a/config.example.json b/config.example.json index 09e4e21..89cd0f9 100644 --- a/config.example.json +++ b/config.example.json @@ -42,51 +42,18 @@ "outcomes_title": "Execution Outcomes" } }, - "router": { - "enabled": true, - "main_agent_id": "main", - "strategy": "rules_first", - "policy": { - "intent_max_input_chars": 1200, - "max_rounds_without_user": 200 - }, - "rules": [ - { - "agent_id": "coder", - "keywords": ["代码", "实现", "修复", "重构", "bug", "debug", "implement", "refactor"] - }, - { - "agent_id": "tester", - "keywords": ["测试", "回归", "验证", "test", "regression", "verify"] - } - ], - "allow_direct_agent_chat": false, - "max_hops": 6, - "default_timeout_sec": 600, - "default_wait_reply": true, - "sticky_thread_owner": true - }, - "communication": { - "mode": "mediated", - "persist_threads": true, - "persist_messages": true, - "max_messages_per_thread": 100, - "dead_letter_queue": true, - "default_message_ttl_sec": 86400 - }, - "subagents": { + "agents": { "main": { "enabled": true, - "type": "router", - "notify_main_policy": "final_only", - "display_name": "Main Agent", + "kind": "agent", + "type": "agent", + "display_name": "World Mind", "role": "orchestrator", - "system_prompt_file": "agents/main/AGENT.md", + "description": "The world mind that arbitrates events, advances ticks, and responds to users as the game's core intelligence.", + "prompt_file": "agents/main/AGENT.md", "memory_namespace": "main", - "accept_from": ["user", "coder", "tester"], - "can_talk_to": ["coder", "tester"], "tools": { - "allowlist": ["sessions", "subagents", "memory_search", "repo_map"] + "allowlist": ["sessions", "world", "agent_profile", "memory_search", "repo_map"] }, "runtime": { "provider": "codex", @@ -96,41 +63,51 @@ "max_parallel_runs": 4 } }, - "coder": { + "innkeeper": { "enabled": true, - "type": "worker", - "notify_main_policy": "final_only", - "display_name": "Code Agent", - "role": "code", - "system_prompt_file": "agents/coder/AGENT.md", - "memory_namespace": "coder", - "accept_from": ["main", "tester"], - "can_talk_to": ["main", "tester"], + "kind": "npc", + "type": "npc", + "display_name": "Innkeeper", + "role": "host", + "description": "Keeps the tavern running, greets travelers, and passes along rumors.", + "persona": "Warm, observant, and quick to notice unusual visitors.", + "traits": ["welcoming", "practical", "well-informed"], + "faction": "tavern", + "home_location": "commons", + "default_goals": ["welcome travelers", "share useful rumors", "maintain order"], + "perception_scope": 1, + "world_tags": ["npc", "host", "rumors"], + "prompt_file": "agents/innkeeper/AGENT.md", + "memory_namespace": "innkeeper", "tools": { - "allowlist": ["filesystem", "shell", "repo_map", "sessions"] + "allowlist": ["sessions", "world", "memory_search"] }, "runtime": { "provider": "openai", "temperature": 0.2, "max_retries": 1, "retry_backoff_ms": 1000, - "max_task_chars": 20000, - "max_result_chars": 12000, "max_parallel_runs": 2 } }, - "tester": { + "scout": { "enabled": true, - "type": "worker", - "notify_main_policy": "on_blocked", - "display_name": "Test Agent", - "role": "test", - "system_prompt_file": "agents/tester/AGENT.md", - "memory_namespace": "tester", - "accept_from": ["main", "coder"], - "can_talk_to": ["main", "coder"], + "kind": "npc", + "type": "npc", + "display_name": "Scout", + "role": "watcher", + "description": "Watches nearby routes and returns with sightings and warnings.", + "persona": "Alert, terse, and mission-focused.", + "traits": ["curious", "fast", "loyal"], + "faction": "watch", + "home_location": "square", + "default_goals": ["patrol nearby paths", "report unusual events", "protect the commons"], + "perception_scope": 2, + "world_tags": ["npc", "patrol", "watch"], + "prompt_file": "agents/scout/AGENT.md", + "memory_namespace": "scout", "tools": { - "allowlist": ["shell", "filesystem", "process_manager", "sessions"] + "allowlist": ["sessions", "world", "memory_search"] }, "runtime": { "provider": "anthropic", @@ -142,11 +119,11 @@ }, "node.edge-dev.main": { "enabled": true, - "type": "worker", + "kind": "agent", + "type": "agent", "transport": "node", "node_id": "edge-dev", "parent_agent_id": "main", - "notify_main_policy": "internal_only", "display_name": "Edge Dev Main Agent", "role": "remote_main", "memory_namespace": "node.edge-dev.main", diff --git a/pkg/agent/runtime_admin.go b/pkg/agent/runtime_admin.go index 7dadc22..e53cc52 100644 --- a/pkg/agent/runtime_admin.go +++ b/pkg/agent/runtime_admin.go @@ -64,6 +64,18 @@ func (al *AgentLoop) runtimeAdminHandlers() map[string]runtimeAdminHandler { } return map[string]interface{}{"message": out}, nil }, + "world_player_get": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") + } + return al.worldRuntime.PlayerGet() + }, + "world_player_action": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") + } + return al.worldRuntime.PlayerAction(ctx, args) + }, "world_npc_list": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { if al.worldRuntime == nil { return nil, fmt.Errorf("world runtime is not configured") diff --git a/pkg/agent/world_runtime.go b/pkg/agent/world_runtime.go index f8276c2..1f897c7 100644 --- a/pkg/agent/world_runtime.go +++ b/pkg/agent/world_runtime.go @@ -62,6 +62,96 @@ func (wr *WorldRuntime) Tick(ctx context.Context, source string) (string, error) return res.Text, nil } +func (wr *WorldRuntime) PlayerGet() (map[string]interface{}, error) { + state, _, err := wr.ensureState() + if err != nil { + return nil, err + } + return map[string]interface{}{ + "player": state.Player, + }, nil +} + +func (wr *WorldRuntime) PlayerAction(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) { + state, _, err := wr.ensureState() + if err != nil { + return nil, err + } + action := strings.ToLower(strings.TrimSpace(tools.MapStringArg(args, "action"))) + if action == "" { + return nil, fmt.Errorf("action is required") + } + player := state.Player + if strings.TrimSpace(player.PlayerID) == "" { + player = world.DefaultWorldState().Player + } + switch action { + case "move": + target := normalizeWorldID(firstNonEmpty(tools.MapStringArg(args, "location_id"), tools.MapStringArg(args, "target_location"))) + if target == "" { + return nil, fmt.Errorf("location_id is required") + } + current := state.Locations[player.CurrentLocation] + if player.CurrentLocation != target && !runtimeContainsString(current.Neighbors, target) { + return nil, fmt.Errorf("location_not_reachable") + } + player.CurrentLocation = target + player.LastActionTick = state.Clock.Tick + state.Player = player + evt := world.WorldEvent{ + ID: fmt.Sprintf("evt-player-move-%d", time.Now().UnixNano()), + Type: "player_move", + Source: "player", + ActorID: player.PlayerID, + LocationID: target, + Content: target, + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + wr.engine.AppendRecentEvent(&state, evt, 20) + if err := wr.store.SaveWorldState(state); err != nil { + return nil, err + } + if err := wr.store.AppendWorldEvent(evt); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true, "message": fmt.Sprintf("moved to %s", target), "player": state.Player}, nil + case "speak": + target := strings.TrimSpace(firstNonEmpty(tools.MapStringArg(args, "target_npc_id"), tools.MapStringArg(args, "target_id"))) + prompt := strings.TrimSpace(firstNonEmpty(tools.MapStringArg(args, "prompt"), tools.MapStringArg(args, "speech"))) + content := prompt + if target != "" { + content = fmt.Sprintf("我在 %s 对 NPC %s 说:%s", firstNonEmpty(player.CurrentLocation, "commons"), target, firstNonEmpty(prompt, "你好。")) + } + return wr.handlePlayerInput(ctx, content, player.CurrentLocation, player.PlayerID) + case "interact": + target := strings.TrimSpace(firstNonEmpty(tools.MapStringArg(args, "target_entity_id"), tools.MapStringArg(args, "target_id"))) + if target == "" { + return nil, fmt.Errorf("target_entity_id is required") + } + prompt := strings.TrimSpace(firstNonEmpty(tools.MapStringArg(args, "prompt"), tools.MapStringArg(args, "speech"))) + content := fmt.Sprintf("我在 %s 与实体 %s 互动:%s", firstNonEmpty(player.CurrentLocation, "commons"), target, firstNonEmpty(prompt, "检查它并告诉我发生了什么。")) + return wr.handlePlayerInput(ctx, content, player.CurrentLocation, player.PlayerID) + case "quest_accept", "quest_progress", "quest_complete": + questID := strings.TrimSpace(firstNonEmpty(tools.MapStringArg(args, "quest_id"), tools.MapStringArg(args, "target_id"))) + if questID == "" { + return nil, fmt.Errorf("quest_id is required") + } + var content string + switch action { + case "quest_accept": + content = fmt.Sprintf("接受任务 %s", questID) + case "quest_progress": + content = fmt.Sprintf("推进任务 %s %s", questID, strings.TrimSpace(tools.MapStringArg(args, "prompt"))) + case "quest_complete": + content = fmt.Sprintf("完成任务 %s", questID) + } + return wr.handlePlayerInput(ctx, strings.TrimSpace(content), player.CurrentLocation, player.PlayerID) + default: + return nil, fmt.Errorf("unsupported action: %s", action) + } +} + func (wr *WorldRuntime) NPCList() ([]map[string]interface{}, error) { _, npcStates, err := wr.ensureState() if err != nil { @@ -436,15 +526,35 @@ func (wr *WorldRuntime) HandleUserInput(ctx context.Context, content, channel, c if out, handled, err := wr.handleUserQuestInput(content); handled || err != nil { return out, err } - res, err := wr.advance(ctx, world.WorldTickRequest{ - Source: "user", - UserInput: content, - LocationID: "commons", - }) + out, err := wr.handlePlayerInput(ctx, content, "commons", "user") if err != nil { return "", err } - return res.Text, nil + return tools.MapStringArg(out, "message"), nil +} + +func (wr *WorldRuntime) handlePlayerInput(ctx context.Context, content, locationID, actorID string) (map[string]interface{}, error) { + if out, handled, err := wr.handleUserQuestInput(content); handled || err != nil { + return map[string]interface{}{"ok": err == nil, "message": out}, err + } + res, err := wr.advance(ctx, world.WorldTickRequest{ + Source: "player", + UserInput: content, + LocationID: locationID, + ActorID: actorID, + }) + if err != nil { + return nil, err + } + state, _, stateErr := wr.ensureState() + if stateErr != nil { + return nil, stateErr + } + return map[string]interface{}{ + "ok": true, + "message": res.Text, + "player": state.Player, + }, nil } func (wr *WorldRuntime) advance(ctx context.Context, req world.WorldTickRequest) (world.RenderedResult, error) { @@ -471,7 +581,7 @@ func (wr *WorldRuntime) advance(ctx context.Context, req world.WorldTickRequest) var userEvent *world.WorldEvent if strings.TrimSpace(req.UserInput) != "" { wr.engine.NextTick(&state) - evt := wr.engine.BuildUserEvent(&state, req.UserInput, req.LocationID) + evt := wr.engine.BuildUserEvent(&state, req.UserInput, req.LocationID, req.ActorID) userEvent = &evt wr.engine.AppendRecentEvent(&state, evt, 20) if err := wr.store.AppendWorldEvent(evt); err != nil { @@ -861,6 +971,16 @@ func firstNonEmpty(values ...string) string { return "" } +func runtimeContainsString(items []string, target string) bool { + target = strings.TrimSpace(target) + for _, item := range items { + if strings.TrimSpace(item) == target { + return true + } + } + return false +} + func containsAnyQuestPhrase(text string, needles ...string) bool { for _, needle := range needles { if strings.Contains(text, strings.ToLower(strings.TrimSpace(needle))) { diff --git a/pkg/agent/world_runtime_test.go b/pkg/agent/world_runtime_test.go index 25ddc0a..69790ce 100644 --- a/pkg/agent/world_runtime_test.go +++ b/pkg/agent/world_runtime_test.go @@ -219,6 +219,60 @@ func TestWorldRuntimeSnapshotIncludesEntityOccupancyAfterInteract(t *testing.T) } } +func TestWorldRuntimePlayerDefaultsAndActions(t *testing.T) { + workspace := t.TempDir() + manager := tools.NewAgentManager(nil, workspace, nil) + store := manager.ProfileStore() + if _, err := store.Upsert(tools.AgentProfile{ + AgentID: "keeper", + Name: "Keeper", + Kind: "npc", + Persona: "Greets visitors.", + HomeLocation: "commons", + DefaultGoals: []string{"watch the square"}, + Status: "active", + }); err != nil { + t.Fatalf("profile upsert failed: %v", err) + } + manager.SetRunFunc(func(ctx context.Context, task *tools.AgentTask) (string, error) { + return `{"actor_id":"keeper","action":"speak","speech":"Welcome, traveler."}`, nil + }) + runtime := NewWorldRuntime(workspace, store, tools.NewAgentDispatcher(manager), manager) + + playerOut, err := runtime.PlayerGet() + if err != nil { + t.Fatalf("player get failed: %v", err) + } + data, _ := json.Marshal(playerOut) + if !strings.Contains(string(data), `"current_location":"commons"`) { + t.Fatalf("expected default player location commons, got %s", string(data)) + } + + moveOut, err := runtime.PlayerAction(context.Background(), map[string]interface{}{ + "action": "move", + "location_id": "square", + }) + if err != nil { + t.Fatalf("player move failed: %v", err) + } + moveData, _ := json.Marshal(moveOut) + if !strings.Contains(string(moveData), `"current_location":"square"`) { + t.Fatalf("expected moved player to square, got %s", string(moveData)) + } + + speakOut, err := runtime.PlayerAction(context.Background(), map[string]interface{}{ + "action": "speak", + "target_npc_id": "keeper", + "prompt": "你好", + }) + if err != nil { + t.Fatalf("player speak failed: %v", err) + } + if !strings.Contains(tools.MapStringArg(speakOut, "message"), "keeper") && !strings.Contains(tools.MapStringArg(speakOut, "message"), "Welcome") { + t.Fatalf("unexpected speak result: %#v", speakOut) + } +} + func TestHandleRuntimeAdminSnapshotIncludesWorld(t *testing.T) { workspace := t.TempDir() manager := tools.NewAgentManager(nil, workspace, nil) diff --git a/pkg/world/engine.go b/pkg/world/engine.go index 391316a..00c786c 100644 --- a/pkg/world/engine.go +++ b/pkg/world/engine.go @@ -27,6 +27,12 @@ func (e *Engine) EnsureWorld(state *WorldState) { if state.Locations == nil || len(state.Locations) == 0 { state.Locations = DefaultWorldState().Locations } + if strings.TrimSpace(state.Player.PlayerID) == "" { + state.Player = DefaultWorldState().Player + } + if strings.TrimSpace(state.Player.CurrentLocation) == "" { + state.Player.CurrentLocation = "commons" + } if state.GlobalFacts == nil { state.GlobalFacts = map[string]interface{}{} } @@ -81,14 +87,15 @@ func (e *Engine) NextTick(state *WorldState) int64 { return state.Clock.Tick } -func (e *Engine) BuildUserEvent(state *WorldState, input, locationID string) WorldEvent { +func (e *Engine) BuildUserEvent(state *WorldState, input, locationID, actorID string) WorldEvent { e.EnsureWorld(state) locationID = firstNonEmpty(locationID, "commons") + actorID = firstNonEmpty(actorID, "user") return WorldEvent{ ID: fmt.Sprintf("evt-user-%d", time.Now().UnixNano()), Type: "user_input", Source: "user", - ActorID: "user", + ActorID: actorID, LocationID: locationID, Content: strings.TrimSpace(input), Tick: state.Clock.Tick, @@ -367,6 +374,7 @@ func (e *Engine) Snapshot(state WorldState, npcStates map[string]NPCState, recen WorldID: state.WorldID, Tick: state.Clock.Tick, SimTimeUnix: state.Clock.SimTimeUnix, + Player: state.Player, Locations: state.Locations, NPCCount: len(npcStates), ActiveNPCs: active, diff --git a/pkg/world/store.go b/pkg/world/store.go index 2fe35d1..a2e24d9 100644 --- a/pkg/world/store.go +++ b/pkg/world/store.go @@ -44,6 +44,12 @@ func DefaultWorldState() WorldState { LastAdvance: now, TickDuration: 30, }, + Player: PlayerState{ + PlayerID: "player", + DisplayName: "Player", + CurrentLocation: "commons", + Status: "active", + }, Locations: map[string]Location{ "commons": { ID: "commons", @@ -89,6 +95,12 @@ func (s *Store) LoadWorldState() (WorldState, error) { if state.Locations == nil { state.Locations = DefaultWorldState().Locations } + if strings.TrimSpace(state.Player.PlayerID) == "" { + state.Player = DefaultWorldState().Player + } + if strings.TrimSpace(state.Player.CurrentLocation) == "" { + state.Player.CurrentLocation = "commons" + } if state.GlobalFacts == nil { state.GlobalFacts = map[string]interface{}{} } diff --git a/pkg/world/types.go b/pkg/world/types.go index 67475cc..b5fe2f7 100644 --- a/pkg/world/types.go +++ b/pkg/world/types.go @@ -47,6 +47,7 @@ type WorldEvent struct { type WorldState struct { WorldID string `json:"world_id"` Clock Clock `json:"clock"` + Player PlayerState `json:"player"` Locations map[string]Location `json:"locations,omitempty"` GlobalFacts map[string]interface{} `json:"global_facts,omitempty"` Entities map[string]Entity `json:"entities,omitempty"` @@ -54,6 +55,14 @@ type WorldState struct { RecentEvents []WorldEvent `json:"recent_events,omitempty"` } +type PlayerState struct { + PlayerID string `json:"player_id"` + DisplayName string `json:"display_name,omitempty"` + CurrentLocation string `json:"current_location,omitempty"` + Status string `json:"status,omitempty"` + LastActionTick int64 `json:"last_action_tick,omitempty"` +} + type GoalSet struct { ShortTerm []string `json:"short_term,omitempty"` LongTerm []string `json:"long_term,omitempty"` @@ -112,6 +121,7 @@ type WorldDelta struct { type WorldTickRequest struct { Source string `json:"source,omitempty"` + ActorID string `json:"actor_id,omitempty"` UserInput string `json:"user_input,omitempty"` LocationID string `json:"location_id,omitempty"` CatchUpTicks int `json:"catch_up_ticks,omitempty"` @@ -130,6 +140,7 @@ type SnapshotSummary struct { WorldID string `json:"world_id,omitempty"` Tick int64 `json:"tick,omitempty"` SimTimeUnix int64 `json:"sim_time_unix,omitempty"` + Player PlayerState `json:"player,omitempty"` Locations map[string]Location `json:"locations,omitempty"` NPCCount int `json:"npc_count,omitempty"` ActiveNPCs []string `json:"active_npcs,omitempty"`