Skip to main content

netget/client/mysql/
actions.rs

1//! MySQL 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::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// MySQL client connected event
15pub static MYSQL_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "mysql_connected",
18        "MySQL client successfully connected to server",
19        json!({
20            "type": "execute_query",
21            "query": "SELECT * FROM users LIMIT 10"
22        }),
23    )
24    .with_parameters(vec![Parameter {
25        name: "remote_addr".to_string(),
26        type_hint: "string".to_string(),
27        description: "MySQL server address".to_string(),
28        required: true,
29    }])
30});
31
32/// MySQL client query result received event
33pub static MYSQL_CLIENT_RESULT_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
34    EventType::new(
35        "mysql_result_received",
36        "Query result received from MySQL server",
37        json!({
38            "type": "wait_for_more"
39        }),
40    )
41    .with_parameters(vec![
42        Parameter {
43            name: "query".to_string(),
44            type_hint: "string".to_string(),
45            description: "The SQL query this result belongs to".to_string(),
46            required: true,
47        },
48        Parameter {
49            name: "result".to_string(),
50            type_hint: "array".to_string(),
51            description: "The rows returned, one object per row keyed by column name".to_string(),
52            required: true,
53        },
54        Parameter {
55            name: "row_count".to_string(),
56            type_hint: "number".to_string(),
57            description: "Number of rows returned".to_string(),
58            required: true,
59        },
60        Parameter {
61            name: "affected_rows".to_string(),
62            type_hint: "number".to_string(),
63            description: "Rows the server reports as affected (INSERT/UPDATE/DELETE); 0 for a \
64                          SELECT"
65                .to_string(),
66            required: true,
67        },
68        Parameter {
69            name: "last_insert_id".to_string(),
70            type_hint: "number".to_string(),
71            description: "The AUTO_INCREMENT id the statement generated, when it generated one"
72                .to_string(),
73            required: false,
74        },
75    ])
76});
77
78/// MySQL client protocol action handler
79pub struct MysqlClientProtocol;
80
81impl MysqlClientProtocol {
82    pub fn new() -> Self {
83        Self
84    }
85}
86
87// Implement Protocol trait (common functionality)
88impl Protocol for MysqlClientProtocol {
89    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
90        vec![
91            ActionDefinition {
92                name: "execute_query".to_string(),
93                description: "Execute a SQL query (SELECT, INSERT, UPDATE, DELETE, etc.)"
94                    .to_string(),
95                parameters: vec![Parameter {
96                    name: "query".to_string(),
97                    type_hint: "string".to_string(),
98                    description: "SQL query to execute".to_string(),
99                    required: true,
100                }],
101                example: json!({
102                    "type": "execute_query",
103                    "query": "SELECT * FROM users WHERE id = 1"
104                }),
105                log_template: None,
106            },
107            ActionDefinition {
108                name: "begin_transaction".to_string(),
109                description: "Begin a transaction".to_string(),
110                parameters: vec![],
111                example: json!({
112                    "type": "begin_transaction"
113                }),
114                log_template: None,
115            },
116            ActionDefinition {
117                name: "commit_transaction".to_string(),
118                description: "Commit the current transaction".to_string(),
119                parameters: vec![],
120                example: json!({
121                    "type": "commit_transaction"
122                }),
123                log_template: None,
124            },
125            ActionDefinition {
126                name: "rollback_transaction".to_string(),
127                description: "Rollback the current transaction".to_string(),
128                parameters: vec![],
129                example: json!({
130                    "type": "rollback_transaction"
131                }),
132                log_template: None,
133            },
134            ActionDefinition {
135                name: "disconnect".to_string(),
136                description: "Disconnect from the MySQL server".to_string(),
137                parameters: vec![],
138                example: json!({
139                    "type": "disconnect"
140                }),
141                log_template: None,
142            },
143        ]
144    }
145    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
146        vec![
147            ActionDefinition {
148                name: "execute_query".to_string(),
149                description: "Execute a SQL query in response to received data".to_string(),
150                parameters: vec![Parameter {
151                    name: "query".to_string(),
152                    type_hint: "string".to_string(),
153                    description: "SQL query to execute".to_string(),
154                    required: true,
155                }],
156                example: json!({
157                    "type": "execute_query",
158                    "query": "INSERT INTO logs (message) VALUES ('processed')"
159                }),
160                log_template: None,
161            },
162            ActionDefinition {
163                name: "wait_for_more".to_string(),
164                description: "Wait for more results without executing new queries".to_string(),
165                parameters: vec![],
166                example: json!({
167                    "type": "wait_for_more"
168                }),
169                log_template: None,
170            },
171        ]
172    }
173    fn protocol_name(&self) -> &'static str {
174        "MySQL"
175    }
176    /// The **statics**, not fresh `EventType::new(..)` copies.
177    ///
178    /// This used to build a second, different `EventType` for each id: no parameters, and a
179    /// `{"type": "placeholder"}` example. Those are what the registry and the model-facing
180    /// docs surface, while the emitted events carry the statics above — two sources of truth
181    /// for the same event id, and they had already drifted.
182    fn get_event_types(&self) -> Vec<EventType> {
183        vec![
184            MYSQL_CLIENT_CONNECTED_EVENT.clone(),
185            MYSQL_CLIENT_RESULT_RECEIVED_EVENT.clone(),
186        ]
187    }
188    fn stack_name(&self) -> &'static str {
189        "ETH>IP>TCP>MySQL"
190    }
191    fn keywords(&self) -> Vec<&'static str> {
192        vec![
193            "mysql",
194            "mysql client",
195            "connect to mysql",
196            "sql",
197            "database",
198        ]
199    }
200    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
201        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
202
203        ProtocolMetadataV2::builder()
204            .state(DevelopmentState::Beta)
205            .implementation(
206                "mysql_async, one connection, text protocol. Each query's rows and the server's \
207                 affected_rows / last_insert_id go back to the model as mysql_result_received, \
208                 and the model's answer is executed in turn, bounded at four follow-ups",
209            )
210            .llm_control("Full control over SQL queries and transactions")
211            .e2e_testing(
212                "tests/client/mysql/real_server_test.rs, 6 LLM calls, against a real mysqld \
213                 (--initialize-insecure, then mysqld on a loopback port) read back with the \
214                 mysql CLI. The client connects with the database startup parameter; the model \
215                 creates a table, inserts a row, selects it, and inserts a second row built \
216                 from the SELECT's rows. The INSERT's result is matched on affected_rows 1 and \
217                 last_insert_id 1, the SELECT's on the row it carries, and the CLI must read \
218                 both rows back exactly. Not #[ignore]d; a missing mysqld or mysql fails the \
219                 test rather than skipping it.",
220            )
221            .notes(
222                "No TLS, and only a passwordless account is exercised against a real server. A \
223                 query the server rejects marks the client Error and raises no event, so the \
224                 model does not learn of the error. Text protocol only; no prepared \
225                 statements.",
226            )
227            .build()
228    }
229    fn description(&self) -> &'static str {
230        "MySQL client for database operations"
231    }
232    fn example_prompt(&self) -> &'static str {
233        "Connect to MySQL at localhost:3306 as root and query the users table"
234    }
235    fn group_name(&self) -> &'static str {
236        "Database"
237    }
238
239    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
240        use crate::llm::actions::StartupExamples;
241        use serde_json::json;
242
243        StartupExamples::new(
244            // LLM mode: LLM controls MySQL queries
245            json!({
246                "type": "open_client",
247                "remote_addr": "localhost:3306",
248                "base_stack": "mysql",
249                "instruction": "Query the users table and show all active users, then summarize the results"
250            }),
251            // Script mode: Code-based deterministic responses
252            json!({
253                "type": "open_client",
254                "remote_addr": "localhost:3306",
255                "base_stack": "mysql",
256                "event_handlers": [{
257                    "event_pattern": "mysql_result_received",
258                    "handler": {
259                        "type": "script",
260                        "language": "python",
261                        "code": "<mysql_client_handler>"
262                    }
263                }]
264            }),
265            // Static mode: Fixed MySQL query on connect
266            json!({
267                "type": "open_client",
268                "remote_addr": "localhost:3306",
269                "base_stack": "mysql",
270                "event_handlers": [
271                    {
272                        "event_pattern": "mysql_connected",
273                        "handler": {
274                            "type": "static",
275                            "actions": [{
276                                "type": "execute_query",
277                                "query": "SELECT * FROM users LIMIT 10"
278                            }]
279                        }
280                    },
281                    {
282                        "event_pattern": "mysql_result_received",
283                        "handler": {
284                            "type": "static",
285                            "actions": [{
286                                "type": "disconnect"
287                            }]
288                        }
289                    }
290                ]
291            }),
292        )
293    }
294
295    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
296        vec![
297            crate::llm::actions::ParameterDefinition {
298                name: "username".to_string(),
299                type_hint: "string".to_string(),
300                description: "MySQL username (default: root)".to_string(),
301                required: false,
302                example: json!("myuser"),
303                default: None,
304            },
305            crate::llm::actions::ParameterDefinition {
306                name: "password".to_string(),
307                type_hint: "string".to_string(),
308                description: "MySQL password (default: empty)".to_string(),
309                required: false,
310                example: json!("mypassword"),
311                default: None,
312            },
313            crate::llm::actions::ParameterDefinition {
314                name: "database".to_string(),
315                type_hint: "string".to_string(),
316                description: "Database name to connect to (default: none)".to_string(),
317                required: false,
318                example: json!("mydb"),
319                default: None,
320            },
321        ]
322    }
323}
324
325// Implement Client trait (client-specific functionality)
326impl Client for MysqlClientProtocol {
327    fn connect(
328        &self,
329        ctx: crate::protocol::ConnectContext,
330    ) -> std::pin::Pin<
331        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
332    > {
333        Box::pin(async move {
334            use crate::client::mysql::MysqlClient;
335            MysqlClient::connect_with_llm_actions(
336                ctx.remote_addr,
337                ctx.llm_client,
338                ctx.state,
339                ctx.status_tx,
340                ctx.client_id,
341                ctx.startup_params,
342            )
343            .await
344        })
345    }
346    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
347        let action_type = action
348            .get("type")
349            .and_then(|v| v.as_str())
350            .context("Missing 'type' field in action")?;
351
352        match action_type {
353            "execute_query" => {
354                let query = action
355                    .get("query")
356                    .and_then(|v| v.as_str())
357                    .context("Missing 'query' field")?
358                    .to_string();
359
360                Ok(ClientActionResult::Custom {
361                    name: "mysql_query".to_string(),
362                    data: json!({
363                        "query": query,
364                    }),
365                })
366            }
367            "begin_transaction" => Ok(ClientActionResult::Custom {
368                name: "mysql_query".to_string(),
369                data: json!({
370                    "query": "BEGIN",
371                }),
372            }),
373            "commit_transaction" => Ok(ClientActionResult::Custom {
374                name: "mysql_query".to_string(),
375                data: json!({
376                    "query": "COMMIT",
377                }),
378            }),
379            "rollback_transaction" => Ok(ClientActionResult::Custom {
380                name: "mysql_query".to_string(),
381                data: json!({
382                    "query": "ROLLBACK",
383                }),
384            }),
385            "disconnect" => Ok(ClientActionResult::Disconnect),
386            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
387            _ => Err(anyhow::anyhow!(
388                "Unknown MySQL client action: {}",
389                action_type
390            )),
391        }
392    }
393}