Skip to main content

nika_engine/runtime/builtin/
complete.rs

1//! nika:complete - Signal agent task completion
2//!
3//! The completion tool is called by the agent to signal task completion
4//! with a structured result. This is part of the "explicit" completion mode.
5//!
6//! # Parameters
7//!
8//! ```json
9//! {
10//!   "result": "The final answer or output",        // Required
11//!   "confidence": 0.95,                            // Optional: 0.0-1.0
12//!   "reasoning": "Explanation of the approach"     // Optional: For audit
13//! }
14//! ```
15//!
16//! # Returns
17//!
18//! ```json
19//! {
20//!   "completed": true,
21//!   "result": "...",
22//!   "confidence": 0.95,
23//!   "is_final": true
24//! }
25//! ```
26//!
27//! # Integration with CompletionConfig
28//!
29//! When the agent is configured with `completion.mode: explicit`, the system
30//! prompt instructs the agent to call this tool when done. The RigAgentLoop
31//! detects calls to this tool and triggers completion.
32
33use super::BuiltinTool;
34use crate::error::NikaError;
35use serde::{Deserialize, Serialize};
36use serde_json::Value;
37use std::future::Future;
38use std::pin::Pin;
39
40// ═══════════════════════════════════════════════════════════════════════════
41// Constants
42// ═══════════════════════════════════════════════════════════════════════════
43
44/// Special marker that RigAgentLoop uses to detect completion
45pub const COMPLETION_MARKER: &str = "__NIKA_COMPLETE__";
46
47// ═══════════════════════════════════════════════════════════════════════════
48// Parameters
49// ═══════════════════════════════════════════════════════════════════════════
50
51/// Parameters for nika:complete tool.
52#[derive(Debug, Clone, Deserialize)]
53pub struct CompleteParams {
54    /// The final result of the task (required).
55    pub result: Value,
56
57    /// Confidence level in the result (0.0-1.0, optional).
58    #[serde(default)]
59    pub confidence: Option<f64>,
60
61    /// Reasoning or explanation for the result (optional).
62    #[serde(default)]
63    pub reasoning: Option<String>,
64
65    /// Additional metadata (optional).
66    #[serde(default)]
67    pub metadata: Option<Value>,
68}
69
70impl CompleteParams {
71    /// Validate the parameters.
72    pub fn validate(&self) -> Result<(), NikaError> {
73        // Validate confidence range if provided
74        if let Some(conf) = self.confidence {
75            if !(0.0..=1.0).contains(&conf) {
76                return Err(NikaError::ValidationError {
77                    reason: format!("confidence must be between 0.0 and 1.0, got {}", conf),
78                });
79            }
80        }
81
82        Ok(())
83    }
84
85    /// Get the result as a string (for simple text results).
86    pub fn result_as_string(&self) -> String {
87        match &self.result {
88            Value::String(s) => s.clone(),
89            other => other.to_string(),
90        }
91    }
92}
93
94// ═══════════════════════════════════════════════════════════════════════════
95// Response
96// ═══════════════════════════════════════════════════════════════════════════
97
98/// Response from nika:complete tool.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct CompleteResponse {
101    /// Whether completion was successful.
102    pub completed: bool,
103
104    /// The result value.
105    pub result: Value,
106
107    /// Confidence level (if provided).
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub confidence: Option<f64>,
110
111    /// Marker for RigAgentLoop to detect completion.
112    #[serde(default = "default_marker")]
113    pub marker: String,
114
115    /// Whether this is a final completion (not low-confidence retry).
116    #[serde(default)]
117    pub is_final: bool,
118}
119
120fn default_marker() -> String {
121    COMPLETION_MARKER.to_string()
122}
123
124impl CompleteResponse {
125    /// Create a successful completion response.
126    pub fn success(params: &CompleteParams, is_final: bool) -> Self {
127        Self {
128            completed: true,
129            result: params.result.clone(),
130            confidence: params.confidence,
131            marker: COMPLETION_MARKER.to_string(),
132            is_final,
133        }
134    }
135}
136
137// ═══════════════════════════════════════════════════════════════════════════
138// Tool Implementation
139// ═══════════════════════════════════════════════════════════════════════════
140
141/// nika:complete builtin tool.
142///
143/// Called by the agent to signal task completion in "explicit" mode.
144/// The RigAgentLoop detects this tool call and triggers the completion flow.
145pub struct CompleteTool;
146
147impl BuiltinTool for CompleteTool {
148    fn name(&self) -> &'static str {
149        "complete"
150    }
151
152    fn description(&self) -> &'static str {
153        "Signal task completion with a structured result"
154    }
155
156    fn parameters_schema(&self) -> serde_json::Value {
157        serde_json::json!({
158            "type": "object",
159            "properties": {
160                "result": {
161                    "type": "string",
162                    "description": "The final result or answer for the task. Serialize complex values as JSON strings."
163                },
164                "confidence": {
165                    "type": "number",
166                    "description": "Confidence level in the result (0.0-1.0)"
167                },
168                "reasoning": {
169                    "type": "string",
170                    "description": "Explanation of how you arrived at this result"
171                }
172            },
173            "required": ["result", "confidence", "reasoning"],
174            "additionalProperties": false
175        })
176    }
177
178    fn call<'a>(
179        &'a self,
180        args: String,
181    ) -> Pin<Box<dyn Future<Output = Result<String, NikaError>> + Send + 'a>> {
182        Box::pin(async move {
183            // Parse parameters
184            let params: CompleteParams =
185                serde_json::from_str(&args).map_err(|e| NikaError::BuiltinInvalidParams {
186                    tool: "nika:complete".into(),
187                    reason: format!("Invalid JSON parameters: {}", e),
188                })?;
189
190            // Validate parameters
191            params
192                .validate()
193                .map_err(|e| NikaError::BuiltinInvalidParams {
194                    tool: "nika:complete".into(),
195                    reason: e.to_string(),
196                })?;
197
198            tracing::debug!(
199                target: "nika_complete",
200                confidence = ?params.confidence,
201                has_reasoning = params.reasoning.is_some(),
202                "Agent signaling completion"
203            );
204
205            // Create response
206            // Note: is_final will be determined by RigAgentLoop based on confidence config
207            let response = CompleteResponse::success(&params, true);
208
209            serde_json::to_string(&response).map_err(|e| NikaError::BuiltinToolError {
210                tool: "nika:complete".into(),
211                reason: format!("Failed to serialize response: {}", e),
212            })
213        })
214    }
215}
216
217// ═══════════════════════════════════════════════════════════════════════════
218// Utility Functions
219// ═══════════════════════════════════════════════════════════════════════════
220
221/// Check if a tool call response indicates completion.
222pub fn is_completion_signal(tool_name: &str, response: &str) -> bool {
223    if tool_name != "nika:complete" && tool_name != "complete" {
224        return false;
225    }
226
227    // Check for the completion marker in the response
228    response.contains(COMPLETION_MARKER)
229}
230
231/// Parse a completion response from tool output.
232pub fn parse_completion_response(response: &str) -> Option<CompleteResponse> {
233    serde_json::from_str(response).ok()
234}
235
236// ═══════════════════════════════════════════════════════════════════════════
237// Tests
238// ═══════════════════════════════════════════════════════════════════════════
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_complete_tool_name() {
246        let tool = CompleteTool;
247        assert_eq!(tool.name(), "complete");
248    }
249
250    #[test]
251    fn test_complete_tool_description() {
252        let tool = CompleteTool;
253        assert!(tool.description().contains("completion"));
254    }
255
256    #[test]
257    fn test_complete_tool_schema() {
258        let tool = CompleteTool;
259        let schema = tool.parameters_schema();
260        assert_eq!(schema["type"], "object");
261        assert!(schema["properties"]["result"].is_object());
262        assert!(schema["properties"]["confidence"].is_object());
263        assert!(schema["properties"]["reasoning"].is_object());
264        assert_eq!(schema["additionalProperties"], false);
265        assert!(schema["required"]
266            .as_array()
267            .unwrap()
268            .contains(&serde_json::json!("result")));
269    }
270
271    #[tokio::test]
272    async fn test_complete_simple_string_result() {
273        let tool = CompleteTool;
274        let result = tool
275            .call(r#"{"result": "Task completed successfully"}"#.to_string())
276            .await;
277
278        assert!(result.is_ok());
279        let response: CompleteResponse = serde_json::from_str(&result.unwrap()).unwrap();
280        assert!(response.completed);
281        assert_eq!(response.result, "Task completed successfully");
282        assert_eq!(response.marker, COMPLETION_MARKER);
283        assert!(response.is_final);
284    }
285
286    #[tokio::test]
287    async fn test_complete_with_confidence() {
288        let tool = CompleteTool;
289        let result = tool
290            .call(r#"{"result": "Answer", "confidence": 0.95}"#.to_string())
291            .await;
292
293        assert!(result.is_ok());
294        let response: CompleteResponse = serde_json::from_str(&result.unwrap()).unwrap();
295        assert!(response.completed);
296        assert_eq!(response.confidence, Some(0.95));
297    }
298
299    #[tokio::test]
300    async fn test_complete_with_reasoning() {
301        let tool = CompleteTool;
302        let result = tool
303            .call(r#"{"result": "42", "reasoning": "Based on the calculation..."}"#.to_string())
304            .await;
305
306        assert!(result.is_ok());
307        let response: CompleteResponse = serde_json::from_str(&result.unwrap()).unwrap();
308        assert!(response.completed);
309    }
310
311    #[tokio::test]
312    async fn test_complete_with_complex_result() {
313        let tool = CompleteTool;
314        let result = tool
315            .call(
316                r#"{
317                    "result": {"items": [1, 2, 3], "total": 6},
318                    "confidence": 0.99
319                }"#
320                .to_string(),
321            )
322            .await;
323
324        assert!(result.is_ok());
325        let response: CompleteResponse = serde_json::from_str(&result.unwrap()).unwrap();
326        assert!(response.completed);
327        assert_eq!(response.result["items"][0], 1);
328        assert_eq!(response.result["total"], 6);
329    }
330
331    #[tokio::test]
332    async fn test_complete_invalid_confidence_too_high() {
333        let tool = CompleteTool;
334        let result = tool
335            .call(r#"{"result": "x", "confidence": 1.5}"#.to_string())
336            .await;
337
338        assert!(result.is_err());
339        let err = result.unwrap_err();
340        assert!(err.to_string().contains("confidence"));
341    }
342
343    #[tokio::test]
344    async fn test_complete_invalid_confidence_negative() {
345        let tool = CompleteTool;
346        let result = tool
347            .call(r#"{"result": "x", "confidence": -0.1}"#.to_string())
348            .await;
349
350        assert!(result.is_err());
351        let err = result.unwrap_err();
352        assert!(err.to_string().contains("confidence"));
353    }
354
355    #[tokio::test]
356    async fn test_complete_missing_result() {
357        let tool = CompleteTool;
358        let result = tool.call(r#"{"confidence": 0.9}"#.to_string()).await;
359
360        assert!(result.is_err());
361        let err = result.unwrap_err();
362        assert!(err.to_string().contains("Invalid JSON parameters"));
363    }
364
365    #[tokio::test]
366    async fn test_complete_invalid_json() {
367        let tool = CompleteTool;
368        let result = tool.call("not json".to_string()).await;
369
370        assert!(result.is_err());
371        let err = result.unwrap_err();
372        assert!(err.to_string().contains("Invalid JSON parameters"));
373    }
374
375    /// BUG-4: OpenAI rejects schemas where properties lack a "type" field.
376    /// This test prevents regressions by verifying all properties have one.
377    #[test]
378    fn test_all_properties_have_type_field() {
379        let tool = CompleteTool;
380        let schema = tool.parameters_schema();
381        let props = schema["properties"]
382            .as_object()
383            .expect("properties must be an object");
384        for (name, prop_schema) in props {
385            assert!(
386                prop_schema.get("type").is_some(),
387                "Property '{}' missing 'type' field — OpenAI will reject this schema",
388                name,
389            );
390        }
391    }
392
393    #[test]
394    fn test_is_completion_signal_positive() {
395        let response = serde_json::to_string(&CompleteResponse {
396            completed: true,
397            result: Value::String("done".into()),
398            confidence: Some(0.9),
399            marker: COMPLETION_MARKER.to_string(),
400            is_final: true,
401        })
402        .unwrap();
403
404        assert!(is_completion_signal("nika:complete", &response));
405        assert!(is_completion_signal("complete", &response));
406    }
407
408    #[test]
409    fn test_is_completion_signal_negative_wrong_tool() {
410        let response = format!(r#"{{"marker": "{}"}}"#, COMPLETION_MARKER);
411        assert!(!is_completion_signal("nika:emit", &response));
412    }
413
414    #[test]
415    fn test_is_completion_signal_negative_no_marker() {
416        let response = r#"{"completed": true}"#;
417        assert!(!is_completion_signal("nika:complete", response));
418    }
419
420    #[test]
421    fn test_parse_completion_response() {
422        let response = serde_json::to_string(&CompleteResponse {
423            completed: true,
424            result: Value::String("test".into()),
425            confidence: Some(0.8),
426            marker: COMPLETION_MARKER.to_string(),
427            is_final: true,
428        })
429        .unwrap();
430
431        let parsed = parse_completion_response(&response).unwrap();
432        assert!(parsed.completed);
433        assert_eq!(parsed.result, "test");
434        assert_eq!(parsed.confidence, Some(0.8));
435    }
436
437    #[test]
438    fn test_complete_params_validate_valid() {
439        let params = CompleteParams {
440            result: Value::String("ok".into()),
441            confidence: Some(0.5),
442            reasoning: None,
443            metadata: None,
444        };
445        assert!(params.validate().is_ok());
446    }
447
448    #[test]
449    fn test_complete_params_result_as_string() {
450        // String result
451        let params = CompleteParams {
452            result: Value::String("hello".into()),
453            confidence: None,
454            reasoning: None,
455            metadata: None,
456        };
457        assert_eq!(params.result_as_string(), "hello");
458
459        // Number result
460        let params = CompleteParams {
461            result: serde_json::json!(42),
462            confidence: None,
463            reasoning: None,
464            metadata: None,
465        };
466        assert_eq!(params.result_as_string(), "42");
467
468        // Object result
469        let params = CompleteParams {
470            result: serde_json::json!({"key": "value"}),
471            confidence: None,
472            reasoning: None,
473            metadata: None,
474        };
475        assert!(params.result_as_string().contains("key"));
476    }
477
478    #[test]
479    fn test_complete_response_success() {
480        let params = CompleteParams {
481            result: Value::String("done".into()),
482            confidence: Some(0.99),
483            reasoning: Some("explanation".into()),
484            metadata: None,
485        };
486
487        let response = CompleteResponse::success(&params, true);
488        assert!(response.completed);
489        assert_eq!(response.result, "done");
490        assert_eq!(response.confidence, Some(0.99));
491        assert!(response.is_final);
492        assert_eq!(response.marker, COMPLETION_MARKER);
493    }
494}