Files
sovereign_browser/src/agent_loop.c
T

485 lines
16 KiB
C

/*
* 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 <glib.h>
#include <string.h>
/* ── 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);
}