@johpaz/hive-sdk 0.4.9 → 0.5.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.
- package/CHANGELOG.md +52 -1
- package/README.md +1 -1
- 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 +199 -0
- package/packages/core/src/agent/jev-planner.ts +298 -0
- 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/services/swarms.ts +4 -0
- package/packages/core/src/storage/collections.ts +9 -2
- package/packages/core/src/storage/crypto.ts +15 -7
- 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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/** Optional decision plane. Jev is never used through the chat completions API. */
|
|
2
|
+
import { col } from "../storage/hive.ts"
|
|
3
|
+
import type { ProviderDoc } from "../storage/collections.ts"
|
|
4
|
+
import { loadDurableProviderApiKey, loadProviderApiKey } from "../storage/crypto.ts"
|
|
5
|
+
import { recordJevDecision, recordUsage } from "../storage/usage.ts"
|
|
6
|
+
import { catalogModelKey } from "../storage/model-id.ts"
|
|
7
|
+
import { currentTenant } from "../storage/tenant.ts"
|
|
8
|
+
import { logger } from "../utils/logger.ts"
|
|
9
|
+
import { emitCanvas, type CanvasJevDecision } from "../canvas/emitter.ts"
|
|
10
|
+
|
|
11
|
+
const log = logger.child("jev-decisions")
|
|
12
|
+
export const JEV_MODEL = "typesafe/jev-1.13"
|
|
13
|
+
const ENDPOINT = "https://openrouter.ai/api/alpha/decisions"
|
|
14
|
+
const TIMEOUT_MS = 3000
|
|
15
|
+
const COOLDOWN_MS = 60_000
|
|
16
|
+
/** Where the user turns an MCP server on, when the host does not say otherwise. */
|
|
17
|
+
export const DEFAULT_JEV_MCP_SETTINGS_PATH = "Ajustes → Entorno → MCP Servers"
|
|
18
|
+
let decisionSequence = 0
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How a caller controls Jev for one run.
|
|
22
|
+
*
|
|
23
|
+
* - `{ apiKey }`: use this OpenRouter key. A multi-tenant host resolves its
|
|
24
|
+
* tenant's key and passes it here, exactly like `credentials`.
|
|
25
|
+
* `mcpSettingsPath` names where that host's users turn an MCP server on.
|
|
26
|
+
* - `false`: Jev is off for this run; nothing is sent to OpenRouter.
|
|
27
|
+
* - `undefined`: the `openrouter` provider row of the current tenant decides.
|
|
28
|
+
*/
|
|
29
|
+
export type JevOption = { apiKey: string; mcpSettingsPath?: string } | false
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Failure and cooldown bookkeeping, per tenant: one tenant's invalid key must
|
|
33
|
+
* not put every other tenant in the same process into fallback.
|
|
34
|
+
*/
|
|
35
|
+
interface JevTenantState {
|
|
36
|
+
failures: number
|
|
37
|
+
cooldownUntil: number
|
|
38
|
+
lastError: string | null
|
|
39
|
+
lastSuccessAt: number | null
|
|
40
|
+
/** Since process start; the office shows them as the oracle's running contribution. */
|
|
41
|
+
totals: { decisions: number; savedTokens: number; costUsd: number }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const states = new Map<string, JevTenantState>()
|
|
45
|
+
|
|
46
|
+
function tenantState(): JevTenantState {
|
|
47
|
+
const key = currentTenant() ?? "default"
|
|
48
|
+
let state = states.get(key)
|
|
49
|
+
if (!state) {
|
|
50
|
+
state = { failures: 0, cooldownUntil: 0, lastError: null, lastSuccessAt: null, totals: { decisions: 0, savedTokens: 0, costUsd: 0 } }
|
|
51
|
+
states.set(key, state)
|
|
52
|
+
}
|
|
53
|
+
return state
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type JevQuestion =
|
|
57
|
+
| { type: "choice"; instructions: string; criteria: Record<string, string> }
|
|
58
|
+
| { type: "noul"; instructions: string; criteria?: { true: string; false: string } }
|
|
59
|
+
|
|
60
|
+
export type JevAnswer =
|
|
61
|
+
| { type: "choice"; choice: string; confidence: number; probabilities: Record<string, number> }
|
|
62
|
+
| { type: "noul"; noul: number }
|
|
63
|
+
|
|
64
|
+
export interface JevResult {
|
|
65
|
+
answers: Record<string, JevAnswer>
|
|
66
|
+
inputTokens: number
|
|
67
|
+
costUsd: number
|
|
68
|
+
latencyMs: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The OpenRouter key Jev would use, or null when Jev is off.
|
|
73
|
+
*
|
|
74
|
+
* With a tenant in scope the key comes only from that tenant's `secrets`
|
|
75
|
+
* partition — never from `OPENROUTER_API_KEY`, which is the platform's, nor
|
|
76
|
+
* from the process-wide secret cache, which is not partitioned.
|
|
77
|
+
*/
|
|
78
|
+
export async function getJevKey(option?: JevOption): Promise<string | null> {
|
|
79
|
+
if (option === false) return null
|
|
80
|
+
if (option) return option.apiKey || null
|
|
81
|
+
const provider = await (await col<ProviderDoc>("providers")).get("openrouter")
|
|
82
|
+
if (!provider?.doc.enabled || !provider.doc.active) return null
|
|
83
|
+
if (currentTenant()) return (await loadDurableProviderApiKey("openrouter")) || null
|
|
84
|
+
return (await loadProviderApiKey("openrouter")) || process.env.OPENROUTER_API_KEY || null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface JevStatus {
|
|
88
|
+
state: "off" | "ready" | "fallback"
|
|
89
|
+
lastError: string | null
|
|
90
|
+
lastSuccessAt: number | null
|
|
91
|
+
totals: { decisions: number; savedTokens: number; costUsd: number }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function getJevStatus(option?: JevOption): Promise<JevStatus> {
|
|
95
|
+
const key = await getJevKey(option).catch(() => null)
|
|
96
|
+
const state = tenantState()
|
|
97
|
+
return {
|
|
98
|
+
state: !key ? "off" : Date.now() < state.cooldownUntil || state.lastError ? "fallback" : "ready",
|
|
99
|
+
lastError: key ? state.lastError : null,
|
|
100
|
+
lastSuccessAt: key ? state.lastSuccessAt : null,
|
|
101
|
+
totals: { ...state.totals },
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function broadcastStatus(option?: JevOption): void {
|
|
106
|
+
getJevStatus(option).then(status => emitCanvas("canvas:jev_status", status)).catch(() => { /* best effort */ })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Publishes a served decision to the office and persists it for the dashboard.
|
|
111
|
+
* Callers estimate savings; Jev itself only answers questions. `provider`/`model`
|
|
112
|
+
* are the advised agent's main model, used to price the avoided tokens.
|
|
113
|
+
* Returns the published event so the caller can forward it to its own host.
|
|
114
|
+
*/
|
|
115
|
+
export function emitJevDecision({ provider, model, ...decision }: Omit<CanvasJevDecision, "eventId" | "totals"> & { provider: string; model: string }): CanvasJevDecision {
|
|
116
|
+
recordJevDecision({ agentId: decision.agentId, provider, model, savedTokens: decision.savedTokens, costUsd: decision.costUsd })
|
|
117
|
+
const { totals } = tenantState()
|
|
118
|
+
totals.decisions++
|
|
119
|
+
totals.savedTokens += decision.savedTokens
|
|
120
|
+
totals.costUsd += decision.costUsd
|
|
121
|
+
const event = {
|
|
122
|
+
...decision,
|
|
123
|
+
eventId: `jev:${Date.now().toString(36)}:${++decisionSequence}`,
|
|
124
|
+
summary: decision.summary.slice(0, 160),
|
|
125
|
+
totals: { ...totals },
|
|
126
|
+
} satisfies CanvasJevDecision
|
|
127
|
+
emitCanvas("canvas:jev_decision", event)
|
|
128
|
+
return event
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Clears the current tenant's failure state (after its key changed, for instance). */
|
|
132
|
+
export function resetJevStatus(option?: JevOption): void {
|
|
133
|
+
const state = tenantState()
|
|
134
|
+
state.failures = 0
|
|
135
|
+
state.cooldownUntil = 0
|
|
136
|
+
state.lastError = null
|
|
137
|
+
state.lastSuccessAt = null
|
|
138
|
+
broadcastStatus(option)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function askJev(
|
|
142
|
+
state: unknown,
|
|
143
|
+
questions: Record<string, JevQuestion>,
|
|
144
|
+
options: { fetcher?: typeof fetch; signal?: AbortSignal; jev?: JevOption } = {},
|
|
145
|
+
): Promise<JevResult | null> {
|
|
146
|
+
const key = await getJevKey(options.jev).catch(() => null)
|
|
147
|
+
const tenant = tenantState()
|
|
148
|
+
if (!key || Date.now() < tenant.cooldownUntil || Object.keys(questions).length === 0) return null
|
|
149
|
+
const started = performance.now()
|
|
150
|
+
try {
|
|
151
|
+
const response = await (options.fetcher ?? fetch)(ENDPOINT, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
154
|
+
body: JSON.stringify({ model: JEV_MODEL, state, questions }),
|
|
155
|
+
signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(TIMEOUT_MS)]) : AbortSignal.timeout(TIMEOUT_MS),
|
|
156
|
+
})
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
if (response.status === 401 || response.status === 403) tenant.cooldownUntil = Date.now() + COOLDOWN_MS
|
|
159
|
+
throw new Error(`OpenRouter HTTP ${response.status}`)
|
|
160
|
+
}
|
|
161
|
+
const data = await response.json() as {
|
|
162
|
+
answers?: Record<string, JevAnswer>
|
|
163
|
+
usage?: { input_tokens?: number; output_tokens?: number; cost?: number }
|
|
164
|
+
}
|
|
165
|
+
const answers: Record<string, JevAnswer> = {}
|
|
166
|
+
for (const [name, question] of Object.entries(questions)) {
|
|
167
|
+
const answer = data.answers?.[name]
|
|
168
|
+
if (question.type === "choice") {
|
|
169
|
+
if (answer?.type !== "choice" || !Object.hasOwn(question.criteria, answer.choice) ||
|
|
170
|
+
!Number.isFinite(answer.confidence) || answer.confidence < 0 || answer.confidence > 1) {
|
|
171
|
+
throw new Error(`Invalid choice answer: ${name}`)
|
|
172
|
+
}
|
|
173
|
+
} else if (answer?.type !== "noul" || !Number.isFinite(answer.noul) || answer.noul < 0 || answer.noul > 1) {
|
|
174
|
+
throw new Error(`Invalid noul answer: ${name}`)
|
|
175
|
+
}
|
|
176
|
+
answers[name] = answer
|
|
177
|
+
}
|
|
178
|
+
const recovered = tenant.lastError !== null
|
|
179
|
+
tenant.failures = 0
|
|
180
|
+
tenant.lastError = null
|
|
181
|
+
tenant.lastSuccessAt = Date.now()
|
|
182
|
+
if (recovered) broadcastStatus(options.jev)
|
|
183
|
+
const inputTokens = data.usage?.input_tokens ?? 0
|
|
184
|
+
const costUsd = data.usage?.cost ?? inputTokens * 0.042 / 1_000_000
|
|
185
|
+
log.info(`Decision served: questions=${Object.keys(questions).join(",")} latency_ms=${Math.round(performance.now() - started)} input_tokens=${inputTokens} cost_usd=${costUsd}`)
|
|
186
|
+
if (inputTokens > 0) {
|
|
187
|
+
recordUsage({ provider: "openrouter", model: catalogModelKey("openrouter", JEV_MODEL), inputTokens, outputTokens: data.usage?.output_tokens ?? 0, latencyMs: Math.round(performance.now() - started) })
|
|
188
|
+
}
|
|
189
|
+
return { answers, inputTokens, costUsd, latencyMs: Math.round(performance.now() - started) }
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (options.signal?.aborted) return null
|
|
192
|
+
tenant.failures++
|
|
193
|
+
tenant.lastError = error instanceof Error ? error.message : "Jev unavailable"
|
|
194
|
+
if (tenant.failures >= 3) tenant.cooldownUntil = Date.now() + COOLDOWN_MS
|
|
195
|
+
log.warn(`Decision fallback: ${tenant.lastError}`)
|
|
196
|
+
broadcastStatus(options.jev)
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
@@ -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 {
|
|
@@ -51,7 +51,10 @@ async function _get(name: string): Promise<string | null> {
|
|
|
51
51
|
|
|
52
52
|
// Durable store first — it is the one every write goes to.
|
|
53
53
|
const stored = await _readCollectionSecret(name)
|
|
54
|
-
if (stored)
|
|
54
|
+
if (stored) {
|
|
55
|
+
_mem.set(name, stored)
|
|
56
|
+
return stored
|
|
57
|
+
}
|
|
55
58
|
|
|
56
59
|
// Legacy/desktop installs may only have the value in the OS keychain.
|
|
57
60
|
const fromKeychain = await _keychainGet(name)
|
|
@@ -84,12 +87,8 @@ async function _readCollectionSecret(name: string): Promise<string | null> {
|
|
|
84
87
|
const secrets = await col<SecretDoc>("secrets")
|
|
85
88
|
const entry = await secrets.get(name)
|
|
86
89
|
if (!entry) return null
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// Cache in memory for subsequent lookups in this process
|
|
90
|
-
_mem.set(name, plain)
|
|
91
|
-
}
|
|
92
|
-
return plain || null
|
|
90
|
+
// `_get` caches it; `loadDurableProviderApiKey` must not.
|
|
91
|
+
return decryptSecret(entry.doc.ciphertext, entry.doc.iv) || null
|
|
93
92
|
} catch {
|
|
94
93
|
return null
|
|
95
94
|
}
|
|
@@ -195,6 +194,15 @@ export async function loadProviderApiKey(id: string): Promise<string> {
|
|
|
195
194
|
return (await _get(`provider:${id}:api_key`)) ?? ""
|
|
196
195
|
}
|
|
197
196
|
|
|
197
|
+
/**
|
|
198
|
+
* The provider key from the durable `secrets` collection only. That collection
|
|
199
|
+
* is partitioned by tenant; the in-memory cache and the OS keychain are not, so
|
|
200
|
+
* a multi-tenant caller that must never see another tenant's key reads here.
|
|
201
|
+
*/
|
|
202
|
+
export async function loadDurableProviderApiKey(id: string): Promise<string> {
|
|
203
|
+
return (await _readCollectionSecret(`provider:${id}:api_key`)) ?? ""
|
|
204
|
+
}
|
|
205
|
+
|
|
198
206
|
export async function storeProviderHeaders(id: string, headers: Record<string, unknown>): Promise<boolean> {
|
|
199
207
|
return await _set(`provider:${id}:headers`, JSON.stringify(headers))
|
|
200
208
|
}
|