Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeAgentBrain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
60 changes: 54 additions & 6 deletions backend/nakama/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -4271,6 +4283,7 @@ function tryDosAiAgentDecision(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
ownerId: string,
context: any,
request: any,
world: any,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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");
Expand Down Expand Up @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions backend/nakama/tests/supabase_custom_auth.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} },
Expand Down
Loading