diff --git a/apps/gui/src/lib/simulation/turn-executor.ts b/apps/gui/src/lib/simulation/turn-executor.ts index 70d30e1..60dcb8c 100644 --- a/apps/gui/src/lib/simulation/turn-executor.ts +++ b/apps/gui/src/lib/simulation/turn-executor.ts @@ -49,10 +49,12 @@ async function processIntents( // eslint-disable-next-line @typescript-eslint/no-explicit-any worldState: any, session: SimSession, -): Promise { +): Promise<{ intentInfos: IntentInfo[]; validatorCalls: ValidatorCall[] }> { const intentInfos: IntentInfo[] = []; + const validatorCalls: ValidatorCall[] = []; - for (const intent of intents) { + for (let i = 0; i < intents.length; i++) { + const intent = intents[i]; const outcome = await session.architect.processIntent(worldState, intent); const ts = worldState.clock.get().toISOString(); @@ -66,6 +68,53 @@ async function processIntents( minutesToAdvance: outcome.timeDelta?.minutesToAdvance, }); + if ( + intent.type === "action" && + (session.architect.validator as any).lastResult + ) { + const lastResult = (session.architect.validator as any).lastResult; + let usage = undefined; + if ( + session.validatorProvider.lastCalls && + session.validatorProvider.lastCalls.length > 0 + ) { + const valCall = + session.validatorProvider.lastCalls[ + session.validatorProvider.lastCalls.length - 1 + ]; + usage = valCall.usage; + } + + validatorCalls.push({ + intentIndex: i, + intentContent: intent.content, + prompt: { + systemPrompt: lastResult.systemPrompt || "", + userContext: lastResult.userContext || "", + components: lastResult.promptComponents, + }, + response: { + isValid: outcome.isValid, + reason: outcome.reason, + }, + usage, + }); + } else { + const reason = + intent.type === "dialogue" + ? "Dialogue intents represent verbal/communication actions and are automatically valid." + : "Monologue/thought intents represent internal reflections and bypass validation."; + + validatorCalls.push({ + intentIndex: i, + intentContent: intent.content, + response: { + isValid: true, + reason: outcome.reason || reason, + }, + }); + } + const actorEntry = buildBufferEntryForIntent(intent, ts, entity.locationId); if (intent.type === "action") { actorEntry.outcome = { isValid: outcome.isValid, reason: outcome.reason }; @@ -99,7 +148,7 @@ async function processIntents( } } - return intentInfos; + return { intentInfos, validatorCalls }; } // --------------------------------------------------------------------------- @@ -219,13 +268,21 @@ export async function processNpcTurn( entry.decoderUsage = decoderCall.usage; } - entry.intents = await processIntents( + const { intentInfos, validatorCalls } = await processIntents( result.intents.intents, info.id, entity, worldState, session, ); + entry.intents = intentInfos; + entry.validatorCalls = validatorCalls; + entry.decodedIntents = result.intents.intents.map((intent) => ({ + type: intent.type, + content: intent.content, + modifiers: intent.modifiers || [], + targetIds: intent.targetIds, + })); session.log.push(entry); session.coreRepo.saveWorldState(worldState); @@ -303,13 +360,21 @@ export async function executePlayerAction( entry.decoderUsage = call.usage; } - entry.intents = await processIntents( + const { intentInfos, validatorCalls: playerValCalls } = await processIntents( result.intents.intents, ctx.entityId, entity, worldState, session, ); + entry.intents = intentInfos; + entry.validatorCalls = playerValCalls; + entry.decodedIntents = result.intents.intents.map((intent) => ({ + type: intent.type, + content: intent.content, + modifiers: intent.modifiers || [], + targetIds: intent.targetIds, + })); session.log.push(entry); session.coreRepo.saveWorldState(worldState); diff --git a/packages/actor/src/actor-prompt-builder.ts b/packages/actor/src/actor-prompt-builder.ts index dff4fa9..2cd0676 100644 --- a/packages/actor/src/actor-prompt-builder.ts +++ b/packages/actor/src/actor-prompt-builder.ts @@ -14,7 +14,7 @@ import { LedgerRepository, } from "@omnia/memory"; import { hydrate } from "@omnia/voice"; -import { PromptComponent } from "@omnia/llm"; +import { PromptComponent, IPromptBuilder, PromptBreakdown } from "@omnia/llm"; /** * Zod schema for the structured response expected from the actor LLM. @@ -38,7 +38,9 @@ export type ActorResponse = z.infer; * ACL'd to it), its own Cognitive Buffer, and the entities co-located * with it. System UUIDs are surfaced as subjective aliases. */ -export class ActorPromptBuilder { +export class ActorPromptBuilder implements IPromptBuilder< + [WorldState, Entity] +> { /** * @param bufferRepo Used to fetch the actor's Cognitive Buffer. Optional — * if absent, the memory section is omitted. @@ -64,14 +66,7 @@ export class ActorPromptBuilder { /** * Assembles the system prompt and user context for a given entity. */ - build( - worldState: WorldState, - entity: Entity, - ): { - systemPrompt: string; - userContext: string; - components: PromptComponent[]; - } { + build(worldState: WorldState, entity: Entity): PromptBreakdown { const systemPrompt = this.buildSystemPrompt(); const { userContext, components } = this.buildUserContext( worldState, diff --git a/packages/architect/src/architect.ts b/packages/architect/src/architect.ts index 5de9945..22f8331 100644 --- a/packages/architect/src/architect.ts +++ b/packages/architect/src/architect.ts @@ -9,7 +9,7 @@ export interface ProcessResult extends ValidationResult { } export class Architect { - private validator: LLMValidator; + public validator: LLMValidator; private timeDeltaGenerator: TimeDeltaGenerator; constructor( diff --git a/packages/architect/src/index.ts b/packages/architect/src/index.ts index 19e95a8..d5ad0bd 100644 --- a/packages/architect/src/index.ts +++ b/packages/architect/src/index.ts @@ -1,3 +1,4 @@ export * from "./llm-validator.js"; +export * from "./llm-validator-prompt-builder.js"; export * from "./architect.js"; export * from "./delta.js"; diff --git a/packages/architect/src/llm-validator-prompt-builder.ts b/packages/architect/src/llm-validator-prompt-builder.ts new file mode 100644 index 0000000..733a348 --- /dev/null +++ b/packages/architect/src/llm-validator-prompt-builder.ts @@ -0,0 +1,59 @@ +import { WorldState, serializeObjectiveWorldState } from "@omnia/core"; +import { Intent } from "@omnia/intent"; +import { hydrateObjective } from "@omnia/voice"; +import { PromptBreakdown, PromptComponent, IPromptBuilder } from "@omnia/llm"; + +/** + * Prompt builder for the LLM Validator (World Architect). + * Separates prompt generation, structure, and component breakdowns. + */ +export class LLMValidatorPromptBuilder implements IPromptBuilder< + [WorldState, Intent] +> { + build(worldState: WorldState, intent: Intent): PromptBreakdown { + const serializedWorld = serializeObjectiveWorldState(worldState); + + const systemPrompt = ` +You are the World Architect, a deterministic and objective judge of reality, physics, and narration for a simulation game. +Your task is to judge whether a proposed action (Intent) by an actor is physically and logically possible given the current objective state of the world. +Exempt dialogue or speech actions from validation (consider them always valid). +Enforce logical boundaries such as: +- Spatial boundaries (an actor cannot grab an object in another location unless they are there). +- Physical boundaries (an actor cannot open a locked drawer without a key or breaking it). +- State Boundaries (an actor cannot perform a task if their state doesn't allow them to do so). +- State/Attribute constraints. +- An actor can perform actions on themselves as long as it follows the boundaries stated above. + +You must respond with a JSON object containing: +- "isValid": boolean indicating if the action is possible/allowed. +- "reason": a very short explanation of why the action is allowed or denied. +`.trim(); + + const objectiveContent = hydrateObjective(intent.content, worldState); + + const worldStateSection = `=== CURRENT WORLD STATE ===\nCurrent Time: ${worldState.clock.get().toISOString()}\nEntities & Attributes:\n${serializedWorld}`; + const proposedActionSection = `=== PROPOSED ACTION ===\nActor ID: ${intent.actorId}\nType: ${intent.type}\nContent: "${objectiveContent}"\nTarget IDs: ${intent.targetIds.join(", ") || "(None)"}`; + + const userContext = `${worldStateSection}\n\n${proposedActionSection}\n\nDecide if the proposed action is logically valid and physically possible.`; + + const components: PromptComponent[] = [ + { label: "System Prompt", type: "system", content: systemPrompt }, + { + label: "Current World State", + type: "world", + content: worldStateSection, + }, + { + label: "Proposed Action", + type: "input", + content: proposedActionSection, + }, + ]; + + return { + systemPrompt, + userContext, + components, + }; + } +} diff --git a/packages/architect/src/llm-validator.ts b/packages/architect/src/llm-validator.ts index 93cb572..893c0db 100644 --- a/packages/architect/src/llm-validator.ts +++ b/packages/architect/src/llm-validator.ts @@ -1,8 +1,8 @@ import { z } from "zod"; -import { WorldState, serializeObjectiveWorldState } from "@omnia/core"; -import { ILLMProvider } from "@omnia/llm"; +import { WorldState } from "@omnia/core"; +import { ILLMProvider, PromptBreakdown } from "@omnia/llm"; import { Intent } from "@omnia/intent"; -import { hydrateObjective } from "@omnia/voice"; +import { LLMValidatorPromptBuilder } from "./llm-validator-prompt-builder.js"; export const ValidationResultSchema = z.object({ isValid: z.boolean(), @@ -12,7 +12,12 @@ export const ValidationResultSchema = z.object({ export type ValidationResult = z.infer; export class LLMValidator { - constructor(private llmProvider: ILLMProvider) {} + public lastResult: PromptBreakdown | null = null; + private promptBuilder: LLMValidatorPromptBuilder; + + constructor(private llmProvider: ILLMProvider) { + this.promptBuilder = new LLMValidatorPromptBuilder(); + } /** * Validates an action intent against the objective world state. @@ -25,6 +30,8 @@ export class LLMValidator { worldState: WorldState, intent: Intent, ): Promise { + this.lastResult = null; + // Defensive guard: monologue and thought intents bypass validation. if (intent.type === "monologue" || intent.type === "thought") { return { @@ -42,41 +49,16 @@ export class LLMValidator { }; } - // 1. Serialize the objective world state for the LLM - const serializedWorld = serializeObjectiveWorldState(worldState); + const { systemPrompt, userContext, components } = this.promptBuilder.build( + worldState, + intent, + ); - // 2. Build the prompts - const systemPrompt = ` -You are the World Architect, a deterministic and objective judge of reality, physics, and narration for a simulation game. -Your task is to judge whether a proposed action (Intent) by an actor is physically and logically possible given the current objective state of the world. -Exempt dialogue or speech actions from validation (consider them always valid). -Enforce logical boundaries such as: -- Spatial boundaries (an actor cannot grab an object in another location unless they are there). -- Physical boundaries (an actor cannot open a locked drawer without a key or breaking it). -- State Boundaries (an actor cannot perform a task if their state doesn't allow them to do so). -- State/Attribute constraints. - -You must respond with a JSON object containing: -- "isValid": boolean indicating if the action is possible/allowed. -- "reason": a concise explanation of why the action is allowed or denied. -`.trim(); - - const objectiveContent = hydrateObjective(intent.content, worldState); - - const userContext = ` -=== CURRENT WORLD STATE === -Current Time: ${worldState.clock.get().toISOString()} -Entities & Attributes: -${serializedWorld} - -=== PROPOSED ACTION === -Actor ID: ${intent.actorId} -Type: ${intent.type} -Content: "${objectiveContent}" -Target IDs: ${intent.targetIds.join(", ") || "(None)"} - -Decide if the proposed action is logically valid and physically possible. -`.trim(); + this.lastResult = { + systemPrompt, + userContext, + components, + }; // structured call via the LLM provider const response = await this.llmProvider.generateStructuredResponse({ diff --git a/packages/intent/src/intent-prompt-builder.ts b/packages/intent/src/intent-prompt-builder.ts index 972d12d..316edb7 100644 --- a/packages/intent/src/intent-prompt-builder.ts +++ b/packages/intent/src/intent-prompt-builder.ts @@ -1,14 +1,14 @@ import { WorldState, resolveAlias } from "@omnia/core"; -import { PromptBreakdown, PromptComponent } from "@omnia/llm"; +import { PromptBreakdown, PromptComponent, IPromptBuilder } from "@omnia/llm"; import { Intent } from "./intent.js"; -// TODO: Builder a generic interface for prompt builders in @omnia/llm: IPromptBuilder or something - /** * Prompt builder for the Intent Decoder. * Separates prompt generation, structure, and component breakdowns. */ -export class IntentDecoderPromptBuilder { +export class IntentDecoderPromptBuilder implements IPromptBuilder< + [WorldState, string, string, Intent[]] +> { build( worldState: WorldState, actorId: string, diff --git a/packages/memory/src/handoff-prompt-builder.ts b/packages/memory/src/handoff-prompt-builder.ts index 879dd16..3a019bc 100644 --- a/packages/memory/src/handoff-prompt-builder.ts +++ b/packages/memory/src/handoff-prompt-builder.ts @@ -1,12 +1,14 @@ import { Entity } from "@omnia/core"; -import { PromptBreakdown, PromptComponent } from "@omnia/llm"; +import { PromptBreakdown, PromptComponent, IPromptBuilder } from "@omnia/llm"; import { BufferEntry, serializeSubjectiveBufferEntry } from "./buffer.js"; /** * Prompt builder for the Handoff Engine. * Separates prompt generation, structure, and component breakdowns. */ -export class HandoffPromptBuilder { +export class HandoffPromptBuilder implements IPromptBuilder< + [Entity, BufferEntry[], Date] +> { build(entity: Entity, candidates: BufferEntry[], now: Date): PromptBreakdown { const candidatesList = candidates .map((entry) => {