Skip to main content

ferrum_types/
harmony.rs

1//! Strict terminal-output parsing for the GPT-OSS Harmony wire protocol.
2//!
3//! This intentionally supports only Ferrum's first product slice: a direct
4//! final answer, one analysis message followed by a final answer, or one
5//! analysis message followed by a function call on the commentary channel.
6
7use serde::{Deserialize, Serialize};
8
9use crate::{FerrumError, Result};
10
11const START: &str = "<|start|>";
12const END: &str = "<|end|>";
13const MESSAGE: &str = "<|message|>";
14const CHANNEL: &str = "<|channel|>";
15const CONSTRAIN: &str = "<|constrain|>";
16const CALL: &str = "<|call|>";
17const RETURN: &str = "<|return|>";
18const FUNCTION_RECIPIENT_PREFIX: &str = "functions.";
19
20/// A validated function call emitted through the Harmony commentary channel.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct HarmonyToolCall {
23    pub name: String,
24    /// The original JSON object text, with only surrounding whitespace removed.
25    pub arguments_json: String,
26}
27
28/// Product-facing terminal result of a complete Harmony model output.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct ParsedHarmonyResponse {
31    pub reasoning_content: Option<String>,
32    pub content: String,
33    pub tool_call: Option<HarmonyToolCall>,
34}
35
36/// Parse one complete decoded GPT-OSS Harmony response.
37///
38/// Accepted shapes are deliberately narrow:
39///
40/// - `final<|return|>`
41/// - `analysis<|end|> -> final<|return|>`
42/// - `analysis<|end|> -> commentary to=functions.NAME ... <|call|>`
43///
44/// The first message may omit `<|start|>assistant` because that prefix is
45/// normally already present at the end of the rendered generation prompt.
46pub fn parse_harmony_response(output: &str) -> Result<ParsedHarmonyResponse> {
47    parse_harmony_response_internal(output, false)
48}
49
50/// Parse a Harmony response that the engine stopped at its token limit.
51///
52/// A length stop may legitimately omit the terminal token from an analysis or
53/// final text message. Tool calls remain fail-closed and still require a
54/// complete `<|call|>` envelope.
55pub fn parse_length_truncated_harmony_response(output: &str) -> Result<ParsedHarmonyResponse> {
56    parse_harmony_response_internal(output, true)
57}
58
59fn parse_harmony_response_internal(
60    output: &str,
61    allow_missing_text_terminal: bool,
62) -> Result<ParsedHarmonyResponse> {
63    let first = parse_message(output, true, allow_missing_text_terminal)?;
64    match first.channel {
65        HarmonyChannel::Final => {
66            validate_plain_message(&first, "final")?;
67            require_text_terminal(&first, HarmonyTerminal::Return, allow_missing_text_terminal)?;
68            require_no_trailing_output(&first)?;
69            Ok(ParsedHarmonyResponse {
70                reasoning_content: None,
71                content: first.payload.to_string(),
72                tool_call: None,
73            })
74        }
75        HarmonyChannel::Analysis => {
76            validate_plain_message(&first, "analysis")?;
77            if first.terminal.is_none() && allow_missing_text_terminal {
78                return Ok(ParsedHarmonyResponse {
79                    reasoning_content: Some(first.payload.to_string()),
80                    content: String::new(),
81                    tool_call: None,
82                });
83            }
84            require_terminal(&first, HarmonyTerminal::End)?;
85            if first.remaining.is_empty() {
86                if allow_missing_text_terminal {
87                    return Ok(ParsedHarmonyResponse {
88                        reasoning_content: Some(first.payload.to_string()),
89                        content: String::new(),
90                        tool_call: None,
91                    });
92                }
93                return Err(invalid_harmony(
94                    "analysis message was not followed by a terminal final answer or tool call",
95                ));
96            }
97
98            if allow_missing_text_terminal && is_length_truncated_followup_envelope(first.remaining)
99            {
100                return Ok(ParsedHarmonyResponse {
101                    reasoning_content: Some(first.payload.to_string()),
102                    content: String::new(),
103                    tool_call: None,
104                });
105            }
106
107            let second = parse_message(first.remaining, false, allow_missing_text_terminal)?;
108            match second.channel {
109                HarmonyChannel::Final => {
110                    validate_plain_message(&second, "final")?;
111                    require_text_terminal(
112                        &second,
113                        HarmonyTerminal::Return,
114                        allow_missing_text_terminal,
115                    )?;
116                    require_no_trailing_output(&second)?;
117                    Ok(ParsedHarmonyResponse {
118                        reasoning_content: Some(first.payload.to_string()),
119                        content: second.payload.to_string(),
120                        tool_call: None,
121                    })
122                }
123                HarmonyChannel::Commentary => {
124                    let tool_call = parse_tool_call(&second)?;
125                    require_terminal(&second, HarmonyTerminal::Call)?;
126                    require_no_trailing_output(&second)?;
127                    Ok(ParsedHarmonyResponse {
128                        reasoning_content: Some(first.payload.to_string()),
129                        content: String::new(),
130                        tool_call: Some(tool_call),
131                    })
132                }
133                HarmonyChannel::Analysis => Err(invalid_harmony(
134                    "only one analysis message is supported before the terminal response",
135                )),
136            }
137        }
138        HarmonyChannel::Commentary => Err(invalid_harmony(
139            "a commentary tool call must follow one complete analysis message",
140        )),
141    }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum HarmonyChannel {
146    Analysis,
147    Commentary,
148    Final,
149}
150
151impl HarmonyChannel {
152    fn parse(value: &str) -> Result<Self> {
153        match value {
154            "analysis" => Ok(Self::Analysis),
155            "commentary" => Ok(Self::Commentary),
156            "final" => Ok(Self::Final),
157            _ => Err(invalid_harmony(format!(
158                "unsupported Harmony channel {value:?}"
159            ))),
160        }
161    }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165enum HarmonyTerminal {
166    End,
167    Call,
168    Return,
169}
170
171impl HarmonyTerminal {
172    const fn text(self) -> &'static str {
173        match self {
174            Self::End => END,
175            Self::Call => CALL,
176            Self::Return => RETURN,
177        }
178    }
179}
180
181#[derive(Debug)]
182struct ParsedMessage<'a> {
183    channel: HarmonyChannel,
184    recipient: Option<&'a str>,
185    content_type: Option<&'a str>,
186    payload: &'a str,
187    terminal: Option<HarmonyTerminal>,
188    remaining: &'a str,
189}
190
191fn parse_message(
192    output: &str,
193    first: bool,
194    allow_missing_terminal: bool,
195) -> Result<ParsedMessage<'_>> {
196    if output.is_empty() {
197        return Err(invalid_harmony("Harmony output is empty"));
198    }
199
200    let (role_recipient, after_channel_marker) =
201        if let Some(after_start) = output.strip_prefix(START) {
202            let channel_offset = after_start.find(CHANNEL).ok_or_else(|| {
203                invalid_harmony("message start was not followed by a channel marker")
204            })?;
205            let role_header = &after_start[..channel_offset];
206            reject_raw_marker(role_header, "assistant role header")?;
207            let recipient = parse_role_header(role_header)?;
208            (recipient, &after_start[channel_offset + CHANNEL.len()..])
209        } else if first {
210            let after_channel = output
211                .strip_prefix(CHANNEL)
212                .ok_or_else(|| invalid_harmony("first message must begin with a channel marker"))?;
213            (None, after_channel)
214        } else {
215            return Err(invalid_harmony(
216                "a message after analysis must begin with <|start|>assistant",
217            ));
218        };
219
220    let message_offset = after_channel_marker.find(MESSAGE);
221    let channel_header = message_offset
222        .map(|offset| &after_channel_marker[..offset])
223        .unwrap_or(after_channel_marker);
224    let (channel, channel_recipient, content_type) = parse_channel_header(channel_header)?;
225    let recipient = match (role_recipient, channel_recipient) {
226        (Some(_), Some(_)) => {
227            return Err(invalid_harmony(
228                "recipient was repeated in both role and channel headers",
229            ));
230        }
231        (Some(recipient), None) | (None, Some(recipient)) => Some(recipient),
232        (None, None) => None,
233    };
234
235    let Some(message_offset) = message_offset else {
236        if allow_missing_terminal
237            && matches!(channel, HarmonyChannel::Analysis | HarmonyChannel::Final)
238            && recipient.is_none()
239            && content_type.is_none()
240        {
241            return Ok(ParsedMessage {
242                channel,
243                recipient,
244                content_type,
245                payload: "",
246                terminal: None,
247                remaining: "",
248            });
249        }
250        return Err(invalid_harmony(
251            "channel header was not followed by a message marker",
252        ));
253    };
254
255    let after_message = &after_channel_marker[message_offset + MESSAGE.len()..];
256    let Some(terminal_offset) = after_message.find("<|") else {
257        reject_raw_marker(after_message, "message payload")?;
258        if !allow_missing_terminal {
259            return Err(invalid_harmony(
260                "message is missing a terminal control token",
261            ));
262        }
263        return Ok(ParsedMessage {
264            channel,
265            recipient,
266            content_type,
267            payload: after_message,
268            terminal: None,
269            remaining: "",
270        });
271    };
272    let payload = &after_message[..terminal_offset];
273    reject_raw_marker(payload, "message payload")?;
274    let terminal_and_remaining = &after_message[terminal_offset..];
275    let (terminal, remaining) = if let Some(remaining) = terminal_and_remaining.strip_prefix(END) {
276        (Some(HarmonyTerminal::End), remaining)
277    } else if let Some(remaining) = terminal_and_remaining.strip_prefix(CALL) {
278        (Some(HarmonyTerminal::Call), remaining)
279    } else if let Some(remaining) = terminal_and_remaining.strip_prefix(RETURN) {
280        (Some(HarmonyTerminal::Return), remaining)
281    } else {
282        return Err(invalid_harmony(
283            "message payload contains an unknown or misplaced raw control marker",
284        ));
285    };
286
287    Ok(ParsedMessage {
288        channel,
289        recipient,
290        content_type,
291        payload,
292        terminal,
293        remaining,
294    })
295}
296
297fn is_length_truncated_followup_envelope(output: &str) -> bool {
298    output == START
299        || output == concat!("<|start|>", "assistant")
300        || output == concat!("<|start|>", "assistant", "<|channel|>")
301}
302
303fn parse_role_header(header: &str) -> Result<Option<&str>> {
304    let mut parts = header.split_ascii_whitespace();
305    if parts.next() != Some("assistant") {
306        return Err(invalid_harmony(
307            "generated Harmony messages must have the assistant role",
308        ));
309    }
310    let recipient = parts.next().map(parse_recipient_token).transpose()?;
311    if parts.next().is_some() {
312        return Err(invalid_harmony(
313            "assistant role header contains unsupported metadata",
314        ));
315    }
316    Ok(recipient)
317}
318
319fn parse_channel_header(header: &str) -> Result<(HarmonyChannel, Option<&str>, Option<&str>)> {
320    let mut constrain_parts = header.split(CONSTRAIN);
321    let channel_part = constrain_parts.next().unwrap_or_default();
322    let content_type = constrain_parts.next().map(str::trim);
323    if constrain_parts.next().is_some() {
324        return Err(invalid_harmony(
325            "channel header contains repeated constrain markers",
326        ));
327    }
328    reject_raw_marker(channel_part, "channel header")?;
329    if let Some(content_type) = content_type {
330        reject_raw_marker(content_type, "content type")?;
331        if content_type.is_empty() {
332            return Err(invalid_harmony("constrain marker has no content type"));
333        }
334    }
335
336    let mut parts = channel_part.split_ascii_whitespace();
337    let channel = parts
338        .next()
339        .ok_or_else(|| invalid_harmony("channel marker has no channel value"))?;
340    let channel = HarmonyChannel::parse(channel)?;
341    let recipient = parts.next().map(parse_recipient_token).transpose()?;
342    if parts.next().is_some() {
343        return Err(invalid_harmony(
344            "channel header contains unsupported metadata",
345        ));
346    }
347    Ok((channel, recipient, content_type))
348}
349
350fn parse_recipient_token(token: &str) -> Result<&str> {
351    let recipient = token
352        .strip_prefix("to=")
353        .ok_or_else(|| invalid_harmony("recipient metadata must use the to= form"))?;
354    if recipient.is_empty() {
355        return Err(invalid_harmony("recipient must not be empty"));
356    }
357    Ok(recipient)
358}
359
360fn parse_tool_call(message: &ParsedMessage<'_>) -> Result<HarmonyToolCall> {
361    let recipient = message
362        .recipient
363        .ok_or_else(|| invalid_harmony("commentary tool call has no recipient"))?;
364    let name = recipient
365        .strip_prefix(FUNCTION_RECIPIENT_PREFIX)
366        .ok_or_else(|| invalid_harmony(format!("unknown tool recipient {recipient:?}")))?;
367    if name.is_empty() {
368        return Err(invalid_harmony("tool name must not be empty"));
369    }
370    if let Some(content_type) = message.content_type {
371        if content_type != "json" {
372            return Err(invalid_harmony(format!(
373                "tool arguments must use the json content type, got {content_type:?}"
374            )));
375        }
376    }
377
378    let arguments_json = message.payload.trim();
379    let arguments: serde_json::Value = serde_json::from_str(arguments_json)
380        .map_err(|error| invalid_harmony(format!("tool arguments are not valid JSON: {error}")))?;
381    if !arguments.is_object() {
382        return Err(invalid_harmony("tool arguments must be a JSON object"));
383    }
384
385    Ok(HarmonyToolCall {
386        name: name.to_string(),
387        arguments_json: arguments_json.to_string(),
388    })
389}
390
391fn validate_plain_message(message: &ParsedMessage<'_>, channel: &str) -> Result<()> {
392    if message.recipient.is_some() {
393        return Err(invalid_harmony(format!(
394            "{channel} channel must not contain a recipient"
395        )));
396    }
397    if message.content_type.is_some() {
398        return Err(invalid_harmony(format!(
399            "{channel} channel must not contain a constrain marker"
400        )));
401    }
402    Ok(())
403}
404
405fn require_terminal(message: &ParsedMessage<'_>, expected: HarmonyTerminal) -> Result<()> {
406    if message.terminal != Some(expected) {
407        return Err(invalid_harmony(format!(
408            "{} channel must end with {}, got {}",
409            match message.channel {
410                HarmonyChannel::Analysis => "analysis",
411                HarmonyChannel::Commentary => "commentary",
412                HarmonyChannel::Final => "final",
413            },
414            expected.text(),
415            message
416                .terminal
417                .map(HarmonyTerminal::text)
418                .unwrap_or("no terminal token"),
419        )));
420    }
421    Ok(())
422}
423
424fn require_text_terminal(
425    message: &ParsedMessage<'_>,
426    expected: HarmonyTerminal,
427    allow_missing: bool,
428) -> Result<()> {
429    if allow_missing && message.terminal.is_none() {
430        return Ok(());
431    }
432    require_terminal(message, expected)
433}
434
435fn require_no_trailing_output(message: &ParsedMessage<'_>) -> Result<()> {
436    if !message.remaining.is_empty() {
437        return Err(invalid_harmony(
438            "terminal control token was followed by duplicate terminal data or trailing garbage",
439        ));
440    }
441    Ok(())
442}
443
444fn reject_raw_marker(value: &str, location: &str) -> Result<()> {
445    if value.contains("<|") || value.contains("|>") {
446        return Err(invalid_harmony(format!(
447            "{location} contains a raw or incomplete control marker"
448        )));
449    }
450    Ok(())
451}
452
453fn invalid_harmony(message: impl Into<String>) -> FerrumError {
454    FerrumError::invalid_format(format!(
455        "invalid GPT-OSS Harmony output: {}",
456        message.into()
457    ))
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn parses_direct_final_response() {
466        let parsed =
467            parse_harmony_response("<|channel|>final<|message|>The capital is Paris.<|return|>")
468                .unwrap();
469
470        assert_eq!(
471            parsed,
472            ParsedHarmonyResponse {
473                reasoning_content: None,
474                content: "The capital is Paris.".to_string(),
475                tool_call: None,
476            }
477        );
478    }
479
480    #[test]
481    fn parses_analysis_then_final_response() {
482        let parsed = parse_harmony_response(
483            "<|channel|>analysis<|message|>Need the capital.<|end|>\
484             <|start|>assistant<|channel|>final<|message|>Paris.<|return|>",
485        )
486        .unwrap();
487
488        assert_eq!(
489            parsed.reasoning_content.as_deref(),
490            Some("Need the capital.")
491        );
492        assert_eq!(parsed.content, "Paris.");
493        assert_eq!(parsed.tool_call, None);
494    }
495
496    #[test]
497    fn parses_length_truncated_text_messages_without_weakening_strict_parser() {
498        for (output, reasoning, content) in [
499            ("<|channel|>final", None, ""),
500            ("<|channel|>analysis", Some(""), ""),
501            (
502                "<|channel|>final<|message|>Partial answer",
503                None,
504                "Partial answer",
505            ),
506            (
507                "<|channel|>analysis<|message|>Partial reasoning",
508                Some("Partial reasoning"),
509                "",
510            ),
511            (
512                "<|channel|>analysis<|message|>Reason.<|end|>",
513                Some("Reason."),
514                "",
515            ),
516            (
517                "<|channel|>analysis<|message|>Reason.<|end|>\
518                 <|start|>assistant<|channel|>final",
519                Some("Reason."),
520                "",
521            ),
522            (
523                "<|channel|>analysis<|message|>Reason.<|end|>\
524                 <|start|>assistant<|channel|>final<|message|>Partial answer",
525                Some("Reason."),
526                "Partial answer",
527            ),
528        ] {
529            assert!(parse_harmony_response(output).is_err());
530            let parsed = parse_length_truncated_harmony_response(output).unwrap();
531            assert_eq!(parsed.reasoning_content.as_deref(), reasoning);
532            assert_eq!(parsed.content, content);
533            assert!(parsed.tool_call.is_none());
534        }
535    }
536
537    #[test]
538    fn parses_length_truncation_between_followup_envelope_markers() {
539        for output in [
540            "<|channel|>analysis<|message|>Reason.<|end|>\
541             <|start|>",
542            "<|channel|>analysis<|message|>Reason.<|end|>\
543             <|start|>assistant",
544            "<|channel|>analysis<|message|>Reason.<|end|>\
545             <|start|>assistant<|channel|>",
546        ] {
547            assert!(parse_harmony_response(output).is_err());
548            let parsed = parse_length_truncated_harmony_response(output).unwrap();
549            assert_eq!(parsed.reasoning_content.as_deref(), Some("Reason."));
550            assert!(parsed.content.is_empty());
551            assert!(parsed.tool_call.is_none());
552        }
553    }
554
555    #[test]
556    fn length_truncation_keeps_tool_calls_and_control_markers_fail_closed() {
557        for output in [
558            "<|channel|>analysis<|message|>Reason.<|end|>\
559             <|start|>assistant<|channel|>commentary to=functions.weather\
560             <|constrain|>json<|message|>{\"city\":\"Paris\"}",
561            "<|channel|>final<|message|>leak <|bogus|>",
562            "<|channel|>final<|message|>incomplete <|",
563            "<|channel|>analysis<|message|>Reason.<|end|>\
564             <|start|>assistant<|channel|>commentary to=functions.weather\
565             <|constrain|>json",
566            "<|channel|>analysis<|message|>Reason.<|end|>\
567             <|start|>assistant<|channel|>final<|mess",
568            "<|channel|>analysis<|message|>Reason.<|end|>\
569             <|start|>assistant<|channel|>fina",
570            "<|channel|>analysis<|message|>Reason.<|end|>\
571             <|start|>assistant to=functions.weather<|channel|>final",
572        ] {
573            assert!(
574                parse_length_truncated_harmony_response(output).is_err(),
575                "accepted {output:?}"
576            );
577        }
578    }
579
580    #[test]
581    fn parses_analysis_then_function_tool_call_in_both_header_orders() {
582        for output in [
583            "<|channel|>analysis<|message|>Need weather.<|end|>\
584             <|start|>assistant<|channel|>commentary to=functions.weather<|constrain|>json\
585             <|message|>{\"city\":\"Paris\"}<|call|>",
586            "<|channel|>analysis<|message|>Need weather.<|end|>\
587             <|start|>assistant to=functions.weather<|channel|>commentary<|constrain|>json\
588             <|message|>{\"city\":\"Paris\"}<|call|>",
589        ] {
590            let parsed = parse_harmony_response(output).unwrap();
591            assert_eq!(parsed.reasoning_content.as_deref(), Some("Need weather."));
592            assert!(parsed.content.is_empty());
593            assert_eq!(
594                parsed.tool_call,
595                Some(HarmonyToolCall {
596                    name: "weather".to_string(),
597                    arguments_json: "{\"city\":\"Paris\"}".to_string(),
598                })
599            );
600        }
601    }
602
603    #[test]
604    fn rejects_invalid_or_non_object_tool_json() {
605        for arguments in ["{", "[]", "null", "\"Paris\""] {
606            let output = format!(
607                "<|channel|>analysis<|message|>Need weather.<|end|>\
608                 <|start|>assistant<|channel|>commentary to=functions.weather<|constrain|>json\
609                 <|message|>{arguments}<|call|>"
610            );
611            assert!(
612                parse_harmony_response(&output).is_err(),
613                "accepted {arguments:?}"
614            );
615        }
616    }
617
618    #[test]
619    fn rejects_raw_marker_leakage_and_incomplete_envelopes() {
620        for output in [
621            "<|channel|>final<|message|>leak <|bogus|> marker<|return|>",
622            "<|channel|>final answer<|return|>",
623            "<|channel|>final<|message|>incomplete <| marker",
624        ] {
625            assert!(
626                parse_harmony_response(output).is_err(),
627                "accepted {output:?}"
628            );
629        }
630    }
631
632    #[test]
633    fn rejects_wrong_missing_or_repeated_terminal_tokens() {
634        for output in [
635            "<|channel|>final<|message|>Paris<|end|>",
636            "<|channel|>final<|message|>Paris<|call|>",
637            "<|channel|>final<|message|>Paris",
638            "<|channel|>final<|message|>Paris<|return|><|return|>",
639            "<|channel|>final<|message|>Paris<|return|>garbage",
640            "<|channel|>analysis<|message|>Need weather.<|end|>\
641             <|start|>assistant<|channel|>commentary to=functions.weather<|message|>{}<|return|>",
642        ] {
643            assert!(
644                parse_harmony_response(output).is_err(),
645                "accepted {output:?}"
646            );
647        }
648    }
649
650    #[test]
651    fn rejects_unknown_or_empty_tool_recipients() {
652        for recipient in ["browser.search", "python", "functions."] {
653            let output = format!(
654                "<|channel|>analysis<|message|>Need a tool.<|end|>\
655                 <|start|>assistant<|channel|>commentary to={recipient}<|constrain|>json\
656                 <|message|>{{}}<|call|>"
657            );
658            assert!(
659                parse_harmony_response(&output).is_err(),
660                "accepted {recipient:?}"
661            );
662        }
663    }
664
665    #[test]
666    fn rejects_invalid_channel_or_extra_message() {
667        for output in [
668            "<|channel|>developer<|message|>no<|return|>",
669            "<|channel|>analysis<|message|>one<|end|>\
670             <|start|>assistant<|channel|>analysis<|message|>two<|end|>\
671             <|start|>assistant<|channel|>final<|message|>answer<|return|>",
672        ] {
673            assert!(
674                parse_harmony_response(output).is_err(),
675                "accepted {output:?}"
676            );
677        }
678    }
679}