RSS Git Download  Clone
Raw Blame History 12kB 308 lines
/**
 * AI tool-use — read-only Redis tools the LLM can call to investigate live state.
 *
 * Used by the agentic loop in redis-query.mjs. Each tool has:
 *   - schema: OpenAI-compatible tool definition passed to Groq
 *   - run:    executor that runs against the user's active Redis connection
 *
 * Safety rules (do NOT relax):
 *   - All tools are read-only — no SET, DEL, CONFIG SET, FLUSHDB, SCRIPT, EVAL, SHUTDOWN
 *   - SCAN is exposed (bounded COUNT), KEYS is never exposed
 *   - Per-turn tool-call cap is enforced by the caller (agenticLoop)
 *   - Results are truncated if huge — prevents token blowup
 */

const MAX_RESULT_CHARS = 8000

function truncate(s) {
    if (typeof s !== 'string') s = JSON.stringify(s)
    if (s.length <= MAX_RESULT_CHARS) return s
    return s.slice(0, MAX_RESULT_CHARS) + `\n… [truncated, original ${s.length} chars]`
}

function formatReply(reply) {
    if (reply === null || reply === undefined) return ''
    if (typeof reply === 'string') return truncate(reply)
    if (Buffer.isBuffer(reply)) return truncate(reply.toString('utf8'))
    if (Array.isArray(reply)) return truncate(JSON.stringify(reply, null, 2))
    return truncate(JSON.stringify(reply, null, 2))
}

export const TOOL_SCHEMAS = [
    {
        type: 'function',
        function: {
            name: 'redis_info',
            description: 'Run INFO [section] to get server/memory/stats/replication/clients/cpu/keyspace data. Use this to answer "why is memory high", "how many clients", "what is the uptime".',
            parameters: {
                type: 'object',
                properties: {
                    section: {
                        type: 'string',
                        description: 'Optional INFO section: server, clients, memory, stats, replication, cpu, modules, keyspace, all. Omit for default summary.',
                    },
                },
                required: [],
            },
        },
    },
    {
        type: 'function',
        function: {
            name: 'redis_memory_stats',
            description: 'Run MEMORY STATS for a detailed memory breakdown (overhead, per-type usage, allocator stats). Prefer this over INFO memory when the user asks about memory composition.',
            parameters: { type: 'object', properties: {}, required: [] },
        },
    },
    {
        type: 'function',
        function: {
            name: 'redis_slowlog_get',
            description: 'Run SLOWLOG GET [count] to retrieve the most recent slow queries with their execution duration. Use for "why is it slow", "what queries are slow".',
            parameters: {
                type: 'object',
                properties: {
                    count: {
                        type: 'integer',
                        description: 'Max entries to return (default 16, max 128).',
                    },
                },
                required: [],
            },
        },
    },
    {
        type: 'function',
        function: {
            name: 'redis_client_list',
            description: 'Run CLIENT LIST to see all connected clients (address, name, current command, idle time).',
            parameters: { type: 'object', properties: {}, required: [] },
        },
    },
    {
        type: 'function',
        function: {
            name: 'redis_config_get',
            description: 'Run CONFIG GET <pattern> to read server configuration. Common patterns: maxmemory, maxmemory-policy, save, timeout, *, client-output-buffer-limit.',
            parameters: {
                type: 'object',
                properties: {
                    pattern: {
                        type: 'string',
                        description: 'Config key glob, e.g. "maxmemory*" or "save".',
                    },
                },
                required: ['pattern'],
            },
        },
    },
    {
        type: 'function',
        function: {
            name: 'redis_dbsize',
            description: 'Run DBSIZE to count keys in the current database.',
            parameters: { type: 'object', properties: {}, required: [] },
        },
    },
    {
        type: 'function',
        function: {
            name: 'redis_latency_latest',
            description: 'Run LATENCY LATEST to see the most recent latency events recorded by the server.',
            parameters: { type: 'object', properties: {}, required: [] },
        },
    },
    {
        type: 'function',
        function: {
            name: 'redis_scan',
            description: 'Run SCAN <cursor> [MATCH <pattern>] [COUNT <n>] [TYPE <t>] to enumerate keys safely. NEVER exposes KEYS. Default COUNT 100, max 1000.',
            parameters: {
                type: 'object',
                properties: {
                    cursor: { type: 'string', description: 'Cursor (0 to start, use returned cursor to continue).' },
                    match: { type: 'string', description: 'Optional MATCH glob pattern.' },
                    count: { type: 'integer', description: 'Optional COUNT hint (1-1000, default 100).' },
                    type: { type: 'string', description: 'Optional TYPE filter: string, list, set, hash, zset, stream, ReJSON-RL.' },
                },
                required: ['cursor'],
            },
        },
    },
    {
        type: 'function',
        function: {
            name: 'redis_type',
            description: 'Run TYPE <key> to see the data type of a specific key.',
            parameters: {
                type: 'object',
                properties: {
                    key: { type: 'string', description: 'The Redis key name.' },
                },
                required: ['key'],
            },
        },
    },
    {
        type: 'function',
        function: {
            name: 'redis_ttl',
            description: 'Run TTL <key> to see seconds until a key expires. -1 = no expiry, -2 = does not exist.',
            parameters: {
                type: 'object',
                properties: {
                    key: { type: 'string', description: 'The Redis key name.' },
                },
                required: ['key'],
            },
        },
    },
    {
        type: 'function',
        function: {
            name: 'redis_memory_usage',
            description: 'Run MEMORY USAGE <key> to get bytes of memory a specific key uses (including overhead).',
            parameters: {
                type: 'object',
                properties: {
                    key: { type: 'string', description: 'The Redis key name.' },
                },
                required: ['key'],
            },
        },
    },
    {
        type: 'function',
        function: {
            name: 'redis_cluster_info',
            description: 'Run CLUSTER INFO to see cluster state (enabled, slot assignment, epoch, stats). Only meaningful in cluster mode.',
            parameters: { type: 'object', properties: {}, required: [] },
        },
    },
    {
        type: 'function',
        function: {
            name: 'redis_cluster_nodes',
            description: 'Run CLUSTER NODES to see cluster topology (masters, replicas, slot ranges). Only meaningful in cluster mode.',
            parameters: { type: 'object', properties: {}, required: [] },
        },
    },
    {
        type: 'function',
        function: {
            name: 'redis_acl_whoami',
            description: 'Run ACL WHOAMI to see which ACL user the connection is authenticated as.',
            parameters: { type: 'object', properties: {}, required: [] },
        },
    },
    {
        type: 'function',
        function: {
            name: 'redis_module_list',
            description: 'Run MODULE LIST to enumerate loaded Redis modules (ReJSON, RediSearch, RedisTimeSeries, RedisBloom, etc.) with versions.',
            parameters: { type: 'object', properties: {}, required: [] },
        },
    },
]

const TOOL_EXECUTORS = {
    redis_info: async (redis, args) => {
        const section = typeof args.section === 'string' && args.section.length > 0 ? args.section : ''
        const reply = section ? await redis.call('INFO', section) : await redis.call('INFO')
        return formatReply(reply)
    },
    redis_memory_stats: async (redis) => {
        const reply = await redis.call('MEMORY', 'STATS')
        return formatReply(reply)
    },
    redis_slowlog_get: async (redis, args) => {
        const count = Math.min(Math.max(parseInt(args.count, 10) || 16, 1), 128)
        const reply = await redis.call('SLOWLOG', 'GET', count)
        return formatReply(reply)
    },
    redis_client_list: async (redis) => {
        const reply = await redis.call('CLIENT', 'LIST')
        return formatReply(reply)
    },
    redis_config_get: async (redis, args) => {
        const pattern = String(args.pattern || '*')
        const reply = await redis.call('CONFIG', 'GET', pattern)
        return formatReply(reply)
    },
    redis_dbsize: async (redis) => {
        const reply = await redis.call('DBSIZE')
        return formatReply(reply)
    },
    redis_latency_latest: async (redis) => {
        const reply = await redis.call('LATENCY', 'LATEST')
        return formatReply(reply)
    },
    redis_scan: async (redis, args) => {
        const cursor = String(args.cursor ?? '0')
        const params = [cursor]
        if (typeof args.match === 'string' && args.match.length > 0) {
            params.push('MATCH', args.match)
        }
        const rawCount = parseInt(args.count, 10)
        const count = Number.isFinite(rawCount) ? Math.min(Math.max(rawCount, 1), 1000) : 100
        params.push('COUNT', count)
        if (typeof args.type === 'string' && args.type.length > 0) {
            params.push('TYPE', args.type)
        }
        const reply = await redis.call('SCAN', ...params)
        return formatReply(reply)
    },
    redis_type: async (redis, args) => {
        const key = String(args.key || '')
        if (!key) throw new Error('redis_type requires a key argument')
        const reply = await redis.call('TYPE', key)
        return formatReply(reply)
    },
    redis_ttl: async (redis, args) => {
        const key = String(args.key || '')
        if (!key) throw new Error('redis_ttl requires a key argument')
        const reply = await redis.call('TTL', key)
        return formatReply(reply)
    },
    redis_memory_usage: async (redis, args) => {
        const key = String(args.key || '')
        if (!key) throw new Error('redis_memory_usage requires a key argument')
        const reply = await redis.call('MEMORY', 'USAGE', key)
        return formatReply(reply)
    },
    redis_cluster_info: async (redis) => {
        const reply = await redis.call('CLUSTER', 'INFO')
        return formatReply(reply)
    },
    redis_cluster_nodes: async (redis) => {
        const reply = await redis.call('CLUSTER', 'NODES')
        return formatReply(reply)
    },
    redis_acl_whoami: async (redis) => {
        const reply = await redis.call('ACL', 'WHOAMI')
        return formatReply(reply)
    },
    redis_module_list: async (redis) => {
        const reply = await redis.call('MODULE', 'LIST')
        return formatReply(reply)
    },
}

/**
 * Run a single tool call. Returns { result, ms, ok, error? }.
 * Never throws — any error is packaged into the result so the LLM can see and recover.
 */
export async function runTool(redis, name, args) {
    const t0 = Date.now()
    try {
        const fn = TOOL_EXECUTORS[name]
        if (!fn) {
            return { ok: false, error: `Unknown tool: ${name}`, ms: Date.now() - t0 }
        }
        const result = await fn(redis, args || {})
        return { ok: true, result, ms: Date.now() - t0 }
    } catch (e) {
        return { ok: false, error: e.message || String(e), ms: Date.now() - t0 }
    }
}