[fix] Keep Google Calendar slots within working hours (#10878)

## Summary

Fixes #10880.

`find_available_slots` can offer 16:30–17:15 for a 45-minute appointment
when working hours end at 17:00. Comparing only the end hour also admits
appointments that cross midnight. Compare the complete slot end with the
closing time on its start date, preserving appointments that end exactly
at closing time and custom working hours.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [ ] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing open pull requests and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Validation in a fresh Python 3.12 environment:

- Before the fix, six new regression cases failed and the 30-minute
control passed.
- After the fix, all 70 tests in
`libs/agno/tests/unit/tools/test_google_calendar.py` passed.
- Changed files pass Ruff lint, Ruff formatting, and `git diff --check`.
- `scripts/format.sh` passed.
- `scripts/validate.sh` passed: all Ruff checks, mypy for 1,091 Agno and
21 agnoctl source files, and the cookbook pattern check.

The tests cover minute overflow, exact closing time, a full working day,
oversized and next-day appointments, and custom closing hours. No Google
credentials or network calls are used. AI tool: Codex.

Co-authored-by: Sannya Singal <[email protected]>
This commit is contained in:
Jialiang Liang
2026-10-08 12:08:58 +05:30
committed by GitHub
co-authored by Sannya Singal
parent 1a07cbcb4f
commit 718ae26fb9
2 changed files with 45 additions and 1 deletions
+2 -1
View File
@@ -610,7 +610,8 @@ class GoogleCalendarTools(GoogleToolkit):
is_available = False
break
if is_available and slot_end.hour <= working_hours_end:
working_day_end = current.replace(hour=working_hours_end, minute=0, second=0, microsecond=0)
if is_available and slot_end <= working_day_end:
available_slots.append({"start": current.isoformat(), "end": slot_end.isoformat()})
current += datetime.timedelta(minutes=30)
@@ -3,6 +3,7 @@
import json
import os
import tempfile
from datetime import datetime
from unittest.mock import MagicMock, Mock, patch
import pytest
@@ -464,6 +465,48 @@ class TestFetchAllEvents:
class TestFindAvailableSlots:
@pytest.mark.parametrize(
("duration_minutes", "end_hour", "expected_last_end", "expected_count"),
[
(30, 17, "17:00:00", 16),
(45, 17, "16:45:00", 15),
(60, 17, "17:00:00", 15),
(480, 17, "17:00:00", 1),
(481, 17, None, 0),
(1500, 17, None, 0),
(45, 18, "17:45:00", 17),
],
)
@patch.object(GoogleCalendarTools, "fetch_all_events")
@patch.object(GoogleCalendarTools, "_get_working_hours")
def test_available_slots_end_within_working_hours(
self,
mock_working_hours,
mock_fetch,
calendar_tools,
duration_minutes,
end_hour,
expected_last_end,
expected_count,
):
mock_working_hours.return_value = json.dumps(
{"start_hour": 9, "end_hour": end_hour, "timezone": "UTC", "locale": "en"}
)
mock_fetch.return_value = json.dumps([])
result = json.loads(
calendar_tools.find_available_slots("2026-10-02", "2026-10-02", duration_minutes=duration_minutes)
)
slots = result["available_slots"]
assert len(slots) == expected_count
if expected_last_end is not None:
assert slots[-1]["end"] == f"2026-10-02T{expected_last_end}+00:00"
for slot in slots:
start = datetime.fromisoformat(slot["start"])
end = datetime.fromisoformat(slot["end"])
assert end <= start.replace(hour=end_hour, minute=0, second=0, microsecond=0)
@patch.object(GoogleCalendarTools, "fetch_all_events")
@patch.object(GoogleCalendarTools, "_get_working_hours")
def test_find_available_slots_success(self, mock_working_hours, mock_fetch, calendar_tools):