Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 3 additions & 26 deletions apis/src/openai/responses/agentic_loop/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,13 +334,13 @@ fn evaluate_loop_decision(
) -> Result<FilterAction, FilterError> {
if state.tool_calls.is_empty() && state.web_search_calls.is_empty() {
trace!("no tool calls, signaling done");
finalize_response_body(state, body);
state.finalize_response_body(body);
return set_done(ctx);
}
match check_exit_conditions(state, config) {
Some(ExitReason::FinishReasonLength) => {
ctx.set_metadata(META_STATUS, "incomplete");
finalize_response_body(state, body);
state.finalize_response_body(body);
set_action(ctx, ACTION_DONE)?;
Ok(FilterAction::Continue)
},
Expand All @@ -354,7 +354,7 @@ fn evaluate_loop_decision(
state.iteration += 1;
let (tc, wsc) = (state.tool_calls.len(), state.web_search_calls.len());
debug!(iteration = state.iteration, tc, wsc, "pending calls, signaling loop");
finalize_response_body(state, body);
state.finalize_response_body(body);
set_action(ctx, ACTION_LOOP)?;
Ok(FilterAction::Continue)
},
Expand Down Expand Up @@ -480,29 +480,6 @@ fn is_finish_reason_length(state: &ResponsesState) -> bool {
// Helpers
// -----------------------------------------------------------------------------

/// Build the final response body from accumulated state.
///
/// Replaces `response_object["output"]` with the full
/// `accumulated_output` (all rounds), stamps accumulated usage,
/// and serializes back to body bytes.
fn finalize_response_body(state: &ResponsesState, body: &mut Option<Bytes>) {
if !state.response_object.is_object() {
return;
}
let mut response = state.response_object.clone();
if let Some(obj) = response.as_object_mut() {
if !state.accumulated_output.is_empty() {
obj.insert("output".to_owned(), Value::Array(state.accumulated_output.clone()));
}
if !state.usage.is_null() {
obj.insert("usage".to_owned(), state.usage.clone());
}
}
if let Ok(serialized) = serde_json::to_vec(&response) {
*body = Some(Bytes::from(serialized));
}
}

/// Shorthand: set `action = "done"` and return `Continue`.
fn set_done(ctx: &mut HttpFilterContext<'_>) -> Result<FilterAction, FilterError> {
set_action(ctx, ACTION_DONE)?;
Expand Down
8 changes: 6 additions & 2 deletions apis/src/openai/responses/mcp_dispatch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ impl McpDispatchFilter {
/// Handle a tool call that requires approval.
fn handle_approval_required(
ctx: &mut HttpFilterContext<'_>,
body: &mut Option<Bytes>,
pending: &PendingApproval,
) -> Result<FilterAction, FilterError> {
debug!(
Expand All @@ -139,6 +140,9 @@ impl McpDispatchFilter {
};
state.accumulated_output.push(approval_event);

// Re-serialize the response body with the new mcp_approval_request event
state.finalize_response_body(body);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this now mutates the body, you must udated the body access mode to ReadWrite


ctx.set_metadata("openai_mcp_dispatch.action".to_owned(), "done".to_owned());
set_action(ctx, ACTION_DONE)?;

Expand Down Expand Up @@ -220,7 +224,7 @@ impl HttpFilter for McpDispatchFilter {
fn on_response_body(
&self,
ctx: &mut HttpFilterContext<'_>,
_body: &mut Option<Bytes>,
body: &mut Option<Bytes>,
end_of_stream: bool,
) -> Result<FilterAction, FilterError> {
if !end_of_stream {
Expand All @@ -238,7 +242,7 @@ impl HttpFilter for McpDispatchFilter {
}

if let Some(pending) = find_approval_required(&mcp_calls, &state.mcp_tool_map) {
return Self::handle_approval_required(ctx, &pending);
return Self::handle_approval_required(ctx, body, &pending);
}

ctx.set_metadata("openai_mcp_dispatch.action".to_owned(), "execute_mcp".to_owned());
Expand Down
35 changes: 35 additions & 0 deletions apis/src/openai/responses/mcp_dispatch/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1033,6 +1033,41 @@ fn on_response_body_approval_emits_correct_arguments() {
);
}

#[test]
fn on_response_body_approval_serializes_approval_request_into_body() {
let filter = make_dispatch_filter();
let req = make_request(http::Method::POST, "/v1/responses");
let mut ctx = make_filter_context(&req);
let state = ResponsesState {
mcp_tool_map: sample_tool_map(),
tool_calls: vec![json!({
"name": "weather__get_weather",
"call_id": "c1",
"arguments": "{\"city\":\"Paris\"}"
})],
response_object: json!({
"id": "resp_123",
"output": []
}),
..ResponsesState::default()
};
ctx.extensions.insert(state);

let mut body = Some(Bytes::from(r#"{"id":"resp_123","output":[]}"#));
let result = filter.on_response_body(&mut ctx, &mut body, true).unwrap();
assert!(matches!(result, FilterAction::Continue));

let bytes = body.expect("response body should be serialized with approval request");
let response_json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let output = response_json["output"].as_array().expect("output should be an array");
assert_eq!(output.len(), 1, "output array should contain 1 item");
assert_eq!(
output[0]["type"], "mcp_approval_request",
"output item should be mcp_approval_request"
);
assert_eq!(output[0]["id"], "c1");
}

// =========================================================================
// on_request (HttpFilter trait)
// =========================================================================
Expand Down
Loading
Loading