Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ Godot `_log_error` code/rationale when available, error type, resolved source
location, and stack/error-tree context corresponding to the Debugger dock's
Errors tab.

For incremental editor-log polling, call `logs_read(source="editor")` once and
save the returned `next_cursor`; later calls can pass
`logs_read(source="editor", since_cursor=N)` to receive only Logger-backed
editor entries appended after that cursor. Cursor responses include
`cursor`, `oldest_cursor`, `next_cursor`, `appended_total`, `truncated`, and
`has_more`; `since_cursor` supersedes `offset`. If `truncated=true`, the
caller fell behind the ring and some entries were evicted before the poll —
continue from the returned `next_cursor` and treat `oldest_cursor` as the
earliest retained sequence. If the plugin reloads, a stale high cursor
self-heals to the new tail with an empty response and a corrected
`next_cursor`. Live Debugger dock Errors-tab rows are merged into regular
`source="editor"` reads, but they are UI state rather than ring-buffer entries
and are not included in `since_cursor` responses.

`editor_manage(op="logs_clear")` accepts `clear_debugger_errors=true` to also
clear the Debugger dock's visible Errors-tab rows (routed through the panel's
own Clear path so the tab badge and counters reset). The Errors panel is
Expand Down
48 changes: 46 additions & 2 deletions plugin/addons/godot_ai/handlers/editor_handler.gd
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ func get_logs(params: Dictionary) -> Dictionary:
var offset: int = maxi(0, int(params.get("offset", 0)))
var source: String = str(params.get("source", "plugin"))
var include_details: bool = bool(params.get("include_details", false))
var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null
var since_cursor: int = maxi(0, int(params.get("since_cursor", 0)))
if not source in VALID_LOG_SOURCES:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
Expand All @@ -82,7 +84,7 @@ func get_logs(params: Dictionary) -> Dictionary:
"game":
return _get_game_logs(count, offset, include_details)
"editor":
return _get_editor_logs(count, offset, include_details)
return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor)
"all":
return _get_all_logs(count, offset, include_details)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable")
Expand Down Expand Up @@ -134,15 +136,18 @@ func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionar
}


func _get_editor_logs(count: int, offset: int, include_details: bool) -> Dictionary:
func _get_editor_logs(count: int, offset: int, include_details: bool, has_since_cursor: bool = false, since_cursor: int = 0) -> Dictionary:
## Editor-process script errors (parse errors, @tool runtime errors,
## EditorPlugin errors, push_error/push_warning). Captured by
## editor_logger.gd via OS.add_logger and gated on Godot 4.5+; on older
## engines the buffer can be null. Godot also sends GDScript reload
## warnings/errors straight to the Debugger dock's Errors tab; those do
## not flow through OS.add_logger, so merge the visible tree rows here.
if has_since_cursor:
return _get_editor_logs_since(count, since_cursor, include_details)
var all_entries := _collect_editor_log_entries()
var page := _entries_for_response(_slice_entries(all_entries, offset, count), include_details)
var appended_total := _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
return {
"data": {
"source": "editor",
Expand All @@ -151,6 +156,45 @@ func _get_editor_logs(count: int, offset: int, include_details: bool) -> Diction
"returned_count": page.size(),
"offset": offset,
"dropped_count": _editor_log_buffer.dropped_count() if _editor_log_buffer != null else 0,
"next_cursor": appended_total,
"appended_total": appended_total,
}
}


func _get_editor_logs_since(count: int, since_cursor: int, include_details: bool) -> Dictionary:
## Cursor reads are defined over the monotonic editor logger ring only.
## Visible Debugger Errors-tab rows are live UI state, not ring entries,
## so regular offset reads still merge them while since_cursor polling
## reports only Logger-backed entries.
var captured := {
"cursor": since_cursor,
"oldest_cursor": 0,
"next_cursor": 0,
"appended_total": 0,
"truncated": false,
"has_more": false,
"entries": [],
}
var dropped := 0
if _editor_log_buffer != null:
captured = _editor_log_buffer.get_since(since_cursor, count)
dropped = _editor_log_buffer.dropped_count()
var page := _entries_for_response(captured.get("entries", []), include_details)
return {
"data": {
"source": "editor",
"lines": page,
"total_count": int(captured.get("appended_total", 0)),
"returned_count": page.size(),
"offset": 0,
"dropped_count": dropped,
"cursor": int(captured.get("cursor", since_cursor)),
"oldest_cursor": int(captured.get("oldest_cursor", 0)),
"next_cursor": int(captured.get("next_cursor", 0)),
"appended_total": int(captured.get("appended_total", 0)),
"truncated": bool(captured.get("truncated", false)),
"has_more": bool(captured.get("has_more", false)),
}
}

Expand Down
19 changes: 16 additions & 3 deletions src/godot_ai/handlers/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ async def logs_read(
offset: int = 0,
source: str = "plugin",
since_run_id: str = "",
since_cursor: int | None = None,
include_details: bool = False,
) -> dict:
if source not in _VALID_LOG_SOURCES:
Expand Down Expand Up @@ -227,6 +228,8 @@ async def logs_read(
## ring buffer's run_id, dropped_count, and is_running stay
## authoritative on the editor side.
params = {"count": count, "offset": offset, "source": source}
if source == "editor" and since_cursor is not None:
params["since_cursor"] = since_cursor
if include_details:
params["include_details"] = True
result = await runtime.send_command(
Expand All @@ -253,19 +256,29 @@ async def logs_read(
}
lines = result.get("lines", [])
total = int(result.get("total_count", len(lines)))
return {
response = {
"source": source,
"lines": lines,
"total_count": total,
"returned_count": len(lines),
"offset": offset,
"offset": int(result.get("offset", offset)),
"limit": count,
"has_more": offset + count < total,
"has_more": bool(result.get("has_more", offset + count < total)),
"run_id": run_id,
"is_running": result.get("is_running", False),
"dropped_count": result.get("dropped_count", 0),
"stale_run_id": False,
}
for key in (
"cursor",
"oldest_cursor",
"next_cursor",
"appended_total",
"truncated",
):
if key in result:
response[key] = result[key]
return response


async def editor_reload_plugin(runtime: DirectRuntime) -> dict:
Expand Down
16 changes: 13 additions & 3 deletions src/godot_ai/tools/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ async def logs_read(
offset: int = 0,
source: str = "plugin",
since_run_id: str = "",
since_cursor: int | None = None,
include_details: bool = False,
session_id: str = "",
) -> dict:
Expand All @@ -105,9 +106,16 @@ async def logs_read(
are not captured.
- "all": plugin → editor → game lines (with source per entry).

Tail pattern: poll with offset=N + since_run_id=R. ``stale_run_id: true``
means the buffer has rotated; reset offset to 0 and capture new run_id.
``run_id`` is empty for ``source="editor"`` (editor logs don't rotate).
Tail pattern: for game logs, poll with offset=N + since_run_id=R.
``stale_run_id: true`` means the buffer has rotated; reset offset to 0
and capture new run_id. For editor logs, read once to capture
``next_cursor`` and pass it back as ``since_cursor`` on later calls.
``since_cursor`` reads Logger-backed editor entries only; live Debugger
Errors-tab rows are included in regular source="editor" reads but do
not have stable cursors. When ``since_cursor`` is set, it supersedes
``offset``. ``truncated: true`` means older entries fell out of the
ring before the poll; continue from the returned ``next_cursor`` and
treat ``oldest_cursor`` as the earliest retained sequence.
Set ``include_details=True`` for Errors-tab style metadata on game/editor
entries: original code/rationale, error type, resolved source, and
stack frames. Default false preserves compact responses.
Expand All @@ -117,6 +125,7 @@ async def logs_read(
offset: Lines to skip. Default 0.
source: "plugin" | "game" | "editor" | "all". Default "plugin".
since_run_id: Stale-detection token from a previous response.
since_cursor: Editor-log cursor from a previous source="editor" response.
include_details: Include rich error metadata for game/editor entries.
session_id: Optional Godot session to target. Empty = active session.
"""
Expand All @@ -127,6 +136,7 @@ async def logs_read(
offset=offset,
source=source,
since_run_id=since_run_id,
since_cursor=since_cursor,
include_details=include_details,
)

Expand Down
54 changes: 54 additions & 0 deletions test_project/tests/test_editor.gd
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,60 @@ func test_get_logs_source_editor_offset_applies() -> void:
assert_eq(result.data.total_count, 5)


func test_get_logs_source_editor_regular_read_returns_next_cursor() -> void:
var ed_buf := McpEditorLogBuffer.new()
ed_buf.append("error", "before", "res://x.gd", 1)
var handler := EditorHandler.new(McpLogBuffer.new(), null, null, null, ed_buf)
var result := handler.get_logs({"source": "editor", "count": 10})
assert_eq(result.data.lines.size(), 1)
assert_eq(result.data.next_cursor, ed_buf.appended_total())
assert_eq(result.data.appended_total, ed_buf.appended_total())


func test_get_logs_source_editor_since_cursor_returns_incremental_entries() -> void:
var ed_buf := McpEditorLogBuffer.new()
ed_buf.append("error", "before-a", "res://before.gd", 1)
ed_buf.append("error", "before-b", "res://before.gd", 2)
var cursor := ed_buf.appended_total()
ed_buf.append("error", "after-a", "res://after.gd", 3)
ed_buf.append("warn", "after-b", "res://after.gd", 4)
var handler := EditorHandler.new(McpLogBuffer.new(), null, null, null, ed_buf)
var result := handler.get_logs({"source": "editor", "since_cursor": cursor, "count": 1})
assert_eq(result.data.lines.size(), 1)
assert_eq(result.data.lines[0].text, "after-a")
assert_eq(result.data.cursor, cursor)
assert_eq(result.data.next_cursor, cursor + 1)
assert_eq(result.data.appended_total, ed_buf.appended_total())
assert_true(result.data.has_more)
assert_false(result.data.truncated)


func test_get_logs_source_editor_since_cursor_reports_truncation() -> void:
var ed_buf := McpEditorLogBuffer.new()
var cap := McpEditorLogBuffer.MAX_LINES
for i in range(cap + 2):
ed_buf.append("error", "storm %d" % i, "res://storm.gd", i)
var handler := EditorHandler.new(McpLogBuffer.new(), null, null, null, ed_buf)
var result := handler.get_logs({"source": "editor", "since_cursor": 0, "count": 10})
assert_true(result.data.truncated)
assert_eq(result.data.oldest_cursor, 2)
assert_eq(result.data.lines[0].text, "storm 2")
assert_eq(result.data.next_cursor, 12)
assert_true(result.data.has_more)


func test_get_logs_source_editor_since_cursor_excludes_debugger_errors_tree() -> void:
var ed_buf := McpEditorLogBuffer.new()
var cursor := ed_buf.appended_total()
var tree := _make_debugger_errors_tree()
var handler := EditorHandler.new(McpLogBuffer.new(), null, McpDebuggerPlugin.new(), null, ed_buf, tree)
var result := handler.get_logs({"source": "editor", "since_cursor": cursor, "count": 10})
assert_eq(result.data.lines.size(), 0, "Cursor mode is scoped to Logger-backed editor entries")
assert_eq(result.data.next_cursor, cursor)
assert_false(result.data.has_more)
tree.free()


func test_get_logs_source_all_includes_editor_between_plugin_and_game() -> void:
var plugin_buf := McpLogBuffer.new()
plugin_buf.log("plugin-a")
Expand Down
55 changes: 55 additions & 0 deletions tests/integration/test_mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,61 @@ async def respond():
assert data["dropped_count"] == 0
assert data["stale_run_id"] is False

async def test_source_editor_since_cursor_passes_through(self, mcp_stack):
client, plugin = mcp_stack
entries = [
{
"source": "editor",
"level": "error",
"text": "Parse Error: Expected statement",
"path": "res://broken.gd",
"line": 12,
"function": "GDScript::reload",
},
]

async def respond():
cmd = await plugin.recv_command()
assert cmd["command"] == "get_logs"
assert cmd["params"] == {
"count": 1,
"offset": 0,
"source": "editor",
"since_cursor": 7,
}
await plugin.send_response(
cmd["request_id"],
{
"source": "editor",
"lines": entries,
"total_count": 9,
"returned_count": 1,
"offset": 0,
"dropped_count": 0,
"cursor": 7,
"oldest_cursor": 0,
"next_cursor": 8,
"appended_total": 9,
"truncated": False,
"has_more": True,
},
)

task = asyncio.create_task(respond())
result = await client.call_tool(
"logs_read",
{"source": "editor", "since_cursor": 7, "count": 1},
)
await task

data = result.data
assert data["lines"] == entries
assert data["cursor"] == 7
assert data["next_cursor"] == 8
assert data["appended_total"] == 9
assert data["truncated"] is False
assert data["has_more"] is True

async def test_include_details_returns_errors_tab_context(self, mcp_stack):
client, plugin = mcp_stack
entries = [
Expand Down
Loading