Node-Gen

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

📄 Docs only

Tags: ai llm codegen

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: generating a node executes model output, which is code-execution, so 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
  3A natural-language description ("a sprite that spins") is turned into a SimVX
  4scene 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: generating a node executes model output, which is
  9code-execution, so 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
 59import tempfile
 60from pathlib import Path
 61
 62from simvx.ai import CachingClient, GenerationResult, NodeGenerator, OpenAICompatibleClient
 63from simvx.ai.client import LLMClient, LLMResponse
 64from simvx.core import AnchorPreset, Input, InputMap, Key, Label, Node2D
 65from simvx.core.scene_io import load_scene
 66
 67CACHE_DIR = Path(tempfile.gettempdir()) / "simvx_nodegen_cache"
 68OUT_DIR = Path(tempfile.gettempdir()) / "simvx_nodegen_out"
 69
 70# A canned, valid Spinner module the offline fake "generates". It satisfies the
 71# whole contract: one Node2D subclass, a class-scope Property, on_-prefixed
 72# hooks, no top-level side effects, imports only allowlisted modules
 73# (simvx.core and math). The on_draw makes the spin visible in --allow-exec
 74# mode: rotation is a Property, so each change re-captures this node's draw
 75# and no queue_redraw() is needed.
 76_CANNED_SPINNER = """import math
 77
 78from simvx.core import Node2D, Property
 79
 80
 81class Spinner(Node2D):
 82    speed = Property(2.0, range=(0.0, 10.0))
 83
 84    def on_update(self, dt):
 85        self.rotation += self.speed * dt
 86
 87    def on_draw(self, renderer):
 88        # World-space draw (the Sprite2D idiom): a square outline with one
 89        # highlighted spoke so the rotation is unmistakable.
 90        pos, _scale, rot = self.world_transform
 91        corners = [
 92            (pos.x + math.cos(rot + i * math.tau / 4) * 120.0,
 93             pos.y + math.sin(rot + i * math.tau / 4) * 120.0)
 94            for i in range(4)
 95        ]
 96        renderer.draw_lines(corners, closed=True, colour=(0.35, 0.8, 1.0, 1.0))
 97        renderer.draw_thick_line(pos.x, pos.y, corners[0][0], corners[0][1], 3.0, colour=(1.0, 0.85, 0.3, 1.0))
 98        renderer.draw_circle(pos, 10.0, colour=(1.0, 0.85, 0.3, 1.0), filled=True)
 99"""
100
101
102class ScriptedNodeGenClient(LLMClient):
103    """Offline fake: returns a canned valid Spinner module for any description.
104
105    Stands in for a real model so the demo runs with no network. It exercises the
106    full generator path (static gate + optional subprocess smoke), proving the
107    feature end to end without an endpoint.
108    """
109
110    async def complete(self, messages, **kwargs) -> LLMResponse:
111        return LLMResponse(text=_CANNED_SPINNER)
112
113
114def _build_client(live: bool) -> LLMClient:
115    if not live:
116        return ScriptedNodeGenClient()
117    inner = OpenAICompatibleClient.from_env()
118    return CachingClient(inner, CACHE_DIR, mode="auto")
119
120
121async def _generate(client: LLMClient, description: str, allow_exec: bool) -> GenerationResult:
122    generator = NodeGenerator(client, smoke_frames=10)
123    return await generator.generate(description, allow_execution=allow_exec)
124
125
126class NodeGenHud(Node2D):
127    """Loads the generated scene as a child and shows its status in a HUD."""
128
129    def __init__(self, scene_path: Path, result: GenerationResult, **kwargs) -> None:
130        super().__init__(**kwargs)
131        self._scene_path = scene_path
132        self._result = result
133
134    def on_ready(self) -> None:
135        InputMap.add_action("quit", [Key.ESCAPE])
136
137        # The generated node was already smoke-validated in an isolated subprocess
138        # (status "verified"); only now do we load it into this host process.
139        generated = load_scene(self._scene_path)
140        generated.position = (self.app.width / 2, self.app.height / 2)
141        self.add_child(generated)
142
143        # HUD bands: anchors + margins only, so both survive a resize.
144        title = self.add_child(Label(text=f"Generated + verified: {self._result.node_name}"))
145        title.set_anchor_preset(AnchorPreset.TOP_WIDE)
146        title.margin_left = 16.0
147        title.margin_right = 16.0
148        title.margin_top = 16.0
149        title.margin_bottom = 40.0
150
151        sub = self.add_child(Label(text=f"loaded from {self._scene_path} -- Esc to quit"))
152        sub.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
153        sub.margin_left = 16.0
154        sub.margin_right = 16.0
155        sub.margin_top = -40.0
156        sub.margin_bottom = -16.0
157
158    def on_update(self, dt: float) -> None:
159        if Input.is_action_just_pressed("quit"):
160            self.app.quit()
161
162
163def main() -> int:
164    parser = argparse.ArgumentParser(description=__doc__)
165    parser.add_argument("--describe", default="a node that spins", help="natural-language node description")
166    parser.add_argument(
167        "--allow-exec",
168        action="store_true",
169        help="opt in to executing generated code: validate in an isolated subprocess, then load + run it",
170    )
171    parser.add_argument("--live", action="store_true", help="use a real model via SIMVX_LLM_* (cached)")
172    args = parser.parse_args()
173
174    client = _build_client(args.live)
175    print(f"Generating a Node from: {args.describe!r}")
176    print(f"allow_execution={args.allow_exec} (OFF by default: model output is code-execution)")
177
178    result = asyncio.run(_generate(client, args.describe, args.allow_exec))
179    print(f"\nstatus={result.status}  node={result.node_name!r}  attempts={result.attempts}")
180    if result.errors:
181        print("repair history:")
182        for i, err in enumerate(result.errors, 1):
183            print(f"  attempt {i} rejected: {err.splitlines()[0]}")
184    print("\n--- generated source ---")
185    print(result.source)
186    print("--- end source ---\n")
187
188    if not result.ok:
189        print("Generation was rejected within the attempt cap; nothing to run.", file=sys.stderr)
190        return 1
191
192    OUT_DIR.mkdir(parents=True, exist_ok=True)
193    scene_path = OUT_DIR / "generated_scene.py"
194    scene_path.write_text(result.source, encoding="utf-8")
195    print(f"Wrote scene to {scene_path}")
196
197    if not args.allow_exec:
198        print(
199            "\nExecution OFF (default): the source above was static-validated only and was NOT run.\n"
200            "Re-run with --allow-exec to validate it in an isolated subprocess and then load + run it live."
201        )
202        return 0
203
204    # Only reached when --allow-exec gave a "verified" result: the node already
205    # survived an isolated subprocess smoke test, so loading it here is the
206    # reviewed opt-in path, not auto-execution of un-validated model output.
207    from simvx.graphics import App
208
209    app = App(width=1280, height=720, title=f"Node-Gen: {result.node_name}")
210    app.run(NodeGenHud(scene_path, result))
211    return 0
212
213
214if __name__ == "__main__":
215    sys.exit(main())