met 1.0.0-beta-preview

Metrale Engine: pure Rust LLM inference server (HTTP API, scheduler, TUI, CLI).
// SPDX-License-Identifier: MIT OR Apache-2.0

//! 2026-09-26: The Poolside v1 tool-call format, including its bare-name
//! zero-argument call.
//!
//! Owner: server (tool parsing).
//! Invariants:
//! - `parse_poolside_v1_call` returns `None` for a repeated key, and for a
//!   name or key that is not a plain name (`is_tool_name_component`).

use super::*;

/// 2026-09-26: Poolside v1 format:
/// `<tool_call>NAME<arg_key>K</arg_key><arg_value>V</arg_value></tool_call>`.
pub struct PoolsideV1Parser;

impl ToolCallParser for PoolsideV1Parser {
    fn name(&self) -> &str {
        "poolside_v1"
    }

    fn system_prompt(
        &self,
        _tools: &[ToolDefinition],
        _tool_choice: &ToolChoice,
        _levers: &PromptLevers,
    ) -> String {
        String::new()
    }

    fn compile_tool_grammar(
        &self,
        engine: &mut GrammarEngine,
        tools: &[ToolDefinition],
        use_triggers: bool,
    ) -> Option<Result<CompiledGrammar, GrammarError>> {
        let value_close = self
            .param_value_close_delim()
            .expect("poolside_v1 declares a parameter-value close delimiter");
        Some(engine.compile_poolside_v1_tool_grammar(tools, use_triggers, value_close))
    }

    fn has_tool_grammar(&self) -> bool {
        true
    }

    fn param_value_close_delim(&self) -> Option<&'static str> {
        Some("</arg_value>")
    }

    fn format_tool_calls(&self, calls: &[IncomingToolCall]) -> String {
        let mut output = String::new();
        for call in calls {
            output.push_str("<tool_call>");
            output.push_str(&call.function.name);
            let args = serde_json::from_str::<serde_json::Value>(&call.function.arguments)
                .unwrap_or_else(|_| serde_json::json!({}));
            if let Some(args) = args.as_object() {
                for (key, value) in args {
                    output.push_str("<arg_key>");
                    output.push_str(key);
                    output.push_str("</arg_key><arg_value>");
                    match value {
                        serde_json::Value::String(value) => output.push_str(value),
                        value => output.push_str(&serde_json::to_string(value).unwrap_or_default()),
                    }
                    output.push_str("</arg_value>");
                }
            }
            output.push_str("</tool_call>");
        }
        output
    }

    fn wants_typed_arguments(&self) -> bool {
        true
    }

    fn promotes_bare_call_names(&self) -> bool {
        // 2026-09-26: Poolside v1's zero-argument call is a bare name inside
        // the envelope: `<tool_call>get_status</tool_call>`. The trait
        // method's doc says why the default stays false.
        true
    }

    fn leak_markers(&self) -> LeakMarkers {
        LeakMarkers {
            orphan_open: &["<arg_key>", "<arg_value>"],
            close: &["</arg_key>", "</arg_value>", "</tool_call>"],
            envelope_open: &["<tool_call>"],
            envelope_close: &["</tool_call>"],
        }
    }
}

pub(super) fn parse_poolside_v1_call(text: &str) -> Option<ToolCall> {
    let name_end = text.find("<arg_key>").unwrap_or(text.len());
    let name = normalize_tool_name(text[..name_end].trim());
    if !is_tool_name_component(&name) {
        return None;
    }

    let mut args = serde_json::Map::new();
    let mut rest = &text[name_end..];
    while !rest.is_empty() {
        rest = rest.strip_prefix("<arg_key>")?;
        let key_end = rest.find("</arg_key>")?;
        let key = rest[..key_end].trim();
        if !is_tool_name_component(key) || args.contains_key(key) {
            return None;
        }
        rest = &rest[key_end + "</arg_key>".len()..];
        rest = rest.strip_prefix("<arg_value>")?;
        let value_end = rest.find("</arg_value>")?;
        let raw_value = &rest[..value_end];
        let value = serde_json::from_str(raw_value)
            .unwrap_or_else(|_| serde_json::Value::String(raw_value.to_string()));
        args.insert(key.to_string(), value);
        rest = &rest[value_end + "</arg_value>".len()..];
    }

    Some(ToolCall {
        id: next_tool_call_id(),
        call_type: "function".into(),
        function: FunctionCall {
            name,
            arguments: serde_json::to_string(&args).unwrap_or_else(|_| "{}".into()),
        },
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_native_poolside_call() {
        let call = parse_poolside_v1_call(
            "Bash<arg_key>command</arg_key><arg_value>pwd</arg_value>\
             <arg_key>timeout</arg_key><arg_value>120</arg_value>",
        )
        .expect("poolside call");
        assert_eq!(call.function.name, "Bash");
        assert_eq!(
            call.function.arguments,
            r#"{"command":"pwd","timeout":120}"#
        );
    }

    #[test]
    fn preserves_unquoted_string_whitespace_exactly() {
        let call = parse_poolside_v1_call(
            "write_file<arg_key>content</arg_key><arg_value>  first\nlast  </arg_value>",
        )
        .expect("poolside call");
        let args: serde_json::Value = serde_json::from_str(&call.function.arguments).unwrap();

        assert_eq!(args["content"], "  first\nlast  ");
    }

    #[test]
    fn rejects_duplicate_argument_keys() {
        let call = parse_poolside_v1_call(
            "write_file<arg_key>path</arg_key><arg_value>/tmp/a</arg_value>\
             <arg_key>path</arg_key><arg_value>/tmp/b</arg_value>",
        );

        assert!(call.is_none());
    }

    #[test]
    fn rejects_incomplete_argument_pair() {
        assert!(
            parse_poolside_v1_call("write_file<arg_key>path</arg_key><arg_value>/tmp/a").is_none()
        );
    }

    #[test]
    fn blocking_parser_accepts_complete_zero_argument_call() {
        // 2026-09-26: The bare-name form is a Poolside encoding, so a caller
        // opts in through the promoting entry point, chosen by
        // `ToolCallParser::promotes_bare_call_names`.
        let (_, calls) = parse_tool_calls_promoting_bare_names("<tool_call>get_status</tool_call>");

        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "get_status");
        assert_eq!(calls[0].function.arguments, "{}");
    }

    #[test]
    fn default_parse_drops_bare_identifier_instead_of_fabricating_a_call() {
        // 2026-09-26: For every other format a bare identifier inside
        // `<tool_call>` is malformed output; promoting it would make up a
        // call the model never made.
        let (_content, calls) = parse_tool_calls("<tool_call>get_status</tool_call>");

        assert!(
            calls.is_empty(),
            "bare identifier must not become a call by default — got {calls:#?}"
        );
    }

    #[test]
    fn streaming_detector_promotes_bare_names_only_when_opted_in() {
        let closed = "<tool_call>get_status</tool_call>";

        let mut default_detector = StreamingToolDetector::new();
        let mut outputs = default_detector.process(closed);
        outputs.extend(default_detector.flush());
        assert!(
            outputs
                .iter()
                .all(|output| !matches!(output, DetectorOutput::ToolCall(..))),
            "default detector must not fabricate a call from a bare identifier"
        );

        let mut poolside_detector = StreamingToolDetector::new();
        poolside_detector.set_promote_bare_names(true);
        let mut outputs = poolside_detector.process(closed);
        outputs.extend(poolside_detector.flush());
        let call = outputs
            .iter()
            .find_map(|output| match output {
                DetectorOutput::ToolCall(tc, _) => Some(tc),
                _ => None,
            })
            .expect("poolside detector must emit the zero-argument call");
        assert_eq!(call.function.name, "get_status");
        assert_eq!(call.function.arguments, "{}");
    }

    #[test]
    fn blocking_parser_does_not_salvage_incomplete_poolside_envelope() {
        let (_, calls) = parse_tool_calls(
            "<tool_call>write_file<arg_key>path</arg_key>\
             <arg_value>/tmp/a</arg_value>",
        );

        assert!(calls.is_empty());
    }

    #[test]
    fn streaming_parser_does_not_emit_incomplete_poolside_envelope() {
        let mut detector = StreamingToolDetector::new();
        let mut outputs = detector.process(
            "<tool_call>write_file<arg_key>path</arg_key>\
             <arg_value>/tmp/a</arg_value>",
        );
        outputs.extend(detector.flush());

        assert!(
            outputs
                .iter()
                .all(|output| matches!(output, DetectorOutput::Content(_)))
        );
    }
}