Skip to content

Commit 914f571

Browse files
committed
feat(agent-sdk): implement programmatic experiments API and decouple InferenceStrategy/Parser design
1 parent be8f977 commit 914f571

42 files changed

Lines changed: 395 additions & 147 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

agent_sdks/python/a2ui_agent/agent_development.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ The first step in any A2UI-enabled agent is initializing the
2828

2929
```python
3030
from a2ui.schema.constants import VERSION_0_9
31-
from a2ui.schema.manager import A2uiSchemaManager, CatalogConfig
31+
from a2ui.strategies.schema import A2uiSchemaManager
32+
from a2ui.schema.catalog import CatalogConfig
3233
from a2ui.basic_catalog.provider import BasicCatalog
3334

3435
# Define your catalogs (basic or bring your own) with optional examples

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import logging
1616
from typing import Any, Optional, List, AsyncIterable, TYPE_CHECKING
17+
from a2ui.inference_strategy import Parser
1718

1819
if TYPE_CHECKING:
1920
from a2ui.parser.streaming import A2uiStreamParser
@@ -87,26 +88,35 @@ def get_a2ui_datapart(part: Part) -> Optional[DataPart]:
8788

8889
def parse_response_to_parts(
8990
content: str,
91+
parser: Optional[Parser] = None,
9092
validator: Optional[Any] = None,
9193
fallback_text: Optional[str] = None,
9294
version: Optional[str] = None,
95+
catalog: Optional[Any] = None,
9396
) -> List[Part]:
9497
"""Helper to parse LLM response content into A2A Parts, with optional validation.
9598
9699
Args:
97100
content: The LLM response content, potentially containing A2UI delimiters.
101+
parser: Optional Parser instance.
98102
validator: Optional validator to run against extracted JSON payloads.
99103
fallback_text: Optional text to return if no parts are successfully created.
100104
version: Optional version string.
105+
catalog: Optional A2uiCatalog for fallback schema parser creation.
101106
102107
Returns:
103108
A list of A2A Part objects (TextPart and/or DataPart).
104109
"""
105-
from a2ui.parser.parser import parse_response
110+
if parser is None:
111+
if catalog is None:
112+
raise ValueError("catalog is required when parser is None.")
113+
from a2ui.strategies.schema.parser import A2uiSchemaParser
114+
115+
parser = A2uiSchemaParser(catalog)
106116

107117
parts = []
108118
try:
109-
response_parts = parse_response(content)
119+
response_parts = parser.parse_response(content)
110120

111121
for part in response_parts:
112122
if part.text:

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838

3939
from a2a import types as a2a_types
4040
from a2ui.a2a.parts import create_a2ui_part, parse_response_to_parts
41-
from a2ui.parser.parser import has_a2ui_parts
41+
from a2ui.inference_strategy import Parser
42+
from a2ui.strategies.schema.parser import A2uiSchemaParser
4243
from a2ui.schema import constants
4344
from a2ui.schema.catalog import A2uiCatalog
4445
from google.adk.a2a.converters import part_converter
@@ -69,11 +70,13 @@ def __init__(
6970
bypass_tool_check: bool = False,
7071
fallback_text: Optional[str] = None,
7172
version: str = constants.VERSION_0_8,
73+
parser: Optional[Parser] = None,
7274
):
7375
self._catalog = a2ui_catalog
7476
self._bypass_tool_check = bypass_tool_check
7577
self._fallback_text = fallback_text
7678
self._version = version
79+
self._parser = parser or A2uiSchemaParser(a2ui_catalog)
7780

7881
def convert(self, part: genai_types.Part) -> list[a2a_types.Part]:
7982
"""Converts a GenAI part to A2A parts, with A2UI validation.
@@ -117,9 +120,10 @@ def convert(self, part: genai_types.Part) -> list[a2a_types.Part]:
117120

118121
if function_response.response:
119122
result = function_response.response.get("result")
120-
if isinstance(result, str) and has_a2ui_parts(result):
123+
if isinstance(result, str) and self._parser.has_a2ui_parts(result):
121124
return parse_response_to_parts(
122125
result,
126+
parser=self._parser,
123127
validator=self._catalog.validator,
124128
fallback_text=self._fallback_text,
125129
version=self._version,
@@ -133,9 +137,10 @@ def convert(self, part: genai_types.Part) -> list[a2a_types.Part]:
133137

134138
# 3. Handle Text-based A2UI (TextPart)
135139
if text := part.text:
136-
if has_a2ui_parts(text):
140+
if self._parser.has_a2ui_parts(text):
137141
return parse_response_to_parts(
138142
text,
143+
parser=self._parser,
139144
validator=self._catalog.validator,
140145
fallback_text=self._fallback_text,
141146
version=self._version,

agent_sdks/python/a2ui_agent/src/a2ui/inference_strategy.py

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,37 @@
1313
# limitations under the License.
1414

1515
from abc import ABC, abstractmethod
16-
from typing import Optional, Any
16+
from typing import Optional, Any, List
17+
from a2ui.parser.response_part import ResponsePart
18+
19+
20+
class Parser(ABC):
21+
"""Abstract interface defining the response parser and tokenizer."""
22+
23+
@abstractmethod
24+
def parse_response(self, content: str) -> List[ResponsePart]:
25+
"""Parses full response content into standard JSON payload parts."""
26+
pass
27+
28+
@abstractmethod
29+
def process_chunk(self, chunk: str) -> List[ResponsePart]:
30+
"""Processes a streamed token chunk (incremental parsing)."""
31+
pass
32+
33+
@abstractmethod
34+
def has_a2ui_parts(self, content: str) -> bool:
35+
"""Checks if the content contains formatted structured blocks for this parser."""
36+
pass
1737

1838

1939
class InferenceStrategy(ABC):
40+
"""Interface coordinating system prompt generation and response parsing."""
41+
42+
@property
43+
@abstractmethod
44+
def parser(self) -> Parser:
45+
"""The Parser instance associated with this inference strategy."""
46+
pass
2047

2148
@abstractmethod
2249
def generate_system_prompt(
@@ -30,22 +57,9 @@ def generate_system_prompt(
3057
include_schema: bool = False,
3158
include_examples: bool = False,
3259
validate_examples: bool = False,
60+
format_strategy: Optional[Any] = None,
3361
) -> str:
3462
"""
3563
Generates a system prompt for all LLM requests.
36-
37-
Args:
38-
role_description: Description of the agent's role.
39-
workflow_description: Description of the workflow.
40-
ui_description: Description of the UI.
41-
client_ui_capabilities: Capabilities reported by the client for targeted schema pruning.
42-
allowed_components: List of allowed catalog components.
43-
allowed_messages: List of allowed messages.
44-
include_schema: Whether to include the schema.
45-
include_examples: Whether to include examples.
46-
validate_examples: Whether to validate examples.
47-
48-
Returns:
49-
The system prompt.
5064
"""
5165
pass

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@
2929
SURFACE_ID_KEY,
3030
CATALOG_COMPONENTS_KEY,
3131
)
32-
from ..schema.validator import (
32+
from a2ui.validation.validator import (
3333
extract_component_ref_fields,
3434
extract_component_required_fields,
3535
)
36-
from ..schema.validator import A2uiValidator
36+
from a2ui.validation.validator import A2uiValidator
3737
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

agent_sdks/python/a2ui_agent/src/a2ui/schema/catalog.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
)
4040

4141
if TYPE_CHECKING:
42-
from .validator import A2uiValidator
42+
from a2ui.validation.validator import A2uiValidator
4343

4444

4545
@dataclass
@@ -163,6 +163,7 @@ class A2uiCatalog:
163163
common_types_schema: Dict[str, Any]
164164
catalog_schema: Dict[str, Any]
165165
custom_cuttable_keys: Optional[frozenset[str]] = None
166+
experiments: Optional[frozenset[str]] = None
166167

167168
@property
168169
def cuttable_keys(self) -> frozenset[str]:
@@ -181,9 +182,9 @@ def catalog_id(self) -> str:
181182

182183
@property
183184
def validator(self) -> "A2uiValidator":
184-
from .validator import A2uiValidator
185+
from a2ui.validation.validator import A2uiValidator
185186

186-
return A2uiValidator(self)
187+
return A2uiValidator(self, experiments=self.experiments)
187188

188189
@property
189190
def core_catalog(self) -> Catalog[Any, Any]:
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from .inference_strategy import A2uiSchemaManager
16+
from .parser import A2uiSchemaParser
17+
from .prompt_generator import SchemaPromptGenerator
18+
19+
__all__ = [
20+
"A2uiSchemaManager",
21+
"A2uiSchemaParser",
22+
"SchemaPromptGenerator",
23+
]

agent_sdks/python/a2ui_agent/src/a2ui/schema/manager.py renamed to agent_sdks/python/a2ui_agent/src/a2ui/strategies/schema/inference_strategy.py

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,15 @@
1313
# limitations under the License.
1414

1515
import copy
16-
import json
17-
import logging
18-
import os
19-
import importlib.resources
20-
from typing import Any, Optional, Callable
21-
from dataclasses import dataclass, field
22-
from .utils import load_from_bundled_resource
23-
from ..inference_strategy import InferenceStrategy
24-
from .constants import *
25-
from .catalog import CatalogConfig, A2uiCatalog
16+
from typing import Any, Optional, Callable, Union
17+
18+
from a2ui.schema.utils import load_from_bundled_resource
19+
from a2ui.inference_strategy import InferenceStrategy, Parser
20+
from a2ui.schema.constants import *
21+
from a2ui.schema.catalog import CatalogConfig, A2uiCatalog
2622
from a2ui.core import A2uiCatalogError
23+
from a2ui.strategies.schema.parser import A2uiSchemaParser
24+
from a2ui.strategies.schema.prompt_generator import SchemaPromptGenerator
2725

2826

2927
class A2uiSchemaManager(InferenceStrategy):
@@ -37,9 +35,11 @@ def __init__(
3735
schema_modifiers: Optional[
3836
list[Callable[[dict[str, Any]], dict[str, Any]]]
3937
] = None,
38+
experiments: Optional[Union[set[str], frozenset[str]]] = None,
4039
):
4140
self._version = version
4241
self._accepts_inline_catalogs = accepts_inline_catalogs
42+
self.experiments = frozenset(experiments) if experiments else frozenset()
4343

4444
self._server_to_client_schema: dict[str, Any] = {}
4545
self._common_types_schema: dict[str, Any] = {}
@@ -48,6 +48,19 @@ def __init__(
4848
self._schema_modifiers = schema_modifiers or []
4949
self._load_schemas(version, catalogs or [])
5050

51+
@property
52+
def parser(self) -> Parser:
53+
"""Returns the Parser instance for this strategy."""
54+
if not self._supported_catalogs:
55+
raise ValueError(
56+
"No supported catalogs configured for the schema strategy."
57+
)
58+
default_catalog = self._supported_catalogs[0]
59+
return A2uiSchemaParser(
60+
default_catalog,
61+
default_catalog.validator,
62+
)
63+
5164
@property
5265
def accepts_inline_catalogs(self) -> bool:
5366
return self._accepts_inline_catalogs
@@ -98,6 +111,7 @@ def _load_schemas(
98111
s2c_schema=self._server_to_client_schema,
99112
common_types_schema=self._common_types_schema,
100113
custom_cuttable_keys=config.custom_cuttable_keys,
114+
experiments=self.experiments,
101115
)
102116
self._supported_catalogs.append(catalog)
103117
if config.examples_path:
@@ -175,6 +189,7 @@ def _select_catalog(
175189
catalog_schema=merged_schema,
176190
s2c_schema=self._server_to_client_schema,
177191
common_types_schema=self._common_types_schema,
192+
experiments=self.experiments,
178193
)
179194

180195
if not client_supported_catalog_ids:
@@ -220,30 +235,24 @@ def generate_system_prompt(
220235
include_schema: bool = False,
221236
include_examples: bool = False,
222237
validate_examples: bool = False,
238+
format_strategy: Optional[Any] = None,
223239
) -> str:
224240
"""Assembles the final system instruction for the LLM."""
225-
parts = [role_description]
226-
227-
workflow = DEFAULT_WORKFLOW_RULES
228-
if workflow_description:
229-
workflow += f"\n{workflow_description}"
230-
parts.append(f"## Workflow Description:\n{workflow}")
231-
232-
if ui_description:
233-
parts.append(f"## UI Description:\n{ui_description}")
234-
235241
selected_catalog = self.get_selected_catalog(
236242
client_ui_capabilities, allowed_components, allowed_messages
237243
)
238244

239-
if include_schema:
240-
parts.append(selected_catalog.render_as_llm_instructions())
241-
245+
examples_str = ""
242246
if include_examples:
243247
examples_str = self.load_examples(
244248
selected_catalog, validate=validate_examples
245249
)
246-
if examples_str:
247-
parts.append(f"### Examples:\n{examples_str}")
248250

249-
return "\n\n".join(parts)
251+
generator = SchemaPromptGenerator(selected_catalog)
252+
return generator.generate(
253+
role_description=role_description,
254+
workflow_description=workflow_description,
255+
ui_description=ui_description,
256+
examples=examples_str,
257+
include_schema=include_schema,
258+
)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from typing import List, Optional
16+
from a2ui.inference_strategy import Parser
17+
from a2ui.parser.response_part import ResponsePart
18+
from a2ui.parser.parser import parse_response
19+
from a2ui.parser.streaming import A2uiStreamParser
20+
from a2ui.schema.catalog import A2uiCatalog
21+
from a2ui.validation.validator import A2uiValidator
22+
23+
24+
class A2uiSchemaParser(Parser):
25+
"""Concrete parser implementation for standard A2UI JSON schema responses."""
26+
27+
def __init__(
28+
self,
29+
catalog: A2uiCatalog,
30+
validator: Optional[A2uiValidator] = None,
31+
):
32+
self._catalog = catalog
33+
self._validator = validator
34+
self._stream_parser: Optional[A2uiStreamParser] = None
35+
36+
def parse_response(self, content: str) -> List[ResponsePart]:
37+
"""Parses standard A2UI JSON tags in LLM responses."""
38+
return parse_response(content)
39+
40+
def process_chunk(self, chunk: str) -> List[ResponsePart]:
41+
"""Processes streamed token chunks incrementally."""
42+
if not self._stream_parser:
43+
self._stream_parser = A2uiStreamParser(self._catalog)
44+
return self._stream_parser.process_chunk(chunk)
45+
46+
def has_a2ui_parts(self, content: str) -> bool:
47+
from a2ui.schema.constants import A2UI_OPEN_TAG
48+
49+
return A2UI_OPEN_TAG.rstrip(">") in content

0 commit comments

Comments
 (0)