Skip to main content

gproxy_protocol/transform/memory/
response.rs

1use crate::{
2    codec::{self, CodecLimits},
3    transform::TransformError,
4    wire::{
5        claude::generate_content as c,
6        gemini as g,
7        openai::{chat as o, responses as r},
8    },
9};
10
11#[derive(serde::Deserialize)]
12#[serde(deny_unknown_fields)]
13struct Output {
14    trace_summary: String,
15    memory_summary: String,
16}
17
18pub fn parse_output(text: &str, limits: CodecLimits) -> Result<(String, String), TransformError> {
19    let value: Output = codec::decode_json(text.as_bytes(), limits).map_err(|e| {
20        TransformError::new(
21            if e.kind() == codec::CodecErrorKind::Limit {
22                crate::transform::TransformErrorKind::Limit
23            } else {
24                crate::transform::TransformErrorKind::InvalidResult
25            },
26            "memory.output",
27            e.to_string(),
28        )
29    })?;
30    Ok((value.trace_summary, value.memory_summary))
31}
32
33fn nonempty(text: String) -> Result<String, TransformError> {
34    if text.is_empty() {
35        Err(TransformError::invalid_result(
36            "memory.response",
37            "empty generated text",
38        ))
39    } else {
40        Ok(text)
41    }
42}
43
44pub fn claude_text(input: c::GenerateContentResponseBody) -> Result<String, TransformError> {
45    if input.stop_reason != c::StopReason::EndTurn {
46        return Err(TransformError::invalid_result(
47            "memory.stop_reason",
48            "memory requires fully completed end_turn",
49        ));
50    }
51    let mut text = String::new();
52    for block in input.content {
53        match block {
54            c::ResponseContentBlock::Text(v) => text.push_str(&v.text),
55            c::ResponseContentBlock::Thinking(_) | c::ResponseContentBlock::RedactedThinking(_) => {
56            }
57            c::ResponseContentBlock::ToolUse(_)
58            | c::ResponseContentBlock::ServerToolUse(_)
59            | c::ResponseContentBlock::WebSearchToolResult(_)
60            | c::ResponseContentBlock::WebFetchToolResult(_)
61            | c::ResponseContentBlock::AdvisorToolResult(_)
62            | c::ResponseContentBlock::CodeExecutionToolResult(_)
63            | c::ResponseContentBlock::BashCodeExecutionToolResult(_)
64            | c::ResponseContentBlock::TextEditorCodeExecutionToolResult(_)
65            | c::ResponseContentBlock::ToolSearchToolResult(_)
66            | c::ResponseContentBlock::McpToolUse(_)
67            | c::ResponseContentBlock::McpToolResult(_)
68            | c::ResponseContentBlock::ContainerUpload(_)
69            | c::ResponseContentBlock::McpToolListing(_)
70            | c::ResponseContentBlock::Compaction(_)
71            | c::ResponseContentBlock::Fallback(_) => {
72                return Err(TransformError::invalid_result(
73                    "memory.content",
74                    "unexpected tool/resource state instead of summary",
75                ));
76            }
77        }
78    }
79    nonempty(text)
80}
81
82pub fn gemini_text(input: g::GenerateContentResponseBody) -> Result<String, TransformError> {
83    if input
84        .prompt_feedback
85        .as_ref()
86        .and_then(|v| v.block_reason)
87        .is_some_and(|v| v != g::BlockReason::Unspecified)
88    {
89        return Err(TransformError::invalid_result(
90            "memory.prompt_feedback",
91            "prompt blocked",
92        ));
93    }
94    let candidates = input
95        .candidates
96        .ok_or_else(|| TransformError::invalid_result("memory.candidates", "missing candidate"))?;
97    if candidates.len() != 1 {
98        return Err(TransformError::invalid_result(
99            "memory.candidates",
100            "exactly one summary candidate required",
101        ));
102    }
103    let candidate = candidates.into_iter().next().unwrap();
104    if candidate.finish_reason != Some(g::FinishReason::Stop)
105        || candidate.index.is_some_and(|v| v != 0)
106    {
107        return Err(TransformError::invalid_result(
108            "memory.candidate",
109            "nonterminal/failed or wrong index",
110        ));
111    }
112    let content = candidate
113        .content
114        .ok_or_else(|| TransformError::invalid_result("memory.content", "missing content"))?;
115    if content.role.as_deref().is_some_and(|v| v != "model") {
116        return Err(TransformError::invalid_result(
117            "memory.role",
118            "model role required",
119        ));
120    }
121    let mut text = String::new();
122    for part in content.parts.unwrap_or_default() {
123        if part.inline_data.is_some()
124            || part.file_data.is_some()
125            || part.function_call.is_some()
126            || part.function_response.is_some()
127            || part.executable_code.is_some()
128            || part.code_execution_result.is_some()
129            || part.tool_call.is_some()
130            || part.tool_response.is_some()
131        {
132            return Err(TransformError::invalid_result(
133                "memory.part",
134                "unexpected nontext summary payload",
135            ));
136        }
137        if part.thought != Some(true) {
138            text.push_str(part.text.as_deref().unwrap_or(""));
139        }
140    }
141    nonempty(text)
142}
143
144pub fn chat_text(input: o::GenerateContentResponseBody) -> Result<String, TransformError> {
145    if input.choices.len() != 1 {
146        return Err(TransformError::invalid_result(
147            "memory.choices",
148            "exactly one choice required",
149        ));
150    }
151    let choice = input.choices.into_iter().next().unwrap();
152    if choice.index != 0
153        || choice.finish_reason != o::FinishReason::Stop
154        || choice.message.refusal.is_some()
155        || choice.message.function_call.is_some()
156        || choice
157            .message
158            .tool_calls
159            .as_ref()
160            .is_some_and(|v| !v.is_empty())
161        || choice.message.audio.flatten().is_some()
162    {
163        return Err(TransformError::invalid_result(
164            "memory.choice",
165            "refused/truncated/tool/audio response is not a summary",
166        ));
167    }
168    nonempty(
169        choice
170            .message
171            .content
172            .ok_or_else(|| TransformError::invalid_result("memory.content", "missing content"))?,
173    )
174}
175
176pub fn responses_text(input: r::GenerateContentResponseBody) -> Result<String, TransformError> {
177    if input.status != Some(r::ResponseStatus::Completed)
178        || input.error.is_some()
179        || input.incomplete_details.is_some()
180    {
181        return Err(TransformError::invalid_result(
182            "memory.status",
183            "Responses must be completed without error/incomplete state",
184        ));
185    }
186    let mut text = String::new();
187    for item in input.output {
188        if crate::transform::generate::multi_agent::excluded_output(&item) {
189            continue;
190        }
191        match item {
192            r::ResponseOutputItem::Message(message) => {
193                if message.status != r::OutputMessageStatus::Completed {
194                    return Err(TransformError::invalid_result(
195                        "memory.message.status",
196                        "message not completed",
197                    ));
198                }
199                for part in message.content {
200                    match part {
201                        r::OutputContent::Text(part) => text.push_str(&part.text),
202                        r::OutputContent::Refusal(_) => {
203                            return Err(TransformError::invalid_result(
204                                "memory.refusal",
205                                "refused summary",
206                            ));
207                        }
208                    }
209                }
210            }
211            r::ResponseOutputItem::Reasoning(value) => {
212                if matches!(
213                    value.status,
214                    Some(r::ReasoningStatus::InProgress | r::ReasoningStatus::Incomplete)
215                ) {
216                    return Err(TransformError::invalid_result(
217                        "memory.reasoning.status",
218                        "completed response contains unfinished reasoning",
219                    ));
220                }
221            }
222            r::ResponseOutputItem::FunctionCall(_)
223            | r::ResponseOutputItem::FunctionCallOutput(_)
224            | r::ResponseOutputItem::FileSearchCall(_)
225            | r::ResponseOutputItem::WebSearchCall(_)
226            | r::ResponseOutputItem::ComputerCall(_)
227            | r::ResponseOutputItem::ComputerCallOutput(_)
228            | r::ResponseOutputItem::Program(_)
229            | r::ResponseOutputItem::ProgramOutput(_)
230            | r::ResponseOutputItem::ToolSearchCall(_)
231            | r::ResponseOutputItem::ToolSearchOutput(_)
232            | r::ResponseOutputItem::AdditionalTools(_)
233            | r::ResponseOutputItem::MultiAgentCall(_)
234            | r::ResponseOutputItem::MultiAgentCallOutput(_)
235            | r::ResponseOutputItem::AgentMessage(_)
236            | r::ResponseOutputItem::ConfigurationUpdate(_)
237            | r::ResponseOutputItem::Compaction(_)
238            | r::ResponseOutputItem::ImageGenerationCall(_)
239            | r::ResponseOutputItem::CodeInterpreterCall(_)
240            | r::ResponseOutputItem::LocalShellCall(_)
241            | r::ResponseOutputItem::LocalShellCallOutput(_)
242            | r::ResponseOutputItem::ShellCall(_)
243            | r::ResponseOutputItem::ShellCallOutput(_)
244            | r::ResponseOutputItem::ApplyPatchCall(_)
245            | r::ResponseOutputItem::ApplyPatchCallOutput(_)
246            | r::ResponseOutputItem::McpCall(_)
247            | r::ResponseOutputItem::McpListTools(_)
248            | r::ResponseOutputItem::McpApprovalRequest(_)
249            | r::ResponseOutputItem::McpApprovalResponse(_)
250            | r::ResponseOutputItem::CustomToolCall(_)
251            | r::ResponseOutputItem::CustomToolCallOutput(_) => {
252                return Err(TransformError::invalid_result(
253                    "memory.output",
254                    "unexpected native execution output",
255                ));
256            }
257        }
258    }
259    if let Some(Some(alias)) = input.output_text {
260        if !text.is_empty() && text != alias {
261            return Err(TransformError::invalid_result(
262                "memory.output_text",
263                "alias conflicts with typed output",
264            ));
265        }
266        if text.is_empty() {
267            text = alias;
268        }
269    }
270    nonempty(text)
271}