@kuralle-agents/core 0.3.3 → 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.
- package/dist/flow/collectUntilComplete.js +43 -2
- package/dist/flow/extraction.js +10 -3
- package/dist/runtime/channels/TextDriver.d.ts +1 -0
- package/dist/runtime/channels/TextDriver.js +8 -0
- package/dist/runtime/channels/VoiceDriver.d.ts +1 -0
- package/dist/runtime/channels/VoiceDriver.js +9 -0
- package/dist/runtime/channels/extractionTurn.d.ts +14 -0
- package/dist/runtime/channels/extractionTurn.js +86 -0
- package/dist/types/channel.d.ts +7 -0
- package/dist/types/flow.d.ts +7 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/flow/extraction.js
CHANGED
|
@@ -34,11 +34,18 @@ export function schemaSatisfied(node, state) {
|
|
|
34
34
|
return computeMissingFields(node, data).length === 0;
|
|
35
35
|
}
|
|
36
36
|
export function projectCollectData(node, state) {
|
|
37
|
+
// Hand onComplete EVERY field the node collected (all schema keys present in
|
|
38
|
+
// the collected data) — not just the required subset. Projecting only the
|
|
39
|
+
// required fields silently dropped optional extracted values (e.g. a welcome
|
|
40
|
+
// node that also captures occasion/recipient), so onComplete could never read
|
|
41
|
+
// them. The schema is the contract for what a collect node yields.
|
|
37
42
|
const data = getCollectData(state, node.id);
|
|
38
|
-
const
|
|
43
|
+
const fields = inferRequiredFields(node.schema);
|
|
39
44
|
const projected = {};
|
|
40
|
-
for (const field of
|
|
41
|
-
|
|
45
|
+
for (const field of fields) {
|
|
46
|
+
if (field in data) {
|
|
47
|
+
projected[field] = data[field];
|
|
48
|
+
}
|
|
42
49
|
}
|
|
43
50
|
return projected;
|
|
44
51
|
}
|
|
@@ -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
|
+
}
|
package/dist/types/channel.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/flow.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
100
|
+
"@kuralle-agents/realtime-audio": "0.3.5"
|
|
101
101
|
},
|
|
102
102
|
"dependencies": {
|
|
103
103
|
"chrono-node": "^2.6.0",
|