Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/specs/004-python-function-calling-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,11 @@ that manually replay messages own the equivalent rule: do not resend an approval
Changing arguments after security middleware has processed them fails closed with `MiddlewareFailure`.
- Argument-validation failures after middleware retain the established `Argument parsing failed` result contract.
Exceptions raised by middleware or the tool body retain the separate `Function failed` contract.
- Provider-hosted shell calls remain informational transcript content. Only a well-formed explicit `local_shell_call`
or a shell call marked with a local environment, paired with a configured local executor, can enter the local
function loop and its approval boundary. Stateless replay preserves provider shell call/output items in their
original shape and fails explicitly when that provider representation is unavailable or inconsistent. Locally
generated shell outputs are sent in both service-side and stateless continuation modes.

### Reasoning-bound calls

Expand Down Expand Up @@ -514,6 +519,7 @@ that manually replay messages own the equivalent rule: do not resend an approval
| Calls across response messages | Every actionable call is executed once. | `test_base_client_executes_function_calls_across_multiple_response_messages` |
| Parallel calls | Results retain the corresponding call ids and execution count. | `test_max_function_calls_limits_parallel_invocations`, `test_streaming_multiple_function_calls_parallel_execution` |
| Informational-only call | The call is returned but not executed or approved. | `test_informational_only_function_call_is_not_invoked`, `test_informational_only_function_call_does_not_request_approval`, `test_streaming_informational_only_function_call_is_not_invoked` |
| OpenAI hosted/local shell boundary | Hosted shell calls remain informational in streaming and non-streaming responses even when a local executor is configured; only valid explicit local-shell items or shell calls marked with a local environment can execute, local execution preserves its configured approval mode, stateless loops preserve the complete provider shell transcript or fail explicitly, and locally generated shell outputs are sent in every continuation mode. | `packages/openai/tests/openai/test_openai_chat_client.py::test_response_content_creation_with_shell_call_remains_hosted_with_local_tool`, `test_parse_chunk_from_openai_shell_call_done_remains_hosted`, `test_parse_chunk_from_openai_local_environment_shell_call_done_emits_command`, `test_mixed_shell_calls_only_invoke_explicit_local_shell_call`, `test_stateless_shell_transcript_without_provider_item_fails`, `test_prepare_messages_keeps_local_shell_output_under_storage`, `test_malformed_local_environment_shell_call_is_not_executable`, `test_response_content_creation_with_local_shell_call_maps_to_function_call`, `test_malformed_local_shell_call_is_not_executable`, `test_response_function_call_named_local_shell_is_informational`, `test_parse_chunk_function_call_named_local_shell_is_informational`, `test_local_shell_tool_requires_approval_before_function_loop_execution` |
| Declaration-only call | The call is surfaced as user input and is not executed; streaming arguments appear once while finalized request metadata remains available. | `test_declaration_only_tool`, `test_streaming_declaration_only_tool_preserves_metadata_without_duplicate_arguments` |
| Function invocation disabled | The client bypasses the invocation loop without losing invocation kwargs. | `test_function_invocation_config_enabled_false`, `test_function_invocation_config_enabled_false_preserves_invocation_kwargs`, `test_streaming_function_invocation_config_enabled_false` |
| Runtime tool changes | Added tools become available on the next iteration and retain approval behavior. | `test_add_tools_available_next_iteration`, `test_add_tools_with_approval_required_tool` |
Expand Down
131 changes: 100 additions & 31 deletions python/packages/openai/agent_framework_openai/_chat_client.py
Comment thread
eavanvalkenburg marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
from openai.types.responses import (
FunctionShellToolParam,
ResponseCustomToolCall,
ResponseFunctionShellToolCall,
ResponseFunctionShellToolCallOutput,
ResponseToolSearchCall,
response_create_params,
)
Expand All @@ -85,6 +87,7 @@
ParsedResponse,
)
from openai.types.responses.response import Response as OpenAIResponse
from openai.types.responses.response_input_item_param import LocalShellCall
from openai.types.responses.response_stream_event import (
ResponseStreamEvent as OpenAIResponseStreamEvent,
)
Expand All @@ -96,7 +99,7 @@
Mcp,
)
from openai.types.responses.web_search_tool_param import WebSearchToolParam
from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter, ValidationError

from ._exceptions import OpenAIContentFilterException
from ._feature_usage import FeatureIndex
Expand Down Expand Up @@ -1742,16 +1745,6 @@ def _prepare_message_for_openai(
serialized_reasoning_ids.add(content.id)
continue
case "function_result":
if request_uses_service_side_storage:
props = content.additional_properties or {}
# Local-shell variant serializes as `local_shell_call` carrying a server-issued id;
# plain function_call_output pairs by call_id and is safe under storage.
if props.get(
OPENAI_SHELL_OUTPUT_TYPE_KEY
) == OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL and props.get(
OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY
):
continue
new_args: dict[str, Any] = {}
new_args.update(
self._prepare_content_for_openai(
Expand All @@ -1771,7 +1764,25 @@ def _prepare_message_for_openai(
replays_local_storage=replays_local_storage,
)
if function_call:
if function_call.get("type") in {"shell_call", "local_shell_call"} and (
"content" in args or "tool_calls" in args
):
all_messages.append(args)
args = {"type": "message", "role": message.role}
all_messages.append(function_call)
case "shell_tool_call" | "shell_tool_result":
if request_uses_service_side_storage:
continue
if "content" in args or "tool_calls" in args:
all_messages.append(args)
args = {"type": "message", "role": message.role}
all_messages.append(
self._prepare_content_for_openai(
message.role,
content,
replays_local_storage=replays_local_storage,
)
)
case "function_approval_request":
# Service-stored hosted requests are already present remotely, and local approvals
# are resolved in-process; neither should be serialized as an MCP input item.
Expand Down Expand Up @@ -1984,6 +1995,11 @@ def _prepare_content_for_openai(
return _attach_prompt_cache_breakpoint(file_obj, content)
return {}
case "function_call":
shell_output_type = content.additional_properties.get(OPENAI_SHELL_OUTPUT_TYPE_KEY)
if shell_output_type == OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL:
return self._prepare_shell_transcript_item_for_openai(content, expected_type="shell_call")
if shell_output_type == OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL:
return self._prepare_shell_transcript_item_for_openai(content, expected_type="local_shell_call")
if not content.call_id:
logger.warning(f"FunctionCallContent missing call_id for function '{content.name}'")
return {}
Expand All @@ -2006,6 +2022,10 @@ def _prepare_content_for_openai(
if status := content.additional_properties.get("status"):
function_call_obj["status"] = status
return function_call_obj
case "shell_tool_call":
return self._prepare_shell_transcript_item_for_openai(content, expected_type="shell_call")
case "shell_tool_result":
return self._prepare_shell_transcript_item_for_openai(content, expected_type="shell_call_output")
case "function_result":
shell_output_type = (
content.additional_properties.get(OPENAI_SHELL_OUTPUT_TYPE_KEY)
Expand Down Expand Up @@ -2175,11 +2195,6 @@ def _to_shell_call_output_payload(content: Content) -> list[dict[str, Any]]:
}
]

@staticmethod
def _join_shell_commands(commands: Sequence[str]) -> str:
"""Join shell commands into a single executable command string."""
return "\n".join(command for command in commands if command).strip()

def _shell_item_to_contents(self, item: Any, local_shell_tool_name: str | None) -> list[Content]:
"""Convert a shell output item into framework ``Content`` objects.

Expand All @@ -2193,21 +2208,34 @@ def _shell_item_to_contents(self, item: Any, local_shell_tool_name: str | None)
contents: list[Content] = []
item_type = getattr(item, "type", None)
if item_type == "shell_call":
shell_call_id = getattr(item, "call_id", None) or ""
shell_commands: list[str] = []
shell_timeout_ms: int | None = None
shell_max_output: int | None = None
if action := getattr(item, "action", None):
shell_commands = list(getattr(action, "commands", []) or [])
shell_timeout_ms = getattr(action, "timeout_ms", None)
shell_max_output = getattr(action, "max_output_length", None)
if local_shell_tool_name:
command_text = self._join_shell_commands(shell_commands)
raw_shell_call_id = getattr(item, "call_id", None)
shell_call_id = raw_shell_call_id if isinstance(raw_shell_call_id, str) else ""
raw_shell_call_item_id = getattr(item, "id", None)
shell_call_item_id = raw_shell_call_item_id if isinstance(raw_shell_call_item_id, str) else ""
action = getattr(item, "action", None)
raw_shell_commands = getattr(action, "commands", None)
shell_commands: list[str] = (
cast("list[str]", raw_shell_commands)
if isinstance(raw_shell_commands, list)
and raw_shell_commands
and all(isinstance(command, str) for command in cast("list[object]", raw_shell_commands))
else []
)
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"
if (
Comment thread
eavanvalkenburg marked this conversation as resolved.
local_shell_tool_name
and is_local_environment
and shell_call_id
and shell_call_item_id
and shell_commands
):
contents.append(
Content.from_function_call(
call_id=shell_call_id,
name=local_shell_tool_name,
arguments=json.dumps({"command": command_text}),
arguments=json.dumps({"command": "\n".join(shell_commands).strip()}),
additional_properties={
OPENAI_SHELL_OUTPUT_TYPE_KEY: OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL,
OPENAI_LOCAL_SHELL_COMMAND_PARTS_KEY: shell_commands,
Expand All @@ -2227,18 +2255,28 @@ def _shell_item_to_contents(self, item: Any, local_shell_tool_name: str | None)
)
)
elif item_type == "local_shell_call":
local_call_id = getattr(item, "call_id", None) or ""
local_command_parts = list(getattr(getattr(item, "action", None), "command", []) or [])
raw_local_call_id = getattr(item, "call_id", None)
local_call_id = raw_local_call_id if isinstance(raw_local_call_id, str) else ""
raw_local_call_item_id = getattr(item, "id", None)
local_call_item_id = raw_local_call_item_id if isinstance(raw_local_call_item_id, str) else ""
raw_local_command_parts = getattr(getattr(item, "action", None), "command", None)
local_command_parts: list[str] = (
cast("list[str]", raw_local_command_parts)
if isinstance(raw_local_command_parts, list)
and raw_local_command_parts
and all(isinstance(part, str) for part in cast("list[object]", raw_local_command_parts))
else []
)
local_command = shlex.join(local_command_parts) if local_command_parts else ""
if local_shell_tool_name:
if local_shell_tool_name and local_call_id and local_call_item_id and local_command_parts:
contents.append(
Content.from_function_call(
call_id=local_call_id,
name=local_shell_tool_name,
arguments=json.dumps({"command": local_command}),
additional_properties={
OPENAI_SHELL_OUTPUT_TYPE_KEY: OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL,
OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY: getattr(item, "id", None),
OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY: local_call_item_id,
OPENAI_LOCAL_SHELL_COMMAND_PARTS_KEY: local_command_parts,
},
raw_representation=item,
Expand Down Expand Up @@ -2370,6 +2408,35 @@ def _coalesce_pending_mcp_results(items: list[dict[str, Any]]) -> list[dict[str,
out.append(item)
return out

@classmethod
def _prepare_shell_transcript_item_for_openai(
cls,
content: Content,
*,
expected_type: Literal["shell_call", "shell_call_output", "local_shell_call"],
) -> dict[str, Any]:
"""Restore a provider-issued shell transcript item for stateless replay."""
payload = cls._serialize_provider_payload(content.raw_representation)
if isinstance(payload, Mapping):
typed_payload = cast("Mapping[str, Any]", payload)
raw_call_id = typed_payload.get("call_id")
try:
if expected_type == "shell_call":
ResponseFunctionShellToolCall.model_validate(typed_payload)
elif expected_type == "shell_call_output":
ResponseFunctionShellToolCallOutput.model_validate(typed_payload)
else:
TypeAdapter(LocalShellCall).validate_python(typed_payload)
except ValidationError:
pass
else:
if isinstance(raw_call_id, str) and raw_call_id and raw_call_id == content.call_id:
return dict(typed_payload)
raise ChatClientInvalidRequestException(
f"Stateless replay cannot reconstruct {expected_type} for shell call {content.call_id!r}. "
"Use service-side continuation or preserve the original provider response item."
)

@staticmethod
def _serialize_provider_payload(value: Any) -> Any:
"""Convert OpenAI SDK objects into JSON-serializable Python values."""
Expand Down Expand Up @@ -2849,6 +2916,7 @@ def _parse_response_from_openai(
call_id=item.call_id,
name=item.name,
arguments=item.arguments,
informational_only=item.name == local_shell_tool_name,
additional_properties={"fc_id": item.id, "status": item.status},
raw_representation=item,
)
Expand Down Expand Up @@ -3348,6 +3416,7 @@ def output_text_properties(output: Any) -> dict[str, Any] | None:
call_id=call_id,
name=name,
arguments=event.delta,
informational_only=name == local_shell_tool_name,
additional_properties={
"output_index": event.output_index,
"fc_id": event.item_id,
Expand Down
Loading
Loading