Node-Gen¶
generate a Node subclass from a description, validate in isolation, run it.
📄 Docs onlyTags: 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_sceneand 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-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
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())