@kuralle-agents/core 0.3.15 → 0.3.16

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
+ }
@@ -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
@@ -24,6 +24,10 @@ export async function selectHostTarget(options) {
24
24
  if (availableFlows.length === 1 && agentRoutes.length === 0) {
25
25
  return { kind: 'enterFlow', flow: availableFlows[0] };
26
26
  }
27
+ const deterministic = deterministicRouteMatch(latestUser, routes, availableFlows);
28
+ if (deterministic) {
29
+ return deterministic;
30
+ }
27
31
  const flowLines = availableFlows
28
32
  .map((flow) => `- flow "${flow.name}": ${flow.description}`)
29
33
  .join('\n');
@@ -72,6 +76,32 @@ export async function selectHostTarget(options) {
72
76
  }
73
77
  return keywordRouteFallback(latestUser, routes, availableFlows);
74
78
  }
79
+ function deterministicRouteMatch(message, routes, availableFlows) {
80
+ const lower = message.toLowerCase();
81
+ const hits = [];
82
+ for (const route of routes) {
83
+ const terms = route.when
84
+ .toLowerCase()
85
+ .split(/[^a-z0-9]+/)
86
+ .filter((term) => term.length > 3);
87
+ if (!terms.some((term) => lower.includes(term))) {
88
+ continue;
89
+ }
90
+ if (route.flow) {
91
+ const flow = availableFlows.find((candidate) => candidate.name === route.flow);
92
+ if (flow) {
93
+ hits.push({ kind: 'enterFlow', flow });
94
+ }
95
+ }
96
+ else if (route.agent) {
97
+ hits.push({ kind: 'route', agentId: route.agent });
98
+ }
99
+ }
100
+ if (hits.length === 1) {
101
+ return hits[0];
102
+ }
103
+ return undefined;
104
+ }
75
105
  function keywordRouteFallback(message, routes, availableFlows) {
76
106
  const lower = message.toLowerCase();
77
107
  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.16",
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.16"
101
101
  },
102
102
  "dependencies": {
103
103
  "chrono-node": "^2.6.0",