chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
"""Tests for tool approval.
|
||||
|
||||
Covers require_approval flag, check_pause for approval, the handler
|
||||
pause/resume flow, and gen_continuation with approved/denied actions.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from application.agents.tool_executor import ToolExecutor
|
||||
from application.llm.handlers.base import LLMHandler, LLMResponse, ToolCall
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_pause with require_approval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCheckPauseApproval:
|
||||
|
||||
def _make_call(self, name="action_0", call_id="c1"):
|
||||
call = Mock()
|
||||
call.name = name
|
||||
call.id = call_id
|
||||
call.arguments = "{}"
|
||||
call.thought_signature = None
|
||||
return call
|
||||
|
||||
def test_approval_required_triggers_pause(self):
|
||||
executor = ToolExecutor()
|
||||
tools_dict = {
|
||||
"0": {
|
||||
"name": "telegram",
|
||||
"actions": [
|
||||
{
|
||||
"name": "send_msg",
|
||||
"active": True,
|
||||
"require_approval": True,
|
||||
"parameters": {},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
call = self._make_call(name="send_msg_0")
|
||||
result = executor.check_pause(tools_dict, call, "OpenAILLM")
|
||||
|
||||
assert result is not None
|
||||
assert result["pause_type"] == "awaiting_approval"
|
||||
assert result["tool_name"] == "telegram"
|
||||
assert result["action_name"] == "send_msg"
|
||||
assert result["tool_id"] == "0"
|
||||
|
||||
def test_approval_not_required_no_pause(self):
|
||||
executor = ToolExecutor()
|
||||
tools_dict = {
|
||||
"0": {
|
||||
"name": "brave",
|
||||
"actions": [
|
||||
{
|
||||
"name": "search",
|
||||
"active": True,
|
||||
"require_approval": False,
|
||||
"parameters": {},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
call = self._make_call(name="search_0")
|
||||
result = executor.check_pause(tools_dict, call, "OpenAILLM")
|
||||
assert result is None
|
||||
|
||||
def test_approval_absent_defaults_to_false(self):
|
||||
executor = ToolExecutor()
|
||||
tools_dict = {
|
||||
"0": {
|
||||
"name": "brave",
|
||||
"actions": [
|
||||
{
|
||||
"name": "search",
|
||||
"active": True,
|
||||
"parameters": {},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
call = self._make_call(name="search_0")
|
||||
result = executor.check_pause(tools_dict, call, "OpenAILLM")
|
||||
assert result is None
|
||||
|
||||
def test_api_tool_approval(self):
|
||||
executor = ToolExecutor()
|
||||
tools_dict = {
|
||||
"0": {
|
||||
"name": "api_tool",
|
||||
"config": {
|
||||
"actions": {
|
||||
"delete_user": {
|
||||
"name": "delete_user",
|
||||
"require_approval": True,
|
||||
"url": "http://example.com",
|
||||
"method": "DELETE",
|
||||
"active": True,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
call = self._make_call(name="delete_user_0")
|
||||
result = executor.check_pause(tools_dict, call, "OpenAILLM")
|
||||
assert result is not None
|
||||
assert result["pause_type"] == "awaiting_approval"
|
||||
|
||||
def test_api_tool_no_approval(self):
|
||||
executor = ToolExecutor()
|
||||
tools_dict = {
|
||||
"0": {
|
||||
"name": "api_tool",
|
||||
"config": {
|
||||
"actions": {
|
||||
"list_users": {
|
||||
"name": "list_users",
|
||||
"url": "http://example.com",
|
||||
"method": "GET",
|
||||
"active": True,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
call = self._make_call(name="list_users_0")
|
||||
result = executor.check_pause(tools_dict, call, "OpenAILLM")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler: approval tool causes pause signal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ConcreteHandler(LLMHandler):
|
||||
def parse_response(self, response):
|
||||
return LLMResponse(
|
||||
content=str(response), tool_calls=[], finish_reason="stop",
|
||||
raw_response=response,
|
||||
)
|
||||
|
||||
def create_tool_message(self, tool_call, result):
|
||||
import json as _json
|
||||
content = _json.dumps(result) if not isinstance(result, str) else result
|
||||
return {"role": "tool", "tool_call_id": tool_call.id, "content": content}
|
||||
|
||||
def _iterate_stream(self, response):
|
||||
for chunk in response:
|
||||
yield chunk
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHandlerApprovalPause:
|
||||
|
||||
def _make_agent(self, pause_return):
|
||||
agent = Mock()
|
||||
agent._check_context_limit = Mock(return_value=False)
|
||||
agent.context_limit_reached = False
|
||||
agent.llm.__class__.__name__ = "MockLLM"
|
||||
agent.tool_executor.check_pause = Mock(return_value=pause_return)
|
||||
|
||||
def fake_execute(tools_dict, call):
|
||||
yield {"type": "tool_call", "data": {"status": "pending"}}
|
||||
return ("tool result", call.id)
|
||||
|
||||
agent._execute_tool_action = Mock(side_effect=fake_execute)
|
||||
return agent
|
||||
|
||||
def test_approval_tool_pauses(self):
|
||||
handler = ConcreteHandler()
|
||||
pause_info = {
|
||||
"call_id": "c1",
|
||||
"name": "send_msg_0",
|
||||
"tool_name": "telegram",
|
||||
"tool_id": "0",
|
||||
"action_name": "send_msg",
|
||||
"arguments": {"text": "hello"},
|
||||
"pause_type": "awaiting_approval",
|
||||
"thought_signature": None,
|
||||
}
|
||||
agent = self._make_agent(pause_info)
|
||||
|
||||
call = ToolCall(id="c1", name="send_msg_0", arguments='{"text": "hello"}')
|
||||
gen = handler.handle_tool_calls(
|
||||
agent, [call], {"0": {"name": "telegram"}}, []
|
||||
)
|
||||
|
||||
events = []
|
||||
pending = None
|
||||
try:
|
||||
while True:
|
||||
events.append(next(gen))
|
||||
except StopIteration as e:
|
||||
messages, pending = e.value
|
||||
|
||||
assert pending is not None
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["pause_type"] == "awaiting_approval"
|
||||
|
||||
# Should NOT have executed the tool
|
||||
assert agent._execute_tool_action.call_count == 0
|
||||
|
||||
# Should have yielded awaiting_approval status
|
||||
approval_events = [
|
||||
e for e in events
|
||||
if e.get("type") == "tool_call"
|
||||
and e.get("data", {}).get("status") == "awaiting_approval"
|
||||
]
|
||||
assert len(approval_events) == 1
|
||||
|
||||
def test_mixed_normal_and_approval(self):
|
||||
"""First tool runs normally, second needs approval."""
|
||||
handler = ConcreteHandler()
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def selective_pause(tools_dict, call, llm_class):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 2:
|
||||
return {
|
||||
"call_id": "c2",
|
||||
"name": "send_msg_0",
|
||||
"tool_name": "telegram",
|
||||
"tool_id": "0",
|
||||
"action_name": "send_msg",
|
||||
"arguments": {},
|
||||
"pause_type": "awaiting_approval",
|
||||
"thought_signature": None,
|
||||
}
|
||||
return None
|
||||
|
||||
agent = self._make_agent(None)
|
||||
agent.tool_executor.check_pause = Mock(side_effect=selective_pause)
|
||||
|
||||
calls = [
|
||||
ToolCall(id="c1", name="search_0", arguments="{}"),
|
||||
ToolCall(id="c2", name="send_msg_0", arguments="{}"),
|
||||
]
|
||||
|
||||
gen = handler.handle_tool_calls(
|
||||
agent, calls, {"0": {"name": "multi"}}, []
|
||||
)
|
||||
|
||||
events = []
|
||||
try:
|
||||
while True:
|
||||
events.append(next(gen))
|
||||
except StopIteration as e:
|
||||
messages, pending = e.value
|
||||
|
||||
# First tool executed
|
||||
assert agent._execute_tool_action.call_count == 1
|
||||
# Second tool is pending
|
||||
assert pending is not None
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["call_id"] == "c2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# gen_continuation: approval and denial flows
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGenContinuationApproval:
|
||||
|
||||
def _make_agent(self):
|
||||
from application.agents.classic_agent import ClassicAgent
|
||||
|
||||
mock_llm = Mock()
|
||||
mock_llm._supports_tools = True
|
||||
mock_llm.gen_stream = Mock(return_value=iter(["Answer"]))
|
||||
mock_llm._supports_structured_output = Mock(return_value=False)
|
||||
mock_llm.__class__.__name__ = "MockLLM"
|
||||
|
||||
mock_handler = Mock()
|
||||
mock_handler.process_message_flow = Mock(return_value=iter([]))
|
||||
mock_handler.create_tool_message = Mock(
|
||||
return_value={"role": "tool", "tool_call_id": "c1", "content": "result"}
|
||||
)
|
||||
|
||||
mock_executor = Mock()
|
||||
mock_executor.tool_calls = []
|
||||
mock_executor.prepare_tools_for_llm = Mock(return_value=[])
|
||||
mock_executor.get_truncated_tool_calls = Mock(return_value=[])
|
||||
|
||||
def fake_execute(tools_dict, call, llm_class):
|
||||
yield {"type": "tool_call", "data": {"status": "pending"}}
|
||||
return ("executed_result", "c1")
|
||||
|
||||
mock_executor.execute = Mock(side_effect=fake_execute)
|
||||
|
||||
agent = ClassicAgent(
|
||||
endpoint="stream",
|
||||
llm_name="openai",
|
||||
model_id="gpt-4",
|
||||
api_key="test",
|
||||
llm=mock_llm,
|
||||
llm_handler=mock_handler,
|
||||
tool_executor=mock_executor,
|
||||
)
|
||||
return agent, mock_executor, mock_handler
|
||||
|
||||
def test_approved_tool_executes(self):
|
||||
agent, mock_executor, mock_handler = self._make_agent()
|
||||
|
||||
messages = [{"role": "system", "content": "test"}]
|
||||
pending = [
|
||||
{
|
||||
"call_id": "c1",
|
||||
"name": "send_msg_0",
|
||||
"tool_name": "telegram",
|
||||
"tool_id": "0",
|
||||
"action_name": "send_msg",
|
||||
"arguments": {"text": "hello"},
|
||||
"pause_type": "awaiting_approval",
|
||||
"thought_signature": None,
|
||||
}
|
||||
]
|
||||
tool_actions = [{"call_id": "c1", "decision": "approved"}]
|
||||
|
||||
list(agent.gen_continuation(
|
||||
messages, {"0": {"name": "telegram"}}, pending, tool_actions
|
||||
))
|
||||
|
||||
# Tool should have been executed
|
||||
assert mock_executor.execute.called
|
||||
|
||||
def test_denied_tool_sends_denial_to_llm(self):
|
||||
agent, mock_executor, mock_handler = self._make_agent()
|
||||
|
||||
messages = [{"role": "system", "content": "test"}]
|
||||
pending = [
|
||||
{
|
||||
"call_id": "c1",
|
||||
"name": "send_msg_0",
|
||||
"tool_name": "telegram",
|
||||
"tool_id": "0",
|
||||
"action_name": "send_msg",
|
||||
"arguments": {},
|
||||
"pause_type": "awaiting_approval",
|
||||
"thought_signature": None,
|
||||
}
|
||||
]
|
||||
tool_actions = [
|
||||
{"call_id": "c1", "decision": "denied", "comment": "not safe"},
|
||||
]
|
||||
|
||||
events = list(agent.gen_continuation(
|
||||
messages, {"0": {"name": "telegram"}}, pending, tool_actions
|
||||
))
|
||||
|
||||
# Tool should NOT have been executed
|
||||
assert not mock_executor.execute.called
|
||||
|
||||
# Should have a denied event
|
||||
denied = [
|
||||
e for e in events
|
||||
if isinstance(e, dict)
|
||||
and e.get("type") == "tool_call"
|
||||
and e.get("data", {}).get("status") == "denied"
|
||||
]
|
||||
assert len(denied) == 1
|
||||
|
||||
# create_tool_message should have been called with denial text
|
||||
denial_text = mock_handler.create_tool_message.call_args[0][1]
|
||||
assert "denied" in denial_text.lower()
|
||||
assert "not safe" in denial_text
|
||||
|
||||
def test_denied_without_comment(self):
|
||||
agent, mock_executor, mock_handler = self._make_agent()
|
||||
|
||||
messages = [{"role": "system", "content": "test"}]
|
||||
pending = [
|
||||
{
|
||||
"call_id": "c1",
|
||||
"name": "act_0",
|
||||
"tool_name": "tool",
|
||||
"tool_id": "0",
|
||||
"action_name": "act",
|
||||
"arguments": {},
|
||||
"pause_type": "awaiting_approval",
|
||||
"thought_signature": None,
|
||||
}
|
||||
]
|
||||
tool_actions = [{"call_id": "c1", "decision": "denied"}]
|
||||
|
||||
list(agent.gen_continuation(
|
||||
messages, {"0": {"name": "tool"}}, pending, tool_actions
|
||||
))
|
||||
|
||||
denial_text = mock_handler.create_tool_message.call_args[0][1]
|
||||
assert "denied" in denial_text.lower()
|
||||
|
||||
def test_mixed_approve_deny_batch(self):
|
||||
"""Two tools: one approved, one denied."""
|
||||
agent, mock_executor, mock_handler = self._make_agent()
|
||||
|
||||
messages = [{"role": "system", "content": "test"}]
|
||||
pending = [
|
||||
{
|
||||
"call_id": "c1",
|
||||
"name": "safe_0",
|
||||
"tool_name": "safe",
|
||||
"tool_id": "0",
|
||||
"action_name": "safe",
|
||||
"arguments": {},
|
||||
"pause_type": "awaiting_approval",
|
||||
"thought_signature": None,
|
||||
},
|
||||
{
|
||||
"call_id": "c2",
|
||||
"name": "danger_0",
|
||||
"tool_name": "danger",
|
||||
"tool_id": "0",
|
||||
"action_name": "danger",
|
||||
"arguments": {},
|
||||
"pause_type": "awaiting_approval",
|
||||
"thought_signature": None,
|
||||
},
|
||||
]
|
||||
tool_actions = [
|
||||
{"call_id": "c1", "decision": "approved"},
|
||||
{"call_id": "c2", "decision": "denied", "comment": "too risky"},
|
||||
]
|
||||
|
||||
events = list(agent.gen_continuation(
|
||||
messages, {"0": {"name": "multi"}}, pending, tool_actions
|
||||
))
|
||||
|
||||
# First tool executed, second denied
|
||||
assert mock_executor.execute.call_count == 1
|
||||
|
||||
denied = [
|
||||
e for e in events
|
||||
if isinstance(e, dict)
|
||||
and e.get("type") == "tool_call"
|
||||
and e.get("data", {}).get("status") == "denied"
|
||||
]
|
||||
assert len(denied) == 1
|
||||
|
||||
def test_missing_action_defaults_to_denial(self):
|
||||
"""If client doesn't respond for a pending tool, treat as denied."""
|
||||
agent, mock_executor, mock_handler = self._make_agent()
|
||||
|
||||
messages = [{"role": "system", "content": "test"}]
|
||||
pending = [
|
||||
{
|
||||
"call_id": "c1",
|
||||
"name": "act_0",
|
||||
"tool_name": "tool",
|
||||
"tool_id": "0",
|
||||
"action_name": "act",
|
||||
"arguments": {},
|
||||
"pause_type": "awaiting_approval",
|
||||
"thought_signature": None,
|
||||
}
|
||||
]
|
||||
# Empty tool_actions — no response for c1
|
||||
tool_actions = []
|
||||
|
||||
events = list(agent.gen_continuation(
|
||||
messages, {"0": {"name": "tool"}}, pending, tool_actions
|
||||
))
|
||||
|
||||
# Should have been treated as denied
|
||||
assert not mock_executor.execute.called
|
||||
denied = [
|
||||
e for e in events
|
||||
if isinstance(e, dict)
|
||||
and e.get("type") == "tool_call"
|
||||
and e.get("data", {}).get("status") == "denied"
|
||||
]
|
||||
assert len(denied) == 1
|
||||
Reference in New Issue
Block a user