llm-agent-runtime 0.1.6 → 0.1.16

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 (49) hide show
  1. package/README.md +8 -2
  2. package/USAGE.md +522 -283
  3. package/dist/agent.d.ts +2 -1
  4. package/dist/agent.d.ts.map +1 -1
  5. package/dist/agent.js +36 -4
  6. package/dist/create-runtime.d.ts +14 -4
  7. package/dist/create-runtime.d.ts.map +1 -1
  8. package/dist/create-runtime.js +108 -20
  9. package/dist/embeddings/xenova.d.ts +19 -0
  10. package/dist/embeddings/xenova.d.ts.map +1 -0
  11. package/dist/embeddings/xenova.js +69 -0
  12. package/dist/env-llm.d.ts +14 -4
  13. package/dist/env-llm.d.ts.map +1 -1
  14. package/dist/env-llm.js +50 -16
  15. package/dist/errors.d.ts +10 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +17 -0
  18. package/dist/index.d.ts +11 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +7 -1
  21. package/dist/intents/define.d.ts +35 -0
  22. package/dist/intents/define.d.ts.map +1 -0
  23. package/dist/intents/define.js +52 -0
  24. package/dist/intents/general.d.ts +37 -0
  25. package/dist/intents/general.d.ts.map +1 -0
  26. package/dist/intents/general.js +67 -0
  27. package/dist/llm.d.ts +2 -2
  28. package/dist/llm.d.ts.map +1 -1
  29. package/dist/llm.js +45 -15
  30. package/dist/rag/index.d.ts +67 -0
  31. package/dist/rag/index.d.ts.map +1 -0
  32. package/dist/rag/index.js +138 -0
  33. package/dist/rag/loaders.d.ts +11 -0
  34. package/dist/rag/loaders.d.ts.map +1 -0
  35. package/dist/rag/loaders.js +80 -0
  36. package/dist/rag/sources.d.ts +29 -0
  37. package/dist/rag/sources.d.ts.map +1 -0
  38. package/dist/rag/sources.js +101 -0
  39. package/dist/services/intent-embeddings.d.ts.map +1 -1
  40. package/dist/services/intent-embeddings.js +5 -6
  41. package/dist/services/token-usage.d.ts +26 -0
  42. package/dist/services/token-usage.d.ts.map +1 -0
  43. package/dist/services/token-usage.js +63 -0
  44. package/dist/services/usage-store.d.ts +24 -0
  45. package/dist/services/usage-store.d.ts.map +1 -0
  46. package/dist/services/usage-store.js +92 -0
  47. package/dist/types.d.ts +47 -15
  48. package/dist/types.d.ts.map +1 -1
  49. package/package.json +10 -3
package/USAGE.md CHANGED
@@ -4,28 +4,405 @@ Reusable LangGraph agent engine. Your app supplies **intents, prompts, and tools
4
4
 
5
5
  `resolveFlow → runAgent → extract tool outputs → finalize`
6
6
 
7
- Same shape as Xel-agent, but flexible for any product.
7
+ RAG stays in **your app**: index docs, retrieve chunks, inject via `context` / `buildSystemPrompt`.
8
+
9
+ **Where to add RAG docs:** put paths/URLs in `.env.local` as `RAG_SOURCES` (see [Example B](#example-b--rag-chatbot-matches-plain-agent-demo)). Prefer `.md` / `.docx` (some PDFs fail with `bad XRef entry`).
10
+
11
+ ```bash
12
+ RAG_SOURCES=doc/faq.md,doc/handbook.docx,https://cdn.example.com/policy.docx
13
+ ```
14
+
15
+ Index **once** with `getRagStore()`; each message only runs `retrieve()` (query embed + top-K). Example B matches `plain-agent-demo`.
8
16
 
9
17
  ---
10
18
 
11
19
  ## Install
12
20
 
13
21
  ```bash
14
- npm install llm-agent-runtime
15
- # or
16
- pnpm add llm-agent-runtime
22
+ pnpm add llm-agent-runtime @langchain/core @langchain/langgraph @langchain/langgraph-checkpoint zod
23
+ ```
24
+
25
+ **Optional (recommended for production without Ollama):**
26
+
27
+ ```bash
28
+ pnpm add @xenova/transformers # free in-process embeddings
29
+ pnpm add langsmith # tracing
17
30
  ```
18
31
 
19
- **Peer dependencies** (usually already in a LangChain app):
32
+ If `@xenova/transformers` fails on `sharp` (pnpm ignored build scripts):
20
33
 
21
- - `@langchain/core`
22
- - `@langchain/langgraph`
23
- - `@langchain/langgraph-checkpoint`
24
- - `zod`
34
+ ```bash
35
+ cd node_modules/sharp && npm run install
36
+ ```
25
37
 
26
38
  ---
27
39
 
28
- ## Quick start
40
+ ## Configure a project (`.env`)
41
+
42
+ Minimal **chatbot** on OpenRouter (free embeddings via Xenova):
43
+
44
+ ```bash
45
+ # Chat
46
+ LLM_PROVIDER=openrouter
47
+ OPENROUTER_API_KEY=sk-or-v1-...
48
+ OPENROUTER_MODEL=meta-llama/llama-3.1-70b-instruct
49
+ OPENROUTER_INTENT_ROUTER_MODEL=meta-llama/llama-3.1-70b-instruct
50
+
51
+ # Embeddings — omit EMBEDDINGS_PROVIDER → xenova when chat is openrouter
52
+ EMBEDDINGS_PROVIDER=xenova
53
+ # XENOVA_EMBEDDINGS_MODEL=Xenova/all-MiniLM-L6-v2
54
+
55
+ # Optional Redis (flow state / cache). Plain Redis works for cache;
56
+ # Redis Stack (RedisJSON) needed for durable LangGraph checkpoints.
57
+ REDIS_URL=redis://localhost:6379
58
+
59
+ # Optional LangSmith
60
+ LANGSMITH_TRACING=true
61
+ LANGSMITH_API_KEY=lsv2_pt_...
62
+ LANGSMITH_PROJECT=my-agent
63
+ ```
64
+
65
+ **Embeddings options**
66
+
67
+ | `EMBEDDINGS_PROVIDER` | Cost | When to use |
68
+ | --- | --- | --- |
69
+ | `xenova` | Free | Default for OpenRouter/OpenAI chat when unset |
70
+ | `ollama` | Free (self-host) | Default when `LLM_PROVIDER=ollama` |
71
+ | `openai` | Paid | Strongest cloud embeddings |
72
+
73
+ ---
74
+
75
+ ## Suggested app layout
76
+
77
+ ```
78
+ your-app/
79
+ .env / .env.local
80
+ agent/
81
+ runtime.ts # createAgentRuntime({ ... })
82
+ intents.ts # intents + menuMap + aliases
83
+ prompts/ # system / intent prompts
84
+ tools/ # LangChain tools (APIs, DB, …)
85
+ rag/ # optional — or skip and use getRagStore() from the package
86
+ controllers/ # HTTP / WhatsApp → retrieve then invoke
87
+ ```
88
+
89
+ Put document paths/URLs in `.env` as `RAG_SOURCES=...` (see Example B). No custom `rag/store.ts` required — import `getRagStore` from `llm-agent-runtime`.
90
+
91
+ ---
92
+
93
+ ## Example A — Normal chatbot (no RAG)
94
+
95
+ Intent routing + tools + sticky flows. No vector store.
96
+
97
+ ```ts
98
+ // agent/runtime.ts
99
+ import {
100
+ createAgentRuntime,
101
+ llmConfigFromEnv,
102
+ } from "llm-agent-runtime";
103
+ import { tool } from "@langchain/core/tools";
104
+ import { z } from "zod";
105
+
106
+ const greet = tool(async () => "Welcome! Reply 1 to recharge or 2 to login.", {
107
+ name: "show_menu",
108
+ description: "Show the main menu",
109
+ schema: z.object({}),
110
+ });
111
+
112
+ const recharge = tool(
113
+ async ({ amount, phone }) => ({ ok: true, amount, phone }),
114
+ {
115
+ name: "buy_airtime",
116
+ description: "Buy airtime for a phone number",
117
+ schema: z.object({
118
+ amount: z.number(),
119
+ phone: z.string(),
120
+ }),
121
+ },
122
+ );
123
+
124
+ export async function getRuntime() {
125
+ const llm = llmConfigFromEnv(); // reads LLM_PROVIDER, EMBEDDINGS_PROVIDER, …
126
+
127
+ const runtime = await createAgentRuntime({
128
+ redisUrl: process.env.REDIS_URL,
129
+ llm,
130
+ tracing: {
131
+ enabled: Boolean(process.env.LANGSMITH_API_KEY),
132
+ projectName: process.env.LANGSMITH_PROJECT ?? "my-agent",
133
+ },
134
+
135
+ intents: [
136
+ {
137
+ name: "greetings",
138
+ examples: ["hi", "hello", "menu", "help"],
139
+ description: "greetings or main menu",
140
+ },
141
+ {
142
+ name: "recharge",
143
+ examples: ["buy airtime", "recharge my phone", "buy data"],
144
+ sticky: true,
145
+ description: "buy airtime or data",
146
+ regex: /(buy|recharge).*(airtime|data)/i,
147
+ },
148
+ ],
149
+
150
+ menuMap: { "1": "recharge" },
151
+
152
+ intentRegistry: {
153
+ greetings: {
154
+ prompt: () =>
155
+ "You are a helpful recharge assistant. Greet the user and show menu options.",
156
+ tools: () => [greet],
157
+ },
158
+ recharge: {
159
+ prompt: () =>
160
+ "Collect amount and phone, then call buy_airtime when ready.",
161
+ tools: () => [recharge],
162
+ },
163
+ },
164
+
165
+ toolOutputMap: { buy_airtime: "transaction" },
166
+ completeOn: ["buy_airtime"],
167
+ });
168
+
169
+ await runtime.connect();
170
+ return runtime;
171
+ }
172
+ ```
173
+
174
+ ```ts
175
+ // controllers/chat.ts
176
+ const runtime = await getRuntime();
177
+
178
+ const result = await runtime.invoke({
179
+ message: req.body.message,
180
+ threadId: req.body.phoneNumber, // conversation id
181
+ phoneKey: req.body.phoneNumber,
182
+ context: { userInfo: req.body.user ?? {} },
183
+ });
184
+
185
+ res.json({
186
+ message: result.answer,
187
+ intent: result.intent,
188
+ flow: result.flow,
189
+ toolOutputs: result.toolOutputs,
190
+ });
191
+ ```
192
+
193
+ ---
194
+
195
+ ## Example B — RAG chatbot (matches `plain-agent-demo`)
196
+
197
+ Point the app at **local files and/or http(s) URLs**, **index once**, then **retrieve per message**.
198
+ Document chunking + embeddings are **not** rebuilt on every chat — only the user query is embedded each time.
199
+
200
+ ### Layout (what each file does)
201
+
202
+ ```
203
+ your-app/
204
+ .env.local # RAG_SOURCES + LLM + EMBEDDINGS_PROVIDER
205
+ index.ts # load .env.local FIRST, mount POST /chat-bot/rag-chatbot
206
+ src/
207
+ routers/index.ts # route → ragChatBotController
208
+ controllers/index.ts # retrieve snippets → invoke RAG runtime
209
+ rag/
210
+ store.ts # optional warm-up (getRagStore lives in the package)
211
+ runtime.ts # createAgentRuntime + buildSystemPrompt (RAG Q&A)
212
+ ```
213
+
214
+ | Piece | What it does |
215
+ |-------|----------------|
216
+ | `RAG_SOURCES` | Comma-separated paths/URLs to index |
217
+ | `getRagStore()` | Singleton: load → chunk → embed docs **once** per process |
218
+ | `store.retrieve(msg, 4)` | Embed the **query only**, return top-K text snippets |
219
+ | `buildSystemPrompt` | Puts snippets into the system prompt as `Knowledge:` |
220
+ | `getRagRuntime()` | Separate agent (no tools); answers from Knowledge only |
221
+ | `explicitIntent: "qa"` | Skip intent-router LLM (faster, deterministic) |
222
+
223
+ ### 1) Env — documents + embeddings
224
+
225
+ ```bash
226
+ # .env.local — `import "dotenv/config"` alone only loads `.env`, not `.env.local`
227
+ EMBEDDINGS_PROVIDER=xenova
228
+
229
+ # Prefer .md / .docx (some PDFs fail pdf.js with "bad XRef entry")
230
+ RAG_SOURCES=doc/faq.md,doc/handbook.docx,https://cdn.example.com/policy.docx
231
+
232
+ # Multiple files = comma-separated
233
+ # RAG_SOURCES=/abs/a.docx,/abs/b.md
234
+ ```
235
+
236
+ ```bash
237
+ pnpm add pdf-parse mammoth @xenova/transformers
238
+ ```
239
+
240
+ ```ts
241
+ // index.ts — load env BEFORE getRagStore / createAgentRuntime
242
+ import dotenv from "dotenv";
243
+ dotenv.config({ path: ".env.local" });
244
+ dotenv.config(); // optional `.env` fallback (won't override keys already set)
245
+ ```
246
+
247
+ ### 2) Optional warm-up (`src/rag/store.ts`)
248
+
249
+ ```ts
250
+ import { getRagStore, getRagDocumentCount } from "llm-agent-runtime";
251
+
252
+ /** Optional: index at startup so the first HTTP request is faster. */
253
+ export async function warmRagStore() {
254
+ const store = await getRagStore(); // reads RAG_SOURCES; chunks + embeds once
255
+ console.log(`[rag] ready — ${getRagDocumentCount()} document(s) indexed`);
256
+ return store;
257
+ }
258
+
259
+ export { getRagStore, getRagDocumentCount };
260
+ ```
261
+
262
+ - **`getRagStore()`** — lazy singleton. First await indexes; later awaits reuse memory.
263
+ - **`resetRagStore()`** — clear singleton (tests / re-index after changing sources).
264
+ - Overrides: `getRagStore({ embeddings, env, chunkSize, overlap, log: false })`.
265
+ - Unreadable sources are **skipped with a warning**; if none load, it throws (next call can retry).
266
+
267
+ ### 3) RAG agent runtime (`src/rag/runtime.ts`)
268
+
269
+ ```ts
270
+ import {
271
+ createAgentRuntime,
272
+ llmConfigFromEnv,
273
+ withGeneralIntents, // or defineIntents([...]) without the general catch-all
274
+ type AgentRuntime,
275
+ } from "llm-agent-runtime";
276
+
277
+ export type RagContext = {
278
+ ragContext?: string;
279
+ userInfo?: Record<string, unknown>;
280
+ };
281
+
282
+ export const buildSystemPrompt = (ctx: { context?: RagContext }) => {
283
+ const rag = String(ctx.context?.ragContext ?? "").trim();
284
+ const base =
285
+ "Answer using ONLY the knowledge snippets below. If missing, say you don't know.";
286
+ return rag ? `${base}\n\nKnowledge:\n${rag}` : base;
287
+ };
288
+
289
+ let ragPromise: Promise<AgentRuntime<RagContext>> | null = null;
290
+
291
+ export async function getRagRuntime(): Promise<AgentRuntime<RagContext>> {
292
+ if (!ragPromise) {
293
+ ragPromise = (async () => {
294
+ // Unified intent: name/examples/description + prompt/tools in ONE object
295
+ const { intents, intentRegistry } = withGeneralIntents([
296
+ {
297
+ name: "qa",
298
+ examples: ["what is", "how do I", "explain", "tell me about", "question"],
299
+ description: "answer questions from indexed documents",
300
+ prompt: () =>
301
+ "Answer the user question from the knowledge provided. Be concise.",
302
+ tools: () => [],
303
+ },
304
+ ]);
305
+
306
+ const runtime = await createAgentRuntime<RagContext>({
307
+ // omit redisUrl for RAG Q&A
308
+ llm: llmConfigFromEnv(),
309
+ intents,
310
+ intentRegistry,
311
+ defaultIntent: "qa",
312
+ buildSystemPrompt,
313
+ recursionLimit: 4,
314
+ });
315
+
316
+ await runtime.connect();
317
+ return runtime;
318
+ })();
319
+ }
320
+ return ragPromise;
321
+ }
322
+ ```
323
+
324
+ `withGeneralIntents` / `defineIntents` split that one object into the two lists the runtime still uses internally (`intents` for routing, `intentRegistry` for prompt/tools).
325
+
326
+ ### 4) Controller — retrieve then invoke
327
+
328
+ ```ts
329
+ import { getRagStore } from "llm-agent-runtime";
330
+ import { getRagRuntime } from "../rag/runtime.js";
331
+
332
+ export const ragChatBotController = async (req, res) => {
333
+ const { message, phoneNumber } = req.body;
334
+ if (!message || !phoneNumber) {
335
+ return res.status(400).json({ error: "message and phoneNumber are required" });
336
+ }
337
+
338
+ try {
339
+ const threadId = String(req.body.sessionId ?? phoneNumber);
340
+
341
+ // Singleton store (indexes once). retrieve() embeds ONLY the user message.
342
+ const store = await getRagStore();
343
+ const ragContext = await store.retrieve(String(message), 4);
344
+
345
+ const runtime = await getRagRuntime();
346
+ const result = await runtime.invoke({
347
+ message: String(message),
348
+ threadId,
349
+ phoneKey: threadId,
350
+ explicitIntent: "qa", // skip intent-router LLM
351
+ context: {
352
+ ragContext, // consumed by buildSystemPrompt
353
+ userInfo: req.body.user ?? {},
354
+ },
355
+ });
356
+
357
+ res.json({
358
+ message: result.answer,
359
+ intent: result.intent,
360
+ ragChars: ragContext.length,
361
+ });
362
+ } catch (err) {
363
+ console.error("[rag] chat failed:", err);
364
+ res.status(500).json({
365
+ error: err instanceof Error ? err.message : "RAG chat failed",
366
+ });
367
+ }
368
+ };
369
+ ```
370
+
371
+ ```ts
372
+ chatBotRouter.post("/rag-chatbot", ragChatBotController);
373
+ // POST /chat-bot/rag-chatbot { "message": "...", "phoneNumber": "..." }
374
+ ```
375
+
376
+ ### Flow (once vs per message)
377
+
378
+ ```
379
+ Startup / first getRagStore():
380
+ RAG_SOURCES=doc/a.md,doc/b.docx
381
+ → read/download files
382
+ → extract text (.md utf8, .docx mammoth, .pdf pdf-parse)
383
+ → chunk (default 800 / overlap 120) + embed each chunk
384
+ → keep in memory (singleton)
385
+
386
+ Each POST /chat-bot/rag-chatbot:
387
+ store.retrieve(userMessage, 4) // embed query only + cosine top-K
388
+ → invoke({ explicitIntent: "qa", context: { ragContext } })
389
+ → buildSystemPrompt injects Knowledge
390
+ → LLM answers from snippets only
391
+ ```
392
+
393
+ ### Tips / pitfalls
394
+
395
+ | Issue | Fix |
396
+ |-------|-----|
397
+ | `No RAG sources configured` | Set `RAG_SOURCES` in `.env.local` and load it |
398
+ | Request hangs forever | Omit `redisUrl` on the RAG runtime |
399
+ | `bad XRef entry` on PDF | Prefer `.docx` / `.md`; bad PDFs are skipped with a warning |
400
+ | First chat slow | Call `warmRagStore()` at startup |
401
+ | Re-index after changing files | `resetRagStore()` then `getRagStore()` (or restart) |
402
+
403
+ ---
404
+
405
+ ## Quick start (shortest)
29
406
 
30
407
  ```ts
31
408
  import { createAgentRuntime } from "llm-agent-runtime";
@@ -39,37 +416,20 @@ const greet = tool(async () => "ok", {
39
416
  });
40
417
 
41
418
  const runtime = await createAgentRuntime({
42
- redisUrl: process.env.REDIS_URL, // omit → in-memory checkpointer
43
- // llm omitted → reads process.env (see Env vars below)
44
-
419
+ redisUrl: process.env.REDIS_URL,
45
420
  intents: [
46
421
  {
47
422
  name: "greetings",
48
- examples: ["hi", "hello", "good morning"],
49
- description: "hello or small talk",
50
- },
51
- {
52
- name: "create_campaign",
53
- examples: ["create a campaign", "start fundraising"],
54
- sticky: true,
55
- description: "new campaign wizard",
423
+ examples: ["hi", "hello"],
424
+ description: "hello",
56
425
  },
57
426
  ],
58
-
59
427
  intentRegistry: {
60
428
  greetings: {
61
429
  prompt: (ctx) =>
62
- `Greet the user. Name: ${ctx.context?.contactName ?? "friend"}.`,
430
+ `Greet ${ctx.context?.contactName ?? "friend"}.`,
63
431
  tools: () => [greet],
64
432
  },
65
- create_campaign: {
66
- prompt: () => "Help create a campaign step by step. Call create_campaign when ready.",
67
- tools: (phoneKey) => [/* your tools scoped by phoneKey */],
68
- },
69
- },
70
-
71
- toolOutputMap: {
72
- create_campaign: "campaign",
73
433
  },
74
434
  });
75
435
 
@@ -81,7 +441,7 @@ const result = await runtime.invoke({
81
441
  context: { contactName: "Ada" },
82
442
  });
83
443
 
84
- console.log(result.answer, result.intent, result.flow, result.toolOutputs);
444
+ console.log(result.answer, result.intent);
85
445
  ```
86
446
 
87
447
  ---
@@ -91,178 +451,121 @@ console.log(result.answer, result.intent, result.flow, result.toolOutputs);
91
451
  | You configure | Library handles |
92
452
  | --- | --- |
93
453
  | `intents` (examples + `sticky` + `regex`) | Hybrid regex + LLM + embedding router |
94
- | `intentRegistry` (prompt + tools) | LangGraph agent + Redis/memory checkpointer |
95
- | `toolOutputMap` | Parse tool calls into structured outputs |
96
- | `completeOn` | Sticky-flow completion + Redis flow state |
97
- | `menuMap` (optional) | Digit intent after a numbered menu |
454
+ | `intentRegistry` (prompt + tools) | LangGraph agent + checkpointer |
455
+ | `toolOutputMap` / `completeOn` | Tool output parse + sticky-flow end |
456
+ | `menuMap` | Digit intent |
457
+ | RAG retrieve + inject via `context` | App calls `getRagStore()` / `store.retrieve` |
98
458
  | Prompts, API tools, webhooks | — stay in your app |
99
459
 
100
460
  ---
101
461
 
102
462
  ## Config reference
103
463
 
104
- ### `createAgentRuntime(config)`
105
-
106
- #### Required
464
+ ### Required
107
465
 
108
466
  ```ts
109
467
  intents: IntentDefinition[]
110
468
  intentRegistry: Record<string, { prompt: (ctx) => string; tools: (phoneKey) => Tool[] }>
111
469
  ```
112
470
 
113
- #### Common optional
471
+ ### Common optional
114
472
 
115
473
  | Option | Default | Purpose |
116
474
  | --- | --- | --- |
117
- | `redisUrl` | none (MemorySaver) | Checkpointer + flow state + optional answer cache |
118
- | `llm` | `llmConfigFromEnv()` | Provider / models |
475
+ | `redisUrl` | MemorySaver | Checkpointer + flow state + optional answer cache |
476
+ | `llm` | `llmConfigFromEnv()` | Provider / models / embeddings |
119
477
  | `cache.enabled` | `false` | Exact-match answer cache (needs Redis) |
120
478
  | `toolOutputMap` | raw tool args | Map tool name → output key |
121
- | `completeOn` | keys of `toolOutputMap` | Tool names that finish a sticky flow |
122
- | `menuMap` | `{}` | `{ "1": "create_campaign" }` for digit menus |
123
- | `intentAliases` | `{}` | Legacy name catalog name |
124
- | `defaultIntent` | first intent | Fallback intent |
125
- | `buildSystemPrompt` | registry `prompt` | One builder for all intents |
126
- | `resolvePhoneKey` | `phoneKey ?? threadId` | Key passed to `tools(phoneKey)` |
127
- | `tracing` | auto-detect | LangSmith tracing config |
479
+ | `completeOn` | keys of `toolOutputMap` | Tools that finish a sticky flow |
480
+ | `menuMap` | `{}` | `{ "1": "recharge" }` |
481
+ | `buildSystemPrompt` | registry `prompt` (+ `sharedPrompt`) | One builder (use this for RAG) |
482
+ | `sharedPrompt` | none | Prepended to **every** intent prompt (emojis, tone, …) |
483
+ | `tracing` | auto if `LANGSMITH_API_KEY` | LangSmith |
128
484
 
129
485
  ---
130
486
 
131
487
  ## Intents
132
488
 
133
- ```ts
134
- intents: [
135
- {
136
- name: "greetings",
137
- examples: ["hi", "hello"],
138
- description: "Shown to the LLM router",
139
- },
140
- {
141
- name: "create_campaign",
142
- examples: ["create a campaign"],
143
- sticky: true, // keep this flow across turns until completeOn fires
144
- regex: /create.*(campaign|fundrais)/i, // fast-path: skip LLM if matched
145
- },
146
- ]
147
- ```
148
-
149
- - **examples** — embedding routing
150
- - **sticky** — multi-step wizards
151
- - **description** — LLM intent router
152
- - **regex** — fast-path: if message matches, skip LLM/embeddings entirely (zero tokens)
153
-
154
- ### Intent detection flow
155
-
156
- ```
157
- 1. explicitIntent passed? → Use it directly (score 1.0)
158
- 2. Wizard input + sticky? → Reuse cached intent (skip LLM)
159
- 3. Menu digit match? → Use mapped intent (score 1.0)
160
- 4. Regex matches? → Use matched intent (score 1.0, 0 tokens)
161
- 5. Embeddings + LLM (hybrid) → Existing flow
162
- ```
163
-
164
- The regex fast-path is a token-saving optimization. When a user message matches an intent's regex pattern, the library returns that intent immediately without calling the LLM or embeddings. Define regexes for high-confidence, pattern-based intents (e.g. "generate an image of...") and leave regex undefined for ambiguous intents that need LLM classification.
165
-
166
- ---
167
-
168
- ## Prompts & tools (`intentRegistry`)
169
-
170
- ```ts
171
- intentRegistry: {
172
- greetings: {
173
- prompt: (ctx) => string,
174
- tools: (phoneKey) => DynamicStructuredTool[],
175
- },
176
- }
177
- ```
178
-
179
- `prompt` receives:
180
-
181
489
  ```ts
182
490
  {
183
- hasConversationHistory: boolean
184
- intent: string
185
- intentScore?: number
186
- message?: string
187
- context?: Record<string, unknown> // whatever you passed to invoke()
491
+ name: "recharge",
492
+ examples: ["buy airtime"], // embedding routing
493
+ sticky: true, // multi-step wizard
494
+ description: "buy airtime", // LLM router
495
+ regex: /buy.*airtime/i, // fast-path, 0 tokens
188
496
  }
189
497
  ```
190
498
 
191
- Or use one builder for everything:
499
+ Detection order: `explicitIntent` sticky wizard skip → `menuMap` → `regex` → embeddings + LLM.
192
500
 
193
- ```ts
194
- buildSystemPrompt: (ctx) => combineGeneralAndIntent(ctx)
195
- ```
501
+ **Fallback:** `defaultIntent` (optional) — defaults to the first intent in your list, else `"greetings"`.
196
502
 
197
- ---
503
+ ### Optional general intent (Xel-style catch-all)
198
504
 
199
- ## Tool outputs (`toolOutputMap`)
200
-
201
- Replaces hand-written extractors:
505
+ Not required. If you want a first catch-all (hello / help / unclear → general chat):
202
506
 
203
507
  ```ts
204
- toolOutputMap: {
205
- create_campaign: "campaign",
206
- buy_airtime: "buyAirtime",
207
- // Nested field on args + seed:
208
- propose_tickets: { as: "tickets", from: "tickets", initial: [] },
209
- }
210
- ```
211
-
212
- Result lands on `result.toolOutputs` and completed keys on `result.extras`.
213
-
214
- ---
215
-
216
- ## Flow completion (`completeOn`)
508
+ import {
509
+ createAgentRuntime,
510
+ withGeneralIntent,
511
+ } from "llm-agent-runtime";
217
512
 
218
- Pass tool **names**. When any of them produces output, the sticky flow ends:
513
+ const { intents, intentRegistry } = withGeneralIntent(
514
+ [{ name: "recharge", examples: ["buy airtime"], sticky: true }],
515
+ {
516
+ recharge: {
517
+ prompt: () => "Collect amount and phone…",
518
+ tools: () => [buyAirtime],
519
+ },
520
+ },
521
+ // Optional: match Xel naming
522
+ // { intent: { name: "greetings" } },
523
+ );
219
524
 
220
- ```ts
221
- completeOn: ["create_campaign", "buy_airtime", "add_airtime"]
525
+ const runtime = await createAgentRuntime({
526
+ intents,
527
+ intentRegistry,
528
+ defaultIntent: intents[0]!.name, // "general" (or "greetings")
529
+ });
222
530
  ```
223
531
 
224
- If you omit `completeOn`, the library defaults `completeOn` to the keys of `toolOutputMap`.
225
-
226
- This is tuned to Xel-style wizards: when a sticky flow is already active and the user message looks like wizard input (digits / yes-no), the router LLM call is skipped for that turn to reduce tokens.
227
-
228
- ---
532
+ Helpers: `createGeneralIntent`, `createGeneralIntentBinding`, `GENERAL_INTENT_NAME`.
533
+ If you already define `greetings` / `general` yourself, skip these — `withGeneralIntent` is a no-op when that name exists.
229
534
 
230
- ## Menu digits (`menuMap`)
535
+ ### Shared prompt (rules for every intent)
231
536
 
232
- Optional. Needed only if users pick a numbered menu (`1`, `2`, …).
537
+ `withGeneralIntent` is only a **catch-all routing intent**. It does **not** inject style rules into recharge / other flows.
233
538
 
234
- The **prompt** shows the menu text. **`menuMap`** tells the **router** that `"1"` means `create_campaign`. Without it, a bare `"1"` is not reliably routed.
539
+ For instructions that should apply to **all** intents (emojis, tone, language), use optional `sharedPrompt` the package prepends it to whichever intent prompt is active:
235
540
 
236
541
  ```ts
237
- menuMap: {
238
- "1": "create_campaign",
239
- "2": "support_campaign",
240
- "3": "buy_airtime_and_data",
241
- }
542
+ const runtime = await createAgentRuntime({
543
+ intents,
544
+ intentRegistry,
545
+ sharedPrompt:
546
+ "Always use friendly emojis. Keep replies concise and warm.",
547
+ // or: sharedPrompt: (ctx) => `User: ${ctx.context?.userInfo?.name ?? "friend"}`,
548
+ });
242
549
  ```
243
550
 
551
+ You do **not** need to repeat those rules in every `intentRegistry.*.prompt`.
552
+
244
553
  ---
245
554
 
246
- ## Invoke
555
+ ## Invoke & result
247
556
 
248
557
  ```ts
249
- const result = await runtime.invoke({
558
+ await runtime.invoke({
250
559
  message: "hi",
251
- threadId: "whatsapp:234...", // conversation / checkpoint id
252
- phoneKey: "234...", // optional; passed to tools()
253
- explicitIntent: "greetings", // optional; skips hybrid router
254
- context: {
255
- contactName: "Ada",
256
- userInfo: { email: "ada@example.com" },
257
- },
560
+ threadId: "whatsapp:234...",
561
+ phoneKey: "234...",
562
+ explicitIntent: "greetings", // optional
563
+ context: { contactName: "Ada", ragContext: "..." },
258
564
  });
259
565
  ```
260
566
 
261
- **Result**
262
-
263
567
  ```ts
264
568
  {
265
- messages: unknown[]
266
569
  answer?: string
267
570
  intent: { intent: string, score: number }
268
571
  flow: { active: boolean, intent: string | null }
@@ -270,167 +573,85 @@ const result = await runtime.invoke({
270
573
  flowCompleted: boolean
271
574
  extras?: Record<string, unknown>
272
575
  router?: { action: "continue" | "switch" | "cancel", reason?: string }
576
+ usage?: {
577
+ turn: { inputTokens, outputTokens, totalTokens } // this invoke
578
+ flow: { …, intent } // sticky flow bucket
579
+ total: { inputTokens, outputTokens, totalTokens } // whole thread
580
+ }
273
581
  }
274
582
  ```
275
583
 
276
- ---
584
+ Token totals are stored in **Redis** when `redisUrl` is set (`usage:thread:{id}`, `usage:flow:{id}:{intent}`), otherwise in memory.
277
585
 
278
- ## Lifecycle
586
+ ### Clear session (frontend)
279
587
 
280
588
  ```ts
281
- await runtime.connect() // Redis (if redisUrl set)
282
- await runtime.invoke(...)
283
- await runtime.clearFlow(id) // drop sticky flow state
284
- await runtime.disconnect()
589
+ // Clears checkpoint history + sticky flow + token usage for this threadId
590
+ await runtime.clearSession(threadId);
591
+ // aliases: clearThread(threadId) | clearFlow(threadId) only sticky state
592
+ const total = await runtime.getUsage(threadId);
285
593
  ```
286
594
 
287
- ---
288
-
289
- ## LLM Providers
595
+ Example HTTP for the UI “New chat / Clear session” button:
290
596
 
291
- ### Supported providers
597
+ ```ts
598
+ // DELETE /session body: { sessionId }
599
+ await runtime.clearSession(req.body.sessionId);
600
+ res.json({ ok: true });
601
+ ```
292
602
 
293
- | Provider | `LLM_PROVIDER` | API Key Env Var | Default Model |
294
- | --- | --- | --- | --- |
295
- | Ollama | `ollama` (default) | — | `qwen2.5:7b` |
296
- | OpenAI | `openai` | `OPENAI_API_KEY` | `gpt-4o-mini` |
297
- | OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | `meta-llama/llama-4-scout` |
603
+ ```ts
604
+ await runtime.connect()
605
+ await runtime.clearFlow(threadId)
606
+ await runtime.clearSession(threadId)
607
+ await runtime.disconnect()
608
+ ```
298
609
 
299
- ### Env vars
610
+ ---
300
611
 
301
- | Variable | Notes |
302
- | --- | --- |
303
- | `LLM_PROVIDER` | `ollama` (default), `openai`, or `openrouter` |
304
- | `EMBEDDINGS_PROVIDER` | defaults to `LLM_PROVIDER` |
305
- | `OLLAMA_MODEL` | default `qwen2.5:7b` |
306
- | `OLLAMA_BASE_URL` | e.g. `http://localhost:11434` |
307
- | `OLLAMA_EMBEDDINGS_MODEL` | default `mxbai-embed-large:latest` |
308
- | `INTENT_ROUTER_MODEL` | optional smaller router model (ollama) |
309
- | `OPENAI_API_KEY` | required for openai |
310
- | `OPENAI_MODEL` | default `gpt-4o-mini` |
311
- | `OPENAI_EMBEDDINGS_MODEL` | default `text-embedding-3-small` |
312
- | `OPENROUTER_API_KEY` | required for openrouter |
313
- | `OPENROUTER_MODEL` | default `meta-llama/llama-4-scout` |
314
- | `OPENROUTER_BASE_URL` | default `https://openrouter.ai/api/v1` |
315
- | `OPENROUTER_INTENT_ROUTER_MODEL` | optional separate router model |
316
- | `REDIS_URL` | your app usually passes this as `redisUrl` |
317
- | `ANSWER_CACHE` | app convention: `"1"` to enable cache |
318
-
319
- ### OpenRouter example
612
+ ## LLM & embeddings
320
613
 
321
- ```bash
322
- LLM_PROVIDER=openrouter OPENROUTER_API_KEY=sk-or-... pnpm dev
323
- ```
614
+ | Chat `LLM_PROVIDER` | Key | Default model |
615
+ | --- | --- | --- |
616
+ | `ollama` | — | `qwen2.5:7b` |
617
+ | `openai` | `OPENAI_API_KEY` | `gpt-4o-mini` |
618
+ | `openrouter` | `OPENROUTER_API_KEY` | `meta-llama/llama-4-scout` |
324
619
 
325
- Or pass explicitly:
620
+ | `EMBEDDINGS_PROVIDER` | Default model |
621
+ | --- | --- |
622
+ | `xenova` (default for openrouter/openai when unset) | `Xenova/all-MiniLM-L6-v2` |
623
+ | `ollama` (default for ollama chat when unset) | `mxbai-embed-large:latest` |
624
+ | `openai` | `text-embedding-3-small` |
326
625
 
327
626
  ```ts
328
627
  createAgentRuntime({
329
628
  llm: {
330
629
  provider: "openrouter",
331
- model: "meta-llama/llama-4-scout",
332
- apiKey: "sk-or-...",
630
+ model: "meta-llama/llama-3.1-70b-instruct",
631
+ apiKey: process.env.OPENROUTER_API_KEY,
632
+ embeddingsProvider: "xenova",
633
+ embeddingsModel: "Xenova/all-MiniLM-L6-v2",
333
634
  },
334
635
  // ...
335
636
  });
336
637
  ```
337
638
 
338
- ### LangSmith tracing
339
-
340
- Tracing auto-enables when `LANGSMITH_API_KEY` is set in the environment. No code changes needed.
639
+ ### LangSmith
341
640
 
342
641
  ```bash
343
- LANGSMITH_API_KEY=ls_... LLM_PROVIDER=openrouter OPENROUTER_API_KEY=sk-or-... pnpm dev
344
- ```
345
-
346
- Or enable explicitly:
347
-
348
- ```ts
349
- createAgentRuntime({
350
- tracing: { enabled: true, projectName: "my-project" },
351
- // ...
352
- });
642
+ LANGSMITH_TRACING=true
643
+ LANGSMITH_API_KEY=lsv2_pt_...
644
+ LANGSMITH_PROJECT=my-agent
353
645
  ```
354
646
 
355
- Tracing sets `LANGCHAIN_TRACING_V2`, `LANGCHAIN_API_KEY`, and `LANGCHAIN_PROJECT` env vars before model creation. The `langsmith` package must be installed (`pnpm add langsmith`).
647
+ Or `tracing: { enabled: true, projectName: "my-agent" }`.
356
648
 
357
649
  ---
358
650
 
359
- ## Minimal app shape (recommended)
360
-
361
- ```
362
- your-app/
363
- agent/
364
- runtime.ts # createAgentRuntime({ ... })
365
- intents.ts # intents + menuMap + aliases
366
- prompts/ # prompt strings
367
- tools/ # LangChain tools / API clients
368
- controllers/ # HTTP / WhatsApp — call runtime.invoke()
369
- ```
370
-
371
- Keep channel adapters and product APIs **out** of this package.
372
-
373
- ---
374
-
375
- ## Full example (Xel-like)
376
-
377
- ```ts
378
- import { createAgentRuntime } from "llm-agent-runtime";
379
-
380
- const runtime = await createAgentRuntime({
381
- redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
382
- cache: { enabled: process.env.ANSWER_CACHE === "1" },
383
-
384
- intents: [
385
- { name: "greetings", examples: ["hi", "hello"] },
386
- {
387
- name: "create_campaign",
388
- examples: ["create a campaign"],
389
- sticky: true,
390
- regex: /create.*(campaign|fundrais)/i,
391
- },
392
- {
393
- name: "buy_airtime_and_data",
394
- examples: ["buy airtime", "vend airtime"],
395
- sticky: true,
396
- regex: /(buy|vend|purchase).*(airtime|data|credit)/i,
397
- },
398
- ],
399
-
400
- menuMap: {
401
- "1": "create_campaign",
402
- "4": "buy_airtime_and_data",
403
- },
404
-
405
- intentAliases: {
406
- buy_airtime: "buy_airtime_and_data",
407
- },
408
-
409
- intentRegistry: {
410
- greetings: {
411
- prompt: (ctx) => `Greet ${ctx.context?.contactName ?? "there"}. Show menu 1–7.`,
412
- tools: () => [],
413
- },
414
- create_campaign: {
415
- prompt: () => "Walk through campaign creation.",
416
- tools: (phoneKey) => createCampaignTools(phoneKey),
417
- },
418
- buy_airtime_and_data: {
419
- prompt: () => "Help buy airtime/data.",
420
- tools: (phoneKey) => airtimeTools(phoneKey),
421
- },
422
- },
651
+ ## Redis note
423
652
 
424
- toolOutputMap: {
425
- create_campaign: "campaign",
426
- buy_airtime: "buyAirtime",
427
- add_airtime: "addAirtime",
428
- },
429
- completeOn: ["create_campaign", "buy_airtime", "add_airtime"],
430
- });
431
-
432
- await runtime.connect();
433
- ```
653
+ - **Flow cache / answer cache / token usage:** plain Redis is fine.
654
+ - **Durable LangGraph checkpoints:** need **Redis Stack** (RedisJSON). Without it the package falls back to an in-memory checkpointer and logs a warning.
434
655
 
435
656
  ---
436
657
 
@@ -440,9 +661,27 @@ await runtime.connect();
440
661
  import {
441
662
  createAgentRuntime,
442
663
  llmConfigFromEnv,
664
+ resolveEmbeddingsProvider,
665
+ XenovaEmbeddings,
666
+ // Optional catch-all intent
667
+ withGeneralIntent,
668
+ withGeneralIntents,
669
+ defineIntents,
670
+ createGeneralIntent,
671
+ createGeneralIntentBinding,
672
+ // RAG (path + URL)
673
+ getRagStore,
674
+ getRagDocumentCount,
675
+ resetRagStore,
676
+ ragSourcesFromEnv,
677
+ createSimpleRagFromEnv,
678
+ createSimpleRagFromSources,
443
679
  getLastMessageContent,
444
- // advanced (usually not needed)
445
- createToolOutputExtractor,
446
- toolOutputMapToExtractors,
447
680
  } from "llm-agent-runtime";
448
681
  ```
682
+
683
+ ---
684
+
685
+ ## Version
686
+
687
+ Current local package: **0.1.16** (`defineIntents` / `withGeneralIntents` unified intent objects, `getRagStore` singleton, Example B aligned with `plain-agent-demo`, Xenova embeddings, token usage + `clearSession`).