Skip to content

Commit 91d178b

Browse files
authored
Merge branch 'main' into fix/non-vertex-live-resumption
2 parents 40d1f38 + baf7efb commit 91d178b

50 files changed

Lines changed: 2918 additions & 1126 deletions

Some content is hidden

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

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ scripts.adk = "google.adk.cli:main"
166166

167167
[tool.flit.sdist]
168168
include = [ 'src/**/*', 'README.md', 'pyproject.toml', 'LICENSE' ]
169-
exclude = [ 'src/**/*.sh' ]
169+
exclude = [ 'src/**/*.sh', 'src/**/README.md' ]
170170

171171
[tool.flit.module]
172172
name = "google.adk"

src/google/adk/a2a/converters/event_converter.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,10 @@ def convert_event_to_a2a_events(
570570

571571
# Handle regular message content
572572
message = convert_event_to_a2a_message(
573-
event, invocation_context, part_converter=part_converter
573+
event,
574+
invocation_context,
575+
part_converter=part_converter,
576+
role=Role.user if event.author == "user" else Role.agent,
574577
)
575578
if message:
576579
running_event = _create_status_update_event(

src/google/adk/a2a/utils/agent_to_a2a.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from ...runners import Runner
3636
from ...sessions.in_memory_session_service import InMemorySessionService
3737
from ..executor.a2a_agent_executor import A2aAgentExecutor
38+
from ..executor.config import A2aAgentExecutorConfig
3839
from ..experimental import a2a_experimental
3940
from .agent_card_builder import AgentCardBuilder
4041

@@ -86,6 +87,7 @@ def to_a2a(
8687
task_store: TaskStore | None = None,
8788
runner: Runner | None = None,
8889
lifespan: Callable[[Starlette], AsyncIterator[None]] | None = None,
90+
agent_executor_factory: Callable[[Runner], A2aAgentExecutor] | None = None,
8991
) -> Starlette:
9092
"""Convert an ADK agent to a A2A Starlette application.
9193
@@ -95,20 +97,21 @@ def to_a2a(
9597
port: The port for the A2A RPC URL (default: 8000)
9698
protocol: The protocol for the A2A RPC URL (default: "http")
9799
agent_card: Optional pre-built AgentCard object or path to agent card
98-
JSON. If not provided, will be built automatically from the
99-
agent.
100+
JSON. If not provided, will be built automatically from the agent.
100101
push_config_store: Optional A2A push notification config store. If not
101-
provided, an in-memory store will be created so push-notification
102-
config RPC methods are supported.
102+
provided, an in-memory store will be created so push-notification config
103+
RPC methods are supported.
103104
task_store: Optional A2A task store for persisting task state. If not
104105
provided, an in-memory store will be created.
105106
runner: Optional pre-built Runner object. If not provided, a default
106-
runner will be created using in-memory services.
107-
lifespan: Optional async context manager for Starlette lifespan
108-
events. Use this to run startup/shutdown logic (e.g. initializing
109-
database connections or loading resources). The context manager
110-
receives the Starlette app instance and can set state on
111-
``app.state``.
107+
runner will be created using in-memory services.
108+
lifespan: Optional async context manager for Starlette lifespan events.
109+
Use this to run startup/shutdown logic (e.g. initializing database
110+
connections or loading resources). The context manager receives the
111+
Starlette app instance and can set state on ``app.state``.
112+
agent_executor_factory: Optional factory function that creates an instance
113+
of A2aAgentExecutor. If not provided, a default A2aAgentExecutor will be
114+
created.
112115
113116
Returns:
114117
A Starlette application that can be run with uvicorn
@@ -148,7 +151,7 @@ async def lifespan(app):
148151
adk_logger = logging.getLogger("google_adk")
149152
adk_logger.setLevel(logging.INFO)
150153

151-
async def create_runner() -> Runner:
154+
def create_runner() -> Runner:
152155
"""Create a runner for the agent."""
153156
return Runner(
154157
app_name=agent.name or "adk_agent",
@@ -164,8 +167,10 @@ async def create_runner() -> Runner:
164167
if task_store is None:
165168
task_store = InMemoryTaskStore()
166169

167-
agent_executor = A2aAgentExecutor(
168-
runner=runner or create_runner,
170+
agent_executor = (
171+
agent_executor_factory(runner or create_runner())
172+
if agent_executor_factory is not None
173+
else A2aAgentExecutor(runner=runner or create_runner)
169174
)
170175

171176
if push_config_store is None:

src/google/adk/agents/invocation_context.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
from pydantic import Field
2626
from pydantic import PrivateAttr
2727

28-
from ..apps.app import EventsCompactionConfig
29-
from ..apps.app import ResumabilityConfig
28+
from ..apps._configs import EventsCompactionConfig
29+
from ..apps._configs import ResumabilityConfig
3030
from ..artifacts.base_artifact_service import BaseArtifactService
3131
from ..auth.auth_credential import AuthCredential
3232
from ..auth.credential_service.base_credential_service import BaseCredentialService

src/google/adk/apps/__init__.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,28 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from .app import App
16-
from .app import ResumabilityConfig
15+
from __future__ import annotations
16+
17+
import importlib
18+
from typing import TYPE_CHECKING
19+
20+
if TYPE_CHECKING:
21+
from ._configs import ResumabilityConfig
22+
from .app import App
1723

1824
__all__ = [
1925
'App',
2026
'ResumabilityConfig',
2127
]
28+
29+
_LAZY_MEMBERS: dict[str, str] = {
30+
'App': 'app',
31+
'ResumabilityConfig': '_configs',
32+
}
33+
34+
35+
def __getattr__(name: str):
36+
if name in _LAZY_MEMBERS:
37+
module = importlib.import_module(f'{__name__}.{_LAZY_MEMBERS[name]}')
38+
return vars(module)[name]
39+
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')

src/google/adk/apps/_configs.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
# http://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 __future__ import annotations
16+
17+
from typing import Optional
18+
19+
from pydantic import BaseModel
20+
from pydantic import ConfigDict
21+
from pydantic import Field
22+
from pydantic import model_validator
23+
24+
from ..utils.feature_decorator import experimental
25+
from .base_events_summarizer import BaseEventsSummarizer
26+
27+
28+
@experimental
29+
class ResumabilityConfig(BaseModel):
30+
"""The config of the resumability for an application.
31+
32+
The "resumability" in ADK refers to the ability to:
33+
1. pause an invocation upon a long-running function call.
34+
2. resume an invocation from the last event, if it's paused or failed midway
35+
through.
36+
37+
Note: ADK resumes the invocation in a best-effort manner:
38+
1. Tool call to resume needs to be idempotent because we only guarantee
39+
an at-least-once behavior once resumed.
40+
2. Any temporary / in-memory state will be lost upon resumption.
41+
"""
42+
43+
is_resumable: bool = False
44+
"""Whether the app supports agent resumption.
45+
If enabled, the feature will be enabled for all agents in the app.
46+
"""
47+
48+
49+
@experimental
50+
class EventsCompactionConfig(BaseModel):
51+
"""The config of event compaction for an application."""
52+
53+
model_config = ConfigDict(
54+
arbitrary_types_allowed=True,
55+
extra="forbid",
56+
)
57+
58+
summarizer: Optional[BaseEventsSummarizer] = None
59+
"""The event summarizer to use for compaction."""
60+
61+
compaction_interval: int
62+
"""The number of *new* user-initiated invocations that, once
63+
fully represented in the session's events, will trigger a compaction."""
64+
65+
overlap_size: int
66+
"""The number of preceding invocations to include from the
67+
end of the last compacted range. This creates an overlap between consecutive
68+
compacted summaries, maintaining context."""
69+
70+
token_threshold: Optional[int] = Field(
71+
default=None,
72+
gt=0,
73+
)
74+
"""Post-invocation token threshold trigger.
75+
76+
If set, ADK will attempt a post-invocation compaction when the most recently
77+
observed prompt token count meets or exceeds this threshold.
78+
"""
79+
80+
event_retention_size: Optional[int] = Field(default=None, ge=0)
81+
"""Post-invocation raw event retention size.
82+
83+
If token-based post-invocation compaction is triggered, this keeps the last N
84+
raw events un-compacted.
85+
"""
86+
87+
@model_validator(mode="after")
88+
def _validate_token_params(self) -> EventsCompactionConfig:
89+
token_threshold_set = self.token_threshold is not None
90+
retention_size_set = self.event_retention_size is not None
91+
if token_threshold_set != retention_size_set:
92+
raise ValueError(
93+
"token_threshold and event_retention_size must be set together."
94+
)
95+
return self

src/google/adk/apps/app.py

Lines changed: 9 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,16 @@
2222

2323
from ..agents.base_agent import BaseAgent
2424
from ..agents.context_cache_config import ContextCacheConfig
25-
from ..apps.base_events_summarizer import BaseEventsSummarizer
2625
from ..plugins.base_plugin import BasePlugin
27-
from ..utils.feature_decorator import experimental
26+
from ._configs import EventsCompactionConfig
27+
from ._configs import ResumabilityConfig
28+
29+
__all__ = [
30+
"App",
31+
"EventsCompactionConfig",
32+
"ResumabilityConfig",
33+
"validate_app_name",
34+
]
2835

2936

3037
def validate_app_name(name: str) -> None:
@@ -38,76 +45,6 @@ def validate_app_name(name: str) -> None:
3845
raise ValueError("App name cannot be 'user'; reserved for end-user input.")
3946

4047

41-
@experimental
42-
class ResumabilityConfig(BaseModel):
43-
"""The config of the resumability for an application.
44-
45-
The "resumability" in ADK refers to the ability to:
46-
1. pause an invocation upon a long-running function call.
47-
2. resume an invocation from the last event, if it's paused or failed midway
48-
through.
49-
50-
Note: ADK resumes the invocation in a best-effort manner:
51-
1. Tool call to resume needs to be idempotent because we only guarantee
52-
an at-least-once behavior once resumed.
53-
2. Any temporary / in-memory state will be lost upon resumption.
54-
"""
55-
56-
is_resumable: bool = False
57-
"""Whether the app supports agent resumption.
58-
If enabled, the feature will be enabled for all agents in the app.
59-
"""
60-
61-
62-
@experimental
63-
class EventsCompactionConfig(BaseModel):
64-
"""The config of event compaction for an application."""
65-
66-
model_config = ConfigDict(
67-
arbitrary_types_allowed=True,
68-
extra="forbid",
69-
)
70-
71-
summarizer: Optional[BaseEventsSummarizer] = None
72-
"""The event summarizer to use for compaction."""
73-
74-
compaction_interval: int
75-
"""The number of *new* user-initiated invocations that, once
76-
fully represented in the session's events, will trigger a compaction."""
77-
78-
overlap_size: int
79-
"""The number of preceding invocations to include from the
80-
end of the last compacted range. This creates an overlap between consecutive
81-
compacted summaries, maintaining context."""
82-
83-
token_threshold: Optional[int] = Field(
84-
default=None,
85-
gt=0,
86-
)
87-
"""Post-invocation token threshold trigger.
88-
89-
If set, ADK will attempt a post-invocation compaction when the most recently
90-
observed prompt token count meets or exceeds this threshold.
91-
"""
92-
93-
event_retention_size: Optional[int] = Field(default=None, ge=0)
94-
"""Post-invocation raw event retention size.
95-
96-
If token-based post-invocation compaction is triggered, this keeps the last N
97-
raw events un-compacted.
98-
"""
99-
100-
@model_validator(mode="after")
101-
def _validate_token_params(self) -> EventsCompactionConfig:
102-
token_threshold_set = self.token_threshold is not None
103-
retention_size_set = self.event_retention_size is not None
104-
if token_threshold_set != retention_size_set:
105-
raise ValueError(
106-
"token_threshold and event_retention_size must be set together."
107-
)
108-
return self
109-
110-
11148
class App(BaseModel):
11249
"""Represents an LLM-backed agentic application.
11350

src/google/adk/apps/compaction.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,9 @@ def _events_to_compact_for_token_threshold(
270270
events_to_compact = _truncate_events_before_pending_function_call(
271271
events_to_compact, pending_ids
272272
)
273+
events_to_compact = _truncate_events_before_hitl_signal(
274+
events_to_compact, _resolved_hitl_call_ids(events)
275+
)
273276
if not events_to_compact:
274277
return []
275278

@@ -344,6 +347,45 @@ def _truncate_events_before_pending_function_call(
344347
return events
345348

346349

350+
def _resolved_hitl_call_ids(events: list[Event]) -> set[str]:
351+
"""Returns HITL call ids resolved by a later function_response in `events`."""
352+
hitl_position: dict[str, int] = {}
353+
resolved: set[str] = set()
354+
for index, event in enumerate(events):
355+
if event.actions:
356+
for call_id in event.actions.requested_tool_confirmations:
357+
hitl_position.setdefault(call_id, index)
358+
for call_id in event.actions.requested_auth_configs:
359+
hitl_position.setdefault(call_id, index)
360+
for resp_id in _event_function_response_ids(event):
361+
hitl_pos = hitl_position.get(resp_id)
362+
if hitl_pos is not None and index > hitl_pos:
363+
resolved.add(resp_id)
364+
return resolved
365+
366+
367+
def _is_pending_hitl(event: Event, resolved_call_ids: set[str]) -> bool:
368+
"""Returns True if the event has an HITL request not in `resolved_call_ids`."""
369+
if not event.actions:
370+
return False
371+
requested = set(event.actions.requested_tool_confirmations) | set(
372+
event.actions.requested_auth_configs
373+
)
374+
if not requested:
375+
return False
376+
return bool(requested - resolved_call_ids)
377+
378+
379+
def _truncate_events_before_hitl_signal(
380+
events: list[Event], resolved_call_ids: set[str]
381+
) -> list[Event]:
382+
"""Returns the leading contiguous events before any pending HITL request."""
383+
for index, event in enumerate(events):
384+
if _is_pending_hitl(event, resolved_call_ids):
385+
return events[:index]
386+
return events
387+
388+
347389
def _safe_token_compaction_split_index(
348390
*,
349391
candidate_events: list[Event],
@@ -631,6 +673,9 @@ async def _run_compaction_for_sliding_window(
631673
events_to_compact = _truncate_events_before_pending_function_call(
632674
events_to_compact, pending_ids
633675
)
676+
events_to_compact = _truncate_events_before_hitl_signal(
677+
events_to_compact, _resolved_hitl_call_ids(events)
678+
)
634679

635680
if not events_to_compact:
636681
return None

0 commit comments

Comments
 (0)