@dpid/prefrontal-ai
v0.3.0
Published
LLM-driven deliberation layer for needs-based AI agents
Maintainers
Readme
@dpid/prefrontal-ai
LLM-driven deliberation layer on top of @dpid/needs-based-ai. Needs remain the ground truth for agent behavior; the LLM modulates, inhibits, and expresses on top of that substrate rather than replacing it.
Core invariants: the tick is never blocked (LLM calls are async, results apply on a later tick), the LLM is optional (with none configured, or on timeout/failure, the agent behaves exactly like a vanilla needs-based agent), and expression never drives behavior (monologue is generated from decisions already made).
Install
npm install @dpid/prefrontal-ai @dpid/needs-based-ai @dpid/command-state-machine
# Optional: only if you use AnthropicLlmClient
npm install @anthropic-ai/sdk@anthropic-ai/sdk is an optional peer dependency. The package is fully importable without it; it is resolved lazily on the first AnthropicLlmClient call.
Quick start
PrefrontalAgent wraps a needs-based Agent without subclassing it. Its rankingFunction is the sole behavioral hook into needs-based-ai; drive its clock with update(dt).
import { AdvertisementHandler } from '@dpid/needs-based-ai';
import { PrefrontalAgent, MockLlmClient, candidateIdFor } from '@dpid/prefrontal-ai';
const prefrontal = PrefrontalAgent.create(agent, {
llmClient: MockLlmClient.create(), // swap for Cached/Anthropic; omit for vanilla behavior
});
// Hand the ranking function to needs-based-ai. It is pure and synchronous — all LLM work
// happens outside the ranking call, biasing later ticks.
const handler = AdvertisementHandler.create(
agent,
'targetFound', // transition fired when a target is reached
0, // distance weight
prefrontal.rankingFunction
);
// Each frame: advance the prefrontal clock (ticks willpower, suppression timers, the gate,
// and consumes any settled deliberation). Sits alongside your other commands.
function tick(dt: number): void {
prefrontal.update(dt);
}
// Optional feedback + drama hooks. candidateIdFor(ad) is the canonical candidate id
// (the same string used in deliberation contexts) — never hand-build it.
prefrontal.reportOutcome('food', candidateIdFor(ad), true); // feeds the plan-failure trigger
prefrontal.onMonologue.addListener((e) => render(e.text));
prefrontal.onOverrideFailed.addListener((e) => animateGivingIn(e.needId));
// Read-only inspector state for HUDs. Unlike buildContext(), polling these never
// perturbs the trend fields the next deliberation context reports.
renderBars(prefrontal.willpower, prefrontal.activeSuppressions);Events emitted: onDeliberationStarted / onDeliberationCompleted, onSuppressionStarted / onSuppressionEnded, onOverrideFailed, onMonologue. UX is the game's job; the library only emits.
LlmClient options
All clients implement the same LlmClient interface (deliberate + express) and are interchangeable in config.llmClient.
| Client | Factory | Use |
| --- | --- | --- |
| NullLlmClient | NullLlmClient.create() | Inert. Agent behaves exactly like vanilla needs-based-ai. The default when no client is configured. |
| MockLlmClient | MockLlmClient.create(results?, options?) | FIFO queue of scripted DeliberationResults + records received contexts. For tests and offline sims. |
| CachedLlmClient | CachedLlmClient.create(inner, quantizer?, options?) | Decorator over any client. Memoizes deliberation decisions by a quantized context, coalesces concurrent identical calls into one inner call, and caps concurrency. Never caches express(). |
| AnthropicLlmClient | AnthropicLlmClient.create(config) | Live constrained deliberation over @anthropic-ai/sdk via forced tool use. budget.tier picks the model (fast → Haiku-class, escalated → Sonnet-class). |
| WebLlmClient | WebLlmClient.create(config?) | Local, in-browser deliberation on the player's GPU over @mlc-ai/web-llm (WebGPU). No API key, no per-token cost, no network after the one-time model download. See below. |
CachedLlmClient is the many-agent scaling win: wrap one inner client and share the single decorator across every agent so identical situations reuse one decision. Its stats getter exposes { hits, misses, coalesced, evictions }. Options: ttlTicks (TTL against ctx.tick), maxEntries (LRU bound), maxConcurrent (inner-call ceiling with a FIFO overflow queue).
import { CachedLlmClient, AnthropicLlmClient } from '@dpid/prefrontal-ai';
const shared = CachedLlmClient.create(AnthropicLlmClient.create({ apiKey: process.env.ANTHROPIC_API_KEY }));
// pass `shared` as config.llmClient for every agent, then read shared.statsBrowser usage caveat
AnthropicLlmClient.create({ dangerouslyAllowBrowser: true }) maps to the SDK's browser-access flag. Direct browser calls expose your API key to anyone who can read the page — acceptable for local dev only. For anything shared, front the API with a thin server-side proxy and point baseURL at it, keeping the key server-side.
WebLLM (local, in-browser)
WebLlmClient runs deliberation on the player's own GPU via @mlc-ai/web-llm (WebGPU): no API key, no per-token cost, and no network traffic after the one-time model download. @mlc-ai/web-llm is an optional peer dependency — the package is fully importable without it, and it is resolved lazily on the first live call.
import { CachedLlmClient, WebLlmClient } from '@dpid/prefrontal-ai';
// No WebGPU (Firefox/Safari today, older Chrome) → skip the download; agents run vanilla.
if (!WebLlmClient.isSupported()) return runVanilla();
const web = WebLlmClient.create({
model: 'Llama-3.2-3B-Instruct-q4f16_1-MLC', // the default
initProgressCallback: (r) => renderProgress(r.progress, r.text), // 0..1 + status text
});
// The model is a multi-hundred-MB–GB one-time download — gate this behind a user action
// (a "Load model" button), not page load. Resolves once ready, rejects if init fails.
await web.preload();
// One on-GPU engine generates serially, so cap the shared cache at a single inner call.
const llmClient = CachedLlmClient.create(web, undefined, { maxConcurrent: 1 });
// Pass `llmClient` as config.llmClient for every agent.deliberate()/express() never await the (minutes-long) init: until state === 'ready' they reject immediately and the agent behaves exactly like vanilla needs-based-ai (the Deliberator's cooldown keeps that from thrashing). Drive a loading screen with preload() + initProgressCallback; deliberations begin flowing once it resolves.
Models (ids from prebuiltAppConfig.model_list; sizes are approximate WebGPU VRAM / download):
| Model id | Size | Notes |
| --- | --- | --- |
| Llama-3.2-1B-Instruct-q4f16_1-MLC | ~0.9 GB | Low-VRAM; weakest schema adherence. |
| Llama-3.2-3B-Instruct-q4f16_1-MLC | ~2.2 GB | Default. Good balance of size and decision quality. |
| Llama-3.1-8B-Instruct-q4f16_1-MLC | ~5.0 GB | Best decisions; needs a capable GPU. |
Run inference in a Worker. Main-thread inference janks the game loop. Create a CreateWebWorkerMLCEngine and hand it in via the engine escape hatch (an injected engine starts ready, so preload() becomes a no-op):
import { CreateWebWorkerMLCEngine } from '@mlc-ai/web-llm';
const engine = await CreateWebWorkerMLCEngine(
new Worker(new URL('./mlc.worker.ts', import.meta.url), { type: 'module' }),
'Llama-3.2-3B-Instruct-q4f16_1-MLC',
{ initProgressCallback }
);
const web = WebLlmClient.create({ engine }); // inference stays off the main threadBudget. Local 3B inference is seconds, not milliseconds — set budget.timeoutMs in the 3000–8000ms range (vs the API-client default). An optional escalatedModel spins up a second engine for the escalated tier; leave it unset (the default) to serve both tiers from one engine and avoid doubling VRAM + download.
Browser support. Chrome and Edge ship WebGPU. Safari and Firefox coverage is partial/behind flags; isSupported() + a vanilla fallback is the mitigation. See npm run example:webllm for a full page (progress bar, ?mock offline fallback, unsupported-browser banner).
Examples
Two runnable variants of the same 20-agent simulation. Both run offline with a scripted MockLlmClient by default; set ANTHROPIC_API_KEY to swap in the live AnthropicLlmClient (fast tier) through the identical code path.
npm run example:hidden # behavior only; prints per-tick decisions + final cache hit-rate
npm run example:monologue # same sim; renders the inner voice + suppression/override drama
npm run example:webllm # browser page: same sim over WebGPU; add ?mock to run offlineThe WebLLM page serves at http://localhost:5173. Load /?mock to watch scripted deliberations with zero download (fully offline), or / to download the model and deliberate on your GPU (Chrome/Edge; a "Load model" button gates the ~2GB fetch).
Scripts
npm run build # Build with tsup (ESM + CJS + d.ts)
npm run typecheck # Type check library and examples with tsc
npm test # Run tests with vitest
npm run test:watch # Run tests in watch mode