From 0f47d92ca591930a1f01c1a75aa45f9c09ddb154 Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 21 May 2026 01:10:12 +0700 Subject: [PATCH] feat: carry NPC conversation session context --- .../Scripts/AI/AgentContextDto.cs | 2 + .../Scripts/AI/PrototypeAgentBrain.cs | 2 + backend/nakama/modules/index.ts | 60 ++++++++++++++++-- .../tests/supabase_custom_auth.test.mjs | 62 +++++++++++++++++++ 4 files changed, 120 insertions(+), 6 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs index f9c4659b..37dfc778 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs @@ -885,6 +885,8 @@ public sealed class WorldSnapshotDto public WorldObjectDto[] nearby_objects; public PlayerChatContextDto last_player_message; public string[] recent_speech; + public string conversation_session_id; + public string trigger_event_id; public int danger_level; public long body_time_seconds; } diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs index 081c6e79..ecc525eb 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs @@ -632,6 +632,8 @@ private AgentDecisionRequestDto BuildDecisionRequest() safe_radius = _patrolRadius, danger_level = 0, body_time_seconds = _context?.body?.time?.remaining_seconds ?? 3600, + conversation_session_id = _pendingConversationSessionId, + trigger_event_id = _pendingTriggerEventId, nearby_actors = ExtractNearbyActors(nearbyObjects), nearby_objects = nearbyObjects, last_player_message = pendingPlayerChat, diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 8b9c7961..8c766d95 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -621,7 +621,7 @@ function rpcAgentDecide( if (!budget.allowed) { modelFallbackReason = budget.reason; } else { - var modelResult = tryDosAiAgentDecision(ctx, logger, nk, context, request, world, allowed); + var modelResult = tryDosAiAgentDecision(ctx, logger, nk, userId, context, request, world, allowed); modelTrace = modelResult; if (modelResult.decision) { decision = modelResult.decision; @@ -3575,6 +3575,7 @@ function normalizeConversationSession(session: any, sessionId: string, channelId var maxTurns = clampNumber(session.max_turns || conversationSessionTurnLimit, 1, conversationSessionTurnLimit); var currentTurn = clampNumber(session.current_turn || 0, 0, conversationSessionTurnLimit); var status = trimString(session.status) || "open"; + var transcriptSummary = trimString(session.transcript_summary) || initialConversationTranscriptFromDecisions(decisions); return { session_id: sessionId, channel_id: channelId, @@ -3594,12 +3595,23 @@ function normalizeConversationSession(session: any, sessionId: string, channelId trigger_event_id: triggerEventId, max_turns: maxTurns, current_turn: currentTurn, - transcript_summary: trimString(session.transcript_summary) || "", + transcript_summary: transcriptSummary, created_at: trimString(session.created_at) || timestamp, updated_at: timestamp }; } +function initialConversationTranscriptFromDecisions(decisions: any[]): string { + if (!decisions || decisions.length === 0) { + return ""; + } + + var firstDecision = decisions[0] || {}; + var speaker = firstNonEmpty(firstDecision.target_display_name, "Player"); + var text = trimString(firstDecision.player_message); + return text ? speaker + ": " + text : ""; +} + function normalizeConversationObjective( objective: any, sessionId: string, @@ -4271,6 +4283,7 @@ function tryDosAiAgentDecision( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, + ownerId: string, context: any, request: any, world: any, @@ -4293,7 +4306,7 @@ function tryDosAiAgentDecision( model: model, messages: [ { role: "system", content: dosAiAgentDecisionSystemPrompt() }, - { role: "user", content: dosAiAgentDecisionUserPrompt(nk, context, request, world, allowed) } + { role: "user", content: dosAiAgentDecisionUserPrompt(nk, ownerId, context, request, world, allowed) } ], max_tokens: dosAiDecisionMaxTokens, max_completion_tokens: dosAiDecisionMaxTokens, @@ -4400,7 +4413,7 @@ function consumeDosAiDecisionBudget( return { allowed: true, reason: "" }; } - var estimatedTokens = estimateDosAiDecisionTokens(nk, context, request, world, allowed); + var estimatedTokens = estimateDosAiDecisionTokens(nk, ownerId, context, request, world, allowed); var today = new Date().toISOString().substring(0, 10); for (var attempt = 0; attempt < 4; attempt += 1) { var existing = readDosAiDecisionBudget(nk, ownerId, today, budgetLane); @@ -4432,10 +4445,11 @@ function consumeDosAiDecisionBudget( return { allowed: false, reason: "dos_ai_budget_write_conflict" }; } -function estimateDosAiDecisionTokens(nk: nkruntime.Nakama, context: any, request: any, world: any, allowed: string[]): number { +function estimateDosAiDecisionTokens(nk: nkruntime.Nakama, ownerId: string, context: any, request: any, world: any, allowed: string[]): number { var roughPrompt = JSON.stringify({ context: context, knowledge_packs: buildDecisionKnowledgePacks(nk, context, world), + conversation_session: buildDecisionConversationSession(nk, ownerId, world), request: request, world: world, allowed: allowed @@ -4769,10 +4783,11 @@ function stableAngle(seed: string): number { return (hash % 6283) / 1000; } -function dosAiAgentDecisionUserPrompt(nk: nkruntime.Nakama, context: any, request: any, world: any, allowed: string[]): string { +function dosAiAgentDecisionUserPrompt(nk: nkruntime.Nakama, ownerId: string, context: any, request: any, world: any, allowed: string[]): string { return JSON.stringify({ agent_context: compactAgentDecisionContext(context), knowledge_packs: buildDecisionKnowledgePacks(nk, context, world), + conversation_session: buildDecisionConversationSession(nk, ownerId, world), allowed_actions: allowed, allowed_target_ids: compactDecisionTargetIds(world), world_snapshot: compactDecisionWorld(world), @@ -4783,6 +4798,37 @@ function dosAiAgentDecisionUserPrompt(nk: nkruntime.Nakama, context: any, reques }); } +function buildDecisionConversationSession(nk: nkruntime.Nakama, ownerId: string, world: any): any { + var sessionId = normalizeConversationSessionId(world && (world.conversation_session_id || world.session_id)); + if (!sessionId) { + return null; + } + + var existing = readConversationSession(nk, ownerId, sessionId); + if (!existing) { + return { + session_id: sessionId, + status: "unknown", + transcript_summary: "", + turns_remaining: 0 + }; + } + + var session = normalizeConversationSession(existing.value || {}, sessionId, trimString(world && world.zone_id) || "prototype-hub", []); + return { + session_id: session.session_id, + status: session.status, + topic: session.topic, + objective: session.objective, + conversation_objective: compactConversationObjective(session.conversation_objective), + participant_actor_ids: normalizeStringArray(session.participant_actor_ids, []).slice(0, societyEventTargetLimit + 1), + current_turn: session.current_turn, + max_turns: session.max_turns, + turns_remaining: Math.max(0, Number(session.max_turns || 0) - Number(session.current_turn || 0)), + transcript_summary: truncateForLog(session.transcript_summary, 1200) + }; +} + function buildDecisionKnowledgePacks(nk: nkruntime.Nakama, context: any, world: any): any[] { var ids: string[] = []; appendKnowledgePackId(ids, "zone_lore:vinh_hai_relay_ward:v1"); @@ -5060,6 +5106,8 @@ function compactDecisionWorld(world: any): any { body_time_seconds: world && world.body_time_seconds, threat_level: world && world.threat_level, zone_id: world && world.zone_id, + conversation_session_id: normalizeConversationSessionId(world && (world.conversation_session_id || world.session_id)), + trigger_event_id: trimString(world && world.trigger_event_id), conversation_objective: compactConversationObjective(world && (world.conversation_objective || world.objective)), nearby_actors: nearbyActors, nearby_objects: nearbyObjects, diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 856158ce..73f68c4e 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -348,6 +348,68 @@ assert.ok(/Keep your voice steady/.test(societyNpcIntent.conversation_session.tr const societyLogAfterNpcSpeech = harness.storage.get(storageKey("user-1", "secondspawn_society", "events:prototype-hub")); assert.ok(societyLogAfterNpcSpeech.value.events.some((event) => event.kind === "npc_speech")); +const conversationPromptCalls = []; +harness.nk.httpRequest = (url, method, headers, body, timeout) => { + conversationPromptCalls.push({ url, method, headers, body: JSON.parse(body), timeout }); + return { + code: 200, + body: JSON.stringify({ + model: "dos-ai", + choices: [{ + message: { + content: JSON.stringify({ + action: "say", + target_id: "player-body-user-1", + say: "I told you to keep your voice steady. The gate hears panic first.", + reason: "uses active conversation history", + confidence: 0.84 + }) + } + }] + }) + }; +}; +const conversationPromptDecision = JSON.parse(harness.registeredRpcs.get("secondspawn_agent_decide")( + { + userId: "user-1", + env: { + DOS_AI_API_KEY: "dos-ai-test-key", + DOS_AI_BASE_URL: "https://api.dos.ai/v1", + AGENT_DECISION_MODEL: "dos-ai" + } + }, + harness.logger, + harness.nk, + JSON.stringify({ + context: { + player: { + player_id: "npc-synthetic-sentinel-0101", + display_name: "Gate Sentinel 0101" + } + }, + world_snapshot: { + position: { x: 2, z: 3 }, + body_time_seconds: 3600, + conversation_session_id: societyTick.conversation_session.session_id, + last_player_message: { + player_actor_id: "player-body-user-1", + player_display_name: "JOY", + text: "What did you just tell me?" + }, + nearby_objects: [{ id: "player-body-user-1", kind: "nearby_player", distance: 2 }] + }, + allowed: ["say", "stop"] + }) +)); +assert.equal(conversationPromptDecision.source, "model"); +assert.equal(conversationPromptDecision.action, "say"); +assert.equal(conversationPromptCalls.length, 1); +const conversationPromptPayload = JSON.parse(conversationPromptCalls[0].body.messages[1].content); +assert.equal(conversationPromptPayload.conversation_session.session_id, societyTick.conversation_session.session_id); +assert.equal(conversationPromptPayload.conversation_session.status, "open"); +assert.match(conversationPromptPayload.conversation_session.transcript_summary, /JOY: Anyone hear me near the gate/); +assert.match(conversationPromptPayload.conversation_session.transcript_summary, /Gate Sentinel 0101: I hear you near the gate/); + const npcIntentWriteConflictHarness = createRuntimeHarness(module); npcIntentWriteConflictHarness.registeredRpcs.get("secondspawn_npc_seed")( { userId: "npc-intent-race-user", env: {} },