1use 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
15pub 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
43pub 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
119pub 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
162pub 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
200pub 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
213fn 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
284impl 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 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 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 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 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
511impl 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 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 "wait_for_more" => Ok(ClientActionResult::WaitForMore),
638 _ => Err(anyhow::anyhow!(
639 "Unknown Redis client action: {}",
640 action_type
641 )),
642 }
643 }
644}
645
646fn 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}