4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
1013 lines
37 KiB
Python
1013 lines
37 KiB
Python
"""Load turn scenario directories into typed fixtures for pytest."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import os
|
|
import random
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
import yaml
|
|
|
|
from surfaces.interactive_shell.command_registry import SLASH_COMMANDS
|
|
from tests.core.agent._planned_action import (
|
|
default_target_surface,
|
|
)
|
|
from tools.interactive_shell.actions.synthetic import (
|
|
list_rds_postgres_scenarios,
|
|
)
|
|
|
|
TESTS_DIR = Path(__file__).resolve().parent
|
|
SCENARIOS_DIR = TESTS_DIR / "scenarios"
|
|
|
|
INTENT_CLASSES = frozenset(
|
|
{
|
|
"chat_handoff",
|
|
"local_execution",
|
|
"investigation",
|
|
"complex_shell_prompts",
|
|
"compound",
|
|
"remote",
|
|
"follow_up",
|
|
"non_actionable",
|
|
}
|
|
)
|
|
VALID_TOOL_ACTION_SURFACES = frozenset({"dispatch", "gather"})
|
|
VALID_GATHER_EXPECTS = frozenset(
|
|
{
|
|
"not_called",
|
|
"called",
|
|
"call_any",
|
|
"valid_data",
|
|
"valid_data_any",
|
|
}
|
|
)
|
|
VALID_ACTION_KINDS = frozenset(
|
|
{
|
|
"llm_provider",
|
|
"slash",
|
|
"shell",
|
|
"sample_alert",
|
|
"investigation",
|
|
"synthetic_test",
|
|
"task_cancel",
|
|
"cli_command",
|
|
"implementation",
|
|
"assistant_handoff",
|
|
}
|
|
)
|
|
VALID_ACTION_SOURCES = frozenset({"deterministic", "llm"})
|
|
VALID_TARGET_SURFACES = frozenset({"slash", "terminal", "investigation", "implementation"})
|
|
|
|
INTENT_TO_BEHAVIOR_CLASS: dict[str, str] = {
|
|
"chat_handoff": "chat_handoff",
|
|
"local_execution": "local_execution",
|
|
"investigation": "investigations",
|
|
"complex_shell_prompts": "complex_shell_prompts",
|
|
"compound": "compound",
|
|
"remote": "remote",
|
|
"follow_up": "follow_up",
|
|
"non_actionable": "non_actionable",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScenarioInput:
|
|
prompt: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScenarioSession:
|
|
has_prior_state: bool
|
|
configured_integrations: tuple[str, ...]
|
|
resolved_integrations: dict[str, Any] | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScenarioCapabilities:
|
|
"""Per-scenario planner capability constraints (three-state).
|
|
|
|
Each field carries one of three states that map directly onto the runtime
|
|
capability gate (``capability_not_explicitly_disabled``):
|
|
|
|
* ``None`` — the capability key is absent; the tool stays available, which
|
|
matches the production default (``Session()`` has no capability
|
|
constraints).
|
|
* ``()`` — an explicit empty list; the tool is explicitly disabled (hidden
|
|
from the planner specs and blocked at dispatch).
|
|
* a non-empty tuple — an allowlist; the tool is available and the action
|
|
normalizer drops proposed actions outside the list.
|
|
"""
|
|
|
|
slash_commands: tuple[str, ...] | None
|
|
cli_commands: tuple[str, ...] | None
|
|
synthetic_suites: tuple[str, ...] | None
|
|
llm_provider: tuple[str, ...] | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Scenario:
|
|
id: str
|
|
title: str
|
|
intent_class: str
|
|
input: ScenarioInput
|
|
session: ScenarioSession
|
|
available_capabilities: ScenarioCapabilities
|
|
notes: tuple[str, ...]
|
|
behavior_class: str
|
|
scenario_dir: Path
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AnswerTurn:
|
|
expected_kind: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AnswerPolicy:
|
|
"""Execution expectation for the action-agent tool path only.
|
|
|
|
``executes_terminal_action`` is true when the turn is expected to run at
|
|
least one shell action AgentTool -- a slash command, shell command, sample
|
|
alert, investigation start, synthetic run, etc. It is false for
|
|
conversational turns that answer in chat without executing a terminal
|
|
action.
|
|
|
|
This flag does NOT describe the conversational data-gathering path
|
|
(``gather_tool_evidence``), where the assistant may query configured
|
|
integrations (Sentry, GitHub, PostHog, ...) while composing a chat answer.
|
|
That path is not modeled as planned/executed actions; it is asserted via
|
|
``response_contract`` text and by execution-layer tests. See the ``Answer``
|
|
docstring for the full two-path model.
|
|
"""
|
|
|
|
executes_terminal_action: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GatheredToolsContract:
|
|
"""Assertions on which registered tools fire during the conversational
|
|
``gather_tool_evidence`` loop for a turn.
|
|
|
|
A turn's conversational data-gathering pass runs the same registered tools
|
|
the investigation uses. This contract lets a scenario assert that the right
|
|
tools were (or were not) invoked when grounding a chat answer:
|
|
|
|
* ``must_call_any`` — at least one of these tool names must be invoked.
|
|
* ``must_call_all`` — every one of these tool names must be invoked.
|
|
* ``must_not_call`` — none of these tool names may be invoked.
|
|
* ``must_return_valid_data`` — every one of these tool names must be invoked
|
|
AND return a successful result (a real integration response, not an error
|
|
or an ``available: false`` placeholder). This is strictly stronger than
|
|
``must_call_all``: it fails on a credential 401, a malformed-param 400, or
|
|
any other errored call, so it can only pass when the tool actually reached
|
|
the live integration and got valid data back.
|
|
* ``must_return_valid_data_any`` — at least one of these tool names must be
|
|
invoked AND return valid data (same success criteria as
|
|
``must_return_valid_data``).
|
|
|
|
For ``must_call_any``, ``must_call_all``, and ``must_not_call`` a tool counts
|
|
as "called" when it appears in ``AgentRunResult.executed`` regardless of
|
|
whether the call succeeded. ``must_return_valid_data`` additionally inspects
|
|
the tool's output and only counts a call that returned valid data.
|
|
"""
|
|
|
|
must_call_any: tuple[str, ...]
|
|
must_call_all: tuple[str, ...]
|
|
must_not_call: tuple[str, ...]
|
|
must_return_valid_data: tuple[str, ...]
|
|
must_return_valid_data_any: tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Answer:
|
|
"""Expected behavior for one turn scenario.
|
|
|
|
A turn can resolve down one of two independent execution paths, and these
|
|
fields only describe the first:
|
|
|
|
1. Action agent -> AgentTool execution (the "execution" path). Covered by
|
|
``policy.executes_terminal_action``, ``planned_actions``, and dispatch
|
|
entries in ``tool_actions`` (``surface: dispatch``). An empty
|
|
``planned_actions`` means the action agent is expected to hand the turn to
|
|
the conversational assistant (an ``assistant_handoff``), i.e. no terminal
|
|
action runs.
|
|
|
|
2. Conversational answer + ``gather_tool_evidence`` tool loop (the "chat"
|
|
path). Assert gather behaviour via ``tool_actions`` entries with
|
|
``surface: gather`` and an ``expect`` mode (``not_called``, ``called``,
|
|
``valid_data``, etc.). ``response_contract`` still covers reply text.
|
|
"""
|
|
|
|
turn: AnswerTurn
|
|
policy: AnswerPolicy
|
|
planned_actions: tuple[dict[str, Any], ...]
|
|
executed_actions: tuple[dict[str, Any], ...]
|
|
response_contract: dict[str, list[str]]
|
|
history_expected: tuple[dict[str, Any], ...]
|
|
runs: int
|
|
gathered_tools_contract: GatheredToolsContract | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScenarioCase:
|
|
scenario: Scenario
|
|
answer: Answer
|
|
|
|
|
|
def _require_mapping(raw: object, *, label: str) -> dict[str, Any]:
|
|
if not isinstance(raw, dict):
|
|
msg = f"{label} must be a mapping, got {type(raw).__name__}."
|
|
raise ValueError(msg)
|
|
return cast(dict[str, Any], raw)
|
|
|
|
|
|
def _optional_mapping(raw: object, *, label: str) -> dict[str, Any] | None:
|
|
"""Parse an optional mapping field.
|
|
|
|
Returns ``None`` when the key is absent or explicitly null (preserving the
|
|
"use the real resolved store" default), and the mapping itself when present
|
|
(including an explicit empty ``{}`` that forces an isolated, empty store).
|
|
"""
|
|
if raw is None:
|
|
return None
|
|
if not isinstance(raw, dict):
|
|
msg = f"{label} must be a mapping, got {type(raw).__name__}."
|
|
raise ValueError(msg)
|
|
return cast(dict[str, Any], raw)
|
|
|
|
|
|
def _gather_tool_names(entry: dict[str, Any], *, label: str) -> tuple[str, ...]:
|
|
tool = entry.get("tool")
|
|
tools = entry.get("tools")
|
|
if tool is not None and tools is not None:
|
|
msg = f"{label}: set either 'tool' or 'tools', not both."
|
|
raise ValueError(msg)
|
|
if isinstance(tool, str) and tool.strip():
|
|
return (tool.strip(),)
|
|
if tools is not None:
|
|
return _string_list(tools, label=f"{label}.tools")
|
|
msg = f"{label}: gather action requires 'tool' or 'tools'."
|
|
raise ValueError(msg)
|
|
|
|
|
|
def _parse_tool_actions(
|
|
raw: object,
|
|
*,
|
|
label: str,
|
|
scenario_id: str,
|
|
executes_terminal_action: bool,
|
|
) -> tuple[tuple[dict[str, Any], ...], GatheredToolsContract | None]:
|
|
"""Parse unified ``tool_actions`` into dispatch + gather contract views.
|
|
|
|
``surface: dispatch`` entries become ``executed_actions`` shapes. ``surface:
|
|
gather`` entries aggregate into a :class:`GatheredToolsContract` by
|
|
``expect`` mode.
|
|
"""
|
|
if raw is None:
|
|
return (), None
|
|
if not isinstance(raw, list):
|
|
msg = f"{label} must be a list, got {type(raw).__name__}."
|
|
raise ValueError(msg)
|
|
|
|
executed: list[dict[str, Any]] = []
|
|
must_call_any: list[str] = []
|
|
must_call_all: list[str] = []
|
|
must_not_call: list[str] = []
|
|
must_return_valid_data: list[str] = []
|
|
must_return_valid_data_any: list[str] = []
|
|
|
|
for index, item in enumerate(raw):
|
|
entry_label = f"{label}[{index}]"
|
|
if not isinstance(item, dict):
|
|
msg = f"{entry_label} must be a mapping."
|
|
raise ValueError(msg)
|
|
entry = cast(dict[str, Any], item)
|
|
surface = str(entry.get("surface", "")).strip()
|
|
if surface not in VALID_TOOL_ACTION_SURFACES:
|
|
msg = (
|
|
f"{entry_label}: surface must be one of "
|
|
f"{sorted(VALID_TOOL_ACTION_SURFACES)!r}, got {surface!r}."
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
if surface == "dispatch":
|
|
if "expect" in entry:
|
|
msg = f"{entry_label}: dispatch actions must not set 'expect'."
|
|
raise ValueError(msg)
|
|
dispatch_action = {key: value for key, value in entry.items() if key != "surface"}
|
|
validate_action_shape(
|
|
dispatch_action,
|
|
prefix=f"{scenario_id} tool_actions[{index}]",
|
|
require_source=False,
|
|
)
|
|
executed.append(dispatch_action)
|
|
continue
|
|
|
|
expect = str(entry.get("expect", "")).strip()
|
|
if expect not in VALID_GATHER_EXPECTS:
|
|
msg = (
|
|
f"{entry_label}: expect must be one of {sorted(VALID_GATHER_EXPECTS)!r}, "
|
|
f"got {expect!r}."
|
|
)
|
|
raise ValueError(msg)
|
|
tool_names = _gather_tool_names(entry, label=entry_label)
|
|
if expect == "not_called":
|
|
must_not_call.extend(tool_names)
|
|
elif expect == "called":
|
|
must_call_all.extend(tool_names)
|
|
elif expect == "call_any":
|
|
must_call_any.extend(tool_names)
|
|
elif expect == "valid_data":
|
|
must_return_valid_data.extend(tool_names)
|
|
else:
|
|
must_return_valid_data_any.extend(tool_names)
|
|
|
|
if not executes_terminal_action and executed:
|
|
msg = f"{label}: executes_terminal_action=false requires no dispatch tool_actions."
|
|
raise ValueError(msg)
|
|
|
|
contract = GatheredToolsContract(
|
|
must_call_any=tuple(must_call_any),
|
|
must_call_all=tuple(must_call_all),
|
|
must_not_call=tuple(must_not_call),
|
|
must_return_valid_data=tuple(must_return_valid_data),
|
|
must_return_valid_data_any=tuple(must_return_valid_data_any),
|
|
)
|
|
if not (
|
|
contract.must_call_any
|
|
or contract.must_call_all
|
|
or contract.must_not_call
|
|
or contract.must_return_valid_data
|
|
or contract.must_return_valid_data_any
|
|
):
|
|
return tuple(executed), None
|
|
return tuple(executed), contract
|
|
|
|
|
|
def _string_list(raw: object, *, label: str) -> tuple[str, ...]:
|
|
if raw is None:
|
|
return ()
|
|
if not isinstance(raw, list):
|
|
msg = f"{label} must be a list, got {type(raw).__name__}."
|
|
raise ValueError(msg)
|
|
values: list[str] = []
|
|
for index, item in enumerate(raw):
|
|
if not isinstance(item, str) or not item.strip():
|
|
msg = f"{label}[{index}] must be a non-empty string."
|
|
raise ValueError(msg)
|
|
values.append(item.strip())
|
|
return tuple(values)
|
|
|
|
|
|
def _optional_string_list(raw: object, *, label: str) -> tuple[str, ...] | None:
|
|
"""Parse a capability allowlist while preserving the absent-vs-empty split.
|
|
|
|
Returns ``None`` when the key is absent or explicitly null (no constraint;
|
|
the tool stays available, matching the production default), ``()`` for an
|
|
explicit empty list (the capability is explicitly disabled), and a tuple of
|
|
non-empty strings for an allowlist.
|
|
"""
|
|
if raw is None:
|
|
return None
|
|
return _string_list(raw, label=label)
|
|
|
|
|
|
def _action_list(raw: object, *, label: str) -> tuple[dict[str, Any], ...]:
|
|
if raw is None:
|
|
return ()
|
|
if not isinstance(raw, list):
|
|
msg = f"{label} must be a list, got {type(raw).__name__}."
|
|
raise ValueError(msg)
|
|
actions: list[dict[str, Any]] = []
|
|
for index, item in enumerate(raw):
|
|
if not isinstance(item, dict):
|
|
msg = f"{label}[{index}] must be a mapping."
|
|
raise ValueError(msg)
|
|
actions.append(cast(dict[str, Any], item))
|
|
return tuple(actions)
|
|
|
|
|
|
def _slash_content(command: str, args: list[str]) -> str:
|
|
return " ".join([command, *args]) if args else command
|
|
|
|
|
|
def _normalize_planned_action(action: dict[str, Any]) -> dict[str, Any]:
|
|
"""Backfill derived fields so YAMLs can omit redundant data."""
|
|
kind = str(action.get("kind", "")).strip()
|
|
if kind == "slash":
|
|
command = str(action.get("command", "")).strip()
|
|
raw_args = action.get("args") or []
|
|
args = [str(arg).strip() for arg in raw_args] if isinstance(raw_args, list) else []
|
|
if "content" not in action and command:
|
|
action["content"] = _slash_content(command, args)
|
|
elif kind == "synthetic_test":
|
|
suite = str(action.get("suite", "")).strip()
|
|
scenario = str(action.get("scenario", "")).strip()
|
|
if "content" not in action and suite and scenario:
|
|
action["content"] = f"{suite}:{scenario}"
|
|
elif kind == "cli_command":
|
|
payload = str(action.get("payload", "")).strip()
|
|
if "content" not in action and payload:
|
|
action["content"] = payload
|
|
elif kind == "sample_alert":
|
|
if "content" not in action and "template" in action:
|
|
action["content"] = str(action["template"]).strip()
|
|
return action
|
|
|
|
|
|
def validate_action_shape(
|
|
action: dict[str, Any],
|
|
*,
|
|
prefix: str,
|
|
require_source: bool,
|
|
) -> None:
|
|
kind = str(action.get("kind", "")).strip()
|
|
if kind not in VALID_ACTION_KINDS:
|
|
msg = f"{prefix} has invalid kind {kind!r}."
|
|
raise ValueError(msg)
|
|
|
|
if require_source and kind != "assistant_handoff":
|
|
source = str(action.get("source", "")).strip()
|
|
if source not in VALID_ACTION_SOURCES:
|
|
msg = f"{prefix} has invalid source {source!r}."
|
|
raise ValueError(msg)
|
|
target_surface = str(action.get("target_surface", "")).strip()
|
|
if target_surface not in VALID_TARGET_SURFACES:
|
|
msg = f"{prefix} has invalid target_surface {target_surface!r}."
|
|
raise ValueError(msg)
|
|
canonical = default_target_surface(kind) # type: ignore[arg-type]
|
|
if target_surface != canonical:
|
|
msg = (
|
|
f"{prefix} target_surface {target_surface!r} "
|
|
f"must be {canonical!r} for kind {kind!r}."
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
if kind == "slash":
|
|
command = str(action.get("command", "")).strip()
|
|
raw_args = action.get("args")
|
|
if not command.startswith("/"):
|
|
msg = f"{prefix} slash command must start with '/'."
|
|
raise ValueError(msg)
|
|
source = str(action.get("source", "")).strip()
|
|
if require_source and source == "llm" and command not in SLASH_COMMANDS:
|
|
msg = f"{prefix} references unknown slash command {command!r}."
|
|
raise ValueError(msg)
|
|
if not isinstance(raw_args, list):
|
|
msg = f"{prefix} slash action must define args list."
|
|
raise ValueError(msg)
|
|
args = [str(arg).strip() for arg in raw_args]
|
|
content = str(action.get("content", "")).strip()
|
|
if content and content != _slash_content(command, args):
|
|
msg = f"{prefix} content must match command+args when set."
|
|
raise ValueError(msg)
|
|
elif kind == "synthetic_test":
|
|
suite = str(action.get("suite", "")).strip()
|
|
scenario = str(action.get("scenario", "")).strip()
|
|
if not suite or not scenario:
|
|
msg = f"{prefix} synthetic_test requires suite and scenario."
|
|
raise ValueError(msg)
|
|
available = set(list_rds_postgres_scenarios())
|
|
if scenario not in available:
|
|
msg = f"{prefix} unknown synthetic scenario {scenario!r}."
|
|
raise ValueError(msg)
|
|
content = str(action.get("content", "")).strip()
|
|
if content and content != f"{suite}:{scenario}":
|
|
msg = f"{prefix} content must match suite:scenario when set."
|
|
raise ValueError(msg)
|
|
elif kind == "cli_command":
|
|
payload = str(action.get("payload", "")).strip()
|
|
if not payload:
|
|
msg = f"{prefix} cli_command requires payload."
|
|
raise ValueError(msg)
|
|
if payload.lower().startswith("opensre "):
|
|
msg = f"{prefix} cli_command payload must not include opensre prefix."
|
|
raise ValueError(msg)
|
|
|
|
|
|
def _parse_scenario_yaml(
|
|
scenario_path: Path,
|
|
*,
|
|
behavior_class: str,
|
|
) -> Scenario:
|
|
raw = yaml.safe_load(scenario_path.read_text(encoding="utf-8"))
|
|
data = _require_mapping(raw, label=str(scenario_path))
|
|
|
|
scenario_id = str(data.get("id", "")).strip()
|
|
if not scenario_id:
|
|
msg = f"{scenario_path}: missing id."
|
|
raise ValueError(msg)
|
|
|
|
title = str(data.get("title", "")).strip()
|
|
if not title:
|
|
msg = f"{scenario_path}: missing title."
|
|
raise ValueError(msg)
|
|
|
|
intent_class = str(data.get("intent_class", "")).strip()
|
|
if intent_class not in INTENT_CLASSES:
|
|
msg = f"{scenario_path}: invalid intent_class {intent_class!r}."
|
|
raise ValueError(msg)
|
|
|
|
expected_behavior = INTENT_TO_BEHAVIOR_CLASS.get(intent_class)
|
|
if expected_behavior != behavior_class:
|
|
msg = (
|
|
f"{scenario_path}: intent_class {intent_class!r} "
|
|
f"does not match directory behavior class {behavior_class!r}."
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
input_raw = _require_mapping(data.get("input"), label=f"{scenario_path} input")
|
|
prompt = str(input_raw.get("prompt", "")).strip()
|
|
if not prompt:
|
|
msg = f"{scenario_path}: input.prompt must be non-empty."
|
|
raise ValueError(msg)
|
|
|
|
session_raw = _require_mapping(data.get("session"), label=f"{scenario_path} session")
|
|
capabilities_raw = _require_mapping(
|
|
data.get("available_capabilities", {}),
|
|
label=f"{scenario_path} available_capabilities",
|
|
)
|
|
|
|
return Scenario(
|
|
id=scenario_id,
|
|
title=title,
|
|
intent_class=intent_class,
|
|
input=ScenarioInput(prompt=prompt),
|
|
session=ScenarioSession(
|
|
has_prior_state=bool(session_raw.get("has_prior_state", False)),
|
|
configured_integrations=_string_list(
|
|
session_raw.get("configured_integrations"),
|
|
label=f"{scenario_path} session.configured_integrations",
|
|
),
|
|
resolved_integrations=_optional_mapping(
|
|
session_raw.get("resolved_integrations"),
|
|
label=f"{scenario_path} session.resolved_integrations",
|
|
),
|
|
),
|
|
available_capabilities=ScenarioCapabilities(
|
|
slash_commands=_optional_string_list(
|
|
capabilities_raw.get("slash_commands"),
|
|
label=f"{scenario_path} slash_commands",
|
|
),
|
|
cli_commands=_optional_string_list(
|
|
capabilities_raw.get("cli_commands"),
|
|
label=f"{scenario_path} cli_commands",
|
|
),
|
|
synthetic_suites=_optional_string_list(
|
|
capabilities_raw.get("synthetic_suites"),
|
|
label=f"{scenario_path} synthetic_suites",
|
|
),
|
|
llm_provider=_optional_string_list(
|
|
capabilities_raw.get("llm_provider"),
|
|
label=f"{scenario_path} llm_provider",
|
|
),
|
|
),
|
|
notes=_string_list(data.get("notes"), label=f"{scenario_path} notes"),
|
|
behavior_class=behavior_class,
|
|
scenario_dir=scenario_path,
|
|
)
|
|
|
|
|
|
def _parse_answer_yaml(answer_path: Path, *, scenario_id: str) -> Answer:
|
|
raw = yaml.safe_load(answer_path.read_text(encoding="utf-8"))
|
|
data = _require_mapping(raw, label=str(answer_path))
|
|
|
|
turn_raw = _require_mapping(data.get("turn"), label=f"{answer_path} turn")
|
|
policy_raw = _require_mapping(data.get("policy"), label=f"{answer_path} policy")
|
|
response_raw = _require_mapping(
|
|
data.get("response_contract", {}),
|
|
label=f"{answer_path} response_contract",
|
|
)
|
|
history_raw = _require_mapping(data.get("history", {}), label=f"{answer_path} history")
|
|
|
|
expected_kind = str(turn_raw.get("expected_kind", "")).strip()
|
|
if expected_kind != "agent":
|
|
msg = f"{answer_path}: invalid turn.expected_kind {expected_kind!r}."
|
|
raise ValueError(msg)
|
|
if "expected_signals" in turn_raw:
|
|
msg = f"{answer_path}: turn.expected_signals was removed; drop it from the fixture."
|
|
raise ValueError(msg)
|
|
if "expected_command_text" in turn_raw:
|
|
msg = (
|
|
f"{answer_path}: turn.expected_command_text was removed along with the "
|
|
"deterministic command-detection layer; drop it from the fixture."
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
for removed_key in ("should_execute", "has_unhandled_clause", "fail_closed"):
|
|
if removed_key in policy_raw:
|
|
msg = (
|
|
f"{answer_path}: policy.{removed_key!r} was removed; "
|
|
"use policy.executes_terminal_action instead."
|
|
)
|
|
raise ValueError(msg)
|
|
executes_terminal_action = bool(policy_raw.get("executes_terminal_action", False))
|
|
|
|
if "executed_actions" in data or "gathered_tools_contract" in data:
|
|
msg = (
|
|
f"{answer_path}: executed_actions and gathered_tools_contract were removed; "
|
|
"use tool_actions with surface dispatch|gather instead."
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
planned_actions = tuple(
|
|
_normalize_planned_action(dict(item))
|
|
for item in _action_list(
|
|
data.get("planned_actions"), label=f"{answer_path} planned_actions"
|
|
)
|
|
)
|
|
|
|
for index, action in enumerate(planned_actions):
|
|
validate_action_shape(
|
|
action,
|
|
prefix=f"{scenario_id} planned_actions[{index}]",
|
|
require_source=True,
|
|
)
|
|
|
|
executed_actions, gathered_tools_contract = _parse_tool_actions(
|
|
data.get("tool_actions"),
|
|
label=f"{answer_path} tool_actions",
|
|
scenario_id=scenario_id,
|
|
executes_terminal_action=executes_terminal_action,
|
|
)
|
|
|
|
must_contain_any = list(
|
|
_string_list(
|
|
response_raw.get("must_contain_any", response_raw.get("any_of_contains")),
|
|
label=f"{answer_path} response_contract.must_contain_any",
|
|
)
|
|
)
|
|
must_contain_all = list(
|
|
_string_list(
|
|
response_raw.get("must_contain_all"),
|
|
label=f"{answer_path} response_contract.must_contain_all",
|
|
)
|
|
)
|
|
must_not_contain = list(
|
|
_string_list(
|
|
response_raw.get("must_not_contain"),
|
|
label=f"{answer_path} response_contract.must_not_contain",
|
|
)
|
|
)
|
|
forbidden_actions = list(
|
|
_string_list(
|
|
response_raw.get("forbidden_actions"),
|
|
label=f"{answer_path} response_contract.forbidden_actions",
|
|
)
|
|
)
|
|
# Validate that forbidden_actions entries reference known action kinds.
|
|
for entry in forbidden_actions:
|
|
if entry not in VALID_ACTION_KINDS:
|
|
msg = f"{answer_path}: forbidden_actions entry {entry!r} is not a valid action kind."
|
|
raise ValueError(msg)
|
|
|
|
if not executes_terminal_action and "$ /" not in must_not_contain:
|
|
must_not_contain.append("$ /")
|
|
|
|
runs_raw = data.get("runs", 1)
|
|
runs = int(runs_raw) if isinstance(runs_raw, int | str) else 1
|
|
if runs < 1:
|
|
msg = f"{answer_path}: runs must be >= 1."
|
|
raise ValueError(msg)
|
|
|
|
history_expected = _action_list(
|
|
history_raw.get("expected"),
|
|
label=f"{answer_path} history.expected",
|
|
)
|
|
|
|
return Answer(
|
|
turn=AnswerTurn(
|
|
expected_kind=expected_kind,
|
|
),
|
|
policy=AnswerPolicy(
|
|
executes_terminal_action=executes_terminal_action,
|
|
),
|
|
planned_actions=planned_actions,
|
|
executed_actions=executed_actions,
|
|
response_contract={
|
|
"must_contain_any": must_contain_any,
|
|
"must_contain_all": must_contain_all,
|
|
"must_not_contain": must_not_contain,
|
|
"forbidden_actions": forbidden_actions,
|
|
},
|
|
history_expected=history_expected,
|
|
runs=runs,
|
|
gathered_tools_contract=gathered_tools_contract,
|
|
)
|
|
|
|
|
|
def load_scenario_case(scenario_file: Path, *, behavior_class: str) -> ScenarioCase:
|
|
"""Load one scenario file into a ScenarioCase."""
|
|
if not scenario_file.is_file():
|
|
msg = f"Missing scenario file: {scenario_file}"
|
|
raise FileNotFoundError(msg)
|
|
|
|
scenario = _parse_scenario_yaml(scenario_file, behavior_class=behavior_class)
|
|
if scenario.scenario_dir.stem != scenario.id:
|
|
msg = (
|
|
f"{scenario_file}: file stem {scenario.scenario_dir.stem!r} "
|
|
f"does not match scenario id {scenario.id!r}."
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
answer = _parse_answer_yaml(scenario_file, scenario_id=scenario.id)
|
|
return ScenarioCase(scenario=scenario, answer=answer)
|
|
|
|
|
|
def load_all_scenarios() -> list[ScenarioCase]:
|
|
"""Discover and load every scenario under scenarios/<behavior_class>/*.yml."""
|
|
if not SCENARIOS_DIR.is_dir():
|
|
return []
|
|
|
|
cases: list[ScenarioCase] = []
|
|
seen_ids: set[str] = set()
|
|
|
|
for behavior_dir in sorted(SCENARIOS_DIR.iterdir()):
|
|
if not behavior_dir.is_dir():
|
|
continue
|
|
behavior_class = behavior_dir.name
|
|
for scenario_file in sorted(behavior_dir.iterdir()):
|
|
if not scenario_file.is_file() or scenario_file.suffix != ".yml":
|
|
continue
|
|
case = load_scenario_case(scenario_file, behavior_class=behavior_class)
|
|
if case.scenario.id in seen_ids:
|
|
msg = f"Duplicate scenario id {case.scenario.id!r}."
|
|
raise ValueError(msg)
|
|
seen_ids.add(case.scenario.id)
|
|
cases.append(case)
|
|
|
|
return cases
|
|
|
|
|
|
def load_scenarios_for_class(behavior_class: str) -> list[ScenarioCase]:
|
|
"""Load scenarios for one behavior-class directory."""
|
|
return [case for case in load_all_scenarios() if case.scenario.behavior_class == behavior_class]
|
|
|
|
|
|
def read_shard_config() -> tuple[int, int]:
|
|
"""Read TURN_SHARD_TOTAL and TURN_SHARD_INDEX from the environment."""
|
|
total = int(os.getenv("TURN_SHARD_TOTAL", "1"))
|
|
index = int(os.getenv("TURN_SHARD_INDEX", "0"))
|
|
if total < 1:
|
|
msg = "TURN_SHARD_TOTAL must be >= 1"
|
|
raise ValueError(msg)
|
|
if index < 0 or index >= total:
|
|
msg = "TURN_SHARD_INDEX must satisfy 0 <= index < TURN_SHARD_TOTAL"
|
|
raise ValueError(msg)
|
|
return total, index
|
|
|
|
|
|
def iter_scenarios_for_shard(
|
|
cases: list[ScenarioCase],
|
|
*,
|
|
total: int | None = None,
|
|
index: int | None = None,
|
|
) -> list[ScenarioCase]:
|
|
"""Return the shard subset of cases using stable offset modulo sharding."""
|
|
shard_total, shard_index = (
|
|
(total, index) if total is not None and index is not None else read_shard_config()
|
|
)
|
|
return [case for offset, case in enumerate(cases) if offset % shard_total == shard_index]
|
|
|
|
|
|
_INTENT_COMPLEXITY_WEIGHT: dict[str, float] = {
|
|
"compound": 5.0,
|
|
"complex_shell_prompts": 4.0,
|
|
"remote": 4.0,
|
|
"investigation": 3.0,
|
|
"follow_up": 2.0,
|
|
"local_execution": 1.5,
|
|
"chat_handoff": 1.0,
|
|
"non_actionable": 0.5,
|
|
}
|
|
_LIVE_INTEGRATION_SENTINEL = "@live"
|
|
_SELECT_MODES = frozenset({"sample", "complex"})
|
|
_DEFAULT_SELECT_FRACTION = 0.05
|
|
|
|
# Spec values that explicitly request the FULL suite (opt out of the default
|
|
# representative downsample). Accepted by ``--turn-select`` / ``TURN_SELECT``.
|
|
FULL_SELECT_SENTINELS = frozenset({"all", "full", "everything", "*"})
|
|
|
|
# Default gate: the live suite is downsampled everywhere (local AND CI) to a
|
|
# small, stratified, representative subset so a run stays fast and cheap. Pick
|
|
# this many of the most complex scenarios per behaviour class; classes with
|
|
# fewer scenarios contribute all of theirs. Override with ``TURN_SELECT=all``
|
|
# to run the complete suite, or ``--turn-select`` for a different subset.
|
|
DEFAULT_GATE_PER_CLASS = 2
|
|
|
|
# Env var capping the majority-vote ``runs`` of each scenario. Defaults to 1 so
|
|
# a downsampled run does a single LLM call per test; set ``TURN_MAX_RUNS=0`` (or
|
|
# ``all``/``off``) to honour each fixture's ``runs`` (CI keeps full majority
|
|
# voting this way).
|
|
_TURN_MAX_RUNS_ENV = "TURN_MAX_RUNS"
|
|
_DEFAULT_MAX_RUNS_CAP = 1
|
|
_UNCAPPED_RUNS_TOKENS = frozenset({"", "0", "all", "none", "off", "uncapped"})
|
|
|
|
|
|
def is_full_selection(spec: str | None) -> bool:
|
|
"""True when ``spec`` explicitly requests the full (non-downsampled) suite."""
|
|
return spec is not None and spec.strip().lower() in FULL_SELECT_SENTINELS
|
|
|
|
|
|
def select_representative(
|
|
cases: list[ScenarioCase],
|
|
*,
|
|
per_class: int = DEFAULT_GATE_PER_CLASS,
|
|
) -> list[ScenarioCase]:
|
|
"""Return a small, deterministic, behaviour-class-stratified subset.
|
|
|
|
For each behaviour class, keep the ``per_class`` most complex scenarios
|
|
(ties broken by id), so every intent class stays represented while the total
|
|
stays tiny. Selected cases keep their original ordering for stable test ids.
|
|
This is the default gate applied when no explicit selection is requested.
|
|
"""
|
|
if per_class < 1:
|
|
msg = "per_class must be >= 1"
|
|
raise ValueError(msg)
|
|
if not cases:
|
|
return []
|
|
by_class: dict[str, list[ScenarioCase]] = {}
|
|
for case in cases:
|
|
by_class.setdefault(case.scenario.behavior_class, []).append(case)
|
|
chosen_ids: set[str] = set()
|
|
for behavior_class in sorted(by_class):
|
|
ranked = sorted(
|
|
by_class[behavior_class],
|
|
key=lambda case: (scenario_complexity(case), case.scenario.id),
|
|
)
|
|
for case in ranked[max(0, len(ranked) - per_class) :]:
|
|
chosen_ids.add(case.scenario.id)
|
|
return [case for case in cases if case.scenario.id in chosen_ids]
|
|
|
|
|
|
def max_runs_cap(value: str | None = None) -> int | None:
|
|
"""Resolve the majority-vote ``runs`` cap from ``TURN_MAX_RUNS``.
|
|
|
|
Returns ``None`` when uncapped (honour each fixture's ``runs``) and a
|
|
positive int otherwise. Defaults to ``_DEFAULT_MAX_RUNS_CAP`` (1) when the
|
|
env var is unset, so downsampled runs do a single LLM call per test.
|
|
"""
|
|
raw = value if value is not None else os.getenv(_TURN_MAX_RUNS_ENV)
|
|
if raw is None:
|
|
return _DEFAULT_MAX_RUNS_CAP
|
|
text = raw.strip().lower()
|
|
if text in _UNCAPPED_RUNS_TOKENS:
|
|
return None
|
|
parsed = int(text)
|
|
if parsed < 1:
|
|
return None
|
|
return parsed
|
|
|
|
|
|
def effective_runs(answer_runs: int) -> int:
|
|
"""Apply the ``TURN_MAX_RUNS`` cap to a fixture's ``runs`` value."""
|
|
base = max(1, answer_runs)
|
|
cap = max_runs_cap()
|
|
return base if cap is None else min(base, cap)
|
|
|
|
|
|
def scenario_complexity(case: ScenarioCase) -> float:
|
|
"""Heuristic difficulty/cost score for ranking live turn scenarios.
|
|
|
|
Higher means "more worth running when you can only afford a few": multi-step
|
|
plans, majority-vote ``runs`` (the dominant live cost), gather-loop tool
|
|
contracts, real ``@live`` integration calls, and prior-state/long prompts all
|
|
push the score up. Used by ``select_cases`` in ``complex`` mode.
|
|
"""
|
|
scenario = case.scenario
|
|
answer = case.answer
|
|
score = _INTENT_COMPLEXITY_WEIGHT.get(scenario.intent_class, 1.0)
|
|
score += 3.0 * len(answer.planned_actions)
|
|
score += 2.0 * len(answer.executed_actions)
|
|
# Majority-vote fixtures issue ``runs`` LLM calls per test in BOTH the
|
|
# planning and oracle suites, so this is the single biggest time multiplier.
|
|
score += 2.0 * max(0, answer.runs - 1)
|
|
contract = answer.gathered_tools_contract
|
|
if contract is not None:
|
|
score += float(
|
|
len(contract.must_call_any)
|
|
+ len(contract.must_call_all)
|
|
+ len(contract.must_not_call)
|
|
+ len(contract.must_return_valid_data)
|
|
+ len(contract.must_return_valid_data_any)
|
|
)
|
|
override = scenario.session.resolved_integrations or {}
|
|
score += 2.0 * sum(
|
|
1 for value in override.values() if str(value).strip() == _LIVE_INTEGRATION_SENTINEL
|
|
)
|
|
if scenario.session.has_prior_state:
|
|
score += 1.0
|
|
score += min(len(scenario.input.prompt) / 200.0, 2.0)
|
|
return score
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SelectionSpec:
|
|
"""Parsed ``--turn-select`` / ``TURN_SELECT`` request.
|
|
|
|
Exactly one of ``count`` (absolute) or ``fraction`` (0 < f <= 1) is set.
|
|
"""
|
|
|
|
mode: str
|
|
fraction: float | None = None
|
|
count: int | None = None
|
|
|
|
|
|
def parse_selection_spec(spec: str | None) -> SelectionSpec | None:
|
|
"""Parse a selection spec like ``complex:5``, ``sample:0.1``, or ``complex``.
|
|
|
|
Returns ``None`` for an empty/unset spec (meaning "run everything"). The
|
|
count component may be an absolute integer (``6``), a percentage (``5%``), or
|
|
a fraction (``0.05``); a bare ``complex``/``sample`` defaults to 5%.
|
|
"""
|
|
if spec is None:
|
|
return None
|
|
text = spec.strip().lower()
|
|
if not text:
|
|
return None
|
|
mode, sep, raw = text.partition(":")
|
|
mode = mode.strip()
|
|
if mode not in _SELECT_MODES:
|
|
msg = f"Invalid turn selection mode {mode!r}; expected one of {sorted(_SELECT_MODES)}."
|
|
raise ValueError(msg)
|
|
raw = raw.strip()
|
|
if not sep or not raw:
|
|
return SelectionSpec(mode=mode, fraction=_DEFAULT_SELECT_FRACTION)
|
|
if raw.endswith("%"):
|
|
percent = float(raw[:-1])
|
|
if not 0.0 < percent <= 100.0:
|
|
msg = f"Turn selection percentage must be in (0, 100]; got {raw!r}."
|
|
raise ValueError(msg)
|
|
return SelectionSpec(mode=mode, fraction=percent / 100.0)
|
|
value = float(raw)
|
|
if value <= 0.0:
|
|
msg = f"Turn selection count must be positive; got {raw!r}."
|
|
raise ValueError(msg)
|
|
if value < 1.0:
|
|
return SelectionSpec(mode=mode, fraction=value)
|
|
return SelectionSpec(mode=mode, count=int(value))
|
|
|
|
|
|
def select_cases(
|
|
cases: list[ScenarioCase],
|
|
*,
|
|
spec: str | SelectionSpec | None,
|
|
seed: int = 1337,
|
|
) -> list[ScenarioCase]:
|
|
"""Return a subset of ``cases`` for fast local iteration.
|
|
|
|
``spec`` selects either the most complex cases (``complex:N``) or a random
|
|
sample (``sample:N``). ``None``/empty returns every case (the default, so CI
|
|
and the full local suite are unchanged). Selected cases keep their original
|
|
ordering for stable, readable test ids.
|
|
"""
|
|
parsed = spec if isinstance(spec, SelectionSpec) else parse_selection_spec(spec)
|
|
if parsed is None or not cases:
|
|
return list(cases)
|
|
if parsed.count is not None:
|
|
count = parsed.count
|
|
else:
|
|
fraction = parsed.fraction if parsed.fraction is not None else _DEFAULT_SELECT_FRACTION
|
|
count = math.ceil(len(cases) * fraction)
|
|
count = max(1, min(count, len(cases)))
|
|
if parsed.mode == "complex":
|
|
ranked = sorted(cases, key=lambda case: (scenario_complexity(case), case.scenario.id))
|
|
chosen = ranked[len(ranked) - count :]
|
|
else:
|
|
chosen = random.Random(seed).sample(cases, count)
|
|
order = {case.scenario.id: index for index, case in enumerate(cases)}
|
|
return sorted(chosen, key=lambda case: order[case.scenario.id])
|
|
|
|
|
|
__all__ = [
|
|
"Answer",
|
|
"AnswerPolicy",
|
|
"AnswerTurn",
|
|
"GatheredToolsContract",
|
|
"SCENARIOS_DIR",
|
|
"Scenario",
|
|
"ScenarioCapabilities",
|
|
"ScenarioCase",
|
|
"ScenarioInput",
|
|
"ScenarioSession",
|
|
"SelectionSpec",
|
|
"DEFAULT_GATE_PER_CLASS",
|
|
"FULL_SELECT_SENTINELS",
|
|
"effective_runs",
|
|
"is_full_selection",
|
|
"load_all_scenarios",
|
|
"load_scenario_case",
|
|
"load_scenarios_for_class",
|
|
"iter_scenarios_for_shard",
|
|
"max_runs_cap",
|
|
"parse_selection_spec",
|
|
"read_shard_config",
|
|
"scenario_complexity",
|
|
"select_cases",
|
|
"select_representative",
|
|
"validate_action_shape",
|
|
]
|