/* * agent_loop.c — ReAct tool-call loop on a background GThread * * Implements the standard ReAct loop: * 1. Load chat history from agent_chat_store_get_messages(). * 2. Prepend a system prompt message (must be first in the array). * 3. Call the LLM via agent_llm_chat(). * 4. If the response contains tool_calls, dispatch each: * - Browser tools → hop to the GTK main thread via g_idle_add() + * GAsyncQueue (WebKitGTK is not thread-safe). * - Filesystem/shell tools → run directly on this thread via * agent_fs_tools_dispatch(). * 5. Append tool results to the chat store. * 6. Repeat until no more tool_calls, the iteration cap is reached, or * the cancel flag is set. * 7. Persist the final assistant message and update session status. * * See plans/embedded-agent.md for the full concurrency model. */ #include "agent_loop.h" #include "agent_llm.h" #include "agent_chat_store.h" #include "agent_fs_tools.h" #include "agent_tools.h" #include "agent_skills.h" #include "agent_server.h" #include "settings.h" #include "cjson/cJSON.h" #include #include /* ── Context ────────────────────────────────────────────────────────── */ typedef struct { /* Atomic flags */ volatile gint cancel_flag; volatile gint running; /* gboolean as gint */ /* Status (protected by status_lock) */ GMutex status_lock; agent_loop_state_t state; int iteration; char *current_tool; /* tool name being executed */ char *last_message; /* last assistant text */ char *error; /* error message */ /* Thread handle */ GThread *thread; } agent_loop_ctx_t; static agent_loop_ctx_t g_ctx = {0}; /* ── Status helpers ─────────────────────────────────────────────────── */ /* Map a state enum to its string name for JSON events. */ static const char * state_name(agent_loop_state_t s) { switch (s) { case AGENT_LOOP_IDLE: return "idle"; case AGENT_LOOP_THINKING: return "thinking"; case AGENT_LOOP_TOOL_CALL: return "tool_call"; case AGENT_LOOP_COMPLETE: return "complete"; case AGENT_LOOP_ERROR: return "error"; case AGENT_LOOP_CANCELLED: return "cancelled"; } return "idle"; } /* Emit the current status as a WebSocket push event so the chat UI * can update instantly without polling. Must be called WITHOUT the * mutex held (it takes it itself). */ static void emit_status_event(void) { g_mutex_lock(&g_ctx.status_lock); cJSON *data = cJSON_CreateObject(); cJSON_AddStringToObject(data, "state", state_name(g_ctx.state)); cJSON_AddNumberToObject(data, "iteration", g_ctx.iteration); if (g_ctx.current_tool) cJSON_AddStringToObject(data, "current_tool", g_ctx.current_tool); else cJSON_AddNullToObject(data, "current_tool"); if (g_ctx.last_message) cJSON_AddStringToObject(data, "last_message", g_ctx.last_message); else cJSON_AddNullToObject(data, "last_message"); if (g_ctx.error) cJSON_AddStringToObject(data, "error", g_ctx.error); else cJSON_AddNullToObject(data, "error"); g_mutex_unlock(&g_ctx.status_lock); /* agent_server_emit_event takes ownership of data. */ agent_server_emit_event("agent_status", data); } static void set_status(agent_loop_state_t state, int iter, const char *tool, const char *msg, const char *err) { g_mutex_lock(&g_ctx.status_lock); g_ctx.state = state; g_ctx.iteration = iter; if (tool) { g_free(g_ctx.current_tool); g_ctx.current_tool = g_strdup(tool); } else { /* Clear tool name when not in a tool-call state. */ g_free(g_ctx.current_tool); g_ctx.current_tool = NULL; } if (msg) { g_free(g_ctx.last_message); g_ctx.last_message = g_strdup(msg); } /* Only set the error field when err is non-NULL. When err is NULL * and we're transitioning to a non-error state, clear any stale * error so the UI doesn't show an old error message. */ if (err) { g_free(g_ctx.error); g_ctx.error = g_strdup(err); } else if (state != AGENT_LOOP_ERROR) { g_free(g_ctx.error); g_ctx.error = NULL; } g_mutex_unlock(&g_ctx.status_lock); emit_status_event(); } static void set_state(agent_loop_state_t state) { g_mutex_lock(&g_ctx.status_lock); g_ctx.state = state; /* Clear stale error when transitioning to a non-error state. */ if (state != AGENT_LOOP_ERROR) { g_free(g_ctx.error); g_ctx.error = NULL; } /* Clear tool name when leaving the tool_call state. */ if (state != AGENT_LOOP_TOOL_CALL) { g_free(g_ctx.current_tool); g_ctx.current_tool = NULL; } g_mutex_unlock(&g_ctx.status_lock); emit_status_event(); } static void set_error(const char *msg) { g_mutex_lock(&g_ctx.status_lock); g_ctx.state = AGENT_LOOP_ERROR; g_free(g_ctx.error); g_ctx.error = g_strdup(msg); g_mutex_unlock(&g_ctx.status_lock); /* Emit a status event so the UI learns about the error immediately. * Without this, the chat page stays stuck on "Thinking..." because * the WebSocket push never fires and the polling fallback may have * been stopped when the WebSocket connected. */ emit_status_event(); } /* ── Build messages array with system prompt first ──────────────────── */ /* * Build the messages array for the LLM: a system message first, then * all messages from the chat store. Returns a newly-allocated cJSON * array (caller frees with cJSON_Delete), or NULL on error. */ static cJSON * build_messages_with_system(const char *system_prompt) { cJSON *history = agent_chat_store_get_messages(); if (history == NULL) return NULL; cJSON *messages = cJSON_CreateArray(); if (messages == NULL) { cJSON_Delete(history); return NULL; } /* System prompt must be the first message */ cJSON *sys_msg = cJSON_CreateObject(); cJSON_AddStringToObject(sys_msg, "role", "system"); cJSON_AddStringToObject(sys_msg, "content", system_prompt); cJSON_AddItemToArray(messages, sys_msg); /* Copy all history messages into the new array */ cJSON *msg; cJSON_ArrayForEach(msg, history) { cJSON_AddItemToArray(messages, cJSON_Duplicate(msg, 1)); } cJSON_Delete(history); return messages; } /* ── Background thread ──────────────────────────────────────────────── */ static gpointer agent_loop_thread(gpointer data) { char *user_message = (char *)data; g_print("[agent-loop] Thread started, message: %s\n", user_message); /* 1. Add user message to chat store */ agent_chat_store_add_user_message(user_message); g_free(user_message); /* Notify UI that a message was added. */ cJSON *umsg = cJSON_CreateObject(); cJSON_AddStringToObject(umsg, "role", "user"); agent_server_emit_event("agent_message", umsg); /* 2. Get provider settings (copy — they could change while running) */ const browser_settings_t *s = settings_get(); char base_url[512], api_key[512], model[128]; g_strlcpy(base_url, s->agent_llm_base_url, sizeof(base_url)); g_strlcpy(api_key, s->agent_llm_api_key, sizeof(api_key)); g_strlcpy(model, s->agent_llm_model, sizeof(model)); g_print("[agent-loop] base_url=%s model=%s api_key=%s max_iter=%d\n", base_url, model, api_key[0] ? "(set)" : "(empty)", s->agent_max_iterations); /* Build the system prompt. agent_skills_build_system_prompt() now * always returns a non-NULL string: it starts with the Sovereign * Browser Skill template (from settings) as the base, then * appends any selected Nostr skills' templates. */ char *system_prompt = agent_skills_build_system_prompt(); if (system_prompt == NULL) { /* Defensive fallback — should never happen. */ system_prompt = g_strdup(SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT); } g_print("[agent-loop] Using system prompt (%zu bytes)\n", strlen(system_prompt)); int max_iter = s->agent_max_iterations; if (max_iter <= 0) max_iter = SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT; /* Empty API key → pass NULL (local servers like Ollama don't need one) */ const char *key_arg = (api_key[0] != '\0') ? api_key : NULL; /* 3. Build OpenAI tools array (built once, reused each iteration) */ cJSON *tools = agent_llm_build_openai_tools(); /* 4. ReAct loop */ for (int iter = 0; iter < max_iter; iter++) { /* Check cancel */ if (g_atomic_int_get(&g_ctx.cancel_flag)) { set_state(AGENT_LOOP_CANCELLED); break; } /* Update status: thinking */ set_status(AGENT_LOOP_THINKING, iter, NULL, NULL, NULL); /* Build messages: system prompt first, then chat history */ cJSON *messages = build_messages_with_system(system_prompt); if (messages == NULL) { set_error("Failed to load messages"); break; } /* Call LLM (blocking HTTP on this thread) */ g_print("[agent-loop] iter %d: calling LLM...\n", iter); agent_llm_response_t *resp = agent_llm_chat(base_url, key_arg, model, messages, tools); cJSON_Delete(messages); if (resp == NULL) { g_print("[agent-loop] iter %d: LLM call returned NULL\n", iter); set_error("LLM API call failed"); break; } g_print("[agent-loop] iter %d: LLM responded, finish=%s, content=%s, tool_calls=%d\n", iter, resp->finish_reason ? resp->finish_reason : "(null)", resp->content ? resp->content : "(null)", resp->tool_calls ? cJSON_GetArraySize(resp->tool_calls) : 0); /* Persist assistant message */ char *tool_calls_str = (resp->tool_calls != NULL) ? cJSON_PrintUnformatted(resp->tool_calls) : NULL; agent_chat_store_add_assistant_message(resp->content, tool_calls_str); g_free(tool_calls_str); /* Notify UI that a message was added. */ cJSON *amsg = cJSON_CreateObject(); cJSON_AddStringToObject(amsg, "role", "assistant"); agent_server_emit_event("agent_message", amsg); /* Update last_message status */ set_status(AGENT_LOOP_THINKING, iter, NULL, resp->content ? resp->content : "", NULL); /* If no tool_calls, we're done */ if (resp->tool_calls == NULL || cJSON_GetArraySize(resp->tool_calls) == 0) { agent_llm_response_free(resp); set_state(AGENT_LOOP_COMPLETE); goto done; } /* Dispatch each tool call */ cJSON *tc; cJSON_ArrayForEach(tc, resp->tool_calls) { if (g_atomic_int_get(&g_ctx.cancel_flag)) break; cJSON *fn = cJSON_GetObjectItem(tc, "function"); const char *tool_name = fn ? cJSON_GetStringValue(cJSON_GetObjectItem(fn, "name")) : NULL; const char *args_str = fn ? cJSON_GetStringValue(cJSON_GetObjectItem(fn, "arguments")) : NULL; const char *tc_id = cJSON_GetStringValue(cJSON_GetObjectItem(tc, "id")); cJSON *args = (args_str != NULL) ? cJSON_Parse(args_str) : cJSON_CreateObject(); if (args == NULL) args = cJSON_CreateObject(); set_status(AGENT_LOOP_TOOL_CALL, iter, tool_name ? tool_name : "?", NULL, NULL); g_print("[agent-loop] iter %d: tool %s (args: %s)\n", iter, tool_name ? tool_name : "?", args_str ? args_str : "(null)"); /* Dispatch: fs/shell tools and browser tools both run on * this background thread. The browser tools use the same * sync JS eval path (conn=NULL) as the MCP HTTP handler, * which calls gtk_main_iteration() to pump the main loop * while waiting for the async JS result. This is safe to * call from a background thread — the main loop keeps * running and processes the JS eval callback. */ cJSON *result; if (tool_name != NULL && agent_fs_is_tool(tool_name)) { result = agent_fs_tools_dispatch(tool_name, args); } else { /* Build the request JSON and dispatch directly (same * as the MCP HTTP handler does from the libsoup thread). */ cJSON *request = cJSON_CreateObject(); cJSON_AddStringToObject(request, "tool", tool_name ? tool_name : ""); cJSON_AddItemToObject(request, "params", args); args = NULL; /* transferred to request */ result = agent_tools_dispatch(request, NULL); cJSON_Delete(request); } if (args) cJSON_Delete(args); /* Get result JSON string */ char *result_str = (result != NULL) ? cJSON_PrintUnformatted(result) : g_strdup("{}"); cJSON_Delete(result); /* Persist tool result */ agent_chat_store_add_tool_result( tc_id ? tc_id : "", result_str ? result_str : "{}"); g_free(result_str); /* Notify UI that a tool result was added. */ cJSON *tmsg = cJSON_CreateObject(); cJSON_AddStringToObject(tmsg, "role", "tool"); agent_server_emit_event("agent_message", tmsg); } agent_llm_response_free(resp); } /* If we exited the loop without completing or erroring, we hit the * iteration cap — treat as complete (the LLM was still working). */ { g_mutex_lock(&g_ctx.status_lock); if (g_ctx.state != AGENT_LOOP_CANCELLED && g_ctx.state != AGENT_LOOP_ERROR) { g_ctx.state = AGENT_LOOP_COMPLETE; } g_mutex_unlock(&g_ctx.status_lock); } done: g_free(system_prompt); cJSON_Delete(tools); g_atomic_int_set(&g_ctx.running, 0); return NULL; } /* ── Public API ─────────────────────────────────────────────────────── */ /* * Lazily initialize the mutex on first use. g_mutex_init() is idempotent * enough for our purposes — we guard with a g_once_init_enter block so * it only happens once. */ static void ensure_mutex_init(void) { static gsize initialized = 0; if (g_once_init_enter(&initialized)) { g_mutex_init(&g_ctx.status_lock); g_ctx.state = AGENT_LOOP_IDLE; g_once_init_leave(&initialized, 1); } } int agent_loop_run(const char *user_message) { g_print("[agent-loop] agent_loop_run called: %s\n", user_message ? user_message : "(null)"); if (user_message == NULL) return -1; ensure_mutex_init(); if (g_atomic_int_get(&g_ctx.running)) { g_print("[agent-loop] already running, rejecting\n"); return -1; /* already running */ } /* Check that a provider is configured (base URL + model). * An empty API key is allowed (local servers like Ollama). */ const browser_settings_t *s = settings_get(); if (s->agent_llm_base_url[0] == '\0' || s->agent_llm_model[0] == '\0') { g_print("[agent-loop] no provider configured: base_url=%s model=%s\n", s->agent_llm_base_url, s->agent_llm_model); set_error("No LLM provider configured (set base URL and model on " "sovereign://agents)"); return -1; } /* Reset cancel flag */ g_atomic_int_set(&g_ctx.cancel_flag, 0); /* Reset status */ set_status(AGENT_LOOP_THINKING, 0, NULL, NULL, NULL); g_atomic_int_set(&g_ctx.running, 1); /* Spawn background thread (takes ownership of the duplicated string) */ g_ctx.thread = g_thread_new("agent-loop", agent_loop_thread, g_strdup(user_message)); return 0; } void agent_loop_cancel(void) { g_atomic_int_set(&g_ctx.cancel_flag, 1); } gboolean agent_loop_is_running(void) { return g_atomic_int_get(&g_ctx.running) != 0; } void agent_loop_get_status(agent_loop_state_t *state_out, int *iteration_out, char **current_tool_out, char **last_message_out, char **error_out) { ensure_mutex_init(); g_mutex_lock(&g_ctx.status_lock); if (state_out) *state_out = g_ctx.state; if (iteration_out) *iteration_out = g_ctx.iteration; if (current_tool_out) *current_tool_out = g_strdup(g_ctx.current_tool); if (last_message_out) *last_message_out = g_strdup(g_ctx.last_message); if (error_out) *error_out = g_strdup(g_ctx.error); g_mutex_unlock(&g_ctx.status_lock); }