167 lines
5.5 KiB
Python
167 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
|
|
PromptMode = Literal["auto", "continue", "final-report"]
|
|
|
|
FINAL_REPORT_PROMPT = (
|
|
"Your workflow attempt is over. Do not continue coding. Return only the "
|
|
"final challenge report using the required challenge_report YAML schema. "
|
|
"Include run_id, evidence, failed attempts, read flags, missed requirements, "
|
|
"and whether the run succeeded."
|
|
)
|
|
|
|
CONTINUE_PROMPT = (
|
|
"Continue this same trial from the current session. Do not restart in a new "
|
|
"workspace. If the workflow is already complete, stop and return only the "
|
|
"final challenge_report YAML using the required schema."
|
|
)
|
|
|
|
|
|
def _event_session_id(event: dict[str, Any]) -> str | None:
|
|
session_id = event.get("sessionID")
|
|
if isinstance(session_id, str) and session_id:
|
|
return session_id
|
|
part = event.get("part")
|
|
if isinstance(part, dict):
|
|
nested = part.get("sessionID")
|
|
if isinstance(nested, str) and nested:
|
|
return nested
|
|
return None
|
|
|
|
|
|
def extract_session_id(stdout: str) -> str | None:
|
|
"""Return the first OpenCode session id found in JSONL stdout."""
|
|
for line in stdout.splitlines():
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
event = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if not isinstance(event, dict):
|
|
continue
|
|
session_id = _event_session_id(event)
|
|
if session_id is not None:
|
|
return session_id
|
|
return None
|
|
|
|
|
|
def resume_prompt_for_result(result: dict[str, object]) -> str:
|
|
"""Choose a continuation prompt from the result failure shape."""
|
|
task_outcome = result.get("task_outcome")
|
|
assertion_failures = result.get("assertion_failures")
|
|
failures = assertion_failures if isinstance(assertion_failures, list) else []
|
|
if task_outcome == "timeout":
|
|
return CONTINUE_PROMPT
|
|
if any("could not extract challenge report" in str(item) for item in failures):
|
|
return FINAL_REPORT_PROMPT
|
|
if result.get("parsed") is None and result.get("stdout"):
|
|
return FINAL_REPORT_PROMPT
|
|
return CONTINUE_PROMPT
|
|
|
|
|
|
def resume_prompt_for_mode(result: dict[str, object], mode: PromptMode) -> str:
|
|
"""Return the operator-selected resume prompt, falling back to auto detection."""
|
|
if mode == "continue":
|
|
return CONTINUE_PROMPT
|
|
if mode == "final-report":
|
|
return FINAL_REPORT_PROMPT
|
|
return resume_prompt_for_result(result)
|
|
|
|
|
|
def build_resume_command(
|
|
*,
|
|
session_id: str,
|
|
attach_url: str | None,
|
|
model: str,
|
|
variant: str,
|
|
prompt: str,
|
|
) -> list[str]:
|
|
command = ["opencode", "run", "--session", session_id]
|
|
if attach_url is not None:
|
|
command.extend(["--attach", attach_url])
|
|
command.extend(["--format", "json", "--model", model, "--variant", variant, prompt])
|
|
return command
|
|
|
|
|
|
def resume_command_from_result(
|
|
result: dict[str, Any],
|
|
*,
|
|
session_id: str | None = None,
|
|
attach_url: str | None = None,
|
|
model: str | None = None,
|
|
variant: str | None = None,
|
|
prompt_mode: PromptMode = "auto",
|
|
) -> list[str]:
|
|
"""Rebuild a resume command from validated metadata or old raw results."""
|
|
opencode = result.get("opencode")
|
|
|
|
stdout = result.get("stdout")
|
|
stdout_text = stdout if isinstance(stdout, str) else ""
|
|
if isinstance(opencode, dict):
|
|
metadata_session_id = opencode.get("session_id")
|
|
metadata_model = opencode.get("model")
|
|
metadata_variant = opencode.get("variant")
|
|
metadata_attach_url = opencode.get("attach_url")
|
|
else:
|
|
metadata_session_id = None
|
|
metadata_model = None
|
|
metadata_variant = None
|
|
metadata_attach_url = None
|
|
|
|
recovered_session_id = (
|
|
session_id
|
|
or (metadata_session_id if isinstance(metadata_session_id, str) else None)
|
|
or extract_session_id(stdout_text)
|
|
)
|
|
if not recovered_session_id:
|
|
raise ValueError("result has no opencode session id; pass --session")
|
|
|
|
result_model = result.get("model")
|
|
result_variant = result.get("variant")
|
|
resolved_model = (
|
|
model
|
|
or (metadata_model if isinstance(metadata_model, str) else None)
|
|
or (result_model if isinstance(result_model, str) else None)
|
|
)
|
|
resolved_variant = (
|
|
variant
|
|
or (metadata_variant if isinstance(metadata_variant, str) else None)
|
|
or (result_variant if isinstance(result_variant, str) else None)
|
|
)
|
|
resolved_attach_url = attach_url or (
|
|
metadata_attach_url if isinstance(metadata_attach_url, str) else None
|
|
)
|
|
|
|
if not resolved_model:
|
|
raise ValueError("result has no opencode model; pass --model")
|
|
if not resolved_variant:
|
|
raise ValueError("result has no opencode variant; pass --variant")
|
|
|
|
return build_resume_command(
|
|
session_id=recovered_session_id,
|
|
attach_url=resolved_attach_url,
|
|
model=resolved_model,
|
|
variant=resolved_variant,
|
|
prompt=resume_prompt_for_mode(result, prompt_mode),
|
|
)
|
|
|
|
|
|
def display_resume_command(command: list[str]) -> str:
|
|
"""Render an argv list for copy-paste display without changing execution."""
|
|
return subprocess.list2cmdline(command)
|
|
|
|
|
|
def resume_result_path(result_path: Path) -> Path:
|
|
stem = result_path.with_suffix("")
|
|
index = 1
|
|
while True:
|
|
candidate = stem.with_name(f"{stem.name}.resume-{index:03d}.json")
|
|
if not candidate.exists():
|
|
return candidate
|
|
index += 1
|