UNPKG

pi-decider

Version:

Decision backends for pi and omp — TypeSafe Jev, OpenRouter's Decisions API, or an OpenAI-compatible chat proxy — exposed as one typed tool (noul / choice / score)

578 lines (541 loc) • 24.9 kB
/** * The `/decide` command surface. Harness-agnostic: it talks to a small UI port, so the interactive * flows are driven by the pi/omp harness in production and by a scripted double in tests. */ import { listChatCatalog } from "./chat.ts"; import { applySetting, BACKEND_IDS, clearSetting, formatSettingValue, maskKey, mergeConfig, readConfigFile, resolveConfig, saveConfigFile, selectBackend, SETTABLE_FIELDS, SETTABLE_TARGETS, SUGGESTED_KEY_ENV, type BackendConfig, type BackendId, type BackendSelection, type DeciderConfig, } from "./config.ts"; import { describeModelRoute, listCatalog } from "./decisions.ts"; import { buildGroups, normalizeState, runGroup } from "./dispatch.ts"; import { JevError, messageOf } from "./errors.ts"; import { formatAnswers, formatComposite, formatConfigStatus, formatGate, formatModelList, type AnswerReport, type GroupMeta, } from "./format.ts"; import { detectHarness } from "./harness.ts"; import { normalizeQuestions, type ConformResult, type JevQuestion, type JsonValue, type QuestionRoute } from "./questions.ts"; import { compositeScore, evaluateGate, parseWeights, SHAPES, type ShapeKind } from "./shapes.ts"; /** The UI port the command surface needs; pi and omp both satisfy it. */ export interface CommandContext { ui: { select(title: string, options: string[], options2?: unknown): Promise<string | undefined>; input(title: string, placeholder?: string): Promise<string | undefined>; editor(title: string, prefill?: string): Promise<string | undefined>; confirm(title: string, message: string, options2?: unknown): Promise<boolean>; notify(message: string, type?: "info" | "warning" | "error"): void; }; signal?: AbortSignal; } const HELP_TEXT = [ "/decide resolved config, per-backend state, live probe (default)", "/decide add [backend] add/configure a backend: key, model, base URL (guided)", "/decide set [t] [f] [v] set one field, e.g. set openrouter model ~typesafe/jev-latest (guided)", "/decide unset [t] [f] drop a field, or a whole backend block (guided)", "/decide question [text] ask your own question; Enter through the prefills = smoke test", " shapes: single | fanout | gate | composite", "/decide models [b] [text] models reported by a backend catalogue", "/decide setup guided setup incl. the default-backend choice", "/decide help this text", "", "Aliases: /jev = /decide; setup = init; unset = remove = rm; question = ask.", `Backends: ${BACKEND_IDS.join(", ")}. Targets for set/unset: ${SETTABLE_TARGETS.join(", ")}.`, `Every value lives in <agentDir>/decider.json (harness ${detectHarness()}); keys may be "$ENV_VAR" references.`, ].join("\n"); export const SMOKE_STATE = "Help! My payouts have been failing for 3 days."; export const SMOKE_INSTRUCTION = "Does this convey urgency?"; const QUESTION_TYPE_OPTIONS = [ "noul — P(yes) in [0,1]", "choice — pick one option from a set", "score — position across ordered levels", ]; export function createCommandHandler(agentDir: string) { return async function handleCommand(args: string, ctx: CommandContext): Promise<void> { const parts = args.trim() === "" ? [] : args.trim().split(/\s+/); const subcommand = (parts[0] ?? "status").toLowerCase(); try { const cfg = resolveConfig({ agentDir }); if (subcommand === "help") { ctx.ui.notify(HELP_TEXT, "info"); return; } if (subcommand === "setup" || subcommand === "init") { await runSetup(cfg, ctx); return; } if (subcommand === "add") { await runAdd(cfg, parts[1], ctx); return; } if (subcommand === "set") { await runSet(cfg, parts.slice(1), ctx); return; } if (subcommand === "unset" || subcommand === "remove" || subcommand === "rm") { await runUnset(cfg, parts.slice(1), ctx); return; } if (subcommand === "models") { const [rest, backendId] = splitBackendArg(parts.slice(1)); await showModels(cfg, backendId, rest, ctx); return; } if (subcommand === "question" || subcommand === "ask") { const [rest, backendId] = splitBackendArg(parts.slice(1)); await runQuestion(cfg, backendId, rest, ctx); return; } if (subcommand !== "status") { ctx.ui.notify(`Unknown subcommand "${subcommand}".\n\n${HELP_TEXT}`, "warning"); return; } await showStatus(cfg, ctx); } catch (error) { ctx.ui.notify( `decide: ${messageOf(error)}${error instanceof JevError && error.hint ? `\n${error.hint}` : ""}`, "error", ); } }; } /** `models openrouter gpt` -> backend override + filter text. */ function splitBackendArg(parts: string[]): [string, BackendId | undefined] { const first = parts[0]; if (first !== undefined && (BACKEND_IDS as readonly string[]).includes(first)) { return [parts.slice(1).join(" "), first as BackendId]; } return [parts.join(" "), undefined]; } async function showStatus(cfg: DeciderConfig, ctx: CommandContext) { let selected: BackendConfig | undefined; let probe: string | undefined; try { selected = selectBackend(cfg); } catch (error) { probe = `no usable backend: ${messageOf(error)}`; } if (selected !== undefined) probe = await probeBackend(cfg, selected, ctx); ctx.ui.notify(formatConfigStatus(cfg, selected), selected === undefined ? "warning" : "info"); if (probe !== undefined) ctx.ui.notify(probe, "info"); } async function probeBackend(cfg: DeciderConfig, backend: BackendConfig, ctx: CommandContext): Promise<string> { try { if (backend.kind === "chat") { const names = await listChatCatalog(backend, cfg.modelsPath, ctx.signal); const wanted = backend.model; if (wanted === undefined) return `${backend.id}: catalogue lists ${names.length} model(s); no model configured`; return `${backend.id} catalogue → ${names.length} model(s); ${wanted} ${names.includes(wanted) ? "is available" : "is NOT in the list"}`; } const models = await listCatalog(backend, cfg.modelsPath, ctx.signal); if (backend.model !== undefined && models.some((model) => model.name === backend.model)) { return `${backend.id} catalogue → ${backend.model} available (${models.length} entries)`; } if (models.length === 0) return `${backend.id} catalogue returned no models`; try { const route = await describeModelRoute(backend, cfg.modelsPath, backend.model ?? "", ctx.signal); const parts = [ route.name === undefined ? route.id : `${route.id} (${route.name})`, ...(route.modality === undefined ? [] : [route.modality]), ...(route.outputModalities.length === 0 ? [] : [`out: ${route.outputModalities.join(",")}`]), ]; return `${backend.id} catalogue → ${backend.model} absent from ${models.length} entries but resolves: ${parts.join(" · ")}`; } catch (error) { return `${backend.id} catalogue → ${backend.model} absent from ${models.length} entries; route lookup failed: ${messageOf(error)}`; } } catch (error) { return `${backend.id} probe failed: ${messageOf(error)}`; } } async function showModels(cfg: DeciderConfig, backendId: BackendId | undefined, filter: string, ctx: CommandContext) { const backend = selectBackend(cfg, backendId); const names = backend.kind === "chat" ? await listChatCatalog(backend, cfg.modelsPath, ctx.signal) : (await listCatalog(backend, cfg.modelsPath, ctx.signal)).map((model) => model.releaseDate === undefined ? model.name : `${model.name} (${model.releaseDate})`, ); const needle = filter.trim().toLowerCase(); const filtered = needle === "" ? names : names.filter((name) => name.toLowerCase().includes(needle)); ctx.ui.notify(formatModelList(`${backend.id} catalogue @ ${backend.baseUrl}`, filtered), "info"); } interface RawQuestionInput { id: string; type: string; instructions: string; criteria?: unknown; } /** `/decide question [instruction]` — ask your own question across the documented shapes. */ async function runQuestion( cfg: DeciderConfig, backendId: BackendId | undefined, presetInstruction: string, ctx: CommandContext, ) { const dialogs = { signal: ctx.signal }; const backend = selectBackend(cfg, backendId); const model = backend.model; if (model === undefined || model.trim() === "") { ctx.ui.notify(`Backend "${backend.id}" has no model id configured. Run /decide add ${backend.id}.`, "warning"); return; } const shapeLabel = await ctx.ui.select("Question shape", Object.values(SHAPES).map((shape) => shape.label), dialogs); const shape = Object.values(SHAPES).find((candidate) => candidate.label === shapeLabel); if (shape === undefined) return cancelled(ctx); const collected = await collectQuestions(shape.kind, presetInstruction, ctx); if (collected === undefined) return cancelled(ctx); const stateInput = await ctx.ui.editor("State (text, or a JSON object/array)", SMOKE_STATE); if (stateInput === undefined) return cancelled(ctx); const state = normalizeState(stateInput); let threshold = 0.5; if (shape.needsThreshold) { const typed = await ctx.ui.input("Threshold (0..1)", "0.8"); if (typed === undefined) return cancelled(ctx); threshold = Number(typed.trim() === "" ? "0.8" : typed.trim()); if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) { ctx.ui.notify(`Threshold must be a number in [0,1] (got "${typed}").`, "error"); return; } } let weights: number[] = []; if (shape.needsWeights) { const typed = await ctx.ui.input( `Weights for ${collected.length} question(s), comma separated`, collected.map(() => "1").join(","), ); if (typed === undefined) return cancelled(ctx); weights = parseWeights(typed, collected.length); } const { ids, questions } = normalizeQuestions(collected); const outcome = await runQuestions(cfg, backend, ids, questions, {}, state, ctx); const report: AnswerReport = { ids, questions, ...outcome.conform }; const blocks = [formatAnswers(report, outcome.metas)]; if (shape.kind === "gate") blocks.push(formatGate(evaluateGate(ids, outcome.conform.answers, threshold), threshold)); if (shape.kind === "composite") blocks.push(formatComposite(compositeScore(ids, outcome.conform.answers, weights))); ctx.ui.notify(blocks.join("\n"), Object.keys(outcome.conform.issues).length > 0 ? "warning" : "info"); } interface QuestionBatchOutcome { conform: ConformResult; metas: GroupMeta[]; } /** One dispatch for a whole question set: group by backend+model, run the batches in parallel. */ async function runQuestions( cfg: DeciderConfig, backend: BackendConfig, ids: string[], questions: Record<string, JevQuestion>, routes: Record<string, QuestionRoute>, state: JsonValue, ctx: CommandContext, ): Promise<QuestionBatchOutcome> { const { groups, failures } = buildGroups(cfg, ids, routes, backend.id, undefined); const settled = await Promise.all(groups.map((group) => runGroup(group, state, questions, ctx.signal))); const conform: ConformResult = { answers: {}, issues: { ...failures }, notes: {} }; const metas: GroupMeta[] = []; for (const outcome of settled) { Object.assign(conform.answers, outcome.conform.answers); Object.assign(conform.issues, outcome.conform.issues); Object.assign(conform.notes, outcome.conform.notes); metas.push(outcome.meta); } return { conform, metas }; } /** Collect one question per pass, stopping when the shape has enough and the user says run. */ async function collectQuestions( kind: ShapeKind, preset: string, ctx: CommandContext, ): Promise<RawQuestionInput[] | undefined> { const dialogs = { signal: ctx.signal }; const spec = SHAPES[kind]; const collected: RawQuestionInput[] = []; let instruction = preset.trim(); for (;;) { if (collected.length > 0) { const enough = collected.length >= spec.minQuestions; const answer = await ctx.ui.select( enough ? "Add another question?" : `${spec.kind} needs at least ${spec.minQuestions} questions`, enough ? ["run now", "add another"] : ["keep going"], dialogs, ); if (answer === undefined) return undefined; if (answer === "run now") break; } if (instruction === "") { const typed = await ctx.ui.input("Question instructions (Enter = the suggestion)", SMOKE_INSTRUCTION); if (typed === undefined) return undefined; // A blank submit accepts the suggestion: that is what makes "Enter through" a smoke test. instruction = typed.trim() === "" ? SMOKE_INSTRUCTION : typed.trim(); } const typeOption = await ctx.ui.select("Question type", QUESTION_TYPE_OPTIONS, dialogs); if (typeOption === undefined) return undefined; const type = typeOption.split(/[\s—]/)[0] ?? "noul"; const criteria = await promptCriteria(type, ctx); if (!criteria.ok) return undefined; collected.push({ id: questionId(instruction, collected), type, instructions: instruction, ...(criteria.value === undefined ? {} : { criteria: criteria.value }), }); instruction = ""; if (kind === "single") break; } return collected; } async function promptCriteria( type: string, ctx: CommandContext, ): Promise<{ ok: true; value?: unknown } | { ok: false }> { const dialogs = { signal: ctx.signal }; if (type === "noul") { const text = await ctx.ui.editor("Noul criteria (optional)\nline 1 = what true means, line 2 = what false means", ""); if (text === undefined) return { ok: false }; const [yes, no] = text.split("\n").map((line) => line.trim()); if ((yes ?? "") === "" && (no ?? "") === "") return { ok: true }; return { ok: true, value: { ...((yes ?? "") === "" ? {} : { true: yes }), ...((no ?? "") === "" ? {} : { false: no }) } }; } if (type === "choice") { const text = await ctx.ui.editor("Choice options: one per line, `option` or `option: rubric` (at least two)", ""); if (text === undefined) return { ok: false }; const options: Record<string, string | null> = {}; for (const line of text.split("\n").map((entry) => entry.trim()).filter((entry) => entry !== "")) { const [option, ...rest] = line.split(":"); const rubric = rest.join(":").trim(); if ((option ?? "").trim() !== "") options[option.trim()] = rubric === "" ? null : rubric; } if (Object.keys(options).length < 2) { ctx.ui.notify("A choice question needs at least two options.", "error"); return { ok: false }; } return { ok: true, value: options }; } const text = await ctx.ui.editor("Score levels: one per line, lowest first (at least two)", ""); if (text === undefined) return { ok: false }; const levels = text.split("\n").map((entry) => entry.trim()).filter((entry) => entry !== ""); if (levels.length < 2) { ctx.ui.notify("A score question needs at least two levels.", "error"); return { ok: false }; } return { ok: true, value: levels }; } /** Slug of the instructions, deduplicated against the ids already collected. */ function questionId(instruction: string, collected: RawQuestionInput[]): string { const base = instruction .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, "") .slice(0, 32) || "question"; const taken = new Set(collected.map((entry) => entry.id)); if (!taken.has(base)) return base; for (let suffix = 2; ; suffix += 1) { const candidate = `${base}_${suffix}`; if (!taken.has(candidate)) return candidate; } } /** `/decide add [backend]` — guided configuration for one backend. */ async function runAdd(cfg: DeciderConfig, idArg: string | undefined, ctx: CommandContext) { const id = idArg ?? (await ctx.ui.select("Add which backend?", [...BACKEND_IDS], { signal: ctx.signal })); if (id === undefined) return cancelled(ctx); if (!(BACKEND_IDS as readonly string[]).includes(id)) { ctx.ui.notify(`Unknown backend "${id}". Backends: ${BACKEND_IDS.join(", ")}.`, "error"); return; } await configureBackend(cfg, id as BackendId, ctx); } /** The guided per-backend flow shared by `/decide add` and `/decide setup`. */ async function configureBackend( cfg: DeciderConfig, targetId: BackendId, ctx: CommandContext, extras: { backend?: BackendSelection } = {}, ) { const dialogs = { signal: ctx.signal }; const backend = cfg.backends[targetId]; const values: Record<string, unknown> = {}; const keyMode = await ctx.ui.select( `API key for ${targetId} (current: ${backend.apiKeySource})`, ["reference an environment variable (recommended)", "store a literal key in the config file", "keep current", "clear the key"], dialogs, ); if (keyMode === undefined) return cancelled(ctx); if (keyMode.startsWith("reference")) { const typed = await ctx.ui.input(`Environment variable holding the ${targetId} key`, SUGGESTED_KEY_ENV[targetId]); if (typed === undefined) return cancelled(ctx); const name = typed.trim() === "" ? SUGGESTED_KEY_ENV[targetId] : typed.trim(); values.apiKey = `$${name}`; if (!process.env[name]?.trim()) { ctx.ui.notify(`Note: $${name} is not set in this process yet — export it before the next call.`, "warning"); } } else if (keyMode.startsWith("store")) { const key = await ctx.ui.input(`API key (stored in ${cfg.configPath}; the dialog echoes in plain text)`); if (key === undefined || key.trim() === "") return cancelled(ctx); values.apiKey = key.trim(); } else if (keyMode.startsWith("clear")) { values.apiKey = undefined; } if (!keyMode.startsWith("clear")) { const model = await ctx.ui.input(`Model id for ${targetId} (current: ${backend.model ?? "not set"})`, backend.model ?? ""); if (model === undefined) return cancelled(ctx); if (model.trim() !== "") values.model = model.trim(); const baseUrl = await ctx.ui.input(`Base URL for ${targetId} (current: ${backend.baseUrl})`, backend.baseUrl); if (baseUrl === undefined) return cancelled(ctx); if (baseUrl.trim() !== "" && baseUrl.trim() !== backend.baseUrl) values.baseUrl = baseUrl.trim(); } const rows = [ `file ${cfg.configPath}`, ...(extras.backend === undefined ? [] : [`backend ${extras.backend}`]), `target ${targetId}`, ...Object.entries(values).map( ([key, value]) => `${key.padEnd(10)} ${formatSettingValue(key, targetId, value)}`, ), ]; const confirmed = await ctx.ui.confirm("Write config?", rows.join("\n"), dialogs); if (!confirmed) return cancelled(ctx); const root = readConfigFile(cfg.configPath); saveConfigFile( cfg.configPath, mergeConfig(root, { ...(extras.backend === undefined ? {} : { backend: extras.backend }), backendBlock: { id: targetId, values }, }), ); ctx.ui.notify(`Wrote ${cfg.configPath}\n\n${rows.join("\n")}\n\nRun /decide status to verify.`, "info"); } /** `/decide set [target] [field] [value]` — interactive when any part is missing. */ async function runSet(cfg: DeciderConfig, parts: string[], ctx: CommandContext) { const dialogs = { signal: ctx.signal }; const [targetArg, fieldArg, ...rest] = parts; let target: string | undefined = targetArg; let field: string | undefined = fieldArg; if (target === undefined) { const picked = await ctx.ui.select("Set which target?", [...SETTABLE_TARGETS], dialogs); if (picked === undefined) return cancelled(ctx); target = picked; } const resolvedTarget = target === "backend" ? "root" : target; // `set backend llm` puts the value where a field name would go, so handle it before field lookup. if (target === "backend" && field !== undefined) { await writeSetting(cfg, target, field, field, ctx); return; } const table = SETTABLE_FIELDS[resolvedTarget as BackendId | "root"]; if (table === undefined) { ctx.ui.notify(`Unknown target "${target}". Targets: ${SETTABLE_TARGETS.join(", ")}.`, "error"); return; } if (field === undefined) { const picked = await ctx.ui.select( `Set which field of ${resolvedTarget}?`, Object.keys(table).map((name) => `${name} — now ${resolvedFieldValue(cfg, resolvedTarget, name)}`), dialogs, ); if (picked === undefined) return cancelled(ctx); field = picked.split(" — ")[0]; } const spec = field === undefined ? undefined : table[field]; if (spec === undefined) { ctx.ui.notify(`Unknown field "${field}" for ${resolvedTarget}. Writable: ${Object.keys(table).join(", ")}.`, "error"); return; } // `set backend llm` names the value in the field slot; there is no separate value argument. const shortcut = target === "backend" && field !== undefined; let raw = shortcut ? field : rest.join(" ").trim(); if (raw === "") { const typed = await ctx.ui.input(`${resolvedTarget}.${field}`, resolvedFieldValue(cfg, resolvedTarget, field)); if (typed === undefined) return cancelled(ctx); raw = typed.trim(); if (raw === "") return cancelled(ctx); } await writeSetting(cfg, target, field as string, raw, ctx); } /** Write one validated setting, then report it with credentials masked. */ async function writeSetting( cfg: DeciderConfig, target: string, field: string, raw: string, ctx: CommandContext, ) { const resolvedTarget = target === "backend" ? "root" : target; const label = resolvedTarget === "root" ? field : `${resolvedTarget}.${field}`; saveConfigFile(cfg.configPath, applySetting(readConfigFile(cfg.configPath), target, field, raw)); ctx.ui.notify( `Wrote ${cfg.configPath}\n${label} = ${formatSettingValue(field, resolvedTarget, raw)}\n\nRun /decide status to verify.`, "info", ); } /** `/decide unset [target] [field]` — drop one field, or a whole backend block. */ async function runUnset(cfg: DeciderConfig, parts: string[], ctx: CommandContext) { const dialogs = { signal: ctx.signal }; const [targetArg, fieldArg] = parts; let target: string | undefined = targetArg; let field: string | undefined = fieldArg; if (target === undefined) { const picked = await ctx.ui.select("Unset from which target?", [...SETTABLE_TARGETS], dialogs); if (picked === undefined) return cancelled(ctx); target = picked; } const resolvedTarget = target === "backend" ? "root" : target; const table = SETTABLE_FIELDS[resolvedTarget as BackendId | "root"]; if (table === undefined) { ctx.ui.notify(`Unknown target "${target}". Targets: ${SETTABLE_TARGETS.join(", ")}.`, "error"); return; } if (field === undefined && resolvedTarget !== "root") { const picked = await ctx.ui.select(`Unset what from ${target}?`, ["(whole block)", ...Object.keys(table)], dialogs); if (picked === undefined) return cancelled(ctx); field = picked === "(whole block)" ? undefined : picked; } const what = field === undefined ? `the ${target} block` : `${resolvedTarget}.${field}`; const confirmed = await ctx.ui.confirm("Unset?", `Remove ${what} from ${cfg.configPath}`, dialogs); if (!confirmed) return cancelled(ctx); saveConfigFile(cfg.configPath, clearSetting(readConfigFile(cfg.configPath), target, field)); ctx.ui.notify(`Wrote ${cfg.configPath}\nRemoved ${what}.\n\nRun /decide status to verify.`, "info"); } /** Resolved value of one writable field, for dialog labels and prefills. */ function resolvedFieldValue(cfg: DeciderConfig, target: string, field: string): string { if (target === "root") { if (field === "backend") return `${cfg.backend} (${cfg.backendSource})`; if (field === "modelsPath") return cfg.modelsPath; return "not set"; } const value = (cfg.backends[target as BackendId] as unknown as Record<string, unknown>)[field]; if (value === undefined) return "not set"; if (field === "apiKey") return maskKey(value as string); return String(value); } /** `/decide setup`: pick the default backend, then run the guided flow for it. */ async function runSetup(cfg: DeciderConfig, ctx: CommandContext) { const dialogs = { signal: ctx.signal }; const selection = await ctx.ui.select(`Default backend (current: ${cfg.backend})`, ["auto", ...BACKEND_IDS], dialogs); if (selection === undefined) return cancelled(ctx); const targetId = (selection === "auto" ? await ctx.ui.select("Configure which backend?", [...BACKEND_IDS], dialogs) : selection) as BackendId | undefined; if (targetId === undefined) return cancelled(ctx); await configureBackend(cfg, targetId, ctx, { backend: selection as BackendSelection }); } function cancelled(ctx: CommandContext) { ctx.ui.notify("Cancelled.", "warning"); }