From d006d3fa0d9753fb7cd96abc1308f20b97dfb445 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Sat, 11 Jul 2026 22:09:48 -0400 Subject: [PATCH] v0.0.9 - Fixed snapshot tool: AGENT_SNAPSHOT_JS was self-executing IIFE causing syntax error when wrapped with args. Changed to function definition. Snapshot now works async. Eval works async. Login, open, get_title, tab_list all pass. --- VERSION | 2 +- plans/agent-tools.md | 9 ++ src/agent_server.c | 28 ++---- src/agent_snapshot.c | 225 ++++++++++++++++++++++++++++++++++++------- src/agent_snapshot.h | 37 +++++++ src/agent_tools.c | 57 ++++++++--- src/agent_tools.h | 11 ++- src/version.h | 4 +- 8 files changed, 301 insertions(+), 72 deletions(-) diff --git a/VERSION b/VERSION index d169b2f..c5d54ec 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.8 +0.0.9 diff --git a/plans/agent-tools.md b/plans/agent-tools.md index 99451d9..866c88c 100644 --- a/plans/agent-tools.md +++ b/plans/agent-tools.md @@ -46,6 +46,15 @@ flowchart TB ### Key design decisions +0. **Agent tools must use the same code path as the GUI** — Agent tools + should call the same C functions the GUI calls, not a parallel + implementation. This ensures that any error a user would see, the + agent also sees. Debugging WebKit internals is not our concern; our + C code is. If the agent and GUI take different paths, bugs can hide + from the agent. The login tools are the one exception (backend-only, + bypassing the GTK dialog) — all browser tools go through the same + `WebKitWebView` and `tab_manager` APIs. + 1. **WebSocket server via libsoup** — `SoupServer` with `soup_server_add_websocket_handler()` provides a production-ready WebSocket server. libsoup is already linked (WebKitGTK dependency). diff --git a/src/agent_server.c b/src/agent_server.c index 68ddb9e..9cff6dc 100644 --- a/src/agent_server.c +++ b/src/agent_server.c @@ -14,8 +14,8 @@ #include #include -/* Forward declarations from agent_tools.c (created next) */ -extern cJSON *agent_tools_dispatch(cJSON *request); +/* Forward declarations from agent_tools.c */ +extern cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn); /* ── Static state ─────────────────────────────────────────────────── */ @@ -92,39 +92,27 @@ static void on_ws_message(SoupWebsocketConnection *conn, (void)user_data; (void)type; - g_print("[agent] on_ws_message called\n"); - gsize size = 0; const gchar *data = g_bytes_get_data(message, &size); - if (data == NULL || size == 0) { - g_print("[agent] on_ws_message: empty data\n"); - return; - } - - g_print("[agent] on_ws_message: %.*s\n", (int)(size > 200 ? 200 : size), data); + if (data == NULL || size == 0) return; /* Parse the JSON request. */ cJSON *request = cJSON_ParseWithLength(data, size); if (request == NULL) { - g_print("[agent] on_ws_message: JSON parse failed\n"); const char *err_json = "{\"success\":false,\"error\":{\"code\":\"INVALID_JSON\"," "\"message\":\"Failed to parse JSON request\"}}"; send_json_to_client(conn, err_json); return; } - g_print("[agent] on_ws_message: dispatching tool\n"); - - /* Dispatch the tool. */ - cJSON *response = agent_tools_dispatch(request); + /* Dispatch the tool. Pass the WebSocket connection so async tools + * (snapshot, eval) can send their response directly when JS completes. */ + cJSON *response = agent_tools_dispatch(request, conn); cJSON_Delete(request); - g_print("[agent] on_ws_message: dispatch returned %p\n", (void*)response); - if (response == NULL) { - const char *err_json = "{\"success\":false,\"error\":{\"code\":\"INTERNAL_ERROR\"," - "\"message\":\"Tool dispatch returned NULL\"}}"; - send_json_to_client(conn, err_json); + /* NULL means the tool is sending its response asynchronously + * (e.g. snapshot, eval). Don't send anything here. */ return; } diff --git a/src/agent_snapshot.c b/src/agent_snapshot.c index 0b9c245..32f14f4 100644 --- a/src/agent_snapshot.c +++ b/src/agent_snapshot.c @@ -13,7 +13,9 @@ /* ── Synchronous JS evaluation ─────────────────────────────────────── * * WebKitGTK's evaluate_javascript is async. We use a nested GMainLoop - * to wait for the result synchronously. + * on the default context to wait for the result. The key is to acquire + * the context before creating the loop so that the WebKit IPC sources + * are properly dispatched. */ typedef struct { @@ -28,7 +30,6 @@ static void on_js_finished(GObject *source, GAsyncResult *res, gpointer user_dat JSCValue *value = webkit_web_view_evaluate_javascript_finish(webview, res, NULL); if (value != NULL) { - /* Convert the JSCValue to a string. */ char *str = jsc_value_to_string(value); if (str != NULL) { ctx->result = g_strdup(str); @@ -38,43 +39,165 @@ static void on_js_finished(GObject *source, GAsyncResult *res, gpointer user_dat } ctx->done = TRUE; - if (g_main_loop_is_running(ctx->loop)) { + if (ctx->loop && g_main_loop_is_running(ctx->loop)) { g_main_loop_quit(ctx->loop); } } +static gboolean on_js_timeout(gpointer user_data) { + js_eval_ctx_t *ctx = (js_eval_ctx_t *)user_data; + if (!ctx->done) { + g_printerr("[agent] JS evaluation timed out\n"); + if (ctx->loop && g_main_loop_is_running(ctx->loop)) { + g_main_loop_quit(ctx->loop); + } + } + return G_SOURCE_REMOVE; +} + char *agent_js_eval_sync(WebKitWebView *webview, const char *script, int timeout_ms) { if (webview == NULL || script == NULL) return NULL; js_eval_ctx_t ctx = {0}; - ctx.loop = g_main_loop_new(NULL, FALSE); + + /* Use the default main context for the nested loop. */ + GMainContext *main_ctx = g_main_context_default(); + g_main_context_acquire(main_ctx); + + ctx.loop = g_main_loop_new(main_ctx, FALSE); + + /* Add a timeout source to the same context. */ + guint timeout_id = g_timeout_add(timeout_ms, on_js_timeout, &ctx); /* Start async evaluation. */ webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL, on_js_finished, &ctx); - /* Run a nested main loop with a timeout. */ - if (!ctx.done) { - GSource *timeout_src = g_timeout_source_new(timeout_ms); - g_source_set_callback(timeout_src, (GSourceFunc)g_main_loop_quit, ctx.loop, NULL); - g_source_attach(timeout_src, g_main_context_get_thread_default()); - g_source_unref(timeout_src); - - g_main_loop_run(ctx.loop); - g_source_destroy(timeout_src); - } + /* Run the nested loop until JS completes or timeout. */ + g_main_loop_run(ctx.loop); + /* Cleanup. */ + g_source_remove(timeout_id); g_main_loop_unref(ctx.loop); - - if (!ctx.done) { - g_printerr("[agent] JS evaluation timed out (%d ms)\n", timeout_ms); - return NULL; - } + g_main_context_release(main_ctx); return ctx.result; } +/* ── Async JS evaluation ──────────────────────────────────────────── * + * For tools that need JS return values (snapshot, eval, get_text, etc.), + * we use async evaluation. The JS completion callback sends the + * WebSocket response directly, avoiding the nested main loop issue. + */ + +typedef struct { + SoupWebsocketConnection *conn; + int request_id; + char *tool_name; + js_result_handler_t handler; +} async_js_ctx_t; + +static void send_ws_response(SoupWebsocketConnection *conn, cJSON *response) { + if (conn == NULL || response == NULL) return; + if (soup_websocket_connection_get_state(conn) != SOUP_WEBSOCKET_STATE_OPEN) { + cJSON_Delete(response); + return; + } + char *str = cJSON_PrintUnformatted(response); + if (str) { + soup_websocket_connection_send_text(conn, str); + free(str); + } + cJSON_Delete(response); +} + +static cJSON *make_success_resp(int id, cJSON *data) { + cJSON *resp = cJSON_CreateObject(); + cJSON_AddBoolToObject(resp, "success", TRUE); + cJSON_AddNumberToObject(resp, "id", id); + if (data) cJSON_AddItemToObject(resp, "data", data); + return resp; +} + +static cJSON *make_error_resp(int id, const char *code, const char *msg) { + cJSON *resp = cJSON_CreateObject(); + cJSON_AddBoolToObject(resp, "success", FALSE); + cJSON_AddNumberToObject(resp, "id", id); + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "code", code); + cJSON_AddStringToObject(err, "message", msg); + cJSON_AddItemToObject(resp, "error", err); + return resp; +} + +static void on_async_js_finished(GObject *source, GAsyncResult *res, gpointer user_data) { + async_js_ctx_t *ctx = (async_js_ctx_t *)user_data; + WebKitWebView *webview = WEBKIT_WEB_VIEW(source); + + JSCValue *value = webkit_web_view_evaluate_javascript_finish(webview, res, NULL); + if (value == NULL) { + send_ws_response(ctx->conn, + make_error_resp(ctx->request_id, "EVAL_FAILED", "JS evaluation returned NULL")); + goto cleanup; + } + + char *str = jsc_value_to_string(value); + g_object_unref(value); + + if (str == NULL) { + send_ws_response(ctx->conn, + make_error_resp(ctx->request_id, "EVAL_FAILED", "JS returned null")); + goto cleanup; + } + + char *result = g_strdup(str); + free(str); + + if (ctx->handler) { + /* Let the tool-specific handler transform the result. */ + cJSON *response = ctx->handler(result); + g_free(result); + if (response) { + /* Add the request id. */ + cJSON_AddNumberToObject(response, "id", ctx->request_id); + send_ws_response(ctx->conn, response); + } else { + send_ws_response(ctx->conn, + make_error_resp(ctx->request_id, "TOOL_FAILED", "Tool handler returned NULL")); + } + } else { + /* No handler — return raw result. */ + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "result", result); + g_free(result); + send_ws_response(ctx->conn, make_success_resp(ctx->request_id, data)); + } + +cleanup: + g_free(ctx->tool_name); + g_free(ctx); +} + +gboolean agent_js_eval_async(WebKitWebView *webview, + const char *script, + SoupWebsocketConnection *conn, + int request_id, + const char *tool_name, + js_result_handler_t handler) { + if (webview == NULL || script == NULL) return FALSE; + + async_js_ctx_t *ctx = g_new0(async_js_ctx_t, 1); + ctx->conn = conn; + ctx->request_id = request_id; + ctx->tool_name = g_strdup(tool_name ? tool_name : "unknown"); + ctx->handler = handler; + + webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL, + on_async_js_finished, ctx); + return TRUE; +} + /* ── Snapshot JS script ───────────────────────────────────────────── * * This script walks the DOM, assigns refs to interactive elements, * stores the ref map in window.__agentRefs, and returns a JSON string @@ -248,11 +371,11 @@ const char *AGENT_SNAPSHOT_JS = "\n" " /* Return JSON result. */\n" " return JSON.stringify({\n" -" snapshot: treeLines.join('\\n'),\n" +" snapshot: treeLines.join('\\\\n'),\n" " refs: refMap,\n" " refCount: refCount\n" " });\n" -"})();\n"; +"})\n"; /* ── Snapshot API ─────────────────────────────────────────────────── */ @@ -261,23 +384,15 @@ cJSON *agent_snapshot_take(WebKitWebView *webview, gboolean compact) { if (webview == NULL) return NULL; - /* Build the JS call with arguments. */ - char script[512]; - snprintf(script, sizeof(script), - "AGENT_SNAPSHOT_JS_WRAPPER = (%s); AGENT_SNAPSHOT_JS_WRAPPER(%s, %s);", - AGENT_SNAPSHOT_JS, - interactive ? "true" : "false", - compact ? "true" : "false"); + /* Build the JS call with arguments. Use g_strconcat instead of + * g_strdup_printf to avoid % format specifier issues in the JS. */ + char *full_script = g_strconcat( + "(", AGENT_SNAPSHOT_JS, ")(", + interactive ? "true" : "false", ", ", + compact ? "true" : "false", ")", + NULL); - /* Actually, we need to call the IIFE with arguments. Let's use a - * different approach — wrap the function and call it. */ - char *full_script = g_strdup_printf( - "(%s)(%s, %s)", - AGENT_SNAPSHOT_JS, - interactive ? "true" : "false", - compact ? "true" : "false"); - - char *result = agent_js_eval_sync(webview, full_script, 10000); + char *result = agent_js_eval_sync(webview, full_script, 15000); g_free(full_script); if (result == NULL) { @@ -289,3 +404,39 @@ cJSON *agent_snapshot_take(WebKitWebView *webview, return json; } + +/* ── Async snapshot ───────────────────────────────────────────────── */ + +/* Handler that transforms the snapshot JS result into a cJSON response. */ +static cJSON *snapshot_result_handler(const char *js_result) { + if (js_result == NULL || js_result[0] == '\0') { + return make_error_resp(0, "SNAPSHOT_FAILED", "Snapshot returned empty result"); + } + + cJSON *json = cJSON_Parse(js_result); + if (json == NULL) { + return make_error_resp(0, "SNAPSHOT_FAILED", "Failed to parse snapshot JSON"); + } + + return make_success_resp(0, json); +} + +gboolean agent_snapshot_take_async(WebKitWebView *webview, + gboolean interactive, + gboolean compact, + SoupWebsocketConnection *conn, + int request_id) { + if (webview == NULL) return FALSE; + + char *full_script = g_strconcat( + "(", AGENT_SNAPSHOT_JS, ")(", + interactive ? "true" : "false", ", ", + compact ? "true" : "false", ")", + NULL); + + gboolean started = agent_js_eval_async(webview, full_script, conn, + request_id, "snapshot", + snapshot_result_handler); + g_free(full_script); + return started; +} diff --git a/src/agent_snapshot.h b/src/agent_snapshot.h index f8d173a..13f814a 100644 --- a/src/agent_snapshot.h +++ b/src/agent_snapshot.h @@ -10,6 +10,7 @@ #define AGENT_SNAPSHOT_H #include +#include #include "cjson/cJSON.h" #ifdef __cplusplus @@ -38,6 +39,42 @@ cJSON *agent_snapshot_take(WebKitWebView *webview, gboolean interactive, gboolean compact); +/* + * Async snapshot — starts JS evaluation and sends the response through + * the WebSocket connection when the JS completes. Returns TRUE if the + * async evaluation was started (response will be sent async), FALSE if + * it failed immediately (caller should send an error response). + * + * conn: WebSocket connection to send the response through + * request_id: the JSON request id to include in the response + */ +gboolean agent_snapshot_take_async(WebKitWebView *webview, + gboolean interactive, + gboolean compact, + SoupWebsocketConnection *conn, + int request_id); + +/* + * Async JS evaluation — evaluates a script and sends the result as a + * tool response through the WebSocket connection when the JS completes. + * Returns TRUE if started async, FALSE on immediate failure. + * + * conn: WebSocket connection to send the response through + * request_id: the JSON request id to include in the response + * tool_name: the tool name (for logging) + * success_handler: optional callback to transform the JS result into + * a cJSON response before sending. If NULL, the raw + * JS result string is returned in data.result. + */ +typedef cJSON *(*js_result_handler_t)(const char *js_result); + +gboolean agent_js_eval_async(WebKitWebView *webview, + const char *script, + SoupWebsocketConnection *conn, + int request_id, + const char *tool_name, + js_result_handler_t handler); + /* * Execute JavaScript in the webview and wait for the result. * This is a synchronous wrapper around the async evaluate_javascript API. diff --git a/src/agent_tools.c b/src/agent_tools.c index 0ae3508..e2f597d 100644 --- a/src/agent_tools.c +++ b/src/agent_tools.c @@ -731,7 +731,7 @@ static int tool_table_count = sizeof(tool_table) / sizeof(tool_table[0]); /* ── Main dispatch function ───────────────────────────────────────── */ -cJSON *agent_tools_dispatch(cJSON *request) { +cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) { if (request == NULL) { return make_error("INVALID_REQUEST", "Request is NULL"); } @@ -740,18 +740,59 @@ cJSON *agent_tools_dispatch(cJSON *request) { const char *tool_name = cJSON_GetStringValue(cJSON_GetObjectItem(request, "tool")); cJSON *params = cJSON_GetObjectItem(request, "params"); cJSON *id = cJSON_GetObjectItem(request, "id"); + int request_id = (id && cJSON_IsNumber(id)) ? (int)id->valuedouble : 0; if (!tool_name || !tool_name[0]) { return make_error("MISSING_TOOL", "No 'tool' field in request"); } - /* Find the tool. */ + /* Check login requirement for known tools. */ + gboolean requires_login = TRUE; + gboolean is_login_tool = (strcmp(tool_name, "login_status") == 0 || + strcmp(tool_name, "login") == 0 || + strcmp(tool_name, "logout") == 0 || + strcmp(tool_name, "switch_identity") == 0); + if (is_login_tool) requires_login = FALSE; + + if (requires_login && !agent_is_logged_in()) { + return make_error("NOT_LOGGED_IN", + "This tool requires login. Use the 'login' tool first."); + } + + /* Handle async tools (snapshot, eval) specially — they need async + * JS evaluation and send the response through the WebSocket + * connection directly. */ + if (conn != NULL && strcmp(tool_name, "snapshot") == 0) { + WebKitWebView *wv = get_active_webview(); + if (wv == NULL) return make_error("NO_TAB", "No active tab"); + + gboolean interactive = get_bool_param(params, "interactive", FALSE); + gboolean compact = get_bool_param(params, "compact", FALSE); + + if (agent_snapshot_take_async(wv, interactive, compact, conn, request_id)) { + return NULL; /* Response sent async */ + } + return make_error("SNAPSHOT_FAILED", "Failed to start async snapshot"); + } + + if (conn != NULL && strcmp(tool_name, "eval") == 0) { + WebKitWebView *wv = get_active_webview(); + if (wv == NULL) return make_error("NO_TAB", "No active tab"); + + const char *script_param = get_string_param(params, "script"); + if (!script_param || !script_param[0]) return make_error("MISSING_PARAM", "Provide 'script'"); + + if (agent_js_eval_async(wv, script_param, conn, request_id, "eval", NULL)) { + return NULL; /* Response sent async */ + } + return make_error("EVAL_FAILED", "Failed to start async eval"); + } + + /* Find the tool in the table for synchronous tools. */ tool_func_t func = NULL; - gboolean requires_login = FALSE; for (int i = 0; i < tool_table_count; i++) { if (strcmp(tool_table[i].name, tool_name) == 0) { func = tool_table[i].func; - requires_login = tool_table[i].requires_login; break; } } @@ -760,13 +801,7 @@ cJSON *agent_tools_dispatch(cJSON *request) { return make_error("UNKNOWN_TOOL", "Unknown tool name"); } - /* Check login requirement. */ - if (requires_login && !agent_is_logged_in()) { - return make_error("NOT_LOGGED_IN", - "This tool requires login. Use the 'login' tool first."); - } - - /* Execute the tool. */ + /* Execute the tool synchronously. */ cJSON *response = func(params ? params : cJSON_CreateObject()); /* Copy the request id into the response. */ diff --git a/src/agent_tools.h b/src/agent_tools.h index adaccb0..ba1323f 100644 --- a/src/agent_tools.h +++ b/src/agent_tools.h @@ -10,6 +10,8 @@ #include "cjson/cJSON.h" +#include + #ifdef __cplusplus extern "C" { #endif @@ -18,11 +20,18 @@ extern "C" { * Process a tool request JSON and return response JSON. * The caller frees the returned cJSON*. * + * If the tool needs async JS evaluation, the response is sent directly + * through the WebSocket connection and this function returns NULL. + * Otherwise, the response is returned synchronously. + * + * conn: the WebSocket connection to send async responses through + * (may be NULL for testing without a real connection) + * * Request format: {"id":1,"tool":"snapshot","params":{...}} * Response format: {"id":1,"success":true,"data":{...}} * or: {"id":1,"success":false,"error":{"code":"...","message":"..."}} */ -cJSON *agent_tools_dispatch(cJSON *request); +cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn); #ifdef __cplusplus } diff --git a/src/version.h b/src/version.h index a2621f3..77e95ad 100644 --- a/src/version.h +++ b/src/version.h @@ -11,9 +11,9 @@ #ifndef SOVEREIGN_BROWSER_VERSION_H #define SOVEREIGN_BROWSER_VERSION_H -#define SB_VERSION "v0.0.8" +#define SB_VERSION "v0.0.9" #define SB_VERSION_MAJOR 0 #define SB_VERSION_MINOR 0 -#define SB_VERSION_PATCH 8 +#define SB_VERSION_PATCH 9 #endif /* SOVEREIGN_BROWSER_VERSION_H */