更新godot ai到最新版本
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
@tool
|
||||
extends "res://addons/godot_ai/handlers/command_handler.gd"
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
## Executes a list of sub-commands through the dispatcher with stop-on-first-error
|
||||
## semantics. When undo=true (default), any successful sub-commands are rolled
|
||||
## back via the scene's UndoRedo history if a later sub-command fails.
|
||||
|
||||
## Commands that cannot run as batch sub-commands, each with the reason a batch
|
||||
## can't host it.
|
||||
## - batch_execute: would recurse.
|
||||
## - reload_plugin: schedules teardown; later batch operations could pump
|
||||
## its callback while the batch handler is still executing.
|
||||
## - run_tests: a batch executes synchronously inside one dispatcher tick with
|
||||
## NO transport servicing, so a full suite starves the WebSocket heartbeat
|
||||
## (the exact disconnect the serviced test_run path exists to prevent) and
|
||||
## the Python batch handler only allows 30s anyway — call the test_run tool
|
||||
## directly.
|
||||
## - game_command: it is deferred — its reply flows out-of-band correlated by a
|
||||
## _request_id that dispatch_direct deliberately strips (see
|
||||
## McpDispatcher.dispatch_direct), so a game op nested in a batch would have
|
||||
## no completion channel and hang or lose its reply. input_sequence made this
|
||||
## concrete (#814); the whole game_command surface shares the deferred path.
|
||||
const FORBIDDEN_SUBCOMMANDS := {
|
||||
"reload_plugin": "reload_plugin must be called directly so no batch continues during reload",
|
||||
"batch_execute": "batch_execute cannot be nested inside another batch",
|
||||
"run_tests":
|
||||
"run_tests is not allowed as a sub-command — a batch runs synchronously "
|
||||
+ "with no transport servicing; call the test_run tool directly",
|
||||
"game_command":
|
||||
"game_command ops are deferred (their reply arrives out-of-band) and "
|
||||
+ "have no completion channel inside a batch — run them as their own tool call",
|
||||
"configure_client":
|
||||
"configure_client is deferred and has no completion channel inside a batch — "
|
||||
+ "run it as its own tool call",
|
||||
"remove_client":
|
||||
"remove_client is deferred and has no completion channel inside a batch — "
|
||||
+ "run it as its own tool call",
|
||||
}
|
||||
|
||||
## The whole batch executes synchronously inside one dispatcher tick,
|
||||
## outside the 4ms frame budget — an unbounded array freezes the editor
|
||||
## for the batch's full duration. 500 is far above any legitimate scene
|
||||
## edit while keeping worst-case stalls in check.
|
||||
const MAX_BATCH_COMMANDS := 500
|
||||
|
||||
var _dispatcher: McpDispatcher
|
||||
var _undo_redo: EditorUndoRedoManager
|
||||
|
||||
|
||||
func _init(dispatcher: McpDispatcher, undo_redo: EditorUndoRedoManager) -> void:
|
||||
_dispatcher = dispatcher
|
||||
_undo_redo = undo_redo
|
||||
|
||||
|
||||
func batch_execute(params: Dictionary) -> Dictionary:
|
||||
var commands = params.get("commands", null)
|
||||
if typeof(commands) != TYPE_ARRAY:
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands must be a list")
|
||||
if commands.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "commands must not be empty")
|
||||
if commands.size() > MAX_BATCH_COMMANDS:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"commands exceeds the %d-command batch cap (got %d) — split into multiple batches" % [MAX_BATCH_COMMANDS, commands.size()]
|
||||
)
|
||||
|
||||
var undo: bool = params.get("undo", true)
|
||||
|
||||
for idx in range(commands.size()):
|
||||
var item = commands[idx]
|
||||
if typeof(item) != TYPE_DICTIONARY:
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands[%d] must be a dict" % idx)
|
||||
var cmd_name: String = item.get("command", "")
|
||||
if cmd_name.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "commands[%d] missing 'command' field" % idx)
|
||||
if FORBIDDEN_SUBCOMMANDS.has(cmd_name):
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"commands[%d]: %s" % [idx, FORBIDDEN_SUBCOMMANDS[cmd_name]])
|
||||
if not _dispatcher.has_command(cmd_name):
|
||||
return _unknown_command_error(idx, cmd_name)
|
||||
## Pre-validate params type: the execution loop's typed Dictionary
|
||||
## local would hard-error on a non-dict mid-batch, aborting AFTER
|
||||
## earlier mutations committed. Catching it here keeps the
|
||||
## all-or-nothing contract for malformed input.
|
||||
if typeof(item.get("params", {})) != TYPE_DICTIONARY:
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands[%d].params must be a dict" % idx)
|
||||
if cmd_name.begins_with("custom_tool:"):
|
||||
var _custom_tools := McpToolRegistry.get_instance()
|
||||
if _custom_tools:
|
||||
var spec := _custom_tools.get_spec(cmd_name.trim_prefix("custom_tool:"))
|
||||
## A deferred tool's real reply arrives via ctx.send_deferred
|
||||
## AFTER the batch has already returned its synchronous
|
||||
## results — the sub-command would report an opaque
|
||||
## "_deferred" placeholder and the actual payload would be
|
||||
## orphaned. Reject up-front, matching the FORBIDDEN_SUBCOMMANDS
|
||||
## contract for run_tests.
|
||||
if spec != null and spec.deferred:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"commands[%d] custom_tool:%s is deferred — its reply outlives the batch response; call it directly instead" % [idx, cmd_name.trim_prefix("custom_tool:")]
|
||||
)
|
||||
if undo and spec != null and not spec.undoable:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.CUSTOM_TOOL_NOT_UNDOABLE,
|
||||
"commands[%d] custom_tool:%s is not undoable" % [idx, cmd_name.trim_prefix("custom_tool:")]
|
||||
)
|
||||
|
||||
var results: Array = []
|
||||
var succeeded := 0
|
||||
var stopped_at = null
|
||||
var all_undoable := true
|
||||
## One entry per action a sub-command actually committed, in commit order,
|
||||
## naming the UndoRedo that received it. Rollback replays this in reverse.
|
||||
##
|
||||
## Measured rather than inferred. Counting *successes* over-undoes, because
|
||||
## a sub-command can succeed without committing anything (`create_script`
|
||||
## and `write_text_file` write straight to disk) — the extra undos then eat
|
||||
## the user's own pre-batch edits. Trusting the response's `undoable` flag
|
||||
## instead under-undoes, because `custom_tool:` sub-commands never carry one
|
||||
## (`custom_tool_wrapper.gd` returns the addon handler's dict verbatim) even
|
||||
## when their spec declares `undoable = true` and they did commit. Comparing
|
||||
## `UndoRedo.get_version()` across the call is the only reading that can't
|
||||
## disagree with what the editor actually recorded.
|
||||
var committed: Array = []
|
||||
|
||||
for idx in range(commands.size()):
|
||||
var item: Dictionary = commands[idx]
|
||||
var cmd_name: String = item["command"]
|
||||
var sub_params: Dictionary = item.get("params", {})
|
||||
|
||||
var tracked := _tracked_histories()
|
||||
var before: Array = []
|
||||
for ur in tracked:
|
||||
before.append(ur.get_version())
|
||||
|
||||
var raw_result: Dictionary = _dispatcher.dispatch_direct(cmd_name, sub_params)
|
||||
## Record before reading status. A handler can commit_action() and still
|
||||
## return an error; those actions must be in `committed` so a later
|
||||
## (or this) failure rolls them back.
|
||||
_record_committed(tracked, before, committed)
|
||||
var status: String = raw_result.get("status", "ok")
|
||||
|
||||
var result_entry: Dictionary = {"command": cmd_name, "status": status}
|
||||
if status == "error":
|
||||
result_entry["error"] = raw_result.get("error", {})
|
||||
results.append(result_entry)
|
||||
stopped_at = idx
|
||||
break
|
||||
else:
|
||||
var data: Dictionary = raw_result.get("data", raw_result)
|
||||
result_entry["data"] = data
|
||||
if typeof(data) == TYPE_DICTIONARY and data.get("undoable", false) != true:
|
||||
all_undoable = false
|
||||
results.append(result_entry)
|
||||
succeeded += 1
|
||||
|
||||
var rolled_back := false
|
||||
if stopped_at != null and undo and not committed.is_empty():
|
||||
rolled_back = _rollback(committed)
|
||||
|
||||
var response_data: Dictionary = {
|
||||
"succeeded": succeeded,
|
||||
"stopped_at": stopped_at,
|
||||
"results": results,
|
||||
"undo": undo,
|
||||
"rolled_back": rolled_back,
|
||||
"undoable": stopped_at == null and all_undoable and not rolled_back,
|
||||
}
|
||||
if stopped_at != null:
|
||||
response_data["error"] = results[-1]["error"]
|
||||
return {"data": response_data}
|
||||
|
||||
|
||||
## The histories a batch sub-command can commit into.
|
||||
##
|
||||
## Most write handlers pin their action to the edited scene, but not all:
|
||||
## handlers that `create_action(label)` without a context object bind it to the
|
||||
## handler `RefCounted` instead, and it lands in `GLOBAL_HISTORY` (see
|
||||
## `animation_handler.gd`'s note on mismatched histories, and
|
||||
## `testing/test_suite.gd`, which watches both for the same reason). Watching
|
||||
## only the scene history would leave those actions un-rolled-back.
|
||||
func _tracked_histories() -> Array:
|
||||
var out: Array = []
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
if scene_root != null:
|
||||
var scene_ur := _undo_redo.get_history_undo_redo(
|
||||
_undo_redo.get_object_history_id(scene_root)
|
||||
)
|
||||
if scene_ur != null:
|
||||
out.append(scene_ur)
|
||||
var global_ur := _undo_redo.get_history_undo_redo(EditorUndoRedoManager.GLOBAL_HISTORY)
|
||||
if global_ur != null and not global_ur in out:
|
||||
out.append(global_ur)
|
||||
return out
|
||||
|
||||
|
||||
## Append one entry to `committed` per action the just-finished sub-command
|
||||
## pushed, by diffing each watched history's version against `before`.
|
||||
## `UndoRedo.get_version()` advances once per `commit_action`, so the delta is
|
||||
## the action count — a handler that commits twice is recorded twice, and one
|
||||
## that commits nothing is recorded not at all.
|
||||
func _record_committed(tracked: Array, before: Array, committed: Array) -> void:
|
||||
for i in range(tracked.size()):
|
||||
var delta: int = tracked[i].get_version() - int(before[i])
|
||||
for _n in range(delta):
|
||||
committed.append(tracked[i])
|
||||
|
||||
|
||||
## Build the unknown-command error for a sub-command. Clarifies that
|
||||
## batch_execute expects plugin command names (not MCP tool names) and
|
||||
## surfaces fuzzy suggestions in both the message and structured data.
|
||||
func _unknown_command_error(idx: int, cmd_name: String) -> Dictionary:
|
||||
var suggestions := _dispatcher.suggest_similar(cmd_name)
|
||||
var msg := "commands[%d]: unknown plugin command '%s'. batch_execute expects plugin command names (e.g. 'create_node'), not MCP tool names (e.g. 'node_create')." % [idx, cmd_name]
|
||||
if not suggestions.is_empty():
|
||||
msg += " Did you mean: %s?" % ", ".join(suggestions)
|
||||
var err := ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, msg)
|
||||
err["error"]["data"] = {"suggestions": suggestions}
|
||||
return err
|
||||
|
||||
|
||||
## Undo every action the batch committed, newest first, against the history
|
||||
## that actually received it. Returns true iff every undo() reported success.
|
||||
##
|
||||
## Strict LIFO across histories matters: undoing a scene action before a global
|
||||
## one committed after it would replay them out of order. Walking `committed`
|
||||
## in reverse preserves commit order regardless of which history each landed in.
|
||||
func _rollback(committed: Array) -> bool:
|
||||
if committed.is_empty():
|
||||
return false
|
||||
var ok := true
|
||||
for i in range(committed.size() - 1, -1, -1):
|
||||
if not committed[i].undo():
|
||||
ok = false
|
||||
return ok
|
||||
Reference in New Issue
Block a user