Skip to main content

netget/client/mongodb/
mod.rs

1//! MongoDB client implementation
2pub mod actions;
3
4pub use actions::MongodbClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tracing::{error, info, trace, warn};
11
12#[cfg(not(target_arch = "wasm32"))]
13use mongodb::{
14    bson::Document,
15    options::{ClientOptions, FindOptions},
16    Client as MongoClient, Database,
17};
18
19use crate::client::llm_budget::call_llm_for_client;
20use crate::client::mongodb::actions::{
21    MONGODB_CLIENT_CONNECTED_EVENT, MONGODB_CLIENT_RESULT_RECEIVED_EVENT,
22};
23use crate::llm::actions::client_trait::Client;
24use crate::llm::ollama_client::OllamaClient;
25use crate::llm::ClientLlmResult;
26use crate::protocol::Event;
27use crate::state::app_state::AppState;
28use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
29use crate::state::{ClientId, ClientStatus};
30
31/// What one executed action did to the MongoDB database handle.
32///
33/// Returned by [`MongodbClient::apply_action`], the single place an action reaches the
34/// server — the connected-event LLM path and injected dashboard commands both go through it.
35#[cfg(not(target_arch = "wasm32"))]
36enum Applied {
37    /// An operation ran. `detail` is the injected-action outcome text; `event` is the
38    /// `mongodb_result_received` the client raises next, exactly as the LLM path does.
39    Ran { detail: String, event: Event },
40    /// The session should end.
41    Disconnect,
42    /// The action executed but touched the database in no way.
43    Nothing(&'static str),
44}
45
46/// How many action → result → action hops one operation may set off.
47///
48/// The chain is genuinely self-referential: an action produces a result, the result is an
49/// event, and the model's answer to that event is more actions. The previous code cut it by
50/// running follow-ups through `apply_action` directly, so a follow-up raised no event at all
51/// — the chain was exactly one step deep and a `find` the model issued in reply to an insert
52/// confirmation ran with its documents going nowhere. That is the recorded elasticsearch
53/// defect, in a MongoDB shirt, with a comment explaining why it was fine.
54///
55/// A depth bound is the honest version of the same protection: every operation reports, and
56/// the chain still terminates. Past the bound an action is still executed — refusing it would
57/// discard work the model asked for — but its result is not reported, so nothing recurses.
58const MAX_FOLLOWUP_DEPTH: u8 = 4;
59
60/// MongoDB client that connects to a MongoDB server
61pub struct MongodbClient;
62
63impl MongodbClient {
64    /// Connect to a MongoDB server with integrated LLM actions
65    pub async fn connect_with_llm_actions(
66        remote_addr: String,
67        llm_client: OllamaClient,
68        app_state: Arc<AppState>,
69        status_tx: mpsc::UnboundedSender<String>,
70        client_id: ClientId,
71        startup_params: Option<crate::protocol::StartupParams>,
72    ) -> Result<SocketAddr> {
73        Self::connect_impl(
74            remote_addr,
75            llm_client,
76            app_state,
77            status_tx,
78            client_id,
79            startup_params,
80        )
81        .await
82    }
83
84    #[cfg(not(target_arch = "wasm32"))]
85    async fn connect_impl(
86        remote_addr: String,
87        llm_client: OllamaClient,
88        app_state: Arc<AppState>,
89        status_tx: mpsc::UnboundedSender<String>,
90        client_id: ClientId,
91        startup_params: Option<crate::protocol::StartupParams>,
92    ) -> Result<SocketAddr> {
93        // Parse startup parameters
94        let database_name = startup_params
95            .as_ref()
96            .map(|p| p.get_optional_string("database"))
97            .transpose()?
98            .flatten()
99            .unwrap_or_else(|| "admin".to_string());
100
101        let username = startup_params
102            .as_ref()
103            .map(|p| p.get_optional_string("username"))
104            .transpose()?
105            .flatten();
106
107        let password = startup_params
108            .as_ref()
109            .map(|p| p.get_optional_string("password"))
110            .transpose()?
111            .flatten();
112
113        // Build MongoDB connection string
114        let connection_string = if let (Some(user), Some(pass)) = (username, password) {
115            format!("mongodb://{}:{}@{}", user, pass, remote_addr)
116        } else {
117            format!("mongodb://{}", remote_addr)
118        };
119
120        // Parse connection options
121        let client_options = ClientOptions::parse(&connection_string)
122            .await
123            .context(format!(
124                "Failed to parse MongoDB connection string for {}",
125                remote_addr
126            ))?;
127
128        // Connect to MongoDB server
129        let mongo_client = MongoClient::with_options(client_options)
130            .context(format!("Failed to connect to MongoDB at {}", remote_addr))?;
131
132        info!("MongoDB client {} connected to {}", client_id, remote_addr);
133
134        // Get database
135        let db = mongo_client.database(&database_name);
136
137        // Parse socket address (MongoDB connection string to SocketAddr)
138        let socket_addr: SocketAddr = if remote_addr.contains(':') {
139            remote_addr
140                .parse()
141                .context("Failed to parse socket address")?
142        } else {
143            format!("{}:27017", remote_addr)
144                .parse()
145                .context("Failed to parse socket address")?
146        };
147
148        // Update client state
149        app_state
150            .update_client_status(client_id, ClientStatus::Connected)
151            .await;
152        let _ = status_tx.send(format!(
153            "[CLIENT] MongoDB client {} connected to {}",
154            client_id, remote_addr
155        ));
156        let _ = status_tx.send("__UPDATE_UI__".to_string());
157
158        // Wrap database in Arc for shared access. `Database` is a cheap handle onto the
159        // driver's connection pool and is `Send + Sync`, so the LLM path and the injected
160        // command loop can hold it at once with no mutex.
161        let db_arc = Arc::new(db);
162        let protocol = Arc::new(MongodbClientProtocol::new());
163
164        // Command channel for injected actions (the dashboard's [ send ]).
165        // Registered BEFORE the connected-event LLM call, which a manual `*` rule can park
166        // for minutes — the operator must be able to reach the client while it waits.
167        let command_rx =
168            crate::client::command_support::register_command_channel(&app_state, client_id).await;
169        let cmd_task = tokio::spawn(Self::command_loop(
170            command_rx,
171            protocol.clone(),
172            db_arc.clone(),
173            client_id,
174            llm_client.clone(),
175            app_state.clone(),
176            status_tx.clone(),
177        ));
178        app_state.register_client_task(client_id, cmd_task).await;
179
180        // Call LLM with connected event
181        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
182            let event = Event::new(
183                &MONGODB_CLIENT_CONNECTED_EVENT,
184                serde_json::json!({
185                    "remote_addr": remote_addr,
186                    "database": database_name,
187                }),
188            );
189
190            let memory = app_state
191                .get_memory_for_client(client_id)
192                .await
193                .unwrap_or_default();
194
195            let protocol_clone = protocol.clone();
196            let db_clone = db_arc.clone();
197            let app_state_clone = app_state.clone();
198            let status_tx_clone = status_tx.clone();
199
200            // Registered with AppState so stop_client can abort this task —
201            // dropping a JoinHandle only detaches it in Tokio.
202            let task_registrar = app_state.clone();
203            let task_handle = tokio::spawn(async move {
204                match call_llm_for_client(
205                    &llm_client,
206                    &app_state_clone,
207                    client_id.to_string(),
208                    &instruction,
209                    &memory,
210                    Some(&event),
211                    protocol_clone.as_ref(),
212                    &status_tx_clone,
213                )
214                .await
215                {
216                    Ok(ClientLlmResult {
217                        actions,
218                        memory_updates,
219                    }) => {
220                        // Update memory
221                        if let Some(mem) = memory_updates {
222                            app_state_clone.set_memory_for_client(client_id, mem).await;
223                        }
224
225                        // Execute actions
226                        for action in actions {
227                            match Self::execute_llm_action(
228                                client_id,
229                                action,
230                                &protocol_clone,
231                                &db_clone,
232                                &app_state_clone,
233                                &llm_client,
234                                &status_tx_clone,
235                                0,
236                            )
237                            .await
238                            {
239                                Ok(true) => {}
240                                Ok(false) => break,
241                                Err(e) => error!("Error executing MongoDB action: {}", e),
242                            }
243                        }
244                    }
245                    Err(e) => {
246                        error!("LLM error for MongoDB client {}: {}", client_id, e);
247                    }
248                }
249            });
250            task_registrar
251                .register_client_task(client_id, task_handle)
252                .await;
253        }
254
255        Ok(socket_addr)
256    }
257
258    #[cfg(target_arch = "wasm32")]
259    async fn connect_impl(
260        _remote_addr: String,
261        _llm_client: OllamaClient,
262        _app_state: Arc<AppState>,
263        _status_tx: mpsc::UnboundedSender<String>,
264        _client_id: ClientId,
265        _startup_params: Option<crate::protocol::StartupParams>,
266    ) -> Result<SocketAddr> {
267        Err(anyhow::anyhow!("MongoDB client feature not enabled"))
268    }
269
270    /// Drain injected commands until the channel closes (the client was removed or stopped)
271    /// or an injected `disconnect` ends the session.
272    ///
273    /// The generic `command_support::handle_stream_client_command` cannot serve this client:
274    /// the driver owns the socket, so every verb yields `ClientActionResult::Custom` and the
275    /// effect goes through the shared [`Self::apply_action`].
276    #[cfg(not(target_arch = "wasm32"))]
277    #[allow(clippy::too_many_arguments)]
278    async fn command_loop(
279        mut command_rx: mpsc::Receiver<ClientCommand>,
280        protocol: Arc<MongodbClientProtocol>,
281        db: Arc<Database>,
282        client_id: ClientId,
283        llm_client: OllamaClient,
284        app_state: Arc<AppState>,
285        status_tx: mpsc::UnboundedSender<String>,
286    ) {
287        use crate::llm::actions::protocol_trait::Protocol;
288        use crate::state::AccessLogOwner;
289
290        while let Some(command) = command_rx.recv().await {
291            let action = command.action.clone();
292
293            let mut follow_up: Option<Event> = None;
294            let outcome = match protocol.execute_action(action.clone()) {
295                Err(e) => Ok(ClientSendOutcome::Rejected {
296                    error: e.to_string(),
297                }),
298                Ok(result) => match Self::apply_action(client_id, result, &db, &status_tx).await {
299                    Err(e) => Err(e),
300                    Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
301                    Ok(Applied::Nothing(what)) => Ok(ClientSendOutcome::Executed {
302                        detail: what.to_string(),
303                    }),
304                    Ok(Applied::Ran { detail, event }) => {
305                        follow_up = Some(event);
306                        Ok(ClientSendOutcome::Executed { detail })
307                    }
308                },
309            };
310
311            let outcome_json = match &outcome {
312                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
313                Err(e) => serde_json::json!({"error": e.to_string()}),
314            };
315            app_state
316                .record_access_log(
317                    AccessLogOwner::Client(client_id.as_u32()),
318                    protocol.protocol_name(),
319                    None,
320                    "injected_action",
321                    action,
322                    vec![outcome_json],
323                )
324                .await;
325
326            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
327            if let Err(e) = &outcome {
328                error!("MongoDB client {} injected action failed: {}", client_id, e);
329                let _ = status_tx.send(format!(
330                    "[WARN] Client {} injected action failed: {}",
331                    client_id, e
332                ));
333            }
334            let _ = status_tx.send("__UPDATE_UI__".to_string());
335            crate::client::command_support::reply(command, outcome);
336
337            if disconnect {
338                app_state
339                    .update_client_status(client_id, ClientStatus::Disconnected)
340                    .await;
341                let _ = status_tx.send("__UPDATE_UI__".to_string());
342                break;
343            }
344
345            // The model sees an injected operation's result exactly as it sees one it asked
346            // for — after the reply, so the dashboard is not held for an LLM round-trip.
347            if let Some(event) = follow_up {
348                if let Err(e) = Self::raise_result_event(
349                    client_id,
350                    event,
351                    &protocol,
352                    &db,
353                    &app_state,
354                    &llm_client,
355                    &status_tx,
356                    0,
357                )
358                .await
359                {
360                    error!("MongoDB client {} result event failed: {}", client_id, e);
361                }
362            }
363        }
364
365        // Every exit path lands here: drop the command handle so the dashboard stops
366        // offering [ send ] on a client whose loop is gone.
367        app_state.remove_client_handle(client_id).await;
368        let _ = status_tx.send("__UPDATE_UI__".to_string());
369    }
370
371    /// Execute one action from the LLM and report what it did.
372    ///
373    /// Returns `false` once the session has ended, so a caller iterating a batch stops rather
374    /// than running the rest against a disconnected client.
375    ///
376    /// `depth` is how many follow-up hops led here; 0 for the connected event and for an
377    /// injected command. See [`MAX_FOLLOWUP_DEPTH`].
378    #[cfg(not(target_arch = "wasm32"))]
379    #[allow(clippy::too_many_arguments)]
380    async fn execute_llm_action(
381        client_id: ClientId,
382        action: serde_json::Value,
383        protocol: &Arc<MongodbClientProtocol>,
384        db: &Arc<Database>,
385        app_state: &Arc<AppState>,
386        llm_client: &OllamaClient,
387        status_tx: &mpsc::UnboundedSender<String>,
388        depth: u8,
389    ) -> Result<bool> {
390        match Self::apply_action(client_id, protocol.execute_action(action)?, db, status_tx).await?
391        {
392            Applied::Ran { event, detail } => {
393                if depth >= MAX_FOLLOWUP_DEPTH {
394                    warn!(
395                        "MongoDB client {} stopped a follow-up chain at depth {}: '{}' ran but \
396                         its result is not being reported, so the model will not answer it",
397                        client_id, depth, detail
398                    );
399                    let _ = status_tx.send(format!(
400                        "[WARN] MongoDB client {} hit the follow-up depth limit ({}); the last \
401                         operation ran but its result was not reported",
402                        client_id, MAX_FOLLOWUP_DEPTH
403                    ));
404                } else {
405                    Self::raise_result_event(
406                        client_id, event, protocol, db, app_state, llm_client, status_tx, depth,
407                    )
408                    .await?;
409                }
410            }
411            Applied::Disconnect => {
412                info!("MongoDB client {} disconnecting", client_id);
413                app_state
414                    .update_client_status(client_id, ClientStatus::Disconnected)
415                    .await;
416                return Ok(false);
417            }
418            Applied::Nothing(what) => {
419                trace!(
420                    "MongoDB client {} action had no effect: {}",
421                    client_id,
422                    what
423                );
424            }
425        }
426
427        Ok(true)
428    }
429
430    /// Run one executed action against the database. Shared by the LLM path and injected
431    /// commands so the BSON conversion and driver calls exist exactly once.
432    #[cfg(not(target_arch = "wasm32"))]
433    async fn apply_action(
434        client_id: ClientId,
435        result: crate::llm::actions::client_trait::ClientActionResult,
436        db: &Arc<Database>,
437        status_tx: &mpsc::UnboundedSender<String>,
438    ) -> Result<Applied> {
439        use crate::llm::actions::client_trait::ClientActionResult;
440
441        match result {
442            ClientActionResult::Custom { name, data } if name == "mongodb_find" => {
443                let collection_name = data
444                    .get("collection")
445                    .and_then(|v| v.as_str())
446                    .context("Missing collection")?;
447                let filter_json = data.get("filter").cloned().unwrap_or(serde_json::json!({}));
448                let projection_json = data.get("projection").cloned();
449                let limit = data.get("limit").and_then(|v| v.as_i64());
450
451                trace!(
452                    "MongoDB client {} finding in collection: {}",
453                    client_id,
454                    collection_name
455                );
456
457                let collection = db.collection::<Document>(collection_name);
458
459                // Convert JSON filter to BSON document
460                let filter = serde_json::from_value::<Document>(filter_json.clone())
461                    .context("Failed to convert filter to BSON")?;
462
463                // Build find options
464                let mut find_options = FindOptions::default();
465                if let Some(proj_json) = projection_json {
466                    let projection = serde_json::from_value::<Document>(proj_json).ok();
467                    find_options.projection = projection;
468                }
469                if let Some(lim) = limit {
470                    find_options.limit = Some(lim);
471                }
472
473                // Execute find
474                let cursor = collection
475                    .find(filter)
476                    .with_options(find_options)
477                    .await
478                    .context("Failed to execute find")?;
479
480                // Collect results
481                use futures::stream::StreamExt;
482                let documents: Vec<Document> = cursor
483                    .collect::<Vec<Result<Document, mongodb::error::Error>>>()
484                    .await
485                    .into_iter()
486                    .filter_map(|r| r.ok())
487                    .collect();
488
489                let _ = status_tx.send(format!(
490                    "[MongoDB] Found {} documents in {}",
491                    documents.len(),
492                    collection_name
493                ));
494
495                let detail = format!(
496                    "find on '{}' returned {} documents; no byte count — the driver owns the socket",
497                    collection_name,
498                    documents.len()
499                );
500                let event = result_event("find", Some(documents), None);
501                Ok(Applied::Ran { detail, event })
502            }
503            ClientActionResult::Custom { name, data } if name == "mongodb_insert" => {
504                let collection_name = data
505                    .get("collection")
506                    .and_then(|v| v.as_str())
507                    .context("Missing collection")?;
508                let doc_json = data.get("document").context("Missing document")?;
509
510                trace!(
511                    "MongoDB client {} inserting into collection: {}",
512                    client_id,
513                    collection_name
514                );
515
516                let collection = db.collection::<Document>(collection_name);
517
518                // Convert JSON to BSON document
519                let document = serde_json::from_value::<Document>(doc_json.clone())
520                    .context("Failed to convert document to BSON")?;
521
522                // Execute insert
523                let result = collection
524                    .insert_one(document)
525                    .await
526                    .context("Failed to insert document")?;
527
528                let _ = status_tx.send(format!(
529                    "[MongoDB] Inserted document into {} (id: {:?})",
530                    collection_name, result.inserted_id
531                ));
532
533                let detail = format!(
534                    "insert into '{}' acknowledged (id {:?}); no byte count — the driver owns the socket",
535                    collection_name, result.inserted_id
536                );
537                let event = result_event("insert", None, Some(1));
538                Ok(Applied::Ran { detail, event })
539            }
540            ClientActionResult::Custom { name, data } if name == "mongodb_update" => {
541                let collection_name = data
542                    .get("collection")
543                    .and_then(|v| v.as_str())
544                    .context("Missing collection")?;
545                let filter_json = data.get("filter").context("Missing filter")?;
546                let update_json = data.get("update").context("Missing update")?;
547
548                trace!(
549                    "MongoDB client {} updating collection: {}",
550                    client_id,
551                    collection_name
552                );
553
554                let collection = db.collection::<Document>(collection_name);
555
556                // Convert JSON to BSON documents
557                let filter = serde_json::from_value::<Document>(filter_json.clone())
558                    .context("Failed to convert filter to BSON")?;
559                let update = serde_json::from_value::<Document>(update_json.clone())
560                    .context("Failed to convert update to BSON")?;
561
562                // Execute update
563                let result = collection
564                    .update_many(filter, update)
565                    .await
566                    .context("Failed to update documents")?;
567
568                let _ = status_tx.send(format!(
569                    "[MongoDB] Updated {} documents in {}",
570                    result.modified_count, collection_name
571                ));
572
573                let detail = format!(
574                    "update on '{}' modified {} documents; no byte count — the driver owns the socket",
575                    collection_name, result.modified_count
576                );
577                let event = result_event("update", None, Some(result.modified_count));
578                Ok(Applied::Ran { detail, event })
579            }
580            ClientActionResult::Custom { name, data } if name == "mongodb_delete" => {
581                let collection_name = data
582                    .get("collection")
583                    .and_then(|v| v.as_str())
584                    .context("Missing collection")?;
585                let filter_json = data.get("filter").context("Missing filter")?;
586
587                trace!(
588                    "MongoDB client {} deleting from collection: {}",
589                    client_id,
590                    collection_name
591                );
592
593                let collection = db.collection::<Document>(collection_name);
594
595                // Convert JSON filter to BSON document
596                let filter = serde_json::from_value::<Document>(filter_json.clone())
597                    .context("Failed to convert filter to BSON")?;
598
599                // Execute delete
600                let result = collection
601                    .delete_many(filter)
602                    .await
603                    .context("Failed to delete documents")?;
604
605                let _ = status_tx.send(format!(
606                    "[MongoDB] Deleted {} documents from {}",
607                    result.deleted_count, collection_name
608                ));
609
610                let detail = format!(
611                    "delete on '{}' removed {} documents; no byte count — the driver owns the socket",
612                    collection_name, result.deleted_count
613                );
614                let event = result_event("delete", None, Some(result.deleted_count));
615                Ok(Applied::Ran { detail, event })
616            }
617            ClientActionResult::Custom { name, .. } => {
618                trace!(
619                    "MongoDB client {} ignoring custom result '{}'",
620                    client_id,
621                    name
622                );
623                Ok(Applied::Nothing("custom result not handled by this client"))
624            }
625            ClientActionResult::Disconnect => Ok(Applied::Disconnect),
626            ClientActionResult::WaitForMore => Ok(Applied::Nothing("wait_for_more")),
627            ClientActionResult::NoAction => Ok(Applied::Nothing("no_action")),
628            ClientActionResult::SendData(_) => Ok(Applied::Nothing(
629                "send_data is not meaningful for a mongodb driver handle",
630            )),
631            ClientActionResult::Multiple(_) => Ok(Applied::Nothing(
632                "multiple results not handled by this client",
633            )),
634        }
635    }
636
637    #[cfg(target_arch = "wasm32")]
638    async fn execute_llm_action(
639        _client_id: ClientId,
640        _action: serde_json::Value,
641        _protocol: &Arc<MongodbClientProtocol>,
642        _db: &Arc<()>,
643        _app_state: &Arc<AppState>,
644        _llm_client: &OllamaClient,
645        _status_tx: &mpsc::UnboundedSender<String>,
646    ) -> Result<()> {
647        Err(anyhow::anyhow!("MongoDB client feature not enabled"))
648    }
649
650    /// Send a prepared result event to the LLM and run whatever it answers with.
651    #[cfg(not(target_arch = "wasm32"))]
652    #[allow(clippy::too_many_arguments)]
653    async fn raise_result_event(
654        client_id: ClientId,
655        event: Event,
656        protocol: &Arc<MongodbClientProtocol>,
657        db: &Arc<Database>,
658        app_state: &Arc<AppState>,
659        llm_client: &OllamaClient,
660        status_tx: &mpsc::UnboundedSender<String>,
661        depth: u8,
662    ) -> Result<()> {
663        let memory = app_state
664            .get_memory_for_client(client_id)
665            .await
666            .unwrap_or_default();
667        let instruction = app_state
668            .get_instruction_for_client(client_id)
669            .await
670            .unwrap_or_default();
671
672        match call_llm_for_client(
673            llm_client,
674            app_state,
675            client_id.to_string(),
676            &instruction,
677            &memory,
678            Some(&event),
679            protocol.as_ref(),
680            status_tx,
681        )
682        .await
683        {
684            Ok(ClientLlmResult {
685                actions,
686                memory_updates,
687            }) => {
688                // Update memory
689                if let Some(mem) = memory_updates {
690                    app_state.set_memory_for_client(client_id, mem).await;
691                }
692
693                // Execute the follow-up actions, and report their results too.
694                //
695                // These were once logged and dropped outright ("we'd need to pass db_arc
696                // here"), then run through `apply_action` — which executes but raises no
697                // event, so the chain stopped dead after one hop and a query the model
698                // issued in reply to a write confirmation returned its documents to nobody.
699                // They now go back through `execute_llm_action`, which reports, bounded by
700                // `MAX_FOLLOWUP_DEPTH`. The recursion is boxed because an `async fn` that
701                // awaits itself has an infinitely-sized future.
702                for action in actions {
703                    match Box::pin(Self::execute_llm_action(
704                        client_id,
705                        action.clone(),
706                        protocol,
707                        db,
708                        app_state,
709                        llm_client,
710                        status_tx,
711                        depth + 1,
712                    ))
713                    .await
714                    {
715                        Ok(true) => {}
716                        Ok(false) => {
717                            info!("MongoDB client {} follow-up ended the session", client_id);
718                            break;
719                        }
720                        Err(e) => {
721                            error!(
722                                "MongoDB client {} follow-up action failed: {}",
723                                client_id, e
724                            );
725                        }
726                    }
727                }
728            }
729            Err(e) => {
730                error!("LLM error for MongoDB result: {}", e);
731            }
732        }
733
734        Ok(())
735    }
736}
737
738/// Build the `mongodb_result_received` event for one completed operation.
739#[cfg(not(target_arch = "wasm32"))]
740fn result_event(result_type: &str, documents: Option<Vec<Document>>, count: Option<u64>) -> Event {
741    let mut event_data = serde_json::json!({
742        "result_type": result_type,
743    });
744
745    if let Some(docs) = documents {
746        // Convert BSON documents to JSON
747        let json_docs: Vec<serde_json::Value> = docs
748            .iter()
749            .filter_map(|doc| serde_json::to_value(doc).ok())
750            .collect();
751        event_data["documents"] = serde_json::json!(json_docs);
752    }
753
754    if let Some(c) = count {
755        event_data["count"] = serde_json::json!(c);
756    }
757
758    Event::new(&MONGODB_CLIENT_RESULT_RECEIVED_EVENT, event_data)
759}