selfware 0.6.0

Your personal AI workshop — software you own, software that lasts
Documentation
use tracing::{debug, info, warn};

use super::*;
use crate::api::tool_calling::{extract_tool_calls, extract_tool_calls_from_text};

pub(super) type CollectedToolCall = (String, String, Option<String>);

impl Agent {
    /// Collect tool calls from a model response into the agent's internal
    /// `(name, args, id)` triples.
    ///
    /// Routes through the unified [`extract_tool_calls`] policy:
    /// - If the message has native `tool_calls`, those are returned (validated).
    /// - Otherwise, the multi-format parser scans `content`, then falls back to
    ///   `reasoning_content` if the content branch yields nothing.
    ///
    /// This replaces the previously-duplicated native-vs-text branching logic
    /// so the agent and the SWL runtime parse responses identically.
    pub(super) fn collect_tool_calls(
        &self,
        content: &str,
        reasoning_content: Option<&str>,
        native_tool_calls: Option<&Vec<crate::api::types::ToolCall>>,
    ) -> Vec<(String, String, Option<String>)> {
        // Build a synthetic Message we can hand off to the unified extractor.
        let msg = crate::api::types::Message {
            role: "assistant".to_string(),
            content: crate::api::types::MessageContent::from_text(content),
            reasoning_content: reasoning_content.map(|s| s.to_string()),
            tool_calls: native_tool_calls.cloned(),
            tool_call_id: None,
            name: None,
        };

        let mut extracted = extract_tool_calls(&msg, self.config.agent.native_function_calling);

        // Validate native tool calls against the loaded schema for early
        // diagnostic.  Validation failures are logged but do not abort —
        // individual bad calls will still be rejected at dispatch time.
        if !extracted.is_empty() && msg.tool_calls.as_ref().is_some_and(|c| !c.is_empty()) {
            let defs = self.tools.definitions();
            if let Err(e) = crate::agent::tool_validator::validate_tool_calls(&extracted, &defs) {
                warn!("Native tool call batch validation failed: {}", e);
            }
            extracted.retain(|tc| match tc.validate_structure() {
                Ok(()) => true,
                Err(e) => {
                    warn!(
                        "Skipping malformed native tool call '{}': {}",
                        tc.function.name, e
                    );
                    false
                }
            });
        }

        // Fallback: when the content branch produced nothing, scan reasoning_content.
        if extracted.is_empty() {
            if let Some(reasoning_text) = reasoning_content {
                let from_reasoning = extract_tool_calls_from_text(reasoning_text);
                if !from_reasoning.is_empty() {
                    info!(
                        "Found {} tool calls in reasoning content",
                        from_reasoning.len()
                    );
                    extracted = from_reasoning;
                }
            }
        }

        if !extracted.is_empty() {
            info!(
                "Extracted {} tool call(s) via unified extractor",
                extracted.len()
            );
        }

        extracted
            .into_iter()
            .map(|tc| {
                debug!(
                    "Tool call: {} (id: {}) with args: {}",
                    tc.function.name, tc.id, tc.function.arguments
                );
                // Synthetic ids generated by the text-fallback extractor start
                // with `parsed_`. Downstream dispatch code uses
                // `tool_call_id.is_some()` to decide whether to use the native
                // function-calling result-message shape, so we collapse those
                // synthetic ids back to `None` to preserve the prior behaviour.
                let id = if tc.id.is_empty() || tc.id.starts_with("parsed_") {
                    None
                } else {
                    Some(tc.id)
                };
                (tc.function.name, tc.function.arguments, id)
            })
            .collect()
    }
}