fix(guardrails): keep the assistant turn when scoping empties the request history

A request whose turns all fall outside the guardrail's scope, such as a user-only
request under scan_only_tool_results, still supplied a conversation, so the response
scan now carries the reply as the sole assistant turn instead of dropping
structured_messages. Response-only behavior stays when no conversation was supplied

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-15 19:12:15 +00:00
parent 43ae9aff3d
commit d5e056491c
4 changed files with 31 additions and 2 deletions

View file

@ -30,6 +30,7 @@ class RequestScanContext:
structured_messages: tuple["AllMessageValues", ...] = ()
tools: tuple["ChatCompletionToolParam", ...] = ()
conversation_supplied: bool = False
@staticmethod
def scoped(
@ -51,6 +52,7 @@ class RequestScanContext:
return RequestScanContext(
structured_messages=tuple(structured_messages[index] for index in scoped_indices),
tools=() if scan_only_tool_results else tuple(tools),
conversation_supplied=bool(structured_messages),
)
@ -308,7 +310,7 @@ class BaseTranslation(ABC):
if request_data is None:
return inputs
context: Final = self.request_scan_context(request_data, guardrail_to_apply)
if not context.structured_messages:
if not context.conversation_supplied:
return inputs
assistant_turn: Final = response_assistant_turn(inputs.get("texts") or (), inputs.get("tool_calls") or ())
contextual_inputs: Final[GenericGuardrailAPIInputs] = {

View file

@ -454,8 +454,9 @@ class OpenAIResponsesHandler(BaseTranslation):
def request_scan_context(self, data: dict, guardrail_to_apply: "CustomGuardrail") -> RequestScanContext:
raw_tools: Final = data.get("tools")
structured_messages: Final = tuple(self.get_structured_messages(data) or ())
return RequestScanContext(
structured_messages=tuple(self.get_structured_messages(data) or ()),
structured_messages=structured_messages,
tools=tuple(
cast(ChatCompletionToolParam, tool) # cast-ok: mcp tools ride along in the guardrail's tool list
for form in LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(
@ -463,6 +464,7 @@ class OpenAIResponsesHandler(BaseTranslation):
)
for tool in form.chat_tools
),
conversation_supplied=bool(structured_messages),
)
async def process_input_messages(

View file

@ -2360,6 +2360,19 @@ class TestResponseScanCarriesRequestConversation:
assert [m["role"] for m in inputs["structured_messages"]] == ["tool", "assistant"]
assert "tools" not in inputs
@pytest.mark.asyncio
async def test_scan_only_tool_results_without_tool_turns_still_carries_the_reply(self):
handler = OpenAIChatCompletionsHandler()
guardrail = InputsRecordingGuardrail()
guardrail.scan_only_tool_results = True
request = {**self._request(), "messages": [{"role": "user", "content": "Delete everything"}]}
await handler.process_output_response(self._tool_call_response(), guardrail, request_data=request)
[(_, inputs)] = guardrail.seen
assert [m["role"] for m in inputs["structured_messages"]] == ["assistant"]
assert inputs["structured_messages"][0]["tool_calls"][0]["function"]["name"] == "run_shell"
@pytest.mark.asyncio
async def test_response_scan_without_request_data_stays_response_only(self):
guardrail = InputsRecordingGuardrail()

View file

@ -3397,3 +3397,15 @@ class TestResponsesResponseScanCarriesRequestConversation:
"assistant",
]
assert inputs["structured_messages"][-1] == {"role": "assistant", "content": "Paris is the capital"}
@pytest.mark.asyncio
async def test_response_scan_without_request_input_stays_response_only(self):
handler = OpenAIResponsesHandler()
guardrail = TypedInputsRecordingGuardrail()
request = {k: v for k, v in self._request().items() if k not in ("input", "instructions")}
await handler.process_output_response(self._tool_call_response(), guardrail, request_data=request)
[(_, inputs)] = guardrail.seen
assert "structured_messages" not in inputs
assert "tools" not in inputs