Skip to main content

switchyard_translation/
conversation.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! This module groups native Responses input items into turns for routing and other APIs.
5
6use std::borrow::Cow;
7
8use crate::{ContentBlock, InstructionBlock, LlmRequest, Message, Role, ToolCall, ToolResult};
9
10/// A temporary conversation view groups native items without changing the request.
11pub struct Conversation<'a> {
12    /// Instructions include native inline system and developer messages.
13    pub instructions: Cow<'a, [InstructionBlock]>,
14    /// Reasoning and parallel tool calls share their original assistant turn.
15    pub messages: Cow<'a, [Message]>,
16}
17
18/// This function groups the current content for routing and non-Responses APIs.
19/// The request retains native item order and fields for Responses encoding.
20/// Indexes in this view do not refer to the original request's messages.
21pub fn conversation(request: &LlmRequest) -> Conversation<'_> {
22    if !request
23        .messages
24        .iter()
25        .any(|message| message.responses.is_some())
26    {
27        return Conversation {
28            instructions: Cow::Borrowed(&request.instructions),
29            messages: Cow::Borrowed(&request.messages),
30        };
31    }
32    // A state reference can identify provider-held calls that are absent from this request.
33    let fields = &request.extensions.fields;
34    let stored_state = fields
35        .get("previous_response_id")
36        .and_then(serde_json::Value::as_str)
37        .is_some_and(|id| !id.is_empty())
38        || fields.get("conversation").is_some_and(|conversation| {
39            conversation
40                .as_str()
41                .or_else(|| conversation.get("id").and_then(serde_json::Value::as_str))
42                .is_some_and(|id| !id.is_empty())
43        });
44    let mut instructions = request.instructions.clone();
45    let mut turns = ConversationTurns::default();
46    for message in &request.messages {
47        // Inline tool definitions are already present in the request's tool catalog.
48        if matches!(message.content.as_slice(), [ContentBlock::Unknown { raw, .. }]
49            if raw.get("type").and_then(serde_json::Value::as_str) == Some("additional_tools"))
50        {
51            continue;
52        }
53        // Inline instructions leave pending reasoning and tool calls in the same turn.
54        if matches!(message.role, Role::System | Role::Developer) {
55            instructions.push(InstructionBlock {
56                role: message.role,
57                content: message.content.clone(),
58            });
59            continue;
60        }
61        match message.content.as_slice() {
62            [ContentBlock::Reasoning { .. }] if message.role == Role::Assistant => {
63                // Reasoning after tool outputs starts the next assistant turn.
64                if !turns.outputs.is_empty() {
65                    turns.flush_tools();
66                }
67                turns.reasoning.extend(message.content.clone());
68            }
69            [ContentBlock::ToolCall(call)] => {
70                if !turns.outputs.is_empty() {
71                    turns.flush_tools();
72                }
73                turns.calls.push(call.clone());
74            }
75            [ContentBlock::ToolResult(result)] => {
76                if stored_state
77                    || turns
78                        .calls
79                        .iter()
80                        .any(|call| call.id == result.tool_call_id)
81                {
82                    turns.outputs.push(result.clone());
83                } else {
84                    let text = result
85                        .content
86                        .iter()
87                        .filter_map(|block| match block {
88                            ContentBlock::Text { text, .. } | ContentBlock::Refusal { text } => {
89                                Some(text.as_str())
90                            }
91                            ContentBlock::Unknown { raw, .. } => raw.as_str(),
92                            _ => None,
93                        })
94                        .collect::<Vec<_>>()
95                        .join(" ");
96                    turns.push_message(Message::text(
97                        Role::User,
98                        format!("Tool result {}: {text}", result.tool_call_id),
99                    ));
100                }
101            }
102            _ => {
103                let unknown_item =
104                    matches!(message.content.as_slice(), [ContentBlock::Unknown { .. }])
105                        && message.responses.as_ref().is_some_and(|source| {
106                            source
107                                .fields
108                                .get("type")
109                                .and_then(serde_json::Value::as_str)
110                                .is_some_and(|kind| kind != "message")
111                        });
112                let mut message = message.clone();
113                message.responses = None;
114                if message.role == Role::Assistant && !unknown_item && turns.calls.is_empty() {
115                    let mut content = std::mem::take(&mut turns.reasoning);
116                    content.append(&mut message.content);
117                    message.content = content;
118                } else if turns.calls.is_empty() {
119                    turns.flush_reasoning();
120                }
121                turns.push_message(message);
122            }
123        }
124    }
125    turns.flush_tools();
126    turns.flush_reasoning();
127    Conversation {
128        instructions: Cow::Owned(instructions),
129        messages: Cow::Owned(turns.messages),
130    }
131}
132
133// These lists hold temporary clones while the view is built; the request owns the native history.
134#[derive(Default)]
135struct ConversationTurns {
136    messages: Vec<Message>,
137    calls: Vec<ToolCall>,
138    outputs: Vec<ToolResult>,
139    deferred: Vec<Message>,
140    reasoning: Vec<ContentBlock>,
141}
142
143impl ConversationTurns {
144    fn push_message(&mut self, message: Message) {
145        if !self.calls.is_empty() && self.outputs.is_empty() {
146            self.deferred.push(message);
147            return;
148        }
149        self.flush_tools();
150        self.messages.push(message);
151    }
152
153    fn flush_tools(&mut self) {
154        if !self.calls.is_empty() {
155            let mut content = std::mem::take(&mut self.reasoning);
156            content.extend(
157                std::mem::take(&mut self.calls)
158                    .into_iter()
159                    .map(ContentBlock::ToolCall),
160            );
161            self.messages.push(Message {
162                role: Role::Assistant,
163                content,
164                responses: None,
165            });
166        }
167        for result in std::mem::take(&mut self.outputs) {
168            self.messages.push(Message {
169                role: Role::User,
170                content: vec![ContentBlock::ToolResult(result)],
171                responses: None,
172            });
173        }
174        self.messages.append(&mut self.deferred);
175    }
176
177    fn flush_reasoning(&mut self) {
178        if !self.reasoning.is_empty() {
179            let content = std::mem::take(&mut self.reasoning);
180            self.push_message(Message {
181                role: Role::Assistant,
182                content,
183                responses: None,
184            });
185        }
186    }
187}