Skip to main content

netget/client/postgresql/
actions.rs

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