Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion content/develop/ai/agent-builder/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The agent builder will generate complete, working code examples for your chosen

## Features

- **Multiple programming languages**: Generate code in Python, with JavaScript (Node.js), Java, and C# coming soon
- **Multiple programming languages**: Generate code in Python and JavaScript (Node.js), with Java and C# coming soon
- **LLM integration**: Support for OpenAI, Anthropic Claude, and Llama 2
- **Redis optimized**: Uses Redis data structures for optimal performance

Expand Down
2 changes: 2 additions & 0 deletions layouts/shortcodes/agent-builder.html
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,7 @@ <h4 class="text-lg font-medium text-redis-ink-900 mb-4">Generated Agent Code</h4

</div>

<!-- Inject Hugo-resolved base path for template files so subdirectory deployments work -->
<script>window.AGENT_TEMPLATE_BASE = "{{ "code/agent-templates" | relURL }}";</script>
<!-- Load the agent builder JavaScript -->
<script src="{{ "js/agent-builder.js" | relURL }}"></script>
273 changes: 273 additions & 0 deletions static/code/agent-templates/javascript/conversational_agent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
/*
* Redis Conversational Agent (Node.js)
* Uses node-redis with Redis Search for semantic message history
*
* Requires Redis Stack 6.2+ or Redis 8 with the Search module for JSON
* vector indexing. The vector field is stored as a JSON array of floats,
* which is the correct on-disk format for JSON-backed vector indexes.
*
* To run this code:
* Install dependencies:
* npm install redis openai dotenv
*
* Set environment variables:
* LLM_API_KEY=your_${formData.llmModel.toLowerCase()}_api_key
* LLM_API_BASE_URL=your_base_url (optional, default: ${CONFIG.models[formData.llmModel].baseUrl})
* LLM_MODEL=your_model_name (optional, default: ${CONFIG.models[formData.llmModel].defaultModel})
* REDIS_URL=redis://localhost:6379
* (or use REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_USERNAME separately)
*
* Embeddings use a separate client so you can mix providers:
* EMBEDDING_API_KEY=your_key (optional - defaults to LLM_API_KEY)
* EMBEDDING_API_BASE_URL=your_url (optional - defaults to LLM_API_BASE_URL)
* EMBEDDING_MODEL=your_embed_model (optional, default: text-embedding-3-small;
* for Ollama use nomic-embed-text)
* VECTOR_DIM=1536 (optional, must match your embedding model's output dimension)
*/

require('dotenv').config();
const { createClient } = require('redis');
const OpenAI = require('openai');

const INDEX_NAME = 'message_history_idx';
const MESSAGE_PREFIX = 'message:';
const RECENT_KEY = (session) => `recent:${session}`;
const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL || 'text-embedding-3-small';
const VECTOR_DIM = parseInt(process.env.VECTOR_DIM) || 1536;
const RECENT_WINDOW = 6; // always include this many recent turns in context
const SEMANTIC_TOP_K = 4; // additional turns retrieved by semantic similarity
const MAX_CONTENT_CHARS = 2000;

class ConversationalAgent {
constructor(sessionName = 'chat') {
this.sessionName = sessionName;
this.messageCount = 0;
this._dimValidated = false;

// For local providers (e.g. Ollama), any non-empty string works. For hosted providers, use your real key.
this.llmApiKey = process.env.LLM_API_KEY || 'no-key-needed';

this.llmBaseUrl = process.env.LLM_API_BASE_URL || '${CONFIG.models[formData.llmModel].baseUrl}';
this.llmModel = process.env.LLM_MODEL || '${CONFIG.models[formData.llmModel].defaultModel}';

this.openai = new OpenAI({ apiKey: this.llmApiKey, baseURL: this.llmBaseUrl });
Comment thread
cursor[bot] marked this conversation as resolved.

// Embeddings can use a different provider than chat completions.
// For Ollama users: set EMBEDDING_MODEL=nomic-embed-text (no extra keys needed).
// For Anthropic users: set EMBEDDING_API_KEY and EMBEDDING_API_BASE_URL to an
// OpenAI-compatible embedding endpoint (e.g. OpenAI or Ollama).
this.embedder = new OpenAI({
apiKey: process.env.EMBEDDING_API_KEY || this.llmApiKey,
baseURL: process.env.EMBEDDING_API_BASE_URL || this.llmBaseUrl,
});

this.redisClient = null;
}

async connect() {
const clientOptions = process.env.REDIS_URL
? { url: process.env.REDIS_URL }
: {
socket: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT) || 6379,
},
password: process.env.REDIS_PASSWORD || undefined,
username: process.env.REDIS_USERNAME || 'default',
};

this.redisClient = createClient(clientOptions);
this.redisClient.on('error', (err) => console.error('Redis error:', err));
await this.redisClient.connect();
console.log('Connected to Redis successfully');

await this._ensureIndex();
console.log('LLM configured:', this.llmModel);
console.log('Embedding model:', EMBEDDING_MODEL, `(VECTOR_DIM=${VECTOR_DIM})`);
}

async _ensureIndex() {
try {
await this.redisClient.ft.info(INDEX_NAME);
} catch {
await this.redisClient.ft.create(
INDEX_NAME,
{
'$.role': { type: 'TAG', AS: 'role' },
'$.content': { type: 'TEXT', AS: 'content' },
'$.session': { type: 'TAG', AS: 'session' },
'$.embedding': {
type: 'VECTOR',
AS: 'embedding',
ALGORITHM: 'FLAT',
TYPE: 'FLOAT32',
DIM: VECTOR_DIM,
DISTANCE_METRIC: 'COSINE',
},
},
{ ON: 'JSON', PREFIX: MESSAGE_PREFIX }
);
console.log('Created search index:', INDEX_NAME);
}
}

async _embed(text) {
const response = await this.embedder.embeddings.create({
model: EMBEDDING_MODEL,
input: text,
});
const embedding = response.data[0].embedding;

// Validate dimension on first call. If this throws, either set VECTOR_DIM
// to the correct value in your environment, or recreate the index.
if (!this._dimValidated) {
if (embedding.length !== VECTOR_DIM) {
throw new Error(
`Embedding model '${EMBEDDING_MODEL}' returned ${embedding.length} dimensions ` +
`but VECTOR_DIM is ${VECTOR_DIM}. ` +
`Set VECTOR_DIM=${embedding.length} and recreate the index.`
);
}
this._dimValidated = true;
}

return embedding; // plain JS number array
}

_toQueryBuffer(embedding) {
return Buffer.from(new Float32Array(embedding).buffer);
}

async _storeMessage(role, content) {
const truncated = content.slice(0, MAX_CONTENT_CHARS);
const embedding = await this._embed(truncated);
const key = `${MESSAGE_PREFIX}${this.sessionName}:${Date.now()}_${this.messageCount++}`;

await this.redisClient.json.set(key, '$', {
role,
content: truncated,
session: this.sessionName,
embedding, // stored as JSON array of floats, required for JSON vector index
});

// Track insertion order for recent-turn retrieval.
// Only trim the recent list — do NOT delete the underlying JSON documents.
// Older messages must stay in the search index so _getSemanticMessages can
// retrieve them; deleting docs here would make semantic recall a no-op since
// every KNN hit would already be in the recent window and deduped out.
// For long-running sessions, set a TTL on message keys if you need a memory bound.
await this.redisClient.rPush(RECENT_KEY(this.sessionName), key);
await this.redisClient.lTrim(RECENT_KEY(this.sessionName), -RECENT_WINDOW * 2, -1);
Comment thread
cursor[bot] marked this conversation as resolved.
}

async _getRecentMessages() {
const keys = await this.redisClient.lRange(RECENT_KEY(this.sessionName), -(RECENT_WINDOW * 2), -1);
if (!keys.length) return [];
const docs = await this.redisClient.json.mGet(keys, '$');
return keys
.map((key, i) => ({ key, doc: docs[i]?.[0] }))
.filter(({ doc }) => doc != null)
.map(({ key, doc }) => ({ role: doc.role, content: doc.content, _key: key }));
}
Comment thread
cursor[bot] marked this conversation as resolved.

async _getSemanticMessages(query) {
const queryBuffer = this._toQueryBuffer(await this._embed(query));
const results = await this.redisClient.ft.search(
INDEX_NAME,
`(@session:{${this.sessionName.replace(/[^a-zA-Z0-9_\-]/g, '_')}})=>[KNN ${SEMANTIC_TOP_K} @embedding $vec AS score]`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Session TAG query mismatch

Medium Severity

Semantic history search filters on @session using a transformed copy of sessionName, while _storeMessage indexes the raw sessionName on each JSON document. Any session name containing characters rewritten to underscores (or relying on exact punctuation) will not match the TAG predicate, so KNN recall returns nothing for that session even though messages exist.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 174b716. Configure here.

{
PARAMS: { vec: queryBuffer },
RETURN: ['role', 'content'],
SORTBY: { BY: 'score', DIRECTION: 'ASC' },
DIALECT: 2,
}
);
return results.documents.map((doc) => ({
role: doc.value.role,
content: doc.value.content,
_key: doc.id,
}));
}

async _buildContext(userInput) {
// Hybrid: recent turns for conversational coherence + semantic search for deeper context.
const [recent, semantic] = await Promise.all([
this._getRecentMessages().catch(() => []),
this._getSemanticMessages(userInput).catch(() => []),
]);

// Deduplicate by key, then sort chronologically — keys encode timestamp so
// lexicographic order preserves insertion time across both result sets.
const seen = new Set(recent.map((m) => m._key));
const extra = semantic.filter((m) => !seen.has(m._key));

return [...recent, ...extra]
.sort((a, b) => (a._key < b._key ? -1 : a._key > b._key ? 1 : 0))
.map(({ role, content }) => ({ role, content }));
}

async chat(userInput) {
const context = await this._buildContext(userInput);

const messages = [
{
role: 'system',
content: 'You are a helpful assistant that answers questions based on the conversation history.',
},
...context,
{ role: 'user', content: userInput },
];

const response = await this.openai.chat.completions.create({
model: this.llmModel,
messages,
});

const assistantResponse = response.choices[0]?.message?.content;
if (!assistantResponse) throw new Error('Empty response from LLM');

await this._storeMessage('user', userInput);
await this._storeMessage('assistant', assistantResponse);

return assistantResponse;
}

async disconnect() {
if (this.redisClient) await this.redisClient.disconnect();
}
}

async function main() {
const agent = new ConversationalAgent();
try {
await agent.connect();
console.log(await agent.chat('Tell me about yourself.'));
} catch (err) {
console.error('Failed to initialize agent:', err.message);
await agent.disconnect();
process.exit(1);
}

const readline = require('readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });

const askQuestion = () => {
rl.question('Enter a prompt: ', async (input) => {
if (['quit', 'exit', 'bye'].includes(input.toLowerCase())) {
console.log('Goodbye!');
rl.close();
await agent.disconnect();
return;
}
try {
console.log(await agent.chat(input));
} catch (err) {
console.error('Error:', err.message);
}
askQuestion();
});
};
askQuestion();
}

main();
Loading
Loading