fix(guardrails): filter tool/function roles from AIM inspection messages

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-07-07 22:59:59 +00:00
parent 06a97c83bd
commit 507e34650d
2 changed files with 50 additions and 2 deletions

View file

@ -25,6 +25,15 @@ from typing import Any, Callable, Dict, FrozenSet, Iterator, List
# guardrails on those paths are a separate scope.
TEXT_CONTENT_CALL_TYPES: FrozenSet[str] = frozenset({"completion", "acompletion", "aresponses"})
# Roles that only make sense alongside tool-call metadata (``tool_call_id``,
# ``name``, …). ``build_inspection_messages`` flattens each message down to a
# plain ``{role, content}`` pair, dropping that metadata, so emitting these
# roles produces payloads that remote guardrail APIs reject (e.g. AIM returns
# 422 "Tool Call ID required on tool calls"). Their content is intermediate
# tool plumbing, not user input or model output, so it is excluded from
# guardrail inspection.
INSPECTION_EXCLUDED_ROLES: FrozenSet[str] = frozenset({"tool", "function"})
def is_text_content_call_type(call_type: str) -> bool:
"""Return True if ``call_type`` carries free-form text that text
@ -207,7 +216,9 @@ def build_inspection_messages(data: Dict[str, Any]) -> List[Dict[str, str]]:
Each returned message has a plain-string ``content`` multimodal text
parts are joined with newlines and Responses-API ``input`` is lifted
into synthetic messages. Messages with no inspectable text are dropped.
into synthetic messages. Messages with no inspectable text are dropped,
as are ``tool``/``function`` role messages (see
:data:`INSPECTION_EXCLUDED_ROLES`).
Hooks that POST ``{"messages": [...]}`` to an external service should
call this instead of ``data.get("messages", [])`` so the Responses API
@ -217,9 +228,11 @@ def build_inspection_messages(data: Dict[str, Any]) -> List[Dict[str, str]]:
for message in _iter_inspection_messages(data):
if not isinstance(message, dict):
continue
role = message.get("role", "user") or "user"
if role in INSPECTION_EXCLUDED_ROLES:
continue
text = "\n".join(_iter_text_parts_in_content(message.get("content")))
if not text:
continue
role = message.get("role", "user") or "user"
flattened.append({"role": role, "content": text})
return flattened

View file

@ -239,6 +239,41 @@ def test_build_inspection_messages_empty_data():
assert build_inspection_messages({"input": ""}) == []
def test_build_inspection_messages_drops_tool_and_function_roles():
"""AIM (and other remote guardrail APIs) validate against an
OpenAI-compatible schema that requires ``tool_call_id`` on ``tool`` role
messages. ``build_inspection_messages`` flattens messages to plain
``{role, content}`` and discards that metadata, so passing tool/function
role messages through produced a 422 "Tool Call ID required on tool calls"
and made AIM non-functional for any conversation with tool calls. They
must be filtered out entirely."""
data = {
"messages": [
{"role": "system", "content": "be helpful"},
{"role": "user", "content": "show me milk"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "search_products", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "milk $3.99"},
{"role": "function", "name": "search_products", "content": "eggs $2.50"},
{"role": "assistant", "content": "Here is the milk you asked for"},
]
}
assert build_inspection_messages(data) == [
{"role": "system", "content": "be helpful"},
{"role": "user", "content": "show me milk"},
{"role": "assistant", "content": "Here is the milk you asked for"},
]
# ── has_non_string_content ────────────────────────────────────────────────────