@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.
- package/dist/flow/choiceMatch.d.ts +17 -0
- package/dist/flow/choiceMatch.js +119 -0
- package/dist/flow/collectDigression.d.ts +31 -0
- package/dist/flow/collectDigression.js +99 -0
- package/dist/flow/collectUntilComplete.d.ts +6 -1
- package/dist/flow/collectUntilComplete.js +28 -5
- package/dist/flow/normalizeTransition.d.ts +6 -1
- package/dist/flow/runFlow.d.ts +2 -1
- package/dist/flow/runFlow.js +29 -7
- package/dist/runtime/channels/TextDriver.js +4 -21
- package/dist/runtime/channels/VoiceDriver.js +3 -19
- package/dist/runtime/hostLoop.js +4 -4
- package/dist/runtime/select.d.ts +2 -0
- package/dist/runtime/select.js +35 -1
- package/package.json +2 -2
|
@@ -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
|
|
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
|
|
48
|
-
const submitTool = createExtractionSubmitTool(node,
|
|
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,
|
|
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;
|
package/dist/flow/runFlow.d.ts
CHANGED
|
@@ -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>;
|
package/dist/flow/runFlow.js
CHANGED
|
@@ -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
|
|
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
|
|
117
|
-
|
|
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
|
|
69
|
-
|
|
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
|
package/dist/runtime/hostLoop.js
CHANGED
|
@@ -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
|
}
|
package/dist/runtime/select.d.ts
CHANGED
|
@@ -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>;
|
package/dist/runtime/select.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
100
|
+
"@kuralle-agents/realtime-audio": "0.3.17"
|
|
101
101
|
},
|
|
102
102
|
"dependencies": {
|
|
103
103
|
"chrono-node": "^2.6.0",
|