Skip to main content

cognee_http_server/
responses_dispatch.rs

1//! Function-call dispatcher for `POST /api/v1/responses`.
2//!
3//! Mirrors Python's [`cognee.api.v1.responses.dispatch_function.dispatch_function`].
4//! When the OpenAI Responses API returns a function-call item in `output`, this
5//! module routes it to the in-process pipeline (search / cognify) and returns
6//! a JSON-serialisable result string the handler can fold back into the
7//! response body as a `ToolCallOutput`.
8
9use std::sync::Arc;
10
11use serde_json::{Value, json};
12use tracing::warn;
13
14use cognee_search::types::{SearchOutput, SearchRequest, SearchType};
15
16use crate::auth::AuthenticatedUser;
17use crate::components::ComponentHandles;
18
19/// Default tools advertised to the upstream OpenAI Responses API.
20///
21/// Wire-shape parity with Python's
22/// [`cognee.api.v1.responses.default_tools.DEFAULT_TOOLS`]. The `"prune"` tool
23/// is intentionally omitted (commented as dangerous in the Python source).
24pub fn default_tools() -> Vec<Value> {
25    vec![
26        json!({
27            "type": "function",
28            "name": "search",
29            "description": "Search for information within the knowledge graph",
30            "parameters": {
31                "type": "object",
32                "properties": {
33                    "search_query": {
34                        "type": "string",
35                        "description": "The query to search for in the knowledge graph",
36                    },
37                    "search_type": {
38                        "type": "string",
39                        "description": "Type of search to perform",
40                        "enum": ["CODE", "GRAPH_COMPLETION", "NATURAL_LANGUAGE"],
41                    },
42                    "top_k": {
43                        "type": "integer",
44                        "description": "Maximum number of results to return",
45                        "default": 10,
46                    },
47                    "datasets": {
48                        "type": "array",
49                        "items": {"type": "string"},
50                        "description": "Optional list of dataset names to search within",
51                    },
52                },
53                "required": ["search_query"],
54            },
55        }),
56        json!({
57            "type": "function",
58            "name": "cognify",
59            "description": "Convert text into a knowledge graph or process all added content",
60            "parameters": {
61                "type": "object",
62                "properties": {
63                    "text": {
64                        "type": "string",
65                        "description": "Text content to be converted into a knowledge graph",
66                    },
67                    "ontology_file_path": {
68                        "type": "string",
69                        "description": "Path to a custom ontology file",
70                    },
71                    "custom_prompt": {
72                        "type": "string",
73                        "description": "Custom prompt for entity extraction and graph generation. If provided, this prompt will be used instead of the default prompts.",
74                    },
75                },
76                "required": ["text"],
77            },
78        }),
79    ]
80}
81
82/// A parsed function-call item extracted from the Responses API `output` array.
83#[derive(Debug, Clone)]
84pub struct ToolCall {
85    /// `call_id` from the upstream output (or a generated `call_*` fallback).
86    pub id: String,
87    /// Function name.
88    pub name: String,
89    /// JSON-string arguments. Stored as a string for wire-parity with Python.
90    pub arguments: String,
91}
92
93/// Outcome of dispatching one tool call.
94#[derive(Debug, Clone)]
95pub struct ToolDispatchResult {
96    /// `"success"` or `"error"` — fed into `ToolCallOutputDTO.status`.
97    pub status: String,
98    /// The function's structured result, wrapped in `{"result": ...}` to match
99    /// Python's `ToolCallOutput(data={"result": function_result})` shape.
100    pub data: Value,
101}
102
103impl ToolDispatchResult {
104    fn success(result: Value) -> Self {
105        Self {
106            status: "success".into(),
107            data: json!({ "result": result }),
108        }
109    }
110
111    fn error(msg: impl Into<String>) -> Self {
112        Self {
113            status: "error".into(),
114            data: json!({ "result": msg.into() }),
115        }
116    }
117}
118
119/// Trait used to fan out tool calls. Implementations may run real cognee
120/// pipelines or canned stubs (the mocked dispatcher tests use a stub
121/// `ToolDispatcher`).
122#[async_trait::async_trait]
123pub trait ToolDispatcher: Send + Sync {
124    async fn dispatch_search(
125        &self,
126        arguments: &Value,
127        user: &AuthenticatedUser,
128    ) -> ToolDispatchResult;
129
130    async fn dispatch_cognify(
131        &self,
132        arguments: &Value,
133        user: &AuthenticatedUser,
134    ) -> ToolDispatchResult;
135}
136
137/// Production dispatcher backed by the wired `ComponentHandles`.
138///
139/// `search` is routed to the `SearchOrchestrator`; `cognify` returns an
140/// explicit `error` `ToolDispatchResult` pending end-to-end wiring of the
141/// cognify pipeline (see [`Self::dispatch_cognify`]).
142pub struct ComponentHandlesDispatcher {
143    components: Arc<ComponentHandles>,
144}
145
146impl ComponentHandlesDispatcher {
147    pub fn new(components: Arc<ComponentHandles>) -> Self {
148        Self { components }
149    }
150}
151
152#[async_trait::async_trait]
153impl ToolDispatcher for ComponentHandlesDispatcher {
154    async fn dispatch_search(
155        &self,
156        arguments: &Value,
157        user: &AuthenticatedUser,
158    ) -> ToolDispatchResult {
159        let Some(query) = arguments
160            .get("search_query")
161            .and_then(Value::as_str)
162            .filter(|s| !s.is_empty())
163        else {
164            return ToolDispatchResult::error(
165                "Error: Missing required 'search_query' parameter".to_string(),
166            );
167        };
168
169        let search_type = parse_search_type(arguments.get("search_type").and_then(Value::as_str));
170        let top_k = arguments
171            .get("top_k")
172            .and_then(Value::as_i64)
173            .and_then(|n| if n > 0 { Some(n as usize) } else { None })
174            .or(Some(10));
175        let datasets = arguments
176            .get("datasets")
177            .and_then(Value::as_array)
178            .map(|arr| {
179                arr.iter()
180                    .filter_map(Value::as_str)
181                    .map(str::to_string)
182                    .collect::<Vec<_>>()
183            });
184
185        let Some(orchestrator) = self.components.search_orchestrator.clone() else {
186            return ToolDispatchResult::error(
187                "Error: search orchestrator is not wired in this build",
188            );
189        };
190
191        let request = SearchRequest {
192            query_text: query.to_string(),
193            search_type,
194            top_k,
195            datasets,
196            dataset_ids: None,
197            system_prompt: None,
198            system_prompt_path: Some("answer_simple_question.txt".to_string()),
199            only_context: None,
200            use_combined_context: None,
201            session_id: None,
202            node_type: None,
203            node_name: None,
204            node_name_filter_operator: None,
205            wide_search_top_k: None,
206            triplet_distance_penalty: None,
207            save_interaction: None,
208            user_id: Some(user.id),
209            verbose: None,
210            feedback_influence: None,
211            retriever_specific_config: None,
212            response_schema: None,
213            custom_search_type: None,
214            auto_feedback_detection: None,
215            neighborhood_depth: None,
216            neighborhood_seed_top_k: None,
217            summarize_context: None,
218        };
219
220        match orchestrator.search(&request).await {
221            Ok(response) => ToolDispatchResult::success(search_output_to_json(response.result)),
222            Err(e) => ToolDispatchResult::error(format!("Error executing search: {e}")),
223        }
224    }
225
226    async fn dispatch_cognify(
227        &self,
228        arguments: &Value,
229        _user: &AuthenticatedUser,
230    ) -> ToolDispatchResult {
231        // KNOWN PARITY GAP — see docs/http-server/gaps/impl/07-responses-openai.md
232        // followup. Python's `handle_cognify` calls `add()` + `cognify()` end
233        // to end (`/tmp/cognee-python/cognee/api/v1/responses/dispatch_function.py:87-106`).
234        // Wiring that here requires the same multi-handle plumbing the
235        // `remember.rs` router uses (graph_db / vector_db / embedding_engine /
236        // thread_pool, plus an ingestion pipeline). Scoping that into the
237        // tool-dispatch surface is tracked as a follow-up; for now we return
238        // an explicit error so the upstream model is not silently misled
239        // into believing the knowledge graph has been updated.
240        let _text = arguments
241            .get("text")
242            .and_then(Value::as_str)
243            .unwrap_or_default();
244        let _ = self.components.database.as_ref();
245        ToolDispatchResult::error(
246            "Error: cognify tool dispatch is not yet wired in this build; \
247             call POST /api/v1/cognify directly",
248        )
249    }
250}
251
252/// Parse a `SearchType` string from the tool arguments. Falls back to
253/// `GraphCompletion` for unknown or missing values (matches Python parity).
254fn parse_search_type(s: Option<&str>) -> SearchType {
255    let raw = s.unwrap_or("GRAPH_COMPLETION");
256    if raw == "CODE" {
257        // Python's tool description advertises "CODE" but there is no such
258        // SearchType variant in either runtime today — fall back to the
259        // default (Python uses the same default for unrecognised values).
260        warn!(
261            value = raw,
262            "responses tool: 'CODE' not supported, falling back to GRAPH_COMPLETION"
263        );
264        return SearchType::GraphCompletion;
265    }
266    serde_json::from_value::<SearchType>(Value::String(raw.to_string())).unwrap_or_else(|_| {
267        warn!(
268            value = raw,
269            "responses tool: invalid search_type, defaulting to GRAPH_COMPLETION"
270        );
271        SearchType::GraphCompletion
272    })
273}
274
275/// Flatten a `SearchOutput` into a single JSON value for the tool result.
276fn search_output_to_json(out: SearchOutput) -> Value {
277    match out {
278        SearchOutput::Text(s) => Value::String(s),
279        SearchOutput::Texts(v) => Value::Array(v.into_iter().map(Value::String).collect()),
280        SearchOutput::Items(items) => Value::Array(items.into_iter().map(|i| i.payload).collect()),
281        SearchOutput::GraphQueryRows(rows) => Value::Array(
282            rows.into_iter()
283                .map(|row| Value::Array(row.into_iter().collect()))
284                .collect(),
285        ),
286        SearchOutput::Rules(rules) => Value::Array(
287            rules
288                .into_iter()
289                .map(|r| json!({"node_set": r.node_set, "text": r.text}))
290                .collect(),
291        ),
292        SearchOutput::Ack { message } => json!({"message": message}),
293        SearchOutput::Structured(v) => v,
294    }
295}
296
297/// Extract function-call entries from a Responses API `output` array.
298///
299/// Mirrors Python's iteration in `get_responses_router.py:124-138` which
300/// inspects each `output` item for `type == "function_call"`.
301pub fn extract_tool_calls(output: &Value) -> Vec<ToolCall> {
302    let Some(items) = output.as_array() else {
303        return Vec::new();
304    };
305    let mut calls = Vec::new();
306    for item in items {
307        if item.get("type").and_then(Value::as_str) != Some("function_call") {
308            continue;
309        }
310        let name = item
311            .get("name")
312            .and_then(Value::as_str)
313            .unwrap_or("")
314            .to_string();
315        let arguments = item
316            .get("arguments")
317            .and_then(Value::as_str)
318            .unwrap_or("{}")
319            .to_string();
320        let id = item
321            .get("call_id")
322            .and_then(Value::as_str)
323            .map(str::to_string)
324            .unwrap_or_else(|| format!("call_{}", uuid::Uuid::new_v4().simple()));
325        calls.push(ToolCall {
326            id,
327            name,
328            arguments,
329        });
330    }
331    calls
332}
333
334/// Dispatch a single tool call to the appropriate handler.
335pub async fn dispatch_one(
336    call: &ToolCall,
337    dispatcher: &dyn ToolDispatcher,
338    user: &AuthenticatedUser,
339) -> ToolDispatchResult {
340    let parsed_args: Value = serde_json::from_str(&call.arguments).unwrap_or_else(|_| json!({}));
341    match call.name.as_str() {
342        "search" => dispatcher.dispatch_search(&parsed_args, user).await,
343        "cognify" => dispatcher.dispatch_cognify(&parsed_args, user).await,
344        other => ToolDispatchResult::error(format!("Error: Unknown function {other}")),
345    }
346}
347
348#[cfg(test)]
349#[allow(
350    clippy::unwrap_used,
351    clippy::expect_used,
352    reason = "test code — panics are acceptable failures"
353)]
354mod tests {
355    use super::*;
356    use uuid::Uuid;
357
358    fn fake_user() -> AuthenticatedUser {
359        AuthenticatedUser {
360            id: Uuid::new_v4(),
361            email: "t@example.com".into(),
362            is_superuser: false,
363            is_verified: true,
364            is_active: true,
365            tenant_id: Some(Uuid::new_v4()),
366            auth_method: crate::auth::AuthMethod::DefaultUser,
367        }
368    }
369
370    struct StubDispatcher {
371        search_result: Value,
372        cognify_result: Value,
373    }
374
375    #[async_trait::async_trait]
376    impl ToolDispatcher for StubDispatcher {
377        async fn dispatch_search(
378            &self,
379            arguments: &Value,
380            _user: &AuthenticatedUser,
381        ) -> ToolDispatchResult {
382            // Echo back arguments so tests can verify the dispatcher routed
383            // the right call.
384            ToolDispatchResult::success(json!({
385                "echo_args": arguments.clone(),
386                "result": self.search_result.clone(),
387            }))
388        }
389
390        async fn dispatch_cognify(
391            &self,
392            arguments: &Value,
393            _user: &AuthenticatedUser,
394        ) -> ToolDispatchResult {
395            ToolDispatchResult::success(json!({
396                "echo_args": arguments.clone(),
397                "result": self.cognify_result.clone(),
398            }))
399        }
400    }
401
402    #[test]
403    fn default_tools_contains_search_and_cognify() {
404        let tools = default_tools();
405        assert_eq!(tools.len(), 2);
406        let names: Vec<&str> = tools
407            .iter()
408            .map(|t| t["name"].as_str().expect("name"))
409            .collect();
410        assert!(names.contains(&"search"));
411        assert!(names.contains(&"cognify"));
412        // Prune is intentionally omitted per Python parity.
413        assert!(!names.contains(&"prune"));
414    }
415
416    #[test]
417    fn extract_tool_calls_picks_up_function_call_items() {
418        let output = json!([
419            {"type": "message", "content": "hi"},
420            {
421                "type": "function_call",
422                "name": "search",
423                "arguments": "{\"search_query\":\"alice\"}",
424                "call_id": "call_abc"
425            },
426            {
427                "type": "function_call",
428                "name": "cognify",
429                "arguments": "{\"text\":\"foo\"}"
430            }
431        ]);
432        let calls = extract_tool_calls(&output);
433        assert_eq!(calls.len(), 2);
434        assert_eq!(calls[0].name, "search");
435        assert_eq!(calls[0].id, "call_abc");
436        assert!(calls[0].arguments.contains("alice"));
437        assert_eq!(calls[1].name, "cognify");
438        // Synthesised id when `call_id` is missing.
439        assert!(calls[1].id.starts_with("call_"));
440    }
441
442    #[test]
443    fn extract_tool_calls_on_non_array_returns_empty() {
444        let calls = extract_tool_calls(&json!({"foo": "bar"}));
445        assert!(calls.is_empty());
446    }
447
448    #[tokio::test]
449    async fn dispatch_one_routes_search_to_dispatcher() {
450        let stub = StubDispatcher {
451            search_result: json!("from-search"),
452            cognify_result: json!("from-cognify"),
453        };
454        let call = ToolCall {
455            id: "c1".into(),
456            name: "search".into(),
457            arguments: r#"{"search_query":"q","top_k":3}"#.into(),
458        };
459        let user = fake_user();
460        let out = dispatch_one(&call, &stub, &user).await;
461        assert_eq!(out.status, "success");
462        // `data.result` is wrapped in `{"result": ...}`, so the stub's
463        // `result.result` lives at `data.result.result`.
464        assert_eq!(out.data["result"]["result"], "from-search");
465        assert_eq!(out.data["result"]["echo_args"]["search_query"], "q");
466        assert_eq!(out.data["result"]["echo_args"]["top_k"], 3);
467    }
468
469    #[tokio::test]
470    async fn dispatch_one_routes_cognify_to_dispatcher() {
471        let stub = StubDispatcher {
472            search_result: json!("ignored"),
473            cognify_result: json!("from-cognify"),
474        };
475        let call = ToolCall {
476            id: "c2".into(),
477            name: "cognify".into(),
478            arguments: r#"{"text":"hello"}"#.into(),
479        };
480        let user = fake_user();
481        let out = dispatch_one(&call, &stub, &user).await;
482        assert_eq!(out.status, "success");
483        assert_eq!(out.data["result"]["result"], "from-cognify");
484        assert_eq!(out.data["result"]["echo_args"]["text"], "hello");
485    }
486
487    #[tokio::test]
488    async fn dispatch_one_unknown_function_returns_error() {
489        let stub = StubDispatcher {
490            search_result: json!("x"),
491            cognify_result: json!("x"),
492        };
493        let call = ToolCall {
494            id: "c3".into(),
495            name: "prune".into(),
496            arguments: "{}".into(),
497        };
498        let user = fake_user();
499        let out = dispatch_one(&call, &stub, &user).await;
500        assert_eq!(out.status, "error");
501        assert!(
502            out.data["result"]
503                .as_str()
504                .expect("error msg")
505                .contains("Unknown function")
506        );
507    }
508
509    #[tokio::test]
510    async fn dispatch_one_malformed_arguments_becomes_empty_object() {
511        // Malformed JSON in `arguments` must not panic — Python parses with
512        // `json.loads` and would raise, but we want a defensive default so a
513        // single malformed call doesn't kill the whole response.
514        let stub = StubDispatcher {
515            search_result: json!("ok"),
516            cognify_result: json!("ok"),
517        };
518        let call = ToolCall {
519            id: "c4".into(),
520            name: "search".into(),
521            arguments: "not json".into(),
522        };
523        let user = fake_user();
524        let out = dispatch_one(&call, &stub, &user).await;
525        // The stub dispatcher succeeds; the missing search_query is caught
526        // by the real dispatcher, not the stub. Either way, no panic.
527        assert_eq!(out.status, "success");
528    }
529
530    #[test]
531    fn parse_search_type_handles_known_values() {
532        assert_eq!(
533            parse_search_type(Some("GRAPH_COMPLETION")),
534            SearchType::GraphCompletion
535        );
536        assert_eq!(
537            parse_search_type(Some("NATURAL_LANGUAGE")),
538            SearchType::NaturalLanguage
539        );
540    }
541
542    #[test]
543    fn parse_search_type_falls_back_on_unknown() {
544        // "CODE" appears in the Python tool description but no SearchType
545        // variant exists — falls back to GraphCompletion.
546        assert_eq!(parse_search_type(Some("CODE")), SearchType::GraphCompletion);
547        assert_eq!(
548            parse_search_type(Some("UNKNOWN_X")),
549            SearchType::GraphCompletion
550        );
551        assert_eq!(parse_search_type(None), SearchType::GraphCompletion);
552    }
553
554    #[test]
555    fn search_output_to_json_handles_each_variant() {
556        let v = search_output_to_json(SearchOutput::Text("hi".into()));
557        assert_eq!(v, json!("hi"));
558
559        let v = search_output_to_json(SearchOutput::Items(vec![]));
560        assert!(v.is_array());
561
562        let v = search_output_to_json(SearchOutput::Structured(json!({"k":"v"})));
563        assert_eq!(v["k"], "v");
564    }
565}