@johpaz/hive-sdk 0.1.6 → 0.3.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/CHANGELOG.md +441 -0
  2. package/README.md +21 -3
  3. package/package.json +27 -5
  4. package/packages/core/src/agent/acceptance-checks.ts +9 -9
  5. package/packages/core/src/agent/agent-catalog.ts +82 -25
  6. package/packages/core/src/agent/agent-loop.ts +115 -26
  7. package/packages/core/src/agent/capability-search.ts +2 -2
  8. package/packages/core/src/agent/catalog-selector.ts +4 -4
  9. package/packages/core/src/agent/compaction.ts +30 -10
  10. package/packages/core/src/agent/context-compiler.ts +63 -36
  11. package/packages/core/src/agent/conversation-store.ts +167 -9
  12. package/packages/core/src/agent/curator.ts +16 -7
  13. package/packages/core/src/agent/delegation-runtime.ts +5 -5
  14. package/packages/core/src/agent/goal-runner.ts +9 -9
  15. package/packages/core/src/agent/index.ts +1 -0
  16. package/packages/core/src/agent/llm-client.ts +98 -37
  17. package/packages/core/src/agent/llm-providers/anthropic.ts +4 -4
  18. package/packages/core/src/agent/llm-providers/deepseek.ts +1 -1
  19. package/packages/core/src/agent/llm-providers/gemini.ts +4 -4
  20. package/packages/core/src/agent/llm-providers/groq.ts +1 -1
  21. package/packages/core/src/agent/llm-providers/hiveagents.ts +3 -3
  22. package/packages/core/src/agent/llm-providers/interface.ts +2 -2
  23. package/packages/core/src/agent/llm-providers/kimi.ts +1 -1
  24. package/packages/core/src/agent/llm-providers/minimax.ts +1 -1
  25. package/packages/core/src/agent/llm-providers/mistral.ts +1 -1
  26. package/packages/core/src/agent/llm-providers/modelscope.ts +1 -1
  27. package/packages/core/src/agent/llm-providers/nvidia.ts +40 -1
  28. package/packages/core/src/agent/llm-providers/ollama.ts +4 -4
  29. package/packages/core/src/agent/llm-providers/openai-compat-base.ts +47 -7
  30. package/packages/core/src/agent/llm-providers/openai.ts +1 -1
  31. package/packages/core/src/agent/llm-providers/opencode-go.ts +1 -1
  32. package/packages/core/src/agent/llm-providers/openrouter.ts +1 -1
  33. package/packages/core/src/agent/llm-providers/qwen.ts +1 -1
  34. package/packages/core/src/agent/llm-providers/z-ai.ts +1 -1
  35. package/packages/core/src/agent/mcp-result-normalizer.ts +192 -0
  36. package/packages/core/src/agent/playbook-selector.ts +22 -7
  37. package/packages/core/src/agent/prompt-builder.ts +5 -5
  38. package/packages/core/src/agent/proof-packet.ts +5 -5
  39. package/packages/core/src/agent/providers/index.ts +39 -4
  40. package/packages/core/src/agent/realtime-providers/gemini-live.ts +238 -0
  41. package/packages/core/src/agent/realtime-providers/index.ts +29 -0
  42. package/packages/core/src/agent/realtime-providers/interface.ts +108 -0
  43. package/packages/core/src/agent/reflector.ts +38 -15
  44. package/packages/core/src/agent/run-store.ts +8 -8
  45. package/packages/core/src/agent/service.ts +10 -10
  46. package/packages/core/src/agent/skill-selector.ts +6 -6
  47. package/packages/core/src/agent/thread-id.ts +71 -0
  48. package/packages/core/src/agent/thread-store.ts +293 -0
  49. package/packages/core/src/agent/tool-selector.ts +9 -5
  50. package/packages/core/src/agent/tracer.ts +5 -5
  51. package/packages/core/src/api/createAgent.ts +67 -2
  52. package/packages/core/src/artifacts/index.ts +15 -0
  53. package/packages/core/src/artifacts/store.ts +161 -5
  54. package/packages/core/src/canvas/emitter.ts +2 -2
  55. package/packages/core/src/canvas/index.ts +9 -0
  56. package/packages/core/src/channels/telegram.ts +1 -1
  57. package/packages/core/src/channels/webchat.ts +1 -1
  58. package/packages/core/src/config/loader.ts +14 -5
  59. package/packages/core/src/ethics/EthicsGuard.ts +7 -1
  60. package/packages/core/src/events/agent-bus.ts +3 -3
  61. package/packages/core/src/events/channel-narration.ts +3 -3
  62. package/packages/core/src/events/event-bus.ts +1 -1
  63. package/packages/core/src/events/index.ts +18 -0
  64. package/packages/core/src/events/narration.ts +3 -3
  65. package/packages/core/src/events/tool-narration.ts +4 -0
  66. package/packages/core/src/gateway/channel-notify.ts +103 -6
  67. package/packages/core/src/gateway/delegation-groups.ts +4 -4
  68. package/packages/core/src/gateway/durable-queue.ts +18 -6
  69. package/packages/core/src/gateway/index.ts +3 -0
  70. package/packages/core/src/gateway/job-store.ts +11 -5
  71. package/packages/core/src/gateway/notification-inbox.ts +2 -2
  72. package/packages/core/src/gateway/server.ts +2 -2
  73. package/packages/core/src/harness/executors.ts +493 -0
  74. package/packages/core/src/harness/index.ts +12 -2
  75. package/packages/core/src/hooks/index.ts +203 -0
  76. package/packages/core/src/images/index.ts +161 -0
  77. package/packages/core/src/index.ts +1 -0
  78. package/packages/core/src/mcp/MCPClient.ts +3 -3
  79. package/packages/core/src/mcp/hot-reload.ts +5 -5
  80. package/packages/core/src/mcp/tool-sync.ts +5 -5
  81. package/packages/core/src/mcp/transports/index.ts +2 -2
  82. package/packages/core/src/mcp/transports/sse.ts +1 -1
  83. package/packages/core/src/models/index.ts +36 -0
  84. package/packages/core/src/multimodal/index.ts +2 -2
  85. package/packages/core/src/multimodal/vision-service.ts +51 -19
  86. package/packages/core/src/plugins/loader.ts +4 -1
  87. package/packages/core/src/resilience/circuit-breaker.ts +16 -5
  88. package/packages/core/src/resilience/index.ts +13 -0
  89. package/packages/core/src/resilience/retry.ts +1 -1
  90. package/packages/core/src/scheduler/CronScheduler.ts +54 -27
  91. package/packages/core/src/scheduler/cron/expression.ts +165 -0
  92. package/packages/core/src/scheduler/cron/index.ts +10 -0
  93. package/packages/core/src/scheduler/cron/job.ts +339 -0
  94. package/packages/core/src/scheduler/cron/next-run.ts +121 -0
  95. package/packages/core/src/scheduler/cron/zoned-time.ts +138 -0
  96. package/packages/core/src/scheduler/index.ts +21 -3
  97. package/packages/core/src/scheduler/integration.ts +25 -14
  98. package/packages/core/src/scheduler/types.ts +3 -18
  99. package/packages/core/src/services/agents.ts +268 -0
  100. package/packages/core/src/services/cron.ts +257 -0
  101. package/packages/core/src/services/endpoints.ts +289 -0
  102. package/packages/core/src/services/ethics.ts +107 -0
  103. package/packages/core/src/services/images.ts +212 -0
  104. package/packages/core/src/services/index.ts +112 -0
  105. package/packages/core/src/services/mcp.ts +201 -0
  106. package/packages/core/src/services/memory.ts +133 -0
  107. package/packages/core/src/services/models.ts +179 -0
  108. package/packages/core/src/services/providers.ts +152 -0
  109. package/packages/core/src/services/setup.ts +222 -0
  110. package/packages/core/src/services/skills.ts +241 -0
  111. package/packages/core/src/services/swarms.ts +307 -0
  112. package/packages/core/src/services/tools.ts +106 -0
  113. package/packages/core/src/sessions/index.ts +268 -0
  114. package/packages/core/src/sessions/resolve.ts +108 -0
  115. package/packages/core/src/skills/SkillLoader.ts +8 -1
  116. package/packages/core/src/skills/bundled/artifacts/artifact_reader/SKILL.md +105 -0
  117. package/packages/core/src/skills/bundled/cron_manager/SKILL.md +21 -11
  118. package/packages/core/src/skills/bundled/images/image_editor/SKILL.md +120 -0
  119. package/packages/core/src/skills/bundled/web/browser_automate/SKILL.md +12 -3
  120. package/packages/core/src/skills/bundled/web/browser_scrape/SKILL.md +22 -7
  121. package/packages/core/src/skills/bundled-data.generated.ts +110 -12
  122. package/packages/core/src/storage/bootstrap.ts +107 -12
  123. package/packages/core/src/storage/causal-events.ts +1 -1
  124. package/packages/core/src/storage/collections.ts +138 -2
  125. package/packages/core/src/storage/crypto.ts +35 -4
  126. package/packages/core/src/storage/hive.ts +1 -1
  127. package/packages/core/src/storage/hivedb.ts +10 -1
  128. package/packages/core/src/storage/index.ts +2 -1
  129. package/packages/core/src/storage/onboarding.ts +61 -45
  130. package/packages/core/src/storage/reconcile.ts +11 -6
  131. package/packages/core/src/storage/seed.ts +191 -23
  132. package/packages/core/src/storage/usage.ts +3 -3
  133. package/packages/core/src/swarm/AgentExecutor.ts +2 -2
  134. package/packages/core/src/swarm/Coordinator.ts +8 -8
  135. package/packages/core/src/swarm/EventBridge.ts +2 -2
  136. package/packages/core/src/swarm/RoleSwarm.ts +234 -0
  137. package/packages/core/src/swarm/TaskGraph.ts +2 -2
  138. package/packages/core/src/swarm/index.ts +7 -0
  139. package/packages/core/src/swarm/presets/HiveLearnPreset.ts +2 -2
  140. package/packages/core/src/swarm/presets/ResearchPreset.ts +2 -2
  141. package/packages/core/src/swarm/strategies/ParallelStrategy.ts +1 -1
  142. package/packages/core/src/swarm/strategies/PriorityStrategy.ts +3 -3
  143. package/packages/core/src/swarm/types.ts +3 -18
  144. package/packages/core/src/tool-runtime/embedded-worker.generated.ts +21 -0
  145. package/packages/core/src/tool-runtime/index.ts +129 -14
  146. package/packages/core/src/tools/ToolExecutor.ts +7 -3
  147. package/packages/core/src/tools/agents/index.ts +18 -60
  148. package/packages/core/src/tools/cli/index.ts +55 -0
  149. package/packages/core/src/tools/core/index.ts +52 -4
  150. package/packages/core/src/tools/cron/index.ts +8 -8
  151. package/packages/core/src/tools/images/index.ts +130 -0
  152. package/packages/core/src/tools/index.ts +14 -1
  153. package/packages/core/src/tools/office/office-escribir-xlsx.ts +2 -1
  154. package/packages/core/src/tools/office/office-leer-xlsx.ts +2 -1
  155. package/packages/core/src/tools/office/xlsx-loader.ts +19 -0
  156. package/packages/core/src/tools/web/artifact-inspect.ts +2 -2
  157. package/packages/core/src/tools/web/artifact-read.ts +162 -0
  158. package/packages/core/src/tools/web/browser-backend.ts +141 -44
  159. package/packages/core/src/tools/web/browser-click.ts +2 -2
  160. package/packages/core/src/tools/web/browser-extract.ts +2 -2
  161. package/packages/core/src/tools/web/browser-navigate.ts +2 -2
  162. package/packages/core/src/tools/web/browser-screenshot.ts +12 -5
  163. package/packages/core/src/tools/web/browser-script.ts +2 -2
  164. package/packages/core/src/tools/web/browser-service.ts +63 -384
  165. package/packages/core/src/tools/web/browser-session.ts +125 -0
  166. package/packages/core/src/tools/web/browser-type.ts +2 -2
  167. package/packages/core/src/tools/web/browser-wait.ts +2 -2
  168. package/packages/core/src/tools/web/computer-use.ts +553 -0
  169. package/packages/core/src/tools/web/index.ts +8 -1
  170. package/packages/core/src/tools/web/webview-backend.ts +460 -21
  171. package/packages/core/src/utils/index.ts +1 -0
  172. package/packages/core/src/utils/logger.ts +12 -4
  173. package/packages/core/src/utils/redact-binary.ts +17 -0
  174. package/packages/core/src/utils/toon.ts +1 -1
  175. package/packages/core/src/voice/index.ts +6 -6
  176. package/bun.lock +0 -859
  177. package/bunfig.toml +0 -9
  178. package/docs/API-AGENTS.md +0 -367
  179. package/docs/API-CONTEXT-COMPILER.md +0 -249
  180. package/docs/API-DAG-SCHEDULER.md +0 -273
  181. package/docs/API-TOOLS-SKILLS-CHANNELS.md +0 -446
  182. package/docs/API-WORKERS-EVENTS.md +0 -299
  183. package/docs/HIVE-HARNESS.md +0 -113
  184. package/docs/INDEX.md +0 -190
  185. package/docs/TEMPLATE-HIVE-APP.md +0 -360
  186. package/packages/cli/package.json +0 -17
  187. package/packages/cli/src/commands/create-app.test.ts +0 -180
  188. package/packages/core/package.json +0 -70
  189. package/packages/core/src/api/createAgent.test.ts +0 -160
  190. package/packages/core/src/canvas/canvas.test.ts +0 -36
  191. package/packages/core/src/channels/channels.test.ts +0 -18
  192. package/packages/core/src/ethics/EthicsGuard.test.ts +0 -108
  193. package/packages/core/src/gateway/gateway.test.ts +0 -38
  194. package/packages/core/src/memory/Scratchpad.test.ts +0 -68
  195. package/packages/core/src/scheduler/scheduler.test.ts +0 -15
  196. package/packages/core/src/skills/skills.test.ts +0 -62
  197. package/packages/core/src/swarm/swarm.test.ts +0 -24
  198. package/packages/core/src/tool-runtime/tool-runtime.test.ts +0 -99
  199. package/packages/core/src/tools/ToolRegistry.test.ts +0 -98
  200. package/packages/core/src/tools/api/api-request.test.ts +0 -164
  201. package/packages/core/src/tools/web/browser-service.test.ts +0 -83
  202. package/packages/core/src/workers/workers.test.ts +0 -41
  203. package/scripts/bump-version.ts +0 -248
  204. package/scripts/generate-skill-bundle.ts +0 -108
  205. package/test/acceptance-checks.test.ts +0 -403
  206. package/test/agent-loop-terminal-synthesis.test.ts +0 -32
  207. package/test/browser-backend.test.ts +0 -308
  208. package/test/catalog-agents-stay-enabled.test.ts +0 -117
  209. package/test/causal-events.test.ts +0 -117
  210. package/test/compaction.test.ts +0 -105
  211. package/test/context-compiler.test.ts +0 -269
  212. package/test/curator.test.ts +0 -130
  213. package/test/durable-queue.test.ts +0 -114
  214. package/test/harness-barrel.test.ts +0 -64
  215. package/test/hive-helpers.test.ts +0 -130
  216. package/test/hivedb-search.test.ts +0 -189
  217. package/test/internal-turns.test.ts +0 -166
  218. package/test/job-idempotency.test.ts +0 -68
  219. package/test/job-retry-backoff.test.ts +0 -184
  220. package/test/job-store.test.ts +0 -381
  221. package/test/llm-retry.test.ts +0 -97
  222. package/test/memory-perf.test.ts +0 -774
  223. package/test/minimal-loadout.test.ts +0 -78
  224. package/test/model-catalog.test.ts +0 -105
  225. package/test/preload.ts +0 -12
  226. package/test/reflector.test.ts +0 -320
  227. package/test/retention-cap.test.ts +0 -91
  228. package/test/retired-capabilities-pruned.test.ts +0 -192
  229. package/test/run-store.test.ts +0 -355
  230. package/test/scratchpad.test.ts +0 -74
  231. package/test/secrets-durability.test.ts +0 -119
  232. package/test/seed-model-reseed.test.ts +0 -155
  233. package/test/setup-agent-seed.test.ts +0 -264
  234. package/test/tool-inventory.test.ts +0 -65
  235. package/test/tool-runtime.test.ts +0 -258
  236. package/test/tool-selector-runtime-tools.test.ts +0 -117
  237. package/test/toon.test.ts +0 -429
  238. package/tsconfig.json +0 -42
@@ -11,26 +11,26 @@
11
11
  * Public interface (LLMMessage, callLLM, resolveProviderConfig) is stable.
12
12
  */
13
13
 
14
- import { logger } from "../utils/logger"
15
- import { loadConfig } from "../config/loader"
16
- import { withRetry, isRetryableError, type RetryPolicy } from "../resilience/retry"
17
- import { GeminiProvider } from "./llm-providers/gemini"
18
- import { AnthropicProvider } from "./llm-providers/anthropic"
19
- import { OllamaProvider } from "./llm-providers/ollama"
20
- import { OpenAIProvider } from "./llm-providers/openai"
21
- import { GroqProvider } from "./llm-providers/groq"
22
- import { MistralProvider } from "./llm-providers/mistral"
23
- import { OpenRouterProvider } from "./llm-providers/openrouter"
24
- import { DeepSeekProvider } from "./llm-providers/deepseek"
25
- import { KimiProvider } from "./llm-providers/kimi"
26
- import { NvidiaProvider } from "./llm-providers/nvidia"
27
- import { QwenProvider } from "./llm-providers/qwen"
28
- import { MiniMaxProvider } from "./llm-providers/minimax"
29
- import { OpenCodeGoProvider } from "./llm-providers/opencode-go"
30
- import { HiveAgentsProvider } from "./llm-providers/hiveagents"
31
- import { ZaiProvider } from "./llm-providers/z-ai"
32
- import { ModelScopeProvider } from "./llm-providers/modelscope"
33
- import type { LLMProvider } from "./llm-providers/interface"
14
+ import { logger } from "../utils/logger.ts"
15
+ import { loadConfig } from "../config/loader.ts"
16
+ import { withRetry, isRetryableError, type RetryPolicy } from "../resilience/retry.ts"
17
+ import { GeminiProvider } from "./llm-providers/gemini.ts"
18
+ import { AnthropicProvider } from "./llm-providers/anthropic.ts"
19
+ import { OllamaProvider } from "./llm-providers/ollama.ts"
20
+ import { OpenAIProvider } from "./llm-providers/openai.ts"
21
+ import { GroqProvider } from "./llm-providers/groq.ts"
22
+ import { MistralProvider } from "./llm-providers/mistral.ts"
23
+ import { OpenRouterProvider } from "./llm-providers/openrouter.ts"
24
+ import { DeepSeekProvider } from "./llm-providers/deepseek.ts"
25
+ import { KimiProvider } from "./llm-providers/kimi.ts"
26
+ import { NvidiaProvider } from "./llm-providers/nvidia.ts"
27
+ import { QwenProvider } from "./llm-providers/qwen.ts"
28
+ import { MiniMaxProvider } from "./llm-providers/minimax.ts"
29
+ import { OpenCodeGoProvider } from "./llm-providers/opencode-go.ts"
30
+ import { HiveAgentsProvider } from "./llm-providers/hiveagents.ts"
31
+ import { ZaiProvider } from "./llm-providers/z-ai.ts"
32
+ import { ModelScopeProvider } from "./llm-providers/modelscope.ts"
33
+ import type { LLMProvider } from "./llm-providers/interface.ts"
34
34
 
35
35
  const log = logger.child("llm-client")
36
36
 
@@ -182,20 +182,55 @@ export async function callLLM(options: LLMCallOptions): Promise<LLMResponse> {
182
182
  const cleanModel = options.model.replace(new RegExp(`^${options.provider}\\/`), "")
183
183
  const status = extractErrorStatus(err)
184
184
  const modelUnavailable = status === 404 || status === 410
185
- const msg = modelUnavailable
186
- ? `El modelo "${cleanModel}" ya no existe en ${options.provider} (HTTP ${status}). `
187
- + `El proveedor lo retiró de su catálogo; reintentar no sirve. `
188
- + `Elegí otro modelo en Ajustes → Proveedores.`
189
- : (err as Error).message
185
+ const msg = describeProviderFailure(err, status, options.provider, cleanModel)
190
186
  log.error(`[llm-client] Error calling ${options.provider}/${cleanModel}: ${msg}`, err)
191
187
  return {
192
- content: `[LLM Error] ${msg}`,
188
+ content: `${LLM_ERROR_PREFIX} ${msg}`,
193
189
  stop_reason: "error",
194
190
  error: { message: msg, status, modelUnavailable },
195
191
  }
196
192
  }
197
193
  }
198
194
 
195
+ /**
196
+ * Marks content that is a provider failure rather than something the agent
197
+ * said. webchat-turn.ts keys the failed-turn UI off this prefix, and
198
+ * agent-loop.ts keeps such content out of the conversation history.
199
+ */
200
+ export const LLM_ERROR_PREFIX = "[LLM Error]"
201
+
202
+ /**
203
+ * Turns a provider failure into something the user can act on.
204
+ *
205
+ * The raw SDK text is written for whoever is reading a stack trace: "429 status
206
+ * code (no body)" showed up verbatim in the chat and says nothing about what
207
+ * happened (the account hit its rate limit) or what to do about it. Statuses
208
+ * without a specific mapping keep the original message — a wrong explanation is
209
+ * worse than a technical one.
210
+ */
211
+ export function describeProviderFailure(
212
+ err: unknown,
213
+ status: number | undefined,
214
+ provider: string,
215
+ cleanModel: string,
216
+ ): string {
217
+ if (status === 404 || status === 410) {
218
+ return `El modelo "${cleanModel}" ya no existe en ${provider} (HTTP ${status}). `
219
+ + `El proveedor lo retiró de su catálogo; reintentar no sirve. `
220
+ + `Elige otro modelo en Ajustes → Proveedores.`
221
+ }
222
+ if (status === 429) {
223
+ return `${provider} está limitando las peticiones (HTTP 429) y los reintentos tampoco pasaron. `
224
+ + `Es el límite de uso de tu cuenta, no un problema del modelo: esperá unos minutos, `
225
+ + `revisa tu cuota con el proveedor, o cambiá de modelo en Ajustes → Proveedores.`
226
+ }
227
+ if (status === 401 || status === 403) {
228
+ return `${provider} rechazó la API key (HTTP ${status}). `
229
+ + `Revisa que siga siendo válida y tenga acceso a "${cleanModel}" en Ajustes → Proveedores.`
230
+ }
231
+ return (err as Error).message
232
+ }
233
+
199
234
  /** Provider SDKs disagree on where the HTTP status lands; check every shape we've seen. */
200
235
  function extractErrorStatus(err: unknown): number | undefined {
201
236
  const e = err as { status?: number; statusCode?: number; response?: { status?: number } }
@@ -208,10 +243,10 @@ function extractErrorStatus(err: unknown): number | undefined {
208
243
  * Returns null when the DB has no usable LLM (e.g. fresh install before setup).
209
244
  */
210
245
  export async function getDefaultLLM(): Promise<{ provider: string; model: string } | null> {
211
- const { col, fromIndexable } = await import("../storage/hive")
212
- const agentsCol = await col<import("../storage/collections").AgentDoc>("agents")
213
- const modelsCol = await col<import("../storage/collections").ModelDoc>("models")
214
- const providersCol = await col<import("../storage/collections").ProviderDoc>("providers")
246
+ const { col, fromIndexable } = await import("../storage/hive.ts")
247
+ const agentsCol = await col<import("../storage/collections.ts").AgentDoc>("agents")
248
+ const modelsCol = await col<import("../storage/collections.ts").ModelDoc>("models")
249
+ const providersCol = await col<import("../storage/collections.ts").ProviderDoc>("providers")
215
250
 
216
251
  const coordinators = await agentsCol.findBy("role", "coordinator")
217
252
  const coordinator = coordinators[0]
@@ -234,14 +269,34 @@ export async function getDefaultLLM(): Promise<{ provider: string; model: string
234
269
  /**
235
270
  * Resolve provider config from DB (decrypts API key).
236
271
  */
272
+ /**
273
+ * Credenciales y endpoint resueltos por quien llama, en vez de por el proceso.
274
+ *
275
+ * Sin esto, la única forma de elegir la key era el secret store de HiveDB o
276
+ * `process.env[PROVIDER_API_KEY]` — ambos globales al proceso. Un backend
277
+ * multi-tenant no tiene un proceso por inquilino: o mutaba `process.env` antes
278
+ * de cada llamada (y entonces dos workspaces concurrentes se pisaban la key), o
279
+ * levantaba un proceso por llamada sólo para aislarla. Pasar la credencial en la
280
+ * llamada elimina las dos cosas.
281
+ *
282
+ * Todo es opcional: lo que no venga acá se resuelve como siempre.
283
+ */
284
+ export interface ProviderCredentials {
285
+ /** Reemplaza la key del secret store y la de `process.env`. */
286
+ apiKey?: string
287
+ /** Reemplaza el `base_url` de la fila del proveedor. */
288
+ baseUrl?: string
289
+ }
290
+
237
291
  export async function resolveProviderConfig(
238
292
  providerId: string,
239
- modelId: string
293
+ modelId: string,
294
+ credentials?: ProviderCredentials
240
295
  ): Promise<Pick<LLMCallOptions, "provider" | "model" | "apiKey" | "baseUrl" | "numCtx" | "numGpu" | "contextWindow">> {
241
- const { col } = await import("../storage/hive")
242
- const { loadProviderApiKey } = await import("../storage/crypto")
243
- const providersCol = await col<import("../storage/collections").ProviderDoc>("providers")
244
- const modelsCol = await col<import("../storage/collections").ModelDoc>("models")
296
+ const { col } = await import("../storage/hive.ts")
297
+ const { loadProviderApiKey } = await import("../storage/crypto.ts")
298
+ const providersCol = await col<import("../storage/collections.ts").ProviderDoc>("providers")
299
+ const modelsCol = await col<import("../storage/collections.ts").ModelDoc>("models")
245
300
 
246
301
  const providerEntry = await providersCol.get(providerId)
247
302
  const providerRow = (providerEntry?.doc.enabled && providerEntry?.doc.active) ? providerEntry.doc : undefined
@@ -249,7 +304,13 @@ export async function resolveProviderConfig(
249
304
  // Load model's context window for token budget management
250
305
  const modelEntry = await modelsCol.get(modelId)
251
306
 
252
- let apiKey = await loadProviderApiKey(providerId)
307
+ // La credencial de la llamada gana, y corta acá: si vino una key explícita no
308
+ // se consulta el secret store ni el entorno, así que un fallback global no
309
+ // puede colarse en el lugar de la del inquilino.
310
+ let apiKey = credentials?.apiKey
311
+ if (!apiKey) {
312
+ apiKey = await loadProviderApiKey(providerId)
313
+ }
253
314
  if (!apiKey) {
254
315
  apiKey = process.env[`${providerId.toUpperCase()}_API_KEY`] || ""
255
316
  }
@@ -258,7 +319,7 @@ export async function resolveProviderConfig(
258
319
  provider: providerId,
259
320
  model: modelId,
260
321
  apiKey,
261
- baseUrl: providerRow?.base_url || undefined,
322
+ baseUrl: credentials?.baseUrl || providerRow?.base_url || undefined,
262
323
  numCtx: providerRow?.num_ctx ?? undefined,
263
324
  numGpu: providerRow?.num_gpu ?? undefined,
264
325
  contextWindow: modelEntry?.doc.context_window ?? undefined,
@@ -1,7 +1,7 @@
1
- import { logger } from "../../utils/logger"
2
- import { normalizeToolName, resolveMaxTokens, ensureArrayItems } from "./interface"
3
- import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall, ThinkingBlock } from "./interface"
4
- import type { ContentPart, LLMMessage } from "../llm-client"
1
+ import { logger } from "../../utils/logger.ts"
2
+ import { normalizeToolName, resolveMaxTokens, ensureArrayItems } from "./interface.ts"
3
+ import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall, ThinkingBlock } from "./interface.ts"
4
+ import type { ContentPart, LLMMessage } from "../llm-client.ts"
5
5
 
6
6
  const log = logger.child("llm-client")
7
7
 
@@ -1,4 +1,4 @@
1
- import { OpenAICompatBase } from "./openai-compat-base"
1
+ import { OpenAICompatBase } from "./openai-compat-base.ts"
2
2
 
3
3
  export class DeepSeekProvider extends OpenAICompatBase {
4
4
  constructor() { super("deepseek") }
@@ -1,7 +1,7 @@
1
- import { logger } from "../../utils/logger"
2
- import { sanitizeMessages, resolveMaxTokens, ensureArrayItems } from "./interface"
3
- import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall } from "./interface"
4
- import type { ContentPart, LLMMessage } from "../llm-client"
1
+ import { logger } from "../../utils/logger.ts"
2
+ import { sanitizeMessages, resolveMaxTokens, ensureArrayItems } from "./interface.ts"
3
+ import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall } from "./interface.ts"
4
+ import type { ContentPart, LLMMessage } from "../llm-client.ts"
5
5
 
6
6
  const log = logger.child("llm-client")
7
7
 
@@ -1,4 +1,4 @@
1
- import { OpenAICompatBase } from "./openai-compat-base"
1
+ import { OpenAICompatBase } from "./openai-compat-base.ts"
2
2
 
3
3
  export class GroqProvider extends OpenAICompatBase {
4
4
  constructor() { super("groq") }
@@ -1,6 +1,6 @@
1
- import { logger } from "../../utils/logger"
2
- import { OpenAICompatBase } from "./openai-compat-base"
3
- import type { LLMCallOptions, LLMResponse } from "./interface"
1
+ import { logger } from "../../utils/logger.ts"
2
+ import { OpenAICompatBase } from "./openai-compat-base.ts"
3
+ import type { LLMCallOptions, LLMResponse } from "./interface.ts"
4
4
 
5
5
  const log = logger.child("llm-client")
6
6
 
@@ -2,10 +2,10 @@
2
2
  * Shared types and utilities for LLM providers.
3
3
  */
4
4
 
5
- import type { LLMCallOptions, LLMMessage, LLMResponse, LLMToolCall, ContentPart, ThinkingBlock } from "../llm-client"
5
+ import type { LLMCallOptions, LLMMessage, LLMResponse, LLMToolCall, ContentPart, ThinkingBlock } from "../llm-client.ts"
6
6
  export type { LLMCallOptions, LLMMessage, LLMResponse, LLMToolCall, ContentPart, ThinkingBlock }
7
7
 
8
- import { logger } from "../../utils/logger"
8
+ import { logger } from "../../utils/logger.ts"
9
9
  const log = logger.child("llm-client")
10
10
 
11
11
  // ─── Provider interface ────────────────────────────────────────────────────────
@@ -1,4 +1,4 @@
1
- import { OpenAICompatBase } from "./openai-compat-base"
1
+ import { OpenAICompatBase } from "./openai-compat-base.ts"
2
2
 
3
3
  export class KimiProvider extends OpenAICompatBase {
4
4
  constructor() { super("kimi") }
@@ -1,4 +1,4 @@
1
- import { OpenAICompatBase } from "./openai-compat-base"
1
+ import { OpenAICompatBase } from "./openai-compat-base.ts"
2
2
 
3
3
  export class MiniMaxProvider extends OpenAICompatBase {
4
4
  static readonly secretKey = "MINIMAX_API_KEY"
@@ -1,4 +1,4 @@
1
- import { OpenAICompatBase } from "./openai-compat-base"
1
+ import { OpenAICompatBase } from "./openai-compat-base.ts"
2
2
 
3
3
  export class MistralProvider extends OpenAICompatBase {
4
4
  constructor() { super("mistral") }
@@ -1,4 +1,4 @@
1
- import { OpenAICompatBase } from "./openai-compat-base"
1
+ import { OpenAICompatBase } from "./openai-compat-base.ts"
2
2
 
3
3
  export class ModelScopeProvider extends OpenAICompatBase {
4
4
  constructor() { super("modelscope") }
@@ -1,5 +1,44 @@
1
- import { OpenAICompatBase } from "./openai-compat-base"
1
+ import { OpenAICompatBase } from "./openai-compat-base.ts"
2
+ import type { LLMCallOptions } from "../llm-client.ts"
3
+
4
+ /**
5
+ * NIM mantiene el razonamiento APAGADO por defecto en su endpoint compatible
6
+ * con OpenAI, y el interruptor no es el `reasoning_effort` de OpenAI sino
7
+ * `chat_template_kwargs`, con una clave distinta por familia de modelo.
8
+ *
9
+ * Verificado en vivo contra integrate.api.nvidia.com (2026-08-15):
10
+ * `z-ai/glm-5.2` no emite un solo `reasoning_content` sin esto — el delta trae
11
+ * únicamente `role` y `content` — y sí lo emite con `enable_thinking`. Por eso
12
+ * la app no mostraba razonamiento con los modelos de NVIDIA: no llegaba.
13
+ *
14
+ * Las familias que no están acá se dejan en paz a propósito. Nemotron 3 ya
15
+ * emite `reasoning_content` sin ningún kwarg (también verificado), y mandarle
16
+ * una clave que su plantilla no acepta es justamente lo que rompe la llamada.
17
+ */
18
+ const THINKING_KWARGS: Array<{ pattern: RegExp; kwargs: Record<string, unknown> }> = [
19
+ // Verificado con z-ai/glm-5.2.
20
+ { pattern: /glm/i, kwargs: { enable_thinking: true, clear_thinking: false } },
21
+ // Verificado con minimaxai/minimax-m3.
22
+ { pattern: /minimax/i, kwargs: { thinking_mode: "enabled" } },
23
+ // Documentado por NVIDIA, sin verificar: moonshotai/kimi-k2.6 responde 404
24
+ // en la cuenta con la que se probó. Si la plantilla lo rechaza, el reintento
25
+ // de openai-compat-base repite la llamada sin kwargs.
26
+ { pattern: /kimi|deepseek/i, kwargs: { thinking: true } },
27
+ { pattern: /qwen|qwq/i, kwargs: { enable_thinking: true } },
28
+ ]
2
29
 
3
30
  export class NvidiaProvider extends OpenAICompatBase {
4
31
  constructor() { super("nvidia") }
32
+
33
+ protected modifyRequestBody(body: any, options: LLMCallOptions): any {
34
+ if (!options.thinking?.enabled) return body
35
+
36
+ const match = THINKING_KWARGS.find(({ pattern }) => pattern.test(options.model))
37
+ if (!match) return body
38
+
39
+ return {
40
+ ...body,
41
+ chat_template_kwargs: { ...(body.chat_template_kwargs ?? {}), ...match.kwargs },
42
+ }
43
+ }
5
44
  }
@@ -1,7 +1,7 @@
1
- import { logger } from "../../utils/logger"
2
- import { sanitizeMessages, resolveMaxTokens, ensureArrayItems } from "./interface"
3
- import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall } from "./interface"
4
- import type { ContentPart, LLMMessage } from "../llm-client"
1
+ import { logger } from "../../utils/logger.ts"
2
+ import { sanitizeMessages, resolveMaxTokens, ensureArrayItems } from "./interface.ts"
3
+ import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall } from "./interface.ts"
4
+ import type { ContentPart, LLMMessage } from "../llm-client.ts"
5
5
 
6
6
  const log = logger.child("llm-client")
7
7
 
@@ -1,11 +1,37 @@
1
- import { logger } from "../../utils/logger"
1
+ import { logger } from "../../utils/logger.ts"
2
2
  import {
3
3
  sanitizeMessages, requiresTemperature1, OPENAI_COMPAT_BASE_URLS,
4
4
  getProviderProfile, modelSupportsTools, normalizeToolName, normalizeToolSchema,
5
5
  resolveMaxTokens,
6
- } from "./interface"
7
- import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall } from "./interface"
8
- import type { ContentPart, LLMMessage } from "../llm-client"
6
+ } from "./interface.ts"
7
+ import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall } from "./interface.ts"
8
+ import type { ContentPart, LLMMessage } from "../llm-client.ts"
9
+
10
+ /**
11
+ * Statuses that mean "this body is malformed or unsupported" — the only ones
12
+ * where dropping the provider extras can possibly help.
13
+ *
14
+ * Deliberately narrow. A first version retried on any status and immediately
15
+ * misfired in production against a 429: a rate limit is not a body problem, so
16
+ * the retry spent a second request against the very limit that rejected the
17
+ * first, and turned thinking off for nothing. Same for 401/403 (auth) and 404
18
+ * (unknown model).
19
+ */
20
+ const EXTRAS_REJECTED_CODES = [400, 422]
21
+
22
+ /**
23
+ * Drops the non-standard fields a provider added on top of an OpenAI-shaped
24
+ * body, for use on a retry after the body itself was rejected. Right now that
25
+ * is only NIM's `chat_template_kwargs` (nvidia.ts): it enables the model's
26
+ * thinking, so losing it costs the reasoning display and nothing else — a far
27
+ * better outcome than a turn that dies because one model's chat template did
28
+ * not recognize the switch.
29
+ */
30
+ function stripProviderExtras(body: any): any {
31
+ if (!body?.chat_template_kwargs) return body
32
+ const { chat_template_kwargs: _dropped, ...rest } = body
33
+ return rest
34
+ }
9
35
 
10
36
  const log = logger.child("llm-client")
11
37
 
@@ -44,7 +70,11 @@ function compactBodyForContextOverflow(body: any, err: any): void {
44
70
  }
45
71
 
46
72
  export abstract class OpenAICompatBase implements LLMProvider {
47
- constructor(protected readonly providerName: string) {}
73
+ protected readonly providerName: string
74
+
75
+ constructor(providerName: string) {
76
+ this.providerName = providerName
77
+ }
48
78
 
49
79
  /** Override to true when the provider requires reasoning_content to be round-tripped. */
50
80
  protected needsReasoningRoundtrip(): boolean { return false }
@@ -183,7 +213,14 @@ export abstract class OpenAICompatBase implements LLMProvider {
183
213
  delete bodyNoTools.tools
184
214
  delete bodyNoTools.tool_choice
185
215
  delete bodyNoTools.parallel_tool_calls
186
- response = await client.chat.completions.create(this.modifyRequestBody(bodyNoTools, options), { signal: options.signal })
216
+ response = await client.chat.completions.create(stripProviderExtras(this.modifyRequestBody(bodyNoTools, options)), { signal: options.signal })
217
+ }
218
+ // Retry 3: the provider-specific extras are the only other thing we added
219
+ // to an otherwise standard body (NIM's chat_template_kwargs — see
220
+ // nvidia.ts). Losing the reasoning display beats failing the turn.
221
+ else if (EXTRAS_REJECTED_CODES.includes(status) && this.modifyRequestBody(body, options).chat_template_kwargs) {
222
+ log.warn(`[llm-client] ${this.providerName}: request rejected (HTTP ${status}) — retrying without chat_template_kwargs`)
223
+ response = await client.chat.completions.create(stripProviderExtras(this.modifyRequestBody(body, options)), { signal: options.signal })
187
224
  }
188
225
  else {
189
226
  throw err
@@ -254,7 +291,10 @@ export abstract class OpenAICompatBase implements LLMProvider {
254
291
  delete bodyNoTools.tools
255
292
  delete bodyNoTools.tool_choice
256
293
  delete bodyNoTools.parallel_tool_calls
257
- stream = await client.chat.completions.create({ ...this.modifyRequestBody(bodyNoTools, options), stream: true }, { signal: options.signal })
294
+ stream = await client.chat.completions.create({ ...stripProviderExtras(this.modifyRequestBody(bodyNoTools, options)), stream: true }, { signal: options.signal })
295
+ } else if (EXTRAS_REJECTED_CODES.includes(status) && this.modifyRequestBody(body, options).chat_template_kwargs) {
296
+ log.warn(`[llm-client] ${this.providerName}: request rejected (HTTP ${status}) — retrying stream without chat_template_kwargs`)
297
+ stream = await client.chat.completions.create({ ...stripProviderExtras(this.modifyRequestBody(body, options)), stream: true }, { signal: options.signal })
258
298
  } else {
259
299
  throw err
260
300
  }
@@ -1,4 +1,4 @@
1
- import { OpenAICompatBase } from "./openai-compat-base"
1
+ import { OpenAICompatBase } from "./openai-compat-base.ts"
2
2
 
3
3
  export class OpenAIProvider extends OpenAICompatBase {
4
4
  constructor() { super("openai") }
@@ -1,4 +1,4 @@
1
- import { OpenAICompatBase } from "./openai-compat-base"
1
+ import { OpenAICompatBase } from "./openai-compat-base.ts"
2
2
 
3
3
  export class OpenCodeGoProvider extends OpenAICompatBase {
4
4
  static readonly secretKey = "OPENCODE_GO_API_KEY"
@@ -1,4 +1,4 @@
1
- import { OpenAICompatBase } from "./openai-compat-base"
1
+ import { OpenAICompatBase } from "./openai-compat-base.ts"
2
2
 
3
3
  export class OpenRouterProvider extends OpenAICompatBase {
4
4
  constructor() { super("openrouter") }
@@ -1,4 +1,4 @@
1
- import { OpenAICompatBase } from "./openai-compat-base"
1
+ import { OpenAICompatBase } from "./openai-compat-base.ts"
2
2
 
3
3
  export class QwenProvider extends OpenAICompatBase {
4
4
  constructor() { super("qwen") }
@@ -1,4 +1,4 @@
1
- import { OpenAICompatBase } from "./openai-compat-base"
1
+ import { OpenAICompatBase } from "./openai-compat-base.ts"
2
2
 
3
3
  export class ZaiProvider extends OpenAICompatBase {
4
4
  constructor() { super("z-ai") }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * mcp-result-normalizer — keeps large/binary MCP tool results out of the LLM
3
+ * context window.
4
+ *
5
+ * `MCPClientManager.callTool()` (packages/mcp/src/manager.ts) returns the raw
6
+ * `content` array from the MCP SDK's CallToolResult. Per the MCP spec that
7
+ * array can contain `image`/`audio` blocks (base64 `data`) or `resource`
8
+ * blocks (base64 `blob`) alongside plain `text` blocks. Nothing downstream
9
+ * (agent-loop.ts's formatToolResult) inspects those blocks — a base64 image
10
+ * would be serialized whole into a `role:"tool"` message and sent to the
11
+ * model, which is exactly what filled the context window and hung the agent
12
+ * in the incident this module fixes.
13
+ *
14
+ * This mirrors the pattern already used by browser-screenshot.ts: binary
15
+ * content gets persisted via createArtifact() (artifacts/store.ts) and the
16
+ * model only ever sees a lightweight { type: "artifact_ref", ... } reference.
17
+ */
18
+
19
+ import { createArtifact } from "../artifacts/store.ts";
20
+ import { logger } from "../utils/logger.ts";
21
+
22
+ const log = logger.child("mcp-result-normalizer");
23
+
24
+ /** Blocks longer than this (in chars) get materialized as a text artifact instead of inlined. Override via HIVE_MCP_INLINE_MAX_CHARS. */
25
+ const MCP_INLINE_MAX_CHARS = Number(process.env.HIVE_MCP_INLINE_MAX_CHARS) || 20_000;
26
+
27
+ export interface McpNormalizeContext {
28
+ userId?: string;
29
+ runId?: string | null;
30
+ taskId?: string | null;
31
+ }
32
+
33
+ type McpContentBlock = Record<string, unknown> & { type?: unknown };
34
+
35
+ function isBinaryBlock(block: McpContentBlock): block is McpContentBlock & { type: "image" | "audio"; data: string; mimeType: string } {
36
+ return (block.type === "image" || block.type === "audio") && typeof block.data === "string";
37
+ }
38
+
39
+ function isBlobResourceBlock(block: McpContentBlock): block is McpContentBlock & { type: "resource"; resource: { blob: string; mimeType?: string; uri?: string } } {
40
+ if (block.type !== "resource") return false;
41
+ const resource = block.resource as Record<string, unknown> | undefined;
42
+ return !!resource && typeof resource.blob === "string";
43
+ }
44
+
45
+ function isOversizedTextBlock(block: McpContentBlock): block is McpContentBlock & { type: "text"; text: string } {
46
+ return block.type === "text" && typeof block.text === "string" && block.text.length > MCP_INLINE_MAX_CHARS;
47
+ }
48
+
49
+ async function materializeBinary(
50
+ bytesBase64: string,
51
+ mimeType: string,
52
+ kind: string,
53
+ ctx: McpNormalizeContext,
54
+ ): Promise<Record<string, unknown>> {
55
+ if (!ctx.userId) {
56
+ // Without a user_id we can't set ownership on the artifact (inspectArtifact
57
+ // would reject any read against it later) — never let raw base64 through,
58
+ // just describe what was omitted.
59
+ const approxBytes = Math.floor((bytesBase64.length * 3) / 4);
60
+ log.warn(`[materializeBinary] No user_id in tool context — omitting ${mimeType} block (${approxBytes} bytes) instead of creating an artifact`);
61
+ return { type: "artifact_omitted", mime_type: mimeType, approx_size: approxBytes, reason: "no user_id in tool context" };
62
+ }
63
+
64
+ const bytes = Buffer.from(bytesBase64, "base64");
65
+ const artifact = await createArtifact({
66
+ bytes,
67
+ mimeType,
68
+ kind,
69
+ userId: ctx.userId,
70
+ runId: ctx.runId ?? null,
71
+ taskId: ctx.taskId ?? null,
72
+ });
73
+ log.info(`[materializeBinary] Stored ${kind} as artifact ${artifact.id} (${artifact.size} bytes, ${artifact.mime_type})`);
74
+ return {
75
+ type: "artifact_ref",
76
+ artifact_id: artifact.id,
77
+ mime_type: artifact.mime_type,
78
+ size: artifact.size,
79
+ sha256: artifact.sha256,
80
+ expires_at: artifact.expires_at,
81
+ };
82
+ }
83
+
84
+ /** Above this, parsing the text just to describe it costs more than it explains. */
85
+ const MAX_SHAPE_PROBE_CHARS = 5_000_000;
86
+
87
+ /**
88
+ * Describes the shape of a JSON payload so the model can aim `artifact_read`
89
+ * instead of paging blind.
90
+ *
91
+ * The 500-char preview alone is close to useless on the payload this was
92
+ * written for — a Gmail MCP result where the first message's `received`
93
+ * headers eat the whole window before a single subject line appears. Knowing
94
+ * "12 items, keyed id/threadId/labelIds/headers" is what turns a search into
95
+ * one call.
96
+ */
97
+ function describeJsonShape(text: string): Record<string, unknown> {
98
+ if (text.length > MAX_SHAPE_PROBE_CHARS) return {};
99
+ const trimmed = text.trimStart();
100
+ if (!trimmed.startsWith("[") && !trimmed.startsWith("{")) return {};
101
+
102
+ try {
103
+ const parsed = JSON.parse(text);
104
+ if (Array.isArray(parsed)) {
105
+ const first = parsed.find((item) => item && typeof item === "object" && !Array.isArray(item));
106
+ return {
107
+ json_items: parsed.length,
108
+ ...(first ? { json_item_keys: Object.keys(first as Record<string, unknown>).slice(0, 25) } : {}),
109
+ };
110
+ }
111
+ if (parsed && typeof parsed === "object") {
112
+ return { json_keys: Object.keys(parsed as Record<string, unknown>).slice(0, 25) };
113
+ }
114
+ } catch {
115
+ // Not JSON, or truncated JSON — the plain preview still stands.
116
+ }
117
+ return {};
118
+ }
119
+
120
+ async function materializeText(text: string, ctx: McpNormalizeContext): Promise<Record<string, unknown>> {
121
+ if (!ctx.userId) {
122
+ log.warn(`[materializeText] No user_id in tool context — truncating oversized text block (${text.length} chars) instead of creating an artifact`);
123
+ return { type: "text", text: `${text.slice(0, 500)}… [truncated: ${text.length} chars total, no user_id to persist full text as artifact]` };
124
+ }
125
+
126
+ const artifact = await createArtifact({
127
+ bytes: Buffer.from(text, "utf-8"),
128
+ mimeType: "text/plain",
129
+ kind: "mcp_text_result",
130
+ userId: ctx.userId,
131
+ runId: ctx.runId ?? null,
132
+ taskId: ctx.taskId ?? null,
133
+ });
134
+ log.info(`[materializeText] Stored oversized text result as artifact ${artifact.id} (${artifact.size} bytes)`);
135
+ return {
136
+ type: "artifact_ref",
137
+ artifact_id: artifact.id,
138
+ mime_type: "text/plain",
139
+ size: artifact.size,
140
+ chars: text.length,
141
+ preview: text.length > 500 ? `${text.slice(0, 500)}…` : text,
142
+ ...describeJsonShape(text),
143
+ // Without this the model only knows the data exists somewhere. It used to
144
+ // reach for artifact_inspect (metadata only), find nothing usable, and
145
+ // spend its remaining iterations guessing.
146
+ hint: "Full content is available via artifact_read (artifactId + offset/limit, or search).",
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Normalizes a raw MCP CallToolResult.content array: binary blocks (image,
152
+ * audio, resource-with-blob) become { type: "artifact_ref", ... }, oversized
153
+ * text blocks are persisted the same way with a short preview kept inline,
154
+ * everything else (text, resource_link) passes through unchanged.
155
+ *
156
+ * Never pre-stringifies — returns JS values so formatToolResult (toon.ts)
157
+ * keeps doing the actual TOON/JSON encoding, same contract context-compiler.ts
158
+ * already documents for MCP tool executors.
159
+ */
160
+ export async function normalizeMcpResult(content: unknown, ctx: McpNormalizeContext): Promise<unknown> {
161
+ if (!Array.isArray(content)) return content;
162
+
163
+ const out: unknown[] = [];
164
+ for (const raw of content) {
165
+ if (!raw || typeof raw !== "object") {
166
+ out.push(raw);
167
+ continue;
168
+ }
169
+ const block = raw as McpContentBlock;
170
+ try {
171
+ if (isBinaryBlock(block)) {
172
+ out.push(await materializeBinary(block.data, block.mimeType, "mcp_result", ctx));
173
+ continue;
174
+ }
175
+ if (isBlobResourceBlock(block)) {
176
+ const mimeType = block.resource.mimeType || "application/octet-stream";
177
+ out.push(await materializeBinary(block.resource.blob, mimeType, "mcp_result", ctx));
178
+ continue;
179
+ }
180
+ if (isOversizedTextBlock(block)) {
181
+ out.push(await materializeText(block.text, ctx));
182
+ continue;
183
+ }
184
+ } catch (err) {
185
+ log.error(`[normalizeMcpResult] Failed to materialize block (type=${String(block.type)}): ${(err as Error).message}`);
186
+ out.push({ type: "artifact_error", mime_type: (block as { mimeType?: string }).mimeType, error: (err as Error).message });
187
+ continue;
188
+ }
189
+ out.push(block);
190
+ }
191
+ return out;
192
+ }