|
@@ -0,0 +1,1284 @@
|
|
|
|
|
+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;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|