Files
tekton/addons/godot_ai/handlers/project_handler.gd
T

420 lines
17 KiB
GDScript

@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles project settings and filesystem search commands.
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
const RUN_READY_WAIT_SEC := 3.0
var _connection: McpConnection
var _debugger_plugin
var _editor_log_buffer
func _init(connection: McpConnection = null, debugger_plugin = null, editor_log_buffer = null) -> void:
_connection = connection
_debugger_plugin = debugger_plugin
_editor_log_buffer = editor_log_buffer
func get_project_setting(params: Dictionary) -> Dictionary:
var key: String = params.get("key", "")
if key.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key")
if not ProjectSettings.has_setting(key):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Setting not found: %s" % key)
var value = ProjectSettings.get_setting(key)
return {
"data": {
"key": key,
"value": NodeHandler._serialize_value(value),
"type": type_string(typeof(value)),
}
}
func set_project_setting(params: Dictionary) -> Dictionary:
var key: String = params.get("key", "")
if key.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key")
if not params.has("value"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
var value = params.get("value")
var had_setting := ProjectSettings.has_setting(key)
var old_value = ProjectSettings.get_setting(key) if had_setting else null
# JSON has no distinct int type: Godot parses `1920` as float. If the
# existing setting is TYPE_INT, coerce whole-number floats back to int so
# we don't silently flip typed-int settings (viewport_width, etc.) to
# floats on disk. See issue #31.
if had_setting and typeof(old_value) == TYPE_INT and typeof(value) == TYPE_FLOAT and float(int(value)) == value:
value = int(value)
ProjectSettings.set_setting(key, value)
var err := ProjectSettings.save()
if err != OK:
if had_setting:
ProjectSettings.set_setting(key, old_value)
else:
ProjectSettings.clear(key)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save project settings (error %d)" % err)
return {
"data": {
"key": key,
"value": NodeHandler._serialize_value(value),
"old_value": NodeHandler._serialize_value(old_value),
"type": type_string(typeof(value)),
"undoable": false,
"reason": "ProjectSettings changes are saved to disk",
}
}
func run_project(params: Dictionary) -> Dictionary:
var mode: String = params.get("mode", "main")
var autosave: bool = params.get("autosave", true)
# Idempotent: a project that's already running satisfies the caller's intent.
# Returning INVALID_PARAMS here punished agents that legitimately called run
# to ensure the project is playing (87+ installs/day hit the matching
# stop-not-running case in telemetry). Surface state via was_already_running
# so a caller wanting a *different* scene can detect and stop+restart.
if EditorInterface.is_playing_scene():
return _run_project_current_liveness_response(
_run_project_base_data(
mode,
str(params.get("scene", "")),
autosave,
true,
"Project was already running; no action taken"
)
)
var validation_error: Variant = null
if mode == "custom":
var custom_scene: String = params.get("scene", "")
if custom_scene.is_empty():
validation_error = ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: scene (required when mode='custom')")
elif mode != "main" and mode != "current":
validation_error = ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid mode '%s' — use 'main', 'current', or 'custom'" % mode)
if validation_error != null:
return validation_error
# play_*_scene internally triggers try_autosave() → _save_scene_with_preview()
# which renders a preview thumbnail and calls frame processing. If our
# WebSocket connection's _process() re-enters during that render, the
# engine crashes (SIGABRT in _save_scene_with_preview). Pause processing
# around the play call — same pattern as SceneHandler.save_scene.
if _connection:
_connection.pause_processing = true
# try_autosave() reads run/auto_save/save_before_running every call, so
# toggling it off around the play call suppresses the save without
# touching the user's persisted preference. Issue #81.
var autosave_key := "run/auto_save/save_before_running"
var editor_settings: EditorSettings = null
if not autosave:
editor_settings = EditorInterface.get_editor_settings()
var prior_autosave: bool = true
var restore_setting := false
if editor_settings != null and editor_settings.has_setting(autosave_key):
prior_autosave = bool(editor_settings.get_setting(autosave_key))
editor_settings.set_setting(autosave_key, false)
restore_setting = true
if _debugger_plugin != null:
_debugger_plugin.begin_game_run(_editor_log_cursor(), _game_helper_autoload_expected())
match mode:
"main":
EditorInterface.play_main_scene()
"current":
EditorInterface.play_current_scene()
"custom":
var scene_path: String = params.get("scene", "")
EditorInterface.play_custom_scene(scene_path)
if restore_setting:
editor_settings.set_setting(autosave_key, prior_autosave)
if _connection:
_connection.pause_processing = false
var base_data := _run_project_base_data(
mode,
str(params.get("scene", "")),
autosave,
false,
"Play/stop is a runtime action"
)
var request_id: String = params.get("_request_id", "")
if _connection != null and _debugger_plugin != null and not request_id.is_empty():
_finish_run_project_deferred(request_id, base_data)
return McpDispatcher.DEFERRED_RESPONSE
return _run_project_current_liveness_response(base_data)
func _editor_log_cursor() -> int:
return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
func _game_helper_autoload_expected() -> bool:
return ProjectSettings.has_setting("autoload/_mcp_game_helper")
func _run_project_base_data(
mode: String,
scene: String,
autosave: bool,
was_already_running: bool,
reason: String
) -> Dictionary:
return {
"mode": mode,
"scene": scene,
"autosave": autosave,
"was_already_running": was_already_running,
"undoable": false,
"reason": reason,
}
func _run_project_current_liveness_response(base_data: Dictionary) -> Dictionary:
if _debugger_plugin == null:
return {"data": base_data}
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
return _run_project_response(base_data, _run_project_liveness_decision(status, errors_info))
func _finish_run_project_deferred(request_id: String, base_data: Dictionary) -> void:
var tree := _connection.get_tree()
while true:
await tree.process_frame
if not is_instance_valid(_connection):
return
var pre_status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
if (
not EditorInterface.is_playing_scene()
and int(pre_status.get("elapsed_msec", 0)) > 100
and str(pre_status.get("status", "stopped")) == "launching"
):
_debugger_plugin.end_game_run()
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
var decision := _run_project_liveness_decision(status, errors_info)
if not bool(decision.get("resolve", false)):
continue
_connection.send_deferred_response(request_id, _run_project_response(base_data, decision))
return
func _run_project_response(base_data: Dictionary, decision: Dictionary) -> Dictionary:
var data := base_data.duplicate(true)
var game_status: Dictionary = decision.get("game_status", {})
data["game_status"] = game_status
data["helper_live"] = bool(game_status.get("helper_live", false))
data["session_active"] = bool(game_status.get("session_active", false))
if bool(data.get("was_already_running", false)):
data["reason"] = _run_project_already_running_message(decision)
else:
data["reason"] = decision.get("message", data.get("reason", "Play/stop is a runtime action"))
data["recent_errors"] = decision.get("recent_errors", [])
data["recent_errors_scope"] = decision.get("recent_errors_scope", "none")
data["recent_errors_may_predate_run"] = decision.get("recent_errors_may_predate_run", false)
data["recent_errors_truncated"] = decision.get("recent_errors_truncated", false)
return {"data": data}
func _run_project_already_running_message(decision: Dictionary) -> String:
var state := str(decision.get("liveness_status", "unknown"))
match state:
"live":
return "Project was already running; the Godot AI game helper is live."
"not_live":
var errors: Array = decision.get("recent_errors", [])
var scope := str(decision.get("recent_errors_scope", "none"))
if not errors.is_empty() and scope == "run":
return "Project was already running but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
if not errors.is_empty():
return "Project was already running but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
return "Project was already running but did not become live before the helper-ready window elapsed. Check logs_read(source='editor', include_details=true) and poll editor_state."
"no_helper":
return "Project was already running, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness."
"launching":
return "Project was already running and is still waiting for the Godot AI game helper to register. Poll editor_state shortly."
"stopped":
return "Project was already marked playing by the editor, but no active game liveness run exists."
_:
return "Project was already running; current liveness status is %s." % state
func _run_project_liveness_decision(status: Dictionary, errors_info: Dictionary = {}) -> Dictionary:
var enriched_status := McpDebuggerPlugin.with_liveness_flags(status)
var state := str(status.get("status", "stopped"))
var recent_errors: Array = errors_info.get("errors", [])
var errors_scope := str(errors_info.get("scope", "none"))
var truncated := bool(errors_info.get("truncated", false))
var correlated_error := not recent_errors.is_empty() and errors_scope == "run"
var elapsed_msec := int(status.get("elapsed_msec", 0))
var ready_wait_msec := int(status.get("ready_wait_msec", int(RUN_READY_WAIT_SEC * 1000.0)))
var decision := {
"resolve": false,
"game_status": enriched_status,
"liveness_status": state,
"recent_errors": recent_errors,
"recent_errors_scope": errors_scope,
"recent_errors_may_predate_run": errors_scope == "retained_recent",
"recent_errors_truncated": truncated,
"message": "",
}
if state == "live":
decision["resolve"] = true
decision["message"] = "Game launched and the Godot AI game helper is live."
elif correlated_error:
decision["resolve"] = true
decision["liveness_status"] = "not_live"
decision["message"] = "Game launched but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
if truncated:
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
elif state == "not_live":
decision["resolve"] = true
if not recent_errors.is_empty():
decision["message"] = "Game launched but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
else:
decision["message"] = "Game launched but did not become live before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and poll editor_state."
elif state == "no_helper":
decision["resolve"] = true
decision["message"] = "Game launched, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness; use editor_state and viewport/editor tools where applicable."
elif state == "stopped":
decision["resolve"] = true
decision["message"] = "The play session stopped, or no active game liveness run exists, before the Godot AI game helper became live."
elif state == "launching" and elapsed_msec >= ready_wait_msec:
decision["resolve"] = true
decision["message"] = "Game launched but is not yet live after %.1fs; it may still be booting. Poll editor_state and check logs_read(source='editor', include_details=true)." % (float(elapsed_msec) / 1000.0)
return decision
func _format_editor_error_summary(entry: Dictionary) -> String:
var text := str(entry.get("text", "editor error"))
var path := str(entry.get("path", ""))
var line := int(entry.get("line", 0))
if not path.is_empty() and line > 0:
return "%s (%s:%d)" % [text, path, line]
if not path.is_empty():
return "%s (%s)" % [text, path]
return text
func stop_project(params: Dictionary) -> Dictionary:
# Idempotent: a project that's already stopped satisfies the caller's intent.
# Returning INVALID_PARAMS here was the largest single source of fleet-wide
# project_manage failures (87 installs/24h). was_running=false lets callers
# distinguish a no-op stop from one that actually halted a running session.
if not EditorInterface.is_playing_scene():
return {
"data": {
"stopped": true,
"was_running": false,
"undoable": false,
"reason": "Project was not running; no action taken",
}
}
if _debugger_plugin != null:
_debugger_plugin.end_game_run()
EditorInterface.stop_playing_scene()
# stop_playing_scene() is async — is_playing_scene() only flips to false on
# the next frame, and readiness_changed follows in _process. Defer the
# response so we can reply with authoritative readiness instead of letting
# the server poll for the event. Issue #29.
var request_id: String = params.get("_request_id", "")
if _connection != null and not request_id.is_empty():
_finish_stop_project_deferred(request_id)
return McpDispatcher.DEFERRED_RESPONSE
# Fallback for contexts without a connection (e.g. batch_execute via
# dispatch_direct, or unit tests that instantiate the handler with null).
return {
"data": {
"stopped": true,
"was_running": true,
"undoable": false,
"reason": "Play/stop is a runtime action",
}
}
func _finish_stop_project_deferred(request_id: String) -> void:
# Wait two frames so Godot can tick the stop-play state change. After this
# is_playing_scene() reflects truth and get_readiness() is authoritative.
# If the plugin tears down (_exit_tree frees _connection) during the await,
# is_instance_valid() goes false and we drop the response silently — the
# server's 5s request timeout will surface the failure to the caller.
var tree := _connection.get_tree()
await tree.process_frame
await tree.process_frame
if not is_instance_valid(_connection):
return
_connection.send_deferred_response(request_id, {
"data": {
"stopped": true,
"was_running": true,
"undoable": false,
"reason": "Play/stop is a runtime action",
"readiness_after": McpConnection.get_readiness(),
}
})
func search_filesystem(params: Dictionary) -> Dictionary:
var name_filter: String = params.get("name", "")
var type_filter: String = params.get("type", "")
var path_filter: String = params.get("path", "")
if name_filter.is_empty() and type_filter.is_empty() and path_filter.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (name, type, path) is required")
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
var results: Array[Dictionary] = []
_scan_directory(efs.get_filesystem(), name_filter, type_filter, path_filter, results)
return {"data": {"files": results, "count": results.size()}}
func _scan_directory(dir: EditorFileSystemDirectory, name_filter: String, type_filter: String, path_filter: String, out: Array[Dictionary]) -> void:
for i in dir.get_file_count():
var file_path := dir.get_file_path(i)
var file_type := dir.get_file_type(i)
var matches := true
if not name_filter.is_empty():
if file_path.get_file().to_lower().find(name_filter.to_lower()) == -1:
matches = false
if matches and not type_filter.is_empty():
if file_type != type_filter:
matches = false
if matches and not path_filter.is_empty():
if file_path.to_lower().find(path_filter.to_lower()) == -1:
matches = false
if matches:
out.append({
"path": file_path,
"type": file_type,
})
for i in dir.get_subdir_count():
_scan_directory(dir.get_subdir(i), name_filter, type_filter, path_filter, out)