/** * Capability-gateway Jev-assisted tool routing. * * Two callers, one decision body: * * - The **pre-turn tie-break** (host-driven): when the deterministic router returns an ambiguous * lexical hint among optional tools, the gateway offers just those hinted tools to Jev as one * bounded choice question. Jev may answer `none` or one hinted canonical tool name; the host * revalidates eligibility against the live catalog before activating anything for this run. * - The **on-demand route** (agent-driven): the agent calls `capability_discover` with a job * description instead of a name. Tools and skills are different decisions — a tool is code the * agent may call many times, a skill is a procedure it reads once — so one request asks two * choice questions, each over its own catalog slice with its own `none`. Tools are fitted into * the budget first, so skills can never crowd a tool out. * * Either way Jev never sees a tool schema, prior conversation, or system prompt: the request carries * a bounded material text and compact discovery lines (name, kind, category, one-line purpose). It * is never consulted for a unique deterministic activation, a skill-only match, or a prompt with no * lexical tool signal; every failure, timeout, low-confidence answer, or `none` is an abstention. * The transport and validation half lives in the shared `../jev/decisions.ts` client. * * Configuration (enabled by default, but requires Token-In credentials). The gateway reads its * own settings area — it does not inherit `jevAdvisory` route policy; only the Jev provider/model * identity is shared so every Jev consumer defaults to the same deployment: * * "capabilityGateway": { * "routing": { "jev": { "enabled": true, "timeoutMs": 1000, "discoverTimeoutMs": 5000, * "minConfidence": 0.6, "payloadBytes": 32768 } } * } */ import { readFileSync } from "node:fs"; import { getSettingsPath, type ExtensionContext } from "@selesai/code"; import { askJev, buildConversation, buildJevPayload, DEFAULT_JEV_ADVISORY_CONFIG, JEV_REQUEST_MAX_BYTES, type JevAbstainReason as JevClientAbstainReason, type JevConnection, type JevDecision, type JevQuestion, } from "../jev/decisions.ts"; import type { CatalogEntry } from "./catalog.ts"; /** Choice-question key for capability routing. */ export const JEV_CAPABILITY_QUESTION = "capability"; /** The abstention answer that is always offered to Jev. */ export const NO_TOOL = "none"; /** Jev only breaks real ties: offer two or three hinted tools, never a singleton. */ export const MIN_GATEWAY_JEV_CANDIDATES = 2; export const MAX_GATEWAY_JEV_CANDIDATES = 3; /** Bounded slice of the current prompt sent as Jev's only conversation material. */ export const GATEWAY_JEV_PROMPT_CHARS = 2_000; /** * A pre-turn tie-break must not hold up the run: the default timeout is short even though the * shared advisory default is 8s, and `gatewayJevConnection` hard-caps any configured override. */ export const DEFAULT_GATEWAY_JEV_TIMEOUT_MS = 1_000; export const GATEWAY_JEV_MAX_TIMEOUT_MS = 2_000; /** * The agent's on-demand route asks over the whole catalog and the agent chose to wait for it, so it * gets its own, longer deadline — capped like the `ask_jev` route rather than like a pre-turn hop. */ export const DEFAULT_GATEWAY_JEV_DISCOVER_TIMEOUT_MS = 5_000; export const GATEWAY_JEV_DISCOVER_MAX_TIMEOUT_MS = 15_000; /** One gateway route's Jev settings: the shared Jev endpoint plus this route's timing. */ export interface GatewayJevConfig { enabled: boolean; provider: string; model: string; baseUrl?: string; timeoutMs: number; /** Deadline for the agent's `capability_discover({ job })` decision. */ discoverTimeoutMs: number; minConfidence: number; /** Hard cap on the serialized decision request; oversized requests abstain. */ payloadBytes: number; } export const DEFAULT_GATEWAY_JEV_CONFIG: GatewayJevConfig = { enabled: true, // The Jev deployment identity, shared with every other Jev consumer. provider: DEFAULT_JEV_ADVISORY_CONFIG.provider, model: DEFAULT_JEV_ADVISORY_CONFIG.model, timeoutMs: DEFAULT_GATEWAY_JEV_TIMEOUT_MS, discoverTimeoutMs: DEFAULT_GATEWAY_JEV_DISCOVER_TIMEOUT_MS, minConfidence: 0.6, // Room for a whole catalog on the on-demand route; the tie-break sends a few hundred bytes. payloadBytes: 32 * 1024, }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function stringOr(value: unknown, fallback: string): string { return typeof value === "string" && value.trim() !== "" ? value : fallback; } function numberOr(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; } /** Read `capabilityGateway.routing.jev` over the gateway defaults. Never throws. */ export function readGatewayJevConfig(settingsPath: string = getSettingsPath()): GatewayJevConfig { let raw: Record | undefined; try { const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf-8")); if (isRecord(parsed) && isRecord(parsed.capabilityGateway) && isRecord(parsed.capabilityGateway.routing)) { const jev = parsed.capabilityGateway.routing.jev; if (isRecord(jev)) raw = jev; } } catch { // Missing or malformed settings: use the default-on route preference. } if (!raw) return DEFAULT_GATEWAY_JEV_CONFIG; const baseUrl = raw.baseUrl; return { enabled: raw.enabled === undefined ? DEFAULT_GATEWAY_JEV_CONFIG.enabled : raw.enabled === true, provider: stringOr(raw.provider, DEFAULT_GATEWAY_JEV_CONFIG.provider), model: stringOr(raw.model, DEFAULT_GATEWAY_JEV_CONFIG.model), baseUrl: typeof baseUrl === "string" && baseUrl.trim() !== "" ? baseUrl : undefined, timeoutMs: numberOr(raw.timeoutMs, DEFAULT_GATEWAY_JEV_CONFIG.timeoutMs), discoverTimeoutMs: numberOr(raw.discoverTimeoutMs, DEFAULT_GATEWAY_JEV_CONFIG.discoverTimeoutMs), minConfidence: numberOr(raw.minConfidence, DEFAULT_GATEWAY_JEV_CONFIG.minConfidence), payloadBytes: numberOr(raw.payloadBytes, DEFAULT_GATEWAY_JEV_CONFIG.payloadBytes), }; } /** The transport settings one gateway route uses, each with its own hard-capped deadline. */ export function gatewayJevConnection(config: GatewayJevConfig, onDemand = false): JevConnection { return { provider: config.provider, model: config.model, baseUrl: config.baseUrl, timeoutMs: onDemand ? Math.min(config.discoverTimeoutMs, GATEWAY_JEV_DISCOVER_MAX_TIMEOUT_MS) : Math.min(config.timeoutMs, GATEWAY_JEV_MAX_TIMEOUT_MS), minConfidence: config.minConfidence, }; } /** * The tools an ambiguous deterministic hint may offer Jev: eligible extension tools only, never * skills or always-active tools, capped at `MAX_GATEWAY_JEV_CANDIDATES`. */ export function hintedToolCandidates(hints: readonly CatalogEntry[] = []): CatalogEntry[] { // ponytail: retain catalog order after the cap; add specificity ranking if real hint ties crowd out candidates. return hints.filter((entry) => entry.kind === "tool" && entry.eligible).slice(0, MAX_GATEWAY_JEV_CANDIDATES); } /** Longest candidate summary sent as a criterion; a whole catalog has to fit one request. */ export const MAX_CANDIDATE_CRITERION_CHARS = 160; /** JSON scaffolding one candidate costs in the decision request: key, quoting, separator. */ const CANDIDATE_ENVELOPE_BYTES = 32; function clipText(text: string, maxChars: number): string { return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text; } /** Allowlisted choices: `none` plus one compact discovery-metadata line per candidate. */ export function candidateCriteria(candidates: CatalogEntry[]): Record { const criteria: Record = { [NO_TOOL]: "No optional capability is needed for the job.", }; for (const candidate of candidates) { const category = candidate.category ? ` Category: ${candidate.category}.` : ""; const aliases = candidate.aliases.length > 0 ? ` Aliases: ${candidate.aliases.join(", ")}.` : ""; criteria[candidate.name] = clipText(`${candidate.summary}${category}${aliases}`, MAX_CANDIDATE_CRITERION_CHARS); } return criteria; } /** * The offered entries that fit one bounded decision request, in catalog order. * * An entry that does not fit is skipped rather than truncated: a candidate offered without a usable * criterion would be judged on its name alone. `dropped` is reported so the caller can tell the * agent the search was not exhaustive. * * ponytail: greedy order-independent fit; rank by relevance first if a real install drops enough * candidates to misroute. */ export function fitCatalogCandidates( entries: readonly CatalogEntry[], budgetBytes: number, ): { candidates: CatalogEntry[]; dropped: number } { const offered = entries.filter((entry) => entry.eligible); const candidates: CatalogEntry[] = []; let used = 0; for (const entry of offered) { const criterion = candidateCriteria([entry])[entry.name] ?? ""; const cost = CANDIDATE_ENVELOPE_BYTES + Buffer.byteLength(`${entry.name}${criterion}`, "utf-8"); if (used + cost > budgetBytes) continue; candidates.push(entry); used += cost; } return { candidates, dropped: offered.length - candidates.length }; } /** Why no tool was routed: a clean `none`, an empty candidate set, or an absent Jev decision. */ export type JevToolAbstainReason = "none" | "no-candidates" | "unquantified" | JevClientAbstainReason; export type JevToolRoute = | { selected: true; tool: string; confidence: number; candidates: number; elapsedMs: number } | { selected: false; reason: JevToolAbstainReason; candidates: number; elapsedMs: number }; /** Absence reasons that mean Jev could not be consulted at all, rather than answering nothing. */ export const JEV_UNAVAILABLE_REASONS: ReadonlySet = new Set([ "no-template", "no-credential", "timeout", "transport", ]); /** The decision call, injectable so catalog routing is testable without a live transport. */ export type JevAsk = typeof askJev; /** One route's question wording; the criteria and allowlist are built from the candidates. */ export interface CapabilityQuestion { question: string; focus: string; } /** The host's pre-turn tie-break: only the hinted tools, and only a weak lexical signal. */ export const TIE_BREAK_QUESTION: CapabilityQuestion = { question: "Which single hinted optional tool should be activated for the latest request in `conversation`, " + "or `none` when none of them is clearly needed?", focus: "Choose `none` unless exactly one listed tool is clearly needed; the prompt only weakly suggests these tools.", }; /** Question keys of the on-demand route: one decision per capability kind. */ export const JEV_TOOL_QUESTION = "tool"; export const JEV_SKILL_QUESTION = "skill"; /** On demand, tools: callable code the agent may use many times during the job. */ export const JOB_TOOL_QUESTION: CapabilityQuestion = { question: "Which single listed tool should the agent activate for the job in `conversation`, or `none` when the " + "built-in tools (read, bash, edit, write, grep, find, ls) or a direct answer are enough?", focus: "A tool is callable code the agent may call many times during the job. Most jobs need no optional tool: " + "choose one name only when it clearly fits the job.", }; /** On demand, skills: a written procedure the agent reads once before doing the job. */ export const JOB_SKILL_QUESTION: CapabilityQuestion = { question: "Which single listed skill's instructions should the agent load for the job in `conversation`, or `none` " + "when no written procedure is needed?", focus: "A skill is a written procedure the agent reads once before doing the job. Choose one name only when the " + "job clearly matches what that skill describes.", }; /** * Offer one candidate set to Jev and return the capability it selected. Anything outside one * confident, canonical, allowlisted choice is an abstention. */ async function offerCandidates( candidates: readonly CatalogEntry[], material: string, prompt: CapabilityQuestion, ctx: Pick, config: GatewayJevConfig, ask: JevAsk, ): Promise { if (candidates.length === 0) return { selected: false, reason: "no-candidates", candidates: 0, elapsedMs: 0 }; const question: JevQuestion = { question: prompt.question, focus: prompt.focus, criteria: candidateCriteria([...candidates]), }; // Only the current material, bounded: no prior conversation, no tool schema, no history. const payload = buildJevPayload( buildConversation(material, [], { contextTurns: 1, contextChars: GATEWAY_JEV_PROMPT_CHARS }), { [JEV_CAPABILITY_QUESTION]: question }, ); const decision = await ask(ctx, gatewayJevConnection(config), { payload, maxBytes: Math.min(config.payloadBytes, JEV_REQUEST_MAX_BYTES), allowed: { [JEV_CAPABILITY_QUESTION]: [NO_TOOL, ...candidates.map((candidate) => candidate.name)] }, }); const picked = pickFrom(decision, JEV_CAPABILITY_QUESTION); return picked.selected ? { selected: true, tool: picked.name, confidence: picked.confidence, candidates: candidates.length, elapsedMs: decision.elapsedMs } : { selected: false, reason: picked.reason, candidates: candidates.length, elapsedMs: decision.elapsedMs }; } /** One question's outcome: a confident allowlisted name, or why there is none. */ export type JevPick = | { selected: true; name: string; confidence: number } | { selected: false; reason: JevToolAbstainReason }; /** Read one question out of a decision. Anything outside one confident, canonical, non-`none` choice abstains. */ function pickFrom(decision: JevDecision, question: string): JevPick { const answer = decision.choices[question]; if (!answer) return { selected: false, reason: decision.rejected[question] ?? decision.failure ?? "missing" }; if (answer.choice === NO_TOOL) return { selected: false, reason: "none" }; // A choice without a numeric confidence is not a confident enough answer to act on. if (answer.confidence === undefined) return { selected: false, reason: "unquantified" }; return { selected: true, name: answer.choice, confidence: answer.confidence }; } /** * Offer the hinted tools to Jev and return the tool it selected for this run. */ export async function routeToJevTool( hints: readonly CatalogEntry[], prompt: string, ctx: Pick, config: GatewayJevConfig, ask: JevAsk = askJev, ): Promise { return offerCandidates(hintedToolCandidates(hints), prompt, TIE_BREAK_QUESTION, ctx, config, ask); } /** Room kept per question for its wording and scaffolding around the candidate criteria. */ const QUESTION_SCAFFOLDING_BYTES = 512; /** One capability kind's side of an on-demand decision. */ export interface JevJobPick { pick: JevPick; /** Candidates Jev was shown. */ offered: number; /** Eligible candidates left out to fit the request budget. */ dropped: number; } /** The on-demand decision: a tool and a skill, each chosen or not on its own. */ export interface JevJobRoute { tool: JevJobPick; skill: JevJobPick; elapsedMs: number; } /** * The agent's on-demand route: ask which tool and which skill fit the job, as two choice questions * in one request. Pass an empty list to leave a kind out; it is then not asked at all. * * The budget goes to tools first. Skills are the long tail users, agents, and teammates keep adding, * so if anything is dropped it is a skill, and the caller reports it. */ export async function routeJobToJev( tools: readonly CatalogEntry[], skills: readonly CatalogEntry[], job: string, ctx: Pick, config: GatewayJevConfig, ask: JevAsk = askJev, ): Promise { const maxBytes = Math.min(config.payloadBytes, JEV_REQUEST_MAX_BYTES); const material = job.slice(0, GATEWAY_JEV_PROMPT_CHARS); let budget = maxBytes - Buffer.byteLength(material, "utf-8") - 2 * QUESTION_SCAFFOLDING_BYTES; const fittedTools = fitCatalogCandidates(tools, Math.max(0, budget)); budget -= Buffer.byteLength(JSON.stringify(candidateCriteria(fittedTools.candidates)), "utf-8"); const fittedSkills = fitCatalogCandidates(skills, Math.max(0, budget)); const side = (fitted: { candidates: CatalogEntry[]; dropped: number }, pick: JevPick): JevJobPick => ({ pick, offered: fitted.candidates.length, dropped: fitted.dropped, }); const notAsked: JevPick = { selected: false, reason: "no-candidates" }; const questions: Record = {}; const allowed: Record = {}; for (const [key, fitted, wording] of [ [JEV_TOOL_QUESTION, fittedTools, JOB_TOOL_QUESTION], [JEV_SKILL_QUESTION, fittedSkills, JOB_SKILL_QUESTION], ] as const) { if (fitted.candidates.length === 0) continue; questions[key] = { ...wording, criteria: candidateCriteria(fitted.candidates) }; allowed[key] = [NO_TOOL, ...fitted.candidates.map((candidate) => candidate.name)]; } if (Object.keys(questions).length === 0) { return { tool: side(fittedTools, notAsked), skill: side(fittedSkills, notAsked), elapsedMs: 0 }; } // Only the job, bounded: no prior conversation, no tool schema, no skill body. const payload = buildJevPayload( buildConversation(material, [], { contextTurns: 1, contextChars: GATEWAY_JEV_PROMPT_CHARS }), questions, ); const decision = await ask(ctx, gatewayJevConnection(config, true), { payload, maxBytes, allowed }); return { tool: side(fittedTools, allowed[JEV_TOOL_QUESTION] ? pickFrom(decision, JEV_TOOL_QUESTION) : notAsked), skill: side(fittedSkills, allowed[JEV_SKILL_QUESTION] ? pickFrom(decision, JEV_SKILL_QUESTION) : notAsked), elapsedMs: decision.elapsedMs, }; }