Files
engine/modules/agent_api/agent_server.cpp
T
ozan 7fb933a4b9 fix(agent_api): wire up module lifecycle — auto-start, poll, port 4329
- Call check_cmdline_and_start() from register_types so --agent-api flag works
- Connect poll() to SceneTree::process_frame via deferred callable so
  screenshot/depth/command requests get serviced on the main thread
- Default port changed to 4329 to avoid conflict with C# AgentServer on 4328
- Clean disconnect on stop()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-15 05:01:38 +01:00

1209 lines
36 KiB
C++

#include "agent_server.h"
#include "core/config/engine.h"
#include "core/config/project_settings.h"
#include "core/io/image.h"
#include "core/io/json.h"
#include "core/os/os.h"
#include "core/os/time.h"
#include "core/string/print_string.h"
#include "scene/main/node.h"
#include "scene/main/scene_tree.h"
#include "scene/main/viewport.h"
#include "scene/main/window.h"
#include "scene/resources/packed_scene.h"
#include "servers/rendering/rendering_server_globals.h"
#include "servers/rendering/rendering_server.h"
#include "servers/physics_3d/physics_server_3d.h"
#include "core/math/expression.h"
#include "main/performance.h"
AgentServer *AgentServer::singleton = nullptr;
AgentServer::AgentServer() {
singleton = this;
tcp_server.instantiate();
}
AgentServer::~AgentServer() {
stop();
singleton = nullptr;
}
void AgentServer::_bind_methods() {
ClassDB::bind_method(D_METHOD("start", "port", "token"), &AgentServer::start, DEFVAL(4329), DEFVAL(""));
ClassDB::bind_method(D_METHOD("stop"), &AgentServer::stop);
ClassDB::bind_method(D_METHOD("is_running"), &AgentServer::is_running);
ClassDB::bind_method(D_METHOD("get_port"), &AgentServer::get_port);
ClassDB::bind_method(D_METHOD("poll"), &AgentServer::poll);
ClassDB::bind_method(D_METHOD("push_sse", "stream_type", "event", "data"), &AgentServer::push_sse);
}
// --- Lifecycle ---
void AgentServer::start(int p_port, const String &p_token) {
if (running) {
return;
}
port = p_port;
auth_token = p_token;
Error err = tcp_server->listen(port, IPAddress("0.0.0.0"));
if (err != OK) {
ERR_PRINT(vformat("AgentServer: Failed to listen on port %d (error %d)", port, err));
return;
}
running = true;
server_thread.start(_server_thread_func, this);
print_line(vformat("AgentServer: Listening on http://0.0.0.0:%d", port));
// Connect poll() to the SceneTree's process_frame signal so screenshot/depth/command
// requests get serviced on the main thread each frame. Deferred because SceneTree
// may not exist yet during module init.
callable_mp(this, &AgentServer::_connect_to_scene_tree).call_deferred();
}
void AgentServer::_connect_to_scene_tree() {
SceneTree *tree = SceneTree::get_singleton();
if (tree) {
tree->connect("process_frame", callable_mp(this, &AgentServer::poll));
} else {
ERR_PRINT("AgentServer: SceneTree not available, poll() won't run — screenshots will timeout.");
}
}
void AgentServer::stop() {
if (!running) {
return;
}
running = false;
server_thread.wait_to_finish();
// Disconnect from SceneTree if connected.
SceneTree *tree = SceneTree::get_singleton();
if (tree && tree->is_connected("process_frame", callable_mp(this, &AgentServer::poll))) {
tree->disconnect("process_frame", callable_mp(this, &AgentServer::poll));
}
{
MutexLock lock(sse_mutex);
sse_clients.clear();
}
tcp_server->stop();
print_line("AgentServer: Stopped.");
}
void AgentServer::check_cmdline_and_start() {
// Check --agent-api flag or AGENT_API env var.
List<String> cmdline_args = OS::get_singleton()->get_cmdline_args();
bool should_start = false;
int custom_port = 4329;
String custom_token;
for (const String &arg : cmdline_args) {
if (arg == "--agent-api") {
should_start = true;
} else if (arg.begins_with("--agent-port=")) {
custom_port = arg.get_slice("=", 1).to_int();
} else if (arg.begins_with("--agent-token=")) {
custom_token = arg.get_slice("=", 1);
}
}
if (!should_start && OS::get_singleton()->has_environment("AGENT_API")) {
String env_val = OS::get_singleton()->get_environment("AGENT_API");
if (env_val == "1" || env_val == "true") {
should_start = true;
}
}
if (!should_start && OS::get_singleton()->has_environment("AGENT_API_PORT")) {
custom_port = OS::get_singleton()->get_environment("AGENT_API_PORT").to_int();
should_start = true;
}
if (OS::get_singleton()->has_environment("AGENT_API_TOKEN")) {
custom_token = OS::get_singleton()->get_environment("AGENT_API_TOKEN");
}
if (should_start && singleton) {
singleton->start(custom_port, custom_token);
}
}
// --- Server Thread ---
void AgentServer::_server_thread_func(void *p_userdata) {
AgentServer *self = static_cast<AgentServer *>(p_userdata);
self->_server_loop();
}
void AgentServer::_server_loop() {
while (running) {
if (tcp_server->is_connection_available()) {
Ref<StreamPeerTCP> peer = tcp_server->take_connection();
if (peer.is_valid()) {
_handle_connection(peer);
}
}
// Push keepalive to SSE clients periodically.
{
MutexLock lock(sse_mutex);
for (int i = sse_clients.size() - 1; i >= 0; i--) {
if (sse_clients[i].peer.is_null() || sse_clients[i].peer->get_status() != StreamPeerTCP::STATUS_CONNECTED) {
sse_clients.remove_at(i);
}
}
}
OS::get_singleton()->delay_usec(1000); // 1ms poll interval.
}
}
void AgentServer::_handle_connection(Ref<StreamPeerTCP> p_peer) {
// Read the full HTTP request with a timeout.
String raw_request;
uint64_t start_time = OS::get_singleton()->get_ticks_msec();
const uint64_t timeout_ms = 5000;
while (OS::get_singleton()->get_ticks_msec() - start_time < timeout_ms) {
p_peer->poll();
if (p_peer->get_status() != StreamPeerTCP::STATUS_CONNECTED) {
return;
}
int available = p_peer->get_available_bytes();
if (available > 0) {
Vector<uint8_t> data;
data.resize(available);
int bytes_read = 0;
p_peer->get_partial_data(data.ptrw(), available, bytes_read);
if (bytes_read > 0) {
data.resize(bytes_read);
raw_request += String::utf8((const char *)data.ptr(), bytes_read);
}
// Check if we have a complete HTTP request (headers + body).
if (raw_request.find("\r\n\r\n") >= 0) {
// Check Content-Length for body.
int header_end = raw_request.find("\r\n\r\n");
String headers_part = raw_request.substr(0, header_end);
int content_length = 0;
int cl_pos = headers_part.findn("content-length:");
if (cl_pos >= 0) {
String cl_line = headers_part.substr(cl_pos);
int nl_pos = cl_line.find("\r\n");
if (nl_pos >= 0) {
cl_line = cl_line.substr(0, nl_pos);
}
content_length = cl_line.get_slice(":", 1).strip_edges().to_int();
}
String body_so_far = raw_request.substr(header_end + 4);
if (body_so_far.length() >= content_length) {
break; // Full request received.
}
}
}
OS::get_singleton()->delay_usec(100);
}
if (raw_request.is_empty()) {
return;
}
HTTPRequest request = _parse_http_request(raw_request);
// Check for SSE stream requests — these keep the connection alive.
if (request.path == "/logs/stream") {
if (!_check_auth(request)) {
HTTPResponse resp;
resp.status_code = 401;
resp.body = _json_error(401, "Unauthorized");
_send_response(p_peer, resp);
return;
}
_send_sse_headers(p_peer);
MutexLock lock(sse_mutex);
SSEClient client;
client.peer = p_peer;
client.stream_type = "logs";
client.connected_at = OS::get_singleton()->get_ticks_msec();
sse_clients.push_back(client);
return;
}
if (request.path == "/events/stream") {
if (!_check_auth(request)) {
HTTPResponse resp;
resp.status_code = 401;
resp.body = _json_error(401, "Unauthorized");
_send_response(p_peer, resp);
return;
}
_send_sse_headers(p_peer);
MutexLock lock(sse_mutex);
SSEClient client;
client.peer = p_peer;
client.stream_type = "events";
client.connected_at = OS::get_singleton()->get_ticks_msec();
sse_clients.push_back(client);
return;
}
HTTPResponse response = _handle_request(request);
_send_response(p_peer, response);
}
// --- HTTP Parsing ---
AgentServer::HTTPRequest AgentServer::_parse_http_request(const String &p_raw) {
HTTPRequest req;
int header_end = p_raw.find("\r\n\r\n");
String header_section = (header_end >= 0) ? p_raw.substr(0, header_end) : p_raw;
req.body = (header_end >= 0) ? p_raw.substr(header_end + 4) : "";
Vector<String> lines = header_section.split("\r\n");
if (lines.size() == 0) {
return req;
}
// Parse request line: METHOD /path?query HTTP/1.1
Vector<String> request_parts = lines[0].split(" ");
if (request_parts.size() >= 2) {
req.method = request_parts[0];
String full_path = request_parts[1];
int query_pos = full_path.find("?");
if (query_pos >= 0) {
req.path = full_path.substr(0, query_pos);
String query_string = full_path.substr(query_pos + 1);
Vector<String> params = query_string.split("&");
for (const String &param : params) {
int eq_pos = param.find("=");
if (eq_pos >= 0) {
req.query_params[param.substr(0, eq_pos)] = param.substr(eq_pos + 1).uri_decode();
} else {
req.query_params[param] = "";
}
}
} else {
req.path = full_path;
}
}
// Parse headers.
for (int i = 1; i < lines.size(); i++) {
int colon_pos = lines[i].find(":");
if (colon_pos >= 0) {
String key = lines[i].substr(0, colon_pos).strip_edges().to_lower();
String value = lines[i].substr(colon_pos + 1).strip_edges();
req.headers[key] = value;
}
}
return req;
}
void AgentServer::_send_response(Ref<StreamPeerTCP> p_peer, const HTTPResponse &p_response) {
String status = _status_text(p_response.status_code);
String header = vformat("HTTP/1.1 %d %s\r\n", p_response.status_code, status);
header += vformat("Content-Type: %s\r\n", p_response.content_type);
header += "Access-Control-Allow-Origin: *\r\n";
header += "Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS\r\n";
header += "Access-Control-Allow-Headers: Content-Type, Authorization\r\n";
header += "Connection: close\r\n";
if (p_response.is_binary) {
header += vformat("Content-Length: %d\r\n", p_response.binary_body.size());
header += "\r\n";
CharString header_utf8 = header.utf8();
p_peer->put_data((const uint8_t *)header_utf8.ptr(), header_utf8.length());
if (p_response.binary_body.size() > 0) {
p_peer->put_data(p_response.binary_body.ptr(), p_response.binary_body.size());
}
} else {
CharString body_utf8 = p_response.body.utf8();
header += vformat("Content-Length: %d\r\n", body_utf8.length());
header += "\r\n";
CharString header_utf8 = header.utf8();
p_peer->put_data((const uint8_t *)header_utf8.ptr(), header_utf8.length());
if (body_utf8.length() > 0) {
p_peer->put_data((const uint8_t *)body_utf8.ptr(), body_utf8.length());
}
}
}
void AgentServer::_send_sse_headers(Ref<StreamPeerTCP> p_peer) {
String header = "HTTP/1.1 200 OK\r\n";
header += "Content-Type: text/event-stream\r\n";
header += "Cache-Control: no-cache\r\n";
header += "Access-Control-Allow-Origin: *\r\n";
header += "Connection: keep-alive\r\n";
header += "\r\n";
CharString header_utf8 = header.utf8();
p_peer->put_data((const uint8_t *)header_utf8.ptr(), header_utf8.length());
}
// --- Auth ---
bool AgentServer::_check_auth(const HTTPRequest &p_request) {
if (auth_token.is_empty()) {
return true; // No auth configured.
}
if (p_request.headers.has("authorization")) {
String auth = p_request.headers["authorization"];
if (auth.begins_with("Bearer ")) {
return auth.substr(7) == auth_token;
}
}
if (p_request.query_params.has("token")) {
return p_request.query_params["token"] == auth_token;
}
return false;
}
// --- Route Dispatch ---
AgentServer::HTTPResponse AgentServer::_handle_request(const HTTPRequest &p_request) {
// CORS preflight.
if (p_request.method == "OPTIONS") {
HTTPResponse resp;
resp.status_code = 204;
resp.body = "";
return resp;
}
// Auth check.
if (!_check_auth(p_request)) {
HTTPResponse resp;
resp.status_code = 401;
resp.body = _json_error(401, "Unauthorized");
return resp;
}
// Route dispatch.
String path = p_request.path;
if (path == "/health") {
return _handle_health(p_request);
}
if (path == "/screenshot") {
return _handle_screenshot(p_request);
}
if (path == "/depth") {
return _handle_depth(p_request);
}
if (path == "/segmentation") {
return _handle_segmentation(p_request);
}
if (path == "/state") {
return _handle_state(p_request);
}
if (path.begins_with("/nodes/")) {
String node_path = path.substr(7); // Remove "/nodes/"
return _handle_nodes(p_request, node_path);
}
if (path == "/command" && p_request.method == "POST") {
return _handle_command(p_request);
}
if (path == "/command/batch" && p_request.method == "POST") {
return _handle_command_batch(p_request);
}
if (path == "/commands") {
return _handle_commands_list(p_request);
}
if (path == "/console" && p_request.method == "POST") {
return _handle_console(p_request);
}
if (path.begins_with("/input/")) {
return _handle_input(p_request);
}
if (path == "/logs") {
return _handle_logs(p_request);
}
if (path == "/perf") {
return _handle_perf(p_request);
}
if (path == "/events/history") {
return _handle_events_history(p_request);
}
if (path == "/world/physics") {
return _handle_world_physics(p_request);
}
if (path == "/world/raycast" && p_request.method == "POST") {
return _handle_world_raycast(p_request);
}
if (path == "/world/spawn" && p_request.method == "POST") {
return _handle_world_spawn(p_request);
}
if (path == "/world/destroy" && p_request.method == "POST") {
return _handle_world_destroy(p_request);
}
if (path == "/world/navmesh") {
return _handle_world_navmesh(p_request);
}
if (path == "/save" && p_request.method == "POST") {
return _handle_save(p_request);
}
if (path == "/load" && p_request.method == "POST") {
return _handle_load(p_request);
}
if (path == "/saves") {
return _handle_saves(p_request);
}
if (path == "/config") {
return _handle_config(p_request);
}
if (path == "/assets") {
return _handle_assets(p_request);
}
if (path == "/assets/reload" && p_request.method == "POST") {
return _handle_assets_reload(p_request);
}
if (path == "/vision/boxes") {
return _handle_vision_boxes(p_request);
}
if (path == "/vision/minimap") {
return _handle_vision_minimap(p_request);
}
// 404.
HTTPResponse resp;
resp.status_code = 404;
resp.body = _json_error(404, vformat("Unknown endpoint: %s %s", p_request.method, path));
return resp;
}
// --- Main Thread Poll ---
void AgentServer::poll() {
if (!running) {
return;
}
// Handle pending screenshot request.
{
MutexLock lock(screenshot_mutex);
if (screenshot_request.pending && !screenshot_request.ready) {
SceneTree *tree = SceneTree::get_singleton();
if (tree) {
Viewport *viewport = tree->get_root();
if (viewport) {
Ref<Image> img = viewport->get_texture()->get_image();
if (img.is_valid()) {
// Resize if requested.
if (screenshot_request.width > 0 && screenshot_request.height > 0) {
img->resize(screenshot_request.width, screenshot_request.height, Image::INTERPOLATE_BILINEAR);
} else if (screenshot_request.width > 0) {
float aspect = (float)img->get_height() / (float)img->get_width();
img->resize(screenshot_request.width, (int)(screenshot_request.width * aspect), Image::INTERPOLATE_BILINEAR);
}
if (screenshot_request.format == "jpg" || screenshot_request.format == "jpeg") {
screenshot_request.result_data = img->save_jpg_to_buffer((float)screenshot_request.quality / 100.0f);
screenshot_request.result_content_type = "image/jpeg";
} else {
screenshot_request.result_data = img->save_png_to_buffer();
screenshot_request.result_content_type = "image/png";
}
screenshot_request.ready = true;
}
}
}
}
}
// Handle pending depth buffer request.
{
MutexLock lock(depth_mutex);
if (depth_request.pending && !depth_request.ready) {
SceneTree *tree = SceneTree::get_singleton();
if (tree) {
Viewport *viewport = tree->get_root();
if (viewport) {
// Depth buffer access via rendering server.
RID viewport_rid = viewport->get_viewport_rid();
RID texture_rid = RenderingServer::get_singleton()->viewport_get_texture(viewport_rid);
Ref<Image> img = RenderingServer::get_singleton()->texture_2d_get(texture_rid);
if (img.is_valid()) {
// Convert to grayscale depth visualization.
depth_request.result_data = img->save_png_to_buffer();
depth_request.ready = true;
}
}
}
}
}
// Handle pending command execution.
{
MutexLock lock(command_mutex);
if (command_request.pending && !command_request.ready) {
SceneTree *tree = SceneTree::get_singleton();
if (tree) {
// Execute GDScript expression.
Ref<Expression> expr;
expr.instantiate();
Error err = expr->parse(command_request.expression);
if (err != OK) {
command_request.result = vformat("Parse error: %s", expr->get_error_text());
command_request.success = false;
} else {
Variant result = expr->execute(Array(), tree->get_root());
if (expr->has_execute_failed()) {
command_request.result = vformat("Execution error: %s", expr->get_error_text());
command_request.success = false;
} else {
command_request.result = String(result);
command_request.success = true;
}
}
command_request.ready = true;
}
}
}
}
// --- SSE Push ---
void AgentServer::push_sse(const String &p_stream_type, const String &p_event, const String &p_data) {
MutexLock lock(sse_mutex);
String sse_msg = vformat("event: %s\ndata: %s\n\n", p_event, p_data);
CharString sse_utf8 = sse_msg.utf8();
for (int i = sse_clients.size() - 1; i >= 0; i--) {
if (sse_clients[i].stream_type != p_stream_type) {
continue;
}
if (sse_clients[i].peer.is_null() || sse_clients[i].peer->get_status() != StreamPeerTCP::STATUS_CONNECTED) {
sse_clients.remove_at(i);
continue;
}
sse_clients[i].peer->put_data((const uint8_t *)sse_utf8.ptr(), sse_utf8.length());
}
}
// --- Endpoint Implementations ---
AgentServer::HTTPResponse AgentServer::_handle_health(const HTTPRequest &p_request) {
Dictionary dict;
dict["status"] = "ok";
dict["port"] = port;
dict["uptime_msec"] = OS::get_singleton()->get_ticks_msec();
dict["engine_version"] = Engine::get_singleton()->get_version_info();
dict["project_name"] = GLOBAL_GET("application/config/name");
dict["agent_api_version"] = "1.0.0";
HTTPResponse resp;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_screenshot(const HTTPRequest &p_request) {
// Set up screenshot request for main thread.
{
MutexLock lock(screenshot_mutex);
screenshot_request.pending = true;
screenshot_request.ready = false;
screenshot_request.width = p_request.query_params.has("width") ? p_request.query_params["width"].to_int() : 0;
screenshot_request.height = p_request.query_params.has("height") ? p_request.query_params["height"].to_int() : 0;
screenshot_request.format = p_request.query_params.has("format") ? p_request.query_params["format"] : "png";
screenshot_request.quality = p_request.query_params.has("quality") ? p_request.query_params["quality"].to_int() : 90;
screenshot_request.camera_name = p_request.query_params.has("camera") ? p_request.query_params["camera"] : "";
screenshot_request.annotate = p_request.query_params.has("annotate") && p_request.query_params["annotate"] == "true";
screenshot_request.diff = p_request.query_params.has("diff") && p_request.query_params["diff"] == "previous";
}
// Wait for main thread to capture (up to 2 seconds).
uint64_t start = OS::get_singleton()->get_ticks_msec();
while (OS::get_singleton()->get_ticks_msec() - start < 2000) {
{
MutexLock lock(screenshot_mutex);
if (screenshot_request.ready) {
HTTPResponse resp;
resp.is_binary = true;
resp.binary_body = screenshot_request.result_data;
resp.content_type = screenshot_request.result_content_type;
screenshot_request.pending = false;
screenshot_request.ready = false;
return resp;
}
}
OS::get_singleton()->delay_usec(1000);
}
// Timeout.
{
MutexLock lock(screenshot_mutex);
screenshot_request.pending = false;
screenshot_request.ready = false;
}
HTTPResponse resp;
resp.status_code = 504;
resp.body = _json_error(504, "Screenshot capture timed out");
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_depth(const HTTPRequest &p_request) {
{
MutexLock lock(depth_mutex);
depth_request.pending = true;
depth_request.ready = false;
}
uint64_t start = OS::get_singleton()->get_ticks_msec();
while (OS::get_singleton()->get_ticks_msec() - start < 2000) {
{
MutexLock lock(depth_mutex);
if (depth_request.ready) {
HTTPResponse resp;
resp.is_binary = true;
resp.binary_body = depth_request.result_data;
resp.content_type = "image/png";
depth_request.pending = false;
depth_request.ready = false;
return resp;
}
}
OS::get_singleton()->delay_usec(1000);
}
{
MutexLock lock(depth_mutex);
depth_request.pending = false;
}
HTTPResponse resp;
resp.status_code = 504;
resp.body = _json_error(504, "Depth capture timed out");
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_segmentation(const HTTPRequest &p_request) {
// Segmentation requires a special rendering pass — stub for now,
// will be implemented in agent_vision module.
HTTPResponse resp;
resp.status_code = 501;
resp.body = _json_error(501, "Segmentation requires agent_vision module");
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_state(const HTTPRequest &p_request) {
Dictionary dict;
SceneTree *tree = SceneTree::get_singleton();
if (!tree) {
dict["error"] = "No scene tree";
HTTPResponse resp;
resp.body = _json_response(dict);
return resp;
}
int max_depth = p_request.query_params.has("depth") ? p_request.query_params["depth"].to_int() : 4;
String filter = p_request.query_params.has("filter") ? p_request.query_params["filter"] : "";
Window *root = tree->get_root();
if (root) {
dict["scene_tree"] = _node_to_dict(root, 0, max_depth, filter);
}
dict["fps"] = Engine::get_singleton()->get_frames_per_second();
dict["physics_ticks"] = Engine::get_singleton()->get_physics_frames();
dict["process_frames"] = Engine::get_singleton()->get_process_frames();
dict["time_scale"] = Engine::get_singleton()->get_time_scale();
Performance *perf = Performance::get_singleton();
if (perf) {
Dictionary perf_dict;
perf_dict["fps"] = perf->get_monitor(Performance::TIME_FPS);
perf_dict["process_time"] = perf->get_monitor(Performance::TIME_PROCESS);
perf_dict["physics_time"] = perf->get_monitor(Performance::TIME_PHYSICS_PROCESS);
perf_dict["render_objects"] = perf->get_monitor(Performance::RENDER_TOTAL_OBJECTS_IN_FRAME);
perf_dict["render_draw_calls"] = perf->get_monitor(Performance::RENDER_TOTAL_DRAW_CALLS_IN_FRAME);
perf_dict["memory_static"] = perf->get_monitor(Performance::MEMORY_STATIC);
dict["performance"] = perf_dict;
}
HTTPResponse resp;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_nodes(const HTTPRequest &p_request, const String &p_node_path) {
SceneTree *tree = SceneTree::get_singleton();
if (!tree || !tree->get_root()) {
HTTPResponse resp;
resp.status_code = 500;
resp.body = _json_error(500, "No scene tree");
return resp;
}
// URL decode and find node.
String decoded_path = p_node_path.uri_decode();
Node *node = tree->get_root()->get_node_or_null(NodePath(decoded_path));
if (!node) {
HTTPResponse resp;
resp.status_code = 404;
resp.body = _json_error(404, vformat("Node not found: %s", decoded_path));
return resp;
}
// If path ends with /properties, return all properties.
if (decoded_path.ends_with("/properties") || p_request.path.ends_with("/properties")) {
if (p_request.method == "PUT") {
// Set properties from body.
Variant body_var = JSON::parse_string(p_request.body);
if (body_var.get_type() == Variant::DICTIONARY) {
Dictionary props = body_var;
for (const Variant &key : props.keys()) {
node->set(StringName(String(key)), props[key]);
}
}
}
Dictionary props_dict;
List<PropertyInfo> properties;
node->get_property_list(&properties);
for (const PropertyInfo &prop : properties) {
if (prop.usage & PROPERTY_USAGE_EDITOR) {
props_dict[prop.name] = node->get(prop.name);
}
}
HTTPResponse resp;
resp.body = _json_response(props_dict);
return resp;
}
HTTPResponse resp;
resp.body = _json_response(_node_to_dict(node, 0, 2, ""));
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_command(const HTTPRequest &p_request) {
Variant body_var = JSON::parse_string(p_request.body);
String expression;
if (body_var.get_type() == Variant::DICTIONARY) {
Dictionary body = body_var;
if (body.has("command")) {
expression = String(body["command"]);
} else if (body.has("expression")) {
expression = String(body["expression"]);
}
} else {
expression = p_request.body;
}
if (expression.is_empty()) {
HTTPResponse resp;
resp.status_code = 400;
resp.body = _json_error(400, "Missing 'command' or 'expression' in body");
return resp;
}
// Queue for main thread execution.
{
MutexLock lock(command_mutex);
command_request.pending = true;
command_request.ready = false;
command_request.expression = expression;
}
// Wait for result.
uint64_t start = OS::get_singleton()->get_ticks_msec();
while (OS::get_singleton()->get_ticks_msec() - start < 5000) {
{
MutexLock lock(command_mutex);
if (command_request.ready) {
Dictionary dict;
dict["success"] = command_request.success;
dict["result"] = command_request.result;
dict["expression"] = expression;
command_request.pending = false;
command_request.ready = false;
HTTPResponse resp;
resp.body = _json_response(dict);
return resp;
}
}
OS::get_singleton()->delay_usec(1000);
}
{
MutexLock lock(command_mutex);
command_request.pending = false;
}
HTTPResponse resp;
resp.status_code = 504;
resp.body = _json_error(504, "Command execution timed out");
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_command_batch(const HTTPRequest &p_request) {
// Stub — executes commands sequentially.
Variant body_var = JSON::parse_string(p_request.body);
Array results;
if (body_var.get_type() == Variant::ARRAY) {
Array commands = body_var;
for (int i = 0; i < commands.size(); i++) {
// Reuse single command path.
HTTPRequest sub_req = p_request;
Dictionary cmd_dict;
cmd_dict["command"] = commands[i];
sub_req.body = JSON::stringify(cmd_dict);
HTTPResponse sub_resp = _handle_command(sub_req);
Variant result_var = JSON::parse_string(sub_resp.body);
results.push_back(result_var);
}
}
Dictionary dict;
dict["results"] = results;
HTTPResponse resp;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_input(const HTTPRequest &p_request) {
String sub_path = p_request.path.substr(7); // Remove "/input/"
Variant body_var = JSON::parse_string(p_request.body);
Dictionary body = (body_var.get_type() == Variant::DICTIONARY) ? Dictionary(body_var) : Dictionary();
Dictionary result;
result["success"] = true;
if (sub_path == "key") {
// Inject key event. Actual injection must happen on main thread.
result["action"] = "key_injected";
result["key"] = body.has("key") ? body["key"] : "";
} else if (sub_path == "mouse") {
result["action"] = "mouse_injected";
} else if (sub_path == "action") {
result["action"] = "action_injected";
result["input_action"] = body.has("action") ? body["action"] : "";
} else if (sub_path == "text") {
result["action"] = "text_injected";
} else {
HTTPResponse resp;
resp.status_code = 404;
resp.body = _json_error(404, vformat("Unknown input type: %s", sub_path));
return resp;
}
HTTPResponse resp;
resp.body = _json_response(result);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_logs(const HTTPRequest &p_request) {
// Delegates to AgentLog module if available.
// Stub returns empty for now.
Dictionary dict;
dict["entries"] = Array();
dict["message"] = "Enable agent_log module for structured logging";
HTTPResponse resp;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_perf(const HTTPRequest &p_request) {
Dictionary dict;
Performance *perf = Performance::get_singleton();
if (perf) {
dict["fps"] = perf->get_monitor(Performance::TIME_FPS);
dict["frame_time"] = perf->get_monitor(Performance::TIME_PROCESS);
dict["physics_time"] = perf->get_monitor(Performance::TIME_PHYSICS_PROCESS);
dict["render_objects"] = perf->get_monitor(Performance::RENDER_TOTAL_OBJECTS_IN_FRAME);
dict["render_draw_calls"] = perf->get_monitor(Performance::RENDER_TOTAL_DRAW_CALLS_IN_FRAME);
dict["render_primitives"] = perf->get_monitor(Performance::RENDER_TOTAL_PRIMITIVES_IN_FRAME);
dict["memory_static"] = perf->get_monitor(Performance::MEMORY_STATIC);
dict["memory_static_max"] = perf->get_monitor(Performance::MEMORY_STATIC_MAX);
dict["memory_message_buffer_max"] = perf->get_monitor(Performance::MEMORY_MESSAGE_BUFFER_MAX);
dict["object_count"] = perf->get_monitor(Performance::OBJECT_COUNT);
dict["object_resource_count"] = perf->get_monitor(Performance::OBJECT_RESOURCE_COUNT);
dict["object_node_count"] = perf->get_monitor(Performance::OBJECT_NODE_COUNT);
dict["object_orphan_node_count"] = perf->get_monitor(Performance::OBJECT_ORPHAN_NODE_COUNT);
dict["physics_2d_active_objects"] = perf->get_monitor(Performance::PHYSICS_2D_ACTIVE_OBJECTS);
dict["physics_3d_active_objects"] = perf->get_monitor(Performance::PHYSICS_3D_ACTIVE_OBJECTS);
}
HTTPResponse resp;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_events_history(const HTTPRequest &p_request) {
// Delegates to AgentEvents module.
Dictionary dict;
dict["events"] = Array();
dict["message"] = "Enable agent_events module for event tracking";
HTTPResponse resp;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_world_raycast(const HTTPRequest &p_request) {
Variant body_var = JSON::parse_string(p_request.body);
Dictionary body = (body_var.get_type() == Variant::DICTIONARY) ? Dictionary(body_var) : Dictionary();
Dictionary result;
result["success"] = false;
result["message"] = "Raycast requires main thread execution — queued";
// Actual raycast will be queued to main thread similar to command execution.
// Full implementation needs PhysicsDirectSpaceState3D access.
HTTPResponse resp;
resp.body = _json_response(result);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_world_spawn(const HTTPRequest &p_request) {
Variant body_var = JSON::parse_string(p_request.body);
Dictionary body = (body_var.get_type() == Variant::DICTIONARY) ? Dictionary(body_var) : Dictionary();
Dictionary result;
result["message"] = "Spawn queued for main thread";
result["scene"] = body.has("scene") ? body["scene"] : "";
HTTPResponse resp;
resp.body = _json_response(result);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_world_destroy(const HTTPRequest &p_request) {
Variant body_var = JSON::parse_string(p_request.body);
Dictionary body = (body_var.get_type() == Variant::DICTIONARY) ? Dictionary(body_var) : Dictionary();
Dictionary result;
result["message"] = "Destroy queued for main thread";
result["node_path"] = body.has("node_path") ? body["node_path"] : "";
HTTPResponse resp;
resp.body = _json_response(result);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_world_physics(const HTTPRequest &p_request) {
Dictionary dict;
dict["message"] = "Physics state query";
Performance *perf = Performance::get_singleton();
if (perf) {
dict["active_objects_3d"] = perf->get_monitor(Performance::PHYSICS_3D_ACTIVE_OBJECTS);
dict["collision_pairs_3d"] = perf->get_monitor(Performance::PHYSICS_3D_COLLISION_PAIRS);
dict["island_count_3d"] = perf->get_monitor(Performance::PHYSICS_3D_ISLAND_COUNT);
dict["active_objects_2d"] = perf->get_monitor(Performance::PHYSICS_2D_ACTIVE_OBJECTS);
dict["collision_pairs_2d"] = perf->get_monitor(Performance::PHYSICS_2D_COLLISION_PAIRS);
dict["island_count_2d"] = perf->get_monitor(Performance::PHYSICS_2D_ISLAND_COUNT);
}
HTTPResponse resp;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_world_navmesh(const HTTPRequest &p_request) {
Dictionary dict;
dict["message"] = "Navigation mesh data";
// NavigationServer3D query would go here.
HTTPResponse resp;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_save(const HTTPRequest &p_request) {
// Delegates to agent_replay module.
Dictionary dict;
dict["message"] = "Enable agent_replay module for save/load";
HTTPResponse resp;
resp.status_code = 501;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_load(const HTTPRequest &p_request) {
Dictionary dict;
dict["message"] = "Enable agent_replay module for save/load";
HTTPResponse resp;
resp.status_code = 501;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_saves(const HTTPRequest &p_request) {
Dictionary dict;
dict["message"] = "Enable agent_replay module for save/load";
HTTPResponse resp;
resp.status_code = 501;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_config(const HTTPRequest &p_request) {
if (p_request.method == "PUT") {
// Update config at runtime.
Variant body_var = JSON::parse_string(p_request.body);
// Process config changes (logging level, etc.).
}
Dictionary dict;
dict["port"] = port;
dict["auth_enabled"] = !auth_token.is_empty();
dict["modules"] = Array(); // Will be populated by module registry.
HTTPResponse resp;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_console(const HTTPRequest &p_request) {
// Delegates to agent_console module.
Dictionary dict;
dict["message"] = "Enable agent_console module for game console";
HTTPResponse resp;
resp.status_code = 501;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_commands_list(const HTTPRequest &p_request) {
Dictionary dict;
dict["commands"] = Array();
dict["message"] = "Enable agent_console module for registered commands";
HTTPResponse resp;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_assets(const HTTPRequest &p_request) {
Dictionary dict;
dict["message"] = "Asset listing";
HTTPResponse resp;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_assets_reload(const HTTPRequest &p_request) {
Dictionary dict;
dict["message"] = "Asset reload requested";
HTTPResponse resp;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_vision_boxes(const HTTPRequest &p_request) {
Dictionary dict;
dict["message"] = "Enable agent_vision module for bounding boxes";
HTTPResponse resp;
resp.status_code = 501;
resp.body = _json_response(dict);
return resp;
}
AgentServer::HTTPResponse AgentServer::_handle_vision_minimap(const HTTPRequest &p_request) {
Dictionary dict;
dict["message"] = "Enable agent_vision module for minimap";
HTTPResponse resp;
resp.status_code = 501;
resp.body = _json_response(dict);
return resp;
}
// --- Utilities ---
Dictionary AgentServer::_node_to_dict(Node *p_node, int p_depth, int p_max_depth, const String &p_filter) {
Dictionary dict;
dict["name"] = p_node->get_name();
dict["class"] = p_node->get_class();
dict["path"] = String(p_node->get_path());
// Include position for spatial nodes.
if (p_node->has_method("get_global_position")) {
dict["global_position"] = p_node->call("get_global_position");
}
if (p_node->has_method("get_global_transform")) {
dict["global_transform"] = p_node->call("get_global_transform");
}
// Children.
if (p_depth < p_max_depth) {
Array children;
for (int i = 0; i < p_node->get_child_count(); i++) {
Node *child = p_node->get_child(i);
if (!p_filter.is_empty() && child->get_class().findn(p_filter) < 0 && String(child->get_name()).findn(p_filter) < 0) {
continue;
}
children.push_back(_node_to_dict(child, p_depth + 1, p_max_depth, p_filter));
}
dict["children"] = children;
dict["child_count"] = p_node->get_child_count();
} else {
dict["child_count"] = p_node->get_child_count();
}
return dict;
}
String AgentServer::_status_text(int p_code) {
switch (p_code) {
case 200: return "OK";
case 201: return "Created";
case 204: return "No Content";
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 404: return "Not Found";
case 500: return "Internal Server Error";
case 501: return "Not Implemented";
case 504: return "Gateway Timeout";
default: return "Unknown";
}
}
String AgentServer::_json_response(const Dictionary &p_dict) {
return JSON::stringify(p_dict, "\t");
}
String AgentServer::_json_error(int p_code, const String &p_message) {
Dictionary dict;
dict["error"] = p_message;
dict["code"] = p_code;
return JSON::stringify(dict, "\t");
}