Skip to main content

netget/client/redis/
actions.rs

1//! Redis client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter,
7};
8use crate::protocol::log_template::LogTemplate;
9use crate::protocol::EventType;
10use crate::state::app_state::AppState;
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::LazyLock;
14
15/// Redis client connected event
16pub static REDIS_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
17    EventType::new(
18        "redis_connected",
19        "Redis client successfully connected to server",
20        json!({
21            "type": "execute_redis_command",
22            "command": "GET mykey"
23        }),
24    )
25    .with_parameters(vec![
26        Parameter {
27            name: "remote_addr".to_string(),
28            type_hint: "string".to_string(),
29            description: "Redis server address".to_string(),
30            required: true,
31        },
32        Parameter {
33            name: "cluster_nodes".to_string(),
34            type_hint: "array".to_string(),
35            description: "Cluster mode only: every master (host:port) in the slot map read at \
36                          connect"
37                .to_string(),
38            required: false,
39        },
40    ])
41});
42
43/// Redis client response received event
44pub static REDIS_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
45    EventType::new(
46        "redis_response_received",
47        "Response received from Redis server",
48        json!({
49            "type": "execute_redis_command",
50            "command": "SET result OK"
51        }),
52    )
53    .with_parameters(vec![
54        Parameter {
55            name: "response".to_string(),
56            type_hint: "string".to_string(),
57            description: "One complete reply rendered as redis-cli prints it: OK, (integer) 3, \
58                          \"value\", (nil), (error) ERR ...; arrays and maps as JSON"
59                .to_string(),
60            required: true,
61        },
62        Parameter {
63            name: "reply_type".to_string(),
64            type_hint: "string".to_string(),
65            description: "simple_string, error, integer, bulk_string, null, array, map, set, \
66                          push, boolean, double, big_number or verbatim_string"
67                .to_string(),
68            required: true,
69        },
70        Parameter {
71            name: "value".to_string(),
72            type_hint: "any".to_string(),
73            description: "The reply as JSON: a string, an integer, null, an array, an object \
74                          for a map, or {\"error\": \"...\"} for an error"
75                .to_string(),
76            required: true,
77        },
78        Parameter {
79            name: "node".to_string(),
80            type_hint: "string".to_string(),
81            description: "Cluster mode only: the node (host:port) that gave this answer"
82                .to_string(),
83            required: false,
84        },
85        Parameter {
86            name: "slot".to_string(),
87            type_hint: "number".to_string(),
88            description: "Cluster mode only: the hash slot the command's keys hash to".to_string(),
89            required: false,
90        },
91        Parameter {
92            name: "redirects".to_string(),
93            type_hint: "array".to_string(),
94            description: "Cluster mode only: each MOVED/ASK redirection the client followed \
95                          for this command"
96                .to_string(),
97            required: false,
98        },
99        Parameter {
100            name: "redirects_exhausted".to_string(),
101            type_hint: "boolean".to_string(),
102            description: "Cluster mode only: true when the command was still being redirected \
103                          after the redirection limit, so the reply is the last redirection error"
104                .to_string(),
105            required: false,
106        },
107    ])
108});
109
110fn param(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
111    Parameter {
112        name: name.to_string(),
113        type_hint: type_hint.to_string(),
114        description: description.to_string(),
115        required,
116    }
117}
118
119/// A message delivered on a channel this client subscribed to.
120pub static REDIS_CLIENT_MESSAGE_EVENT: LazyLock<EventType> =
121    LazyLock::new(|| {
122        EventType::new(
123            "redis_message_received",
124            "A pub/sub message arrived on a channel or pattern this client subscribed to",
125            json!({
126                "type": "redis_publish",
127                "channel": "replies",
128                "message": "got it"
129            }),
130        )
131        .with_parameters(vec![
132        param(
133            "kind",
134            "string",
135            "message (a SUBSCRIBE channel), pmessage (matched a PSUBSCRIBE pattern) or smessage \
136             (a shard channel)",
137            true,
138        ),
139        param("channel", "string", "The channel the message was published on", true),
140        param(
141            "pattern",
142            "string",
143            "For a pmessage, the pattern that matched; absent otherwise",
144            false,
145        ),
146        param("message", "string", "The message text", true),
147        param(
148            "subscriptions",
149            "object",
150            "What this connection is subscribed to: channels, patterns, shard_channels",
151            true,
152        ),
153        param(
154            "protocol",
155            "number",
156            "The connection's RESP version: 3 if the message came as a push",
157            true,
158        ),
159    ])
160    });
161
162/// A subscribe or unsubscribe the server confirmed.
163pub static REDIS_CLIENT_SUBSCRIPTION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
164    EventType::new(
165        "redis_subscription_changed",
166        "The server confirmed a subscribe or unsubscribe",
167        json!({
168            "type": "wait_for_more"
169        }),
170    )
171    .with_parameters(vec![
172        param(
173            "kind",
174            "string",
175            "subscribe, psubscribe, ssubscribe, unsubscribe, punsubscribe or sunsubscribe",
176            true,
177        ),
178        param(
179            "channel",
180            "string",
181            "The channel or pattern confirmed; null when an unsubscribe found nothing to drop",
182            false,
183        ),
184        param(
185            "count",
186            "number",
187            "Subscriptions the server says this connection now holds (shard channels are \
188             counted apart from channels and patterns)",
189            true,
190        ),
191        param(
192            "subscriptions",
193            "object",
194            "What this connection is subscribed to: channels, patterns, shard_channels",
195            true,
196        ),
197    ])
198});
199
200/// Redis client protocol action handler
201pub struct RedisClientProtocol;
202
203impl RedisClientProtocol {
204    pub fn new() -> Self {
205        Self
206    }
207}
208
209fn names_param(name: &str, what: &str) -> Parameter {
210    param(name, "array", what, false)
211}
212
213/// `redis_subscribe`, `redis_unsubscribe` and `redis_publish`: pub/sub as structured actions,
214/// so a channel name with a space needs no quoting and the client knows what it is doing.
215fn pubsub_actions() -> Vec<ActionDefinition> {
216    vec![
217        ActionDefinition {
218            name: "redis_subscribe".to_string(),
219            description: "Subscribe to channels (SUBSCRIBE), glob patterns (PSUBSCRIBE) or shard \
220                          channels (SSUBSCRIBE). Each confirmation arrives as \
221                          redis_subscription_changed and each message as redis_message_received. \
222                          Not available in cluster mode."
223                .to_string(),
224            parameters: vec![
225                names_param("channels", "Channel names to subscribe to"),
226                names_param("patterns", "Glob patterns to subscribe to, e.g. news.*"),
227                names_param("shard_channels", "Shard channel names to subscribe to"),
228            ],
229            example: json!({"type": "redis_subscribe", "channels": ["news"], "patterns": ["alerts.*"]}),
230            log_template: Some(
231                LogTemplate::new()
232                    .with_info("Redis client SUBSCRIBE {channels} {patterns}")
233                    .with_debug("Redis client redis_subscribe"),
234            ),
235        },
236        ActionDefinition {
237            name: "redis_unsubscribe".to_string(),
238            description:
239                "Unsubscribe. Name channels, patterns or shard_channels to drop those; an \
240                          empty list drops every subscription of that kind; no field at all drops \
241                          everything this connection holds."
242                    .to_string(),
243            parameters: vec![
244                names_param("channels", "Channels to unsubscribe from ([] for all)"),
245                names_param("patterns", "Patterns to unsubscribe from ([] for all)"),
246                names_param(
247                    "shard_channels",
248                    "Shard channels to unsubscribe from ([] for all)",
249                ),
250            ],
251            example: json!({"type": "redis_unsubscribe", "channels": ["news"]}),
252            log_template: Some(
253                LogTemplate::new()
254                    .with_info("Redis client UNSUBSCRIBE")
255                    .with_debug("Redis client redis_unsubscribe"),
256            ),
257        },
258        ActionDefinition {
259            name: "redis_publish".to_string(),
260            description: "Publish a message to a channel (PUBLISH, or SPUBLISH with shard). While \
261                          this connection is subscribed on RESP2 it cannot publish itself, so the \
262                          client publishes over a short second connection to the same server."
263                .to_string(),
264            parameters: vec![
265                param("channel", "string", "The channel to publish on", true),
266                param("message", "string", "The message text", true),
267                param(
268                    "shard",
269                    "boolean",
270                    "true to publish on a shard channel (SPUBLISH)",
271                    false,
272                ),
273            ],
274            example: json!({"type": "redis_publish", "channel": "news", "message": "hello"}),
275            log_template: Some(
276                LogTemplate::new()
277                    .with_info("Redis client PUBLISH {channel}")
278                    .with_debug("Redis client redis_publish: {channel}"),
279            ),
280        },
281    ]
282}
283
284// Implement Protocol trait (common functionality)
285impl Protocol for RedisClientProtocol {
286    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
287        vec![
288            crate::llm::actions::ParameterDefinition {
289                name: "cluster".to_string(),
290                type_hint: "boolean".to_string(),
291                description: "Treat remote_addr as one node of a Redis Cluster: read the slot map \
292                              with CLUSTER SLOTS, send each command to the node that owns its \
293                              keys' hash slot, and follow MOVED (refreshing the map) and ASK \
294                              redirections, at most a fixed number per command. Commands run one \
295                              at a time; pub/sub subscriptions are not available in this mode."
296                    .to_string(),
297                required: false,
298                example: json!(true),
299                default: None,
300            },
301            crate::llm::actions::ParameterDefinition {
302                name: "resp3".to_string(),
303                type_hint: "boolean".to_string(),
304                description: "Send HELLO 3 on connect and speak RESP3, so pub/sub messages arrive \
305                              as pushes and the connection can still run ordinary commands while \
306                              subscribed. Connecting fails if the server refuses RESP3. Not \
307                              available together with cluster."
308                    .to_string(),
309                required: false,
310                example: json!(true),
311                default: None,
312            },
313        ]
314    }
315    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
316        let mut actions = pubsub_actions();
317        actions.extend(vec![
318            ActionDefinition {
319                name: "execute_redis_command".to_string(),
320                description: "Execute a Redis command".to_string(),
321                parameters: vec![Parameter {
322                    name: "command".to_string(),
323                    type_hint: "string".to_string(),
324                    description: "Redis command as typed at redis-cli (e.g. GET key, \
325                                  SET greeting \"hello world\"). Quote an argument that contains \
326                                  spaces with double or single quotes."
327                        .to_string(),
328                    required: true,
329                }],
330                example: json!({
331                    "type": "execute_redis_command",
332                    "command": "GET mykey"
333                }),
334                log_template: None,
335            },
336            ActionDefinition {
337                name: "disconnect".to_string(),
338                description: "Disconnect from the Redis server".to_string(),
339                parameters: vec![],
340                example: json!({
341                    "type": "disconnect"
342                }),
343                log_template: None,
344            },
345        ]);
346        actions
347    }
348    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
349        let mut actions = pubsub_actions();
350        actions.extend(vec![
351            ActionDefinition {
352                name: "execute_redis_command".to_string(),
353                description: "Execute a Redis command in response to received data".to_string(),
354                parameters: vec![Parameter {
355                    name: "command".to_string(),
356                    type_hint: "string".to_string(),
357                    description: "Redis command".to_string(),
358                    required: true,
359                }],
360                example: json!({
361                    "type": "execute_redis_command",
362                    "command": "SET result OK"
363                }),
364                log_template: None,
365            },
366            ActionDefinition {
367                name: "wait_for_more".to_string(),
368                description:
369                    "Take no action and wait for more data from the server. Use when the reply so \
370                 far is incomplete."
371                        .to_string(),
372                parameters: vec![],
373                example: json!({ "type": "wait_for_more" }),
374                log_template: None,
375            },
376        ]);
377        actions
378    }
379    fn protocol_name(&self) -> &'static str {
380        "Redis"
381    }
382    /// The **statics**, not fresh `EventType::new(..)` copies.
383    ///
384    /// This used to build a second, different `EventType` for each id: no parameters, and a
385    /// `{"type": "placeholder"}` example. Those are what the registry and the model-facing
386    /// docs surface, while the emitted events carry the statics above — two sources of truth
387    /// for the same event id, and they had already drifted.
388    fn get_event_types(&self) -> Vec<EventType> {
389        vec![
390            REDIS_CLIENT_CONNECTED_EVENT.clone(),
391            REDIS_CLIENT_RESPONSE_RECEIVED_EVENT.clone(),
392            REDIS_CLIENT_MESSAGE_EVENT.clone(),
393            REDIS_CLIENT_SUBSCRIPTION_EVENT.clone(),
394        ]
395    }
396    fn stack_name(&self) -> &'static str {
397        "ETH>IP>TCP>Redis"
398    }
399    fn keywords(&self) -> Vec<&'static str> {
400        vec!["redis", "redis client", "connect to redis"]
401    }
402    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
403        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
404
405        ProtocolMetadataV2::builder()
406            .state(DevelopmentState::Beta)
407            .implementation(
408                "Hand-written RESP on tokio (src/client/redis/resp.rs): commands split the way \
409                 redis-cli splits them and sent as RESP arrays; one complete RESP2/RESP3 reply \
410                 per event, bounded in depth, declared size and element count",
411            )
412            .llm_control("Full control over Redis commands")
413            .e2e_testing(
414                "Pub/sub (pubsub_real_server_test.rs, RESP2 and RESP3): a message redis-cli \
415                 publishes to a real redis-server reaches the model as redis_message_received \
416                 (pattern messages with their pattern), and the model's answer, a publish of \
417                 its own, is printed by a redis-cli SUBSCRIBE; the model's unsubscribe empties \
418                 the server's PUBSUB NUMSUB/NUMPAT. Cluster (cluster_real_server_test.rs): a \
419                 real three-node Redis Cluster from redis-cli --cluster create; after a \
420                 CLUSTER SETSLOT reshard the client follows MOVED to the new owner, and through \
421                 a migrating slot it follows ASK with ASKING, each read back with redis-cli. \
422                 tests/client/redis/real_server_test.rs, 9 LLM calls, against a real \
423                 redis-server (Valkey locally, Redis on Ubuntu) read back with redis-cli. The \
424                 model SETs a quoted value containing a space, GETs it, and RPUSHes a line built \
425                 from the reply it was shown; a hash written by redis-cli reaches the model as \
426                 one four-element array event. Every reply is matched on its parsed reply_type \
427                 and value, so a reply split across events fails. Not #[ignore]d, and a missing \
428                 redis-server or redis-cli fails the test rather than skipping it. \
429                 resp_reader_test.rs pins the framing and each bound byte by byte.",
430            )
431            .notes(
432                "Pub/sub: redis_subscribe/redis_unsubscribe/redis_publish, each delivered message \
433                 a redis_message_received event (channel, pattern, message) on RESP2 and RESP3 \
434                 (resp3 parameter); a subscribed RESP2 connection publishes over a short second \
435                 connection. Cluster mode (cluster parameter): slot map from CLUSTER SLOTS, \
436                 commands routed by hash slot, MOVED (with a map refresh) and ASK (with ASKING) \
437                 followed, at most 5 redirections per command; commands run one at a time and \
438                 pub/sub subscriptions are refused there. No AUTH or SELECT startup parameters: \
439                 both are reachable only as explicit commands. Non-UTF-8 bulk strings reach the \
440                 model through a lossy conversion. No connect timeout.",
441            )
442            .build()
443    }
444    fn description(&self) -> &'static str {
445        "Redis client for key-value operations"
446    }
447    fn example_prompt(&self) -> &'static str {
448        "Connect to Redis at localhost:6379 and get the value of 'user:123'"
449    }
450    fn group_name(&self) -> &'static str {
451        "Database"
452    }
453
454    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
455        use crate::llm::actions::StartupExamples;
456        use serde_json::json;
457
458        StartupExamples::new(
459            // LLM mode: LLM controls Redis commands
460            json!({
461                "type": "open_client",
462                "remote_addr": "localhost:6379",
463                "base_stack": "redis",
464                "instruction": "Get the value of 'user:123' and report its contents"
465            }),
466            // Script mode: Code-based deterministic responses
467            json!({
468                "type": "open_client",
469                "remote_addr": "localhost:6379",
470                "base_stack": "redis",
471                "event_handlers": [{
472                    "event_pattern": "redis_response_received",
473                    "handler": {
474                        "type": "script",
475                        "language": "python",
476                        "code": "<redis_client_handler>"
477                    }
478                }]
479            }),
480            // Static mode: Fixed Redis command on connect
481            json!({
482                "type": "open_client",
483                "remote_addr": "localhost:6379",
484                "base_stack": "redis",
485                "event_handlers": [
486                    {
487                        "event_pattern": "redis_connected",
488                        "handler": {
489                            "type": "static",
490                            "actions": [{
491                                "type": "execute_redis_command",
492                                "command": "GET status"
493                            }]
494                        }
495                    },
496                    {
497                        "event_pattern": "redis_response_received",
498                        "handler": {
499                            "type": "static",
500                            "actions": [{
501                                "type": "disconnect"
502                            }]
503                        }
504                    }
505                ]
506            }),
507        )
508    }
509}
510
511// Implement Client trait (client-specific functionality)
512impl Client for RedisClientProtocol {
513    fn connect(
514        &self,
515        ctx: crate::protocol::ConnectContext,
516    ) -> std::pin::Pin<
517        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
518    > {
519        Box::pin(async move {
520            use crate::client::redis::RedisClient;
521            let flag = |name: &str| -> anyhow::Result<bool> {
522                Ok(ctx
523                    .startup_params
524                    .as_ref()
525                    .map(|p| p.get_optional_bool(name))
526                    .transpose()?
527                    .flatten()
528                    .unwrap_or(false))
529            };
530            let options = crate::client::redis::RedisClientOptions {
531                cluster: flag("cluster")?,
532                resp3: flag("resp3")?,
533            };
534            RedisClient::connect_with_llm_actions(
535                ctx.remote_addr,
536                ctx.llm_client,
537                ctx.state,
538                ctx.status_tx,
539                ctx.client_id,
540                options,
541            )
542            .await
543        })
544    }
545    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
546        let action_type = action
547            .get("type")
548            .and_then(|v| v.as_str())
549            .context("Missing 'type' field in action")?;
550
551        match action_type {
552            "execute_redis_command" => {
553                let command = action
554                    .get("command")
555                    .and_then(|v| v.as_str())
556                    .context("Missing 'command' field")?
557                    .to_string();
558                // Refused here, before anything reaches the wire: an unbalanced quote has no
559                // honest reading, and guessing one would send a command the model did not write.
560                crate::client::redis::resp::split_command(&command)?;
561
562                Ok(ClientActionResult::Custom {
563                    name: "redis_command".to_string(),
564                    data: json!({
565                        "command": command,
566                    }),
567                })
568            }
569            "redis_subscribe" => {
570                let mut commands = Vec::new();
571                for (field, verb) in [
572                    ("channels", "SUBSCRIBE"),
573                    ("patterns", "PSUBSCRIBE"),
574                    ("shard_channels", "SSUBSCRIBE"),
575                ] {
576                    let names = string_list(&action, field)?;
577                    if !names.is_empty() {
578                        commands.push(command_with(verb, &names));
579                    }
580                }
581                anyhow::ensure!(
582                    !commands.is_empty(),
583                    "redis_subscribe names no channels, patterns or shard_channels"
584                );
585                Ok(ClientActionResult::Custom {
586                    name: "redis_commands".to_string(),
587                    data: json!({ "commands": commands }),
588                })
589            }
590            "redis_unsubscribe" => {
591                let mut commands = Vec::new();
592                for (field, verb) in [
593                    ("channels", "UNSUBSCRIBE"),
594                    ("patterns", "PUNSUBSCRIBE"),
595                    ("shard_channels", "SUNSUBSCRIBE"),
596                ] {
597                    if action.get(field).is_some() {
598                        commands.push(command_with(verb, &string_list(&action, field)?));
599                    }
600                }
601                if commands.is_empty() {
602                    return Ok(ClientActionResult::Custom {
603                        name: "redis_unsubscribe_all".to_string(),
604                        data: json!({}),
605                    });
606                }
607                Ok(ClientActionResult::Custom {
608                    name: "redis_commands".to_string(),
609                    data: json!({ "commands": commands }),
610                })
611            }
612            "redis_publish" => {
613                let channel = action
614                    .get("channel")
615                    .and_then(|v| v.as_str())
616                    .context("Missing 'channel'")?;
617                let message = action
618                    .get("message")
619                    .and_then(|v| v.as_str())
620                    .context("Missing 'message'")?;
621                let shard = action
622                    .get("shard")
623                    .and_then(|v| v.as_bool())
624                    .unwrap_or(false);
625                let verb = if shard { "SPUBLISH" } else { "PUBLISH" };
626                Ok(ClientActionResult::Custom {
627                    name: "redis_publish".to_string(),
628                    data: json!({ "args": [verb, channel, message] }),
629                })
630            }
631            "disconnect" => Ok(ClientActionResult::Disconnect),
632            // CLAUDE.md lists wait_for_more as one of the three standard client
633            // sync actions, and ftp/imap/doh/rip/tcp/dns all implement it. These two
634            // did not, so returning it was an "unknown action" error the client then
635            // swallowed -- two suites passed while sending an action the client
636            // could never obey.
637            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
638            _ => Err(anyhow::anyhow!(
639                "Unknown Redis client action: {}",
640                action_type
641            )),
642        }
643    }
644}
645
646/// A JSON array of strings from an action field (absent is empty).
647fn string_list(action: &serde_json::Value, field: &str) -> Result<Vec<String>> {
648    match action.get(field) {
649        None | Some(serde_json::Value::Null) => Ok(Vec::new()),
650        Some(serde_json::Value::Array(items)) => items
651            .iter()
652            .map(|v| {
653                v.as_str()
654                    .map(str::to_string)
655                    .with_context(|| format!("'{field}' holds a non-string"))
656            })
657            .collect(),
658        Some(_) => Err(anyhow::anyhow!("'{field}' is an array of strings")),
659    }
660}
661
662fn command_with(verb: &str, names: &[String]) -> Vec<String> {
663    std::iter::once(verb.to_string())
664        .chain(names.iter().cloned())
665        .collect()
666}