fix(responses): treat member-less namespace tools as one flat function in guardrail merge

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-09-02 02:22:59 +00:00
parent aa1396f8de
commit 7d8beeced7
4 changed files with 39 additions and 11 deletions

View file

@ -123,7 +123,7 @@
"limit": 5
},
"reportUnnecessaryIsInstance": {
"limit": 824
"limit": 823
},
"reportUntypedBaseClass": {
"limit": 0

View file

@ -118,9 +118,13 @@ _TOOL_TYPES_NOT_SENT_TO_GUARDRAIL: Final = frozenset(
_NAMESPACE_MEMBERS: Final = TypeAdapter(tuple[Mapping[str, object], ...])
def _namespace_members(tool: Mapping[str, object]) -> tuple[Mapping[str, object], ...]:
def _namespace_members(tool: Mapping[str, object]) -> tuple[Mapping[str, object], ...] | None:
"""``None`` when the namespace has no member list and is flattened to one function named after it."""
raw_members: Final = tool.get("tools")
if not isinstance(raw_members, Sequence) or isinstance(raw_members, (str, bytes)):
return None
try:
return _NAMESPACE_MEMBERS.validate_python(tool.get("tools") or ())
return _NAMESPACE_MEMBERS.validate_python(raw_members)
except ValidationError:
return ()
@ -133,17 +137,21 @@ def _qualified_member_name(namespace: str, member: Mapping[str, object]) -> str:
return f"{namespace}__{member.get('name') or ''}"
def _namespace_function_names(tool: Mapping[str, object]) -> tuple[str, ...]:
namespace: Final = str(tool.get("name") or "")
members: Final = _namespace_members(tool)
if members is None:
return (namespace,)
return tuple(_qualified_member_name(namespace, member) for member in members if _is_function_member(member))
def _flattened_function_names(tools: Sequence[Mapping[str, object]]) -> tuple[str, ...]:
"""Names the guardrail sees for ``tools`` once flattened to Chat Completions format."""
top_level: Final = tuple(
str(tool.get("name") or "") for tool in tools if tool.get("type") in ("function", "custom")
)
nested: Final = tuple(
_qualified_member_name(str(tool.get("name") or ""), member)
for tool in tools
if tool.get("type") == "namespace"
for member in _namespace_members(tool)
if _is_function_member(member)
name for tool in tools if tool.get("type") == "namespace" for name in _namespace_function_names(tool)
)
return top_level + nested
@ -153,6 +161,8 @@ def _merge_namespace_tool(
) -> Mapping[str, object] | None:
namespace: Final = str(tool.get("name") or "")
members: Final = _namespace_members(tool)
if members is None:
return tool if namespace in remapped_functions else None
surviving: Final = tuple(
member
for member in members

View file

@ -1388,6 +1388,24 @@ class TestOpenAIResponsesHandlerNamespaceTools:
assert result["tools"][2]["name"] == "injected_tool"
assert not any(t["name"].startswith("mcp__confluence__") for t in result["tools"])
@pytest.mark.asyncio
async def test_unchanged_guardrail_leaves_custom_mcp_and_memberless_namespace_tools_as_is(self):
handler = OpenAIResponsesHandler()
tools = [
{"type": "custom", "name": "apply_patch", "description": "Apply a patch"},
{"type": "mcp", "server_label": "docs", "server_url": "https://mcp.example.com"},
{"type": "namespace", "name": "mcp__flat", "description": "Flattened to one function"},
]
data = {
"input": [{"role": "user", "content": "fetch page 1", "type": "message"}],
"tools": [dict(t) for t in tools],
"model": "gpt-5.3-codex",
}
result = await handler.process_input_messages(data, MockPassThroughGuardrail(guardrail_name="test"))
assert result["tools"] == tools
@pytest.mark.asyncio
async def test_guardrail_can_still_drop_a_single_namespace_member(self):
handler = OpenAIResponsesHandler()

View file

@ -1,9 +1,9 @@
{
"LIT001": {
"limit": 22358
"limit": 22352
},
"LIT002": {
"limit": 26775
"limit": 26774
},
"LIT003": {
"limit": 269
@ -27,7 +27,7 @@
"limit": 0
},
"LIT010": {
"limit": 16505
"limit": 16504
},
"LIT011": {
"limit": 5535