Skip to content
Merged
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
13 changes: 12 additions & 1 deletion crates/agentic-server-core/src/executor/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use crate::events::EventFrame;
use crate::executor::error::ExecutorResult;
use crate::executor::inference::DONE_MARKER;
use crate::executor::persist::persist_if_needed;
use crate::executor::rehydrate::rehydrate_conversation;
use crate::executor::rehydrate::{prepare_reasoning_for_vllm, rehydrate_conversation, validate_reasoning_for_vllm};
use crate::executor::request::{ExecutionContext, RequestContext};
use crate::executor::upstream::{emit_deferred_stream_events, fetch_blocking_payload, fetch_stream_payload};
use crate::tool::{ToolRegistry, mcp};
Expand Down Expand Up @@ -116,6 +116,13 @@ async fn run_until_gateway_tools_complete(
run_gateway_tool_loop(ctx, exec_ctx, auth, stream_upstream, stream).await
}

fn prepare_initial_reasoning_for_vllm(input: &mut ResponsesInput, round: usize, compacted: bool) -> ExecutorResult<()> {
if round == 0 && !compacted {
return prepare_reasoning_for_vllm(input);
}
Ok(())
}

async fn run_gateway_tool_loop(
mut ctx: RequestContext,
exec_ctx: &ExecutionContext,
Expand All @@ -137,6 +144,7 @@ async fn run_gateway_tool_loop(

for round in 0..MAX_GATEWAY_TOOL_ROUNDS {
let compaction_usage = maybe_compact_context(&mut ctx, exec_ctx, auth).await?;
prepare_initial_reasoning_for_vllm(&mut ctx.enriched_request.input, round, compaction_usage.is_some())?;
accumulate_usage(&mut combined_usage, compaction_usage);
let output_offset = combined_output.len();
let (mut payload, deferred_stream_events): (ResponsePayload, Vec<_>) = if stream_upstream {
Expand Down Expand Up @@ -563,6 +571,9 @@ impl ExecuteRequest {
"executor received responses request"
);
let ctx = rehydrate_conversation(self.payload, &self.exec_ctx).await?;
if !ctx.enriched_request.input.has_compaction_trigger() {
validate_reasoning_for_vllm(&ctx.enriched_request.input)?;
}
if ctx.original_request.stream {
Ok(Either::Right(run_stream(ctx, self.exec_ctx, self.client_auth)))
} else {
Expand Down
199 changes: 198 additions & 1 deletion crates/agentic-server-core/src/executor/rehydrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,79 @@
use crate::executor::error::{ExecutorError, ExecutorResult};
use crate::executor::request::{ExecutionContext, RequestContext};
use crate::storage::InOutItem;
use crate::types::io::{InputItem, ResponsesInput, resolve_tool_choice, resolve_tools};
use crate::types::io::{
InputItem, ReasoningOutput, ReasoningTextContent, ResponsesInput, resolve_tool_choice, resolve_tools,
};
use crate::types::request_response::RequestPayload;
use crate::utils::uuid7_str;

fn has_plaintext_reasoning(reasoning: &ReasoningOutput) -> bool {
reasoning.content.iter().any(|content| !content.text.is_empty())
}

fn has_opaque_reasoning_state(reasoning: &ReasoningOutput) -> bool {
reasoning
.encrypted_content
.as_ref()
.is_some_and(|encrypted| !encrypted.is_null())
}

/// Reject opaque reasoning that vLLM cannot replay before any normal inference call.
pub(super) fn validate_reasoning_for_vllm(input: &ResponsesInput) -> ExecutorResult<()> {
let ResponsesInput::Items(items) = input else {
return Ok(());
};

if items.iter().any(|item| {
matches!(item, InputItem::Reasoning(reasoning) if has_opaque_reasoning_state(reasoning) && !has_plaintext_reasoning(reasoning))
}) {
return Err(ExecutorError::InvalidRequest(
"reasoning item contains encrypted state without plaintext reasoning content and cannot be replayed to vLLM"
.to_owned(),
));
}

Ok(())
}

/// Prepare reasoning in the vLLM-bound request copy.
///
/// vLLM can replay plaintext reasoning content but cannot interpret opaque provider state. Its generic Responses
/// conversion reads only the first reasoning content part and falls back to a summary when content is absent, while
/// its Harmony conversion joins all content parts. Normalize usable plaintext into one ordered, newline-delimited part
/// and remove summaries so both paths receive the same continuation state. Summary-only items have no usable vLLM
/// state and are omitted. [`RequestContext`] keeps the original request and new input items separately, so none of
/// these changes mutate persisted state.
pub(super) fn prepare_reasoning_for_vllm(input: &mut ResponsesInput) -> ExecutorResult<()> {
// Validate the complete input before mutation so an error never leaves a partially prepared request behind.
validate_reasoning_for_vllm(input)?;

let ResponsesInput::Items(items) = input else {
return Ok(());
};

items.retain_mut(|item| {
let InputItem::Reasoning(reasoning) = item else {
return true;
};
if !has_plaintext_reasoning(reasoning) {
return false;
}

let plaintext = reasoning
.content
.iter()
.map(|content| content.text.as_str())
.collect::<Vec<_>>()
.join("\n");
reasoning.content = vec![ReasoningTextContent::new(plaintext)];
reasoning.summary.clear();
reasoning.encrypted_content = None;
true
});
Ok(())
}

/// Step 1 — Build [`RequestContext`] by rehydrating conversation history.
///
/// `request` is moved into the context as `enriched_request`; one clone is taken
Expand Down Expand Up @@ -127,6 +196,134 @@ mod tests {
};
use crate::types::request_response::RequestPayload;

fn reasoning_item(content: &[&str], encrypted_content: Option<serde_json::Value>) -> InputItem {
InputItem::Reasoning(ReasoningOutput {
id: "rs_prior".to_owned(),
content: content.iter().map(|text| ReasoningTextContent::new(*text)).collect(),
summary: vec![serde_json::json!({"type": "summary_text", "text": "public summary"})],
encrypted_content,
status: Some("completed".to_owned()),
})
}

#[test]
fn plaintext_reasoning_is_normalized_for_both_vllm_paths() {
let mut input = ResponsesInput::Items(vec![reasoning_item(
&["first continuation part", "second continuation part"],
Some(serde_json::json!({"ciphertext": "opaque-provider-state"})),
)]);

prepare_reasoning_for_vllm(&mut input).expect("plaintext reasoning is replayable");

let ResponsesInput::Items(items) = input else {
panic!("expected structured input");
};
let InputItem::Reasoning(reasoning) = &items[0] else {
panic!("expected reasoning item");
};
assert_eq!(reasoning.id, "rs_prior");
assert_eq!(reasoning.content.len(), 1);
assert_eq!(
reasoning.content[0].text,
"first continuation part\nsecond continuation part"
);
assert!(reasoning.summary.is_empty());
assert_eq!(reasoning.status.as_deref(), Some("completed"));
assert_eq!(reasoning.encrypted_content, None);
}

#[test]
fn encrypted_reasoning_requires_nonempty_plaintext_content() {
for content in [Vec::new(), vec![""], vec!["", ""]] {
let mut input = ResponsesInput::Items(vec![reasoning_item(
&content,
Some(serde_json::json!("opaque-provider-state")),
)]);

let error =
prepare_reasoning_for_vllm(&mut input).expect_err("encrypted-only reasoning must not reach vLLM");

assert_eq!(error.http_status(), http::StatusCode::BAD_REQUEST);
assert!(
error
.to_string()
.contains("encrypted state without plaintext reasoning content")
);
assert!(!error.to_string().contains("opaque-provider-state"));
}
}

#[test]
fn plaintext_reasoning_with_null_encrypted_state_is_normalized_without_summary() {
let mut item = reasoning_item(&["plaintext continuation"], Some(serde_json::Value::Null));
let InputItem::Reasoning(reasoning) = &mut item else {
panic!("expected reasoning item");
};
reasoning.content[0].type_ = "unexpected_provider_type".to_owned();
let mut input = ResponsesInput::Items(vec![item]);

prepare_reasoning_for_vllm(&mut input).expect("null encrypted state is valid");

let ResponsesInput::Items(items) = input else {
panic!("expected structured input");
};
let InputItem::Reasoning(reasoning) = &items[0] else {
panic!("expected reasoning item");
};
assert_eq!(reasoning.content[0].type_, "reasoning_text");
assert_eq!(reasoning.content[0].text, "plaintext continuation");
assert!(reasoning.summary.is_empty());
assert_eq!(reasoning.encrypted_content, None);
}

#[test]
fn summary_only_reasoning_without_opaque_state_is_removed_from_vllm_copy() {
for encrypted_content in [None, Some(serde_json::Value::Null)] {
let mut input = ResponsesInput::Items(vec![reasoning_item(&[], encrypted_content)]);

prepare_reasoning_for_vllm(&mut input).expect("summary-only reasoning has no usable vLLM state");

let ResponsesInput::Items(items) = input else {
panic!("expected structured input");
};
assert!(
items.is_empty(),
"a reasoning summary must never be promoted to reasoning text"
);
}
}

#[test]
fn validation_failure_does_not_partially_mutate_input() {
let valid = reasoning_item(
&["plaintext continuation"],
Some(serde_json::json!("first-opaque-state")),
);
let invalid = reasoning_item(&[], Some(serde_json::json!("second-opaque-state")));
let mut input = ResponsesInput::Items(vec![valid.clone(), invalid]);

prepare_reasoning_for_vllm(&mut input).expect_err("the complete input must validate before normalization");

let ResponsesInput::Items(items) = input else {
panic!("expected structured input");
};
let (InputItem::Reasoning(actual), InputItem::Reasoning(expected)) = (&items[0], &valid) else {
panic!("expected reasoning items");
};
assert_eq!(actual.content[0].text, expected.content[0].text);
assert_eq!(actual.summary, expected.summary);
assert_eq!(actual.encrypted_content, expected.encrypted_content);
}

#[test]
fn text_input_is_unchanged() {
let mut input = ResponsesInput::Text("plain user input".to_owned());

prepare_reasoning_for_vllm(&mut input).expect("text input contains no reasoning item");

assert!(matches!(input, ResponsesInput::Text(ref text) if text == "plain user input"));
}

fn request(conversation_id: Option<&str>, previous_response_id: Option<&str>) -> RequestPayload {
RequestPayload {
model: "test".into(),
Expand Down
Loading