From a9aab662dbd371633e2df1bc385a26952b92bbf2 Mon Sep 17 00:00:00 2001 From: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:54:56 +0530 Subject: [PATCH 1/2] Python: fix(openai): route unmarked shell call to registered local executor When self hosted Foundry agents emit shell call output items, the environment property is omitted rather than explicitly marked local. Because the local environment check required an explicit local type marker, unmarked calls were misclassified as hosted shell items and skipped by the registered executor. Allow unmarked shell calls to execute via the registered local shell tool when an environment is omitted. Keep explicit non local container environments routed to hosted shell content. Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> --- .../agent_framework_openai/_chat_client.py | 10 +- .../tests/openai/test_openai_chat_client.py | 127 +++++++++++++++++- 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 6ead6a55895..9a2428365f9 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -2405,7 +2405,15 @@ def _shell_item_to_contents(self, item: Any, local_shell_tool_name: str | None) ) shell_timeout_ms = getattr(action, "timeout_ms", None) shell_max_output = getattr(action, "max_output_length", None) - is_local_environment = getattr(getattr(item, "environment", None), "type", None) == "local" + item_environment = getattr(item, "environment", None) + env_type = ( + item_environment.get("type") + if isinstance(item_environment, Mapping) + else getattr(item_environment, "type", None) + ) + is_local_environment = env_type == "local" or ( + item_environment is None and local_shell_tool_name is not None + ) if ( local_shell_tool_name and is_local_environment 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 ea0cdb49113..b98a648e26d 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -2817,11 +2817,14 @@ def local_exec(command: str) -> str: mock_action.timeout_ms = 60000 mock_action.max_output_length = 4096 + mock_environment = MagicMock() + mock_environment.type = "container_reference" + mock_shell_call = MagicMock() mock_shell_call.type = "shell_call" mock_shell_call.call_id = "shell-call-1" mock_shell_call.action = mock_action - mock_shell_call.environment = None + mock_shell_call.environment = mock_environment mock_shell_call.status = "completed" mock_response.output = [mock_shell_call] @@ -2838,6 +2841,89 @@ def local_exec(command: str) -> str: assert call_content.status == "completed" +def test_foundry_shell_call_without_environment_uses_registered_local_executor() -> None: + """An unmarked shell call executes locally when a local shell executor is registered.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + def local_exec(command: str) -> str: + return command + + local_shell_tool = OpenAIChatClient.get_shell_tool(func=local_exec) + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.status = "completed" + mock_response.incomplete = None + + mock_action = MagicMock() + mock_action.commands = ["ls -la", "pwd"] + mock_action.timeout_ms = 60000 + mock_action.max_output_length = 4096 + + mock_shell_call = MagicMock() + mock_shell_call.type = "shell_call" + mock_shell_call.id = "shell-item-1" + mock_shell_call.call_id = "shell-call-1" + mock_shell_call.action = mock_action + mock_shell_call.environment = None + mock_shell_call.status = "completed" + + mock_response.output = [mock_shell_call] + + response = client._parse_response_from_openai(mock_response, options={"tools": [local_shell_tool]}) # type: ignore[arg-type] + + assert len(response.messages[0].contents) == 1 + call_content = response.messages[0].contents[0] + assert call_content.type == "function_call" + assert call_content.call_id == "shell-call-1" + assert call_content.name == local_shell_tool.name + assert call_content.parse_arguments() == {"command": "ls -la\npwd"} + assert call_content.informational_only is False + + +def test_foundry_shell_call_without_environment_without_local_executor_remains_hosted() -> None: + """An unmarked shell call remains hosted informational content when no local executor is registered.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.status = "completed" + mock_response.incomplete = None + + mock_action = MagicMock() + mock_action.commands = ["ls -la", "pwd"] + mock_action.timeout_ms = 60000 + mock_action.max_output_length = 4096 + + mock_shell_call = MagicMock() + mock_shell_call.type = "shell_call" + mock_shell_call.id = "shell-item-1" + mock_shell_call.call_id = "shell-call-1" + mock_shell_call.action = mock_action + mock_shell_call.environment = None + mock_shell_call.status = "completed" + + mock_response.output = [mock_shell_call] + + response = client._parse_response_from_openai(mock_response, options={}) # type: ignore[arg-type] + + assert len(response.messages[0].contents) == 1 + call_content = response.messages[0].contents[0] + assert call_content.type == "shell_tool_call" + assert call_content.call_id == "shell-call-1" + assert call_content.commands == ["ls -la", "pwd"] + + def test_response_content_creation_with_shell_call_output() -> None: """Test _parse_response_from_openai with shell_call_output output.""" client = OpenAIChatClient(model="test-model", api_key="test-key") @@ -3779,6 +3865,45 @@ def local_exec(command: str) -> str: assert call_content.additional_properties["openai.responses.shell.output_type"] == "shell_call_output" +def test_parse_chunk_from_openai_shell_call_without_environment_emits_command() -> None: + """An unmarked completed shell call emits an executable function call when local executor is registered.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + def local_exec(command: str) -> str: + return command + + local_shell_tool = OpenAIChatClient.get_shell_tool(func=local_exec, approval_mode="never_require") + function_call_ids: dict[int, tuple[str, str]] = {} + + mock_action = MagicMock() + mock_action.commands = ["python --version"] + mock_action.timeout_ms = 30000 + + mock_item = MagicMock() + mock_item.type = "shell_call" + mock_item.id = "local-shell-item-1" + mock_item.call_id = "local-shell-call-1" + mock_item.action = mock_action + mock_item.environment = None + mock_item.status = "completed" + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + mock_event.item = mock_item + + update = client._parse_chunk_from_openai( + mock_event, options={"tools": [local_shell_tool]}, function_call_ids=function_call_ids + ) + + assert len(update.contents) == 1 + call_content = update.contents[0] + assert call_content.type == "function_call" + assert call_content.call_id == "local-shell-call-1" + assert call_content.name == local_shell_tool.name + assert call_content.parse_arguments() == {"command": "python --version"} + assert call_content.additional_properties["openai.responses.shell.output_type"] == "shell_call_output" + + def test_parse_chunk_from_openai_shell_call_output_added_defers_result() -> None: """An in-progress shell_call_output on output_item.added must emit nothing. From 1c9bf81d8955d06a2e982bccbc8a261a2781c05f Mon Sep 17 00:00:00 2001 From: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:38:40 +0530 Subject: [PATCH 2/2] Fix pyright type annotations for local shell environment resolution Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> --- .../openai/agent_framework_openai/_chat_client.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 9a2428365f9..473788060a4 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -2405,14 +2405,14 @@ def _shell_item_to_contents(self, item: Any, local_shell_tool_name: str | None) ) shell_timeout_ms = getattr(action, "timeout_ms", None) shell_max_output = getattr(action, "max_output_length", None) - item_environment = getattr(item, "environment", None) - env_type = ( - item_environment.get("type") + item_environment: object = getattr(item, "environment", None) + env_type: object = ( + cast("Mapping[str, object]", item_environment).get("type") if isinstance(item_environment, Mapping) else getattr(item_environment, "type", None) ) - is_local_environment = env_type == "local" or ( - item_environment is None and local_shell_tool_name is not None + is_local_environment: bool = bool( + env_type == "local" or (item_environment is None and local_shell_tool_name is not None) ) if ( local_shell_tool_name