Skip to content

Commit fd5b2f2

Browse files
authored
Merge branch 'main' into feat/generic-a2ui-react-renderer
2 parents 0fe2354 + 4d72dee commit fd5b2f2

25 files changed

Lines changed: 173 additions & 142 deletions

File tree

.github/workflows/python_ci.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ jobs:
5959
uv run pyink --check .
6060
- name: Type Check
6161
run: |
62-
uv run mypy -p a2ui.core
63-
uv run mypy agent_sdks/python/a2ui_core/scripts
62+
uv run --all-packages mypy agent_sdks/python/a2ui_agent/src
63+
uv run --all-packages mypy -p a2ui.core
64+
uv run --all-packages mypy agent_sdks/python/a2ui_core/scripts
6465
- name: Run Unit Tests
6566
run: uv run pytest
6667
- name: Verify Load Real

agent_sdks/python/a2ui_agent/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,5 @@ default = true
6363
dev = [
6464
"antlr4-tools>=0.2.1",
6565
"hatchling>=1.30.1",
66+
"types-jsonschema",
6667
]

agent_sdks/python/a2ui_agent/src/a2ui/a2a/extension.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
# limitations under the License.
1414

1515
import logging
16-
from typing import Optional, List
16+
from typing import Optional, List, Any, Dict
1717

18+
from packaging.version import Version, parse as parse_version
1819
from a2a.server.agent_execution import RequestContext
1920
from a2a.types import AgentCard, AgentExtension
2021

@@ -40,7 +41,7 @@ def get_a2ui_agent_extension(
4041
Returns:
4142
The configured A2UI AgentExtension.
4243
"""
43-
params = {}
44+
params: Dict[str, Any] = {}
4445
if accepts_inline_catalogs:
4546
params[AGENT_EXTENSION_ACCEPTS_INLINE_CATALOGS_KEY] = (
4647
True # Only set if not default of False
@@ -107,10 +108,8 @@ def _select_newest_a2ui_extension(
107108
if not matched_extensions:
108109
return None
109110

110-
def _version_key(uri: str) -> tuple:
111+
def _version_key(uri: str) -> Version:
111112
version_str = uri.replace(f"{A2UI_EXTENSION_BASE_URI}/v", "")
112-
from packaging.version import parse as parse_version
113-
114113
return parse_version(version_str)
115114

116115
return max(matched_extensions, key=_version_key)

agent_sdks/python/a2ui_agent/src/a2ui/a2a/parts.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def is_a2ui_part(part: Part) -> bool:
6363
Returns:
6464
True if the part contains A2UI data, False otherwise.
6565
"""
66-
return (
66+
return bool(
6767
isinstance(part.root, DataPart)
6868
and part.root.metadata
6969
and part.root.metadata.get(MIME_TYPE_KEY)
@@ -80,7 +80,7 @@ def get_a2ui_datapart(part: Part) -> Optional[DataPart]:
8080
Returns:
8181
The DataPart containing A2UI data if present, None otherwise.
8282
"""
83-
if is_a2ui_part(part):
83+
if is_a2ui_part(part) and isinstance(part.root, DataPart):
8484
return part.root
8585
return None
8686

agent_sdks/python/a2ui_agent/src/a2ui/adk/a2a/event_converter.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
```
3434
"""
3535

36+
from __future__ import annotations
3637
from typing import TYPE_CHECKING, Optional
3738

3839
from a2ui.adk.a2a.part_converter import A2uiPartConverter
@@ -78,6 +79,7 @@ def __call__(
7879
)
7980

8081
catalog = invocation_context.session.state.get(self._catalog_key)
82+
effective_converter: GenAIPartToA2APartConverter
8183
if catalog:
8284
# Use the catalog-aware part converter
8385
effective_converter = A2uiPartConverter(

agent_sdks/python/a2ui_agent/src/a2ui/adk/a2a/part_converter.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,9 @@ def convert(self, part: genai_types.Part) -> list[a2a_types.Part]:
115115
logger.info("No result in A2UI tool response")
116116
return []
117117

118-
# Handle generic/other tool responses that returned a string containing A2UI tags.
119-
if function_response.response and function_response.response.get("result"):
118+
if function_response.response:
120119
result = function_response.response.get("result")
121-
if has_a2ui_parts(result):
120+
if isinstance(result, str) and has_a2ui_parts(result):
122121
return parse_response_to_parts(
123122
result,
124123
validator=self._catalog.validator,

agent_sdks/python/a2ui_agent/src/a2ui/adk/send_a2ui_to_client_toolset.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,8 @@ def __init__(
142142
):
143143
super().__init__()
144144
self._a2ui_enabled = a2ui_enabled
145-
self._ui_tools = [self._SendA2uiJsonToClientTool(a2ui_catalog, a2ui_examples)]
145+
self._ui_tool = self._SendA2uiJsonToClientTool(a2ui_catalog, a2ui_examples)
146+
self._ui_tools: list[base_tool.BaseTool] = [self._ui_tool]
146147

147148
async def _resolve_a2ui_enabled(
148149
self, ctx: readonly_context.ReadonlyContext
@@ -196,7 +197,7 @@ async def get_part_converter(
196197
Returns:
197198
A configured A2uiPartConverter.
198199
"""
199-
catalog = await self._ui_tools[0]._resolve_a2ui_catalog(ctx)
200+
catalog = await self._ui_tool._resolve_a2ui_catalog(ctx)
200201
return A2uiPartConverter(catalog)
201202

202203
class _SendA2uiJsonToClientTool(base_tool.BaseTool):

agent_sdks/python/a2ui_agent/src/a2ui/parser/streaming.py

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import json
1919
import logging
2020
import re
21-
from typing import Any, List, Dict, Optional, Set, TYPE_CHECKING
21+
from typing import Any, List, Dict, Optional, Set, TYPE_CHECKING, Union
2222

2323
from .constants import *
2424
from ..schema.constants import (
@@ -30,11 +30,11 @@
3030
CATALOG_COMPONENTS_KEY,
3131
)
3232
from ..schema.validator import (
33-
analyze_topology,
3433
extract_component_ref_fields,
3534
extract_component_required_fields,
3635
)
3736
from ..schema.validator import A2uiValidator
37+
from a2ui.core.validating import analyze_topology
3838
from .response_part import ResponsePart
3939
from a2ui.core.validating.validator import RELAXED_VALIDATION, STRICT_VALIDATION, ValidationConfig
4040
from a2ui.core import A2uiParseError, A2uiIntegrityError
@@ -53,7 +53,7 @@ class A2uiStreamParser:
5353
(V08 or V09) depending on the catalog version.
5454
"""
5555

56-
def __new__(cls, catalog: A2uiCatalog):
56+
def __new__(cls, catalog: A2uiCatalog) -> A2uiStreamParser:
5757
if cls is A2uiStreamParser:
5858
version = catalog.version
5959
# Lazy import inside __new__ to prevent circular import errors, as the
@@ -78,7 +78,7 @@ def __init__(self, catalog: A2uiCatalog):
7878
self._found_delimiter = False
7979
self._buffer = ""
8080
self._json_buffer = ""
81-
self._brace_stack: List[Tuple[str, int]] = []
81+
self._brace_stack: list[tuple[str, int]] = []
8282
self._brace_count = 0
8383
self._in_top_level_list = False
8484
self._in_string = False
@@ -143,7 +143,7 @@ def surface_id(self) -> Optional[str]:
143143
return self._surface_id
144144

145145
@surface_id.setter
146-
def surface_id(self, value: Optional[str]):
146+
def surface_id(self, value: Optional[str]) -> None:
147147
self._surface_id = value
148148
if value is not None and self._unbound_root_id is not None:
149149
self._root_ids[value] = self._unbound_root_id
@@ -161,7 +161,7 @@ def root_id(self) -> Optional[str]:
161161
)
162162

163163
@root_id.setter
164-
def root_id(self, value: Optional[str]):
164+
def root_id(self, value: Optional[str]) -> None:
165165
if self._surface_id:
166166
if value is not None:
167167
self._root_ids[self._surface_id] = value
@@ -174,7 +174,7 @@ def root_id(self, value: Optional[str]):
174174
def msg_types(self) -> List[str]:
175175
return self._msg_types
176176

177-
def add_msg_type(self, msg_type: str):
177+
def add_msg_type(self, msg_type: str) -> None:
178178
if msg_type not in self._msg_types:
179179
self._msg_types.append(msg_type)
180180
if msg_type in (
@@ -204,6 +204,14 @@ def _get_active_msg_type_for_components(self) -> Optional[str]:
204204
"Subclasses must implement _get_active_msg_type_for_components"
205205
)
206206

207+
def _construct_partial_message(
208+
self, components: List[Dict[str, Any]], active_msg_type: str
209+
) -> Dict[str, Any]:
210+
"""Constructs a partial message of the correct type. Subclasses must implement."""
211+
raise NotImplementedError(
212+
"Subclasses must implement _construct_partial_message"
213+
)
214+
207215
def _deduplicate_data_model(self, m: Dict[str, Any]) -> bool:
208216
"""Returns True if message should be yielded, False if skipped."""
209217
return True
@@ -213,7 +221,7 @@ def _yield_messages(
213221
messages_to_yield: List[Dict[str, Any]],
214222
messages: List[ResponsePart],
215223
config: ValidationConfig = STRICT_VALIDATION,
216-
):
224+
) -> None:
217225
"""Validates and appends messages to the final output list."""
218226
for m in messages_to_yield:
219227
if not self._deduplicate_data_model(m):
@@ -358,7 +366,7 @@ def process_chunk(self, chunk: str) -> List[ResponsePart]:
358366
)
359367
return messages
360368

361-
def _reset_json_state(self):
369+
def _reset_json_state(self) -> None:
362370
"""Resets the JSON-specific parsing state (e.g., at the end of a block)."""
363371
self._json_buffer = ""
364372
self._brace_stack = []
@@ -450,7 +458,7 @@ def _fix_json(self, fragment: str) -> str:
450458

451459
return fixed
452460

453-
def _process_json_chunk(self, chunk: str, messages: List[ResponsePart]):
461+
def _process_json_chunk(self, chunk: str, messages: List[ResponsePart]) -> None:
454462
for char in chunk:
455463
char_handled = False
456464
if self._brace_count == 0:
@@ -670,24 +678,27 @@ def _sniff_partial_data_model(self, messages: List[ResponsePart]) -> None:
670678
or "default"
671679
)
672680
# Deduplicate delta_contents by only keeping the LATEST entry for each dirty key
681+
delta_contents: Union[
682+
List[Dict[str, Any]], Dict[str, Any]
683+
]
673684
if isinstance(raw_contents, list):
674-
delta_contents = []
685+
list_contents: List[Dict[str, Any]] = []
675686
seen_keys = set()
676687
for entry in reversed(raw_contents):
677688
if not isinstance(entry, dict):
678689
continue
679-
k = entry.get("key")
690+
entry_key = entry.get("key")
680691
# Only include entries that have a valid parsed key (cumulative)
681692
if (
682-
k
683-
and k in contents_dict
684-
and k not in seen_keys
693+
entry_key
694+
and entry_key in contents_dict
695+
and entry_key not in seen_keys
685696
):
686-
delta_contents.insert(0, entry)
687-
seen_keys.add(k)
697+
list_contents.insert(0, entry)
698+
seen_keys.add(entry_key)
688699
delta_contents = (
689700
self._prune_incomplete_datamodel_entries(
690-
delta_contents
701+
list_contents
691702
)
692703
)
693704
else:
@@ -711,7 +722,7 @@ def _sniff_partial_data_model(self, messages: List[ResponsePart]) -> None:
711722
# Update internal model for path resolution
712723
self.update_data_model(dm_obj, messages)
713724

714-
def _sniff_partial_component(self, messages: List[ResponsePart]):
725+
def _sniff_partial_component(self, messages: List[ResponsePart]) -> None:
715726
"""Attempts to parse a partial component from the current buffer."""
716727
# We only care about components if we are inside a "components" array
717728
if f'"{CATALOG_COMPONENTS_KEY}"' not in self._json_buffer:
@@ -779,7 +790,7 @@ def _prune_incomplete_datamodel_entries(self, entries: Any) -> Any:
779790

780791
def _handle_partial_component(
781792
self, comp: Dict[str, Any], messages: List[ResponsePart]
782-
):
793+
) -> None:
783794
"""Handles a component discovered before its parent message is finished.
784795
785796
When the parser sees a full JSON object that looks like a component
@@ -880,10 +891,10 @@ def _handle_complete_object(
880891

881892
def yield_reachable(
882893
self,
883-
messages: List[Dict[str, Any]],
894+
messages: List[ResponsePart],
884895
check_root: bool = False,
885896
raise_on_orphans: bool = False,
886-
):
897+
) -> None:
887898
"""Yields a partial message containing all reachable and seen components.
888899
889900
This is the core of the streaming logic. Instead of waiting for a UI message
@@ -934,8 +945,8 @@ def yield_reachable(
934945
)
935946

936947
# 1. Process placeholders and partial children
937-
processed_components = []
938-
extra_components = []
948+
processed_components: List[Dict[str, Any]] = []
949+
extra_components: List[Dict[str, Any]] = []
939950
surface_id = self.surface_id or "unknown"
940951
yielded_for_surface = self._yielded_ids.get(surface_id, set())
941952

@@ -1022,7 +1033,7 @@ def _process_component_topology(
10221033
comp: Dict[str, Any],
10231034
extra_components: List[Dict[str, Any]],
10241035
inline_resolved: bool = False,
1025-
):
1036+
) -> None:
10261037
"""Recursively processes path placeholders and child pruning in one pass."""
10271038
comp_id = comp.get("id", "unknown")
10281039

@@ -1033,7 +1044,7 @@ def _process_component_topology(
10331044
else ""
10341045
)
10351046

1036-
def traverse(obj, parent_key=None):
1047+
def traverse(obj: Any, parent_key: Optional[str] = None) -> None:
10371048
if isinstance(obj, dict):
10381049
# 1. Handle Path Placeholders (from _apply_placeholders)
10391050
if (

agent_sdks/python/a2ui_agent/src/a2ui/parser/streaming_v08.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def _data_model_msg_type(self) -> str:
6868
"""Returns the message type identifier for data model updates."""
6969
return MSG_TYPE_DATA_MODEL_UPDATE
7070

71-
def _sniff_metadata(self):
71+
def _sniff_metadata(self) -> None:
7272
"""Sniffs for v0.8 metadata in the json_buffer."""
7373

7474
def get_latest_value(key: str) -> Optional[str]:

agent_sdks/python/a2ui_agent/src/a2ui/parser/streaming_v09.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def is_protocol_msg(self, obj: Dict[str, Any]) -> bool:
6060
)
6161
)
6262

63-
def _sniff_metadata(self):
63+
def _sniff_metadata(self) -> None:
6464
"""Sniffs for v0.9 metadata in the json_buffer."""
6565

6666
def get_latest_value(key: str) -> Optional[str]:
@@ -237,7 +237,7 @@ def _construct_partial_message(
237237
self, processed_components: List[Dict[str, Any]], active_msg_type: str
238238
) -> Dict[str, Any]:
239239
"""Constructs a partial message for v0.9 (updateComponents)."""
240-
payload = {
240+
payload: Dict[str, Any] = {
241241
CATALOG_COMPONENTS_KEY: processed_components,
242242
}
243243
if self.surface_id:

0 commit comments

Comments
 (0)