LLM layer

simvx.ai reaches a language model without stalling a frame. Game behaviour stays ordinary Python: there is no AI contract to implement. The package adds a client, a test cache, in-game loops, and tools that let an agent play or extend a game. It is the only SimVX package that uses asyncio, and nothing on the frame path awaits.

Client

OpenAICompatibleClient talks to any OpenAI-compatible /chat/completions endpoint: a local vLLM or llama.cpp server, OpenWebUI, or a hosted API.

from simvx.ai import OpenAICompatibleClient

client = OpenAICompatibleClient.from_env()   # SIMVX_LLM_BASE_URL, SIMVX_LLM_API_KEY, SIMVX_LLM_MODEL
resp = await client.complete([{"role": "user", "content": "Hello"}])
print(resp.text)

base_url runs up to, not including, /chat/completions, such as http://localhost:8000/v1. Any subclass of LLMClient works wherever a client is taken.

In-game loops

A model call takes far longer than a frame, so Bark and Planner run it on a worker thread. poll(dt, **facts) returns the last good answer at once, starts a new call when period seconds have passed and none is in flight, and keeps the previous answer when a call fails. The facts the model sees are exactly the keyword arguments to poll.

from simvx.ai import Bark

bark = Bark(client, persona="a weary night watchman", period=6.0)

def on_update(self, dt):
    self.line = bark.poll(dt, hp=self.hp)

Loop

Answer

Bark(client, persona=...)

One short in-character line.

Planner(client, schema=..., validate=None)

A dict matching schema; validate returns the plan to keep or None to reject it.

Call close() when the owner leaves the tree. Both are built on simvx.core.BackgroundSlot, which runs any slow callable off the frame thread and is polled the same way.

Reproducible tests

CachingClient(client, cache_dir, mode=...) records each response as JSON and replays it, so a test needs no network and no model.

Mode

Behaviour

auto (default)

Replay when a fixture exists, else call through and record.

replay

Replay only; a miss raises LLMCacheMiss. Use in CI.

record

Always call through and re-record.

off

Pass straight through.

Agents and generated nodes

  • AgentSession(root) wraps a scene in a headless, steppable session an agent observes and drives through the real input path. run_agent(session, client, goal=...) loops observe, decide, act and step until the model stops or max_turns runs out.

  • session.watch(path, signal) records every emit of one node signal without changing the game, and ends when it is closed or its node is destroyed. Like the act verbs, a request it cannot serve returns {"ok": False, "message": ...}; asking again for a watched signal returns the open watch. Each StepResult lists the emits since the last one under emissions, with their arguments as JSON data taken when the signal fired, and a script error the tree raised appears there as its script_error signal. session.step(600, until=watch) stops on the frame the signal fires, or comes back truncated. An attached session does not drive the clock, so there wait_frames(600, until=watch) waits. The watch is a context manager, and a recording that watched signals crystallises into a test that asserts the same emits on the same frames, and the same arguments where they were plain values. The model sees the same thing through the watch tool and the until argument of step and wait_frames; observe kind=node lists a node’s signals and their types.

  • generate_sync(client, description) asks a model for a Node subclass and checks the source before anything runs it; executing it is opt-in with allow_execution=True.

These two are newer than the client and the loops, and may still change.

Examples