@kuralle-agents/core 0.3.4 → 0.3.6

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
  }
@@ -4,5 +4,10 @@ import type { ResolvedNode } from '../types/channel.js';
4
4
  import type { Tool } from '../types/effectTool.js';
5
5
  export declare function resolveInstructions(instructions: Instructions, state: FlowState): string;
6
6
  export declare function buildNodePrompt(node: ReplyNode, state: FlowState): string;
7
+ /** Compose the agent base layer (ADR 0001) into a node's system prompt: the
8
+ * agent's base instructions (persona / safety / grounding) prefix the node's
9
+ * own instructions. Node instructions layer ON TOP — they never replace the
10
+ * base. Base resolves against the current state so dynamic base prompts work. */
11
+ export declare function composeSystem(base: Instructions | undefined, nodeSystem: string, state: FlowState): string;
7
12
  export declare function resolveReplyNode(node: ReplyNode, state: FlowState): ResolvedNode;
8
13
  export declare function resolveCollectExtractionNode(collectNode: CollectNode, missing: string[], state: FlowState, submitTool: Tool): ResolvedNode;
@@ -15,6 +15,14 @@ export function resolveInstructions(instructions, state) {
15
15
  export function buildNodePrompt(node, state) {
16
16
  return resolveInstructions(node.instructions, state);
17
17
  }
18
+ /** Compose the agent base layer (ADR 0001) into a node's system prompt: the
19
+ * agent's base instructions (persona / safety / grounding) prefix the node's
20
+ * own instructions. Node instructions layer ON TOP — they never replace the
21
+ * base. Base resolves against the current state so dynamic base prompts work. */
22
+ export function composeSystem(base, nodeSystem, state) {
23
+ const baseText = base ? resolveInstructions(base, state) : '';
24
+ return [baseText, nodeSystem].filter((s) => s && s.trim()).join('\n\n');
25
+ }
18
26
  function buildNodeTools(node, state) {
19
27
  if (!node.tools) {
20
28
  return {};
@@ -97,6 +97,9 @@ export class Runtime {
97
97
  ? buildMemoryService(this.config.memoryService, opened.agent)
98
98
  : undefined,
99
99
  });
100
+ // Agent base layer (ADR 0001): composed into every node turn by the drivers.
101
+ runCtx.baseInstructions = opened.agent.instructions;
102
+ runCtx.globalTools = opened.agent.globalTools;
100
103
  await this.hooks?.onStart?.(runCtx);
101
104
  const driver = opts.driver ?? new TextDriver();
102
105
  let activeAgent = opened.agent;
@@ -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;
@@ -1,8 +1,9 @@
1
1
  import { streamText, generateObject } from 'ai';
2
- import { buildNodePrompt, resolveInstructions } from '../../flow/nodeBuilders.js';
2
+ import { buildNodePrompt, resolveInstructions, composeSystem } 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';
@@ -28,10 +29,11 @@ export class TextDriver {
28
29
  const gather = await runGatherPhase(ctx);
29
30
  const out = { text: '', toolResults: [] };
30
31
  const model = replyNode.model ?? ctx.model;
31
- const baseSystem = node.prompt || buildNodePrompt(replyNode, ctx.runState.state);
32
+ const nodeSystem = node.prompt || buildNodePrompt(replyNode, ctx.runState.state);
33
+ const baseSystem = composeSystem(ctx.baseInstructions, nodeSystem, ctx.runState.state);
32
34
  const system = appendGatherBlocks(baseSystem, [gather.retrievalBlock, gather.memoryBlock]);
33
35
  const messages = [...ctx.runState.messages];
34
- const aiTools = this.resolveTools(node);
36
+ const aiTools = this.resolveTools(node, ctx.globalTools);
35
37
  const maxSteps = resolveMaxSteps(ctx.limits, this.maxSteps);
36
38
  const toolCallsMade = [];
37
39
  let draftText = '';
@@ -126,8 +128,15 @@ export class TextDriver {
126
128
  ctx.emit({ type: 'turn-end' });
127
129
  return out;
128
130
  }
131
+ // Non-speaking field extraction for collect nodes (shared helper so text and
132
+ // voice are identical). The model's prose is discarded; the user-facing
133
+ // question is emitted deterministically by the flow engine (CollectNode.ask).
134
+ runExtraction(node, ctx) {
135
+ const model = node.node.model ?? ctx.model;
136
+ return runSilentExtraction(node, ctx, model, resolveMaxSteps(ctx.limits, this.maxSteps));
137
+ }
129
138
  async runStructured(node, ctx) {
130
- const base = resolveInstructions(node.instructions, ctx.runState.state);
139
+ const base = composeSystem(ctx.baseInstructions, resolveInstructions(node.instructions, ctx.runState.state), ctx.runState.state);
131
140
  const schema = node.schema;
132
141
  // When the node offers choices (e.g. via withChoices), constrain the model
133
142
  // to return exactly one option id. Otherwise an unconstrained string schema
@@ -151,8 +160,8 @@ export class TextDriver {
151
160
  const input = consumePendingUserInput(ctx.session);
152
161
  return { type: 'message', input };
153
162
  }
154
- resolveTools(resolved) {
155
- const merged = { ...this.toolDefs, ...(resolved.localTools ?? {}) };
163
+ resolveTools(resolved, globalTools) {
164
+ const merged = { ...this.toolDefs, ...(globalTools ?? {}), ...(resolved.localTools ?? {}) };
156
165
  const aiTools = { ...resolved.tools };
157
166
  for (const [name, tool] of Object.entries(merged)) {
158
167
  if (tool && !aiTools[name]) {
@@ -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,5 +1,6 @@
1
1
  import { generateObject } from 'ai';
2
- import { buildNodePrompt, resolveInstructions } from '../../flow/nodeBuilders.js';
2
+ import { runSilentExtraction } from './extractionTurn.js';
3
+ import { buildNodePrompt, resolveInstructions, composeSystem } from '../../flow/nodeBuilders.js';
3
4
  import { classifyControl } from '../../flow/classifyControl.js';
4
5
  import { applyPreTurnPolicies, applyPostTurnPolicies } from '../policies/agentTurn.js';
5
6
  import { resolveMaxSteps } from '../policies/limits.js';
@@ -30,9 +31,10 @@ export class VoiceDriver {
30
31
  }
31
32
  const gather = await runGatherPhase(ctx);
32
33
  const out = { text: '', toolResults: [] };
33
- const baseSystem = node.prompt || buildNodePrompt(replyNode, ctx.runState.state);
34
+ const nodeSystem = node.prompt || buildNodePrompt(replyNode, ctx.runState.state);
35
+ const baseSystem = composeSystem(ctx.baseInstructions, nodeSystem, ctx.runState.state);
34
36
  const system = appendGatherBlocks(baseSystem, [gather.retrievalBlock, gather.memoryBlock]);
35
- const geminiTools = resolveVoiceGeminiTools(node, this.toolDefs);
37
+ const geminiTools = resolveVoiceGeminiTools(node, { ...this.toolDefs, ...(ctx.globalTools ?? {}) });
36
38
  const toolCallsMade = [];
37
39
  const maxSteps = resolveMaxSteps(ctx.limits, this.maxSteps);
38
40
  let draftText = '';
@@ -62,7 +64,7 @@ export class VoiceDriver {
62
64
  return out;
63
65
  }
64
66
  async runStructured(node, ctx) {
65
- const system = resolveInstructions(node.instructions, ctx.runState.state);
67
+ const system = composeSystem(ctx.baseInstructions, resolveInstructions(node.instructions, ctx.runState.state), ctx.runState.state);
66
68
  const schema = node.schema;
67
69
  const { object } = await generateObject({
68
70
  model: ctx.model,
@@ -73,6 +75,14 @@ export class VoiceDriver {
73
75
  });
74
76
  return object;
75
77
  }
78
+ // Non-speaking collect extraction: uses the shared text-model extraction path
79
+ // rather than the realtime audio provider, so the agent never SPEAKS during
80
+ // field collection (which is where ungrounded narration leaked). Identical
81
+ // behavior to TextDriver — voice and text emit the same structural events; the
82
+ // user-facing question is the deterministic CollectNode.ask, synthesized after.
83
+ runExtraction(node, ctx) {
84
+ return runSilentExtraction(node, ctx, ctx.model, resolveMaxSteps(ctx.limits, this.maxSteps));
85
+ }
76
86
  async awaitUser(ctx) {
77
87
  if (this.pendingBargeInInput != null) {
78
88
  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,87 @@
1
+ import { streamText } from 'ai';
2
+ import { buildToolSet } from '../../tools/effect/index.js';
3
+ import { buildNodePrompt, composeSystem } 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 nodeSystem = node.prompt || buildNodePrompt(replyNode, ctx.runState.state);
17
+ const system = composeSystem(ctx.baseInstructions, nodeSystem, ctx.runState.state);
18
+ const messages = [...ctx.runState.messages];
19
+ const aiTools = resolveExtractionTools(node);
20
+ const out = { text: '', toolResults: [] };
21
+ for (let step = 0; step < maxSteps; step += 1) {
22
+ const result = streamText({ model, system, messages, tools: aiTools, abortSignal: ctx.abortSignal });
23
+ for await (const part of result.fullStream) {
24
+ // Intentionally NOT handling 'text-delta' — extraction never speaks.
25
+ if (part.type === 'error') {
26
+ const err = part.error;
27
+ const message = err instanceof Error ? err.message : String(err);
28
+ ctx.emit({ type: 'error', error: message });
29
+ throw err instanceof Error ? err : new Error(message);
30
+ }
31
+ }
32
+ const finishReason = await result.finishReason;
33
+ const response = await result.response;
34
+ messages.push(...response.messages);
35
+ if (finishReason !== 'tool-calls') {
36
+ break;
37
+ }
38
+ const toolCalls = await result.toolCalls;
39
+ for (const call of toolCalls) {
40
+ const localTool = node.localTools?.[call.toolName];
41
+ const toolResult = await ctx.tool(call.toolName, call.input, {
42
+ toolCallId: call.toolCallId,
43
+ ...(localTool && {
44
+ def: localTool,
45
+ toolCtx: {
46
+ session: ctx.session,
47
+ runState: ctx.runState,
48
+ tool: ctx.tool.bind(ctx),
49
+ now: ctx.now.bind(ctx),
50
+ uuid: ctx.uuid.bind(ctx),
51
+ emit: ctx.emit.bind(ctx),
52
+ },
53
+ }),
54
+ });
55
+ out.toolResults.push({
56
+ name: call.toolName,
57
+ args: call.input,
58
+ result: toolResult,
59
+ toolCallId: call.toolCallId,
60
+ });
61
+ messages.push({
62
+ role: 'tool',
63
+ content: [
64
+ {
65
+ type: 'tool-result',
66
+ toolCallId: call.toolCallId,
67
+ toolName: call.toolName,
68
+ output: { type: 'json', value: toolResult },
69
+ },
70
+ ],
71
+ });
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+ /** Tools available to the extraction turn = the node's submit tool only (built
77
+ * from the resolved node, independent of any driver-level tool defs) so text and
78
+ * voice resolve an identical toolset. */
79
+ function resolveExtractionTools(resolved) {
80
+ const aiTools = { ...resolved.tools };
81
+ for (const [name, tool] of Object.entries(resolved.localTools ?? {})) {
82
+ if (tool && !aiTools[name]) {
83
+ Object.assign(aiTools, buildToolSet({ [name]: tool }));
84
+ }
85
+ }
86
+ return Object.keys(aiTools).length > 0 ? aiTools : undefined;
87
+ }
@@ -16,6 +16,12 @@ export interface AgentConfig {
16
16
  model?: LanguageModel;
17
17
  tools?: ToolSet;
18
18
  effectTools?: Record<string, AnyTool>;
19
+ /** Safe, always-available tools made model-visible in EVERY speaking node turn
20
+ * (the agent "base layer", ADR 0001) — e.g. a returns/FAQ knowledge-base
21
+ * lookup the user might ask for mid-flow. This is an explicit allow-list:
22
+ * NEVER put consequential/mutating tools here (they must stay flow-gated), and
23
+ * they are not exposed during non-speaking collect extraction. */
24
+ globalTools?: Record<string, AnyTool>;
19
25
  flows?: Flow[];
20
26
  routes?: Route[];
21
27
  routing?: RoutingPolicy;
@@ -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>;
@@ -8,6 +8,7 @@ import type { RefinementCapability } from '../capabilities/RefinementCapability.
8
8
  import type { ValidationCapability } from '../capabilities/ValidationCapability.js';
9
9
  import type { Limits } from './guardrails.js';
10
10
  import type { AnyTool } from './effectTool.js';
11
+ import type { Instructions } from './agentConfig.js';
11
12
  export interface EffectToolExecutor {
12
13
  execute(args: {
13
14
  name: string;
@@ -57,6 +58,12 @@ export interface RunContext {
57
58
  * context. Reset to false on every `createRunContext` (i.e. every turn).
58
59
  */
59
60
  turnInputConsumed?: boolean;
61
+ /** Agent base layer (ADR 0001), set when entering a flow. `baseInstructions`
62
+ * is composed as a prefix into every node turn's system prompt (persona /
63
+ * safety / grounding floor); `globalTools` are safe tools made model-visible
64
+ * in every speaking turn. */
65
+ baseInstructions?: Instructions;
66
+ globalTools?: Record<string, AnyTool>;
60
67
  tool(name: string, args: unknown, options?: {
61
68
  toolCallId?: string;
62
69
  def?: AnyTool;
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.6",
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.6"
101
101
  },
102
102
  "dependencies": {
103
103
  "chrono-node": "^2.6.0",