gproxy_protocol/transform/memory/
response.rs1use 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}