@p-sw/brainbox 0.1.2-alpha.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -489,15 +489,100 @@ function stripThinkTags(content) {
489
489
  }
490
490
  function parseModelJson(content) {
491
491
  const cleaned = stripThinkTags(content);
492
- try {
493
- return JSON.parse(cleaned);
494
- } catch (first) {
495
- const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i);
496
- if (fence?.[1]) {
497
- return JSON.parse(fence[1].trim());
492
+ const candidates = [cleaned];
493
+ const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i);
494
+ if (fence?.[1])
495
+ candidates.push(fence[1].trim());
496
+ const extracted = extractJsonSlice(cleaned);
497
+ if (extracted)
498
+ candidates.push(extracted);
499
+ let lastErr;
500
+ for (const candidate of candidates) {
501
+ try {
502
+ return decodeJsonValue(candidate);
503
+ } catch (err) {
504
+ lastErr = err;
505
+ }
506
+ }
507
+ throw lastErr instanceof Error ? lastErr : new Error("Failed to parse model JSON");
508
+ }
509
+ function decodeJsonValue(text) {
510
+ let value = JSON.parse(text);
511
+ for (let i = 0;i < 2 && typeof value === "string"; i++) {
512
+ const inner = value.trim();
513
+ if (!(inner.startsWith("{") && inner.endsWith("}") || inner.startsWith("[") && inner.endsWith("]") || inner.startsWith('"') && inner.endsWith('"'))) {
514
+ break;
515
+ }
516
+ value = JSON.parse(inner);
517
+ }
518
+ return value;
519
+ }
520
+ function extractJsonSlice(text) {
521
+ const start = text.search(/[\{\[]/);
522
+ if (start < 0)
523
+ return;
524
+ const open = text[start];
525
+ const close = open === "{" ? "}" : "]";
526
+ let depth = 0;
527
+ let inString = false;
528
+ let escape = false;
529
+ for (let i = start;i < text.length; i++) {
530
+ const ch = text[i];
531
+ if (inString) {
532
+ if (escape) {
533
+ escape = false;
534
+ } else if (ch === "\\") {
535
+ escape = true;
536
+ } else if (ch === '"') {
537
+ inString = false;
538
+ }
539
+ continue;
540
+ }
541
+ if (ch === '"') {
542
+ inString = true;
543
+ continue;
544
+ }
545
+ if (ch === open)
546
+ depth++;
547
+ else if (ch === close) {
548
+ depth--;
549
+ if (depth === 0)
550
+ return text.slice(start, i + 1);
498
551
  }
499
- throw first;
500
552
  }
553
+ return;
554
+ }
555
+ function schemaToolName(name) {
556
+ const cleaned = name.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^(\d)/, "_$1");
557
+ return cleaned.length > 0 ? cleaned : "submit_result";
558
+ }
559
+ function buildStructuredJsonRequest(options) {
560
+ const toolName = schemaToolName(options.jsonSchemaName);
561
+ return {
562
+ toolName,
563
+ tool: {
564
+ name: toolName,
565
+ description: `Submit the structured ${options.jsonSchemaName} result.`,
566
+ parameters: options.jsonSchema ?? {
567
+ type: "object",
568
+ additionalProperties: true
569
+ }
570
+ },
571
+ instruction: `${options.instruction}
572
+
573
+ You MUST call the \`${toolName}\` tool exactly once with the complete answer.
574
+ Do not write the JSON as plain text or inside a markdown code fence.`
575
+ };
576
+ }
577
+ function parseStructuredJsonResult(choice, toolName) {
578
+ const call = choice.message.toolCalls?.find((c) => c.function.name === toolName) ?? choice.message.toolCalls?.[0];
579
+ if (call?.function.arguments) {
580
+ return parseModelJson(call.function.arguments);
581
+ }
582
+ if (choice.message.content) {
583
+ return parseModelJson(choice.message.content);
584
+ }
585
+ throw new Error("Empty response from model");
501
586
  }
502
587
  function readAuthString(auth, key, envName) {
503
588
  const fromAuth = typeof auth?.[key] === "string" ? auth[key] : undefined;
@@ -512,6 +597,56 @@ function readAuthString(auth, key, envName) {
512
597
  }
513
598
 
514
599
  class LLMExecutor {
600
+ async chatWithToolExecution(model, options) {
601
+ const messages = options.messages.slice();
602
+ const maxSteps = options.maxSteps ?? 20;
603
+ let last;
604
+ for (let step = 0;step < maxSteps; step += 1) {
605
+ log2.debug(`chatWithToolExecution: step ${step + 1}/${maxSteps} msgs=${messages.length}`);
606
+ last = await this.chatWithTools(model, {
607
+ instruction: options.instruction,
608
+ messages,
609
+ tools: options.tools,
610
+ caller: options.caller,
611
+ reasoningEffort: options.reasoningEffort,
612
+ parallelToolCalls: options.parallelToolCalls,
613
+ toolChoice: options.toolChoice
614
+ });
615
+ const toolCalls = last.message.toolCalls ?? [];
616
+ log2.debug(`chatWithToolExecution: step ${step + 1} toolCalls=${toolCalls.length}`);
617
+ if (toolCalls.length === 0) {
618
+ const nudge = options.onNoToolCalls ? await options.onNoToolCalls(last) : null;
619
+ if (nudge == null)
620
+ return last;
621
+ log2.debug(`chatWithToolExecution: bare end rejected, re-prompting`);
622
+ messages.push({
623
+ role: "assistant",
624
+ content: last.message.content ?? ""
625
+ });
626
+ messages.push({ role: "user", content: nudge });
627
+ continue;
628
+ }
629
+ messages.push({
630
+ role: "assistant",
631
+ content: last.message.content,
632
+ toolCalls
633
+ });
634
+ for (const call of toolCalls) {
635
+ const content = await options.executeTool(call);
636
+ messages.push({
637
+ role: "tool",
638
+ toolCallId: call.id,
639
+ content
640
+ });
641
+ }
642
+ if (options.shouldEnd?.(toolCalls)) {
643
+ log2.debug(`chatWithToolExecution: shouldEnd after step ${step + 1}`);
644
+ return last;
645
+ }
646
+ }
647
+ log2.warn(`chatWithToolExecution: reached maxSteps (${maxSteps}) without final reply`);
648
+ return last;
649
+ }
515
650
  static providers = [];
516
651
  static registerProvider(p) {
517
652
  LLMExecutor.providers.push(p);
@@ -550,7 +685,7 @@ class LLMExecutor {
550
685
  });
551
686
  };
552
687
  if (conv.provider === id.provider) {
553
- return withLlmLogging(build(conv.provider));
688
+ return build(conv.provider);
554
689
  }
555
690
  const modelToProvider = {
556
691
  [conv.model]: conv.provider,
@@ -561,7 +696,7 @@ class LLMExecutor {
561
696
  [id.provider]: build(id.provider)
562
697
  };
563
698
  const fallback = instances[conv.provider];
564
- return withLlmLogging(new class extends LLMExecutor {
699
+ return new class extends LLMExecutor {
565
700
  providerName = "dispatch";
566
701
  models = {
567
702
  conversation: conv.model,
@@ -575,126 +710,21 @@ class LLMExecutor {
575
710
  const exec = instances[modelToProvider[model] ?? ""] ?? fallback;
576
711
  return exec.chatWithTools(model, options);
577
712
  }
578
- });
579
- }
580
- }
581
- function formatPayload(value) {
582
- if (typeof value === "string")
583
- return value;
584
- try {
585
- return JSON.stringify(value, null, 2);
586
- } catch {
587
- return String(value);
713
+ };
588
714
  }
589
715
  }
590
- function resolveCaller(options, fallback) {
716
+ function resolveLlmCaller(options, fallback = "llm") {
591
717
  return options.caller ?? options.jsonSchemaName ?? fallback;
592
718
  }
593
- function withLlmLogging(inner) {
594
- return new class extends LLMExecutor {
595
- providerName = inner.providerName;
596
- models = inner.models;
597
- async call(model, options) {
598
- const jsonMode = "jsonSchemaName" in options;
599
- const caller = resolveCaller({
600
- caller: options.caller,
601
- jsonSchemaName: jsonMode ? options.jsonSchemaName : undefined
602
- }, "call");
603
- const requestBody = [
604
- `provider: ${inner.providerName}`,
605
- `model: ${model}`,
606
- `caller: ${caller}`,
607
- `reasoningEffort: ${options.reasoningEffort ?? "-"}`,
608
- `jsonSchemaName: ${jsonMode ? options.jsonSchemaName : "-"}`,
609
- "",
610
- "--- instruction ---",
611
- options.instruction,
612
- "",
613
- "--- message ---",
614
- options.message,
615
- ...jsonMode ? ["", "--- jsonSchema ---", formatPayload(options.jsonSchema)] : []
616
- ].join(`
617
- `);
618
- try {
619
- const result = await inner.call(model, options);
620
- if (isLlmLogEnabled()) {
621
- writeLlmExchange(caller, [
622
- "======== REQUEST ========",
623
- requestBody,
624
- "",
625
- "======== RESPONSE ========",
626
- formatPayload(result),
627
- ""
628
- ].join(`
629
- `));
630
- }
631
- return result;
632
- } catch (err) {
633
- const reason = err instanceof Error ? err.message : String(err);
634
- if (isLlmLogEnabled()) {
635
- writeLlmExchange(caller, [
636
- "======== REQUEST ========",
637
- requestBody,
638
- "",
639
- "======== ERROR ========",
640
- reason,
641
- ""
642
- ].join(`
643
- `));
644
- }
645
- throw err;
646
- }
647
- }
648
- async chatWithTools(model, options) {
649
- const caller = resolveCaller(options, "chat");
650
- const requestBody = [
651
- `provider: ${inner.providerName}`,
652
- `model: ${model}`,
653
- `caller: ${caller}`,
654
- `reasoningEffort: ${options.reasoningEffort ?? "-"}`,
655
- `parallelToolCalls: ${options.parallelToolCalls ?? false}`,
656
- "",
657
- "--- instruction ---",
658
- options.instruction,
659
- "",
660
- "--- messages ---",
661
- formatPayload(options.messages),
662
- "",
663
- "--- tools ---",
664
- formatPayload(options.tools)
665
- ].join(`
719
+ function logLlmWire(caller, request, response) {
720
+ if (!isLlmLogEnabled())
721
+ return;
722
+ writeLlmExchange(caller, `======== REQUEST ========
723
+ ${request}
724
+
725
+ ======== RESPONSE ========
726
+ ${response}
666
727
  `);
667
- try {
668
- const result = await inner.chatWithTools(model, options);
669
- if (isLlmLogEnabled()) {
670
- writeLlmExchange(caller, [
671
- "======== REQUEST ========",
672
- requestBody,
673
- "",
674
- "======== RESPONSE ========",
675
- formatPayload(result),
676
- ""
677
- ].join(`
678
- `));
679
- }
680
- return result;
681
- } catch (err) {
682
- const reason = err instanceof Error ? err.message : String(err);
683
- if (isLlmLogEnabled()) {
684
- writeLlmExchange(caller, [
685
- "======== REQUEST ========",
686
- requestBody,
687
- "",
688
- "======== ERROR ========",
689
- reason,
690
- ""
691
- ].join(`
692
- `));
693
- }
694
- throw err;
695
- }
696
- }
697
- };
698
728
  }
699
729
  function listProviderNames() {
700
730
  return LLMExecutor.listProviderNames();
@@ -764,27 +794,27 @@ class OpenRouterExecutor extends LLMExecutor {
764
794
  async call(model, options) {
765
795
  const jsonMode = "jsonSchemaName" in options;
766
796
  log3.debug(`call: model=${model} jsonSchema=${jsonMode ? options.jsonSchemaName : "-"} msgLen=${options.message.length}`);
767
- const result = await this.client.chat.send({
768
- chatRequest: {
769
- model,
770
- messages: [
771
- { role: "system", content: options.instruction },
772
- { role: "user", content: options.message }
773
- ],
774
- reasoning: {
775
- effort: options.reasoningEffort ?? (model === this.models.identity ? REASONING_EFFORT_MAP.medium : REASONING_EFFORT_MAP.none)
776
- },
777
- responseFormat: jsonMode ? {
778
- type: "json_schema",
779
- jsonSchema: {
780
- name: options.jsonSchemaName,
781
- schema: options.jsonSchema,
782
- strict: true
783
- }
784
- } : { type: "text" },
785
- stream: false
786
- }
787
- });
797
+ const chatRequest = {
798
+ model,
799
+ messages: [
800
+ { role: "system", content: options.instruction },
801
+ { role: "user", content: options.message }
802
+ ],
803
+ reasoning: {
804
+ effort: options.reasoningEffort ?? (model === this.models.identity ? REASONING_EFFORT_MAP.medium : REASONING_EFFORT_MAP.none)
805
+ },
806
+ responseFormat: jsonMode ? {
807
+ type: "json_schema",
808
+ jsonSchema: {
809
+ name: options.jsonSchemaName,
810
+ schema: options.jsonSchema,
811
+ strict: true
812
+ }
813
+ } : { type: "text" },
814
+ stream: false
815
+ };
816
+ const result = await this.client.chat.send({ chatRequest });
817
+ logLlmWire(resolveLlmCaller(options), JSON.stringify(chatRequest), JSON.stringify(result));
788
818
  const raw = result.choices[0]?.message?.content;
789
819
  const content = typeof raw === "string" ? stripThinkTags(raw) : raw;
790
820
  if (!content) {
@@ -796,22 +826,22 @@ class OpenRouterExecutor extends LLMExecutor {
796
826
  }
797
827
  async chatWithTools(model, options) {
798
828
  log3.debug(`chatWithTools: model=${model} msgs=${options.messages.length} tools=${options.tools.length}`);
799
- const result = await this.client.chat.send({
800
- chatRequest: {
801
- model,
802
- messages: [
803
- { role: "system", content: options.instruction },
804
- ...options.messages.map(toOrMessage)
805
- ],
806
- reasoning: {
807
- effort: options.reasoningEffort ?? (model === this.models.identity ? REASONING_EFFORT_MAP.medium : REASONING_EFFORT_MAP.none)
808
- },
809
- responseFormat: { type: "text" },
810
- tools: options.tools.map(toOrTool),
811
- parallelToolCalls: options.parallelToolCalls ?? false,
812
- stream: false
813
- }
814
- });
829
+ const chatRequest = {
830
+ model,
831
+ messages: [
832
+ { role: "system", content: options.instruction },
833
+ ...options.messages.map(toOrMessage)
834
+ ],
835
+ reasoning: {
836
+ effort: options.reasoningEffort ?? (model === this.models.identity ? REASONING_EFFORT_MAP.medium : REASONING_EFFORT_MAP.none)
837
+ },
838
+ responseFormat: { type: "text" },
839
+ tools: options.tools.map(toOrTool),
840
+ parallelToolCalls: options.parallelToolCalls ?? false,
841
+ stream: false
842
+ };
843
+ const result = await this.client.chat.send({ chatRequest });
844
+ logLlmWire(resolveLlmCaller(options), JSON.stringify(chatRequest), JSON.stringify(result));
815
845
  const choice = result.choices[0];
816
846
  if (!choice) {
817
847
  log3.debug(`chatWithTools: no choice in response`);
@@ -886,6 +916,7 @@ class OpenAICompatibleExecutor extends LLMExecutor {
886
916
  chatPath;
887
917
  noBearerPrefix;
888
918
  reasoningEffortInQuery;
919
+ supportsResponseFormat;
889
920
  constructor(opts) {
890
921
  super();
891
922
  this.providerName = opts.providerName;
@@ -895,6 +926,7 @@ class OpenAICompatibleExecutor extends LLMExecutor {
895
926
  this.chatPath = opts.chatPath ?? "/chat/completions";
896
927
  this.noBearerPrefix = opts.noBearerPrefix ?? false;
897
928
  this.reasoningEffortInQuery = opts.reasoningEffortInQuery ?? false;
929
+ this.supportsResponseFormat = opts.supportsResponseFormat ?? true;
898
930
  this.models = {
899
931
  conversation: opts.conversationModel,
900
932
  identity: opts.identityModel
@@ -925,7 +957,7 @@ class OpenAICompatibleExecutor extends LLMExecutor {
925
957
  }
926
958
  return url.toString();
927
959
  }
928
- async sendRequest(body, reasoningEffort) {
960
+ async sendRequest(body, reasoningEffort, caller) {
929
961
  const modelName = body["model"];
930
962
  const modelStr = typeof modelName === "string" ? modelName : "";
931
963
  const url = this.buildRequestUrl(modelStr, reasoningEffort);
@@ -935,24 +967,40 @@ class OpenAICompatibleExecutor extends LLMExecutor {
935
967
  ...authHeader,
936
968
  ...this.defaultHeaders
937
969
  };
970
+ const requestRaw = JSON.stringify(body);
938
971
  const res = await fetch(url, {
939
972
  method: "POST",
940
973
  headers,
941
- body: JSON.stringify(body)
974
+ body: requestRaw
942
975
  });
976
+ const responseRaw = await res.text().catch(() => "");
977
+ logLlmWire(caller, requestRaw, responseRaw);
943
978
  if (!res.ok) {
944
- const text = await res.text().catch(() => "");
945
- log4.error(`${this.providerName}: HTTP ${res.status} ${res.statusText} body=${text.slice(0, 500)}`);
979
+ log4.error(`${this.providerName}: HTTP ${res.status} ${res.statusText} body=${responseRaw.slice(0, 500)}`);
946
980
  throw new Error(`${this.providerName} request failed: ${res.status} ${res.statusText}`);
947
981
  }
948
- const data = await res.json();
982
+ let data;
983
+ try {
984
+ data = JSON.parse(responseRaw);
985
+ } catch {
986
+ throw new Error(`${this.providerName}: invalid JSON response`);
987
+ }
949
988
  if (data.error) {
950
989
  log4.error(`${this.providerName}: API error ${data.error.type ?? ""} ${data.error.message ?? ""}`);
951
990
  throw new Error(`${this.providerName} API error: ${data.error.message ?? "unknown"}`);
952
991
  }
992
+ const baseCode = data.base_resp?.status_code;
993
+ if (typeof baseCode === "number" && baseCode !== 0) {
994
+ const msg = data.base_resp?.status_msg?.trim() || `status_code ${baseCode}`;
995
+ log4.error(`${this.providerName}: base_resp ${baseCode} ${msg}`);
996
+ throw new Error(`${this.providerName} API error: ${msg}`);
997
+ }
953
998
  return data;
954
999
  }
955
1000
  async call(model, options) {
1001
+ if ("jsonSchemaName" in options && !this.supportsResponseFormat) {
1002
+ return this.callJsonViaTool(model, options);
1003
+ }
956
1004
  const jsonMode = "jsonSchemaName" in options;
957
1005
  const reasoning = defaultReasoningEffort(options.reasoningEffort, model, this.models.identity);
958
1006
  log4.debug(`call: provider=${this.providerName} model=${model} jsonSchema=${jsonMode ? options.jsonSchemaName : "-"} msgLen=${options.message.length}`);
@@ -965,16 +1013,32 @@ class OpenAICompatibleExecutor extends LLMExecutor {
965
1013
  responseFormat: jsonMode ? buildResponseFormat(options.jsonSchemaName, options.jsonSchema) : undefined,
966
1014
  reasoningEffort: reasoning
967
1015
  });
968
- const data = await this.sendRequest(body, options.reasoningEffort);
969
- const raw = data.choices?.[0]?.message?.content;
1016
+ const data = await this.sendRequest(body, options.reasoningEffort, resolveLlmCaller(options));
1017
+ const choice = data.choices?.[0];
1018
+ const raw = choice?.message?.content;
970
1019
  const content = typeof raw === "string" ? stripThinkTags(raw) : raw;
971
1020
  if (!content) {
972
- log4.debug(`call: empty content in choice 0`);
973
- throw new Error("Empty response from model");
1021
+ const finish = choice?.finish_reason ?? "no-choice";
1022
+ const reasoningLen = typeof choice?.message?.reasoning_content === "string" ? choice.message.reasoning_content.length : 0;
1023
+ log4.debug(`call: empty content in choice 0 finish_reason=${finish} reasoning_len=${reasoningLen} rawType=${raw === null ? "null" : typeof raw}`);
1024
+ throw new Error(reasoningLen > 0 ? `Empty response from model (finish_reason=${finish}; reasoning present but no content)` : "Empty response from model");
974
1025
  }
975
1026
  log4.debug(`call: response ${content.length} chars`);
976
1027
  return jsonMode ? parseModelJson(content) : content;
977
1028
  }
1029
+ async callJsonViaTool(model, options) {
1030
+ const { toolName, tool, instruction } = buildStructuredJsonRequest(options);
1031
+ log4.debug(`callJsonViaTool: provider=${this.providerName} model=${model} tool=${toolName}`);
1032
+ const choice = await this.chatWithTools(model, {
1033
+ caller: options.caller ?? options.jsonSchemaName,
1034
+ instruction,
1035
+ messages: [{ role: "user", content: options.message }],
1036
+ tools: [tool],
1037
+ reasoningEffort: "none",
1038
+ parallelToolCalls: false
1039
+ });
1040
+ return parseStructuredJsonResult(choice, toolName);
1041
+ }
978
1042
  async chatWithTools(model, options) {
979
1043
  const reasoning = defaultReasoningEffort(options.reasoningEffort, model, this.models.identity);
980
1044
  log4.debug(`chatWithTools: provider=${this.providerName} model=${model} msgs=${options.messages.length} tools=${options.tools.length}`);
@@ -988,7 +1052,7 @@ class OpenAICompatibleExecutor extends LLMExecutor {
988
1052
  parallelToolCalls: options.parallelToolCalls ?? false,
989
1053
  reasoningEffort: reasoning
990
1054
  });
991
- const data = await this.sendRequest(body, options.reasoningEffort);
1055
+ const data = await this.sendRequest(body, options.reasoningEffort, resolveLlmCaller(options));
992
1056
  const choice = data.choices?.[0];
993
1057
  if (!choice) {
994
1058
  log4.debug(`chatWithTools: no choice in response`);
@@ -1290,10 +1354,15 @@ class ZenMuxExecutor extends OpenAICompatibleExecutor {
1290
1354
  // src/provider/providers/minimax.ts
1291
1355
  class MiniMaxBase extends OpenAICompatibleExecutor {
1292
1356
  buildBody(opts) {
1357
+ const jsonMode = opts.responseFormat !== undefined;
1293
1358
  const body = super.buildBody(opts);
1294
1359
  delete body["reasoning_effort"];
1360
+ delete body["response_format"];
1361
+ delete body["parallel_tool_calls"];
1295
1362
  body["reasoning_split"] = true;
1296
- body["thinking"] = opts.reasoningEffort && opts.reasoningEffort !== "none" ? { type: "adaptive" } : { type: "disabled" };
1363
+ body["max_completion_tokens"] = 16384;
1364
+ const wantThink = !jsonMode && opts.reasoningEffort !== undefined && opts.reasoningEffort !== "none";
1365
+ body["thinking"] = wantThink ? { type: "adaptive" } : { type: "disabled" };
1297
1366
  return body;
1298
1367
  }
1299
1368
  }
@@ -1303,7 +1372,8 @@ function minimaxOpts(providerName, baseURL, opts) {
1303
1372
  baseURL,
1304
1373
  apiKey: opts.apiKey,
1305
1374
  conversationModel: opts.conversationModel,
1306
- identityModel: opts.identityModel
1375
+ identityModel: opts.identityModel,
1376
+ supportsResponseFormat: false
1307
1377
  };
1308
1378
  }
1309
1379
 
@@ -1379,7 +1449,8 @@ class LmStudioExecutor extends OpenAICompatibleExecutor {
1379
1449
  baseURL: "http://127.0.0.1:1234/v1",
1380
1450
  apiKey: opts.apiKey || "lm-studio",
1381
1451
  conversationModel: opts.conversationModel,
1382
- identityModel: opts.identityModel
1452
+ identityModel: opts.identityModel,
1453
+ supportsResponseFormat: false
1383
1454
  });
1384
1455
  }
1385
1456
  }
@@ -1392,7 +1463,8 @@ class OllamaExecutor extends OpenAICompatibleExecutor {
1392
1463
  baseURL: "http://127.0.0.1:11434/v1",
1393
1464
  apiKey: opts.apiKey || "ollama",
1394
1465
  conversationModel: opts.conversationModel,
1395
- identityModel: opts.identityModel
1466
+ identityModel: opts.identityModel,
1467
+ supportsResponseFormat: false
1396
1468
  });
1397
1469
  }
1398
1470
  }
@@ -1418,7 +1490,8 @@ class LlamaCppExecutor extends OpenAICompatibleExecutor {
1418
1490
  baseURL: "http://127.0.0.1:8080/v1",
1419
1491
  apiKey: opts.apiKey || "llama.cpp",
1420
1492
  conversationModel: opts.conversationModel,
1421
- identityModel: opts.identityModel
1493
+ identityModel: opts.identityModel,
1494
+ supportsResponseFormat: false
1422
1495
  });
1423
1496
  }
1424
1497
  }
@@ -1501,21 +1574,23 @@ class CloudflareWorkersExecutor extends LLMExecutor {
1501
1574
  const accountId = readAuthString(opts.auth, "accountId", "CLOUDFLARE_ACCOUNT_ID");
1502
1575
  this.baseURL = accountId ? `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run` : "https://api.cloudflare.com/client/v4/accounts/__cf_account_id__/ai/run";
1503
1576
  }
1504
- async run(model, body) {
1577
+ async run(model, body, caller) {
1505
1578
  const url = `${this.baseURL}/${encodeURIComponent(model)}`;
1579
+ const requestRaw = JSON.stringify(body);
1506
1580
  const res = await fetch(url, {
1507
1581
  method: "POST",
1508
1582
  headers: {
1509
1583
  Authorization: `Bearer ${this.apiKey}`,
1510
1584
  "Content-Type": "application/json"
1511
1585
  },
1512
- body: JSON.stringify(body)
1586
+ body: requestRaw
1513
1587
  });
1588
+ const responseRaw = await res.text().catch(() => "");
1589
+ logLlmWire(caller, requestRaw, responseRaw);
1514
1590
  if (!res.ok) {
1515
- const text = await res.text().catch(() => "");
1516
- throw new Error(`cloudflare-workers request failed: ${res.status} ${res.statusText} body=${text.slice(0, 500)}`);
1591
+ throw new Error(`cloudflare-workers request failed: ${res.status} ${res.statusText} body=${responseRaw.slice(0, 500)}`);
1517
1592
  }
1518
- return await res.json();
1593
+ return JSON.parse(responseRaw);
1519
1594
  }
1520
1595
  async call(model, options) {
1521
1596
  const jsonMode = "jsonSchemaName" in options;
@@ -1536,7 +1611,7 @@ class CloudflareWorkersExecutor extends LLMExecutor {
1536
1611
  if (reasoning !== "none") {
1537
1612
  body["reasoning_effort"] = reasoning;
1538
1613
  }
1539
- const data = await this.run(model, body);
1614
+ const data = await this.run(model, body, resolveLlmCaller(options));
1540
1615
  if (data.errors && data.errors.length > 0) {
1541
1616
  throw new Error(`cloudflare-workers API error: ${data.errors.map((e) => e.message).join("; ")}`);
1542
1617
  }
@@ -1565,7 +1640,7 @@ class CloudflareWorkersExecutor extends LLMExecutor {
1565
1640
  if (reasoning !== "none") {
1566
1641
  body["reasoning_effort"] = reasoning;
1567
1642
  }
1568
- const data = await this.run(model, body);
1643
+ const data = await this.run(model, body, resolveLlmCaller(options));
1569
1644
  if (data.errors && data.errors.length > 0) {
1570
1645
  throw new Error(`cloudflare-workers API error: ${data.errors.map((e) => e.message).join("; ")}`);
1571
1646
  }
@@ -1655,17 +1730,17 @@ var AnthropicAuthSchema = z4.object({
1655
1730
  apiVersion: z4.string().optional()
1656
1731
  }).loose();
1657
1732
  function toAnthropicMessages(messages) {
1658
- let system;
1659
- const msgs = [];
1733
+ let system2;
1734
+ const msgs2 = [];
1660
1735
  for (const m of messages) {
1661
1736
  if (m.role === "system") {
1662
- system = (system ? system + `
1737
+ system2 = (system2 ? system2 + `
1663
1738
 
1664
1739
  ` : "") + m.content;
1665
1740
  continue;
1666
1741
  }
1667
1742
  if (m.role === "user") {
1668
- msgs.push({ role: "user", content: m.content });
1743
+ msgs2.push({ role: "user", content: m.content });
1669
1744
  continue;
1670
1745
  }
1671
1746
  if (m.role === "assistant") {
@@ -1688,11 +1763,11 @@ function toAnthropicMessages(messages) {
1688
1763
  });
1689
1764
  }
1690
1765
  }
1691
- msgs.push({ role: "assistant", content: blocks });
1766
+ msgs2.push({ role: "assistant", content: blocks });
1692
1767
  continue;
1693
1768
  }
1694
1769
  if (m.role === "tool") {
1695
- msgs.push({
1770
+ msgs2.push({
1696
1771
  role: "user",
1697
1772
  content: [
1698
1773
  {
@@ -1704,7 +1779,7 @@ function toAnthropicMessages(messages) {
1704
1779
  });
1705
1780
  }
1706
1781
  }
1707
- return { system, msgs };
1782
+ return { system: system2, msgs: msgs2 };
1708
1783
  }
1709
1784
  function toAnthropicTool(t) {
1710
1785
  return {
@@ -1732,7 +1807,8 @@ class AnthropicExecutor extends LLMExecutor {
1732
1807
  this.baseURL = (extra.baseURL ?? readAuthString(opts.auth, "baseURL", "ANTHROPIC_BASE_URL") ?? "https://api.anthropic.com").replace(/\/v1\/?$/, "");
1733
1808
  this.apiVersion = extra.apiVersion ?? "2023-06-01";
1734
1809
  }
1735
- async send(body) {
1810
+ async send(body, caller) {
1811
+ const requestRaw = JSON.stringify(body);
1736
1812
  const res = await fetch(`${this.baseURL}/v1/messages`, {
1737
1813
  method: "POST",
1738
1814
  headers: {
@@ -1740,19 +1816,35 @@ class AnthropicExecutor extends LLMExecutor {
1740
1816
  "anthropic-version": this.apiVersion,
1741
1817
  "Content-Type": "application/json"
1742
1818
  },
1743
- body: JSON.stringify(body)
1819
+ body: requestRaw
1744
1820
  });
1821
+ const responseRaw = await res.text().catch(() => "");
1822
+ logLlmWire(caller, requestRaw, responseRaw);
1745
1823
  if (!res.ok) {
1746
- const text = await res.text().catch(() => "");
1747
- throw new Error(`anthropic request failed: ${res.status} ${res.statusText} body=${text.slice(0, 500)}`);
1824
+ throw new Error(`anthropic request failed: ${res.status} ${res.statusText} body=${responseRaw.slice(0, 500)}`);
1748
1825
  }
1749
- return await res.json();
1826
+ return JSON.parse(responseRaw);
1750
1827
  }
1751
1828
  async call(model, options) {
1752
- const jsonMode = "jsonSchemaName" in options;
1829
+ if ("jsonSchemaName" in options) {
1830
+ const { toolName, tool, instruction } = buildStructuredJsonRequest({
1831
+ instruction: options.instruction,
1832
+ jsonSchemaName: options.jsonSchemaName,
1833
+ jsonSchema: options.jsonSchema
1834
+ });
1835
+ const choice = await this.chatWithTools(model, {
1836
+ caller: options.caller ?? options.jsonSchemaName,
1837
+ instruction,
1838
+ messages: [{ role: "user", content: options.message }],
1839
+ tools: [tool],
1840
+ reasoningEffort: "none",
1841
+ toolChoice: { type: "tool", name: toolName }
1842
+ });
1843
+ return parseStructuredJsonResult(choice, toolName);
1844
+ }
1753
1845
  const reasoning = defaultReasoningEffort(options.reasoningEffort, model, this.models.identity);
1754
1846
  const log5 = logger.child("llm:anthropic");
1755
- log5.debug(`call: model=${model} jsonSchema=${jsonMode ? options.jsonSchemaName : "-"} msgLen=${options.message.length}`);
1847
+ log5.debug(`call: model=${model} jsonSchema=- msgLen=${options.message.length}`);
1756
1848
  const outputCap = 4096;
1757
1849
  const body = {
1758
1850
  model,
@@ -1768,7 +1860,7 @@ class AnthropicExecutor extends LLMExecutor {
1768
1860
  budget_tokens: budget
1769
1861
  };
1770
1862
  }
1771
- const data = await this.send(body);
1863
+ const data = await this.send(body, resolveLlmCaller(options));
1772
1864
  if (data.error) {
1773
1865
  throw new Error(`anthropic API error: ${data.error.message ?? "unknown"}`);
1774
1866
  }
@@ -1776,21 +1868,27 @@ class AnthropicExecutor extends LLMExecutor {
1776
1868
  if (!text) {
1777
1869
  throw new Error("Empty response from model");
1778
1870
  }
1779
- return jsonMode ? parseModelJson(text) : text;
1871
+ return text;
1780
1872
  }
1781
1873
  async chatWithTools(model, options) {
1782
1874
  const reasoning = defaultReasoningEffort(options.reasoningEffort, model, this.models.identity);
1783
1875
  const log5 = logger.child("llm:anthropic");
1784
1876
  log5.debug(`chatWithTools: model=${model} msgs=${options.messages.length} tools=${options.tools.length}`);
1785
- const { system, msgs } = toAnthropicMessages(options.messages);
1877
+ const { system: system2, msgs: msgs2 } = toAnthropicMessages(options.messages);
1786
1878
  const outputCap = 4096;
1787
1879
  const body = {
1788
1880
  model,
1789
1881
  max_tokens: outputCap,
1790
- system: system ?? options.instruction,
1791
- messages: msgs,
1882
+ system: system2 ?? options.instruction,
1883
+ messages: msgs2,
1792
1884
  tools: options.tools.map(toAnthropicTool)
1793
1885
  };
1886
+ if (options.toolChoice) {
1887
+ body["tool_choice"] = {
1888
+ type: "tool",
1889
+ name: options.toolChoice.name
1890
+ };
1891
+ }
1794
1892
  if (reasoning !== "none") {
1795
1893
  const budget = REASONING_BUDGET[reasoning];
1796
1894
  body["max_tokens"] = budget + outputCap;
@@ -1799,7 +1897,7 @@ class AnthropicExecutor extends LLMExecutor {
1799
1897
  budget_tokens: budget
1800
1898
  };
1801
1899
  }
1802
- const data = await this.send(body);
1900
+ const data = await this.send(body, resolveLlmCaller(options));
1803
1901
  if (data.error) {
1804
1902
  throw new Error(`anthropic API error: ${data.error.message ?? "unknown"}`);
1805
1903
  }
@@ -1869,58 +1967,6 @@ function signRequest(opts) {
1869
1967
  headers["X-Amz-Security-Token"] = opts.sessionToken;
1870
1968
  return headers;
1871
1969
  }
1872
- function toAnthropicMessages2(messages) {
1873
- let system;
1874
- const msgs = [];
1875
- for (const m of messages) {
1876
- if (m.role === "system") {
1877
- system = (system ? system + `
1878
-
1879
- ` : "") + m.content;
1880
- continue;
1881
- }
1882
- if (m.role === "user") {
1883
- msgs.push({ role: "user", content: [{ type: "text", text: m.content }] });
1884
- continue;
1885
- }
1886
- if (m.role === "assistant") {
1887
- const blocks = [];
1888
- if (m.content)
1889
- blocks.push({ type: "text", text: m.content });
1890
- if (m.toolCalls) {
1891
- for (const c of m.toolCalls) {
1892
- let input = {};
1893
- try {
1894
- input = c.function.arguments ? JSON.parse(c.function.arguments) : {};
1895
- } catch {
1896
- input = {};
1897
- }
1898
- blocks.push({
1899
- type: "tool_use",
1900
- id: c.id,
1901
- name: c.function.name,
1902
- input
1903
- });
1904
- }
1905
- }
1906
- msgs.push({ role: "assistant", content: blocks });
1907
- continue;
1908
- }
1909
- if (m.role === "tool") {
1910
- msgs.push({
1911
- role: "user",
1912
- content: [
1913
- {
1914
- type: "tool_result",
1915
- tool_use_id: m.toolCallId,
1916
- content: [{ type: "text", text: m.content }]
1917
- }
1918
- ]
1919
- });
1920
- }
1921
- }
1922
- return { system, msgs };
1923
- }
1924
1970
  function toAnthropicTool2(t) {
1925
1971
  return {
1926
1972
  name: t.name,
@@ -1962,7 +2008,7 @@ class BedrockExecutor extends LLMExecutor {
1962
2008
  this.sessionToken = readAuthString(opts.auth, "sessionToken", "AWS_SESSION_TOKEN");
1963
2009
  this.baseURL = `https://bedrock-runtime.${this.region}.amazonaws.com`;
1964
2010
  }
1965
- async invoke(model, body) {
2011
+ async invoke(model, body, caller) {
1966
2012
  const bodyStr = JSON.stringify(body);
1967
2013
  const path = `/model/${encodeURIComponent(model)}/invoke`;
1968
2014
  const headers = signRequest({
@@ -1981,16 +2027,32 @@ class BedrockExecutor extends LLMExecutor {
1981
2027
  headers,
1982
2028
  body: bodyStr
1983
2029
  });
2030
+ const responseRaw = await res.text().catch(() => "");
2031
+ logLlmWire(caller, bodyStr, responseRaw);
1984
2032
  if (!res.ok) {
1985
- const text = await res.text().catch(() => "");
1986
- throw new Error(`bedrock request failed: ${res.status} ${res.statusText} body=${text.slice(0, 500)}`);
2033
+ throw new Error(`bedrock request failed: ${res.status} ${res.statusText} body=${responseRaw.slice(0, 500)}`);
1987
2034
  }
1988
- return await res.json();
2035
+ return JSON.parse(responseRaw);
1989
2036
  }
1990
2037
  async call(model, options) {
1991
- const jsonMode = "jsonSchemaName" in options;
1992
2038
  const log5 = logger.child("llm:bedrock");
1993
- log5.debug(`call: model=${model} jsonSchema=${jsonMode ? options.jsonSchemaName : "-"} msgLen=${options.message.length}`);
2039
+ if ("jsonSchemaName" in options) {
2040
+ log5.debug(`call: model=${model} jsonSchema=${options.jsonSchemaName} via tool`);
2041
+ const { toolName, tool, instruction } = buildStructuredJsonRequest({
2042
+ instruction: options.instruction,
2043
+ jsonSchemaName: options.jsonSchemaName,
2044
+ jsonSchema: options.jsonSchema
2045
+ });
2046
+ const choice = await this.chatWithTools(model, {
2047
+ caller: options.caller ?? options.jsonSchemaName,
2048
+ instruction,
2049
+ messages: [{ role: "user", content: options.message }],
2050
+ tools: [tool],
2051
+ toolChoice: { type: "tool", name: toolName }
2052
+ });
2053
+ return parseStructuredJsonResult(choice, toolName);
2054
+ }
2055
+ log5.debug(`call: model=${model} jsonSchema=- msgLen=${options.message.length}`);
1994
2056
  if (!model.startsWith("anthropic.") && !model.startsWith("us.anthropic.")) {
1995
2057
  throw new Error(`bedrock provider currently only supports Anthropic models (got ${model})`);
1996
2058
  }
@@ -2000,12 +2062,12 @@ class BedrockExecutor extends LLMExecutor {
2000
2062
  system: options.instruction,
2001
2063
  messages: [{ role: "user", content: options.message }]
2002
2064
  };
2003
- const data = await this.invoke(model, body);
2065
+ const data = await this.invoke(model, body, resolveLlmCaller(options));
2004
2066
  const { text } = extractAnthropicContent(data);
2005
2067
  if (!text) {
2006
2068
  throw new Error("Empty response from model");
2007
2069
  }
2008
- return jsonMode ? parseModelJson(text) : text;
2070
+ return text;
2009
2071
  }
2010
2072
  async chatWithTools(model, options) {
2011
2073
  const log5 = logger.child("llm:bedrock");
@@ -2013,7 +2075,6 @@ class BedrockExecutor extends LLMExecutor {
2013
2075
  if (!model.startsWith("anthropic.") && !model.startsWith("us.anthropic.")) {
2014
2076
  throw new Error(`bedrock provider currently only supports Anthropic models (got ${model})`);
2015
2077
  }
2016
- const { system, msgs } = toAnthropicMessages2(options.messages);
2017
2078
  const body = {
2018
2079
  anthropic_version: "bedrock-2023-05-31",
2019
2080
  max_tokens: 4096,
@@ -2021,7 +2082,13 @@ class BedrockExecutor extends LLMExecutor {
2021
2082
  messages: msgs,
2022
2083
  tools: options.tools.map(toAnthropicTool2)
2023
2084
  };
2024
- const data = await this.invoke(model, body);
2085
+ if (options.toolChoice) {
2086
+ body.tool_choice = {
2087
+ type: "tool",
2088
+ name: options.toolChoice.name
2089
+ };
2090
+ }
2091
+ const data = await this.invoke(model, body, resolveLlmCaller(options));
2025
2092
  const { text, toolCalls } = extractAnthropicContent(data);
2026
2093
  return { message: { content: text || undefined, toolCalls } };
2027
2094
  }
@@ -2137,21 +2204,23 @@ class VertexExecutor extends LLMExecutor {
2137
2204
  this.region = extra.region ?? readAuthString(opts.auth, "region", "GOOGLE_CLOUD_REGION") ?? "us-central1";
2138
2205
  this.baseURL = this.project ? `https://${this.region}-aiplatform.googleapis.com/v1/projects/${this.project}/locations/${this.region}/publishers/google/models` : "https://us-central1-aiplatform.googleapis.com/v1";
2139
2206
  }
2140
- async generate(model, body) {
2207
+ async generate(model, body, caller) {
2141
2208
  const url = `${this.baseURL}/${encodeURIComponent(model)}:generateContent`;
2209
+ const requestRaw = JSON.stringify(body);
2142
2210
  const res = await fetch(url, {
2143
2211
  method: "POST",
2144
2212
  headers: {
2145
2213
  Authorization: `Bearer ${this.apiKey}`,
2146
2214
  "Content-Type": "application/json"
2147
2215
  },
2148
- body: JSON.stringify(body)
2216
+ body: requestRaw
2149
2217
  });
2218
+ const responseRaw = await res.text().catch(() => "");
2219
+ logLlmWire(caller, requestRaw, responseRaw);
2150
2220
  if (!res.ok) {
2151
- const text = await res.text().catch(() => "");
2152
- throw new Error(`vertex request failed: ${res.status} ${res.statusText} body=${text.slice(0, 500)}`);
2221
+ throw new Error(`vertex request failed: ${res.status} ${res.statusText} body=${responseRaw.slice(0, 500)}`);
2153
2222
  }
2154
- return await res.json();
2223
+ return JSON.parse(responseRaw);
2155
2224
  }
2156
2225
  async call(model, options) {
2157
2226
  const jsonMode = "jsonSchemaName" in options;
@@ -2172,7 +2241,7 @@ class VertexExecutor extends LLMExecutor {
2172
2241
  responseSchema: options.jsonSchema
2173
2242
  };
2174
2243
  }
2175
- const data = await this.generate(model, body);
2244
+ const data = await this.generate(model, body, resolveLlmCaller(options));
2176
2245
  if (data.error) {
2177
2246
  throw new Error(`vertex API error: ${data.error.message ?? "unknown"}`);
2178
2247
  }
@@ -2191,7 +2260,7 @@ class VertexExecutor extends LLMExecutor {
2191
2260
  systemInstruction: { parts: [{ text: options.instruction }] },
2192
2261
  tools: options.tools.length > 0 ? [toGeminiTools(options.tools)] : undefined
2193
2262
  };
2194
- const data = await this.generate(model, body);
2263
+ const data = await this.generate(model, body, resolveLlmCaller(options));
2195
2264
  if (data.error) {
2196
2265
  throw new Error(`vertex API error: ${data.error.message ?? "unknown"}`);
2197
2266
  }
@@ -2267,25 +2336,41 @@ class GitLabDuoExecutor extends LLMExecutor {
2267
2336
  const extra = parsed.success ? parsed.data : {};
2268
2337
  this.baseURL = (extra.baseURL ?? readAuthString(opts.auth, "baseURL", "GITLAB_BASE_URL") ?? "https://gitlab.com/api/v4/ai/llm/proxy").replace(/\/+$/, "");
2269
2338
  }
2270
- async send(body) {
2339
+ async send(body, caller) {
2340
+ const requestRaw = JSON.stringify(body);
2271
2341
  const res = await fetch(this.baseURL, {
2272
2342
  method: "POST",
2273
2343
  headers: {
2274
2344
  Authorization: `Bearer ${this.apiKey}`,
2275
2345
  "Content-Type": "application/json"
2276
2346
  },
2277
- body: JSON.stringify(body)
2347
+ body: requestRaw
2278
2348
  });
2349
+ const responseRaw = await res.text().catch(() => "");
2350
+ logLlmWire(caller, requestRaw, responseRaw);
2279
2351
  if (!res.ok) {
2280
- const text = await res.text().catch(() => "");
2281
- throw new Error(`gitlab-duo request failed: ${res.status} ${res.statusText} body=${text.slice(0, 500)}`);
2352
+ throw new Error(`gitlab-duo request failed: ${res.status} ${res.statusText} body=${responseRaw.slice(0, 500)}`);
2282
2353
  }
2283
- return await res.json();
2354
+ return JSON.parse(responseRaw);
2284
2355
  }
2285
2356
  async call(model, options) {
2286
- const jsonMode = "jsonSchemaName" in options;
2287
2357
  const log5 = logger.child("llm:gitlab-duo");
2288
- log5.debug(`call: model=${model} jsonSchema=${jsonMode ? options.jsonSchemaName : "-"} msgLen=${options.message.length}`);
2358
+ if ("jsonSchemaName" in options) {
2359
+ log5.debug(`call: model=${model} jsonSchema=${options.jsonSchemaName} via tool`);
2360
+ const { toolName, tool, instruction } = buildStructuredJsonRequest({
2361
+ instruction: options.instruction,
2362
+ jsonSchemaName: options.jsonSchemaName,
2363
+ jsonSchema: options.jsonSchema
2364
+ });
2365
+ const choice = await this.chatWithTools(model, {
2366
+ caller: options.caller ?? options.jsonSchemaName,
2367
+ instruction,
2368
+ messages: [{ role: "user", content: options.message }],
2369
+ tools: [tool]
2370
+ });
2371
+ return parseStructuredJsonResult(choice, toolName);
2372
+ }
2373
+ log5.debug(`call: model=${model} jsonSchema=- msgLen=${options.message.length}`);
2289
2374
  const body = {
2290
2375
  model,
2291
2376
  messages: [
@@ -2293,7 +2378,7 @@ class GitLabDuoExecutor extends LLMExecutor {
2293
2378
  { role: "user", content: options.message }
2294
2379
  ]
2295
2380
  };
2296
- const data = await this.send(body);
2381
+ const data = await this.send(body, resolveLlmCaller(options));
2297
2382
  if (data.error) {
2298
2383
  throw new Error(`gitlab-duo API error: ${data.error.message ?? "unknown"}`);
2299
2384
  }
@@ -2301,7 +2386,7 @@ class GitLabDuoExecutor extends LLMExecutor {
2301
2386
  if (!content) {
2302
2387
  throw new Error("Empty response from model");
2303
2388
  }
2304
- return jsonMode ? parseModelJson(content) : content;
2389
+ return content;
2305
2390
  }
2306
2391
  async chatWithTools(model, options) {
2307
2392
  const log5 = logger.child("llm:gitlab-duo");
@@ -2314,7 +2399,7 @@ class GitLabDuoExecutor extends LLMExecutor {
2314
2399
  ],
2315
2400
  tools: options.tools.map(toTool)
2316
2401
  };
2317
- const data = await this.send(body);
2402
+ const data = await this.send(body, resolveLlmCaller(options));
2318
2403
  if (data.error) {
2319
2404
  throw new Error(`gitlab-duo API error: ${data.error.message ?? "unknown"}`);
2320
2405
  }
@@ -2382,26 +2467,43 @@ class SnowflakeCortexExecutor extends LLMExecutor {
2382
2467
  const account = extra.account ?? readAuthString(opts.auth, "account", "SNOWFLAKE_ACCOUNT") ?? "";
2383
2468
  this.baseURL = account ? `https://${account}.snowflakecomputing.com/api/v2/cortex/inference:complete` : "https://__account__.snowflakecomputing.com/api/v2/cortex/inference:complete";
2384
2469
  }
2385
- async send(body) {
2470
+ async send(body, caller) {
2471
+ const requestRaw = JSON.stringify(body);
2386
2472
  const res = await fetch(this.baseURL, {
2387
2473
  method: "POST",
2388
2474
  headers: {
2389
2475
  Authorization: `Bearer ${this.apiKey}`,
2390
2476
  "Content-Type": "application/json"
2391
2477
  },
2392
- body: JSON.stringify(body)
2478
+ body: requestRaw
2393
2479
  });
2480
+ const responseRaw = await res.text().catch(() => "");
2481
+ logLlmWire(caller, requestRaw, responseRaw);
2394
2482
  if (!res.ok) {
2395
- const text = await res.text().catch(() => "");
2396
- throw new Error(`snowflake-cortex request failed: ${res.status} ${res.statusText} body=${text.slice(0, 500)}`);
2483
+ throw new Error(`snowflake-cortex request failed: ${res.status} ${res.statusText} body=${responseRaw.slice(0, 500)}`);
2397
2484
  }
2398
- return await res.json();
2485
+ return JSON.parse(responseRaw);
2399
2486
  }
2400
2487
  async call(model, options) {
2401
- const jsonMode = "jsonSchemaName" in options;
2402
- const reasoning = defaultReasoningEffort(options.reasoningEffort, model, this.models.identity);
2403
2488
  const log5 = logger.child("llm:snowflake-cortex");
2404
- log5.debug(`call: model=${model} jsonSchema=${jsonMode ? options.jsonSchemaName : "-"} msgLen=${options.message.length}`);
2489
+ if ("jsonSchemaName" in options) {
2490
+ log5.debug(`call: model=${model} jsonSchema=${options.jsonSchemaName} via tool`);
2491
+ const { toolName, tool, instruction } = buildStructuredJsonRequest({
2492
+ instruction: options.instruction,
2493
+ jsonSchemaName: options.jsonSchemaName,
2494
+ jsonSchema: options.jsonSchema
2495
+ });
2496
+ const choice = await this.chatWithTools(model, {
2497
+ caller: options.caller ?? options.jsonSchemaName,
2498
+ instruction,
2499
+ messages: [{ role: "user", content: options.message }],
2500
+ tools: [tool],
2501
+ reasoningEffort: "none"
2502
+ });
2503
+ return parseStructuredJsonResult(choice, toolName);
2504
+ }
2505
+ const reasoning = defaultReasoningEffort(options.reasoningEffort, model, this.models.identity);
2506
+ log5.debug(`call: model=${model} jsonSchema=- msgLen=${options.message.length}`);
2405
2507
  const body = {
2406
2508
  model,
2407
2509
  messages: [
@@ -2412,7 +2514,7 @@ class SnowflakeCortexExecutor extends LLMExecutor {
2412
2514
  if (reasoning !== "none") {
2413
2515
  body["reasoning_effort"] = reasoning;
2414
2516
  }
2415
- const data = await this.send(body);
2517
+ const data = await this.send(body, resolveLlmCaller(options));
2416
2518
  if (data.error) {
2417
2519
  throw new Error(`snowflake-cortex API error: ${data.error.message ?? "unknown"}`);
2418
2520
  }
@@ -2420,7 +2522,7 @@ class SnowflakeCortexExecutor extends LLMExecutor {
2420
2522
  if (!content) {
2421
2523
  throw new Error("Empty response from model");
2422
2524
  }
2423
- return jsonMode ? parseModelJson(content) : content;
2525
+ return content;
2424
2526
  }
2425
2527
  async chatWithTools(model, options) {
2426
2528
  const log5 = logger.child("llm:snowflake-cortex");
@@ -2433,7 +2535,7 @@ class SnowflakeCortexExecutor extends LLMExecutor {
2433
2535
  ],
2434
2536
  tools: options.tools.map(toCortexTool)
2435
2537
  };
2436
- const data = await this.send(body);
2538
+ const data = await this.send(body, resolveLlmCaller(options));
2437
2539
  if (data.error) {
2438
2540
  throw new Error(`snowflake-cortex API error: ${data.error.message ?? "unknown"}`);
2439
2541
  }
@@ -2822,7 +2924,11 @@ class Brain {
2822
2924
  async regenerateSchedules() {
2823
2925
  const today = new Date;
2824
2926
  const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1, today.getDate());
2825
- await this.createMonthlySchedule(nextMonth);
2927
+ const monthly = await this.createMonthlySchedule(nextMonth);
2928
+ if (!monthly) {
2929
+ log7.debug(`regenerateSchedules: skip daily — monthly schedule generation failed`);
2930
+ return;
2931
+ }
2826
2932
  const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
2827
2933
  await this.createDailySchedule(tomorrow);
2828
2934
  await this.createDailySchedule(today);
@@ -3085,75 +3191,56 @@ class Brain {
3085
3191
  content: userPrompt
3086
3192
  }
3087
3193
  ];
3088
- for (let step = 0;step < maxSteps; step += 1) {
3089
- log7.debug(`sendMessage: step ${step + 1}/${maxSteps} → model`);
3090
- let choice;
3091
- try {
3092
- choice = await llm.chatWithTools(llm.models.conversation, {
3093
- caller: initiate ? "start-conversation" : "send-message",
3094
- instruction: `${this.brainbase.baseSystemPrompt}
3194
+ try {
3195
+ await llm.chatWithToolExecution(llm.models.conversation, {
3196
+ caller: initiate ? "start-conversation" : "send-message",
3197
+ instruction: `${this.brainbase.baseSystemPrompt}
3095
3198
 
3096
3199
  ${instruction}`,
3097
- messages,
3098
- tools
3099
- });
3100
- } catch (error) {
3101
- const reason = error instanceof Error ? error.message : String(error);
3102
- logger.error(`sendMessage: LLM call failed at step ${step}: ${reason}`);
3103
- return replyMessages;
3104
- }
3105
- const assistantMessage = choice.message;
3106
- const toolCalls = assistantMessage.toolCalls ?? [];
3107
- const hasContent = typeof assistantMessage.content === "string" && assistantMessage.content.length > 0;
3108
- log7.debug(`sendMessage: step ${step + 1} → toolCalls=${toolCalls.length} hasContent=${hasContent}`);
3109
- if (toolCalls.length === 0) {
3110
- log7.debug(`sendMessage: model returned no tool calls; finalising with ${replyMessages.length} replies`);
3111
- return replyMessages;
3112
- }
3113
- messages.push(stripAssistantForHistory(assistantMessage));
3114
- for (const call of toolCalls) {
3115
- if (call.function.name === "addReplyMessage") {
3116
- const content = parseAddReplyMessageArguments(call.function.arguments);
3117
- if (content !== null) {
3118
- log7.debug(`sendMessage: addReplyMessage[${replyMessages.length}] (${content.length} chars)`);
3119
- send(content);
3120
- replyMessages.push(content);
3121
- } else {
3200
+ messages,
3201
+ tools,
3202
+ maxSteps,
3203
+ executeTool: async (call) => {
3204
+ if (call.function.name === "addReplyMessage") {
3205
+ const content = parseAddReplyMessageArguments(call.function.arguments);
3206
+ if (content !== null) {
3207
+ log7.debug(`sendMessage: addReplyMessage[${replyMessages.length}] (${content.length} chars)`);
3208
+ await send(content);
3209
+ replyMessages.push(content);
3210
+ return JSON.stringify({
3211
+ ok: true,
3212
+ index: replyMessages.length - 1
3213
+ });
3214
+ }
3122
3215
  log7.debug(`sendMessage: addReplyMessage rejected (invalid arguments: ${call.function.arguments})`);
3216
+ return JSON.stringify({ ok: false, error: "invalid arguments" });
3123
3217
  }
3124
- messages.push({
3125
- role: "tool",
3126
- toolCallId: call.id,
3127
- content: content === null ? JSON.stringify({ ok: false, error: "invalid arguments" }) : JSON.stringify({ ok: true, index: replyMessages.length - 1 })
3128
- });
3129
- continue;
3130
- }
3131
- if (call.function.name === "searchMemory") {
3132
- log7.debug(`sendMessage: searchMemory tool call`);
3133
- const result = await this.executeSearchTool(call.function.arguments);
3134
- messages.push({
3135
- role: "tool",
3136
- toolCallId: call.id,
3137
- content: result
3138
- });
3139
- continue;
3140
- }
3141
- log7.debug(`sendMessage: unknown tool "${call.function.name}"`);
3142
- messages.push({
3143
- role: "tool",
3144
- toolCallId: call.id,
3145
- content: JSON.stringify({
3218
+ if (call.function.name === "searchMemory") {
3219
+ log7.debug(`sendMessage: searchMemory tool call`);
3220
+ return this.executeSearchTool(call.function.arguments);
3221
+ }
3222
+ if (call.function.name === "stop") {
3223
+ log7.debug(`sendMessage: stop tool call`);
3224
+ return JSON.stringify({ ok: true });
3225
+ }
3226
+ log7.debug(`sendMessage: unknown tool "${call.function.name}"`);
3227
+ return JSON.stringify({
3146
3228
  ok: false,
3147
3229
  error: `Unknown tool: ${call.function.name}`
3148
- })
3149
- });
3150
- }
3151
- if (!hasContent && toolCalls.every((c) => c.function.name === "searchMemory")) {
3152
- log7.debug(`sendMessage: step was pure searchMemory, looping back`);
3153
- continue;
3154
- }
3230
+ });
3231
+ },
3232
+ shouldEnd: (toolCalls) => toolCalls.some((c) => c.function.name === "stop"),
3233
+ onNoToolCalls: () => {
3234
+ if (replyMessages.length > 0)
3235
+ return null;
3236
+ return "If you do not want to send a message, call the `stop` tool explicitly to end your turn. " + "If you meant to send a message but ended by mistake, call `addReplyMessage`.";
3237
+ }
3238
+ });
3239
+ } catch (error) {
3240
+ const reason = error instanceof Error ? error.message : String(error);
3241
+ logger.error(`sendMessage: LLM call failed: ${reason}`);
3155
3242
  }
3156
- logger.warn(`sendMessage: reached maxSteps (${maxSteps}) without final reply`);
3243
+ log7.debug(`sendMessage: done with ${replyMessages.length} replies`);
3157
3244
  return replyMessages;
3158
3245
  }
3159
3246
  async buildMemoryBlock() {
@@ -3356,7 +3443,7 @@ function buildSendMessageTools() {
3356
3443
  return [
3357
3444
  {
3358
3445
  name: "addReplyMessage",
3359
- description: "Append one chat bubble to the reply stream. Call once per bubble you want to send. Do not call when you are done just return text without tool calls.",
3446
+ description: "Append one chat bubble to the reply stream. Call once per bubble you want to send. After at least one successful call, you may end your turn without calling stop.",
3360
3447
  parameters: {
3361
3448
  type: "object",
3362
3449
  additionalProperties: false,
@@ -3380,6 +3467,15 @@ function buildSendMessageTools() {
3380
3467
  },
3381
3468
  required: ["query"]
3382
3469
  }
3470
+ },
3471
+ {
3472
+ name: "stop",
3473
+ description: "End your turn without sending any further messages. Required when you choose not to send a message. Not needed once you have already called addReplyMessage at least once.",
3474
+ parameters: {
3475
+ type: "object",
3476
+ additionalProperties: false,
3477
+ properties: {}
3478
+ }
3383
3479
  }
3384
3480
  ];
3385
3481
  }
@@ -3405,13 +3501,6 @@ function parseSearchArguments(json) {
3405
3501
  }
3406
3502
  return null;
3407
3503
  }
3408
- function stripAssistantForHistory(message) {
3409
- return {
3410
- role: "assistant",
3411
- content: message.content,
3412
- toolCalls: message.toolCalls
3413
- };
3414
- }
3415
3504
 
3416
3505
  // src/channel/discord.ts
3417
3506
  import {
@@ -3431,10 +3520,18 @@ var SLEEP_MEMORY_CRON_KEY = "__sleep-memory__";
3431
3520
  var SLEEP_MEMORY_CRON_PATTERN = "0 * * * *";
3432
3521
  var START_CONVERSATION_CRON_KEY = "__start-conversation__";
3433
3522
  var START_CONVERSATION_CRON_PATTERN = "*/10 * * * *";
3434
- var DAILY_SCHEDULE_CRON_KEY = "__daily-schedule__";
3435
- var DAILY_SCHEDULE_CRON_PATTERN = "0 0 * * *";
3436
- var DAILY_SCHEDULE_NOON_CRON_KEY = "__daily-schedule-noon__";
3437
- var DAILY_SCHEDULE_NOON_CRON_PATTERN = "0 12 * * *";
3523
+ var SCHEDULE_CRON_KEY = "__schedule__";
3524
+ var SCHEDULE_CRON_PATTERN = "0 0 * * *";
3525
+ var SCHEDULE_NOON_CRON_KEY = "__schedule-noon__";
3526
+ var SCHEDULE_NOON_CRON_PATTERN = "0 12 * * *";
3527
+ var DO_ACTIONS = ["generateSchedule", "sleepMemory"];
3528
+ var VIEW_THINGS = [
3529
+ "daily-schedule",
3530
+ "monthly-schedule",
3531
+ "sending-queue",
3532
+ "deferred-queue",
3533
+ "today-availability"
3534
+ ];
3438
3535
 
3439
3536
  class BaseChannel {
3440
3537
  brain;
@@ -3454,8 +3551,14 @@ class BaseChannel {
3454
3551
  static activeChannels = new Map;
3455
3552
  constructor(brain) {
3456
3553
  this.brain = brain;
3457
- this.registerCron(SLEEP_MEMORY_CRON_KEY, SLEEP_MEMORY_CRON_PATTERN, async () => {
3458
- const dateKey2 = formatDateKey(new Date);
3554
+ this.registerCron(SLEEP_MEMORY_CRON_KEY, SLEEP_MEMORY_CRON_PATTERN, () => this.runSleepMemory());
3555
+ this.registerCron(SCHEDULE_CRON_KEY, SCHEDULE_CRON_PATTERN, () => this.regenerateSchedules());
3556
+ this.registerCron(SCHEDULE_NOON_CRON_KEY, SCHEDULE_NOON_CRON_PATTERN, () => this.regenerateSchedules());
3557
+ this.registerCron(START_CONVERSATION_CRON_KEY, START_CONVERSATION_CRON_PATTERN, () => this.runStartConversation());
3558
+ }
3559
+ async runSleepMemory(force = false) {
3560
+ const dateKey2 = formatDateKey(new Date);
3561
+ if (!force) {
3459
3562
  const availability = await this.brain.getAvailability();
3460
3563
  if (availability.status !== "offline") {
3461
3564
  logger.debug(`sleepMemory cron: skip — availability=${availability.status}`);
@@ -3466,12 +3569,9 @@ class BaseChannel {
3466
3569
  logger.debug(`sleepMemory cron: skip — journal for ${dateKey2} exists`);
3467
3570
  return;
3468
3571
  }
3469
- const history = await this.getMessageHistoryBetween(new Date(Date.now() - 24 * 60 * 60 * 1000), new Date);
3470
- await this.brain.sleepMemory(new Date, history);
3471
- });
3472
- this.registerCron(DAILY_SCHEDULE_CRON_KEY, DAILY_SCHEDULE_CRON_PATTERN, () => this.regenerateSchedules());
3473
- this.registerCron(DAILY_SCHEDULE_NOON_CRON_KEY, DAILY_SCHEDULE_NOON_CRON_PATTERN, () => this.regenerateSchedules());
3474
- this.registerCron(START_CONVERSATION_CRON_KEY, START_CONVERSATION_CRON_PATTERN, () => this.runStartConversation());
3572
+ }
3573
+ const history = await this.getMessageHistoryBetween(new Date(Date.now() - 24 * 60 * 60 * 1000), new Date);
3574
+ await this.brain.sleepMemory(new Date, history);
3475
3575
  }
3476
3576
  async regenerateSchedules() {
3477
3577
  logger.debug(`regenerateSchedules: tick for ${this.brain.brainbase.displayName}`);
@@ -3693,6 +3793,88 @@ class BaseChannel {
3693
3793
  static all() {
3694
3794
  return Array.from(BaseChannel.activeChannels.values());
3695
3795
  }
3796
+ static forceDo(brainId, action) {
3797
+ const channel = BaseChannel.activeChannels.get(brainId);
3798
+ if (!channel) {
3799
+ return {
3800
+ ok: false,
3801
+ error: `no active channel for brain "${brainId}" (is it activated and daemon running?)`
3802
+ };
3803
+ }
3804
+ const displayName = channel.brain.brainbase.displayName;
3805
+ logger.info(`do ${action}: queued for "${displayName}" (${brainId})`);
3806
+ (async () => {
3807
+ try {
3808
+ if (action === "generateSchedule") {
3809
+ await channel.regenerateSchedules();
3810
+ } else {
3811
+ await channel.runSleepMemory(true);
3812
+ }
3813
+ logger.success(`do ${action}: done for "${displayName}" (${brainId})`);
3814
+ } catch (error) {
3815
+ const reason = error instanceof Error ? error.message : String(error);
3816
+ logger.error(`do ${action}: failed for "${displayName}" (${brainId}): ${reason}`);
3817
+ }
3818
+ })();
3819
+ return { ok: true, displayName };
3820
+ }
3821
+ static async view(brainId, thing) {
3822
+ const channel = BaseChannel.activeChannels.get(brainId);
3823
+ if (!channel) {
3824
+ return {
3825
+ ok: false,
3826
+ error: `no active channel for brain "${brainId}" (is it activated and daemon running?)`
3827
+ };
3828
+ }
3829
+ const displayName = channel.brain.brainbase.displayName;
3830
+ logger.debug(`view ${thing}: "${displayName}" (${brainId})`);
3831
+ return {
3832
+ ok: true,
3833
+ displayName,
3834
+ value: await channel.readView(thing)
3835
+ };
3836
+ }
3837
+ async readView(thing) {
3838
+ const now = new Date;
3839
+ switch (thing) {
3840
+ case "daily-schedule": {
3841
+ const key = formatDateKey(now);
3842
+ const stored = await this.brain.memory.get(`daily-schedule:${key}`);
3843
+ if (!stored)
3844
+ return null;
3845
+ try {
3846
+ return { key, schedule: JSON.parse(stored.content) };
3847
+ } catch {
3848
+ return { key, raw: stored.content };
3849
+ }
3850
+ }
3851
+ case "monthly-schedule": {
3852
+ const key = formatMonthKey(now);
3853
+ const stored = await this.brain.memory.get(`monthly-schedule:${key}`);
3854
+ if (!stored)
3855
+ return null;
3856
+ try {
3857
+ return { key, schedule: JSON.parse(stored.content) };
3858
+ } catch {
3859
+ return { key, raw: stored.content };
3860
+ }
3861
+ }
3862
+ case "sending-queue":
3863
+ return this.isSendingQueue.map((m) => ({
3864
+ sender: m.sender,
3865
+ time: m.time.toISOString(),
3866
+ content: m.content
3867
+ }));
3868
+ case "deferred-queue":
3869
+ return this.deferredQueue.map((m) => ({
3870
+ sender: m.sender,
3871
+ time: m.time.toISOString(),
3872
+ content: m.content
3873
+ }));
3874
+ case "today-availability":
3875
+ return await this.brain.getTodayScheduledAvailability(now);
3876
+ }
3877
+ }
3696
3878
  static async shutdownAll() {
3697
3879
  await Promise.all(BaseChannel.all().map((c) => c.shutdown()));
3698
3880
  }
@@ -4202,6 +4384,63 @@ defineCommand({
4202
4384
  }
4203
4385
  });
4204
4386
 
4387
+ // src/commands/daemon/doCommand.ts
4388
+ defineCommand({
4389
+ name: "do",
4390
+ handler: async (args) => {
4391
+ const action = args?.action;
4392
+ const brainId = args?.brainId;
4393
+ logger.debug(`do handler: action="${action}" brainId="${brainId}"`);
4394
+ if (typeof action !== "string" || !DO_ACTIONS.includes(action)) {
4395
+ return {
4396
+ ok: false,
4397
+ error: `invalid action (expected one of: ${DO_ACTIONS.join(", ")})`
4398
+ };
4399
+ }
4400
+ if (typeof brainId !== "string" || brainId.trim().length === 0) {
4401
+ return { ok: false, error: "missing brainId" };
4402
+ }
4403
+ const result = BaseChannel.forceDo(brainId.trim(), action);
4404
+ if (!result.ok)
4405
+ return result;
4406
+ return {
4407
+ ok: true,
4408
+ result: { action, brainId: brainId.trim(), displayName: result.displayName }
4409
+ };
4410
+ }
4411
+ });
4412
+
4413
+ // src/commands/daemon/viewCommand.ts
4414
+ defineCommand({
4415
+ name: "view",
4416
+ handler: async (args) => {
4417
+ const thing = args?.thing;
4418
+ const brainId = args?.brainId;
4419
+ logger.debug(`view handler: thing="${thing}" brainId="${brainId}"`);
4420
+ if (typeof thing !== "string" || !VIEW_THINGS.includes(thing)) {
4421
+ return {
4422
+ ok: false,
4423
+ error: `invalid thing (expected one of: ${VIEW_THINGS.join(", ")})`
4424
+ };
4425
+ }
4426
+ if (typeof brainId !== "string" || brainId.trim().length === 0) {
4427
+ return { ok: false, error: "missing brainId" };
4428
+ }
4429
+ const result = await BaseChannel.view(brainId.trim(), thing);
4430
+ if (!result.ok)
4431
+ return result;
4432
+ return {
4433
+ ok: true,
4434
+ result: {
4435
+ thing,
4436
+ brainId: brainId.trim(),
4437
+ displayName: result.displayName,
4438
+ value: result.value
4439
+ }
4440
+ };
4441
+ }
4442
+ });
4443
+
4205
4444
  // src/commands/daemon.ts
4206
4445
  async function startChannels() {
4207
4446
  const items = await brainManager.listAvailableBrain();
@@ -4238,8 +4477,8 @@ async function startChannels() {
4238
4477
  async function daemon() {
4239
4478
  const logDir = join5(config.brainboxRoot, "logs");
4240
4479
  logger.configure({ logDir });
4241
- configureLlmLog(join5(logDir, "llm"));
4242
- logger.debug(`daemon: boot (logDir=${logDir}, llmLogDir=${join5(logDir, "llm")})`);
4480
+ configureLlmLog(config.debug ? join5(logDir, "llm") : undefined);
4481
+ logger.debug(`daemon: boot (debug=${config.debug}, logDir=${logDir}, llmLog=${config.debug})`);
4243
4482
  const started = await startChannels();
4244
4483
  if (started === 0) {
4245
4484
  logger.info("No activated brains with channels. Daemon idling.");
@@ -4406,6 +4645,33 @@ async function removeBrain(brainId) {
4406
4645
  }
4407
4646
  logger.success(`Removed brain "${brain.displayName}" (${chalk2.cyan(brainId)})`);
4408
4647
  }
4648
+ async function doAction(action, brainId) {
4649
+ if (!DO_ACTIONS.includes(action)) {
4650
+ logger.error(`Unknown action "${action}". Expected one of: ${DO_ACTIONS.join(", ")}`);
4651
+ process.exit(1);
4652
+ }
4653
+ logger.debug(`do: action=${action} brainId=${brainId}`);
4654
+ const response = await sendToDaemon({
4655
+ command: "do",
4656
+ args: { action, brainId }
4657
+ });
4658
+ const name = response.result?.displayName ?? brainId;
4659
+ logger.success(`Successfully sent ${action} for "${name}" (${brainId}).`);
4660
+ }
4661
+ async function viewThing(thing, brainId) {
4662
+ if (!VIEW_THINGS.includes(thing)) {
4663
+ logger.error(`Unknown thing "${thing}". Expected one of: ${VIEW_THINGS.join(", ")}`);
4664
+ process.exit(1);
4665
+ }
4666
+ logger.debug(`view: thing=${thing} brainId=${brainId}`);
4667
+ const response = await sendToDaemon({
4668
+ command: "view",
4669
+ args: { thing, brainId }
4670
+ });
4671
+ const name = response.result?.displayName ?? brainId;
4672
+ logger.info(`${thing} — "${name}" (${brainId})`);
4673
+ console.log(JSON.stringify(response.result?.value ?? null, null, 2));
4674
+ }
4409
4675
  function register3(program) {
4410
4676
  const cmd = registerCommand(program, {
4411
4677
  name: "brain",
@@ -4416,6 +4682,8 @@ function register3(program) {
4416
4682
  cmd.command("remove <brainId>").description("Remove a brain and its memory").action(removeBrain);
4417
4683
  cmd.command("activate <brainId>").description("Activate a brain").action(activateBrain);
4418
4684
  cmd.command("deactivate <brainId>").description("Deactivate a brain").action(deactivateBrain);
4685
+ cmd.command("do <action> <brainId>").description(`Force-run a daemon job (${DO_ACTIONS.join(" | ")}) for a live brain`).action(doAction);
4686
+ cmd.command("view <thing> <brainId>").description(`Inspect a live brain value (${VIEW_THINGS.join(" | ")})`).action(viewThing);
4419
4687
  return cmd;
4420
4688
  }
4421
4689