llm-agent-runtime 0.1.6 → 0.1.17

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