Repository navigation
Python: replay an empty local_shell_call with its matching shell output #10155
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b630dd9
9524ec0
d18cdc9
78789c3
1e949dc
dcc0045
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2825,15 +2825,63 @@ def _shell_item_to_contents(self, item: Any, local_shell_tool_name: str | None) | |
| ) | ||
| ) | ||
| else: | ||
| provider_status = _provider_status(getattr(item, "status", None)) | ||
| empty_local_call = bool( | ||
| local_shell_tool_name | ||
| and local_call_id | ||
| and local_call_item_id | ||
| and isinstance(raw_local_command_parts, list) | ||
| and not raw_local_command_parts | ||
| ) | ||
| contents.append( | ||
| Content.from_shell_tool_call( | ||
| call_id=local_call_id, | ||
| commands=[local_command] if local_command else [], | ||
| timeout_ms=getattr(getattr(item, "action", None), "timeout_ms", None), | ||
| status=_provider_status(getattr(item, "status", None)), | ||
| raw_representation=item, | ||
| status=provider_status, | ||
| # An empty ``local_shell_call`` has no replayable local form: its provider | ||
| # payload only validates as ``local_shell_call``, which this client never | ||
| # emits on replay. A call that ran no commands is equivalent to a | ||
| # ``shell_call`` with an empty action, so record that shape and pair it with | ||
| # the terminal output below instead of leaving an orphaned shell call. | ||
| # The item id also goes into the serialized metadata, because | ||
| # ``raw_representation`` does not survive ``Content.to_dict()``. | ||
| additional_properties=( | ||
| _openai_additional_properties( | ||
| { | ||
| _OPENAI_SHELL_OUTPUT_TYPE_NAMESPACE_KEY: OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL, | ||
| _OPENAI_LOCAL_SHELL_CALL_ITEM_ID_NAMESPACE_KEY: local_call_item_id, | ||
| }, | ||
| legacy={ | ||
| OPENAI_SHELL_OUTPUT_TYPE_KEY: OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL, | ||
| OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY: local_call_item_id, | ||
| }, | ||
| ) | ||
| if empty_local_call | ||
| else None | ||
| ), | ||
| raw_representation=( | ||
| { | ||
| "id": local_call_item_id, | ||
| "type": "shell_call", | ||
| "call_id": local_call_id, | ||
| "status": provider_status or "completed", | ||
| "environment": {"type": "local"}, | ||
| "action": {"commands": []}, | ||
| } | ||
| if empty_local_call | ||
| else item | ||
| ), | ||
| ) | ||
| ) | ||
| if empty_local_call: | ||
| contents.append( | ||
| Content.from_shell_tool_result( | ||
| call_id=local_call_id, | ||
| outputs=[], | ||
| additional_properties={_LOCAL_TOOL_RESULT_KEY: True}, | ||
|
Comment on lines
+2879
to
+2882
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reading the serializer again, the mismatch you describe is real: the call content keeps the local-shell metadata ( That is exactly the inconsistency to fix, but the fix changes what the service-side continuation stores, and the two candidates are not equivalent:
Option 1 matches the native item the service already stores; option 2 matches the "an empty call that ran no commands is equivalent to I don't want to guess at the wire contract: the repo tests can assert either shape, so whichever one the service actually accepts decides it. I've asked in the thread; if option 1 is the intended contract I'll swap the metadata and update |
||
| ) | ||
| ) | ||
| elif item_type == "shell_call_output": | ||
| shell_output_call_id = getattr(item, "call_id", None) or "" | ||
| shell_outputs: list[Content] = [] | ||
|
|
@@ -3072,6 +3120,8 @@ def _prepare_shell_transcript_item_for_openai( | |
| output["max_output_length"] = content.max_output_length | ||
| return output | ||
| payload = cls._serialize_provider_payload(content.raw_representation) | ||
| if payload is None: | ||
| payload = cls._rebuild_shell_call_payload(content, expected_type) | ||
| if isinstance(payload, Mapping): | ||
| typed_payload = cast("Mapping[str, Any]", payload) | ||
| raw_call_id = typed_payload.get("call_id") | ||
|
|
@@ -3092,6 +3142,32 @@ def _prepare_shell_transcript_item_for_openai( | |
| "Use service-side continuation or preserve the original provider response item." | ||
| ) | ||
|
|
||
| @classmethod | ||
| def _rebuild_shell_call_payload(cls, content: Content, expected_type: str) -> dict[str, Any] | None: | ||
| """Rebuild a shell call payload from the fields ``Content.to_dict()`` keeps. | ||
|
|
||
| ``raw_representation`` is dropped on serialization, so a call restored from a checkpoint | ||
| only carries its typed fields. An empty local shell call has everything needed to rebuild | ||
| the wire payload, which is what keeps a restored conversation replayable. | ||
| """ | ||
| if expected_type != "shell_call" or not content.call_id: | ||
| return None | ||
| item_id = _get_openai_metadata( | ||
| content.additional_properties, | ||
| _OPENAI_LOCAL_SHELL_CALL_ITEM_ID_NAMESPACE_KEY, | ||
| OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY, | ||
| ) | ||
| payload: dict[str, Any] = { | ||
| "type": "shell_call", | ||
| "call_id": content.call_id, | ||
| "status": content.status or "completed", | ||
| "action": {"commands": list(content.commands or [])}, | ||
| } | ||
| if isinstance(item_id, str) and item_id: | ||
| payload["id"] = item_id | ||
| payload["environment"] = {"type": "local"} | ||
| return payload | ||
|
|
||
| @staticmethod | ||
| def _serialize_provider_payload(value: Any) -> Any: | ||
| """Convert OpenAI SDK objects into JSON-serializable Python values.""" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DZH166 Persist the reconstructed call for history replay — The reconstructed shell call exists only in raw_representation, which Content.to_dict() omits. After the whole message is serialized and restored, stateless replay raises ChatClientInvalidRequestException because it cannot reconstruct the call. The new round-trip test serializes only the result, so it misses this path. Preserve sufficient call data across message serialization and test the complete message round trip.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Implemented in
dcc0045, on top of the focused issue you asked for (#10373):local_shell_callnow records the provider item id inadditional_properties(whichContent.to_dict()does serialize) instead of relying onraw_representation;_prepare_shell_transcript_item_for_openai()falls back to_rebuild_shell_call_payload()whenraw_representationisNone, rebuilding{type, call_id, status, action.commands}plus the item id andenvironment: {"type": "local"}, i.e. exactly the payload this branch already emits in memory and the provider already validates;test_empty_local_shell_call_item_replays_with_empty_outputnow round-trips the whole message (Message.from_json(message.to_json())) instead of only the result, so it exercises the persisted-conversation path that used to raiseChatClientInvalidRequestException.The pair still serializes as
shell_call+shell_call_outputkeyed bycall_id(option 2 from my earlier note), which is the shape the empty-action path can rebuild from typed fields alone.