@tool extends Logger ## Editor-process Logger subclass. ## ## NOTE: deliberately no `class_name` — `extends Logger` requires the Logger ## class which Godot only exposes from 4.5+. This file lives in the ## `.gdignore`'d `runtime/loggers/` folder so Godot's editor filesystem scan ## skips it entirely — on Godot < 4.5 it is never parsed, so it emits no ## "Could not find base class Logger" error (it used to, before #475's ## follow-up). plugin.gd builds it from source at runtime via ## `logger_loader.gd` and only calls OS.add_logger() after gating on ## ClassDB.class_exists("Logger"), so the `extends Logger` parse only ever ## happens on 4.5+ where it resolves. Registered from plugin.gd::_enter_tree ## so we can intercept editor-process script errors — parse errors, @tool ## runtime errors, EditorPlugin errors, push_error/push_warning — and ## surface them via `logs_read(source="editor")`. Without this, the LLM ## sees nothing in `logs_read` while the same errors show in red lines in ## Godot's Output panel. ## ## Why only `_log_error` and not `_log_message`: ## `_log_message(msg, error)` covers print() and printerr(), which is the ## firehose path — running editors print thousands of internal info lines ## a session. The issue (#231) explicitly asks to filter so the buffer ## isn't drowned. Errors and warnings flow through `_log_error` (parse ## errors, push_error/push_warning, runtime errors), which is what ## debugging callers actually need. If we discover @tool printerr() is a ## valuable source later, _log_message can be added behind the same filter. ## ## Logger virtuals can be called from any thread (e.g. async script ## loaders push parse errors off the main thread). McpEditorLogBuffer is ## mutex-protected so we can append directly without an intermediate queue. const ADDON_PATH_MARKER := "/addons/godot_ai/" ## Resolve McpLogBacktrace by path, not by the `McpLogBacktrace` class_name. ## This script is compiled from source at runtime by logger_loader.gd; a bare ## class_name reference depends on the global class-name table being populated ## at compile time, which isn't guaranteed on a cold editor enable mid-scan. ## `const preload` resolves at compile time independent of the registry — ## matches game_logger.gd's deliberate choice for the same reason. const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd") ## McpEditorLogBuffer — untyped because this script is loaded dynamically and ## McpEditorLogBuffer's class_name isn't yet registered on the parser at the ## time `extends Logger` resolves. Constructor-injected so the hot path ## doesn't need a per-call null check. var _buffer func _init(buffer = null) -> void: _buffer = buffer func _log_error( function: String, file: String, line: int, code: String, rationale: String, _editor_notify: bool, error_type: int, script_backtraces: Array, ) -> void: if _buffer == null: return ## Cheap reject for the firehose: when `file` is already non-user (the ## bulk of editor-internal C++ chatter), there's no backtrace to remap ## from, and the message doesn't name a project resource, the resolved ## path can only stay non-user — drop without paying for resolve_error's ## call frame + dict allocation. var message := rationale if not rationale.is_empty() else code var message_res_path := _extract_user_res_path(message) if not _is_user_script(file) and script_backtraces.is_empty() and message_res_path.is_empty(): return var resolved := _LogBacktrace.resolve_error( function, file, line, code, rationale, error_type, script_backtraces, ) if not _is_user_script(resolved.path): if message_res_path.is_empty(): return resolved.path = message_res_path resolved.line = 0 resolved.function = function _update_resolved_details(resolved) if _is_in_godot_ai_addon(resolved.path): return if not message_res_path.is_empty() and _is_in_godot_ai_addon(message_res_path): return var details: Dictionary = resolved.get("details", {}) _buffer.append(resolved.level, resolved.message, resolved.path, resolved.line, resolved.function, details) static func _update_resolved_details(resolved: Dictionary) -> void: var details: Dictionary = resolved.get("details", {}) if details.is_empty(): return details["resolved"] = { "path": resolved.get("path", ""), "line": resolved.get("line", 0), "function": resolved.get("function", ""), } resolved["details"] = details ## Predicate broken out so tests can drive the path-filter logic without ## constructing real Logger calls. static func _is_user_script(path: String) -> bool: if path.is_empty(): return false ## Match .gd / .cs (case-insensitively to handle .GD on case-insensitive ## filesystems). C# scripts compile elsewhere but the parser path can ## still surface .cs files for assembly load failures. var lower := path.to_lower() return lower.ends_with(".gd") or lower.ends_with(".cs") ## Path-substring check works for both `res://addons/godot_ai/foo.gd` and ## globalized absolute paths (`/Users/.../addons/godot_ai/foo.gd`) that ## Godot can also report depending on where the error originated. static func _is_in_godot_ai_addon(path: String) -> bool: if path.begins_with("res://addons/godot_ai/"): return true return path.find(ADDON_PATH_MARKER) >= 0 ## Some engine-origin errors have no ScriptBacktrace even though they are ## project-relevant, notably ResourceLoader failures: ## `Failed loading resource: res://does/not/exist.tres.`. Capture these by ## extracting a named `res://` path from the message while keeping editor ## internals and this addon's own resources filtered. static func _extract_user_res_path(message: String) -> String: var start := message.find("res://") if start < 0: return "" var end := message.length() var quote_end := message.find("'", start) if quote_end >= 0: end = mini(end, quote_end) quote_end = message.find("\"", start) if quote_end >= 0: end = mini(end, quote_end) quote_end = message.find("`", start) if quote_end >= 0: end = mini(end, quote_end) var path := message.substr(start, end - start).strip_edges() while not path.is_empty() and path.substr(path.length() - 1, 1) in [".", ",", ";", ":", ")"]: path = path.substr(0, path.length() - 1) if path.is_empty() or _is_in_godot_ai_addon(path): return "" return path