refactor: extract browser challenge harness modules
This commit is contained in:
@@ -74,6 +74,20 @@ examples/agent_challenges/browser_click_challenge/workspaces/
|
|||||||
files can be graded by hand without polluting the repository. The template's
|
files can be graded by hand without polluting the repository. The template's
|
||||||
store directory is also ignored.
|
store directory is also ignored.
|
||||||
|
|
||||||
|
## Saving Trial Reports
|
||||||
|
|
||||||
|
To save an agent's final answer from a harness result into its trial workspace:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run python examples/agent_challenges/browser_click_challenge/save_trial_report.py `
|
||||||
|
--from-result examples/agent_challenges/browser_click_challenge/results/<trial>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The script infers the workspace from the result file and only writes
|
||||||
|
`<trial>/final-report.md`. Add evaluator commentary by editing that file after it
|
||||||
|
is saved. For manually copied reports, pass an explicit workspace and
|
||||||
|
`--input-file final-answer.md`.
|
||||||
|
|
||||||
## Optional Opencode Server Attachment
|
## Optional Opencode Server Attachment
|
||||||
|
|
||||||
`--attach` is opencode's server attach flag. It connects this non-interactive
|
`--attach` is opencode's server attach flag. It connects this non-interactive
|
||||||
@@ -113,6 +127,18 @@ challenge_report:
|
|||||||
after_clicked: true
|
after_clicked: true
|
||||||
run_failed: false
|
run_failed: false
|
||||||
leftover_processes: false
|
leftover_processes: false
|
||||||
|
read:
|
||||||
|
skills: true
|
||||||
|
docs: true
|
||||||
|
product_code: false
|
||||||
|
adjacent_attempts: false
|
||||||
|
prior_store: false
|
||||||
|
existing_solution: false
|
||||||
|
attempts:
|
||||||
|
total: 1
|
||||||
|
failed: 0
|
||||||
|
missed_requirements:
|
||||||
|
- "none"
|
||||||
notes: "short explanation"
|
notes: "short explanation"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
Classification = Literal[
|
||||||
|
"success",
|
||||||
|
"workflow_script",
|
||||||
|
"workflow_not_used",
|
||||||
|
"run_failed",
|
||||||
|
"timeout",
|
||||||
|
"parse_error",
|
||||||
|
"unknown",
|
||||||
|
]
|
||||||
|
|
||||||
|
CHALLENGE_REPORT_REQUIRED_FIELDS = {
|
||||||
|
"used_product_path",
|
||||||
|
"used_helper_script",
|
||||||
|
"workflow_file",
|
||||||
|
"deployment_id",
|
||||||
|
"run_id",
|
||||||
|
"before_clicked",
|
||||||
|
"after_clicked",
|
||||||
|
"run_failed",
|
||||||
|
"leftover_processes",
|
||||||
|
"read",
|
||||||
|
"attempts",
|
||||||
|
"missed_requirements",
|
||||||
|
"notes",
|
||||||
|
}
|
||||||
|
CHALLENGE_REPORT_READ_FIELDS = {
|
||||||
|
"skills",
|
||||||
|
"docs",
|
||||||
|
"product_code",
|
||||||
|
"adjacent_attempts",
|
||||||
|
"prior_store",
|
||||||
|
"existing_solution",
|
||||||
|
}
|
||||||
|
CHALLENGE_REPORT_ATTEMPT_FIELDS = {"total", "failed"}
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
CHALLENGE_DIR = Path(__file__).resolve().parent
|
||||||
|
DEFAULT_RESULTS_DIR = CHALLENGE_DIR / "results"
|
||||||
|
DEFAULT_WORKSPACES_DIR = CHALLENGE_DIR / "workspaces"
|
||||||
|
DEFAULT_WORKSPACE_TEMPLATE = CHALLENGE_DIR / "workspace_template"
|
||||||
|
DEFAULT_PROMPT = DEFAULT_WORKSPACE_TEMPLATE / "prompt.md"
|
||||||
|
DEFAULT_SERVER_PORT = 8772
|
||||||
|
EXAMPLE_CONFIG = ROOT / "examples" / "browser_click_workflow" / "wf.config.json"
|
||||||
|
EXAMPLE_SOURCE_ROOT = ROOT / "examples" / "browser_click_workflow"
|
||||||
|
EXAMPLE_CONFIG_ARG = "examples/browser_click_workflow/wf.config.json"
|
||||||
|
LOCAL_WF_COMMAND_PREFIX = f"uv run wf --config {EXAMPLE_CONFIG_ARG} --local"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TrialConfig:
|
||||||
|
model: str
|
||||||
|
variant: str
|
||||||
|
prompt_path: Path
|
||||||
|
attach_url: str | None
|
||||||
|
timeout_seconds: int
|
||||||
|
wf_command_prefix: str
|
||||||
|
server_context: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TrialWorkspace:
|
||||||
|
"""Per-trial scratch area copied from the challenge workspace template."""
|
||||||
|
|
||||||
|
root: Path
|
||||||
|
config_path: Path
|
||||||
|
prompt_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
def render_prompt(
|
||||||
|
prompt_path: Path,
|
||||||
|
*,
|
||||||
|
wf_command_prefix: str,
|
||||||
|
server_context: str,
|
||||||
|
) -> str:
|
||||||
|
return (
|
||||||
|
prompt_path.read_text(encoding="utf-8")
|
||||||
|
.replace("{{wf_command_prefix}}", wf_command_prefix)
|
||||||
|
.replace("{{server_context}}", server_context)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rpc_url_for_port(port: int) -> str:
|
||||||
|
return f"http://127.0.0.1:{port}/rpc"
|
||||||
|
|
||||||
|
|
||||||
|
def server_command(*, port: int) -> list[str]:
|
||||||
|
return [
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"wf-rpc-server",
|
||||||
|
"--config",
|
||||||
|
EXAMPLE_CONFIG_ARG,
|
||||||
|
"--host",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--port",
|
||||||
|
str(port),
|
||||||
|
]
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from examples.agent_challenges.browser_click_challenge.challenge import (
|
||||||
|
CHALLENGE_REPORT_ATTEMPT_FIELDS,
|
||||||
|
CHALLENGE_REPORT_READ_FIELDS,
|
||||||
|
CHALLENGE_REPORT_REQUIRED_FIELDS,
|
||||||
|
Classification,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_output(text: str) -> Classification:
|
||||||
|
report = extract_challenge_report(text)
|
||||||
|
if report is not None:
|
||||||
|
return classify_challenge_report(report)
|
||||||
|
|
||||||
|
lowered = text.lower()
|
||||||
|
product_command_markers = [
|
||||||
|
"wf ",
|
||||||
|
"wf-rpc-server",
|
||||||
|
]
|
||||||
|
workflow_evidence_markers = [
|
||||||
|
"deployment",
|
||||||
|
"run id",
|
||||||
|
"run_",
|
||||||
|
]
|
||||||
|
used_product_command = any(marker in lowered for marker in product_command_markers)
|
||||||
|
has_workflow_evidence = any(
|
||||||
|
marker in lowered for marker in workflow_evidence_markers
|
||||||
|
)
|
||||||
|
used_helper_script = (
|
||||||
|
"uv run python" in lowered
|
||||||
|
or "python examples/" in lowered
|
||||||
|
or "run_workflow.py" in lowered
|
||||||
|
)
|
||||||
|
failed = any(
|
||||||
|
marker in lowered
|
||||||
|
for marker in [
|
||||||
|
"error:",
|
||||||
|
"failed",
|
||||||
|
"traceback",
|
||||||
|
"exception",
|
||||||
|
"validation failed",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
before_false = _contains_bool_marker(lowered, "before.clicked", "false") or (
|
||||||
|
'"before"' in lowered and '"clicked": false' in lowered
|
||||||
|
)
|
||||||
|
after_true = _contains_bool_marker(lowered, "after.clicked", "true") or (
|
||||||
|
'"after"' in lowered and '"clicked": true' in lowered
|
||||||
|
)
|
||||||
|
|
||||||
|
if used_product_command and before_false and after_true and not failed:
|
||||||
|
return "success"
|
||||||
|
if has_workflow_evidence and used_helper_script and before_false and after_true:
|
||||||
|
return "workflow_script"
|
||||||
|
if (used_product_command or has_workflow_evidence) and failed:
|
||||||
|
return "run_failed"
|
||||||
|
if not has_workflow_evidence and (
|
||||||
|
before_false or after_true or "playwright" in lowered
|
||||||
|
):
|
||||||
|
return "workflow_not_used"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_challenge_report(text: str) -> dict[str, Any] | None:
|
||||||
|
"""Extract the required final YAML challenge report from agent output."""
|
||||||
|
marker = "```yaml"
|
||||||
|
start = text.lower().rfind(marker)
|
||||||
|
if start == -1:
|
||||||
|
return None
|
||||||
|
body_start = text.find("\n", start)
|
||||||
|
if body_start == -1:
|
||||||
|
return None
|
||||||
|
end = text.find("```", body_start + 1)
|
||||||
|
if end == -1:
|
||||||
|
return None
|
||||||
|
raw_yaml = text[body_start + 1 : end]
|
||||||
|
loaded = yaml.safe_load(raw_yaml)
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
return None
|
||||||
|
report = loaded.get("challenge_report")
|
||||||
|
return report if isinstance(report, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def classify_challenge_report(report: dict[str, Any]) -> Classification:
|
||||||
|
if challenge_report_schema_errors(report):
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
used_product_path = report.get("used_product_path") is True
|
||||||
|
used_helper_script = report.get("used_helper_script") is True
|
||||||
|
workflow_file = report.get("workflow_file")
|
||||||
|
deployment_id = report.get("deployment_id")
|
||||||
|
run_id = report.get("run_id")
|
||||||
|
before_clicked = report.get("before_clicked")
|
||||||
|
after_clicked = report.get("after_clicked")
|
||||||
|
failed = report.get("run_failed") is True
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
return "run_failed"
|
||||||
|
if used_helper_script:
|
||||||
|
return "workflow_script"
|
||||||
|
if (
|
||||||
|
used_product_path
|
||||||
|
and isinstance(workflow_file, str)
|
||||||
|
and bool(workflow_file)
|
||||||
|
and isinstance(deployment_id, str)
|
||||||
|
and bool(deployment_id)
|
||||||
|
and isinstance(run_id, str)
|
||||||
|
and bool(run_id)
|
||||||
|
and before_clicked is False
|
||||||
|
and after_clicked is True
|
||||||
|
):
|
||||||
|
return "success"
|
||||||
|
if not used_product_path and (
|
||||||
|
before_clicked is not None or after_clicked is not None
|
||||||
|
):
|
||||||
|
return "workflow_not_used"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def challenge_report_schema_errors(report: dict[str, Any]) -> list[str]:
|
||||||
|
"""Return human-readable schema errors for the final YAML report block."""
|
||||||
|
errors: list[str] = []
|
||||||
|
missing = sorted(CHALLENGE_REPORT_REQUIRED_FIELDS.difference(report))
|
||||||
|
errors.extend(f"missing challenge_report.{field}" for field in missing)
|
||||||
|
|
||||||
|
for field in (
|
||||||
|
"used_product_path",
|
||||||
|
"used_helper_script",
|
||||||
|
"before_clicked",
|
||||||
|
"after_clicked",
|
||||||
|
"run_failed",
|
||||||
|
"leftover_processes",
|
||||||
|
):
|
||||||
|
if field in report and not isinstance(report[field], bool):
|
||||||
|
errors.append(f"challenge_report.{field} must be boolean")
|
||||||
|
for field in ("workflow_file", "deployment_id", "run_id", "notes"):
|
||||||
|
if field in report and not isinstance(report[field], str):
|
||||||
|
errors.append(f"challenge_report.{field} must be string")
|
||||||
|
|
||||||
|
read = report.get("read")
|
||||||
|
if isinstance(read, dict):
|
||||||
|
missing_read = sorted(CHALLENGE_REPORT_READ_FIELDS.difference(read))
|
||||||
|
errors.extend(f"missing challenge_report.read.{field}" for field in missing_read)
|
||||||
|
for field in CHALLENGE_REPORT_READ_FIELDS.intersection(read):
|
||||||
|
if not isinstance(read[field], bool):
|
||||||
|
errors.append(f"challenge_report.read.{field} must be boolean")
|
||||||
|
elif "read" in report:
|
||||||
|
errors.append("challenge_report.read must be object")
|
||||||
|
|
||||||
|
attempts = report.get("attempts")
|
||||||
|
if isinstance(attempts, dict):
|
||||||
|
missing_attempts = sorted(CHALLENGE_REPORT_ATTEMPT_FIELDS.difference(attempts))
|
||||||
|
errors.extend(
|
||||||
|
f"missing challenge_report.attempts.{field}"
|
||||||
|
for field in missing_attempts
|
||||||
|
)
|
||||||
|
for field in CHALLENGE_REPORT_ATTEMPT_FIELDS.intersection(attempts):
|
||||||
|
value = attempts[field]
|
||||||
|
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||||
|
errors.append(f"challenge_report.attempts.{field} must be >= 0 integer")
|
||||||
|
elif "attempts" in report:
|
||||||
|
errors.append("challenge_report.attempts must be object")
|
||||||
|
|
||||||
|
missed = report.get("missed_requirements")
|
||||||
|
if "missed_requirements" in report and (
|
||||||
|
not isinstance(missed, list)
|
||||||
|
or any(not isinstance(item, str) for item in missed)
|
||||||
|
):
|
||||||
|
errors.append("challenge_report.missed_requirements must be list of strings")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_bool_marker(text: str, marker: str, value: str) -> bool:
|
||||||
|
marker_index = text.find(marker)
|
||||||
|
if marker_index == -1:
|
||||||
|
return False
|
||||||
|
return value in text[marker_index : marker_index + 80]
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from examples.agent_challenges.browser_click_challenge.challenge import (
|
||||||
|
TrialConfig,
|
||||||
|
render_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_opencode_command(config: TrialConfig) -> list[str]:
|
||||||
|
prompt_text = render_prompt(
|
||||||
|
config.prompt_path,
|
||||||
|
wf_command_prefix=config.wf_command_prefix,
|
||||||
|
server_context=config.server_context,
|
||||||
|
)
|
||||||
|
command = [
|
||||||
|
"opencode",
|
||||||
|
"run",
|
||||||
|
]
|
||||||
|
if config.attach_url is not None:
|
||||||
|
command.extend(["--attach", config.attach_url])
|
||||||
|
command.extend(
|
||||||
|
[
|
||||||
|
prompt_text,
|
||||||
|
"--format",
|
||||||
|
"json",
|
||||||
|
"--model",
|
||||||
|
config.model,
|
||||||
|
"--variant",
|
||||||
|
config.variant,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def parse_opencode_output(stdout: str) -> dict[str, Any]:
|
||||||
|
text = stdout.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("opencode produced no JSON output")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
parsed = _parse_jsonl_tail(text)
|
||||||
|
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("opencode output was not a JSON object")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_jsonl_tail(text: str) -> dict[str, Any]:
|
||||||
|
last_error: json.JSONDecodeError | None = None
|
||||||
|
parsed_events: list[dict[str, Any]] = []
|
||||||
|
for line in reversed(text.splitlines()):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(stripped)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
last_error = exc
|
||||||
|
continue
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
parsed_events.append(parsed)
|
||||||
|
event_text = _event_text(parsed)
|
||||||
|
if event_text is not None:
|
||||||
|
return {"text": event_text, "event": parsed}
|
||||||
|
if parsed_events:
|
||||||
|
return parsed_events[0]
|
||||||
|
if last_error is not None:
|
||||||
|
raise last_error
|
||||||
|
raise ValueError("opencode output did not contain JSON lines")
|
||||||
|
|
||||||
|
|
||||||
|
def _event_text(event: dict[str, Any]) -> str | None:
|
||||||
|
"""Extract assistant text from opencode JSON events.
|
||||||
|
|
||||||
|
`opencode run --format json` emits many events. The final event can be a
|
||||||
|
`step_finish`; the answer text is usually the previous `text` event under
|
||||||
|
`part.text`.
|
||||||
|
"""
|
||||||
|
part = event.get("part")
|
||||||
|
if isinstance(part, dict):
|
||||||
|
text = part.get("text")
|
||||||
|
if isinstance(text, str):
|
||||||
|
return text
|
||||||
|
text = event.get("text")
|
||||||
|
return text if isinstance(text, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _result_text(parsed: dict[str, Any]) -> str:
|
||||||
|
for key in ("text", "message", "content", "output"):
|
||||||
|
value = parsed.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return json.dumps(parsed, sort_keys=True)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from examples.agent_challenges.browser_click_challenge.classification import (
|
||||||
|
extract_challenge_report,
|
||||||
|
)
|
||||||
|
from examples.agent_challenges.browser_click_challenge.opencode_io import _result_text
|
||||||
|
|
||||||
|
|
||||||
|
def save_report(
|
||||||
|
*,
|
||||||
|
workspace: Path,
|
||||||
|
report_text: str,
|
||||||
|
output_name: str = "final-report.md",
|
||||||
|
) -> Path:
|
||||||
|
"""Copy one finished trial report into an existing trial workspace."""
|
||||||
|
if not workspace.is_dir():
|
||||||
|
raise ValueError(f"workspace does not exist or is not a directory: {workspace}")
|
||||||
|
output_path = workspace / output_name
|
||||||
|
output_path.write_text(report_text.rstrip() + "\n", encoding="utf-8")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def report_from_result(result_path: Path) -> tuple[Path, str]:
|
||||||
|
"""Return inferred workspace and final report text from one harness result.
|
||||||
|
|
||||||
|
The workspace is usually recovered from the serialized prompt path. Older
|
||||||
|
or hand-edited result files may only expose the workflow path inside the
|
||||||
|
final YAML report, so that path remains a secondary inference source.
|
||||||
|
"""
|
||||||
|
result = json.loads(result_path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise ValueError("result file must contain a JSON object")
|
||||||
|
parsed = result.get("parsed")
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("result file is missing parsed output")
|
||||||
|
report_text = _result_text(parsed)
|
||||||
|
workspace = _workspace_from_result(result, report_text)
|
||||||
|
return workspace, report_text
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_from_result(result: dict[str, object], report_text: str) -> Path:
|
||||||
|
config = result.get("config")
|
||||||
|
if isinstance(config, dict):
|
||||||
|
prompt_path = config.get("prompt_path")
|
||||||
|
if isinstance(prompt_path, str) and prompt_path:
|
||||||
|
return Path(prompt_path).parent
|
||||||
|
|
||||||
|
report = extract_challenge_report(report_text)
|
||||||
|
if report is not None:
|
||||||
|
workflow_file = report.get("workflow_file")
|
||||||
|
if isinstance(workflow_file, str) and workflow_file:
|
||||||
|
return Path(workflow_file).parent
|
||||||
|
|
||||||
|
raise ValueError("could not infer trial workspace from result")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_report_text(input_file: Path | None) -> str:
|
||||||
|
if input_file is None:
|
||||||
|
return sys.stdin.read()
|
||||||
|
return input_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("workspace", type=Path, nargs="?")
|
||||||
|
parser.add_argument("--from-result", type=Path, default=None)
|
||||||
|
parser.add_argument("--input-file", type=Path, default=None)
|
||||||
|
parser.add_argument("--output-name", default="final-report.md")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.from_result is not None:
|
||||||
|
if args.workspace is not None or args.input_file is not None:
|
||||||
|
parser.error("--from-result cannot be combined with workspace/input")
|
||||||
|
workspace, report_text = report_from_result(args.from_result)
|
||||||
|
else:
|
||||||
|
if args.workspace is None:
|
||||||
|
parser.error("workspace is required unless --from-result is used")
|
||||||
|
workspace = args.workspace
|
||||||
|
report_text = _read_report_text(args.input_file)
|
||||||
|
output_path = save_report(
|
||||||
|
workspace=workspace,
|
||||||
|
report_text=report_text,
|
||||||
|
output_name=args.output_name,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
parser.error(str(exc))
|
||||||
|
print(output_path.as_posix())
|
||||||
|
return 0
|
||||||
@@ -9,62 +9,90 @@ import sys
|
|||||||
import time
|
import time
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
Classification = Literal[
|
|
||||||
"success",
|
|
||||||
"workflow_script",
|
|
||||||
"workflow_not_used",
|
|
||||||
"run_failed",
|
|
||||||
"timeout",
|
|
||||||
"parse_error",
|
|
||||||
"unknown",
|
|
||||||
]
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[3]
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
CHALLENGE_DIR = Path(__file__).resolve().parent
|
if str(ROOT) not in sys.path:
|
||||||
DEFAULT_PROMPT = CHALLENGE_DIR / "prompt.md"
|
sys.path.insert(0, str(ROOT))
|
||||||
DEFAULT_RESULTS_DIR = CHALLENGE_DIR / "results"
|
|
||||||
DEFAULT_WORKSPACES_DIR = CHALLENGE_DIR / "workspaces"
|
|
||||||
DEFAULT_WORKSPACE_TEMPLATE = CHALLENGE_DIR / "workspace_template"
|
|
||||||
DEFAULT_SERVER_PORT = 8772
|
|
||||||
EXAMPLE_CONFIG = ROOT / "examples" / "browser_click_workflow" / "wf.config.json"
|
|
||||||
EXAMPLE_SOURCE_ROOT = ROOT / "examples" / "browser_click_workflow"
|
|
||||||
EXAMPLE_CONFIG_ARG = "examples/browser_click_workflow/wf.config.json"
|
|
||||||
LOCAL_WF_COMMAND_PREFIX = f"uv run wf --config {EXAMPLE_CONFIG_ARG} --local"
|
|
||||||
|
|
||||||
|
from examples.agent_challenges.browser_click_challenge.challenge import ( # noqa: E402
|
||||||
def rpc_url_for_port(port: int) -> str:
|
CHALLENGE_DIR,
|
||||||
return f"http://127.0.0.1:{port}/rpc"
|
CHALLENGE_REPORT_ATTEMPT_FIELDS,
|
||||||
|
CHALLENGE_REPORT_READ_FIELDS,
|
||||||
|
CHALLENGE_REPORT_REQUIRED_FIELDS,
|
||||||
def server_command(*, port: int) -> list[str]:
|
DEFAULT_PROMPT,
|
||||||
return [
|
DEFAULT_RESULTS_DIR,
|
||||||
"uv",
|
DEFAULT_SERVER_PORT,
|
||||||
"run",
|
DEFAULT_WORKSPACE_TEMPLATE,
|
||||||
"wf-rpc-server",
|
DEFAULT_WORKSPACES_DIR,
|
||||||
"--config",
|
EXAMPLE_CONFIG,
|
||||||
EXAMPLE_CONFIG_ARG,
|
EXAMPLE_CONFIG_ARG,
|
||||||
"--host",
|
EXAMPLE_SOURCE_ROOT,
|
||||||
"127.0.0.1",
|
LOCAL_WF_COMMAND_PREFIX,
|
||||||
"--port",
|
Classification,
|
||||||
str(port),
|
TrialConfig,
|
||||||
]
|
TrialWorkspace,
|
||||||
|
render_prompt,
|
||||||
|
rpc_url_for_port,
|
||||||
def render_prompt(
|
server_command,
|
||||||
prompt_path: Path,
|
|
||||||
*,
|
|
||||||
wf_command_prefix: str,
|
|
||||||
server_context: str,
|
|
||||||
) -> str:
|
|
||||||
return (
|
|
||||||
prompt_path.read_text(encoding="utf-8")
|
|
||||||
.replace("{{wf_command_prefix}}", wf_command_prefix)
|
|
||||||
.replace("{{server_context}}", server_context)
|
|
||||||
)
|
)
|
||||||
|
from examples.agent_challenges.browser_click_challenge.classification import ( # noqa: E402
|
||||||
|
_contains_bool_marker,
|
||||||
|
challenge_report_schema_errors,
|
||||||
|
classify_challenge_report,
|
||||||
|
classify_output,
|
||||||
|
extract_challenge_report,
|
||||||
|
)
|
||||||
|
from examples.agent_challenges.browser_click_challenge.opencode_io import ( # noqa: E402
|
||||||
|
_event_text,
|
||||||
|
_parse_jsonl_tail,
|
||||||
|
_result_text,
|
||||||
|
build_opencode_command,
|
||||||
|
parse_opencode_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CHALLENGE_DIR",
|
||||||
|
"CHALLENGE_REPORT_ATTEMPT_FIELDS",
|
||||||
|
"CHALLENGE_REPORT_READ_FIELDS",
|
||||||
|
"CHALLENGE_REPORT_REQUIRED_FIELDS",
|
||||||
|
"DEFAULT_PROMPT",
|
||||||
|
"DEFAULT_RESULTS_DIR",
|
||||||
|
"DEFAULT_SERVER_PORT",
|
||||||
|
"DEFAULT_WORKSPACE_TEMPLATE",
|
||||||
|
"DEFAULT_WORKSPACES_DIR",
|
||||||
|
"EXAMPLE_CONFIG",
|
||||||
|
"EXAMPLE_CONFIG_ARG",
|
||||||
|
"EXAMPLE_SOURCE_ROOT",
|
||||||
|
"LOCAL_WF_COMMAND_PREFIX",
|
||||||
|
"ROOT",
|
||||||
|
"Classification",
|
||||||
|
"ManagedServer",
|
||||||
|
"TrialConfig",
|
||||||
|
"TrialWorkspace",
|
||||||
|
"_contains_bool_marker",
|
||||||
|
"_event_text",
|
||||||
|
"_parse_jsonl_tail",
|
||||||
|
"_result_text",
|
||||||
|
"build_opencode_command",
|
||||||
|
"challenge_report_schema_errors",
|
||||||
|
"classify_challenge_report",
|
||||||
|
"classify_output",
|
||||||
|
"extract_challenge_report",
|
||||||
|
"main",
|
||||||
|
"parse_opencode_output",
|
||||||
|
"prepare_trial_workspace",
|
||||||
|
"render_prompt",
|
||||||
|
"rpc_url_for_port",
|
||||||
|
"run_trial",
|
||||||
|
"server_command",
|
||||||
|
"start_server",
|
||||||
|
"starting_trial_index",
|
||||||
|
"stop_server",
|
||||||
|
"trial_output_path",
|
||||||
|
"wf_command_prefix_for_config",
|
||||||
|
"write_trial_config",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -73,15 +101,6 @@ class ManagedServer:
|
|||||||
rpc_url: str
|
rpc_url: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class TrialWorkspace:
|
|
||||||
"""Per-trial scratch area copied from the challenge workspace template."""
|
|
||||||
|
|
||||||
root: Path
|
|
||||||
config_path: Path
|
|
||||||
prompt_path: Path
|
|
||||||
|
|
||||||
|
|
||||||
def start_server(*, port: int, timeout_seconds: int = 30) -> ManagedServer:
|
def start_server(*, port: int, timeout_seconds: int = 30) -> ManagedServer:
|
||||||
command = server_command(port=port)
|
command = server_command(port=port)
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
@@ -234,212 +253,6 @@ def stop_server(process: subprocess.Popen[str]) -> None:
|
|||||||
process.wait(timeout=10)
|
process.wait(timeout=10)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class TrialConfig:
|
|
||||||
model: str
|
|
||||||
variant: str
|
|
||||||
prompt_path: Path
|
|
||||||
attach_url: str | None
|
|
||||||
timeout_seconds: int
|
|
||||||
wf_command_prefix: str
|
|
||||||
server_context: str
|
|
||||||
|
|
||||||
|
|
||||||
def build_opencode_command(config: TrialConfig) -> list[str]:
|
|
||||||
prompt_text = render_prompt(
|
|
||||||
config.prompt_path,
|
|
||||||
wf_command_prefix=config.wf_command_prefix,
|
|
||||||
server_context=config.server_context,
|
|
||||||
)
|
|
||||||
command = [
|
|
||||||
"opencode",
|
|
||||||
"run",
|
|
||||||
]
|
|
||||||
if config.attach_url is not None:
|
|
||||||
command.extend(["--attach", config.attach_url])
|
|
||||||
command.extend(
|
|
||||||
[
|
|
||||||
prompt_text,
|
|
||||||
"--format",
|
|
||||||
"json",
|
|
||||||
"--model",
|
|
||||||
config.model,
|
|
||||||
"--variant",
|
|
||||||
config.variant,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return command
|
|
||||||
|
|
||||||
|
|
||||||
def parse_opencode_output(stdout: str) -> dict[str, Any]:
|
|
||||||
text = stdout.strip()
|
|
||||||
if not text:
|
|
||||||
raise ValueError("opencode produced no JSON output")
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = json.loads(text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
parsed = _parse_jsonl_tail(text)
|
|
||||||
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise ValueError("opencode output was not a JSON object")
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_jsonl_tail(text: str) -> dict[str, Any]:
|
|
||||||
last_error: json.JSONDecodeError | None = None
|
|
||||||
parsed_events: list[dict[str, Any]] = []
|
|
||||||
for line in reversed(text.splitlines()):
|
|
||||||
stripped = line.strip()
|
|
||||||
if not stripped:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
parsed = json.loads(stripped)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
last_error = exc
|
|
||||||
continue
|
|
||||||
if isinstance(parsed, dict):
|
|
||||||
parsed_events.append(parsed)
|
|
||||||
event_text = _event_text(parsed)
|
|
||||||
if event_text is not None:
|
|
||||||
return {"text": event_text, "event": parsed}
|
|
||||||
if parsed_events:
|
|
||||||
return parsed_events[0]
|
|
||||||
if last_error is not None:
|
|
||||||
raise last_error
|
|
||||||
raise ValueError("opencode output did not contain JSON lines")
|
|
||||||
|
|
||||||
|
|
||||||
def _event_text(event: dict[str, Any]) -> str | None:
|
|
||||||
"""Extract assistant text from opencode JSON events.
|
|
||||||
|
|
||||||
`opencode run --format json` emits many events. The final event can be a
|
|
||||||
`step_finish`; the answer text is usually the previous `text` event under
|
|
||||||
`part.text`.
|
|
||||||
"""
|
|
||||||
part = event.get("part")
|
|
||||||
if isinstance(part, dict):
|
|
||||||
text = part.get("text")
|
|
||||||
if isinstance(text, str):
|
|
||||||
return text
|
|
||||||
text = event.get("text")
|
|
||||||
return text if isinstance(text, str) else None
|
|
||||||
|
|
||||||
|
|
||||||
def classify_output(text: str) -> Classification:
|
|
||||||
report = extract_challenge_report(text)
|
|
||||||
if report is not None:
|
|
||||||
return classify_challenge_report(report)
|
|
||||||
|
|
||||||
lowered = text.lower()
|
|
||||||
product_command_markers = [
|
|
||||||
"wf ",
|
|
||||||
"wf-rpc-server",
|
|
||||||
]
|
|
||||||
workflow_evidence_markers = [
|
|
||||||
"deployment",
|
|
||||||
"run id",
|
|
||||||
"run_",
|
|
||||||
]
|
|
||||||
used_product_command = any(marker in lowered for marker in product_command_markers)
|
|
||||||
has_workflow_evidence = any(
|
|
||||||
marker in lowered for marker in workflow_evidence_markers
|
|
||||||
)
|
|
||||||
used_helper_script = (
|
|
||||||
"uv run python" in lowered
|
|
||||||
or "python examples/" in lowered
|
|
||||||
or "run_workflow.py" in lowered
|
|
||||||
)
|
|
||||||
failed = any(
|
|
||||||
marker in lowered
|
|
||||||
for marker in [
|
|
||||||
"error:",
|
|
||||||
"failed",
|
|
||||||
"traceback",
|
|
||||||
"exception",
|
|
||||||
"validation failed",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
before_false = _contains_bool_marker(lowered, "before.clicked", "false") or (
|
|
||||||
'"before"' in lowered and '"clicked": false' in lowered
|
|
||||||
)
|
|
||||||
after_true = _contains_bool_marker(lowered, "after.clicked", "true") or (
|
|
||||||
'"after"' in lowered and '"clicked": true' in lowered
|
|
||||||
)
|
|
||||||
|
|
||||||
if used_product_command and before_false and after_true and not failed:
|
|
||||||
return "success"
|
|
||||||
if has_workflow_evidence and used_helper_script and before_false and after_true:
|
|
||||||
return "workflow_script"
|
|
||||||
if (used_product_command or has_workflow_evidence) and failed:
|
|
||||||
return "run_failed"
|
|
||||||
if not has_workflow_evidence and (
|
|
||||||
before_false or after_true or "playwright" in lowered
|
|
||||||
):
|
|
||||||
return "workflow_not_used"
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def extract_challenge_report(text: str) -> dict[str, Any] | None:
|
|
||||||
"""Extract the required final YAML challenge report from agent output."""
|
|
||||||
marker = "```yaml"
|
|
||||||
start = text.lower().rfind(marker)
|
|
||||||
if start == -1:
|
|
||||||
return None
|
|
||||||
body_start = text.find("\n", start)
|
|
||||||
if body_start == -1:
|
|
||||||
return None
|
|
||||||
end = text.find("```", body_start + 1)
|
|
||||||
if end == -1:
|
|
||||||
return None
|
|
||||||
raw_yaml = text[body_start + 1 : end]
|
|
||||||
loaded = yaml.safe_load(raw_yaml)
|
|
||||||
if not isinstance(loaded, dict):
|
|
||||||
return None
|
|
||||||
report = loaded.get("challenge_report")
|
|
||||||
return report if isinstance(report, dict) else None
|
|
||||||
|
|
||||||
|
|
||||||
def classify_challenge_report(report: dict[str, Any]) -> Classification:
|
|
||||||
used_product_path = report.get("used_product_path") is True
|
|
||||||
used_helper_script = report.get("used_helper_script") is True
|
|
||||||
workflow_file = report.get("workflow_file")
|
|
||||||
deployment_id = report.get("deployment_id")
|
|
||||||
run_id = report.get("run_id")
|
|
||||||
before_clicked = report.get("before_clicked")
|
|
||||||
after_clicked = report.get("after_clicked")
|
|
||||||
failed = report.get("run_failed") is True
|
|
||||||
|
|
||||||
if failed:
|
|
||||||
return "run_failed"
|
|
||||||
if used_helper_script:
|
|
||||||
return "workflow_script"
|
|
||||||
if (
|
|
||||||
used_product_path
|
|
||||||
and isinstance(workflow_file, str)
|
|
||||||
and bool(workflow_file)
|
|
||||||
and isinstance(deployment_id, str)
|
|
||||||
and bool(deployment_id)
|
|
||||||
and isinstance(run_id, str)
|
|
||||||
and bool(run_id)
|
|
||||||
and before_clicked is False
|
|
||||||
and after_clicked is True
|
|
||||||
):
|
|
||||||
return "success"
|
|
||||||
if not used_product_path and (
|
|
||||||
before_clicked is not None or after_clicked is not None
|
|
||||||
):
|
|
||||||
return "workflow_not_used"
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def _contains_bool_marker(text: str, marker: str, value: str) -> bool:
|
|
||||||
marker_index = text.find(marker)
|
|
||||||
if marker_index == -1:
|
|
||||||
return False
|
|
||||||
return value in text[marker_index : marker_index + 80]
|
|
||||||
|
|
||||||
|
|
||||||
def trial_output_path(results_dir: Path, *, model: str, index: int) -> Path:
|
def trial_output_path(results_dir: Path, *, model: str, index: int) -> Path:
|
||||||
return results_dir / f"{_safe_model_name(model)}-trial-{index:03d}.json"
|
return results_dir / f"{_safe_model_name(model)}-trial-{index:03d}.json"
|
||||||
|
|
||||||
@@ -496,14 +309,6 @@ def run_trial(config: TrialConfig, *, index: int, results_dir: Path) -> dict[str
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _result_text(parsed: dict[str, Any]) -> str:
|
|
||||||
for key in ("text", "message", "content", "output"):
|
|
||||||
value = parsed.get(key)
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value
|
|
||||||
return json.dumps(parsed, sort_keys=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _jsonable_config(config: TrialConfig) -> dict[str, Any]:
|
def _jsonable_config(config: TrialConfig) -> dict[str, Any]:
|
||||||
payload = asdict(config)
|
payload = asdict(config)
|
||||||
payload["prompt_path"] = str(config.prompt_path)
|
payload["prompt_path"] = str(config.prompt_path)
|
||||||
@@ -527,7 +332,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
parser.add_argument("--model", default="opencode/mimo-v2.5-free")
|
parser.add_argument("--model", default="opencode/mimo-v2.5-free")
|
||||||
parser.add_argument("--variant", default="high")
|
parser.add_argument("--variant", default="high")
|
||||||
parser.add_argument("--trials", type=int, default=1)
|
parser.add_argument("--trials", type=int, default=1)
|
||||||
parser.add_argument("--timeout-seconds", type=int, default=600)
|
parser.add_argument("--timeout-seconds", type=int, default=1000)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--attach",
|
"--attach",
|
||||||
dest="attach_url",
|
dest="attach_url",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Support direct execution as `python examples/.../save_trial_report.py`.
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from examples.agent_challenges.browser_click_challenge.reports import ( # noqa: E402
|
||||||
|
main,
|
||||||
|
report_from_result,
|
||||||
|
save_report,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["main", "report_from_result", "save_report"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -55,14 +55,17 @@ The repository already includes a deterministic source example at:
|
|||||||
examples/browser_click_workflow/
|
examples/browser_click_workflow/
|
||||||
```
|
```
|
||||||
|
|
||||||
You may inspect and use it. A successful final answer must include:
|
You may inspect and use it.
|
||||||
|
|
||||||
|
Your final answer should include a short human-readable report with:
|
||||||
|
|
||||||
- the commands you ran,
|
- the commands you ran,
|
||||||
- the deployment id,
|
- the deployment id,
|
||||||
- the run id if one was produced,
|
- the run id if one was produced,
|
||||||
- evidence that `before.clicked` is `false`,
|
- evidence that `before.clicked` is `false`,
|
||||||
- evidence that `after.clicked` is `true`,
|
- evidence that `after.clicked` is `true`,
|
||||||
- whether any server/browser process remains running.
|
- whether any server/browser process remains running,
|
||||||
|
- important failed attempts and how you fixed them.
|
||||||
|
|
||||||
End your answer with exactly one fenced YAML block using this shape:
|
End your answer with exactly one fenced YAML block using this shape:
|
||||||
|
|
||||||
@@ -77,6 +80,18 @@ challenge_report:
|
|||||||
after_clicked: true
|
after_clicked: true
|
||||||
run_failed: false
|
run_failed: false
|
||||||
leftover_processes: false
|
leftover_processes: false
|
||||||
|
read:
|
||||||
|
skills: true
|
||||||
|
docs: true
|
||||||
|
product_code: false
|
||||||
|
adjacent_attempts: false
|
||||||
|
prior_store: false
|
||||||
|
existing_solution: false
|
||||||
|
attempts:
|
||||||
|
total: 1
|
||||||
|
failed: 0
|
||||||
|
missed_requirements:
|
||||||
|
- "none"
|
||||||
notes: "short explanation"
|
notes: "short explanation"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,62 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from examples.agent_challenges.browser_click_challenge.run_opencode_trials import (
|
from examples.agent_challenges.browser_click_challenge.challenge import (
|
||||||
LOCAL_WF_COMMAND_PREFIX,
|
LOCAL_WF_COMMAND_PREFIX,
|
||||||
TrialConfig,
|
TrialConfig,
|
||||||
build_opencode_command,
|
render_prompt,
|
||||||
|
server_command,
|
||||||
|
)
|
||||||
|
from examples.agent_challenges.browser_click_challenge.classification import (
|
||||||
|
challenge_report_schema_errors,
|
||||||
classify_challenge_report,
|
classify_challenge_report,
|
||||||
classify_output,
|
classify_output,
|
||||||
extract_challenge_report,
|
extract_challenge_report,
|
||||||
|
)
|
||||||
|
from examples.agent_challenges.browser_click_challenge.opencode_io import (
|
||||||
|
build_opencode_command,
|
||||||
parse_opencode_output,
|
parse_opencode_output,
|
||||||
|
)
|
||||||
|
from examples.agent_challenges.browser_click_challenge.reports import (
|
||||||
|
report_from_result,
|
||||||
|
save_report,
|
||||||
|
)
|
||||||
|
from examples.agent_challenges.browser_click_challenge.run_opencode_trials import (
|
||||||
prepare_trial_workspace,
|
prepare_trial_workspace,
|
||||||
render_prompt,
|
|
||||||
server_command,
|
|
||||||
starting_trial_index,
|
starting_trial_index,
|
||||||
trial_output_path,
|
trial_output_path,
|
||||||
wf_command_prefix_for_config,
|
wf_command_prefix_for_config,
|
||||||
)
|
)
|
||||||
|
from examples.agent_challenges.browser_click_challenge.save_trial_report import (
|
||||||
|
main as save_trial_report_main,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_challenge_report(**overrides: object) -> dict[str, object]:
|
||||||
|
report: dict[str, object] = {
|
||||||
|
"used_product_path": True,
|
||||||
|
"used_helper_script": False,
|
||||||
|
"workflow_file": "browser-click.workflow.yaml",
|
||||||
|
"deployment_id": "browser_click_case_study.default",
|
||||||
|
"run_id": "run_123",
|
||||||
|
"before_clicked": False,
|
||||||
|
"after_clicked": True,
|
||||||
|
"run_failed": False,
|
||||||
|
"leftover_processes": False,
|
||||||
|
"read": {
|
||||||
|
"skills": True,
|
||||||
|
"docs": True,
|
||||||
|
"product_code": False,
|
||||||
|
"adjacent_attempts": False,
|
||||||
|
"prior_store": False,
|
||||||
|
"existing_solution": False,
|
||||||
|
},
|
||||||
|
"attempts": {"total": 1, "failed": 0},
|
||||||
|
"missed_requirements": ["none"],
|
||||||
|
"notes": "ok",
|
||||||
|
}
|
||||||
|
report.update(overrides)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
def test_build_opencode_command_without_attach(tmp_path: Path) -> None:
|
def test_build_opencode_command_without_attach(tmp_path: Path) -> None:
|
||||||
@@ -137,6 +178,18 @@ def test_extract_challenge_report_from_yaml_block() -> None:
|
|||||||
after_clicked: true
|
after_clicked: true
|
||||||
run_failed: false
|
run_failed: false
|
||||||
leftover_processes: false
|
leftover_processes: false
|
||||||
|
read:
|
||||||
|
skills: true
|
||||||
|
docs: true
|
||||||
|
product_code: false
|
||||||
|
adjacent_attempts: false
|
||||||
|
prior_store: false
|
||||||
|
existing_solution: false
|
||||||
|
attempts:
|
||||||
|
total: 1
|
||||||
|
failed: 0
|
||||||
|
missed_requirements:
|
||||||
|
- "none"
|
||||||
notes: "ok"
|
notes: "ok"
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
@@ -150,22 +203,19 @@ def test_extract_challenge_report_from_yaml_block() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_classify_challenge_report_success() -> None:
|
def test_classify_challenge_report_success() -> None:
|
||||||
result = classify_challenge_report(
|
result = classify_challenge_report(_valid_challenge_report())
|
||||||
{
|
|
||||||
"used_product_path": True,
|
|
||||||
"used_helper_script": False,
|
|
||||||
"workflow_file": "browser-click.workflow.yaml",
|
|
||||||
"deployment_id": "browser_click_case_study.default",
|
|
||||||
"run_id": "run_123",
|
|
||||||
"before_clicked": False,
|
|
||||||
"after_clicked": True,
|
|
||||||
"run_failed": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == "success"
|
assert result == "success"
|
||||||
|
|
||||||
|
|
||||||
|
def test_challenge_report_schema_errors_reject_missing_read_block() -> None:
|
||||||
|
report = _valid_challenge_report()
|
||||||
|
report.pop("read")
|
||||||
|
|
||||||
|
assert challenge_report_schema_errors(report) == ["missing challenge_report.read"]
|
||||||
|
assert classify_challenge_report(report) == "unknown"
|
||||||
|
|
||||||
|
|
||||||
def test_classify_output_prefers_yaml_report() -> None:
|
def test_classify_output_prefers_yaml_report() -> None:
|
||||||
result = classify_output(
|
result = classify_output(
|
||||||
"""
|
"""
|
||||||
@@ -182,6 +232,18 @@ def test_classify_output_prefers_yaml_report() -> None:
|
|||||||
after_clicked: true
|
after_clicked: true
|
||||||
run_failed: false
|
run_failed: false
|
||||||
leftover_processes: false
|
leftover_processes: false
|
||||||
|
read:
|
||||||
|
skills: true
|
||||||
|
docs: true
|
||||||
|
product_code: false
|
||||||
|
adjacent_attempts: false
|
||||||
|
prior_store: false
|
||||||
|
existing_solution: false
|
||||||
|
attempts:
|
||||||
|
total: 1
|
||||||
|
failed: 0
|
||||||
|
missed_requirements:
|
||||||
|
- "none"
|
||||||
notes: "ok"
|
notes: "ok"
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
@@ -192,16 +254,11 @@ def test_classify_output_prefers_yaml_report() -> None:
|
|||||||
|
|
||||||
def test_classify_challenge_report_detects_helper_script() -> None:
|
def test_classify_challenge_report_detects_helper_script() -> None:
|
||||||
result = classify_challenge_report(
|
result = classify_challenge_report(
|
||||||
{
|
_valid_challenge_report(
|
||||||
"used_product_path": False,
|
used_product_path=False,
|
||||||
"used_helper_script": True,
|
used_helper_script=True,
|
||||||
"workflow_file": "",
|
workflow_file="",
|
||||||
"deployment_id": "browser_click_case_study.default",
|
)
|
||||||
"run_id": "run_123",
|
|
||||||
"before_clicked": False,
|
|
||||||
"after_clicked": True,
|
|
||||||
"run_failed": False,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == "workflow_script"
|
assert result == "workflow_script"
|
||||||
@@ -287,6 +344,61 @@ def test_prepare_trial_workspace_copies_template_to_model_trial_dir(
|
|||||||
assert (prepared.root / ".gitignore").read_text(encoding="utf-8") == ".wf_store/\n"
|
assert (prepared.root / ".gitignore").read_text(encoding="utf-8") == ".wf_store/\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_trial_report_copies_report_into_workspace(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "trial"
|
||||||
|
workspace.mkdir()
|
||||||
|
|
||||||
|
output = save_report(workspace=workspace, report_text="# Report\n\nok\n")
|
||||||
|
|
||||||
|
assert output == workspace / "final-report.md"
|
||||||
|
assert output.read_text(encoding="utf-8") == "# Report\n\nok\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_from_result_infers_workspace_from_prompt_path(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "trial"
|
||||||
|
workspace.mkdir()
|
||||||
|
result_path = tmp_path / "result.json"
|
||||||
|
result_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"config": {"prompt_path": str(workspace / "prompt.md")},
|
||||||
|
"parsed": {"text": "# Report\n\nok"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
inferred_workspace, report_text = report_from_result(result_path)
|
||||||
|
|
||||||
|
assert inferred_workspace == workspace
|
||||||
|
assert report_text == "# Report\n\nok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_trial_report_from_result_writes_inferred_workspace(
|
||||||
|
tmp_path: Path,
|
||||||
|
capsys,
|
||||||
|
) -> None:
|
||||||
|
workspace = tmp_path / "trial"
|
||||||
|
workspace.mkdir()
|
||||||
|
result_path = tmp_path / "result.json"
|
||||||
|
result_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"config": {"prompt_path": str(workspace / "prompt.md")},
|
||||||
|
"parsed": {"text": "# Report\n\nok"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert save_trial_report_main(["--from-result", str(result_path)]) == 0
|
||||||
|
|
||||||
|
assert (workspace / "final-report.md").read_text(encoding="utf-8") == (
|
||||||
|
"# Report\n\nok\n"
|
||||||
|
)
|
||||||
|
assert (workspace / "final-report.md").as_posix() in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_trial_workspace_uses_next_available_directory(
|
def test_prepare_trial_workspace_uses_next_available_directory(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user