llm-agent-runtime 0.1.5 → 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 (52) hide show
  1. package/README.md +8 -2
  2. package/USAGE.md +530 -234
  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 +112 -14
  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 -7
  13. package/dist/env-llm.d.ts.map +1 -1
  14. package/dist/env-llm.js +67 -13
  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 +60 -13
  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/flow-router.d.ts +1 -0
  40. package/dist/services/flow-router.d.ts.map +1 -1
  41. package/dist/services/flow-router.js +12 -0
  42. package/dist/services/intent-embeddings.d.ts.map +1 -1
  43. package/dist/services/intent-embeddings.js +5 -6
  44. package/dist/services/token-usage.d.ts +26 -0
  45. package/dist/services/token-usage.d.ts.map +1 -0
  46. package/dist/services/token-usage.js +63 -0
  47. package/dist/services/usage-store.d.ts +24 -0
  48. package/dist/services/usage-store.d.ts.map +1 -0
  49. package/dist/services/usage-store.js +92 -0
  50. package/dist/types.d.ts +50 -16
  51. package/dist/types.d.ts.map +1 -1
  52. 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
30
+ ```
31
+
32
+ If `@xenova/transformers` fails on `sharp` (pnpm ignored build scripts):
33
+
34
+ ```bash
35
+ cd node_modules/sharp && npm run install
36
+ ```
37
+
38
+ ---
39
+
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
+ };
17
369
  ```
18
370
 
19
- **Peer dependencies** (usually already in a LangChain app):
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
20
394
 
21
- - `@langchain/core`
22
- - `@langchain/langgraph`
23
- - `@langchain/langgraph-checkpoint`
24
- - `zod`
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) |
25
402
 
26
403
  ---
27
404
 
28
- ## Quick start
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
  ---
@@ -90,164 +450,122 @@ console.log(result.answer, result.intent, result.flow, result.toolOutputs);
90
450
 
91
451
  | You configure | Library handles |
92
452
  | --- | --- |
93
- | `intents` (examples + `sticky`) | Hybrid 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 |
453
+ | `intents` (examples + `sticky` + `regex`) | Hybrid regex + LLM + embedding router |
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)` |
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 |
127
484
 
128
485
  ---
129
486
 
130
487
  ## Intents
131
488
 
132
- ```ts
133
- intents: [
134
- {
135
- name: "greetings",
136
- examples: ["hi", "hello"],
137
- description: "Shown to the LLM router",
138
- },
139
- {
140
- name: "create_campaign",
141
- examples: ["create a campaign"],
142
- sticky: true, // keep this flow across turns until completeOn fires
143
- },
144
- ]
145
- ```
146
-
147
- - **examples** — embedding routing
148
- - **sticky** — multi-step wizards
149
- - **description** — LLM intent router
150
-
151
- ---
152
-
153
- ## Prompts & tools (`intentRegistry`)
154
-
155
- ```ts
156
- intentRegistry: {
157
- greetings: {
158
- prompt: (ctx) => string,
159
- tools: (phoneKey) => DynamicStructuredTool[],
160
- },
161
- }
162
- ```
163
-
164
- `prompt` receives:
165
-
166
489
  ```ts
167
490
  {
168
- hasConversationHistory: boolean
169
- intent: string
170
- intentScore?: number
171
- message?: string
172
- 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
173
496
  }
174
497
  ```
175
498
 
176
- Or use one builder for everything:
177
-
178
- ```ts
179
- buildSystemPrompt: (ctx) => combineGeneralAndIntent(ctx)
180
- ```
499
+ Detection order: `explicitIntent` sticky wizard skip → `menuMap` → `regex` → embeddings + LLM.
181
500
 
182
- ---
501
+ **Fallback:** `defaultIntent` (optional) — defaults to the first intent in your list, else `"greetings"`.
183
502
 
184
- ## Tool outputs (`toolOutputMap`)
503
+ ### Optional general intent (Xel-style catch-all)
185
504
 
186
- Replaces hand-written extractors:
505
+ Not required. If you want a first catch-all (hello / help / unclear → general chat):
187
506
 
188
507
  ```ts
189
- toolOutputMap: {
190
- create_campaign: "campaign",
191
- buy_airtime: "buyAirtime",
192
- // Nested field on args + seed:
193
- propose_tickets: { as: "tickets", from: "tickets", initial: [] },
194
- }
195
- ```
196
-
197
- Result lands on `result.toolOutputs` and completed keys on `result.extras`.
198
-
199
- ---
200
-
201
- ## Flow completion (`completeOn`)
508
+ import {
509
+ createAgentRuntime,
510
+ withGeneralIntent,
511
+ } from "llm-agent-runtime";
202
512
 
203
- 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
+ );
204
524
 
205
- ```ts
206
- 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
+ });
207
530
  ```
208
531
 
209
- If you omit `completeOn`, the library defaults `completeOn` to the keys of `toolOutputMap`.
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.
210
534
 
211
- 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.
212
-
213
- ---
535
+ ### Shared prompt (rules for every intent)
214
536
 
215
- ## Menu digits (`menuMap`)
537
+ `withGeneralIntent` is only a **catch-all routing intent**. It does **not** inject style rules into recharge / other flows.
216
538
 
217
- Optional. Needed only if users pick a numbered menu (`1`, `2`, …).
218
-
219
- 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:
220
540
 
221
541
  ```ts
222
- menuMap: {
223
- "1": "create_campaign",
224
- "2": "support_campaign",
225
- "3": "buy_airtime_and_data",
226
- }
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
+ });
227
549
  ```
228
550
 
551
+ You do **not** need to repeat those rules in every `intentRegistry.*.prompt`.
552
+
229
553
  ---
230
554
 
231
- ## Invoke
555
+ ## Invoke & result
232
556
 
233
557
  ```ts
234
- const result = await runtime.invoke({
558
+ await runtime.invoke({
235
559
  message: "hi",
236
- threadId: "whatsapp:234...", // conversation / checkpoint id
237
- phoneKey: "234...", // optional; passed to tools()
238
- explicitIntent: "greetings", // optional; skips hybrid router
239
- context: {
240
- contactName: "Ada",
241
- userInfo: { email: "ada@example.com" },
242
- },
560
+ threadId: "whatsapp:234...",
561
+ phoneKey: "234...",
562
+ explicitIntent: "greetings", // optional
563
+ context: { contactName: "Ada", ragContext: "..." },
243
564
  });
244
565
  ```
245
566
 
246
- **Result**
247
-
248
567
  ```ts
249
568
  {
250
- messages: unknown[]
251
569
  answer?: string
252
570
  intent: { intent: string, score: number }
253
571
  flow: { active: boolean, intent: string | null }
@@ -255,125 +573,85 @@ const result = await runtime.invoke({
255
573
  flowCompleted: boolean
256
574
  extras?: Record<string, unknown>
257
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
+ }
258
581
  }
259
582
  ```
260
583
 
261
- ---
584
+ Token totals are stored in **Redis** when `redisUrl` is set (`usage:thread:{id}`, `usage:flow:{id}:{intent}`), otherwise in memory.
262
585
 
263
- ## Lifecycle
586
+ ### Clear session (frontend)
264
587
 
265
588
  ```ts
266
- await runtime.connect() // Redis (if redisUrl set)
267
- await runtime.invoke(...)
268
- await runtime.clearFlow(id) // drop sticky flow state
269
- 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);
270
593
  ```
271
594
 
272
- ---
273
-
274
- ## Env vars (when `llm` is omitted)
275
-
276
- | Variable | Notes |
277
- | --- | --- |
278
- | `LLM_PROVIDER` | `ollama` (default) or `openai` |
279
- | `EMBEDDINGS_PROVIDER` | defaults to `LLM_PROVIDER` |
280
- | `OLLAMA_MODEL` | default `qwen2.5:7b` |
281
- | `OLLAMA_BASE_URL` | e.g. `http://localhost:11434` |
282
- | `INTENT_ROUTER_MODEL` | optional smaller router model |
283
- | `OLLAMA_EMBEDDINGS_MODEL` | default `mxbai-embed-large:latest` |
284
- | `OPENAI_API_KEY` | required for openai |
285
- | `OPENAI_MODEL` | default `gpt-4o-mini` |
286
- | `OPENAI_EMBEDDINGS_MODEL` | default `text-embedding-3-small` |
287
- | `REDIS_URL` | your app usually passes this as `redisUrl` |
288
- | `ANSWER_CACHE` | app convention: `"1"` to enable cache |
289
-
290
- Or pass LLM explicitly:
595
+ Example HTTP for the UI “New chat / Clear session” button:
291
596
 
292
597
  ```ts
293
- import { llmConfigFromEnv } from "llm-agent-runtime";
598
+ // DELETE /session body: { sessionId }
599
+ await runtime.clearSession(req.body.sessionId);
600
+ res.json({ ok: true });
601
+ ```
294
602
 
295
- createAgentRuntime({
296
- llm: llmConfigFromEnv(),
297
- // or
298
- llm: { provider: "openai", model: "gpt-4o-mini", apiKey: "..." },
299
- })
603
+ ```ts
604
+ await runtime.connect()
605
+ await runtime.clearFlow(threadId)
606
+ await runtime.clearSession(threadId)
607
+ await runtime.disconnect()
300
608
  ```
301
609
 
302
610
  ---
303
611
 
304
- ## Minimal app shape (recommended)
305
-
306
- ```
307
- your-app/
308
- agent/
309
- runtime.ts # createAgentRuntime({ ... })
310
- intents.ts # intents + menuMap + aliases
311
- prompts/ # prompt strings
312
- tools/ # LangChain tools / API clients
313
- controllers/ # HTTP / WhatsApp — call runtime.invoke()
314
- ```
315
-
316
- Keep channel adapters and product APIs **out** of this package.
612
+ ## LLM & embeddings
317
613
 
318
- ---
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` |
319
619
 
320
- ## Full example (Xel-like)
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` |
321
625
 
322
626
  ```ts
323
- import { createAgentRuntime } from "llm-agent-runtime";
324
-
325
- const runtime = await createAgentRuntime({
326
- redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
327
- cache: { enabled: process.env.ANSWER_CACHE === "1" },
627
+ createAgentRuntime({
628
+ llm: {
629
+ provider: "openrouter",
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",
634
+ },
635
+ // ...
636
+ });
637
+ ```
328
638
 
329
- intents: [
330
- { name: "greetings", examples: ["hi", "hello"] },
331
- {
332
- name: "create_campaign",
333
- examples: ["create a campaign"],
334
- sticky: true,
335
- },
336
- {
337
- name: "buy_airtime_and_data",
338
- examples: ["buy airtime", "vend airtime"],
339
- sticky: true,
340
- },
341
- ],
639
+ ### LangSmith
342
640
 
343
- menuMap: {
344
- "1": "create_campaign",
345
- "4": "buy_airtime_and_data",
346
- },
641
+ ```bash
642
+ LANGSMITH_TRACING=true
643
+ LANGSMITH_API_KEY=lsv2_pt_...
644
+ LANGSMITH_PROJECT=my-agent
645
+ ```
347
646
 
348
- intentAliases: {
349
- buy_airtime: "buy_airtime_and_data",
350
- },
647
+ Or `tracing: { enabled: true, projectName: "my-agent" }`.
351
648
 
352
- intentRegistry: {
353
- greetings: {
354
- prompt: (ctx) => `Greet ${ctx.context?.contactName ?? "there"}. Show menu 1–7.`,
355
- tools: () => [],
356
- },
357
- create_campaign: {
358
- prompt: () => "Walk through campaign creation.",
359
- tools: (phoneKey) => createCampaignTools(phoneKey),
360
- },
361
- buy_airtime_and_data: {
362
- prompt: () => "Help buy airtime/data.",
363
- tools: (phoneKey) => airtimeTools(phoneKey),
364
- },
365
- },
649
+ ---
366
650
 
367
- toolOutputMap: {
368
- create_campaign: "campaign",
369
- buy_airtime: "buyAirtime",
370
- add_airtime: "addAirtime",
371
- },
372
- completeOn: ["create_campaign", "buy_airtime", "add_airtime"],
373
- });
651
+ ## Redis note
374
652
 
375
- await runtime.connect();
376
- ```
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.
377
655
 
378
656
  ---
379
657
 
@@ -383,9 +661,27 @@ await runtime.connect();
383
661
  import {
384
662
  createAgentRuntime,
385
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,
386
679
  getLastMessageContent,
387
- // advanced (usually not needed)
388
- createToolOutputExtractor,
389
- toolOutputMapToExtractors,
390
680
  } from "llm-agent-runtime";
391
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`).