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

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" }
]
}