@kuralle-agents/core 0.3.15 → 0.3.17

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.
@@ -0,0 +1,17 @@
1
+ import type { ModelMessage } from 'ai';
2
+ import { z } from 'zod';
3
+ import type { DecideNode } from '../types/flow.js';
4
+ import type { ChoiceOption } from '../types/selection.js';
5
+ import type { RunContext } from '../types/run-context.js';
6
+ /** Reserved enum member: model declines to pick a listed choice (→ author stay/unmatched). */
7
+ export declare const CHOICE_NONE = "__none";
8
+ export declare function isConstrainedChoiceEnumSchema(schema: unknown): boolean;
9
+ export declare function isChoiceFieldSchema(schema: unknown): schema is z.ZodObject<{
10
+ choice: z.ZodString;
11
+ }>;
12
+ export declare function buildChoiceEnumSchema(choices: ChoiceOption[]): z.ZodObject<{
13
+ choice: z.ZodEnum<[string, ...string[]]>;
14
+ }>;
15
+ export declare function latestUserMessageText(messages: ModelMessage[]): string | undefined;
16
+ export declare function matchChoiceFromInput(input: string, choices: ChoiceOption[]): string | undefined;
17
+ export declare function resolveStructuredDecide(node: DecideNode, ctx: RunContext, system: string): Promise<unknown>;
@@ -0,0 +1,119 @@
1
+ import { generateObject } from 'ai';
2
+ import { z } from 'zod';
3
+ /** Reserved enum member: model declines to pick a listed choice (→ author stay/unmatched). */
4
+ export const CHOICE_NONE = '__none';
5
+ function normalize(raw) {
6
+ return raw.trim().toLowerCase().replace(/\s+/g, ' ');
7
+ }
8
+ function keywordTerms(text) {
9
+ return normalize(text)
10
+ .split(/[^a-z0-9]+/)
11
+ .filter((term) => term.length > 4);
12
+ }
13
+ export function isConstrainedChoiceEnumSchema(schema) {
14
+ if (!(schema instanceof z.ZodObject)) {
15
+ return false;
16
+ }
17
+ return schema.shape.choice instanceof z.ZodEnum;
18
+ }
19
+ export function isChoiceFieldSchema(schema) {
20
+ if (!(schema instanceof z.ZodObject)) {
21
+ return false;
22
+ }
23
+ const keys = Object.keys(schema.shape);
24
+ if (keys.length !== 1 || keys[0] !== 'choice') {
25
+ return false;
26
+ }
27
+ return schema.shape.choice instanceof z.ZodString;
28
+ }
29
+ export function buildChoiceEnumSchema(choices) {
30
+ const ids = choices.map((c) => c.id);
31
+ if (ids.length === 0) {
32
+ return z.object({ choice: z.enum([CHOICE_NONE]) });
33
+ }
34
+ const members = [ids[0], ...ids.slice(1), CHOICE_NONE];
35
+ return z.object({ choice: z.enum(members) });
36
+ }
37
+ export function latestUserMessageText(messages) {
38
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
39
+ const message = messages[i];
40
+ if (message?.role === 'user') {
41
+ if (typeof message.content === 'string') {
42
+ return message.content;
43
+ }
44
+ if (Array.isArray(message.content)) {
45
+ const text = message.content
46
+ .filter((part) => part.type === 'text')
47
+ .map((part) => part.text)
48
+ .join('');
49
+ if (text) {
50
+ return text;
51
+ }
52
+ }
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+ export function matchChoiceFromInput(input, choices) {
58
+ const normalized = normalize(input);
59
+ if (!normalized) {
60
+ return undefined;
61
+ }
62
+ const idMatches = choices.filter((c) => normalize(c.id) === normalized).map((c) => c.id);
63
+ if (idMatches.length === 1) {
64
+ return idMatches[0];
65
+ }
66
+ if (idMatches.length > 1) {
67
+ return undefined;
68
+ }
69
+ const labelMatches = choices.filter((c) => normalize(c.label) === normalized).map((c) => c.id);
70
+ if (labelMatches.length === 1) {
71
+ return labelMatches[0];
72
+ }
73
+ if (labelMatches.length > 1) {
74
+ return undefined;
75
+ }
76
+ const keywordMatches = [];
77
+ for (const choice of choices) {
78
+ const terms = keywordTerms(choice.label);
79
+ if (terms.length > 0 && terms.some((term) => normalized.includes(term))) {
80
+ keywordMatches.push(choice.id);
81
+ }
82
+ }
83
+ if (keywordMatches.length === 1) {
84
+ return keywordMatches[0];
85
+ }
86
+ return undefined;
87
+ }
88
+ export async function resolveStructuredDecide(node, ctx, system) {
89
+ const schema = node.schema;
90
+ const useConstrainedChoices = (node.choices?.length ?? 0) > 0 && isChoiceFieldSchema(schema);
91
+ if (!useConstrainedChoices) {
92
+ const { object } = await generateObject({
93
+ model: ctx.controlModel,
94
+ schema,
95
+ system,
96
+ messages: ctx.runState.messages,
97
+ temperature: 0,
98
+ abortSignal: ctx.abortSignal,
99
+ });
100
+ return object;
101
+ }
102
+ const input = latestUserMessageText(ctx.runState.messages);
103
+ if (input) {
104
+ const matched = matchChoiceFromInput(input, node.choices);
105
+ if (matched) {
106
+ return { choice: matched };
107
+ }
108
+ }
109
+ const choiceSchema = buildChoiceEnumSchema(node.choices);
110
+ const { object } = await generateObject({
111
+ model: ctx.controlModel,
112
+ schema: choiceSchema,
113
+ system,
114
+ messages: ctx.runState.messages,
115
+ temperature: 0,
116
+ abortSignal: ctx.abortSignal,
117
+ });
118
+ return object;
119
+ }
@@ -0,0 +1,31 @@
1
+ import type { AgentConfig } from '../types/agentConfig.js';
2
+ import type { ChannelDriver } from '../types/channel.js';
3
+ import type { CollectNode } from '../types/flow.js';
4
+ import type { RunContext } from '../types/run-context.js';
5
+ import type { RunState } from '../runtime/durable/types.js';
6
+ import type { NormalizedTransition } from './normalizeTransition.js';
7
+ import { selectHostTarget } from '../runtime/select.js';
8
+ export interface FlowPark {
9
+ flow: string;
10
+ node: string;
11
+ }
12
+ export declare function getFlowPark(state: Record<string, unknown>): FlowPark | undefined;
13
+ export declare function looksLikeOffScriptQuestion(input: string): boolean;
14
+ export interface CollectDigressionOptions {
15
+ agent: AgentConfig;
16
+ node: CollectNode;
17
+ activeFlowName: string;
18
+ run: RunState;
19
+ driver: ChannelDriver;
20
+ ctx: RunContext;
21
+ select?: typeof selectHostTarget;
22
+ }
23
+ export type CollectDigressionResult = {
24
+ kind: 'transition';
25
+ transition: NormalizedTransition;
26
+ } | {
27
+ kind: 'answeredThenResume';
28
+ } | {
29
+ kind: 'none';
30
+ };
31
+ export declare function runCollectDigression(options: CollectDigressionOptions): Promise<CollectDigressionResult>;
@@ -0,0 +1,99 @@
1
+ import { resolveReplyNode } from './nodeBuilders.js';
2
+ import { selectHostTarget } from '../runtime/select.js';
3
+ const FLOW_PARK_KEY = '__flowPark';
4
+ export function getFlowPark(state) {
5
+ const raw = state[FLOW_PARK_KEY];
6
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
7
+ return undefined;
8
+ }
9
+ const flow = raw.flow;
10
+ const node = raw.node;
11
+ if (typeof flow === 'string' && typeof node === 'string') {
12
+ return { flow, node };
13
+ }
14
+ return undefined;
15
+ }
16
+ function setFlowPark(state, park) {
17
+ state[FLOW_PARK_KEY] = park;
18
+ }
19
+ function appendAssistantMessage(run, text) {
20
+ if (!text.trim()) {
21
+ return;
22
+ }
23
+ const message = { role: 'assistant', content: text };
24
+ run.messages = [...run.messages, message];
25
+ }
26
+ export function looksLikeOffScriptQuestion(input) {
27
+ const trimmed = input.trim();
28
+ if (!trimmed) {
29
+ return false;
30
+ }
31
+ if (trimmed.includes('?')) {
32
+ return true;
33
+ }
34
+ return /^(what|how|why|when|where|who|can|could|do|does|is|are|will|would)\b/i.test(trimmed);
35
+ }
36
+ export async function runCollectDigression(options) {
37
+ const { agent, node, activeFlowName, run, driver, ctx } = options;
38
+ const select = options.select ?? selectHostTarget;
39
+ const offScriptInput = peekLatestUserMessage(run);
40
+ if (!offScriptInput) {
41
+ return { kind: 'none' };
42
+ }
43
+ const selection = await select({
44
+ agent,
45
+ run,
46
+ model: agent.routing?.model ?? ctx.controlModel,
47
+ alwaysRoute: agent.routing?.always === true,
48
+ excludeFlowNames: [activeFlowName],
49
+ });
50
+ if (selection.kind === 'route') {
51
+ return {
52
+ kind: 'transition',
53
+ transition: { kind: 'handoff', to: selection.agentId, reason: selection.reason },
54
+ };
55
+ }
56
+ if (selection.kind === 'enterFlow') {
57
+ setFlowPark(run.state, { flow: activeFlowName, node: node.id });
58
+ return {
59
+ kind: 'transition',
60
+ transition: {
61
+ kind: 'switchFlow',
62
+ flow: selection.flow,
63
+ park: { flow: activeFlowName, node: node.id },
64
+ },
65
+ };
66
+ }
67
+ if (!looksLikeOffScriptQuestion(offScriptInput)) {
68
+ return { kind: 'none' };
69
+ }
70
+ const replyNode = {
71
+ kind: 'reply',
72
+ id: `${node.id}__digression`,
73
+ instructions: ctx.baseInstructions ??
74
+ 'Answer the user helpfully and concisely. Do not mention internal routing or flows.',
75
+ tools: ctx.globalTools,
76
+ };
77
+ const turn = await driver.runAgentTurn(resolveReplyNode(replyNode, run.state, { freeConversation: true }), ctx);
78
+ if (turn.text.trim()) {
79
+ ctx.emit({ type: 'text-delta', text: turn.text });
80
+ ctx.emit({ type: 'turn-end' });
81
+ appendAssistantMessage(run, turn.text);
82
+ }
83
+ if (turn.control?.type === 'handoff') {
84
+ return {
85
+ kind: 'transition',
86
+ transition: { kind: 'handoff', to: turn.control.target, reason: turn.control.reason },
87
+ };
88
+ }
89
+ return { kind: 'answeredThenResume' };
90
+ }
91
+ function peekLatestUserMessage(run) {
92
+ for (let i = run.messages.length - 1; i >= 0; i -= 1) {
93
+ const message = run.messages[i];
94
+ if (message?.role === 'user' && typeof message.content === 'string') {
95
+ return message.content;
96
+ }
97
+ }
98
+ return undefined;
99
+ }
@@ -1,6 +1,11 @@
1
+ import type { AgentConfig } from '../types/agentConfig.js';
1
2
  import type { ChannelDriver } from '../types/channel.js';
2
3
  import type { CollectNode } from '../types/flow.js';
3
4
  import type { RunContext } from '../types/run-context.js';
4
5
  import type { RunState } from '../runtime/durable/types.js';
5
6
  import type { NormalizedTransition } from './normalizeTransition.js';
6
- export declare function collectUntilComplete(node: CollectNode, run: RunState, driver: ChannelDriver, ctx: RunContext): Promise<NormalizedTransition>;
7
+ export declare function collectUntilComplete(node: CollectNode, run: RunState, driver: ChannelDriver, ctx: RunContext, options?: {
8
+ agent?: AgentConfig;
9
+ activeFlowName?: string;
10
+ }): Promise<NormalizedTransition>;
11
+ export declare function emitCollectAsk(node: CollectNode, run: RunState, ctx: RunContext): void;
@@ -1,3 +1,4 @@
1
+ import { runCollectDigression } from './collectDigression.js';
1
2
  import { hasPendingUserInput } from '../runtime/channels/inputBuffer.js';
2
3
  import { resolveCollectExtractionNode } from './nodeBuilders.js';
3
4
  import { computeMissingFields, createExtractionSubmitTool, getCollectData, incrementCollectTurns, emitExtractionTelemetry, mergeTurnExtraction, projectCollectData, schemaSatisfied, } from './extraction.js';
@@ -13,7 +14,7 @@ function appendUserMessage(run, input) {
13
14
  const message = { role: 'user', content: input };
14
15
  run.messages = [...run.messages, message];
15
16
  }
16
- export async function collectUntilComplete(node, run, driver, ctx) {
17
+ export async function collectUntilComplete(node, run, driver, ctx, options) {
17
18
  for (;;) {
18
19
  if (schemaSatisfied(node, run.state)) {
19
20
  const data = projectCollectData(node, run.state);
@@ -44,11 +45,11 @@ export async function collectUntilComplete(node, run, driver, ctx) {
44
45
  const data = projectCollectData(node, run.state);
45
46
  return normalizeTransition(await node.onComplete(data, run.state));
46
47
  }
47
- const missing = computeMissingFields(node, getCollectData(run.state, node.id));
48
- const submitTool = createExtractionSubmitTool(node, missing, {
48
+ const missingBefore = computeMissingFields(node, getCollectData(run.state, node.id));
49
+ const submitTool = createExtractionSubmitTool(node, missingBefore, {
49
50
  userMessage: peekLatestUserMessage(run),
50
51
  });
51
- const resolved = resolveCollectExtractionNode(node, missing, run.state, submitTool);
52
+ const resolved = resolveCollectExtractionNode(node, missingBefore, run.state, submitTool);
52
53
  // Non-speaking extraction: the model's prose is DISCARDED (never emitted or
53
54
  // appended), so a collect turn cannot author narration that contradicts flow
54
55
  // state. Falls back to runAgentTurn for drivers without runExtraction; its
@@ -58,6 +59,28 @@ export async function collectUntilComplete(node, run, driver, ctx) {
58
59
  ? driver.runExtraction(resolved, ctx)
59
60
  : driver.runAgentTurn(resolved, ctx));
60
61
  mergeExtractionFromTurn(node, run, turn, ctx);
62
+ const missingAfter = computeMissingFields(node, getCollectData(run.state, node.id));
63
+ const advanced = missingAfter.length < missingBefore.length || schemaSatisfied(node, run.state);
64
+ if (!advanced &&
65
+ ctx.outOfBandControl &&
66
+ options?.agent &&
67
+ options.activeFlowName) {
68
+ const digression = await runCollectDigression({
69
+ agent: options.agent,
70
+ node,
71
+ activeFlowName: options.activeFlowName,
72
+ run,
73
+ driver,
74
+ ctx,
75
+ });
76
+ if (digression.kind === 'transition') {
77
+ return digression.transition;
78
+ }
79
+ if (digression.kind === 'answeredThenResume') {
80
+ emitCollectAsk(node, run, ctx);
81
+ return { kind: 'stay' };
82
+ }
83
+ }
61
84
  }
62
85
  }
63
86
  function humanizeField(field) {
@@ -82,7 +105,7 @@ function renderCollectAsk(node, missing, state) {
82
105
  }
83
106
  return 'Could you tell me a little more?';
84
107
  }
85
- function emitCollectAsk(node, run, ctx) {
108
+ export function emitCollectAsk(node, run, ctx) {
86
109
  const missing = computeMissingFields(node, getCollectData(run.state, node.id));
87
110
  const text = renderCollectAsk(node, missing, run.state);
88
111
  if (!text.trim()) {
@@ -1,4 +1,5 @@
1
- import type { FlowNode, Transition } from '../types/flow.js';
1
+ import type { Flow, FlowNode, Transition } from '../types/flow.js';
2
+ import type { FlowPark } from './collectDigression.js';
2
3
  export type NormalizedTransition = {
3
4
  kind: 'goto';
4
5
  node: FlowNode;
@@ -15,6 +16,10 @@ export type NormalizedTransition = {
15
16
  reason: string;
16
17
  } | {
17
18
  kind: 'stay';
19
+ } | {
20
+ kind: 'switchFlow';
21
+ flow: Flow;
22
+ park: FlowPark;
18
23
  };
19
24
  export declare function resolveNodeRef(ref: FlowNode | (() => FlowNode)): FlowNode;
20
25
  export declare function normalizeTransition(transition: Transition): NormalizedTransition;
@@ -1,3 +1,4 @@
1
+ import type { AgentConfig } from '../types/agentConfig.js';
1
2
  import type { ChannelDriver } from '../types/channel.js';
2
3
  import type { Flow } from '../types/flow.js';
3
4
  import type { RunContext } from '../types/run-context.js';
@@ -15,4 +16,4 @@ export type FlowResult = {
15
16
  export declare class FlowOscillationError extends Error {
16
17
  constructor(from: string, to: string);
17
18
  }
18
- export declare function runFlow(flow: Flow, run: RunState, driver: ChannelDriver, ctx: RunContext): Promise<FlowResult>;
19
+ export declare function runFlow(flow: Flow, run: RunState, driver: ChannelDriver, ctx: RunContext, agent?: AgentConfig): Promise<FlowResult>;
@@ -1,3 +1,4 @@
1
+ import { getFlowPark } from './collectDigression.js';
1
2
  import { parseConfirmation } from './confirmParse.js';
2
3
  import { hasPendingUserInput } from '../runtime/channels/inputBuffer.js';
3
4
  import { collectUntilComplete } from './collectUntilComplete.js';
@@ -87,12 +88,15 @@ function appendAssistantMessage(run, text) {
87
88
  const message = { role: 'assistant', content: text };
88
89
  run.messages = [...run.messages, message];
89
90
  }
90
- async function dispatchNode(node, run, driver, ctx) {
91
+ async function dispatchNode(node, run, driver, ctx, agent, flow) {
91
92
  if (isActionNode(node)) {
92
93
  return normalizeTransition(await node.run(run.state, toActionContext(ctx)));
93
94
  }
94
95
  if (isCollectNode(node)) {
95
- return collectUntilComplete(node, run, driver, ctx);
96
+ return collectUntilComplete(node, run, driver, ctx, {
97
+ agent,
98
+ activeFlowName: flow.name,
99
+ });
96
100
  }
97
101
  if (isDecideNode(node)) {
98
102
  if (node.confirmGate) {
@@ -139,7 +143,7 @@ async function dispatchNode(node, run, driver, ctx) {
139
143
  if (decision.kind === 'redispatch') {
140
144
  const signal = await driver.awaitUser(ctx);
141
145
  appendUserMessage(run, signal.input);
142
- return dispatchNode(node, run, driver, ctx);
146
+ return dispatchNode(node, run, driver, ctx, agent, flow);
143
147
  }
144
148
  appendAssistantMessage(run, turn.text);
145
149
  if (decision.kind === 'transition') {
@@ -151,7 +155,7 @@ async function dispatchNode(node, run, driver, ctx) {
151
155
  if (turn.interrupted) {
152
156
  const signal = await driver.awaitUser(ctx);
153
157
  appendUserMessage(run, signal.input);
154
- return dispatchNode(node, run, driver, ctx);
158
+ return dispatchNode(node, run, driver, ctx, agent, flow);
155
159
  }
156
160
  if (turn.control?.type === 'handoff') {
157
161
  return { kind: 'handoff', to: turn.control.target, reason: turn.control.reason };
@@ -172,7 +176,7 @@ async function dispatchNode(node, run, driver, ctx) {
172
176
  }
173
177
  throw new Error(`Unknown node kind: ${node.kind}`);
174
178
  }
175
- export async function runFlow(flow, run, driver, ctx) {
179
+ export async function runFlow(flow, run, driver, ctx, agent) {
176
180
  const registry = buildNodeRegistry(flow);
177
181
  const startNode = resolveStartNode(flow);
178
182
  const initialNodeId = run.activeNode ?? startNode.id;
@@ -192,7 +196,7 @@ export async function runFlow(flow, run, driver, ctx) {
192
196
  for (;;) {
193
197
  let transition;
194
198
  try {
195
- transition = await dispatchNode(node, run, driver, ctx);
199
+ transition = await dispatchNode(node, run, driver, ctx, agent, flow);
196
200
  }
197
201
  catch (error) {
198
202
  if (error instanceof SuspendError || error instanceof ToolApprovalDeniedError) {
@@ -200,9 +204,27 @@ export async function runFlow(flow, run, driver, ctx) {
200
204
  }
201
205
  const message = error instanceof Error ? error.message : String(error);
202
206
  ctx.emit({ type: 'error', error: message });
203
- return degradeFlowError(flow, registry, run, driver, ctx, dispatchNode);
207
+ return degradeFlowError(flow, registry, run, driver, ctx, (n, r, d, c) => dispatchNode(n, r, d, c, agent, flow));
208
+ }
209
+ if (transition.kind === 'switchFlow') {
210
+ run.activeFlow = transition.flow.name;
211
+ run.activeNode = undefined;
212
+ await ctx.runStore.putRunState(run);
213
+ return runFlow(transition.flow, run, driver, ctx, agent);
204
214
  }
205
215
  if (transition.kind === 'end') {
216
+ const park = getFlowPark(run.state);
217
+ if (park && agent) {
218
+ delete run.state.__flowPark;
219
+ const parkedFlow = agent.flows?.find((candidate) => candidate.name === park.flow);
220
+ if (parkedFlow) {
221
+ run.activeFlow = park.flow;
222
+ run.activeNode = park.node;
223
+ await ctx.runStore.putRunState(run);
224
+ ctx.emit({ type: 'flow-end', flow: flow.name, reason: transition.reason });
225
+ return runFlow(parkedFlow, run, driver, ctx, agent);
226
+ }
227
+ }
206
228
  ctx.emit({ type: 'flow-end', flow: flow.name, reason: transition.reason });
207
229
  return { kind: 'ended', reason: transition.reason };
208
230
  }
@@ -1,4 +1,4 @@
1
- import { streamText, generateObject } from 'ai';
1
+ import { streamText } from 'ai';
2
2
  import { buildNodePrompt, resolveInstructions, composeSystem } from '../../flow/nodeBuilders.js';
3
3
  import { buildToolSet } from '../../tools/effect/index.js';
4
4
  import { executeModelToolCall, toolResultMessage } from './executeModelTool.js';
@@ -8,6 +8,7 @@ import { applyPreTurnPolicies, applyPostTurnPolicies } from '../policies/agentTu
8
8
  import { resolveMaxSteps } from '../policies/limits.js';
9
9
  import { appendGatherBlocks, resolveNodeGatherScope, runGatherPhase } from '../grounding/index.js';
10
10
  import { isFlowTransitionControlTool } from '../../flow/flowControlTools.js';
11
+ import { resolveStructuredDecide } from '../../flow/choiceMatch.js';
11
12
  export class TextDriver {
12
13
  toolDefs;
13
14
  maxSteps;
@@ -113,26 +114,8 @@ export class TextDriver {
113
114
  return runSilentExtraction(node, ctx, ctx.controlModel, resolveMaxSteps(ctx.limits, this.maxSteps));
114
115
  }
115
116
  async runStructured(node, ctx) {
116
- const base = composeSystem(ctx.baseInstructions, resolveInstructions(node.instructions, ctx.runState.state), ctx.runState.state);
117
- const schema = node.schema;
118
- // When the node offers choices (e.g. via withChoices), constrain the model
119
- // to return exactly one option id. Otherwise an unconstrained string schema
120
- // lets the model reply with free-form prose that `decide()` can't match,
121
- // stalling the flow at every interactive node.
122
- const system = node.choices?.length
123
- ? `${base}\n\nYou MUST pick exactly ONE option by its id. Valid ids: ${node.choices
124
- .map((c) => c.id)
125
- .join(', ')}. Respond with only the chosen id, nothing else.`
126
- : base;
127
- const { object } = await generateObject({
128
- model: ctx.controlModel,
129
- schema,
130
- system,
131
- messages: ctx.runState.messages,
132
- temperature: 0,
133
- abortSignal: ctx.abortSignal,
134
- });
135
- return object;
117
+ const system = composeSystem(ctx.baseInstructions, resolveInstructions(node.instructions, ctx.runState.state), ctx.runState.state);
118
+ return resolveStructuredDecide(node, ctx, system);
136
119
  }
137
120
  async awaitUser(ctx) {
138
121
  const input = consumePendingUserInput(ctx.session);
@@ -1,5 +1,5 @@
1
- import { generateObject } from 'ai';
2
1
  import { runSilentExtraction } from './extractionTurn.js';
2
+ import { resolveStructuredDecide } from '../../flow/choiceMatch.js';
3
3
  import { buildNodePrompt, resolveInstructions, composeSystem } from '../../flow/nodeBuilders.js';
4
4
  import { executeModelToolCall } from './executeModelTool.js';
5
5
  import { applyPreTurnPolicies, applyPostTurnPolicies } from '../policies/agentTurn.js';
@@ -65,24 +65,8 @@ export class VoiceDriver {
65
65
  return out;
66
66
  }
67
67
  async runStructured(node, ctx) {
68
- const base = composeSystem(ctx.baseInstructions, resolveInstructions(node.instructions, ctx.runState.state), ctx.runState.state);
69
- const schema = node.schema;
70
- // Parity with TextDriver: when the node offers choices, constrain the model
71
- // to a single option id so an unconstrained string can't stall the decide.
72
- const system = node.choices?.length
73
- ? `${base}\n\nYou MUST pick exactly ONE option by its id. Valid ids: ${node.choices
74
- .map((c) => c.id)
75
- .join(', ')}. Respond with only the chosen id, nothing else.`
76
- : base;
77
- const { object } = await generateObject({
78
- model: ctx.controlModel,
79
- schema,
80
- system,
81
- messages: ctx.runState.messages,
82
- temperature: 0,
83
- abortSignal: ctx.abortSignal,
84
- });
85
- return object;
68
+ const system = composeSystem(ctx.baseInstructions, resolveInstructions(node.instructions, ctx.runState.state), ctx.runState.state);
69
+ return resolveStructuredDecide(node, ctx, system);
86
70
  }
87
71
  // Non-speaking collect extraction: uses the shared text-model extraction path
88
72
  // rather than the realtime audio provider, so the agent never SPEAKS during
@@ -14,7 +14,7 @@ export async function hostLoop(options) {
14
14
  if (!flow) {
15
15
  throw new Error(`Active flow "${run.activeFlow}" not found on agent "${agent.id}"`);
16
16
  }
17
- return await runActiveFlow(flow, run, driver, ctx);
17
+ return await runActiveFlow(flow, run, driver, ctx, agent);
18
18
  }
19
19
  const alwaysRoute = agent.routing?.always === true;
20
20
  if (shouldRunHostSelector(agent, run.activeFlow, alwaysRoute)) {
@@ -25,7 +25,7 @@ export async function hostLoop(options) {
25
25
  alwaysRoute,
26
26
  });
27
27
  if (selection.kind === 'enterFlow') {
28
- return await runActiveFlow(selection.flow, run, driver, ctx);
28
+ return await runActiveFlow(selection.flow, run, driver, ctx, agent);
29
29
  }
30
30
  if (selection.kind === 'route') {
31
31
  ctx.emit({ type: 'handoff', targetAgent: selection.agentId, reason: selection.reason });
@@ -45,10 +45,10 @@ export async function hostLoop(options) {
45
45
  throw error;
46
46
  }
47
47
  }
48
- async function runActiveFlow(flow, run, driver, ctx) {
48
+ async function runActiveFlow(flow, run, driver, ctx, agent) {
49
49
  incrementTurnCount(run);
50
50
  assertWithinTurnLimit(run, ctx.limits);
51
- const result = await runFlow(flow, run, driver, ctx);
51
+ const result = await runFlow(flow, run, driver, ctx, agent);
52
52
  if (result.kind === 'handoff') {
53
53
  return { kind: 'handoff', to: result.to, reason: result.reason };
54
54
  }
@@ -17,5 +17,7 @@ export interface SelectHostOptions {
17
17
  run: RunState;
18
18
  model: LanguageModel;
19
19
  alwaysRoute?: boolean;
20
+ /** Flow names to exclude from enterFlow (e.g. the active flow during in-flow digression). */
21
+ excludeFlowNames?: string[];
20
22
  }
21
23
  export declare function selectHostTarget(options: SelectHostOptions): Promise<HostSelection>;
@@ -19,11 +19,19 @@ export async function selectHostTarget(options) {
19
19
  }
20
20
  const completed = run.state.__completedFlows;
21
21
  const completedFlows = Array.isArray(completed) ? completed : [];
22
- const availableFlows = flows.filter((flow) => !completedFlows.includes(flow.name));
22
+ const excluded = new Set(options.excludeFlowNames ?? []);
23
+ const availableFlows = flows.filter((flow) => !completedFlows.includes(flow.name) && !excluded.has(flow.name));
23
24
  const agentRoutes = routes.filter((route) => route.agent);
25
+ if (availableFlows.length === 0 && agentRoutes.length === 0) {
26
+ return { kind: 'keep' };
27
+ }
24
28
  if (availableFlows.length === 1 && agentRoutes.length === 0) {
25
29
  return { kind: 'enterFlow', flow: availableFlows[0] };
26
30
  }
31
+ const deterministic = deterministicRouteMatch(latestUser, routes, availableFlows);
32
+ if (deterministic) {
33
+ return deterministic;
34
+ }
27
35
  const flowLines = availableFlows
28
36
  .map((flow) => `- flow "${flow.name}": ${flow.description}`)
29
37
  .join('\n');
@@ -72,6 +80,32 @@ export async function selectHostTarget(options) {
72
80
  }
73
81
  return keywordRouteFallback(latestUser, routes, availableFlows);
74
82
  }
83
+ function deterministicRouteMatch(message, routes, availableFlows) {
84
+ const lower = message.toLowerCase();
85
+ const hits = [];
86
+ for (const route of routes) {
87
+ const terms = route.when
88
+ .toLowerCase()
89
+ .split(/[^a-z0-9]+/)
90
+ .filter((term) => term.length > 3);
91
+ if (!terms.some((term) => lower.includes(term))) {
92
+ continue;
93
+ }
94
+ if (route.flow) {
95
+ const flow = availableFlows.find((candidate) => candidate.name === route.flow);
96
+ if (flow) {
97
+ hits.push({ kind: 'enterFlow', flow });
98
+ }
99
+ }
100
+ else if (route.agent) {
101
+ hits.push({ kind: 'route', agentId: route.agent });
102
+ }
103
+ }
104
+ if (hits.length === 1) {
105
+ return hits[0];
106
+ }
107
+ return undefined;
108
+ }
75
109
  function keywordRouteFallback(message, routes, availableFlows) {
76
110
  const lower = message.toLowerCase();
77
111
  for (const route of routes) {
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/kuralle/kuralle-agents.git",
7
7
  "directory": "packages/kuralle-core"
8
8
  },
9
- "version": "0.3.15",
9
+ "version": "0.3.17",
10
10
  "description": "A framework for structured conversational AI agents",
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -97,7 +97,7 @@
97
97
  "dotenv": "^16.4.0",
98
98
  "typescript": "^5.3.0",
99
99
  "zod": "^3.23.0",
100
- "@kuralle-agents/realtime-audio": "0.3.15"
100
+ "@kuralle-agents/realtime-audio": "0.3.17"
101
101
  },
102
102
  "dependencies": {
103
103
  "chrono-node": "^2.6.0",