@kuralle-agents/core 0.3.16 → 0.3.18

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.
@@ -1 +1,2 @@
1
1
  export { filterAuditEntries } from './filterAuditEntries.js';
2
+ export { appendConversationAudit, type AuditRecordContext, type NewAuditEntry } from './record.js';
@@ -1 +1,2 @@
1
1
  export { filterAuditEntries } from './filterAuditEntries.js';
2
+ export { appendConversationAudit } from './record.js';
@@ -0,0 +1,15 @@
1
+ import type { ConversationAuditEntry, AuditEntryBase } from './types.js';
2
+ import type { Session } from '../types/session.js';
3
+ export interface AuditRecordContext {
4
+ sessionId: string;
5
+ conversationId?: string;
6
+ userId?: string;
7
+ agentId?: string;
8
+ turnIndex?: number;
9
+ }
10
+ export type NewAuditEntry = {
11
+ [K in ConversationAuditEntry['type']]: Omit<Extract<ConversationAuditEntry, {
12
+ type: K;
13
+ }>, keyof AuditEntryBase>;
14
+ }[ConversationAuditEntry['type']];
15
+ export declare function appendConversationAudit(session: Session, ctx: AuditRecordContext, entry: NewAuditEntry): void;
@@ -0,0 +1,23 @@
1
+ export function appendConversationAudit(session, ctx, entry) {
2
+ const at = new Date().toISOString();
3
+ const full = {
4
+ at,
5
+ sessionId: ctx.sessionId,
6
+ conversationId: ctx.conversationId,
7
+ userId: ctx.userId,
8
+ agentId: ctx.agentId,
9
+ turnIndex: ctx.turnIndex,
10
+ ...entry,
11
+ };
12
+ if (!session.metadata) {
13
+ session.metadata = {
14
+ createdAt: session.createdAt,
15
+ lastActiveAt: session.updatedAt,
16
+ totalTokens: 0,
17
+ totalSteps: 0,
18
+ handoffHistory: [],
19
+ };
20
+ }
21
+ session.metadata.audit ??= [];
22
+ session.metadata.audit.push(full);
23
+ }
@@ -1,3 +1,4 @@
1
+ import type { EscalationReason } from '../escalation/types.js';
1
2
  import type { Session, SourceRef, ToolCallRecord } from '../types/index.js';
2
3
  export interface ValidationCapability {
3
4
  readonly name: string;
@@ -21,6 +22,13 @@ export type ValidateDecision = {
21
22
  confidence: number;
22
23
  rewrittenOutput: string;
23
24
  rationale: string;
25
+ } | {
26
+ decision: 'escalate';
27
+ confidence: number;
28
+ rationale: string;
29
+ escalateTo?: string;
30
+ escalationReason?: EscalationReason;
31
+ userFacingMessage?: string;
24
32
  } | {
25
33
  decision: 'block';
26
34
  confidence: number;
@@ -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';
@@ -11,6 +12,7 @@ import { loadRecordedSteps } from '../runtime/durable/replay.js';
11
12
  import { SuspendError } from '../runtime/durable/RunStore.js';
12
13
  import { ToolApprovalDeniedError } from '../tools/effect/errors.js';
13
14
  import { emitInteractiveOnNodeEnter } from './emitInteractive.js';
15
+ import { appendConversationAudit } from '../audit/record.js';
14
16
  import { appendSafeAssistantMessage, degradeFlowError, findEscalateNode, } from './degrade.js';
15
17
  export class FlowOscillationError extends Error {
16
18
  constructor(from, to) {
@@ -87,12 +89,15 @@ function appendAssistantMessage(run, text) {
87
89
  const message = { role: 'assistant', content: text };
88
90
  run.messages = [...run.messages, message];
89
91
  }
90
- async function dispatchNode(node, run, driver, ctx) {
92
+ async function dispatchNode(node, run, driver, ctx, agent, flow) {
91
93
  if (isActionNode(node)) {
92
94
  return normalizeTransition(await node.run(run.state, toActionContext(ctx)));
93
95
  }
94
96
  if (isCollectNode(node)) {
95
- return collectUntilComplete(node, run, driver, ctx);
97
+ return collectUntilComplete(node, run, driver, ctx, {
98
+ agent,
99
+ activeFlowName: flow.name,
100
+ });
96
101
  }
97
102
  if (isDecideNode(node)) {
98
103
  if (node.confirmGate) {
@@ -139,7 +144,7 @@ async function dispatchNode(node, run, driver, ctx) {
139
144
  if (decision.kind === 'redispatch') {
140
145
  const signal = await driver.awaitUser(ctx);
141
146
  appendUserMessage(run, signal.input);
142
- return dispatchNode(node, run, driver, ctx);
147
+ return dispatchNode(node, run, driver, ctx, agent, flow);
143
148
  }
144
149
  appendAssistantMessage(run, turn.text);
145
150
  if (decision.kind === 'transition') {
@@ -151,7 +156,7 @@ async function dispatchNode(node, run, driver, ctx) {
151
156
  if (turn.interrupted) {
152
157
  const signal = await driver.awaitUser(ctx);
153
158
  appendUserMessage(run, signal.input);
154
- return dispatchNode(node, run, driver, ctx);
159
+ return dispatchNode(node, run, driver, ctx, agent, flow);
155
160
  }
156
161
  if (turn.control?.type === 'handoff') {
157
162
  return { kind: 'handoff', to: turn.control.target, reason: turn.control.reason };
@@ -165,6 +170,21 @@ async function dispatchNode(node, run, driver, ctx) {
165
170
  if (turn.control?.type === 'recover') {
166
171
  return { kind: 'end', reason: turn.control.reason ?? 'error_degraded' };
167
172
  }
173
+ if (node.confidenceGate &&
174
+ turn.confidence != null &&
175
+ turn.confidence < node.confidenceGate.min) {
176
+ appendConversationAudit(ctx.session, {
177
+ sessionId: ctx.session.id,
178
+ conversationId: ctx.session.conversationId,
179
+ userId: ctx.session.userId,
180
+ agentId: ctx.runState.activeAgentId,
181
+ }, {
182
+ type: 'escalation',
183
+ reason: 'low-confidence',
184
+ confidence: turn.confidence,
185
+ });
186
+ return normalizeTransition(node.confidenceGate.onLow);
187
+ }
168
188
  if (node.next) {
169
189
  return normalizeTransition(await node.next(turn, run.state));
170
190
  }
@@ -172,7 +192,7 @@ async function dispatchNode(node, run, driver, ctx) {
172
192
  }
173
193
  throw new Error(`Unknown node kind: ${node.kind}`);
174
194
  }
175
- export async function runFlow(flow, run, driver, ctx) {
195
+ export async function runFlow(flow, run, driver, ctx, agent) {
176
196
  const registry = buildNodeRegistry(flow);
177
197
  const startNode = resolveStartNode(flow);
178
198
  const initialNodeId = run.activeNode ?? startNode.id;
@@ -192,7 +212,7 @@ export async function runFlow(flow, run, driver, ctx) {
192
212
  for (;;) {
193
213
  let transition;
194
214
  try {
195
- transition = await dispatchNode(node, run, driver, ctx);
215
+ transition = await dispatchNode(node, run, driver, ctx, agent, flow);
196
216
  }
197
217
  catch (error) {
198
218
  if (error instanceof SuspendError || error instanceof ToolApprovalDeniedError) {
@@ -200,9 +220,27 @@ export async function runFlow(flow, run, driver, ctx) {
200
220
  }
201
221
  const message = error instanceof Error ? error.message : String(error);
202
222
  ctx.emit({ type: 'error', error: message });
203
- return degradeFlowError(flow, registry, run, driver, ctx, dispatchNode);
223
+ return degradeFlowError(flow, registry, run, driver, ctx, (n, r, d, c) => dispatchNode(n, r, d, c, agent, flow));
224
+ }
225
+ if (transition.kind === 'switchFlow') {
226
+ run.activeFlow = transition.flow.name;
227
+ run.activeNode = undefined;
228
+ await ctx.runStore.putRunState(run);
229
+ return runFlow(transition.flow, run, driver, ctx, agent);
204
230
  }
205
231
  if (transition.kind === 'end') {
232
+ const park = getFlowPark(run.state);
233
+ if (park && agent) {
234
+ delete run.state.__flowPark;
235
+ const parkedFlow = agent.flows?.find((candidate) => candidate.name === park.flow);
236
+ if (parkedFlow) {
237
+ run.activeFlow = park.flow;
238
+ run.activeNode = park.node;
239
+ await ctx.runStore.putRunState(run);
240
+ ctx.emit({ type: 'flow-end', flow: flow.name, reason: transition.reason });
241
+ return runFlow(parkedFlow, run, driver, ctx, agent);
242
+ }
243
+ }
206
244
  ctx.emit({ type: 'flow-end', flow: flow.name, reason: transition.reason });
207
245
  return { kind: 'ended', reason: transition.reason };
208
246
  }
@@ -98,11 +98,13 @@ export class TextDriver {
98
98
  messages.push(toolResultMessage({ toolName: call.toolName, input: call.input, toolCallId: call.toolCallId }, toolResult));
99
99
  }
100
100
  }
101
- const postTurn = await applyPostTurnPolicies(ctx, draftText, toolCallsMade);
102
- const finalText = postTurn.text;
103
- out.text = finalText;
104
- if (finalText) {
105
- ctx.emit({ type: 'text-delta', text: finalText });
101
+ const postTurn = await applyPostTurnPolicies(ctx, draftText, toolCallsMade, gather.citations ?? []);
102
+ out.confidence = postTurn.confidence;
103
+ out.control = postTurn.control;
104
+ const emitText = postTurn.control ? (postTurn.blockedMessage ?? postTurn.text) : postTurn.text;
105
+ out.text = emitText;
106
+ if (emitText) {
107
+ ctx.emit({ type: 'text-delta', text: emitText });
106
108
  }
107
109
  ctx.emit({ type: 'turn-end' });
108
110
  return out;
@@ -56,10 +56,13 @@ export class VoiceDriver {
56
56
  break;
57
57
  }
58
58
  }
59
- const postTurn = await applyPostTurnPolicies(ctx, draftText, toolCallsMade);
60
- out.text = postTurn.text;
61
- if (out.text) {
62
- ctx.emit({ type: 'text-delta', text: out.text });
59
+ const postTurn = await applyPostTurnPolicies(ctx, draftText, toolCallsMade, gather.citations ?? []);
60
+ out.confidence = postTurn.confidence;
61
+ out.control = postTurn.control;
62
+ const emitText = postTurn.control ? (postTurn.blockedMessage ?? postTurn.text) : postTurn.text;
63
+ out.text = emitText;
64
+ if (emitText) {
65
+ ctx.emit({ type: 'text-delta', text: emitText });
63
66
  }
64
67
  ctx.emit({ type: 'turn-end' });
65
68
  return out;
@@ -1,7 +1,9 @@
1
+ import type { SourceRef } from '../../types/voice.js';
1
2
  import type { GatherScope, RunContext } from '../../types/run-context.js';
2
3
  export type { GatherScope } from '../../types/run-context.js';
3
4
  export interface GatherResult {
4
5
  retrievalBlock?: string;
5
6
  memoryBlock?: string;
7
+ citations?: SourceRef[];
6
8
  }
7
9
  export declare function runGatherPhase(ctx: RunContext, scope?: GatherScope): Promise<GatherResult>;
@@ -1,9 +1,27 @@
1
+ function normalizeRetrieveResult(raw) {
2
+ if (raw == null) {
3
+ return {};
4
+ }
5
+ if (typeof raw === 'string') {
6
+ return { retrievalBlock: raw };
7
+ }
8
+ return { retrievalBlock: raw.block, citations: raw.citations };
9
+ }
1
10
  export async function runGatherPhase(ctx, scope) {
2
- const retrievalBlock = ctx.autoRetrieve && scope?.knowledge?.autoRetrieve !== false
3
- ? await ctx.autoRetrieve.retrieve(ctx, scope)
4
- : undefined;
11
+ let retrievalBlock;
12
+ let citations;
13
+ if (ctx.autoRetrieve && scope?.knowledge?.autoRetrieve !== false) {
14
+ const raw = await ctx.autoRetrieve.retrieve(ctx, scope);
15
+ const normalized = normalizeRetrieveResult(raw);
16
+ retrievalBlock = normalized.retrievalBlock;
17
+ citations = normalized.citations;
18
+ ctx.lastRetrievalCitations = citations;
19
+ }
20
+ else {
21
+ ctx.lastRetrievalCitations = undefined;
22
+ }
5
23
  const memoryBlock = ctx.memoryService?.preload && scope?.memory?.preload !== false
6
24
  ? await ctx.memoryService.preload(ctx, scope)
7
25
  : undefined;
8
- return { retrievalBlock, memoryBlock };
26
+ return { retrievalBlock, memoryBlock, citations };
9
27
  }
@@ -1,3 +1,4 @@
1
+ import { normalizeCitations } from '../citations/index.js';
1
2
  import { KnowledgeProvider } from '../KnowledgeProvider.js';
2
3
  function latestUserMessage(ctx) {
3
4
  for (let index = ctx.runState.messages.length - 1; index >= 0; index -= 1) {
@@ -51,7 +52,12 @@ export function buildAutoRetrieveProvider(provider, agent) {
51
52
  ...retrievalResults.map((result) => ({ text: result.text })),
52
53
  ];
53
54
  const maxChars = provider.resolveConfig(merged).maxOutputTokens * 4;
54
- return formatRetrievalBlock(combined, maxChars);
55
+ const block = formatRetrievalBlock(combined, maxChars);
56
+ const citations = normalizeCitations(retrievalResults);
57
+ if (!block && citations.length === 0) {
58
+ return undefined;
59
+ }
60
+ return { block, citations: citations.length > 0 ? citations : undefined };
55
61
  },
56
62
  };
57
63
  }
@@ -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
  }
@@ -1,4 +1,6 @@
1
+ import type { TurnControl } from '../../types/channel.js';
1
2
  import type { ToolCallRecord } from '../../types/session.js';
3
+ import type { SourceRef } from '../../types/voice.js';
2
4
  import type { RunContext } from '../../types/run-context.js';
3
5
  export interface PreTurnResult {
4
6
  proceed: boolean;
@@ -9,6 +11,8 @@ export interface PostTurnResult {
9
11
  proceed: boolean;
10
12
  text: string;
11
13
  blockedMessage?: string;
14
+ control?: TurnControl;
15
+ confidence?: number;
12
16
  }
13
17
  export declare function applyPreTurnPolicies(ctx: RunContext): Promise<PreTurnResult>;
14
- export declare function applyPostTurnPolicies(ctx: RunContext, assistantOutput: string, toolCallsMade?: ToolCallRecord[]): Promise<PostTurnResult>;
18
+ export declare function applyPostTurnPolicies(ctx: RunContext, assistantOutput: string, toolCallsMade?: ToolCallRecord[], knowledgeCitations?: SourceRef[]): Promise<PostTurnResult>;
@@ -1,3 +1,5 @@
1
+ import { appendConversationAudit } from '../../audit/record.js';
2
+ import { SAFE_DEGRADED_MESSAGE } from '../../flow/degrade.js';
1
3
  import { runInputProcessors, runOutputProcessors } from '../../processors/ProcessorRunner.js';
2
4
  function latestUserMessage(messages) {
3
5
  for (let index = messages.length - 1; index >= 0; index -= 1) {
@@ -8,6 +10,67 @@ function latestUserMessage(messages) {
8
10
  }
9
11
  return '';
10
12
  }
13
+ function auditContext(ctx) {
14
+ return {
15
+ sessionId: ctx.session.id,
16
+ conversationId: ctx.session.conversationId,
17
+ userId: ctx.session.userId,
18
+ agentId: ctx.runState.activeAgentId,
19
+ };
20
+ }
21
+ function recordKnowledgeCitations(ctx, citations) {
22
+ for (const citation of citations) {
23
+ appendConversationAudit(ctx.session, auditContext(ctx), {
24
+ type: 'knowledge-citation',
25
+ sourceId: citation.id,
26
+ title: citation.title,
27
+ score: citation.score,
28
+ });
29
+ }
30
+ }
31
+ function recordValidationDecision(ctx, policyName, decision) {
32
+ if (decision.decision === 'block') {
33
+ appendConversationAudit(ctx.session, auditContext(ctx), {
34
+ type: 'safety-block',
35
+ moderator: policyName,
36
+ rationale: decision.rationale,
37
+ userFacingMessage: decision.userFacingMessage ?? decision.rationale,
38
+ });
39
+ appendConversationAudit(ctx.session, auditContext(ctx), {
40
+ type: 'validation',
41
+ aggregate: 'block',
42
+ confidence: decision.confidence,
43
+ rationale: decision.rationale,
44
+ moderator: policyName,
45
+ });
46
+ return;
47
+ }
48
+ if (decision.decision === 'escalate') {
49
+ appendConversationAudit(ctx.session, auditContext(ctx), {
50
+ type: 'escalation',
51
+ reason: decision.escalationReason ?? 'low-confidence',
52
+ confidence: decision.confidence,
53
+ });
54
+ appendConversationAudit(ctx.session, auditContext(ctx), {
55
+ type: 'validation',
56
+ aggregate: 'block',
57
+ confidence: decision.confidence,
58
+ rationale: decision.rationale,
59
+ moderator: policyName,
60
+ });
61
+ return;
62
+ }
63
+ appendConversationAudit(ctx.session, auditContext(ctx), {
64
+ type: 'validation',
65
+ aggregate: decision.decision === 'rewrite' ? 'rewrite' : 'continue',
66
+ confidence: decision.confidence,
67
+ rationale: decision.rationale ?? '',
68
+ moderator: policyName,
69
+ });
70
+ }
71
+ function safeBlockedText(decision) {
72
+ return decision.userFacingMessage?.trim() || decision.rationale?.trim() || SAFE_DEGRADED_MESSAGE;
73
+ }
11
74
  async function runRefinementPolicies(ctx, userMessage) {
12
75
  const policies = ctx.refinementPolicies ?? [];
13
76
  if (policies.length === 0) {
@@ -36,34 +99,56 @@ async function runRefinementPolicies(ctx, userMessage) {
36
99
  }
37
100
  return { proceed: true, userMessage: current };
38
101
  }
39
- async function runValidationPolicies(ctx, userMessage, assistantOutput, toolCallsMade) {
102
+ async function runValidationPolicies(ctx, userMessage, assistantOutput, toolCallsMade, knowledgeCitations) {
40
103
  const policies = ctx.validationPolicies ?? [];
41
104
  if (policies.length === 0) {
42
105
  return { proceed: true, text: assistantOutput };
43
106
  }
107
+ if (knowledgeCitations.length > 0) {
108
+ recordKnowledgeCitations(ctx, knowledgeCitations);
109
+ }
44
110
  const sorted = [...policies].sort((a, b) => a.name.localeCompare(b.name));
45
111
  let current = assistantOutput;
112
+ let lastConfidence;
46
113
  for (const policy of sorted) {
47
114
  const decision = await policy.validate({
48
115
  session: ctx.session,
49
116
  userMessage,
50
117
  assistantOutput: current,
51
118
  toolCallsMade,
52
- knowledgeCitations: [],
119
+ knowledgeCitations,
53
120
  abortSignal: ctx.abortSignal,
54
121
  });
122
+ recordValidationDecision(ctx, policy.name, decision);
123
+ lastConfidence = decision.confidence;
55
124
  if (decision.decision === 'block') {
125
+ const safe = safeBlockedText(decision);
56
126
  return {
57
127
  proceed: false,
58
- text: decision.userFacingMessage ?? '',
59
- blockedMessage: decision.userFacingMessage ?? decision.rationale,
128
+ text: safe,
129
+ blockedMessage: safe,
130
+ control: { type: 'recover', reason: decision.rationale },
131
+ confidence: decision.confidence,
132
+ };
133
+ }
134
+ if (decision.decision === 'escalate') {
135
+ const safe = safeBlockedText(decision);
136
+ return {
137
+ proceed: false,
138
+ text: safe,
139
+ blockedMessage: safe,
140
+ control: { type: 'escalate', reason: decision.rationale },
141
+ confidence: decision.confidence,
60
142
  };
61
143
  }
62
144
  if (decision.decision === 'rewrite') {
63
145
  current = decision.rewrittenOutput;
64
146
  }
65
147
  }
66
- return { proceed: true, text: current };
148
+ if (ctx.session.metadata) {
149
+ ctx.session.metadata.lastValidationConfidence = lastConfidence;
150
+ }
151
+ return { proceed: true, text: current, confidence: lastConfidence };
67
152
  }
68
153
  export async function applyPreTurnPolicies(ctx) {
69
154
  const userMessage = latestUserMessage(ctx.runState.messages);
@@ -92,7 +177,7 @@ export async function applyPreTurnPolicies(ctx) {
92
177
  }
93
178
  return runRefinementPolicies(ctx, latestUserMessage(ctx.runState.messages));
94
179
  }
95
- export async function applyPostTurnPolicies(ctx, assistantOutput, toolCallsMade = []) {
180
+ export async function applyPostTurnPolicies(ctx, assistantOutput, toolCallsMade = [], knowledgeCitations = []) {
96
181
  const userMessage = latestUserMessage(ctx.runState.messages);
97
182
  const processors = ctx.outputProcessors ?? [];
98
183
  let current = assistantOutput;
@@ -117,7 +202,8 @@ export async function applyPostTurnPolicies(ctx, assistantOutput, toolCallsMade
117
202
  }
118
203
  current = outcome.text;
119
204
  }
120
- return runValidationPolicies(ctx, userMessage, current, toolCallsMade);
205
+ const citations = knowledgeCitations.length > 0 ? knowledgeCitations : (ctx.lastRetrievalCitations ?? []);
206
+ return runValidationPolicies(ctx, userMessage, current, toolCallsMade, citations);
121
207
  }
122
208
  function patchLatestUserMessage(messages, next) {
123
209
  for (let index = messages.length - 1; index >= 0; index -= 1) {
@@ -5,8 +5,8 @@ export function resolveAgentPolicies(agent) {
5
5
  return {
6
6
  inputProcessors: guardrails?.input ?? [],
7
7
  outputProcessors: guardrails?.output ?? [],
8
- refinementPolicies: [],
9
- validationPolicies: [],
8
+ refinementPolicies: agent.refine ?? [],
9
+ validationPolicies: agent.validate ?? [],
10
10
  limits: agent.limits,
11
11
  enforcer: enforcementRules.length > 0 ? createToolEnforcer(enforcementRules) : undefined,
12
12
  };
@@ -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
  }
@@ -4,6 +4,8 @@ import type { Flow } from './flow.js';
4
4
  import type { Route, RoutingPolicy } from './route.js';
5
5
  import type { Guardrails, Limits } from './guardrails.js';
6
6
  import type { AgentKnowledge, AgentMemory } from './grounding.js';
7
+ import type { RefinementCapability } from '../capabilities/RefinementCapability.js';
8
+ import type { ValidationCapability } from '../capabilities/ValidationCapability.js';
7
9
  import type { AnyTool } from './effectTool.js';
8
10
  export type Instructions = string | AgentPrompt | ((ctx: {
9
11
  state: Record<string, unknown>;
@@ -35,6 +37,10 @@ export interface AgentConfig {
35
37
  memory?: AgentMemory;
36
38
  guardrails?: Guardrails;
37
39
  limits?: Limits;
40
+ /** Post-turn validation policies (grounding/confidence gate). Default: none. */
41
+ validate?: ValidationCapability[];
42
+ /** Pre-turn refinement policies. Default: none. */
43
+ refine?: RefinementCapability[];
38
44
  experimental?: {
39
45
  /** Flow reply nodes: silo flow-transition control tools + deterministic evaluator (ADR 0003 H1). Default OFF. */
40
46
  outOfBandControl?: boolean;
@@ -56,6 +56,11 @@ export interface ReplyNode {
56
56
  model?: LanguageModel;
57
57
  context?: ContextStrategy;
58
58
  grounding?: NodeGrounding;
59
+ /** When set, routes to `onLow` when post-turn validation confidence is below `min`. */
60
+ confidenceGate?: {
61
+ min: number;
62
+ onLow: Transition;
63
+ };
59
64
  next?: (turn: TurnResult, state: FlowState) => Transition | Promise<Transition>;
60
65
  }
61
66
  export interface CollectNode {
@@ -9,7 +9,7 @@ import type { ValidationCapability } from '../capabilities/ValidationCapability.
9
9
  import type { Limits } from './guardrails.js';
10
10
  import type { AnyTool } from './effectTool.js';
11
11
  import type { Instructions } from './agentConfig.js';
12
- import type { AgentKnowledgeOverrides } from './voice.js';
12
+ import type { AgentKnowledgeOverrides, SourceRef } from './voice.js';
13
13
  export interface GatherScope {
14
14
  query?: string;
15
15
  knowledge?: AgentKnowledgeOverrides & {
@@ -33,8 +33,12 @@ export interface EffectToolExecutor {
33
33
  /** Resolve a registered tool definition by name (used to read flags like `needsApproval`). */
34
34
  getTool?(name: string): AnyTool | undefined;
35
35
  }
36
+ export interface AutoRetrieveResult {
37
+ block?: string;
38
+ citations?: SourceRef[];
39
+ }
36
40
  export interface AutoRetrieveProvider {
37
- retrieve(ctx: RunContext, scope?: GatherScope): Promise<string | undefined>;
41
+ retrieve(ctx: RunContext, scope?: GatherScope): Promise<AutoRetrieveResult | string | undefined>;
38
42
  }
39
43
  export interface MemoryService {
40
44
  preload?(ctx: RunContext, scope?: GatherScope): Promise<string | undefined>;
@@ -73,6 +77,8 @@ export interface RunContext {
73
77
  * context. Reset to false on every `createRunContext` (i.e. every turn).
74
78
  */
75
79
  turnInputConsumed?: boolean;
80
+ /** Citations from the latest gather-phase retrieval on this turn. */
81
+ lastRetrievalCitations?: SourceRef[];
76
82
  /** Agent base layer (ADR 0001), set when entering a flow. `baseInstructions`
77
83
  * is composed as a prefix into every node turn's system prompt (persona /
78
84
  * safety / grounding floor); `globalTools` are safe tools made model-visible
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.18",
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.18"
101
101
  },
102
102
  "dependencies": {
103
103
  "chrono-node": "^2.6.0",