llm-agent-runtime 0.1.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 (47) hide show
  1. package/README.md +5 -0
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.d.ts.map +1 -0
  4. package/dist/agent.js +37 -0
  5. package/dist/create-runtime.d.ts +17 -0
  6. package/dist/create-runtime.d.ts.map +1 -0
  7. package/dist/create-runtime.js +197 -0
  8. package/dist/index.d.ts +9 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +7 -0
  11. package/dist/llm.d.ts +17 -0
  12. package/dist/llm.d.ts.map +1 -0
  13. package/dist/llm.js +65 -0
  14. package/dist/redis.d.ts +9 -0
  15. package/dist/redis.d.ts.map +1 -0
  16. package/dist/redis.js +18 -0
  17. package/dist/services/agent-runner.d.ts +17 -0
  18. package/dist/services/agent-runner.d.ts.map +1 -0
  19. package/dist/services/agent-runner.js +18 -0
  20. package/dist/services/chat-cache.d.ts +16 -0
  21. package/dist/services/chat-cache.d.ts.map +1 -0
  22. package/dist/services/chat-cache.js +70 -0
  23. package/dist/services/conversation.d.ts +7 -0
  24. package/dist/services/conversation.d.ts.map +1 -0
  25. package/dist/services/conversation.js +56 -0
  26. package/dist/services/extract.d.ts +33 -0
  27. package/dist/services/extract.d.ts.map +1 -0
  28. package/dist/services/extract.js +64 -0
  29. package/dist/services/flow-router.d.ts +42 -0
  30. package/dist/services/flow-router.d.ts.map +1 -0
  31. package/dist/services/flow-router.js +167 -0
  32. package/dist/services/intent-embeddings.d.ts +17 -0
  33. package/dist/services/intent-embeddings.d.ts.map +1 -0
  34. package/dist/services/intent-embeddings.js +46 -0
  35. package/dist/services/intent-router.d.ts +21 -0
  36. package/dist/services/intent-router.d.ts.map +1 -0
  37. package/dist/services/intent-router.js +98 -0
  38. package/dist/types.d.ts +177 -0
  39. package/dist/types.d.ts.map +1 -0
  40. package/dist/types.js +1 -0
  41. package/dist/utils/message.d.ts +9 -0
  42. package/dist/utils/message.d.ts.map +1 -0
  43. package/dist/utils/message.js +13 -0
  44. package/dist/utils/normalize.d.ts +3 -0
  45. package/dist/utils/normalize.d.ts.map +1 -0
  46. package/dist/utils/normalize.js +8 -0
  47. package/package.json +55 -0
@@ -0,0 +1,33 @@
1
+ import type { ToolCall, ToolExtractorMap } from "../types.js";
2
+ export declare const getToolCallsFromMessages: (messages: unknown[]) => ToolCall[];
3
+ /** Returns args from the first matching tool call in the message list. */
4
+ export declare function extractToolCallArgs<T extends Record<string, unknown>>(messages: unknown[], toolName: string): T | undefined;
5
+ /**
6
+ * Generic extractor: maps each unique tool name → last args object.
7
+ */
8
+ export declare function extractRawToolOutputs(messages: unknown[]): Record<string, unknown>;
9
+ /**
10
+ * Build an extractor from a toolName → parser map (Xel extractToolCall style).
11
+ *
12
+ * Pass this as `extractToolOutputs`, or use `toolExtractors` on createAgentRuntime
13
+ * and the runtime builds it for you.
14
+ *
15
+ * @example
16
+ * createToolOutputExtractor(
17
+ * {
18
+ * propose_tickets: (args, acc) => {
19
+ * if (Array.isArray(acc.tickets) && (acc.tickets as unknown[]).length > 0) return;
20
+ * acc.tickets = (args.tickets as unknown[]) ?? [];
21
+ * },
22
+ * create_campaign: (args, acc) => {
23
+ * if (acc.campaign) return;
24
+ * acc.campaign = args;
25
+ * },
26
+ * },
27
+ * () => ({ tickets: [] }),
28
+ * )
29
+ */
30
+ export declare function createToolOutputExtractor(extractors: ToolExtractorMap, initialOutputs?: () => Record<string, unknown>, options?: {
31
+ includeUnknown?: boolean;
32
+ }): (messages: unknown[]) => Record<string, unknown>;
33
+ //# sourceMappingURL=extract.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../src/services/extract.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAiB,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAE7E,eAAO,MAAM,wBAAwB,GAAI,UAAU,OAAO,EAAE,KAAG,QAAQ,EAIpE,CAAC;AAEJ,0EAA0E;AAC1E,wBAAgB,mBAAmB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnE,QAAQ,EAAE,OAAO,EAAE,EACnB,QAAQ,EAAE,MAAM,GACf,CAAC,GAAG,SAAS,CAOf;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,OAAO,EAAE,GAClB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,gBAAgB,EAC5B,cAAc,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9C,OAAO,CAAC,EAAE;IAAE,cAAc,CAAC,EAAE,OAAO,CAAA;CAAE,GACrC,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAwBlD"}
@@ -0,0 +1,64 @@
1
+ export const getToolCallsFromMessages = (messages) => messages.flatMap((message) => message.tool_calls ?? message.additional_kwargs?.tool_calls ?? []);
2
+ /** Returns args from the first matching tool call in the message list. */
3
+ export function extractToolCallArgs(messages, toolName) {
4
+ for (const call of getToolCallsFromMessages(messages)) {
5
+ if (call.name === toolName) {
6
+ return call.args;
7
+ }
8
+ }
9
+ return undefined;
10
+ }
11
+ /**
12
+ * Generic extractor: maps each unique tool name → last args object.
13
+ */
14
+ export function extractRawToolOutputs(messages) {
15
+ const outputs = {};
16
+ for (const call of getToolCallsFromMessages(messages)) {
17
+ if (!call.name || !call.args)
18
+ continue;
19
+ outputs[call.name] = call.args;
20
+ }
21
+ return outputs;
22
+ }
23
+ /**
24
+ * Build an extractor from a toolName → parser map (Xel extractToolCall style).
25
+ *
26
+ * Pass this as `extractToolOutputs`, or use `toolExtractors` on createAgentRuntime
27
+ * and the runtime builds it for you.
28
+ *
29
+ * @example
30
+ * createToolOutputExtractor(
31
+ * {
32
+ * propose_tickets: (args, acc) => {
33
+ * if (Array.isArray(acc.tickets) && (acc.tickets as unknown[]).length > 0) return;
34
+ * acc.tickets = (args.tickets as unknown[]) ?? [];
35
+ * },
36
+ * create_campaign: (args, acc) => {
37
+ * if (acc.campaign) return;
38
+ * acc.campaign = args;
39
+ * },
40
+ * },
41
+ * () => ({ tickets: [] }),
42
+ * )
43
+ */
44
+ export function createToolOutputExtractor(extractors, initialOutputs, options) {
45
+ const includeUnknown = options?.includeUnknown ?? false;
46
+ return (messages) => {
47
+ const outputs = initialOutputs
48
+ ? initialOutputs()
49
+ : {};
50
+ for (const call of getToolCallsFromMessages(messages)) {
51
+ if (!call.name)
52
+ continue;
53
+ const extractor = extractors[call.name];
54
+ if (extractor) {
55
+ extractor(call.args ?? {}, outputs);
56
+ continue;
57
+ }
58
+ if (includeUnknown && call.args) {
59
+ outputs[call.name] = call.args;
60
+ }
61
+ }
62
+ return outputs;
63
+ };
64
+ }
@@ -0,0 +1,42 @@
1
+ import type { ChatFlowState, DetectedIntentResult, FinalizeFlowInput, FinalizeFlowResult, FlowResolution, FlowState, IntentDefinition, LlmIntentResult } from "../types.js";
2
+ import type { ChatCache } from "./chat-cache.js";
3
+ export declare const isWizardInput: (message: string) => boolean;
4
+ export declare function createFlowRouter(options: {
5
+ stickyFlows: Set<string>;
6
+ menuMap: Record<string, string>;
7
+ normalizeIntent: (intent: string) => string;
8
+ getEmbeddedIntents: () => Promise<{
9
+ name: string;
10
+ embedding: number[];
11
+ }[]>;
12
+ scoreIntentsForMessage: (message: string, embedded: {
13
+ name: string;
14
+ embedding: number[];
15
+ }[]) => Promise<DetectedIntentResult[]>;
16
+ classifyIntentWithLlm: (input: {
17
+ message: string;
18
+ conversationContext: string;
19
+ activeFlowIntent?: string | null;
20
+ }) => Promise<LlmIntentResult>;
21
+ mergeIntentResults: (embeddingTop: DetectedIntentResult, llmResult: LlmIntentResult) => DetectedIntentResult & {
22
+ action: LlmIntentResult["action"];
23
+ };
24
+ chatCache: ChatCache;
25
+ finalizeFlow?: (input: FinalizeFlowInput) => FinalizeFlowResult | Promise<FinalizeFlowResult>;
26
+ thresholds?: {
27
+ switch?: number;
28
+ ambiguous?: number;
29
+ switchMargin?: number;
30
+ };
31
+ }): {
32
+ resolveFlow: ({ message, explicitIntent, cachedFlowState, conversationContext, }: {
33
+ message: string;
34
+ explicitIntent?: string;
35
+ cachedFlowState: ChatFlowState | null;
36
+ conversationContext: string;
37
+ }) => Promise<FlowResolution>;
38
+ finalizeAgentFlow: (input: FinalizeFlowInput) => Promise<FinalizeFlowResult>;
39
+ };
40
+ export declare function stickySetFromIntents(intents: IntentDefinition[]): Set<string>;
41
+ export type { FlowState, FlowResolution };
42
+ //# sourceMappingURL=flow-router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flow-router.d.ts","sourceRoot":"","sources":["../../src/services/flow-router.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,aAAa,EACb,oBAAoB,EACpB,iBAAiB,EACjB,kBAAkB,EAClB,cAAc,EACd,SAAS,EACT,gBAAgB,EAChB,eAAe,EAChB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAOjD,eAAO,MAAM,aAAa,GAAI,SAAS,MAAM,KAAG,OAI/C,CAAC;AAEF,wBAAgB,gBAAgB,CAAC,OAAO,EAAE;IACxC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5C,kBAAkB,EAAE,MAAM,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,EAAE,CAAA;KAAE,EAAE,CAAC,CAAC;IAC3E,sBAAsB,EAAE,CACtB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,EAAE,CAAA;KAAE,EAAE,KAC9C,OAAO,CAAC,oBAAoB,EAAE,CAAC,CAAC;IACrC,qBAAqB,EAAE,CAAC,KAAK,EAAE;QAC7B,OAAO,EAAE,MAAM,CAAC;QAChB,mBAAmB,EAAE,MAAM,CAAC;QAC5B,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KAClC,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC;IAC/B,kBAAkB,EAAE,CAClB,YAAY,EAAE,oBAAoB,EAClC,SAAS,EAAE,eAAe,KACvB,oBAAoB,GAAG;QAAE,MAAM,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAA;KAAE,CAAC;IAClE,SAAS,EAAE,SAAS,CAAC;IACrB,YAAY,CAAC,EAAE,CACb,KAAK,EAAE,iBAAiB,KACrB,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACtD,UAAU,CAAC,EAAE;QACX,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,YAAY,CAAC,EAAE,MAAM,CAAC;KACvB,CAAC;CACH;sFAoBI;QACD,OAAO,EAAE,MAAM,CAAC;QAChB,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,eAAe,EAAE,aAAa,GAAG,IAAI,CAAC;QACtC,mBAAmB,EAAE,MAAM,CAAC;KAC7B,KAAG,OAAO,CAAC,cAAc,CAAC;+BA6JlB,iBAAiB,KACvB,OAAO,CAAC,kBAAkB,CAAC;EAkB/B;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC,CAE7E;AAED,YAAY,EAAE,SAAS,EAAE,cAAc,EAAE,CAAC"}
@@ -0,0 +1,167 @@
1
+ const WIZARD_SHORT_REPLIES = /^(yes|no|ok|okay|sure|yep|nope|cancel|stop|skip|continue|done|never mind)$/i;
2
+ const isNumericOnlyMessage = (message) => /^\d+(\.\d+)?$/.test(message.trim());
3
+ export const isWizardInput = (message) => {
4
+ const trimmed = message.trim();
5
+ if (isNumericOnlyMessage(trimmed))
6
+ return true;
7
+ return WIZARD_SHORT_REPLIES.test(trimmed);
8
+ };
9
+ export function createFlowRouter(options) {
10
+ const SWITCH_THRESHOLD = options.thresholds?.switch ?? 0.65;
11
+ const AMBIGUOUS_THRESHOLD = options.thresholds?.ambiguous ?? 0.45;
12
+ const SWITCH_MARGIN = options.thresholds?.switchMargin ?? 0.12;
13
+ const resolveGreetingMenuSelection = (message, activeIntent) => {
14
+ const trimmed = message.trim();
15
+ if (!(trimmed in options.menuMap))
16
+ return null;
17
+ if (activeIntent && options.stickyFlows.has(activeIntent))
18
+ return null;
19
+ return options.menuMap[trimmed] ?? null;
20
+ };
21
+ const resolveFlow = async ({ message, explicitIntent, cachedFlowState, conversationContext, }) => {
22
+ if (explicitIntent) {
23
+ const intent = options.normalizeIntent(explicitIntent);
24
+ const sticky = options.stickyFlows.has(intent);
25
+ return {
26
+ messageIntent: { intent, score: 1 },
27
+ flow: { active: sticky, intent: sticky ? intent : null },
28
+ promptIntent: intent,
29
+ };
30
+ }
31
+ const cachedIntent = cachedFlowState?.intent
32
+ ? options.normalizeIntent(cachedFlowState.intent)
33
+ : null;
34
+ const menuIntent = resolveGreetingMenuSelection(message, cachedIntent);
35
+ if (menuIntent) {
36
+ const sticky = options.stickyFlows.has(menuIntent);
37
+ return {
38
+ messageIntent: { intent: menuIntent, score: 1 },
39
+ flow: { active: sticky, intent: sticky ? menuIntent : null },
40
+ promptIntent: menuIntent,
41
+ };
42
+ }
43
+ const embeddedIntents = await options.getEmbeddedIntents();
44
+ const [embeddingScores, llmResult] = await Promise.all([
45
+ options.scoreIntentsForMessage(message, embeddedIntents),
46
+ options.classifyIntentWithLlm({
47
+ message,
48
+ conversationContext,
49
+ activeFlowIntent: cachedFlowState?.intent ?? null,
50
+ }),
51
+ ]);
52
+ const merged = options.mergeIntentResults(embeddingScores[0], llmResult);
53
+ const messageIntent = {
54
+ intent: merged.intent,
55
+ score: merged.score,
56
+ };
57
+ const inStickyFlow = cachedIntent !== null && options.stickyFlows.has(cachedIntent);
58
+ if (!inStickyFlow) {
59
+ const shouldStartFlow = options.stickyFlows.has(messageIntent.intent) &&
60
+ (messageIntent.score >= SWITCH_THRESHOLD ||
61
+ llmResult.action === "switch" ||
62
+ llmResult.intent === messageIntent.intent);
63
+ return {
64
+ messageIntent,
65
+ flow: shouldStartFlow
66
+ ? { active: true, intent: messageIntent.intent }
67
+ : { active: false, intent: null },
68
+ promptIntent: messageIntent.intent,
69
+ llmResult,
70
+ };
71
+ }
72
+ const cachedFlowIntent = cachedIntent;
73
+ if (llmResult.action === "switch" || llmResult.action === "cancel") {
74
+ const newSticky = options.stickyFlows.has(messageIntent.intent);
75
+ return {
76
+ messageIntent,
77
+ flow: newSticky
78
+ ? { active: true, intent: messageIntent.intent }
79
+ : { active: false, intent: null },
80
+ promptIntent: messageIntent.intent,
81
+ llmResult,
82
+ };
83
+ }
84
+ if (isWizardInput(message)) {
85
+ return {
86
+ messageIntent,
87
+ flow: { active: true, intent: cachedFlowIntent },
88
+ promptIntent: cachedFlowIntent,
89
+ llmResult,
90
+ };
91
+ }
92
+ if (messageIntent.intent === cachedFlowIntent) {
93
+ return {
94
+ messageIntent,
95
+ flow: { active: true, intent: cachedFlowIntent },
96
+ promptIntent: cachedFlowIntent,
97
+ llmResult,
98
+ };
99
+ }
100
+ const cachedScore = embeddingScores.find((s) => s.intent === cachedFlowIntent)?.score ?? 0;
101
+ const wantsSwitch = messageIntent.intent !== cachedFlowIntent &&
102
+ messageIntent.score >= SWITCH_THRESHOLD &&
103
+ messageIntent.score - cachedScore >= SWITCH_MARGIN;
104
+ if (wantsSwitch) {
105
+ const newSticky = options.stickyFlows.has(messageIntent.intent);
106
+ return {
107
+ messageIntent,
108
+ flow: newSticky
109
+ ? { active: true, intent: messageIntent.intent }
110
+ : { active: false, intent: null },
111
+ promptIntent: messageIntent.intent,
112
+ llmResult,
113
+ };
114
+ }
115
+ const isAmbiguous = messageIntent.score < AMBIGUOUS_THRESHOLD;
116
+ if (isAmbiguous) {
117
+ return {
118
+ messageIntent,
119
+ flow: { active: true, intent: cachedFlowIntent },
120
+ promptIntent: cachedFlowIntent,
121
+ llmResult,
122
+ };
123
+ }
124
+ return {
125
+ messageIntent,
126
+ flow: { active: true, intent: cachedFlowIntent },
127
+ promptIntent: cachedFlowIntent,
128
+ llmResult,
129
+ };
130
+ };
131
+ const defaultFinalize = async ({ threadId, flow, }) => {
132
+ if (!flow.active || !flow.intent) {
133
+ await options.chatCache.clearChatCacheFlowState(threadId);
134
+ return {
135
+ flow: { active: false, intent: null },
136
+ flowCompleted: false,
137
+ };
138
+ }
139
+ await options.chatCache.setChatCacheFlowState(threadId, {
140
+ intent: flow.intent,
141
+ timestamp: Date.now(),
142
+ });
143
+ return {
144
+ flow,
145
+ flowCompleted: false,
146
+ };
147
+ };
148
+ const finalizeAgentFlow = async (input) => {
149
+ const custom = options.finalizeFlow
150
+ ? await options.finalizeFlow(input)
151
+ : await defaultFinalize(input);
152
+ if (custom.flowCompleted || !custom.flow.active || !custom.flow.intent) {
153
+ await options.chatCache.clearChatCacheFlowState(input.threadId);
154
+ }
155
+ else {
156
+ await options.chatCache.setChatCacheFlowState(input.threadId, {
157
+ intent: custom.flow.intent,
158
+ timestamp: Date.now(),
159
+ });
160
+ }
161
+ return custom;
162
+ };
163
+ return { resolveFlow, finalizeAgentFlow };
164
+ }
165
+ export function stickySetFromIntents(intents) {
166
+ return new Set(intents.filter((i) => i.sticky).map((i) => i.name));
167
+ }
@@ -0,0 +1,17 @@
1
+ import type { Embeddings } from "@langchain/core/embeddings";
2
+ import type { DetectedIntentResult, IntentDefinition } from "../types.js";
3
+ export type EmbeddedIntent = {
4
+ name: string;
5
+ embedding: number[];
6
+ };
7
+ export declare function createIntentEmbeddings(options: {
8
+ embeddings: Embeddings;
9
+ intents: IntentDefinition[];
10
+ normalizeIntent: (intent: string) => string;
11
+ }): {
12
+ getEmbeddedIntents: () => Promise<EmbeddedIntent[]>;
13
+ scoreIntentsForMessage: (message: string, embeddedIntents: EmbeddedIntent[]) => Promise<DetectedIntentResult[]>;
14
+ /** Rebuild embeddings after intent catalog changes. */
15
+ invalidate: () => void;
16
+ };
17
+ //# sourceMappingURL=intent-embeddings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"intent-embeddings.d.ts","sourceRoot":"","sources":["../../src/services/intent-embeddings.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,KAAK,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAgB1E,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB,CAAC;AAEF,wBAAgB,sBAAsB,CAAC,OAAO,EAAE;IAC9C,UAAU,EAAE,UAAU,CAAC;IACvB,OAAO,EAAE,gBAAgB,EAAE,CAAC;IAC5B,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;CAC7C;8BAsBsC,OAAO,CAAC,cAAc,EAAE,CAAC;sCAQnD,MAAM,mBACE,cAAc,EAAE,KAChC,OAAO,CAAC,oBAAoB,EAAE,CAAC;IAchC,uDAAuD;;EAK1D"}
@@ -0,0 +1,46 @@
1
+ function cosineSimilarity(a, b) {
2
+ let dot = 0;
3
+ let magA = 0;
4
+ let magB = 0;
5
+ for (let i = 0; i < a.length; i++) {
6
+ dot += a[i] * b[i];
7
+ magA += a[i] * a[i];
8
+ magB += b[i] * b[i];
9
+ }
10
+ return dot / (Math.sqrt(magA) * Math.sqrt(magB));
11
+ }
12
+ export function createIntentEmbeddings(options) {
13
+ let cached = null;
14
+ const build = async () => {
15
+ const results = [];
16
+ for (const intent of options.intents) {
17
+ const embeddings = await Promise.all(intent.examples.map((ex) => options.embeddings.embedQuery(ex)));
18
+ const avgEmbedding = embeddings[0].map((_, i) => embeddings.reduce((sum, emb) => sum + emb[i], 0) / embeddings.length);
19
+ results.push({ name: intent.name, embedding: avgEmbedding });
20
+ }
21
+ return results;
22
+ };
23
+ const getEmbeddedIntents = async () => {
24
+ if (!cached) {
25
+ cached = await build();
26
+ }
27
+ return cached;
28
+ };
29
+ const scoreIntentsForMessage = async (message, embeddedIntents) => {
30
+ const queryEmbedding = await options.embeddings.embedQuery(message);
31
+ return embeddedIntents
32
+ .map((intent) => ({
33
+ intent: options.normalizeIntent(intent.name),
34
+ score: cosineSimilarity(queryEmbedding, intent.embedding),
35
+ }))
36
+ .sort((a, b) => b.score - a.score);
37
+ };
38
+ return {
39
+ getEmbeddedIntents,
40
+ scoreIntentsForMessage,
41
+ /** Rebuild embeddings after intent catalog changes. */
42
+ invalidate: () => {
43
+ cached = null;
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,21 @@
1
+ import type { BaseChatModel } from "@langchain/core/language_models/chat_models";
2
+ import type { DetectedIntentResult, IntentDefinition, LlmIntentResult } from "../types.js";
3
+ export declare function createIntentRouter(options: {
4
+ model: BaseChatModel;
5
+ intents: IntentDefinition[];
6
+ normalizeIntent: (intent: string) => string;
7
+ defaultIntent: string;
8
+ routerSystemPrompt?: string | ((intents: IntentDefinition[]) => string);
9
+ llmConfidence?: number;
10
+ embeddingConfidence?: number;
11
+ }): {
12
+ classifyIntentWithLlm: ({ message, conversationContext, activeFlowIntent, }: {
13
+ message: string;
14
+ conversationContext: string;
15
+ activeFlowIntent?: string | null;
16
+ }) => Promise<LlmIntentResult>;
17
+ mergeIntentResults: (embeddingTop: DetectedIntentResult, llmResult: LlmIntentResult) => DetectedIntentResult & {
18
+ action: LlmIntentResult["action"];
19
+ };
20
+ };
21
+ //# sourceMappingURL=intent-router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"intent-router.d.ts","sourceRoot":"","sources":["../../src/services/intent-router.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6CAA6C,CAAC;AACjF,OAAO,KAAK,EACV,oBAAoB,EACpB,gBAAgB,EAChB,eAAe,EAChB,MAAM,aAAa,CAAC;AAuBrB,wBAAgB,kBAAkB,CAAC,OAAO,EAAE;IAC1C,KAAK,EAAE,aAAa,CAAC;IACrB,OAAO,EAAE,gBAAgB,EAAE,CAAC;IAC5B,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5C,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,OAAO,EAAE,gBAAgB,EAAE,KAAK,MAAM,CAAC,CAAC;IACxE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;iFA2BI;QACD,OAAO,EAAE,MAAM,CAAC;QAChB,mBAAmB,EAAE,MAAM,CAAC;QAC5B,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KAClC,KAAG,OAAO,CAAC,eAAe,CAAC;uCAsCZ,oBAAoB,aACvB,eAAe,KACzB,oBAAoB,GAAG;QAAE,MAAM,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAA;KAAE;EA4BhE"}
@@ -0,0 +1,98 @@
1
+ import { HumanMessage, SystemMessage } from "@langchain/core/messages";
2
+ import { z } from "zod";
3
+ function buildDefaultRouterPrompt(intents) {
4
+ const lines = intents.map((intent) => {
5
+ const desc = intent.description ?? intent.examples.slice(0, 3).join(", ");
6
+ return `- ${intent.name} — ${desc}`;
7
+ });
8
+ return `You classify user intent for an assistant.
9
+
10
+ Available intents:
11
+ ${lines.join("\n")}
12
+
13
+ Return JSON only with: intent, confidence (0-1), action, optional short reason.
14
+
15
+ action rules:
16
+ - continue — user is answering the current flow, confirming a step, or their message fits the active flow
17
+ - switch — user wants a different service
18
+ - cancel — user explicitly stops or abandons the current flow
19
+
20
+ Wizard replies (digits only, yes/no/ok/skip) during an active sticky flow → action: continue, intent: that sticky flow.`;
21
+ }
22
+ export function createIntentRouter(options) {
23
+ const intentNames = options.intents.map((i) => i.name);
24
+ if (intentNames.length === 0) {
25
+ throw new Error("intents must contain at least one intent definition.");
26
+ }
27
+ // zod enum needs a non-empty tuple
28
+ const IntentRouterSchema = z.object({
29
+ intent: z.enum(intentNames),
30
+ confidence: z.number().min(0).max(1),
31
+ action: z.enum(["continue", "switch", "cancel"]),
32
+ reason: z.string().max(200).optional(),
33
+ });
34
+ const systemPrompt = typeof options.routerSystemPrompt === "function"
35
+ ? options.routerSystemPrompt(options.intents)
36
+ : (options.routerSystemPrompt ??
37
+ buildDefaultRouterPrompt(options.intents));
38
+ const llmConfidence = options.llmConfidence ?? 0.6;
39
+ const embeddingConfidence = options.embeddingConfidence ?? 0.65;
40
+ const classifyIntentWithLlm = async ({ message, conversationContext, activeFlowIntent, }) => {
41
+ const userPrompt = [
42
+ `Active flow: ${activeFlowIntent ?? "none"}`,
43
+ "",
44
+ "Recent conversation:",
45
+ conversationContext || "(no prior messages)",
46
+ "",
47
+ `Latest user message: "${message}"`,
48
+ "",
49
+ "Classify intent and action. Reply with JSON only.",
50
+ ].join("\n");
51
+ try {
52
+ const structured = options.model.withStructuredOutput(IntentRouterSchema, {
53
+ name: "intent_router",
54
+ });
55
+ const result = await structured.invoke([
56
+ new SystemMessage(systemPrompt),
57
+ new HumanMessage(userPrompt),
58
+ ]);
59
+ return {
60
+ intent: options.normalizeIntent(result.intent),
61
+ score: result.confidence,
62
+ action: result.action,
63
+ reason: result.reason,
64
+ };
65
+ }
66
+ catch {
67
+ return {
68
+ intent: options.defaultIntent,
69
+ score: 0.5,
70
+ action: activeFlowIntent ? "continue" : "switch",
71
+ };
72
+ }
73
+ };
74
+ const mergeIntentResults = (embeddingTop, llmResult) => {
75
+ if (llmResult.score >= llmConfidence) {
76
+ return {
77
+ intent: llmResult.intent,
78
+ score: llmResult.score,
79
+ action: llmResult.action,
80
+ };
81
+ }
82
+ if (embeddingTop.score >= embeddingConfidence) {
83
+ return {
84
+ intent: embeddingTop.intent,
85
+ score: embeddingTop.score,
86
+ action: llmResult.action === "cancel" || llmResult.action === "switch"
87
+ ? llmResult.action
88
+ : "continue",
89
+ };
90
+ }
91
+ return {
92
+ intent: llmResult.intent,
93
+ score: llmResult.score,
94
+ action: llmResult.action,
95
+ };
96
+ };
97
+ return { classifyIntentWithLlm, mergeIntentResults };
98
+ }