| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670 |
- import json
- from datetime import datetime, timezone
- import pytest
- import agent_lab.application.events.builtin_plugins as builtin_plugins
- from agent_lab.application.events import (
- EventArgumentResolution,
- EventExecutionContext,
- EventRequest,
- ResultPolicy,
- RiskLevel,
- )
- from agent_lab.application.tools import build_default_tool_registry
- from agent_lab.domain.events import ToolCallEvent
- from agent_lab.domain.messages import ChatMessage
- BUILTIN_EVENT_NAMES = [
- "session.terminate",
- "device.volume.adjust",
- "calendar.schedule.create",
- "knowledge.web.search",
- ]
- def _event(event_id: str, name: str, arguments: dict):
- return ToolCallEvent(
- id=event_id,
- name=name,
- arguments=arguments,
- raw_arguments=json.dumps(arguments, ensure_ascii=False),
- )
- def test_default_registry_preserves_legacy_tools_and_adds_builtin_event_plugins():
- registry = build_default_tool_registry()
- assert [item["name"] for item in registry.available_tools()] == [
- "handoff_note",
- "mock_search",
- "mock_ticket",
- *BUILTIN_EVENT_NAMES,
- ]
- @pytest.mark.parametrize(
- (
- "name",
- "result_policy",
- "risk_level",
- "fallback_allowed",
- "idempotency_key_fields",
- "concurrency_class",
- "conflict_keys",
- "timeout_seconds",
- "terminal",
- ),
- [
- (
- "session.terminate",
- ResultPolicy.TERMINATE,
- RiskLevel.HIGH,
- False,
- ("event_id",),
- "session-lifecycle",
- ("session",),
- 5.0,
- True,
- ),
- (
- "device.volume.adjust",
- ResultPolicy.SILENT_SUCCESS,
- RiskLevel.MEDIUM,
- True,
- ("event_id",),
- "device-volume",
- ("device.volume",),
- 5.0,
- False,
- ),
- (
- "calendar.schedule.create",
- ResultPolicy.TEMPLATE_FOLLOW_UP,
- RiskLevel.MEDIUM,
- True,
- ("event_id",),
- "schedule-write",
- ("calendar.schedule",),
- 10.0,
- False,
- ),
- (
- "knowledge.web.search",
- ResultPolicy.LLM_FOLLOW_UP,
- RiskLevel.LOW,
- False,
- (),
- "read-only",
- (),
- 10.0,
- False,
- ),
- ],
- )
- def test_builtin_definition_metadata_is_owned_by_each_flat_plugin(
- name,
- result_policy,
- risk_level,
- fallback_allowed,
- idempotency_key_fields,
- concurrency_class,
- conflict_keys,
- timeout_seconds,
- terminal,
- ):
- definition = build_default_tool_registry().event_registry.definition(name)
- assert definition is not None
- assert definition.result_policy is result_policy
- assert definition.risk_level is risk_level
- assert definition.fallback_allowed is fallback_allowed
- assert definition.idempotency_key_fields == idempotency_key_fields
- assert definition.concurrency_class == concurrency_class
- assert definition.conflict_keys == conflict_keys
- assert definition.timeout_seconds == timeout_seconds
- assert definition.terminal is terminal
- def test_session_terminate_schema_accepts_optional_reason_only():
- registry = build_default_tool_registry().event_registry
- assert registry.iter_validation_errors("session.terminate", {}) == ()
- assert registry.iter_validation_errors(
- "session.terminate", {"reason": "user requested"}
- ) == ()
- assert registry.iter_validation_errors(
- "session.terminate", {"reason": 1}
- )
- assert registry.iter_validation_errors(
- "session.terminate", {"unexpected": True}
- )
- @pytest.mark.parametrize(
- "arguments",
- [
- {"mode": "absolute", "value": 0},
- {"mode": "absolute", "value": 100},
- {"mode": "relative", "delta": -100},
- {"mode": "relative", "delta": 100},
- {"mode": "mute"},
- {"mode": "unmute"},
- ],
- )
- def test_volume_schema_accepts_mode_specific_valid_arguments(arguments):
- registry = build_default_tool_registry().event_registry
- assert registry.iter_validation_errors("device.volume.adjust", arguments) == ()
- @pytest.mark.parametrize(
- "arguments",
- [
- {"mode": "absolute"},
- {"mode": "absolute", "value": -1},
- {"mode": "absolute", "value": 101},
- {"mode": "absolute", "value": 20, "delta": 5},
- {"mode": "relative"},
- {"mode": "relative", "delta": 0},
- {"mode": "relative", "delta": 101},
- {"mode": "relative", "delta": 5, "value": 20},
- {"mode": "mute", "value": 0},
- {"mode": "unmute", "delta": 1},
- ],
- )
- def test_volume_schema_rejects_invalid_or_cross_mode_arguments(arguments):
- registry = build_default_tool_registry().event_registry
- assert registry.iter_validation_errors("device.volume.adjust", arguments)
- @pytest.mark.parametrize(
- ("content", "expected"),
- [
- ("把音量调到 35", {"mode": "absolute", "value": 35}),
- ("set volume to 42", {"mode": "absolute", "value": 42}),
- ("音量增加 15", {"mode": "relative", "delta": 15}),
- ("decrease volume by 20", {"mode": "relative", "delta": -20}),
- ("请静音", {"mode": "mute"}),
- ("please unmute", {"mode": "unmute"}),
- ],
- )
- def test_volume_resolver_recognizes_clear_chinese_and_english(content, expected):
- resolved = _resolve_text_event("device.volume.adjust", content)
- assert resolved == expected
- @pytest.mark.parametrize("content", ["把音量调高", "静音还是取消静音"])
- def test_volume_resolver_marks_incomplete_or_ambiguous_requests_for_fallback(content):
- resolved = _resolve_text_event("device.volume.adjust", content)
- assert isinstance(resolved, EventArgumentResolution)
- assert resolved.complete is False
- def test_schedule_schema_requires_explicit_rfc3339_and_timezone():
- registry = build_default_tool_registry().event_registry
- valid = {
- "title": "Design review",
- "start_at": "2026-07-14T09:30:00+08:00",
- "timezone": "Asia/Shanghai",
- "recurrence": "FREQ=WEEKLY",
- "reminder_minutes": 15,
- }
- assert registry.iter_validation_errors("calendar.schedule.create", valid) == ()
- assert registry.iter_validation_errors(
- "calendar.schedule.create",
- {**valid, "start_at": "tomorrow at nine"},
- )
- assert registry.iter_validation_errors(
- "calendar.schedule.create",
- {**valid, "timezone": "Shanghai"},
- )
- assert registry.iter_validation_errors(
- "calendar.schedule.create",
- {**valid, "reminder_minutes": -1},
- )
- @pytest.mark.parametrize(
- ("content", "expected"),
- [
- (
- 'Schedule "Design review" at 2026-07-14T09:30:00+08:00 '
- "timezone Asia/Shanghai",
- {
- "title": "Design review",
- "start_at": "2026-07-14T09:30:00+08:00",
- "timezone": "Asia/Shanghai",
- },
- ),
- (
- "标题:项目评审;开始时间:2026-07-14T09:30:00+08:00;"
- "时区:Asia/Shanghai",
- {
- "title": "项目评审",
- "start_at": "2026-07-14T09:30:00+08:00",
- "timezone": "Asia/Shanghai",
- },
- ),
- ],
- )
- def test_schedule_resolver_accepts_only_explicit_datetime_details(content, expected):
- resolved = _resolve_text_event("calendar.schedule.create", content)
- assert resolved == expected
- def test_schedule_resolver_does_not_guess_relative_time():
- resolved = _resolve_text_event(
- "calendar.schedule.create", "Schedule standup tomorrow at nine"
- )
- assert isinstance(resolved, EventArgumentResolution)
- assert resolved.complete is False
- def test_web_search_resolver_uses_latest_relevant_user_request():
- registry = build_default_tool_registry()
- definition = registry.event_registry.definition("knowledge.web.search")
- assert definition is not None
- assert definition.resolver is not None
- resolved = definition.resolver(
- EventRequest(id="search-1", name="knowledge.web.search"),
- EventExecutionContext(
- history=(
- ChatMessage(role="user", content="old query"),
- ChatMessage(role="assistant", content="assistant summary"),
- ChatMessage(role="user", content="latest focused query"),
- ChatMessage(role="assistant", content="I will search"),
- )
- ),
- )
- assert resolved == {"query": "latest focused query"}
- def test_web_search_schema_rejects_empty_query_and_out_of_range_result_limit():
- registry = build_default_tool_registry().event_registry
- assert registry.iter_validation_errors(
- "knowledge.web.search", {"query": "focused", "max_results": 1}
- ) == ()
- assert registry.iter_validation_errors(
- "knowledge.web.search", {"query": "focused", "max_results": 5}
- ) == ()
- assert registry.iter_validation_errors(
- "knowledge.web.search", {"query": ""}
- )
- assert registry.iter_validation_errors(
- "knowledge.web.search", {"query": "focused", "max_results": 0}
- )
- assert registry.iter_validation_errors(
- "knowledge.web.search", {"query": "focused", "max_results": 6}
- )
- def test_catalog_and_direct_provider_modes_share_the_same_builtin_schemas():
- registry = build_default_tool_registry()
- catalog = {item["name"]: item for item in registry.available_tools()}
- direct = {
- item["function"]["name"]: item["function"]
- for item in registry.provider_tool_schemas(BUILTIN_EVENT_NAMES)
- }
- for name in BUILTIN_EVENT_NAMES:
- assert direct[name]["description"] == catalog[name]["description"]
- assert direct[name]["parameters"] == catalog[name]["parameters"]
- class RecordingSessionPort:
- def __init__(self):
- self.calls = []
- def terminate(self, event_id, *, reason=None):
- self.calls.append((event_id, reason))
- return {"port": "session", "event_id": event_id, "reason": reason}
- class RecordingVolumePort:
- def __init__(self):
- self.calls = []
- def adjust(self, event_id, *, mode, value=None, delta=None):
- self.calls.append((event_id, mode, value, delta))
- return {
- "port": "volume",
- "event_id": event_id,
- "mode": mode,
- "value": value,
- "delta": delta,
- }
- class RecordingCalendarPort:
- def __init__(self):
- self.calls = []
- def create(
- self,
- event_id,
- *,
- title,
- start_at,
- timezone,
- recurrence=None,
- reminder_minutes=None,
- ):
- self.calls.append(
- (
- event_id,
- title,
- start_at,
- timezone,
- recurrence,
- reminder_minutes,
- )
- )
- return {"port": "calendar", "event_id": event_id, "title": title}
- class RecordingSearchPort:
- def __init__(self):
- self.calls = []
- def search(self, event_id, *, query, max_results=3):
- self.calls.append((event_id, query, max_results))
- return {"port": "search", "query": query, "max_results": max_results}
- def test_injected_sync_ports_receive_validated_arguments_and_event_ids():
- session = RecordingSessionPort()
- volume = RecordingVolumePort()
- calendar = RecordingCalendarPort()
- search = RecordingSearchPort()
- registry = build_default_tool_registry(
- session_termination_port=session,
- device_volume_port=volume,
- calendar_schedule_port=calendar,
- web_search_port=search,
- )
- assert registry.execute(
- _event("session-1", "session.terminate", {"reason": "done"})
- ) == {"port": "session", "event_id": "session-1", "reason": "done"}
- assert registry.execute(
- _event("volume-1", "device.volume.adjust", {"mode": "absolute", "value": 30})
- )["port"] == "volume"
- assert registry.execute(
- _event(
- "calendar-1",
- "calendar.schedule.create",
- {
- "title": "Review",
- "start_at": "2026-07-14T09:30:00+08:00",
- "timezone": "Asia/Shanghai",
- "reminder_minutes": 10,
- },
- )
- )["port"] == "calendar"
- assert registry.execute(
- _event(
- "search-1",
- "knowledge.web.search",
- {"query": "agent kernels", "max_results": 2},
- )
- ) == {"port": "search", "query": "agent kernels", "max_results": 2}
- assert session.calls == [("session-1", "done")]
- assert volume.calls == [("volume-1", "absolute", 30, None)]
- assert calendar.calls == [
- (
- "calendar-1",
- "Review",
- "2026-07-14T09:30:00+08:00",
- "Asia/Shanghai",
- None,
- 10,
- )
- ]
- assert search.calls == [("search-1", "agent kernels", 2)]
- @pytest.mark.asyncio
- async def test_injected_async_ports_are_awaited_for_all_builtin_plugins():
- class AsyncSessionPort(RecordingSessionPort):
- async def terminate(self, event_id, *, reason=None):
- return super().terminate(event_id, reason=reason)
- class AsyncVolumePort(RecordingVolumePort):
- async def adjust(self, event_id, *, mode, value=None, delta=None):
- return super().adjust(
- event_id, mode=mode, value=value, delta=delta
- )
- class AsyncCalendarPort(RecordingCalendarPort):
- async def create(self, event_id, **arguments):
- return super().create(event_id, **arguments)
- class AsyncSearchPort(RecordingSearchPort):
- async def search(self, event_id, *, query, max_results=3):
- return super().search(
- event_id, query=query, max_results=max_results
- )
- registry = build_default_tool_registry(
- session_termination_port=AsyncSessionPort(),
- device_volume_port=AsyncVolumePort(),
- calendar_schedule_port=AsyncCalendarPort(),
- web_search_port=AsyncSearchPort(),
- )
- events = [
- _event("session-1", "session.terminate", {}),
- _event(
- "volume-1",
- "device.volume.adjust",
- {"mode": "relative", "delta": -10},
- ),
- _event(
- "calendar-1",
- "calendar.schedule.create",
- {
- "title": "Review",
- "start_at": "2026-07-14T09:30:00+08:00",
- "timezone": "Asia/Shanghai",
- },
- ),
- _event("search-1", "knowledge.web.search", {"query": "agent kernels"}),
- ]
- payloads = [await registry.execute_async(event) for event in events]
- assert [payload["port"] for payload in payloads] == [
- "session",
- "volume",
- "calendar",
- "search",
- ]
- @pytest.mark.parametrize(
- "arguments",
- [
- {"mode": "absolute", "value": 101},
- {"mode": "relative", "delta": 0},
- {"mode": "mute", "value": 1},
- ],
- )
- def test_invalid_volume_arguments_never_call_the_port(arguments):
- volume = RecordingVolumePort()
- registry = build_default_tool_registry(device_volume_port=volume)
- payload = registry.execute(_event("volume-invalid", "device.volume.adjust", arguments))
- assert payload["tool"] == "device.volume.adjust"
- assert "invalid" in payload["error"]
- assert volume.calls == []
- def test_ambiguous_volume_request_without_fallback_never_calls_the_port():
- volume = RecordingVolumePort()
- registry = build_default_tool_registry(device_volume_port=volume)
- payload = registry.handle(
- _event("volume-ambiguous", "device.volume.adjust", {}),
- EventExecutionContext(
- history=(ChatMessage(role="user", content="静音还是取消静音"),)
- ),
- )
- assert payload == {
- "tool": "device.volume.adjust",
- "error": "missing required arguments: mode",
- }
- assert volume.calls == []
- @pytest.mark.parametrize(
- ("port_name", "event"),
- [
- ("session_termination_port", _event("s", "session.terminate", {})),
- (
- "device_volume_port",
- _event("v", "device.volume.adjust", {"mode": "mute"}),
- ),
- (
- "calendar_schedule_port",
- _event(
- "c",
- "calendar.schedule.create",
- {
- "title": "Review",
- "start_at": "2026-07-14T09:30:00+08:00",
- "timezone": "Asia/Shanghai",
- },
- ),
- ),
- (
- "web_search_port",
- _event("w", "knowledge.web.search", {"query": "agent kernels"}),
- ),
- ],
- )
- def test_port_failures_are_normalized_by_the_generic_kernel(port_name, event):
- class FailingPort:
- def terminate(self, *args, **kwargs):
- raise RuntimeError("port unavailable")
- adjust = terminate
- create = terminate
- search = terminate
- registry = build_default_tool_registry(**{port_name: FailingPort()})
- payload = registry.execute(event)
- assert payload == {
- "tool": event.name,
- "error": "tool handler failed: port unavailable",
- }
- def test_default_stateful_adapters_are_idempotent_by_event_id():
- registry = build_default_tool_registry()
- first_session = registry.execute(
- _event("same-session", "session.terminate", {"reason": "first"})
- )
- second_session = registry.execute(
- _event("same-session", "session.terminate", {"reason": "second"})
- )
- first_volume = registry.execute(
- _event(
- "same-volume",
- "device.volume.adjust",
- {"mode": "absolute", "value": 20},
- )
- )
- second_volume = registry.execute(
- _event(
- "same-volume",
- "device.volume.adjust",
- {"mode": "absolute", "value": 80},
- )
- )
- first_schedule = registry.execute(
- _event(
- "same-schedule",
- "calendar.schedule.create",
- {
- "title": "First",
- "start_at": "2026-07-14T09:30:00+08:00",
- "timezone": "Asia/Shanghai",
- },
- )
- )
- second_schedule = registry.execute(
- _event(
- "same-schedule",
- "calendar.schedule.create",
- {
- "title": "Second",
- "start_at": "2026-07-15T09:30:00+08:00",
- "timezone": "Asia/Shanghai",
- },
- )
- )
- assert second_session == first_session
- assert first_session["reason"] == "first"
- assert second_volume == first_volume
- assert first_volume["value"] == 20
- assert second_schedule == first_schedule
- assert first_schedule["schedule"]["title"] == "First"
- def test_default_web_search_is_deterministic_compact_and_uses_injected_clock():
- registry = build_default_tool_registry(
- clock=lambda: datetime(2030, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
- )
- payload = registry.execute(
- _event(
- "search-1",
- "knowledge.web.search",
- {"query": "agent kernels", "max_results": 2},
- )
- )
- assert payload["tool"] == "knowledge.web.search"
- assert payload["query"] == "agent kernels"
- assert payload["retrieved_at"] == "2030-01-02T03:04:05Z"
- assert len(payload["sources"]) == 2
- assert all(source["url"].startswith("https://example.invalid/") for source in payload["sources"])
- assert all(set(source) == {"title", "url", "snippet"} for source in payload["sources"])
- def test_builtin_port_protocols_and_default_adapter_types_are_public():
- assert builtin_plugins.SessionTerminationPort
- assert builtin_plugins.DeviceVolumePort
- assert builtin_plugins.CalendarSchedulePort
- assert builtin_plugins.WebSearchPort
- assert builtin_plugins.InMemorySessionTerminationAdapter
- assert builtin_plugins.InMemoryDeviceVolumeAdapter
- assert builtin_plugins.InMemoryCalendarScheduleAdapter
- assert builtin_plugins.InMemoryWebSearchAdapter
- def _resolve_text_event(name: str, content: str):
- registry = build_default_tool_registry()
- definition = registry.event_registry.definition(name)
- assert definition is not None
- assert definition.resolver is not None
- return definition.resolver(
- EventRequest(id="event-1", name=name),
- EventExecutionContext(history=(ChatMessage(role="user", content=content),)),
- )
|