-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Python: Preserve MCP Host payloads in AG-UI snapshots #8129
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
Contributor
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. Could we preserve the model replay metadata on the event path used by |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,7 +37,18 @@ | |
|
|
||
| from ._predictive_state import PredictiveStateHandler | ||
| from ._state import TOOL_RESULT_DISPLAY_KEY, TOOL_RESULT_STATE_KEY | ||
| from ._utils import _approval_interrupt_id, generate_event_id, make_json_safe, normalize_agui_role | ||
| from ._utils import ( | ||
| _AGUI_MCP_TOOL_RESULT_KEY, | ||
| _AGUI_TOOL_RESULT_MODEL_CONTENT_KEY, | ||
| _approval_interrupt_id, | ||
| _extract_mcp_tool_result_host_payload, | ||
| _extract_tool_result_marker_values, | ||
| _model_items_for_agui_replay, | ||
| _stringify_tool_result, | ||
| generate_event_id, | ||
| make_json_safe, | ||
| normalize_agui_role, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
@@ -696,22 +707,6 @@ def _emit_tool_call( | |
| return events | ||
|
|
||
|
|
||
| def _extract_tool_result_marker_values(content: Content, key: str) -> list[Any]: | ||
| """Extract marker values from outer and inner tool-result content.""" | ||
| values: list[Any] = [] | ||
|
|
||
| outer_ap = getattr(content, "additional_properties", None) or {} | ||
| if key in outer_ap: | ||
| values.append(outer_ap[key]) | ||
|
|
||
| for item in content.items or (): | ||
| item_ap = getattr(item, "additional_properties", None) or {} | ||
| if key in item_ap: | ||
| values.append(item_ap[key]) | ||
|
|
||
| return values | ||
|
|
||
|
|
||
| def _extract_tool_result_state(content: Content) -> dict[str, Any] | None: | ||
| """Extract a deterministic AG-UI state update from a tool-result ``Content``. | ||
|
|
||
|
|
@@ -745,15 +740,19 @@ def _extract_tool_result_display(content: Content) -> Any: # noqa: ANN401 | |
| return display_values[-1] if display_values else _UNSET | ||
|
|
||
|
|
||
| def _stringify_tool_result(raw_result: Any) -> str: # noqa: ANN401 | ||
| return raw_result if isinstance(raw_result, str) else json.dumps(make_json_safe(raw_result)) | ||
|
|
||
|
|
||
| def _resolve_ui_payload(llm_str: str, display_result: Any) -> str: # noqa: ANN401 | ||
| """Pick the UI-bound string: the serialized display payload when set, else the LLM string.""" | ||
| return llm_str if display_result is _UNSET else _stringify_tool_result(display_result) | ||
|
|
||
|
|
||
| def _resolve_tool_result_host_payload(content: Content, display_result: Any) -> tuple[bool, Any]: # noqa: ANN401 | ||
| """Resolve an MCP Host payload, preferring an explicit display projection.""" | ||
| has_host_payload, host_payload = _extract_mcp_tool_result_host_payload(content) | ||
| if has_host_payload and display_result is not _UNSET: | ||
| host_payload = display_result | ||
| return has_host_payload, host_payload | ||
|
|
||
|
|
||
| def _emit_tool_result_common( | ||
| call_id: str, | ||
| raw_result: Any, | ||
|
|
@@ -762,6 +761,8 @@ def _emit_tool_result_common( | |
| *, | ||
| state_update: Mapping[str, Any] | None = None, | ||
| display_result: Any = _UNSET, # noqa: ANN401 | ||
| snapshot_result: Any = _UNSET, # noqa: ANN401 | ||
| model_items: list[dict[str, Any]] | None = None, | ||
| ) -> list[BaseEvent]: | ||
| """Shared helper for emitting ToolCallEnd + ToolCallResult events and performing FlowState cleanup. | ||
|
|
||
|
|
@@ -789,6 +790,7 @@ def _emit_tool_result_common( | |
|
|
||
| result_content = _stringify_tool_result(raw_result) | ||
| ui_result_content = _resolve_ui_payload(result_content, display_result) | ||
| snapshot_result_content = _resolve_ui_payload(result_content, snapshot_result) | ||
| message_id = generate_event_id() | ||
| events.append( | ||
| ToolCallResultEvent( | ||
|
|
@@ -799,14 +801,18 @@ def _emit_tool_result_common( | |
| ) | ||
| ) | ||
|
|
||
| flow.tool_results.append( | ||
| { | ||
| "id": message_id, | ||
| "role": "tool", | ||
| "toolCallId": call_id, | ||
| "content": result_content, | ||
| } | ||
| ) | ||
| snapshot_message: dict[str, Any] = { | ||
| "id": message_id, | ||
| "role": "tool", | ||
| "toolCallId": call_id, | ||
| "content": snapshot_result_content, | ||
|
Contributor
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. This persists the Host projection in canonical |
||
| } | ||
| if snapshot_result is not _UNSET: | ||
| snapshot_message[_AGUI_MCP_TOOL_RESULT_KEY] = True | ||
| snapshot_message[_AGUI_TOOL_RESULT_MODEL_CONTENT_KEY] = model_items or [ | ||
| {"type": "text", "text": result_content} | ||
| ] | ||
| flow.tool_results.append(snapshot_message) | ||
| # A result closes the current tool-call segment: a later call opens a new | ||
| # one, so `call A -> result A -> call B` snapshots as two call/result pairs | ||
| # in stream order instead of grouping B with A (moonbox3's replay concern). | ||
|
|
@@ -851,13 +857,20 @@ def _emit_tool_result( | |
| raw_result = content.result if content.result is not None else "" | ||
| state_update = _extract_tool_result_state(content) | ||
| display_result = _extract_tool_result_display(content) | ||
| has_host_payload, host_payload = _resolve_tool_result_host_payload(content, display_result) | ||
| if has_host_payload and display_result is _UNSET: | ||
| display_result = host_payload | ||
| return _emit_tool_result_common( | ||
| content.call_id, | ||
| raw_result, | ||
| flow, | ||
| predictive_handler, | ||
| state_update=state_update, | ||
| display_result=display_result, | ||
| snapshot_result=host_payload if has_host_payload else _UNSET, | ||
| model_items=( | ||
| _model_items_for_agui_replay(content, _stringify_tool_result(raw_result)) if has_host_payload else None | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
|
|
@@ -1022,13 +1035,20 @@ def _emit_mcp_tool_result( | |
| raw_output = content.output if content.output is not None else "" | ||
| state_update = _extract_tool_result_state(content) | ||
| display_result = _extract_tool_result_display(content) | ||
| has_host_payload, host_payload = _resolve_tool_result_host_payload(content, display_result) | ||
| if has_host_payload and display_result is _UNSET: | ||
| display_result = host_payload | ||
| return _emit_tool_result_common( | ||
| content.call_id, | ||
| raw_output, | ||
| flow, | ||
| predictive_handler, | ||
| state_update=state_update, | ||
| display_result=display_result, | ||
| snapshot_result=host_payload if has_host_payload else _UNSET, | ||
| model_items=( | ||
| _model_items_for_agui_replay(content, _stringify_tool_result(raw_output)) if has_host_payload else None | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
|
|
||
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.
The sidecar writer serializes every
Contentreturned by the publicMCPTool.parse_tool_resultscontract, but this allowlist rejects every valid type outside these four. A custom parser returning another model-facingContenttype works on the live turn, then the next snapshot replay replaces the entire result with the text fallback and changes provider history. Ensure the writer and reader support the same valid content set, or reject/normalize unsupported items before the first model turn so replay remains equivalent.