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)
215 lines (196 loc) • 9.76 kB
text/typescript
/**
* pi extension: TypeSafe Jev (and chat-model stand-ins) as a single decision tool.
*
* Registers:
* - `decide` tool — state + typed questions (noul/choice/score) -> typed answers
* - `/decide` — status | add | set | unset | question | models | setup | help (alias: `/jev`)
*
* Wiring only: the dispatch lives in lib/dispatch.ts and the command surface in lib/commands.ts,
* so both stay testable without a harness. Every call picks a backend, and both the call and each
* individual question may override it, so one request can combine several models.
*/
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import {
BACKEND_IDS,
describeBackends,
resolveConfig,
selectBackend,
type BackendId,
} from "./lib/config.ts";
import { createCommandHandler, type CommandContext } from "./lib/commands.ts";
import { buildGroups, runGroup, normalizeState, type GroupOutcome } from "./lib/dispatch.ts";
import { JevError, messageOf } from "./lib/errors.ts";
import { formatAnswers, type AnswerReport, type GroupMeta } from "./lib/format.ts";
import { resolveAgentDirWithHarness } from "./lib/harness.ts";
import { normalizeQuestions, type JevAnswer, type JevQuestion, type JsonValue } from "./lib/questions.ts";
import { stringEnum } from "./lib/schema.ts";
import { toPiUsage, type TokenCounts } from "./lib/usage.ts";
const QUESTION_TYPES = ["noul", "choice", "score"] as const;
const QUESTION_SCHEMA = Type.Object({
id: Type.String({ description: "Answer key for this question; the answer comes back under the same id." }),
type: stringEnum(QUESTION_TYPES),
instructions: Type.Union([Type.String(), Type.Object({}, { additionalProperties: true }), Type.Array(Type.Any())], {
description:
"One narrow, coherent judgment. A string for plain questions, or structured JSON when definitions, contrasts, or examples clarify it.",
}),
criteria: Type.Optional(
Type.Union(
[
Type.Record(Type.String(), Type.Union([Type.String(), Type.Null()])),
Type.Array(Type.Union([Type.String(), Type.Object({}, { additionalProperties: true })])),
],
{
description:
"noul: {true?, false?} descriptions. choice: {option: rubric|null} (or an array of options), at least two. score: ordered level descriptions, lowest first, at least two.",
},
),
),
backend: Type.Optional(stringEnum(BACKEND_IDS)),
model: Type.Optional(Type.String({ description: "Route only this question to a specific model id." })),
});
const TOOL_PARAMETERS = Type.Object({
state: Type.Union([Type.String(), Type.Object({}, { additionalProperties: true }), Type.Array(Type.Any())], {
description:
"Everything the judgments need: source text, records, policies, current facts. A string, or structured JSON (a JSON-encoded string is also accepted).",
}),
questions: Type.Array(QUESTION_SCHEMA, {
description:
"One entry per independent judgment. type: noul = P(yes) in [0,1]; choice = one option from criteria plus a full distribution; score = graded position across ordered levels. Ask independent questions together — they are answered in parallel.",
}),
backend: Type.Optional(
stringEnum(
BACKEND_IDS,
"Backend for every question that does not set its own. Defaults to the configured backend. Requests to different backends run in parallel.",
),
),
model: Type.Optional(
Type.String({ description: "Model id for every question that does not set its own; overrides the backend's configured model." }),
),
});
interface DeciderToolDetails {
status: "asking" | "answered";
groups?: GroupMeta[];
questions?: Record<string, JevQuestion>;
answers?: Record<string, JevAnswer>;
issues?: Record<string, string>;
notes?: Record<string, string>;
costKnown?: boolean;
message?: string;
}
export default async function deciderExtension(pi: ExtensionAPI) {
// Resolved once at load time so every tool call and command share one config location.
const agentDir = await resolveAgentDirWithHarness();
pi.registerTool({
name: "decide",
label: "Decide (Jev / decision backends)",
description:
"Ask a decision backend (TypeSafe Jev, OpenRouter's Decisions API, or a chat-model proxy) for calibrated typed " +
"judgments over state you provide: a yes/no probability (noul), one option out of a defined set with a full " +
"probability distribution (choice), or a graded score across ordered levels (score). Use it for routing, " +
"ranking, extraction-verification, moderation, and any decision where a probability beats prose. Code keeps " +
"control: answers come back with probabilities and confidence you can threshold. Each question may name its own " +
"backend/model, so one call can combine several. " +
"Configured by <agentDir>/decider.json — run /decide setup, or set TYPESAFE_API_KEY (typesafe), " +
"OPENROUTER_API_KEY (openrouter), or DECIDER_LLM_API_KEY plus a chat model id (llm). " +
"Load the `decide` skill for question-design and shape guidance, including the gate and composite patterns.",
promptSnippet: "Ask Jev or another decision backend for calibrated typed judgments (noul/choice/score) over supplied state",
promptGuidelines: [
"Use decide when a decision needs a calibrated probability, a pick among named options, or a graded score instead of a general-purpose model's prose.",
"Batch independent decide questions into one call; keep each question narrow and put all needed evidence in state.",
"Route individual decide questions with their own backend/model only when a second opinion or a cheaper model is genuinely useful; unset questions use the call-level backend.",
"Give every decide choice question a no-match option, and threshold the returned probabilities/confidence in code rather than in the prompt.",
],
parameters: TOOL_PARAMETERS,
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
const cfg = resolveConfig({ agentDir });
const { ids, questions, routes } = normalizeQuestions(params.questions);
const state = normalizeState(params.state);
const { groups, failures } = buildGroups(cfg, ids, routes, params.backend, params.model);
onUpdate?.({
content: [
{
type: "text",
text: `Asking ${ids.length} question(s) across ${groups.length} backend batch(es): ${groups
.map((group) => `${group.backend.id}/${group.model} (${group.ids.length})`)
.join(", ")}`,
},
],
details: { status: "asking" } satisfies DeciderToolDetails,
});
const settled = await Promise.all(
groups.map(async (group) => {
try {
return { group, outcome: await runGroup(group, state, questions, signal), error: undefined };
} catch (error) {
return { group, outcome: undefined, error };
}
}),
);
const answers: Record<string, JevAnswer> = {};
const issues: Record<string, string> = { ...failures };
const notes: Record<string, string> = {};
const metas: GroupMeta[] = [];
const tokens: TokenCounts = { input: 0, output: 0 };
let costUsd = 0;
let costKnown = Object.keys(failures).length === 0;
for (const { group, outcome, error } of settled) {
if (outcome === undefined) {
for (const id of group.ids) issues[id] = `${group.backend.id} backend failed: ${messageOf(error)}`;
costKnown = false;
continue;
}
Object.assign(answers, outcome.conform.answers);
Object.assign(issues, outcome.conform.issues);
Object.assign(notes, outcome.conform.notes);
metas.push(outcome.meta);
tokens.input = (tokens.input ?? 0) + (outcome.tokens.input ?? 0);
tokens.output = (tokens.output ?? 0) + (outcome.tokens.output ?? 0);
if (outcome.costUsd === undefined) costKnown = false;
else costUsd += outcome.costUsd;
}
if (Object.keys(answers).length === 0) {
const detail = Object.entries(issues)
.map(([id, issue]) => `${id}: ${issue}`)
.join("; ");
throw new JevError(
`No question could be answered: ${detail}`,
groups.length === 0 ? { hint: describeBackends(cfg) } : {},
);
}
const report: AnswerReport = { ids, questions, answers, issues, notes };
const text = formatAnswers(report, metas);
const failed = Object.keys(issues).length;
return {
content: [
{ type: "text", text },
...(failed > 0 ? [{ type: "text" as const, text: `${failed} question(s) failed; see the lines marked ERROR.` }] : []),
],
details: {
status: "answered",
groups: metas,
questions,
answers,
issues,
notes,
costKnown,
} satisfies DeciderToolDetails,
usage: toPiUsage(tokens, costKnown ? costUsd : undefined),
};
},
});
// The command surface lives in lib/commands.ts so it is testable without a harness.
const handle = createCommandHandler(agentDir);
pi.registerCommand("decide", {
description: "Decision backends: status, add, set, unset, question, models, setup",
getArgumentCompletions: (prefix) =>
["status", "add", "set", "unset", "question", "models", "setup", "help"]
.filter((name) => name.startsWith(prefix))
.map((name) => ({ value: name, label: name })),
handler: handle,
});
pi.registerCommand("jev", {
description: "Alias of /decide",
handler: handle,
});
}