using Invercargill; using Invercargill.DataStructures; using Invercargill.Expressions; using InvercargillSql.Orm; using Inversion; using Soup; using ClankerSaddle.Data; namespace ClankerSaddle.Services { /** * The advisor's OpenAI-compatible endpoint (goal 27): the same * shape as {@link SaddleService.LlmEndpoint} but resolved from * ADVISOR_LLM_* first, falling back to SADDLE_LLM_* so an * operator with one provider configures nothing extra. */ public class AdvisorLlmEndpoint : Object { public string base_url { get; set; default = ""; } public string api_key { get; set; default = ""; } public string model { get; set; default = ""; } public static string first_non_empty(string primary, string fallback) { var value = (Environment.get_variable(primary) ?? "").strip(); if (value.length > 0) { return value; } return (Environment.get_variable(fallback) ?? "").strip(); } /** The configured endpoint, or null when no base URL resolves. */ public static AdvisorLlmEndpoint? from_environment() { var base_url = first_non_empty("ADVISOR_LLM_BASE_URL", "SADDLE_LLM_BASE_URL"); if (base_url.length == 0) { return null; } return new AdvisorLlmEndpoint() { base_url = base_url, api_key = first_non_empty("ADVISOR_LLM_API_KEY", "SADDLE_LLM_API_KEY"), model = first_non_empty("ADVISOR_LLM_MODEL", "SADDLE_LLM_MODEL") }; } } /** * Content, tool calls and finish_reason of one advisor * chat-completions reply (the slice of the wire format the loop * needs; usage counters are not consumed). */ public class AdvisorReply : Object { public string content = ""; public string finish_reason = ""; public AdvisorToolCall[] tool_calls = new AdvisorToolCall[0]; } /** One tool call parsed out of a reply. */ public class AdvisorToolCall : Object { public string id = ""; public string name = ""; /** The raw arguments JSON string (verbatim to the wire). */ public string arguments = "{}"; } /** * The advisor loop (goal 27): a chat-bound LLM turn runner over * the clanker-saddle state. A singleton (a turn outlives its * request): `run_turn`/`answer_question` return immediately and * the turn executes in its OWN container scope — the * {@link PushService} pattern — appending advisor_messages rows * and bumping StatePulse as it goes, so the chat page's sealed * refresh renders progress without any long-held request. * * The loop is strictly READ-ONLY over work state: tools call the * existing services, and the only writes are chat rows and * pending suggestion rows — nothing is created until the human * approves a suggestion in the UI (a later task). ask_question * parks the chat ('awaiting_answer'); the answer action calls * {@link answer_question} to record it and run the next turn. * * Per-chat lock: an in-memory running set — a second trigger * while a chat's turn is in flight is a no-op (the answer action * re-runs after parking). Iterations are capped so a chatty model * cannot loop forever; any failure appends a kind='error' message * and returns the chat to 'idle'. */ public class AdvisorService : Object { /** Upper bound of LLM round trips in one turn. */ private const int MAX_ITERATIONS = 12; /** Long text fields fed to tools are clamped to this. */ private const int TEXT_CLAMP_CHARS = 2000; /** List-shaped tool results cap their row counts to this. */ private const int LIST_CLAMP_ROWS = 50; private Container container = inject(); private StatePulse pulse = inject(); private Soup.Session http = new Soup.Session() { timeout = 120, idle_timeout = 15 }; /** The chats whose turn is currently executing (the lock). */ private Invercargill.DataStructures.HashSet running = new Invercargill.DataStructures.HashSet(); /** True when an LLM endpoint resolves from the environment. */ public bool configured { get { return AdvisorLlmEndpoint.from_environment() != null; } } // ------------------------------------------------------------------ // Turn entry points // ------------------------------------------------------------------ /** * Runs one turn for `chat_id` (owned by `user_id`): the user * message is ALREADY written by the action — this only runs * the loop. Returns immediately; appends land as they happen. * A turn already in flight for the chat is a no-op. */ public async void run_turn(int64 chat_id, int64 user_id) { if (try_lock(chat_id)) { yield turn_locked(chat_id, user_id); } } /** * Records the human's answer to the chat's pending question * (option match → "Human selected: X"; free text → "Human * answered: …" — the saddle wording) as a user message, then * runs the next turn. Returns false when there is no pending * question (or the chat is not the user's / nothing to do). */ public async bool answer_question(int64 chat_id, int64 user_id, string answer) throws Error { var scope = container.create_scope(); try { var db = scope.resolve(); var chats = scope.resolve(); if ((yield chats.get_chat(chat_id, user_id)) == null) { return false; } var question = yield chats.pending_question(chat_id); if (question == null) { return false; } string trimmed = answer.strip(); if (trimmed.length == 0) { return false; } var selected = option_member(((!)question).payload ?? "", trimmed); string quoted; if (selected != null) { quoted = @"Human selected: $(((!)selected))"; } else { quoted = @"Human answered: $(trimmed)"; } ((!)question).answer = trimmed; ((!)question).answered_at = new DateTime.now_local(); yield db.update_async((!)question); yield chats.append_message(chat_id, "user", quoted, null); yield pulse.bump(0, "advisor answer recorded"); } finally { scope.release(); } if (try_lock(chat_id)) { yield turn_locked(chat_id, user_id); } return true; } /** Acquires the per-chat turn lock; false when already held. */ private bool try_lock(int64 chat_id) { var key = chat_id.to_string(); if (running.contains(key)) { return false; } running.add(key); return true; } /** * The turn body, with the lock held: status 'running', then * the LLM loop until a plain assistant reply, an ask_question * park or the iteration cap; every exit path resets a * terminal status. */ private async void turn_locked(int64 chat_id, int64 user_id) { var endpoint = AdvisorLlmEndpoint.from_environment(); try { if (endpoint == null) { yield append(chat_id, "error", "The advisor is not configured — set ADVISOR_LLM_BASE_URL" + " (or SADDLE_LLM_BASE_URL) to enable it.", null); yield set_status(chat_id, "idle"); return; } yield set_status(chat_id, "running"); var messages = yield wire_messages(chat_id, (!)endpoint); for (var iteration = 0; iteration < MAX_ITERATIONS; iteration++) { var reply = yield chat_once((!)endpoint, messages); foreach (var call in reply.tool_calls) { messages.add_object_element( assistant_call_node(reply.content, call)); } if (reply.tool_calls.length == 0) { yield append(chat_id, "assistant", reply.content.length > 0 ? reply.content : "(no reply)", null); yield set_status(chat_id, "idle"); return; } bool parked = false; foreach (var call in reply.tool_calls) { var outcome = yield execute_tool(chat_id, user_id, call); messages.add_object_element(tool_result_node(call, outcome)); if (outcome.parked) { parked = true; } } if (parked) { // ask_question: the question row is already // appended by execute_tool — the turn ends // here and the answer action resumes it. yield set_status(chat_id, "awaiting_answer"); return; } } yield append(chat_id, "error", @"The advisor stopped after $(MAX_ITERATIONS) rounds without a final reply —" + " please rephrase or continue.", null); yield set_status(chat_id, "idle"); } catch (Error e) { yield append(chat_id, "error", @"Advisor turn failed: $(e.message)", null); yield set_status(chat_id, "idle"); } finally { running.remove(chat_id.to_string()); } } /** One outcome of executing a tool call. */ private class ToolOutcome : Object { /** The JSON result handed back to the model. */ public string result = "{}"; /** True when the tool parked the turn (ask_question). */ public bool parked = false; } /** An error message handed back to the model, never thrown. */ private static ToolOutcome tool_error(string message) { var body = new Json.Object(); body.set_string_member("error", message); var generator = new Json.Generator(); generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(body)); var outcome = new ToolOutcome(); outcome.result = generator.to_data(null); return outcome; } // ------------------------------------------------------------------ // Wire construction // ------------------------------------------------------------------ /** * The OpenAI messages array rebuilt from the stored rows: the * system prompt, then user/assistant/tool rows in order — an * assistant row that carried tool_calls reconstructs them * from its payload, and a tool row becomes the `tool` role * message with tool_call_id/name/content. error and system * rows ride as user-bracketed notices so the model sees them. */ private async Json.Array wire_messages(int64 chat_id, AdvisorLlmEndpoint endpoint) throws Error { var scope = container.create_scope(); var messages = new Json.Array(); try { var chats = scope.resolve(); messages.add_object_element(system_message_node()); foreach (var row in yield chats.list_messages(chat_id)) { if (row.kind == "user") { var node = new Json.Object(); node.set_string_member("role", "user"); node.set_string_member("content", row.content); messages.add_object_element(node); } else if (row.kind == "assistant") { var payload = row.payload ?? ""; var calls = payload_tool_calls(payload); if (calls.length > 0) { // One assistant row per call was stored; // reconstruct this row's own call. var stored = payload_call(payload); var node = new Json.Object(); node.set_string_member("role", "assistant"); node.set_string_member("content", row.content); if (stored != null) { node.set_array_member("tool_calls", tool_calls_array(new AdvisorToolCall[] { (!)stored })); } messages.add_object_element(node); } else { var node = new Json.Object(); node.set_string_member("role", "assistant"); node.set_string_member("content", row.content); messages.add_object_element(node); } } else if (row.kind == "tool") { var payload = row.payload ?? ""; var node = new Json.Object(); node.set_string_member("role", "tool"); node.set_string_member("tool_call_id", payload_string(payload, "call_id")); node.set_string_member("name", payload_string(payload, "name")); node.set_string_member("content", payload_string(payload, "result")); messages.add_object_element(node); } else { // error / system: a bracketed notice the model // must see but that is neither party's voice. var node = new Json.Object(); node.set_string_member("role", "user"); node.set_string_member("content", @"[($(row.kind)) notice] $(row.content)"); messages.add_object_element(node); } } } finally { scope.release(); } return messages; } /** The persona/system message. */ private static Json.Object system_message_node() { var node = new Json.Object(); node.set_string_member("role", "system"); node.set_string_member("content", SYSTEM_PROMPT); return node; } /** * The advisor persona: a READ-ONLY operations advisor for a * clanker-saddle fleet. It can inspect work state through its * tools, ask the user questions when unsure, and SUGGEST * tasks/goals that stay inert until explicitly approved in * the UI. It can never run commands. */ private const string SYSTEM_PROMPT = "You are the advisor: a read-only operations" + " advisor for a clanker-saddle fleet (an LLM task orchestration platform with" + " projects, tasks, goals, rules, discoveries and capability-tagged saddles)." + " You chat with the fleet's human operator.\n" + "Your tools can READ the fleet's state (projects, tasks, goals, rules," + " discoveries, capabilities) and nothing else. You CANNOT run commands," + " edit files, dispatch work or change any state.\n" + "When a course of action needs real work, call suggest_task or suggest_goal:" + " a suggestion is INERT until the user explicitly approves it in the chat UI" + " — nothing is created without that approval, and you should say so plainly.\n" + "When you are unsure what the user wants, or a decision genuinely needs" + " them, call ask_question with concrete options (an empty options array asks" + " a free-text question). Prefer asking over guessing.\n" + "Be concise and specific; answer in short markdown where it helps."; /** The assistant wire node carrying one tool call. */ private static Json.Object assistant_call_node(string content, AdvisorToolCall call) { var node = new Json.Object(); node.set_string_member("role", "assistant"); node.set_string_member("content", content); node.set_array_member("tool_calls", tool_calls_array( new AdvisorToolCall[] { call })); return node; } /** The tool_calls array for the wire. */ private static Json.Array tool_calls_array(AdvisorToolCall[] calls) { var array = new Json.Array(); foreach (var call in calls) { var element = new Json.Object(); element.set_string_member("id", call.id); element.set_string_member("type", "function"); var function = new Json.Object(); function.set_string_member("name", call.name); function.set_string_member("arguments", call.arguments); element.set_object_member("function", function); array.add_object_element(element); } return array; } /** The `tool` role wire node for one executed call. */ private static Json.Object tool_result_node(AdvisorToolCall call, ToolOutcome outcome) { var node = new Json.Object(); node.set_string_member("role", "tool"); node.set_string_member("tool_call_id", call.id); node.set_string_member("name", call.name); node.set_string_member("content", outcome.result); return node; } // --- payload helpers (payload JSON of stored rows) --- private static string payload_string(string payload, string member) { var parser = new Json.Parser(); try { parser.load_from_data(payload); } catch (Error e) { return ""; } var root = parser.get_root(); if (root == null || root.get_node_type() != Json.NodeType.OBJECT) { return ""; } var body = ((!)root).get_object(); if (!body.has_member(member)) { return ""; } var node = body.get_member(member); return node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null ? (!)node.get_string() : ""; } /** The single stored tool call of an assistant row, or null. */ private static AdvisorToolCall? payload_call(string payload) { var call = new AdvisorToolCall(); call.id = payload_string(payload, "call_id"); call.name = payload_string(payload, "name"); call.arguments = payload_string(payload, "arguments"); if (call.id.length == 0 && call.name.length == 0) { return null; } return call; } /** Whether a payload carries a stored tool call. */ private static AdvisorToolCall[] payload_tool_calls(string payload) { var call = payload_call(payload); return call != null ? new AdvisorToolCall[] { (!)call } : new AdvisorToolCall[0]; } /** * The options array of a question payload, or null when the * trimmed answer is not one of them. */ private static string? option_member(string payload, string answer) { var parser = new Json.Parser(); try { parser.load_from_data(payload); } catch (Error e) { return null; } var root = parser.get_root(); if (root == null || root.get_node_type() != Json.NodeType.OBJECT) { return null; } var body = ((!)root).get_object(); if (!body.has_member("options") || body.get_member("options").get_node_type() != Json.NodeType.ARRAY) { return null; } foreach (var node in body.get_array_member("options").get_elements()) { if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null && ((!)node.get_string()) == answer) { return (!)node.get_string(); } } return null; } // ------------------------------------------------------------------ // LLM transport // ------------------------------------------------------------------ /** * One non-streaming chat-completions call: the messages built * so far plus the tool set. Transport failures and HTTP >= * 400 throw (the turn's error row); the reply is parsed into * content + tool calls. */ private async AdvisorReply chat_once(AdvisorLlmEndpoint endpoint, Json.Array messages) throws Error { var body = new Json.Object(); body.set_string_member("model", endpoint.model.length > 0 ? endpoint.model : "advisor"); body.set_array_member("messages", messages); body.set_array_member("tools", tools_array()); var generator = new Json.Generator(); var body_node = new Json.Node(Json.NodeType.OBJECT); body_node.init_object(body); generator.set_root(body_node); string url = endpoint.base_url.has_suffix("/") ? endpoint.base_url.substring(0, endpoint.base_url.length - 1) : endpoint.base_url; var message = new Soup.Message("POST", @"$(url)/chat/completions"); message.set_request_body_from_bytes("application/json", new Bytes(generator.to_data(null).data)); if (endpoint.api_key.length > 0) { message.request_headers.append("Authorization", @"Bearer $(endpoint.api_key)"); } Bytes? raw; try { raw = yield http.send_and_read_async(message, Priority.DEFAULT, null); } catch (Error e) { throw new Error(Quark.from_string("clanker-saddle"), 80, @"LLM request failed: $(e.message)"); } if (message.status_code >= 400) { throw new Error(Quark.from_string("clanker-saddle"), 81, @"LLM endpoint answered HTTP $(message.status_code)"); } // get_data() hands back an unterminated array — casting it // to string would read past the buffer until an unrelated // NUL. Copy into a length+1 buffer and terminate. var data = raw != null ? ((!)raw).get_data() : new uint8[0]; if (data.length == 0) { return parse_reply("{}"); } var terminated = new uint8[data.length + 1]; for (var index = 0; index < data.length; index++) { terminated[index] = data[index]; } terminated[data.length] = '\0'; return parse_reply((string) terminated); } /** * Parses a chat-completions response body: the first choice's * content, tool calls (id, name, raw arguments) and * finish_reason. Mirrors the daemon's parser shape. */ internal static AdvisorReply parse_reply(string body) throws Error { var parser = new Json.Parser(); try { parser.load_from_data(body); } catch (Error e) { throw new Error(Quark.from_string("clanker-saddle"), 82, @"LLM response is not JSON: $(e.message)"); } var root = parser.get_root(); if (root == null || root.get_node_type() != Json.NodeType.OBJECT) { throw new Error(Quark.from_string("clanker-saddle"), 82, "LLM response is not a JSON object"); } var object = ((!)root).get_object(); if (!object.has_member("choices") || object.get_member("choices").get_node_type() != Json.NodeType.ARRAY) { throw new Error(Quark.from_string("clanker-saddle"), 82, "LLM response has no choices array"); } var choices = object.get_array_member("choices"); if (choices.get_length() == 0) { throw new Error(Quark.from_string("clanker-saddle"), 82, "LLM response choices array is empty"); } var first = choices.get_object_element(0); if (!first.has_member("message")) { throw new Error(Quark.from_string("clanker-saddle"), 82, "LLM choice has no message"); } var reply = new AdvisorReply(); var choice_message = first.get_object_member("message"); if (choice_message.has_member("content")) { var content = choice_message.get_member("content"); if (content.get_node_type() == Json.NodeType.VALUE && content.get_string() != null) { reply.content = (!)content.get_string(); } } if (choice_message.has_member("tool_calls") && choice_message.get_member("tool_calls").get_node_type() == Json.NodeType.ARRAY) { var calls = choice_message.get_array_member("tool_calls"); for (var index = 0; index < calls.get_length(); index++) { var element = calls.get_element(index); if (element.get_node_type() != Json.NodeType.OBJECT) { continue; } var call = element.get_object(); var parsed = new AdvisorToolCall(); parsed.id = member_string(call, "id"); parsed.name = ""; parsed.arguments = "{}"; if (call.has_member("function") && call.get_member("function").get_node_type() == Json.NodeType.OBJECT) { var function = call.get_object_member("function"); parsed.name = member_string(function, "name"); parsed.arguments = member_string(function, "arguments"); if (parsed.arguments.length == 0) { parsed.arguments = "{}"; } } if (reply.tool_calls.length == 0) { reply.tool_calls = new AdvisorToolCall[] { parsed }; } else { var grown = new AdvisorToolCall[reply.tool_calls.length + 1]; for (var copy = 0; copy < reply.tool_calls.length; copy++) { grown[copy] = reply.tool_calls[copy]; } grown[reply.tool_calls.length] = parsed; reply.tool_calls = grown; } } } reply.finish_reason = member_string(first, "finish_reason"); return reply; } /** A string member of `body`, or "" (never null). */ private static string member_string(Json.Object body, string member) { if (!body.has_member(member)) { return ""; } var node = body.get_member(member); if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) { return (!)node.get_string(); } return ""; } // ------------------------------------------------------------------ // Tool dispatch // ------------------------------------------------------------------ /** * Executes one tool call inside a FRESH scope (the container * owns every service), stores the assistant call row and the * tool result row, bumps the pulse, and reports the outcome * the model sees. Failures are reported as tool errors, never * thrown — the loop keeps going so the model can react. */ private async ToolOutcome execute_tool(int64 chat_id, int64 user_id, AdvisorToolCall call) { var outcome = new ToolOutcome(); var scope = container.create_scope(); try { var db = scope.resolve(); var chats = scope.resolve(); // The assistant row carrying this call, stored in the // wire shape (call_id + name + raw arguments). var call_payload = new Json.Object(); call_payload.set_string_member("call_id", call.id); call_payload.set_string_member("name", call.name); call_payload.set_string_member("arguments", call.arguments); yield chats.append_message(chat_id, "assistant", "@tool call", json_string(call_payload)); string result; bool parked = false; if (call.name == "ask_question") { var question = yield tool_ask_question(db, chats, chat_id, call.arguments); result = question.result; parked = question.parked; } else if (call.name == "suggest_task" || call.name == "suggest_goal") { // Suggestion rows belong to THIS chat and its user — // that ownership is what the later approve action // and the completion callback key on. result = yield dispatch_read_tool(db, call.name, call.arguments, chat_id, user_id); } else { result = yield dispatch_read_tool(db, call.name, call.arguments, chat_id, user_id); } outcome.result = result; var result_payload = new Json.Object(); result_payload.set_string_member("call_id", call.id); result_payload.set_string_member("name", call.name); result_payload.set_string_member("arguments", call.arguments); result_payload.set_string_member("result", result); yield chats.append_message(chat_id, "tool", "@tool result", json_string(result_payload)); outcome.parked = parked; yield pulse.bump(0, @"advisor tool $(call.name)"); } catch (Error e) { outcome.result = tool_error_json(@"$(call.name) failed: $(e.message)"); try { var chats = scope.resolve(); var result_payload = new Json.Object(); result_payload.set_string_member("call_id", call.id); result_payload.set_string_member("name", call.name); result_payload.set_string_member("arguments", call.arguments); result_payload.set_string_member("result", outcome.result); yield chats.append_message(chat_id, "tool", "@tool result", json_string(result_payload)); } catch (Error ignored) { // The error row below is the last resort record. } } finally { scope.release(); } return outcome; } /** tool_error's JSON string (kept separate for reuse above). */ private static string tool_error_json(string message) { return tool_error(message).result; } /** * The read-only tool switch: every case resolves its data * through the EXISTING services; unknown names report a tool * error. suggest_task/suggest_goal insert pending suggestion * rows — the only write besides chat rows. */ private async string dispatch_read_tool(OrmSession db, string name, string arguments, int64 chat_id, int64 user_id) throws Error { switch (name) { case "list_projects": return yield tool_list_projects(db); case "get_project": return yield tool_get_project(db, arguments); case "list_tasks": return yield tool_list_tasks(db, arguments); case "get_task": return yield tool_get_task(db, arguments); case "list_goals": return yield tool_list_goals(db, arguments); case "get_goal": return yield tool_get_goal(db, arguments); case "list_rules": return yield tool_list_rules(db); case "search_discoveries": return yield tool_search_discoveries(db, arguments); case "list_capabilities": return yield tool_list_capabilities(db); case "suggest_task": return yield tool_suggest_task(db, arguments, chat_id, user_id); case "suggest_goal": return yield tool_suggest_goal(db, arguments, chat_id, user_id); default: return tool_error_json(@"Unknown tool '$(name)' — the advisor's tool set is read-only"); } } /** Parses the arguments JSON string, or an empty object. */ private static Json.Object parse_arguments(string arguments) { var parser = new Json.Parser(); try { parser.load_from_data(arguments); } catch (Error e) { return new Json.Object(); } var root = parser.get_root(); if (root == null || root.get_node_type() != Json.NodeType.OBJECT) { return new Json.Object(); } return ((!)root).get_object(); } private static int64 argument_int(Json.Object arguments, string member, int64 fallback = 0) { if (!arguments.has_member(member)) { return fallback; } var node = arguments.get_member(member); if (node.get_node_type() == Json.NodeType.VALUE) { return node.get_int(); } return fallback; } private static string argument_string(Json.Object arguments, string member, string fallback = "") { if (!arguments.has_member(member)) { return fallback; } var node = arguments.get_member(member); if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) { return (!)node.get_string(); } return fallback; } private static string[] argument_strings(Json.Object arguments, string member) { var values = new string[0]; if (!arguments.has_member(member) || arguments.get_member(member).get_node_type() != Json.NodeType.ARRAY) { return values; } foreach (var node in arguments.get_array_member(member).get_elements()) { if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) { values += (!)node.get_string(); } } return values; } private static string json_string(Json.Object body) { var generator = new Json.Generator(); var node = new Json.Node(Json.NodeType.OBJECT); node.init_object(body); generator.set_root(node); return generator.to_data(null); } /** Long text clamped for tool results. */ private static string clamp_text(string text) { return text.length > TEXT_CLAMP_CHARS ? text.substring(0, TEXT_CLAMP_CHARS) + "…" : text; } private static string stamp_text(DateTime? stamp) { return stamp != null ? ((!)stamp).format("%Y-%m-%d %H:%M") : ""; } // --- read tools (data shaping over the existing services) --- private async string tool_list_projects(OrmSession db) throws Error { var projects = yield db.query() .order_by(expr("name")) .materialise_async(); var array = new Json.Array(); int count = 0; foreach (var project in projects) { if (count++ >= LIST_CLAMP_ROWS) { break; } var element = new Json.Object(); element.set_int_member("id", project.id); element.set_string_member("name", project.name); element.set_string_member("default_branch", project.default_branch); element.set_string_member("summary", clamp_text(project.summary ?? "")); array.add_object_element(element); } var body = new Json.Object(); body.set_array_member("projects", array); return json_string(body); } private async string tool_get_project(OrmSession db, string arguments) throws Error { int64 id = argument_int(parse_arguments(arguments), "project_id"); var project = yield db.query() .where(expr("id == $0", elem(id))) .first_async(); if (project == null) { return tool_error_json(@"No project exists with id $(id)"); } var body = new Json.Object(); body.set_int_member("id", ((!)project).id); body.set_string_member("name", ((!)project).name); body.set_string_member("git_url", ((!)project).git_url); body.set_string_member("default_branch", ((!)project).default_branch); body.set_string_member("summary", clamp_text(((!)project).summary ?? "")); body.set_string_member("created_at", stamp_text(((!)project).created_at)); return json_string(body); } private async string tool_list_tasks(OrmSession db, string arguments) throws Error { var args = parse_arguments(arguments); var query = db.query(); string status = argument_string(args, "status"); int64 project_id = argument_int(args, "project_id"); if (status.length > 0) { query.where(expr("status == $0", elem(status))); } if (project_id > 0) { query.where(expr("project_id == $0", elem(project_id))); } var array = new Json.Array(); int count = 0; foreach (var task in yield query.order_by_desc(expr("id")).materialise_async()) { if (count++ >= LIST_CLAMP_ROWS) { break; } var element = new Json.Object(); element.set_int_member("id", task.id); element.set_int_member("project_id", task.project_id); element.set_string_member("title", task.title); element.set_string_member("status", task.status); element.set_string_member("mode", task.mode); element.set_int_member("priority", task.priority); element.set_int_member("goal_id", task.goal_id); array.add_object_element(element); } var body = new Json.Object(); body.set_array_member("tasks", array); return json_string(body); } private async string tool_get_task(OrmSession db, string arguments) throws Error { int64 id = argument_int(parse_arguments(arguments), "task_id"); var task = yield db.query() .where(expr("id == $0", elem(id))) .first_async(); if (task == null) { return tool_error_json(@"No task exists with id $(id)"); } var body = new Json.Object(); body.set_int_member("id", ((!)task).id); body.set_int_member("project_id", ((!)task).project_id); body.set_string_member("title", ((!)task).title); body.set_string_member("description", clamp_text(((!)task).description ?? "")); body.set_string_member("status", ((!)task).status); body.set_string_member("mode", ((!)task).mode); body.set_string_member("creator", ((!)task).creator); body.set_int_member("priority", ((!)task).priority); body.set_int_member("goal_id", ((!)task).goal_id); body.set_string_member("task_branch", ((!)task).task_branch ?? ""); body.set_string_member("created_at", stamp_text(((!)task).created_at)); return json_string(body); } private async string tool_list_goals(OrmSession db, string arguments) throws Error { var args = parse_arguments(arguments); var query = db.query(); int64 project_id = argument_int(args, "project_id"); if (project_id > 0) { query.where(expr("project_id == $0", elem(project_id))); } var array = new Json.Array(); int count = 0; foreach (var goal in yield query.order_by_desc(expr("id")).materialise_async()) { if (count++ >= LIST_CLAMP_ROWS) { break; } var element = new Json.Object(); element.set_int_member("id", goal.id); element.set_int_member("project_id", goal.project_id); element.set_string_member("title", goal.title); element.set_string_member("status", goal.status); element.set_string_member("branch_name", goal.branch_name); array.add_object_element(element); } var body = new Json.Object(); body.set_array_member("goals", array); return json_string(body); } private async string tool_get_goal(OrmSession db, string arguments) throws Error { int64 id = argument_int(parse_arguments(arguments), "goal_id"); var goal = yield db.query() .where(expr("id == $0", elem(id))) .first_async(); if (goal == null) { return tool_error_json(@"No goal exists with id $(id)"); } var body = new Json.Object(); body.set_int_member("id", ((!)goal).id); body.set_int_member("project_id", ((!)goal).project_id); body.set_string_member("title", ((!)goal).title); body.set_string_member("brief", clamp_text(((!)goal).brief)); body.set_string_member("status", ((!)goal).status); body.set_string_member("branch_name", ((!)goal).branch_name); body.set_string_member("plan", clamp_text(((!)goal).plan ?? "")); body.set_string_member("created_at", stamp_text(((!)goal).created_at)); return json_string(body); } private async string tool_list_rules(OrmSession db) throws Error { var rules = yield db.query() .order_by(expr("name")) .materialise_async(); var array = new Json.Array(); int count = 0; foreach (var rule in rules) { if (count++ >= LIST_CLAMP_ROWS) { break; } var element = new Json.Object(); element.set_int_member("id", rule.id); element.set_string_member("name", rule.name); element.set_string_member("body", clamp_text(rule.body)); array.add_object_element(element); } var body = new Json.Object(); body.set_array_member("rules", array); return json_string(body); } private async string tool_search_discoveries(OrmSession db, string arguments) throws Error { string query = argument_string(parse_arguments(arguments), "query"); var rows = yield db.query() .order_by_desc(expr("id")) .materialise_async(); var array = new Json.Array(); int count = 0; string needle = query.strip().down(); foreach (var row in rows) { if (needle.length > 0 && !row.body.down().contains(needle)) { continue; } if (count++ >= LIST_CLAMP_ROWS) { break; } var element = new Json.Object(); element.set_int_member("id", row.id); element.set_string_member("body", clamp_text(row.body)); element.set_string_member("updated_at", stamp_text(row.updated_at)); array.add_object_element(element); } var body = new Json.Object(); body.set_array_member("discoveries", array); return json_string(body); } private async string tool_list_capabilities(OrmSession db) throws Error { var capabilities = yield db.query() .order_by(expr("name")) .materialise_async(); var array = new Json.Array(); foreach (var capability in capabilities) { var element = new Json.Object(); element.set_int_member("id", capability.id); element.set_string_member("name", capability.name); array.add_object_element(element); } var body = new Json.Object(); body.set_array_member("capabilities", array); return json_string(body); } // --- suggestions (the only non-chat write) --- /** * Inserts a pending task suggestion row. The payload keeps * every field the approve action will need verbatim. */ private async string tool_suggest_task(OrmSession db, string arguments, int64 chat_id, int64 user_id) throws Error { var args = parse_arguments(arguments); string title = argument_string(args, "title").strip(); string mandate = argument_string(args, "mandate").strip(); if (title.length == 0 || mandate.length == 0) { return tool_error_json("suggest_task needs a non-empty title and mandate"); } string mode = argument_string(args, "mode", "implement"); var payload = new Json.Object(); payload.set_string_member("title", title); payload.set_string_member("mandate", mandate); payload.set_string_member("mode", mode); payload.set_int_member("project_id", argument_int(args, "project_id")); payload.set_int_member("priority", argument_int(args, "priority", 5)); var row = new AdvisorSuggestion() { chat_id = chat_id, user_id = user_id, kind = "task", payload = json_string(payload), status = "pending", created_at = new DateTime.now_local() }; yield db.insert_async(row); var body = new Json.Object(); body.set_int_member("suggestion_id", row.id); body.set_string_member("status", "pending"); body.set_string_member("note", "Suggestion stored — nothing is created until the user approves it in the chat UI."); return json_string(body); } /** * Inserts a pending goal suggestion row (created as an * UNQUEUED draft on approval). */ private async string tool_suggest_goal(OrmSession db, string arguments, int64 chat_id, int64 user_id) throws Error { var args = parse_arguments(arguments); string title = argument_string(args, "title").strip(); string brief = argument_string(args, "brief").strip(); if (title.length == 0 || brief.length == 0) { return tool_error_json("suggest_goal needs a non-empty title and brief"); } var payload = new Json.Object(); payload.set_string_member("title", title); payload.set_string_member("brief", brief); payload.set_int_member("project_id", argument_int(args, "project_id")); payload.set_string_member("branch_name", argument_string(args, "branch_name")); var row = new AdvisorSuggestion() { chat_id = chat_id, user_id = user_id, kind = "goal", payload = json_string(payload), status = "pending", created_at = new DateTime.now_local() }; yield db.insert_async(row); var body = new Json.Object(); body.set_int_member("suggestion_id", row.id); body.set_string_member("status", "pending"); body.set_string_member("note", "Suggestion stored — nothing is created until the user approves it in the chat UI."); return json_string(body); } // --- ask_question --- /** * The ONE parking tool: validates the question and stores it * as an assistant row whose payload carries the question and * its options; the turn then ends with status * 'awaiting_answer' and the answer action resumes it. An * empty options array is allowed (a free-text question, like * the saddle's ask_question). */ private async ToolOutcome tool_ask_question(OrmSession db, ChatService chats, int64 chat_id, string arguments) throws Error { var args = parse_arguments(arguments); string question = argument_string(args, "question").strip(); var outcome = new ToolOutcome(); if (question.length == 0) { // An invalid question must NOT park the turn: report a // tool error and let the model recover. outcome.result = tool_error( "ask_question needs a non-empty question").result; return outcome; } var options = argument_strings(args, "options"); var payload = new Json.Object(); payload.set_string_member("question", question); var options_array = new Json.Array(); foreach (var option in options) { var node = new Json.Node(Json.NodeType.VALUE); node.init_string(option); options_array.add_element(node); } payload.set_array_member("options", options_array); var body = new Json.Object(); body.set_string_member("parked", "awaiting_answer"); outcome.result = json_string(body); yield chats.append_message(chat_id, "assistant", question, json_string(payload)); yield pulse.bump(0, "advisor question parked"); outcome.parked = true; return outcome; } // ------------------------------------------------------------------ // Shared appends (own scope per operation) // ------------------------------------------------------------------ private async void append(int64 chat_id, string kind, string content, string? payload) { var scope = container.create_scope(); try { var chats = scope.resolve(); yield chats.append_message(chat_id, kind, content, payload); } catch (Error e) { warning("[ClankerSaddle] Advisor append failed: %s", e.message); } finally { scope.release(); } } private async void set_status(int64 chat_id, string status) { var scope = container.create_scope(); try { var chats = scope.resolve(); yield chats.set_chat_status(chat_id, status); yield pulse.bump(0, @"advisor chat $(status)"); } catch (Error e) { warning("[ClankerSaddle] Advisor status change failed: %s", e.message); } finally { scope.release(); } } // ------------------------------------------------------------------ // Tool definitions (the OpenAI tools array) // ------------------------------------------------------------------ /** * The tools array sent with every request: the read tools, * ask_question and the two suggest tools, described for * function calling. */ public static Json.Array tools_array() { var tools = new Json.Array(); tools.add_object_element(tool_definition("list_projects", "List every project registered in this clanker-saddle fleet.", new string[0], new string[0], new string[0])); tools.add_object_element(tool_definition("get_project", "One project's full record by id.", new string[] { "project_id" }, new string[] { "integer" }, new string[] { "The project's id" })); tools.add_object_element(tool_definition("list_tasks", "List tasks, optionally filtered by status and project.", new string[] { "status", "project_id" }, new string[] { "string", "integer" }, new string[] { "Optional status filter (draft/queued/working/parked/suspended/done/failed)", "Optional project id filter (0 = standalone tasks excluded)" })); tools.add_object_element(tool_definition("get_task", "One task's full record by id (description, status, mode, branch).", new string[] { "task_id" }, new string[] { "integer" }, new string[] { "The task's id" })); tools.add_object_element(tool_definition("list_goals", "List goals, optionally filtered by project.", new string[] { "project_id" }, new string[] { "integer" }, new string[] { "Optional project id filter" })); tools.add_object_element(tool_definition("get_goal", "One goal's full record by id (brief, plan, branch).", new string[] { "goal_id" }, new string[] { "integer" }, new string[] { "The goal's id" })); tools.add_object_element(tool_definition("list_rules", "List every rule (reusable instructions attached to projects/tasks).", new string[0], new string[0], new string[0])); tools.add_object_element(tool_definition("search_discoveries", "Search the fleet's discoveries ledger (non-project facts: compiler quirks, library behaviours, tool gotchas) by substring.", new string[] { "query" }, new string[] { "string" }, new string[] { "Case-insensitive substring; empty lists the newest rows" })); tools.add_object_element(tool_definition("list_capabilities", "List the fleet's capability tags.", new string[0], new string[0], new string[0])); tools.add_object_element(tool_definition("ask_question", "Ask the user a question with multiple-choice options. The turn parks until they answer. An empty options array asks a free-text question.", new string[] { "question", "options" }, new string[] { "string", "array of string" }, new string[] { "The question to ask", "The concrete options (may be empty for free text)" })); tools.add_object_element(tool_definition("suggest_task", "Suggest a task for the user to approve. INERT until they approve it in the chat UI — nothing is created by this call.", new string[] { "title", "mandate", "mode", "project_id", "priority" }, new string[] { "string", "string", "string", "integer", "integer" }, new string[] { "Short task title", "The task's mandate: what to do, on which project, with what verification", "Work mode: implement / investigate / review / test", "The project id (optional)", "Priority 1-9, 5 default (optional)" })); tools.add_object_element(tool_definition("suggest_goal", "Suggest a goal for the user to approve. INERT until they approve it in the chat UI; an approved goal is created as an unqueued draft.", new string[] { "title", "brief", "project_id", "branch_name" }, new string[] { "string", "string", "integer", "string" }, new string[] { "Short goal title", "The goal brief: what this goal should achieve", "The primary project id", "Branch name for the goal (optional)" })); return tools; } /** * One `{type: function, function: {name, description, * parameters}}` definition. `required` is the first * `names.len`-many parameters when `required_count` > 0 — * here every declared parameter of the ask/suggest tools that * matter; simple read tools pass none. */ private static Json.Object tool_definition(string name, string description, string[] names, string[] types, string[] descriptions) { var properties = new Json.Object(); for (var index = 0; index < names.length; index++) { var property = new Json.Object(); property.set_string_member("type", index < types.length ? types[index] : "string"); property.set_string_member("description", index < descriptions.length ? descriptions[index] : ""); properties.set_object_member(names[index], property); } var parameters = new Json.Object(); parameters.set_string_member("type", "object"); parameters.set_object_member("properties", properties); var wrapper = new Json.Object(); wrapper.set_string_member("type", "function"); var function = new Json.Object(); function.set_string_member("name", name); function.set_string_member("description", description); function.set_object_member("parameters", parameters); wrapper.set_object_member("function", function); return wrapper; } } }