| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284 |
- 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<Container>();
- private StatePulse pulse = inject<StatePulse>();
- 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<string> running =
- new Invercargill.DataStructures.HashSet<string>();
- /** 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<OrmSession>();
- var chats = scope.resolve<ChatService>();
- 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<AdvisorMessage>((!)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<ChatService>();
- 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<OrmSession>();
- var chats = scope.resolve<ChatService>();
- // 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<ChatService>();
- 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<Project>()
- .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<Project>()
- .where(expr("id == $0", elem<int64?>(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<TaskEntity>();
- 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<int64?>(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<TaskEntity>()
- .where(expr("id == $0", elem<int64?>(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<Goal>();
- int64 project_id = argument_int(args, "project_id");
- if (project_id > 0) {
- query.where(expr("project_id == $0", elem<int64?>(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<Goal>()
- .where(expr("id == $0", elem<int64?>(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<Rule>()
- .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<Discovery>()
- .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<Capability>()
- .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<AdvisorSuggestion>(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<AdvisorSuggestion>(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<ChatService>();
- 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<ChatService>();
- 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;
- }
- }
- }
|