1use 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
19pub 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#[derive(Debug, Clone)]
84pub struct ToolCall {
85 pub id: String,
87 pub name: String,
89 pub arguments: String,
91}
92
93#[derive(Debug, Clone)]
95pub struct ToolDispatchResult {
96 pub status: String,
98 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#[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
137pub 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 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
252fn parse_search_type(s: Option<&str>) -> SearchType {
255 let raw = s.unwrap_or("GRAPH_COMPLETION");
256 if raw == "CODE" {
257 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
275fn 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
297pub 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
334pub 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 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 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 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 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 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 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 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}