Skip to content

Commit f307148

Browse files
Merge pull request #230 from GoogleCloudPlatform/gemini-cli-evals
Add Simulated LLM user for true multi turn conversation
2 parents d486608 + 8c5e225 commit f307148

10 files changed

Lines changed: 338 additions & 114 deletions

File tree

datasets/gemini-cli-tools/example_run_config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ setup_directory: datasets/gemini-cli-tools/setup
88
# Orchestrator Configuration
99
orchestrator: geminicli
1010
model_config: datasets/model_configs/gemini_cli_model.yaml
11+
simulated_user_model_config: datasets/model_configs/gemini_2.5_pro_model.yaml
1112

1213
############################################################
1314
### Scorer Related Configs

datasets/gemini-cli-tools/gemini-cli.evalset.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
{
44
"id": "cloud-sql-debug-01",
55
"starting_prompt": "list all instances in project astana-evaluation",
6-
"conversation_plan": "Ask the agent to list instances in project astana-evaluation.",
7-
"expected_trajectory": ["list_instances"],
6+
"conversation_plan": "Ask the agent to list instances in project astana-evaluation. Once all instances are listed if nl2code exist get its state and validate its RUNNABLE",
7+
"expected_trajectory": ["list_instances", "get_instance"],
88
"env": {
99
"GOOGLE_CLOUD_PROJECT": "astana-evaluation"
1010
},
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
gemini_cli_version: "@google/gemini-cli@0.23.0"
1+
gemini_cli_version: "@google/gemini-cli@0.25.1"
22
generator: gemini_cli

evalbench/evaluator/agentevaluator.py

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
from util.config import load_yaml_config
99
from mp import mprunner
1010
from work.agentgenwork import AgentGenWork
11+
from evaluator.simulateduser import SimulatedUser
12+
from work.agentscorework import AgentScoreWork
13+
import json
14+
import subprocess
15+
from typing import Dict
1116

1217

1318
class AgentEvaluator:
@@ -68,7 +73,14 @@ def _evaluate_gemini_cli(
6873
}
6974

7075
for item in dataset:
71-
work = AgentGenWork(self.generator, self.agent_version, item, job_id=job_id, metadata=metadata)
76+
simulated_user = SimulatedUser(self.config)
77+
work = AgentGenWork(
78+
processor=self.process_scenario,
79+
eval_result=item,
80+
job_id=job_id,
81+
metadata=metadata,
82+
simulated_user=simulated_user
83+
)
7284
self.agentrunner.execute_work(work)
7385

7486
for future in concurrent.futures.as_completed(self.agentrunner.futures):
@@ -80,3 +92,117 @@ def _evaluate_gemini_cli(
8092
scoring_results.extend(item.scoring_results)
8193

8294
return eval_outputs, scoring_results
95+
96+
def process_scenario(
97+
self,
98+
scenario: Dict[str, Any],
99+
eval_result: Any,
100+
job_id: str,
101+
metadata: Dict[str, Any],
102+
simulated_user: Any = None
103+
):
104+
"""Processes a single scenario."""
105+
current_prompt = scenario["starting_prompt"]
106+
env = scenario.get("env", {})
107+
max_turns = scenario.get("max_turns", 1)
108+
conversation_plan = scenario.get("conversation_plan", "")
109+
conversation_history = []
110+
accumulated_tools = []
111+
last_result = None
112+
113+
for turn in range(max_turns):
114+
logging.info(f"Turn {turn + 1}/{max_turns} - Prompt: {current_prompt}")
115+
116+
if isinstance(self.generator, GeminiCliGenerator):
117+
cli_cmd = self.generator.create_command(
118+
cli=self.agent_version,
119+
prompt=current_prompt,
120+
env=env,
121+
resume=(turn > 0)
122+
)
123+
result = self.generator.safe_generate(cli_cmd)
124+
else:
125+
result = self.generator.generate(current_prompt)
126+
127+
last_result = result
128+
129+
self._log_cli_result(turn, max_turns, result)
130+
131+
tools = []
132+
if isinstance(self.generator, GeminiCliGenerator):
133+
tools = self.generator.extract_tools(result.stdout)
134+
accumulated_tools.extend(tools)
135+
136+
conversation_history.append({
137+
"user": current_prompt,
138+
"agent": result.stdout
139+
})
140+
141+
if turn < max_turns - 1:
142+
if simulated_user:
143+
next_response = simulated_user.get_next_response(
144+
conversation_plan,
145+
conversation_history,
146+
result.stdout
147+
)
148+
if "TERMINATE" in next_response:
149+
logging.info("Simulated user terminated conversation.")
150+
break
151+
current_prompt = next_response
152+
else:
153+
break
154+
155+
if last_result:
156+
self._finalize_scenario(
157+
scenario,
158+
last_result,
159+
conversation_history,
160+
accumulated_tools,
161+
eval_result,
162+
job_id,
163+
metadata
164+
)
165+
166+
def _log_cli_result(self, turn: int, max_turns: int, result: subprocess.CompletedProcess):
167+
logging.info(f"Turn {turn + 1}/{max_turns} - Gemini CLI exit code: {result.returncode}")
168+
logging.info(f"Turn {turn + 1}/{max_turns} - Gemini CLI stdout: {result.stdout}")
169+
logging.info(f"Turn {turn + 1}/{max_turns} - Gemini CLI stderr: {result.stderr}")
170+
171+
def _finalize_scenario(
172+
self,
173+
scenario: Dict[str, Any],
174+
last_result: subprocess.CompletedProcess,
175+
conversation_history: List[Dict[str, str]],
176+
accumulated_tools: List[str],
177+
eval_result: Any,
178+
job_id: str,
179+
metadata: Dict[str, Any]
180+
):
181+
"""Finalizes the scenario by scoring and appending results."""
182+
# Prepare intermediate eval_output with all necessary data for scoring
183+
eval_output_data = {
184+
"eval_id": scenario["id"],
185+
"stdout": last_result.stdout,
186+
"stderr": last_result.stderr,
187+
"returncode": last_result.returncode,
188+
"prompt_generator_error": None,
189+
"generated_error": None,
190+
"sql_generator_error": None,
191+
"golden_error": None,
192+
"generated_sql": "skipped",
193+
"prompt": scenario["starting_prompt"],
194+
"conversation_history": json.dumps(conversation_history, indent=2),
195+
"scenario": scenario,
196+
"accumulated_tools": accumulated_tools,
197+
"job_id": job_id,
198+
"metadata": metadata
199+
}
200+
201+
score_work = AgentScoreWork(
202+
config=metadata,
203+
eval_output=eval_output_data,
204+
scoring_results=eval_result.scoring_results
205+
)
206+
score_work.run()
207+
208+
eval_result.agent_results.append(eval_output_data)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import generators.models as models
2+
import generators.prompts as prompts
3+
import threading
4+
import logging
5+
6+
7+
class SimulatedUser:
8+
def __init__(self, config):
9+
self.config = config
10+
11+
global_models = {
12+
"lock": threading.Lock(),
13+
"registered_models": {}
14+
}
15+
16+
# Expect 'simulated_user_model_config' path in config
17+
model_config_path = config.get("simulated_user_model_config")
18+
19+
self.prompt_generator = prompts.get_generator(
20+
None,
21+
{"prompt_generator": "SimulatedUserPromptGenerator"},
22+
"SimulatedUserPromptGenerator"
23+
)
24+
25+
self.model_generator = None
26+
if model_config_path:
27+
try:
28+
self.model_generator = models.get_generator(
29+
global_models, model_config_path, None
30+
)
31+
except Exception as e:
32+
logging.warning(f"Failed to load simulated user model from {model_config_path}: {e}")
33+
else:
34+
logging.warning("No 'simulated_user_model_config' provided. SimulatedUser will not be able to generate responses.")
35+
36+
def get_next_response(self, conversation_plan: str, history: list, last_agent_reply: str) -> str:
37+
if not self.model_generator:
38+
logging.error("Model generator not initialized.")
39+
return "TERMINATE"
40+
41+
payload = {
42+
"conversation_plan": conversation_plan,
43+
"history": history,
44+
"last_agent_reply": last_agent_reply
45+
}
46+
47+
# Generate prompt
48+
self.prompt_generator.generate(payload)
49+
prompt = payload["prompt"]
50+
51+
# Call model
52+
response = self.model_generator.generate(prompt)
53+
return response

evalbench/generators/models/gemini_cli.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,37 @@ def _run_gemini_cli(self, cli_cmd: CLICommand):
8080
command,
8181
env=env,
8282
)
83+
84+
def parse_response(self, stdout: str) -> dict:
85+
"""Parses the JSON output from Gemini CLI."""
86+
try:
87+
return json.loads(stdout)
88+
except json.JSONDecodeError:
89+
return {}
90+
91+
def extract_tools(self, stdout: str) -> list[str]:
92+
"""Extracts the list of tools used from the CLI output."""
93+
output_json = self.parse_response(stdout)
94+
if (
95+
"stats" in output_json
96+
and "tools" in output_json["stats"]
97+
and "byName" in output_json["stats"]["tools"]
98+
):
99+
return list(output_json["stats"]["tools"]["byName"].keys())
100+
return []
101+
102+
def safe_generate(self, cli_cmd: CLICommand) -> subprocess.CompletedProcess:
103+
"""Runs the generation and handles empty responses."""
104+
result = self.generate(cli_cmd)
105+
if isinstance(result, str) and not result:
106+
return subprocess.CompletedProcess(
107+
args=[cli_cmd.cli],
108+
returncode=1,
109+
stdout="",
110+
stderr="Error: Generator returned empty response (possibly resource exhausted).",
111+
)
112+
return result
113+
114+
def create_command(self, cli: str, prompt: str, env: dict = None, resume: bool = False) -> CLICommand:
115+
"""Creates a CLICommand object."""
116+
return CLICommand(cli=cli, prompt=prompt, env=env, resume=resume)

evalbench/generators/prompts/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .passthrough import NOOPGenerator
33
from .interactsystem import InteractSystemGenerator
44
from .interactuser import InteractUserGenerator
5+
from .simulateduser import SimulatedUserPromptGenerator
56
from .dataagentinteractuser import DataAgentInteractUserGenerator
67

78

@@ -17,6 +18,8 @@ def get_generator(db, promptgenerator_config, generator_name=None):
1718
return InteractSystemGenerator(db, promptgenerator_config)
1819
if promptgenerator_config["prompt_generator"] == "InteractUserGenerator":
1920
return InteractUserGenerator(db, promptgenerator_config)
21+
if promptgenerator_config["prompt_generator"] == "SimulatedUserPromptGenerator":
22+
return SimulatedUserPromptGenerator(db, promptgenerator_config)
2023
if promptgenerator_config["prompt_generator"] == "DataAgentInteractUserGenerator":
2124
return DataAgentInteractUserGenerator(db, promptgenerator_config)
2225
raise ValueError("Prompt Generator not Supported")
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from .generator import PromptGenerator
2+
3+
SIMULATED_USER_PROMPT = """You are a simulated user interacting with Gemini CLI agent.
4+
Your objective is to follow the conversation plan provided below and engage with the agent naturally to achieve the goals.
5+
6+
# Conversation Plan:
7+
[[conversation_plan]]
8+
9+
# Interaction History:
10+
[[history]]
11+
12+
# Last Agent Reply:
13+
[[last_agent_reply]]
14+
15+
Based on the plan and the agent's last reply, provide your next input to the CLI agent.
16+
If the plan is fully completed or you cannot proceed, reply with "TERMINATE".
17+
Only provide the text command or response. Do not include markdown formatting or explanations unless necessary for the command.
18+
"""
19+
20+
21+
class SimulatedUserPromptGenerator(PromptGenerator):
22+
def __init__(self, db, promptgenerator_config):
23+
super().__init__(db, promptgenerator_config)
24+
self.prompt_template = SIMULATED_USER_PROMPT
25+
26+
def setup(self):
27+
pass
28+
29+
def generate(self, item):
30+
# item is the payload dictionary
31+
plan = item.get("conversation_plan", "")
32+
history_list = item.get("history", [])
33+
last_reply = item.get("last_agent_reply", "")
34+
35+
# Format history
36+
history_str = ""
37+
for turn in history_list:
38+
history_str += f"User: {turn['user']}\nAgent: {turn['agent']}\n"
39+
40+
prompt = self.prompt_template.replace("[[conversation_plan]]", str(plan))
41+
prompt = prompt.replace("[[history]]", history_str)
42+
prompt = prompt.replace("[[last_agent_reply]]", str(last_reply))
43+
44+
item["prompt"] = prompt
45+
return item

0 commit comments

Comments
 (0)