Skip to content

Commit 90816a6

Browse files
Merge branch 'main' into encrypt-google-credential-in-session
2 parents f5aacd3 + d8d8a6e commit 90816a6

4 files changed

Lines changed: 195 additions & 8 deletions

File tree

contributing/samples/adk_team/adk_issue_monitoring_agent/main.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
import asyncio
1616
import logging
1717
import re
18+
import sys
1819
import time
20+
from typing import Literal
1921

2022
from adk_issue_monitoring_agent.agent import root_agent
2123
from adk_issue_monitoring_agent.settings import BOT_ALERT_SIGNATURE
@@ -40,12 +42,17 @@
4042
APP_NAME = "issue_monitoring_app"
4143
USER_ID = "issue_monitoring_user"
4244

45+
# An issue is skipped when there is deliberately nothing to review, which is a
46+
# normal outcome and not a failure.
47+
_Outcome = Literal["audited", "skipped", "failed"]
48+
4349

4450
async def process_single_issue(
4551
runner: InMemoryRunner, issue_number: int, maintainers: list[str]
46-
) -> tuple[float, int]:
52+
) -> tuple[float, int, _Outcome]:
4753
start_time = time.perf_counter()
4854
start_api_calls = get_api_call_count()
55+
outcome: _Outcome = "audited"
4956

5057
try:
5158
# 1. Fetch the main issue AND the comments
@@ -87,6 +94,7 @@ async def process_single_issue(
8794
return (
8895
time.perf_counter() - start_time,
8996
get_api_call_count() - start_api_calls,
97+
"skipped",
9098
)
9199

92100
if (
@@ -111,6 +119,7 @@ async def process_single_issue(
111119
return (
112120
time.perf_counter() - start_time,
113121
get_api_call_count() - start_api_calls,
122+
"skipped",
114123
)
115124

116125
logger.info(
@@ -147,14 +156,15 @@ async def process_single_issue(
147156

148157
except Exception as e:
149158
logger.error(f"Error processing issue #{issue_number}: {e}", exc_info=True)
159+
outcome = "failed"
150160

151161
# Calculate duration and API calls regardless of success or failure
152162
duration = time.perf_counter() - start_time
153163
issue_api_calls = get_api_call_count() - start_api_calls
154-
return duration, issue_api_calls
164+
return duration, issue_api_calls, outcome
155165

156166

157-
async def main():
167+
async def main() -> int:
158168
logger.info(f"--- Starting Issue Monitoring Agent for {OWNER}/{REPO} ---")
159169
reset_api_call_count()
160170

@@ -164,25 +174,29 @@ async def main():
164174
logger.info(f"Found {len(maintainers)} maintainers.")
165175
except Exception as e:
166176
logger.critical(f"Failed to fetch maintainers: {e}")
167-
return
177+
return 1
168178

169179
# Step 2: Fetch target issues
170180
try:
171181
all_issues = get_target_issues(OWNER, REPO)
172182
except Exception as e:
173183
logger.critical(f"Failed to fetch issue list: {e}")
174-
return
184+
return 1
175185

176186
total_count = len(all_issues)
177187
if total_count == 0:
178188
logger.info("No issues matched criteria. Run finished.")
179-
return
189+
return 0
180190

181191
logger.info(f"Found {total_count} issues to process.")
182192

183193
# Initialize the runner ONCE for the entire run
184194
runner = InMemoryRunner(agent=root_agent, app_name=APP_NAME)
185195

196+
audited_count = 0
197+
skipped_count = 0
198+
failed_count = 0
199+
186200
# Step 3: Iterate through issues async 'CONCURRENCY_LIMIT' at a time
187201
for i in range(0, total_count, CONCURRENCY_LIMIT):
188202
chunk = all_issues[i : i + CONCURRENCY_LIMIT]
@@ -192,13 +206,28 @@ async def main():
192206
process_single_issue(runner, issue_num, maintainers)
193207
for issue_num in chunk
194208
]
195-
await asyncio.gather(*tasks)
209+
results = await asyncio.gather(*tasks)
210+
211+
for _, _, outcome in results:
212+
if outcome == "audited":
213+
audited_count += 1
214+
elif outcome == "skipped":
215+
skipped_count += 1
216+
else:
217+
failed_count += 1
196218

197219
if (i + CONCURRENCY_LIMIT) < total_count:
198220
await asyncio.sleep(SLEEP_BETWEEN_CHUNKS)
199221

200222
logger.info(f"--- Run Finished. Total API calls: {get_api_call_count()} ---")
223+
logger.info(f"Successfully processed {audited_count} issues.")
224+
if skipped_count:
225+
logger.info(f"Skipped {skipped_count} issues.")
226+
if failed_count:
227+
logger.error(f"Failed to process {failed_count} issues.")
228+
229+
return 1 if failed_count else 0
201230

202231

203232
if __name__ == "__main__":
204-
asyncio.run(main())
233+
sys.exit(asyncio.run(main()))

src/google/adk/tools/_gemini_schema_util.py

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

1515
from __future__ import annotations
1616

17+
import json
1718
import re
1819
from typing import Any
1920
from typing import Optional
@@ -100,6 +101,21 @@ def _sanitize_schema_type(
100101
if is_array:
101102
schema.setdefault("items", {"type": "string"})
102103

104+
effective_type = schema_type
105+
if isinstance(schema_type, list):
106+
non_null = [t for t in schema_type if t != "null"]
107+
effective_type = non_null[0] if non_null else None
108+
if effective_type == "string" and isinstance(schema.get("enum"), list):
109+
# Gemini rejects non-string enum values on a string-typed field; some
110+
# servers emit integer enums on string fields, so render them in their
111+
# JSON form. A null member is dropped: nullability is carried by the
112+
# schema type, not by an enum entry.
113+
schema["enum"] = [
114+
v if isinstance(v, str) else json.dumps(v)
115+
for v in schema["enum"]
116+
if v is not None
117+
]
118+
103119
return schema
104120

105121

tests/unittests/test_samples.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,3 +354,102 @@ async def run_async(
354354
assert ("Failed to process 1 issues." in caplog.text) == (
355355
failing_issue is not None
356356
)
357+
358+
359+
@pytest.mark.parametrize(
360+
"failing_issue, expected_exit_code", [(None, 0), (4, 1)]
361+
)
362+
async def test_issue_monitoring_agent_separates_skips_from_failures(
363+
failing_issue: int | None,
364+
expected_exit_code: int,
365+
monkeypatch,
366+
caplog,
367+
):
368+
"""A skipped issue is not a failure, and a failed one is not a success."""
369+
for key, value in _DUMMY_ENV.items():
370+
monkeypatch.setenv(key, value)
371+
372+
reviewed: list[int] = []
373+
374+
class _FakeSession:
375+
id = "fake-session"
376+
377+
class _FakeSessionService:
378+
379+
async def create_session(
380+
self, *, user_id: str, app_name: str
381+
) -> _FakeSession:
382+
return _FakeSession()
383+
384+
class _FakeRunner:
385+
"""Stands in for InMemoryRunner, failing the audit of one issue."""
386+
387+
def __init__(self, *, agent: Any, app_name: str) -> None:
388+
self.session_service = _FakeSessionService()
389+
390+
async def run_async(
391+
self, *, user_id: str, session_id: str, new_message: types.Content
392+
) -> AsyncIterator[Event]:
393+
text = new_message.parts[0].text
394+
issue_number = int(text.split("#")[1].split(":")[0])
395+
reviewed.append(issue_number)
396+
if issue_number == failing_issue:
397+
raise RuntimeError("model backend unavailable")
398+
yield Event(
399+
author="agent",
400+
content=types.Content(
401+
role="model", parts=[types.Part(text="Not spam.")]
402+
),
403+
)
404+
405+
with _sample_module(
406+
SAMPLES_DIR / "adk_team" / "adk_issue_monitoring_agent", "main"
407+
) as main_module:
408+
# 1 and 4 are audited, 2 is skipped because the bot already alerted on it,
409+
# 3 is skipped because only a maintainer has written on it.
410+
details = {
411+
n: {"user": {"login": "maintainer"}, "body": "tracking"} for n in (2, 3)
412+
}
413+
details[1] = {"user": {"login": "outsider"}, "body": "buy things"}
414+
details[4] = {"user": {"login": "outsider"}, "body": "buy more things"}
415+
comments = {
416+
2: [{
417+
"user": {"login": main_module.BOT_NAME},
418+
"body": main_module.BOT_ALERT_SIGNATURE,
419+
}],
420+
3: [{"user": {"login": "maintainer"}, "body": "still looking"}],
421+
}
422+
423+
monkeypatch.setattr(main_module, "InMemoryRunner", _FakeRunner)
424+
monkeypatch.setattr(main_module, "SLEEP_BETWEEN_CHUNKS", 0)
425+
monkeypatch.setattr(
426+
main_module,
427+
"get_repository_maintainers",
428+
lambda owner, repo: ["maintainer"],
429+
)
430+
monkeypatch.setattr(
431+
main_module, "get_target_issues", lambda owner, repo: [1, 2, 3, 4]
432+
)
433+
monkeypatch.setattr(
434+
main_module,
435+
"get_issue_details",
436+
lambda owner, repo, issue_number: details[issue_number],
437+
)
438+
monkeypatch.setattr(
439+
main_module,
440+
"get_issue_comments",
441+
lambda owner, repo, issue_number: comments.get(issue_number, []),
442+
)
443+
with caplog.at_level(logging.INFO, logger="google_adk"):
444+
exit_code = await main_module.main()
445+
446+
assert exit_code == expected_exit_code
447+
# Every reviewable issue still reaches the agent: one failure must not abort
448+
# the batch.
449+
assert sorted(reviewed) == [1, 4]
450+
expected_successes = 2 if failing_issue is None else 1
451+
assert f"Successfully processed {expected_successes} issues." in caplog.text
452+
assert "Skipped 2 issues." in caplog.text
453+
assert ("Failed to process 1 issues." in caplog.text) == (
454+
failing_issue is not None
455+
)

tests/unittests/tools/test_gemini_schema_util.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,49 @@ def test_to_gemini_schema_enum(self):
217217
gemini_schema = _to_gemini_schema(openapi_schema)
218218
assert gemini_schema.enum == ["a", "b", "c"]
219219

220+
def test_to_gemini_schema_stringifies_int_enum_on_string_type(self):
221+
openapi_schema = {"type": "string", "enum": [256, 512, 1024]}
222+
gemini_schema = _to_gemini_schema(openapi_schema)
223+
assert gemini_schema.type == Type.STRING
224+
assert gemini_schema.enum == ["256", "512", "1024"]
225+
226+
def test_to_gemini_schema_stringifies_nested_int_enum(self):
227+
openapi_schema = {
228+
"type": "object",
229+
"properties": {
230+
"p": {"type": "string", "enum": [256, 512]},
231+
},
232+
}
233+
gemini_schema = _to_gemini_schema(openapi_schema)
234+
assert gemini_schema.properties["p"].type == Type.STRING
235+
assert gemini_schema.properties["p"].enum == ["256", "512"]
236+
237+
def test_to_gemini_schema_int_enum_on_integer_type_unchanged(self):
238+
openapi_schema = {"type": "integer", "enum": [1, 2]}
239+
gemini_schema = _to_gemini_schema(openapi_schema)
240+
assert gemini_schema.type == Type.INTEGER
241+
assert gemini_schema.enum == [1, 2]
242+
243+
def test_to_gemini_schema_nullable_string_enum(self):
244+
openapi_schema = {"type": ["string", "null"], "enum": [256]}
245+
gemini_schema = _to_gemini_schema(openapi_schema)
246+
assert gemini_schema.type == Type.STRING
247+
assert gemini_schema.nullable
248+
assert gemini_schema.enum == ["256"]
249+
250+
def test_to_gemini_schema_drops_null_enum_member(self):
251+
openapi_schema = {"type": ["string", "null"], "enum": ["a", None]}
252+
gemini_schema = _to_gemini_schema(openapi_schema)
253+
assert gemini_schema.type == Type.STRING
254+
assert gemini_schema.nullable
255+
assert gemini_schema.enum == ["a"]
256+
257+
def test_to_gemini_schema_stringifies_bool_enum_as_json(self):
258+
openapi_schema = {"type": "string", "enum": [True, False]}
259+
gemini_schema = _to_gemini_schema(openapi_schema)
260+
assert gemini_schema.type == Type.STRING
261+
assert gemini_schema.enum == ["true", "false"]
262+
220263
def test_to_gemini_schema_required(self):
221264
openapi_schema = {
222265
"type": "object",

0 commit comments

Comments
 (0)