|
|
@@ -0,0 +1,409 @@
|
|
|
+import json
|
|
|
+from collections.abc import Mapping
|
|
|
+from types import MappingProxyType
|
|
|
+from typing import Literal, TypeAlias
|
|
|
+from urllib.parse import urlsplit
|
|
|
+
|
|
|
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
+
|
|
|
+from agent_lab.application.contracts import (
|
|
|
+ AgentParams,
|
|
|
+ DebugRunRequest,
|
|
|
+ EventAgentParams,
|
|
|
+)
|
|
|
+from agent_lab.domain.events import ToolCallEvent
|
|
|
+from agent_lab.domain.messages import StreamItem
|
|
|
+
|
|
|
+
|
|
|
+BenchmarkMode: TypeAlias = Literal["dual_agent", "chat_agent_tools"]
|
|
|
+BenchmarkCaseId: TypeAlias = Literal[
|
|
|
+ "ordinary_chat",
|
|
|
+ "device_volume_silent",
|
|
|
+ "calendar_schedule_template",
|
|
|
+ "web_search_two_answers",
|
|
|
+ "session_terminate",
|
|
|
+ "parallel_volume_schedule",
|
|
|
+]
|
|
|
+JsonScalar: TypeAlias = str | int | float | bool | None
|
|
|
+
|
|
|
+BENCHMARK_MODES: tuple[BenchmarkMode, ...] = (
|
|
|
+ "dual_agent",
|
|
|
+ "chat_agent_tools",
|
|
|
+)
|
|
|
+BENCHMARK_CASE_IDS: tuple[BenchmarkCaseId, ...] = (
|
|
|
+ "ordinary_chat",
|
|
|
+ "device_volume_silent",
|
|
|
+ "calendar_schedule_template",
|
|
|
+ "web_search_two_answers",
|
|
|
+ "session_terminate",
|
|
|
+ "parallel_volume_schedule",
|
|
|
+)
|
|
|
+
|
|
|
+BENCHMARK_SYSTEM_PROMPT = "Exercise the requested business scenario."
|
|
|
+BENCHMARK_MAX_TOKENS = 128
|
|
|
+BENCHMARK_MAX_EVENT_LOOPS = 1
|
|
|
+BENCHMARK_MAX_PARALLEL_EVENTS = 2
|
|
|
+BENCHMARK_BATCH_TIMEOUT_SECONDS = 1.0
|
|
|
+
|
|
|
+
|
|
|
+class _StrictBenchmarkModel(BaseModel):
|
|
|
+ model_config = ConfigDict(
|
|
|
+ extra="forbid",
|
|
|
+ strict=True,
|
|
|
+ hide_input_in_errors=True,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class _FrozenStrictBenchmarkModel(BaseModel):
|
|
|
+ model_config = ConfigDict(
|
|
|
+ extra="forbid",
|
|
|
+ strict=True,
|
|
|
+ hide_input_in_errors=True,
|
|
|
+ frozen=True,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _validate_base_url(value: str) -> str:
|
|
|
+ if not value or value != value.strip() or any(char.isspace() for char in value):
|
|
|
+ raise ValueError("base_url must be a non-empty HTTP(S) URL")
|
|
|
+ try:
|
|
|
+ parsed = urlsplit(value)
|
|
|
+ hostname = parsed.hostname
|
|
|
+ parsed.port
|
|
|
+ except ValueError as exc:
|
|
|
+ raise ValueError("base_url must be a valid HTTP(S) URL") from exc
|
|
|
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc or not hostname:
|
|
|
+ raise ValueError("base_url must use http or https and include a host")
|
|
|
+ if parsed.username is not None or parsed.password is not None or "@" in parsed.netloc:
|
|
|
+ raise ValueError("base_url must not contain userinfo")
|
|
|
+ if "?" in value:
|
|
|
+ raise ValueError("base_url must not contain a query")
|
|
|
+ if "#" in value:
|
|
|
+ raise ValueError("base_url must not contain a fragment")
|
|
|
+ return value
|
|
|
+
|
|
|
+
|
|
|
+def _validate_model(value: str) -> str:
|
|
|
+ stripped = value.strip()
|
|
|
+ if not stripped:
|
|
|
+ raise ValueError("model must not be empty")
|
|
|
+ return stripped
|
|
|
+
|
|
|
+
|
|
|
+class BenchmarkTarget(_StrictBenchmarkModel):
|
|
|
+ base_url: str
|
|
|
+ model: str
|
|
|
+
|
|
|
+ _base_url_validator = field_validator("base_url")(_validate_base_url)
|
|
|
+ _model_validator = field_validator("model")(_validate_model)
|
|
|
+
|
|
|
+
|
|
|
+class BenchmarkConfig(BenchmarkTarget):
|
|
|
+ schema_version: Literal[1]
|
|
|
+ runs_per_case: int = Field(default=1, ge=1)
|
|
|
+ modes: list[BenchmarkMode] = Field(
|
|
|
+ default_factory=lambda: list(BENCHMARK_MODES),
|
|
|
+ min_length=1,
|
|
|
+ )
|
|
|
+ cases: list[BenchmarkCaseId] = Field(
|
|
|
+ default_factory=lambda: list(BENCHMARK_CASE_IDS),
|
|
|
+ min_length=1,
|
|
|
+ )
|
|
|
+
|
|
|
+ @field_validator("schema_version", mode="before")
|
|
|
+ @classmethod
|
|
|
+ def require_integer_schema_version(cls, value: object) -> object:
|
|
|
+ if type(value) is not int or value != 1:
|
|
|
+ raise ValueError("schema_version must be the integer 1")
|
|
|
+ return value
|
|
|
+
|
|
|
+ @field_validator("modes", "cases")
|
|
|
+ @classmethod
|
|
|
+ def reject_duplicates(cls, values: list[str]) -> list[str]:
|
|
|
+ if len(values) != len(set(values)):
|
|
|
+ raise ValueError("values must not contain duplicates")
|
|
|
+ return values
|
|
|
+
|
|
|
+
|
|
|
+class BenchmarkExpectation(_FrozenStrictBenchmarkModel):
|
|
|
+ visible_messages: tuple[str, ...]
|
|
|
+ event_names: tuple[str, ...]
|
|
|
+ answer_count: int = Field(ge=1)
|
|
|
+ terminal: bool
|
|
|
+ first_reply_before_tool: bool
|
|
|
+
|
|
|
+ @model_validator(mode="after")
|
|
|
+ def match_answer_count(self) -> "BenchmarkExpectation":
|
|
|
+ if self.answer_count != len(self.visible_messages):
|
|
|
+ raise ValueError("answer_count must match visible_messages")
|
|
|
+ return self
|
|
|
+
|
|
|
+
|
|
|
+class BenchmarkMockEvent(_FrozenStrictBenchmarkModel):
|
|
|
+ id: str
|
|
|
+ name: str
|
|
|
+ arguments: tuple[tuple[str, JsonScalar], ...]
|
|
|
+
|
|
|
+ @model_validator(mode="after")
|
|
|
+ def validate_event(self) -> "BenchmarkMockEvent":
|
|
|
+ if not self.id.strip():
|
|
|
+ raise ValueError("mock event id must not be empty")
|
|
|
+ if not self.name.strip():
|
|
|
+ raise ValueError("mock event name must not be empty")
|
|
|
+ keys = tuple(key for key, _ in self.arguments)
|
|
|
+ if any(not key.strip() for key in keys):
|
|
|
+ raise ValueError("mock event argument names must not be empty")
|
|
|
+ if len(keys) != len(set(keys)):
|
|
|
+ raise ValueError("mock event arguments must not contain duplicate names")
|
|
|
+ return self
|
|
|
+
|
|
|
+
|
|
|
+class BenchmarkCase(_FrozenStrictBenchmarkModel):
|
|
|
+ case_id: BenchmarkCaseId
|
|
|
+ user_message: str
|
|
|
+ enabled_tools: tuple[str, ...]
|
|
|
+ expectation: BenchmarkExpectation
|
|
|
+ mock_visible_messages: tuple[str, ...]
|
|
|
+ mock_events: tuple[BenchmarkMockEvent, ...]
|
|
|
+
|
|
|
+ @model_validator(mode="after")
|
|
|
+ def validate_case_semantics(self) -> "BenchmarkCase":
|
|
|
+ if not self.user_message.strip():
|
|
|
+ raise ValueError("user_message must not be empty")
|
|
|
+ if len(self.enabled_tools) != len(set(self.enabled_tools)):
|
|
|
+ raise ValueError("enabled_tools must not contain duplicates")
|
|
|
+ mock_event_names = tuple(event.name for event in self.mock_events)
|
|
|
+ if mock_event_names != self.expectation.event_names:
|
|
|
+ raise ValueError("mock event order must match expected event order")
|
|
|
+ if self.expectation.first_reply_before_tool:
|
|
|
+ if not self.mock_events or not self.mock_visible_messages:
|
|
|
+ raise ValueError(
|
|
|
+ "first_reply_before_tool requires mock text and at least one event"
|
|
|
+ )
|
|
|
+ return self
|
|
|
+
|
|
|
+
|
|
|
+def _mock_event(
|
|
|
+ event_id: str,
|
|
|
+ name: str,
|
|
|
+ arguments: tuple[tuple[str, JsonScalar], ...] = (),
|
|
|
+) -> BenchmarkMockEvent:
|
|
|
+ return BenchmarkMockEvent(id=event_id, name=name, arguments=arguments)
|
|
|
+
|
|
|
+
|
|
|
+_SCHEDULE_ARGUMENTS: tuple[tuple[str, JsonScalar], ...] = (
|
|
|
+ ("title", "Design review"),
|
|
|
+ ("start_at", "2026-07-14T09:30:00+08:00"),
|
|
|
+ ("timezone", "Asia/Shanghai"),
|
|
|
+)
|
|
|
+_SCHEDULE_CONFIRMATION = (
|
|
|
+ "Scheduled Design review for 2026-07-14T09:30:00+08:00 "
|
|
|
+ "(Asia/Shanghai)."
|
|
|
+)
|
|
|
+
|
|
|
+BENCHMARK_CASE_CATALOG: Mapping[BenchmarkCaseId, BenchmarkCase] = MappingProxyType(
|
|
|
+ {
|
|
|
+ "ordinary_chat": BenchmarkCase(
|
|
|
+ case_id="ordinary_chat",
|
|
|
+ user_message="Hello.",
|
|
|
+ enabled_tools=(
|
|
|
+ "session.terminate",
|
|
|
+ "device.volume.adjust",
|
|
|
+ "calendar.schedule.create",
|
|
|
+ "knowledge.web.search",
|
|
|
+ ),
|
|
|
+ expectation=BenchmarkExpectation(
|
|
|
+ visible_messages=("Ordinary answer.",),
|
|
|
+ event_names=(),
|
|
|
+ answer_count=1,
|
|
|
+ terminal=False,
|
|
|
+ first_reply_before_tool=False,
|
|
|
+ ),
|
|
|
+ mock_visible_messages=("Ordinary answer.",),
|
|
|
+ mock_events=(),
|
|
|
+ ),
|
|
|
+ "device_volume_silent": BenchmarkCase(
|
|
|
+ case_id="device_volume_silent",
|
|
|
+ user_message="set volume to 30",
|
|
|
+ enabled_tools=("device.volume.adjust",),
|
|
|
+ expectation=BenchmarkExpectation(
|
|
|
+ visible_messages=("I will set the volume to 30.",),
|
|
|
+ event_names=("device.volume.adjust",),
|
|
|
+ answer_count=1,
|
|
|
+ terminal=False,
|
|
|
+ first_reply_before_tool=True,
|
|
|
+ ),
|
|
|
+ mock_visible_messages=("I will set the volume to 30.",),
|
|
|
+ mock_events=(
|
|
|
+ _mock_event(
|
|
|
+ "volume-1",
|
|
|
+ "device.volume.adjust",
|
|
|
+ (("mode", "absolute"), ("value", 30)),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ "calendar_schedule_template": BenchmarkCase(
|
|
|
+ case_id="calendar_schedule_template",
|
|
|
+ user_message=(
|
|
|
+ 'schedule "Design review" at 2026-07-14T09:30:00+08:00 '
|
|
|
+ "timezone Asia/Shanghai"
|
|
|
+ ),
|
|
|
+ enabled_tools=("calendar.schedule.create",),
|
|
|
+ expectation=BenchmarkExpectation(
|
|
|
+ visible_messages=("I will schedule that.", _SCHEDULE_CONFIRMATION),
|
|
|
+ event_names=("calendar.schedule.create",),
|
|
|
+ answer_count=2,
|
|
|
+ terminal=False,
|
|
|
+ first_reply_before_tool=True,
|
|
|
+ ),
|
|
|
+ mock_visible_messages=("I will schedule that.",),
|
|
|
+ mock_events=(
|
|
|
+ _mock_event(
|
|
|
+ "schedule-1",
|
|
|
+ "calendar.schedule.create",
|
|
|
+ _SCHEDULE_ARGUMENTS,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ "web_search_two_answers": BenchmarkCase(
|
|
|
+ case_id="web_search_two_answers",
|
|
|
+ user_message="event batch safety",
|
|
|
+ enabled_tools=("knowledge.web.search",),
|
|
|
+ expectation=BenchmarkExpectation(
|
|
|
+ visible_messages=("I will check.", "Grounded search update."),
|
|
|
+ event_names=("knowledge.web.search",),
|
|
|
+ answer_count=2,
|
|
|
+ terminal=False,
|
|
|
+ first_reply_before_tool=True,
|
|
|
+ ),
|
|
|
+ mock_visible_messages=("I will check.", "Grounded search update."),
|
|
|
+ mock_events=(
|
|
|
+ _mock_event(
|
|
|
+ "search-1",
|
|
|
+ "knowledge.web.search",
|
|
|
+ (("query", "event batch safety"),),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ "session_terminate": BenchmarkCase(
|
|
|
+ case_id="session_terminate",
|
|
|
+ user_message="end this session",
|
|
|
+ enabled_tools=("session.terminate",),
|
|
|
+ expectation=BenchmarkExpectation(
|
|
|
+ visible_messages=("Goodbye.",),
|
|
|
+ event_names=("session.terminate",),
|
|
|
+ answer_count=1,
|
|
|
+ terminal=True,
|
|
|
+ first_reply_before_tool=True,
|
|
|
+ ),
|
|
|
+ mock_visible_messages=("Goodbye.",),
|
|
|
+ mock_events=(_mock_event("terminate-1", "session.terminate"),),
|
|
|
+ ),
|
|
|
+ "parallel_volume_schedule": BenchmarkCase(
|
|
|
+ case_id="parallel_volume_schedule",
|
|
|
+ user_message=(
|
|
|
+ 'set volume to 30 and schedule "Design review" at '
|
|
|
+ "2026-07-14T09:30:00+08:00 timezone Asia/Shanghai"
|
|
|
+ ),
|
|
|
+ enabled_tools=(
|
|
|
+ "device.volume.adjust",
|
|
|
+ "calendar.schedule.create",
|
|
|
+ ),
|
|
|
+ expectation=BenchmarkExpectation(
|
|
|
+ visible_messages=(
|
|
|
+ "I will set the volume to 30 and schedule that.",
|
|
|
+ _SCHEDULE_CONFIRMATION,
|
|
|
+ ),
|
|
|
+ event_names=(
|
|
|
+ "device.volume.adjust",
|
|
|
+ "calendar.schedule.create",
|
|
|
+ ),
|
|
|
+ answer_count=2,
|
|
|
+ terminal=False,
|
|
|
+ first_reply_before_tool=True,
|
|
|
+ ),
|
|
|
+ mock_visible_messages=(
|
|
|
+ "I will set the volume to 30 and schedule that.",
|
|
|
+ ),
|
|
|
+ mock_events=(
|
|
|
+ _mock_event(
|
|
|
+ "parallel-volume",
|
|
|
+ "device.volume.adjust",
|
|
|
+ (("mode", "absolute"), ("value", 30)),
|
|
|
+ ),
|
|
|
+ _mock_event(
|
|
|
+ "parallel-schedule",
|
|
|
+ "calendar.schedule.create",
|
|
|
+ _SCHEDULE_ARGUMENTS,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+def build_benchmark_request(
|
|
|
+ case: BenchmarkCase,
|
|
|
+ mode: BenchmarkMode,
|
|
|
+ model: str,
|
|
|
+) -> DebugRunRequest:
|
|
|
+ if mode not in BENCHMARK_MODES:
|
|
|
+ raise ValueError(f"unsupported benchmark mode: {mode}")
|
|
|
+ validated_model = _validate_model(model)
|
|
|
+ return DebugRunRequest(
|
|
|
+ user_message=case.user_message,
|
|
|
+ system_prompts=[BENCHMARK_SYSTEM_PROMPT],
|
|
|
+ pre_messages=[],
|
|
|
+ chat_agent=AgentParams(
|
|
|
+ model=validated_model,
|
|
|
+ temperature=0.0,
|
|
|
+ max_tokens=BENCHMARK_MAX_TOKENS,
|
|
|
+ ),
|
|
|
+ event_agent=EventAgentParams(
|
|
|
+ model=validated_model,
|
|
|
+ temperature=0.0,
|
|
|
+ max_tokens=BENCHMARK_MAX_TOKENS,
|
|
|
+ enabled_tools=list(case.enabled_tools),
|
|
|
+ max_event_loops=BENCHMARK_MAX_EVENT_LOOPS,
|
|
|
+ max_parallel_events=BENCHMARK_MAX_PARALLEL_EVENTS,
|
|
|
+ batch_timeout_seconds=BENCHMARK_BATCH_TIMEOUT_SECONDS,
|
|
|
+ ),
|
|
|
+ tool_invocation_mode=mode,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def build_mock_rounds(
|
|
|
+ case: BenchmarkCase,
|
|
|
+ mode: BenchmarkMode,
|
|
|
+) -> list[list[StreamItem]]:
|
|
|
+ if mode not in BENCHMARK_MODES:
|
|
|
+ raise ValueError(f"unsupported benchmark mode: {mode}")
|
|
|
+
|
|
|
+ first_round: list[StreamItem] = []
|
|
|
+ if case.mock_visible_messages:
|
|
|
+ first_round.append(StreamItem.message_delta(case.mock_visible_messages[0]))
|
|
|
+ for mock_event in case.mock_events:
|
|
|
+ arguments = dict(mock_event.arguments)
|
|
|
+ event = ToolCallEvent(
|
|
|
+ id=mock_event.id,
|
|
|
+ name=mock_event.name,
|
|
|
+ arguments=arguments,
|
|
|
+ raw_arguments=json.dumps(
|
|
|
+ arguments,
|
|
|
+ sort_keys=True,
|
|
|
+ separators=(",", ":"),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ event_item = (
|
|
|
+ StreamItem.text_event(event)
|
|
|
+ if mode == "dual_agent"
|
|
|
+ else StreamItem.provider_tool_call(event)
|
|
|
+ )
|
|
|
+ first_round.append(event_item)
|
|
|
+
|
|
|
+ rounds = [first_round]
|
|
|
+ rounds.extend(
|
|
|
+ [StreamItem.message_delta(message)]
|
|
|
+ for message in case.mock_visible_messages[1:]
|
|
|
+ )
|
|
|
+ return rounds
|