|
@@ -7,7 +7,10 @@ import agent_lab.application.events.builtin_plugins as builtin_plugins
|
|
|
from agent_lab.application.events import (
|
|
from agent_lab.application.events import (
|
|
|
EventArgumentResolution,
|
|
EventArgumentResolution,
|
|
|
EventExecutionContext,
|
|
EventExecutionContext,
|
|
|
|
|
+ EventKernel,
|
|
|
EventRequest,
|
|
EventRequest,
|
|
|
|
|
+ EventStatus,
|
|
|
|
|
+ ResolvedEventArguments,
|
|
|
ResultPolicy,
|
|
ResultPolicy,
|
|
|
RiskLevel,
|
|
RiskLevel,
|
|
|
)
|
|
)
|
|
@@ -714,27 +717,90 @@ def test_volume_structured_arguments_are_canonical_in_text_and_provider_modes():
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
@pytest.mark.parametrize(
|
|
@pytest.mark.parametrize(
|
|
|
"content",
|
|
"content",
|
|
|
[
|
|
[
|
|
|
|
|
+ "不静音",
|
|
|
|
|
+ "不用静音",
|
|
|
|
|
+ "无需静音",
|
|
|
|
|
+ "no mute",
|
|
|
"don't 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()
|
|
volume = RecordingVolumePort()
|
|
|
registry = build_default_tool_registry(device_volume_port=volume)
|
|
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 == []
|
|
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"
|
|
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(
|
|
@pytest.mark.parametrize(
|
|
|
"arguments",
|
|
"arguments",
|
|
|
[
|
|
[
|
|
@@ -828,3 +911,100 @@ def test_invalid_schedule_boundaries_never_call_the_port(arguments):
|
|
|
assert payload["tool"] == "calendar.schedule.create"
|
|
assert payload["tool"] == "calendar.schedule.create"
|
|
|
assert "invalid" in payload["error"]
|
|
assert "invalid" in payload["error"]
|
|
|
assert calendar.calls == []
|
|
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 == []
|