@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.
- package/README.md +2 -4
- package/dist/capabilities/index.d.ts +0 -3
- package/dist/capabilities/index.js +0 -2
- package/dist/flow/classifyControl.js +4 -0
- package/dist/flow/collectDigression.js +0 -1
- package/dist/flow/controlEvaluator.js +3 -0
- package/dist/flow/flowControlTools.js +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/runtime/Runtime.d.ts +3 -1
- package/dist/runtime/Runtime.js +3 -1
- package/dist/runtime/agentReply.d.ts +2 -1
- package/dist/runtime/agentReply.js +14 -3
- package/dist/runtime/channels/TextDriver.d.ts +1 -0
- package/dist/runtime/channels/TextDriver.js +31 -18
- package/dist/runtime/channels/VoiceDriver.d.ts +1 -0
- package/dist/runtime/channels/VoiceDriver.js +5 -1
- package/dist/runtime/channels/streaming/hostControlSpeak.d.ts +30 -0
- package/dist/runtime/channels/streaming/hostControlSpeak.js +80 -0
- package/dist/runtime/deriveAgent.d.ts +10 -1
- package/dist/runtime/deriveAgent.js +63 -13
- package/dist/runtime/dispatchMode.d.ts +5 -0
- package/dist/runtime/dispatchMode.js +18 -0
- package/dist/runtime/hostClassifyAdapter.d.ts +2 -0
- package/dist/runtime/hostClassifyAdapter.js +21 -0
- package/dist/runtime/hostControlGuard.d.ts +23 -0
- package/dist/runtime/hostControlGuard.js +67 -0
- package/dist/runtime/hostControlTools.d.ts +12 -0
- package/dist/runtime/hostControlTools.js +89 -0
- package/dist/runtime/hostLoop.d.ts +4 -1
- package/dist/runtime/hostLoop.js +122 -34
- package/dist/runtime/select.d.ts +17 -4
- package/dist/runtime/select.js +107 -76
- package/dist/tools/enterFlow.d.ts +14 -0
- package/dist/tools/enterFlow.js +31 -0
- package/dist/types/channel.d.ts +12 -0
- package/dist/types/route.d.ts +3 -3
- package/guides/AGENTS.md +7 -10
- package/guides/FLOWS.md +0 -4
- package/guides/RUNTIME.md +2 -2
- package/package.json +2 -2
- package/dist/capabilities/HandoffCapability.d.ts +0 -18
- package/dist/capabilities/HandoffCapability.js +0 -69
- package/dist/capabilities/TriageCapability.d.ts +0 -15
- package/dist/capabilities/TriageCapability.js +0 -60
package/dist/runtime/select.js
CHANGED
|
@@ -1,129 +1,159 @@
|
|
|
1
1
|
import { generateObject } from 'ai';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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 {
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
return {
|
|
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
|
-
|
|
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
|
|
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
|
-
'
|
|
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
|
-
|
|
58
|
+
(transferLines ? `Transfer targets:\n${transferLines}\n\n` : '') +
|
|
59
|
+
actionHint,
|
|
53
60
|
});
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 {
|
|
76
|
+
return {
|
|
77
|
+
action: 'transfer',
|
|
78
|
+
targetAgentId: matchedRoute.agent,
|
|
79
|
+
reason: object.reason ?? undefined,
|
|
80
|
+
confidence,
|
|
81
|
+
};
|
|
65
82
|
}
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
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 {
|
|
98
|
+
return {
|
|
99
|
+
action: 'enterFlow',
|
|
100
|
+
flowName: flow.name,
|
|
101
|
+
reason: object.reason ?? undefined,
|
|
102
|
+
confidence,
|
|
103
|
+
};
|
|
76
104
|
}
|
|
77
105
|
}
|
|
78
|
-
if (
|
|
79
|
-
|
|
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
|
|
115
|
+
return { action: 'keep', confidence: confidence ?? 1 };
|
|
82
116
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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 (
|
|
105
|
-
return
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/types/channel.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/route.d.ts
CHANGED
|
@@ -7,9 +7,9 @@ export interface Route {
|
|
|
7
7
|
filter?: HandoffInputFilter;
|
|
8
8
|
}
|
|
9
9
|
export interface RoutingPolicy {
|
|
10
|
-
|
|
10
|
+
/** Model for control-path reasoning (guard, pure-dispatcher classifier). */
|
|
11
11
|
model?: LanguageModel;
|
|
12
|
-
|
|
13
|
-
|
|
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`
|
|
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: {
|
|
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: {
|
|
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`
|
|
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
|
|
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
|
-
|
|
|
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. **
|
|
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`
|
|
108
|
-
Use `handoffs: ['billing']` to expose the invisible `
|
|
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.
|
|
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.
|
|
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
|
-
}
|