nodal-agents 0.4.0 → 0.4.2

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.
Files changed (174) hide show
  1. package/README.md +38 -14
  2. package/cli.js +16 -1
  3. package/migrations/0030_agent_fallback_llm_keys.sql +6 -0
  4. package/migrations/0031_llm_key_capabilities.sql +7 -0
  5. package/migrations/0032_drop_llm_key_model_caps.sql +9 -0
  6. package/migrations/0033_agent_fallback_chain.sql +21 -0
  7. package/migrations/meta/_journal.json +28 -0
  8. package/package.json +1 -1
  9. package/runner.js +512 -79
  10. package/web/.next/BUILD_ID +1 -1
  11. package/web/.next/build-manifest.json +2 -2
  12. package/web/.next/prerender-manifest.json +3 -3
  13. package/web/.next/server/app/(dashboard)/agents/[id]/edit/page.js +3 -3
  14. package/web/.next/server/app/(dashboard)/agents/[id]/edit/page.js.nft.json +1 -1
  15. package/web/.next/server/app/(dashboard)/agents/[id]/edit/page_client-reference-manifest.js +1 -1
  16. package/web/.next/server/app/(dashboard)/agents/[id]/telegram/page.js +1 -1
  17. package/web/.next/server/app/(dashboard)/agents/[id]/telegram/page.js.nft.json +1 -1
  18. package/web/.next/server/app/(dashboard)/agents/[id]/telegram/page_client-reference-manifest.js +1 -1
  19. package/web/.next/server/app/(dashboard)/agents/page.js +2 -2
  20. package/web/.next/server/app/(dashboard)/agents/page.js.nft.json +1 -1
  21. package/web/.next/server/app/(dashboard)/agents/page_client-reference-manifest.js +1 -1
  22. package/web/.next/server/app/(dashboard)/approvals/page.js +2 -2
  23. package/web/.next/server/app/(dashboard)/approvals/page.js.nft.json +1 -1
  24. package/web/.next/server/app/(dashboard)/approvals/page_client-reference-manifest.js +1 -1
  25. package/web/.next/server/app/(dashboard)/automations/page.js +1 -1
  26. package/web/.next/server/app/(dashboard)/automations/page.js.nft.json +1 -1
  27. package/web/.next/server/app/(dashboard)/automations/page_client-reference-manifest.js +1 -1
  28. package/web/.next/server/app/(dashboard)/billing/page.js +1 -1
  29. package/web/.next/server/app/(dashboard)/billing/page.js.nft.json +1 -1
  30. package/web/.next/server/app/(dashboard)/billing/page_client-reference-manifest.js +1 -1
  31. package/web/.next/server/app/(dashboard)/chat/page.js +2 -2
  32. package/web/.next/server/app/(dashboard)/chat/page.js.nft.json +1 -1
  33. package/web/.next/server/app/(dashboard)/chat/page_client-reference-manifest.js +1 -1
  34. package/web/.next/server/app/(dashboard)/connectors/page.js +1 -1
  35. package/web/.next/server/app/(dashboard)/connectors/page.js.nft.json +1 -1
  36. package/web/.next/server/app/(dashboard)/connectors/page_client-reference-manifest.js +1 -1
  37. package/web/.next/server/app/(dashboard)/credentials/page.js +1 -1
  38. package/web/.next/server/app/(dashboard)/credentials/page.js.nft.json +1 -1
  39. package/web/.next/server/app/(dashboard)/credentials/page_client-reference-manifest.js +1 -1
  40. package/web/.next/server/app/(dashboard)/jobs/[id]/page.js +2 -2
  41. package/web/.next/server/app/(dashboard)/jobs/[id]/page.js.nft.json +1 -1
  42. package/web/.next/server/app/(dashboard)/jobs/[id]/page_client-reference-manifest.js +1 -1
  43. package/web/.next/server/app/(dashboard)/jobs/page.js +2 -2
  44. package/web/.next/server/app/(dashboard)/jobs/page.js.nft.json +1 -1
  45. package/web/.next/server/app/(dashboard)/jobs/page_client-reference-manifest.js +1 -1
  46. package/web/.next/server/app/(dashboard)/llm-providers/page.js +2 -2
  47. package/web/.next/server/app/(dashboard)/llm-providers/page.js.nft.json +1 -1
  48. package/web/.next/server/app/(dashboard)/llm-providers/page_client-reference-manifest.js +1 -1
  49. package/web/.next/server/app/(dashboard)/logs/page.js +1 -1
  50. package/web/.next/server/app/(dashboard)/logs/page.js.nft.json +1 -1
  51. package/web/.next/server/app/(dashboard)/logs/page_client-reference-manifest.js +1 -1
  52. package/web/.next/server/app/(dashboard)/mcp/page.js +2 -2
  53. package/web/.next/server/app/(dashboard)/mcp/page.js.nft.json +1 -1
  54. package/web/.next/server/app/(dashboard)/mcp/page_client-reference-manifest.js +1 -1
  55. package/web/.next/server/app/(dashboard)/memories/page.js +2 -2
  56. package/web/.next/server/app/(dashboard)/memories/page.js.nft.json +1 -1
  57. package/web/.next/server/app/(dashboard)/memories/page_client-reference-manifest.js +1 -1
  58. package/web/.next/server/app/(dashboard)/page.js +3 -3
  59. package/web/.next/server/app/(dashboard)/page.js.nft.json +1 -1
  60. package/web/.next/server/app/(dashboard)/page_client-reference-manifest.js +1 -1
  61. package/web/.next/server/app/(dashboard)/settings/page.js +2 -2
  62. package/web/.next/server/app/(dashboard)/settings/page.js.nft.json +1 -1
  63. package/web/.next/server/app/(dashboard)/settings/page_client-reference-manifest.js +1 -1
  64. package/web/.next/server/app/(dashboard)/skills/[id]/edit/page.js +1 -1
  65. package/web/.next/server/app/(dashboard)/skills/[id]/edit/page.js.nft.json +1 -1
  66. package/web/.next/server/app/(dashboard)/skills/[id]/edit/page_client-reference-manifest.js +1 -1
  67. package/web/.next/server/app/(dashboard)/skills/new/page.js +2 -2
  68. package/web/.next/server/app/(dashboard)/skills/new/page.js.nft.json +1 -1
  69. package/web/.next/server/app/(dashboard)/skills/new/page_client-reference-manifest.js +1 -1
  70. package/web/.next/server/app/(dashboard)/skills/page.js +2 -2
  71. package/web/.next/server/app/(dashboard)/skills/page.js.nft.json +1 -1
  72. package/web/.next/server/app/(dashboard)/skills/page_client-reference-manifest.js +1 -1
  73. package/web/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  74. package/web/.next/server/app/_global-error.html +1 -1
  75. package/web/.next/server/app/_global-error.rsc +2 -2
  76. package/web/.next/server/app/_global-error.segments/_full.segment.rsc +2 -2
  77. package/web/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  78. package/web/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  79. package/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  80. package/web/.next/server/app/_global-error.segments/_index.segment.rsc +2 -2
  81. package/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  82. package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  83. package/web/.next/server/app/_not-found.html +1 -1
  84. package/web/.next/server/app/_not-found.rsc +3 -3
  85. package/web/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  86. package/web/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  87. package/web/.next/server/app/_not-found.segments/_index.segment.rsc +3 -3
  88. package/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  89. package/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  90. package/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  91. package/web/.next/server/app/api/oauth/[provider]/callback/route.js +1 -1
  92. package/web/.next/server/app/api/oauth/[provider]/start/route.js +1 -1
  93. package/web/.next/server/app/login/page_client-reference-manifest.js +1 -1
  94. package/web/.next/server/app/onboarding/page_client-reference-manifest.js +1 -1
  95. package/web/.next/server/app/onboarding.html +1 -1
  96. package/web/.next/server/app/onboarding.rsc +3 -3
  97. package/web/.next/server/app/onboarding.segments/_full.segment.rsc +3 -3
  98. package/web/.next/server/app/onboarding.segments/_head.segment.rsc +1 -1
  99. package/web/.next/server/app/onboarding.segments/_index.segment.rsc +3 -3
  100. package/web/.next/server/app/onboarding.segments/_tree.segment.rsc +2 -2
  101. package/web/.next/server/app/onboarding.segments/onboarding/__PAGE__.segment.rsc +1 -1
  102. package/web/.next/server/app/onboarding.segments/onboarding.segment.rsc +1 -1
  103. package/web/.next/server/chunks/1945.js +1 -0
  104. package/web/.next/server/chunks/3233.js +1 -0
  105. package/web/.next/server/chunks/4574.js +1 -1
  106. package/web/.next/server/chunks/4808.js +1 -0
  107. package/web/.next/server/chunks/4839.js +1 -0
  108. package/web/.next/server/chunks/{3057.js → 7466.js} +1 -1
  109. package/web/.next/server/chunks/7741.js +3 -3
  110. package/web/.next/server/chunks/8206.js +1 -0
  111. package/web/.next/server/chunks/8398.js +1 -0
  112. package/web/.next/server/chunks/{7557.js → 9606.js} +2 -2
  113. package/web/.next/server/middleware-build-manifest.js +1 -1
  114. package/web/.next/server/pages/404.html +1 -1
  115. package/web/.next/server/pages/500.html +1 -1
  116. package/web/.next/server/server-reference-manifest.js +1 -1
  117. package/web/.next/server/server-reference-manifest.json +1 -1
  118. package/web/.next/static/chunks/{9060-df7c0c4c6fa27737.js → 2575-e660568bd1a9bcb6.js} +2 -2
  119. package/web/.next/static/chunks/3233-e6efb7fb1fa24591.js +1 -0
  120. package/web/.next/static/chunks/5436-c1006a40e59853ed.js +1 -0
  121. package/web/.next/static/chunks/7025-7afa82fda10bddc4.js +62 -0
  122. package/web/.next/static/chunks/{5801-e411029984b17b8b.js → 8396-f3502b9af3172006.js} +1 -1
  123. package/web/.next/static/chunks/{8503-ced632da5c3fce79.js → 9098-2bfef80a73c706b3.js} +1 -1
  124. package/web/.next/static/chunks/9123-20653d928e33410a.js +1 -0
  125. package/web/.next/static/chunks/{6679-7c76034b83edeb06.js → 9582-fbf7c8d9b2a39101.js} +1 -1
  126. package/web/.next/static/chunks/app/(dashboard)/agents/[id]/edit/page-a8e293c54c818084.js +2 -0
  127. package/web/.next/static/chunks/app/(dashboard)/agents/[id]/telegram/{page-e6b35d5f361044a9.js → page-7a94ae67b2c3c9c3.js} +1 -1
  128. package/web/.next/static/chunks/app/(dashboard)/agents/page-b258b8975ac6450b.js +1 -0
  129. package/web/.next/static/chunks/app/(dashboard)/approvals/page-79dea6e91956eeba.js +1 -0
  130. package/web/.next/static/chunks/app/(dashboard)/automations/page-3b863b7af8e2c1a3.js +1 -0
  131. package/web/.next/static/chunks/app/(dashboard)/chat/page-4d965bb7ee3732db.js +1 -0
  132. package/web/.next/static/chunks/app/(dashboard)/connectors/page-4a437ba82f4086da.js +1 -0
  133. package/web/.next/static/chunks/app/(dashboard)/jobs/[id]/page-be20dcbf25c8f3ce.js +1 -0
  134. package/web/.next/static/chunks/app/(dashboard)/jobs/page-94a311f688a255d8.js +1 -0
  135. package/web/.next/static/chunks/app/(dashboard)/layout-e1b0d4fad2926646.js +1 -0
  136. package/web/.next/static/chunks/app/(dashboard)/llm-providers/page-e5e2c4e2b783d37f.js +1 -0
  137. package/web/.next/static/chunks/app/(dashboard)/mcp/page-c071c54f76273ac4.js +1 -0
  138. package/web/.next/static/chunks/app/(dashboard)/memories/page-8ca0b34ad35eb1fa.js +1 -0
  139. package/web/.next/static/chunks/app/(dashboard)/{page-fc49d7ed8e472118.js → page-fb50e1576a3ab2e4.js} +1 -1
  140. package/web/.next/static/chunks/app/(dashboard)/settings/page-7b256e9c462e97f8.js +1 -0
  141. package/web/.next/static/chunks/app/(dashboard)/skills/[id]/edit/page-12930816795e8b20.js +1 -0
  142. package/web/.next/static/chunks/app/(dashboard)/skills/new/page-e3a19abaf7468db9.js +1 -0
  143. package/web/.next/static/chunks/app/(dashboard)/skills/page-43f1475a0bc9c45f.js +1 -0
  144. package/web/.next/static/css/78ead23854ab041e.css +3 -0
  145. package/web/.next/server/chunks/1511.js +0 -1
  146. package/web/.next/server/chunks/2103.js +0 -1
  147. package/web/.next/server/chunks/211.js +0 -1
  148. package/web/.next/server/chunks/8178.js +0 -1
  149. package/web/.next/server/chunks/9201.js +0 -1
  150. package/web/.next/server/chunks/9824.js +0 -1
  151. package/web/.next/static/chunks/1165-ec573be2aa63710b.js +0 -1
  152. package/web/.next/static/chunks/2569-6b5e0af9c1f584a4.js +0 -1
  153. package/web/.next/static/chunks/6522-3f865de55adb618d.js +0 -1
  154. package/web/.next/static/chunks/921-f437093debcddbb3.js +0 -1
  155. package/web/.next/static/chunks/9421-d522a48618c4fe37.js +0 -62
  156. package/web/.next/static/chunks/app/(dashboard)/agents/[id]/edit/page-d3724fbf38b71806.js +0 -2
  157. package/web/.next/static/chunks/app/(dashboard)/agents/page-b58294bf588f4581.js +0 -1
  158. package/web/.next/static/chunks/app/(dashboard)/approvals/page-b9e504918d043b6d.js +0 -1
  159. package/web/.next/static/chunks/app/(dashboard)/automations/page-4807e81e2af3030e.js +0 -1
  160. package/web/.next/static/chunks/app/(dashboard)/chat/page-2c8f9571a443f250.js +0 -1
  161. package/web/.next/static/chunks/app/(dashboard)/connectors/page-72ccb0e3a5ed6f2d.js +0 -1
  162. package/web/.next/static/chunks/app/(dashboard)/jobs/[id]/page-40172a14d0b1368f.js +0 -1
  163. package/web/.next/static/chunks/app/(dashboard)/jobs/page-d4a3a16745e02fd1.js +0 -1
  164. package/web/.next/static/chunks/app/(dashboard)/layout-4d5634ba460464d7.js +0 -1
  165. package/web/.next/static/chunks/app/(dashboard)/llm-providers/page-90fb785e2ab32759.js +0 -1
  166. package/web/.next/static/chunks/app/(dashboard)/mcp/page-426478332dfe8313.js +0 -1
  167. package/web/.next/static/chunks/app/(dashboard)/memories/page-aa46f5f7efbfa262.js +0 -1
  168. package/web/.next/static/chunks/app/(dashboard)/settings/page-1cc10beb46234c7d.js +0 -1
  169. package/web/.next/static/chunks/app/(dashboard)/skills/[id]/edit/page-0b61f21847f4c7a0.js +0 -1
  170. package/web/.next/static/chunks/app/(dashboard)/skills/new/page-9de96e643c361732.js +0 -1
  171. package/web/.next/static/chunks/app/(dashboard)/skills/page-4566512d74e54bfe.js +0 -1
  172. package/web/.next/static/css/0a81480f93d3ab37.css +0 -3
  173. /package/web/.next/static/{9FXcaPSw8KYgjKzjKLpT2 → ZuUX-HBTQOhLf0tFI6JQI}/_buildManifest.js +0 -0
  174. /package/web/.next/static/{9FXcaPSw8KYgjKzjKLpT2 → ZuUX-HBTQOhLf0tFI6JQI}/_ssgManifest.js +0 -0
package/runner.js CHANGED
@@ -1102,6 +1102,143 @@ var init_connector_catalog = __esm({
1102
1102
  }
1103
1103
  });
1104
1104
 
1105
+ // ../../packages/shared/src/model-catalog.ts
1106
+ function findModelCatalogEntry(provider, modelId) {
1107
+ return MODEL_CATALOG[provider]?.find((e) => e.modelId === modelId);
1108
+ }
1109
+ var MODEL_CATALOG;
1110
+ var init_model_catalog = __esm({
1111
+ "../../packages/shared/src/model-catalog.ts"() {
1112
+ "use strict";
1113
+ MODEL_CATALOG = {
1114
+ anthropic: [
1115
+ {
1116
+ modelId: "claude-opus-4-8",
1117
+ label: "Claude Opus 4.8",
1118
+ capabilities: { tools: true, forcedToolChoice: true }
1119
+ },
1120
+ {
1121
+ modelId: "claude-sonnet-4-6",
1122
+ label: "Claude Sonnet 4.6",
1123
+ capabilities: { tools: true, forcedToolChoice: true }
1124
+ },
1125
+ {
1126
+ modelId: "claude-haiku-4-5-20251001",
1127
+ label: "Claude Haiku 4.5",
1128
+ capabilities: { tools: true, forcedToolChoice: true }
1129
+ }
1130
+ ],
1131
+ openai: [
1132
+ { modelId: "gpt-5", label: "GPT-5", capabilities: { tools: true, forcedToolChoice: true } },
1133
+ {
1134
+ modelId: "gpt-5-mini",
1135
+ label: "GPT-5 mini",
1136
+ capabilities: { tools: true, forcedToolChoice: true }
1137
+ }
1138
+ ],
1139
+ google: [
1140
+ {
1141
+ modelId: "gemini-2.0-flash",
1142
+ label: "Gemini 2.0 Flash",
1143
+ capabilities: { tools: true, forcedToolChoice: true }
1144
+ },
1145
+ {
1146
+ modelId: "gemini-2.5-pro",
1147
+ label: "Gemini 2.5 Pro",
1148
+ capabilities: { tools: true, forcedToolChoice: true }
1149
+ }
1150
+ ],
1151
+ groq: [
1152
+ {
1153
+ modelId: "llama-3.3-70b-versatile",
1154
+ label: "Llama 3.3 70B",
1155
+ capabilities: { tools: true, forcedToolChoice: true }
1156
+ }
1157
+ ],
1158
+ mistral: [
1159
+ {
1160
+ modelId: "mistral-large-latest",
1161
+ label: "Mistral Large",
1162
+ capabilities: { tools: true, forcedToolChoice: true }
1163
+ }
1164
+ ],
1165
+ // OpenRouter models are namespaced by sub-vendor (anthropic/, deepseek/, …).
1166
+ // The UI groups them by that vendor (see modelGroupLabel). Tested + working
1167
+ // routes; all accept a forced tool_choice.
1168
+ openrouter: [
1169
+ // Anthropic
1170
+ {
1171
+ modelId: "anthropic/claude-haiku-4.5",
1172
+ label: "Claude Haiku 4.5",
1173
+ capabilities: { tools: true, forcedToolChoice: true }
1174
+ },
1175
+ {
1176
+ modelId: "anthropic/claude-opus-4.7",
1177
+ label: "Claude Opus 4.7",
1178
+ capabilities: { tools: true, forcedToolChoice: true }
1179
+ },
1180
+ {
1181
+ modelId: "anthropic/claude-opus-4.7-fast",
1182
+ label: "Claude Opus 4.7 (fast)",
1183
+ capabilities: { tools: true, forcedToolChoice: true }
1184
+ },
1185
+ {
1186
+ modelId: "anthropic/claude-opus-4.8",
1187
+ label: "Claude Opus 4.8",
1188
+ capabilities: { tools: true, forcedToolChoice: true }
1189
+ },
1190
+ {
1191
+ modelId: "anthropic/claude-opus-4.8-fast",
1192
+ label: "Claude Opus 4.8 (fast)",
1193
+ capabilities: { tools: true, forcedToolChoice: true }
1194
+ },
1195
+ {
1196
+ modelId: "anthropic/claude-sonnet-4.6",
1197
+ label: "Claude Sonnet 4.6",
1198
+ capabilities: { tools: true, forcedToolChoice: true }
1199
+ },
1200
+ // DeepSeek
1201
+ {
1202
+ modelId: "deepseek/deepseek-v3.2",
1203
+ label: "DeepSeek V3.2",
1204
+ capabilities: { tools: true, forcedToolChoice: true }
1205
+ },
1206
+ {
1207
+ modelId: "deepseek/deepseek-v4-flash",
1208
+ label: "DeepSeek V4 Flash",
1209
+ capabilities: { tools: true, forcedToolChoice: true }
1210
+ },
1211
+ {
1212
+ modelId: "deepseek/deepseek-v4-pro",
1213
+ label: "DeepSeek V4 Pro",
1214
+ capabilities: { tools: true, forcedToolChoice: true }
1215
+ },
1216
+ // Google
1217
+ {
1218
+ modelId: "google/gemini-3.1-flash-lite-preview",
1219
+ label: "Gemini 3.1 Flash Lite (preview)",
1220
+ capabilities: { tools: true, forcedToolChoice: true }
1221
+ },
1222
+ {
1223
+ modelId: "google/gemini-3.1-pro-preview",
1224
+ label: "Gemini 3.1 Pro (preview)",
1225
+ capabilities: { tools: true, forcedToolChoice: true }
1226
+ },
1227
+ {
1228
+ modelId: "google/gemini-3.5-flash",
1229
+ label: "Gemini 3.5 Flash",
1230
+ capabilities: { tools: true, forcedToolChoice: true }
1231
+ },
1232
+ {
1233
+ modelId: "google/gemma-4-31b-it",
1234
+ label: "Gemma 4 31B-IT",
1235
+ capabilities: { tools: true, forcedToolChoice: true }
1236
+ }
1237
+ ]
1238
+ };
1239
+ }
1240
+ });
1241
+
1105
1242
  // ../../packages/shared/src/index.ts
1106
1243
  var init_src = __esm({
1107
1244
  "../../packages/shared/src/index.ts"() {
@@ -1127,6 +1264,7 @@ var init_src = __esm({
1127
1264
  init_providers();
1128
1265
  init_root_agent();
1129
1266
  init_connector_catalog();
1267
+ init_model_catalog();
1130
1268
  }
1131
1269
  });
1132
1270
 
@@ -1262,7 +1400,6 @@ var init_llm_keys = __esm({
1262
1400
  apiKeyLast4: text3("api_key_last4").notNull().default(""),
1263
1401
  baseUrl: text3("base_url"),
1264
1402
  nickname: text3("nickname"),
1265
- defaultModel: text3("default_model"),
1266
1403
  isActive: boolean2("is_active").notNull().default(true),
1267
1404
  createdAt: timestamp3("created_at", { withTimezone: true }).notNull().defaultNow(),
1268
1405
  updatedAt: timestamp3("updated_at", { withTimezone: true }).notNull().defaultNow()
@@ -1281,6 +1418,7 @@ import {
1281
1418
  integer,
1282
1419
  bigint,
1283
1420
  timestamp as timestamp4,
1421
+ jsonb as jsonb2,
1284
1422
  index as index3,
1285
1423
  check as check2
1286
1424
  } from "drizzle-orm/pg-core";
@@ -1301,6 +1439,14 @@ var init_agents = __esm({
1301
1439
  personality: text4("personality").notNull(),
1302
1440
  model: text4("model").default("claude-sonnet-4-6-20260217"),
1303
1441
  llmKeyId: uuid4("llm_key_id").references(() => entityLlmKeys.id, { onDelete: "set null" }),
1442
+ // Ordered LLM-key fallback chain (Guard 2). When the primary key
1443
+ // (llmKeyId) exhausts retries / times out / hits quota mid-job, the runner
1444
+ // fails over to these in order; all-down fails loud (`all_providers_failed`).
1445
+ // Empty = no failover (default). Each link is a (keyId, model) pair so a
1446
+ // fallback runs on a CHOSEN model (empty model ⇒ that provider's catalog
1447
+ // default). FK integrity is enforced in the app layer; a deleted key is
1448
+ // skipped at resolution time.
1449
+ fallbackChain: jsonb2("fallback_chain").$type().default(sql2`'[]'::jsonb`),
1304
1450
  active: boolean3("active").default(true),
1305
1451
  isDefault: boolean3("is_default").default(false),
1306
1452
  role: text4("role").default("agent"),
@@ -1380,7 +1526,7 @@ var init_agents = __esm({
1380
1526
  });
1381
1527
 
1382
1528
  // ../../packages/db/src/schema/jobs.ts
1383
- import { pgTable as pgTable5, text as text5, uuid as uuid5, integer as integer2, jsonb as jsonb2, timestamp as timestamp5, index as index4, check as check3 } from "drizzle-orm/pg-core";
1529
+ import { pgTable as pgTable5, text as text5, uuid as uuid5, integer as integer2, jsonb as jsonb3, timestamp as timestamp5, index as index4, check as check3 } from "drizzle-orm/pg-core";
1384
1530
  import { sql as sql3 } from "drizzle-orm";
1385
1531
  var agentJobs;
1386
1532
  var init_jobs = __esm({
@@ -1400,7 +1546,7 @@ var init_jobs = __esm({
1400
1546
  originalTask: text5("original_task"),
1401
1547
  chatId: text5("chat_id"),
1402
1548
  systemPrompt: text5("system_prompt"),
1403
- messages: jsonb2("messages").default(sql3`'[]'::jsonb`),
1549
+ messages: jsonb3("messages").default(sql3`'[]'::jsonb`),
1404
1550
  toolsUsed: text5("tools_used").array().default(sql3`'{}'::text[]`),
1405
1551
  turn: integer2("turn").default(0),
1406
1552
  result: text5("result"),
@@ -1428,7 +1574,7 @@ var init_jobs = __esm({
1428
1574
  * different specialist (live regression: job `7767a3c1`, 2026-05-19).
1429
1575
  */
1430
1576
  lastFailedDelegationSlug: text5("last_failed_delegation_slug"),
1431
- pendingDelegation: jsonb2("pending_delegation"),
1577
+ pendingDelegation: jsonb3("pending_delegation"),
1432
1578
  completedAt: timestamp5("completed_at", { withTimezone: true }),
1433
1579
  createdAt: timestamp5("created_at", { withTimezone: true }).defaultNow(),
1434
1580
  updatedAt: timestamp5("updated_at", { withTimezone: true }).defaultNow()
@@ -1463,7 +1609,7 @@ import {
1463
1609
  text as text6,
1464
1610
  uuid as uuid6,
1465
1611
  integer as integer3,
1466
- jsonb as jsonb3,
1612
+ jsonb as jsonb4,
1467
1613
  timestamp as timestamp6,
1468
1614
  numeric,
1469
1615
  index as index5,
@@ -1499,7 +1645,7 @@ var init_tasks = __esm({
1499
1645
  outputTokens: integer3("output_tokens").default(0),
1500
1646
  costUsd: numeric("cost_usd", { precision: 10, scale: 6 }).default("0"),
1501
1647
  dependsOn: uuid6("depends_on").array().default(sql4`'{}'::uuid[]`),
1502
- context: jsonb3("context").default(sql4`'{}'::jsonb`),
1648
+ context: jsonb4("context").default(sql4`'{}'::jsonb`),
1503
1649
  rootJobId: uuid6("root_job_id"),
1504
1650
  lockedAt: timestamp6("locked_at", { withTimezone: true }),
1505
1651
  lockedBy: text6("locked_by"),
@@ -1607,7 +1753,7 @@ var init_connectors = __esm({
1607
1753
  });
1608
1754
 
1609
1755
  // ../../packages/db/src/schema/tool_calls.ts
1610
- import { pgTable as pgTable9, text as text9, uuid as uuid9, integer as integer4, jsonb as jsonb4, timestamp as timestamp9, index as index8 } from "drizzle-orm/pg-core";
1756
+ import { pgTable as pgTable9, text as text9, uuid as uuid9, integer as integer4, jsonb as jsonb5, timestamp as timestamp9, index as index8 } from "drizzle-orm/pg-core";
1611
1757
  import { sql as sql7 } from "drizzle-orm";
1612
1758
  var toolCalls;
1613
1759
  var init_tool_calls = __esm({
@@ -1622,7 +1768,7 @@ var init_tool_calls = __esm({
1622
1768
  entityId: uuid9("entity_id").references(() => entities.id, { onDelete: "cascade" }),
1623
1769
  jobId: uuid9("job_id").references(() => agentJobs.id, { onDelete: "cascade" }),
1624
1770
  toolName: text9("tool_name").notNull(),
1625
- toolInput: jsonb4("tool_input"),
1771
+ toolInput: jsonb5("tool_input"),
1626
1772
  toolOutput: text9("tool_output"),
1627
1773
  durationMs: integer4("duration_ms"),
1628
1774
  turn: integer4("turn"),
@@ -1639,7 +1785,7 @@ var init_tool_calls = __esm({
1639
1785
  });
1640
1786
 
1641
1787
  // ../../packages/db/src/schema/approvals.ts
1642
- import { pgTable as pgTable10, text as text10, uuid as uuid10, jsonb as jsonb5, timestamp as timestamp10, index as index9, check as check7 } from "drizzle-orm/pg-core";
1788
+ import { pgTable as pgTable10, text as text10, uuid as uuid10, jsonb as jsonb6, timestamp as timestamp10, index as index9, check as check7 } from "drizzle-orm/pg-core";
1643
1789
  import { sql as sql8 } from "drizzle-orm";
1644
1790
  var approvalRequests, approvalRules;
1645
1791
  var init_approvals = __esm({
@@ -1656,7 +1802,7 @@ var init_approvals = __esm({
1656
1802
  jobId: uuid10("job_id").notNull().references(() => agentJobs.id, { onDelete: "cascade" }),
1657
1803
  agentId: uuid10("agent_id").references(() => agents.id, { onDelete: "cascade" }),
1658
1804
  toolName: text10("tool_name").notNull(),
1659
- toolInput: jsonb5("tool_input").notNull(),
1805
+ toolInput: jsonb6("tool_input").notNull(),
1660
1806
  status: text10("status").default("pending"),
1661
1807
  requestedAt: timestamp10("requested_at", { withTimezone: true }).defaultNow(),
1662
1808
  resolvedAt: timestamp10("resolved_at", { withTimezone: true }),
@@ -1691,7 +1837,7 @@ var init_approvals = __esm({
1691
1837
  agentId: uuid10("agent_id").references(() => agents.id, { onDelete: "cascade" }),
1692
1838
  toolName: text10("tool_name").notNull(),
1693
1839
  action: text10("action").notNull(),
1694
- conditionJson: jsonb5("condition_json").default(sql8`'{}'::jsonb`),
1840
+ conditionJson: jsonb6("condition_json").default(sql8`'{}'::jsonb`),
1695
1841
  createdAt: timestamp10("created_at", { withTimezone: true }).defaultNow(),
1696
1842
  updatedAt: timestamp10("updated_at", { withTimezone: true }).defaultNow()
1697
1843
  },
@@ -1819,7 +1965,7 @@ import {
1819
1965
  uuid as uuid13,
1820
1966
  boolean as boolean7,
1821
1967
  integer as integer7,
1822
- jsonb as jsonb6,
1968
+ jsonb as jsonb7,
1823
1969
  timestamp as timestamp13,
1824
1970
  index as index12
1825
1971
  } from "drizzle-orm/pg-core";
@@ -1843,8 +1989,8 @@ var init_skills = __esm({
1843
1989
  description: text13("description"),
1844
1990
  defaultContent: text13("default_content"),
1845
1991
  contentOverridden: boolean7("content_overridden").default(false),
1846
- requiredConfig: jsonb6("required_config").default(sql10`'[]'::jsonb`),
1847
- operations: jsonb6("operations").default(sql10`'[]'::jsonb`),
1992
+ requiredConfig: jsonb7("required_config").default(sql10`'[]'::jsonb`),
1993
+ operations: jsonb7("operations").default(sql10`'[]'::jsonb`),
1848
1994
  requiredBuiltins: text13("required_builtins").array().notNull().default(sql10`'{}'::text[]`),
1849
1995
  createdAt: timestamp13("created_at", { withTimezone: true }).defaultNow(),
1850
1996
  updatedAt: timestamp13("updated_at", { withTimezone: true }).defaultNow()
@@ -1889,7 +2035,7 @@ var init_skills = __esm({
1889
2035
  entityId: uuid13("entity_id").notNull().references(() => entities.id, { onDelete: "cascade" }),
1890
2036
  agentId: uuid13("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
1891
2037
  skillId: uuid13("skill_id").notNull().references(() => agentSkills.id, { onDelete: "cascade" }),
1892
- approvalOverrides: jsonb6("approval_overrides").default(sql10`'{}'::jsonb`),
2038
+ approvalOverrides: jsonb7("approval_overrides").default(sql10`'{}'::jsonb`),
1893
2039
  useCustomInstructions: boolean7("use_custom_instructions").notNull().default(false),
1894
2040
  enabledOperations: text13("enabled_operations").array(),
1895
2041
  createdAt: timestamp13("created_at", { withTimezone: true }).notNull().defaultNow()
@@ -1999,7 +2145,7 @@ import {
1999
2145
  text as text16,
2000
2146
  uuid as uuid16,
2001
2147
  boolean as boolean10,
2002
- jsonb as jsonb7,
2148
+ jsonb as jsonb8,
2003
2149
  timestamp as timestamp16,
2004
2150
  index as index15,
2005
2151
  uniqueIndex as uniqueIndex2,
@@ -2023,7 +2169,7 @@ var init_mcp = __esm({
2023
2169
  url: text16("url"),
2024
2170
  command: text16("command"),
2025
2171
  args: text16("args").array().default(sql13`'{}'::text[]`),
2026
- envVars: jsonb7("env_vars").default(sql13`'{}'::jsonb`),
2172
+ envVars: jsonb8("env_vars").default(sql13`'{}'::jsonb`),
2027
2173
  // Encrypted (enc:v1: blob) credential for HTTP MCP servers — same pattern
2028
2174
  // as connectors.api_key. NULL for servers that need no auth.
2029
2175
  apiKey: text16("api_key"),
@@ -2036,7 +2182,7 @@ var init_mcp = __esm({
2036
2182
  // The literal header name or query param name (e.g. 'x-api-key', 'api_key').
2037
2183
  authParamName: text16("auth_param_name"),
2038
2184
  active: boolean10("active").default(true),
2039
- availableTools: jsonb7("available_tools"),
2185
+ availableTools: jsonb8("available_tools"),
2040
2186
  createdAt: timestamp16("created_at", { withTimezone: true }).defaultNow(),
2041
2187
  updatedAt: timestamp16("updated_at", { withTimezone: true }).defaultNow()
2042
2188
  },
@@ -2058,7 +2204,7 @@ var init_mcp = __esm({
2058
2204
  entityId: uuid16("entity_id").notNull().references(() => entities.id, { onDelete: "cascade" }),
2059
2205
  agentId: uuid16("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
2060
2206
  mcpServerId: uuid16("mcp_server_id").notNull().references(() => mcpServers.id, { onDelete: "cascade" }),
2061
- enabledTools: jsonb7("enabled_tools"),
2207
+ enabledTools: jsonb8("enabled_tools"),
2062
2208
  createdAt: timestamp16("created_at", { withTimezone: true }).notNull().defaultNow(),
2063
2209
  updatedAt: timestamp16("updated_at", { withTimezone: true }).notNull().defaultNow()
2064
2210
  },
@@ -2077,7 +2223,7 @@ var init_mcp = __esm({
2077
2223
  entityId: uuid16("entity_id").notNull().references(() => entities.id, { onDelete: "cascade" }),
2078
2224
  slug: text16("slug").notNull(),
2079
2225
  active: boolean10("active").notNull().default(true),
2080
- toolConfig: jsonb7("tool_config").notNull().default(sql13`'{}'::jsonb`),
2226
+ toolConfig: jsonb8("tool_config").notNull().default(sql13`'{}'::jsonb`),
2081
2227
  createdAt: timestamp16("created_at", { withTimezone: true }).notNull().defaultNow(),
2082
2228
  updatedAt: timestamp16("updated_at", { withTimezone: true }).notNull().defaultNow()
2083
2229
  },
@@ -2093,7 +2239,7 @@ import {
2093
2239
  uuid as uuid17,
2094
2240
  boolean as boolean11,
2095
2241
  integer as integer9,
2096
- jsonb as jsonb8,
2242
+ jsonb as jsonb9,
2097
2243
  timestamp as timestamp17,
2098
2244
  index as index16,
2099
2245
  check as check12
@@ -2111,7 +2257,7 @@ var init_misc = __esm({
2111
2257
  id: uuid17("id").primaryKey().defaultRandom(),
2112
2258
  entityId: uuid17("entity_id").notNull().references(() => entities.id, { onDelete: "cascade" }),
2113
2259
  agentId: uuid17("agent_id").references(() => agents.id, { onDelete: "cascade" }),
2114
- messages: jsonb8("messages").notNull().default(sql14`'[]'::jsonb`),
2260
+ messages: jsonb9("messages").notNull().default(sql14`'[]'::jsonb`),
2115
2261
  status: text17("status").notNull().default("active"),
2116
2262
  turnCount: integer9("turn_count").notNull().default(0),
2117
2263
  createdAt: timestamp17("created_at", { withTimezone: true }).notNull().defaultNow(),
@@ -2131,7 +2277,7 @@ var init_misc = __esm({
2131
2277
  slug: text17("slug").notNull(),
2132
2278
  description: text17("description"),
2133
2279
  pluginType: text17("plugin_type").notNull(),
2134
- config: jsonb8("config").default(sql14`'{}'::jsonb`),
2280
+ config: jsonb9("config").default(sql14`'{}'::jsonb`),
2135
2281
  active: boolean11("active").default(true),
2136
2282
  hook: text17("hook").notNull(),
2137
2283
  webhookUrl: text17("webhook_url"),
@@ -10307,6 +10453,21 @@ Caused by: ${underlyingCause.stack}`;
10307
10453
  }
10308
10454
  }
10309
10455
  };
10456
+ var AllProvidersFailedError = class extends Error {
10457
+ code = "all_providers_failed";
10458
+ underlyingCause;
10459
+ constructor(providerCount, underlyingCause) {
10460
+ super(
10461
+ `All ${providerCount} LLM providers failed; last: ${formatCauseSummary(underlyingCause)}`
10462
+ );
10463
+ this.name = "AllProvidersFailedError";
10464
+ this.underlyingCause = underlyingCause;
10465
+ if (underlyingCause instanceof Error) {
10466
+ this.stack = `${this.stack}
10467
+ Caused by: ${underlyingCause.stack}`;
10468
+ }
10469
+ }
10470
+ };
10310
10471
  function formatCauseSummary(cause) {
10311
10472
  if (cause instanceof Error) {
10312
10473
  const name = cause.name || "Error";
@@ -10587,6 +10748,48 @@ function sleep(ms) {
10587
10748
  return new Promise((resolve) => setTimeout(resolve, ms));
10588
10749
  }
10589
10750
 
10751
+ // ../../packages/llm/src/tool-choice-floor.ts
10752
+ function isUnsupportedToolChoiceError(err) {
10753
+ let cur = err;
10754
+ for (let depth = 0; depth < 5 && cur; depth++) {
10755
+ if (!(cur instanceof Error)) return false;
10756
+ const parts = [cur.message ?? ""];
10757
+ const body = cur.responseBody;
10758
+ if (typeof body === "string") parts.push(body);
10759
+ const data = cur.data;
10760
+ if (data !== void 0 && data !== null) {
10761
+ try {
10762
+ parts.push(JSON.stringify(data));
10763
+ } catch {
10764
+ }
10765
+ }
10766
+ const text22 = parts.join(" ").toLowerCase();
10767
+ if (text22.includes("tool_choice") && (text22.includes("no endpoints") || text22.includes("not supported") || text22.includes("does not support") || text22.includes("unsupported") || text22.includes("invalid value"))) {
10768
+ return true;
10769
+ }
10770
+ const inner = cur.cause;
10771
+ if (inner === cur) return false;
10772
+ cur = inner;
10773
+ }
10774
+ return false;
10775
+ }
10776
+ async function generateWithToolChoiceFloor(run, toolChoice, label) {
10777
+ try {
10778
+ return await run();
10779
+ } catch (err) {
10780
+ const wasForced = toolChoice !== void 0 && toolChoice !== "auto";
10781
+ if (wasForced && isUnsupportedToolChoiceError(err)) {
10782
+ console.warn(
10783
+ `[tool_choice_relaxed] ${label}: provider rejected tool_choice=${JSON.stringify(
10784
+ toolChoice
10785
+ )} \u2014 retrying with 'auto'`
10786
+ );
10787
+ return run("auto");
10788
+ }
10789
+ throw err;
10790
+ }
10791
+ }
10792
+
10590
10793
  // ../../packages/llm/src/providers/anthropic.ts
10591
10794
  import { createAnthropic } from "@ai-sdk/anthropic";
10592
10795
  function buildAnthropicModel(config) {
@@ -11068,21 +11271,27 @@ function createLlmClient(config) {
11068
11271
  };
11069
11272
  const clientGenerateText = async (args) => {
11070
11273
  validateIfMessages(args);
11071
- return withRetry(
11072
- () => callWithTimeout(
11073
- () => generateText({
11074
- ...args,
11075
- model,
11076
- // AI SDK native timeout via AbortSignal.timeout(). Survives middleware
11077
- // wrapping unlike a passed-in abortSignal which their internal retry
11078
- // can swallow.
11079
- timeout: LLM_TIMEOUT_MS,
11080
- // Disable AI SDK internal retry we own retries via withRetry to
11081
- // preserve typed error handling (Quota/MessageStructure/LLMTimeout).
11082
- maxRetries: 0
11083
- })
11274
+ const toolChoice = args.toolChoice;
11275
+ return generateWithToolChoiceFloor(
11276
+ (override) => withRetry(
11277
+ () => callWithTimeout(
11278
+ () => generateText({
11279
+ ...args,
11280
+ model,
11281
+ ...override ? { toolChoice: override } : {},
11282
+ // AI SDK native timeout via AbortSignal.timeout(). Survives
11283
+ // middleware wrapping unlike a passed-in abortSignal which their
11284
+ // internal retry can swallow.
11285
+ timeout: LLM_TIMEOUT_MS,
11286
+ // Disable AI SDK internal retry — we own retries via withRetry to
11287
+ // preserve typed error handling (Quota/MessageStructure/LLMTimeout).
11288
+ maxRetries: 0
11289
+ })
11290
+ ),
11291
+ retryOpts
11084
11292
  ),
11085
- retryOpts
11293
+ toolChoice,
11294
+ `${config.provider}/${config.model}`
11086
11295
  );
11087
11296
  };
11088
11297
  const clientStreamText = (args) => {
@@ -11112,6 +11321,66 @@ function createLlmClient(config) {
11112
11321
  };
11113
11322
  }
11114
11323
 
11324
+ // ../../packages/llm/src/failover.ts
11325
+ function isFailoverWorthy(err) {
11326
+ return err instanceof RetryExhaustedError || err instanceof LLMTimeoutError || err instanceof QuotaExhaustedError;
11327
+ }
11328
+ function errLabel(err) {
11329
+ return err instanceof Error ? err.name : String(err);
11330
+ }
11331
+ function createFailoverFromClients(clients) {
11332
+ if (clients.length === 0) {
11333
+ throw new ProviderConfigError("failover: at least one client is required");
11334
+ }
11335
+ if (clients.length === 1) return clients[0];
11336
+ let activeIndex = 0;
11337
+ async function runWithFailover(op, label) {
11338
+ let lastErr;
11339
+ for (let i = activeIndex; i < clients.length; i++) {
11340
+ try {
11341
+ const result = await op(clients[i]);
11342
+ activeIndex = i;
11343
+ return result;
11344
+ } catch (err) {
11345
+ lastErr = err;
11346
+ if (!isFailoverWorthy(err)) throw err;
11347
+ const next = i + 1;
11348
+ if (next < clients.length) {
11349
+ console.warn(
11350
+ `[llm-failover] ${label}: ${clients[i].config.provider}/${clients[i].config.model} failed (${errLabel(err)}) \u2014 failing over to ${clients[next].config.provider}/${clients[next].config.model}`
11351
+ );
11352
+ }
11353
+ }
11354
+ }
11355
+ throw new AllProvidersFailedError(clients.length, lastErr);
11356
+ }
11357
+ const primary = clients[0];
11358
+ return {
11359
+ // Surface the primary's identity/capabilities; the chain is homogeneous in
11360
+ // the capability that matters here (tool use). Failover is for outages, not
11361
+ // capability switching.
11362
+ config: primary.config,
11363
+ capabilities: primary.capabilities,
11364
+ generateText: ((args) => runWithFailover(
11365
+ (c) => c.generateText(args),
11366
+ "generateText"
11367
+ )),
11368
+ // Streaming keeps single-provider semantics (the runner loop uses
11369
+ // generateText). Delegate to the currently-active provider.
11370
+ streamText: ((args) => clients[activeIndex].streamText(args)),
11371
+ generateObject: ((args) => runWithFailover(
11372
+ (c) => c.generateObject(args),
11373
+ "generateObject"
11374
+ ))
11375
+ };
11376
+ }
11377
+ function createFailoverLlmClient(configs) {
11378
+ if (configs.length === 0) {
11379
+ throw new ProviderConfigError("failover: at least one provider config is required");
11380
+ }
11381
+ return createFailoverFromClients(configs.map((c) => createLlmClient(c)));
11382
+ }
11383
+
11115
11384
  // ../../packages/llm/src/embeddings.ts
11116
11385
  import { embed } from "ai";
11117
11386
  import { createOllama as createOllama2 } from "ollama-ai-provider-v2";
@@ -11344,7 +11613,10 @@ async function _writeToolCall(ctx, toolName, input, output, durationMs) {
11344
11613
 
11345
11614
  // ../../packages/tools/src/tool-choice.ts
11346
11615
  function computeToolChoice(cfg) {
11347
- const { isOrchestrator, turn, hasAdapterTools } = cfg;
11616
+ const { isOrchestrator, turn, hasAdapterTools, modelSupportsForcedToolChoice = true } = cfg;
11617
+ if (!modelSupportsForcedToolChoice) {
11618
+ return "auto";
11619
+ }
11348
11620
  if (hasAdapterTools && !isOrchestrator) {
11349
11621
  return "required";
11350
11622
  }
@@ -26212,6 +26484,67 @@ async function createMcpTools(opts) {
26212
26484
  return { tools, close: conn.close };
26213
26485
  }
26214
26486
 
26487
+ // src/job/resolve-llm.ts
26488
+ init_src();
26489
+ async function resolveAgentLlmClient(db, agent, onSkip) {
26490
+ if (!agent.llmKeyId) return { ok: false, reason: "agent_no_llm_configured" };
26491
+ const seen = /* @__PURE__ */ new Set();
26492
+ const requested = [];
26493
+ for (const link of [
26494
+ { keyId: agent.llmKeyId, model: agent.model },
26495
+ ...agent.fallbackChain ?? []
26496
+ ]) {
26497
+ if (typeof link.keyId === "string" && link.keyId.length > 0 && !seen.has(link.keyId)) {
26498
+ seen.add(link.keyId);
26499
+ requested.push({ keyId: link.keyId, model: link.model ?? "" });
26500
+ }
26501
+ }
26502
+ const ids = requested.map((r) => r.keyId);
26503
+ const rows = await db.select().from(entityLlmKeys).where(inArray3(entityLlmKeys.id, ids));
26504
+ const byId = new Map(rows.map((r) => [r.id, r]));
26505
+ try {
26506
+ const configs = [];
26507
+ for (const { keyId, model: requestedModel } of requested) {
26508
+ const row = byId.get(keyId);
26509
+ if (!row || !row.isActive) {
26510
+ onSkip?.({ keyId, reason: "missing_or_inactive" });
26511
+ continue;
26512
+ }
26513
+ const model = requestedModel.length > 0 ? requestedModel : MODEL_CATALOG[row.provider]?.[0]?.modelId ?? "";
26514
+ if (!model) {
26515
+ onSkip?.({ keyId, reason: "no_catalog_model" });
26516
+ continue;
26517
+ }
26518
+ const plaintextKey = row.apiKey ? decrypt(row.apiKey) : "";
26519
+ configs.push({
26520
+ provider: row.provider,
26521
+ model,
26522
+ apiKey: plaintextKey || void 0,
26523
+ baseURL: row.baseUrl ?? void 0
26524
+ });
26525
+ }
26526
+ if (configs.length === 0) return { ok: false, reason: "agent_no_llm_configured" };
26527
+ const effectivePrimary = configs[0];
26528
+ const client = configs.length > 1 ? createFailoverLlmClient(configs) : createLlmClient(effectivePrimary);
26529
+ return {
26530
+ ok: true,
26531
+ client,
26532
+ primaryProvider: effectivePrimary.provider,
26533
+ chainLength: configs.length,
26534
+ // Capability comes from the model CATALOG (provider, model of the
26535
+ // effective primary), not a stored column. Unknown/custom models default
26536
+ // to true; the runtime tool_choice floor backstops a wrong guess.
26537
+ primarySupportsForcedToolChoice: findModelCatalogEntry(effectivePrimary.provider, effectivePrimary.model)?.capabilities.forcedToolChoice ?? true
26538
+ };
26539
+ } catch (err) {
26540
+ return {
26541
+ ok: false,
26542
+ reason: "llm_key_invalid",
26543
+ detail: err instanceof Error ? err.message.slice(0, 200) : "llm_key_invalid"
26544
+ };
26545
+ }
26546
+ }
26547
+
26215
26548
  // ../../packages/orchestration/src/errors.ts
26216
26549
  var DelegationPendingError = class extends Error {
26217
26550
  constructor(childJobId, childSlug) {
@@ -26273,7 +26606,16 @@ var DEFAULT_LIMITS = {
26273
26606
  maxDelegationDepth: 3,
26274
26607
  maxTurns: 50,
26275
26608
  // matches Hermes Agent's per-subagent iteration budget; cumulative cap across resumes
26276
- maxConsecutiveDeliveryTurns: 3
26609
+ maxConsecutiveDeliveryTurns: 3,
26610
+ // 1.5M total tokens: a loud backstop well above any legitimate single job
26611
+ // (typical jobs sit in the tens of thousands) yet below the ~2.4M-token
26612
+ // runaway that motivated it. Override per-deployment via MAX_TOTAL_TOKENS_PER_JOB.
26613
+ maxTotalTokensPerJob: 15e5,
26614
+ // 12 identical (toolName+input+output) turns in a row before declaring the job
26615
+ // stuck. Deliberately conservative: a real poll completes (output changes) long
26616
+ // before 12 identical reads, so this only catches genuinely degenerate loops —
26617
+ // and maxTurns (50) is the ultimate backstop above it.
26618
+ maxNoProgressRepeats: 12
26277
26619
  };
26278
26620
  var ChainCounters = class _ChainCounters {
26279
26621
  constructor(limits = DEFAULT_LIMITS) {
@@ -26971,7 +27313,7 @@ function buildJobContextBlock(ctx) {
26971
27313
  if (ctx.telegramChatId) lines.push(`- telegram_chat_id: ${ctx.telegramChatId}`);
26972
27314
  if (ctx.surface === "chat") {
26973
27315
  lines.push(
26974
- '- surface: in-app dashboard chat \u2014 you are talking directly with the user; reply in plain text. For conversation or recalling facts, just reply (your durable facts are loaded below). For ANY action \u2014 using a connector or skill, delegating to your team, sending/fetching/creating/publishing, or (as the workspace ROOT) creating agents, skills, MCP servers, connectors or automations \u2014 call `run_task` with a clear, self-contained instruction: it runs as a tracked job with your FULL toolset. `run_task` is your gateway to everything you can do \u2014 NEVER tell the user you cannot do something that an action could accomplish; escalate it via `run_task` instead. When you call `run_task`, ALSO write a brief one-line acknowledgment in your own voice (e.g. "Je m\'en occupe\u2026") so the user sees you started. Do not call any other named tool on this surface.'
27316
+ '- surface: in-app dashboard chat \u2014 you are talking directly with the user; reply in plain text. For conversation or recalling facts, just reply (your durable facts are loaded below). For ANY action \u2014 using a connector or skill, delegating to your team, sending/fetching/creating/publishing, or (as the workspace ROOT) creating agents, skills, MCP servers, connectors or automations \u2014 you MUST call the `run_task` tool with a clear, self-contained instruction. CRITICAL: writing in text that you will do something (e.g. "Je lance X\u2026") does NOT start anything \u2014 ONLY an actual `run_task` tool call performs the action. If you intend to act, the `run_task` tool call is mandatory; a text-only reply about an action accomplishes nothing. It runs as a tracked job with your FULL toolset. `run_task` is your gateway to everything you can do \u2014 NEVER tell the user you cannot do something that an action could accomplish; escalate it via `run_task` instead. You may add a one-line acknowledgment in your own voice alongside the call, but the `run_task` call is what actually does the work. Do not call any other named tool on this surface.'
26975
27317
  );
26976
27318
  }
26977
27319
  if (ctx.notifyOnSuccess) {
@@ -27312,9 +27654,21 @@ function truncateForContext(value) {
27312
27654
 
27313
27655
  [... truncated: ${dropped} chars dropped (total ${value.length}) ...]`;
27314
27656
  }
27657
+ function stableStringify(value) {
27658
+ return JSON.stringify(value, (_key, val) => {
27659
+ if (val && typeof val === "object" && !Array.isArray(val)) {
27660
+ return Object.keys(val).sort().reduce((acc, k) => {
27661
+ acc[k] = val[k];
27662
+ return acc;
27663
+ }, {});
27664
+ }
27665
+ return val;
27666
+ });
27667
+ }
27315
27668
  async function executeJob(jobId, deps, _runnerEnv) {
27316
27669
  const { db, registry } = deps;
27317
27670
  let llmClient;
27671
+ let modelSupportsForcedToolChoice = true;
27318
27672
  const startedAt = Date.now();
27319
27673
  const trace = (event, data) => {
27320
27674
  console.error(`[exec ${jobId}] ${event}`, data ? JSON.stringify(data) : "");
@@ -27378,28 +27732,27 @@ async function executeJob(jobId, deps, _runnerEnv) {
27378
27732
  return { status: "failed", error: "agent_no_llm_configured" };
27379
27733
  }
27380
27734
  {
27381
- const [keyRow] = await db.select().from(entityLlmKeys).where(eq4(entityLlmKeys.id, agentRow.llmKeyId)).limit(1);
27382
- if (!keyRow || !keyRow.isActive) {
27383
- await failJob(db, jobId, "agent_no_llm_configured", runStats());
27384
- return { status: "failed", error: "agent_no_llm_configured" };
27385
- }
27386
- try {
27387
- const plaintextKey = keyRow.apiKey ? decrypt(keyRow.apiKey) : "";
27388
- llmClient = createLlmClient({
27389
- provider: keyRow.provider,
27390
- model: agent.model,
27391
- apiKey: plaintextKey || void 0,
27392
- baseURL: keyRow.baseUrl ?? void 0
27393
- });
27394
- trace("llm_client_from_key", {
27395
- keyId: keyRow.id,
27396
- provider: keyRow.provider
27397
- });
27398
- } catch (err) {
27399
- const errorCode = err instanceof Error ? err.message.slice(0, 200) : "llm_key_invalid";
27400
- await failJob(db, jobId, `llm_key_invalid:${errorCode}`, runStats());
27401
- return { status: "failed", error: `llm_key_invalid:${errorCode}` };
27402
- }
27735
+ const resolved = await resolveAgentLlmClient(
27736
+ db,
27737
+ {
27738
+ llmKeyId: agentRow.llmKeyId,
27739
+ fallbackChain: agentRow.fallbackChain ?? null,
27740
+ model: agent.model
27741
+ },
27742
+ (info) => trace("fallback_key_skipped", info)
27743
+ );
27744
+ if (!resolved.ok) {
27745
+ const code = resolved.reason === "agent_no_llm_configured" ? "agent_no_llm_configured" : `llm_key_invalid:${resolved.detail}`;
27746
+ await failJob(db, jobId, code, runStats());
27747
+ return { status: "failed", error: code };
27748
+ }
27749
+ llmClient = resolved.client;
27750
+ modelSupportsForcedToolChoice = resolved.primarySupportsForcedToolChoice;
27751
+ trace("llm_client_from_key", {
27752
+ provider: resolved.primaryProvider,
27753
+ chainLength: resolved.chainLength,
27754
+ forcedToolChoice: modelSupportsForcedToolChoice
27755
+ });
27403
27756
  }
27404
27757
  const childRows = await db.select({ id: agents.id, slug: agents.slug, role: agents.role }).from(agentAssignments).innerJoin(agents, eq4(agentAssignments.subAgentId, agents.id)).where(and3(eq4(agentAssignments.orchestratorId, agentRow.id), eq4(agents.active, true)));
27405
27758
  const children = childRows.map((r) => ({
@@ -27734,6 +28087,20 @@ async function executeJob(jobId, deps, _runnerEnv) {
27734
28087
  await setJobStatus(db, jobId, "awaiting_approval");
27735
28088
  return { status: "awaiting_approval" };
27736
28089
  };
28090
+ const maxTotalTokensPerJob = (() => {
28091
+ const raw = process.env["MAX_TOTAL_TOKENS_PER_JOB"];
28092
+ const n = raw ? Number(raw) : NaN;
28093
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_LIMITS.maxTotalTokensPerJob;
28094
+ })();
28095
+ const recentTurnSignatures = [];
28096
+ const maxNoProgressRepeats = (() => {
28097
+ const raw = process.env["MAX_NO_PROGRESS_REPEATS"];
28098
+ const n = raw ? Number(raw) : NaN;
28099
+ return Number.isFinite(n) && n >= 2 ? Math.floor(n) : DEFAULT_LIMITS.maxNoProgressRepeats;
28100
+ })();
28101
+ const unresolvedToolFailures = /* @__PURE__ */ new Set();
28102
+ const MAX_UNRESOLVED_FAILURE_NUDGES = 2;
28103
+ let unresolvedFailureNudges = 0;
27737
28104
  try {
27738
28105
  while (true) {
27739
28106
  turn += 1;
@@ -27749,7 +28116,12 @@ async function executeJob(jobId, deps, _runnerEnv) {
27749
28116
  return { status: "failed", error: "turn_limit_exceeded" };
27750
28117
  }
27751
28118
  validateMessageStructure(messages);
27752
- const toolChoice = computeToolChoice({ isOrchestrator, turn, hasAdapterTools });
28119
+ const toolChoice = computeToolChoice({
28120
+ isOrchestrator,
28121
+ turn,
28122
+ hasAdapterTools,
28123
+ modelSupportsForcedToolChoice
28124
+ });
27753
28125
  const aiSdkTools = {};
27754
28126
  for (const [name, toolDef] of toolMap) {
27755
28127
  const description = authoringToolsSuffix && (name === "create_skill" || name === "update_skill") ? toolDef.description + authoringToolsSuffix : toolDef.description;
@@ -27767,6 +28139,11 @@ async function executeJob(jobId, deps, _runnerEnv) {
27767
28139
  const completionT = Number(usage?.outputTokens ?? 0);
27768
28140
  inputTokens += Number.isFinite(promptT) ? promptT : 0;
27769
28141
  outputTokens += Number.isFinite(completionT) ? completionT : 0;
28142
+ if (inputTokens + outputTokens > maxTotalTokensPerJob) {
28143
+ trace("token_budget_exceeded", { turn, inputTokens, outputTokens, maxTotalTokensPerJob });
28144
+ await failJob(db, jobId, "token_budget_exceeded", runStats());
28145
+ return { status: "failed", error: "token_budget_exceeded" };
28146
+ }
27770
28147
  const rawToolCalls = response.toolCalls ?? [];
27771
28148
  trace("llm_call_done", {
27772
28149
  turn,
@@ -28011,6 +28388,11 @@ async function executeJob(jobId, deps, _runnerEnv) {
28011
28388
  if (toolResult.outcome === "success" && DELIVERY_TOOL_NAMES.has(call.name)) {
28012
28389
  telegramDelivered = true;
28013
28390
  }
28391
+ if (toolResult.outcome === "success") {
28392
+ unresolvedToolFailures.delete(call.name);
28393
+ } else {
28394
+ unresolvedToolFailures.add(call.name);
28395
+ }
28014
28396
  toolResultBlocks.push({
28015
28397
  type: "tool-result",
28016
28398
  toolCallId: call.id,
@@ -28059,6 +28441,38 @@ async function executeJob(jobId, deps, _runnerEnv) {
28059
28441
  }
28060
28442
  if (returnResultCall) {
28061
28443
  trace("return_result_branch", { turn });
28444
+ const rrStatus = returnResultCall.input?.status;
28445
+ if (rrStatus === "success" && unresolvedToolFailures.size > 0) {
28446
+ const stuck = [...unresolvedToolFailures];
28447
+ if (unresolvedFailureNudges < MAX_UNRESOLVED_FAILURE_NUDGES) {
28448
+ unresolvedFailureNudges += 1;
28449
+ trace("unresolved_tool_failure_nudge", {
28450
+ turn,
28451
+ attempt: unresolvedFailureNudges,
28452
+ stuck
28453
+ });
28454
+ toolResultBlocks.push({
28455
+ type: "tool-result",
28456
+ toolCallId: returnResultCall.toolCallId,
28457
+ toolName: "return_result",
28458
+ output: toResultOutput({
28459
+ error: "deferred: tu signales success mais ces actions ont \xE9chou\xE9 sans \xEAtre corrig\xE9es (" + stuck.join(", ") + "). R\xE9essaie l'action jusqu'\xE0 r\xE9ussite, ou appelle return_result avec status='blocked'."
28460
+ })
28461
+ });
28462
+ messages = [...messages, { role: "tool", content: toolResultBlocks }];
28463
+ messages = [
28464
+ ...messages,
28465
+ {
28466
+ role: "user",
28467
+ content: "[syst\xE8me] Ne d\xE9clare pas un succ\xE8s qui n'a pas eu lieu. Une action a \xE9chou\xE9 et n'a pas \xE9t\xE9 corrig\xE9e. Corrige-la, ou termine honn\xEAtement avec status='blocked'."
28468
+ }
28469
+ ];
28470
+ continue;
28471
+ }
28472
+ trace("unresolved_tool_failure", { turn, stuck });
28473
+ await failJob(db, jobId, "unresolved_tool_failure", runStats());
28474
+ return { status: "failed", error: "unresolved_tool_failure" };
28475
+ }
28062
28476
  const taskRows = await db.select({ id: agentTasks.id }).from(agentTasks).where(eq4(agentTasks.rootJobId, jobId));
28063
28477
  if (requiresToolDelivery && !telegramDelivered && taskRows.length === 0) {
28064
28478
  if (telegramRedeliveryNudges < MAX_TELEGRAM_REDELIVERY_NUDGES) {
@@ -28118,6 +28532,23 @@ async function executeJob(jobId, deps, _runnerEnv) {
28118
28532
  if (toolResultBlocks.length > 0) {
28119
28533
  messages = [...messages, { role: "tool", content: toolResultBlocks }];
28120
28534
  }
28535
+ const turnSignature = toolResultBlocks.map((b) => {
28536
+ const call = callsToProcess.find((c) => c.id === b.toolCallId);
28537
+ const input = call ? stableStringify(call.input) : "";
28538
+ const output = b.output.type === "text" ? b.output.value : stableStringify(b.output.value ?? null);
28539
+ return `${b.toolName}\0${input}\0${output}`;
28540
+ }).sort().join("\n");
28541
+ if (turnSignature !== "") {
28542
+ recentTurnSignatures.push(turnSignature);
28543
+ if (recentTurnSignatures.length > maxNoProgressRepeats) {
28544
+ recentTurnSignatures.shift();
28545
+ }
28546
+ if (recentTurnSignatures.length === maxNoProgressRepeats && recentTurnSignatures.every((s) => s === turnSignature)) {
28547
+ trace("no_progress_detected", { turn, repeats: recentTurnSignatures.length });
28548
+ await failJob(db, jobId, "no_progress_detected", runStats());
28549
+ return { status: "failed", error: "no_progress_detected" };
28550
+ }
28551
+ }
28121
28552
  await saveCheckpoint(db, jobId, {
28122
28553
  messages,
28123
28554
  turn,
@@ -28148,6 +28579,10 @@ async function executeJob(jobId, deps, _runnerEnv) {
28148
28579
  await failJob(db, jobId, "quota_exhausted", runStats());
28149
28580
  return { status: "failed", error: "quota_exhausted" };
28150
28581
  }
28582
+ if (err instanceof AllProvidersFailedError) {
28583
+ await failJob(db, jobId, err.code, runStats());
28584
+ return { status: "failed", error: err.code };
28585
+ }
28151
28586
  if (err instanceof MessageStructureError) {
28152
28587
  await failJob(db, jobId, `message_structure_invalid:${err.code}`, runStats());
28153
28588
  return { status: "failed", error: `message_structure_invalid:${err.code}` };
@@ -28786,20 +29221,18 @@ async function runChatTurn(opts) {
28786
29221
  const title = message.trim().slice(0, TITLE_MAX) + (message.trim().length > TITLE_MAX ? "\u2026" : "");
28787
29222
  await db.update(conversations).set({ title }).where(eq4(conversations.id, conversationId));
28788
29223
  }
28789
- const [keyRow] = await db.select().from(entityLlmKeys).where(eq4(entityLlmKeys.id, agentRow.llmKeyId)).limit(1);
28790
- if (!keyRow || !keyRow.isActive) return { ok: false, error: "agent_no_llm_configured" };
28791
- let llmClient;
28792
- try {
28793
- const plaintextKey = keyRow.apiKey ? decrypt(keyRow.apiKey) : "";
28794
- llmClient = createLlmClient({
28795
- provider: keyRow.provider,
28796
- model: agentRow.model ?? DEFAULT_MODEL,
28797
- apiKey: plaintextKey || void 0,
28798
- baseURL: keyRow.baseUrl ?? void 0
28799
- });
28800
- } catch {
28801
- return { ok: false, error: "llm_key_invalid" };
29224
+ const resolved = await resolveAgentLlmClient(db, {
29225
+ llmKeyId: agentRow.llmKeyId,
29226
+ fallbackChain: agentRow.fallbackChain ?? null,
29227
+ model: agentRow.model ?? DEFAULT_MODEL
29228
+ });
29229
+ if (!resolved.ok) {
29230
+ return {
29231
+ ok: false,
29232
+ error: resolved.reason === "agent_no_llm_configured" ? "agent_no_llm_configured" : "llm_key_invalid"
29233
+ };
28802
29234
  }
29235
+ const llmClient = resolved.client;
28803
29236
  const agent = {
28804
29237
  id: agentRow.id,
28805
29238
  name: agentRow.name,
@@ -28915,7 +29348,6 @@ async function seedDefaultLlmKey(db, env2) {
28915
29348
  apiKeyLast4: last4(plaintextKey),
28916
29349
  baseUrl: env2.LLM_BASE_URL ?? null,
28917
29350
  nickname: "Default (env)",
28918
- defaultModel: env2.LLM_MODEL,
28919
29351
  isActive: true
28920
29352
  }).returning({ id: entityLlmKeys.id });
28921
29353
  if (!newKey) return;
@@ -30710,7 +31142,7 @@ function createApp(deps, runnerEnv) {
30710
31142
  throw err;
30711
31143
  }
30712
31144
  });
30713
- app.use("/api/approve", async (c, next) => {
31145
+ const bearerOrSession = async (c, next) => {
30714
31146
  const auth2 = c.req.header("authorization") ?? "";
30715
31147
  const bearer = auth2.startsWith("Bearer ") ? auth2.slice(7) : null;
30716
31148
  if (bearer && runnerEnv.WORKER_SECRET && bearer === runnerEnv.WORKER_SECRET) {
@@ -30729,7 +31161,8 @@ function createApp(deps, runnerEnv) {
30729
31161
  }
30730
31162
  throw err;
30731
31163
  }
30732
- });
31164
+ };
31165
+ app.use("/api/approve", bearerOrSession);
30733
31166
  app.get("/api/health", (c) => healthRoute(c, deps));
30734
31167
  app.post("/api/agent", (c) => agentRoute(c, deps, runnerEnv));
30735
31168
  app.post("/api/worker", (c) => workerRoute(c, deps, runnerEnv));