diff --git a/python/packages/core/agent_framework/_sessions.py b/python/packages/core/agent_framework/_sessions.py index fc6e395a20..c202e2cba4 100644 --- a/python/packages/core/agent_framework/_sessions.py +++ b/python/packages/core/agent_framework/_sessions.py @@ -28,7 +28,6 @@ import weakref from abc import abstractmethod from base64 import urlsafe_b64encode -from collections import deque from collections.abc import AsyncIterable, Awaitable, Callable, Iterable, Mapping, Sequence from dataclasses import dataclass from pathlib import Path @@ -826,42 +825,49 @@ def _is_approval_placeholder_result(content: Content) -> bool: def _approval_controls_to_keep(messages: Sequence[Message]) -> set[int]: + resolved_call_ids: set[str] = set() + response_ids: set[str] = set() + + for message in messages: + for content in message.contents: + if content.type == "function_approval_response": + if content.id is not None: + response_ids.add(content.id) + elif content.call_id is not None: + is_terminal_result = content.type == "function_result" and not _is_approval_placeholder_result(content) + is_follow_up_request = content.user_input_request and content.type not in { + "function_approval_request", + "function_approval_response", + } + if is_terminal_result or is_follow_up_request: + resolved_call_ids.add(content.call_id) + unresolved_requests_by_id: dict[str, Content] = {} unresolved_local_responses_by_id: dict[str, Content] = {} - local_response_ids_by_call_id: dict[str, deque[str]] = {} for message in messages: for content in message.contents: if content.type == "function_approval_request": function_call = content.function_call - if content.id is not None and function_call is not None and function_call.call_id is not None: + if ( + content.id is not None + and function_call is not None + and function_call.call_id is not None + and content.id not in response_ids + and function_call.call_id not in resolved_call_ids + ): unresolved_requests_by_id.setdefault(content.id, content) - continue - if content.type == "function_approval_response": + elif content.type == "function_approval_response": function_call = content.function_call - if content.id is not None: - unresolved_requests_by_id.pop(content.id, None) if ( content.id is not None and function_call is not None and function_call.call_id is not None and not function_call.additional_properties.get("server_label") + and function_call.call_id not in resolved_call_ids and content.id not in unresolved_local_responses_by_id ): unresolved_local_responses_by_id[content.id] = content - local_response_ids_by_call_id.setdefault(function_call.call_id, deque()).append(content.id) - continue - if content.call_id is None: - continue - is_terminal_result = content.type == "function_result" and not _is_approval_placeholder_result(content) - is_follow_up_request = content.user_input_request and content.type not in { - "function_approval_request", - "function_approval_response", - } - if not (is_terminal_result or is_follow_up_request): - continue - if response_ids := local_response_ids_by_call_id.get(content.call_id): - unresolved_local_responses_by_id.pop(response_ids.popleft(), None) return { id(content) for content in (*unresolved_requests_by_id.values(), *unresolved_local_responses_by_id.values()) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index ce9606d3de..6d9dc3cb88 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -2157,77 +2157,86 @@ def _collect_approval_responses( Hosted tool approvals (e.g. MCP) are excluded because they must be forwarded to the API as-is rather than processed locally. """ + resolved_call_ids: set[str] = set() + + # First pass: collect what resolves the responses (order-independent) + for message in messages: + for content in message.contents: + if content.call_id is not None: + is_terminal_result = content.type == "function_result" and not _is_approval_placeholder_result(content) + is_follow_up_request = content.user_input_request and content.type not in { + "function_approval_request", + "function_approval_response", + } + if is_terminal_result or is_follow_up_request: + resolved_call_ids.add(content.call_id) + + # Second pass: collect unresolved approval responses approval_responses: list[Content] = [] - pending_by_call_id: dict[str, deque[Content]] = {} - resolved_response_ids: set[int] = set() for message in messages: for content in message.contents: if content.type == "function_approval_response" and not _is_hosted_tool_approval(content): function_call = content.function_call if function_call is None or function_call.call_id is None: continue - approval_responses.append(content) - pending_by_call_id.setdefault(function_call.call_id, deque()).append(content) - continue - if content.call_id is None: - continue - is_terminal_result = content.type == "function_result" and not _is_approval_placeholder_result(content) - is_follow_up_request = content.user_input_request and content.type not in { - "function_approval_request", - "function_approval_response", - } - if not (is_terminal_result or is_follow_up_request): - continue - pending_responses = pending_by_call_id.get(content.call_id) - if pending_responses: - resolved_response_ids.add(id(pending_responses.popleft())) + if function_call.call_id not in resolved_call_ids: + approval_responses.append(content) - return { - content.id: content - for content in approval_responses - if id(content) not in resolved_response_ids and content.id is not None - } + return {content.id: content for content in approval_responses if content.id is not None} def _collect_unanswered_approval_requests(messages: Sequence[Message]) -> list[Content]: - approval_requests_by_id: dict[str, Content] = {} - pending_request_ids_by_call_id: dict[str, deque[str]] = {} answered_approval_ids: set[str] = set() + resolved_call_ids: set[str] = set() + # First pass: collect what resolves the requests (order-independent) + for message in messages: + for content in message.contents: + if content.type == "function_approval_response": + if content.id is not None: + answered_approval_ids.add(content.id) + elif content.call_id is not None: + is_terminal_result = content.type == "function_result" and not _is_approval_placeholder_result(content) + is_follow_up_request = content.user_input_request and content.type not in { + "function_approval_request", + "function_approval_response", + } + if is_terminal_result or is_follow_up_request: + resolved_call_ids.add(content.call_id) + + # Second pass: collect unanswered requests + approval_requests_by_id: dict[str, Content] = {} for message in messages: for content in message.contents: if content.type == "function_approval_request": function_call = content.function_call if content.id is None or function_call is None or function_call.call_id is None: continue + if content.id in answered_approval_ids or function_call.call_id in resolved_call_ids: + continue if content.id not in approval_requests_by_id: approval_requests_by_id[content.id] = content - pending_request_ids_by_call_id.setdefault(function_call.call_id, deque()).append(content.id) - continue - if content.type == "function_approval_response": - if content.id is not None: - answered_approval_ids.add(content.id) - continue - if content.call_id is None: - continue - is_terminal_result = content.type == "function_result" and not _is_approval_placeholder_result(content) - is_follow_up_request = content.user_input_request and content.type not in { - "function_approval_request", - "function_approval_response", - } - if not (is_terminal_result or is_follow_up_request): - continue - if request_ids := pending_request_ids_by_call_id.get(content.call_id): - answered_approval_ids.add(request_ids.popleft()) - return [ - request for approval_id, request in approval_requests_by_id.items() if approval_id not in answered_approval_ids - ] + return list(approval_requests_by_id.values()) def _remove_unanswered_approval_batches_from_model_input(messages: list[Message]) -> None: pending_requests = _collect_unanswered_approval_requests(messages) - if not pending_requests: + + # Collect resolved call_ids to strip resolved local approval responses so they don't leak to the API + resolved_call_ids: set[str] = set() + for message in messages: + for content in message.contents: + if content.call_id is not None: + is_terminal_result = content.type == "function_result" and not _is_approval_placeholder_result(content) + is_follow_up_request = content.user_input_request and content.type not in { + "function_approval_request", + "function_approval_response", + } + if is_terminal_result or is_follow_up_request: + resolved_call_ids.add(content.call_id) + + if not pending_requests and not resolved_call_ids: return pending_approval_ids = {request.id for request in pending_requests if request.id is not None} @@ -2306,6 +2315,12 @@ def _remove_unanswered_approval_batches_from_model_input(messages: list[Message] for content in message.contents if not ( (content.type == "function_approval_request" and content.id in pending_approval_ids) + or ( + content.type == "function_approval_response" + and not _is_hosted_tool_approval(content) + and content.function_call is not None + and content.function_call.call_id in resolved_call_ids + ) or ( message_index in call_batch_message_indices and ( diff --git a/python/packages/core/tests/core/test_sessions.py b/python/packages/core/tests/core/test_sessions.py index 5a229b2a41..8603f4fde6 100644 --- a/python/packages/core/tests/core/test_sessions.py +++ b/python/packages/core/tests/core/test_sessions.py @@ -439,6 +439,88 @@ def test_filter_approval_controls_keeps_response_for_pending_placeholder() -> No assert any(placeholder in message.contents for message in filtered) +def test_filter_approval_controls_resolves_when_result_precedes_response() -> None: + """when a terminal function_result appears BEFORE the approval response + + (the 'approval-resume' layout), the two-pass approach must still + correctly identify the approval as resolved and filter it out. + """ + function_call = Content.from_function_call(call_id="call_resume", name="guarded", arguments="{}") + request = Content.from_function_approval_request(id="approval_resume", function_call=function_call) + response = request.to_function_approval_response(approved=True) + result = Content.from_function_result(call_id="call_resume", result="completed successfully") + + filtered = _filter_approval_control_messages([ + Message(role="assistant", contents=[function_call, request]), + Message(role="tool", contents=[result, response]), + ]) + + controls = [ + content + for message in filtered + for content in message.contents + if content.type in {"function_approval_request", "function_approval_response"} + ] + assert controls == [] + + +def test_filter_approval_controls_follow_up_does_not_resolve_approval_controls() -> None: + """Follow-up requests do NOT resolve approval controls in session history filtering. + + _approval_controls_to_keep resolves requests via matching function_approval_response + (not via follow-up requests). The request is removed because a response exists, + but the response itself is preserved since no terminal result has arrived. + Follow-up requests have no effect on this filtering logic. + """ + function_call = Content.from_function_call(call_id="call_followup", name="guarded", arguments="{}") + request = Content.from_function_approval_request(id="approval_followup", function_call=function_call) + response = request.to_function_approval_response(approved=True) + + follow_up = Content.from_text("Please provide more details") + follow_up.user_input_request = True + + filtered = _filter_approval_control_messages([ + Message(role="assistant", contents=[function_call, request]), + Message(role="user", contents=[response]), + Message(role="user", contents=[follow_up]), + ]) + + controls = [ + content + for message in filtered + for content in message.contents + if content.type in {"function_approval_request", "function_approval_response"} + ] + # Request is resolved by the matching response: removed + # Response is kept because no terminal result exists: preserved + # Follow-up has no effect on either decision + assert len(controls) == 1 + assert controls[0].type == "function_approval_response" + assert controls[0].id == "approval_followup" + + +def test_filter_approval_controls_preserves_unresolved_across_messages() -> None: + """Ensures unresolved approvals survive when no terminal result exists.""" + function_call = Content.from_function_call(call_id="call_pending", name="guarded", arguments="{}") + request = Content.from_function_approval_request(id="approval_pending", function_call=function_call) + response = request.to_function_approval_response(approved=True) + + filtered = _filter_approval_control_messages([ + Message(role="assistant", contents=[function_call, request]), + Message(role="user", contents=[response]), + ]) + + controls = [ + content + for message in filtered + for content in message.contents + if content.type in {"function_approval_request", "function_approval_response"} + ] + assert len(controls) == 1 + assert controls[0].type == "function_approval_response" + assert controls[0].id == "approval_pending" + + class TestHistoryProviderBase: def test_default_flags(self) -> None: provider = ConcreteHistoryProvider("mem") diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index a24e20cacd..95b5c5b73b 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -13,13 +13,17 @@ SKIP_PARSING, Content, FunctionTool, + Message, tool, ) from agent_framework._middleware import FunctionInvocationContext from agent_framework._tools import ( _auto_invoke_function, + _collect_approval_responses, + _collect_unanswered_approval_requests, _parse_annotation, _parse_inputs, + _remove_unanswered_approval_batches_from_model_input, normalize_function_invocation_configuration, ) from agent_framework.observability import OtelAttr @@ -1547,3 +1551,114 @@ def test_skip_parsing_is_singleton() -> None: # endregion + +# region Approval collection and filtering regression tests + + +def test_collect_approval_responses_order_independent_result_first() -> None: + """Result before response must still mark as resolved.""" + call = Content.from_function_call(call_id="c1", name="t", arguments="{}") + req = Content.from_function_approval_request(id="a1", function_call=call) + resp = req.to_function_approval_response(approved=True) + result = Content.from_function_result(call_id="c1", result="done") + + messages = [Message(role="tool", contents=[result, resp])] + collected = _collect_approval_responses(messages) + + assert collected == {} + + +def test_collect_approval_responses_follow_up_does_not_suppress_response() -> None: + """An unrelated user-input request without a call_id does not resolve approval responses. + + `_collect_approval_responses` only treats an approval response as resolved when a terminal + `function_result` or a follow-up `user_input_request` with the same `call_id` exists. + """ + call = Content.from_function_call(call_id="c2", name="t", arguments="{}") + req = Content.from_function_approval_request(id="a2", function_call=call) + resp = req.to_function_approval_response(approved=True) + follow_up = Content.from_text("more info needed") + follow_up.user_input_request = True + + messages = [ + Message(role="assistant", contents=[call, req]), + Message(role="user", contents=[resp]), + Message(role="user", contents=[follow_up]), + ] + collected = _collect_approval_responses(messages) + + # Response is still included because no terminal result exists + assert "a2" in collected + assert collected["a2"].type == "function_approval_response" + + +def test_collect_unanswered_requests_order_independent_result_first() -> None: + """Unanswered check is order-independent.""" + call = Content.from_function_call(call_id="c3", name="t", arguments="{}") + req = Content.from_function_approval_request(id="a3", function_call=call) + result = Content.from_function_result(call_id="c3", result="done") + + # Terminal result before request in history + messages = [ + Message(role="tool", contents=[result]), + Message(role="assistant", contents=[call, req]), + ] + unanswered = _collect_unanswered_approval_requests(messages) + + assert unanswered == [] + + +def test_remove_unanswered_batches_strips_resolved_local_responses() -> None: + """Resolved local approval responses are stripped from model input. + + The corresponding answered request is preserved for model context. + Only the local response is removed to prevent MCP serialization leaks. + """ + call = Content.from_function_call(call_id="c4", name="local_tool", arguments="{}") + req = Content.from_function_approval_request(id="a4", function_call=call) + resp = req.to_function_approval_response(approved=True) + result = Content.from_function_result(call_id="c4", result="ok") + + messages = [ + Message(role="assistant", contents=[call, req]), + Message(role="user", contents=[resp]), + Message(role="tool", contents=[result]), + ] + + _remove_unanswered_approval_batches_from_model_input(messages) + + remaining_controls = [ + c for m in messages for c in m.contents if c.type in {"function_approval_request", "function_approval_response"} + ] + # Local response is stripped; answered request is preserved for context + assert len(remaining_controls) == 1 + assert remaining_controls[0].type == "function_approval_request" + assert remaining_controls[0].id == "a4" + + +def test_remove_unanswered_batches_preserves_hosted_responses() -> None: + """Hosted (MCP) approval responses are never stripped by this function.""" + hosted_call = Content.from_function_call( + call_id="mcp1", + name="hosted", + arguments="{}", + additional_properties={"server_label": "srv"}, + ) + hosted_req = Content.from_function_approval_request(id="mcp_a1", function_call=hosted_call) + hosted_resp = hosted_req.to_function_approval_response(approved=True) + result = Content.from_function_result(call_id="mcp1", result="ok") + + messages = [ + Message(role="assistant", contents=[hosted_call, hosted_req]), + Message(role="user", contents=[hosted_resp]), + Message(role="tool", contents=[result]), + ] + + _remove_unanswered_approval_batches_from_model_input(messages) + + remaining = [c for m in messages for c in m.contents if c.type == "function_approval_response"] + assert len(remaining) == 1 + assert remaining[0].id == "mcp_a1" + + +# endregion diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 14efdcdca9..8ba795b612 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -45,6 +45,7 @@ FunctionInvocationLayer, FunctionTool, ToolTypes, + _is_hosted_tool_approval, normalize_tools, tool, ) @@ -1953,16 +1954,18 @@ def _prepare_content_for_openai( "output": output, } case "function_approval_request": + if not _is_hosted_tool_approval(content): + return {} return { "type": "mcp_approval_request", "id": content.id, "arguments": content.function_call.arguments, # type: ignore[union-attr] "name": content.function_call.name, # type: ignore[union-attr] - "server_label": content.function_call.additional_properties.get("server_label") # type: ignore[union-attr] - if content.function_call.additional_properties # type: ignore[union-attr] - else None, + "server_label": content.function_call.additional_properties.get("server_label"), # type: ignore[union-attr] } case "function_approval_response": + if not _is_hosted_tool_approval(content): + return {} return { "type": "mcp_approval_response", "approval_request_id": content.id, diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index d52157842e..42027822c2 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -2665,9 +2665,7 @@ def test_prepare_content_for_opentool_approval_response() -> None: result = client._prepare_content_for_openai("assistant", approval_response) - assert result["type"] == "mcp_approval_response" - assert result["approval_request_id"] == "approval_001" - assert result["approve"] is True + assert result == {} def test_prepare_content_for_openai_error_content() -> None: @@ -3285,11 +3283,7 @@ def test_prepare_message_for_openai_with_function_approval_response() -> None: result = client._prepare_message_for_openai(message, request_uses_service_side_storage=False) # FunctionApprovalResponseContent is added directly, not nested in args with role - assert len(result) == 1 - prepared_message = result[0] - assert prepared_message["type"] == "mcp_approval_response" - assert prepared_message["approval_request_id"] == "approval_003" - assert prepared_message["approve"] is True + assert len(result) == 0 def test_prepare_messages_for_openai_keeps_active_function_call_for_tool_loop() -> None: @@ -3792,11 +3786,14 @@ def test_function_approval_response_with_mcp_tool_call() -> None: """Test function approval response content with MCP server tool call content.""" client = OpenAIChatClient(model="test-model", api_key="test-key") - mcp_call = Content.from_mcp_server_tool_call( + # from_mcp_server_tool_call does NOT set server_label in additional_properties, + # so we must construct a hosted function call explicitly for _is_hosted_tool_approval + # to recognize it as hosted. + mcp_call = Content.from_function_call( call_id="mcp_call_999", - tool_name="sensitive_action", - server_name="SecureServer", - arguments={"action": "delete"}, + name="sensitive_action", + arguments='{"action": "delete"}', + additional_properties={"server_label": "SecureServer"}, ) approval_response = Content.from_function_approval_response( @@ -7833,13 +7830,18 @@ def test_prepare_messages_keeps_function_call_without_storage() -> None: @pytest.mark.parametrize("approved", [True, False], ids=["approved", "rejected"]) def test_prepare_messages_strips_approval_request_but_keeps_response_under_storage(approved: bool) -> None: - """Stored requests are not replayed, but the new approval decision must reach the service.""" + """Under service-side storage, both approval request and response are suppressed. + + When storage is off, hosted (MCP) approvals are serialized normally. + Local approvals are always dropped regardless of storage setting. + """ client = OpenAIChatClient(model="test-model", api_key="test-key") function_call = Content.from_function_call( call_id="mcp_1", name="sensitive_action", arguments='{"action": "delete"}', + additional_properties={"server_label": "test_server"}, ) approval_request = Content.from_function_approval_request( id="approval_req_1", @@ -7855,6 +7857,7 @@ def test_prepare_messages_strips_approval_request_but_keeps_response_under_stora Message(role="user", contents=[approval_response]), ] + # Storage ON: request suppressed, response kept storage_on = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=True) storage_on_types = [item.get("type") for item in storage_on] assert "mcp_approval_request" not in storage_on_types @@ -7862,6 +7865,7 @@ def test_prepare_messages_strips_approval_request_but_keeps_response_under_stora assert storage_on[0]["approval_request_id"] == "approval_req_1" assert storage_on[0]["approve"] is approved + # Storage OFF: hosted approvals are serialized normally storage_off = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False) storage_off_types = [item.get("type") for item in storage_off] assert "mcp_approval_request" in storage_off_types @@ -7974,9 +7978,6 @@ def test_prepare_messages_strips_mcp_items_under_storage() -> None: # endregion -# endregion - - # region Prompt cache breakpoints and options @@ -8056,3 +8057,108 @@ async def test_prepare_options_prompt_cache_options_guarded_on_old_openai(monkey # endregion + +# region Approval serialization regression tests + + +def test_prepare_content_drops_local_approval_request() -> None: + """Local tool approval requests must not be serialized as mcp_approval_request.""" + client = RawOpenAIChatClient("gpt-4o-mini", api_key="sk-test") + local_call = Content.from_function_call(call_id="local_1", name="read_file", arguments="{}") + local_request = Content.from_function_approval_request(id="a1", function_call=local_call) + + result = client._prepare_content_for_openai("assistant", local_request) + + # Local requests return empty dict, which upstream filters out via `if prepared:` + assert result == {} + + +def test_prepare_content_drops_local_approval_response() -> None: + """Local tool approval responses must not be serialized as mcp_approval_response. + + without the hosted-tool guard, local approvals + were emitted as mcp_approval_response with no matching request 400 from API. + """ + client = RawOpenAIChatClient("gpt-4o-mini", api_key="sk-test") + local_call = Content.from_function_call(call_id="local_2", name="read_file", arguments="{}") + local_response = Content.from_function_approval_response(approved=True, id="a2", function_call=local_call) + + result = client._prepare_content_for_openai("user", local_response) + + assert result == {} + + +def test_prepare_content_serializes_hosted_approval_request() -> None: + """Hosted (MCP) approval requests ARE serialized with server_label.""" + client = RawOpenAIChatClient("gpt-4o-mini", api_key="sk-test") + hosted_call = Content.from_function_call( + call_id="mcp_1", + name="hosted_tool", + arguments='{"x": 1}', + additional_properties={"server_label": "my_server"}, + ) + hosted_request = Content.from_function_approval_request(id="mcp_a1", function_call=hosted_call) + + result = client._prepare_content_for_openai("assistant", hosted_request) + + assert result["type"] == "mcp_approval_request" + assert result["id"] == "mcp_a1" + assert result["server_label"] == "my_server" + assert result["name"] == "hosted_tool" + + +def test_prepare_content_serializes_hosted_approval_response() -> None: + """Hosted (MCP) approval responses ARE serialized normally.""" + client = RawOpenAIChatClient("gpt-4o-mini", api_key="sk-test") + hosted_call = Content.from_function_call( + call_id="mcp_2", + name="hosted_tool", + arguments="{}", + additional_properties={"server_label": "my_server"}, + ) + hosted_response = Content.from_function_approval_response(approved=True, id="mcp_a2", function_call=hosted_call) + + result = client._prepare_content_for_openai("user", hosted_response) + + assert result["type"] == "mcp_approval_response" + assert result["approval_request_id"] == "mcp_a2" + assert result["approve"] is True + + +def test_prepare_messages_stores_suppresses_request_but_keeps_response() -> None: + """Under service-side storage, approval request is suppressed but response is kept. + + The response must reach the service so the decision is recorded. + mcp_approval_response 400 from API. + """ + client = RawOpenAIChatClient("gpt-4o-mini", api_key="sk-test") + hosted_call = Content.from_function_call( + call_id="mcp_3", + name="hosted_tool", + arguments="{}", + additional_properties={"server_label": "srv"}, + ) + hosted_request = Content.from_function_approval_request(id="mcp_a3", function_call=hosted_call) + hosted_response = Content.from_function_approval_response(approved=True, id="mcp_a3", function_call=hosted_call) + + messages = [ + Message(role="assistant", contents=[hosted_request]), + Message(role="user", contents=[hosted_response]), + ] + + # Simulate service-side storage being active + prepared_messages: list[dict] = [] + for message in messages: + for content in message.contents: + if content.type == "function_approval_request": + continue + prepared = client._prepare_content_for_openai(message.role, content) + if prepared: + prepared_messages.append(prepared) + + approval_types = {m.get("type") for m in prepared_messages} + assert "mcp_approval_request" not in approval_types + assert "mcp_approval_response" in approval_types + + +# endregion