@kuralle-agents/core 0.3.4 → 0.3.5

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.
@@ -32,6 +32,9 @@ export async function collectUntilComplete(node, run, driver, ctx) {
32
32
  appendUserMessage(run, signal.input);
33
33
  }
34
34
  else if (ctx.turnInputConsumed) {
35
+ // No fresh input to extract this turn: ask (deterministically) for the
36
+ // fields still missing and wait. Never run extraction over stale context.
37
+ emitCollectAsk(node, run, ctx);
35
38
  return { kind: 'stay' };
36
39
  }
37
40
  ctx.turnInputConsumed = true;
@@ -46,11 +49,49 @@ export async function collectUntilComplete(node, run, driver, ctx) {
46
49
  userMessage: peekLatestUserMessage(run),
47
50
  });
48
51
  const resolved = resolveCollectExtractionNode(node, missing, run.state, submitTool);
49
- const turn = await driver.runAgentTurn(resolved, ctx);
52
+ // Non-speaking extraction: the model's prose is DISCARDED (never emitted or
53
+ // appended), so a collect turn cannot author narration that contradicts flow
54
+ // state. Falls back to runAgentTurn for drivers without runExtraction; its
55
+ // text is likewise dropped here. The user-facing question is the deterministic
56
+ // `ask` emitted above — never model-authored.
57
+ const turn = await (driver.runExtraction
58
+ ? driver.runExtraction(resolved, ctx)
59
+ : driver.runAgentTurn(resolved, ctx));
50
60
  mergeExtractionFromTurn(node, run, turn);
51
- appendAssistantMessage(run, turn.text);
52
61
  }
53
62
  }
63
+ function humanizeField(field) {
64
+ return field
65
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
66
+ .replace(/[_-]+/g, ' ')
67
+ .trim()
68
+ .toLowerCase();
69
+ }
70
+ /** Deterministic, framework-authored question for the still-missing fields. Uses
71
+ * the node's `ask` when provided, else a safe default that never references a
72
+ * downstream outcome (order/delivery/payment/website). */
73
+ function renderCollectAsk(node, missing, state) {
74
+ if (node.ask) {
75
+ return node.ask(missing, state);
76
+ }
77
+ if (missing.length === 1) {
78
+ return `Could you share your ${humanizeField(missing[0])}?`;
79
+ }
80
+ if (missing.length > 1) {
81
+ return `Could you share: ${missing.map(humanizeField).join(', ')}?`;
82
+ }
83
+ return 'Could you tell me a little more?';
84
+ }
85
+ function emitCollectAsk(node, run, ctx) {
86
+ const missing = computeMissingFields(node, getCollectData(run.state, node.id));
87
+ const text = renderCollectAsk(node, missing, run.state);
88
+ if (!text.trim()) {
89
+ return;
90
+ }
91
+ ctx.emit({ type: 'text-delta', text });
92
+ ctx.emit({ type: 'turn-end' });
93
+ appendAssistantMessage(run, text);
94
+ }
54
95
  function mergeExtractionFromTurn(node, run, turn) {
55
96
  mergeTurnExtraction(node, run.state, turn.toolResults.map((record) => ({ name: record.name, result: record.result })));
56
97
  }
@@ -13,6 +13,7 @@ export declare class TextDriver implements ChannelDriver {
13
13
  private readonly maxSteps;
14
14
  constructor(config?: TextDriverConfig);
15
15
  runAgentTurn(node: ResolvedNode, ctx: RunContext): Promise<TurnResult>;
16
+ runExtraction(node: ResolvedNode, ctx: RunContext): Promise<TurnResult>;
16
17
  runStructured(node: DecideNode, ctx: RunContext): Promise<unknown>;
17
18
  awaitUser(ctx: RunContext): Promise<UserSignal>;
18
19
  private resolveTools;
@@ -3,6 +3,7 @@ import { buildNodePrompt, resolveInstructions } from '../../flow/nodeBuilders.js
3
3
  import { buildToolSet } from '../../tools/effect/index.js';
4
4
  import { classifyControl } from '../../flow/classifyControl.js';
5
5
  import { consumePendingUserInput } from './inputBuffer.js';
6
+ import { runSilentExtraction } from './extractionTurn.js';
6
7
  import { applyPreTurnPolicies, applyPostTurnPolicies } from '../policies/agentTurn.js';
7
8
  import { resolveMaxSteps } from '../policies/limits.js';
8
9
  import { appendGatherBlocks, runGatherPhase } from '../grounding/index.js';
@@ -126,6 +127,13 @@ export class TextDriver {
126
127
  ctx.emit({ type: 'turn-end' });
127
128
  return out;
128
129
  }
130
+ // Non-speaking field extraction for collect nodes (shared helper so text and
131
+ // voice are identical). The model's prose is discarded; the user-facing
132
+ // question is emitted deterministically by the flow engine (CollectNode.ask).
133
+ runExtraction(node, ctx) {
134
+ const model = node.node.model ?? ctx.model;
135
+ return runSilentExtraction(node, ctx, model, resolveMaxSteps(ctx.limits, this.maxSteps));
136
+ }
129
137
  async runStructured(node, ctx) {
130
138
  const base = resolveInstructions(node.instructions, ctx.runState.state);
131
139
  const schema = node.schema;
@@ -20,6 +20,7 @@ export declare class VoiceDriver implements ChannelDriver {
20
20
  constructor(config: VoiceDriverConfig);
21
21
  runAgentTurn(node: ResolvedNode, ctx: RunContext): Promise<TurnResult>;
22
22
  runStructured(node: DecideNode, ctx: RunContext): Promise<unknown>;
23
+ runExtraction(node: ResolvedNode, ctx: RunContext): Promise<TurnResult>;
23
24
  awaitUser(ctx: RunContext): Promise<UserSignal>;
24
25
  reconfigure(config: Partial<RealtimeSessionConfig>): Promise<void>;
25
26
  getHeardCharCount(): number;
@@ -1,4 +1,5 @@
1
1
  import { generateObject } from 'ai';
2
+ import { runSilentExtraction } from './extractionTurn.js';
2
3
  import { buildNodePrompt, resolveInstructions } from '../../flow/nodeBuilders.js';
3
4
  import { classifyControl } from '../../flow/classifyControl.js';
4
5
  import { applyPreTurnPolicies, applyPostTurnPolicies } from '../policies/agentTurn.js';
@@ -73,6 +74,14 @@ export class VoiceDriver {
73
74
  });
74
75
  return object;
75
76
  }
77
+ // Non-speaking collect extraction: uses the shared text-model extraction path
78
+ // rather than the realtime audio provider, so the agent never SPEAKS during
79
+ // field collection (which is where ungrounded narration leaked). Identical
80
+ // behavior to TextDriver — voice and text emit the same structural events; the
81
+ // user-facing question is the deterministic CollectNode.ask, synthesized after.
82
+ runExtraction(node, ctx) {
83
+ return runSilentExtraction(node, ctx, ctx.model, resolveMaxSteps(ctx.limits, this.maxSteps));
84
+ }
76
85
  async awaitUser(ctx) {
77
86
  if (this.pendingBargeInInput != null) {
78
87
  const input = this.pendingBargeInInput;
@@ -0,0 +1,14 @@
1
+ import { type LanguageModel } from 'ai';
2
+ import type { ResolvedNode, TurnResult } from '../../types/channel.js';
3
+ import type { RunContext } from '../../types/run-context.js';
4
+ /**
5
+ * Shared, NON-SPEAKING field extraction for `collect` nodes, used by every
6
+ * ChannelDriver so text and voice behave identically. It runs the model with the
7
+ * node's submit tool to pull structured fields, but never emits a `text-delta`,
8
+ * never emits `turn-end`, and never appends model prose — the model's words are
9
+ * discarded by construction. The user-facing question is emitted deterministically
10
+ * by the flow engine (`CollectNode.ask`), not by the model. This is the structural
11
+ * invariant that stops a collect turn from narrating outcomes that contradict
12
+ * flow state, regardless of which model is used.
13
+ */
14
+ export declare function runSilentExtraction(node: ResolvedNode, ctx: RunContext, model: LanguageModel, maxSteps: number): Promise<TurnResult>;
@@ -0,0 +1,86 @@
1
+ import { streamText } from 'ai';
2
+ import { buildToolSet } from '../../tools/effect/index.js';
3
+ import { buildNodePrompt } from '../../flow/nodeBuilders.js';
4
+ /**
5
+ * Shared, NON-SPEAKING field extraction for `collect` nodes, used by every
6
+ * ChannelDriver so text and voice behave identically. It runs the model with the
7
+ * node's submit tool to pull structured fields, but never emits a `text-delta`,
8
+ * never emits `turn-end`, and never appends model prose — the model's words are
9
+ * discarded by construction. The user-facing question is emitted deterministically
10
+ * by the flow engine (`CollectNode.ask`), not by the model. This is the structural
11
+ * invariant that stops a collect turn from narrating outcomes that contradict
12
+ * flow state, regardless of which model is used.
13
+ */
14
+ export async function runSilentExtraction(node, ctx, model, maxSteps) {
15
+ const replyNode = node.node;
16
+ const system = node.prompt || buildNodePrompt(replyNode, ctx.runState.state);
17
+ const messages = [...ctx.runState.messages];
18
+ const aiTools = resolveExtractionTools(node);
19
+ const out = { text: '', toolResults: [] };
20
+ for (let step = 0; step < maxSteps; step += 1) {
21
+ const result = streamText({ model, system, messages, tools: aiTools, abortSignal: ctx.abortSignal });
22
+ for await (const part of result.fullStream) {
23
+ // Intentionally NOT handling 'text-delta' — extraction never speaks.
24
+ if (part.type === 'error') {
25
+ const err = part.error;
26
+ const message = err instanceof Error ? err.message : String(err);
27
+ ctx.emit({ type: 'error', error: message });
28
+ throw err instanceof Error ? err : new Error(message);
29
+ }
30
+ }
31
+ const finishReason = await result.finishReason;
32
+ const response = await result.response;
33
+ messages.push(...response.messages);
34
+ if (finishReason !== 'tool-calls') {
35
+ break;
36
+ }
37
+ const toolCalls = await result.toolCalls;
38
+ for (const call of toolCalls) {
39
+ const localTool = node.localTools?.[call.toolName];
40
+ const toolResult = await ctx.tool(call.toolName, call.input, {
41
+ toolCallId: call.toolCallId,
42
+ ...(localTool && {
43
+ def: localTool,
44
+ toolCtx: {
45
+ session: ctx.session,
46
+ runState: ctx.runState,
47
+ tool: ctx.tool.bind(ctx),
48
+ now: ctx.now.bind(ctx),
49
+ uuid: ctx.uuid.bind(ctx),
50
+ emit: ctx.emit.bind(ctx),
51
+ },
52
+ }),
53
+ });
54
+ out.toolResults.push({
55
+ name: call.toolName,
56
+ args: call.input,
57
+ result: toolResult,
58
+ toolCallId: call.toolCallId,
59
+ });
60
+ messages.push({
61
+ role: 'tool',
62
+ content: [
63
+ {
64
+ type: 'tool-result',
65
+ toolCallId: call.toolCallId,
66
+ toolName: call.toolName,
67
+ output: { type: 'json', value: toolResult },
68
+ },
69
+ ],
70
+ });
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+ /** Tools available to the extraction turn = the node's submit tool only (built
76
+ * from the resolved node, independent of any driver-level tool defs) so text and
77
+ * voice resolve an identical toolset. */
78
+ function resolveExtractionTools(resolved) {
79
+ const aiTools = { ...resolved.tools };
80
+ for (const [name, tool] of Object.entries(resolved.localTools ?? {})) {
81
+ if (tool && !aiTools[name]) {
82
+ Object.assign(aiTools, buildToolSet({ [name]: tool }));
83
+ }
84
+ }
85
+ return Object.keys(aiTools).length > 0 ? aiTools : undefined;
86
+ }
@@ -14,6 +14,13 @@ export interface ChannelDriver {
14
14
  runStructured?(node: Extract<FlowNode, {
15
15
  kind: 'decide';
16
16
  }>, ctx: RunContext): Promise<unknown>;
17
+ /** Non-speaking field extraction for `collect` nodes: runs the submit tool to
18
+ * pull structured fields but MUST NOT emit any user-facing text (no
19
+ * text-delta, no spoken transcript). The returned `text` is ignored by the
20
+ * flow engine. This is the structural backstop that stops a collect turn from
21
+ * authoring narration that contradicts flow state. Drivers without it fall
22
+ * back to runAgentTurn, whose text the engine then discards. */
23
+ runExtraction?(node: ResolvedNode, ctx: RunContext): Promise<TurnResult>;
17
24
  }
18
25
  export interface TurnResult {
19
26
  text: string;
@@ -42,7 +42,14 @@ export interface CollectNode {
42
42
  id: string;
43
43
  schema: StandardSchemaV1;
44
44
  required?: string[];
45
+ /** Extraction-only guidance for the (non-speaking) field extraction turn.
46
+ * This text is NEVER shown to the user — see `ask` for user-facing copy. */
45
47
  instructions?: (missing: string[], state: FlowState) => Instructions;
48
+ /** Deterministic, framework-emitted question shown when fields are still
49
+ * missing. Collect extraction never speaks model-authored text, so this is
50
+ * the only user-facing copy a collect node produces. Must not claim any
51
+ * downstream outcome (order placed, delivery scheduled, payment, website). */
52
+ ask?: (missing: string[], state: FlowState) => string;
46
53
  choices?: ChoiceOption[];
47
54
  maxTurns?: number;
48
55
  onComplete: (data: unknown, state: FlowState) => Transition | Promise<Transition>;
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.4",
9
+ "version": "0.3.5",
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.4"
100
+ "@kuralle-agents/realtime-audio": "0.3.5"
101
101
  },
102
102
  "dependencies": {
103
103
  "chrono-node": "^2.6.0",