@noetic-tools/core 5.5.0 → 5.7.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.
- package/dist/builders/control-flow-builders.d.ts +6 -4
- package/dist/builders/control-flow-builders.js +3 -2
- package/dist/builders/decide-builders.d.ts +86 -0
- package/dist/builders/decide-builders.js +230 -0
- package/dist/builders/dynamic-workflow.js +1 -0
- package/dist/builders/workflow-hydrator.js +81 -2
- package/dist/conditions/conditions.d.ts +9 -3
- package/dist/conditions/conditions.js +84 -25
- package/dist/harness/agent-harness.d.ts +18 -3
- package/dist/harness/agent-harness.js +63 -6
- package/dist/harness/decide-call.d.ts +19 -0
- package/dist/harness/decide-call.js +483 -0
- package/dist/harness/deps/adapters.d.ts +1 -0
- package/dist/harness/deps/adapters.js +1 -0
- package/dist/harness/deps/context.d.ts +1 -1
- package/dist/harness/deps/context.js +1 -1
- package/dist/harness/deps/types.d.ts +1 -1
- package/dist/index.d.ts +14 -6
- package/dist/index.js +6 -2
- package/dist/interpreter/action-deps.d.ts +1 -1
- package/dist/interpreter/action-deps.js +1 -1
- package/dist/interpreter/action-types.d.ts +1 -1
- package/dist/interpreter/collect-tools.js +2 -0
- package/dist/interpreter/execute-action.js +3 -20
- package/dist/interpreter/execute-control.js +1 -1
- package/dist/interpreter/execute-decide.d.ts +10 -0
- package/dist/interpreter/execute-decide.js +417 -0
- package/dist/interpreter/execute.js +8 -3
- package/dist/observability/genai-attributes.d.ts +6 -0
- package/dist/observability/genai-attributes.js +6 -0
- package/dist/portable/context-layers.d.ts +1 -1
- package/dist/portable/context-layers.js +1 -1
- package/dist/runtime/context-impl.d.ts +3 -0
- package/dist/runtime/context-impl.js +2 -0
- package/dist/schemas/workflow.d.ts +1 -1
- package/dist/util/status-code.d.ts +2 -0
- package/dist/util/status-code.js +7 -0
- package/package.json +7 -6
- 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
|
|
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
|
|
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 -
|
|
107
|
-
* @
|
|
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 {
|
|
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
|
|
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 -
|
|
245
|
-
* @
|
|
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
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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,
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|