Просмотр исходного кода

fix: close plugin compatibility gaps

Problem:
- Negated and malformed volume phrases could still fallback or execute, integral floats produced unstable port/cache types, fallback errors lost provenance, and new normalizer fields broke positional constructors.

Risk:
- Substantive invalid arguments must remain non-fallback, numeric canonicalization must exclude booleans, and date-time compatibility must preserve standard FormatChecker behavior across optional dependency environments.
zhenyu.hu 2 недель назад
Родитель
Сommit
90ca68cfc3

+ 117 - 2
src/agent_lab/application/events/builtin_plugins.py

@@ -347,6 +347,7 @@ def _calendar_schedule_definition(port: CalendarSchedulePort) -> EventDefinition
         },
         handler=handler,
         resolver=_resolve_calendar_schedule,
+        normalizer=_normalize_calendar_schedule,
         fallback_allowed=True,
         result_policy=ResultPolicy.TEMPLATE_FOLLOW_UP,
         risk_level=RiskLevel.MEDIUM,
@@ -422,10 +423,13 @@ def _resolve_device_volume(
 
     content = _latest_user_content(context).lower()
     if re.search(
-        r"\bdon['’]t\b|\bdo\s+not\b|\bnot\b|\bnever\b|不要|别|不许|禁止",
+        (
+            r"\bno\s+mute\b|\bdon['’]t\b|\bdo\s+not\b|\bnot\b|\bnever\b|"
+            r"不静音|不用|无需|不要|别|不许|禁止"
+        ),
         content,
     ):
-        return EventArgumentResolution(arguments={}, complete=False)
+        return {"mode": "unsupported"}
     unmute = bool(re.search(r"\bunmute\b|取消静音|解除静音|恢复声音", content))
     mute = bool(re.search(r"\bmute\b|(?<!取消)(?<!解除)静音", content))
     if mute and unmute:
@@ -437,6 +441,10 @@ def _resolve_device_volume(
             arguments["value"] = int(number.group())
         return arguments
 
+    unsupported_number = _unsupported_volume_numeric_arguments(content)
+    if unsupported_number is not None:
+        return unsupported_number
+
     absolute_matches = [
         re.search(
             r"音量.{0,6}?(?:调到|调至|设置为|设为|到)\s*(\d+)",
@@ -547,9 +555,97 @@ def _normalize_device_volume(arguments: dict[str, Any]) -> dict[str, Any]:
     mode = normalized.get("mode")
     if isinstance(mode, str):
         normalized["mode"] = mode.strip().lower()
+    for field in ("value", "delta"):
+        if field in normalized:
+            normalized[field] = _canonical_integral(normalized[field])
     return normalized
 
 
+def _unsupported_volume_numeric_arguments(
+    content: str,
+) -> dict[str, Any] | None:
+    token = r"(\d+(?:[.,]\d+)+)"
+    patterns = [
+        (
+            "absolute",
+            "value",
+            1,
+            re.search(
+                rf"音量.{{0,6}}?(?:调到|调至|设置为|设为|到)\s*{token}",
+                content,
+            ),
+        ),
+        (
+            "absolute",
+            "value",
+            1,
+            re.search(
+                rf"(?:set|change)\s+(?:the\s+)?volume\s+(?:to|at)\s*{token}",
+                content,
+            ),
+        ),
+        (
+            "relative",
+            "delta",
+            1,
+            re.search(
+                rf"音量.{{0,4}}?(?:增加|调高|提高|加)\s*{token}",
+                content,
+            ),
+        ),
+        (
+            "relative",
+            "delta",
+            1,
+            re.search(
+                rf"(?:increase|raise|turn\s+up)\s+(?:the\s+)?volume"
+                rf"(?:\s+by)?\s*{token}",
+                content,
+            ),
+        ),
+        (
+            "relative",
+            "delta",
+            -1,
+            re.search(
+                rf"音量.{{0,4}}?(?:降低|调低|减少|减)\s*{token}",
+                content,
+            ),
+        ),
+        (
+            "relative",
+            "delta",
+            -1,
+            re.search(
+                rf"(?:decrease|lower|turn\s+down)\s+(?:the\s+)?volume"
+                rf"(?:\s+by)?\s*{token}",
+                content,
+            ),
+        ),
+    ]
+    for mode, field, sign, match in patterns:
+        if match is not None:
+            value = match.group(1)
+            return {
+                "mode": mode,
+                field: value if sign > 0 else f"-{value}",
+            }
+    signed = re.search(
+        rf"(?:音量|\bvolume\b)\s*([+-])\s*{token}",
+        content,
+    )
+    if signed is not None:
+        return {
+            "mode": "relative",
+            "delta": (
+                signed.group(2)
+                if signed.group(1) == "+"
+                else f"-{signed.group(2)}"
+            ),
+        }
+    return None
+
+
 def _resolve_knowledge_search(
     request: EventRequest,
     context: EventExecutionContext,
@@ -564,14 +660,33 @@ def _resolve_knowledge_search(
     return EventArgumentResolution(arguments=arguments, complete=False)
 
 
+def _normalize_calendar_schedule(arguments: dict[str, Any]) -> dict[str, Any]:
+    normalized = deepcopy(arguments)
+    if "reminder_minutes" in normalized:
+        normalized["reminder_minutes"] = _canonical_integral(
+            normalized["reminder_minutes"]
+        )
+    return normalized
+
+
 def _normalize_knowledge_search(arguments: dict[str, Any]) -> dict[str, Any]:
     normalized = deepcopy(arguments)
     query = normalized.get("query")
     if isinstance(query, str):
         normalized["query"] = query.strip()
+    if "max_results" in normalized:
+        normalized["max_results"] = _canonical_integral(
+            normalized["max_results"]
+        )
     return normalized
 
 
+def _canonical_integral(value: Any) -> Any:
+    if isinstance(value, float) and value.is_integer():
+        return int(value)
+    return value
+
+
 def _latest_user_content(context: EventExecutionContext) -> str:
     for message in reversed(context.history):
         if message.role == "user" and message.content.strip():

+ 5 - 0
src/agent_lab/application/events/kernel.py

@@ -126,6 +126,7 @@ class EventKernel:
                     definition,
                     request,
                     resolved,
+                    used_fallback=True,
                 )
                 if normalization_error is not None:
                     return normalization_error
@@ -400,6 +401,8 @@ class EventKernel:
         definition: EventDefinition,
         request: EventRequest,
         resolved: ResolvedEventArguments,
+        *,
+        used_fallback: bool = False,
     ) -> tuple[ResolvedEventArguments | None, EventResult | None]:
         if definition.normalizer is None:
             return resolved, None
@@ -420,12 +423,14 @@ class EventKernel:
                     error=(
                         "provider-resolved event arguments could not be normalized"
                     ),
+                    used_fallback=used_fallback,
                 )
             return None, self._resolution_error(
                 definition,
                 request,
                 "event argument normalizer failed",
                 resolved=resolved,
+                used_fallback=used_fallback,
             )
         return (
             ResolvedEventArguments(

+ 1 - 1
src/agent_lab/application/events/models.py

@@ -86,7 +86,6 @@ class EventDefinition:
     parameters: dict[str, Any]
     handler: EventHandler
     resolver: EventArgumentResolver | None = None
-    normalizer: EventArgumentNormalizer | None = None
     schema_version: str = "1"
     fallback_allowed: bool = True
     result_policy: ResultPolicy = ResultPolicy.LLM_FOLLOW_UP
@@ -97,6 +96,7 @@ class EventDefinition:
     conflict_keys: tuple[str, ...] = ()
     timeout_seconds: float | None = None
     terminal: bool = False
+    normalizer: EventArgumentNormalizer | None = None
 
 
 @dataclass(frozen=True)

+ 14 - 12
src/agent_lab/application/events/registry.py

@@ -16,20 +16,22 @@ from agent_lab.application.events.models import EventDefinition
 
 
 _FORMAT_CHECKER = FormatChecker()
-_RFC3339_RE = re.compile(
-    r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"
-    r"(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$"
-)
-
+if "date-time" not in _FORMAT_CHECKER.checkers:
+    _FALLBACK_RFC3339_RE = re.compile(
+        r"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}"
+        r"(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$"
+    )
 
-@_FORMAT_CHECKER.checks("date-time", raises=ValueError)
-def _is_rfc3339_datetime(value: object) -> bool:
-    if not isinstance(value, str):
+    @_FORMAT_CHECKER.checks("date-time", raises=ValueError)
+    def _fallback_rfc3339_datetime(value: object) -> bool:
+        if not isinstance(value, str):
+            return True
+        if _FALLBACK_RFC3339_RE.fullmatch(value) is None:
+            return False
+        datetime.fromisoformat(
+            value.upper().replace("Z", "+00:00")
+        )
         return True
-    if _RFC3339_RE.fullmatch(value) is None:
-        return False
-    datetime.fromisoformat(value.replace("Z", "+00:00"))
-    return True
 
 
 @_FORMAT_CHECKER.checks(

+ 1 - 1
src/agent_lab/application/tools.py

@@ -47,7 +47,6 @@ class ToolDefinition:
     parameters: dict[str, Any]
     handler: ToolHandler
     argument_resolver: ToolArgumentResolver | None = None
-    normalizer: ToolArgumentNormalizer | None = None
     schema_version: str = "1"
     fallback_allowed: bool = True
     result_policy: ResultPolicy = ResultPolicy.LLM_FOLLOW_UP
@@ -58,6 +57,7 @@ class ToolDefinition:
     conflict_keys: tuple[str, ...] = ()
     timeout_seconds: float | None = None
     terminal: bool = False
+    normalizer: ToolArgumentNormalizer | None = None
 
 
 class ToolRegistry:

+ 190 - 10
tests/test_builtin_event_plugins.py

@@ -7,7 +7,10 @@ import agent_lab.application.events.builtin_plugins as builtin_plugins
 from agent_lab.application.events import (
     EventArgumentResolution,
     EventExecutionContext,
+    EventKernel,
     EventRequest,
+    EventStatus,
+    ResolvedEventArguments,
     ResultPolicy,
     RiskLevel,
 )
@@ -714,27 +717,90 @@ def test_volume_structured_arguments_are_canonical_in_text_and_provider_modes():
     ]
 
 
+@pytest.mark.asyncio
 @pytest.mark.parametrize(
     "content",
     [
+        "不静音",
+        "不用静音",
+        "无需静音",
+        "no mute",
         "don't mute",
-        "do not set volume to 40",
-        "不要静音",
-        "别把音量调到 20",
-        "不许音量增加 10",
+        "do not mute",
     ],
 )
-def test_negated_volume_requests_never_call_the_port(content):
+async def test_negated_volume_requests_are_substantive_invalid_without_fallback(
+    content,
+):
     volume = RecordingVolumePort()
     registry = build_default_tool_registry(device_volume_port=volume)
+    fallback_calls = 0
+
+    async def fallback(*args):
+        nonlocal fallback_calls
+        fallback_calls += 1
+        return ResolvedEventArguments(
+            event_name="device.volume.adjust",
+            arguments={"mode": "mute"},
+            raw_arguments='{"mode":"mute"}',
+        )
 
-    payload = registry.handle(
-        _event("negated-volume", "device.volume.adjust", {}),
-        EventExecutionContext(history=(ChatMessage(role="user", content=content),)),
+    result = await EventKernel(
+        registry.event_registry,
+        argument_fallback=fallback,
+    ).execute(
+        registry.event_request(_event("negated-volume", "device.volume.adjust", {})),
+        enabled_names=["device.volume.adjust"],
+        context=EventExecutionContext(
+            history=(ChatMessage(role="user", content=content),)
+        ),
     )
 
-    assert payload["tool"] == "device.volume.adjust"
-    assert "error" in payload
+    assert result.status is EventStatus.INVALID_ARGUMENTS
+    assert result.arguments == {"mode": "unsupported"}
+    assert result.used_fallback is False
+    assert fallback_calls == 0
+    assert volume.calls == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("content", "expected_arguments"),
+    [
+        ("set volume to 40.5", {"mode": "absolute", "value": "40.5"}),
+        ("increase volume by 10.5", {"mode": "relative", "delta": "10.5"}),
+        ("set volume to 1,000", {"mode": "absolute", "value": "1,000"}),
+        ("音量增加 1,000", {"mode": "relative", "delta": "1,000"}),
+    ],
+)
+async def test_unsupported_volume_numeric_tokens_are_not_truncated_or_fallback(
+    content,
+    expected_arguments,
+):
+    volume = RecordingVolumePort()
+    registry = build_default_tool_registry(device_volume_port=volume)
+    fallback_calls = 0
+
+    async def fallback(*args):
+        nonlocal fallback_calls
+        fallback_calls += 1
+        raise AssertionError("unsupported numeric tokens must not fallback")
+
+    result = await EventKernel(
+        registry.event_registry,
+        argument_fallback=fallback,
+    ).execute(
+        registry.event_request(_event("invalid-number", "device.volume.adjust", {})),
+        enabled_names=["device.volume.adjust"],
+        context=EventExecutionContext(
+            history=(ChatMessage(role="user", content=content),)
+        ),
+    )
+
+    assert result.status is EventStatus.INVALID_ARGUMENTS
+    assert result.arguments == expected_arguments
+    assert result.used_fallback is False
+    assert fallback_calls == 0
     assert volume.calls == []
 
 
@@ -792,6 +858,23 @@ def test_schedule_catalog_uses_standard_datetime_and_iana_timezone_formats():
     assert schema["properties"]["timezone"]["format"] == "iana-timezone"
 
 
+def test_schedule_standard_datetime_accepts_lowercase_tz_and_rejects_bad_date():
+    registry = build_default_tool_registry().event_registry
+    base = {
+        "title": "Review",
+        "timezone": "Asia/Shanghai",
+    }
+
+    assert registry.iter_validation_errors(
+        "calendar.schedule.create",
+        {**base, "start_at": "2026-07-14t09:30:00z"},
+    ) == ()
+    assert registry.iter_validation_errors(
+        "calendar.schedule.create",
+        {**base, "start_at": "2026-02-31T09:30:00Z"},
+    )
+
+
 @pytest.mark.parametrize(
     "arguments",
     [
@@ -828,3 +911,100 @@ def test_invalid_schedule_boundaries_never_call_the_port(arguments):
     assert payload["tool"] == "calendar.schedule.create"
     assert "invalid" in payload["error"]
     assert calendar.calls == []
+
+
+def test_integral_float_numeric_arguments_reach_ports_as_ints():
+    volume = RecordingVolumePort()
+    calendar = RecordingCalendarPort()
+    search = RecordingSearchPort()
+    registry = build_default_tool_registry(
+        device_volume_port=volume,
+        calendar_schedule_port=calendar,
+        web_search_port=search,
+    )
+
+    registry.execute(
+        _event(
+            "absolute-float",
+            "device.volume.adjust",
+            {"mode": "absolute", "value": 40.0},
+        )
+    )
+    registry.execute(
+        _event(
+            "relative-float",
+            "device.volume.adjust",
+            {"mode": "relative", "delta": -10.0},
+        )
+    )
+    registry.execute(
+        _event(
+            "schedule-float",
+            "calendar.schedule.create",
+            {
+                "title": "Review",
+                "start_at": "2026-07-14T09:30:00+08:00",
+                "timezone": "Asia/Shanghai",
+                "reminder_minutes": 15.0,
+            },
+        )
+    )
+    registry.execute(
+        _event(
+            "search-float",
+            "knowledge.web.search",
+            {"query": "focused", "max_results": 2.0},
+        )
+    )
+
+    assert volume.calls == [
+        ("absolute-float", "absolute", 40, None),
+        ("relative-float", "relative", None, -10),
+    ]
+    assert type(volume.calls[0][2]) is int
+    assert type(volume.calls[1][3]) is int
+    assert calendar.calls[0][-1] == 15
+    assert type(calendar.calls[0][-1]) is int
+    assert search.calls == [("search-float", "focused", 2)]
+    assert type(search.calls[0][-1]) is int
+
+
+def test_integral_float_and_int_share_the_same_volume_cache_key():
+    adapter = builtin_plugins.InMemoryDeviceVolumeAdapter()
+    registry = build_default_tool_registry(device_volume_port=adapter)
+
+    first = registry.execute(
+        _event(
+            "same-number",
+            "device.volume.adjust",
+            {"mode": "absolute", "value": 40},
+        )
+    )
+    replay = registry.execute(
+        _event(
+            "same-number",
+            "device.volume.adjust",
+            {"mode": "absolute", "value": 40.0},
+        )
+    )
+
+    assert replay == first
+    assert len(adapter._results) == 1
+
+
+@pytest.mark.parametrize(
+    "arguments",
+    [
+        {"mode": "absolute", "value": True},
+        {"mode": "relative", "delta": False},
+    ],
+)
+def test_volume_normalizer_does_not_treat_bool_as_integral_number(arguments):
+    volume = RecordingVolumePort()
+    registry = build_default_tool_registry(device_volume_port=volume)
+
+    payload = registry.execute(_event("bool-volume", "device.volume.adjust", arguments))
+
+    assert payload["tool"] == "device.volume.adjust"
+    assert "invalid" in payload["error"]
+    assert volume.calls == []

+ 71 - 0
tests/test_event_kernel.py

@@ -1832,3 +1832,74 @@ def test_tool_definition_normalizer_remains_compatible_with_provider_execution()
     )
 
     assert payload == {"tool": "compat.normalize", "query": "provider"}
+
+
+@pytest.mark.asyncio
+async def test_post_fallback_normalizer_failure_preserves_used_fallback_flag():
+    normalizer_calls = 0
+
+    def normalizer(arguments: dict[str, Any]) -> dict[str, Any]:
+        nonlocal normalizer_calls
+        normalizer_calls += 1
+        if "query" in arguments:
+            raise RuntimeError("post-fallback failure")
+        return arguments
+
+    async def fallback(*args: Any) -> ResolvedEventArguments:
+        return ResolvedEventArguments(
+            event_name="example.lookup",
+            arguments={"query": "fallback"},
+            raw_arguments='{"query":"fallback"}',
+        )
+
+    definition = _definition(
+        resolver=lambda request, context: {},
+        normalizer=normalizer,
+    )
+
+    result = await EventKernel(
+        EventRegistry([definition]),
+        argument_fallback=fallback,
+    ).execute(
+        EventRequest(id="event-1", name=definition.name),
+        enabled_names=[definition.name],
+    )
+
+    assert result.status is EventStatus.RESOLUTION_ERROR
+    assert result.error == "event argument normalizer failed"
+    assert result.used_fallback is True
+    assert normalizer_calls == 2
+
+
+def test_event_definition_preserves_historical_positional_field_order():
+    definition = EventDefinition(
+        "compat.event",
+        "Compatibility event.",
+        {"type": "object"},
+        lambda request: {"ok": True},
+        None,
+        "legacy-schema",
+        False,
+    )
+
+    assert definition.resolver is None
+    assert definition.schema_version == "legacy-schema"
+    assert definition.fallback_allowed is False
+    assert definition.normalizer is None
+
+
+def test_tool_definition_preserves_historical_positional_field_order():
+    definition = ToolDefinition(
+        "compat.tool",
+        "Compatibility tool.",
+        {"type": "object"},
+        lambda event: {"ok": True},
+        None,
+        "legacy-schema",
+        False,
+    )
+
+    assert definition.argument_resolver is None
+    assert definition.schema_version == "legacy-schema"
+    assert definition.fallback_allowed is False
+    assert definition.normalizer is None