mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
chore: Refactor runner to infer invocation_id from FunctionResponse Event for HITL resuming
invocation_id is no longer required in resuming case, unless no new_message is provided. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 875432024
This commit is contained in:
committed by
Copybara-Service
parent
de4dee899c
commit
5f806ed73a
@@ -396,23 +396,20 @@ class InvocationContext(BaseModel):
|
||||
return False
|
||||
|
||||
# TODO: Move this method from invocation_context to a dedicated module.
|
||||
# TODO: Converge this method with find_matching_function_call in llm_flows.
|
||||
def _find_matching_function_call(
|
||||
self, function_response_event: Event
|
||||
) -> Optional[Event]:
|
||||
"""Finds the function call event in the current invocation that matches the function response id."""
|
||||
from ..flows.llm_flows.functions import find_event_by_function_call_id
|
||||
|
||||
function_responses = function_response_event.get_function_responses()
|
||||
if not function_responses:
|
||||
return None
|
||||
function_call_id = function_responses[0].id
|
||||
|
||||
events = self._get_events(current_invocation=True)
|
||||
# The last event is function_response_event, so we search backwards from the
|
||||
# one before it.
|
||||
for event in reversed(events[:-1]):
|
||||
if any(fc.id == function_call_id for fc in event.get_function_calls()):
|
||||
return event
|
||||
return None
|
||||
# Search backwards from the event before the current response event.
|
||||
return find_event_by_function_call_id(
|
||||
self._get_events(current_invocation=True)[:-1], function_responses[0].id
|
||||
)
|
||||
|
||||
|
||||
def new_invocation_context_id() -> str:
|
||||
|
||||
@@ -37,7 +37,6 @@ from google.adk.tools.computer_use.computer_use_tool import ComputerUseTool
|
||||
from google.genai import types
|
||||
|
||||
from ...agents.active_streaming_tool import ActiveStreamingTool
|
||||
from ...agents.invocation_context import InvocationContext
|
||||
from ...agents.live_request_queue import LiveRequestQueue
|
||||
from ...auth.auth_tool import AuthConfig
|
||||
from ...auth.auth_tool import AuthToolArguments
|
||||
@@ -52,6 +51,7 @@ from ...tools.tool_context import ToolContext
|
||||
from ...utils.context_utils import Aclosing
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...agents.invocation_context import InvocationContext
|
||||
from ...agents.llm_agent import LlmAgent
|
||||
|
||||
AF_FUNCTION_CALL_ID_PREFIX = 'adk-'
|
||||
@@ -1157,6 +1157,18 @@ def merge_parallel_function_response_events(
|
||||
return merged_event
|
||||
|
||||
|
||||
def find_event_by_function_call_id(
|
||||
events: list[Event],
|
||||
function_call_id: str,
|
||||
) -> Optional[Event]:
|
||||
"""Finds the function call event that matches the function call id."""
|
||||
for event in reversed(events):
|
||||
for function_call in event.get_function_calls():
|
||||
if function_call.id == function_call_id:
|
||||
return event
|
||||
return None
|
||||
|
||||
|
||||
def find_matching_function_call(
|
||||
events: list[Event],
|
||||
) -> Optional[Event]:
|
||||
@@ -1165,25 +1177,8 @@ def find_matching_function_call(
|
||||
return None
|
||||
|
||||
last_event = events[-1]
|
||||
if (
|
||||
last_event.content
|
||||
and last_event.content.parts
|
||||
and any(part.function_response for part in last_event.content.parts)
|
||||
):
|
||||
function_responses = last_event.get_function_responses()
|
||||
if not function_responses:
|
||||
return None
|
||||
|
||||
function_call_id = next(
|
||||
part.function_response.id
|
||||
for part in last_event.content.parts
|
||||
if part.function_response
|
||||
)
|
||||
for i in range(len(events) - 2, -1, -1):
|
||||
event = events[i]
|
||||
# looking for the system long-running request euc function call
|
||||
function_calls = event.get_function_calls()
|
||||
if not function_calls:
|
||||
continue
|
||||
|
||||
for function_call in function_calls:
|
||||
if function_call.id == function_call_id:
|
||||
return event
|
||||
return None
|
||||
return find_event_by_function_call_id(events[:-1], function_responses[0].id)
|
||||
|
||||
+78
-23
@@ -49,6 +49,7 @@ from .errors.session_not_found_error import SessionNotFoundError
|
||||
from .events.event import Event
|
||||
from .events.event import EventActions
|
||||
from .flows.llm_flows import contents
|
||||
from .flows.llm_flows.functions import find_event_by_function_call_id
|
||||
from .flows.llm_flows.functions import find_matching_function_call
|
||||
from .memory.base_memory_service import BaseMemoryService
|
||||
from .memory.in_memory_memory_service import InMemoryMemoryService
|
||||
@@ -70,6 +71,16 @@ def _is_tool_call_or_response(event: Event) -> bool:
|
||||
return bool(event.get_function_calls() or event.get_function_responses())
|
||||
|
||||
|
||||
def _get_function_responses_from_content(
|
||||
content: types.Content,
|
||||
) -> list[types.FunctionResponse]:
|
||||
if not content:
|
||||
return []
|
||||
return [
|
||||
part.function_response for part in content.parts if part.function_response
|
||||
]
|
||||
|
||||
|
||||
def _is_transcription(event: Event) -> bool:
|
||||
return (
|
||||
event.input_transcription is not None
|
||||
@@ -341,6 +352,35 @@ class Runner:
|
||||
self._app_name_alignment_hint = f'{mismatch_details} {resolution}'
|
||||
logger.warning('App name mismatch detected. %s', mismatch_details)
|
||||
|
||||
def _resolve_invocation_id(
|
||||
self,
|
||||
session: Session,
|
||||
new_message: Optional[types.Content],
|
||||
invocation_id: Optional[str],
|
||||
) -> Optional[str]:
|
||||
"""Infers invocation_id from new_message if it is a function response."""
|
||||
function_responses = _get_function_responses_from_content(new_message)
|
||||
if not function_responses:
|
||||
return invocation_id
|
||||
|
||||
fc_event = find_event_by_function_call_id(
|
||||
session.events, function_responses[0].id
|
||||
)
|
||||
if not fc_event:
|
||||
raise ValueError(
|
||||
'Function call event not found for function response id:'
|
||||
f' {function_responses[0].id}'
|
||||
)
|
||||
|
||||
if invocation_id and invocation_id != fc_event.invocation_id:
|
||||
logger.warning(
|
||||
'Provided invocation_id %s is ignored because new_message has a '
|
||||
'function response with invocation_id %s.',
|
||||
invocation_id,
|
||||
fc_event.invocation_id,
|
||||
)
|
||||
return fc_event.invocation_id
|
||||
|
||||
def _format_session_not_found_message(self, session_id: str) -> str:
|
||||
message = f'Session not found: {session_id}'
|
||||
if not self._app_name_alignment_hint:
|
||||
@@ -497,6 +537,7 @@ class Runner:
|
||||
session = await self._get_or_create_session(
|
||||
user_id=user_id, session_id=session_id
|
||||
)
|
||||
|
||||
if not invocation_id and not new_message:
|
||||
raise ValueError(
|
||||
'Running an agent requires either a new_message or an '
|
||||
@@ -504,35 +545,49 @@ class Runner:
|
||||
f'Session: {session_id}, User: {user_id}'
|
||||
)
|
||||
|
||||
if invocation_id:
|
||||
if (
|
||||
not self.resumability_config
|
||||
or not self.resumability_config.is_resumable
|
||||
):
|
||||
raise ValueError(
|
||||
f'invocation_id: {invocation_id} is provided but the app is not'
|
||||
' resumable.'
|
||||
)
|
||||
invocation_context = await self._setup_context_for_resumed_invocation(
|
||||
session=session,
|
||||
new_message=new_message,
|
||||
invocation_id=invocation_id,
|
||||
run_config=run_config,
|
||||
state_delta=state_delta,
|
||||
is_resumable = (
|
||||
self.resumability_config and self.resumability_config.is_resumable
|
||||
)
|
||||
if not is_resumable and not new_message:
|
||||
raise ValueError(
|
||||
'Running an agent requires a new_message or a resumable app. '
|
||||
f'Session: {session_id}, User: {user_id}'
|
||||
)
|
||||
if invocation_context.end_of_agents.get(
|
||||
invocation_context.agent.name
|
||||
):
|
||||
# Directly return if the current agent in invocation context is
|
||||
# already final.
|
||||
return
|
||||
else:
|
||||
|
||||
if not is_resumable:
|
||||
invocation_context = await self._setup_context_for_new_invocation(
|
||||
session=session,
|
||||
new_message=new_message, # new_message is not None.
|
||||
new_message=new_message,
|
||||
run_config=run_config,
|
||||
state_delta=state_delta,
|
||||
)
|
||||
else:
|
||||
invocation_id = self._resolve_invocation_id(
|
||||
session, new_message, invocation_id
|
||||
)
|
||||
if not invocation_id:
|
||||
invocation_context = await self._setup_context_for_new_invocation(
|
||||
session=session,
|
||||
new_message=new_message,
|
||||
run_config=run_config,
|
||||
state_delta=state_delta,
|
||||
)
|
||||
else:
|
||||
invocation_context = (
|
||||
await self._setup_context_for_resumed_invocation(
|
||||
session=session,
|
||||
new_message=new_message,
|
||||
invocation_id=invocation_id,
|
||||
run_config=run_config,
|
||||
state_delta=state_delta,
|
||||
)
|
||||
)
|
||||
if invocation_context.end_of_agents.get(
|
||||
invocation_context.agent.name
|
||||
):
|
||||
# Directly return if the current agent in invocation context is
|
||||
# already final.
|
||||
return
|
||||
|
||||
async def execute(ctx: InvocationContext) -> AsyncGenerator[Event]:
|
||||
async with Aclosing(ctx.agent.run_async(ctx)) as agen:
|
||||
|
||||
@@ -502,6 +502,86 @@ class TestHITLConfirmationFlowWithResumableApp:
|
||||
== expected_parts_final
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_and_resume_on_request_confirmation_without_invocation_id(
|
||||
self,
|
||||
runner: testing_utils.InMemoryRunner,
|
||||
agent: LlmAgent,
|
||||
):
|
||||
"""Tests HITL flow where all tool calls are confirmed."""
|
||||
events = runner.run("test user query")
|
||||
|
||||
# Verify that the invocation is paused when tool confirmation is requested.
|
||||
# The tool call returns error response, and summarization was skipped.
|
||||
assert testing_utils.simplify_resumable_app_events(
|
||||
copy.deepcopy(events)
|
||||
) == [
|
||||
(
|
||||
agent.name,
|
||||
Part(function_call=FunctionCall(name=agent.tools[0].name, args={})),
|
||||
),
|
||||
(
|
||||
agent.name,
|
||||
Part(
|
||||
function_call=FunctionCall(
|
||||
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
||||
args={
|
||||
"originalFunctionCall": {
|
||||
"name": agent.tools[0].name,
|
||||
"id": mock.ANY,
|
||||
"args": {},
|
||||
},
|
||||
"toolConfirmation": {
|
||||
"hint": HINT_TEXT,
|
||||
"confirmed": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
),
|
||||
),
|
||||
(
|
||||
agent.name,
|
||||
Part(
|
||||
function_response=FunctionResponse(
|
||||
name=agent.tools[0].name, response=TOOL_CALL_ERROR_RESPONSE
|
||||
)
|
||||
),
|
||||
),
|
||||
]
|
||||
ask_for_confirmation_function_call_id = (
|
||||
events[1].content.parts[0].function_call.id
|
||||
)
|
||||
invocation_id = events[1].invocation_id
|
||||
user_confirmation = testing_utils.UserContent(
|
||||
Part(
|
||||
function_response=FunctionResponse(
|
||||
id=ask_for_confirmation_function_call_id,
|
||||
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
||||
response={"confirmed": True},
|
||||
)
|
||||
)
|
||||
)
|
||||
events = await runner.run_async(user_confirmation)
|
||||
expected_parts_final = [
|
||||
(
|
||||
agent.name,
|
||||
Part(
|
||||
function_response=FunctionResponse(
|
||||
name=agent.tools[0].name,
|
||||
response={"result": "confirmed=True"},
|
||||
)
|
||||
),
|
||||
),
|
||||
(agent.name, "test llm response after tool call"),
|
||||
(agent.name, testing_utils.END_OF_AGENT),
|
||||
]
|
||||
for event in events:
|
||||
assert event.invocation_id == invocation_id
|
||||
assert (
|
||||
testing_utils.simplify_resumable_app_events(copy.deepcopy(events))
|
||||
== expected_parts_final
|
||||
)
|
||||
|
||||
|
||||
class TestHITLConfirmationFlowWithSequentialAgentAndResumableApp:
|
||||
"""Tests the HITL confirmation flow with a resumable sequential agent app."""
|
||||
|
||||
Reference in New Issue
Block a user