Decisions
Typed choices with a confidence, made by a swappable decider, with a person below the threshold.
A decision step asks fixed questions, each with fixed options, and gets back one option per question with a confidence. Who answers is a decider: a plain function, a language model, a dedicated decision model, a rules table. The step guarantees the shape. An answer outside the options fails the step and never reaches a dependent.
const triage = defineDecisionStep({
type: 'mail:triage',
workflowInputSchema: input,
dependencies: { parseMail },
questions: {
tone: ['aggressive', 'friendly'],
audience: ['invoicing', 'customer-support', 'tech-support'],
priority: ['low', 'medium', 'high'],
},
input: (ctx) => ctx.deps.parseMail, // the state to decide on: data, not a prompt
decider: createModelDecider(), // from octaflow/ai, or any Decider
threshold: 0.8,
});
The output is typed by the options: output.answers.audience.choice is
'invoicing' | 'customer-support' | 'tech-support'. Each answer also carries a
confidence and, where the decider gives them, a distribution and a reason. The output
also records the decider’s id, its calibrated claim, and review: the questions
answered below their threshold.
Write the questions inline. That keeps their literals, so nothing needs as const.
A person below the threshold
threshold is one number for every question, or one per question. A question left out of
a per-question map is never flagged. Add a review step and an outcome step:
const triageReview = defineDecisionReviewStep({ type: 'mail:review', decision: { triage } });
const outcome = defineDecisionOutcomeStep({ type: 'mail:outcome', decision: { triage }, review: { triageReview } });
const invoicing = defineStep({
dependencies: { outcome },
when: (ctx) => ctx.deps.outcome.choices.audience === 'invoicing',
// …
});
- The review is a wait step guarded on
review.length > 0. When nothing was flagged it is skipped. Otherwise it suspends untilengine.resumeStep(workflowId, 'triageReview', { answers: { audience: 'tech-support' }, reviewer }). A person may answer any subset of the questions.timeoutMswithonTimeout: 'accept'keeps the decider’s answers when nobody reviews in time. - The outcome joins the two with
join: 'any'. It runs straight after the decision when the review was skipped, and waits while a review is pending. It outputs{ choices, decidedBy, decider, reviewer? }: a person’s choice wins where they made one.resolveDecisionis the same merge as a function.
Branch on outcome.choices with ordinary when guards. Route on the
dimension that changes which work happens, and pass the others along as data.
Deciders
interface Decider<TContext> {
readonly id: string; // recorded on every output
readonly calibrated: boolean; // are the confidences probabilities?
decide(request: DecisionRequest, context: TContext): Promise<DecisionResponse>;
}
functionDecider(id, fn)wraps rules you can state. It answers with confidence 1 and never asks for review.createModelDecider()(octaflow/ai) asks a language model with structured output. The options become an enum it must choose from, and it states a confidence and a reason. By default it uses the step’s instrumentedctx.context.model, so tokens and cost are recorded. It iscalibrated: false: a language model’s stated confidence is a signal, not a probability, so treat its threshold as a policy setting and check it against what reviewers overrule. The decision input leaves the process, so pass a model behind your disclosure gate.- Your own: a decision model trained for calibrated confidences, a local classifier,
a rules engine. Implement
decideand setcalibratedhonestly. A response may name the model that actually answered indecider.
A decider that answers a question it was not asked, skips one, or gives an option or
confidence out of range fails the step without retry, because that decider is broken,
not flaky. A thrown error is classified like any step’s, so a provider’s 429 still retries
under the step’s retry policy.
Version 0.28.0, MIT-licensed.npm