Skip to content

Commit 1d505ea

Browse files
authored
Enrich EDITOR_NOT_READY with editor_state + actionable hint (#454)
* Enrich EDITOR_NOT_READY with editor_state + actionable hint Telemetry on plugin v2.5.6 surfaced an EDITOR_NOT_READY-loop pattern: 89% of EDITOR_NOT_READY errors over 24h came from two users alone, each retrying node_set_property dozens of times during play mode before eventually calling project_manage(op="stop") and seeing the write succeed immediately. The editor was correctly refusing — the caller had no signal telling it why the editor was unwritable or how to unstick it, so the LLM looped the failing call. Mirror the F-001/PROPERTY_NOT_ON_CLASS approach (PR #436): give the AI caller an explicit, action-oriented recovery hint in the error payload. Now every EDITOR_NOT_READY response carries: data: { editor_state: "playing" | "importing" | "no_scene", retryable: bool, hint: "<one-line recovery instruction naming the exact tool to call>" } Both halves of the system contribute: the Python require_writable_async gate attaches the payload for "playing" / "importing", and the GDScript scene_path.require_edited_scene helper attaches it for "no_scene". The "playing" hint names project_manage(op="stop") so an LLM can chain directly to the recovery call instead of guessing. The data-key name changed from "state" to "editor_state" so it reads unambiguously in the GodotCommandError serialized form (which LLMs often see in tool error responses). * Scope EDITOR_NOT_READY-hint claim to recoverable editor states only Copilot review on #454 caught an overclaim in the contract test's docstring (and the parallel comment in scene_path.gd): they read as if *every* EDITOR_NOT_READY response now carries the structured data block. It doesn't — only the three recoverable editor *states* (playing / importing / no_scene) do, because those are the paths an AI caller can act on. Other EDITOR_NOT_READY callsites in handlers ("EditorFileSystem not available", "AnimationHandler not available", "No 3D viewport available", etc.) describe internal-state failures with no caller-actionable recovery and intentionally stay unenriched. Tighten the docstring and the GDScript comment to say exactly that. No behavior change.
1 parent 0c8c17c commit 1d505ea

4 files changed

Lines changed: 141 additions & 18 deletions

File tree

plugin/addons/godot_ai/utils/scene_path.gd

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,23 @@ static func resolve(scene_path: String, scene_root: Node) -> Node:
7474
static func require_edited_scene(expected_scene_file: String) -> Dictionary:
7575
var root := EditorInterface.get_edited_scene_root()
7676
if root == null:
77-
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No scene open")
77+
# Mirrors the structured payload that the Python-side require_writable
78+
# gate attaches for `playing` / `importing`. Together these cover the
79+
# three recoverable editor *states* (playing / importing / no_scene)
80+
# — the EDITOR_NOT_READY paths an AI caller can act on. Other
81+
# EDITOR_NOT_READY callsites describing internal-state failures
82+
# ("EditorFileSystem not available" etc.) intentionally don't carry
83+
# this payload because there's no useful caller hint to give.
84+
var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No scene open")
85+
err["error"]["data"] = {
86+
"editor_state": "no_scene",
87+
"retryable": false,
88+
"hint": (
89+
"No scene is open. Call scene_open with a scene path "
90+
+ "(e.g. \"res://main.tscn\") before issuing scene-mutating tools."
91+
),
92+
}
93+
return err
7894
if not expected_scene_file.is_empty() and root.scene_file_path != expected_scene_file:
7995
var actual := root.scene_file_path if not root.scene_file_path.is_empty() else "<unsaved>"
8096
return ErrorCodes.make(

src/godot_ai/handlers/_readiness.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,32 @@
1010
if TYPE_CHECKING:
1111
from godot_ai.runtime.direct import DirectRuntime
1212

13-
# (message, retryable). Retryable means the condition clears on its own
13+
# (message, retryable, hint). Retryable means the condition clears on its own
1414
# (Godot finishes reimporting); non-retryable requires the caller to change
15-
# state (stop the game).
16-
_READINESS_INFO: dict[str, tuple[str, bool]] = {
17-
"importing": ("Editor is importing resources — try again shortly", True),
18-
"playing": ("Editor is in play mode — call project_stop to stop the game, then retry", False),
15+
# state (stop the game). The ``hint`` is a one-line, action-oriented sentence
16+
# surfaced to AI callers via the error ``data`` payload — its job is to tell
17+
# an LLM exactly which tool call (or wait) breaks the stall, so it stops
18+
# looping the failing write. See PR for the F-EDITOR-NOT-READY-LOOP fix
19+
# (telemetry showed two users alone producing 89% of EDITOR_NOT_READY
20+
# errors on plugin v2.5.6, all from caller-side retry loops during
21+
# ``playing``).
22+
_READINESS_INFO: dict[str, tuple[str, bool, str]] = {
23+
"importing": (
24+
"Editor is importing resources — try again shortly",
25+
True,
26+
(
27+
"Editor is importing assets. Wait briefly and retry — "
28+
"readiness will update via the response envelope."
29+
),
30+
),
31+
"playing": (
32+
'Editor is in play mode — call project_manage(op="stop") to stop the game, then retry',
33+
False,
34+
(
35+
'Editor is playing the scene. Call project_manage(op="stop") '
36+
"(or wait for the user to stop the game) before retrying writes."
37+
),
38+
),
1939
}
2040

2141
# Every readiness value the plugin can emit. Derived from the blocking-state
@@ -87,9 +107,12 @@ async def require_writable_async(runtime: "DirectRuntime") -> None:
87107
is open. If no session exists, this is a no-op; the downstream
88108
``send_command`` will raise on its own.
89109
90-
The raised error carries ``data={"retryable": bool, "state": str}`` so
91-
callers can distinguish a transient ``importing`` window (retry with
92-
backoff) from a terminal ``playing`` state (stop the game first).
110+
The raised error carries ``data={"editor_state": str, "retryable": bool,
111+
"hint": str}`` so callers can distinguish a transient ``importing`` window
112+
(retry with backoff) from a terminal ``playing`` state (stop the game
113+
first) AND get an explicit one-line recovery instruction. The hint is
114+
what stops the EDITOR_NOT_READY-loop pattern: without it, AI callers
115+
just retry the failing write until the user notices.
93116
"""
94117
session = runtime.get_active_session()
95118
if session is None:
@@ -121,9 +144,13 @@ def _enforce_blocking_state(session: "Session | None") -> None:
121144
## Hoisting the import to module top would re-establish the cycle.
122145
from godot_ai.godot_client.client import GodotCommandError
123146

124-
message, retryable = info
147+
message, retryable, hint = info
125148
raise GodotCommandError(
126149
code=ErrorCode.EDITOR_NOT_READY,
127150
message=message,
128-
data={"retryable": retryable, "state": session.readiness},
151+
data={
152+
"editor_state": session.readiness,
153+
"retryable": retryable,
154+
"hint": hint,
155+
},
129156
)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Source-level contract for the structured EDITOR_NOT_READY payload.
2+
3+
Telemetry on plugin v2.5.6 showed 89% of EDITOR_NOT_READY errors came from
4+
two users in retry loops during ``playing`` — the LLM saw a bare error and
5+
kept guessing. The fix attaches a ``data`` block with
6+
``editor_state``/``retryable``/``hint`` to the EDITOR_NOT_READY paths that
7+
correspond to recoverable editor *states* — the Python ``require_writable``
8+
gate (``playing``/``importing``) and the GDScript ``require_edited_scene``
9+
helper (``no_scene``). These are the paths AI callers loop on because
10+
there's an obvious recovery action.
11+
12+
Other EDITOR_NOT_READY callsites in handlers (e.g. "EditorFileSystem not
13+
available", "AnimationHandler not available", "No 3D viewport available")
14+
describe internal-state failures with no caller-actionable recovery and
15+
are intentionally left unenriched — adding a fabricated hint there would
16+
mislead more than it would help.
17+
18+
The Python gate is covered behaviorally by ``test_readiness.py``. This
19+
file locks the GDScript-side ``no_scene`` branch in ``utils/scene_path.gd``
20+
— that path can't be exercised from the live GDScript test runner because
21+
the test harness always has a scene open, so we verify the source attaches
22+
the structured payload.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
from pathlib import Path
28+
29+
PLUGIN_ROOT = Path(__file__).resolve().parents[2] / "plugin" / "addons" / "godot_ai"
30+
31+
32+
def test_scene_path_no_scene_error_carries_editor_state_payload() -> None:
33+
source = (PLUGIN_ROOT / "utils" / "scene_path.gd").read_text(encoding="utf-8")
34+
# Locate the no-scene branch by its message so we don't false-positive
35+
# on the EDITED_SCENE_MISMATCH path further down.
36+
no_scene_marker = '"No scene open"'
37+
assert no_scene_marker in source
38+
branch_start = source.index(no_scene_marker)
39+
# The data attachment must follow within the same branch (next ~25 lines).
40+
branch = source[branch_start : branch_start + 600]
41+
assert '"editor_state": "no_scene"' in branch, (
42+
"no_scene error must carry editor_state for the AI-caller hint payload"
43+
)
44+
assert '"retryable": false' in branch, "no_scene is terminal until scene_open is called"
45+
assert '"hint":' in branch
46+
# The hint must name the exact recovery tool so the LLM doesn't guess.
47+
assert "scene_open" in branch
48+
49+
50+
def test_python_gate_payload_uses_editor_state_key_not_legacy_state() -> None:
51+
"""The data shape changed from ``state`` to ``editor_state`` to match
52+
the GDScript-side payload. Both halves must stay in sync — same key
53+
name, same shape, regardless of which non-writable condition triggers
54+
the error."""
55+
source = (
56+
Path(__file__).resolve().parents[2] / "src" / "godot_ai" / "handlers" / "_readiness.py"
57+
).read_text(encoding="utf-8")
58+
assert '"editor_state": session.readiness' in source
59+
assert '"hint": hint' in source
60+
# Make sure the legacy key didn't survive a careless refactor.
61+
assert '"state":' not in source.split("_enforce_blocking_state")[1].split("def ")[0], (
62+
"the data block must use 'editor_state' (mirrors GDScript no_scene payload), not 'state'"
63+
)

tests/unit/test_readiness.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,17 @@ async def test_require_writable_async_rejects_importing_after_probe_confirms():
202202
with pytest.raises(GodotCommandError) as exc_info:
203203
await require_writable_async(runtime)
204204
assert exc_info.value.code == ErrorCode.EDITOR_NOT_READY
205-
assert exc_info.value.data == {"retryable": True, "state": "importing"}
205+
data = exc_info.value.data
206+
assert data["editor_state"] == "importing"
207+
assert data["retryable"] is True
208+
# The hint must be an explicit, action-oriented instruction so AI callers
209+
# don't loop the failing write. See F-EDITOR-NOT-READY-LOOP fix.
210+
assert "Wait" in data["hint"] or "wait" in data["hint"]
211+
assert "importing" in data["hint"].lower()
206212
# Structured hints are also embedded in the serialized form so MCP
207213
# clients that only see str(exc) can still distinguish retryable cases.
208214
assert "retryable=True" in str(exc_info.value)
209-
assert "state=importing" in str(exc_info.value)
215+
assert "editor_state=importing" in str(exc_info.value)
210216
assert client.probe_calls == 1
211217

212218

@@ -216,11 +222,19 @@ async def test_require_writable_async_rejects_playing_after_probe_confirms():
216222
await require_writable_async(runtime)
217223
assert exc_info.value.code == ErrorCode.EDITOR_NOT_READY
218224
assert "play mode" in exc_info.value.message
219-
# The message names the recovery tool so MCP clients don't have to
220-
# infer "how do I unstick this" from the state string alone.
221-
assert "project_stop" in exc_info.value.message
222-
assert exc_info.value.data == {"retryable": False, "state": "playing"}
225+
# The message names the recovery tool (the rolled-up form) so MCP
226+
# clients don't have to infer "how do I unstick this" from the state
227+
# string alone.
228+
assert 'project_manage(op="stop")' in exc_info.value.message
229+
data = exc_info.value.data
230+
assert data["editor_state"] == "playing"
231+
assert data["retryable"] is False
232+
# Hint must name the exact recovery call — this is the F-EDITOR-NOT-READY-
233+
# LOOP fix: cure the 89%-of-EDITOR_NOT_READYs-from-2-users retry pattern
234+
# by telling the LLM exactly which tool stops the stall.
235+
assert 'project_manage(op="stop")' in data["hint"]
223236
assert "retryable=False" in str(exc_info.value)
237+
assert "editor_state=playing" in str(exc_info.value)
224238
assert client.probe_calls == 1
225239

226240

@@ -235,7 +249,10 @@ async def test_require_writable_async_probe_failure_falls_back_to_cached_value()
235249
with pytest.raises(GodotCommandError) as exc_info:
236250
await require_writable_async(runtime)
237251
assert exc_info.value.code == ErrorCode.EDITOR_NOT_READY
238-
assert exc_info.value.data == {"retryable": False, "state": "playing"}
252+
data = exc_info.value.data
253+
assert data["editor_state"] == "playing"
254+
assert data["retryable"] is False
255+
assert 'project_manage(op="stop")' in data["hint"]
239256
assert client.probe_calls == 1
240257

241258

0 commit comments

Comments
 (0)