Skip to main content

netget/server/redis/
actions.rs

1//! Redis protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::server::connection::ConnectionId;
10use crate::state::app_state::AppState;
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::atomic::{AtomicU8, Ordering};
14use std::sync::{Arc, LazyLock};
15use tokio::sync::mpsc;
16use tracing::{debug, warn};
17
18/// Redis protocol action handler
19pub struct RedisProtocol {
20    #[allow(dead_code)]
21    connection_id: ConnectionId,
22    #[allow(dead_code)]
23    app_state: Arc<AppState>,
24    status_tx: mpsc::UnboundedSender<String>,
25    /// The RESP version this connection speaks: 2 until a `HELLO 3`. Shared by the read loop,
26    /// the peer-injection task and the pub/sub hub, which encodes each delivered message for
27    /// its subscriber's version.
28    protover: Arc<AtomicU8>,
29    /// The running server's pub/sub hub and cluster topology. `None` on the registry's
30    /// stateless instance, which reaches them through `execute_action_with_state` instead.
31    shared: Option<Arc<super::RedisShared>>,
32}
33
34impl RedisProtocol {
35    pub fn new(
36        connection_id: ConnectionId,
37        app_state: Arc<AppState>,
38        status_tx: mpsc::UnboundedSender<String>,
39    ) -> Self {
40        Self {
41            connection_id,
42            app_state,
43            status_tx,
44            protover: Arc::new(AtomicU8::new(2)),
45            shared: None,
46        }
47    }
48
49    /// The instance one connection encodes through, bound to its server's shared state.
50    pub fn for_connection(
51        connection_id: ConnectionId,
52        app_state: Arc<AppState>,
53        status_tx: mpsc::UnboundedSender<String>,
54        shared: Arc<super::RedisShared>,
55    ) -> Self {
56        Self {
57            shared: Some(shared),
58            ..Self::new(connection_id, app_state, status_tx)
59        }
60    }
61
62    /// The RESP version replies are encoded for (2 or 3).
63    pub fn protocol_version(&self) -> u8 {
64        self.protover.load(Ordering::Relaxed)
65    }
66
67    pub fn set_protocol_version(&self, version: u8) {
68        self.protover.store(version, Ordering::Relaxed);
69    }
70
71    /// The shared version cell, handed to the pub/sub hub with a subscription.
72    pub fn protover_cell(&self) -> Arc<AtomicU8> {
73        self.protover.clone()
74    }
75}
76
77// Implement Protocol trait (common functionality)
78impl Protocol for RedisProtocol {
79    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
80        vec![
81                crate::llm::actions::ParameterDefinition {
82                    name: "send_first".to_string(),
83                    type_hint: "boolean".to_string(),
84                    description: "Unsupported by this server and refused if set to true. Redis: a RESP server answers commands and never speaks first; an unsolicited reply would desynchronise every client's command pipeline."
85                        .to_string(),
86                    required: false,
87                    example: json!(false),
88                    default: None,
89                },
90                crate::llm::actions::ParameterDefinition {
91                    name: "first_byte_timeout_secs".to_string(),
92                    type_hint: "number".to_string(),
93                    description: "Seconds a connected peer may send no command at all before \
94                                  the server closes it. Default 300, matching the window a \
95                                  `manual` rule gives a human to answer one event - the peer \
96                                  is often NetGet's own Redis client, which opens a socket and \
97                                  sends nothing until someone uses [ send message ]. Lower it \
98                                  (30 was the old value) for a listener exposed to strangers."
99                        .to_string(),
100                    required: false,
101                    example: json!(300),
102                    default: Some(serde_json::json!(super::FIRST_COMMAND_READ_TIMEOUT.as_secs())),
103                },
104                crate::llm::actions::ParameterDefinition {
105                    name: "idle_timeout_secs".to_string(),
106                    type_hint: "number".to_string(),
107                    description: "Seconds an established connection may go without a further \
108                                  command before the server closes it. Default 300. Real Redis \
109                                  ships `timeout 0` and every pooling client holds connections \
110                                  unused between bursts, so raise it rather than lower it if \
111                                  your clients pool."
112                        .to_string(),
113                    required: false,
114                    example: json!(300),
115                    default: Some(serde_json::json!(super::IDLE_BETWEEN_COMMANDS_TIMEOUT.as_secs())),
116                },
117                crate::llm::actions::ParameterDefinition {
118                    name: "cluster_nodes".to_string(),
119                    type_hint: "array".to_string(),
120                    description: "Run in cluster mode as one master of the Redis Cluster this \
121                                  array describes. Each node is {\"host\", \"port\", \
122                                  \"slots\": [\"0-5460\", ...], \"id\" (optional, 40 hex), \
123                                  \"myself\": true for this server}; this server's own host and \
124                                  port default to where it is listening. The server then answers \
125                                  CLUSTER SLOTS/SHARDS/NODES/INFO/KEYSLOT/MYID itself and \
126                                  redirects a key in a slot another node owns with -MOVED, so a \
127                                  cluster client can navigate several NetGet servers given the \
128                                  same array. Omit it for a standalone server."
129                        .to_string(),
130                    required: false,
131                    example: json!([
132                        {"host": "127.0.0.1", "port": 7000, "slots": ["0-8191"], "myself": true},
133                        {"host": "127.0.0.1", "port": 7001, "slots": ["8192-16383"]}
134                    ]),
135                    default: None,
136                },
137            ]
138    }
139    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
140        // Both reach the running server through `execute_action_with_state`. (A
141        // `list_redis_connections` action used to be declared here; its executor returned a
142        // hardcoded empty list, so it only ever misled the model.)
143        vec![
144            redis_publish_message_action(),
145            redis_cluster_set_slot_action(),
146        ]
147    }
148    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
149        vec![
150            redis_simple_string_action(),
151            redis_bulk_string_action(),
152            redis_array_action(),
153            redis_integer_action(),
154            redis_error_action(),
155            redis_null_action(),
156            close_this_connection_action(),
157            redis_accept_subscription_action(),
158            redis_deliver_message_action(),
159            redis_publish_message_action(),
160            redis_cluster_set_slot_action(),
161        ]
162        .into_iter()
163        .chain(resp3_actions())
164        .collect()
165    }
166    fn protocol_name(&self) -> &'static str {
167        "Redis"
168    }
169    fn get_event_types(&self) -> Vec<EventType> {
170        get_redis_event_types()
171    }
172    fn stack_name(&self) -> &'static str {
173        "ETH>IP>TCP>Redis"
174    }
175    fn keywords(&self) -> Vec<&'static str> {
176        vec!["redis"]
177    }
178    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
179        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
180
181        ProtocolMetadataV2::builder()
182            // Beta: exercised against two independent clients that share no code with each
183            // other or with redis-protocol — redis-rs (Rust) and redis-cli (C). Not Stable:
184            // Stable additionally wants spec compliance and scripting support reviewed, which
185            // has not been done here.
186            .state(DevelopmentState::Beta)
187            .well_known_port(6379)
188            .implementation("redis-protocol v6.0 (RESP2 request parsing), manual RESP2/RESP3 encoding per connection after HELLO")
189            .llm_control(
190                "Every data command (GET, SET, INCR, ...); pub/sub policy (whether a SUBSCRIBE is \
191                 allowed, whether a PUBLISH is delivered and what it returns) and messages of its \
192                 own; cluster slot ownership",
193            )
194            .e2e_testing(
195                "Two independent clients. redis-rs (tests/server/redis/e2e_test.rs, \
196                 llm_failure_test.rs): one test per RESP2 reply type, each deserialised into \
197                 the Rust type the test asked for. redis-cli — the C client, valkey-cli here \
198                 (real_client_test.rs, hard-fails when the binary is absent): seven commands on \
199                 one connection, asserted as one ordered list of what --no-raw printed, so each \
200                 reply type is read off the wire rather than coerced (simple string, bulk \
201                 string quoted, (integer) n, a two-element multi-bulk, (nil) distinguished from \
202                 an empty bulk string, and (error) …) and a reply landing against the wrong \
203                 command fails as a mismatched line. \
204                 RESP3 (resp3_test.rs): HELLO 3 answered in Rust, every RESP3 type read by \
205                 redis-cli -3 and by redis-py 5's own RESP3 parser (with credentials sent in \
206                 HELLO and a push ahead of a reply), and the RESP2 downgrades by both. \
207                 Pub/sub (pubsub_test.rs): redis-cli subscribers on RESP2 and RESP3 and on a \
208                 pattern receive what redis-cli publishes and what the model publishes, and \
209                 redis-rs's RESP2 PubSub and RESP3 push sender read message and pmessage; the \
210                 model's refusal and withholding hold. Cluster (cluster_test.rs): three servers \
211                 sharing a cluster_nodes topology are navigated by redis-cli -c, pass \
212                 redis-cli --cluster check, and serve redis-rs's ClusterClient, which follows \
213                 MOVED after the model moves a slot; ASK/ASKING for a migrating slot. Hash slots \
214                 and glob matching equal a real redis-server's CLUSTER KEYSLOT and KEYS \
215                 (keys_oracle_test.rs). Every pub/sub bound and the slow-consumer disconnect \
216                 are pinned from raw sockets (pubsub_bounds_test.rs). \
217                 NOT proven: inline commands, pipelining \
218                 (neither client sends two commands in one write), AUTH/SELECT/MULTI, \
219                 cluster-wide PUBLISH (there is no bus), and TLS.",
220            )
221            .notes("RESP2, and RESP3 after HELLO 3 (no attributes, no streamed types, no client-side caching); HELLO with AUTH is judged by the handler. Pub/sub: SUBSCRIBE/PSUBSCRIBE/SSUBSCRIBE (model-approved), UNSUBSCRIBE family, PUBLISH/SPUBLISH (model decides delivery), PUBSUB introspection, RESP2 subscribed mode, redis_publish_message for the model's own messages; bounded per connection, per server and per message, and a subscriber 8 MiB behind is disconnected. Cluster mode (cluster_nodes): CLUSTER SLOTS/SHARDS/NODES/INFO/KEYSLOT/MYID, MOVED/ASK/CROSSSLOT/CLUSTERDOWN routing by CRC16 hash slot, redis_cluster_set_slot; no bus, no replicas. No SELECT/MULTI semantics, no inline commands")
222            .max_inbound_bytes(crate::server::redis::MAX_PENDING_FRAME_BYTES)
223            .build()
224    }
225    fn description(&self) -> &'static str {
226        "Redis in-memory data store"
227    }
228    fn example_prompt(&self) -> &'static str {
229        "Start a Redis server on port 6379"
230    }
231    fn group_name(&self) -> &'static str {
232        "Database"
233    }
234
235    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
236        use crate::llm::actions::StartupExamples;
237        use serde_json::json;
238
239        // Deterministic RESP replies keyed off the command verb. The 'command'
240        // event field is the full command line, so split off the first word.
241        let script = r#"import json, sys
242data = json.load(sys.stdin)
243event = data["event"]
244if data["event_type_id"] == "redis_command":
245    parts = event.get("command", "").split()
246    verb = parts[0].upper() if parts else ""
247    if verb == "PING":
248        actions = [{"type": "redis_simple_string", "value": "PONG"}]
249    elif verb == "SET":
250        actions = [{"type": "redis_simple_string", "value": "OK"}]
251    elif verb == "GET":
252        actions = [{"type": "redis_bulk_string", "value": "cached-value"}]
253    elif verb == "INCR":
254        actions = [{"type": "redis_integer", "value": 1}]
255    else:
256        actions = [{"type": "redis_error", "message": "ERR unknown command"}]
257else:
258    actions = []
259print(json.dumps({"actions": actions}))"#;
260
261        StartupExamples::new(
262            // LLM mode: model acts as a coherent key-value store across commands.
263            json!({
264                "type": "open_server",
265                "port": 6379,
266                "base_stack": "redis",
267                "instruction": "Act as a Redis server backing a session cache. Remember values set with SET and return them on GET, treat INCR/DECR as maintaining running counters, and answer TTL/EXISTS consistently with what has been stored so far in this connection."
268            }),
269            // Script mode: fixed replies per command verb, no LLM call.
270            json!({
271                "type": "open_server",
272                "port": 6379,
273                "base_stack": "redis",
274                "event_handlers": [{
275                    "event_pattern": "redis_command",
276                    "handler": {
277                        "type": "script",
278                        "language": "python",
279                        "code": script
280                    }
281                }]
282            }),
283            // Static mode: Fixed responses
284            json!({
285                "type": "open_server",
286                "port": 6379,
287                "base_stack": "redis",
288                "event_handlers": [{
289                    "event_pattern": "redis_command",
290                    "handler": {
291                        "type": "static",
292                        "actions": [{
293                            "type": "redis_simple_string",
294                            "value": "OK"
295                        }]
296                    }
297                }]
298            }),
299        )
300    }
301}
302
303// Implement Server trait (server-specific functionality)
304impl Server for RedisProtocol {
305    fn spawn(
306        &self,
307        ctx: crate::protocol::SpawnContext,
308    ) -> std::pin::Pin<
309        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
310    > {
311        Box::pin(async move {
312            use crate::server::redis::RedisServer;
313            // `send_first` stays declared so a caller that passes `false` still validates - an
314            // undeclared key is refused outright - but `true` is refused rather than ignored.
315            // It was read here, threaded through spawn, and dropped at the far end as
316            // `_send_first`: a knob advertised to the model, plumbed through three hops, and
317            // silently doing nothing. Redis: a RESP server answers commands and never speaks first; an unsolicited reply would desynchronise every client's command pipeline.
318            let send_first = ctx
319                .startup_params
320                .as_ref()
321                .map(|p| p.get_optional_bool("send_first"))
322                .transpose()?
323                .flatten()
324                .unwrap_or(false);
325            if send_first {
326                return Err(anyhow::anyhow!(
327                    "send_first is not supported by the Redis server: a RESP server answers commands and never speaks first; an unsolicited reply would desynchronise every client's command pipeline"
328                ));
329            }
330
331            // Both read deadlines are the operator's to choose: who is on the other end is
332            // the only thing that decides them, and only the operator knows that.
333            let secs = |name: &str| -> anyhow::Result<Option<u64>> {
334                Ok(ctx
335                    .startup_params
336                    .as_ref()
337                    .map(|p| p.get_optional_u64(name))
338                    .transpose()?
339                    .flatten())
340            };
341            let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
342            let idle_timeout_secs = secs("idle_timeout_secs")?;
343            let cluster_nodes = ctx
344                .startup_params
345                .as_ref()
346                .map(|p| p.get_optional_array("cluster_nodes"))
347                .transpose()?
348                .flatten()
349                .map(|nodes| serde_json::Value::Array(nodes.clone()));
350
351            RedisServer::spawn_with_llm_actions(
352                ctx.legacy_listen_addr(),
353                ctx.llm_client,
354                ctx.state,
355                ctx.status_tx,
356                send_first,
357                ctx.server_id,
358                super::RedisServerOptions {
359                    first_byte_timeout_secs,
360                    idle_timeout_secs,
361                    cluster_nodes,
362                },
363            )
364            .await
365        })
366    }
367
368    /// `redis_publish_message` and `redis_cluster_set_slot` act on the running server, which
369    /// the registry's stateless instance can only reach through the handle `spawn` registered.
370    fn execute_action_with_state<'a>(
371        &'a self,
372        action: serde_json::Value,
373        state: AppState,
374        server_id: Option<crate::state::ServerId>,
375    ) -> std::pin::Pin<
376        Box<dyn std::future::Future<Output = anyhow::Result<ActionResult>> + Send + 'a>,
377    > {
378        Box::pin(async move {
379            let needs_server = matches!(
380                action.get("type").and_then(|v| v.as_str()),
381                Some("redis_publish_message" | "redis_cluster_set_slot")
382            );
383            if needs_server && self.shared.is_none() {
384                let shared = match server_id {
385                    Some(id) => state.server_handle::<super::RedisShared>(id).await,
386                    None => None,
387                };
388                let Some(shared) = shared else {
389                    anyhow::bail!(
390                        "this action needs a running Redis server; name it with server_id \
391                         (it is dispatched through execute_action_with_state)"
392                    );
393                };
394                return execute_on_shared(&shared, &action, &self.status_tx);
395            }
396            self.execute_action(action)
397        })
398    }
399    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
400        let action_type = action
401            .get("type")
402            .and_then(|v| v.as_str())
403            .context("Missing 'type' field in action")?;
404
405        match action_type {
406            "redis_simple_string" => self.execute_redis_simple_string(action),
407            "redis_bulk_string" => self.execute_redis_bulk_string(action),
408            "redis_array" => self.execute_redis_array(action),
409            "redis_integer" => self.execute_redis_integer(action),
410            "redis_error" => self.execute_redis_error(action),
411            "redis_null" => self.execute_redis_null(action),
412            "redis_map"
413            | "redis_set"
414            | "redis_double"
415            | "redis_boolean"
416            | "redis_big_number"
417            | "redis_verbatim_string"
418            | "redis_push" => self.execute_resp3(action_type, &action),
419            "close_this_connection" => Ok(ActionResult::CloseConnection),
420            // Decisions the read loop carries out: it holds the channel and the message the
421            // event was about, and the connection's outbox the confirmations go to.
422            "redis_accept_subscription" => Ok(ActionResult::Custom {
423                name: ACCEPT_SUBSCRIPTION.to_string(),
424                data: json!({}),
425            }),
426            "redis_deliver_message" => Ok(ActionResult::Custom {
427                name: DELIVER_MESSAGE.to_string(),
428                data: json!({}),
429            }),
430            "redis_publish_message" | "redis_cluster_set_slot" => match &self.shared {
431                Some(shared) => execute_on_shared(shared, &action, &self.status_tx),
432                None => Err(anyhow::anyhow!(
433                    "{action_type} needs the running server; it is dispatched through \
434                     execute_action_with_state with a server_id"
435                )),
436            },
437            // Not offered to the model (`close_this_connection` is its verb), but the
438            // dashboard's "disconnect this peer" injects `close_connection` through the peer
439            // command task, which half-closes the write side on this result.
440            "close_connection" => Ok(ActionResult::CloseConnection),
441            _ => Err(anyhow::anyhow!("Unknown Redis action: {}", action_type)),
442        }
443    }
444}
445
446impl RedisProtocol {
447    /// Every reply verb returns `ActionResult::Output` with the RESP2 bytes already
448    /// encoded, so the same executor serves both the connection's read loop and the
449    /// dashboard's "message this peer" injection (`server::peer_support` writes
450    /// `Output` bytes; a `Custom` result would be reported as executed without
451    /// touching the wire — the gap this refactor closed).
452    fn execute_redis_simple_string(&self, action: serde_json::Value) -> Result<ActionResult> {
453        let value = action
454            .get("value")
455            .and_then(|v| v.as_str())
456            .context("Missing 'value' field")?;
457
458        debug!("Redis simple string response: {}", value);
459        let _ = self
460            .status_tx
461            .send(format!("[DEBUG] Redis → Simple string: {}", value));
462
463        Ok(ActionResult::Output(encode_simple_string(value)))
464    }
465
466    fn execute_redis_bulk_string(&self, action: serde_json::Value) -> Result<ActionResult> {
467        let value = action.get("value");
468
469        let result = if let Some(v) = value {
470            if v.is_null() {
471                None
472            } else if let Some(s) = v.as_str() {
473                Some(s.as_bytes().to_vec())
474            } else {
475                Some(v.to_string().as_bytes().to_vec())
476            }
477        } else {
478            None
479        };
480
481        debug!("Redis bulk string response: {:?}", result);
482        let _ = self.status_tx.send(format!(
483            "[DEBUG] Redis → Bulk string: {} bytes",
484            result.as_ref().map(|v| v.len()).unwrap_or(0)
485        ));
486
487        Ok(ActionResult::Output(match result {
488            Some(bytes) => encode_bulk_string(&bytes),
489            None => super::resp3::null(self.protocol_version()),
490        }))
491    }
492
493    fn execute_redis_array(&self, action: serde_json::Value) -> Result<ActionResult> {
494        let values = action
495            .get("values")
496            .and_then(|v| v.as_array())
497            .context("Missing 'values' array")?;
498
499        debug!("Redis array response: {} elements", values.len());
500        let _ = self
501            .status_tx
502            .send(format!("[DEBUG] Redis → Array: {} elements", values.len()));
503
504        Ok(ActionResult::Output(super::resp3::array(
505            values,
506            self.protocol_version(),
507        )?))
508    }
509
510    fn execute_redis_integer(&self, action: serde_json::Value) -> Result<ActionResult> {
511        let value = action
512            .get("value")
513            .and_then(|v| v.as_i64())
514            .context("Missing 'value' field")?;
515
516        debug!("Redis integer response: {}", value);
517        let _ = self
518            .status_tx
519            .send(format!("[DEBUG] Redis → Integer: {}", value));
520
521        Ok(ActionResult::Output(encode_integer(value)))
522    }
523
524    fn execute_redis_error(&self, action: serde_json::Value) -> Result<ActionResult> {
525        let message = action
526            .get("message")
527            .and_then(|v| v.as_str())
528            .context("Missing 'message' field")?;
529
530        // An error the handler *chose* to send, not one netget synthesised because it could
531        // not answer. On the wire both are just `-…`, so the log carries the distinction, as
532        // `src/server/radius/` does: `decision=model_reject` here,
533        // `decision=fail_closed_*` in `mod.rs`.
534        debug!(
535            "Redis connection {} decision=model_reject: {}",
536            self.connection_id,
537            crate::utils::truncate_for_log(message, 512)
538        );
539        let _ = self
540            .status_tx
541            .send(format!("[DEBUG] Redis ✗ Error: {}", message));
542
543        Ok(ActionResult::Output(encode_error(message)))
544    }
545
546    fn execute_redis_null(&self, _action: serde_json::Value) -> Result<ActionResult> {
547        debug!("Redis null response");
548        let _ = self.status_tx.send("[DEBUG] Redis → Null".to_string());
549
550        Ok(ActionResult::Output(super::resp3::null(
551            self.protocol_version(),
552        )))
553    }
554
555    /// The RESP3 reply types, encoded for this connection's version (downgraded on RESP2).
556    fn execute_resp3(&self, kind: &str, action: &serde_json::Value) -> Result<ActionResult> {
557        use super::resp3;
558        let proto = self.protocol_version();
559        let items = || -> Result<&Vec<serde_json::Value>> {
560            action
561                .get("values")
562                .and_then(|v| v.as_array())
563                .context("Missing 'values' array")
564        };
565        let bytes = match kind {
566            "redis_map" => resp3::map(
567                &resp3::entries(action.get("entries").context("Missing 'entries'")?)?,
568                proto,
569            )?,
570            "redis_set" => resp3::set(items()?, proto)?,
571            "redis_double" => {
572                resp3::double(action.get("value").context("Missing 'value'")?, proto)?
573            }
574            "redis_boolean" => resp3::boolean(
575                action
576                    .get("value")
577                    .and_then(|v| v.as_bool())
578                    .context("Missing boolean 'value'")?,
579                proto,
580            ),
581            "redis_big_number" => resp3::big_number(
582                action
583                    .get("value")
584                    .and_then(|v| v.as_str())
585                    .context("Missing 'value' (a string of digits)")?,
586                proto,
587            )?,
588            "redis_verbatim_string" => resp3::verbatim(
589                action
590                    .get("format")
591                    .and_then(|v| v.as_str())
592                    .unwrap_or("txt"),
593                action
594                    .get("text")
595                    .and_then(|v| v.as_str())
596                    .context("Missing 'text'")?,
597                proto,
598            )?,
599            _ => resp3::push(items()?, proto)?,
600        };
601        debug!(
602            "Redis {} reply (RESP{}): {} bytes",
603            kind,
604            proto,
605            bytes.len()
606        );
607        let _ = self
608            .status_tx
609            .send(format!("[DEBUG] Redis → {kind} (RESP{proto})"));
610        Ok(ActionResult::Output(bytes))
611    }
612}
613
614// ============================================================================
615// RESP2 encoding — single source of truth
616// ============================================================================
617//
618// Called by the executors above (so the LLM path, script/static handlers and
619// dashboard peer injection all produce identical bytes) and by `mod.rs` for the
620// replies the loop synthesises itself (frame-cap error, LLM-failure error,
621// no-response error).
622
623/// Strip CR and LF from a RESP *simple* payload, mapping each to a space.
624///
625/// A RESP simple string (`+…`) and simple error (`-…`) are CRLF-terminated with **no length
626/// prefix**, so a newline anywhere in the payload ends the frame early and everything after it
627/// is parsed as the *next* reply. The connection is then desynchronised for good: every
628/// subsequent command reads the wrong answer, and the client has no way to notice.
629///
630/// The value comes from model output (`redis_simple_string`'s `value`, `redis_error`'s
631/// `message`), so it is exactly as trustworthy as any other generated text. `mod.rs` documents
632/// this hazard on the LLM-failure path and avoids it by sending a fixed category; the
633/// model-facing verbs had no such guard.
634///
635/// Mapping to a space rather than rejecting the reply is what Redis itself does —
636/// `addReplyErrorFormat` runs `sdsmapchars(s, "\r\n", "  ", 2)` before framing. This goes one
637/// step further than Redis and maps *every* control character, ESC included: a simple string
638/// or error is printed verbatim by `redis-cli`, where an escape sequence forges a screen.
639fn sanitize_simple_payload(s: &str, action: &str) -> String {
640    let cleaned = crate::utils::sanitize::line_field(s);
641    if cleaned == s {
642        return cleaned;
643    }
644    warn!(
645        "Redis: {} payload contained a control character, which for CR/LF would have ended the \
646         RESP frame early and desynchronised the connection; mapping each to a space (as Redis \
647         does)",
648        action
649    );
650    cleaned
651}
652
653/// Encode a simple string response ("+OK\r\n")
654pub fn encode_simple_string(s: &str) -> Vec<u8> {
655    format!("+{}\r\n", sanitize_simple_payload(s, "simple string")).into_bytes()
656}
657
658/// Encode a bulk string response ("$5\r\nhello\r\n")
659pub fn encode_bulk_string(bytes: &[u8]) -> Vec<u8> {
660    let mut result = format!("${}\r\n", bytes.len()).into_bytes();
661    result.extend_from_slice(bytes);
662    result.extend_from_slice(b"\r\n");
663    result
664}
665
666/// Encode a null bulk string ("$-1\r\n")
667pub fn encode_null() -> Vec<u8> {
668    b"$-1\r\n".to_vec()
669}
670
671/// Encode an integer response (":42\r\n")
672pub fn encode_integer(i: i64) -> Vec<u8> {
673    format!(":{}\r\n", i).into_bytes()
674}
675
676/// Encode an error response ("-ERR message\r\n")
677pub fn encode_error(msg: &str) -> Vec<u8> {
678    format!("-{}\r\n", sanitize_simple_payload(msg, "error")).into_bytes()
679}
680
681/// Encode an array response.
682///
683/// The element mapping is part of the `redis_array` action contract documented to the LLM:
684/// strings become bulk strings, integers become RESP integers, booleans become the bulk
685/// strings `"1"`/`"0"`, null becomes a nil bulk string, and nested arrays/objects are
686/// serialized to JSON and sent as a bulk string (RESP2 has no JSON type).
687pub fn encode_array(values: &[serde_json::Value]) -> Vec<u8> {
688    let mut result = format!("*{}\r\n", values.len()).into_bytes();
689
690    for value in values {
691        match value {
692            serde_json::Value::String(s) => {
693                result.extend_from_slice(&encode_bulk_string(s.as_bytes()));
694            }
695            serde_json::Value::Number(n) => {
696                if let Some(i) = n.as_i64() {
697                    result.extend_from_slice(&encode_integer(i));
698                } else {
699                    // Encode as bulk string
700                    let s = n.to_string();
701                    result.extend_from_slice(&encode_bulk_string(s.as_bytes()));
702                }
703            }
704            serde_json::Value::Bool(b) => {
705                let s = if *b { "1" } else { "0" };
706                result.extend_from_slice(&encode_bulk_string(s.as_bytes()));
707            }
708            serde_json::Value::Null => {
709                result.extend_from_slice(&encode_null());
710            }
711            serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
712                // Nested arrays/objects - encode as bulk string JSON
713                let s = value.to_string();
714                result.extend_from_slice(&encode_bulk_string(s.as_bytes()));
715            }
716        }
717    }
718
719    result
720}
721
722/// Action definition: Send Redis simple string response
723pub fn redis_simple_string_action() -> ActionDefinition {
724    ActionDefinition {
725        name: "redis_simple_string".to_string(),
726        description: "Send a simple string response (e.g., '+OK\\r\\n')".to_string(),
727        parameters: vec![Parameter {
728            name: "value".to_string(),
729            type_hint: "string".to_string(),
730            description: "The string value to send (without RESP encoding)".to_string(),
731            required: true,
732        }],
733        example: json!({
734            "type": "redis_simple_string",
735            "value": "OK"
736        }),
737        log_template: Some(
738            LogTemplate::new()
739                .with_info("-> Redis +{value}")
740                .with_debug("Redis redis_simple_string: value={value}"),
741        ),
742    }
743}
744
745/// Action definition: Send Redis bulk string response
746pub fn redis_bulk_string_action() -> ActionDefinition {
747    ActionDefinition {
748        name: "redis_bulk_string".to_string(),
749        description: "Send a bulk string response (e.g., '$5\\r\\nhello\\r\\n'). Use null for nil bulk string".to_string(),
750        parameters: vec![Parameter {
751            name: "value".to_string(),
752            type_hint: "string".to_string(),
753            description: "The string value to send, or null for nil bulk string".to_string(),
754            required: false,
755        }],
756        example: json!({
757            "type": "redis_bulk_string",
758            "value": "hello world"
759        }),
760        log_template: Some(
761            LogTemplate::new()
762                .with_info("-> Redis ${value_len}B")
763                .with_debug("Redis redis_bulk_string: {value_len} bytes"),
764        ),
765    }
766}
767
768/// Action definition: Send Redis array response
769pub fn redis_array_action() -> ActionDefinition {
770    ActionDefinition {
771        name: "redis_array".to_string(),
772        description: "Send an array response, for commands that return a list (KEYS, MGET, LRANGE, SCAN...)".to_string(),
773        parameters: vec![Parameter {
774            name: "values".to_string(),
775            type_hint: "array".to_string(),
776            description: "Array of values. A string becomes a bulk string, an integer becomes a RESP integer, true/false become the bulk strings \"1\"/\"0\", null becomes a nil element, and a nested array or object is sent as its JSON text in a bulk string".to_string(),
777            required: true,
778        }],
779        example: json!({
780            "type": "redis_array",
781            "values": ["value1", "value2", "value3"]
782        }),
783        log_template: Some(
784            LogTemplate::new()
785                .with_info("-> Redis *{values_len} elements")
786                .with_debug("Redis redis_array: {values_len} elements"),
787        ),
788    }
789}
790
791/// Action definition: Send Redis integer response
792pub fn redis_integer_action() -> ActionDefinition {
793    ActionDefinition {
794        name: "redis_integer".to_string(),
795        description: "Send an integer response (e.g., ':42\\r\\n')".to_string(),
796        parameters: vec![Parameter {
797            name: "value".to_string(),
798            type_hint: "integer".to_string(),
799            description: "The integer value to send".to_string(),
800            required: true,
801        }],
802        example: json!({
803            "type": "redis_integer",
804            "value": 42
805        }),
806        log_template: Some(
807            LogTemplate::new()
808                .with_info("-> Redis :{value}")
809                .with_debug("Redis redis_integer: value={value}"),
810        ),
811    }
812}
813
814/// Action definition: Send Redis error response
815pub fn redis_error_action() -> ActionDefinition {
816    ActionDefinition {
817        name: "redis_error".to_string(),
818        description: "Send an error response (e.g., '-ERR message\\r\\n')".to_string(),
819        parameters: vec![Parameter {
820            name: "message".to_string(),
821            type_hint: "string".to_string(),
822            description: "The error message to send".to_string(),
823            required: true,
824        }],
825        example: json!({
826            "type": "redis_error",
827            "message": "ERR unknown command 'foobar'"
828        }),
829        log_template: Some(
830            LogTemplate::new()
831                .with_info("-> Redis -{message}")
832                .with_debug("Redis redis_error: {message}"),
833        ),
834    }
835}
836
837/// Action definition: Send Redis null response
838pub fn redis_null_action() -> ActionDefinition {
839    ActionDefinition {
840        name: "redis_null".to_string(),
841        description: "Send a null response ('$-1\\r\\n')".to_string(),
842        parameters: vec![],
843        example: json!({
844            "type": "redis_null"
845        }),
846        log_template: Some(
847            LogTemplate::new()
848                .with_info("-> Redis (nil)")
849                .with_debug("Redis redis_null"),
850        ),
851    }
852}
853
854/// Action definition: Close current Redis connection
855pub fn close_this_connection_action() -> ActionDefinition {
856    ActionDefinition {
857        name: "close_this_connection".to_string(),
858        description: "Close the current Redis connection".to_string(),
859        parameters: vec![],
860        example: json!({"type": "close_this_connection"}),
861        log_template: Some(
862            LogTemplate::new()
863                .with_info("Redis connection closed")
864                .with_debug("Redis close_this_connection"),
865        ),
866    }
867}
868
869/// `ActionResult::Custom` name of `redis_accept_subscription`.
870pub const ACCEPT_SUBSCRIPTION: &str = "redis_accept_subscription";
871/// `ActionResult::Custom` name of `redis_deliver_message`.
872pub const DELIVER_MESSAGE: &str = "redis_deliver_message";
873
874/// Carry out `redis_publish_message` or `redis_cluster_set_slot` against a running server.
875fn execute_on_shared(
876    shared: &super::RedisShared,
877    action: &serde_json::Value,
878    status_tx: &mpsc::UnboundedSender<String>,
879) -> Result<ActionResult> {
880    match action.get("type").and_then(|v| v.as_str()) {
881        Some("redis_publish_message") => {
882            let channel = action
883                .get("channel")
884                .and_then(|v| v.as_str())
885                .context("Missing 'channel'")?;
886            let message = action
887                .get("message")
888                .and_then(|v| v.as_str())
889                .context("Missing 'message'")?;
890            let shard = action
891                .get("shard")
892                .and_then(|v| v.as_bool())
893                .unwrap_or(false);
894            let kind = if shard {
895                super::pubsub::Kind::Shard
896            } else {
897                super::pubsub::Kind::Channel
898            };
899            super::pubsub::check_name(kind, channel.as_bytes()).map_err(anyhow::Error::msg)?;
900            super::pubsub::check_message(message.as_bytes()).map_err(anyhow::Error::msg)?;
901            let receivers = shared
902                .hub
903                .publish(channel.as_bytes(), message.as_bytes(), shard);
904            debug!(
905                "Redis decision=model_publish channel='{}' receivers={}",
906                crate::utils::truncate_for_log(channel, 128),
907                receivers
908            );
909            let _ = status_tx.send(format!(
910                "[INFO] Redis published to '{}': {} receiver(s)",
911                crate::utils::truncate_for_log(channel, 128),
912                receivers
913            ));
914            Ok(ActionResult::NoAction)
915        }
916        Some("redis_cluster_set_slot") => {
917            let cluster = shared.cluster.as_ref().context(
918                "this server is not in cluster mode (start it with the cluster_nodes parameter)",
919            )?;
920            let slots =
921                super::cluster::parse_slots(action.get("slots").context("Missing 'slots'")?)?;
922            let state = action
923                .get("state")
924                .and_then(|v| v.as_str())
925                .unwrap_or("node");
926            let node = action.get("node").and_then(|v| v.as_str());
927            let summary = cluster
928                .lock()
929                .unwrap_or_else(|p| p.into_inner())
930                .set_slot(slots, state, node)?;
931            let _ = status_tx.send(format!("[INFO] Redis cluster: {summary}"));
932            debug!("Redis cluster slot change: {}", summary);
933            Ok(ActionResult::NoAction)
934        }
935        other => Err(anyhow::anyhow!("not a server-state action: {other:?}")),
936    }
937}
938
939/// Action definition: allow a SUBSCRIBE / PSUBSCRIBE / SSUBSCRIBE.
940pub fn redis_accept_subscription_action() -> ActionDefinition {
941    ActionDefinition {
942        name: "redis_accept_subscription".to_string(),
943        description: "Allow the subscription a redis_subscribe event asks for. The server then \
944                      registers it and sends the client one confirmation per channel; to refuse \
945                      instead, answer with redis_error (e.g. NOPERM ...)."
946            .to_string(),
947        parameters: vec![],
948        example: json!({"type": "redis_accept_subscription"}),
949        log_template: Some(
950            LogTemplate::new()
951                .with_info("-> Redis subscription accepted")
952                .with_debug("Redis redis_accept_subscription"),
953        ),
954    }
955}
956
957/// Action definition: deliver the PUBLISH a redis_publish event is about.
958pub fn redis_deliver_message_action() -> ActionDefinition {
959    ActionDefinition {
960        name: "redis_deliver_message".to_string(),
961        description: "Deliver the message a redis_publish event is about to every subscriber of \
962                      its channel (and of every matching pattern), and answer the publisher with \
963                      the number of deliveries. To answer without delivering, send redis_integer \
964                      instead; to refuse, redis_error."
965            .to_string(),
966        parameters: vec![],
967        example: json!({"type": "redis_deliver_message"}),
968        log_template: Some(
969            LogTemplate::new()
970                .with_info("-> Redis message delivered")
971                .with_debug("Redis redis_deliver_message"),
972        ),
973    }
974}
975
976/// Action definition: publish a message of the model's own.
977pub fn redis_publish_message_action() -> ActionDefinition {
978    ActionDefinition {
979        name: "redis_publish_message".to_string(),
980        description: "Publish a message of your own to every client of this server subscribed \
981                      to the channel (or to a matching pattern), as if a client had sent PUBLISH. \
982                      Writes nothing to the client whose command you are answering, so still \
983                      answer it."
984            .to_string(),
985        parameters: vec![
986            param("channel", "string", "The channel to publish on", true),
987            param("message", "string", "The message text", true),
988            param(
989                "shard",
990                "boolean",
991                "true to publish on the shard channel (SPUBLISH) rather than the ordinary one",
992                false,
993            ),
994        ],
995        example: json!({"type": "redis_publish_message", "channel": "news", "message": "hello"}),
996        log_template: Some(
997            LogTemplate::new()
998                .with_info("-> Redis PUBLISH {channel}")
999                .with_debug("Redis redis_publish_message: channel={channel}"),
1000        ),
1001    }
1002}
1003
1004/// Action definition: change which node serves cluster slots.
1005pub fn redis_cluster_set_slot_action() -> ActionDefinition {
1006    ActionDefinition {
1007        name: "redis_cluster_set_slot".to_string(),
1008        description: "Change this server's view of the cluster, as CLUSTER SETSLOT does (cluster \
1009                      mode only). state node: the slots now belong to node, so this server \
1010                      redirects them there with -MOVED; migrating: this server's slots are moving \
1011                      to node and it answers -ASK; importing: the slots are arriving from node and \
1012                      are served after ASKING; stable: clear migrating/importing."
1013            .to_string(),
1014        parameters: vec![
1015            param(
1016                "slots",
1017                "string",
1018                "A slot (\"42\") or an inclusive range (\"0-100\")",
1019                true,
1020            ),
1021            param(
1022                "state",
1023                "string",
1024                "node (default), migrating, importing or stable",
1025                false,
1026            ),
1027            param(
1028                "node",
1029                "string",
1030                "The other node, as host:port or its 40-character id (not needed for stable)",
1031                false,
1032            ),
1033        ],
1034        example: json!({"type": "redis_cluster_set_slot", "slots": "0-100", "state": "stable"}),
1035        log_template: Some(
1036            LogTemplate::new()
1037                .with_info("-> Redis cluster slots {slots} {state}")
1038                .with_debug("Redis redis_cluster_set_slot: slots={slots} state={state}"),
1039        ),
1040    }
1041}
1042
1043// ============================================================================
1044// Redis Action Constants
1045// ============================================================================
1046
1047pub static REDIS_SIMPLE_STRING_ACTION: LazyLock<ActionDefinition> =
1048    LazyLock::new(|| redis_simple_string_action());
1049pub static REDIS_BULK_STRING_ACTION: LazyLock<ActionDefinition> =
1050    LazyLock::new(|| redis_bulk_string_action());
1051pub static REDIS_ARRAY_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| redis_array_action());
1052pub static REDIS_INTEGER_ACTION: LazyLock<ActionDefinition> =
1053    LazyLock::new(|| redis_integer_action());
1054pub static REDIS_ERROR_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| redis_error_action());
1055pub static REDIS_NULL_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| redis_null_action());
1056pub static REDIS_CLOSE_CONNECTION_ACTION: LazyLock<ActionDefinition> =
1057    LazyLock::new(|| close_this_connection_action());
1058
1059// ============================================================================
1060// Redis Event Type Constants
1061// ============================================================================
1062
1063/// Redis command event - triggered when client sends a command
1064pub static REDIS_COMMAND_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1065    EventType::new(
1066        "redis_command",
1067        "Redis command received from client",
1068        json!({"type": "placeholder", "event_id": "redis_command"}),
1069    )
1070    .with_parameters(vec![
1071        Parameter {
1072            name: "command".to_string(),
1073            type_hint: "string".to_string(),
1074            description: "The Redis command string sent by the client".to_string(),
1075            required: true,
1076        },
1077        Parameter {
1078            name: "protocol".to_string(),
1079            type_hint: "number".to_string(),
1080            description: "The RESP version this connection speaks: 2, or 3 after the client sent HELLO 3. RESP3-only replies (map, set, double, boolean, big number, verbatim) are downgraded on RESP2; a push needs RESP3".to_string(),
1081            required: true,
1082        },
1083    ])
1084    .with_actions(vec![
1085        REDIS_SIMPLE_STRING_ACTION.clone(),
1086        REDIS_BULK_STRING_ACTION.clone(),
1087        REDIS_ARRAY_ACTION.clone(),
1088        REDIS_INTEGER_ACTION.clone(),
1089        REDIS_ERROR_ACTION.clone(),
1090        REDIS_NULL_ACTION.clone(),
1091        REDIS_CLOSE_CONNECTION_ACTION.clone(),
1092        redis_publish_message_action(),
1093        redis_cluster_set_slot_action(),
1094    ]
1095    .into_iter()
1096    .chain(resp3_actions())
1097    .collect())
1098    .with_log_template(
1099        LogTemplate::new()
1100            .with_info("Redis: {command}")
1101            .with_debug("Redis command: {command}")
1102            .with_trace("Redis: {json_pretty(.)}"),
1103    )
1104});
1105
1106/// A client asked to subscribe. Raised for SUBSCRIBE, PSUBSCRIBE and SSUBSCRIBE once the
1107/// request is known to fit every bound, so the model decides policy and nothing else.
1108pub static REDIS_SUBSCRIBE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1109    EventType::new(
1110        "redis_subscribe",
1111        "A client asked to subscribe to channels or patterns",
1112        json!({"type": "redis_accept_subscription"}),
1113    )
1114    .with_parameters(vec![
1115        param(
1116            "command",
1117            "string",
1118            "SUBSCRIBE (channels), PSUBSCRIBE (glob patterns) or SSUBSCRIBE (shard channels)",
1119            true,
1120        ),
1121        param(
1122            "channels",
1123            "array",
1124            "The channels or patterns requested, in order",
1125            true,
1126        ),
1127        param(
1128            "subscriptions",
1129            "number",
1130            "How many subscriptions this connection already holds",
1131            true,
1132        ),
1133        param(
1134            "protocol",
1135            "number",
1136            "The connection's RESP version: messages arrive as pushes on 3, arrays on 2",
1137            true,
1138        ),
1139    ])
1140    .with_actions(vec![
1141        redis_accept_subscription_action(),
1142        REDIS_ERROR_ACTION.clone(),
1143        redis_publish_message_action(),
1144        REDIS_CLOSE_CONNECTION_ACTION.clone(),
1145    ])
1146    .with_log_template(
1147        LogTemplate::new()
1148            .with_info("Redis: {command} {channels}")
1149            .with_debug("Redis subscribe: {command} {channels}"),
1150    )
1151});
1152
1153/// A client published. Raised for PUBLISH and SPUBLISH once the message fits its bound.
1154pub static REDIS_PUBLISH_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1155    EventType::new(
1156        "redis_publish",
1157        "A client published a message to a channel",
1158        json!({"type": "redis_deliver_message"}),
1159    )
1160    .with_parameters(vec![
1161        param(
1162            "command",
1163            "string",
1164            "PUBLISH, or SPUBLISH for a shard channel",
1165            true,
1166        ),
1167        param("channel", "string", "The channel the message is for", true),
1168        param("message", "string", "The message text", true),
1169        param(
1170            "receivers",
1171            "number",
1172            "How many deliveries redis_deliver_message would make right now",
1173            true,
1174        ),
1175        param(
1176            "protocol",
1177            "number",
1178            "The publisher's RESP version (2 or 3)",
1179            true,
1180        ),
1181    ])
1182    .with_actions(vec![
1183        redis_deliver_message_action(),
1184        REDIS_INTEGER_ACTION.clone(),
1185        REDIS_ERROR_ACTION.clone(),
1186        redis_publish_message_action(),
1187        REDIS_CLOSE_CONNECTION_ACTION.clone(),
1188    ])
1189    .with_log_template(
1190        LogTemplate::new()
1191            .with_info("Redis: {command} {channel}")
1192            .with_debug("Redis publish: {command} {channel} ({receivers} receivers)"),
1193    )
1194});
1195
1196/// Get Redis event types
1197pub fn get_redis_event_types() -> Vec<EventType> {
1198    vec![
1199        REDIS_COMMAND_EVENT.clone(),
1200        REDIS_SUBSCRIBE_EVENT.clone(),
1201        REDIS_PUBLISH_EVENT.clone(),
1202    ]
1203}
1204
1205fn resp3_action(
1206    name: &str,
1207    description: &str,
1208    parameters: Vec<Parameter>,
1209    example: serde_json::Value,
1210    info: &str,
1211) -> ActionDefinition {
1212    ActionDefinition {
1213        name: name.to_string(),
1214        description: description.to_string(),
1215        parameters,
1216        example,
1217        log_template: Some(
1218            LogTemplate::new()
1219                .with_info(info)
1220                .with_debug(format!("Redis {name}")),
1221        ),
1222    }
1223}
1224
1225fn param(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
1226    Parameter {
1227        name: name.to_string(),
1228        type_hint: type_hint.to_string(),
1229        description: description.to_string(),
1230        required,
1231    }
1232}
1233
1234/// The RESP3 reply verbs. Each is encoded for the connection's protocol: native on a
1235/// connection that sent `HELLO 3`, downgraded the way Redis downgrades on RESP2.
1236pub fn resp3_actions() -> Vec<ActionDefinition> {
1237    vec![
1238        resp3_action(
1239            "redis_map",
1240            "Send a map (HGETALL, CONFIG GET, XINFO…): a RESP3 map, or on RESP2 the flat key/value array Redis sends there",
1241            vec![param("entries", "object", "The map as a JSON object, or [[key, value], …] when order matters. Values follow redis_array's rules (on RESP3 a nested object is a map, an array an array, a fraction a double, true/false booleans, null a null)", true)],
1242            json!({"type": "redis_map", "entries": {"name": "Ada", "visits": 3}}),
1243            "-> Redis map",
1244        ),
1245        resp3_action(
1246            "redis_set",
1247            "Send a set (SMEMBERS, SINTER…): a RESP3 set, or an array on RESP2",
1248            vec![param("values", "array", "The members, encoded as redis_array encodes its elements", true)],
1249            json!({"type": "redis_set", "values": ["red", "green"]}),
1250            "-> Redis set {values_len} members",
1251        ),
1252        resp3_action(
1253            "redis_double",
1254            "Send a floating-point number (ZSCORE, INCRBYFLOAT…): a RESP3 double, or a bulk string on RESP2",
1255            vec![param("value", "number", "The number, or the string inf, -inf or nan", true)],
1256            json!({"type": "redis_double", "value": 2.5}),
1257            "-> Redis double {value}",
1258        ),
1259        resp3_action(
1260            "redis_boolean",
1261            "Send true or false: a RESP3 boolean, or the integer 1/0 on RESP2",
1262            vec![param("value", "boolean", "The boolean to send: true or false (1 or 0 on RESP2)", true)],
1263            json!({"type": "redis_boolean", "value": true}),
1264            "-> Redis boolean {value}",
1265        ),
1266        resp3_action(
1267            "redis_big_number",
1268            "Send an integer too large for 64 bits: a RESP3 big number, or a bulk string on RESP2",
1269            vec![param("value", "string", "The decimal digits, optionally with a leading minus sign", true)],
1270            json!({"type": "redis_big_number", "value": "3492890328409238509324850943850943825024385"}),
1271            "-> Redis big number",
1272        ),
1273        resp3_action(
1274            "redis_verbatim_string",
1275            "Send text a client should show as is (INFO, LATENCY DOCTOR): a RESP3 verbatim string, or a bulk string on RESP2",
1276            vec![
1277                param("text", "string", "The text, newlines allowed", true),
1278                param("format", "string", "Three letters naming the text's format: txt (default) or mkd", false),
1279            ],
1280            json!({"type": "redis_verbatim_string", "text": "# Server\nredis_version:7.4.0", "format": "txt"}),
1281            "-> Redis verbatim string",
1282        ),
1283        resp3_action(
1284            "redis_push",
1285            "Send an out-of-band push (pub/sub message, client-side-caching invalidation). RESP3 only: refused on a RESP2 connection",
1286            vec![param("values", "array", "The push's elements, the first naming its kind, e.g. [\"message\", \"news\", \"hello\"]", true)],
1287            json!({"type": "redis_push", "values": ["message", "news", "hello"]}),
1288            "-> Redis push {values_len} elements",
1289        ),
1290    ]
1291}