@kuralle-agents/core 0.6.1 → 0.7.0

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.
Files changed (45) hide show
  1. package/README.md +2 -4
  2. package/dist/capabilities/index.d.ts +0 -3
  3. package/dist/capabilities/index.js +0 -2
  4. package/dist/flow/classifyControl.js +4 -0
  5. package/dist/flow/collectDigression.js +0 -1
  6. package/dist/flow/controlEvaluator.js +3 -0
  7. package/dist/flow/flowControlTools.js +1 -0
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +1 -1
  10. package/dist/runtime/Runtime.d.ts +3 -1
  11. package/dist/runtime/Runtime.js +3 -1
  12. package/dist/runtime/agentReply.d.ts +2 -1
  13. package/dist/runtime/agentReply.js +14 -3
  14. package/dist/runtime/channels/TextDriver.d.ts +1 -0
  15. package/dist/runtime/channels/TextDriver.js +31 -18
  16. package/dist/runtime/channels/VoiceDriver.d.ts +1 -0
  17. package/dist/runtime/channels/VoiceDriver.js +5 -1
  18. package/dist/runtime/channels/streaming/hostControlSpeak.d.ts +30 -0
  19. package/dist/runtime/channels/streaming/hostControlSpeak.js +80 -0
  20. package/dist/runtime/deriveAgent.d.ts +10 -1
  21. package/dist/runtime/deriveAgent.js +63 -13
  22. package/dist/runtime/dispatchMode.d.ts +5 -0
  23. package/dist/runtime/dispatchMode.js +18 -0
  24. package/dist/runtime/hostClassifyAdapter.d.ts +2 -0
  25. package/dist/runtime/hostClassifyAdapter.js +21 -0
  26. package/dist/runtime/hostControlGuard.d.ts +23 -0
  27. package/dist/runtime/hostControlGuard.js +67 -0
  28. package/dist/runtime/hostControlTools.d.ts +12 -0
  29. package/dist/runtime/hostControlTools.js +89 -0
  30. package/dist/runtime/hostLoop.d.ts +4 -1
  31. package/dist/runtime/hostLoop.js +122 -34
  32. package/dist/runtime/select.d.ts +17 -4
  33. package/dist/runtime/select.js +107 -76
  34. package/dist/tools/enterFlow.d.ts +14 -0
  35. package/dist/tools/enterFlow.js +31 -0
  36. package/dist/types/channel.d.ts +12 -0
  37. package/dist/types/route.d.ts +3 -3
  38. package/guides/AGENTS.md +7 -10
  39. package/guides/FLOWS.md +0 -4
  40. package/guides/RUNTIME.md +2 -2
  41. package/package.json +2 -2
  42. package/dist/capabilities/HandoffCapability.d.ts +0 -18
  43. package/dist/capabilities/HandoffCapability.js +0 -69
  44. package/dist/capabilities/TriageCapability.d.ts +0 -15
  45. package/dist/capabilities/TriageCapability.js +0 -60
@@ -1,129 +1,159 @@
1
1
  import { generateObject } from 'ai';
2
2
  import { z } from 'zod';
3
- const selectionSchema = z.object({
4
- action: z.enum(['enterFlow', 'route', 'keep']),
3
+ import { availableHostFlows, collectTransferTargets } from './hostControlTools.js';
4
+ const guardSchema = z.object({
5
+ action: z.enum(['keep', 'enterFlow', 'transfer']),
5
6
  flowName: z.union([z.string(), z.null()]),
6
7
  agentId: z.union([z.string(), z.null()]),
7
8
  reason: z.union([z.string(), z.null()]),
9
+ confidence: z.union([z.number(), z.null()]),
8
10
  });
9
- export async function selectHostTarget(options) {
10
- const { agent, run, model } = options;
11
+ const dispatcherSchema = z.object({
12
+ action: z.enum(['enterFlow', 'transfer']),
13
+ flowName: z.union([z.string(), z.null()]),
14
+ agentId: z.union([z.string(), z.null()]),
15
+ reason: z.union([z.string(), z.null()]),
16
+ });
17
+ export async function classifyHostTarget(options) {
18
+ const { agent, run, model, allowKeep } = options;
11
19
  const flows = agent.flows ?? [];
12
20
  const routes = agent.routes ?? [];
13
- if (flows.length === 0 && routes.length === 0) {
14
- return { kind: 'keep' };
15
- }
16
21
  const latestUser = latestUserMessage(run.messages);
17
22
  if (!latestUser) {
18
- return { kind: 'keep' };
23
+ return { action: 'keep', confidence: 1 };
19
24
  }
20
25
  const completed = run.state.__completedFlows;
21
26
  const completedFlows = Array.isArray(completed) ? completed : [];
22
27
  const excluded = new Set(options.excludeFlowNames ?? []);
23
28
  const availableFlows = flows.filter((flow) => !completedFlows.includes(flow.name) && !excluded.has(flow.name));
24
- const agentRoutes = routes.filter((route) => route.agent);
25
- if (availableFlows.length === 0 && agentRoutes.length === 0) {
26
- return { kind: 'keep' };
27
- }
28
- if (availableFlows.length === 1 && agentRoutes.length === 0) {
29
- return { kind: 'enterFlow', flow: availableFlows[0] };
30
- }
31
- const deterministic = deterministicRouteMatch(latestUser, routes, availableFlows);
32
- if (deterministic) {
33
- return deterministic;
29
+ const transferTargets = collectTransferTargets(agent);
30
+ const flowRoutes = routes.filter((route) => route.flow);
31
+ const hasFlowTargets = availableFlows.length > 0 || flowRoutes.length > 0;
32
+ const hasTransferTargets = transferTargets.length > 0 || routes.some((r) => r.agent);
33
+ if (!hasFlowTargets && !hasTransferTargets) {
34
+ return { action: 'keep', confidence: 1 };
34
35
  }
35
36
  const flowLines = availableFlows
36
37
  .map((flow) => `- flow "${flow.name}": ${flow.description}`)
37
38
  .join('\n');
38
- const routeLines = routes
39
- .map((route, index) => formatRouteLine(route, index))
39
+ const routeLines = routes.map((route, index) => formatRouteLine(route, index)).join('\n');
40
+ const transferLines = transferTargets
41
+ .map((t) => `- agent "${t.id}": ${t.descriptions.join('; ')}`)
40
42
  .join('\n');
43
+ const schema = allowKeep ? guardSchema : dispatcherSchema;
44
+ const actionHint = allowKeep
45
+ ? 'Return keep, enterFlow with flowName, or transfer with agentId.'
46
+ : 'Return enterFlow with flowName or transfer with agentId. Do not return keep.';
41
47
  const { object } = await generateObject({
42
48
  model,
43
- schema: selectionSchema,
49
+ schema,
44
50
  temperature: 0,
45
51
  system: 'You are an internal routing classifier. Choose exactly one action. ' +
46
52
  'Output schema fields only — never user-facing prose. ' +
47
- 'Prefer route when a route condition clearly matches; else enterFlow when a flow description matches an uncompleted flow; else keep. Never re-enter a completed flow.',
53
+ 'Reason over semantic descriptions only; never match keywords or substrings.',
48
54
  prompt: `User message:\n${latestUser}\n\n` +
49
55
  (completedFlows.length > 0 ? `Completed flows: ${completedFlows.join(', ')}\n\n` : '') +
50
56
  (flowLines ? `Available flows:\n${flowLines}\n\n` : '') +
51
57
  (routeLines ? `Routes:\n${routeLines}\n\n` : '') +
52
- 'Return enterFlow with flowName, route with agentId, or keep.',
58
+ (transferLines ? `Transfer targets:\n${transferLines}\n\n` : '') +
59
+ actionHint,
53
60
  });
54
- if (object.action === 'route') {
55
- const matchedRoute = routes.find((candidate) => (object.agentId != null && candidate.agent === object.agentId) ||
56
- (object.flowName != null && candidate.flow === object.flowName));
57
- if (matchedRoute?.flow) {
58
- const flow = flows.find((candidate) => candidate.name === matchedRoute.flow);
59
- if (flow) {
60
- return { kind: 'enterFlow', flow };
61
- }
61
+ const rawConfidence = allowKeep && 'confidence' in object ? object.confidence : null;
62
+ const confidence = typeof rawConfidence === 'number' ? rawConfidence : undefined;
63
+ if (object.action === 'transfer') {
64
+ const agentId = object.agentId ?? undefined;
65
+ if (agentId && isValidTransferTarget(agent, agentId)) {
66
+ return {
67
+ action: 'transfer',
68
+ targetAgentId: agentId,
69
+ reason: object.reason ?? undefined,
70
+ confidence,
71
+ };
62
72
  }
73
+ const matchedRoute = routes.find((candidate) => (agentId != null && candidate.agent === agentId) ||
74
+ (object.flowName != null && candidate.flow === object.flowName));
63
75
  if (matchedRoute?.agent) {
64
- return { kind: 'route', agentId: matchedRoute.agent, reason: object.reason ?? undefined };
76
+ return {
77
+ action: 'transfer',
78
+ targetAgentId: matchedRoute.agent,
79
+ reason: object.reason ?? undefined,
80
+ confidence,
81
+ };
65
82
  }
66
- if (object.agentId != null) {
67
- if (agent.handoffs?.includes(object.agentId) || agent.agents?.some((child) => child.id === object.agentId)) {
68
- return { kind: 'route', agentId: object.agentId, reason: object.reason ?? undefined };
83
+ if (matchedRoute?.flow) {
84
+ const flow = flows.find((candidate) => candidate.name === matchedRoute.flow);
85
+ if (flow && availableFlows.some((f) => f.name === flow.name)) {
86
+ return {
87
+ action: 'enterFlow',
88
+ flowName: flow.name,
89
+ reason: object.reason ?? undefined,
90
+ confidence,
91
+ };
69
92
  }
70
93
  }
71
94
  }
72
95
  if (object.action === 'enterFlow' && object.flowName != null) {
73
96
  const flow = availableFlows.find((candidate) => candidate.name === object.flowName);
74
97
  if (flow) {
75
- return { kind: 'enterFlow', flow };
98
+ return {
99
+ action: 'enterFlow',
100
+ flowName: flow.name,
101
+ reason: object.reason ?? undefined,
102
+ confidence,
103
+ };
76
104
  }
77
105
  }
78
- if (object.action === 'enterFlow' && availableFlows.length === 1) {
79
- return { kind: 'enterFlow', flow: availableFlows[0] };
106
+ if (!allowKeep) {
107
+ if (availableFlows.length === 1) {
108
+ return { action: 'enterFlow', flowName: availableFlows[0].name };
109
+ }
110
+ if (transferTargets.length === 1) {
111
+ return { action: 'transfer', targetAgentId: transferTargets[0].id };
112
+ }
113
+ throw new Error('Pure dispatcher could not resolve a valid transfer target');
80
114
  }
81
- return keywordRouteFallback(latestUser, routes, availableFlows);
115
+ return { action: 'keep', confidence: confidence ?? 1 };
82
116
  }
83
- function deterministicRouteMatch(message, routes, availableFlows) {
84
- const lower = message.toLowerCase();
85
- const hits = [];
86
- for (const route of routes) {
87
- const terms = route.when
88
- .toLowerCase()
89
- .split(/[^a-z0-9]+/)
90
- .filter((term) => term.length > 3);
91
- if (!terms.some((term) => lower.includes(term))) {
92
- continue;
93
- }
94
- if (route.flow) {
95
- const flow = availableFlows.find((candidate) => candidate.name === route.flow);
96
- if (flow) {
97
- hits.push({ kind: 'enterFlow', flow });
98
- }
117
+ /** @deprecated Use classifyHostTarget — kept as alias for test injection. */
118
+ export async function selectHostTarget(options) {
119
+ const verdict = await classifyHostTarget({ ...options, allowKeep: true });
120
+ if (verdict.action === 'enterFlow' && verdict.flowName) {
121
+ const flow = (options.agent.flows ?? []).find((f) => f.name === verdict.flowName);
122
+ if (flow) {
123
+ return { kind: 'enterFlow', flow };
99
124
  }
100
- else if (route.agent) {
101
- hits.push({ kind: 'route', agentId: route.agent });
125
+ }
126
+ if (verdict.action === 'transfer' && verdict.targetAgentId) {
127
+ return { kind: 'route', agentId: verdict.targetAgentId, reason: verdict.reason };
128
+ }
129
+ return { kind: 'keep' };
130
+ }
131
+ export function verdictToSelection(verdict, agent) {
132
+ if (verdict.action === 'keep') {
133
+ return { kind: 'keep' };
134
+ }
135
+ if (verdict.action === 'enterFlow' && verdict.flowName) {
136
+ const flow = (agent.flows ?? []).find((f) => f.name === verdict.flowName);
137
+ if (flow) {
138
+ return { kind: 'enterFlow', flow };
102
139
  }
103
140
  }
104
- if (hits.length === 1) {
105
- return hits[0];
141
+ if (verdict.action === 'transfer' && verdict.targetAgentId) {
142
+ return { kind: 'route', agentId: verdict.targetAgentId, reason: verdict.reason };
106
143
  }
107
144
  return undefined;
108
145
  }
109
- function keywordRouteFallback(message, routes, availableFlows) {
110
- const lower = message.toLowerCase();
111
- for (const route of routes) {
112
- if (!route.flow) {
113
- continue;
114
- }
115
- const terms = route.when
116
- .toLowerCase()
117
- .split(/[^a-z0-9]+/)
118
- .filter((term) => term.length > 3);
119
- if (terms.some((term) => lower.includes(term))) {
120
- const flow = availableFlows.find((candidate) => candidate.name === route.flow);
121
- if (flow) {
122
- return { kind: 'enterFlow', flow };
123
- }
124
- }
146
+ function isValidTransferTarget(agent, targetId) {
147
+ if (agent.handoffs?.includes(targetId)) {
148
+ return true;
125
149
  }
126
- return { kind: 'keep' };
150
+ if (agent.agents?.some((child) => child.id === targetId)) {
151
+ return true;
152
+ }
153
+ if (agent.routes?.some((route) => route.agent === targetId)) {
154
+ return true;
155
+ }
156
+ return false;
127
157
  }
128
158
  function formatRouteLine(route, index) {
129
159
  const target = route.agent ? `agent "${route.agent}"` : route.flow ? `flow "${route.flow}"` : 'keep';
@@ -149,3 +179,4 @@ function latestUserMessage(messages) {
149
179
  }
150
180
  return undefined;
151
181
  }
182
+ export { availableHostFlows };
@@ -0,0 +1,14 @@
1
+ import type { AnyTool } from '../types/effectTool.js';
2
+ import type { Flow } from '../types/flow.js';
3
+ /** Result shape emitted by the `enter_flow` control tool. `classifyControl`
4
+ * recognizes it and turns it into a `TurnControl{ type: 'enterFlow' }`, so the
5
+ * host loop enters the named flow — routing folded into the speaking turn
6
+ * instead of a separate upfront `generateObject` selector. */
7
+ export interface EnterFlowResult {
8
+ __enterFlow: true;
9
+ flowName: string;
10
+ reason?: string;
11
+ }
12
+ export declare function isEnterFlowResult(result: unknown): result is EnterFlowResult;
13
+ /** Build the model-visible `enter_flow` control tool from available flows. */
14
+ export declare function createEnterFlowTool(availableFlows: Flow[]): AnyTool;
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from './effect/defineTool.js';
3
+ export function isEnterFlowResult(result) {
4
+ return (typeof result === 'object' &&
5
+ result !== null &&
6
+ '__enterFlow' in result &&
7
+ result.__enterFlow === true &&
8
+ 'flowName' in result);
9
+ }
10
+ /** Build the model-visible `enter_flow` control tool from available flows. */
11
+ export function createEnterFlowTool(availableFlows) {
12
+ const names = availableFlows.map((flow) => flow.name);
13
+ const lines = availableFlows
14
+ .map((flow) => `- ${flow.name}: ${flow.description}`)
15
+ .join('\n');
16
+ return defineTool({
17
+ name: 'enter_flow',
18
+ description: 'Start a structured procedure when the user wants to do one of the tasks below. ' +
19
+ 'Call this INSTEAD of answering in prose; do not announce the transition to the user.\n\n' +
20
+ `Available procedures:\n${lines}`,
21
+ input: z.object({
22
+ flowName: z.enum(names).describe('Which procedure to start'),
23
+ reason: z.string().describe('Why this procedure, in a few words'),
24
+ }),
25
+ execute: async ({ flowName, reason }) => ({
26
+ __enterFlow: true,
27
+ flowName,
28
+ reason,
29
+ }),
30
+ });
31
+ }
@@ -2,6 +2,12 @@ import type { ToolSet } from 'ai';
2
2
  import type { RunContext } from './run-context.js';
3
3
  import type { FlowNode } from './flow.js';
4
4
  import type { AnyTool } from './effectTool.js';
5
+ import type { DispatchMode } from '../runtime/dispatchMode.js';
6
+ export type DriverOutputCapability = 'kuralle-controlled-text' | 'kuralle-controlled-tts' | 'native-realtime';
7
+ export interface HostControlContext {
8
+ dispatchMode: DispatchMode;
9
+ advisoryDispatch: boolean;
10
+ }
5
11
  export interface ResolvedNode {
6
12
  node: FlowNode;
7
13
  prompt: string;
@@ -9,8 +15,10 @@ export interface ResolvedNode {
9
15
  localTools?: Record<string, AnyTool>;
10
16
  /** Free-conversation reply (host loop): keeps model control tools even when outOfBandControl is on. */
11
17
  freeConversation?: boolean;
18
+ hostControl?: HostControlContext;
12
19
  }
13
20
  export interface ChannelDriver {
21
+ readonly outputCapability?: DriverOutputCapability;
14
22
  runAgentTurn(node: ResolvedNode, ctx: RunContext): Promise<TurnResult>;
15
23
  awaitUser(ctx: RunContext): Promise<UserSignal>;
16
24
  runStructured?(node: Extract<FlowNode, {
@@ -48,6 +56,10 @@ export type TurnControl = {
48
56
  type: 'handoff';
49
57
  target: string;
50
58
  reason?: string;
59
+ } | {
60
+ type: 'enterFlow';
61
+ flowName: string;
62
+ reason?: string;
51
63
  } | {
52
64
  type: 'end';
53
65
  reason: string;
@@ -7,9 +7,9 @@ export interface Route {
7
7
  filter?: HandoffInputFilter;
8
8
  }
9
9
  export interface RoutingPolicy {
10
- mode?: 'structured' | 'llm';
10
+ /** Model for control-path reasoning (guard, pure-dispatcher classifier). */
11
11
  model?: LanguageModel;
12
- default?: string;
13
- always?: boolean;
12
+ /** Buffer text until guard/control resolves (compliance text). Default: relaxed cancel-on-late-control. */
13
+ dispatch?: 'strict';
14
14
  }
15
15
  export type { HandoffInputFilter } from '../runtime/handoffFilters.js';
package/guides/AGENTS.md CHANGED
@@ -52,8 +52,6 @@ await handle;
52
52
 
53
53
  Attach one or more flows with `flows: [ defineFlow({ ... }) ]`. Build nodes with `reply`, `collect`, `decide`, and `action`. The runtime emits `node-enter`, `flow-transition`, and `flow-end` events on the turn stream.
54
54
 
55
- Hybrid mode (`hybrid: true` on `defineFlow`) lets the agent answer off-flow questions between steps and resume the SOP.
56
-
57
55
  ```ts
58
56
  import { z } from 'zod';
59
57
 
@@ -78,7 +76,6 @@ const bookingFlow = defineFlow({
78
76
  description: 'Book an appointment',
79
77
  start: collectDate,
80
78
  nodes: [collectDate, confirm],
81
- hybrid: true,
82
79
  });
83
80
 
84
81
  const bookingAgent = defineAgent({
@@ -99,7 +96,7 @@ See [FLOWS.md](./FLOWS.md) for transition patterns and a full runnable example.
99
96
 
100
97
  ## Routing and triage
101
98
 
102
- Add `routes` and `routing: { mode: 'structured', model }` to route user input to specialist agents or flows via the invisible `handoff` tool. Nest specialists in `agents`.
99
+ Add `routes`/`agents` to route user input to specialists. Routing is derived from shape: an agent with its own answering surface (instructions/flows/tools) folds an invisible `transfer_to_agent` tool into its turn; a routes-only agent with no answering surface becomes a silent **pure dispatcher**. Set `routing: { model }` to choose the control-reasoning model. Nest specialists in `agents`.
103
100
 
104
101
  ```ts
105
102
  const billing = defineAgent({
@@ -117,7 +114,7 @@ const support = defineAgent({
117
114
  routes: [
118
115
  { agent: 'billing', when: 'billing, payment, invoice, or refund questions' },
119
116
  ],
120
- routing: { mode: 'structured', model },
117
+ routing: { model },
121
118
  agents: [billing],
122
119
  });
123
120
 
@@ -160,7 +157,7 @@ const lead = defineAgent({
160
157
  model,
161
158
  routes: [{ agent: 'specialist', when: 'specialist topic' }],
162
159
  agents: [specialist],
163
- routing: { mode: 'structured', model },
160
+ routing: { model },
164
161
  });
165
162
  ```
166
163
 
@@ -251,7 +248,7 @@ await handle;
251
248
 
252
249
  ### When NOT to Use
253
250
 
254
- - **Routing to a different agent persona** — Use `routes` + structured routing instead
251
+ - **Routing to a different agent persona** — Use `routes`/`agents` (derived routing) instead
255
252
  - **Simple API calls** — Use `defineTool` / `createTool` directly without the specialist framing
256
253
 
257
254
  ### Example
@@ -271,7 +268,7 @@ See [standalone-agent.ts](../examples/agents/standalone-agent.ts) (Example 4) fo
271
268
  ## Best Practices
272
269
 
273
270
  1. **Keep SOPs in flows** — If instructions exceed ~20 lines of procedure, move them into flow nodes
274
- 2. **Use structured routing for triage** — Prevents user-visible handoff leaks
271
+ 2. **Use derived routing for triage** — Prevents user-visible handoff leaks
275
272
  3. **Tools return data only** — No conversational text in tool outputs; flow tools return transitions
276
273
  4. **Filter handoff context** — Use `handoffFilters` on routes when specialists need trimmed history
277
274
  5. **Lead agent synthesizes** — Consultation tools return structured data; the lead agent speaks to the user
@@ -281,8 +278,8 @@ See [standalone-agent.ts](../examples/agents/standalone-agent.ts) (Example 4) fo
281
278
  | Pattern | Description | Handoffs | User sees |
282
279
  |---------|-------------|----------|-----------|
283
280
  | Agent Consultation | Lead calls specialist tools, synthesizes one answer | No | One agent |
284
- | Structured routing | `routes` + `routing: { mode: 'structured' }` picks a specialist | Yes (invisible) | Active specialist after route |
281
+ | Derived routing | `routes`/`agents` route to a specialist (model-reasoned over `when`) | Yes (invisible) | Active specialist after route |
285
282
  | Explicit handoffs | `handoffs: ['billing']` exposes the `handoff` tool | Yes (invisible) | Active specialist after handoff |
286
283
  | Free conversation | No `flows` or `routes` | No | Same agent throughout |
287
284
 
288
- **Agent consultation** keeps the customer with one lead persona. **Structured routing** transfers session control to a specialist when the topic matches.
285
+ **Agent consultation** keeps the customer with one lead persona. **Derived routing** transfers session control to a specialist when the topic matches.
package/guides/FLOWS.md CHANGED
@@ -9,8 +9,6 @@ Attach one or more flows to an agent via `defineAgent({ flows: [...] })`. The `R
9
9
  Key features:
10
10
  - Node builders: `reply`, `collect`, `decide`, `action`
11
11
  - Tool-driven transitions via `createFlowTransition()` (AI SDK tools) or `next` callbacks on `reply` nodes
12
- - Hybrid mode (`hybrid: true` on `defineFlow`) to answer off-topic questions and resume the flow
13
-
14
12
  ## Minimal flow example
15
13
 
16
14
  See `examples/flows/restaurant-reservation.ts` for a full runnable example. Sketch:
@@ -52,7 +50,6 @@ const agent = defineAgent({
52
50
  name: 'booking',
53
51
  start: greeting,
54
52
  nodes: [greeting, confirm],
55
- hybrid: true,
56
53
  }),
57
54
  ],
58
55
  });
@@ -74,4 +71,3 @@ For tools that return `createFlowTransition(targetId, data)`, the runtime interp
74
71
  - Return `createFlowTransition(targetId, data)` from tool `execute` handlers that move the flow.
75
72
  - On `reply` nodes, return `{ goto: nextNode, data }` from `next` when a tool result is ready.
76
73
  - Use `createFlowUpdate(data, text, keys)` to merge state without leaving the node.
77
- - Set `hybrid: true` on the flow when the agent should answer off-script questions between steps.
package/guides/RUNTIME.md CHANGED
@@ -104,8 +104,8 @@ For external side effects, tools receive a stable `idempotencyKey` in
104
104
 
105
105
  ## Routing & Handoffs
106
106
 
107
- Use `routes` + `routing: { mode: 'structured', model }` for triage without user-visible leaks.
108
- Use `handoffs: ['billing']` to expose the invisible `handoff` tool to specialists.
107
+ Use `routes`/`agents` for triage without user-visible leaks — routing is derived from shape (a routes-only agent is a silent pure dispatcher; an answering agent folds `transfer_to_agent` into its turn). Set `routing: { model }` to choose the control model.
108
+ Use `handoffs: ['billing']` to expose the invisible `transfer_to_agent` tool to specialists.
109
109
 
110
110
  ```ts
111
111
  const support = defineAgent({
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.6.1",
9
+ "version": "0.7.0",
10
10
  "description": "A framework for structured conversational AI agents",
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -103,7 +103,7 @@
103
103
  "typescript": "^5.3.0",
104
104
  "vitest": "^3.2.4",
105
105
  "zod": "^4.0.0",
106
- "@kuralle-agents/realtime-audio": "0.6.1"
106
+ "@kuralle-agents/realtime-audio": "0.7.0"
107
107
  },
108
108
  "dependencies": {
109
109
  "chrono-node": "^2.6.0"
@@ -1,18 +0,0 @@
1
- import type { Capability, ToolDeclaration, PromptSection, CapabilityAction } from './index.js';
2
- export interface HandoffTarget {
3
- id: string;
4
- name: string;
5
- description?: string;
6
- }
7
- /**
8
- * Exposes a single `transfer_to_agent` tool for agents that have explicit
9
- * `canHandoffTo` targets. Unlike TriageCapability (one tool per route),
10
- * this uses a single tool with an enum parameter for the target agent.
11
- */
12
- export declare class HandoffCapability implements Capability {
13
- private targets;
14
- constructor(targets: HandoffTarget[]);
15
- getTools(): ToolDeclaration[];
16
- getPromptSections(): PromptSection[];
17
- processToolResult(toolName: string, args: unknown, result: unknown): CapabilityAction | null;
18
- }
@@ -1,69 +0,0 @@
1
- import { z } from 'zod';
2
- import { isHandoffResult } from '../tools/handoff.js';
3
- // ─── HandoffCapability ───────────────────────────────────────────────────────
4
- /**
5
- * Exposes a single `transfer_to_agent` tool for agents that have explicit
6
- * `canHandoffTo` targets. Unlike TriageCapability (one tool per route),
7
- * this uses a single tool with an enum parameter for the target agent.
8
- */
9
- export class HandoffCapability {
10
- targets;
11
- constructor(targets) {
12
- this.targets = targets;
13
- }
14
- getTools() {
15
- if (this.targets.length === 0)
16
- return [];
17
- const agentIds = this.targets.map(t => t.id);
18
- const agentDescriptions = this.targets
19
- .map(t => `- ${t.id} (${t.name})${t.description ? `: ${t.description}` : ''}`)
20
- .join('\n');
21
- return [
22
- {
23
- name: 'transfer_to_agent',
24
- description: `Transfer the conversation to a specialized agent.\n` +
25
- `Do NOT mention this transfer to the user.\n\n` +
26
- `Available agents:\n${agentDescriptions}`,
27
- parameters: z.object({
28
- targetAgentId: z.enum(agentIds).describe('The ID of the agent to transfer to'),
29
- reason: z.string().describe('Why you are transferring — include relevant context'),
30
- summary: z.string().optional().describe('Optional summary of what has been done so far'),
31
- }),
32
- execute: async (args) => ({
33
- __handoff: true,
34
- targetAgentId: args.targetAgentId,
35
- targetAgent: args.targetAgentId,
36
- reason: args.reason,
37
- summary: args.summary,
38
- }),
39
- },
40
- ];
41
- }
42
- getPromptSections() {
43
- return [];
44
- }
45
- processToolResult(toolName, args, result) {
46
- if (toolName === 'transfer_to_triage') {
47
- if (!isHandoffResult(result))
48
- return null;
49
- const target = result.targetAgentId ?? result.targetAgent;
50
- if (!target)
51
- return null;
52
- return {
53
- type: 'handoff',
54
- targetAgent: target,
55
- reason: result.reason,
56
- };
57
- }
58
- // Match both 'transfer_to_agent' (capability name) and 'handoff' (legacy flow injection key)
59
- if (toolName !== 'transfer_to_agent' && toolName !== 'handoff')
60
- return null;
61
- if (!isHandoffResult(result))
62
- return null;
63
- return {
64
- type: 'handoff',
65
- targetAgent: result.targetAgentId,
66
- reason: result.reason,
67
- };
68
- }
69
- }
@@ -1,15 +0,0 @@
1
- import type { Capability, ToolDeclaration, PromptSection, CapabilityAction } from './index.js';
2
- import type { AgentRoute } from '../types/index.js';
3
- /**
4
- * Exposes triage routing as tools: one `route_to_<agentId>` tool per route.
5
- * The LLM selects the appropriate route; processToolResult converts the
6
- * tool result to a `handoff` action for the host.
7
- */
8
- export declare class TriageCapability implements Capability {
9
- private routes;
10
- private defaultAgent;
11
- constructor(routes: AgentRoute[], defaultAgent?: string);
12
- getTools(): ToolDeclaration[];
13
- getPromptSections(): PromptSection[];
14
- processToolResult(toolName: string, args: unknown, result: unknown): CapabilityAction | null;
15
- }
@@ -1,60 +0,0 @@
1
- import { z } from 'zod';
2
- import { isHandoffResult } from '../tools/handoff.js';
3
- // ─── TriageCapability ────────────────────────────────────────────────────────
4
- /**
5
- * Exposes triage routing as tools: one `route_to_<agentId>` tool per route.
6
- * The LLM selects the appropriate route; processToolResult converts the
7
- * tool result to a `handoff` action for the host.
8
- */
9
- export class TriageCapability {
10
- routes;
11
- defaultAgent;
12
- constructor(routes, defaultAgent) {
13
- this.routes = routes;
14
- this.defaultAgent = defaultAgent;
15
- }
16
- getTools() {
17
- return this.routes.map(route => ({
18
- name: `route_to_${route.agentId}`,
19
- description: route.description,
20
- parameters: z.object({
21
- reason: z.string().describe('Why this route was chosen — include relevant context from the conversation'),
22
- }),
23
- execute: async (args) => ({
24
- __handoff: true,
25
- targetAgentId: route.agentId,
26
- targetAgent: route.agentId,
27
- reason: args.reason,
28
- }),
29
- }));
30
- }
31
- getPromptSections() {
32
- if (this.routes.length === 0)
33
- return [];
34
- const routeList = this.routes
35
- .map(r => `- route_to_${r.agentId}: ${r.description}`)
36
- .join('\n');
37
- const defaultNote = this.defaultAgent
38
- ? `\nIf no route clearly matches, route to ${this.defaultAgent}.`
39
- : '';
40
- return [
41
- {
42
- role: 'routing',
43
- content: `Route the user to the most appropriate agent using the routing tools below.\n` +
44
- `Do NOT mention routing or agent transfers to the user.\n\n` +
45
- `Available routes:\n${routeList}${defaultNote}`,
46
- },
47
- ];
48
- }
49
- processToolResult(toolName, args, result) {
50
- if (!toolName.startsWith('route_to_'))
51
- return null;
52
- if (!isHandoffResult(result))
53
- return null;
54
- return {
55
- type: 'handoff',
56
- targetAgent: result.targetAgentId,
57
- reason: result.reason,
58
- };
59
- }
60
- }