|
@@ -0,0 +1,670 @@
|
|
|
|
|
+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),)),
|
|
|
|
|
+ )
|