@kuralle-agents/core 0.3.13 → 0.3.14

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,18 @@
1
+ import type { FlowState, ReplyNode } from '../types/flow.js';
2
+ import type { TurnResult } from '../types/channel.js';
3
+ import { type NormalizedTransition } from './normalizeTransition.js';
4
+ export interface ControlSignal {
5
+ node: ReplyNode;
6
+ turn: TurnResult;
7
+ state: FlowState;
8
+ interrupted: boolean;
9
+ }
10
+ export type ControlDecision = {
11
+ kind: 'transition';
12
+ transition: NormalizedTransition;
13
+ } | {
14
+ kind: 'redispatch';
15
+ } | {
16
+ kind: 'stay';
17
+ };
18
+ export declare function evaluateReplyControl(signal: ControlSignal): Promise<ControlDecision>;
@@ -0,0 +1,26 @@
1
+ import { normalizeTransition } from './normalizeTransition.js';
2
+ function controlToTransition(control) {
3
+ switch (control.type) {
4
+ case 'handoff':
5
+ return { kind: 'handoff', to: control.target, reason: control.reason };
6
+ case 'end':
7
+ return { kind: 'end', reason: control.reason };
8
+ case 'escalate':
9
+ return { kind: 'escalate', reason: control.reason };
10
+ case 'recover':
11
+ return { kind: 'end', reason: control.reason ?? 'error_degraded' };
12
+ }
13
+ }
14
+ export async function evaluateReplyControl(signal) {
15
+ const { node, turn, state, interrupted } = signal;
16
+ if (interrupted) {
17
+ return { kind: 'redispatch' };
18
+ }
19
+ if (turn.control) {
20
+ return { kind: 'transition', transition: controlToTransition(turn.control) };
21
+ }
22
+ if (node.next) {
23
+ return { kind: 'transition', transition: normalizeTransition(await node.next(turn, state)) };
24
+ }
25
+ return { kind: 'stay' };
26
+ }
@@ -0,0 +1,3 @@
1
+ /** Model-initiated flow-transition tools siloed from flow reply speaking dicts (H1). */
2
+ export declare const FLOW_TRANSITION_CONTROL_TOOL_NAMES: Set<string>;
3
+ export declare function isFlowTransitionControlTool(name: string): boolean;
@@ -0,0 +1,11 @@
1
+ /** Model-initiated flow-transition tools siloed from flow reply speaking dicts (H1). */
2
+ export const FLOW_TRANSITION_CONTROL_TOOL_NAMES = new Set([
3
+ 'handoff',
4
+ 'transfer_to_agent',
5
+ 'final',
6
+ 'escalate',
7
+ 'recover',
8
+ ]);
9
+ export function isFlowTransitionControlTool(name) {
10
+ return FLOW_TRANSITION_CONTROL_TOOL_NAMES.has(name);
11
+ }
@@ -9,5 +9,7 @@ export declare function buildNodePrompt(node: ReplyNode, state: FlowState): stri
9
9
  * own instructions. Node instructions layer ON TOP — they never replace the
10
10
  * base. Base resolves against the current state so dynamic base prompts work. */
11
11
  export declare function composeSystem(base: Instructions | undefined, nodeSystem: string, state: FlowState): string;
12
- export declare function resolveReplyNode(node: ReplyNode, state: FlowState): ResolvedNode;
12
+ export declare function resolveReplyNode(node: ReplyNode, state: FlowState, options?: {
13
+ freeConversation?: boolean;
14
+ }): ResolvedNode;
13
15
  export declare function resolveCollectExtractionNode(collectNode: CollectNode, missing: string[], state: FlowState, submitTool: Tool): ResolvedNode;
@@ -32,7 +32,7 @@ function buildNodeTools(node, state) {
32
32
  }
33
33
  return node.tools;
34
34
  }
35
- export function resolveReplyNode(node, state) {
35
+ export function resolveReplyNode(node, state, options) {
36
36
  const tools = buildNodeTools(node, state);
37
37
  return {
38
38
  node,
@@ -41,6 +41,7 @@ export function resolveReplyNode(node, state) {
41
41
  // Recover the raw executors from the node's `buildToolSet` tools so they run
42
42
  // in-flow (with run context) — without also needing `agent.effectTools`.
43
43
  localTools: rawToolsFromSet(tools),
44
+ ...(options?.freeConversation && { freeConversation: true }),
44
45
  };
45
46
  }
46
47
  export function resolveCollectExtractionNode(collectNode, missing, state, submitTool) {
@@ -5,6 +5,7 @@ import { isActionNode, isCollectNode, isDecideNode, isReplyNode, } from './nodeK
5
5
  import { normalizeTransition, resolveNodeRef } from './normalizeTransition.js';
6
6
  import { reduceTransition } from './reduceTransition.js';
7
7
  import { resolveReplyNode } from './nodeBuilders.js';
8
+ import { evaluateReplyControl } from './controlEvaluator.js';
8
9
  import { runNodeVerify, VerifyBlockedError } from './verify.js';
9
10
  import { loadRecordedSteps } from '../runtime/durable/replay.js';
10
11
  import { SuspendError } from '../runtime/durable/RunStore.js';
@@ -128,6 +129,24 @@ async function dispatchNode(node, run, driver, ctx) {
128
129
  }
129
130
  if (isReplyNode(node)) {
130
131
  const turn = await driver.runAgentTurn(resolveReplyNode(node, run.state), ctx);
132
+ if (ctx.outOfBandControl) {
133
+ const decision = await evaluateReplyControl({
134
+ node,
135
+ turn,
136
+ state: run.state,
137
+ interrupted: !!turn.interrupted,
138
+ });
139
+ if (decision.kind === 'redispatch') {
140
+ const signal = await driver.awaitUser(ctx);
141
+ appendUserMessage(run, signal.input);
142
+ return dispatchNode(node, run, driver, ctx);
143
+ }
144
+ appendAssistantMessage(run, turn.text);
145
+ if (decision.kind === 'transition') {
146
+ return decision.transition;
147
+ }
148
+ return { kind: 'stay' };
149
+ }
131
150
  appendAssistantMessage(run, turn.text);
132
151
  if (turn.interrupted) {
133
152
  const signal = await driver.awaitUser(ctx);
@@ -109,6 +109,7 @@ export class Runtime {
109
109
  // Agent base layer (ADR 0001): composed into every node turn by the drivers.
110
110
  runCtx.baseInstructions = opened.agent.instructions;
111
111
  runCtx.globalTools = opened.agent.globalTools;
112
+ runCtx.outOfBandControl = opened.agent.experimental?.outOfBandControl ?? false;
112
113
  await this.hooks?.onStart?.(runCtx);
113
114
  const driver = opts.driver ?? new TextDriver();
114
115
  let activeAgent = opened.agent;
@@ -7,6 +7,7 @@ import { runSilentExtraction } from './extractionTurn.js';
7
7
  import { applyPreTurnPolicies, applyPostTurnPolicies } from '../policies/agentTurn.js';
8
8
  import { resolveMaxSteps } from '../policies/limits.js';
9
9
  import { appendGatherBlocks, resolveNodeGatherScope, runGatherPhase } from '../grounding/index.js';
10
+ import { isFlowTransitionControlTool } from '../../flow/flowControlTools.js';
10
11
  export class TextDriver {
11
12
  toolDefs;
12
13
  maxSteps;
@@ -34,7 +35,7 @@ export class TextDriver {
34
35
  const baseSystem = composeSystem(ctx.baseInstructions, nodeSystem, ctx.runState.state);
35
36
  const system = appendGatherBlocks(baseSystem, [gather.retrievalBlock, gather.memoryBlock]);
36
37
  const messages = [...ctx.runState.messages];
37
- const aiTools = this.resolveTools(node, ctx.globalTools);
38
+ const aiTools = this.resolveTools(node, ctx);
38
39
  const maxSteps = resolveMaxSteps(ctx.limits, this.maxSteps);
39
40
  const toolCallsMade = [];
40
41
  let draftText = '';
@@ -137,20 +138,41 @@ export class TextDriver {
137
138
  const input = consumePendingUserInput(ctx.session);
138
139
  return { type: 'message', input };
139
140
  }
140
- resolveTools(resolved, globalTools) {
141
- const merged = { ...this.toolDefs, ...(globalTools ?? {}), ...(resolved.localTools ?? {}) };
141
+ resolveTools(resolved, ctx) {
142
+ const siloFlowControl = ctx.outOfBandControl && !resolved.freeConversation;
143
+ const merged = {
144
+ ...this.toolDefs,
145
+ ...(ctx.globalTools ?? {}),
146
+ ...(resolved.localTools ?? {}),
147
+ };
142
148
  const aiTools = { ...resolved.tools };
143
149
  for (const [name, tool] of Object.entries(merged)) {
150
+ if (siloFlowControl && isFlowTransitionControlTool(name)) {
151
+ continue;
152
+ }
144
153
  if (tool && !aiTools[name]) {
145
154
  const built = buildToolSet({ [name]: tool });
146
155
  Object.assign(aiTools, built);
147
156
  }
148
157
  }
158
+ if (siloFlowControl) {
159
+ for (const name of Object.keys(aiTools)) {
160
+ if (isFlowTransitionControlTool(name)) {
161
+ delete aiTools[name];
162
+ }
163
+ }
164
+ }
149
165
  if (Object.keys(aiTools).length === 0 && Object.keys(merged).length === 0) {
150
166
  return undefined;
151
167
  }
152
168
  if (Object.keys(aiTools).length === 0) {
153
- return buildToolSet(merged);
169
+ const filteredMerged = siloFlowControl
170
+ ? Object.fromEntries(Object.entries(merged).filter(([name]) => !isFlowTransitionControlTool(name)))
171
+ : merged;
172
+ if (Object.keys(filteredMerged).length === 0) {
173
+ return undefined;
174
+ }
175
+ return buildToolSet(filteredMerged);
154
176
  }
155
177
  return aiTools;
156
178
  }
@@ -35,7 +35,7 @@ export class VoiceDriver {
35
35
  const nodeSystem = node.prompt || buildNodePrompt(replyNode, ctx.runState.state);
36
36
  const baseSystem = composeSystem(ctx.baseInstructions, nodeSystem, ctx.runState.state);
37
37
  const system = appendGatherBlocks(baseSystem, [gather.retrievalBlock, gather.memoryBlock]);
38
- const geminiTools = resolveVoiceGeminiTools(node, { ...this.toolDefs, ...(ctx.globalTools ?? {}) });
38
+ const geminiTools = resolveVoiceGeminiTools(node, { ...this.toolDefs, ...(ctx.globalTools ?? {}) }, { siloFlowControl: ctx.outOfBandControl && !node.freeConversation });
39
39
  const toolCallsMade = [];
40
40
  const maxSteps = resolveMaxSteps(ctx.limits, this.maxSteps);
41
41
  let draftText = '';
@@ -3,5 +3,7 @@ import type { ResolvedNode } from '../../types/channel.js';
3
3
  import type { AnyTool } from '../../types/effectTool.js';
4
4
  import { buildToolSet } from '../../tools/effect/defineTool.js';
5
5
  export declare function v2ToolsToGemini(tools: Record<string, AnyTool>): GeminiFunctionDeclaration[];
6
- export declare function resolveVoiceGeminiTools(resolved: ResolvedNode, toolDefs: Record<string, AnyTool>): GeminiFunctionDeclaration[];
6
+ export declare function resolveVoiceGeminiTools(resolved: ResolvedNode, toolDefs: Record<string, AnyTool>, options?: {
7
+ siloFlowControl?: boolean;
8
+ }): GeminiFunctionDeclaration[];
7
9
  export { buildToolSet };
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { toGeminiDeclarations } from '../../capabilities/adapters/gemini.js';
3
3
  import { buildToolSet } from '../../tools/effect/defineTool.js';
4
+ import { isFlowTransitionControlTool } from '../../flow/flowControlTools.js';
4
5
  function toolToDeclaration(name, tool) {
5
6
  return {
6
7
  name: tool.name || name,
@@ -13,14 +14,25 @@ export function v2ToolsToGemini(tools) {
13
14
  const declarations = Object.entries(tools).map(([name, tool]) => toolToDeclaration(name, tool));
14
15
  return toGeminiDeclarations(declarations);
15
16
  }
16
- export function resolveVoiceGeminiTools(resolved, toolDefs) {
17
+ export function resolveVoiceGeminiTools(resolved, toolDefs, options) {
18
+ const siloFlowControl = options?.siloFlowControl === true;
17
19
  const merged = { ...toolDefs, ...(resolved.localTools ?? {}) };
18
20
  const fromNode = toolSetToEffectTools(resolved.tools);
19
21
  for (const [name, tool] of Object.entries(fromNode)) {
22
+ if (siloFlowControl && isFlowTransitionControlTool(name)) {
23
+ continue;
24
+ }
20
25
  if (!merged[name]) {
21
26
  merged[name] = tool;
22
27
  }
23
28
  }
29
+ if (siloFlowControl) {
30
+ for (const name of Object.keys(merged)) {
31
+ if (isFlowTransitionControlTool(name)) {
32
+ delete merged[name];
33
+ }
34
+ }
35
+ }
24
36
  return v2ToolsToGemini(merged);
25
37
  }
26
38
  function toolSetToEffectTools(tools) {
@@ -21,6 +21,7 @@ export interface CtxDeps {
21
21
  hookRunner?: HookRunner;
22
22
  model: LanguageModel;
23
23
  controlModel?: LanguageModel;
24
+ outOfBandControl?: boolean;
24
25
  refinementPolicies?: RefinementCapability[];
25
26
  validationPolicies?: ValidationCapability[];
26
27
  inputProcessors?: InputProcessor[];
@@ -100,6 +100,7 @@ function makeCtx(deps) {
100
100
  hookRunner: deps.hookRunner ?? {},
101
101
  model: deps.model,
102
102
  controlModel: deps.controlModel ?? deps.model,
103
+ outOfBandControl: deps.outOfBandControl ?? false,
103
104
  refinementPolicies: deps.refinementPolicies ?? [],
104
105
  validationPolicies: deps.validationPolicies ?? [],
105
106
  inputProcessors: deps.inputProcessors ?? [],
@@ -74,7 +74,7 @@ async function runFreeConversation(agent, run, driver, ctx) {
74
74
  incrementTurnCount(run);
75
75
  assertWithinTurnLimit(run, ctx.limits);
76
76
  const replyNode = buildAgentReplyNode(agent);
77
- const turn = await driver.runAgentTurn(resolveReplyNode(replyNode, run.state), ctx);
77
+ const turn = await driver.runAgentTurn(resolveReplyNode(replyNode, run.state, { freeConversation: true }), ctx);
78
78
  if (turn.text.trim()) {
79
79
  const message = { role: 'assistant', content: turn.text };
80
80
  run.messages = [...run.messages, message];
@@ -35,5 +35,9 @@ export interface AgentConfig {
35
35
  memory?: AgentMemory;
36
36
  guardrails?: Guardrails;
37
37
  limits?: Limits;
38
+ experimental?: {
39
+ /** Flow reply nodes: silo flow-transition control tools + deterministic evaluator (ADR 0003 H1). Default OFF. */
40
+ outOfBandControl?: boolean;
41
+ };
38
42
  }
39
43
  export declare function defineAgent(config: AgentConfig): AgentConfig;
@@ -7,6 +7,8 @@ export interface ResolvedNode {
7
7
  prompt: string;
8
8
  tools: ToolSet;
9
9
  localTools?: Record<string, AnyTool>;
10
+ /** Free-conversation reply (host loop): keeps model control tools even when outOfBandControl is on. */
11
+ freeConversation?: boolean;
10
12
  }
11
13
  export interface ChannelDriver {
12
14
  runAgentTurn(node: ResolvedNode, ctx: RunContext): Promise<TurnResult>;
@@ -53,6 +53,8 @@ export interface RunContext {
53
53
  model: LanguageModel;
54
54
  /** Control-path model (routing, decide, extraction) at temperature 0. */
55
55
  controlModel: LanguageModel;
56
+ /** When true, flow reply nodes use the out-of-band control evaluator (ADR 0003 H1). */
57
+ outOfBandControl: boolean;
56
58
  refinementPolicies: RefinementCapability[];
57
59
  validationPolicies: ValidationCapability[];
58
60
  inputProcessors: InputProcessor[];
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.13",
9
+ "version": "0.3.14",
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.13"
100
+ "@kuralle-agents/realtime-audio": "0.3.14"
101
101
  },
102
102
  "dependencies": {
103
103
  "chrono-node": "^2.6.0",