Skip to content

Commit fc66c41

Browse files
committed
Fix tests; address PR comments
1 parent 5b46fe9 commit fc66c41

3 files changed

Lines changed: 134 additions & 11 deletions

File tree

nemoguardrails/actions/llm/utils.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -931,18 +931,23 @@ def get_and_clear_tool_calls_contextvar() -> Optional[list]:
931931

932932

933933
def extract_tool_calls_from_events(events: list) -> Optional[list]:
934-
"""Extract tool_calls from BotToolCalls events.
934+
"""Extract tool_calls from runtime events.
935935
936-
Args:
937-
events: List of events to search through
938-
939-
Returns:
940-
tool_calls if found in BotToolCalls event, None otherwise
936+
``StartToolCallBotAction`` carries the tool calls that passed tool-output
937+
rails and should be returned to the caller. ``BotToolCalls`` is used as a
938+
fallback for paths that do not emit the post-rail action event.
941939
"""
940+
bot_tool_calls = None
941+
942942
for event in events:
943-
if event.get("type") == "BotToolCalls":
944-
return event.get("tool_calls")
945-
return None
943+
if event.get("type") == "StartToolCallBotAction":
944+
tool_calls = event.get("tool_calls")
945+
if tool_calls is not None:
946+
return tool_calls
947+
elif event.get("type") == "BotToolCalls":
948+
bot_tool_calls = event.get("tool_calls")
949+
950+
return bot_tool_calls
946951

947952

948953
def extract_bot_thinking_from_events(events: list):

tests/test_tool_calls_event_extraction.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,53 @@ def mock_get_and_clear():
126126
assert result["tool_calls"][0]["name"] == "test_tool"
127127

128128

129+
@pytest.mark.asyncio
130+
async def test_extract_tool_calls_from_start_tool_call_action():
131+
from nemoguardrails.actions.llm.utils import extract_tool_calls_from_events
132+
133+
test_tool_calls = [
134+
{
135+
"id": "call_approved",
136+
"type": "function",
137+
"function": {
138+
"name": "approved_tool",
139+
"arguments": {"data": "safe"},
140+
},
141+
}
142+
]
143+
144+
events = [{"type": "StartToolCallBotAction", "tool_calls": test_tool_calls}]
145+
146+
assert extract_tool_calls_from_events(events) == test_tool_calls
147+
148+
149+
@pytest.mark.asyncio
150+
async def test_extract_tool_calls_prefers_post_rail_action_event():
151+
from nemoguardrails.actions.llm.utils import extract_tool_calls_from_events
152+
153+
pre_rail = [
154+
{
155+
"id": "call_modified",
156+
"type": "function",
157+
"function": {"name": "lookup", "arguments": {"query": "unfiltered"}},
158+
}
159+
]
160+
post_rail = [
161+
{
162+
"id": "call_modified",
163+
"type": "function",
164+
"function": {"name": "lookup", "arguments": {"query": "filtered"}},
165+
}
166+
]
167+
168+
events = [
169+
{"type": "BotToolCalls", "tool_calls": pre_rail},
170+
{"type": "StartToolCallBotAction", "tool_calls": post_rail},
171+
]
172+
173+
assert extract_tool_calls_from_events(events) == post_rail
174+
175+
129176
@pytest.mark.asyncio
130177
async def test_llmrails_extracts_tool_calls_from_events():
131178
config = RailsConfig.from_content(config={"models": [], "passthrough": True})

tests/test_tool_output_rails.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16+
import json
1617
from unittest.mock import patch
1718

1819
import pytest
@@ -24,6 +25,17 @@
2425
from tests.utils import FakeLLMModel, TestChat
2526

2627

28+
def _tool_arguments(func: dict) -> dict:
29+
"""Parse OpenAI-style tool call arguments (JSON string or dict)."""
30+
arguments = func.get("arguments", {})
31+
if isinstance(arguments, str):
32+
try:
33+
arguments = json.loads(arguments)
34+
except json.JSONDecodeError:
35+
return {}
36+
return arguments if isinstance(arguments, dict) else {}
37+
38+
2739
@action(is_system_action=True)
2840
async def validate_tool_parameters(tool_calls, context=None, **kwargs):
2941
tool_calls = tool_calls or (context.get("tool_calls", []) if context else [])
@@ -32,7 +44,7 @@ async def validate_tool_parameters(tool_calls, context=None, **kwargs):
3244

3345
for tool_call in tool_calls:
3446
func = tool_call.get("function", {})
35-
args = func.get("arguments", {})
47+
args = _tool_arguments(func)
3648
for param_value in args.values():
3749
if isinstance(param_value, str):
3850
if any(pattern.lower() in param_value.lower() for pattern in dangerous_patterns):
@@ -230,7 +242,7 @@ async def test_assistant_tool_calls_run_tool_output_rails_when_dialog_disabled()
230242
"type": "function",
231243
"function": {
232244
"name": "dangerous_tool",
233-
"arguments": {"param": "eval('malicious code')"},
245+
"arguments": '{"param": "eval(\'malicious code\')"}',
234246
},
235247
}
236248
],
@@ -242,3 +254,62 @@ async def test_assistant_tool_calls_run_tool_output_rails_when_dialog_disabled()
242254
assert isinstance(result, GenerationResponse)
243255
assert isinstance(result.response, list)
244256
assert "parameters may be unsafe" in result.response[0]["content"]
257+
258+
259+
@pytest.mark.asyncio
260+
async def test_approved_assistant_tool_calls_are_returned_when_dialog_disabled():
261+
config = RailsConfig.from_content(
262+
"""
263+
define subflow validate tool parameters
264+
$valid = execute validate_tool_parameters(tool_calls=$tool_calls)
265+
266+
if not $valid
267+
bot refuse dangerous tool parameters
268+
abort
269+
270+
define bot refuse dangerous tool parameters
271+
"I cannot execute this tool request because the parameters may be unsafe."
272+
""",
273+
"""
274+
models: []
275+
passthrough: true
276+
rails:
277+
tool_output:
278+
flows:
279+
- validate tool parameters
280+
""",
281+
)
282+
rails = LLMRails(config)
283+
rails.runtime.register_action(validate_tool_parameters, name="validate_tool_parameters")
284+
285+
messages = [
286+
{"role": "user", "content": "Use the requested tool"},
287+
{
288+
"role": "assistant",
289+
"content": "",
290+
"tool_calls": [
291+
{
292+
"id": "call_safe",
293+
"type": "function",
294+
"function": {
295+
"name": "safe_tool",
296+
"arguments": '{"param": "safe value"}',
297+
},
298+
}
299+
],
300+
},
301+
]
302+
303+
result = await rails.generate_async(messages=messages, options={"rails": {"dialog": False}})
304+
305+
assert isinstance(result, GenerationResponse)
306+
assert result.tool_calls == [
307+
{
308+
"id": "call_safe",
309+
"type": "function",
310+
"function": {
311+
"name": "safe_tool",
312+
"arguments": '{"param": "safe value"}',
313+
},
314+
}
315+
]

0 commit comments

Comments
 (0)