Node-Gen

generate a Node subclass from a description, validate in isolation, run it.

📄 Docs only

Tags: ai llm codegen

The LLM/AI epic capstone. A natural-language description (“a sprite that spins”) is turned into a SimVX scene module (a .py defining one Node subclass) by

class:

~simvx.ai.NodeGenerator, validated, written to disk, then loaded with the canonical :func:~simvx.core.scene_io.load_scene and run live in a window.

Security is first-class (design decision D5: generating a node executes model output, which is code-execution). The security model has two guarantees:

  • OFF BY DEFAULT: without --allow-exec the generated source is only static-validated (ast parse, no execution) and never run. You see and save the code, but nothing from the model is executed.

  • OPT-IN, VALIDATED IN ISOLATION: --allow-exec is the explicit opt-in to running model-generated code. Even then the candidate is first smoke-tested in a SUBPROCESS (never imported into this host process); only a “verified” result is loaded into this process and run live.

How to run

OFFLINE (default, no LLM, no network, no setup): a scripted fake client returns a canned Spinner module. Without --allow-exec it is static-validated and written to disk but NOT executed:

uv run python examples/features/ai/nodegen.py

Add --allow-exec to opt in: validate the candidate in an isolated subprocess, then load + run it live in a window (still offline; uses the canned module):

uv run python examples/features/ai/nodegen.py --allow-exec

LIVE: generate against any OpenAI-compatible endpoint with --live, configured via the same env vars OpenAICompatibleClient.from_env() reads:

SIMVX_LLM_BASE_URL   required, e.g. http://host:8000/v1
SIMVX_LLM_MODEL      required, the model name the endpoint serves
SIMVX_LLM_API_KEY    optional, only if your endpoint needs a key

SIMVX_LLM_BASE_URL=http://host:8000/v1 SIMVX_LLM_MODEL=your-model         uv run python examples/features/ai/nodegen.py --allow-exec --live         --describe "a label that pulses its scale"

Live runs record responses to a local cache (CACHE_DIR) so a re-run is deterministic and free. --describe sets the natural-language prompt.

Controls: Esc quits.

Source

  1"""Node-Gen: generate a Node subclass from a description, validate in isolation, run it.
  2
  3The LLM/AI epic capstone. A natural-language description ("a sprite that spins")
  4is turned into a SimVX scene module (a ``.py`` defining one ``Node`` subclass) by
  5:class:`~simvx.ai.NodeGenerator`, validated, written to disk, then loaded with the
  6canonical :func:`~simvx.core.scene_io.load_scene` and run live in a window.
  7
  8Security is first-class (design decision D5: generating a node executes model
  9output, which is code-execution). The security model has two guarantees:
 10
 11  - OFF BY DEFAULT: without ``--allow-exec`` the generated source is only
 12    static-validated (``ast`` parse, no execution) and never run. You see and
 13    save the code, but nothing from the model is executed.
 14  - OPT-IN, VALIDATED IN ISOLATION: ``--allow-exec`` is the explicit opt-in to
 15    running model-generated code. Even then the candidate is first smoke-tested
 16    in a SUBPROCESS (never imported into this host process); only a "verified"
 17    result is loaded into this process and run live.
 18
 19## How to run
 20
 21OFFLINE (default, no LLM, no network, no setup): a scripted fake client returns
 22a canned ``Spinner`` module. Without ``--allow-exec`` it is static-validated and
 23written to disk but NOT executed:
 24
 25    uv run python examples/features/ai/nodegen.py
 26
 27Add ``--allow-exec`` to opt in: validate the candidate in an isolated subprocess,
 28then load + run it live in a window (still offline; uses the canned module):
 29
 30    uv run python examples/features/ai/nodegen.py --allow-exec
 31
 32LIVE: generate against any OpenAI-compatible endpoint with ``--live``, configured
 33via the same env vars `OpenAICompatibleClient.from_env()` reads:
 34
 35    SIMVX_LLM_BASE_URL   required, e.g. http://host:8000/v1
 36    SIMVX_LLM_MODEL      required, the model name the endpoint serves
 37    SIMVX_LLM_API_KEY    optional, only if your endpoint needs a key
 38
 39    SIMVX_LLM_BASE_URL=http://host:8000/v1 SIMVX_LLM_MODEL=your-model \
 40        uv run python examples/features/ai/nodegen.py --allow-exec --live \
 41        --describe "a label that pulses its scale"
 42
 43Live runs record responses to a local cache (CACHE_DIR) so a re-run is
 44deterministic and free. ``--describe`` sets the natural-language prompt.
 45
 46Controls: Esc quits.
 47
 48# /// simvx
 49# tags = ["ai", "llm", "codegen"]
 50# web = { disabled = true }
 51# ///
 52"""
 53
 54from __future__ import annotations
 55
 56import argparse
 57import asyncio
 58import sys
 59from pathlib import Path
 60
 61from simvx.ai import CachingClient, GenerationResult, NodeGenerator, OpenAICompatibleClient
 62from simvx.ai.client import LLMClient, LLMResponse
 63from simvx.core import AnchorPreset, Input, InputMap, Key, Label, Node2D
 64from simvx.core.scene_io import load_scene
 65
 66CACHE_DIR = "/tmp/simvx_nodegen_cache"
 67OUT_DIR = Path("/tmp/simvx_nodegen_out")
 68
 69# A canned, valid Spinner module the offline fake "generates". It satisfies the
 70# whole contract: one Node2D subclass, a class-scope Property, an on_-prefixed
 71# hook, no top-level side effects, imports only simvx.core.
 72_CANNED_SPINNER = """from simvx.core import Node2D, Property
 73
 74
 75class Spinner(Node2D):
 76    speed = Property(2.0, range=(0.0, 10.0))
 77
 78    def on_update(self, dt):
 79        self.rotation += self.speed * dt
 80"""
 81
 82
 83class ScriptedNodeGenClient(LLMClient):
 84    """Offline fake: returns a canned valid Spinner module for any description.
 85
 86    Stands in for a real model so the demo runs with no network. It exercises the
 87    full generator path (static gate + optional subprocess smoke), proving the
 88    feature end to end without an endpoint.
 89    """
 90
 91    async def complete(self, messages, **kwargs) -> LLMResponse:
 92        return LLMResponse(text=_CANNED_SPINNER)
 93
 94
 95def _build_client(live: bool) -> LLMClient:
 96    if not live:
 97        return ScriptedNodeGenClient()
 98    inner = OpenAICompatibleClient.from_env()
 99    return CachingClient(inner, CACHE_DIR, mode="auto")
100
101
102async def _generate(client: LLMClient, description: str, allow_exec: bool) -> GenerationResult:
103    generator = NodeGenerator(client, smoke_frames=10)
104    return await generator.generate(description, allow_execution=allow_exec)
105
106
107class NodeGenHud(Node2D):
108    """Loads the generated scene as a child and shows its status in a HUD."""
109
110    def __init__(self, scene_path: Path, result: GenerationResult, **kwargs) -> None:
111        super().__init__(**kwargs)
112        self._scene_path = scene_path
113        self._result = result
114
115    def on_ready(self) -> None:
116        InputMap.add_action("quit", [Key.ESCAPE])
117
118        # The generated node was already smoke-validated in an isolated subprocess
119        # (status "verified"); only now do we load it into this host process.
120        generated = load_scene(self._scene_path)
121        generated.position = (640.0, 360.0)
122        self.add_child(generated)
123
124        title = self.add_child(Label(text=f"Generated + verified: {self._result.node_name}"))
125        title.set_anchor_preset(AnchorPreset.TOP_LEFT)
126        title.position = (16.0, 16.0)
127
128        sub = self.add_child(Label(text=f"loaded from {self._scene_path} -- Esc to quit"))
129        sub.set_anchor_preset(AnchorPreset.BOTTOM_LEFT)
130        sub.margin_bottom = -16.0
131        sub.position = (16.0, 0.0)
132
133    def on_update(self, dt: float) -> None:
134        if Input.is_action_just_pressed("quit"):
135            self.app.quit()
136
137
138def main() -> int:
139    parser = argparse.ArgumentParser(description=__doc__)
140    parser.add_argument("--describe", default="a node that spins", help="natural-language node description")
141    parser.add_argument(
142        "--allow-exec",
143        action="store_true",
144        help="opt in to executing generated code: validate in an isolated subprocess, then load + run it",
145    )
146    parser.add_argument("--live", action="store_true", help="use a real model via SIMVX_LLM_* (cached)")
147    args = parser.parse_args()
148
149    client = _build_client(args.live)
150    print(f"Generating a Node from: {args.describe!r}")
151    print(f"allow_execution={args.allow_exec} (OFF by default: model output is code-execution, design D5)")
152
153    result = asyncio.run(_generate(client, args.describe, args.allow_exec))
154    print(f"\nstatus={result.status}  node={result.node_name!r}  attempts={result.attempts}")
155    if result.errors:
156        print("repair history:")
157        for i, err in enumerate(result.errors, 1):
158            print(f"  attempt {i} rejected: {err.splitlines()[0]}")
159    print("\n--- generated source ---")
160    print(result.source)
161    print("--- end source ---\n")
162
163    if not result.ok:
164        print("Generation was rejected within the attempt cap; nothing to run.", file=sys.stderr)
165        return 1
166
167    OUT_DIR.mkdir(parents=True, exist_ok=True)
168    scene_path = OUT_DIR / "generated_scene.py"
169    scene_path.write_text(result.source, encoding="utf-8")
170    print(f"Wrote scene to {scene_path}")
171
172    if not args.allow_exec:
173        print(
174            "\nExecution OFF (default): the source above was static-validated only and was NOT run.\n"
175            "Re-run with --allow-exec to validate it in an isolated subprocess and then load + run it live."
176        )
177        return 0
178
179    # Only reached when --allow-exec gave a "verified" result: the node already
180    # survived an isolated subprocess smoke test, so loading it here is the
181    # reviewed opt-in path, not auto-execution of un-validated model output.
182    from simvx.graphics import App
183
184    app = App(width=1280, height=720, title=f"Node-Gen: {result.node_name}")
185    app.run(NodeGenHud(scene_path, result))
186    return 0
187
188
189if __name__ == "__main__":
190    sys.exit(main())