@noetic-tools/core 5.5.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/builders/control-flow-builders.d.ts +6 -4
  2. package/dist/builders/control-flow-builders.js +3 -2
  3. package/dist/builders/decide-builders.d.ts +86 -0
  4. package/dist/builders/decide-builders.js +230 -0
  5. package/dist/builders/dynamic-workflow.js +1 -0
  6. package/dist/builders/workflow-hydrator.js +81 -2
  7. package/dist/conditions/conditions.d.ts +9 -3
  8. package/dist/conditions/conditions.js +84 -25
  9. package/dist/harness/agent-harness.d.ts +16 -3
  10. package/dist/harness/agent-harness.js +63 -6
  11. package/dist/harness/decide-call.d.ts +19 -0
  12. package/dist/harness/decide-call.js +483 -0
  13. package/dist/harness/deps/adapters.d.ts +1 -0
  14. package/dist/harness/deps/adapters.js +1 -0
  15. package/dist/harness/deps/context.d.ts +1 -1
  16. package/dist/harness/deps/context.js +1 -1
  17. package/dist/harness/deps/types.d.ts +1 -1
  18. package/dist/index.d.ts +12 -4
  19. package/dist/index.js +5 -1
  20. package/dist/interpreter/action-deps.d.ts +1 -1
  21. package/dist/interpreter/action-deps.js +1 -1
  22. package/dist/interpreter/action-types.d.ts +1 -1
  23. package/dist/interpreter/collect-tools.js +2 -0
  24. package/dist/interpreter/execute-control.js +1 -1
  25. package/dist/interpreter/execute-decide.d.ts +10 -0
  26. package/dist/interpreter/execute-decide.js +417 -0
  27. package/dist/interpreter/execute.js +8 -3
  28. package/dist/observability/genai-attributes.d.ts +6 -0
  29. package/dist/observability/genai-attributes.js +6 -0
  30. package/dist/portable/context-layers.d.ts +1 -1
  31. package/dist/portable/context-layers.js +1 -1
  32. package/dist/schemas/workflow.d.ts +1 -1
  33. package/dist/util/status-code.d.ts +2 -0
  34. package/dist/util/status-code.js +7 -0
  35. package/package.json +7 -6
  36. package/schema/noetic-workflow.schema.json +396 -0
@@ -1,5 +1,5 @@
1
1
  import type { ContextData } from '@noetic-tools/context';
2
- import type { Context, SettleResult, Step, StepConditional, StepInParallelAll, StepInParallelRace, StepInParallelSettle } from '@noetic-tools/types';
2
+ import type { Context, ExecuteStepFn, SettleResult, Step, StepConditional, StepInParallelAll, StepInParallelRace, StepInParallelSettle } from '@noetic-tools/types';
3
3
  /**
4
4
  * Creates a parallel execution step that races paths and returns the first result.
5
5
  *
@@ -65,14 +65,16 @@ export declare function inParallel<TContext = ContextData, I = unknown, O = unkn
65
65
  *
66
66
  * @public
67
67
  * @param opts.id - Unique step identifier used in traces and error messages.
68
- * @param opts.route - Routing function `(input, ctx) => Step | null`. Returns the next step
69
- * to execute, or `null` to skip (pass input through as output). May be async.
68
+ * @param opts.route - Routing function `(input, ctx, run) => Step | null`. Returns the next step
69
+ * to execute, or `null` to skip (pass input through as output). May be async. `run`
70
+ * executes a step through the interpreter, for routes that must run one to choose.
70
71
  * @returns A `StepConditional` step.
71
72
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
72
73
  * @throws `NoeticConfigError` with code `MISSING_ROUTE_FUNCTION` if `route` is not provided.
73
74
  */
74
75
  export declare function conditional<TContext = ContextData, I = unknown, O = unknown>(opts: {
75
76
  id: string;
76
- route: (input: I, ctx: Context<TContext>) => Step<TContext, I, O> | null | Promise<Step<TContext, I, O> | null>;
77
+ route: (input: I, ctx: Context<TContext>, run: ExecuteStepFn) => Step<TContext, I, O> | null | Promise<Step<TContext, I, O> | null>;
77
78
  _optimizable?: Step<TContext>[];
79
+ _rebuild?: (children: Step<TContext>[]) => StepConditional<TContext, I, O>;
78
80
  }): StepConditional<TContext, I, O>;
@@ -27,8 +27,9 @@ export function inParallel(opts) {
27
27
  *
28
28
  * @public
29
29
  * @param opts.id - Unique step identifier used in traces and error messages.
30
- * @param opts.route - Routing function `(input, ctx) => Step | null`. Returns the next step
31
- * to execute, or `null` to skip (pass input through as output). May be async.
30
+ * @param opts.route - Routing function `(input, ctx, run) => Step | null`. Returns the next step
31
+ * to execute, or `null` to skip (pass input through as output). May be async. `run`
32
+ * executes a step through the interpreter, for routes that must run one to choose.
32
33
  * @returns A `StepConditional` step.
33
34
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
34
35
  * @throws `NoeticConfigError` with code `MISSING_ROUTE_FUNCTION` if `route` is not provided.
@@ -0,0 +1,86 @@
1
+ import type { AnsweredAll, CaseKeyOf, Context, ContextData, DecideQuestions, DecideStep, DecideView, Lazy, ProjectionPolicy, Step, StepConditional } from '@noetic-tools/types';
2
+ /** @public Options for `decide()`. */
3
+ export interface StepDecideOpts<TContext, Q extends DecideQuestions> {
4
+ id: string;
5
+ /** Decision model id. Falls back to `harness.config.decisionModel`. */
6
+ model?: Lazy<string, TContext>;
7
+ /** Shared framing for every question. */
8
+ instructions?: Lazy<string | undefined, TContext>;
9
+ questions: Q & NonEmpty<Q>;
10
+ /** Which part of the assembled view the model sees. Default `'full'`. */
11
+ view?: DecideView;
12
+ /**
13
+ * Typed state read from context layers, sent under one `context` state entry.
14
+ * Annotate the parameter (`(ctx: Context<MyContext>) => …`) to type `ctx.context`.
15
+ */
16
+ context?: (ctx: Context<TContext>) => Record<string, unknown> | Promise<Record<string, unknown>>;
17
+ /** Budget for the decision's view. Defaults to `DECISION_PROJECTION`. */
18
+ projection?: ProjectionPolicy;
19
+ /** Runs averaged on the estimated path. Ignored on the native path. Default 1. */
20
+ samples?: number;
21
+ emit?: boolean | ((eventType: string, data: Record<string, unknown>) => boolean);
22
+ }
23
+ /** An empty question map is a type error, not just a build-time one. */
24
+ type NonEmpty<Q> = keyof Q extends never ? {
25
+ 'decide needs at least one question': never;
26
+ } : unknown;
27
+ /** @public The input a step takes. */
28
+ export type StepInput<S> = S extends Step<infer _C, infer I, infer _O> ? I : never;
29
+ /** @public The output a step produces. */
30
+ export type StepOutput<S> = S extends Step<infer _C, infer _I, infer O> ? O : never;
31
+ /** A step of any kind, as far as a case needs to be one. */
32
+ type CaseStep = Pick<Step, 'kind' | 'id'>;
33
+ /** Maps every key the switch does not route to onto `never`, so a misspelled case is a type error. */
34
+ type OnlyKeys<Cases, Keys extends PropertyKey> = {
35
+ [P in Exclude<keyof Cases, Keys>]: never;
36
+ };
37
+ /**
38
+ * @public `routeSwitch` options that route by one question. `cases` needs exactly
39
+ * one step per outcome; refusals and low-confidence answers take `uncertain`.
40
+ */
41
+ export interface RouteSwitchByKeyOpts<TContext, Q extends DecideQuestions, K extends keyof Q & string, Cases extends Record<CaseKeyOf<Q[K]>, CaseStep>, U extends CaseStep> {
42
+ id: string;
43
+ decide: DecideStep<TContext, unknown, Q>;
44
+ on: K;
45
+ cases: Cases & OnlyKeys<Cases, CaseKeyOf<Q[K]>>;
46
+ /** Below this confidence, take `uncertain`. */
47
+ minConfidence?: number;
48
+ /** Taken on refusal, or when confidence is below `minConfidence`. */
49
+ uncertain: U;
50
+ }
51
+ /** @public `routeSwitch` options that combine answers in a function; `default` catches the rest. */
52
+ export interface RouteSwitchByFunctionOpts<TContext, Q extends DecideQuestions, C extends string, Cases extends Partial<Record<C, CaseStep>>, D extends CaseStep, U extends CaseStep> {
53
+ id: string;
54
+ decide: DecideStep<TContext, unknown, Q>;
55
+ /** Receives answers with refusals already handled. */
56
+ on: (answers: AnsweredAll<Q>) => C;
57
+ cases: Cases & OnlyKeys<Cases, C>;
58
+ default: D;
59
+ /** Taken when any answer is refused. Defaults to `default`. */
60
+ uncertain?: U;
61
+ }
62
+ /**
63
+ * Creates a step that asks a model typed questions and returns typed answers
64
+ * with probabilities. See spec 32.
65
+ *
66
+ * The question map comes first among the type parameters so it is always
67
+ * inferred: pass no type arguments, and get a typed `ctx.context` by annotating
68
+ * the selector — `context: (ctx: Context<MyContext>) => …`.
69
+ *
70
+ * @public
71
+ * @throws `NoeticConfigError` `EMPTY_STEP_ID`, `MISSING_MODEL` (empty eager model),
72
+ * `INVALID_DECIDE_QUESTIONS`, or `INVALID_DECIDE_SAMPLES`.
73
+ */
74
+ export declare function decide<const Q extends DecideQuestions, TContext = ContextData, I = unknown>(opts: StepDecideOpts<TContext, Q>): DecideStep<TContext, I, Q>;
75
+ /**
76
+ * Creates a conditional step that runs a decide step and routes the original input to
77
+ * the case its answers pick. The output type is the union of every case's
78
+ * output. See spec 32.
79
+ *
80
+ * @public
81
+ * @throws `NoeticConfigError` `EMPTY_STEP_ID`, `UNKNOWN_ROUTE_QUESTION`,
82
+ * `INCOMPLETE_ROUTE_CASES`, `INVALID_MIN_CONFIDENCE`, or `MISSING_UNCERTAIN_STEP`.
83
+ */
84
+ export declare function routeSwitch<TContext, const Q extends DecideQuestions, const C extends string, const Cases extends Partial<Record<C, CaseStep>>, D extends CaseStep, U extends CaseStep = D>(opts: RouteSwitchByFunctionOpts<TContext, Q, C, Cases, D, U>): StepConditional<TContext, StepInput<Cases[keyof Cases] | D | U>, StepOutput<Cases[keyof Cases] | D | U>>;
85
+ export declare function routeSwitch<TContext, const Q extends DecideQuestions, const K extends keyof Q & string, const Cases extends Record<CaseKeyOf<Q[K]>, CaseStep>, U extends CaseStep>(opts: RouteSwitchByKeyOpts<TContext, Q, K, Cases, U>): StepConditional<TContext, StepInput<Cases[keyof Cases] | U>, StepOutput<Cases[keyof Cases] | U>>;
86
+ export {};
@@ -0,0 +1,230 @@
1
+ import { DecideQuestionsSchema, frameworkCast, NoeticConfigError, routeOutcomes, } from '@noetic-tools/types';
2
+ import { getDefaultRegistrar } from '../types/step-registrar.js';
3
+ import { conditional } from './control-flow-builders.js';
4
+ //#endregion
5
+ //#region Public API
6
+ /**
7
+ * Creates a step that asks a model typed questions and returns typed answers
8
+ * with probabilities. See spec 32.
9
+ *
10
+ * The question map comes first among the type parameters so it is always
11
+ * inferred: pass no type arguments, and get a typed `ctx.context` by annotating
12
+ * the selector — `context: (ctx: Context<MyContext>) => …`.
13
+ *
14
+ * @public
15
+ * @throws `NoeticConfigError` `EMPTY_STEP_ID`, `MISSING_MODEL` (empty eager model),
16
+ * `INVALID_DECIDE_QUESTIONS`, or `INVALID_DECIDE_SAMPLES`.
17
+ */
18
+ export function decide(opts) {
19
+ requireId('decide', opts.id);
20
+ if (typeof opts.model === 'string' && opts.model.trim() === '') {
21
+ throw new NoeticConfigError({
22
+ code: 'MISSING_MODEL',
23
+ message: 'decide() was given an empty model.',
24
+ hint: "Pass a model id, e.g. model: 'typesafe/jev-1.14', or omit it to use the harness `decisionModel`.",
25
+ });
26
+ }
27
+ const parsed = DecideQuestionsSchema.safeParse(opts.questions);
28
+ if (!parsed.success) {
29
+ throw new NoeticConfigError({
30
+ code: 'INVALID_DECIDE_QUESTIONS',
31
+ message: `decide(${JSON.stringify(opts.id)}) has invalid questions: ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`,
32
+ hint: 'Give 1+ questions; booleans need instructions or criteria, choices 1–255 unique values, scores 2–10 unique levels.',
33
+ });
34
+ }
35
+ const samples = opts.samples ?? 1;
36
+ if (!Number.isInteger(samples) || samples < 1) {
37
+ throw new NoeticConfigError({
38
+ code: 'INVALID_DECIDE_SAMPLES',
39
+ message: `decide(${JSON.stringify(opts.id)}) has samples=${samples}; it must be a positive integer.`,
40
+ hint: 'Omit samples, or pass a whole number of runs to average, e.g. samples: 3.',
41
+ });
42
+ }
43
+ const built = {
44
+ ...opts,
45
+ kind: 'decide',
46
+ view: opts.view ?? 'full',
47
+ samples,
48
+ };
49
+ getDefaultRegistrar().register(built);
50
+ return built;
51
+ }
52
+ export function routeSwitch(opts) {
53
+ requireId('routeSwitch', opts.id);
54
+ return buildSwitch({
55
+ id: opts.id,
56
+ decide: opts.decide,
57
+ on: opts.on,
58
+ cases: opts.cases,
59
+ minConfidence: 'minConfidence' in opts ? opts.minConfidence : undefined,
60
+ uncertain: opts.uncertain,
61
+ default: 'default' in opts ? opts.default : undefined,
62
+ });
63
+ }
64
+ /**
65
+ * Builds the conditional. Its route closes over the plan, so `_rebuild` rebuilds it
66
+ * around new children for the optimizer, in the same order as `_optimizable`.
67
+ */
68
+ function buildSwitch(plan) {
69
+ const keys = Object.keys(plan.cases).filter((k) => plan.cases[k] !== undefined);
70
+ const pick = typeof plan.on === 'function' ? byFunction(plan, plan.on) : byKey(plan, plan.on);
71
+ const children = [
72
+ plan.decide,
73
+ ...keys.map((k) => plan.cases[k]),
74
+ ...(plan.uncertain
75
+ ? [
76
+ plan.uncertain,
77
+ ]
78
+ : []),
79
+ ...(plan.default
80
+ ? [
81
+ plan.default,
82
+ ]
83
+ : []),
84
+ ];
85
+ return conditional({
86
+ id: plan.id,
87
+ route: async (input, ctx, run) => {
88
+ const answers = frameworkCast(await run(plan.decide, input, ctx));
89
+ const picked = pick(answers);
90
+ ctx.span.addEvent('route_switch', {
91
+ stepId: plan.id,
92
+ case: picked.label,
93
+ });
94
+ return picked.step;
95
+ },
96
+ _optimizable: frameworkCast(children),
97
+ _rebuild: (next) => {
98
+ let i = 1;
99
+ const take = () => frameworkCast(next[i++]);
100
+ return buildSwitch({
101
+ ...plan,
102
+ decide: frameworkCast(next[0]),
103
+ cases: Object.fromEntries(keys.map((k) => [
104
+ k,
105
+ take(),
106
+ ])),
107
+ uncertain: plan.uncertain ? take() : undefined,
108
+ default: plan.default ? take() : undefined,
109
+ });
110
+ },
111
+ });
112
+ }
113
+ function byKey(plan, on) {
114
+ const questions = plan.decide.questions;
115
+ const question = Object.hasOwn(questions, on) ? questions[on] : undefined;
116
+ if (!question) {
117
+ throw new NoeticConfigError({
118
+ code: 'UNKNOWN_ROUTE_QUESTION',
119
+ message: `routeSwitch(${JSON.stringify(plan.id)}) routes on '${on}', which its decide step does not ask.`,
120
+ hint: `Set \`on\` to one of: ${Object.keys(questions).join(', ')}.`,
121
+ });
122
+ }
123
+ const missing = routeOutcomes(question).filter((o) => !caseFor(plan, o));
124
+ if (missing.length > 0) {
125
+ throw new NoeticConfigError({
126
+ code: 'INCOMPLETE_ROUTE_CASES',
127
+ message: `routeSwitch(${JSON.stringify(plan.id)}) has no case for: ${missing.join(', ')}.`,
128
+ hint: `Give a case for every outcome of '${on}'.`,
129
+ });
130
+ }
131
+ const uncertain = requireUncertain(plan);
132
+ return (answers) => {
133
+ const answer = Object.hasOwn(answers, on) ? answers[on] : undefined;
134
+ if (!answer || answer.type === 'refusal') {
135
+ return uncertain;
136
+ }
137
+ if (plan.minConfidence !== undefined && confidenceOf(answer) < plan.minConfidence) {
138
+ return uncertain;
139
+ }
140
+ const key = caseKey(answer);
141
+ return {
142
+ step: caseFor(plan, key),
143
+ label: key,
144
+ };
145
+ };
146
+ }
147
+ function byFunction(plan, on) {
148
+ const fallback = {
149
+ step: frameworkCast(plan.default ?? null),
150
+ label: 'default',
151
+ };
152
+ const refused = plan.uncertain
153
+ ? {
154
+ step: frameworkCast(plan.uncertain),
155
+ label: 'uncertain',
156
+ }
157
+ : fallback;
158
+ return (answers) => {
159
+ if (Object.values(answers).some((a) => a.type === 'refusal')) {
160
+ return refused;
161
+ }
162
+ const key = on(frameworkCast(answers));
163
+ const step = caseFor(plan, key);
164
+ return step
165
+ ? {
166
+ step,
167
+ label: key,
168
+ }
169
+ : fallback;
170
+ };
171
+ }
172
+ /** Own keys only: a choice named `constructor` must not find `Object.prototype.constructor`. */
173
+ function caseFor(plan, key) {
174
+ return Object.hasOwn(plan.cases, key)
175
+ ? frameworkCast(plan.cases[key] ?? null)
176
+ : null;
177
+ }
178
+ function caseKey(answer) {
179
+ switch (answer.type) {
180
+ case 'boolean':
181
+ return String(answer.value);
182
+ case 'choice':
183
+ return answer.value;
184
+ case 'score':
185
+ return answer.level;
186
+ }
187
+ }
188
+ /**
189
+ * Choice and score use the provider's `confidence`. A boolean has none, so it
190
+ * uses the probability of the side `value` picked — which respects the
191
+ * question's `threshold`, unlike distance from a coin flip.
192
+ */
193
+ function confidenceOf(answer) {
194
+ if (answer.type !== 'boolean') {
195
+ return answer.confidence;
196
+ }
197
+ return answer.value ? answer.probability : 1 - answer.probability;
198
+ }
199
+ //#endregion
200
+ //#region Helpers
201
+ function requireId(builder, id) {
202
+ if (!id || id.trim() === '') {
203
+ throw new NoeticConfigError({
204
+ code: 'EMPTY_STEP_ID',
205
+ message: `${builder}() requires a non-empty id.`,
206
+ hint: `Pass a unique string as the id field, e.g. ${builder}({ id: "triage", ... }).`,
207
+ });
208
+ }
209
+ }
210
+ function requireUncertain(plan) {
211
+ if (plan.minConfidence !== undefined && (plan.minConfidence < 0 || plan.minConfidence > 1)) {
212
+ throw new NoeticConfigError({
213
+ code: 'INVALID_MIN_CONFIDENCE',
214
+ message: `routeSwitch(${JSON.stringify(plan.id)}) has minConfidence=${plan.minConfidence}; it must be in [0, 1].`,
215
+ hint: 'Pass a confidence between 0 and 1, e.g. minConfidence: 0.6.',
216
+ });
217
+ }
218
+ if (!plan.uncertain) {
219
+ throw new NoeticConfigError({
220
+ code: 'MISSING_UNCERTAIN_STEP',
221
+ message: `routeSwitch(${JSON.stringify(plan.id)}) has no uncertain step.`,
222
+ hint: 'Pass `uncertain`: the step to run when the model refuses or is not confident enough.',
223
+ });
224
+ }
225
+ return {
226
+ step: frameworkCast(plan.uncertain),
227
+ label: 'uncertain',
228
+ };
229
+ }
230
+ //#endregion
@@ -31,6 +31,7 @@ A WorkflowNode is one of:
31
31
  - { "kind": "spawn", "id": "<unique>", "child": <WorkflowNode>, "timeout": <optional ms> } (run the child in an isolated sub-context)
32
32
  - { "kind": "withContext", "id": "<unique>", "child": <WorkflowNode>, "layers": ["<layer-name>", ...] } (run the child with named context layers; only emit if the task names layers that exist, since this planner provides no layer registry and unknown names resolve to NO layers)
33
33
  - { "kind": "schedule", "id": "<unique>", "step": <WorkflowNode>, "interval": <ms>, "onError": "continue"|"fail" } (re-runs the step forever on an interval and never returns — only emit for an explicitly daemon-style task)
34
+ - { "kind": "routeSwitch", "id": "<unique>", "decide": { "kind": "decide", "id": "<unique>", "questions": { "<question-id>": { "type": "choice", "instructions": "<question>", "choices": ["<option>", ...] } } }, "on": "<question-id>", "cases": { "<option>": <WorkflowNode>, ... }, "uncertain": <WorkflowNode>, "minConfidence": <optional 0-1> } (a model decides, then the input goes to the matching case; a "boolean" question has cases "true" and "false"; a "score" question has "levels": ["<low>", ..., "<high>"] and routes by level; every outcome needs a case)
34
35
  - { "kind": "subflow", "id": "<unique>", "document": { "version": 1, "root": <WorkflowNode> } } (an inline sub-workflow run as one step; only emit the inline form — named refs require a registry this planner does not provide)
35
36
  - { "kind": "acp-agent", "id": "<unique>", "agent": "<registered agent id, e.g. claude-code>", "prompt": "<turn prompt>", "mode": "<optional session mode>", "model": "<optional>" }
36
37
 
@@ -10,6 +10,7 @@ import { all, any } from '../until/combinators.js';
10
10
  import { until } from '../until/predicates.js';
11
11
  import { DetachedHandleImpl } from '../util/detached-handle.js';
12
12
  import { conditional, inParallel } from './control-flow-builders.js';
13
+ import { decide, routeSwitch } from './decide-builders.js';
13
14
  import { schedule } from './every.js';
14
15
  import { loop } from './loop-builder.js';
15
16
  import { withContext } from './provide-builder.js';
@@ -207,7 +208,7 @@ function hydrateConditionalNode(node, ctx) {
207
208
  return conditional({
208
209
  id: node.id,
209
210
  route: (input) => {
210
- const trimmed = input.trim().toLowerCase();
211
+ const trimmed = routeText(input).trim().toLowerCase();
211
212
  for (const r of hydratedRoutes) {
212
213
  if (trimmed.includes(r.match.toLowerCase())) {
213
214
  return r.target;
@@ -218,6 +219,40 @@ function hydrateConditionalNode(node, ctx) {
218
219
  _optimizable: frameworkCast(allTargets),
219
220
  });
220
221
  }
222
+ function hydrateDecideNode(node, _ctx) {
223
+ if (node.kind !== 'decide') {
224
+ return frameworkCast(undefined);
225
+ }
226
+ return frameworkCast(decideFromNode(node));
227
+ }
228
+ function decideFromNode(node) {
229
+ return decide({
230
+ id: node.id,
231
+ model: node.model,
232
+ instructions: node.instructions,
233
+ questions: node.questions,
234
+ view: node.view,
235
+ samples: node.samples,
236
+ });
237
+ }
238
+ function hydrateRouteSwitchNode(node, ctx) {
239
+ if (node.kind !== 'routeSwitch') {
240
+ return frameworkCast(undefined);
241
+ }
242
+ const cases = {};
243
+ for (const [key, target] of Object.entries(node.cases)) {
244
+ cases[key] = hydrateNode(target, ctx);
245
+ }
246
+ // A JSON node's outcomes are only known at runtime; the builder checks them.
247
+ return frameworkCast(routeSwitch({
248
+ id: node.id,
249
+ decide: decideFromNode(node.decide),
250
+ on: node.on,
251
+ cases: frameworkCast(cases),
252
+ minConfidence: node.minConfidence,
253
+ uncertain: hydrateNode(node.uncertain, ctx),
254
+ }));
255
+ }
221
256
  function hydrateInParallelNode(node, ctx) {
222
257
  if (node.kind !== 'inParallel') {
223
258
  return frameworkCast(undefined);
@@ -475,6 +510,8 @@ const NODE_HYDRATORS = {
475
510
  invokeTool: hydrateInvokeToolNode,
476
511
  runCode: hydrateRunCodeNode,
477
512
  conditional: hydrateConditionalNode,
513
+ decide: hydrateDecideNode,
514
+ routeSwitch: hydrateRouteSwitchNode,
478
515
  inParallel: hydrateInParallelNode,
479
516
  spawn: hydrateSpawnNode,
480
517
  withContext: hydrateWithContextNode,
@@ -504,15 +541,57 @@ function resolveTools(toolNames, registry) {
504
541
  return resolved;
505
542
  });
506
543
  }
544
+ /** Paths may return objects (a `decide` node's answers): `concat` writes them as JSON, `first`/`last` pass them through. */
507
545
  function buildMerge(strategy) {
508
546
  if (strategy === 'first') {
509
547
  return (r) => r[0] ?? '';
510
548
  }
511
549
  if (strategy === 'concat') {
512
- return (r) => r.join('\n');
550
+ return (r) => r.map(stringifyResult).join('\n');
513
551
  }
514
552
  return (r) => r[r.length - 1] ?? '';
515
553
  }
554
+ /**
555
+ * The text a branch matches against. An object input — a `decide` node's
556
+ * answers, or their JSON once a sequence has stringified them — matches on its
557
+ * string values only: its keys and probability maps name every choice, so
558
+ * matching the JSON would hit every route.
559
+ */
560
+ function routeText(input) {
561
+ const value = typeof input === 'string' ? parseJsonObject(input) : input;
562
+ if (typeof value === 'string') {
563
+ return value;
564
+ }
565
+ const values = [];
566
+ const walk = (value) => {
567
+ if (typeof value === 'string') {
568
+ values.push(value);
569
+ return;
570
+ }
571
+ if (typeof value !== 'object' || value === null) {
572
+ return;
573
+ }
574
+ for (const child of Object.values(value)) {
575
+ walk(child);
576
+ }
577
+ };
578
+ walk(value);
579
+ return values.join('\n');
580
+ }
581
+ /** The parsed object or array when `text` is one in JSON, else `text` itself. */
582
+ function parseJsonObject(text) {
583
+ const trimmed = text.trim();
584
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
585
+ return text;
586
+ }
587
+ try {
588
+ const parsed = JSON.parse(trimmed);
589
+ return typeof parsed === 'object' && parsed !== null ? parsed : text;
590
+ }
591
+ catch {
592
+ return text;
593
+ }
594
+ }
516
595
  function stringifyResult(value) {
517
596
  if (typeof value === 'string') {
518
597
  return value;
@@ -100,14 +100,20 @@ export declare function anyCondition<I>(...conditions: Condition<I>[]): Conditio
100
100
  */
101
101
  export declare function allCondition<I>(...conditions: Condition<I>[]): Condition<I>;
102
102
  /**
103
- * Creates a condition that uses an LLM to classify the input as true or false.
103
+ * Creates a condition that asks a model a yes/no question about the input — a
104
+ * one-question `decide`. Works with decision models natively and with chat
105
+ * models through estimated probabilities.
104
106
  *
105
107
  * @public
106
- * @param opts - Configuration with model identifier and classification prompt.
107
- * @returns A `Condition` that delegates boolean classification to the model.
108
+ * @param opts.model - Decision or chat model id.
109
+ * @param opts.prompt - The yes/no question.
110
+ * @param opts.threshold - Probability of yes at or above which the condition holds. Default 0.5.
111
+ * @returns A `Condition` that is true when the model answers yes. A refusal counts as no.
112
+ * @throws `NoeticError` `model_parse_error` when the response is not a boolean answer.
108
113
  */
109
114
  export declare function aiCondition<I>(opts: {
110
115
  model: string;
111
116
  prompt: string;
117
+ threshold?: number;
112
118
  }): Condition<I>;
113
119
  export {};
@@ -1,5 +1,5 @@
1
1
  import { storageGetMany } from '@noetic-tools/context';
2
- import { createMessage, extractAssistantText, trackUsage } from '@noetic-tools/types';
2
+ import { DecideOutputError, isNoeticError, NoeticErrorImpl, trackUsage } from '@noetic-tools/types';
3
3
  import { z } from 'zod';
4
4
  import { cosineSimilarity } from './cosine-similarity.js';
5
5
  //#endregion
@@ -234,41 +234,100 @@ export function allCondition(...conditions) {
234
234
  }
235
235
  //#endregion
236
236
  //#region aiCondition
237
- const AiConditionResponseSchema = z.object({
238
- answer: z.boolean(),
239
- });
240
237
  /**
241
- * Creates a condition that uses an LLM to classify the input as true or false.
238
+ * Creates a condition that asks a model a yes/no question about the input — a
239
+ * one-question `decide`. Works with decision models natively and with chat
240
+ * models through estimated probabilities.
242
241
  *
243
242
  * @public
244
- * @param opts - Configuration with model identifier and classification prompt.
245
- * @returns A `Condition` that delegates boolean classification to the model.
243
+ * @param opts.model - Decision or chat model id.
244
+ * @param opts.prompt - The yes/no question.
245
+ * @param opts.threshold - Probability of yes at or above which the condition holds. Default 0.5.
246
+ * @returns A `Condition` that is true when the model answers yes. A refusal counts as no.
247
+ * @throws `NoeticError` `model_parse_error` when the response is not a boolean answer.
246
248
  */
247
249
  export function aiCondition(opts) {
248
250
  return async (input, ctx) => {
249
- const text = serializeInput(input);
250
- const systemPrompt = `You are a boolean classifier. Given the user's input, answer the following question with JSON: {"answer": true} or {"answer": false}.\n\nQuestion: ${opts.prompt}`;
251
- const items = [
252
- createMessage(systemPrompt, 'developer'),
253
- createMessage(text, 'user'),
254
- ];
255
- const response = await ctx.harness.callModel({
251
+ const response = await askDecide(ctx, {
256
252
  model: opts.model,
257
- items,
258
- params: {
259
- temperature: 0,
253
+ state: [
254
+ {
255
+ type: 'text',
256
+ name: 'input',
257
+ text: serializeInput(input),
258
+ },
259
+ ],
260
+ questions: {
261
+ answer: {
262
+ type: 'boolean',
263
+ instructions: opts.prompt,
264
+ threshold: opts.threshold,
265
+ },
260
266
  },
261
267
  });
262
- trackUsage(ctx, response);
263
- const responseText = extractAssistantText(response.items);
264
- try {
265
- const parsed = JSON.parse(responseText);
266
- const validated = AiConditionResponseSchema.parse(parsed);
267
- return validated.answer;
268
- }
269
- catch {
268
+ trackUsage(ctx, {
269
+ items: [],
270
+ usage: response.usage,
271
+ cost: response.cost,
272
+ });
273
+ const answer = response.answers.answer;
274
+ if (answer?.type === 'refusal') {
270
275
  return false;
271
276
  }
277
+ const parsed = AiConditionAnswerSchema.safeParse(answer);
278
+ if (!parsed.success) {
279
+ throw new NoeticErrorImpl({
280
+ kind: 'model_parse_error',
281
+ stepId: 'aiCondition',
282
+ raw: JSON.stringify(answer),
283
+ schema: AiConditionAnswerSchema,
284
+ zodError: parsed.error,
285
+ });
286
+ }
287
+ return parsed.data.value;
272
288
  };
273
289
  }
290
+ async function askDecide(ctx, request) {
291
+ try {
292
+ return await ctx.harness.decide(request);
293
+ }
294
+ catch (e) {
295
+ if (isNoeticError(e)) {
296
+ throw e;
297
+ }
298
+ if (ctx.aborted) {
299
+ throw new NoeticErrorImpl({
300
+ kind: 'cancelled',
301
+ reason: ctx.abortReason ?? 'context aborted',
302
+ });
303
+ }
304
+ if (e instanceof DecideOutputError) {
305
+ if (e.usage) {
306
+ trackUsage(ctx, {
307
+ items: [],
308
+ usage: e.usage,
309
+ cost: e.cost,
310
+ });
311
+ }
312
+ throw new NoeticErrorImpl({
313
+ kind: 'model_parse_error',
314
+ stepId: 'aiCondition',
315
+ raw: e.raw,
316
+ schema: e.schema,
317
+ zodError: e.zodError,
318
+ });
319
+ }
320
+ throw new NoeticErrorImpl({
321
+ kind: 'step_failed',
322
+ stepId: 'aiCondition',
323
+ cause: e instanceof Error ? e : new Error(String(e)),
324
+ retriesExhausted: false,
325
+ });
326
+ }
327
+ }
328
+ const AiConditionAnswerSchema = z.object({
329
+ type: z.literal('boolean'),
330
+ value: z.boolean(),
331
+ probability: z.number().min(0).max(1),
332
+ });
274
333
  //#endregion