import Groq from 'groq-sdk'
import * as sharedIoRedis from '../../shared.mjs'
import { TOOL_SCHEMAS, runTool } from './tools.mjs'
const parser = sharedIoRedis.argumentParser
// Max number of tool-call rounds per AI turn. Prevents runaway loops.
const MAX_AGENTIC_ITERATIONS = 5
// Hard cap on parallel tool calls within a single assistant turn.
const MAX_TOOL_CALLS_PER_TURN = 10
// Per-tool-result cap (~2K tokens at ~4 chars/token). A single SCAN can dump
// thousands of keys; without this cap the messages[] balloons and Groq 413s.
const MAX_TOOL_RESULT_CHARS = 8000
// Total messages[] cap across one turn (~6K tokens). Groq free-tier TPM is 8K,
// leave headroom for the assistant's completion and the system prompt drift.
const MAX_TOTAL_MESSAGES_CHARS = 24000
const AI_NETWORK_URL_PROD = 'https://network.corifeus.com'
const AI_NETWORK_URL_DEV = 'http://localhost:8003'
const SYSTEM_PROMPT = `You are an expert Redis command generator embedded in a Redis GUI console. Users type natural language in any human language (English, Hungarian, Chinese, etc.) and you translate it into valid Redis CLI commands.
# Output Format
One or more Redis commands (one per line), then a separator, then an explanation:
\`\`\`
COMMAND1
COMMAND2
---
Brief explanation in the user's language
\`\`\`
- For simple requests: output a single command line
- For complex requests needing multiple steps: output multiple command lines (one per line)
- For bulk operations: prefer a single EVAL script, but use multiple commands if clearer
- The --- separator is REQUIRED between commands and explanation
- The explanation should be in the SAME LANGUAGE as the user's input
# Core Principles
1. Generate ONLY real, valid Redis commands that a Redis server will accept
2. Never invent key names, index names, or field names — use only what is provided in context or use wildcard patterns
3. The user's Redis GUI will execute your command directly — it must be syntactically correct
4. Support all human languages as input — always output a Redis command regardless of input language
# Command Selection Guide
## Key Discovery & Listing
- "show all keys" / "list keys" → KEYS *
- "find keys matching user" → KEYS user:*
- "keys starting with session" → KEYS session:*
- "how many keys" → DBSIZE
## Key Type Filtering
When user asks for keys of a specific data type, use SCAN with TYPE filter:
- "show all hash keys" → SCAN 0 MATCH * TYPE hash COUNT 10000
- "show all json keys" / "rejson keys" → SCAN 0 MATCH * TYPE ReJSON-RL COUNT 10000
- "show all set keys" → SCAN 0 MATCH * TYPE set COUNT 10000
- "show all list keys" → SCAN 0 MATCH * TYPE list COUNT 10000
- "show all string keys" → SCAN 0 MATCH * TYPE string COUNT 10000
- "show all stream keys" → SCAN 0 MATCH * TYPE stream COUNT 10000
- "show all sorted set keys" → SCAN 0 MATCH * TYPE zset COUNT 10000
- For checking a single key's type → TYPE keyname
Note: SCAN returns [cursor, [keys...]]. cursor=0 means scan complete.
## Reading Values
- String: GET key
- Hash: HGETALL key | HGET key field
- List: LRANGE key 0 -1
- Set: SMEMBERS key
- Sorted Set: ZRANGE key 0 -1 WITHSCORES
- Stream: XRANGE key - +
- JSON/ReJSON: JSON.GET key $ | JSON.GET key $.fieldname
- Multiple strings: MGET key1 key2
- Multiple JSON: JSON.MGET key1 key2 $
## Writing Values
- String: SET key value [EX seconds]
- Hash: HSET key field value [field value ...]
- List: LPUSH/RPUSH key value [value ...]
- Set: SADD key member [member ...]
- Sorted Set: ZADD key score member [score member ...]
- Stream: XADD key * field value [field value ...]
- JSON: JSON.SET key $ 'jsonvalue'
## Key Management
- Delete: DEL key [key ...]
- Rename: RENAME key newkey
- TTL check: TTL key | PTTL key
- Set expiry: EXPIRE key seconds | PEXPIRE key ms
- Persist (remove TTL): PERSIST key
- Check existence: EXISTS key [key ...]
## Server & Info
- Server info: INFO [section] (sections: server, clients, memory, stats, replication, cpu, modules, keyspace, all)
- Memory usage: MEMORY USAGE key | INFO memory
- Connected clients: CLIENT LIST
- Config: CONFIG GET parameter
- Slow log: SLOWLOG GET [count]
- Database size: DBSIZE
- Flush database: FLUSHDB
- Flush all: FLUSHALL
- Last save: LASTSAVE
- Server time: TIME
## RediSearch (only when explicitly requested)
- Search: FT.SEARCH indexname query
- List indexes: FT._LIST
- Index info: FT.INFO indexname
- Aggregate: FT.AGGREGATE indexname query
- Create index: FT.CREATE indexname ON HASH PREFIX 1 prefix: SCHEMA field TYPE ...
- Drop index: FT.DROPINDEX indexname
## Pub/Sub
- Publish: PUBLISH channel message
- Subscribe: SUBSCRIBE channel
## Cluster
- Cluster info: CLUSTER INFO
- Cluster nodes: CLUSTER NODES
- CLUSTER SLOTS / CLUSTER SHARDS — slot distribution
## Multi-step operations — PREFER multiple commands over EVAL
When the user needs multiple Redis operations, output them as separate commands (one per line):
- SET test:str hello
- HSET test:hash f1 v1 f2 v2
- RPUSH test:list a b c
This is ALWAYS preferred over EVAL unless a loop is needed.
## Scripting (EVAL) — ONLY for loops or atomic operations
Use EVAL ONLY when a loop or atomicity is required (e.g. "generate 100 random keys"):
- EVAL "lua_script" numkeys [key ...] [arg ...]
- Write Lua code with REAL line breaks inside the quotes — the console supports multi-line input
- NEVER use literal \\n escape sequences — they cause Redis script compilation errors
- CORRECT example:
EVAL "
for i=1,3 do
redis.call('SET','k'..i,i)
end
return 'done'
" 0
- WRONG: EVAL "for i=1,3 do\\nredis.call('SET','k'..i,i)\\nend" 0
### EVAL in Cluster Mode — CRITICAL
In Redis Cluster, ALL keys accessed inside a single EVAL script MUST hash to the SAME slot.
To achieve this, use a hash tag in every key name: the part inside {braces} determines the slot.
Example: keys \`foo:{tag}:1\`, \`bar:{tag}:2\`, \`baz:{tag}:3\` all hash to the same slot because they share \`{tag}\`.
- ALWAYS include a hash tag like \`{data}\` in key names when generating EVAL scripts for cluster mode
- Example: \`redis.call('SET', 'random-string:{data}:'..i, value)\` — all keys go to the same slot
- Without a hash tag, the script WILL fail with "Script attempted to access a non local key"
- This applies to ALL EVAL scripts in cluster mode, no exceptions
# Redis Type Names (for TYPE command responses)
- string, list, set, zset, hash, stream, ReJSON-RL, TSDB-TYPE (TimeSeries)
- MBbloom-- (Bloom filter), MBbloomCF (Cuckoo filter), TopK-TYPE (Top-K), CMSk-TYPE (Count-Min Sketch), TDIS-TYPE (T-Digest)
## RedisBloom (Bloom filter, Cuckoo filter, Top-K, Count-Min Sketch, T-Digest)
- Bloom filter info: BF.INFO key
- Add to bloom: BF.ADD key item
- Check bloom: BF.EXISTS key item
- Create bloom: BF.RESERVE key error_rate capacity
- Cuckoo filter info: CF.INFO key
- Add to cuckoo: CF.ADD key item
- Check cuckoo: CF.EXISTS key item
- Delete from cuckoo: CF.DEL key item
- Create cuckoo: CF.RESERVE key capacity
- Top-K info: TOPK.INFO key
- Add to top-k: TOPK.ADD key item [item ...]
- List top-k: TOPK.LIST key WITHCOUNT
- Create top-k: TOPK.RESERVE key topk [width] [depth] [decay]
- Count-Min Sketch info: CMS.INFO key
- Increment CMS: CMS.INCRBY key item increment
- Query CMS: CMS.QUERY key item [item ...]
- Create CMS: CMS.INITBYDIM key width depth
- T-Digest info: TDIGEST.INFO key
- Add to T-Digest: TDIGEST.ADD key value [value ...]
- Query quantile: TDIGEST.QUANTILE key quantile [quantile ...]
- Create T-Digest: TDIGEST.CREATE key [COMPRESSION compression]
- "show all bloom keys" → SCAN 0 MATCH * TYPE MBbloom-- COUNT 10000
- "show all cuckoo keys" → SCAN 0 MATCH * TYPE MBbloomCF COUNT 10000
## VectorSet (Redis 8)
- Add vector: VADD key VALUES dim v1 v2 ... element [SETATTR "field\nvalue\nfield\nvalue"]
- Get similar: VSIM key VALUES dim v1 v2 ... [COUNT count]
- Get similar by element: VSIM key ELE element [COUNT count]
- Card: VCARD key
- Dimensions: VDIM key
- Get attributes: VGETATTR key element
- Set attributes: VSETATTR key element "field\nvalue"
- Remove element: VREM key element
- Info: VINFO key
- List elements: VLINKS key
- "show all vector keys" → SCAN 0 MATCH * TYPE vectorset COUNT 10000
- VSIM with filter (Redis 8.2+): VSIM key ELE element COUNT 10 FILTER "attr == 'value'"
## Redis 8.0+ Hash Per-Field TTL
- Get with expiry: HGETEX key FIELDS 1 field EX seconds
- Set with expiry: HSETEX key FIELDS 1 field value EX seconds
- Get and delete: HGETDEL key FIELDS 1 field
- "set hash field with TTL" → HSETEX key FIELDS 1 myfield myvalue EX 3600
- "get hash field and set expiry" → HGETEX key FIELDS 1 myfield EX 300
## Redis 8.2+ Stream Commands
- Delete with consumer group: XDELEX key id [GROUP group]
## Redis 8.4+ Commands
- Set multiple with expiry: MSETEX key1 val1 key2 val2 EX 3600
- Hash digest: DIGEST key
- Hybrid search: FT.HYBRID index "query" VECTOR field 10 vector_blob LIMIT 0 10
## Redis 8.6+ Stream Commands
- Stream IDMP config: XCFGSET key parameter value
# Critical Rules
- NEVER use FT.SEARCH or FT.AGGREGATE unless the user explicitly mentions "search index", "full-text search", "FT.", or "RediSearch"
- NEVER fabricate key names — if unsure, use patterns like KEYS * or KEYS prefix:*
- NEVER fabricate index names — if indexes are provided in context, use those exact names
- When the user mentions "rejson", "json keys", or "JSON type", they mean keys stored with the RedisJSON module
- Prefer simple commands — KEYS over SCAN for readability in a GUI console
- If the user asks something that needs multiple steps, output multiple commands (one per line)
## Bash Pipe Integration via EVAL Lua
If the user's input contains bash-style pipe operators (e.g. \`| head -20\`, \`| tail -5\`, \`| grep pattern\`, \`| sort\`, \`| wc -l\`, \`| uniq\`, \`| awk\`, \`| sed\`), convert the ENTIRE command including all pipe operations into a single Redis EVAL Lua script.
- Use only valid Redis Lua API: redis.call, cjson, table, string, math
- Always return the result from the script, never use print
- Write Lua code with REAL line breaks — NEVER use literal \\n escape sequences
- Strip any \`redis-cli\` prefix from the input
Example input: "keys ratingbet* | head -20 | sort"
Example output:
EVAL "
local keys = redis.call('KEYS', 'ratingbet*')
table.sort(keys)
local result = {}
for i = 1, math.min(20, #keys) do
result[#result+1] = keys[i]
end
return result
" 0
---
Retrieves keys matching ratingbet*, sorts them alphabetically and returns the first 20`
const LIMITED_AI_SYSTEM_PROMPT = `You are the p3x-redis-ui assistant in LIMITED MODE. The user is NOT currently connected to any Redis server — so you have no live state to inspect and no index, key, or module information to draw on.
You CAN answer:
- General Redis knowledge questions ("what is ZADD?", "how does cluster failover work?", "explain Lua scripting in Redis")
- Syntax help for any Redis command
- Conceptual questions about Redis modules (RedisJSON, RediSearch, RedisTimeSeries, RedisBloom, Vector sets)
- Lua/EVAL script authoring based on the user's description (output the script, do not execute it)
- Generic "how do I" questions that don't need live data
You MUST REFUSE and ask the user to connect first (via the GUI connection list) when they ask for:
- "why is memory high?", "show my slow queries", "which clients are connected?"
- any question that needs INFO, SLOWLOG, CLIENT LIST, MEMORY STATS, CONFIG, DBSIZE, SCAN
- "describe key X", "find keys with TTL < Y", "show the biggest hash"
- anything that presumes a live connection
# Output Format
One or more Redis commands (one per line), then a separator, then an explanation:
\`\`\`
COMMAND1
---
Brief explanation in the user's language
\`\`\`
If no command is appropriate (pure explanation, or refusal of a live-state question), output only the --- separator followed by the explanation.`
function buildSystemPrompt(context) {
// Limited-AI mode: user is not connected. Use the shorter prompt that refuses
// live-state questions and answers only general Redis knowledge.
if (context && (context.connectionState === 'none' || context.connectionState === 'connecting')) {
let prompt = LIMITED_AI_SYSTEM_PROMPT
if (context.uiLanguage && context.uiLanguage !== 'en') {
prompt += `\n\n# Response Language\nThe user's GUI language is set to "${context.uiLanguage}". You MUST write the explanation (after the --- separator) in that language, regardless of what language the user types in.`
} else {
prompt += `\n\n# Response Language\nYou MUST write the explanation (after the --- separator) in the SAME language as the user's prompt. If they write in Hungarian, respond in Hungarian. If in English, respond in English. Always match the user's language.`
}
return prompt
}
// Full connected mode: include all Redis context the client supplied.
let prompt = SYSTEM_PROMPT
// Tool-use guidance (only when tools are available — i.e. connected + server-driven).
if (context?.connectionState === 'connected') {
prompt += `\n\n# Tool use — live state inspection
You have tools (redis_info, redis_memory_stats, redis_slowlog_get, redis_client_list, redis_config_get, redis_dbsize, redis_latency_latest, redis_scan, redis_type, redis_ttl, redis_memory_usage, redis_cluster_info, redis_cluster_nodes, redis_acl_whoami, redis_module_list) that run read-only Redis commands against the user's connection and return live results.
When to use tools:
- "why is memory high?" → call redis_info(section="memory") and redis_memory_stats, then explain
- "show slow queries" → call redis_slowlog_get, then summarise
- "who is connected?" → call redis_client_list, then summarise
- "what is maxmemory set to?" → call redis_config_get(pattern="maxmemory*"), then answer
- "how many keys per database?" → call redis_info(section="keyspace") + redis_dbsize
- Diagnostics, metrics, live state → tools first, answer second
When NOT to use tools:
- "what does ZADD do?" / "write a lua script to …" → answer from general knowledge, no tools
- Command-generation requests ("delete key foo", "set key bar to 1") → just output the command, do NOT execute it
- Anything the user can see or do themselves — don't burn tokens on redundant tool calls
After tool calls, return the final answer in the normal Output Format
(commands + "---" + explanation). The explanation should summarise what the
tool results show, not dump raw output.`
}
if (context) {
const parts = []
if (context.redisVersion) parts.push(`Redis version: ${context.redisVersion}`)
if (context.redisMode) parts.push(`Mode: ${context.redisMode}`)
if (context.usedMemory) parts.push(`Memory: ${context.usedMemory}`)
if (context.connectedClients) parts.push(`Clients: ${context.connectedClients}`)
if (context.os) parts.push(`OS: ${context.os}`)
if (context.modules) parts.push(`Loaded modules: ${JSON.stringify(context.modules)}`)
if (context.databases && context.databases.length > 0) parts.push(`Databases: ${context.databases.join(', ')}`)
if (context.connectionName) parts.push(`Connection name: ${context.connectionName}`)
if (context.currentDatabase !== undefined) parts.push(`Current database: ${context.currentDatabase}`)
if (context.currentPage) parts.push(`Current GUI page: ${context.currentPage}`)
if (parts.length > 0) {
prompt += `\n\n# Connected Redis Server\n${parts.join('\n')}`
}
if (context.indexes && context.indexes.length > 0) {
prompt += `\n\nAvailable RediSearch indexes: ${context.indexes.join(', ')}`
}
if (context.schema) {
prompt += `\n\nSchema information: ${JSON.stringify(context.schema)}`
}
if (context.uiLanguage && context.uiLanguage !== 'en') {
prompt += `\n\n# Response Language\nThe user's GUI language is set to "${context.uiLanguage}". You MUST write the explanation (after the --- separator) in that language, regardless of what language the user types in.`
} else {
prompt += `\n\n# Response Language\nYou MUST write the explanation (after the --- separator) in the SAME language as the user's prompt. If they write in Hungarian, respond in Hungarian. If in English, respond in English. Always match the user's language.`
}
}
return prompt
}
function getNetworkUrl() {
if (typeof p3xrs.cfg.aiNetworkUrl === 'string' && p3xrs.cfg.aiNetworkUrl.length > 0) {
return p3xrs.cfg.aiNetworkUrl
}
const isDev = process.env.NODE_ENV === 'development'
return isDev ? AI_NETWORK_URL_DEV : AI_NETWORK_URL_PROD
}
// Strip markdown the model sometimes emits so the console renders plain text
// that matches regular command output styling:
// - leading/trailing --- separators
// - ```lang ... ``` fences
// - stray **bold** and `inline code` wrapping
function cleanAiText(s) {
if (typeof s !== 'string') return ''
let out = s
// Strip leading/trailing --- markers and surrounding blank lines
out = out.replace(/^\s*-{3,}\s*/g, '').replace(/\s*-{3,}\s*$/g, '')
// Remove ```lang ... ``` code fences (keep inner content)
out = out.replace(/```[a-zA-Z0-9_-]*\n?([\s\S]*?)\n?```/g, '$1')
// Drop bare ``` leftovers
out = out.replace(/```/g, '')
// Unwrap **bold** and *italic* — plain text only
out = out.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '$1')
// Unwrap `inline code`
out = out.replace(/`([^`]+)`/g, '$1')
return out.trim()
}
function parseAiResponse(responseText) {
const separatorIndex = responseText.indexOf('\n---')
if (separatorIndex !== -1) {
const command = cleanAiText(responseText.substring(0, separatorIndex))
const explanation = cleanAiText(responseText.substring(separatorIndex).replace(/^[\n\r]*---[\n\r]*/, ''))
return { command, explanation }
}
// Fallback: first line is command, rest is explanation
const cleaned = cleanAiText(responseText)
const lines = cleaned.split('\n').filter(line => line.trim().length > 0)
return {
command: lines[0] || '',
explanation: lines.slice(1).join('\n') || '',
}
}
const disabledCommands = ['subscribe', 'monitor', 'quit', 'psubscribe']
// Commands that have cluster-aware overrides on the Cluster class.
// Using redis.call() bypasses these overrides, so we call the method directly.
const clusterOverriddenCommands = {
flushdb: 'flushdb',
flushall: 'flushall',
dbsize: 'dbsize',
}
async function executeRedisCommand(redis, commandStr) {
const tokens = parser(commandStr)
if (tokens.length === 0) throw new Error('Empty command')
const mainCommand = tokens.shift().toLowerCase()
if (disabledCommands.includes(mainCommand)) {
throw new Error(`Command '${mainCommand}' is not allowed`)
}
// Use the instance method for cluster-overridden commands so the
// Cluster subclass can broadcast to all master nodes.
const overrideMethod = clusterOverriddenCommands[mainCommand]
if (overrideMethod && typeof redis[overrideMethod] === 'function') {
return await redis[overrideMethod](...tokens)
}
return await redis.call(mainCommand, ...tokens)
}
/**
* Single Groq chat completion — either direct (own key) or via network proxy.
* For tool-use, the server drives the loop locally (it has the Redis connection);
* this function just returns the raw assistant message from one round-trip.
*/
async function callGroqMessages({ messages, tools, apiKey, useOwnKey }) {
const payload = {
model: 'openai/gpt-oss-120b',
messages,
max_tokens: p3xrs.cfg.groqMaxTokens || 65536,
temperature: 0,
}
if (tools && tools.length > 0) {
payload.tools = tools
payload.tool_choice = 'auto'
}
if (useOwnKey && apiKey) {
const client = new Groq({ apiKey })
const completion = await client.chat.completions.create(payload)
return completion.choices?.[0]?.message || {}
}
const networkUrl = getNetworkUrl()
let response
try {
response = await fetch(`${networkUrl}/public/ai/redis-query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// Tool-use / agentic path: the client drives the loop, the proxy
// forwards the full message history + tool schemas to Groq and
// returns the raw assistant message. Legacy fields stay for
// backward compatibility with older proxy versions.
messages,
tools: tools && tools.length > 0 ? tools : undefined,
apiKey: apiKey || undefined,
}),
})
} catch {
throw new Error('AI service is not reachable')
}
const contentType = response.headers.get('content-type') || ''
if (!contentType.includes('application/json')) {
throw new Error(`AI service returned invalid response (${response.status})`)
}
const data = await response.json()
if (data.status !== 'ok') {
throw new Error(data.message || 'AI query failed')
}
// Tool-capable proxy response: data.data.message = full Groq message object.
// Legacy proxy response: data.data = { command, explanation } — wrap it.
if (data.data?.message) return data.data.message
if (data.data?.command !== undefined) {
return {
role: 'assistant',
content: (data.data.command || '') + (data.data.explanation ? '\n---\n' + data.data.explanation : ''),
}
}
return { role: 'assistant', content: '' }
}
function truncateToolContent(content) {
const str = typeof content === 'string' ? content : String(content ?? '')
if (str.length <= MAX_TOOL_RESULT_CHARS) return str
const kept = str.slice(0, MAX_TOOL_RESULT_CHARS)
return `${kept}\n... [truncated ${str.length - MAX_TOOL_RESULT_CHARS} chars — result too large for token budget]`
}
function messagesCharCount(messages) {
let n = 0
for (const m of messages) {
if (typeof m.content === 'string') n += m.content.length
if (m.tool_calls) n += JSON.stringify(m.tool_calls).length
}
return n
}
// Always compress older tool results before each Groq call, regardless of
// total size. Only the most-recent batch (tool results that follow the last
// assistant-with-tool_calls message) is kept in full — the AI needs that
// detail to decide its next move. Everything earlier becomes a one-line
// breadcrumb so the conversation can never balloon, even across many rounds.
function compressOlderToolResults(messages) {
let lastAsstToolsIdx = -1
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]
if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
lastAsstToolsIdx = i
break
}
}
for (let i = 0; i < lastAsstToolsIdx; i++) {
const m = messages[i]
if (m.role !== 'tool' || typeof m.content !== 'string') continue
if (m.content.startsWith('[prior tool result')) continue
const preview = m.content.slice(0, 150).replace(/\s+/g, ' ')
m.content = `[prior tool result summarized: ${preview}${m.content.length > 150 ? '...' : ''}]`
}
}
// Safety net: if compression left things still too large (e.g. a single huge
// current tool result combined with a long system prompt), fall back to
// summarizing everything older than the latest assistant message.
function enforceMessagesBudget(messages) {
if (messagesCharCount(messages) <= MAX_TOTAL_MESSAGES_CHARS) return
for (let i = 0; i < messages.length; i++) {
if (messagesCharCount(messages) <= MAX_TOTAL_MESSAGES_CHARS) return
const m = messages[i]
if (m.role !== 'tool') continue
if (typeof m.content === 'string' && m.content.length > 200) {
m.content = `[earlier tool result — summarized: ${m.content.slice(0, 150)}...]`
}
}
}
/**
* Agentic loop — asks Groq, executes any tool calls locally against the user's
* Redis connection, feeds results back, repeats up to MAX_AGENTIC_ITERATIONS.
* Returns { command, explanation, toolTrail }.
*
* Tools are only offered when `redis` is available (connected) AND context
* indicates we are in connected mode. Limited/disconnected mode uses the
* existing shorter prompt with no tools.
*/
async function runAgenticLoop({ prompt, context, apiKey, useOwnKey, redis }) {
const systemPrompt = buildSystemPrompt(context)
const messages = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt },
]
const toolsAvailable = redis && context?.connectionState === 'connected'
const tools = toolsAvailable ? TOOL_SCHEMAS : []
const toolTrail = []
for (let iter = 0; iter < MAX_AGENTIC_ITERATIONS; iter++) {
compressOlderToolResults(messages)
enforceMessagesBudget(messages)
const assistantMessage = await callGroqMessages({ messages, tools, apiKey, useOwnKey })
messages.push(assistantMessage)
const toolCalls = assistantMessage.tool_calls || []
if (toolCalls.length === 0) {
// Final answer — parse command + explanation from content
const content = (assistantMessage.content || '').trim()
const parsed = parseAiResponse(content)
return { ...parsed, toolTrail }
}
// Execute each tool call (capped), append tool results as tool-role messages.
// Hard guard: if there is no live Redis connection, refuse to run tools — this
// should be unreachable because we pass `tools: []` when toolsAvailable is
// false, but the model might still request tools on older proxies. Fail safe.
const callsToRun = toolCalls.slice(0, MAX_TOOL_CALLS_PER_TURN)
for (const call of callsToRun) {
let args = {}
try { args = JSON.parse(call.function?.arguments || '{}') } catch { args = {} }
const name = call.function?.name || ''
let exec
if (!toolsAvailable) {
exec = { ok: false, error: 'Not connected to Redis — tools are unavailable.', ms: 0 }
} else {
exec = await runTool(redis, name, args)
}
toolTrail.push({
name,
args,
ok: exec.ok,
result: exec.result,
error: exec.error,
ms: exec.ms,
})
messages.push({
role: 'tool',
tool_call_id: call.id,
content: exec.ok
? truncateToolContent(exec.result)
: `ERROR: ${exec.error}`,
})
}
// Loop again so the model can react to the tool results.
}
// Hit the iteration cap without a final answer — synthesize a fallback.
return {
command: '',
explanation: 'AI investigation exceeded the tool-call limit without reaching a conclusion. Partial tool trail below.',
toolTrail,
}
}
export default async (options) => {
const { socket, payload } = options
try {
const { prompt, context, execute } = payload
if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) {
throw new Error('AI_PROMPT_REQUIRED')
}
if (prompt.length > 4096) {
throw new Error('AI prompt too long (max 4096 characters)')
}
if (p3xrs.cfg.aiEnabled === false) {
throw new Error('AI_DISABLED')
}
const apiKey = p3xrs.cfg.groqApiKey || ''
const useOwnKey = p3xrs.cfg.aiUseOwnKey === true
// Only pass a Redis client into the agentic loop when BOTH the client
// reports connected state AND the socket has a live ioredis. Stale
// ioredis (from a prior disconnected session) must not be used.
const redis = (context?.connectionState === 'connected' && socket.p3xrs?.ioredis)
? socket.p3xrs.ioredis
: null
console.info(
useOwnKey && apiKey
? 'ai-redis-query: using direct Groq API (own key)'
: 'ai-redis-query: using network proxy',
'— tools',
redis ? 'enabled' : 'disabled',
)
const result = await runAgenticLoop({
prompt: prompt.trim(),
context,
apiKey: apiKey || undefined,
useOwnKey: useOwnKey && Boolean(apiKey),
redis,
})
const response = {
status: 'ok',
command: result.command,
explanation: result.explanation,
toolTrail: result.toolTrail,
}
// Execute commands if requested AND we have a live Redis connection.
// The `redis` variable above is gated on connectionState==='connected';
// if absent, skip execution entirely — no stale client runs.
if (execute && redis) {
if (socket.p3xrs.readonly === true) {
response.executed = false
response.executionError = 'readonly-connection-mode'
} else {
const commandLines = result.command.split('\n').filter(line => line.trim().length > 0)
const executionResults = []
for (const cmd of commandLines) {
try {
const cmdResult = await executeRedisCommand(redis, cmd)
executionResults.push({ command: cmd, result: cmdResult })
} catch (execError) {
executionResults.push({ command: cmd, error: execError.message })
}
}
response.executed = true
response.results = executionResults
}
}
socket.emit(options.responseEvent, response)
} catch (e) {
console.error('ai-redis-query error', e)
let errorMsg = e.message || String(e)
if (e.status === 403 || errorMsg.includes('blocked_api_access')) {
errorMsg = 'blocked_api_access'
} else if (e.status === 429 || errorMsg.includes('rate_limit')) {
errorMsg = 'rate_limit'
}
socket.emit(options.responseEvent, {
status: 'error',
error: errorMsg,
})
}
}