485 lines
16 KiB
C
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);
|
|
}
|