152 lines
6.2 KiB
GDScript
152 lines
6.2 KiB
GDScript
@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
|