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
text/typescript
/**
* 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");
}