@kuralle-agents/core 0.3.16 → 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,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
  }
@@ -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,8 +19,12 @@ 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
  }
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.16",
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.16"
100
+ "@kuralle-agents/realtime-audio": "0.3.17"
101
101
  },
102
102
  "dependencies": {
103
103
  "chrono-node": "^2.6.0",