Skip to content

Commit 54c4ecc

Browse files
KPJoshicopybara-github
authored andcommitted
feat: Adds LLM-Backed User Simulator
Details: - Adds the `LlmBackedUserSimulator` which uses an LLM to generate user prompts until it decides that the conversation is complete. - Adds unit tests for the new functionality. PiperOrigin-RevId: 823557910
1 parent 97a224f commit 54c4ecc

File tree

2 files changed

+431
-2
lines changed

2 files changed

+431
-2
lines changed

src/google/adk/evaluation/llm_backed_user_simulator.py

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

1515
from __future__ import annotations
1616

17+
import logging
1718
from typing import ClassVar
1819
from typing import Optional
1920

@@ -22,14 +23,79 @@
2223
from typing_extensions import override
2324

2425
from ..events.event import Event
26+
from ..models.llm_request import LlmRequest
2527
from ..models.registry import LLMRegistry
28+
from ..utils.context_utils import Aclosing
2629
from ..utils.feature_decorator import experimental
2730
from .conversation_scenarios import ConversationScenario
2831
from .evaluator import Evaluator
2932
from .user_simulator import BaseUserSimulatorConfig
3033
from .user_simulator import NextUserMessage
34+
from .user_simulator import Status
3135
from .user_simulator import UserSimulator
3236

37+
logger = logging.getLogger("google_adk." + __name__)
38+
39+
_AUTHOR_USER = "user"
40+
_STOP_SIGNAL = "</finished>"
41+
42+
_USER_AGENT_INSTRUCTIONS_TEMPLATE = """You are a Simulated User designed to test an AI Agent.
43+
44+
Your single most important job is to react logically to the Agent's last message.
45+
The Conversation Plan is your canonical grounding, not a script; your response MUST be dictated by what the Agent just said.
46+
47+
# Primary Operating Loop
48+
49+
You MUST follow this three-step process while thinking:
50+
51+
Step 1: Analyze what the Agent just said or did. Specifically, is the Agent asking you a question, reporting a successful or unsuccessful operation, or saying something incorrect or unexpected?
52+
53+
Step 2: Choose one action based on your analysis:
54+
* ANSWER any questions the Agent asked.
55+
* ADVANCE to the next request as per the Conversation Plan if the Agent succeeds in satisfying your current request.
56+
* INTERVENE if the Agent is yet to complete your current request and the Conversation Plan requires you to modify it.
57+
* CORRECT the Agent if it is making a mistake or failing.
58+
* END the conversation if any of the below stopping conditions are met:
59+
- The Agent has completed all your requests from the Conversation Plan.
60+
- The Agent has failed to fulfill a request *more than once*.
61+
- The Agent has performed an incorrect operation and informs you that it is unable to correct it.
62+
- The Agent ends the conversation on its own by transferring you to a *human/live agent* (NOT another AI Agent).
63+
64+
Step 3: Formulate a response based on the chosen action and the below Action Protocols and output it.
65+
66+
# Action Protocols
67+
68+
**PROTOCOL: ANSWER**
69+
* Only answer the Agent's questions using information from the Conversation Plan.
70+
* Do NOT provide any additional information the Agent did not explicitly ask for.
71+
* If you do not have the information requested by the Agent, inform the Agent. Do NOT make up information that is not in the Conversation Plan.
72+
* Do NOT advance to the next request in the Conversation Plan.
73+
74+
**PROTOCOL: ADVANCE**
75+
* Make the next request from the Conversation Plan.
76+
* Skip redundant requests already fulfilled by the Agent.
77+
78+
**PROTOCOL: INTERVENE**
79+
* Change your current request as directed by the Conversation Plan with natural phrasing.
80+
81+
**PROTOCOL: CORRECT**
82+
* Challenge illogical or incorrect statements made by the Agent.
83+
* If the Agent did an incorrect operation, ask the Agent to fix it.
84+
* If this is the FIRST time the Agent failed to satisfy your request, ask the Agent to try again.
85+
86+
**PROTOCOL: END**
87+
* End the conversation only when any of the stopping conditions are met; do NOT end prematurely.
88+
* Output `{stop_signal}` to indicate that the conversation with the AI Agents is over.
89+
90+
# Conversation Plan
91+
92+
{conversation_plan}
93+
94+
# Conversation History
95+
96+
{conversation_history}
97+
"""
98+
3399

34100
class LlmBackedUserSimulatorConfig(BaseUserSimulatorConfig):
35101
"""Contains configurations required by an LLM backed user simulator."""
@@ -40,7 +106,12 @@ class LlmBackedUserSimulatorConfig(BaseUserSimulatorConfig):
40106
)
41107

42108
model_configuration: genai_types.GenerateContentConfig = Field(
43-
default_factory=genai_types.GenerateContentConfig,
109+
default_factory=lambda: genai_types.GenerateContentConfig(
110+
thinking_config=genai_types.ThinkingConfig(
111+
include_thoughts=True,
112+
thinking_budget=10240,
113+
)
114+
),
44115
description="The configuration for the model.",
45116
)
46117

@@ -71,6 +142,75 @@ def __init__(
71142
):
72143
super().__init__(config, config_type=LlmBackedUserSimulator.config_type)
73144
self._conversation_scenario = conversation_scenario
145+
self._invocation_count = 0
146+
llm_registry = LLMRegistry()
147+
llm_class = llm_registry.resolve(self._config.model)
148+
self._llm = llm_class(model=self._config.model)
149+
150+
@classmethod
151+
def _summarize_conversation(
152+
cls,
153+
events: list[Event],
154+
) -> str:
155+
"""Summarize the conversation to add to the prompt.
156+
157+
Removes tool calls, responses, and thoughts.
158+
159+
Args:
160+
events: The conversation history to rewrite.
161+
162+
Returns:
163+
The summarized conversation history as a string.
164+
"""
165+
rewritten_dialogue = []
166+
for e in events:
167+
if not e.content or not e.content.parts:
168+
continue
169+
author = e.author
170+
for part in e.content.parts:
171+
if part.text and not part.thought:
172+
rewritten_dialogue.append(f"{author}: {part.text}")
173+
174+
return "\n\n".join(rewritten_dialogue)
175+
176+
async def _get_llm_response(
177+
self,
178+
rewritten_dialogue: str,
179+
) -> str:
180+
"""Sends a user message generation request to the LLM and returns the full response."""
181+
if self._invocation_count == 0:
182+
# first invocation - send the static starting prompt
183+
return self._conversation_scenario.starting_prompt
184+
185+
user_agent_instructions = _USER_AGENT_INSTRUCTIONS_TEMPLATE.format(
186+
stop_signal=_STOP_SIGNAL,
187+
conversation_plan=self._conversation_scenario.conversation_plan,
188+
conversation_history=rewritten_dialogue,
189+
)
190+
191+
llm_request = LlmRequest(
192+
model=self._config.model,
193+
config=self._config.model_configuration,
194+
contents=[
195+
genai_types.Content(
196+
parts=[
197+
genai_types.Part(text=user_agent_instructions),
198+
],
199+
role=_AUTHOR_USER,
200+
),
201+
],
202+
)
203+
204+
response = ""
205+
async with Aclosing(self._llm.generate_content_async(llm_request)) as agen:
206+
async for llm_response in agen:
207+
generated_content: genai_types.Content = llm_response.content
208+
if not generated_content.parts:
209+
continue
210+
for part in generated_content.parts:
211+
if part.text and not part.thought:
212+
response += part.text
213+
return response
74214

75215
@override
76216
async def get_next_user_message(
@@ -86,8 +226,48 @@ async def get_next_user_message(
86226
Returns:
87227
A NextUserMessage object containing the next user message to send to the
88228
agent, or a status indicating why no message was generated.
229+
230+
Raises:
231+
RuntimeError: If the user agent fails to generate a message. This is not a
232+
valid result for the LLM backed user simulator and is different from the
233+
NO_MESSAGE_GENERATED status.
89234
"""
90-
raise NotImplementedError()
235+
# check invocation limit
236+
invocation_limit = self._config.max_allowed_invocations
237+
if invocation_limit >= 0 and self._invocation_count >= invocation_limit:
238+
logger.warning(
239+
"LlmBackedUserSimulator invocation limit (%d) reached!",
240+
invocation_limit,
241+
)
242+
return NextUserMessage(status=Status.TURN_LIMIT_REACHED)
243+
244+
# rewrite events for the user simulator
245+
rewritten_dialogue = self._summarize_conversation(events)
246+
247+
# query the LLM for the next user message
248+
response = await self._get_llm_response(rewritten_dialogue)
249+
self._invocation_count += 1
250+
251+
# is the conversation over? (Has the user simulator output the stop signal?)
252+
if _STOP_SIGNAL.lower() in response.lower():
253+
logger.info(
254+
"Stopping user message generation as the stop signal was detected."
255+
)
256+
return NextUserMessage(status=Status.STOP_SIGNAL_DETECTED)
257+
258+
# is the response non-empty?
259+
if response:
260+
return NextUserMessage(
261+
status=Status.SUCCESS,
262+
# return message as user content
263+
user_message=genai_types.Content(
264+
parts=[genai_types.Part(text=response)], role=_AUTHOR_USER
265+
),
266+
)
267+
268+
# if we are here, the user agent failed to generate a message, which is not
269+
# a valid result for the LLM backed user simulator.
270+
raise RuntimeError("Failed to generate a user message")
91271

92272
@override
93273
def get_simulation_evaluator(

0 commit comments

Comments
 (0)