@desplega.ai/agent-swarm 2.0.0 → 2.1.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.
Files changed (238) hide show
  1. package/dist/{acp-adapter-sj2g0m84.js → acp-adapter-kr4abrsk.js} +10 -10
  2. package/dist/{actions-fegk44gr.js → actions-eh4g6w2r.js} +27 -27
  3. package/dist/{amp-adapter-ad8ft89x.js → amp-adapter-7h7wdt8q.js} +5 -5
  4. package/dist/{app-kfvgbvzy.js → app-7k8s9w5p.js} +9 -9
  5. package/dist/{assistant-z7zdy9ja.js → assistant-wswt2y7p.js} +28 -28
  6. package/dist/{boot-reembed-j8xa181e.js → boot-reembed-9apm9qw6.js} +7 -7
  7. package/dist/{boot-reembed-5c2z0mbe.js → boot-reembed-whzbdthj.js} +7 -7
  8. package/dist/{boot-scrub-sweep-hnkn3rgt.js → boot-scrub-sweep-ndzmvmgt.js} +8 -8
  9. package/dist/{boot-seeds-kwjq4d2z.js → boot-seeds-e9sta0yt.js} +13 -11
  10. package/dist/{claude-adapter-9vmvpvgd.js → claude-adapter-4mqd3r6r.js} +11 -11
  11. package/dist/{claude-managed-adapter-k9qae05j.js → claude-managed-adapter-9wswb52m.js} +3 -3
  12. package/dist/{claude-managed-setup-q54zx5q2.js → claude-managed-setup-tpe1effx.js} +4 -4
  13. package/dist/{claude-sdk-session-9jye6cxr.js → claude-sdk-session-q49ghmh9.js} +10340 -10197
  14. package/dist/{cli-ke896fcf.js → cli-01xvj1rq.js} +10 -10
  15. package/dist/{cli-tr4sxp68.js → cli-035vekcw.js} +2 -2
  16. package/dist/{cli-z5jjpaxw.js → cli-0pd8hzhc.js} +3 -3
  17. package/dist/{cli-n5vwg842.js → cli-0veb92a3.js} +6 -1
  18. package/dist/{cli-wayw93nt.js → cli-10jesr25.js} +3 -3
  19. package/dist/{cli-se8w6wny.js → cli-1y7yywmj.js} +1 -1
  20. package/dist/{cli-v1yb0rqb.js → cli-20krdhz5.js} +47 -19
  21. package/dist/{cli-d5f9ed5c.js → cli-29rja109.js} +39 -20
  22. package/dist/{cli-apx87jne.js → cli-2pwa0q4d.js} +15 -4
  23. package/dist/{cli-ee9gtz70.js → cli-4mengw46.js} +348 -25
  24. package/dist/{cli-03gedhng.js → cli-5twdke5x.js} +6 -6
  25. package/dist/{cli-6razj6t4.js → cli-711vwbxn.js} +2 -2
  26. package/dist/{cli-s2sp957d.js → cli-78vv7446.js} +1 -1
  27. package/dist/{cli-3fbmmawm.js → cli-7n6s5man.js} +4 -4
  28. package/dist/{cli-hnhtk6cj.js → cli-8wwxw8a3.js} +1 -1
  29. package/dist/{cli-hnmhtzvh.js → cli-9jzdj90z.js} +2 -0
  30. package/dist/{cli-skxvvpap.js → cli-cvv9k0hh.js} +28 -25
  31. package/dist/{cli-74ahz61x.js → cli-d9286m9h.js} +2 -2
  32. package/dist/{cli-4jxm2qtz.js → cli-gxzkg1de.js} +28 -16
  33. package/dist/{cli-zkpks3r3.js → cli-h1arqn31.js} +1 -1
  34. package/dist/{cli-r62a35ea.js → cli-hqr88ew6.js} +1 -1
  35. package/dist/{cli-ra0xzggj.js → cli-j67mh2vg.js} +2 -2
  36. package/dist/{cli-29jc5q7w.js → cli-ksphgh48.js} +2 -2
  37. package/dist/{cli-eejsgvm4.js → cli-mgz4nbgg.js} +4 -4
  38. package/dist/{cli-vatyhshy.js → cli-mz5as5ha.js} +4 -4
  39. package/dist/{cli-f7kr7svz.js → cli-n8ndw8j7.js} +2 -2
  40. package/dist/{cli-1e4b6bzh.js → cli-naqzr17j.js} +7 -7
  41. package/dist/{cli-q0ke1yww.js → cli-nm1p8jfr.js} +6 -6
  42. package/dist/{cli-m50t4309.js → cli-p3zbc1rw.js} +5 -5
  43. package/dist/{cli-cgtksawx.js → cli-pe05f8yk.js} +23 -3
  44. package/dist/{cli-vdach5a9.js → cli-qmzsbkvy.js} +4 -4
  45. package/dist/{cli-n3hzg89b.js → cli-qnm4e9e4.js} +57 -579
  46. package/dist/{cli-s4setq91.js → cli-qqy4q48b.js} +3 -3
  47. package/dist/{cli-k382tshb.js → cli-r35rw5de.js} +8 -7
  48. package/dist/{cli-k3dp5tpf.js → cli-r610p9bs.js} +2 -2
  49. package/dist/{cli-e6mpjhed.js → cli-rc6pf5kv.js} +1 -1
  50. package/dist/{cli-3dj8k4vy.js → cli-rdxm77k3.js} +1 -1
  51. package/dist/{cli-tqaazemc.js → cli-s69nfyeh.js} +1 -1
  52. package/dist/{cli-6jyj59nb.js → cli-sawv63e8.js} +2 -2
  53. package/dist/{cli-q50942rs.js → cli-sg19gbf0.js} +1 -1
  54. package/dist/{cli-p1x85d2t.js → cli-shk7r5cs.js} +12 -9
  55. package/dist/{cli-przpmdgr.js → cli-sksetb2q.js} +11 -1
  56. package/dist/{cli-d2qr7aj4.js → cli-t02z59f2.js} +1 -1
  57. package/dist/{cli-j0pfptdp.js → cli-v7g551te.js} +39 -43
  58. package/dist/{cli-bzszjf2p.js → cli-vgqzp4em.js} +519 -126
  59. package/dist/cli-wfnemr1t.js +86 -0
  60. package/dist/{cli-ne236afy.js → cli-wms6x9db.js} +8 -8
  61. package/dist/{cli-5hkm7whv.js → cli-x1r545x8.js} +1 -1
  62. package/dist/{cli-kt6zqg0e.js → cli-xczsgshn.js} +2 -2
  63. package/dist/{cli-ef1hg1wv.js → cli-y3167wt1.js} +430 -238
  64. package/dist/{cli-tsde1hdr.js → cli-yjmqpxt3.js} +12 -11
  65. package/dist/{cli-9nfx44eh.js → cli-z780ec7c.js} +1 -1
  66. package/dist/{cli-r2pnvj8g.js → cli-zm9t2vde.js} +2 -2
  67. package/dist/cli.js +24 -23
  68. package/dist/{codex-adapter-y81sssk0.js → codex-adapter-4z16v9n0.js} +8 -8
  69. package/dist/{codex-login-f2rcfpp9.js → codex-login-pkepy2j1.js} +2 -2
  70. package/dist/{codex-session-runner-8vx4j6a9.js → codex-session-runner-rvms9y2x.js} +8 -8
  71. package/dist/{commands-kcpvpcrr.js → commands-2a9rtyat.js} +8 -8
  72. package/dist/{controller-ywrmt6yd.js → controller-3k3madbn.js} +4 -4
  73. package/dist/{controllers-connect-kwpvj3rf.js → controllers-connect-v9yn8zb9.js} +30 -30
  74. package/dist/{cursor-adapter-cehv1qns.js → cursor-adapter-98gbhyzn.js} +4 -4
  75. package/dist/{db-ww5d7jq3.js → db-eywwsnjw.js} +9 -7
  76. package/dist/{db-k8ankj20.js → db-ncypk9np.js} +9 -9
  77. package/dist/{devin-adapter-x1022qb1.js → devin-adapter-zeq8b9gz.js} +2 -2
  78. package/dist/{dispatcher-e8gq5m68.js → dispatcher-w94kc575.js} +26 -26
  79. package/dist/{docker-ff6y1190.js → docker-b1e4cqab.js} +2 -2
  80. package/dist/{dsh-adapter-3epjm3af.js → dsh-adapter-47ztej36.js} +4 -4
  81. package/dist/{e2b-8ygwhg0f.js → e2b-bbt8ey45.js} +3 -3
  82. package/dist/{e2b-swhnpw3n.js → e2b-dp804sen.js} +3 -3
  83. package/dist/{event-bus-6axnngdj.js → event-bus-j2jdg3qb.js} +2 -2
  84. package/dist/{external-channel-h3359cwn.js → external-channel-zrkg8ar9.js} +8 -8
  85. package/dist/{grok-adapter-0d4x7gkx.js → grok-adapter-s9ncp9vy.js} +11 -11
  86. package/dist/{handlers-an1gzv21.js → handlers-kqftvjgw.js} +29 -29
  87. package/dist/{heartbeat-6dakeb61.js → heartbeat-9q96p8v9.js} +31 -31
  88. package/dist/{hook-6v6cv6d5.js → hook-56qe10dp.js} +7 -7
  89. package/dist/{hook-sa5xye4y.js → hook-yy6rw42k.js} +8 -8
  90. package/dist/{http-r7f3eky0.js → http-r2dnb8f8.js} +963 -75
  91. package/dist/{index-7ep4gzdk.js → index-3gtmkhc6.js} +26 -26
  92. package/dist/{index-v1v6n7hg.js → index-3k5hkz7r.js} +31 -31
  93. package/dist/{index-rmpw9a8q.js → index-5e101240.js} +26 -26
  94. package/dist/{index-s2wxgzr0.js → index-5wyfjc2c.js} +392 -15
  95. package/dist/{index-zk96aq1k.js → index-vxvhxxbn.js} +7 -7
  96. package/dist/{keepalive-be9vwy5h.js → keepalive-psj6j54c.js} +9 -9
  97. package/dist/{lead-0861cwrf.js → lead-hajz4688.js} +43 -43
  98. package/dist/{maintenance-mzzgc8bn.js → maintenance-ps02sb5m.js} +7 -7
  99. package/dist/{node-mn1zs7an.js → node-mwb5r697.js} +1 -1
  100. package/dist/{oauth-refresh-sweep-cpdnfwdj.js → oauth-refresh-sweep-0284h93b.js} +8 -8
  101. package/dist/{onboard-fh8qs4r3.js → onboard-rbf6bg01.js} +2 -2
  102. package/dist/{opencode-adapter-nm42wwj8.js → opencode-adapter-13dcatgq.js} +8 -8
  103. package/dist/{otel-impl-x6c1c9g4.js → otel-impl-zvqcbhp7.js} +2 -2
  104. package/dist/{pi-mono-adapter-9n2ya240.js → pi-mono-adapter-xxy47q4v.js} +7 -7
  105. package/dist/{pricing-refresh-qb0rv0ap.js → pricing-refresh-kefx9v3t.js} +9 -9
  106. package/dist/{rbac-roles-1gzdwbh1.js → rbac-roles-3m36rxm2.js} +8 -8
  107. package/dist/{rbac-roles-3mraey52.js → rbac-roles-z76xv0hq.js} +9 -9
  108. package/dist/{render-v2-agpa45zz.js → render-v2-wz73bs25.js} +9 -9
  109. package/dist/{resume-x0ydy9s9.js → resume-k4wzge8w.js} +14 -14
  110. package/dist/{setup-as9ga0d6.js → setup-2fp2pypp.js} +2 -2
  111. package/dist/{work-objects-y5g1y7ev.js → work-objects-6k0ywz3m.js} +29 -29
  112. package/dist/{worker-860c3e38.js → worker-5v3rxsdh.js} +43 -43
  113. package/dist/{x-m477fr0h.js → x-4mtdeym1.js} +2 -2
  114. package/openapi.json +777 -6
  115. package/package.json +8 -7
  116. package/src/agentmail/handlers.ts +21 -11
  117. package/src/apps/definition.ts +16 -0
  118. package/src/azure-devops/api.ts +52 -2
  119. package/src/azure-devops/boards-sync.ts +50 -20
  120. package/src/azure-devops/index.ts +1 -0
  121. package/src/azure-devops/outbound.ts +95 -0
  122. package/src/be/boot-seeds.ts +11 -0
  123. package/src/be/db/model-profiles.ts +315 -0
  124. package/src/be/db/tasks/read.ts +7 -0
  125. package/src/be/db.ts +53 -47
  126. package/src/be/migrations/219_relabel_pi_context_compactions.sql +22 -0
  127. package/src/be/migrations/220_model_profiles.sql +137 -0
  128. package/src/be/model-tier-resolution.ts +145 -11
  129. package/src/be/model-validation.ts +8 -1
  130. package/src/be/scripts/typecheck.ts +12 -0
  131. package/src/be/secret-registry.ts +6 -2
  132. package/src/be/seed-model-profiles.ts +41 -0
  133. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +368 -0
  134. package/src/be/seed-scripts/index.ts +9 -0
  135. package/src/be/swarm-config-guard.ts +1 -0
  136. package/src/commands/runner.ts +3 -17
  137. package/src/extensions/catalog.generated.json +17 -0
  138. package/src/extensions/contract-types.generated.ts +1 -1
  139. package/src/extensions/loader.ts +10 -5
  140. package/src/heartbeat/heartbeat.ts +2 -1
  141. package/src/http/agents.ts +49 -2
  142. package/src/http/all-routes.ts +1 -0
  143. package/src/http/apps.ts +30 -3
  144. package/src/http/config.ts +26 -2
  145. package/src/http/core.ts +4 -0
  146. package/src/http/index.ts +11 -1
  147. package/src/http/model-profiles.ts +303 -0
  148. package/src/http/pages-public.ts +11 -5
  149. package/src/http/poll.ts +16 -5
  150. package/src/http/script-runs.ts +1 -1
  151. package/src/http/scripts.ts +25 -2
  152. package/src/http/tasks.ts +18 -0
  153. package/src/http/webhooks.ts +1 -2
  154. package/src/pages/feedback-overlay.ts +65 -6
  155. package/src/providers/acp-targets.ts +1 -1
  156. package/src/providers/amp-adapter.ts +1 -1
  157. package/src/providers/dsh-adapter.ts +1 -1
  158. package/src/providers/pi-mono-extension.ts +17 -3
  159. package/src/providers/reasoning-effort.ts +10 -0
  160. package/src/rbac/legacy-policy.ts +8 -0
  161. package/src/rbac/permissions.ts +5 -0
  162. package/src/scheduler/deferred-task-waits.ts +18 -5
  163. package/src/scheduler/scheduler.ts +7 -1
  164. package/src/scripts-runtime/executors/quickjs-prelude.ts +75 -6
  165. package/src/scripts-runtime/executors/quickjs-runner.ts +85 -8
  166. package/src/scripts-runtime/executors/quickjs-web-shims.ts +1041 -0
  167. package/src/scripts-runtime/extract-args-schema.ts +9 -8
  168. package/src/scripts-runtime/quickjs-compat.ts +464 -0
  169. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  170. package/src/scripts-runtime/types/stdlib.d.ts +12 -0
  171. package/src/scripts-runtime/types/swarm-extension.d.ts +3 -0
  172. package/src/scripts-runtime/types/swarm-sdk.d.ts +12 -0
  173. package/src/server.ts +10 -1
  174. package/src/slack/blocks.ts +4 -2
  175. package/src/slack/reaction-shortcode.ts +14 -0
  176. package/src/slack/render-v2.ts +12 -7
  177. package/src/slack/responses.ts +5 -4
  178. package/src/slack/thread-buffer.ts +41 -22
  179. package/src/tasks/worker-follow-up.ts +11 -2
  180. package/src/tests/agentmail-filters.test.ts +47 -0
  181. package/src/tests/apps-spike2.test.ts +35 -0
  182. package/src/tests/azure-devops-outbound.test.ts +420 -0
  183. package/src/tests/db-task-read-contract.test.ts +2 -0
  184. package/src/tests/db-task-write-contract.test.ts +1 -0
  185. package/src/tests/deferred-task-wake.test.ts +78 -1
  186. package/src/tests/docs-index-footprint.test.js +2 -2
  187. package/src/tests/dsh-adapter.test.ts +1 -1
  188. package/src/tests/env-flag.test.ts +1 -0
  189. package/src/tests/extensions-catalog.test.ts +1 -0
  190. package/src/tests/extensions-example-comms-adherence-gate.test.ts +413 -0
  191. package/src/tests/extensions-loader.test.ts +15 -0
  192. package/src/tests/heartbeat-jev-gate-credentials.test.ts +168 -0
  193. package/src/tests/heartbeat-jev-gate.test.ts +385 -0
  194. package/src/tests/model-profile-backcompat.test.ts +327 -0
  195. package/src/tests/model-profile-providers.test.ts +302 -0
  196. package/src/tests/model-profile-runtime-secrets.test.ts +223 -0
  197. package/src/tests/model-profiles-api.test.ts +614 -0
  198. package/src/tests/pages-feedback-keys.test.ts +121 -0
  199. package/src/tests/pages-feedback.test.ts +3 -0
  200. package/src/tests/pi-context-compaction.test.ts +193 -0
  201. package/src/tests/profile-claim.test.ts +269 -0
  202. package/src/tests/quickjs-compat.test.ts +213 -0
  203. package/src/tests/rbac-engine.test.ts +21 -0
  204. package/src/tests/script-connections.test.ts +4 -0
  205. package/src/tests/script-executor-conformance.test.ts +252 -1
  206. package/src/tests/script-executor-quickjs.test.ts +96 -0
  207. package/src/tests/script-runs-http.test.ts +89 -0
  208. package/src/tests/scripts-http.test.ts +16 -0
  209. package/src/tests/secret-scrubber.test.ts +10 -0
  210. package/src/tests/seed-model-profiles.test.ts +174 -0
  211. package/src/tests/seed-scripts.test.ts +2 -2
  212. package/src/tests/skill-invocations.test.ts +4 -2
  213. package/src/tests/slack-blocks.test.ts +36 -0
  214. package/src/tests/slack-inline-output.test.ts +14 -0
  215. package/src/tests/slack-reaction-config.test.ts +20 -0
  216. package/src/tests/slack-render-v2.test.ts +37 -0
  217. package/src/tests/slack-thread-buffer.test.ts +98 -1
  218. package/src/tests/workflow-llm.test.ts +61 -0
  219. package/src/tools/model-profiles/list-model-profiles.ts +54 -0
  220. package/src/tools/request-human-input.ts +11 -3
  221. package/src/tools/script-apis.ts +5 -1
  222. package/src/tools/script-upsert.ts +4 -1
  223. package/src/tools/send-task.ts +24 -1
  224. package/src/tools/system-one-decide.ts +104 -0
  225. package/src/tools/task-action.ts +22 -1
  226. package/src/tools/tool-config.ts +6 -0
  227. package/src/types.ts +36 -1
  228. package/src/utils/secret-scrubber.ts +19 -12
  229. package/src/utils/vcs-repo-match.ts +29 -0
  230. package/src/workflows/executors/raw-llm.ts +8 -3
  231. package/src/workflows/executors/system-one-decision.ts +21 -11
  232. package/src/workflows/executors/workflow-llm.ts +22 -0
  233. package/src/workflows/index.ts +21 -12
  234. package/templates/extensions/README.md +1 -0
  235. package/templates/extensions/comms-adherence-gate/README.md +88 -0
  236. package/templates/extensions/comms-adherence-gate/hooks.ts +489 -0
  237. package/templates/extensions/comms-adherence-gate/manifest.json +8 -0
  238. package/dist/cli-5t4f29ez.js +0 -33
@@ -77,6 +77,67 @@ describe("resolveWorkflowLlmConfig", () => {
77
77
  expect(config.model).toBe("gpt-5.4");
78
78
  });
79
79
 
80
+ test("routes a registry-provider model to that provider, not OpenRouter", async () => {
81
+ const config = await resolveWorkflowLlmConfig("nan/qwen3.8-flash", {
82
+ OPENROUTER_API_KEY: "sk-or-test",
83
+ NAN_API_KEY: "nan-test",
84
+ });
85
+
86
+ expect(config).toEqual({
87
+ apiKey: "nan-test",
88
+ baseURL: "https://api.nan.builders/v1",
89
+ headers: {},
90
+ model: "qwen3.8-flash",
91
+ });
92
+ });
93
+
94
+ test("reads a registry provider's alias key and base URL override", async () => {
95
+ const config = await resolveWorkflowLlmConfig("nan/deepseek-v4-flash", {
96
+ HELMCODE_API_NAN: "nan-alias",
97
+ NAN_BASE_URL: "https://nan.example.test/v1/",
98
+ });
99
+
100
+ expect(config.apiKey).toBe("nan-alias");
101
+ expect(config.baseURL).toBe("https://nan.example.test/v1");
102
+ expect(config.model).toBe("deepseek-v4-flash");
103
+ });
104
+
105
+ test("hands the SDK one key from a canonical NaN key pool", async () => {
106
+ const pool = ["nan-pool-a", "nan-pool-b", "nan-pool-c"];
107
+ for (let i = 0; i < 20; i++) {
108
+ const config = await resolveWorkflowLlmConfig("nan/qwen3.8-flash", {
109
+ NAN_API_KEY: ` ${pool.join(", ")} `,
110
+ });
111
+ expect(pool).toContain(config.apiKey);
112
+ }
113
+ });
114
+
115
+ test("hands the SDK one key from an alias NaN key pool", async () => {
116
+ const pool = ["nan-alias-a", "nan-alias-b"];
117
+ for (let i = 0; i < 20; i++) {
118
+ const config = await resolveWorkflowLlmConfig("nan/deepseek-v4-flash", {
119
+ HELMCODE_API_NAN: pool.join(","),
120
+ });
121
+ expect(pool).toContain(config.apiKey);
122
+ }
123
+ });
124
+
125
+ test("fails a registry-provider model without its key instead of falling back to OpenRouter", async () => {
126
+ await expect(
127
+ resolveWorkflowLlmConfig("nan/qwen3.8-flash", { OPENROUTER_API_KEY: "sk-or-test" }),
128
+ ).rejects.toThrow("No NaN credential found for nan/qwen3.8-flash. Set NAN_API_KEY.");
129
+ });
130
+
131
+ test("keeps OpenRouter vendor ids on OpenRouter", async () => {
132
+ const config = await resolveWorkflowLlmConfig("deepseek/deepseek-v4.1-flash", {
133
+ OPENROUTER_API_KEY: "sk-or-test",
134
+ NAN_API_KEY: "nan-test",
135
+ });
136
+
137
+ expect(config.apiKey).toBe("sk-or-test");
138
+ expect(config.model).toBe("deepseek/deepseek-v4.1-flash");
139
+ });
140
+
80
141
  test("rejects credential kinds without an OpenAI-compatible endpoint", async () => {
81
142
  await expect(
82
143
  resolveWorkflowLlmConfig(undefined, { ANTHROPIC_API_KEY: "example-sk-ant-test" }),
@@ -0,0 +1,54 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { listModelProfiles } from "@/be/db/model-profiles";
4
+ import { createToolRegistrar, swarmToolOutputSchema, toolOk } from "@/tools/utils";
5
+ import { ProviderNameSchema } from "@/types";
6
+
7
+ export const registerListModelProfilesTool = (server: McpServer) => {
8
+ createToolRegistrar(server)(
9
+ "list-model-profiles",
10
+ {
11
+ title: "List Model Profiles",
12
+ description:
13
+ "List model profiles: named harness + model + effort combinations. Pass a profile's name as `profile` on send-task to run that task on it, or set it as an agent's default through PATCH /api/agents/{id}/runtime. A profile runs only on its own harness. Filter by `harness` to see what an agent on that harness can run.",
14
+ annotations: { readOnlyHint: true },
15
+ inputSchema: z.object({
16
+ harness: ProviderNameSchema.optional().describe(
17
+ "Only profiles for this harness (claude, codex, pi, ...).",
18
+ ),
19
+ }),
20
+ outputSchema: swarmToolOutputSchema({
21
+ profiles: z
22
+ .array(
23
+ z.looseObject({
24
+ name: z.string().optional(),
25
+ harness: z.string().optional(),
26
+ model: z.string().optional(),
27
+ effort: z.string().nullable().optional(),
28
+ description: z.string().nullable().optional(),
29
+ }),
30
+ )
31
+ .optional(),
32
+ }),
33
+ },
34
+ async ({ harness }) => {
35
+ const profiles = await listModelProfiles({ harness });
36
+ const lines = profiles.map(
37
+ (p) =>
38
+ `${p.name}: ${p.harness} / ${p.model}${p.effort ? ` / effort ${p.effort}` : ""}${p.description ? ` (${p.description})` : ""}`,
39
+ );
40
+ return toolOk(`${profiles.length} model profile(s)${harness ? ` for ${harness}` : ""}.`, {
41
+ details: lines.join("\n"),
42
+ data: {
43
+ profiles: profiles.map((p) => ({
44
+ name: p.name,
45
+ harness: p.harness,
46
+ model: p.model,
47
+ effort: p.effort,
48
+ description: p.description,
49
+ })),
50
+ },
51
+ });
52
+ },
53
+ );
54
+ };
@@ -56,6 +56,12 @@ export const registerRequestHumanInputTool = (server: McpServer) => {
56
56
  .describe(
57
57
  "Seconds until the request expires. After that the request becomes 'timeout' and you get a hitl-follow-up task. A request with no timeout is cancelled after APPROVAL_REQUEST_AUTO_CANCELLATION_DAYS days (default 7).",
58
58
  ),
59
+ detached: z
60
+ .boolean()
61
+ .optional()
62
+ .describe(
63
+ "Do not attach the request to your current task, so no hitl-follow-up task is created. For scripts that read the request's status themselves.",
64
+ ),
59
65
  }),
60
66
  outputSchema: swarmToolOutputSchema({
61
67
  yourAgentId: z.string().optional(),
@@ -63,7 +69,7 @@ export const registerRequestHumanInputTool = (server: McpServer) => {
63
69
  url: z.string().optional(),
64
70
  }),
65
71
  },
66
- async ({ title, questions, timeoutSeconds }, requestInfo) => {
72
+ async ({ title, questions, timeoutSeconds, detached }, requestInfo) => {
67
73
  if (!requestInfo.agentId) {
68
74
  return toolErr(
69
75
  'Agent ID not found. The MCP client should define the "X-Agent-ID" header.',
@@ -74,8 +80,10 @@ export const registerRequestHumanInputTool = (server: McpServer) => {
74
80
  // Resolve sourceTaskId: prefer header, fall back to agent's current in-progress task.
75
81
  // The X-Source-Task-Id header may be missing when the per-session MCP config wasn't
76
82
  // created (e.g. .mcp.json not found, session resumed, or lead agent on a non-task trigger).
77
- let sourceTaskId = requestInfo.sourceTaskId;
78
- if (!sourceTaskId && requestInfo.agentId) {
83
+ // A detached request has no source task: a script call carries no task
84
+ // header, and the agent's current task is unrelated to the script.
85
+ let sourceTaskId = detached ? undefined : requestInfo.sourceTaskId;
86
+ if (!sourceTaskId && !detached && requestInfo.agentId) {
79
87
  const currentTask = await getAgentCurrentTask(requestInfo.agentId);
80
88
  if (currentTask) {
81
89
  sourceTaskId = currentTask.id;
@@ -149,7 +149,11 @@ export const registerScriptApisTool = (server: McpServer) => {
149
149
  agentId: args.agentId,
150
150
  },
151
151
  requestInfo,
152
- successMessage: (data) => `Endpoint ${(data as ScriptApiWithSecret).id} created.`,
152
+ successMessage: (data) => {
153
+ const warnings = (data as { warnings?: string[] }).warnings ?? [];
154
+ const note = warnings.length > 0 ? `\n${warnings.join("\n")}` : "";
155
+ return `Endpoint ${(data as ScriptApiWithSecret).id} created.${note}`;
156
+ },
153
157
  successDetails: (data) => {
154
158
  const token = (data as ScriptApiWithSecret).token;
155
159
  return token ? `Bearer token (shown once — save it now): ${token}` : undefined;
@@ -47,11 +47,14 @@ export const registerScriptUpsertTool = (server: McpServer) => {
47
47
  name?: unknown;
48
48
  version?: unknown;
49
49
  contentDeduped?: unknown;
50
+ warnings?: unknown;
50
51
  };
51
52
  const name = typeof body.name === "string" ? body.name : args.name;
52
53
  const version = typeof body.version === "number" ? ` v${body.version}` : "";
53
54
  const deduped = body.contentDeduped ? " (content unchanged — deduped)" : "";
54
- return `Script \`${name}\`${version} saved.${deduped}`;
55
+ const warnings = Array.isArray(body.warnings) ? body.warnings.map(String) : [];
56
+ const note = warnings.length > 0 ? `\n${warnings.join("\n")}` : "";
57
+ return `Script \`${name}\`${version} saved.${deduped}${note}`;
55
58
  },
56
59
  }),
57
60
  );
@@ -18,6 +18,7 @@ import {
18
18
  isExtensionAgent,
19
19
  isLinearTrackerContextKey,
20
20
  } from "@/be/db";
21
+ import { modelProfileRefError, taskProfileError } from "@/be/db/model-profiles";
21
22
  import { repointTrackerSyncBySwarmId } from "@/be/db-queries/tracker";
22
23
  import { explicitModelErrorForAgent } from "@/be/model-validation";
23
24
  import { applyPreTaskCreate } from "@/extensions/apply-task-create";
@@ -150,6 +151,14 @@ export const sendTaskInputSchema = z
150
151
  effort: ReasoningEffortSchema.optional().describe(
151
152
  "Reasoning effort for this task: 'off', 'low', 'medium', 'high', 'xhigh', or 'max'. If omitted, the assignee's REASONING_EFFORT_OVERRIDE/default applies.",
152
153
  ),
154
+ profile: z
155
+ .string()
156
+ .trim()
157
+ .min(1)
158
+ .optional()
159
+ .describe(
160
+ "Model profile name (see list-model-profiles): a named harness + model + effort, applied when the task is claimed. Must run on the assignee's harness. Exclusive with model and modelTier. A set `effort` still wins over the profile's.",
161
+ ),
153
162
  allowDuplicate: z
154
163
  .boolean()
155
164
  .default(false)
@@ -281,6 +290,7 @@ export async function sendTaskHandler(
281
290
  modelTier,
282
291
  allowCustomModel,
283
292
  effort,
293
+ profile,
284
294
  allowDuplicate,
285
295
  slackChannelId,
286
296
  slackThreadTs,
@@ -474,6 +484,13 @@ export async function sendTaskHandler(
474
484
  agentId: effectiveAgentId,
475
485
  });
476
486
  if (modelError) return toolErr(modelError, { data: { yourAgentId: creatorAgentId } });
487
+ const profileError = await taskProfileError({
488
+ profile,
489
+ model: normalizedModel.model,
490
+ modelTier: normalizedModel.modelTier,
491
+ agentId: effectiveAgentId,
492
+ });
493
+ if (profileError) return toolErr(profileError, { data: { yourAgentId: creatorAgentId } });
477
494
  const effectiveRoutingReason =
478
495
  requestedAgentId !== undefined
479
496
  ? requestedRoutingReason
@@ -508,6 +525,7 @@ export async function sendTaskHandler(
508
525
  model: normalizedModel.model,
509
526
  modelTier: normalizedModel.modelTier,
510
527
  effort,
528
+ profile,
511
529
  slackChannelId,
512
530
  slackThreadTs,
513
531
  slackUserId,
@@ -647,7 +665,7 @@ export async function sendTaskHandler(
647
665
  : toolErr(guard.message, { data: guardData });
648
666
  }
649
667
 
650
- const result = await getDbClient().transaction(async () => {
668
+ const created = getDbClient().transaction(async () => {
651
669
  // Authoritative re-check: the reads above are separated from the INSERT by
652
670
  // the whole handler, so only a guard inside this transaction can see a
653
671
  // concurrent send-task's committed task.
@@ -743,6 +761,11 @@ export async function sendTaskHandler(
743
761
  task: newTask,
744
762
  };
745
763
  });
764
+ const result = await created.catch((error: unknown) => {
765
+ // The profile was deleted between taskProfileError and the insert.
766
+ if (modelProfileRefError(error) !== "missing") throw error;
767
+ return { success: false, message: `Unknown model profile "${profile}".`, task: undefined };
768
+ });
746
769
 
747
770
  const data = {
748
771
  yourAgentId: creatorAgentId,
@@ -0,0 +1,104 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
4
+ import { getExecutorRegistry } from "@/workflows";
5
+ import {
6
+ SYSTEM_ONE_DECISION_NODE_TYPE,
7
+ SystemOneDecisionConfigSchema,
8
+ SystemOneDecisionExecutor,
9
+ } from "@/workflows/executors/system-one-decision";
10
+ import { SYSTEM_ONE_PROVIDER_IDS } from "@/workflows/executors/system-one-providers";
11
+
12
+ /**
13
+ * One typed decision (Jev and the other SystemOne hosts) made in the API
14
+ * process, the same way a `system-one-decision` workflow node makes it. The
15
+ * provider credential is read and sent here and never reaches the caller, so a
16
+ * script can ask for a decision without a credential binding in its own realm.
17
+ */
18
+ export const registerSystemOneDecideTool = (server: McpServer) => {
19
+ createToolRegistrar(server)(
20
+ "system-one-decide",
21
+ {
22
+ title: "Ask a SystemOne decision model",
23
+ annotations: { destructiveHint: false, openWorldHint: true },
24
+ description:
25
+ "Send a state and typed questions (noul, choice, score) to a SystemOne decision host such as Jev, and return the validated answers. " +
26
+ "Same request, provider list, credentials and validation as the `system-one-decision` workflow node, without its human review band. " +
27
+ "The provider credential is a global secret that stays on the server; this tool never returns it.",
28
+ inputSchema: z.object({
29
+ provider: z
30
+ .enum(SYSTEM_ONE_PROVIDER_IDS)
31
+ .optional()
32
+ .describe("Decision host (default typesafe)"),
33
+ model: z.string().min(1).optional().describe("Provider model id (default: the provider's)"),
34
+ state: z
35
+ .union([z.string(), z.array(z.string()), z.record(z.string(), z.unknown())])
36
+ .describe("The state to decide on"),
37
+ questions: z
38
+ .record(z.string(), z.record(z.string(), z.unknown()))
39
+ .describe(
40
+ "Questions by id, each `{ type: 'noul' | 'choice' | 'score', instructions, criteria }`",
41
+ ),
42
+ timeoutMs: z
43
+ .number()
44
+ .int()
45
+ .optional()
46
+ .describe("Time budget for the call, retries included (default 30000)"),
47
+ maxRetries: z
48
+ .number()
49
+ .int()
50
+ .optional()
51
+ .describe("Retries on transient failures inside the time budget (default 2)"),
52
+ }),
53
+ outputSchema: swarmToolOutputSchema({
54
+ model: z.string().optional(),
55
+ answers: z.record(z.string(), z.unknown()).optional(),
56
+ usage: z.record(z.string(), z.unknown()).optional(),
57
+ requestId: z.string().optional(),
58
+ }),
59
+ },
60
+ async ({ provider, model, state, questions, timeoutMs, maxRetries }, requestInfo) => {
61
+ if (!requestInfo.agentId) {
62
+ return toolErr('Agent ID not found. The MCP client should define the "X-Agent-ID" header.');
63
+ }
64
+
65
+ // `returns` mirrors the questions: the caller reads answers directly, so
66
+ // there is no downstream port to declare.
67
+ const returns = Object.fromEntries(
68
+ Object.entries(questions).map(([id, question]) => [id, { type: question.type }]),
69
+ );
70
+ const parsed = SystemOneDecisionConfigSchema.safeParse({
71
+ provider,
72
+ model,
73
+ state,
74
+ questions,
75
+ returns,
76
+ timeoutMs,
77
+ maxRetries,
78
+ });
79
+ if (!parsed.success) {
80
+ const issues = parsed.error.issues
81
+ .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
82
+ .join("; ");
83
+ return toolErr(`Invalid decision request: ${issues}`.slice(0, 1000));
84
+ }
85
+
86
+ const executor = getExecutorRegistry().get(SYSTEM_ONE_DECISION_NODE_TYPE);
87
+ if (!(executor instanceof SystemOneDecisionExecutor)) {
88
+ return toolErr("The system-one-decision executor is not available");
89
+ }
90
+ const decided = await executor.decide(parsed.data);
91
+ if (!decided.ok) return toolErr(decided.error);
92
+
93
+ const { output } = decided;
94
+ return toolOk(`Decision from ${output.model}`, {
95
+ data: {
96
+ model: output.model,
97
+ answers: output.answers,
98
+ usage: output.usage,
99
+ requestId: output.requestId,
100
+ },
101
+ });
102
+ },
103
+ );
104
+ };
@@ -28,6 +28,7 @@ import {
28
28
  releaseTask,
29
29
  updateTaskClaudeSessionId,
30
30
  } from "@/be/db";
31
+ import { modelProfileRefError, taskProfileError } from "@/be/db/model-profiles";
31
32
  import { explicitModelErrorForAgent } from "@/be/model-validation";
32
33
  import { touchRuntimeInstance } from "@/be/multi-runtime";
33
34
  import { applyPreTaskCreate } from "@/extensions/apply-task-create";
@@ -109,6 +110,14 @@ export const taskActionInputSchema = z.object({
109
110
  effort: ReasoningEffortSchema.optional().describe(
110
111
  "Reasoning effort for the created task: 'off', 'low', 'medium', 'high', 'xhigh', or 'max'. Only used with 'create' action.",
111
112
  ),
113
+ profile: z
114
+ .string()
115
+ .trim()
116
+ .min(1)
117
+ .optional()
118
+ .describe(
119
+ "Model profile name for the created pool task (see list-model-profiles); only workers on its harness claim it. Exclusive with model and modelTier. Only used with 'create' action.",
120
+ ),
112
121
  requiredCapabilities: z
113
122
  .array(z.string())
114
123
  .optional()
@@ -296,6 +305,12 @@ export async function taskActionHandler(
296
305
  allowCustomModel: input.allowCustomModel,
297
306
  });
298
307
  if (modelError) return taskActionResult({ success: false, message: modelError }, agentId);
308
+ const profileError = await taskProfileError({
309
+ profile: input.profile,
310
+ model: normalizedModel.model,
311
+ modelTier: normalizedModel.modelTier,
312
+ });
313
+ if (profileError) return taskActionResult({ success: false, message: profileError }, agentId);
299
314
  try {
300
315
  assetKey = key
301
316
  ? await authorizeAssetKeyWrite(key, await resolveTaskAuditUserId(ctx.sourceTaskId, agentId))
@@ -324,6 +339,7 @@ export async function taskActionHandler(
324
339
  model: normalizedModel.model,
325
340
  modelTier: normalizedModel.modelTier,
326
341
  effort: input.effort,
342
+ profile: input.profile,
327
343
  routingAffinity:
328
344
  leadOnly || requiredCapabilities?.length
329
345
  ? { leadOnly, capabilities: requiredCapabilities ?? [] }
@@ -342,7 +358,7 @@ export async function taskActionHandler(
342
358
  };
343
359
  }
344
360
 
345
- const result = await getDbClient().transaction(async (): Promise<TaskActionResult> => {
361
+ const transacted = getDbClient().transaction(async (): Promise<TaskActionResult> => {
346
362
  switch (action) {
347
363
  case "create": {
348
364
  const newTask = await createTaskExtended(
@@ -644,6 +660,11 @@ export async function taskActionHandler(
644
660
  return { success: false, message: `Unknown action: ${action}` };
645
661
  }
646
662
  });
663
+ const result = await transacted.catch((error: unknown): TaskActionResult => {
664
+ // A create whose profile was deleted between taskProfileError and the insert.
665
+ if (modelProfileRefError(error) !== "missing") throw error;
666
+ return { success: false, message: `Unknown model profile "${input.profile}".` };
667
+ });
647
668
 
648
669
  // Phase 5: when the accept gate refused, run after-commit side
649
670
  // effects (lead follow-up + workflow bus). The dedup row was recorded
@@ -81,6 +81,9 @@ export const DEFERRED_TOOLS = new Set([
81
81
  "model-catalog-refresh",
82
82
  "model-catalog-overlay-upsert",
83
83
 
84
+ // Model profiles (1)
85
+ "list-model-profiles",
86
+
84
87
  // Repos (1)
85
88
  "update-repo",
86
89
 
@@ -151,6 +154,9 @@ export const DEFERRED_TOOLS = new Set([
151
154
  "request-human-input",
152
155
  "cancel-approval-request",
153
156
 
157
+ // Decisions (1)
158
+ "system-one-decide",
159
+
154
160
  // Skills (12)
155
161
  "skill-create",
156
162
  "skill-update",
package/src/types.ts CHANGED
@@ -428,6 +428,31 @@ export const ProviderNameSchema = z.enum([
428
428
  ]);
429
429
  export type ProviderName = z.infer<typeof ProviderNameSchema>;
430
430
 
431
+ // ============================================================================
432
+ // Model Profiles (runbooks/model-tiers.md § Model profiles)
433
+ // ============================================================================
434
+
435
+ /** Lowercase, starts with a letter or digit; dots, dashes and underscores allowed. */
436
+ export const ModelProfileNameSchema = z
437
+ .string()
438
+ .trim()
439
+ .regex(/^[a-z0-9][a-z0-9._-]{0,99}$/, "lowercase letters, digits, '.', '_' or '-', max 100");
440
+
441
+ export const ModelProfileSchema = z.object({
442
+ id: z.string(),
443
+ name: z.string(),
444
+ harness: ProviderNameSchema,
445
+ model: z.string(),
446
+ effort: ReasoningEffortSchema.nullable(),
447
+ allowCustomModel: z.boolean(),
448
+ description: z.string().nullable(),
449
+ /** `tier:<tier>` (boot seed), `agent-config` (made from an agent's MODEL_OVERRIDE), or null. */
450
+ seededFrom: z.string().nullable(),
451
+ createdAt: z.string(),
452
+ lastUpdatedAt: z.string(),
453
+ });
454
+ export type ModelProfile = z.infer<typeof ModelProfileSchema>;
455
+
431
456
  // ============================================================================
432
457
  // Task Steering Messages
433
458
  // ============================================================================
@@ -686,6 +711,11 @@ export const AgentTaskSchema = z
686
711
  // REASONING_EFFORT_OVERRIDE, dropped when the harness/model cannot take
687
712
  // it). Reported at session start; unset when the harness default applied.
688
713
  resolvedEffort: ReasoningEffortSchema.optional(),
714
+ // Model profile (runbooks/model-tiers.md § Model profiles): `profile` is
715
+ // the creator's ask, `resolvedProfile` the name claim applied (the ask or
716
+ // the agent's default). Unset when no profile applied.
717
+ profile: z.string().optional(),
718
+ resolvedProfile: z.string().optional(),
689
719
 
690
720
  // Schedule linking (optional — set when task was created by a schedule).
691
721
  // z.string(): also reachable verbatim from the create-task query param.
@@ -817,6 +847,7 @@ export const CreateTaskOptionsSchema = z.object({
817
847
  model: z.string().optional(),
818
848
  modelTier: ModelTierSchema.optional(),
819
849
  effort: ReasoningEffortSchema.optional(),
850
+ profile: z.string().optional(),
820
851
  scheduleId: z.string().optional(),
821
852
  workflowRunId: z.string().optional(),
822
853
  workflowRunStepId: z.string().optional(),
@@ -2403,7 +2434,11 @@ export const TriggerConfigSchema = z
2403
2434
  }),
2404
2435
  z.object({
2405
2436
  type: z.literal("event"),
2406
- eventName: z.literal("slack.message"),
2437
+ eventName: z.enum([
2438
+ "slack.message",
2439
+ "azure-devops.work_item.assigned",
2440
+ "azure-devops.work_item.commented",
2441
+ ]),
2407
2442
  }),
2408
2443
  ])
2409
2444
  .superRefine((trigger, ctx) => {
@@ -808,6 +808,21 @@ function computeIsSensitiveKey(key: string): boolean {
808
808
  return false;
809
809
  }
810
810
 
811
+ /**
812
+ * Every form of a credential value a consumer may send: the raw value, its
813
+ * trimmed form, and each trimmed member of a comma pool. `selectCredential`
814
+ * trims the key it hands out, so a padded singleton reaches the provider (and
815
+ * its error echo) without the padding.
816
+ */
817
+ function credentialCandidates(value: string): string[] {
818
+ const candidates = new Set([value, value.trim()]);
819
+ if (value.includes(",")) {
820
+ for (const member of value.split(",")) candidates.add(member.trim());
821
+ }
822
+ candidates.delete("");
823
+ return [...candidates];
824
+ }
825
+
811
826
  function buildCache(): ScrubCache {
812
827
  const entries: EnvValueEntry[] = [];
813
828
  const seen = new Set<string>();
@@ -816,14 +831,7 @@ function buildCache(): ScrubCache {
816
831
  if (!rawValue) continue;
817
832
  if (!isSensitiveKey(key)) continue;
818
833
 
819
- // Credential pools: a single env var may hold a comma-separated list of
820
- // tokens that the runner rotates through. Scrub each component too.
821
- const candidates = rawValue.includes(",")
822
- ? [rawValue, ...rawValue.split(",").map((s) => s.trim())]
823
- : [rawValue];
824
-
825
- for (const candidate of candidates) {
826
- if (!candidate) continue;
834
+ for (const candidate of credentialCandidates(rawValue)) {
827
835
  if (candidate.length < MIN_VALUE_LENGTH) continue;
828
836
  for (const form of escapedForms(candidate)) {
829
837
  if (seen.has(form)) continue;
@@ -1089,14 +1097,13 @@ export function registerVolatileSecret(value: string, name: string): void {
1089
1097
  * Register the sensitive values of an env map that is not `process.env`
1090
1098
  * (a runner's per-task env, an adapter's session env). A key picked from a
1091
1099
  * credential pool, or a row admitted after boot, lives only there, so the
1092
- * env pass never sees it. Comma-separated pools register each member too.
1100
+ * env pass never sees it. Comma-separated pools register each member too,
1101
+ * and padded values register their trimmed form.
1093
1102
  */
1094
1103
  export function registerEnvSecrets(env: Record<string, string | undefined>): void {
1095
1104
  for (const [key, value] of Object.entries(env)) {
1096
1105
  if (!value || !isSensitiveKey(key)) continue;
1097
- registerVolatileSecret(value, key);
1098
- if (!value.includes(",")) continue;
1099
- for (const member of value.split(",")) registerVolatileSecret(member.trim(), key);
1106
+ for (const candidate of credentialCandidates(value)) registerVolatileSecret(candidate, key);
1100
1107
  }
1101
1108
  }
1102
1109
 
@@ -0,0 +1,29 @@
1
+ /**
2
+ * How a task's `vcsRepo` names a registered repo (`swarm_repos`). The worker
3
+ * loads that repo's scoped config with it (`fetchRepoConfig`), and the API
4
+ * reads the same repo at claim, so both sides must match the same way.
5
+ */
6
+
7
+ /** `vcsRepo` without surrounding spaces, trailing slashes or `.git`. */
8
+ export function normalizeVcsRepo(vcsRepo: string): string {
9
+ return vcsRepo
10
+ .trim()
11
+ .replace(/\/+$/, "")
12
+ .replace(/\.git$/, "");
13
+ }
14
+
15
+ /** The `swarm_repos.name` candidates are looked up by: the last path segment. */
16
+ export function vcsRepoName(vcsRepo: string): string {
17
+ const requested = normalizeVcsRepo(vcsRepo);
18
+ return requested.split("/").pop() || requested;
19
+ }
20
+
21
+ /** Whether a registered repo URL is exactly the repo `vcsRepo` names. */
22
+ export function vcsRepoUrlMatches(url: string, vcsRepo: string): boolean {
23
+ const requested = normalizeVcsRepo(vcsRepo);
24
+ const normalized = normalizeVcsRepo(url);
25
+ if (normalized === requested) return true;
26
+ // Qualified URLs must retain their host. Only shorthand references use suffix matching.
27
+ if (requested.includes(":")) return false;
28
+ return normalized.endsWith(`/${requested}`) || normalized.endsWith(`:${requested}`);
29
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { ExecutorMeta } from "../../types";
3
+ import { scrubSecrets } from "../../utils/secret-scrubber";
3
4
  import { BaseExecutor, type ExecutorResult } from "./base";
4
5
  import { resolveWorkflowLlmConfig } from "./workflow-llm";
5
6
 
@@ -72,7 +73,7 @@ export async function executeRawLlm(
72
73
  const isRateLimited =
73
74
  httpStatus === 429 ||
74
75
  httpStatus === 529 ||
75
- (err instanceof Error && /rate.?limit|too many requests|529/i.test(err.message));
76
+ (err instanceof Error && /rate.?limit|too many requests|\b529\b/i.test(err.message));
76
77
  if (isRateLimited) {
77
78
  throw err;
78
79
  }
@@ -80,12 +81,16 @@ export async function executeRawLlm(
80
81
  return {
81
82
  status: "success",
82
83
  output: { result: null, model: modelName },
83
- error: `LLM call failed, using fallback port: ${err instanceof Error ? err.message : String(err)}`,
84
+ error: scrubSecrets(
85
+ `LLM call failed, using fallback port: ${err instanceof Error ? err.message : String(err)}`,
86
+ ),
84
87
  };
85
88
  }
86
89
  return {
87
90
  status: "failed",
88
- error: `LLM call failed: ${err instanceof Error ? err.message : String(err)}`,
91
+ // Provider errors can echo the request key back; scrub before any
92
+ // consumer (journal, HTTP route, workflow step) sees the text.
93
+ error: scrubSecrets(`LLM call failed: ${err instanceof Error ? err.message : String(err)}`),
89
94
  };
90
95
  }
91
96
  }