feat: Implement actor prompt builder

This commit is contained in:
2026-07-09 01:40:38 +05:30
parent 15abfdd898
commit fa698619b3
15 changed files with 755 additions and 5 deletions

107
docs/actor.md Normal file
View File

@@ -0,0 +1,107 @@
# Actor Agent Architecture
The Actor Agent is the system component that embodies a single entity and produces narrative prose describing what that entity does, says, or thinks next. It is the "inner voice" of an NPC (or player character), generating behavior proposals that are then validated and executed by the rest of the engine.
## Design Principles
1. **Epistemic boundedness** — The actor only sees what its entity would perceive: public attributes of other entities, private attributes explicitly ACL'd to it, its own memory buffer, and co-located entities. It does not have system-level access to all world state.
2. **Proposal, not mutation** — The actor generates a _proposal_ (narrative prose). It never mutates world state, persists to the database, or writes to memory directly. Validation, execution, and persistence are the Architect's job. This cleanly separates the creative generation layer from the deterministic enforcement layer.
3. **Free prose → structured intents** — The actor outputs free natural-language prose. This is then fed to the existing `IntentDecoder`, which splits and classifies it into a sequence of typed intents. This reuses the entire decode pipeline unchanged and keeps the actor unconstrained.
## Prompt Structure
The actor prompt is assembled by `ActorPromptBuilder` (`packages/actor/src/actor-prompt-builder.ts`) and has two parts:
### System Prompt
Establishes the role, rules, and output contract:
- The LLM **is** the character, not a narrator or system.
- The character may produce three kinds of behavior, each of which maps to an intent type:
- **Spoken dialogue** → `dialogue` intent. Other entities perceive it.
- **Physical/logical action** → `action` intent. Subject to the World Architect's validation.
- **Inner thought / reflection** → `monologue` intent. Purely internal — no one else perceives it, and it bypasses validation entirely (written straight to memory).
- The character must stay in-character, respect its knowledge bounds, and refer to others by subjective aliases (not system UUIDs).
- Not every turn requires an outward action — internal monologue alone is valid.
- The character controls only itself.
### User Context
Epistemically bounded, with these sections:
| Section | Content | Source |
| ---------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Current moment | The subjective present time | `worldState.clock.get().toISOString()` |
| The world as you perceive it | Self-visible attributes, co-located entities + their visible attributes, other presences elsewhere | `serializeSubjectiveWorldState()` (`packages/core/src/world.ts:72`) |
| Your recent memory | Recent `BufferEntry`s, alias-substituted, with `naturalizeTime`-relative phrasing | `serializeSubjectiveBufferEntry()` + `BufferRepository.listForOwner()` |
No system UUIDs, no private attributes the entity lacks ACL access to, and no objective-world-state dump are present.
## The Monologue Intent Type
Monologue (`"monologue"`) is the third intent type alongside `dialogue` and `action`. Its properties:
- **No perceiver** — `targetIds` is always `[]`. No other entity perceives or can react to a monologue.
- **No validation** — The Architect's `processIntent` short-circuits for monologues: no call to `LLMValidator`, no `TimeDeltaGenerator`, no clock advance, no world-state mutation (`packages/architect/src/architect.ts:35`).
- **Direct-to-memory** — The caller writes a `BufferEntry` for the monologue directly to the actor's buffer with no `outcome` field. Monologue bypasses the entire validation/persistence pipeline.
- **Defensive guard** — `LLMValidator.validate` also has an early-return guard (`llm-validator.ts:19`) so a stray monologue can never reach the validation LLM.
## Flow
```
[ActorAgent.act()]
├─ 1. ActorPromptBuilder.build(entity, worldState)
│ → system prompt + user context (subjective world + memory + time)
├─ 2. ILLMProvider.generateStructuredResponse({ schema: ActorResponseSchema })
│ → { narrativeProse: string }
├─ 3. IntentDecoder.decode(worldState, actorId, prose)
│ → IntentSequence (dialogue | action | monologue intents)
└─ returns { narrativeProse, intents }
[Caller (e.g. game loop)]
├─ for each intent in intents:
│ │
│ ├─ if intent.type === "monologue":
│ │ ├─ Architect.processIntent → short-circuit (no-op, 0-min delta)
│ │ └─ BufferRepository.save(BufferEntry { intent, no outcome })
│ │
│ ├─ if intent.type === "dialogue":
│ │ ├─ Architect.processIntent → validates (always valid), 0-min delta
│ │ └─ BufferRepository.save(BufferEntry { intent, no outcome })
│ │
│ └─ if intent.type === "action":
│ ├─ Architect.processIntent → validates, generates time delta, advances clock
│ └─ BufferRepository.save(BufferEntry { intent, outcome })
└─ world state persisted to DB (by Architect for actions; monologue/dialogue skip this)
```
## Key Files
| File | Role |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `packages/actor/src/actor-prompt-builder.ts` | Assembles the epistemically-bounded actor prompt (system + user context) |
| `packages/actor/src/actor.ts` | `ActorAgent` class: orchestrates prompt → LLM → decoder flow; `buildBufferEntryForIntent` helper |
| `packages/actor/src/index.ts` | Package exports |
| `packages/core/src/world.ts:72` | `serializeSubjectiveWorldState()` — viewer-relative world serializer |
| `packages/intent/src/intent.ts:8` | `IntentTypeSchema` — includes `"monologue"` |
| `packages/intent/src/intent-decoder.ts:30` | Decoder system prompt — classifies inner thoughts as monologue |
| `packages/architect/src/architect.ts:35` | Monologue short-circuit in `processIntent` |
| `packages/architect/src/llm-validator.ts:19` | Defensive monologue guard |
## Integration Test
`tests/integration/actor-monologue.test.ts` covers the full flow:
- Actor produces prose containing a monologue, dialogue, and action.
- Decoder splits into 3 intents.
- Architect only validates dialogue+action; clock advances by the action's delta only.
- All 3 intents written to memory; monologue entry has no `outcome`.
- Subjective world serializer hides another entity's private attributes.

View File

@@ -0,0 +1,16 @@
{
"name": "@omnia/actor",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./dist/index.js"
},
"dependencies": {
"@omnia/core": "workspace:*",
"@omnia/intent": "workspace:*",
"@omnia/llm": "workspace:*",
"@omnia/memory": "workspace:*",
"zod": "^4.4.3"
}
}

View File

@@ -0,0 +1,129 @@
import { z } from "zod";
import {
Entity,
WorldState,
naturalizeTime,
serializeSubjectiveWorldState,
} from "@omnia/core";
import {
BufferEntry,
BufferRepository,
serializeSubjectiveBufferEntry,
} from "@omnia/memory";
/**
* Zod schema for the structured response expected from the actor LLM.
*
* The actor emits free narrative prose describing what it does next. This
* prose is subsequently fed into the IntentDecoder, which splits and
* classifies it into dialogue / action / monologue intents. Keeping the
* actor's output as prose (rather than a structured intent sequence) lets
* us reuse the entire existing decode pipeline unchanged.
*/
export const ActorResponseSchema = z.object({
narrativeProse: z.string(),
});
export type ActorResponse = z.infer<typeof ActorResponseSchema>;
/**
* Builds the LLM prompt for an entity to act immersively in the world.
*
* The prompt is strictly epistemically bounded: the entity only sees what
* it is allowed to see (public attributes + private attributes explicitly
* ACL'd to it), its own recent memory buffer, and the entities co-located
* with it. System UUIDs are surfaced as subjective aliases.
*/
export class ActorPromptBuilder {
/**
* @param bufferRepo Used to fetch the actor's recent memory. Optional —
* if absent, the memory section is omitted.
* @param memoryLimit Maximum number of recent buffer entries to inject.
* Defaults to 20.
*/
constructor(
private bufferRepo?: BufferRepository,
private memoryLimit = 20,
) {}
/**
* Assembles the system prompt and user context for a given entity.
*/
build(
worldState: WorldState,
entity: Entity,
): { systemPrompt: string; userContext: string } {
const systemPrompt = this.buildSystemPrompt(entity);
const userContext = this.buildUserContext(worldState, entity);
return { systemPrompt, userContext };
}
private buildSystemPrompt(entity: Entity): string {
return `
You are an actor agent embodying a single character in a narrative simulation. You ARE this character — act immersively, naturally, and in-character at all times. Do not break character, do not reference being an AI or a system, and do not narrate from outside the character's perspective.
Your output is a short block of narrative prose describing what your character does, says, or thinks next. You may:
- Speak aloud → this becomes a "dialogue" intent. Other entities can hear it.
- Perform a physical or logical action → this becomes an "action" intent. It is subject to the world's physics and will be validated by the World Architect.
- Think internally / reflect / feel → this becomes a "monologue" intent. NO ONE else perceives it. It bypasses all validation and is written straight to your private memory. Use this for inner thoughts, doubts, plans, and feelings that you would not voice aloud.
Guidelines:
- Stay strictly within what your character knows. If an attribute, entity, or fact is not present in your context below, your character does not know it — do not invent it or act on it.
- Refer to other entities by the subjective names/aliases given in your context, never by raw system IDs.
- Keep your prose vivid but concise. A single response may contain more than one intent (e.g., you may think, then speak, then act) — write them in natural narrative order.
- Not every response requires an outward action. It is perfectly valid to only think (a monologue) and do nothing perceivable.
- Never speak or act on another entity's behalf — you only control your own character.
Your character's identifier in this world is "${entity.id}".
`.trim();
}
private buildUserContext(
worldState: WorldState,
entity: Entity,
): string {
const sections: string[] = [];
// --- Subjective present time ---
const now = worldState.clock.get();
sections.push(
`=== CURRENT MOMENT ===\nIt is ${now.toISOString()} right now.`,
);
// --- Subjective world state (self + perceived entities + co-location) ---
sections.push(
`=== THE WORLD AS YOU PERCEIVE IT ===\n${serializeSubjectiveWorldState(worldState, entity.id)}`,
);
// --- Recent memory ---
const memorySection = this.buildMemorySection(entity, worldState.clock.get());
if (memorySection) {
sections.push(memorySection);
}
return sections.join("\n\n");
}
private buildMemorySection(entity: Entity, now: Date): string | null {
if (!this.bufferRepo) return null;
let entries: BufferEntry[];
try {
entries = this.bufferRepo.listForOwner(entity.id);
} catch {
return null;
}
if (entries.length === 0) {
return `=== YOUR RECENT MEMORY ===\n(You have no memories yet.)`;
}
const recent = entries.slice(-this.memoryLimit);
const lines = recent.map((entry) => {
const serialized = serializeSubjectiveBufferEntry(entry, entity);
const when = naturalizeTime(now, new Date(entry.timestamp));
return `${serialized} (${when})`;
});
return `=== YOUR RECENT MEMORY ===\n${lines.join("\n")}`;
}
}

109
packages/actor/src/actor.ts Normal file
View File

@@ -0,0 +1,109 @@
import { Entity, WorldState } from "@omnia/core";
import { ILLMProvider } from "@omnia/llm";
import {
BufferEntry,
BufferRepository,
} from "@omnia/memory";
import {
Intent,
IntentDecoder,
IntentSequence,
} from "@omnia/intent";
import { ActorPromptBuilder, ActorResponse, ActorResponseSchema } from "./actor-prompt-builder.js";
/**
* Result of a single actor turn.
*/
export interface ActorTurnResult {
/** The raw narrative prose the actor produced. */
narrativeProse: string;
/** The decoded intent sequence (split/classified from the prose). */
intents: IntentSequence;
}
/**
* The Actor Agent: embodies a single entity and generates its next beat of
* behavior as narrative prose, then decodes that prose into a structured
* intent sequence via the IntentDecoder.
*
* The actor itself does NOT mutate world state or write memory — that is
* the responsibility of the caller (who routes intents through the
* Architect and writes buffer entries). The actor only produces the
* proposal. This keeps the actor's role cleanly separated from
* validation and persistence.
*/
export class ActorAgent {
private promptBuilder: ActorPromptBuilder;
private decoder: IntentDecoder;
constructor(
private llmProvider: ILLMProvider,
bufferRepo?: BufferRepository,
memoryLimit?: number,
) {
this.promptBuilder = new ActorPromptBuilder(bufferRepo, memoryLimit);
this.decoder = new IntentDecoder(llmProvider);
}
/**
* Has the entity produce its next beat of behavior.
*
* 1. Builds an epistemically-bounded prompt for the entity.
* 2. Asks the LLM for narrative prose.
* 3. Decodes the prose into a structured IntentSequence.
*/
async act(
worldState: WorldState,
entity: Entity,
): Promise<ActorTurnResult> {
const { systemPrompt, userContext } = this.promptBuilder.build(
worldState,
entity,
);
const response = await this.llmProvider.generateStructuredResponse({
systemPrompt,
userContext,
schema: ActorResponseSchema,
});
if (!response.success || !response.data) {
throw new Error(
`Actor generation failed for entity "${entity.id}": ${response.error || "Unknown LLM error"}`,
);
}
const prose: ActorResponse = response.data;
const intents = await this.decoder.decode(
worldState,
entity.id,
prose.narrativeProse,
);
return {
narrativeProse: prose.narrativeProse,
intents,
};
}
}
/**
* Helper: builds a BufferEntry for an intent produced on behalf of an
* entity. For "action" intents the caller should attach an `outcome`
* after the Architect has processed it; for "dialogue" and "monologue"
* intents no outcome is needed (dialogue is always valid; monologue
* bypasses validation entirely).
*/
export function buildBufferEntryForIntent(
intent: Intent,
timestamp: string,
locationId: string | null,
): BufferEntry {
return {
id: crypto.randomUUID(),
ownerId: intent.actorId,
timestamp,
locationId,
intent,
};
}

View File

@@ -0,0 +1,2 @@
export * from "./actor-prompt-builder.js";
export * from "./actor.js";

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../core" },
{ "path": "../intent" },
{ "path": "../llm" },
{ "path": "../memory" }
]
}

View File

@@ -31,11 +31,26 @@ export class Architect {
/**
* Processes, validates, generates deltas, applies them to the world state,
* and persists the changes to the database.
*
* "monologue" intents are internal thoughts — they bypass validation and
* time-delta generation entirely: the clock does not advance, the world
* state is not mutated or persisted. The caller is responsible for writing
* the monologue to the actor's memory buffer.
*/
async processIntent(
worldState: WorldState,
intent: Intent,
): Promise<ProcessResult> {
// 0. Monologue intents are purely internal — short-circuit before any
// validation or world mutation.
if (intent.type === "monologue") {
return {
isValid: true,
reason: "Monologue intent bypasses validation (internal thought, not perceivable).",
timeDelta: { minutesToAdvance: 0, explanation: "Internal thought — no time elapsed." },
};
}
// 1. Validate the intent action
const validation = await this.validateIntent(worldState, intent);
if (!validation.isValid) {

View File

@@ -15,11 +15,23 @@ export class LLMValidator {
/**
* Validates an action intent against the objective world state.
*
* "monologue" intents must never reach this validator — they are internal
* thoughts that bypass validation entirely (see Architect.processIntent).
* This guard exists as a defensive safeguard.
*/
async validate(
worldState: WorldState,
intent: Intent,
): Promise<ValidationResult> {
// Defensive guard: monologue intents bypass validation.
if (intent.type === "monologue") {
return {
isValid: true,
reason: "Monologue intents are internal thoughts and bypass validation.",
};
}
const actor = worldState.getEntity(intent.actorId);
if (!actor) {
return {

View File

@@ -1,4 +1,4 @@
import { AttributableObject, serializeAttributes } from "./attribute.js";
import { AttributableObject, Attribute, serializeAttributes } from "./attribute.js";
import { Entity } from "./entity.js";
import { WorldClock } from "./clock.js";
@@ -64,3 +64,105 @@ export function serializeObjectiveWorldState(worldState: WorldState): string {
return lines.join("\n");
}
/**
* Resolves how a viewer subjectively refers to a target entity.
* - Self → "you"
* - Known (in the viewer's alias map) → the subjective alias
* - Unknown → "an unfamiliar figure"
*
* Mirrors the implementation in @omnia/memory's resolveAlias, inlined here
* to avoid a circular dependency (memory depends on core).
*/
function resolveAliasViewer(viewer: Entity, targetId: string): string {
if (targetId === viewer.id) return "you";
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
}
/**
* Serializes a single attribute the way a viewer perceives it — name and
* value only, no visibility/ACL metadata (the viewer already sees only
* what they're allowed to see).
*/
function serializeVisibleAttributes(attrs: Attribute[]): string {
if (attrs.length === 0) return "(No perceivable attributes)";
return attrs.map((a) => `* ${a.name}: ${a.getValue()}`).join("\n");
}
/**
* Subjective world-state serializer for actor/agent prompts.
*
* Epistemic opposite of serializeObjectiveWorldState: renders the world
* strictly as it appears to a given viewer entity. Only attributes the
* viewer has access to (via Attribute.hasAccess) are shown; system UUIDs
* are replaced by subjective aliases ("you", known names, or
* "an unfamiliar figure"). Co-located entities (sharing the viewer's
* locationId) are included; entities elsewhere are listed only as
* presences (name/alias) without their attributes, since the viewer
* cannot perceive them in detail without a location model.
*/
export function serializeSubjectiveWorldState(
worldState: WorldState,
viewerId: string,
): string {
const viewer = worldState.getEntity(viewerId);
if (!viewer) {
return `(Viewer entity "${viewerId}" not found in world state.)`;
}
const lines: string[] = [];
const viewerAlias = resolveAliasViewer(viewer, viewerId);
// --- World attributes (only those the viewer can see) ---
const worldVisible = worldState.getVisibleAttributesFor(viewerId);
if (worldVisible.length > 0) {
lines.push("World (as you know it):");
lines.push(serializeVisibleAttributes(worldVisible).split("\n").map((l) => " " + l).join("\n"));
}
// --- Self ---
lines.push(`Self (${viewerAlias}):`);
const selfVisible = viewer.getVisibleAttributesFor(viewerId);
lines.push(serializeVisibleAttributes(selfVisible).split("\n").map((l) => " " + l).join("\n"));
// --- Location / perceived entities ---
lines.push("What you perceive around you:");
if (viewer.locationId) {
lines.push(` You are at location: ${viewer.locationId}`);
} else {
lines.push(" You are not located anywhere in particular.");
}
const coLocated: Entity[] = [];
const elsewhere: Entity[] = [];
for (const e of worldState.entities.values()) {
if (e.id === viewerId) continue;
if (e.locationId !== null && e.locationId === viewer.locationId) {
coLocated.push(e);
} else {
elsewhere.push(e);
}
}
if (coLocated.length > 0) {
lines.push(" Entities present with you:");
for (const e of coLocated) {
const alias = resolveAliasViewer(viewer, e.id);
lines.push(` - ${alias} (ID: ${e.id}):`);
const eVisible = e.getVisibleAttributesFor(viewerId);
lines.push(serializeVisibleAttributes(eVisible).split("\n").map((l) => " " + l).join("\n"));
}
} else {
lines.push(" You are alone here.");
}
if (elsewhere.length > 0) {
lines.push(" Other presences you are aware of (elsewhere):");
for (const e of elsewhere) {
const alias = resolveAliasViewer(viewer, e.id);
lines.push(` - ${alias} (ID: ${e.id}) [elsewhere]`);
}
}
return lines.join("\n");
}

View File

@@ -10,7 +10,7 @@ export class IntentDecoder {
*
* Responsibilities (from docs/intents.md):
* - Split prose into multiple intents when applicable.
* - Classify each intent as "dialogue" or "action".
* - Classify each intent as "dialogue", "action", or "monologue".
* - Parse narrative text into structured JSON with minimal information loss.
* - Contextually resolve receiving parties (targets).
*/
@@ -35,10 +35,11 @@ For each intent you must:
1. Classify its type:
- "dialogue": Any speech, conversation, or verbal communication directed at another entity.
- "action": Any physical or logical action performed in the world (e.g., moving, picking up, opening, looking).
- "monologue": An inner thought, reflection, or internal monologue. This is purely internal — not spoken aloud, not perceivable by any other entity, and not a physical action. Use this for any prose depicting the character thinking, reflecting, feeling, or narrating to themselves internally.
2. Extract the original text fragment from the prose that corresponds to this intent.
3. Write a concise, structured description of the intent (what is being done or said). Include as much detail about the action as possible that was extracted from the narrative prose. Do not make up qualities.
4. Identify the actorId (the entity performing the intent — this will always be "${actorId}").
5. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" and "ACTOR ALIASES" mapping to resolve any subjective names, descriptions, or nicknames used in the prose to their correct system entity IDs. If no specific target, use an empty array.
5. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" and "ACTOR ALIASES" mapping to resolve any subjective names, descriptions, or nicknames used in the prose to their correct system entity IDs. If no specific target, use an empty array. For "monologue" intents, targetIds must always be an empty array.
Rules:
- Preserve the chronological order of intents as they appear in the prose.

View File

@@ -4,8 +4,11 @@ import { z } from "zod";
* Intent types as classified by the Intent Decoder.
* - "dialogue": Speech or conversation directed at another entity.
* - "action": A physical or logical action performed in the world.
* - "monologue": An inner thought or internal monologue. Not perceivable by
* any other entity. Bypasses the Architect/validators entirely and is
* written directly to the actor's memory buffer with no outcome.
*/
export const IntentTypeSchema = z.enum(["dialogue", "action"]);
export const IntentTypeSchema = z.enum(["dialogue", "action", "monologue"]);
export type IntentType = z.infer<typeof IntentTypeSchema>;
/**
@@ -26,7 +29,8 @@ export const IntentSchema = z.object({
/**
* Entity IDs of the receiving parties (e.g., who is being spoken to,
* what object is being interacted with).
* what object is being interacted with). Always an empty array for
* "monologue" intents, since they are not perceivable by anyone.
*/
targetIds: z.array(z.string()),
});

18
pnpm-lock.yaml generated
View File

@@ -297,6 +297,24 @@ importers:
specifier: ^5
version: 5.9.3
packages/actor:
dependencies:
'@omnia/core':
specifier: workspace:*
version: link:../core
'@omnia/intent':
specifier: workspace:*
version: link:../intent
'@omnia/llm':
specifier: workspace:*
version: link:../llm
'@omnia/memory':
specifier: workspace:*
version: link:../memory
zod:
specifier: ^4.4.3
version: 4.4.3
packages/architect:
dependencies:
'@omnia/core':

View File

@@ -0,0 +1,219 @@
import { describe, test, expect } from "vitest";
import Database from "better-sqlite3";
import {
WorldState,
Entity,
SQLiteRepository,
AttributeVisibility,
} from "@omnia/core";
import { MockLLMProvider } from "@omnia/llm";
import { IntentSequence } from "@omnia/intent";
import { Architect } from "@omnia/architect";
import {
BufferRepository,
BufferEntry,
} from "@omnia/memory";
import {
ActorAgent,
ActorResponseSchema,
buildBufferEntryForIntent,
} from "@omnia/actor";
describe("Actor Agent + Monologue Intent Integration (Tier 2)", () => {
test("actor produces prose → decoder splits into dialogue/action/monologue → architect bypasses monologue", async () => {
const db = new Database(":memory:");
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const startTime = new Date("2026-07-09T12:00:00.000Z");
const world = new WorldState("world-actor", startTime);
world.addAttribute("location", "Tavern Cellar", AttributeVisibility.PUBLIC);
const alice = new Entity("alice", "cellar-1");
alice.addAttribute("name", "Alice", AttributeVisibility.PUBLIC);
alice.addAttribute("role", "rogue", AttributeVisibility.PUBLIC);
// A private, self-visible attribute (explicitly ACL'd to self).
alice.addAttribute(
"secret_goal",
"Steal the ledger without being noticed.",
AttributeVisibility.PRIVATE,
new Set(["alice"]),
);
world.addEntity(alice);
const bob = new Entity("bob", "cellar-1");
bob.addAttribute("name", "Bob", AttributeVisibility.PUBLIC);
bob.addAttribute("role", "guard", AttributeVisibility.PUBLIC);
world.addEntity(bob);
// Alice knows Bob by name.
alice.aliases.set("bob", "Bob");
coreRepo.saveWorldState(world);
// --- Mock LLM response queue ---
// 1. Actor produces prose containing a thought, a spoken line, and an action.
const mockActorProse = { narrativeProse: "I can't believe Bob hasn't noticed me yet, Alice thought. \"Hey Bob,\" she called out softly. She reached for the ledger on the table." };
// 2. IntentDecoder splits that prose into 3 intents.
const mockDecodedSequence: IntentSequence = {
intents: [
{
type: "monologue",
originalText: "I can't believe Bob hasn't noticed me yet, Alice thought.",
description: "Alice internally reflects that Bob has not noticed her.",
actorId: "alice",
targetIds: [],
},
{
type: "dialogue",
originalText: '"Hey Bob," she called out softly.',
description: "Alice softly calls out to Bob.",
actorId: "alice",
targetIds: ["bob"],
},
{
type: "action",
originalText: "She reached for the ledger on the table.",
description: "Alice reaches for the ledger on the table.",
actorId: "alice",
targetIds: [],
},
],
};
// 3. Architect: dialogue is always valid (0 min), action is valid (2 min).
// NOTE: monologue never reaches the validator/delta generator.
const mockDialogueValidation = { isValid: true, reason: "Alice can speak." };
const mockDialogueTimeDelta = { minutesToAdvance: 0, explanation: "Speech is instantaneous." };
const mockActionValidation = { isValid: true, reason: "The ledger is within reach." };
const mockActionTimeDelta = { minutesToAdvance: 2, explanation: "Reaching for the ledger takes 2 minutes." };
const llmProvider = new MockLLMProvider([
mockActorProse, // 1. Actor generation
mockDecodedSequence, // 2. IntentDecoder
mockDialogueValidation, // 3. Architect.validateIntent (dialogue)
mockDialogueTimeDelta, // 4. TimeDeltaGenerator (dialogue)
mockActionValidation, // 5. Architect.validateIntent (action)
mockActionTimeDelta, // 6. TimeDeltaGenerator (action)
]);
const actor = new ActorAgent(llmProvider, bufferRepo);
const architect = new Architect(llmProvider, coreRepo);
// 1. Actor acts
const turn = await actor.act(world, alice);
expect(turn.narrativeProse).toBe(mockActorProse.narrativeProse);
expect(turn.intents.intents).toHaveLength(3);
expect(turn.intents.intents[0].type).toBe("monologue");
expect(turn.intents.intents[1].type).toBe("dialogue");
expect(turn.intents.intents[2].type).toBe("action");
const intents = turn.intents.intents;
const writtenEntries: BufferEntry[] = [];
// 2. Process each intent through the Architect and write memory.
for (const intent of intents) {
const result = await architect.processIntent(world, intent);
const entry = buildBufferEntryForIntent(
intent,
world.clock.get().toISOString(),
alice.locationId,
);
// For action intents, attach the validation outcome.
if (intent.type === "action") {
entry.outcome = { isValid: result.isValid, reason: result.reason };
}
bufferRepo.save(entry);
writtenEntries.push(entry);
}
// 3. Monologue bypassed validation: clock did NOT advance for it,
// and no outcome was attached to its buffer entry.
expect(intents[0].type).toBe("monologue");
expect(writtenEntries[0].outcome).toBeUndefined();
// 4. Dialogue: valid, 0-minute delta, no outcome field.
expect(writtenEntries[1].outcome).toBeUndefined();
// 5. Action: valid, 2-minute delta, outcome attached.
expect(writtenEntries[2].outcome).toEqual({
isValid: true,
reason: "The ledger is within reach.",
});
// 6. Clock advanced by exactly 2 minutes (dialogue 0 + action 2).
const expectedTime = new Date(startTime.getTime() + 2 * 60_000);
expect(world.clock.get().toISOString()).toBe(expectedTime.toISOString());
// 7. All three intents persisted to Alice's memory buffer.
const aliceMemory = bufferRepo.listForOwner("alice");
expect(aliceMemory).toHaveLength(3);
expect(aliceMemory[0].intent.type).toBe("monologue");
expect(aliceMemory[1].intent.type).toBe("dialogue");
expect(aliceMemory[2].intent.type).toBe("action");
// 8. Monologue entry has no outcome; action entry does.
expect(aliceMemory[0].outcome).toBeUndefined();
expect(aliceMemory[2].outcome).toBeDefined();
expect(aliceMemory[2].outcome!.isValid).toBe(true);
// 9. Monologue did NOT touch persisted world clock (only the action did).
const reloaded = coreRepo.loadWorldState("world-actor")!;
expect(reloaded.clock.get().toISOString()).toBe(expectedTime.toISOString());
db.close();
});
test("ActorResponseSchema validates prose output shape", () => {
const valid = { narrativeProse: "Alice thought quietly." };
expect(ActorResponseSchema.parse(valid)).toEqual(valid);
expect(() => ActorResponseSchema.parse({})).toThrow();
expect(() =>
ActorResponseSchema.parse({ narrativeProse: 123 }),
).toThrow();
});
test("serializeSubjectiveWorldState is epistemically bounded", async () => {
const { serializeSubjectiveWorldState } = await import("@omnia/core");
const world = new WorldState("world-subj");
const alice = new Entity("alice", "room-1");
alice.addAttribute("name", "Alice", AttributeVisibility.PUBLIC);
alice.addAttribute(
"secret",
"hidden truth",
AttributeVisibility.PRIVATE,
new Set(["alice"]),
);
world.addEntity(alice);
const bob = new Entity("bob", "room-1");
bob.addAttribute("name", "Bob", AttributeVisibility.PUBLIC);
bob.addAttribute(
"bob_secret",
"bob's hidden truth",
AttributeVisibility.PRIVATE,
new Set(["bob"]),
);
world.addEntity(bob);
const view = serializeSubjectiveWorldState(world, "alice");
// Alice sees her own secret (explicitly ACL'd).
expect(view).toContain("secret: hidden truth");
// Alice sees Bob's public name.
expect(view).toContain("name: Bob");
// Alice does NOT see Bob's private attribute.
expect(view).not.toContain("bob's hidden truth");
// Alice perceives herself as "you".
expect(view).toContain("Self (you)");
// Bob is an unfamiliar figure (no alias set).
expect(view).toContain("an unfamiliar figure");
});
});

View File

@@ -10,6 +10,7 @@
{ "path": "./packages/memory" },
{ "path": "./packages/spatial" },
{ "path": "./packages/llm" },
{ "path": "./packages/actor" },
{ "path": "./cli" }
]
}

View File

@@ -14,6 +14,7 @@ export default defineConfig({
"@omnia/intent": path.resolve(__dirname, "./packages/intent/src"),
"@omnia/memory": path.resolve(__dirname, "./packages/memory/src"),
"@omnia/spatial": path.resolve(__dirname, "./packages/spatial/src"),
"@omnia/actor": path.resolve(__dirname, "./packages/actor/src"),
"@omnia/cli": path.resolve(__dirname, "./cli/src"),
},
},