mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
feat: Implemented Delta generator for time advancement
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
import { WorldState } from "@omnia/core";
|
||||
import { WorldState, SQLiteRepository } from "@omnia/core";
|
||||
import { LLMValidator, ValidationResult } from "./llm-validator.js";
|
||||
import { TimeDeltaGenerator, TimeDelta } from "./delta.js";
|
||||
import { ILLMProvider } from "@omnia/llm";
|
||||
|
||||
export interface ProcessResult extends ValidationResult {
|
||||
timeDelta?: TimeDelta;
|
||||
}
|
||||
|
||||
export class Architect {
|
||||
private validator: LLMValidator;
|
||||
private timeDeltaGenerator: TimeDeltaGenerator;
|
||||
|
||||
constructor(llmProvider: ILLMProvider) {
|
||||
constructor(llmProvider: ILLMProvider, private repo?: SQLiteRepository) {
|
||||
this.validator = new LLMValidator(llmProvider);
|
||||
this.timeDeltaGenerator = new TimeDeltaGenerator(llmProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,4 +27,41 @@ export class Architect {
|
||||
): Promise<ValidationResult> {
|
||||
return this.validator.validate(worldState, actorId, actionIntent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes, validates, generates deltas, applies them to the world state,
|
||||
* and persists the changes to the database.
|
||||
*/
|
||||
async processIntent(
|
||||
worldState: WorldState,
|
||||
actorId: string,
|
||||
actionIntent: string,
|
||||
): Promise<ProcessResult> {
|
||||
// 1. Validate the intent action
|
||||
const validation = await this.validateIntent(worldState, actorId, actionIntent);
|
||||
if (!validation.isValid) {
|
||||
return validation;
|
||||
}
|
||||
|
||||
// 2. Generate time delta for the valid action
|
||||
const timeDelta = await this.timeDeltaGenerator.generate(
|
||||
worldState,
|
||||
actorId,
|
||||
actionIntent,
|
||||
);
|
||||
|
||||
// 3. Apply the time delta to the world state clock
|
||||
worldState.clock.advance(timeDelta.minutesToAdvance);
|
||||
|
||||
// 4. Save and persist the updated world state
|
||||
if (this.repo) {
|
||||
this.repo.saveWorldState(worldState);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
reason: validation.reason,
|
||||
timeDelta,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { z } from "zod";
|
||||
import { WorldState } from "@omnia/core";
|
||||
import { ILLMProvider } from "@omnia/llm";
|
||||
|
||||
export const TimeDeltaSchema = z.object({
|
||||
minutesToAdvance: z.number().int().nonnegative(),
|
||||
explanation: z.string(),
|
||||
});
|
||||
|
||||
export type TimeDelta = z.infer<typeof TimeDeltaSchema>;
|
||||
|
||||
export interface IDeltaGenerator<T> {
|
||||
generate(
|
||||
worldState: WorldState,
|
||||
actorId: string,
|
||||
actionIntent: string,
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
export class TimeDeltaGenerator implements IDeltaGenerator<TimeDelta> {
|
||||
constructor(private llmProvider: ILLMProvider) {}
|
||||
|
||||
async generate(
|
||||
worldState: WorldState,
|
||||
actorId: string,
|
||||
actionIntent: string,
|
||||
): Promise<TimeDelta> {
|
||||
const systemPrompt = `
|
||||
You are the Time Delta Generator for the World Architect.
|
||||
Your task is to judge how much time (in minutes) a proposed action would logically take to execute in the physical world.
|
||||
Enforce realistic physical constraints:
|
||||
- Simple actions (e.g. picking up an unlocked key, looking around) take 1-2 minutes.
|
||||
- Walking between rooms takes 2-5 minutes.
|
||||
- Complex tasks (e.g. searching a room thoroughly, reading a book chapter, picking a lock) take 15-60 minutes.
|
||||
Return a structured JSON object containing:
|
||||
- "minutesToAdvance": integer (0 or more) representing the time elapsed.
|
||||
- "explanation": a brief explanation of why this amount of time is appropriate.
|
||||
`.trim();
|
||||
|
||||
const userContext = `
|
||||
=== CURRENT WORLD STATE ===
|
||||
Current Time: ${worldState.clock.get().toISOString()}
|
||||
World Details:
|
||||
${worldState.serialize()}
|
||||
|
||||
=== ACTION ===
|
||||
Actor ID: ${actorId}
|
||||
Action: "${actionIntent}"
|
||||
`.trim();
|
||||
|
||||
const response = await this.llmProvider.generateStructuredResponse({
|
||||
systemPrompt,
|
||||
userContext,
|
||||
schema: TimeDeltaSchema,
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(`Failed to generate time delta: ${response.error || "Unknown LLM error"}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./llm-validator.js";
|
||||
export * from "./architect.js";
|
||||
export * from "./delta.js";
|
||||
|
||||
@@ -39,6 +39,7 @@ 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:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { WorldState } from "@omnia/core";
|
||||
import { Entity } from "@omnia/core";
|
||||
import Database from "better-sqlite3";
|
||||
import { WorldState, Entity, SQLiteRepository } from "@omnia/core";
|
||||
import { MockLLMProvider } from "@omnia/llm";
|
||||
import { Architect } from "@omnia/architect";
|
||||
|
||||
@@ -71,3 +71,83 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", () => {
|
||||
test("processIntent advances clock and saves state for a valid intent", async () => {
|
||||
const db = new Database(":memory:");
|
||||
const repo = new SQLiteRepository(db);
|
||||
|
||||
const world = new WorldState("world-xyz", new Date("2026-07-06T12:00:00.000Z"));
|
||||
const alice = new Entity("alice");
|
||||
world.addEntity(alice);
|
||||
|
||||
// Initial save so it exists in db
|
||||
repo.saveWorldState(world);
|
||||
|
||||
// Setup mock LLM responses:
|
||||
// First call: validateIntent (ValidationResult)
|
||||
// Second call: TimeDeltaGenerator (TimeDelta)
|
||||
const mockValidation = { isValid: true, reason: "Alice has the lockpick kit and skill." };
|
||||
const mockTimeDelta = { minutesToAdvance: 20, explanation: "Picking a lock takes time." };
|
||||
const llmProvider = new MockLLMProvider([mockValidation, mockTimeDelta]);
|
||||
|
||||
const architect = new Architect(llmProvider, repo);
|
||||
|
||||
const result = await architect.processIntent(
|
||||
world,
|
||||
"alice",
|
||||
"pick the lock of the wooden chest",
|
||||
);
|
||||
|
||||
// Assert results
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.reason).toBe("Alice has the lockpick kit and skill.");
|
||||
expect(result.timeDelta).toBeDefined();
|
||||
expect(result.timeDelta!.minutesToAdvance).toBe(20);
|
||||
|
||||
// Verify clock was advanced locally
|
||||
const expectedTime = new Date(new Date("2026-07-06T12:00:00.000Z").getTime() + 20 * 60_000);
|
||||
expect(world.clock.get().toISOString()).toBe(expectedTime.toISOString());
|
||||
|
||||
// Verify it was persisted to the database
|
||||
const loaded = repo.loadWorldState("world-xyz")!;
|
||||
expect(loaded.clock.get().toISOString()).toBe(expectedTime.toISOString());
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("processIntent does not advance clock or save state for an invalid intent", async () => {
|
||||
const db = new Database(":memory:");
|
||||
const repo = new SQLiteRepository(db);
|
||||
|
||||
const startTime = new Date("2026-07-06T12:00:00.000Z");
|
||||
const world = new WorldState("world-xyz", startTime);
|
||||
const bob = new Entity("bob");
|
||||
world.addEntity(bob);
|
||||
repo.saveWorldState(world);
|
||||
|
||||
const mockValidation = { isValid: false, reason: "Bob is bound by chains." };
|
||||
const llmProvider = new MockLLMProvider([mockValidation]); // TimeDeltaGenerator shouldn't be called
|
||||
|
||||
const architect = new Architect(llmProvider, repo);
|
||||
|
||||
const result = await architect.processIntent(
|
||||
world,
|
||||
"bob",
|
||||
"run away",
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.reason).toBe("Bob is bound by chains.");
|
||||
expect(result.timeDelta).toBeUndefined();
|
||||
|
||||
// Verify clock did not advance
|
||||
expect(world.clock.get().toISOString()).toBe(startTime.toISOString());
|
||||
|
||||
// Verify database value remained unchanged
|
||||
const loaded = repo.loadWorldState("world-xyz")!;
|
||||
expect(loaded.clock.get().toISOString()).toBe(startTime.toISOString());
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { AttributableObject } from "./attribute.js";
|
||||
|
||||
export class Entity extends AttributableObject {
|
||||
constructor(id?: string) {
|
||||
locationId: string | null = null;
|
||||
|
||||
constructor(id?: string, locationId?: string | null) {
|
||||
super(id);
|
||||
this.locationId = locationId ?? null;
|
||||
}
|
||||
|
||||
override serialize(): string {
|
||||
const lines: string[] = [];
|
||||
if (this.locationId) {
|
||||
lines.push(`* Location ID: ${this.locationId}`);
|
||||
}
|
||||
const selfSerialized = super.serialize();
|
||||
if (selfSerialized) {
|
||||
lines.push(selfSerialized);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export class SQLiteRepository {
|
||||
type TEXT NOT NULL,
|
||||
world_id TEXT,
|
||||
clock_iso TEXT,
|
||||
location_id TEXT,
|
||||
FOREIGN KEY (world_id) REFERENCES objects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
@@ -48,6 +49,13 @@ export class SQLiteRepository {
|
||||
} catch {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Safely add location_id column if it does not exist in an existing database
|
||||
try {
|
||||
this.db.exec("ALTER TABLE objects ADD COLUMN location_id TEXT;");
|
||||
} catch {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
}
|
||||
|
||||
save(obj: AttributableObject, type: string, worldId?: string): void {
|
||||
@@ -57,19 +65,25 @@ export class SQLiteRepository {
|
||||
clockIso = obj.clock.get().toISOString();
|
||||
}
|
||||
|
||||
let locationId: string | null = null;
|
||||
if (obj instanceof Entity) {
|
||||
locationId = obj.locationId;
|
||||
}
|
||||
|
||||
// 1. Insert or ignore the object in the objects table
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO objects (id, type, world_id, clock_iso)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO objects (id, type, world_id, clock_iso, location_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
world_id = excluded.world_id,
|
||||
clock_iso = excluded.clock_iso
|
||||
clock_iso = excluded.clock_iso,
|
||||
location_id = excluded.location_id
|
||||
`,
|
||||
)
|
||||
.run(obj.id, type, worldId || null, clockIso);
|
||||
.run(obj.id, type, worldId || null, clockIso, locationId);
|
||||
|
||||
// Get current attributes from db to delete the ones that are no longer present
|
||||
const existingAttrs = this.db
|
||||
@@ -155,16 +169,16 @@ export class SQLiteRepository {
|
||||
const objRow = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT type FROM objects WHERE id = ?
|
||||
SELECT type, location_id FROM objects WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.get(id) as { type: string } | undefined;
|
||||
.get(id) as { type: string; location_id: string | null } | undefined;
|
||||
|
||||
if (!objRow || objRow.type !== "entity") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entity = new Entity(id);
|
||||
const entity = new Entity(id, objRow.location_id);
|
||||
this.reconstituteAttributes(entity);
|
||||
return entity;
|
||||
}
|
||||
@@ -190,13 +204,13 @@ export class SQLiteRepository {
|
||||
const entityRows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id FROM objects WHERE type = 'entity' AND world_id = ?
|
||||
SELECT id, location_id FROM objects WHERE type = 'entity' AND world_id = ?
|
||||
`,
|
||||
)
|
||||
.all(id) as { id: string }[];
|
||||
.all(id) as { id: string; location_id: string | null }[];
|
||||
|
||||
for (const row of entityRows) {
|
||||
const entity = new Entity(row.id);
|
||||
const entity = new Entity(row.id, row.location_id);
|
||||
this.reconstituteAttributes(entity);
|
||||
worldState.addEntity(entity);
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("SQLiteRepository Unit Tests (Tier 1)", () => {
|
||||
world.addAttribute("location", "Outer Rim", AttributeVisibility.PUBLIC);
|
||||
|
||||
// 2. Setup Entities and add attributes with ACL grants
|
||||
const alice = new Entity("alice");
|
||||
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"]));
|
||||
@@ -156,6 +156,8 @@ describe("SQLiteRepository Unit Tests (Tier 1)", () => {
|
||||
expect(loadedBob).toBeDefined();
|
||||
|
||||
// 7. Assert entity attributes and access controls are preserved
|
||||
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);
|
||||
|
||||
@@ -184,7 +186,7 @@ describe("Serializer Unit Tests (Tier 1)", () => {
|
||||
const world = new WorldState("world-1");
|
||||
world.addAttribute("gravity", "9.8", AttributeVisibility.PUBLIC);
|
||||
|
||||
const alice = new Entity("alice");
|
||||
const alice = new Entity("alice", "location-a");
|
||||
alice.addAttribute("role", "warrior", AttributeVisibility.PUBLIC);
|
||||
world.addEntity(alice);
|
||||
|
||||
@@ -196,6 +198,7 @@ describe("Serializer Unit Tests (Tier 1)", () => {
|
||||
expect(result).toContain(" * gravity: 9.8 (Visibility: PUBLIC)");
|
||||
expect(result).toContain("Entities:");
|
||||
expect(result).toContain(" - Entity [ID: alice]:");
|
||||
expect(result).toContain(" * Location ID: location-a");
|
||||
expect(result).toContain(" * role: warrior (Visibility: PUBLIC)");
|
||||
expect(result).toContain(" - Entity [ID: bob]:");
|
||||
expect(result).toContain(" * (No attributes)");
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -331,7 +331,11 @@ importers:
|
||||
|
||||
packages/memory: {}
|
||||
|
||||
packages/spatial: {}
|
||||
packages/spatial:
|
||||
dependencies:
|
||||
'@omnia/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
|
||||
web/landing:
|
||||
devDependencies:
|
||||
|
||||
Reference in New Issue
Block a user