diff --git a/apps/gui/src/lib/simulation-types.ts b/apps/gui/src/lib/simulation-types.ts index f67e264..c4ee4bd 100644 --- a/apps/gui/src/lib/simulation-types.ts +++ b/apps/gui/src/lib/simulation-types.ts @@ -46,6 +46,7 @@ export interface EntityInfo { id: string; name: string; isPlayer: boolean; + isAgent: boolean; } export interface WaitingContext { diff --git a/apps/gui/src/lib/simulation.ts b/apps/gui/src/lib/simulation.ts index 38cf80a..6980e50 100644 --- a/apps/gui/src/lib/simulation.ts +++ b/apps/gui/src/lib/simulation.ts @@ -17,7 +17,12 @@ for (const c of envCandidates) { } } -import { BufferRepository, LedgerRepository, HandoffEngine, checkHandoffTrigger } from "@omnia/memory"; +import { + BufferRepository, + LedgerRepository, + HandoffEngine, + checkHandoffTrigger, +} from "@omnia/memory"; import { Architect, AliasDeltaGenerator } from "@omnia/architect"; import { ActorAgent, @@ -25,7 +30,17 @@ import { IActorProseGenerator, buildBufferEntryForIntent, } from "@omnia/actor"; -import { GeminiProvider, ILLMProvider, MockLLMProvider, ProviderManager, OpenRouterProvider, IEmbeddingProvider, GeminiEmbeddingProvider, MockEmbeddingProvider, ModelProviderInstance } from "@omnia/llm"; +import { + GeminiProvider, + ILLMProvider, + MockLLMProvider, + ProviderManager, + OpenRouterProvider, + IEmbeddingProvider, + GeminiEmbeddingProvider, + MockEmbeddingProvider, + ModelProviderInstance, +} from "@omnia/llm"; import { ScenarioLoader } from "@omnia/scenario"; import type { @@ -69,15 +84,22 @@ interface SavedState { providerMappings: Record; } -function loadSessionState(db: Database.Database, id: string): SavedState | null { +function loadSessionState( + db: Database.Database, + id: string, +): SavedState | null { try { - db.prepare(` + db.prepare( + ` CREATE TABLE IF NOT EXISTS gui_meta ( id TEXT PRIMARY KEY, state_json TEXT ) - `).run(); - const row = db.prepare(`SELECT state_json FROM gui_meta WHERE id = ?`).get(id) as { state_json: string } | undefined; + `, + ).run(); + const row = db + .prepare(`SELECT state_json FROM gui_meta WHERE id = ?`) + .get(id) as { state_json: string } | undefined; return row ? (JSON.parse(row.state_json) as SavedState) : null; } catch { return null; @@ -129,7 +151,13 @@ class SimulationManager { if (!activeInstance) { const envKey = process.env.GOOGLE_API_KEY; if (envKey) { - activeInstance = ProviderManager.create("Default (Env)", "google-genai", envKey, undefined, "generative"); + activeInstance = ProviderManager.create( + "Default (Env)", + "google-genai", + envKey, + undefined, + "generative", + ); } } if (!activeInstance) { @@ -143,7 +171,8 @@ class SimulationManager { entities: [], log: [], entityIndex: 0, - error: "No active LLM Provider Instance found. Please configure a key in Settings first.", + error: + "No active LLM Provider Instance found. Please configure a key in Settings first.", }; } @@ -184,6 +213,7 @@ class SimulationManager { id: e.id, name: (e.attributes.get("name")?.getValue() as string) || e.id, isPlayer: false, + isAgent: e.isAgent, })); let playerEntityId: string | undefined; @@ -192,8 +222,7 @@ class SimulationManager { if (!matched) { for (const ent of rawEntities) { const nameAttr = ent.attributes.get("name")?.getValue() as - | string - | undefined; + string | undefined; if (nameAttr?.toLowerCase() === playEntityName.toLowerCase()) { matched = ent; break; @@ -203,8 +232,7 @@ class SimulationManager { if (!matched) { for (const ent of rawEntities) { const nameAttr = ent.attributes.get("name")?.getValue() as - | string - | undefined; + string | undefined; if ( nameAttr?.toLowerCase().includes(playEntityName.toLowerCase()) || ent.id.toLowerCase().includes(playEntityName.toLowerCase()) @@ -232,7 +260,7 @@ class SimulationManager { inst = active; } - const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || ""); + const key = inst ? inst.apiKey : process.env.GOOGLE_API_KEY || ""; const providerName = inst ? inst.providerName : "google-genai"; const modelName = inst ? inst.modelName : undefined; const instanceName = inst ? inst.name : undefined; @@ -254,7 +282,7 @@ class SimulationManager { inst = ProviderManager.getActive("embedding"); } - const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || ""); + const key = inst ? inst.apiKey : process.env.GOOGLE_API_KEY || ""; const providerName = inst ? inst.providerName : "google-genai"; const modelName = inst ? inst.modelName : undefined; @@ -340,6 +368,12 @@ class SimulationManager { const info = session.entities[session.entityIndex]; + if (!info.isAgent) { + session.entityIndex++; + this.save(session); + return this.snapshot(session); + } + if (info.isPlayer) { await this.preparePlayerTurn(session, info); this.save(session); @@ -402,8 +436,14 @@ class SimulationManager { }, }; - if (session.decoderProvider.lastCalls && session.decoderProvider.lastCalls.length > 0) { - const call = session.decoderProvider.lastCalls[session.decoderProvider.lastCalls.length - 1]; + if ( + session.decoderProvider.lastCalls && + session.decoderProvider.lastCalls.length > 0 + ) { + const call = + session.decoderProvider.lastCalls[ + session.decoderProvider.lastCalls.length - 1 + ]; entry.decoderPrompt = { systemPrompt: call.systemPrompt, userContext: call.userContext, @@ -487,15 +527,17 @@ class SimulationManager { session: SimSession, info: EntityInfo, ): Promise { - const worldState = session.coreRepo.loadWorldState( - session.worldInstanceId, - ); + const worldState = session.coreRepo.loadWorldState(session.worldInstanceId); if (!worldState) throw new Error("World state lost"); const entity = worldState.getEntity(info.id); if (!entity) throw new Error(`Entity "${info.id}" not found`); - const promptBuilder = new ActorPromptBuilder(session.bufferRepo, session.ledgerRepo, 20); + const promptBuilder = new ActorPromptBuilder( + session.bufferRepo, + session.ledgerRepo, + 20, + ); const { systemPrompt, userContext } = promptBuilder.build( worldState, entity, @@ -514,9 +556,7 @@ class SimulationManager { session: SimSession, info: EntityInfo, ): Promise { - const worldState = session.coreRepo.loadWorldState( - session.worldInstanceId, - ); + const worldState = session.coreRepo.loadWorldState(session.worldInstanceId); if (!worldState) throw new Error("World state lost"); const entity = worldState.getEntity(info.id); @@ -539,8 +579,14 @@ class SimulationManager { timestamp: worldState.clock.get().toISOString(), }; - if (session.actorProvider.lastCalls && session.actorProvider.lastCalls.length > 0) { - const actorCall = session.actorProvider.lastCalls[session.actorProvider.lastCalls.length - 1]; + if ( + session.actorProvider.lastCalls && + session.actorProvider.lastCalls.length > 0 + ) { + const actorCall = + session.actorProvider.lastCalls[ + session.actorProvider.lastCalls.length - 1 + ]; entry.rawPrompt = { systemPrompt: actorCall.systemPrompt, userContext: actorCall.userContext, @@ -548,8 +594,14 @@ class SimulationManager { entry.usage = actorCall.usage; } - if (session.decoderProvider.lastCalls && session.decoderProvider.lastCalls.length > 0) { - const decoderCall = session.decoderProvider.lastCalls[session.decoderProvider.lastCalls.length - 1]; + if ( + session.decoderProvider.lastCalls && + session.decoderProvider.lastCalls.length > 0 + ) { + const decoderCall = + session.decoderProvider.lastCalls[ + session.decoderProvider.lastCalls.length - 1 + ]; entry.decoderPrompt = { systemPrompt: decoderCall.systemPrompt, userContext: decoderCall.userContext, @@ -558,10 +610,7 @@ class SimulationManager { } for (const intent of result.intents.intents) { - const outcome = await session.architect.processIntent( - worldState, - intent, - ); + const outcome = await session.architect.processIntent(worldState, intent); const ts = worldState.clock.get().toISOString(); entry.intents.push({ @@ -593,10 +642,7 @@ class SimulationManager { (intent.type === "dialogue" || intent.type === "action") ) { for (const [, other] of worldState.entities) { - if ( - other.id !== info.id && - other.locationId === entity.locationId - ) { + if (other.id !== info.id && other.locationId === entity.locationId) { const observerEntry = buildBufferEntryForIntent( intent, ts, @@ -619,9 +665,7 @@ class SimulationManager { } private async runHandoffResolution(session: SimSession): Promise { - const worldState = session.coreRepo.loadWorldState( - session.worldInstanceId, - ); + const worldState = session.coreRepo.loadWorldState(session.worldInstanceId); if (!worldState) throw new Error("World state lost"); const handoffEngine = new HandoffEngine( @@ -633,24 +677,36 @@ class SimulationManager { const entities = Array.from(worldState.entities.values()); for (const entity of entities) { + if (!entity.isAgent) continue; const bufferEntries = session.bufferRepo.listForOwner(entity.id); - const maxContext = session.handoffProvider.maxContext !== undefined ? session.handoffProvider.maxContext : 32768; + const maxContext = + session.handoffProvider.maxContext !== undefined + ? session.handoffProvider.maxContext + : 32768; - const trigger = checkHandoffTrigger(entity, bufferEntries, worldState.clock.get(), maxContext); + const trigger = checkHandoffTrigger( + entity, + bufferEntries, + worldState.clock.get(), + maxContext, + ); if (trigger !== "none") { - await handoffEngine.runHandoff(entity, bufferEntries, worldState.clock.get()); + await handoffEngine.runHandoff( + entity, + bufferEntries, + worldState.clock.get(), + ); } } } private async runAliasResolution(session: SimSession): Promise { - const worldState = session.coreRepo.loadWorldState( - session.worldInstanceId, - ); + const worldState = session.coreRepo.loadWorldState(session.worldInstanceId); if (!worldState) throw new Error("World state lost"); const entities = Array.from(worldState.entities.values()); for (const viewer of entities) { + if (!viewer.isAgent) continue; if (!viewer.locationId) continue; for (const target of entities) { if (viewer.id === target.id) continue; @@ -723,18 +779,36 @@ class SimulationManager { if (!inst) { const envKey = process.env.GOOGLE_API_KEY; if (envKey) { - inst = ProviderManager.create("Default (Env)", "google-genai", envKey, undefined, "generative"); + inst = ProviderManager.create( + "Default (Env)", + "google-genai", + envKey, + undefined, + "generative", + ); } } if (!inst) { - throw new Error(`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`); + throw new Error( + `No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`, + ); } if (inst.providerName === "google-genai") { - return new GeminiProvider(inst.apiKey, inst.modelName, inst.name, inst.maxContext); + return new GeminiProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ); } else if (inst.providerName === "openrouter") { - return new OpenRouterProvider(inst.apiKey, inst.modelName, inst.name, inst.maxContext); + return new OpenRouterProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ); } else { return new MockLLMProvider([]); } @@ -749,12 +823,20 @@ class SimulationManager { if (!inst) { const envKey = process.env.GOOGLE_API_KEY; if (envKey) { - inst = ProviderManager.create("Default Embed (Env)", "google-genai", envKey, "gemini-embedding-001", "embedding"); + inst = ProviderManager.create( + "Default Embed (Env)", + "google-genai", + envKey, + "gemini-embedding-001", + "embedding", + ); } } if (!inst) { - throw new Error(`No active Embedding Provider Instance found for task "embeddings". Please configure an embedding key in Settings first.`); + throw new Error( + `No active Embedding Provider Instance found for task "embeddings". Please configure an embedding key in Settings first.`, + ); } if (inst.providerName === "google-genai") { @@ -824,7 +906,9 @@ class SimulationManager { if (!fs.existsSync(dbDir)) return []; const snapshots: SimSnapshot[] = []; - const files = fs.readdirSync(dbDir).filter(f => f.startsWith("sim-") && f.endsWith(".db")); + const files = fs + .readdirSync(dbDir) + .filter((f) => f.startsWith("sim-") && f.endsWith(".db")); for (const file of files) { const id = file.replace(".db", ""); @@ -872,15 +956,19 @@ class SimulationManager { const dbDir = path.resolve(process.cwd(), "data"); if (!fs.existsSync(dbDir)) return; - const files = fs.readdirSync(dbDir).filter(f => f.startsWith("sim-") && f.endsWith(".db")); + const files = fs + .readdirSync(dbDir) + .filter((f) => f.startsWith("sim-") && f.endsWith(".db")); const list = ProviderManager.list(); - let inst = newProviderInstanceId ? list.find((p) => p.id === newProviderInstanceId) : null; + let inst = newProviderInstanceId + ? list.find((p) => p.id === newProviderInstanceId) + : null; if (!inst || inst.type !== "embedding") { inst = ProviderManager.getActive("embedding"); } - const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || ""); + const key = inst ? inst.apiKey : process.env.GOOGLE_API_KEY || ""; const providerName = inst ? inst.providerName : "google-genai"; const modelName = inst ? inst.modelName : undefined; @@ -898,12 +986,16 @@ class SimulationManager { const db = activeSession ? activeSession.db : new Database(dbPath); try { - const rows = db.prepare(`SELECT id, content FROM ledger_entries`).all() as { id: string; content: string }[]; - + const rows = db + .prepare(`SELECT id, content FROM ledger_entries`) + .all() as { id: string; content: string }[]; + for (const row of rows) { const vector = await embeddingProvider.embed(row.content); const buffer = Buffer.from(new Float32Array(vector).buffer); - db.prepare(`UPDATE ledger_entries SET embedding = ? WHERE id = ?`).run(buffer, row.id); + db.prepare( + `UPDATE ledger_entries SET embedding = ? WHERE id = ?`, + ).run(buffer, row.id); } } catch (err) { console.error(`Failed to regenerate embeddings for ${file}:`, err); @@ -932,18 +1024,26 @@ class SimulationManager { providerMappings: session.providerMappings, }; - session.db.prepare(` + session.db + .prepare( + ` CREATE TABLE IF NOT EXISTS gui_meta ( id TEXT PRIMARY KEY, state_json TEXT ) - `).run(); + `, + ) + .run(); - session.db.prepare(` + session.db + .prepare( + ` INSERT INTO gui_meta (id, state_json) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json - `).run(session.worldInstanceId, JSON.stringify(state)); + `, + ) + .run(session.worldInstanceId, JSON.stringify(state)); } getSnapshot(id: string): SimSnapshot | null { diff --git a/packages/actor/src/actor.ts b/packages/actor/src/actor.ts index 6f1e3da..a7528b7 100644 --- a/packages/actor/src/actor.ts +++ b/packages/actor/src/actor.ts @@ -1,23 +1,22 @@ import { Entity, WorldState } from "@omnia/core"; import { ILLMProvider } from "@omnia/llm"; +import { BufferEntry, BufferRepository, LedgerRepository } from "@omnia/memory"; +import { Intent, IntentDecoder, IntentSequence } from "@omnia/intent"; import { - BufferEntry, - BufferRepository, - LedgerRepository, -} from "@omnia/memory"; -import { - Intent, - IntentDecoder, - IntentSequence, -} from "@omnia/intent"; -import { ActorPromptBuilder, ActorResponseSchema } from "./actor-prompt-builder.js"; + ActorPromptBuilder, + ActorResponseSchema, +} from "./actor-prompt-builder.js"; /** * Interface to generate narrative prose for an actor. * Allows switching between LLM generators and human CLI inputs. */ export interface IActorProseGenerator { - generate(entityId: string, systemPrompt: string, userContext: string): Promise; + generate( + entityId: string, + systemPrompt: string, + userContext: string, + ): Promise; } /** @@ -26,7 +25,11 @@ export interface IActorProseGenerator { export class LLMActorProseGenerator implements IActorProseGenerator { constructor(private llmProvider: ILLMProvider) {} - async generate(entityId: string, systemPrompt: string, userContext: string): Promise { + async generate( + entityId: string, + systemPrompt: string, + userContext: string, + ): Promise { const response = await this.llmProvider.generateStructuredResponse({ systemPrompt, userContext, @@ -89,7 +92,11 @@ export class ActorAgent { decoderProv = llmProvider; } - this.promptBuilder = new ActorPromptBuilder(bufferRepo, ledgerRepo, memoryLimit); + this.promptBuilder = new ActorPromptBuilder( + bufferRepo, + ledgerRepo, + memoryLimit, + ); this.decoder = new IntentDecoder(decoderProv); this.generator = generator ?? new LLMActorProseGenerator(actorProv); this.llmProvider = actorProv; @@ -102,10 +109,13 @@ export class ActorAgent { * 2. Asks the generator (LLM or human) for narrative prose. * 3. Decodes the prose into a structured IntentSequence. */ - async act( - worldState: WorldState, - entity: Entity, - ): Promise { + async act(worldState: WorldState, entity: Entity): Promise { + if (!entity.isAgent) { + throw new Error( + `Entity "${entity.id}" is not an agent and cannot use the actor interface.`, + ); + } + const { systemPrompt, userContext } = this.promptBuilder.build( worldState, entity, diff --git a/packages/actor/tests/actor.test.ts b/packages/actor/tests/actor.test.ts new file mode 100644 index 0000000..87cc08b --- /dev/null +++ b/packages/actor/tests/actor.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { WorldState, Entity } from "@omnia/core"; +import { ActorAgent } from "../src/actor.js"; +import { MockLLMProvider } from "@omnia/llm"; + +describe("ActorAgent Unit Tests", () => { + it("should throw an error if trying to act with a non-agent entity", async () => { + const world = new WorldState("world-123"); + const nonAgentEntity = new Entity("stone", null, false); // isAgent = false + world.addEntity(nonAgentEntity); + + const mockLlm = new MockLLMProvider([]); + const actor = new ActorAgent(mockLlm); + + await expect(actor.act(world, nonAgentEntity)).rejects.toThrow( + 'Entity "stone" is not an agent and cannot use the actor interface.', + ); + }); +}); diff --git a/packages/core/src/entity.ts b/packages/core/src/entity.ts index 17f4193..037ce3c 100644 --- a/packages/core/src/entity.ts +++ b/packages/core/src/entity.ts @@ -3,9 +3,11 @@ import { AttributableObject } from "./attribute.js"; export class Entity extends AttributableObject { locationId: string | null = null; readonly aliases: Map = new Map(); + isAgent: boolean = true; - constructor(id?: string, locationId?: string | null) { + constructor(id?: string, locationId?: string | null, isAgent?: boolean) { super(id); this.locationId = locationId ?? null; + this.isAgent = isAgent ?? true; } } diff --git a/packages/core/src/repository.ts b/packages/core/src/repository.ts index cfc1cc7..1dd49fd 100644 --- a/packages/core/src/repository.ts +++ b/packages/core/src/repository.ts @@ -73,6 +73,13 @@ export class SQLiteRepository { } catch { // Column already exists, ignore error } + + // Safely add is_agent column if it does not exist in an existing database + try { + this.db.exec("ALTER TABLE objects ADD COLUMN is_agent INTEGER;"); + } catch { + // Column already exists, ignore error + } } save(obj: AttributableObject, type: string, worldId?: string): void { @@ -85,15 +92,20 @@ export class SQLiteRepository { let locationId: string | null = null; let aliasesJson: string | null = null; let connectionsJson: string | null = null; + let isAgent: number | null = null; if (obj instanceof Entity) { locationId = obj.locationId; aliasesJson = JSON.stringify(Array.from(obj.aliases.entries())); + isAgent = obj.isAgent ? 1 : 0; } // Check if it's a location (using duck typing to avoid circular import of Location) if (type === "location") { - const loc = obj as { parentId?: string | null; connections?: unknown[] }; + const loc = obj as { + parentId?: string | null; + connections?: unknown[]; + }; locationId = loc.parentId ?? null; if (loc.connections) { connectionsJson = JSON.stringify(loc.connections); @@ -104,18 +116,28 @@ export class SQLiteRepository { this.db .prepare( ` - INSERT INTO objects (id, type, world_id, clock_iso, location_id, aliases_json, connections_json) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO objects (id, type, world_id, clock_iso, location_id, aliases_json, connections_json, is_agent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET type = excluded.type, world_id = excluded.world_id, clock_iso = excluded.clock_iso, location_id = excluded.location_id, aliases_json = excluded.aliases_json, - connections_json = excluded.connections_json + connections_json = excluded.connections_json, + is_agent = excluded.is_agent `, ) - .run(obj.id, type, worldId || null, clockIso, locationId, aliasesJson, connectionsJson); + .run( + obj.id, + type, + worldId || null, + clockIso, + locationId, + aliasesJson, + connectionsJson, + isAgent, + ); // Get current attributes from db to delete the ones that are no longer present const existingAttrs = this.db @@ -208,16 +230,27 @@ export class SQLiteRepository { const objRow = this.db .prepare( ` - SELECT type, location_id, aliases_json FROM objects WHERE id = ? + SELECT type, location_id, aliases_json, is_agent FROM objects WHERE id = ? `, ) - .get(id) as { type: string; location_id: string | null; aliases_json: string | null } | undefined; + .get(id) as + | { + type: string; + location_id: string | null; + aliases_json: string | null; + is_agent: number | null; + } + | undefined; if (!objRow || objRow.type !== "entity") { return null; } - const entity = new Entity(id, objRow.location_id); + const entity = new Entity( + id, + objRow.location_id, + objRow.is_agent !== null ? objRow.is_agent === 1 : true, + ); if (objRow.aliases_json) { const entries = JSON.parse(objRow.aliases_json) as [string, string][]; for (const [k, v] of entries) { @@ -238,7 +271,13 @@ export class SQLiteRepository { SELECT type, location_id, connections_json FROM objects WHERE id = ? `, ) - .get(id) as { type: string; location_id: string | null; connections_json: string | null } | undefined; + .get(id) as + | { + type: string; + location_id: string | null; + connections_json: string | null; + } + | undefined; if (!objRow || objRow.type !== "location") { return null; @@ -246,7 +285,9 @@ export class SQLiteRepository { const location = factory(id, objRow.location_id); if (objRow.connections_json) { - (location as { connections?: unknown[] }).connections = JSON.parse(objRow.connections_json); + (location as { connections?: unknown[] }).connections = JSON.parse( + objRow.connections_json, + ); } this.reconstituteAttributes(location); return location; @@ -262,13 +303,19 @@ export class SQLiteRepository { SELECT id, location_id, connections_json FROM objects WHERE type = 'location' AND world_id = ? `, ) - .all(worldId) as { id: string; location_id: string | null; connections_json: string | null }[]; + .all(worldId) as { + id: string; + location_id: string | null; + connections_json: string | null; + }[]; const locations: T[] = []; for (const row of rows) { const loc = factory(row.id, row.location_id); if (row.connections_json) { - (loc as { connections?: unknown[] }).connections = JSON.parse(row.connections_json); + (loc as { connections?: unknown[] }).connections = JSON.parse( + row.connections_json, + ); } this.reconstituteAttributes(loc); locations.push(loc); @@ -300,13 +347,19 @@ export class SQLiteRepository { SELECT id, location_id, connections_json FROM objects WHERE type = 'location' AND world_id = ? `, ) - .all(id) as { id: string; location_id: string | null; connections_json: string | null }[]; + .all(id) as { + id: string; + location_id: string | null; + connections_json: string | null; + }[]; for (const row of locationRows) { const loc = new GenericObject(row.id); (loc as { parentId?: string | null }).parentId = row.location_id; if (row.connections_json) { - (loc as { connections?: unknown[] }).connections = JSON.parse(row.connections_json); + (loc as { connections?: unknown[] }).connections = JSON.parse( + row.connections_json, + ); } this.reconstituteAttributes(loc); worldState.addLocation(loc); @@ -316,13 +369,22 @@ export class SQLiteRepository { const entityRows = this.db .prepare( ` - SELECT id, location_id, aliases_json FROM objects WHERE type = 'entity' AND world_id = ? + SELECT id, location_id, aliases_json, is_agent FROM objects WHERE type = 'entity' AND world_id = ? `, ) - .all(id) as { id: string; location_id: string | null; aliases_json: string | null }[]; + .all(id) as { + id: string; + location_id: string | null; + aliases_json: string | null; + is_agent: number | null; + }[]; for (const row of entityRows) { - const entity = new Entity(row.id, row.location_id); + const entity = new Entity( + row.id, + row.location_id, + row.is_agent !== null ? row.is_agent === 1 : true, + ); if (row.aliases_json) { const entries = JSON.parse(row.aliases_json) as [string, string][]; for (const [k, v] of entries) { @@ -340,14 +402,22 @@ export class SQLiteRepository { const rows = this.db .prepare( ` - SELECT id, aliases_json FROM objects WHERE type = 'entity' + SELECT id, aliases_json, is_agent FROM objects WHERE type = 'entity' `, ) - .all() as { id: string; aliases_json: string | null }[]; + .all() as { + id: string; + aliases_json: string | null; + is_agent: number | null; + }[]; const entities: Entity[] = []; for (const row of rows) { - const entity = new Entity(row.id); + const entity = new Entity( + row.id, + null, + row.is_agent !== null ? row.is_agent === 1 : true, + ); if (row.aliases_json) { const entries = JSON.parse(row.aliases_json) as [string, string][]; for (const [k, v] of entries) { diff --git a/packages/core/tests/core.test.ts b/packages/core/tests/core.test.ts index 887580e..2401fe6 100644 --- a/packages/core/tests/core.test.ts +++ b/packages/core/tests/core.test.ts @@ -61,16 +61,21 @@ describe("Attribute & AttributableObject Unit Tests (Tier 1)", () => { test("AttributableObject visibility filtering", () => { const actor = new MockAttributable("actor"); actor.addAttribute("eyes", "blue", AttributeVisibility.PUBLIC); - actor.addAttribute("secret", "42", AttributeVisibility.PRIVATE, new Set(["friend"])); + actor.addAttribute( + "secret", + "42", + AttributeVisibility.PRIVATE, + new Set(["friend"]), + ); // Public viewer should only see public attributes const publicAttrs = actor.getVisibleAttributesFor("stranger"); - expect(publicAttrs.map(a => a.name)).toEqual(["eyes"]); + expect(publicAttrs.map((a) => a.name)).toEqual(["eyes"]); // Authorized viewer should see both const privateAttrs = actor.getVisibleAttributesFor("friend"); - expect(privateAttrs.map(a => a.name)).toContain("eyes"); - expect(privateAttrs.map(a => a.name)).toContain("secret"); + expect(privateAttrs.map((a) => a.name)).toContain("eyes"); + expect(privateAttrs.map((a) => a.name)).toContain("secret"); }); }); @@ -131,7 +136,12 @@ describe("SQLiteRepository Unit Tests (Tier 1)", () => { const alice = new Entity("alice", "location-a"); alice.addAttribute("name", "Alice Smith", AttributeVisibility.PUBLIC); // Secret attribute visible only to 'bob' - alice.addAttribute("diaries", "Private thoughts", AttributeVisibility.PRIVATE, new Set(["bob"])); + alice.addAttribute( + "diaries", + "Private thoughts", + AttributeVisibility.PRIVATE, + new Set(["bob"]), + ); world.addEntity(alice); const bob = new Entity("bob"); @@ -161,7 +171,9 @@ describe("SQLiteRepository Unit Tests (Tier 1)", () => { expect(loadedAlice!.locationId).toBe("location-a"); expect(loadedBob!.locationId).toBeNull(); expect(loadedAlice!.attributes.get("name")?.getValue()).toBe("Alice Smith"); - expect(loadedAlice!.attributes.get("name")?.visibility).toBe(AttributeVisibility.PUBLIC); + expect(loadedAlice!.attributes.get("name")?.visibility).toBe( + AttributeVisibility.PUBLIC, + ); const diaryAttr = loadedAlice!.attributes.get("diaries")!; expect(diaryAttr.getValue()).toBe("Private thoughts"); @@ -191,17 +203,46 @@ describe("SQLiteRepository Unit Tests (Tier 1)", () => { db.close(); }); + + test("Save and load entity isAgent property", () => { + const db = new Database(":memory:"); + const repo = new SQLiteRepository(db); + + const world = new WorldState("world-xyz"); + const alice = new Entity("alice", null, false); + const bob = new Entity("bob", null, true); + world.addEntity(alice); + world.addEntity(bob); + + repo.saveWorldState(world); + + const loadedWorld = repo.loadWorldState("world-xyz")!; + const loadedAlice = loadedWorld.getEntity("alice")!; + const loadedBob = loadedWorld.getEntity("bob")!; + + expect(loadedAlice.isAgent).toBe(false); + expect(loadedBob.isAgent).toBe(true); + + db.close(); + }); }); describe("Serializer & serializeObjectiveWorldState Unit Tests (Tier 1)", () => { test("serializeAttributes utility", () => { const obj = new MockAttributable("obj-1"); obj.addAttribute("health", "100", AttributeVisibility.PUBLIC); - obj.addAttribute("secret", "key-123", AttributeVisibility.PRIVATE, new Set(["alice"])); + obj.addAttribute( + "secret", + "key-123", + AttributeVisibility.PRIVATE, + new Set(["alice"]), + ); const result = serializeAttributes(Array.from(obj.attributes.values())); expect(result).toContain("* health: 100 (Visibility: PUBLIC)"); - expect(result).toContain("* secret: key-123 (Visibility: PRIVATE) (Visible to: alice)"); + expect(result).toContain( + "* secret: key-123 (Visibility: PRIVATE) (Visible to: alice)", + ); }); test("serializeObjectiveWorldState utility", () => { diff --git a/packages/scenario/src/loader.ts b/packages/scenario/src/loader.ts index 13ad99c..ff31959 100644 --- a/packages/scenario/src/loader.ts +++ b/packages/scenario/src/loader.ts @@ -1,4 +1,9 @@ -import { WorldState, Entity, SQLiteRepository, AttributeVisibility } from "@omnia/core"; +import { + WorldState, + Entity, + SQLiteRepository, + AttributeVisibility, +} from "@omnia/core"; import { Location } from "@omnia/spatial"; import { BufferRepository } from "@omnia/memory"; import { ScenarioSchema, Scenario } from "./schema.js"; @@ -17,24 +22,40 @@ export class ScenarioLoader { * @param targetWorldId The unique ID for the running instance to create (e.g. UUID). * Allows launching multiple active runs from one scenario. */ - async initializeWorld(scenarioJson: unknown, targetWorldId: string): Promise { + async initializeWorld( + scenarioJson: unknown, + targetWorldId: string, + ): Promise { // 1. Validate scenario template schema const scenario: Scenario = ScenarioSchema.parse(scenarioJson); // 2. Instantiate running WorldState using the target instance ID const world = new WorldState(targetWorldId, new Date(scenario.startTime)); - + // Seed world-level attributes as system-only (private, empty ACL) - world.addAttribute("name", scenario.name, AttributeVisibility.PRIVATE, new Set()); - world.addAttribute("description", scenario.description, AttributeVisibility.PRIVATE, new Set()); - + world.addAttribute( + "name", + scenario.name, + AttributeVisibility.PRIVATE, + new Set(), + ); + world.addAttribute( + "description", + scenario.description, + AttributeVisibility.PRIVATE, + new Set(), + ); + if (scenario.world?.attributes) { for (const attr of scenario.world.attributes) { - const vis = attr.visibility === "PUBLIC" ? AttributeVisibility.PUBLIC : AttributeVisibility.PRIVATE; + const vis = + attr.visibility === "PUBLIC" + ? AttributeVisibility.PUBLIC + : AttributeVisibility.PRIVATE; world.addAttribute( - attr.name, - attr.value, - vis, + attr.name, + attr.value, + vis, attr.allowedEntities ? new Set(attr.allowedEntities) : null, ); } @@ -47,10 +68,13 @@ export class ScenarioLoader { if (scenario.locations) { for (const locData of scenario.locations) { const location = new Location(locData.id, locData.parentId ?? null); - + if (locData.attributes) { for (const attr of locData.attributes) { - const vis = attr.visibility === "PUBLIC" ? AttributeVisibility.PUBLIC : AttributeVisibility.PRIVATE; + const vis = + attr.visibility === "PUBLIC" + ? AttributeVisibility.PUBLIC + : AttributeVisibility.PRIVATE; location.addAttribute( attr.name, attr.value, @@ -80,12 +104,19 @@ export class ScenarioLoader { // 5. Instantiate and Persist Entities (with Aliases & Memory Buffers) if (scenario.entities) { for (const entData of scenario.entities) { - const entity = new Entity(entData.id, entData.locationId ?? null); - + const entity = new Entity( + entData.id, + entData.locationId ?? null, + entData.isAgent, + ); + // Load attributes if (entData.attributes) { for (const attr of entData.attributes) { - const vis = attr.visibility === "PUBLIC" ? AttributeVisibility.PUBLIC : AttributeVisibility.PRIVATE; + const vis = + attr.visibility === "PUBLIC" + ? AttributeVisibility.PUBLIC + : AttributeVisibility.PRIVATE; entity.addAttribute( attr.name, attr.value, diff --git a/packages/scenario/src/schema.ts b/packages/scenario/src/schema.ts index 307522a..bbfe4ec 100644 --- a/packages/scenario/src/schema.ts +++ b/packages/scenario/src/schema.ts @@ -38,10 +38,12 @@ export const ScenarioMemoryEntrySchema = z.object({ targetIds: z.array(z.string()), modifiers: z.array(z.string()).optional(), }), - outcome: z.object({ - isValid: z.boolean(), - reason: z.string(), - }).optional(), + outcome: z + .object({ + isValid: z.boolean(), + reason: z.string(), + }) + .optional(), }); export const ScenarioEntitySchema = z.object({ @@ -50,6 +52,7 @@ export const ScenarioEntitySchema = z.object({ attributes: z.array(ScenarioAttributeSchema).optional(), aliases: z.record(z.string(), z.string()).optional(), // targetId -> subjective descriptor initialMemories: z.array(ScenarioMemoryEntrySchema).optional(), + isAgent: z.boolean().optional(), }); export const ScenarioSchema = z.object({ @@ -57,9 +60,11 @@ export const ScenarioSchema = z.object({ name: z.string(), description: z.string(), startTime: z.string(), // ISO string - world: z.object({ - attributes: z.array(ScenarioAttributeSchema).optional(), - }).optional(), + world: z + .object({ + attributes: z.array(ScenarioAttributeSchema).optional(), + }) + .optional(), locations: z.array(ScenarioLocationSchema).optional(), entities: z.array(ScenarioEntitySchema).optional(), });