@johpaz/hive-sdk 0.4.8 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -579,6 +579,29 @@
579
579
  job que genera un scaffold con `create-app` y lo typechequea contra el SDK de
580
580
  ese commit.
581
581
 
582
+ ## 0.4.9
583
+
584
+ ### Corregido
585
+
586
+ - **La compactación de historial dejaba de ser excepcional y corría casi en cada
587
+ turno.** `agent.context.compactionThreshold` es una proporción de la ventana
588
+ del modelo —su valor por defecto es `0.8`, o sea el 80 %— pero se leía como un
589
+ número de tokens: el umbral efectivo quedaba en 0.8 tokens. Cualquier hilo con
590
+ más de cinco mensajes se resumía en cada turno, lo que cuesta una llamada extra
591
+ al modelo por mensaje y reemplaza el historial por un resumen desde el primer
592
+ intercambio. Ahora un valor menor o igual a 1 se aplica sobre la ventana del
593
+ modelo y uno mayor se sigue leyendo como tokens, para quien fijó un número
594
+ absoluto. El umbral se calcula además con la ventana del modelo que corre el
595
+ turno, no con la del coordinador.
596
+ - **El resumen se pedía con una credencial global.** `compactThread` resolvía el
597
+ modelo con `getDefaultLLM()` y llamaba a `resolveProviderConfig` sin
598
+ credenciales, así que la llave salía del secret store, del llavero del sistema
599
+ o del entorno del proceso. En una instalación de un solo usuario da igual; en
600
+ una multi-inquilino significa resumir la conversación de un cliente con la
601
+ llave de la plataforma o de otro cliente. `maybeCompact` y `compactThread`
602
+ aceptan ahora el modelo y las credenciales del turno (`CompactionLLM`), y el
603
+ agent loop les pasa los suyos. Sin ese dato se comportan como antes.
604
+
582
605
  ## 0.1.5
583
606
 
584
607
  Sincronización del SDK con el runtime de agentes de `hive`. **Trae rupturas de
package/README.md CHANGED
@@ -271,4 +271,4 @@ npm view @johpaz/hive-sdk dist-tags # verificar después del release
271
271
 
272
272
  ---
273
273
 
274
- *Hive SDK v0.4.8 — MIT*
274
+ *Hive SDK v0.4.9 — MIT*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johpaz/hive-sdk",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "private": false,
5
5
  "description": "Hive SDK — The Agent Harness SDK. Build, deploy, and scale AI agent applications with multi-channel support, context engineering, and swarm orchestration.",
6
6
  "license": "MIT",
@@ -357,12 +357,20 @@ export async function* runAgent(
357
357
  channel: opts.channel,
358
358
  source: opts.historySource ?? "message",
359
359
  })
360
- // Run compaction if conversation history is getting large
360
+ // Run compaction if conversation history is getting large.
361
+ // El modelo del turno viaja con sus credenciales: el resumen es una llamada
362
+ // al modelo como cualquier otra y tiene que cobrarse a la misma cuenta.
361
363
  await maybeCompact(
362
364
  opts.threadId,
363
365
  opts.channel && opts.userId
364
366
  ? { channel: opts.channel, userId: opts.userId }
365
- : undefined
367
+ : undefined,
368
+ {
369
+ provider: providerCfg.provider,
370
+ model: providerCfg.model,
371
+ credentials: opts.credentials,
372
+ contextWindow: providerCfg.contextWindow,
373
+ }
366
374
  )
367
375
  }
368
376
 
@@ -26,7 +26,10 @@ import {
26
26
  type StoredMessage,
27
27
  } from "./conversation-store.ts"
28
28
  import { estimateTokens } from "../utils/toon.ts"
29
- import { callLLM, resolveProviderConfig, getDefaultLLM, type ContentPart } from "./llm-client.ts"
29
+ import {
30
+ callLLM, resolveProviderConfig, getDefaultLLM,
31
+ type ContentPart, type ProviderCredentials,
32
+ } from "./llm-client.ts"
30
33
  import { col, fromIndexable } from "../storage/hive.ts"
31
34
  import type { AgentDoc, ModelDoc } from "../storage/collections.ts"
32
35
  import { loadConfig } from "../config/loader.ts"
@@ -41,6 +44,61 @@ const KEEP_LAST_N_MESSAGES = 5 // always keep most recent N messages
41
44
  const TOOL_RESULT_MAX_CHARS = 200 // max chars for old tool results after clearing
42
45
  const MAX_TRANSCRIPT_MSGS = 30 // cap messages sent to summarizer (avoids OOM on small models)
43
46
  const MAX_MSG_CHARS = 300 // chars per message in transcript
47
+ /** Ventana asumida cuando no se conoce la del modelo: `COMPACT_TOKEN_THRESHOLD` es su 25 %. */
48
+ const ASSUMED_CONTEXT_WINDOW = 128_000
49
+ const DEFAULT_CONTEXT_RATIO = 0.25
50
+
51
+ /**
52
+ * El modelo con el que se pide el resumen: el del turno que disparó la
53
+ * compactación, con SUS credenciales.
54
+ */
55
+ export interface CompactionLLM {
56
+ provider?: string
57
+ model?: string
58
+ /** En multi-inquilino, la llave del cliente. Sin esto se usaría una global. */
59
+ credentials?: ProviderCredentials
60
+ /** La ventana del modelo, si quien llama ya la resolvió. */
61
+ contextWindow?: number
62
+ }
63
+
64
+ /**
65
+ * A partir de cuántos tokens de historial se compacta.
66
+ *
67
+ * `agent.context.compactionThreshold` es una PROPORCIÓN de la ventana del
68
+ * modelo —su valor por defecto es 0.8, o sea el 80 %—, pero se leía como si
69
+ * fueran tokens. Con la configuración por defecto el umbral quedaba en 0.8
70
+ * tokens: cualquier hilo con más de cinco mensajes se resumía en cada turno,
71
+ * pagando una llamada extra al modelo y reemplazando el historial por un
72
+ * resumen desde el primer intercambio. Un valor mayor que 1 se sigue leyendo
73
+ * como tokens, que es lo que espera quien fijó un número absoluto.
74
+ */
75
+ export function resolveCompactionThreshold(configured: number | undefined, contextWindow?: number): number {
76
+ const known = contextWindow && contextWindow > 0 ? contextWindow : undefined
77
+ if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
78
+ return configured <= 1
79
+ ? Math.floor((known ?? ASSUMED_CONTEXT_WINDOW) * configured)
80
+ : Math.floor(configured)
81
+ }
82
+ return known ? Math.floor(known * DEFAULT_CONTEXT_RATIO) : COMPACT_TOKEN_THRESHOLD
83
+ }
84
+
85
+ /** La ventana del modelo del turno; si no se sabe cuál es, la del coordinador. */
86
+ async function modelContextWindow(modelId?: string): Promise<number | undefined> {
87
+ try {
88
+ const modelsCol = await col<ModelDoc>("models")
89
+ if (modelId) return (await modelsCol.get(modelId))?.doc.context_window || undefined
90
+ const agentsCol = await col<AgentDoc>("agents")
91
+ const coordinators = await agentsCol.findBy("role", "coordinator", { limit: 1 })
92
+ // El id se busca completo: recortar el primer segmento rompía cualquier
93
+ // modelo cuyo nombre lleve barra (meta/llama-3.3-70b-instruct buscaba
94
+ // "llama-3.3-70b-instruct", no encontraba nada y caía al default).
95
+ const id = fromIndexable(coordinators[0]?.doc.model_id ?? null)
96
+ if (!id) return undefined
97
+ return (await modelsCol.get(id))?.doc.context_window || undefined
98
+ } catch {
99
+ return undefined
100
+ }
101
+ }
44
102
 
45
103
  /**
46
104
  * Check if compaction is needed and run it if so.
@@ -48,37 +106,16 @@ const MAX_MSG_CHARS = 300 // chars per message in transcript
48
106
  */
49
107
  export async function maybeCompact(
50
108
  threadId: string,
51
- notify?: { channel: string; userId: string }
109
+ notify?: { channel: string; userId: string },
110
+ llm?: CompactionLLM
52
111
  ): Promise<void> {
53
112
  try {
54
113
  const totalTokens = await getTotalTokens(threadId)
55
-
56
- // Orden de precedencia: lo que el usuario configuró gana sobre lo que se
57
- // deduce del modelo, y eso gana sobre la constante.
58
- //
59
- // `agent.context.compactionThreshold` estaba en el esquema de configuración
60
- // y **no lo leía nadie**: alguien podía ajustarlo y no pasaba nada. Una
61
- // opción que no hace nada es peor que no tenerla, porque el usuario cree
62
- // que cambió algo.
63
- let effectiveThreshold = COMPACT_TOKEN_THRESHOLD
64
- const configurado = loadConfig().agent?.context?.compactionThreshold
65
- try {
66
- const agentsCol = await col<AgentDoc>("agents")
67
- const coordinators = await agentsCol.findBy("role", "coordinator", { limit: 1 })
68
- const modelId = fromIndexable(coordinators[0]?.doc.model_id ?? null)
69
- if (modelId) {
70
- const modelsCol = await col<ModelDoc>("models")
71
- // El id se busca completo: recortar el primer segmento rompía cualquier
72
- // modelo cuyo nombre lleve barra (meta/llama-3.3-70b-instruct buscaba
73
- // "llama-3.3-70b-instruct", no encontraba nada y caía al default).
74
- const modelEntry = await modelsCol.get(modelId)
75
- if (modelEntry?.doc.context_window) {
76
- effectiveThreshold = Math.floor(modelEntry.doc.context_window * 0.25)
77
- }
78
- }
79
- } catch { /* use default threshold */ }
80
-
81
- if (configurado && configurado > 0) effectiveThreshold = configurado
114
+ const contextWindow = llm?.contextWindow ?? (await modelContextWindow(llm?.model))
115
+ const effectiveThreshold = resolveCompactionThreshold(
116
+ loadConfig().agent?.context?.compactionThreshold,
117
+ contextWindow,
118
+ )
82
119
 
83
120
  if (totalTokens < effectiveThreshold) return
84
121
 
@@ -96,8 +133,8 @@ export async function maybeCompact(
96
133
  // Already summarized up to near the current state
97
134
  if (summary && summary.last_message_id > totalMessages - KEEP_LAST_N_MESSAGES) return
98
135
 
99
- log.info(`[compaction] Compacting thread=${threadId} tokens=${totalTokens}`)
100
- await compactThread(threadId, notify)
136
+ log.info(`[compaction] Compacting thread=${threadId} tokens=${totalTokens} threshold=${effectiveThreshold}`)
137
+ await compactThread(threadId, notify, llm)
101
138
  } catch (err) {
102
139
  log.warn("[compaction] Error during compaction check:", err)
103
140
  }
@@ -139,7 +176,8 @@ export function renderTranscript(rows: StoredMessage[], maxMsgChars = MAX_MSG_CH
139
176
  */
140
177
  export async function compactThread(
141
178
  threadId: string,
142
- notify?: { channel: string; userId: string }
179
+ notify?: { channel: string; userId: string },
180
+ llm?: CompactionLLM
143
181
  ): Promise<void> {
144
182
  const allMessages = await getHistory(threadId)
145
183
  if (allMessages.length <= KEEP_LAST_N_MESSAGES) return
@@ -162,10 +200,18 @@ export async function compactThread(
162
200
  const capped = toSummarize.slice(-MAX_TRANSCRIPT_MSGS)
163
201
  const transcript = renderTranscript(capped)
164
202
 
165
- const defaultLLM = await getDefaultLLM()
166
- if (!defaultLLM) throw new Error("No active LLM providers/models configured in the database")
203
+ // El modelo del turno y SUS credenciales. Antes el resumen se pedía siempre
204
+ // con `getDefaultLLM()` y sin credenciales, así que `resolveProviderConfig`
205
+ // caía al secret store, al llavero del sistema o al entorno: en una
206
+ // instalación multi-inquilino eso resume la conversación de un cliente con
207
+ // la llave de la plataforma (o de otro cliente). Sin `llm` se comporta como
208
+ // antes, que es lo que necesita una instalación de un solo usuario.
209
+ const target = llm?.provider && llm.model
210
+ ? { provider: llm.provider, model: llm.model }
211
+ : await getDefaultLLM()
212
+ if (!target) throw new Error("No active LLM providers/models configured in the database")
167
213
 
168
- const providerCfg = await resolveProviderConfig(defaultLLM.provider, defaultLLM.model)
214
+ const providerCfg = await resolveProviderConfig(target.provider, target.model, llm?.credentials)
169
215
 
170
216
  const summaryResponse = await callLLM({
171
217
  ...providerCfg,