Node-Gen¶
generate a Node subclass from a description, validate in isolation, run it.
📄 Docs onlyTags: 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_sceneand 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-execthe generated source is only static-validated (astparse, no execution) and never run. You see and save the code, but nothing from the model is executed.OPT-IN, VALIDATED IN ISOLATION:
--allow-execis 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())