@johpaz/hive-sdk 0.4.9 → 0.5.1
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 +85 -1
- package/README.md +9 -4
- package/docs/API-AGENTS.md +70 -0
- package/docs/INDEX.md +5 -1
- package/docs/UPGRADING.md +20 -0
- package/package.json +1 -1
- package/packages/core/src/agent/agent-loop.ts +82 -5
- package/packages/core/src/agent/context-compiler.ts +122 -23
- package/packages/core/src/agent/index.ts +2 -0
- package/packages/core/src/agent/jev-decisions.ts +197 -0
- package/packages/core/src/agent/jev-planner.ts +298 -0
- package/packages/core/src/agent/llm-client.ts +2 -2
- package/packages/core/src/agent/llm-providers/anthropic.ts +2 -1
- package/packages/core/src/agent/llm-providers/gemini.ts +3 -1
- package/packages/core/src/agent/realtime-providers/gemini-live.ts +2 -1
- package/packages/core/src/agent/tool-selector.ts +1 -0
- package/packages/core/src/canvas/emitter.ts +19 -0
- package/packages/core/src/events/channel-narration.ts +3 -0
- package/packages/core/src/multimodal/vision-service.ts +7 -7
- package/packages/core/src/services/swarms.ts +4 -0
- package/packages/core/src/storage/collections.ts +9 -2
- package/packages/core/src/storage/crypto.ts +51 -17
- package/packages/core/src/storage/index.ts +1 -0
- package/packages/core/src/storage/seed.ts +4 -0
- package/packages/core/src/storage/usage.ts +55 -2
- package/packages/core/src/swarm/RoleSwarm.ts +6 -0
- package/packages/core/src/tool-runtime/index.ts +20 -1
- package/packages/core/src/tools/agents/get-available-models.ts +3 -1
- package/packages/core/src/tools/core/index.ts +33 -2
- package/packages/core/src/tools/web/computer-use.ts +2 -2
- package/packages/core/src/voice/index.ts +13 -13
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import type { LLMMessage, LLMToolDef } from "./llm-client.ts"
|
|
2
|
+
import type { SkillDescriptor } from "./skill-selector.ts"
|
|
3
|
+
import type { ContextTool } from "./context-compiler.ts"
|
|
4
|
+
import type { PlaybookRule } from "./playbook-selector.ts"
|
|
5
|
+
import { MINIMAL_TOOLS } from "./minimal-loadout.ts"
|
|
6
|
+
import { searchCapabilities } from "./capability-search.ts"
|
|
7
|
+
import { mcpToolFullName } from "./tool-selector.ts"
|
|
8
|
+
import { askJev, getJevKey, type JevAnswer, type JevOption, type JevQuestion } from "./jev-decisions.ts"
|
|
9
|
+
import { col } from "../storage/hive.ts"
|
|
10
|
+
import type { AgentDoc, McpServerDoc, McpToolDoc } from "../storage/collections.ts"
|
|
11
|
+
|
|
12
|
+
export interface JevDecisionMetrics {
|
|
13
|
+
latencyMs: number
|
|
14
|
+
costUsd: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** "activo": connected now; "disponible": enabled, connects on first use; "apagado": disabled. */
|
|
18
|
+
export type JevMcpState = "activo" | "disponible" | "apagado"
|
|
19
|
+
|
|
20
|
+
export interface JevMcpServer {
|
|
21
|
+
id: string
|
|
22
|
+
name: string
|
|
23
|
+
state: JevMcpState
|
|
24
|
+
tools: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface JevSpecialist {
|
|
28
|
+
id: string
|
|
29
|
+
name: string
|
|
30
|
+
description: string
|
|
31
|
+
tools: string[]
|
|
32
|
+
mcp: Array<{ name: string; state: JevMcpState }>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What the swarm can do right now: every enabled worker (catalog and
|
|
37
|
+
* agent_create alike) with its tools, and every MCP server with its state.
|
|
38
|
+
* Jev routes and selects over this, so it never recommends a specialist whose
|
|
39
|
+
* MCP is off or proposes a tool that is not connected.
|
|
40
|
+
*/
|
|
41
|
+
export async function describeSwarmCapabilities(
|
|
42
|
+
mcpManager: { getServerTools(key: string): unknown[] | undefined } | null,
|
|
43
|
+
opts: { includeSpecialists: boolean },
|
|
44
|
+
): Promise<{ mcpServers: JevMcpServer[]; specialists: JevSpecialist[] }> {
|
|
45
|
+
const servers = (await (await col<McpServerDoc>("mcpServers")).scan({})).map(e => e.doc)
|
|
46
|
+
const mcpServers: JevMcpServer[] = servers.map(server => {
|
|
47
|
+
const tools = mcpManager?.getServerTools(server.id)?.length || mcpManager?.getServerTools(server.name)?.length || 0
|
|
48
|
+
return { id: server.id, name: server.name, tools, state: !server.enabled ? "apagado" : tools > 0 ? "activo" : "disponible" }
|
|
49
|
+
})
|
|
50
|
+
if (!opts.includeSpecialists) return { mcpServers, specialists: [] }
|
|
51
|
+
|
|
52
|
+
const byId = new Map(mcpServers.map(s => [s.id, s]))
|
|
53
|
+
const parse = (json: string | null | undefined): string[] => {
|
|
54
|
+
try { return json ? (JSON.parse(json) as unknown[]).map(String) : [] } catch { return [] }
|
|
55
|
+
}
|
|
56
|
+
const specialists = (await (await col<AgentDoc>("agents")).scan({}))
|
|
57
|
+
.map(e => e.doc)
|
|
58
|
+
.filter(a => a.role === "worker" && a.enabled && a.status !== "archived")
|
|
59
|
+
.map(a => ({
|
|
60
|
+
id: a.id,
|
|
61
|
+
name: a.name,
|
|
62
|
+
description: (a.description ?? a.name).slice(0, 200),
|
|
63
|
+
tools: parse(a.tool_allowlist_json).slice(0, 12),
|
|
64
|
+
mcp: parse(a.mcp_server_ids_json).map(id => byId.get(id)).filter((s): s is JevMcpServer => !!s)
|
|
65
|
+
.map(s => ({ name: s.name, state: s.state })),
|
|
66
|
+
}))
|
|
67
|
+
return { mcpServers, specialists }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One roster line: what the coordinator needs to pick a specialist without calling agent_find. */
|
|
71
|
+
export function renderSpecialistLine(s: JevSpecialist): string {
|
|
72
|
+
const mcp = s.mcp.length ? ` · MCP: ${s.mcp.map(m => `${m.name} (${m.state})`).join(", ")}` : ""
|
|
73
|
+
return `- ${s.id} (${s.name})${mcp}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface JevContextPlan {
|
|
77
|
+
messages: LLMMessage[]
|
|
78
|
+
tools: LLMToolDef[]
|
|
79
|
+
skills: SkillDescriptor[]
|
|
80
|
+
agentId: string | null
|
|
81
|
+
/**
|
|
82
|
+
* MCP servers the recommended specialist depends on that are off. Non-empty
|
|
83
|
+
* means "this is the right specialist, but the user must turn these on
|
|
84
|
+
* first" — delegating now would hand it a task it cannot do.
|
|
85
|
+
*/
|
|
86
|
+
agentMcpOff: string[]
|
|
87
|
+
selectedMessageIds: number[]
|
|
88
|
+
selectedToolNames: string[]
|
|
89
|
+
selectedSkillNames: string[]
|
|
90
|
+
selectedScratchpadKeys: string[]
|
|
91
|
+
selectedPlaybookIds: string[]
|
|
92
|
+
decision: JevDecisionMetrics
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const excerpt = (value: unknown, max = 450): string =>
|
|
96
|
+
(typeof value === "string" ? value : JSON.stringify(value) ?? "").slice(0, max)
|
|
97
|
+
const probability = (answer: JevAnswer | undefined): number | null => answer?.type === "noul" ? answer.noul : null
|
|
98
|
+
const MANDATORY_TAIL = 4
|
|
99
|
+
/** Below this many characters of prunable tool output, a decision costs more latency than it saves. */
|
|
100
|
+
const MIN_PRUNABLE_CHARS = 4000
|
|
101
|
+
|
|
102
|
+
/** Decisions select only from already authorized, discoverable candidates. */
|
|
103
|
+
export async function planJevContext(input: {
|
|
104
|
+
objective: string
|
|
105
|
+
messages: LLMMessage[]
|
|
106
|
+
tools: LLMToolDef[]
|
|
107
|
+
allTools: ContextTool[]
|
|
108
|
+
skills: SkillDescriptor[]
|
|
109
|
+
scratchpadNotes?: Array<{ key: string; value: string }>
|
|
110
|
+
playbookRules?: PlaybookRule[]
|
|
111
|
+
isWorker: boolean
|
|
112
|
+
/** From describeSwarmCapabilities; absent means "unknown", not "none". */
|
|
113
|
+
swarm?: { mcpServers: JevMcpServer[]; specialists: JevSpecialist[] }
|
|
114
|
+
jev?: JevOption
|
|
115
|
+
}): Promise<JevContextPlan | null> {
|
|
116
|
+
if (!await getJevKey(input.jev).catch(() => null)) return null
|
|
117
|
+
const { objective, messages, tools, allTools, skills, isWorker } = input
|
|
118
|
+
const mandatoryMessages = new Set<number>()
|
|
119
|
+
// The last two exchanges carry the thread's immediate referents ("hazlo otra
|
|
120
|
+
// vez", "el anterior"); dropping them sent the model into conversation_read loops.
|
|
121
|
+
for (let i = Math.max(0, messages.length - MANDATORY_TAIL); i < messages.length; i++) mandatoryMessages.add(i)
|
|
122
|
+
messages.forEach((message, i) => {
|
|
123
|
+
if (Array.isArray(message.content) || (typeof message.content === "string" && message.content.startsWith("<hive:internal_event"))) mandatoryMessages.add(i)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const candidateMessageIds = messages.map((_, i) => i).filter(i => !mandatoryMessages.has(i))
|
|
127
|
+
let candidateTools: ContextTool[] = tools.filter(t => !MINIMAL_TOOLS.has(t.function.name))
|
|
128
|
+
.map(t => allTools.find(a => a.name === t.function.name))
|
|
129
|
+
.filter((t): t is ContextTool => !!t)
|
|
130
|
+
try {
|
|
131
|
+
const hits = await searchCapabilities(objective, { types: ["tool", "mcp"], k: 12 })
|
|
132
|
+
// allTools only holds MCP tools of servers that are enabled, connected and
|
|
133
|
+
// allowed for this agent — resolving through it is what keeps a dormant or
|
|
134
|
+
// foreign server's tools out of the plan.
|
|
135
|
+
const available = new Map(allTools.map(t => [t.name, t]))
|
|
136
|
+
const mcpTools = await col<McpToolDoc>("mcpTools")
|
|
137
|
+
const names = await Promise.all(hits.map(async h => {
|
|
138
|
+
if (h.type !== "mcp") return h.rawId
|
|
139
|
+
const tool = (await mcpTools.get(h.rawId))?.doc
|
|
140
|
+
return tool ? mcpToolFullName(tool.server_name, tool.tool_name) : null
|
|
141
|
+
}))
|
|
142
|
+
const discovered = names.map(n => n ? available.get(n) : undefined).filter((t): t is ContextTool => !!t && !MINIMAL_TOOLS.has(t.name))
|
|
143
|
+
candidateTools = [...new Map([...candidateTools, ...discovered].map(t => [t.name, t])).values()].slice(0, 24)
|
|
144
|
+
} catch { /* index unavailable: retain the current loadout */ }
|
|
145
|
+
|
|
146
|
+
const optionalSkills = skills.filter(s => s.active && s.body).slice(0, 8)
|
|
147
|
+
const scratchpadNotes = (input.scratchpadNotes ?? []).slice(-16)
|
|
148
|
+
const playbookRules = (input.playbookRules ?? []).slice(0, 8)
|
|
149
|
+
// Specialists with an MCP off stay eligible: Jev still names the right one,
|
|
150
|
+
// and the coordinator asks the user to turn the server on instead of
|
|
151
|
+
// delegating a task the specialist cannot do yet.
|
|
152
|
+
const agents = isWorker ? [] : (input.swarm?.specialists ?? []).slice(0, 16)
|
|
153
|
+
const questions: Record<string, JevQuestion> = {}
|
|
154
|
+
for (const i of candidateMessageIds) questions[`history_${i}`] = { type: "noul", instructions: `Is earlier conversation item ${i} necessary to complete the current objective?` }
|
|
155
|
+
for (const tool of candidateTools) questions[`tool_${tool.name}`] = { type: "noul", instructions: `Will tool ${tool.name} likely be needed for the current objective?` }
|
|
156
|
+
for (const skill of optionalSkills) questions[`skill_${skill.id}`] = { type: "noul", instructions: `Are instructions from skill ${skill.name} needed for the current objective?` }
|
|
157
|
+
for (const note of scratchpadNotes) questions[`note_${note.key}`] = { type: "noul", instructions: `Is scratchpad note ${note.key} needed for the current objective?` }
|
|
158
|
+
for (const rule of playbookRules) questions[`rule_${rule.id}`] = { type: "noul", instructions: `Does playbook rule ${rule.id} apply to the current objective?` }
|
|
159
|
+
if (agents.length) questions.agent = {
|
|
160
|
+
type: "choice", instructions: "Which existing specialist should handle a bounded part of this objective, or should the coordinator handle it?",
|
|
161
|
+
criteria: {
|
|
162
|
+
coordinator: "No bounded specialist task is needed",
|
|
163
|
+
...Object.fromEntries(agents.map(a => [a.id, [
|
|
164
|
+
a.description,
|
|
165
|
+
a.tools.length ? `tools: ${a.tools.join(", ")}` : "",
|
|
166
|
+
a.mcp.length ? `MCP: ${a.mcp.map(m => `${m.name} (${m.state})`).join(", ")}` : "",
|
|
167
|
+
].filter(Boolean).join(" · ").slice(0, 400)])),
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
const decision = await askJev({
|
|
171
|
+
objective: objective.slice(0, 3500),
|
|
172
|
+
history: candidateMessageIds.map(i => ({ id: i, role: messages[i].role, content: excerpt(messages[i].content) })),
|
|
173
|
+
tools: candidateTools.map(t => ({ name: t.name, description: t.description.slice(0, 240) })),
|
|
174
|
+
skills: optionalSkills.map(s => ({ id: s.id, name: s.name, description: s.description.slice(0, 240) })),
|
|
175
|
+
notes: scratchpadNotes.map(n => ({ key: n.key, value: n.value.slice(0, 350) })),
|
|
176
|
+
rules: playbookRules.map(r => ({ id: r.id, rule: r.rule.slice(0, 350) })),
|
|
177
|
+
mcp_servers: (input.swarm?.mcpServers ?? []).map(s => ({ name: s.name, state: s.state, tools: s.tools })),
|
|
178
|
+
}, questions, { jev: input.jev })
|
|
179
|
+
if (!decision) return null
|
|
180
|
+
|
|
181
|
+
const selected = new Set(messages.map((_, i) => i).filter(i => mandatoryMessages.has(i) ||
|
|
182
|
+
!candidateMessageIds.includes(i) || (probability(decision.answers[`history_${i}`]) ?? 1) >= 0.35))
|
|
183
|
+
// A reply travels with the user turn it answered: Gemini silently drops a
|
|
184
|
+
// model turn that has no preceding user turn.
|
|
185
|
+
for (const i of [...selected]) {
|
|
186
|
+
if (messages[i].role !== "assistant") continue
|
|
187
|
+
for (let j = i - 1; j >= 0; j--) if (messages[j].role === "user") { selected.add(j); break }
|
|
188
|
+
}
|
|
189
|
+
const selectedMessageIds = [...selected].sort((a, b) => a - b)
|
|
190
|
+
const selectedToolNames = candidateTools.filter(t => (probability(decision.answers[`tool_${t.name}`]) ?? 1) >= 0.35).map(t => t.name)
|
|
191
|
+
const selectedSkills = optionalSkills.filter(s => (probability(decision.answers[`skill_${s.id}`]) ?? 1) >= 0.35)
|
|
192
|
+
const toolMap = new Map(allTools.map(t => [t.name, t]))
|
|
193
|
+
const selectedNames = new Set(selectedToolNames)
|
|
194
|
+
const candidateNames = new Set(candidateTools.map(t => t.name))
|
|
195
|
+
const combinedTools = tools.filter(t => !candidateNames.has(t.function.name) || selectedNames.has(t.function.name))
|
|
196
|
+
for (const name of selectedToolNames) {
|
|
197
|
+
const tool = toolMap.get(name)
|
|
198
|
+
if (tool && !combinedTools.some(t => t.function.name === name)) combinedTools.push({
|
|
199
|
+
type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters },
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
const agentAnswer = decision.answers.agent
|
|
203
|
+
const agentId = agentAnswer?.type === "choice" && agentAnswer.confidence >= 0.7 && agentAnswer.choice !== "coordinator"
|
|
204
|
+
? agentAnswer.choice : null
|
|
205
|
+
const agentMcpOff = agents.find(a => a.id === agentId)?.mcp.filter(m => m.state === "apagado").map(m => m.name) ?? []
|
|
206
|
+
return {
|
|
207
|
+
messages: selectedMessageIds.map(i => messages[i]), tools: combinedTools,
|
|
208
|
+
skills: selectedSkills, agentId, agentMcpOff, selectedMessageIds, selectedToolNames,
|
|
209
|
+
selectedSkillNames: selectedSkills.map(s => s.name),
|
|
210
|
+
selectedScratchpadKeys: scratchpadNotes.filter(n => (probability(decision.answers[`note_${n.key}`]) ?? 1) >= 0.35).map(n => n.key),
|
|
211
|
+
selectedPlaybookIds: playbookRules.filter(r => (probability(decision.answers[`rule_${r.id}`]) ?? 1) >= 0.35).map(r => r.id),
|
|
212
|
+
decision: { latencyMs: decision.latencyMs, costUsd: decision.costUsd },
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Only independent reads, or separate delegated tasks, may execute concurrently.
|
|
218
|
+
* null leaves the batch to the runtime default; `decision` is present only when Jev was asked.
|
|
219
|
+
*/
|
|
220
|
+
export async function jevWantsParallel(
|
|
221
|
+
calls: Array<{ function: { name: string; arguments: unknown } }>,
|
|
222
|
+
jev?: JevOption,
|
|
223
|
+
): Promise<{ parallel: boolean; decision?: JevDecisionMetrics } | null> {
|
|
224
|
+
if (calls.length < 2) return null
|
|
225
|
+
if (!await getJevKey(jev).catch(() => null)) return null
|
|
226
|
+
const names = calls.map(c => c.function.name)
|
|
227
|
+
const readOnly = names.every(n => /^(fs_read|fs_list|fs_glob|fs_exists|web_search|web_fetch|memory_read|memory_search|artifact_read|artifact_inspect|task_status|agent_find)$/.test(n))
|
|
228
|
+
const delegated = names.every(n => n === "task_delegate")
|
|
229
|
+
if (!readOnly && !delegated) return { parallel: false }
|
|
230
|
+
if (delegated) {
|
|
231
|
+
const ids = calls.map(call => {
|
|
232
|
+
try {
|
|
233
|
+
const args = typeof call.function.arguments === "string" ? JSON.parse(call.function.arguments) : call.function.arguments
|
|
234
|
+
return String(args?.worker_id ?? "")
|
|
235
|
+
} catch { return "" }
|
|
236
|
+
})
|
|
237
|
+
if (ids.some(id => !id) || new Set(ids).size !== ids.length) return { parallel: false }
|
|
238
|
+
const agentsCol = await col<AgentDoc>("agents")
|
|
239
|
+
const agents = await Promise.all(ids.map(id => agentsCol.get(id)))
|
|
240
|
+
const workspaces = agents.map(row => row?.doc.workspace)
|
|
241
|
+
if (workspaces.some(path => !path) || new Set(workspaces).size !== workspaces.length) return { parallel: false }
|
|
242
|
+
}
|
|
243
|
+
const result = await askJev({ calls: calls.map(c => ({ tool: c.function.name, arguments: excerpt(c.function.arguments, 700) })) }, {
|
|
244
|
+
independent: { type: "noul", instructions: "Can every listed operation run concurrently without needing the result of another listed operation?" },
|
|
245
|
+
}, { jev })
|
|
246
|
+
const answer = result?.answers.independent
|
|
247
|
+
return result && answer?.type === "noul"
|
|
248
|
+
? { parallel: answer.noul >= 0.8, decision: { latencyMs: result.latencyMs, costUsd: result.costUsd } }
|
|
249
|
+
: null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function planJevIteration(input: {
|
|
253
|
+
objective: string
|
|
254
|
+
messages: LLMMessage[]
|
|
255
|
+
tools: LLMToolDef[]
|
|
256
|
+
jev?: JevOption
|
|
257
|
+
}): Promise<{ messages: LLMMessage[]; tools: LLMToolDef[]; action: string; omittedResults: number; decision: JevDecisionMetrics } | null> {
|
|
258
|
+
const toolIndices = input.messages.map((m, i) => m.role === "tool" ? i : -1).filter(i => i >= 0)
|
|
259
|
+
if (!toolIndices.length) return null
|
|
260
|
+
const older = toolIndices.slice(0, -1).slice(-8)
|
|
261
|
+
const prunableChars = older.reduce((sum, i) => sum + excerpt(input.messages[i].content, Infinity).length, 0)
|
|
262
|
+
if (prunableChars < MIN_PRUNABLE_CHARS) return null
|
|
263
|
+
const questions: Record<string, JevQuestion> = {
|
|
264
|
+
action: {
|
|
265
|
+
type: "choice",
|
|
266
|
+
instructions: "What capability should the text model use next to complete the current objective?",
|
|
267
|
+
criteria: {
|
|
268
|
+
continue: "Continue reasoning with currently available tools",
|
|
269
|
+
delegate: "Formulate a bounded task for an existing specialist",
|
|
270
|
+
discover: "Discover another tool or skill before continuing",
|
|
271
|
+
finish: "The evidence is sufficient to compose the final answer without more tools",
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
for (const i of older) questions[`result_${i}`] = {
|
|
276
|
+
type: "noul", instructions: `Is result ${i} still needed to complete the current objective?`,
|
|
277
|
+
}
|
|
278
|
+
const result = await askJev({
|
|
279
|
+
objective: input.objective.slice(0, 2800),
|
|
280
|
+
results: toolIndices.slice(-9).map(i => ({ id: i, tool: input.messages[i].name, content: excerpt(input.messages[i].content, 650) })),
|
|
281
|
+
}, questions, { jev: input.jev })
|
|
282
|
+
if (!result) return null
|
|
283
|
+
const omitted = new Set(older.filter(i => (probability(result.answers[`result_${i}`]) ?? 1) < 0.35))
|
|
284
|
+
const projected = input.messages.map((m, i) => omitted.has(i)
|
|
285
|
+
? { ...m, content: "[Previous tool result omitted from this call]" } : m)
|
|
286
|
+
const answer = result.answers.action
|
|
287
|
+
const latestResult = input.messages[toolIndices[toolIndices.length - 1]]
|
|
288
|
+
const resultFailed = typeof latestResult.content === "string" && (latestResult.content.startsWith("[Tool Error]") || latestResult.content.includes('"error":true'))
|
|
289
|
+
const proposedAction = answer?.type === "choice" && answer.confidence >= (answer.choice === "finish" ? 0.85 : 0.7) ? answer.choice : "continue"
|
|
290
|
+
const action = proposedAction === "finish" && resultFailed ? "continue" : proposedAction
|
|
291
|
+
let tools = input.tools
|
|
292
|
+
if (action === "finish") tools = []
|
|
293
|
+
else if (action === "delegate") tools = tools.filter(t => ["task_delegate", "agent_find", "search_knowledge"].includes(t.function.name))
|
|
294
|
+
else if (action === "discover") tools = tools.filter(t => t.function.name === "search_knowledge")
|
|
295
|
+
const decision = { latencyMs: result.latencyMs, costUsd: result.costUsd }
|
|
296
|
+
if (action !== "finish" && tools.length === 0) return { messages: projected, tools: input.tools, action: "continue", omittedResults: omitted.size, decision }
|
|
297
|
+
return { messages: projected, tools, action, omittedResults: omitted.size, decision }
|
|
298
|
+
}
|
|
@@ -309,7 +309,7 @@ export async function resolveProviderConfig(
|
|
|
309
309
|
credentials?: ProviderCredentials
|
|
310
310
|
): Promise<Pick<LLMCallOptions, "provider" | "model" | "apiKey" | "baseUrl" | "numCtx" | "numGpu" | "contextWindow">> {
|
|
311
311
|
const { col } = await import("../storage/hive.ts")
|
|
312
|
-
const { loadProviderApiKey } = await import("../storage/crypto.ts")
|
|
312
|
+
const { envSecret, loadProviderApiKey } = await import("../storage/crypto.ts")
|
|
313
313
|
const providersCol = await col<import("../storage/collections.ts").ProviderDoc>("providers")
|
|
314
314
|
const modelsCol = await col<import("../storage/collections.ts").ModelDoc>("models")
|
|
315
315
|
|
|
@@ -327,7 +327,7 @@ export async function resolveProviderConfig(
|
|
|
327
327
|
apiKey = await loadProviderApiKey(providerId)
|
|
328
328
|
}
|
|
329
329
|
if (!apiKey) {
|
|
330
|
-
apiKey =
|
|
330
|
+
apiKey = envSecret(`${providerId.toUpperCase()}_API_KEY`) || ""
|
|
331
331
|
}
|
|
332
332
|
|
|
333
333
|
return {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { envSecret } from "../../storage/crypto.ts"
|
|
1
2
|
import { logger } from "../../utils/logger.ts"
|
|
2
3
|
import { normalizeToolName, resolveMaxTokens, ensureArrayItems } from "./interface.ts"
|
|
3
4
|
import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall, ThinkingBlock } from "./interface.ts"
|
|
@@ -74,7 +75,7 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
74
75
|
|
|
75
76
|
async call(options: LLMCallOptions): Promise<LLMResponse> {
|
|
76
77
|
const Anthropic = await import("@anthropic-ai/sdk")
|
|
77
|
-
const client = new Anthropic.default({ apiKey: options.apiKey })
|
|
78
|
+
const client = new Anthropic.default({ apiKey: options.apiKey || envSecret("ANTHROPIC_API_KEY") || "" })
|
|
78
79
|
|
|
79
80
|
// Anthropic requires tool names to match ^[a-zA-Z0-9_-]{1,128}$
|
|
80
81
|
// Native Hive tools use dots (e.g. cron.create) which violate this.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { envSecret } from "../../storage/crypto.ts"
|
|
1
2
|
import { logger } from "../../utils/logger.ts"
|
|
2
3
|
import { sanitizeMessages, resolveMaxTokens, ensureArrayItems } from "./interface.ts"
|
|
3
4
|
import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall } from "./interface.ts"
|
|
@@ -100,7 +101,8 @@ export class GeminiProvider implements LLMProvider {
|
|
|
100
101
|
async call(options: LLMCallOptions): Promise<LLMResponse> {
|
|
101
102
|
const { GoogleGenAI } = await import("@google/genai")
|
|
102
103
|
|
|
103
|
-
|
|
104
|
+
// A string always: undefined makes @google/genai read GEMINI_API_KEY itself, inside a tenant too.
|
|
105
|
+
const clientOpts: any = { apiKey: options.apiKey || envSecret("GEMINI_API_KEY") || envSecret("GOOGLE_API_KEY") || "" }
|
|
104
106
|
if (options.baseUrl?.trim()) clientOpts.httpOptions = { baseUrl: options.baseUrl.trim() }
|
|
105
107
|
|
|
106
108
|
const ai = new GoogleGenAI(clientOpts)
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* al contexto inicial en 3.x, así que no se usa acá.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { envSecret } from "../../storage/crypto.ts"
|
|
13
14
|
import { logger } from "../../utils/logger.ts";
|
|
14
15
|
import { ensureArrayItems } from "../llm-providers/interface.ts";
|
|
15
16
|
import type {
|
|
@@ -106,7 +107,7 @@ export class GeminiLiveProvider implements RealtimeProvider {
|
|
|
106
107
|
|
|
107
108
|
async connect(options: RealtimeSessionOptions): Promise<RealtimeSession> {
|
|
108
109
|
const { GoogleGenAI } = await import("@google/genai");
|
|
109
|
-
const ai = new GoogleGenAI({ apiKey: options.apiKey });
|
|
110
|
+
const ai = new GoogleGenAI({ apiKey: options.apiKey || envSecret("GEMINI_API_KEY") || envSecret("GOOGLE_API_KEY") || "" });
|
|
110
111
|
const cb = options.callbacks;
|
|
111
112
|
|
|
112
113
|
const config: Record<string, unknown> = {
|
|
@@ -195,6 +195,7 @@ export const CORE_TOOL_CATALOG: ToolDescriptor[] = [
|
|
|
195
195
|
|
|
196
196
|
// Capability discovery
|
|
197
197
|
{ name: "search_knowledge", description: "Search everything Hive knows: native tools, MCP tools, skills, catalog agents and playbook rules. Spanish keywords: buscar herramienta, descubrir capacidades, qué puedo hacer, buscar skill, buscar conocimiento", category: "core", abstractionLevel: "atomic" },
|
|
198
|
+
{ name: "conversation_read", description: "Read earlier messages from this conversation by ID or search text when context is missing. Spanish keywords: recuperar contexto, leer historial, conversación anterior", category: "core", abstractionLevel: "atomic" },
|
|
198
199
|
|
|
199
200
|
// HTTP / REST
|
|
200
201
|
{ name: "api_request", description: "Perform an authorized HTTP request against a REST endpoint and validate the response. Spanish keywords: llamar api, request rest, consumir endpoint, petición http, hacer get, hacer post", category: "api", abstractionLevel: "atomic" },
|
|
@@ -15,6 +15,8 @@ export type CanvasEventType =
|
|
|
15
15
|
| "canvas:edge_add"
|
|
16
16
|
| "canvas:edge_remove"
|
|
17
17
|
| "canvas:work_event"
|
|
18
|
+
| "canvas:jev_decision"
|
|
19
|
+
| "canvas:jev_status"
|
|
18
20
|
| "ag-ui:event"
|
|
19
21
|
|
|
20
22
|
export type CanvasWorkPhase =
|
|
@@ -65,6 +67,23 @@ export function unsubscribeCanvas(ws: { send: (data: string) => void }) {
|
|
|
65
67
|
subscribers.delete(ws)
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
/** One Jev decision, shown by the office's oracle as a beam to the agent it advised. */
|
|
71
|
+
export interface CanvasJevDecision {
|
|
72
|
+
eventId: string
|
|
73
|
+
agentId: string
|
|
74
|
+
kind: "context" | "iteration" | "parallel"
|
|
75
|
+
/** Short Spanish description of what was selected ("4/15 mensajes · 9/24 herramientas"). */
|
|
76
|
+
summary: string
|
|
77
|
+
/** Estimated main-model input tokens this decision avoided (chars/4); negative when it added context. */
|
|
78
|
+
savedTokens: number
|
|
79
|
+
latencyMs: number
|
|
80
|
+
costUsd: number
|
|
81
|
+
recommendedAgentId?: string | null
|
|
82
|
+
/** MCP servers the recommended specialist needs that are off; the coordinator asks the user to turn them on. */
|
|
83
|
+
mcpOff?: string[]
|
|
84
|
+
totals: { decisions: number; savedTokens: number; costUsd: number }
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
export function emitCanvas(type: CanvasEventType, data: any) {
|
|
69
88
|
// Track live agent state for new subscribers
|
|
70
89
|
if (type === "canvas:node_update" && data?.nodeId && data?.changes) {
|
|
@@ -49,6 +49,7 @@ const KIND_PREFIX: Record<NarrationEventDoc["kind"], string> = {
|
|
|
49
49
|
verified: "✅",
|
|
50
50
|
failed: "❌",
|
|
51
51
|
group_ready: "📝",
|
|
52
|
+
decision: "🔮",
|
|
52
53
|
};
|
|
53
54
|
|
|
54
55
|
const DETAIL_MAX_CHARS = 200;
|
|
@@ -86,6 +87,8 @@ export async function resolveNarrationMode(channelType: string): Promise<Narrati
|
|
|
86
87
|
|
|
87
88
|
export function shouldDeliverToChannel(event: NarrationEventDoc, mode: NarrationMode): boolean {
|
|
88
89
|
if (mode === "off") return false;
|
|
90
|
+
// Internal bookkeeping for activity views, not something a customer reads.
|
|
91
|
+
if (event.kind === "decision") return false;
|
|
89
92
|
if (MILESTONE_KINDS.has(event.kind)) return true;
|
|
90
93
|
if (mode !== "all") return false;
|
|
91
94
|
// Even in `all`, a successful tool_result only restates the tool_call that
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { col } from "../storage/hive.ts"
|
|
2
2
|
import type { ChannelDoc, ModelDoc, ProviderDoc } from "../storage/collections.ts"
|
|
3
|
-
import { loadProviderApiKey } from "../storage/crypto.ts"
|
|
3
|
+
import { envSecret, loadProviderApiKey } from "../storage/crypto.ts"
|
|
4
4
|
import { logger } from "../utils/logger.ts"
|
|
5
5
|
import type { ImageInput, DocumentInput, VisionConfig } from "./types.ts"
|
|
6
6
|
import type { ContentPart } from "./types.ts"
|
|
@@ -177,7 +177,7 @@ class MultimodalService {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
private async ocrWithOpenAI(image: ImageInput): Promise<string> {
|
|
180
|
-
const key = await this.getProviderApiKey("openai") ||
|
|
180
|
+
const key = await this.getProviderApiKey("openai") || envSecret("OPENAI_API_KEY")
|
|
181
181
|
if (!key) throw new Error("OPENAI_API_KEY not configured for OCR")
|
|
182
182
|
|
|
183
183
|
const imageUrl = await this.resolveImageUrl(image)
|
|
@@ -208,7 +208,7 @@ class MultimodalService {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
private async ocrWithGemini(image: ImageInput): Promise<string> {
|
|
211
|
-
const key = await this.getProviderApiKey("gemini") ||
|
|
211
|
+
const key = await this.getProviderApiKey("gemini") || envSecret("GEMINI_API_KEY")
|
|
212
212
|
if (!key) throw new Error("GEMINI_API_KEY not configured for OCR")
|
|
213
213
|
|
|
214
214
|
let imagePart: any
|
|
@@ -243,7 +243,7 @@ class MultimodalService {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
private async ocrWithAnthropic(image: ImageInput): Promise<string> {
|
|
246
|
-
const key = await this.getProviderApiKey("anthropic") ||
|
|
246
|
+
const key = await this.getProviderApiKey("anthropic") || envSecret("ANTHROPIC_API_KEY")
|
|
247
247
|
if (!key) throw new Error("ANTHROPIC_API_KEY not configured for OCR")
|
|
248
248
|
|
|
249
249
|
const imageUrl = await this.resolveImageUrl(image)
|
|
@@ -299,9 +299,9 @@ class MultimodalService {
|
|
|
299
299
|
])
|
|
300
300
|
|
|
301
301
|
return {
|
|
302
|
-
openai: openai || !!(
|
|
303
|
-
gemini: gemini || !!(
|
|
304
|
-
anthropic: anthropic || !!(
|
|
302
|
+
openai: openai || !!(envSecret("OPENAI_API_KEY")),
|
|
303
|
+
gemini: gemini || !!(envSecret("GEMINI_API_KEY")),
|
|
304
|
+
anthropic: anthropic || !!(envSecret("ANTHROPIC_API_KEY")),
|
|
305
305
|
}
|
|
306
306
|
}
|
|
307
307
|
|
|
@@ -18,6 +18,7 @@ import { col } from "../storage/hive.ts";
|
|
|
18
18
|
import type { SwarmDoc, SwarmMemberSpec, AgentDoc } from "../storage/collections.ts";
|
|
19
19
|
import { runRoleSwarm, type RoleSwarmResult, type SwarmMessage } from "../swarm/RoleSwarm.ts";
|
|
20
20
|
import type { ProviderCredentials } from "../agent/llm-client.ts";
|
|
21
|
+
import type { JevOption } from "../agent/jev-decisions.ts";
|
|
21
22
|
import { slugify } from "./agents.ts";
|
|
22
23
|
import { logger } from "../utils/logger.ts";
|
|
23
24
|
import { enableCatalogAgents, planActivationFor, CATALOG_AGENT_IDS, type ActivationGap } from "./setup.ts";
|
|
@@ -268,6 +269,8 @@ export interface RunSwarmOptions {
|
|
|
268
269
|
channel?: string;
|
|
269
270
|
/** Credenciales del inquilino, propagadas a cada agente. */
|
|
270
271
|
credentials?: ProviderCredentials;
|
|
272
|
+
/** Jev del inquilino, propagado a cada agente igual que `credentials`. */
|
|
273
|
+
jev?: JevOption;
|
|
271
274
|
signal?: AbortSignal;
|
|
272
275
|
/** Se llama en cada paso; acá persiste el consumidor si quiere. */
|
|
273
276
|
onMessage?: (message: SwarmMessage) => void | Promise<void>;
|
|
@@ -301,6 +304,7 @@ export async function runSwarm(
|
|
|
301
304
|
orchestratorAgentId: swarm.orchestratorAgentId ?? undefined,
|
|
302
305
|
maxDelegations: swarm.maxDelegations ?? undefined,
|
|
303
306
|
credentials: opts?.credentials,
|
|
307
|
+
jev: opts?.jev,
|
|
304
308
|
signal: opts?.signal,
|
|
305
309
|
onMessage: opts?.onMessage,
|
|
306
310
|
});
|
|
@@ -41,7 +41,7 @@ export interface ModelDoc {
|
|
|
41
41
|
provider_id: string
|
|
42
42
|
name: string
|
|
43
43
|
/** "realtime": voz full-duplex (Live API), no confundir con stt+tts encadenados. */
|
|
44
|
-
model_type: "llm" | "stt" | "tts" | "vision" | "embedding" | "realtime"
|
|
44
|
+
model_type: "llm" | "stt" | "tts" | "vision" | "embedding" | "realtime" | "decision"
|
|
45
45
|
context_window: number
|
|
46
46
|
capabilities: string | null
|
|
47
47
|
enabled: boolean
|
|
@@ -741,7 +741,8 @@ export interface NarrationEventDoc {
|
|
|
741
741
|
session_id: string
|
|
742
742
|
agent_id: string
|
|
743
743
|
agent_name: string
|
|
744
|
-
|
|
744
|
+
/** "decision": a Jev decision, recorded for the host's activity views; never delivered to a channel. */
|
|
745
|
+
kind: "delegated" | "worker_started" | "tool_call" | "tool_result" | "verified" | "failed" | "group_ready" | "decision"
|
|
745
746
|
status: "queued" | "running" | "done" | "error"
|
|
746
747
|
label: string
|
|
747
748
|
detail: string | null
|
|
@@ -809,6 +810,12 @@ export interface UsageRollupDoc {
|
|
|
809
810
|
toonJsonBytes: number
|
|
810
811
|
byProvider: Record<string, { inputTokens: number; outputTokens: number; costUsd: number }>
|
|
811
812
|
byModel: Record<string, { inputTokens: number; outputTokens: number; costUsd: number }>
|
|
813
|
+
/** Jev decision plane; absent on hours before it existed. Savings are estimates (chars/4) priced at the advised agent's model. */
|
|
814
|
+
jevDecisions?: number
|
|
815
|
+
jevCostUsd?: number
|
|
816
|
+
jevSavedTokens?: number
|
|
817
|
+
jevSavedCostUsd?: number
|
|
818
|
+
jevByAgent?: Record<string, { jevDecisions: number; jevCostUsd: number; jevSavedTokens: number; jevSavedCostUsd: number }>
|
|
812
819
|
}
|
|
813
820
|
|
|
814
821
|
export interface ActivityRollupDoc {
|
|
@@ -4,6 +4,7 @@ import * as path from "node:path"
|
|
|
4
4
|
import { getHiveDir } from "../config/loader.ts"
|
|
5
5
|
import { logger } from "../utils/logger.ts"
|
|
6
6
|
import { col } from "./hive.ts"
|
|
7
|
+
import { currentTenant } from "./tenant.ts"
|
|
7
8
|
|
|
8
9
|
const log = logger.child("crypto")
|
|
9
10
|
const SERVICE = "hive"
|
|
@@ -25,8 +26,28 @@ interface SecretDoc {
|
|
|
25
26
|
// written *only* there does not survive a server restart. That is exactly
|
|
26
27
|
// what was wiping every provider API key, channel token and MCP header on
|
|
27
28
|
// restart in production.
|
|
28
|
-
|
|
29
|
+
//
|
|
30
|
+
// Multi-tenant: the `secrets` collection is partitioned by tenant through
|
|
31
|
+
// `col()`, but this process's memory and the OS keychain are not. So the
|
|
32
|
+
// in-memory cache is keyed by tenant as well, and with a tenant in scope the
|
|
33
|
+
// keychain is never read or written — a machine-wide entry named
|
|
34
|
+
// `provider:openai:api_key` belongs to no tenant in particular, and serving it
|
|
35
|
+
// (or overwriting it) on a tenant's behalf would hand one customer another's
|
|
36
|
+
// key.
|
|
37
|
+
|
|
38
|
+
/** Decrypted values, keyed by {@link _cacheKey}: never shared across tenants. */
|
|
29
39
|
const _mem = new Map<string, string>()
|
|
40
|
+
|
|
41
|
+
/** `name` for the desktop (no tenant); `<tenant>\0name` inside a tenant. */
|
|
42
|
+
function _cacheKey(name: string): string {
|
|
43
|
+
const tenant = currentTenant()
|
|
44
|
+
return tenant ? `${tenant}\0${name}` : name
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The OS keychain only serves the tenant-less (desktop) scope. */
|
|
48
|
+
function _keychainInScope(): boolean {
|
|
49
|
+
return currentTenant() === null
|
|
50
|
+
}
|
|
30
51
|
let _keychainOk: boolean | null = null // null = untested
|
|
31
52
|
|
|
32
53
|
let _keychainApi: unknown = undefined
|
|
@@ -46,16 +67,21 @@ function _getKeychainApi(): any {
|
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
async function _get(name: string): Promise<string | null> {
|
|
49
|
-
const
|
|
70
|
+
const cacheKey = _cacheKey(name)
|
|
71
|
+
const cached = _mem.get(cacheKey)
|
|
50
72
|
if (cached !== undefined) return cached
|
|
51
73
|
|
|
52
74
|
// Durable store first — it is the one every write goes to.
|
|
53
75
|
const stored = await _readCollectionSecret(name)
|
|
54
|
-
if (stored)
|
|
76
|
+
if (stored) {
|
|
77
|
+
_mem.set(cacheKey, stored)
|
|
78
|
+
return stored
|
|
79
|
+
}
|
|
55
80
|
|
|
56
81
|
// Legacy/desktop installs may only have the value in the OS keychain.
|
|
82
|
+
if (!_keychainInScope()) return null
|
|
57
83
|
const fromKeychain = await _keychainGet(name)
|
|
58
|
-
if (fromKeychain) _mem.set(
|
|
84
|
+
if (fromKeychain) _mem.set(cacheKey, fromKeychain)
|
|
59
85
|
return fromKeychain
|
|
60
86
|
}
|
|
61
87
|
|
|
@@ -66,9 +92,9 @@ async function _get(name: string): Promise<string | null> {
|
|
|
66
92
|
* silently accepting a secret that dies with the process.
|
|
67
93
|
*/
|
|
68
94
|
async function _set(name: string, value: string): Promise<boolean> {
|
|
69
|
-
_mem.set(name, value)
|
|
95
|
+
_mem.set(_cacheKey(name), value)
|
|
70
96
|
const durable = await persistSecretToCollection(name, value)
|
|
71
|
-
const mirrored = await _keychainSet(name, value)
|
|
97
|
+
const mirrored = _keychainInScope() ? await _keychainSet(name, value) : false
|
|
72
98
|
if (!durable && !mirrored) {
|
|
73
99
|
log.error(`[secrets] ${name} could not be persisted — it will be lost on restart`)
|
|
74
100
|
}
|
|
@@ -84,12 +110,7 @@ async function _readCollectionSecret(name: string): Promise<string | null> {
|
|
|
84
110
|
const secrets = await col<SecretDoc>("secrets")
|
|
85
111
|
const entry = await secrets.get(name)
|
|
86
112
|
if (!entry) return null
|
|
87
|
-
|
|
88
|
-
if (plain) {
|
|
89
|
-
// Cache in memory for subsequent lookups in this process
|
|
90
|
-
_mem.set(name, plain)
|
|
91
|
-
}
|
|
92
|
-
return plain || null
|
|
113
|
+
return decryptSecret(entry.doc.ciphertext, entry.doc.iv) || null
|
|
93
114
|
} catch {
|
|
94
115
|
return null
|
|
95
116
|
}
|
|
@@ -135,11 +156,13 @@ async function _keychainSet(name: string, value: string): Promise<boolean> {
|
|
|
135
156
|
}
|
|
136
157
|
|
|
137
158
|
async function _del(name: string): Promise<void> {
|
|
138
|
-
_mem.delete(name)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
159
|
+
_mem.delete(_cacheKey(name))
|
|
160
|
+
if (_keychainInScope()) {
|
|
161
|
+
try {
|
|
162
|
+
await (Bun as any).secrets.delete({ service: SERVICE, name })
|
|
163
|
+
} catch {
|
|
164
|
+
// ignore — might not exist or keychain unavailable
|
|
165
|
+
}
|
|
143
166
|
}
|
|
144
167
|
try {
|
|
145
168
|
const secrets = await col<SecretDoc>("secrets")
|
|
@@ -195,6 +218,17 @@ export async function loadProviderApiKey(id: string): Promise<string> {
|
|
|
195
218
|
return (await _get(`provider:${id}:api_key`)) ?? ""
|
|
196
219
|
}
|
|
197
220
|
|
|
221
|
+
/**
|
|
222
|
+
* A credential from the process environment (`OPENAI_API_KEY`, …) — only
|
|
223
|
+
* outside a tenant. The environment is the host's: inside a tenant it belongs
|
|
224
|
+
* to the platform, not the customer, and using it would bill the platform's
|
|
225
|
+
* account for a customer's call (or let one customer run on another's key).
|
|
226
|
+
* A tenant's credentials come from its own secrets or from `credentials`.
|
|
227
|
+
*/
|
|
228
|
+
export function envSecret(name: string): string | undefined {
|
|
229
|
+
return currentTenant() ? undefined : process.env[name] || undefined
|
|
230
|
+
}
|
|
231
|
+
|
|
198
232
|
export async function storeProviderHeaders(id: string, headers: Record<string, unknown>): Promise<boolean> {
|
|
199
233
|
return await _set(`provider:${id}:headers`, JSON.stringify(headers))
|
|
200
234
|
}
|