AdvisorService.vala 59 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using InvercargillSql.Orm;
  5. using Inversion;
  6. using Soup;
  7. using ClankerSaddle.Data;
  8. namespace ClankerSaddle.Services {
  9. /**
  10. * The advisor's OpenAI-compatible endpoint (goal 27): the same
  11. * shape as {@link SaddleService.LlmEndpoint} but resolved from
  12. * ADVISOR_LLM_* first, falling back to SADDLE_LLM_* so an
  13. * operator with one provider configures nothing extra.
  14. */
  15. public class AdvisorLlmEndpoint : Object {
  16. public string base_url { get; set; default = ""; }
  17. public string api_key { get; set; default = ""; }
  18. public string model { get; set; default = ""; }
  19. public static string first_non_empty(string primary, string fallback) {
  20. var value = (Environment.get_variable(primary) ?? "").strip();
  21. if (value.length > 0) {
  22. return value;
  23. }
  24. return (Environment.get_variable(fallback) ?? "").strip();
  25. }
  26. /** The configured endpoint, or null when no base URL resolves. */
  27. public static AdvisorLlmEndpoint? from_environment() {
  28. var base_url = first_non_empty("ADVISOR_LLM_BASE_URL", "SADDLE_LLM_BASE_URL");
  29. if (base_url.length == 0) {
  30. return null;
  31. }
  32. return new AdvisorLlmEndpoint() {
  33. base_url = base_url,
  34. api_key = first_non_empty("ADVISOR_LLM_API_KEY", "SADDLE_LLM_API_KEY"),
  35. model = first_non_empty("ADVISOR_LLM_MODEL", "SADDLE_LLM_MODEL")
  36. };
  37. }
  38. }
  39. /**
  40. * Content, tool calls and finish_reason of one advisor
  41. * chat-completions reply (the slice of the wire format the loop
  42. * needs; usage counters are not consumed).
  43. */
  44. public class AdvisorReply : Object {
  45. public string content = "";
  46. public string finish_reason = "";
  47. public AdvisorToolCall[] tool_calls = new AdvisorToolCall[0];
  48. }
  49. /** One tool call parsed out of a reply. */
  50. public class AdvisorToolCall : Object {
  51. public string id = "";
  52. public string name = "";
  53. /** The raw arguments JSON string (verbatim to the wire). */
  54. public string arguments = "{}";
  55. }
  56. /**
  57. * The advisor loop (goal 27): a chat-bound LLM turn runner over
  58. * the clanker-saddle state. A singleton (a turn outlives its
  59. * request): `run_turn`/`answer_question` return immediately and
  60. * the turn executes in its OWN container scope — the
  61. * {@link PushService} pattern — appending advisor_messages rows
  62. * and bumping StatePulse as it goes, so the chat page's sealed
  63. * refresh renders progress without any long-held request.
  64. *
  65. * The loop is strictly READ-ONLY over work state: tools call the
  66. * existing services, and the only writes are chat rows and
  67. * pending suggestion rows — nothing is created until the human
  68. * approves a suggestion in the UI (a later task). ask_question
  69. * parks the chat ('awaiting_answer'); the answer action calls
  70. * {@link answer_question} to record it and run the next turn.
  71. *
  72. * Per-chat lock: an in-memory running set — a second trigger
  73. * while a chat's turn is in flight is a no-op (the answer action
  74. * re-runs after parking). Iterations are capped so a chatty model
  75. * cannot loop forever; any failure appends a kind='error' message
  76. * and returns the chat to 'idle'.
  77. */
  78. public class AdvisorService : Object {
  79. /** Upper bound of LLM round trips in one turn. */
  80. private const int MAX_ITERATIONS = 12;
  81. /** Long text fields fed to tools are clamped to this. */
  82. private const int TEXT_CLAMP_CHARS = 2000;
  83. /** List-shaped tool results cap their row counts to this. */
  84. private const int LIST_CLAMP_ROWS = 50;
  85. private Container container = inject<Container>();
  86. private StatePulse pulse = inject<StatePulse>();
  87. private Soup.Session http = new Soup.Session() {
  88. timeout = 120,
  89. idle_timeout = 15
  90. };
  91. /** The chats whose turn is currently executing (the lock). */
  92. private Invercargill.DataStructures.HashSet<string> running =
  93. new Invercargill.DataStructures.HashSet<string>();
  94. /** True when an LLM endpoint resolves from the environment. */
  95. public bool configured {
  96. get {
  97. return AdvisorLlmEndpoint.from_environment() != null;
  98. }
  99. }
  100. // ------------------------------------------------------------------
  101. // Turn entry points
  102. // ------------------------------------------------------------------
  103. /**
  104. * Runs one turn for `chat_id` (owned by `user_id`): the user
  105. * message is ALREADY written by the action — this only runs
  106. * the loop. Returns immediately; appends land as they happen.
  107. * A turn already in flight for the chat is a no-op.
  108. */
  109. public async void run_turn(int64 chat_id, int64 user_id) {
  110. if (try_lock(chat_id)) {
  111. yield turn_locked(chat_id, user_id);
  112. }
  113. }
  114. /**
  115. * Records the human's answer to the chat's pending question
  116. * (option match → "Human selected: X"; free text → "Human
  117. * answered: …" — the saddle wording) as a user message, then
  118. * runs the next turn. Returns false when there is no pending
  119. * question (or the chat is not the user's / nothing to do).
  120. */
  121. public async bool answer_question(int64 chat_id, int64 user_id,
  122. string answer) throws Error {
  123. var scope = container.create_scope();
  124. try {
  125. var db = scope.resolve<OrmSession>();
  126. var chats = scope.resolve<ChatService>();
  127. if ((yield chats.get_chat(chat_id, user_id)) == null) {
  128. return false;
  129. }
  130. var question = yield chats.pending_question(chat_id);
  131. if (question == null) {
  132. return false;
  133. }
  134. string trimmed = answer.strip();
  135. if (trimmed.length == 0) {
  136. return false;
  137. }
  138. var selected = option_member(((!)question).payload ?? "", trimmed);
  139. string quoted;
  140. if (selected != null) {
  141. quoted = @"Human selected: $(((!)selected))";
  142. } else {
  143. quoted = @"Human answered: $(trimmed)";
  144. }
  145. ((!)question).answer = trimmed;
  146. ((!)question).answered_at = new DateTime.now_local();
  147. yield db.update_async<AdvisorMessage>((!)question);
  148. yield chats.append_message(chat_id, "user", quoted, null);
  149. yield pulse.bump(0, "advisor answer recorded");
  150. } finally {
  151. scope.release();
  152. }
  153. if (try_lock(chat_id)) {
  154. yield turn_locked(chat_id, user_id);
  155. }
  156. return true;
  157. }
  158. /** Acquires the per-chat turn lock; false when already held. */
  159. private bool try_lock(int64 chat_id) {
  160. var key = chat_id.to_string();
  161. if (running.contains(key)) {
  162. return false;
  163. }
  164. running.add(key);
  165. return true;
  166. }
  167. /**
  168. * The turn body, with the lock held: status 'running', then
  169. * the LLM loop until a plain assistant reply, an ask_question
  170. * park or the iteration cap; every exit path resets a
  171. * terminal status.
  172. */
  173. private async void turn_locked(int64 chat_id, int64 user_id) {
  174. var endpoint = AdvisorLlmEndpoint.from_environment();
  175. try {
  176. if (endpoint == null) {
  177. yield append(chat_id, "error",
  178. "The advisor is not configured — set ADVISOR_LLM_BASE_URL"
  179. + " (or SADDLE_LLM_BASE_URL) to enable it.", null);
  180. yield set_status(chat_id, "idle");
  181. return;
  182. }
  183. yield set_status(chat_id, "running");
  184. var messages = yield wire_messages(chat_id, (!)endpoint);
  185. for (var iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
  186. var reply = yield chat_once((!)endpoint, messages);
  187. foreach (var call in reply.tool_calls) {
  188. messages.add_object_element(
  189. assistant_call_node(reply.content, call));
  190. }
  191. if (reply.tool_calls.length == 0) {
  192. yield append(chat_id, "assistant",
  193. reply.content.length > 0 ? reply.content : "(no reply)", null);
  194. yield set_status(chat_id, "idle");
  195. return;
  196. }
  197. bool parked = false;
  198. foreach (var call in reply.tool_calls) {
  199. var outcome = yield execute_tool(chat_id, user_id, call);
  200. messages.add_object_element(tool_result_node(call, outcome));
  201. if (outcome.parked) {
  202. parked = true;
  203. }
  204. }
  205. if (parked) {
  206. // ask_question: the question row is already
  207. // appended by execute_tool — the turn ends
  208. // here and the answer action resumes it.
  209. yield set_status(chat_id, "awaiting_answer");
  210. return;
  211. }
  212. }
  213. yield append(chat_id, "error",
  214. @"The advisor stopped after $(MAX_ITERATIONS) rounds without a final reply —"
  215. + " please rephrase or continue.", null);
  216. yield set_status(chat_id, "idle");
  217. } catch (Error e) {
  218. yield append(chat_id, "error", @"Advisor turn failed: $(e.message)", null);
  219. yield set_status(chat_id, "idle");
  220. } finally {
  221. running.remove(chat_id.to_string());
  222. }
  223. }
  224. /** One outcome of executing a tool call. */
  225. private class ToolOutcome : Object {
  226. /** The JSON result handed back to the model. */
  227. public string result = "{}";
  228. /** True when the tool parked the turn (ask_question). */
  229. public bool parked = false;
  230. }
  231. /** An error message handed back to the model, never thrown. */
  232. private static ToolOutcome tool_error(string message) {
  233. var body = new Json.Object();
  234. body.set_string_member("error", message);
  235. var generator = new Json.Generator();
  236. generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(body));
  237. var outcome = new ToolOutcome();
  238. outcome.result = generator.to_data(null);
  239. return outcome;
  240. }
  241. // ------------------------------------------------------------------
  242. // Wire construction
  243. // ------------------------------------------------------------------
  244. /**
  245. * The OpenAI messages array rebuilt from the stored rows: the
  246. * system prompt, then user/assistant/tool rows in order — an
  247. * assistant row that carried tool_calls reconstructs them
  248. * from its payload, and a tool row becomes the `tool` role
  249. * message with tool_call_id/name/content. error and system
  250. * rows ride as user-bracketed notices so the model sees them.
  251. */
  252. private async Json.Array wire_messages(int64 chat_id,
  253. AdvisorLlmEndpoint endpoint) throws Error {
  254. var scope = container.create_scope();
  255. var messages = new Json.Array();
  256. try {
  257. var chats = scope.resolve<ChatService>();
  258. messages.add_object_element(system_message_node());
  259. foreach (var row in yield chats.list_messages(chat_id)) {
  260. if (row.kind == "user") {
  261. var node = new Json.Object();
  262. node.set_string_member("role", "user");
  263. node.set_string_member("content", row.content);
  264. messages.add_object_element(node);
  265. } else if (row.kind == "assistant") {
  266. var payload = row.payload ?? "";
  267. var calls = payload_tool_calls(payload);
  268. if (calls.length > 0) {
  269. // One assistant row per call was stored;
  270. // reconstruct this row's own call.
  271. var stored = payload_call(payload);
  272. var node = new Json.Object();
  273. node.set_string_member("role", "assistant");
  274. node.set_string_member("content", row.content);
  275. if (stored != null) {
  276. node.set_array_member("tool_calls",
  277. tool_calls_array(new AdvisorToolCall[] { (!)stored }));
  278. }
  279. messages.add_object_element(node);
  280. } else {
  281. var node = new Json.Object();
  282. node.set_string_member("role", "assistant");
  283. node.set_string_member("content", row.content);
  284. messages.add_object_element(node);
  285. }
  286. } else if (row.kind == "tool") {
  287. var payload = row.payload ?? "";
  288. var node = new Json.Object();
  289. node.set_string_member("role", "tool");
  290. node.set_string_member("tool_call_id",
  291. payload_string(payload, "call_id"));
  292. node.set_string_member("name",
  293. payload_string(payload, "name"));
  294. node.set_string_member("content",
  295. payload_string(payload, "result"));
  296. messages.add_object_element(node);
  297. } else {
  298. // error / system: a bracketed notice the model
  299. // must see but that is neither party's voice.
  300. var node = new Json.Object();
  301. node.set_string_member("role", "user");
  302. node.set_string_member("content",
  303. @"[($(row.kind)) notice] $(row.content)");
  304. messages.add_object_element(node);
  305. }
  306. }
  307. } finally {
  308. scope.release();
  309. }
  310. return messages;
  311. }
  312. /** The persona/system message. */
  313. private static Json.Object system_message_node() {
  314. var node = new Json.Object();
  315. node.set_string_member("role", "system");
  316. node.set_string_member("content", SYSTEM_PROMPT);
  317. return node;
  318. }
  319. /**
  320. * The advisor persona: a READ-ONLY operations advisor for a
  321. * clanker-saddle fleet. It can inspect work state through its
  322. * tools, ask the user questions when unsure, and SUGGEST
  323. * tasks/goals that stay inert until explicitly approved in
  324. * the UI. It can never run commands.
  325. */
  326. private const string SYSTEM_PROMPT = "You are the advisor: a read-only operations"
  327. + " advisor for a clanker-saddle fleet (an LLM task orchestration platform with"
  328. + " projects, tasks, goals, rules, discoveries and capability-tagged saddles)."
  329. + " You chat with the fleet's human operator.\n"
  330. + "Your tools can READ the fleet's state (projects, tasks, goals, rules,"
  331. + " discoveries, capabilities) and nothing else. You CANNOT run commands,"
  332. + " edit files, dispatch work or change any state.\n"
  333. + "When a course of action needs real work, call suggest_task or suggest_goal:"
  334. + " a suggestion is INERT until the user explicitly approves it in the chat UI"
  335. + " — nothing is created without that approval, and you should say so plainly.\n"
  336. + "When you are unsure what the user wants, or a decision genuinely needs"
  337. + " them, call ask_question with concrete options (an empty options array asks"
  338. + " a free-text question). Prefer asking over guessing.\n"
  339. + "Be concise and specific; answer in short markdown where it helps.";
  340. /** The assistant wire node carrying one tool call. */
  341. private static Json.Object assistant_call_node(string content,
  342. AdvisorToolCall call) {
  343. var node = new Json.Object();
  344. node.set_string_member("role", "assistant");
  345. node.set_string_member("content", content);
  346. node.set_array_member("tool_calls", tool_calls_array(
  347. new AdvisorToolCall[] { call }));
  348. return node;
  349. }
  350. /** The tool_calls array for the wire. */
  351. private static Json.Array tool_calls_array(AdvisorToolCall[] calls) {
  352. var array = new Json.Array();
  353. foreach (var call in calls) {
  354. var element = new Json.Object();
  355. element.set_string_member("id", call.id);
  356. element.set_string_member("type", "function");
  357. var function = new Json.Object();
  358. function.set_string_member("name", call.name);
  359. function.set_string_member("arguments", call.arguments);
  360. element.set_object_member("function", function);
  361. array.add_object_element(element);
  362. }
  363. return array;
  364. }
  365. /** The `tool` role wire node for one executed call. */
  366. private static Json.Object tool_result_node(AdvisorToolCall call,
  367. ToolOutcome outcome) {
  368. var node = new Json.Object();
  369. node.set_string_member("role", "tool");
  370. node.set_string_member("tool_call_id", call.id);
  371. node.set_string_member("name", call.name);
  372. node.set_string_member("content", outcome.result);
  373. return node;
  374. }
  375. // --- payload helpers (payload JSON of stored rows) ---
  376. private static string payload_string(string payload, string member) {
  377. var parser = new Json.Parser();
  378. try {
  379. parser.load_from_data(payload);
  380. } catch (Error e) {
  381. return "";
  382. }
  383. var root = parser.get_root();
  384. if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
  385. return "";
  386. }
  387. var body = ((!)root).get_object();
  388. if (!body.has_member(member)) {
  389. return "";
  390. }
  391. var node = body.get_member(member);
  392. return node.get_node_type() == Json.NodeType.VALUE
  393. && node.get_string() != null ? (!)node.get_string() : "";
  394. }
  395. /** The single stored tool call of an assistant row, or null. */
  396. private static AdvisorToolCall? payload_call(string payload) {
  397. var call = new AdvisorToolCall();
  398. call.id = payload_string(payload, "call_id");
  399. call.name = payload_string(payload, "name");
  400. call.arguments = payload_string(payload, "arguments");
  401. if (call.id.length == 0 && call.name.length == 0) {
  402. return null;
  403. }
  404. return call;
  405. }
  406. /** Whether a payload carries a stored tool call. */
  407. private static AdvisorToolCall[] payload_tool_calls(string payload) {
  408. var call = payload_call(payload);
  409. return call != null ? new AdvisorToolCall[] { (!)call } : new AdvisorToolCall[0];
  410. }
  411. /**
  412. * The options array of a question payload, or null when the
  413. * trimmed answer is not one of them.
  414. */
  415. private static string? option_member(string payload, string answer) {
  416. var parser = new Json.Parser();
  417. try {
  418. parser.load_from_data(payload);
  419. } catch (Error e) {
  420. return null;
  421. }
  422. var root = parser.get_root();
  423. if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
  424. return null;
  425. }
  426. var body = ((!)root).get_object();
  427. if (!body.has_member("options")
  428. || body.get_member("options").get_node_type() != Json.NodeType.ARRAY) {
  429. return null;
  430. }
  431. foreach (var node in body.get_array_member("options").get_elements()) {
  432. if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null
  433. && ((!)node.get_string()) == answer) {
  434. return (!)node.get_string();
  435. }
  436. }
  437. return null;
  438. }
  439. // ------------------------------------------------------------------
  440. // LLM transport
  441. // ------------------------------------------------------------------
  442. /**
  443. * One non-streaming chat-completions call: the messages built
  444. * so far plus the tool set. Transport failures and HTTP >=
  445. * 400 throw (the turn's error row); the reply is parsed into
  446. * content + tool calls.
  447. */
  448. private async AdvisorReply chat_once(AdvisorLlmEndpoint endpoint,
  449. Json.Array messages) throws Error {
  450. var body = new Json.Object();
  451. body.set_string_member("model",
  452. endpoint.model.length > 0 ? endpoint.model : "advisor");
  453. body.set_array_member("messages", messages);
  454. body.set_array_member("tools", tools_array());
  455. var generator = new Json.Generator();
  456. var body_node = new Json.Node(Json.NodeType.OBJECT);
  457. body_node.init_object(body);
  458. generator.set_root(body_node);
  459. string url = endpoint.base_url.has_suffix("/")
  460. ? endpoint.base_url.substring(0, endpoint.base_url.length - 1)
  461. : endpoint.base_url;
  462. var message = new Soup.Message("POST", @"$(url)/chat/completions");
  463. message.set_request_body_from_bytes("application/json",
  464. new Bytes(generator.to_data(null).data));
  465. if (endpoint.api_key.length > 0) {
  466. message.request_headers.append("Authorization",
  467. @"Bearer $(endpoint.api_key)");
  468. }
  469. Bytes? raw;
  470. try {
  471. raw = yield http.send_and_read_async(message, Priority.DEFAULT, null);
  472. } catch (Error e) {
  473. throw new Error(Quark.from_string("clanker-saddle"), 80,
  474. @"LLM request failed: $(e.message)");
  475. }
  476. if (message.status_code >= 400) {
  477. throw new Error(Quark.from_string("clanker-saddle"), 81,
  478. @"LLM endpoint answered HTTP $(message.status_code)");
  479. }
  480. // get_data() hands back an unterminated array — casting it
  481. // to string would read past the buffer until an unrelated
  482. // NUL. Copy into a length+1 buffer and terminate.
  483. var data = raw != null ? ((!)raw).get_data() : new uint8[0];
  484. if (data.length == 0) {
  485. return parse_reply("{}");
  486. }
  487. var terminated = new uint8[data.length + 1];
  488. for (var index = 0; index < data.length; index++) {
  489. terminated[index] = data[index];
  490. }
  491. terminated[data.length] = '\0';
  492. return parse_reply((string) terminated);
  493. }
  494. /**
  495. * Parses a chat-completions response body: the first choice's
  496. * content, tool calls (id, name, raw arguments) and
  497. * finish_reason. Mirrors the daemon's parser shape.
  498. */
  499. internal static AdvisorReply parse_reply(string body) throws Error {
  500. var parser = new Json.Parser();
  501. try {
  502. parser.load_from_data(body);
  503. } catch (Error e) {
  504. throw new Error(Quark.from_string("clanker-saddle"), 82,
  505. @"LLM response is not JSON: $(e.message)");
  506. }
  507. var root = parser.get_root();
  508. if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
  509. throw new Error(Quark.from_string("clanker-saddle"), 82,
  510. "LLM response is not a JSON object");
  511. }
  512. var object = ((!)root).get_object();
  513. if (!object.has_member("choices")
  514. || object.get_member("choices").get_node_type() != Json.NodeType.ARRAY) {
  515. throw new Error(Quark.from_string("clanker-saddle"), 82,
  516. "LLM response has no choices array");
  517. }
  518. var choices = object.get_array_member("choices");
  519. if (choices.get_length() == 0) {
  520. throw new Error(Quark.from_string("clanker-saddle"), 82,
  521. "LLM response choices array is empty");
  522. }
  523. var first = choices.get_object_element(0);
  524. if (!first.has_member("message")) {
  525. throw new Error(Quark.from_string("clanker-saddle"), 82,
  526. "LLM choice has no message");
  527. }
  528. var reply = new AdvisorReply();
  529. var choice_message = first.get_object_member("message");
  530. if (choice_message.has_member("content")) {
  531. var content = choice_message.get_member("content");
  532. if (content.get_node_type() == Json.NodeType.VALUE
  533. && content.get_string() != null) {
  534. reply.content = (!)content.get_string();
  535. }
  536. }
  537. if (choice_message.has_member("tool_calls")
  538. && choice_message.get_member("tool_calls").get_node_type()
  539. == Json.NodeType.ARRAY) {
  540. var calls = choice_message.get_array_member("tool_calls");
  541. for (var index = 0; index < calls.get_length(); index++) {
  542. var element = calls.get_element(index);
  543. if (element.get_node_type() != Json.NodeType.OBJECT) {
  544. continue;
  545. }
  546. var call = element.get_object();
  547. var parsed = new AdvisorToolCall();
  548. parsed.id = member_string(call, "id");
  549. parsed.name = "";
  550. parsed.arguments = "{}";
  551. if (call.has_member("function")
  552. && call.get_member("function").get_node_type()
  553. == Json.NodeType.OBJECT) {
  554. var function = call.get_object_member("function");
  555. parsed.name = member_string(function, "name");
  556. parsed.arguments = member_string(function, "arguments");
  557. if (parsed.arguments.length == 0) {
  558. parsed.arguments = "{}";
  559. }
  560. }
  561. if (reply.tool_calls.length == 0) {
  562. reply.tool_calls = new AdvisorToolCall[] { parsed };
  563. } else {
  564. var grown = new AdvisorToolCall[reply.tool_calls.length + 1];
  565. for (var copy = 0; copy < reply.tool_calls.length; copy++) {
  566. grown[copy] = reply.tool_calls[copy];
  567. }
  568. grown[reply.tool_calls.length] = parsed;
  569. reply.tool_calls = grown;
  570. }
  571. }
  572. }
  573. reply.finish_reason = member_string(first, "finish_reason");
  574. return reply;
  575. }
  576. /** A string member of `body`, or "" (never null). */
  577. private static string member_string(Json.Object body, string member) {
  578. if (!body.has_member(member)) {
  579. return "";
  580. }
  581. var node = body.get_member(member);
  582. if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) {
  583. return (!)node.get_string();
  584. }
  585. return "";
  586. }
  587. // ------------------------------------------------------------------
  588. // Tool dispatch
  589. // ------------------------------------------------------------------
  590. /**
  591. * Executes one tool call inside a FRESH scope (the container
  592. * owns every service), stores the assistant call row and the
  593. * tool result row, bumps the pulse, and reports the outcome
  594. * the model sees. Failures are reported as tool errors, never
  595. * thrown — the loop keeps going so the model can react.
  596. */
  597. private async ToolOutcome execute_tool(int64 chat_id, int64 user_id,
  598. AdvisorToolCall call) {
  599. var outcome = new ToolOutcome();
  600. var scope = container.create_scope();
  601. try {
  602. var db = scope.resolve<OrmSession>();
  603. var chats = scope.resolve<ChatService>();
  604. // The assistant row carrying this call, stored in the
  605. // wire shape (call_id + name + raw arguments).
  606. var call_payload = new Json.Object();
  607. call_payload.set_string_member("call_id", call.id);
  608. call_payload.set_string_member("name", call.name);
  609. call_payload.set_string_member("arguments", call.arguments);
  610. yield chats.append_message(chat_id, "assistant",
  611. "@tool call", json_string(call_payload));
  612. string result;
  613. bool parked = false;
  614. if (call.name == "ask_question") {
  615. var question = yield tool_ask_question(db, chats, chat_id,
  616. call.arguments);
  617. result = question.result;
  618. parked = question.parked;
  619. } else if (call.name == "suggest_task" || call.name == "suggest_goal") {
  620. // Suggestion rows belong to THIS chat and its user —
  621. // that ownership is what the later approve action
  622. // and the completion callback key on.
  623. result = yield dispatch_read_tool(db, call.name, call.arguments,
  624. chat_id, user_id);
  625. } else {
  626. result = yield dispatch_read_tool(db, call.name, call.arguments,
  627. chat_id, user_id);
  628. }
  629. outcome.result = result;
  630. var result_payload = new Json.Object();
  631. result_payload.set_string_member("call_id", call.id);
  632. result_payload.set_string_member("name", call.name);
  633. result_payload.set_string_member("arguments", call.arguments);
  634. result_payload.set_string_member("result", result);
  635. yield chats.append_message(chat_id, "tool", "@tool result",
  636. json_string(result_payload));
  637. outcome.parked = parked;
  638. yield pulse.bump(0, @"advisor tool $(call.name)");
  639. } catch (Error e) {
  640. outcome.result = tool_error_json(@"$(call.name) failed: $(e.message)");
  641. try {
  642. var chats = scope.resolve<ChatService>();
  643. var result_payload = new Json.Object();
  644. result_payload.set_string_member("call_id", call.id);
  645. result_payload.set_string_member("name", call.name);
  646. result_payload.set_string_member("arguments", call.arguments);
  647. result_payload.set_string_member("result", outcome.result);
  648. yield chats.append_message(chat_id, "tool", "@tool result",
  649. json_string(result_payload));
  650. } catch (Error ignored) {
  651. // The error row below is the last resort record.
  652. }
  653. } finally {
  654. scope.release();
  655. }
  656. return outcome;
  657. }
  658. /** tool_error's JSON string (kept separate for reuse above). */
  659. private static string tool_error_json(string message) {
  660. return tool_error(message).result;
  661. }
  662. /**
  663. * The read-only tool switch: every case resolves its data
  664. * through the EXISTING services; unknown names report a tool
  665. * error. suggest_task/suggest_goal insert pending suggestion
  666. * rows — the only write besides chat rows.
  667. */
  668. private async string dispatch_read_tool(OrmSession db, string name,
  669. string arguments, int64 chat_id, int64 user_id) throws Error {
  670. switch (name) {
  671. case "list_projects":
  672. return yield tool_list_projects(db);
  673. case "get_project":
  674. return yield tool_get_project(db, arguments);
  675. case "list_tasks":
  676. return yield tool_list_tasks(db, arguments);
  677. case "get_task":
  678. return yield tool_get_task(db, arguments);
  679. case "list_goals":
  680. return yield tool_list_goals(db, arguments);
  681. case "get_goal":
  682. return yield tool_get_goal(db, arguments);
  683. case "list_rules":
  684. return yield tool_list_rules(db);
  685. case "search_discoveries":
  686. return yield tool_search_discoveries(db, arguments);
  687. case "list_capabilities":
  688. return yield tool_list_capabilities(db);
  689. case "suggest_task":
  690. return yield tool_suggest_task(db, arguments, chat_id, user_id);
  691. case "suggest_goal":
  692. return yield tool_suggest_goal(db, arguments, chat_id, user_id);
  693. default:
  694. return tool_error_json(@"Unknown tool '$(name)' — the advisor's tool set is read-only");
  695. }
  696. }
  697. /** Parses the arguments JSON string, or an empty object. */
  698. private static Json.Object parse_arguments(string arguments) {
  699. var parser = new Json.Parser();
  700. try {
  701. parser.load_from_data(arguments);
  702. } catch (Error e) {
  703. return new Json.Object();
  704. }
  705. var root = parser.get_root();
  706. if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
  707. return new Json.Object();
  708. }
  709. return ((!)root).get_object();
  710. }
  711. private static int64 argument_int(Json.Object arguments, string member,
  712. int64 fallback = 0) {
  713. if (!arguments.has_member(member)) {
  714. return fallback;
  715. }
  716. var node = arguments.get_member(member);
  717. if (node.get_node_type() == Json.NodeType.VALUE) {
  718. return node.get_int();
  719. }
  720. return fallback;
  721. }
  722. private static string argument_string(Json.Object arguments, string member,
  723. string fallback = "") {
  724. if (!arguments.has_member(member)) {
  725. return fallback;
  726. }
  727. var node = arguments.get_member(member);
  728. if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) {
  729. return (!)node.get_string();
  730. }
  731. return fallback;
  732. }
  733. private static string[] argument_strings(Json.Object arguments, string member) {
  734. var values = new string[0];
  735. if (!arguments.has_member(member)
  736. || arguments.get_member(member).get_node_type() != Json.NodeType.ARRAY) {
  737. return values;
  738. }
  739. foreach (var node in arguments.get_array_member(member).get_elements()) {
  740. if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) {
  741. values += (!)node.get_string();
  742. }
  743. }
  744. return values;
  745. }
  746. private static string json_string(Json.Object body) {
  747. var generator = new Json.Generator();
  748. var node = new Json.Node(Json.NodeType.OBJECT);
  749. node.init_object(body);
  750. generator.set_root(node);
  751. return generator.to_data(null);
  752. }
  753. /** Long text clamped for tool results. */
  754. private static string clamp_text(string text) {
  755. return text.length > TEXT_CLAMP_CHARS
  756. ? text.substring(0, TEXT_CLAMP_CHARS) + "…"
  757. : text;
  758. }
  759. private static string stamp_text(DateTime? stamp) {
  760. return stamp != null
  761. ? ((!)stamp).format("%Y-%m-%d %H:%M") : "";
  762. }
  763. // --- read tools (data shaping over the existing services) ---
  764. private async string tool_list_projects(OrmSession db) throws Error {
  765. var projects = yield db.query<Project>()
  766. .order_by(expr("name"))
  767. .materialise_async();
  768. var array = new Json.Array();
  769. int count = 0;
  770. foreach (var project in projects) {
  771. if (count++ >= LIST_CLAMP_ROWS) {
  772. break;
  773. }
  774. var element = new Json.Object();
  775. element.set_int_member("id", project.id);
  776. element.set_string_member("name", project.name);
  777. element.set_string_member("default_branch", project.default_branch);
  778. element.set_string_member("summary",
  779. clamp_text(project.summary ?? ""));
  780. array.add_object_element(element);
  781. }
  782. var body = new Json.Object();
  783. body.set_array_member("projects", array);
  784. return json_string(body);
  785. }
  786. private async string tool_get_project(OrmSession db,
  787. string arguments) throws Error {
  788. int64 id = argument_int(parse_arguments(arguments), "project_id");
  789. var project = yield db.query<Project>()
  790. .where(expr("id == $0", elem<int64?>(id)))
  791. .first_async();
  792. if (project == null) {
  793. return tool_error_json(@"No project exists with id $(id)");
  794. }
  795. var body = new Json.Object();
  796. body.set_int_member("id", ((!)project).id);
  797. body.set_string_member("name", ((!)project).name);
  798. body.set_string_member("git_url", ((!)project).git_url);
  799. body.set_string_member("default_branch", ((!)project).default_branch);
  800. body.set_string_member("summary", clamp_text(((!)project).summary ?? ""));
  801. body.set_string_member("created_at", stamp_text(((!)project).created_at));
  802. return json_string(body);
  803. }
  804. private async string tool_list_tasks(OrmSession db,
  805. string arguments) throws Error {
  806. var args = parse_arguments(arguments);
  807. var query = db.query<TaskEntity>();
  808. string status = argument_string(args, "status");
  809. int64 project_id = argument_int(args, "project_id");
  810. if (status.length > 0) {
  811. query.where(expr("status == $0", elem(status)));
  812. }
  813. if (project_id > 0) {
  814. query.where(expr("project_id == $0", elem<int64?>(project_id)));
  815. }
  816. var array = new Json.Array();
  817. int count = 0;
  818. foreach (var task in yield query.order_by_desc(expr("id")).materialise_async()) {
  819. if (count++ >= LIST_CLAMP_ROWS) {
  820. break;
  821. }
  822. var element = new Json.Object();
  823. element.set_int_member("id", task.id);
  824. element.set_int_member("project_id", task.project_id);
  825. element.set_string_member("title", task.title);
  826. element.set_string_member("status", task.status);
  827. element.set_string_member("mode", task.mode);
  828. element.set_int_member("priority", task.priority);
  829. element.set_int_member("goal_id", task.goal_id);
  830. array.add_object_element(element);
  831. }
  832. var body = new Json.Object();
  833. body.set_array_member("tasks", array);
  834. return json_string(body);
  835. }
  836. private async string tool_get_task(OrmSession db,
  837. string arguments) throws Error {
  838. int64 id = argument_int(parse_arguments(arguments), "task_id");
  839. var task = yield db.query<TaskEntity>()
  840. .where(expr("id == $0", elem<int64?>(id)))
  841. .first_async();
  842. if (task == null) {
  843. return tool_error_json(@"No task exists with id $(id)");
  844. }
  845. var body = new Json.Object();
  846. body.set_int_member("id", ((!)task).id);
  847. body.set_int_member("project_id", ((!)task).project_id);
  848. body.set_string_member("title", ((!)task).title);
  849. body.set_string_member("description",
  850. clamp_text(((!)task).description ?? ""));
  851. body.set_string_member("status", ((!)task).status);
  852. body.set_string_member("mode", ((!)task).mode);
  853. body.set_string_member("creator", ((!)task).creator);
  854. body.set_int_member("priority", ((!)task).priority);
  855. body.set_int_member("goal_id", ((!)task).goal_id);
  856. body.set_string_member("task_branch", ((!)task).task_branch ?? "");
  857. body.set_string_member("created_at", stamp_text(((!)task).created_at));
  858. return json_string(body);
  859. }
  860. private async string tool_list_goals(OrmSession db,
  861. string arguments) throws Error {
  862. var args = parse_arguments(arguments);
  863. var query = db.query<Goal>();
  864. int64 project_id = argument_int(args, "project_id");
  865. if (project_id > 0) {
  866. query.where(expr("project_id == $0", elem<int64?>(project_id)));
  867. }
  868. var array = new Json.Array();
  869. int count = 0;
  870. foreach (var goal in yield query.order_by_desc(expr("id")).materialise_async()) {
  871. if (count++ >= LIST_CLAMP_ROWS) {
  872. break;
  873. }
  874. var element = new Json.Object();
  875. element.set_int_member("id", goal.id);
  876. element.set_int_member("project_id", goal.project_id);
  877. element.set_string_member("title", goal.title);
  878. element.set_string_member("status", goal.status);
  879. element.set_string_member("branch_name", goal.branch_name);
  880. array.add_object_element(element);
  881. }
  882. var body = new Json.Object();
  883. body.set_array_member("goals", array);
  884. return json_string(body);
  885. }
  886. private async string tool_get_goal(OrmSession db,
  887. string arguments) throws Error {
  888. int64 id = argument_int(parse_arguments(arguments), "goal_id");
  889. var goal = yield db.query<Goal>()
  890. .where(expr("id == $0", elem<int64?>(id)))
  891. .first_async();
  892. if (goal == null) {
  893. return tool_error_json(@"No goal exists with id $(id)");
  894. }
  895. var body = new Json.Object();
  896. body.set_int_member("id", ((!)goal).id);
  897. body.set_int_member("project_id", ((!)goal).project_id);
  898. body.set_string_member("title", ((!)goal).title);
  899. body.set_string_member("brief", clamp_text(((!)goal).brief));
  900. body.set_string_member("status", ((!)goal).status);
  901. body.set_string_member("branch_name", ((!)goal).branch_name);
  902. body.set_string_member("plan", clamp_text(((!)goal).plan ?? ""));
  903. body.set_string_member("created_at", stamp_text(((!)goal).created_at));
  904. return json_string(body);
  905. }
  906. private async string tool_list_rules(OrmSession db) throws Error {
  907. var rules = yield db.query<Rule>()
  908. .order_by(expr("name"))
  909. .materialise_async();
  910. var array = new Json.Array();
  911. int count = 0;
  912. foreach (var rule in rules) {
  913. if (count++ >= LIST_CLAMP_ROWS) {
  914. break;
  915. }
  916. var element = new Json.Object();
  917. element.set_int_member("id", rule.id);
  918. element.set_string_member("name", rule.name);
  919. element.set_string_member("body", clamp_text(rule.body));
  920. array.add_object_element(element);
  921. }
  922. var body = new Json.Object();
  923. body.set_array_member("rules", array);
  924. return json_string(body);
  925. }
  926. private async string tool_search_discoveries(OrmSession db,
  927. string arguments) throws Error {
  928. string query = argument_string(parse_arguments(arguments), "query");
  929. var rows = yield db.query<Discovery>()
  930. .order_by_desc(expr("id"))
  931. .materialise_async();
  932. var array = new Json.Array();
  933. int count = 0;
  934. string needle = query.strip().down();
  935. foreach (var row in rows) {
  936. if (needle.length > 0 && !row.body.down().contains(needle)) {
  937. continue;
  938. }
  939. if (count++ >= LIST_CLAMP_ROWS) {
  940. break;
  941. }
  942. var element = new Json.Object();
  943. element.set_int_member("id", row.id);
  944. element.set_string_member("body", clamp_text(row.body));
  945. element.set_string_member("updated_at", stamp_text(row.updated_at));
  946. array.add_object_element(element);
  947. }
  948. var body = new Json.Object();
  949. body.set_array_member("discoveries", array);
  950. return json_string(body);
  951. }
  952. private async string tool_list_capabilities(OrmSession db) throws Error {
  953. var capabilities = yield db.query<Capability>()
  954. .order_by(expr("name"))
  955. .materialise_async();
  956. var array = new Json.Array();
  957. foreach (var capability in capabilities) {
  958. var element = new Json.Object();
  959. element.set_int_member("id", capability.id);
  960. element.set_string_member("name", capability.name);
  961. array.add_object_element(element);
  962. }
  963. var body = new Json.Object();
  964. body.set_array_member("capabilities", array);
  965. return json_string(body);
  966. }
  967. // --- suggestions (the only non-chat write) ---
  968. /**
  969. * Inserts a pending task suggestion row. The payload keeps
  970. * every field the approve action will need verbatim.
  971. */
  972. private async string tool_suggest_task(OrmSession db,
  973. string arguments, int64 chat_id, int64 user_id) throws Error {
  974. var args = parse_arguments(arguments);
  975. string title = argument_string(args, "title").strip();
  976. string mandate = argument_string(args, "mandate").strip();
  977. if (title.length == 0 || mandate.length == 0) {
  978. return tool_error_json("suggest_task needs a non-empty title and mandate");
  979. }
  980. string mode = argument_string(args, "mode", "implement");
  981. var payload = new Json.Object();
  982. payload.set_string_member("title", title);
  983. payload.set_string_member("mandate", mandate);
  984. payload.set_string_member("mode", mode);
  985. payload.set_int_member("project_id", argument_int(args, "project_id"));
  986. payload.set_int_member("priority", argument_int(args, "priority", 5));
  987. var row = new AdvisorSuggestion() {
  988. chat_id = chat_id,
  989. user_id = user_id,
  990. kind = "task",
  991. payload = json_string(payload),
  992. status = "pending",
  993. created_at = new DateTime.now_local()
  994. };
  995. yield db.insert_async<AdvisorSuggestion>(row);
  996. var body = new Json.Object();
  997. body.set_int_member("suggestion_id", row.id);
  998. body.set_string_member("status", "pending");
  999. body.set_string_member("note",
  1000. "Suggestion stored — nothing is created until the user approves it in the chat UI.");
  1001. return json_string(body);
  1002. }
  1003. /**
  1004. * Inserts a pending goal suggestion row (created as an
  1005. * UNQUEUED draft on approval).
  1006. */
  1007. private async string tool_suggest_goal(OrmSession db,
  1008. string arguments, int64 chat_id, int64 user_id) throws Error {
  1009. var args = parse_arguments(arguments);
  1010. string title = argument_string(args, "title").strip();
  1011. string brief = argument_string(args, "brief").strip();
  1012. if (title.length == 0 || brief.length == 0) {
  1013. return tool_error_json("suggest_goal needs a non-empty title and brief");
  1014. }
  1015. var payload = new Json.Object();
  1016. payload.set_string_member("title", title);
  1017. payload.set_string_member("brief", brief);
  1018. payload.set_int_member("project_id", argument_int(args, "project_id"));
  1019. payload.set_string_member("branch_name",
  1020. argument_string(args, "branch_name"));
  1021. var row = new AdvisorSuggestion() {
  1022. chat_id = chat_id,
  1023. user_id = user_id,
  1024. kind = "goal",
  1025. payload = json_string(payload),
  1026. status = "pending",
  1027. created_at = new DateTime.now_local()
  1028. };
  1029. yield db.insert_async<AdvisorSuggestion>(row);
  1030. var body = new Json.Object();
  1031. body.set_int_member("suggestion_id", row.id);
  1032. body.set_string_member("status", "pending");
  1033. body.set_string_member("note",
  1034. "Suggestion stored — nothing is created until the user approves it in the chat UI.");
  1035. return json_string(body);
  1036. }
  1037. // --- ask_question ---
  1038. /**
  1039. * The ONE parking tool: validates the question and stores it
  1040. * as an assistant row whose payload carries the question and
  1041. * its options; the turn then ends with status
  1042. * 'awaiting_answer' and the answer action resumes it. An
  1043. * empty options array is allowed (a free-text question, like
  1044. * the saddle's ask_question).
  1045. */
  1046. private async ToolOutcome tool_ask_question(OrmSession db, ChatService chats,
  1047. int64 chat_id, string arguments) throws Error {
  1048. var args = parse_arguments(arguments);
  1049. string question = argument_string(args, "question").strip();
  1050. var outcome = new ToolOutcome();
  1051. if (question.length == 0) {
  1052. // An invalid question must NOT park the turn: report a
  1053. // tool error and let the model recover.
  1054. outcome.result = tool_error(
  1055. "ask_question needs a non-empty question").result;
  1056. return outcome;
  1057. }
  1058. var options = argument_strings(args, "options");
  1059. var payload = new Json.Object();
  1060. payload.set_string_member("question", question);
  1061. var options_array = new Json.Array();
  1062. foreach (var option in options) {
  1063. var node = new Json.Node(Json.NodeType.VALUE);
  1064. node.init_string(option);
  1065. options_array.add_element(node);
  1066. }
  1067. payload.set_array_member("options", options_array);
  1068. var body = new Json.Object();
  1069. body.set_string_member("parked", "awaiting_answer");
  1070. outcome.result = json_string(body);
  1071. yield chats.append_message(chat_id, "assistant", question,
  1072. json_string(payload));
  1073. yield pulse.bump(0, "advisor question parked");
  1074. outcome.parked = true;
  1075. return outcome;
  1076. }
  1077. // ------------------------------------------------------------------
  1078. // Shared appends (own scope per operation)
  1079. // ------------------------------------------------------------------
  1080. private async void append(int64 chat_id, string kind, string content,
  1081. string? payload) {
  1082. var scope = container.create_scope();
  1083. try {
  1084. var chats = scope.resolve<ChatService>();
  1085. yield chats.append_message(chat_id, kind, content, payload);
  1086. } catch (Error e) {
  1087. warning("[ClankerSaddle] Advisor append failed: %s", e.message);
  1088. } finally {
  1089. scope.release();
  1090. }
  1091. }
  1092. private async void set_status(int64 chat_id, string status) {
  1093. var scope = container.create_scope();
  1094. try {
  1095. var chats = scope.resolve<ChatService>();
  1096. yield chats.set_chat_status(chat_id, status);
  1097. yield pulse.bump(0, @"advisor chat $(status)");
  1098. } catch (Error e) {
  1099. warning("[ClankerSaddle] Advisor status change failed: %s", e.message);
  1100. } finally {
  1101. scope.release();
  1102. }
  1103. }
  1104. // ------------------------------------------------------------------
  1105. // Tool definitions (the OpenAI tools array)
  1106. // ------------------------------------------------------------------
  1107. /**
  1108. * The tools array sent with every request: the read tools,
  1109. * ask_question and the two suggest tools, described for
  1110. * function calling.
  1111. */
  1112. public static Json.Array tools_array() {
  1113. var tools = new Json.Array();
  1114. tools.add_object_element(tool_definition("list_projects",
  1115. "List every project registered in this clanker-saddle fleet.",
  1116. new string[0], new string[0], new string[0]));
  1117. tools.add_object_element(tool_definition("get_project",
  1118. "One project's full record by id.",
  1119. new string[] { "project_id" }, new string[] { "integer" },
  1120. new string[] { "The project's id" }));
  1121. tools.add_object_element(tool_definition("list_tasks",
  1122. "List tasks, optionally filtered by status and project.",
  1123. new string[] { "status", "project_id" },
  1124. new string[] { "string", "integer" },
  1125. new string[] {
  1126. "Optional status filter (draft/queued/working/parked/suspended/done/failed)",
  1127. "Optional project id filter (0 = standalone tasks excluded)"
  1128. }));
  1129. tools.add_object_element(tool_definition("get_task",
  1130. "One task's full record by id (description, status, mode, branch).",
  1131. new string[] { "task_id" }, new string[] { "integer" },
  1132. new string[] { "The task's id" }));
  1133. tools.add_object_element(tool_definition("list_goals",
  1134. "List goals, optionally filtered by project.",
  1135. new string[] { "project_id" }, new string[] { "integer" },
  1136. new string[] { "Optional project id filter" }));
  1137. tools.add_object_element(tool_definition("get_goal",
  1138. "One goal's full record by id (brief, plan, branch).",
  1139. new string[] { "goal_id" }, new string[] { "integer" },
  1140. new string[] { "The goal's id" }));
  1141. tools.add_object_element(tool_definition("list_rules",
  1142. "List every rule (reusable instructions attached to projects/tasks).",
  1143. new string[0], new string[0], new string[0]));
  1144. tools.add_object_element(tool_definition("search_discoveries",
  1145. "Search the fleet's discoveries ledger (non-project facts: compiler quirks, library behaviours, tool gotchas) by substring.",
  1146. new string[] { "query" }, new string[] { "string" },
  1147. new string[] { "Case-insensitive substring; empty lists the newest rows" }));
  1148. tools.add_object_element(tool_definition("list_capabilities",
  1149. "List the fleet's capability tags.",
  1150. new string[0], new string[0], new string[0]));
  1151. tools.add_object_element(tool_definition("ask_question",
  1152. "Ask the user a question with multiple-choice options. The turn parks until they answer. An empty options array asks a free-text question.",
  1153. new string[] { "question", "options" },
  1154. new string[] { "string", "array of string" },
  1155. new string[] { "The question to ask", "The concrete options (may be empty for free text)" }));
  1156. tools.add_object_element(tool_definition("suggest_task",
  1157. "Suggest a task for the user to approve. INERT until they approve it in the chat UI — nothing is created by this call.",
  1158. new string[] { "title", "mandate", "mode", "project_id", "priority" },
  1159. new string[] { "string", "string", "string", "integer", "integer" },
  1160. new string[] {
  1161. "Short task title",
  1162. "The task's mandate: what to do, on which project, with what verification",
  1163. "Work mode: implement / investigate / review / test",
  1164. "The project id (optional)",
  1165. "Priority 1-9, 5 default (optional)"
  1166. }));
  1167. tools.add_object_element(tool_definition("suggest_goal",
  1168. "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.",
  1169. new string[] { "title", "brief", "project_id", "branch_name" },
  1170. new string[] { "string", "string", "integer", "string" },
  1171. new string[] {
  1172. "Short goal title",
  1173. "The goal brief: what this goal should achieve",
  1174. "The primary project id",
  1175. "Branch name for the goal (optional)"
  1176. }));
  1177. return tools;
  1178. }
  1179. /**
  1180. * One `{type: function, function: {name, description,
  1181. * parameters}}` definition. `required` is the first
  1182. * `names.len`-many parameters when `required_count` > 0 —
  1183. * here every declared parameter of the ask/suggest tools that
  1184. * matter; simple read tools pass none.
  1185. */
  1186. private static Json.Object tool_definition(string name, string description,
  1187. string[] names, string[] types, string[] descriptions) {
  1188. var properties = new Json.Object();
  1189. for (var index = 0; index < names.length; index++) {
  1190. var property = new Json.Object();
  1191. property.set_string_member("type",
  1192. index < types.length ? types[index] : "string");
  1193. property.set_string_member("description",
  1194. index < descriptions.length ? descriptions[index] : "");
  1195. properties.set_object_member(names[index], property);
  1196. }
  1197. var parameters = new Json.Object();
  1198. parameters.set_string_member("type", "object");
  1199. parameters.set_object_member("properties", properties);
  1200. var wrapper = new Json.Object();
  1201. wrapper.set_string_member("type", "function");
  1202. var function = new Json.Object();
  1203. function.set_string_member("name", name);
  1204. function.set_string_member("description", description);
  1205. function.set_object_member("parameters", parameters);
  1206. wrapper.set_object_member("function", function);
  1207. return wrapper;
  1208. }
  1209. }
  1210. }