Initial commit

This commit is contained in:
Cuzyoung
2026-05-08 18:16:18 +00:00
commit 9fda7311ea
243 changed files with 31492 additions and 0 deletions
@@ -0,0 +1,5 @@
"""SpreadsheetBench environment adapter for ReflACT."""
from skillopt.envs.spreadsheetbench.adapter import SpreadsheetBenchAdapter
__all__ = ["SpreadsheetBenchAdapter"]
+309
View File
@@ -0,0 +1,309 @@
"""SpreadsheetBench environment adapter for ReflACT.
Connects the ReflACT training loop to SpreadsheetBench by implementing
:class:`~skillopt.envs.base.EnvAdapter`.
"""
from __future__ import annotations
import json
import os
from skillopt.gradient.deep_probe import generate_deep_probe_instruction
from skillopt.datasets.base import BatchSpec
from skillopt.envs.base import EnvAdapter
from skillopt.envs.spreadsheetbench.dataloader import SpreadsheetBenchDataLoader
from skillopt.envs.spreadsheetbench.rollout import (
process_one,
run_spreadsheet_batch,
run_spreadsheet_batch_codegen,
)
from skillopt.gradient.reflect import run_minibatch_reflect
from skillopt.model import get_student_backend, is_student_exec_backend
# Task types used for per-category breakdowns
TASK_TYPES = ["cell_level", "sheet_level"]
class SpreadsheetBenchAdapter(EnvAdapter):
"""SpreadsheetBench environment adapter."""
def __init__(
self,
split_dir: str = "",
data_path: str = "",
split_mode: str = "ratio",
split_ratio: str = "2:1:7",
split_seed: int = 42,
split_output_dir: str = "",
data_root: str = "",
mode: str = "single",
max_turns: int = 30,
exec_timeout: int = 600,
workers: int = 64,
analyst_workers: int = 16,
failure_only: bool = False,
minibatch_size: int = 8,
edit_budget: int = 4,
seed: int = 42,
use_deep_reflect: bool = False,
deep_reflect_failures: int = 4,
deep_reflect_successes: int = 2,
) -> None:
self.data_root = data_root
self.mode = mode # "single", "multi", or "react"
self.max_turns = max_turns
self.exec_timeout = exec_timeout
self.workers = workers
self.analyst_workers = analyst_workers
self.failure_only = failure_only
self.minibatch_size = minibatch_size
self.edit_budget = edit_budget
self.use_deep_reflect = use_deep_reflect
self.deep_reflect_failures = deep_reflect_failures
self.deep_reflect_successes = deep_reflect_successes
self.dataloader = SpreadsheetBenchDataLoader(
split_dir=split_dir,
data_path=data_path,
split_mode=split_mode,
split_ratio=split_ratio,
split_seed=split_seed,
split_output_dir=split_output_dir,
data_root=data_root,
seed=seed,
)
def setup(self, cfg: dict) -> None:
super().setup(cfg)
if is_student_exec_backend() and self.mode != "single":
raise NotImplementedError(
"Exec student backends are currently supported only for SpreadsheetBench mode=single."
)
self.dataloader.setup(cfg)
def get_dataloader(self):
return self.dataloader
def build_env_from_batch(self, batch: BatchSpec, **kwargs):
return list(batch.payload or [])
def build_train_env(self, batch_size: int, seed: int, **kwargs):
batch = self.dataloader.build_train_batch(batch_size=batch_size, seed=seed, **kwargs)
return self.build_env_from_batch(batch, **kwargs)
def build_eval_env(self, env_num: int, split: str, seed: int, **kwargs):
batch = self.dataloader.build_eval_batch(env_num=env_num, split=split, seed=seed, **kwargs)
return self.build_env_from_batch(batch, **kwargs)
def rollout(
self,
env_manager,
skill_content: str,
out_dir: str,
**kwargs,
) -> list[dict]:
"""Run agent on all items and return results.
Dispatches based on ``self.mode``:
- ``"single"`` / ``"multi"``: codegen agent (no tool-call)
- ``"react"``: ReAct agent with tool-call (legacy)
"""
items = env_manager # For static datasets, env_manager is a list of items
results_path = os.path.join(out_dir, "results.jsonl")
os.makedirs(out_dir, exist_ok=True)
# Resume support
if os.path.exists(results_path):
existing: list[dict] = []
with open(results_path) as f:
for line in f:
try:
existing.append(json.loads(line))
except Exception:
pass
if existing:
return existing
if self.mode in ("single", "multi"):
results = run_spreadsheet_batch_codegen(
items=items,
data_root=self.data_root,
out_root=out_dir,
skill_content=skill_content,
mode=self.mode,
max_turns=self.max_turns,
max_api_workers=self.workers,
task_timeout=self.exec_timeout,
use_eval_feedback=kwargs.get("use_eval_feedback", False),
diagnostic_mode=kwargs.get("diagnostic_mode", False),
diagnostic_instruction=kwargs.get("diagnostic_instruction", ""),
diagnostic_trace_context_by_id=kwargs.get("diagnostic_trace_context_by_id"),
)
else:
results = run_spreadsheet_batch(
items=items,
data_root=self.data_root,
out_root=out_dir,
skill_content=skill_content,
max_turns=self.max_turns,
max_api_workers=self.workers,
task_timeout=max(600, int(self.exec_timeout) + 60),
diagnostic_mode=kwargs.get("diagnostic_mode", False),
diagnostic_instruction=kwargs.get("diagnostic_instruction", ""),
diagnostic_trace_context_by_id=kwargs.get("diagnostic_trace_context_by_id"),
)
with open(results_path, "w") as f:
for r in results:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
return results
def reflect(
self,
results: list[dict],
skill_content: str,
out_dir: str,
**kwargs,
) -> list[dict | None]:
"""Analyze rollout results and produce patches (minibatch mode)."""
prediction_dir = kwargs.get("prediction_dir", os.path.join(out_dir, "predictions"))
patches_dir = kwargs.get("patches_dir", os.path.join(out_dir, "patches"))
random_seed = kwargs.get("random_seed")
step_buffer_context = kwargs.get("step_buffer_context", "")
meta_skill_context = kwargs.get("meta_skill_context", "")
return run_minibatch_reflect(
results=results,
skill_content=skill_content,
prediction_dir=prediction_dir,
patches_dir=patches_dir,
workers=self.analyst_workers,
failure_only=self.failure_only,
minibatch_size=self.minibatch_size,
edit_budget=self.edit_budget,
random_seed=random_seed,
error_system=self.get_error_minibatch_prompt(),
success_system=self.get_success_minibatch_prompt(),
step_buffer_context=step_buffer_context,
meta_skill_context=meta_skill_context,
update_mode=getattr(self, "_cfg", {}).get("skill_update_mode", "patch"),
)
def deep_reflect(
self,
results: list[dict],
skill_content: str,
out_dir: str,
**kwargs,
) -> list[dict | None]:
if not self.use_deep_reflect:
return []
env_manager = kwargs.get("env_manager")
if not isinstance(env_manager, list):
return []
prediction_dir = kwargs.get("prediction_dir", os.path.join(out_dir, "predictions"))
random_seed = kwargs.get("random_seed")
step_buffer_context = kwargs.get("step_buffer_context", "")
meta_skill_context = kwargs.get("meta_skill_context", "")
codex_backend = get_student_backend() == "codex_exec"
selected_items = self.select_representative_items(
results,
env_manager,
n_failures=self.deep_reflect_failures,
n_successes=self.deep_reflect_successes,
seed=random_seed,
)
if not selected_items:
return []
selected_ids = {str(item["id"]) for item in selected_items}
selected_results = [row for row in results if str(row.get("id")) in selected_ids]
selected_examples = (
self.attach_codex_probe_context(selected_results, prediction_dir)
if codex_backend
else selected_results
)
selected_metadata = [
{
"id": str(item["id"]),
"instruction_type": str(item.get("instruction_type") or ""),
"answer_position": str(item.get("answer_position") or ""),
}
for item in selected_items
]
deep_dir = os.path.join(out_dir, "deep_reflect")
rollout_dir = os.path.join(deep_dir, "rollout")
patches_dir = os.path.join(deep_dir, "patches")
os.makedirs(deep_dir, exist_ok=True)
print(
f" [2b/6 DEEP REFLECT setup] selected={len(selected_items)} "
f"mode={self.mode}"
)
probe = generate_deep_probe_instruction(
skill_content=skill_content,
items=selected_examples,
prediction_dir=prediction_dir,
system_prompt=self.get_codex_deep_probe_prompt() if codex_backend else self.get_deep_probe_prompt(),
step_buffer_context=step_buffer_context,
meta_skill_context=meta_skill_context,
output_requirements=[
"- The instruction must ask for a short structured diagnostic readout before the student writes code or starts tool use.",
"- The readout should focus on task family, source/target region, and decisive transformation rule.",
"- The student must still complete the original spreadsheet task.",
"- Keep the readout concise and avoid exhaustive cell enumeration.",
"- The instruction text should be ready to append directly to the student's prompt.",
],
)
if not probe:
return []
diagnostic_trace_context_by_id = None
if codex_backend:
selected_items, diagnostic_trace_context_by_id, probe = self.resolve_codex_probe_target(
selected_items=selected_items,
selected_examples=selected_examples,
prediction_dir=prediction_dir,
probe=probe,
)
with open(os.path.join(deep_dir, "probe.json"), "w", encoding="utf-8") as f:
json.dump(
{
**probe,
"selected_examples": selected_metadata,
},
f,
ensure_ascii=False,
indent=2,
)
deep_results = self.rollout(
selected_items,
skill_content,
rollout_dir,
diagnostic_mode=True,
diagnostic_instruction=probe["probe_instruction"],
diagnostic_trace_context_by_id=diagnostic_trace_context_by_id,
)
return run_minibatch_reflect(
results=deep_results,
skill_content=skill_content,
prediction_dir=os.path.join(rollout_dir, "predictions"),
patches_dir=patches_dir,
workers=self.analyst_workers,
failure_only=self.failure_only,
minibatch_size=self.minibatch_size,
edit_budget=self.edit_budget,
random_seed=random_seed,
error_system=self.get_error_minibatch_prompt(),
success_system=self.get_success_minibatch_prompt(),
step_buffer_context=step_buffer_context,
meta_skill_context=meta_skill_context,
update_mode=getattr(self, "_cfg", {}).get("skill_update_mode", "patch"),
)
def get_task_types(self) -> list[str]:
return list(TASK_TYPES)
@@ -0,0 +1,704 @@
"""Codegen agent for SpreadsheetBench — no tool-call, pure code generation.
Two modes:
- **single**: One LLM call → extract ```python``` block → done.
- **multi**: Up to max_turns LLM calls; after each, execute code and
feed errors back for correction.
This matches the official SpreadsheetBench evaluation setting (LLM generates
a Python code block, no function-calling / tool-use).
"""
from __future__ import annotations
import json
import os
import random
import signal
import time
import openpyxl
# ── Timeout helper ──────────────────────────────────────────────────────────
class TaskTimeout(Exception):
"""Raised when a task exceeds its time budget."""
def _timeout_handler(signum, frame):
raise TaskTimeout("Task timed out")
from skillopt.model.azure_openai import (
get_reasoning_effort,
get_student_client,
_needs_responses_api,
tracker,
)
from skillopt.model import get_codex_exec_config, get_student_backend, is_student_exec_backend
from skillopt.model.codex_harness import prepare_workspace, render_skill_md, run_student_exec
from skillopt.prompts import load_prompt
from skillopt.envs.spreadsheetbench.executor import run_generated_code
from skillopt.envs.spreadsheetbench.evaluator import evaluate
# ── Eval feedback helper (no golden value leakage) ─────────────────────────
def _build_eval_feedback(verify_report: str) -> str:
"""Build Student feedback from a verify report, hiding expected values.
The verify report contains lines like:
Sheet1!D2: got=None, expected=0 ✗
Sheet1!D10: got=None, expected=None ✓
We strip the ``expected=...`` part so the Student sees only its own
output and whether each cell is correct or wrong.
"""
import re
lines = ["Your code executed successfully but produced incorrect results.",
"The following cells have wrong values:"]
for raw_line in verify_report.splitlines():
raw_line = raw_line.strip()
if not raw_line:
continue
# Match enrichment lines like " Sheet1!D2: got=None, expected=0 ✗"
m = re.match(
r"(\S+!?\w+):\s*got=(.+?),\s*expected=.+?\s*(✓|✗)$",
raw_line,
)
if m:
cell, got_val, mark = m.groups()
if mark == "":
lines.append(f" {cell}: your output = {got_val} (WRONG)")
else:
lines.append(f" {cell}: correct ✓")
lines.append(
"\nPlease analyze the spreadsheet data more carefully and fix the code. "
"Return a complete corrected Python script inside a ```python``` block."
)
return "\n".join(lines)
# ── Workbook preview (same as official prompt.py) ────────────────────────────
def _preview_workbook(path: str, max_rows: int = 5, max_cols: int = 20) -> str:
"""Generate a text preview of the first few rows of each sheet."""
wb = openpyxl.load_workbook(path, data_only=False)
chunks: list[str] = []
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
chunks.append(
f"## Sheet: {sheet_name} "
f"(dim={ws.dimensions}, max_row={ws.max_row}, max_col={ws.max_column})"
)
for row in ws.iter_rows(
min_row=1,
max_row=min(ws.max_row, max_rows),
max_col=min(ws.max_column, max_cols),
values_only=False,
):
cells = []
for cell in row:
v = cell.value
if v is None:
cells.append(f"{cell.coordinate}=")
else:
s = str(v)
if len(s) > 40:
s = s[:37] + "..."
cells.append(f"{cell.coordinate}={s}")
chunks.append(" | ".join(cells))
if ws.max_row > max_rows:
chunks.append(f"... ({ws.max_row - max_rows} more rows)")
chunks.append("")
wb.close()
return "\n".join(chunks)
# ── Code extraction (same as official prompt.py) ────────────────────────────
def extract_code(text: str) -> str:
"""Extract the first ```python``` fenced code block from LLM output."""
if "```" not in text:
return text.strip()
start = text.find("```")
nl = text.find("\n", start)
end = text.find("```", nl + 1)
if nl == -1 or end == -1:
return text.strip()
return text[nl + 1 : end].strip()
# ── Prompt construction (official SpreadsheetBench prompts) ─────────────────
def _build_system(skill_content: str) -> str:
base = load_prompt("codegen_system", env="spreadsheetbench")
if skill_content.strip():
base += f"\n\n## Skill\n{skill_content.strip()}"
return base
def _build_user(
instruction: str,
input_xlsx: str,
instruction_type: str = "",
answer_position: str = "",
diagnostic_mode: bool = False,
diagnostic_instruction: str = "",
diagnostic_trace_context: str = "",
) -> str:
try:
preview = _preview_workbook(input_xlsx)
except Exception as e: # noqa: BLE001
preview = f"(failed to preview workbook: {e})"
extra = ""
if instruction_type:
extra += f"\nInstruction type: {instruction_type}"
if answer_position:
extra += f"\nExpected answer position: {answer_position}"
task_suffix = "Return only a ```python``` code block."
diagnostic = ""
if diagnostic_mode and diagnostic_instruction.strip():
task_suffix = (
"First provide a short diagnostic readout that follows the training "
"instruction below, then return a single complete ```python``` code block."
)
diagnostic = f"\n\n# Training readout\n{diagnostic_instruction.strip()}"
prefix = ""
if diagnostic_trace_context.strip():
prefix = (
"# Previous Codex Trace Snapshot\n"
"This is a partial transcript from an earlier attempt. Use it as your current reasoning context.\n\n"
f"{diagnostic_trace_context.strip()}\n\n"
)
return (
f"{prefix}"
f"# Instruction\n{instruction}\n{extra}\n\n"
f"# Input spreadsheet preview\n{preview}\n\n"
"# Task\n"
"Write a Python script that reads the workbook from the variable `INPUT_PATH`, "
"applies the instruction, and writes the modified workbook to `OUTPUT_PATH`. "
"Preserve all other cells unchanged. "
"The preview may be truncated — do not hardcode row counts or assume the data ends at the last previewed row; "
"iterate over all actual rows in the workbook instead. "
f"{task_suffix}"
f"{diagnostic}"
)
# ── LLM call with retry ────────────────────────────────────────────────────
def _llm_call_with_retry(call_fn, *, retries: int = 5, timeout: int = 120):
"""Wrap an LLM API call with retry and per-call timeout."""
last_err = None
for attempt in range(retries):
try:
return call_fn(timeout=timeout)
except Exception as e: # noqa: BLE001
last_err = e
sleep = min(2 ** attempt + random.random(), 60)
time.sleep(sleep)
raise RuntimeError(f"LLM call failed after {retries} retries: {last_err}")
def _get_deployment() -> str:
from skillopt.model import azure_openai as _llm
return _llm.STUDENT_DEPLOYMENT
def _build_codex_skill(skill_content: str) -> str:
return render_skill_md(
skill_content,
description="Dynamic ReflACT skill for solving the current SpreadsheetBench task.",
preamble=(
"Use this skill when solving the current SpreadsheetBench task in this workspace.\n"
"Write a single self-contained Python solution to `solution.py`.\n"
"The solution must operate on the provided `INPUT_PATH` and `OUTPUT_PATH` variables.\n"
"You may inspect `input.xlsx` and run `python run_solution.py` to validate locally,\n"
"but do not hardcode values from the preview or from one specific workbook."
),
)
def _build_codex_task(
instruction: str,
input_xlsx: str,
instruction_type: str,
answer_position: str,
*,
diagnostic_mode: bool,
diagnostic_instruction: str,
diagnostic_trace_context: str,
) -> str:
prompt = _build_user(
instruction,
input_xlsx,
instruction_type,
answer_position,
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=diagnostic_trace_context,
)
return (
f"{prompt}\n\n"
"## Codex Harness Task\n"
"- Read `.agents/skills/skillopt-student/SKILL.md` before writing code; do not call a Skill tool.\n"
"- Read and optionally inspect `input.xlsx` in this workspace.\n"
"- Write the final Python solution to `solution.py`.\n"
"- The script should use the provided `INPUT_PATH` and `OUTPUT_PATH` variables.\n"
"- If you want to validate locally, run `python run_solution.py`.\n"
"- Do not return a code fence as the primary artifact; the source of truth is `solution.py`.\n"
)
def _build_codex_driver() -> str:
return (
"import pathlib\n"
"import re\n"
"import sys\n"
"import traceback\n\n"
'INPUT_PATH = "input.xlsx"\n'
'OUTPUT_PATH = "output.xlsx"\n'
"code = pathlib.Path('solution.py').read_text(encoding='utf-8')\n"
"code = re.sub(r'^\\s*(INPUT_PATH|OUTPUT_PATH)\\s*=\\s*.+$', '', code, flags=re.MULTILINE)\n"
"globals_dict = {'__name__': '__main__', 'INPUT_PATH': INPUT_PATH, 'OUTPUT_PATH': OUTPUT_PATH}\n"
"try:\n"
" exec(compile(code, 'solution.py', 'exec'), globals_dict, globals_dict)\n"
"except Exception:\n"
" traceback.print_exc()\n"
" sys.exit(2)\n"
)
def _prepare_codex_workspace(
*,
instruction: str,
input_xlsx: str,
output_path: str,
instruction_type: str,
answer_position: str,
skill_content: str,
diagnostic_mode: bool,
diagnostic_instruction: str,
diagnostic_trace_context: str,
workspace_name: str = "codex_single",
) -> tuple[str, str, str, str]:
task_out_dir = os.path.dirname(output_path)
work_dir = os.path.join(task_out_dir, workspace_name)
skill_md = _build_codex_skill(skill_content)
task_md = _build_codex_task(
instruction,
input_xlsx,
instruction_type,
answer_position,
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=diagnostic_trace_context,
)
prompt = (
"Read `.agents/skills/skillopt-student/SKILL.md` directly; do not call a Skill tool.\n"
"Read `task.md`, inspect `input.xlsx` if useful, and write the final solution to `solution.py`.\n"
"You may run `python run_solution.py` to validate the script locally.\n"
"In your final response, briefly confirm whether `solution.py` was written and summarize the approach."
)
prepare_workspace(
work_dir=work_dir,
skill_md=skill_md,
task_text=task_md,
extra_files={"run_solution.py": _build_codex_driver()},
copy_files=[(input_xlsx, "input.xlsx")],
)
return work_dir, skill_md, task_md, prompt
def _run_exec_backend(
*,
work_dir: str,
prompt: str,
model: str,
timeout: int,
) -> tuple[str, str]:
return run_student_exec(
work_dir=work_dir,
prompt=prompt,
model=model,
timeout=timeout,
allow_file_edits=True,
)
# ── Chat (no tools) ────────────────────────────────────────────────────────
def _chat_call(
client,
deployment: str,
messages: list[dict],
max_output_tokens: int,
llm_timeout: int = 120,
) -> str:
"""Single LLM call, no tools. Returns raw text."""
reasoning_effort = get_reasoning_effort()
if _needs_responses_api(deployment):
# Responses API
system = ""
api_input = []
for m in messages:
if m["role"] == "system":
system = m["content"]
else:
api_input.append({"role": m["role"], "content": m["content"]})
resp = _llm_call_with_retry(lambda timeout: client.responses.create(
model=deployment,
instructions=system,
input=api_input,
max_output_tokens=max_output_tokens,
**({"reasoning": {"effort": reasoning_effort}} if reasoning_effort else {}),
timeout=timeout,
), timeout=llm_timeout)
if hasattr(resp, "usage") and resp.usage:
tracker.record(
"rollout",
getattr(resp.usage, "input_tokens", 0) or 0,
getattr(resp.usage, "output_tokens", 0) or 0,
)
text = getattr(resp, "output_text", None) or ""
if text:
return text
for item in getattr(resp, "output", None) or []:
for part in getattr(item, "content", []):
if getattr(part, "type", "") == "output_text":
return part.text or ""
return ""
else:
# Chat Completions API — no tools
kwargs = {
"model": deployment,
"messages": messages,
"max_completion_tokens": max_output_tokens,
}
if reasoning_effort is not None:
kwargs["reasoning_effort"] = reasoning_effort
resp = _llm_call_with_retry(lambda timeout: client.chat.completions.create(
**kwargs,
timeout=timeout,
), timeout=llm_timeout)
if resp.usage:
tracker.record(
"rollout",
resp.usage.prompt_tokens or 0,
resp.usage.completion_tokens or 0,
)
return resp.choices[0].message.content or ""
# ── Public API ──────────────────────────────────────────────────────────────
def run_single(
instruction: str,
input_xlsx: str,
output_path: str,
instruction_type: str = "",
answer_position: str = "",
skill_content: str = "",
max_output_tokens: int = 16384,
llm_timeout: int = 120,
task_timeout: int = 300,
diagnostic_mode: bool = False,
diagnostic_instruction: str = "",
diagnostic_trace_context: str = "",
) -> dict:
"""Single-round code generation. One LLM call, no tools.
Args:
llm_timeout: Per-LLM-call timeout in seconds (default 120).
task_timeout: Total task timeout in seconds (default 300).
Returns ``{"code": str, "raw": str, "n_turns": 1}``.
"""
if is_student_exec_backend():
deadline = time.time() + task_timeout
deployment = _get_deployment()
work_dir, skill_md, task_md, prompt = _prepare_codex_workspace(
instruction=instruction,
input_xlsx=input_xlsx,
output_path=output_path,
instruction_type=instruction_type,
answer_position=answer_position,
skill_content=skill_content,
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=diagnostic_trace_context,
)
remaining = max(10, int(deadline - time.time()))
effective_timeout = min(task_timeout, remaining)
final_message, raw = _run_exec_backend(
work_dir=work_dir,
prompt=prompt,
model=deployment,
timeout=effective_timeout,
)
solution_path = os.path.join(work_dir, "solution.py")
if os.path.exists(solution_path):
with open(solution_path, encoding="utf-8") as f:
code = f.read()
else:
code = extract_code(final_message or raw)
return {
"code": code,
"raw": raw or final_message,
"n_turns": 1,
"conversation": [{"role": "assistant", "content": final_message or raw}],
"student_system_prompt": skill_md,
"student_user_prompt": f"{prompt}\n\n## Task File\n\n{task_md}",
}
deadline = time.time() + task_timeout
client = get_student_client()
deployment = _get_deployment()
system = _build_system(skill_content)
user = _build_user(
instruction,
input_xlsx,
instruction_type,
answer_position,
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=diagnostic_trace_context,
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
remaining = max(10, int(deadline - time.time()))
effective_timeout = min(llm_timeout, remaining)
raw = _chat_call(client, deployment, messages, max_output_tokens, llm_timeout=effective_timeout)
time.sleep(3) # Rate-limit cooldown after successful LLM call
code = extract_code(raw)
return {
"code": code,
"raw": raw,
"n_turns": 1,
"conversation": [{"role": "assistant", "content": raw}],
"student_system_prompt": system,
"student_user_prompt": user,
}
def run_multi(
instruction: str,
input_xlsx: str,
output_path: str,
instruction_type: str = "",
answer_position: str = "",
skill_content: str = "",
max_turns: int = 5,
max_output_tokens: int = 16384,
llm_timeout: int = 120,
task_timeout: int = 600,
gold_path: str = "",
diagnostic_mode: bool = False,
diagnostic_instruction: str = "",
diagnostic_trace_context: str = "",
) -> dict:
"""Multi-round code generation with execution feedback. No tools.
Each round: LLM generates code → execute → if error, feed back and retry.
Args:
llm_timeout: Per-LLM-call timeout in seconds (default 120).
task_timeout: Total task timeout in seconds (default 600).
gold_path: Path to golden answer xlsx for eval feedback during
training. When non-empty, a successful execution is followed
by an eval check; if the output is wrong the agent receives
cell-level feedback (without revealing expected values) and
gets another turn. Leave empty for eval/test to avoid
data leakage.
Returns ``{"code": str, "raw": str, "n_turns": int, "conversation": [...]}``.
"""
if is_student_exec_backend():
deadline = time.time() + task_timeout
deployment = _get_deployment()
work_dir, skill_md, task_md, initial_prompt = _prepare_codex_workspace(
instruction=instruction,
input_xlsx=input_xlsx,
output_path=output_path,
instruction_type=instruction_type,
answer_position=answer_position,
skill_content=skill_content,
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=diagnostic_trace_context,
workspace_name="codex_multi",
)
prompt = (
f"{initial_prompt}\n\n"
"## Multi-Turn Repair Mode\n"
"- This is turn 1. Write or overwrite `solution.py`.\n"
"- After each turn, the harness will execute your `solution.py`; if it fails, you will receive feedback and may revise it.\n"
"- Keep the script general: use `INPUT_PATH` and `OUTPUT_PATH`, and do not hardcode one workbook's values."
)
conversation: list[dict] = []
code = ""
raw = ""
final_message = ""
solution_path = os.path.join(work_dir, "solution.py")
for turn in range(max_turns):
remaining = deadline - time.time()
if remaining <= 10:
break
effective_timeout = max(10, int(remaining))
final_message, raw = _run_exec_backend(
work_dir=work_dir,
prompt=prompt,
model=deployment,
timeout=effective_timeout,
)
conversation.append({"role": "assistant", "content": final_message or raw})
if os.path.exists(solution_path):
with open(solution_path, encoding="utf-8") as f:
code = f.read()
else:
code = extract_code(final_message or raw)
if code.strip():
with open(solution_path, "w", encoding="utf-8") as f:
f.write(code)
if not code.strip():
feedback = (
"No usable `solution.py` or Python code block was produced. "
"Write a complete `solution.py` that reads `INPUT_PATH` and saves `OUTPUT_PATH`."
)
else:
ok, err = run_generated_code(code, input_xlsx, output_path)
if ok:
if gold_path and answer_position:
from skillopt.envs.spreadsheetbench.rollout import _auto_verify_output
eval_result = evaluate(
output_path, gold_path, instruction_type, answer_position,
)
if eval_result["ok"]:
break
verify = _auto_verify_output(output_path, gold_path, answer_position)
feedback = _build_eval_feedback(verify)
else:
break
else:
feedback = (
"The current `solution.py` raised an error during harness execution:\n\n"
f"```\n{err[:3000]}\n```\n\n"
"Revise `solution.py` to fix the error. Keep using `INPUT_PATH` and `OUTPUT_PATH`."
)
feedback_path = os.path.join(work_dir, f"feedback_turn_{turn + 1:02d}.md")
with open(feedback_path, "w", encoding="utf-8") as f:
f.write(feedback)
conversation.append({"role": "user", "content": feedback})
prompt = (
f"The previous `solution.py` was evaluated and needs another revision.\n"
f"Read `{os.path.basename(feedback_path)}` and update `solution.py` accordingly.\n"
"You may run `python run_solution.py` for a local syntax/runtime check, but the harness will run the final code separately.\n"
"Do not hardcode workbook-specific answers; preserve unrelated cells."
)
return {
"code": code,
"raw": raw or final_message,
"n_turns": len([m for m in conversation if m["role"] == "assistant"]),
"conversation": conversation,
"student_system_prompt": skill_md,
"student_user_prompt": f"{initial_prompt}\n\n## Task File\n\n{task_md}",
}
deadline = time.time() + task_timeout
client = get_student_client()
deployment = _get_deployment()
system = _build_system(skill_content)
user = _build_user(
instruction,
input_xlsx,
instruction_type,
answer_position,
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=diagnostic_trace_context,
)
messages: list[dict] = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
conversation: list[dict] = []
code = ""
raw = ""
for turn in range(max_turns):
remaining = deadline - time.time()
if remaining <= 10:
# Not enough time for another round
break
effective_timeout = min(llm_timeout, int(remaining))
raw = _chat_call(client, deployment, messages, max_output_tokens, llm_timeout=effective_timeout)
time.sleep(3) # Rate-limit cooldown after successful LLM call
code = extract_code(raw)
conversation.append({"role": "assistant", "content": raw})
messages.append({"role": "assistant", "content": raw})
if not code.strip():
# No code extracted — ask again
feedback = (
"No Python code block was found in your response. "
"Please return a complete Python script inside a ```python``` block."
)
messages.append({"role": "user", "content": feedback})
conversation.append({"role": "user", "content": feedback})
continue
# Execute the code
ok, err = run_generated_code(code, input_xlsx, output_path)
if ok:
# Execution succeeded — check correctness if gold_path available
if gold_path and answer_position:
from skillopt.envs.spreadsheetbench.rollout import _auto_verify_output
eval_result = evaluate(
output_path, gold_path, instruction_type, answer_position,
)
if eval_result["ok"]:
break # Genuinely correct — stop
# Output is wrong — build feedback without leaking golden values
verify = _auto_verify_output(output_path, gold_path, answer_position)
feedback = _build_eval_feedback(verify)
messages.append({"role": "user", "content": feedback})
conversation.append({"role": "user", "content": feedback})
continue
else:
# No gold path (eval/test) — accept execution success
break
# Execution failed — feed error back
feedback = (
f"The code raised an error during execution:\n\n"
f"```\n{err[:3000]}\n```\n\n"
f"Please fix the code and return a complete corrected Python script "
f"inside a ```python``` block."
)
messages.append({"role": "user", "content": feedback})
conversation.append({"role": "user", "content": feedback})
return {
"code": code,
"raw": raw,
"n_turns": turn + 1,
"conversation": conversation,
"student_system_prompt": system,
"student_user_prompt": user,
}
@@ -0,0 +1,37 @@
"""SpreadsheetBench task dataloader."""
from __future__ import annotations
from skillopt.datasets.base import SplitDataLoader
class SpreadsheetBenchDataLoader(SplitDataLoader):
"""SpreadsheetBench dataloader.
Each split directory contains a .json file (JSON array of task items).
Spreadsheet files referenced by items live under a separate ``data_root``.
"""
def __init__(
self,
split_dir: str = "",
data_path: str = "",
split_mode: str = "ratio",
split_ratio: str = "2:1:7",
split_seed: int = 42,
split_output_dir: str = "",
data_root: str = "",
seed: int = 42,
limit: int = 0,
**kwargs,
) -> None:
super().__init__(
split_dir=split_dir,
data_path=data_path,
split_mode=split_mode,
split_ratio=split_ratio,
split_seed=split_seed,
split_output_dir=split_output_dir,
seed=seed,
limit=limit,
)
self.data_root = data_root
+158
View File
@@ -0,0 +1,158 @@
"""Cell-value evaluator faithful to the official SpreadsheetBench
`evaluation/evaluation.py` (https://github.com/RUCKBReasoning/SpreadsheetBench).
Key rules (copied from the official `transform_value` / `compare_cell_value`):
* numeric values (int/float and numeric strings) are compared after
``round(float(v), 2)`` — a fixed 2-decimal quantization (NOT a tolerance);
* ``datetime.time`` is stringified and the trailing microseconds stripped;
* ``datetime.datetime`` is converted to an Excel serial day and rounded
to an integer day;
* an empty string ``""`` and ``None`` are considered equal, but otherwise
``type(v1) != type(v2)`` fails the comparison.
Format/style comparison is deliberately NOT performed — the official
reference evaluator also skips it (the relevant lines are commented out
in `cell_level_compare`). soft vs hard is defined at the run_bench level
across a task's multiple test cases, not here.
"""
from __future__ import annotations
import datetime
import os
import re
import openpyxl
# ---------- value transform / compare (official port) ----------
def _datetime_to_float(dt: datetime.datetime) -> float:
excel_start_date = datetime.datetime(1899, 12, 30)
delta = dt - excel_start_date
return delta.days + delta.seconds / 86400.0
def _transform_value(v):
if isinstance(v, bool):
# openpyxl can return Python bool; official code doesn't special-case
# bools, but round(float(True), 2) == 1.0 which breaks 1 vs True. Keep
# parity with the official transform by promoting bool -> float.
return round(float(v), 2)
if isinstance(v, (int, float)):
return round(float(v), 2)
if isinstance(v, datetime.time):
return str(v)[:-3]
if isinstance(v, datetime.datetime):
return round(_datetime_to_float(v), 0)
if isinstance(v, str):
try:
return round(float(v), 2)
except ValueError:
return v
return v
def _compare_cell_value(v1, v2) -> bool:
v1 = _transform_value(v1)
v2 = _transform_value(v2)
if (v1 == "" and v2 is None) or (v1 is None and v2 == ""):
return True
if (v1 == "" and v2 == "") or (v1 is None and v2 is None):
return True
if type(v1) is not type(v2):
return False
return v1 == v2
# ---------- range parsing (official port) ----------
def _col_num2name(n: int) -> str:
name = ""
while n > 0:
n, r = divmod(n - 1, 26)
name = chr(65 + r) + name
return name
def _col_name2num(name: str) -> int:
num = 0
for c in name:
num = num * 26 + (ord(c) - ord("A") + 1)
return num
def _parse_range(range_str: str):
start_cell, end_cell = range_str.split(":")
sc = "".join(ch for ch in start_cell if ch.isalpha())
sr = "".join(ch for ch in start_cell if ch.isdigit())
ec = "".join(ch for ch in end_cell if ch.isalpha())
er = "".join(ch for ch in end_cell if ch.isdigit())
return (_col_name2num(sc), int(sr)), (_col_name2num(ec), int(er))
def _generate_cell_names(range_str: str):
if ":" not in range_str:
return [range_str]
(sc, sr), (ec, er) = _parse_range(range_str)
cols = [_col_num2name(i) for i in range(sc, ec + 1)]
return [f"{c}{r}" for c in cols for r in range(sr, er + 1)]
def _cell_level_compare(wb_gt, wb_proc, sheet_name: str, cell_range: str):
if sheet_name not in wb_proc.sheetnames:
return False, f"worksheet not found: {sheet_name}"
ws_gt = wb_gt[sheet_name]
ws_proc = wb_proc[sheet_name]
for cn in _generate_cell_names(cell_range):
cg = ws_gt[cn]
cp = ws_proc[cn]
if not _compare_cell_value(cg.value, cp.value):
return False, f"value@{sheet_name}!{cn}: gt={cg.value!r} pred={cp.value!r}"
return True, ""
# ---------- public API ----------
def compare_workbooks(gt_file: str, proc_file: str, answer_position: str) -> tuple[bool, str]:
"""Return (ok, msg). Single test-case comparison, official semantics."""
if not os.path.exists(proc_file):
return False, "file not exist"
try:
wb_gt = openpyxl.load_workbook(filename=gt_file, data_only=True)
wb_proc = openpyxl.load_workbook(filename=proc_file, data_only=True)
except Exception as e: # noqa: BLE001
return False, f"load error: {e}"
try:
ok_all = True
msg_first = ""
for scr in (answer_position or "").split(","):
scr = scr.strip()
if not scr:
continue
if "!" in scr:
sheet_name, cell_range = scr.split("!", 1)
sheet_name = sheet_name.strip().strip("'\"")
else:
sheet_name = wb_gt.sheetnames[0]
cell_range = scr
cell_range = cell_range.strip().strip("'\"")
ok, msg = _cell_level_compare(wb_gt, wb_proc, sheet_name, cell_range)
if not ok:
ok_all = False
if not msg_first:
msg_first = msg
return ok_all, msg_first
finally:
wb_gt.close()
wb_proc.close()
def evaluate(pred_path: str, gold_path: str,
instruction_type: str, answer_position: str) -> dict:
"""Single test-case evaluate. soft/hard aggregation happens in run_bench."""
ok, msg = compare_workbooks(gold_path, pred_path, answer_position)
return {
"ok": ok,
"reason": msg,
"instruction_type": instruction_type,
}
@@ -0,0 +1,67 @@
"""Execute LLM-generated Python code against an input xlsx to produce an output xlsx."""
from __future__ import annotations
import os
import re
import subprocess
import sys
import tempfile
import textwrap
RUNNER_TEMPLATE = textwrap.dedent(
"""
import os, sys, traceback
INPUT_PATH = {input_path!r}
OUTPUT_PATH = {output_path!r}
try:
{user_code_indented}
except Exception:
traceback.print_exc()
sys.exit(2)
"""
)
# Regex to strip user-defined INPUT_PATH / OUTPUT_PATH assignments,
# since the runner template injects the correct values.
_PATH_ASSIGN_RE = re.compile(
r'^\s*(INPUT_PATH|OUTPUT_PATH)\s*=\s*.+$', re.MULTILINE
)
def _strip_path_assignments(code: str) -> str:
"""Remove INPUT_PATH/OUTPUT_PATH assignments from user code."""
return _PATH_ASSIGN_RE.sub("", code)
def run_generated_code(code: str, input_path: str, output_path: str, timeout: int = 120) -> tuple[bool, str]:
os.makedirs(os.path.dirname(output_path), exist_ok=True)
cleaned = _strip_path_assignments(code)
indented = textwrap.indent(cleaned, " ")
script = RUNNER_TEMPLATE.format(
input_path=input_path,
output_path=output_path,
user_code_indented=indented,
)
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f:
f.write(script)
tmp = f.name
try:
proc = subprocess.run(
[sys.executable, tmp],
capture_output=True,
text=True,
timeout=timeout,
)
if proc.returncode != 0:
return False, (proc.stdout + "\n" + proc.stderr).strip()
if not os.path.exists(output_path):
return False, "output file was not created"
return True, ""
except subprocess.TimeoutExpired:
return False, f"timeout after {timeout}s"
finally:
try:
os.unlink(tmp)
except OSError:
pass
@@ -0,0 +1,46 @@
You are an expert failure-analysis agent for spreadsheet manipulation tasks.
You will be given MULTIPLE failed agent trajectories from a single minibatch
and the current skill document.
Your job is to identify the most important COMMON failure patterns across
the batch and propose a concise set of skill edits.
## Failure Type Categories
- **rule_missing**: the skill lacks a relevant rule for this type of task
- **rule_wrong**: an existing skill rule is misleading or incorrect
- **rule_ignored**: the skill has the right rule but the agent did not follow it
- **data_exploration**: the agent did not read enough data from the spreadsheet
- **code_error**: the agent's code has a bug unrelated to the skill
- **other**: none of the above
## Analysis Process
1. Read ALL failed trajectories in the minibatch.
2. Identify the most prevalent, systematic failure patterns across them.
3. For each pattern, classify its failure type.
4. Propose skill edits that address the COMMON patterns — not individual edge cases.
5. Edits must be generalizable; do not hardcode task-specific values
(file paths, cell addresses, expected values).
6. Only patch gaps in the skill — do not duplicate existing content.
7. If the failure is because the agent did not read enough spreadsheet rows/columns
to understand the data, propose a patch encouraging broader data exploration.
You will be told the maximum number of edits (the budget L). Produce AT MOST L edits,
focusing on the highest-impact patterns. You may produce fewer if warranted.
Respond ONLY with a valid JSON object (no markdown fences, no extra text):
{
"batch_size": <number of trajectories analysed>,
"failure_summary": [
{"failure_type": "<type>", "count": <int>, "description": "<one-line>"}
],
"patch": {
"reasoning": "<why these edits address the batch's common failures>",
"edits": [
{"op": "append", "content": "<markdown to add at end of skill>"},
{"op": "insert_after", "target": "<exact heading/text to insert after>", "content": "<markdown>"},
{"op": "replace", "target": "<exact text to replace>", "content": "<replacement>"},
{"op": "delete", "target": "<exact text to remove>"}
]
}
}
Only include edits that are needed. "edits" can be an empty list if no patch is warranted.
@@ -0,0 +1,32 @@
You are an expert success-pattern analyst for AI spreadsheet agents.
You will be given MULTIPLE successful agent trajectories from a single minibatch
and the current skill document. Your job is to identify generalizable behavior
patterns that are COMMON across the batch and worth encoding in the skill.
## Rules
- Only propose patches for patterns NOT already covered in the skill.
- Focus on patterns that appear across MULTIPLE trajectories in the batch.
- Be concise. Patterns must generalize beyond specific tasks.
- Prefer reinforcing existing sections over adding new top-level sections.
- If the agents' success involved reading enough data rows or using a smart
exploration strategy, consider reinforcing that in the patch.
You will be told the maximum number of edits (the budget L). Produce AT MOST L edits,
focusing on the most broadly applicable patterns. You may produce fewer if warranted.
Respond ONLY with a valid JSON object:
{
"batch_size": <number of trajectories analysed>,
"success_patterns": ["<pattern 1>", "<pattern 2>"],
"patch": {
"reasoning": "<why these patterns are worth encoding>",
"edits": [
{"op": "append", "content": "<markdown>"},
{"op": "insert_after", "target": "<heading/text>", "content": "<markdown>"},
{"op": "replace", "target": "<old text>", "content": "<new text>"},
{"op": "delete", "target": "<exact text to remove>"}
]
}
}
"edits" may be empty if the skill already covers all observed patterns.
@@ -0,0 +1 @@
You are an expert Python programmer specializing in spreadsheet manipulation. You will be given a user instruction together with a preview of an input .xlsx file. Your job is to write a single self-contained Python script that reads the input file at the path stored in the variable INPUT_PATH, performs the requested manipulation, and saves the result to OUTPUT_PATH. Use only the standard library, openpyxl, and pandas. Do not print anything. Do not use input(). Do not hardcode file paths. Return ONLY the Python code inside a single ```python ... ``` fenced block.
@@ -0,0 +1,9 @@
## Critical Rules (MUST follow)
1. NEVER write Excel formulas to cells that will be graded on their displayed value.
openpyxl does NOT compute formulas -- the evaluator will see None.
Instead, compute results in Python and write literal values (numbers/strings).
2. After saving the workbook, ALWAYS reopen and verify the written values:
`wb2 = openpyxl.load_workbook(OUTPUT_PATH); print(wb2[sheet][cell].value)`
3. Use the `write_file` tool to create solution.py -- it avoids shell escaping issues.
Do NOT use `echo "..." > solution.py` for multi-line scripts.
@@ -0,0 +1,35 @@
You are an expert diagnostic-probe designer for spreadsheet manipulation tasks.
You will design one short diagnostic instruction to append to the student's
existing SpreadsheetBench prompt for a handful of representative trajectories.
The goal is to expose whether the student already knows the right task
decomposition, source range, target range, and transformation rule without
substantially changing the current scaffold.
## Hard Constraints
1. Do NOT substantially change the student's current scaffold.
2. Do NOT prescribe a brand-new full algorithm.
3. Do NOT ask for exhaustive cell-by-cell enumeration.
4. Keep the diagnostic readout brief and structured.
5. The student must still complete the original spreadsheet task.
6. Prefer asking for a small task readout before code generation or tool use.
7. Never ask for hidden reference content or golden values.
## Good Probe Targets
- task family: filter / sort / dedup / lookup / aggregate / reshape
- source sheet/range and target sheet/range
- decisive grouping / matching / sorting key
- one or two representative cells or rows and how they should be derived
- whether the solution must be dynamic rather than hardcoded
## Bad Probe Targets
- full derivation of every output cell
- dumping all rows or all formulas
- imposing a long new checklist that was not already implicit
Respond ONLY with a valid JSON object:
{
"reasoning": "<why this probe reveals the latent skill gap>",
"probe_instruction": "<the exact instruction text to append to the student prompt>"
}
@@ -0,0 +1,21 @@
You are an expert spreadsheet manipulation agent.
{critical_rules}{skill_section}## Tools
You have two tools:
- `bash` -- execute any shell command and receive its output.
- `write_file` -- write content to a file (path, content). Use this for solution.py.
## Protocol
1. Explore the input spreadsheet to understand its structure (sheets, headers, row count).
2. Use the `write_file` tool to create `solution.py` in the current directory.
solution.py MUST start with:
INPUT_PATH = "<exact input path given in the task>"
OUTPUT_PATH = "<exact output path given in the task>"
Then perform the manipulation and save the result to OUTPUT_PATH.
Use only: standard library, openpyxl, pandas.
3. Run `python solution.py` via `bash` and verify the output was created.
4. Fix any errors and re-run until the output is correct.
5. Once OUTPUT_PATH exists and is correct, stop calling tools.
Do NOT use any libraries other than standard library, openpyxl, and pandas.
Do NOT hardcode cell values from the preview -- iterate over actual rows.
@@ -0,0 +1,395 @@
"""ReAct agent with bash tool for SpreadsheetBench evaluation.
Adapted from workspace-yqh/refleAct/spreadsheetbench/src/react_agent.py.
Uses the unified ``skillopt.model`` router so SpreadsheetBench follows the same
backend selection as the rest of the framework.
"""
from __future__ import annotations
import json
import os
import subprocess
from skillopt.model import chat_student_messages
from skillopt.prompts import load_prompt
# ── Tool schemas ─────────────────────────────────────────────────────────────
BASH_TOOL_CHAT = {
"type": "function",
"function": {
"name": "bash",
"description": (
"Execute a bash command and receive stdout+stderr (truncated to 4000 chars). "
"Use Python to read / write Excel files."
),
"parameters": {
"type": "object",
"properties": {
"cmd": {"type": "string", "description": "Bash command to execute."}
},
"required": ["cmd"],
},
},
}
BASH_TOOL_RESPONSES = {
"type": "function",
"name": "bash",
"description": (
"Execute a bash command and receive stdout+stderr (truncated to 4000 chars). "
"Use Python to read / write Excel files."
),
"parameters": {
"type": "object",
"properties": {
"cmd": {"type": "string", "description": "Bash command to execute."}
},
"required": ["cmd"],
},
}
WRITE_FILE_TOOL_CHAT = {
"type": "function",
"function": {
"name": "write_file",
"description": (
"Write content to a file. Use this instead of echo/cat for multi-line "
"Python scripts to avoid shell escaping issues."
),
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path to write (relative to working directory).",
},
"content": {
"type": "string",
"description": "File content to write.",
},
},
"required": ["path", "content"],
},
},
}
WRITE_FILE_TOOL_RESPONSES = {
"type": "function",
"name": "write_file",
"description": (
"Write content to a file. Use this instead of echo/cat for multi-line "
"Python scripts to avoid shell escaping issues."
),
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path to write (relative to working directory).",
},
"content": {
"type": "string",
"description": "File content to write.",
},
},
"required": ["path", "content"],
},
}
# ── System prompt ─────────────────────────────────────────────────────────────
def _build_system(skill_content: str) -> str:
if skill_content.strip():
skill_section = f"## Skill\n{skill_content.strip()}\n\n"
else:
skill_section = ""
return load_prompt("react_system", env="spreadsheetbench").format(
critical_rules=load_prompt("critical_rules", env="spreadsheetbench"),
skill_section=skill_section,
)
def _build_user(
instruction: str,
input_path: str,
output_path: str,
instruction_type: str,
answer_position: str,
diagnostic_mode: bool = False,
diagnostic_instruction: str = "",
diagnostic_trace_context: str = "",
) -> str:
parts = []
if diagnostic_trace_context.strip():
parts.append(
"# Previous Codex Trace Snapshot\n"
"This is a partial transcript from an earlier attempt. Use it as your current reasoning context.\n\n"
f"{diagnostic_trace_context.strip()}"
)
parts.extend([
f"# Instruction\n{instruction}",
f"# Input file\n{input_path}",
f"# Output file\n{output_path}",
])
if instruction_type:
parts.append(f"# Instruction type\n{instruction_type}")
if answer_position:
parts.append(f"# Answer position\n{answer_position}")
if diagnostic_mode and diagnostic_instruction.strip():
parts.append(f"# Training readout\n{diagnostic_instruction.strip()}")
parts.append(
"Manipulate the input spreadsheet according to the instruction "
"and save the result to the output file."
)
return "\n\n".join(parts)
# ── File write (bypass shell escaping) ────────────────────────────────────────
def _write_file(path: str, content: str, work_dir: str) -> str:
"""Write content to a file, bypassing shell escaping issues."""
try:
full_path = os.path.join(work_dir, path) if not os.path.isabs(path) else path
parent = os.path.dirname(full_path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
return f"File written: {full_path} ({len(content)} chars)"
except Exception as e: # noqa: BLE001
return f"[write_file error: {e}]"
# ── Auto-verification ─────────────────────────────────────────────────────────
def _auto_verify(work_dir: str) -> str:
"""Auto-verify output xlsx after solution.py runs."""
import glob as _glob
sol_path = os.path.join(work_dir, "solution.py")
output_path = None
if os.path.exists(sol_path):
with open(sol_path) as f:
for line in f:
stripped = line.strip()
if stripped.startswith("OUTPUT_PATH"):
try:
val = stripped.split("=", 1)[1].strip()
output_path = val.strip("'\"").strip()
except Exception: # noqa: BLE001
pass
break
if not output_path or not os.path.exists(output_path):
xlsx_files = [
f for f in _glob.glob(os.path.join(work_dir, "*.xlsx"))
if "_pred" in os.path.basename(f)
]
if xlsx_files:
output_path = xlsx_files[0]
if not output_path or not os.path.exists(output_path):
return (
"\n\n[AUTO-VERIFY] WARNING: Output file not found! "
"Make sure OUTPUT_PATH is correct and wb.save(OUTPUT_PATH) is called."
)
try:
import openpyxl
wb_formula = openpyxl.load_workbook(output_path, data_only=False)
wb_value = openpyxl.load_workbook(output_path, data_only=True)
lines = [f"\n\n[AUTO-VERIFY] Output file exists: {output_path}"]
sn = wb_formula.sheetnames[0]
ws_f = wb_formula[sn]
ws_v = wb_value[sn]
lines.append(f" Sheet '{sn}': {ws_f.dimensions}")
for row in ws_v.iter_rows(
min_row=1, max_row=min(5, ws_v.max_row), values_only=True,
):
lines.append(f" {list(row)}")
none_cells: list[str] = []
for row_f, row_v in zip(
ws_f.iter_rows(min_row=1, max_row=min(30, ws_f.max_row)),
ws_v.iter_rows(min_row=1, max_row=min(30, ws_v.max_row)),
):
for cf, cv in zip(row_f, row_v):
formula_val = cf.value
cached_val = cv.value
if (
isinstance(formula_val, str)
and formula_val.startswith("=")
and cached_val is None
):
none_cells.append(cf.coordinate)
if none_cells:
lines.append(
f" WARNING: {len(none_cells)} cells have formulas but NO cached "
f"value -- evaluator will see None: {none_cells[:10]}"
)
lines.append(
" FIX: Compute values in Python and write literal "
"numbers/strings instead of formulas."
)
else:
lines.append(" All cells have concrete values. Looks good.")
wb_formula.close()
wb_value.close()
return "\n".join(lines)
except Exception as e: # noqa: BLE001
return f"\n\n[AUTO-VERIFY] Could not inspect output: {e}"
# ── Bash execution ────────────────────────────────────────────────────────────
def _run_bash(cmd: str, work_dir: str, timeout: int = 60) -> str:
try:
proc = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True,
timeout=timeout,
cwd=work_dir,
)
out = (proc.stdout + proc.stderr).strip()
except subprocess.TimeoutExpired:
return f"[timeout after {timeout}s]"
except Exception as e: # noqa: BLE001
return f"[error: {e}]"
if len(out) > 4000:
out = out[:3800] + f"\n...[truncated, {len(out)} total chars]"
result = out or "(no output)"
if "solution.py" in cmd and "python" in cmd.lower():
result += _auto_verify(work_dir)
return result
def _assistant_tool_calls(message) -> list[dict]:
tool_calls = getattr(message, "tool_calls", None) or []
return [
tool_call.model_dump() if hasattr(tool_call, "model_dump") else dict(tool_call)
for tool_call in tool_calls
]
def _react_loop(
system: str,
user: str,
work_dir: str,
max_turns: int,
max_output_tokens: int,
) -> dict:
messages: list[dict] = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
conversation: list[dict] = []
n_turns = 0
for _ in range(max_turns):
message, _ = chat_student_messages(
messages=messages,
tools=[BASH_TOOL_CHAT, WRITE_FILE_TOOL_CHAT],
tool_choice="auto",
max_completion_tokens=max_output_tokens,
retries=5,
stage="rollout",
return_message=True,
)
assistant_text = str(getattr(message, "content", "") or "")
tool_calls = _assistant_tool_calls(message)
assistant_payload: dict = {"role": "assistant", "content": assistant_text}
if tool_calls:
assistant_payload["tool_calls"] = tool_calls
messages.append(assistant_payload)
if not tool_calls:
conversation.append({"type": "message", "content": assistant_text})
break
for tool_call in tool_calls:
n_turns += 1
function = tool_call.get("function", {}) or {}
try:
args = json.loads(str(function.get("arguments", "{}") or "{}"))
except json.JSONDecodeError:
args = {}
if function.get("name") == "write_file":
obs = _write_file(
args.get("path", ""),
args.get("content", ""),
work_dir,
)
conversation.append({
"type": "tool_call",
"cmd": f"[write_file] {args.get('path', '')}",
"obs": obs,
})
else:
cmd = args.get("cmd", "")
obs = _run_bash(cmd, work_dir)
conversation.append({"type": "tool_call", "cmd": cmd, "obs": obs})
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.get("id", ""),
"content": obs,
}
)
return {"conversation": conversation, "n_turns": n_turns}
# ── Public API ────────────────────────────────────────────────────────────────
def run_react(
instruction: str,
input_path: str,
output_path: str,
work_dir: str,
instruction_type: str = "",
answer_position: str = "",
skill_content: str = "",
max_turns: int = 30,
max_output_tokens: int = 4096,
diagnostic_mode: bool = False,
diagnostic_instruction: str = "",
diagnostic_trace_context: str = "",
) -> dict:
"""Run the ReAct agent for one task.
Returns:
{
"conversation": [...], # list of {type, cmd/content, obs?}
"n_turns": int, # number of bash tool calls made
}
"""
system = _build_system(skill_content)
user = _build_user(
instruction,
input_path,
output_path,
instruction_type,
answer_position,
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=diagnostic_trace_context,
)
result = _react_loop(system, user, work_dir, max_turns, max_output_tokens)
result["student_system_prompt"] = system
result["student_user_prompt"] = user
return result
@@ -0,0 +1,4 @@
"""SpreadsheetBench Reflect stage.
Prompts are now loaded from .md files by the base adapter.
"""
+921
View File
@@ -0,0 +1,921 @@
"""SpreadsheetBench rollout — codegen & ReAct batch execution.
Provides:
- process_one_codegen(): single/multi-round code generation (no tool-call)
- run_spreadsheet_batch_codegen(): batch wrapper for codegen
- process_one(): ReAct agent with tool-call (legacy)
- run_spreadsheet_batch(): batch wrapper for ReAct (legacy)
- load_items(): load benchmark .json/.jsonl files
"""
from __future__ import annotations
import glob as _glob
import json
import os
import shutil
import tempfile
import time
import traceback
from concurrent.futures import (
FIRST_COMPLETED,
ThreadPoolExecutor,
wait,
TimeoutError as FuturesTimeoutError,
)
import openpyxl
from skillopt.envs.spreadsheetbench.react_agent import run_react
from skillopt.envs.spreadsheetbench.evaluator import evaluate, _generate_cell_names
from skillopt.envs.spreadsheetbench.executor import run_generated_code
# ── Data loading ─────────────────────────────────────────────────────────────
def load_items(path: str) -> list[dict]:
"""Load a benchmark file. Supports both .jsonl and .json (list of dicts)."""
if path.endswith(".json"):
with open(path) as f:
data = json.load(f)
if isinstance(data, dict):
data = data.get("data") or list(data.values())
return list(data)
items = []
with open(path) as f:
for line in f:
line = line.strip()
if line:
items.append(json.loads(line))
return items
# ── Test case discovery ──────────────────────────────────────────────────────
def _find_test_cases(task_dir: str) -> list[tuple[str, str, str]]:
"""Return [(case_no, input_path, answer_path), ...] sorted by case_no.
Supports naming conventions used by SpreadsheetBench releases:
* ``{no}_{id}_input.xlsx`` + ``{no}_{id}_answer.xlsx`` (original)
* ``{no}_{id}_init.xlsx`` + ``{no}_{id}_golden.xlsx`` (verified_400)
* ``initial.xlsx`` + ``golden.xlsx`` (verified_400, no prefix)
"""
cases: list[tuple[str, str, str]] = []
inputs = sorted(_glob.glob(os.path.join(task_dir, "*_input.xlsx")))
for ip in inputs:
no = os.path.basename(ip).split("_", 1)[0]
ap = ip.replace("_input.xlsx", "_answer.xlsx")
if os.path.exists(ap):
cases.append((no, ip, ap))
inits = sorted(_glob.glob(os.path.join(task_dir, "*_init.xlsx")))
for ip in inits:
no = os.path.basename(ip).split("_", 1)[0]
ap = ip.replace("_init.xlsx", "_golden.xlsx")
if os.path.exists(ap):
cases.append((no, ip, ap))
# Fallback: bare initial.xlsx + golden.xlsx (no numbered prefix)
if not cases:
bare_init = os.path.join(task_dir, "initial.xlsx")
bare_gold = os.path.join(task_dir, "golden.xlsx")
if os.path.exists(bare_init) and os.path.exists(bare_gold):
cases.append(("1", bare_init, bare_gold))
return cases
# ── Auto-verify helper ──────────────────────────────────────────────────────
def _auto_verify_output(
pred_path: str,
gold_path: str,
answer_position: str,
) -> str:
"""Reopen the predicted xlsx and compare cells at answer_position with gold.
Returns a human-readable verification report that can be appended to the
trajectory so the error analyst can see exactly what went wrong (e.g.
``cell A1: got=None, expected=420``).
"""
if not os.path.exists(pred_path):
return "Verification: output file does not exist."
try:
wb_pred = openpyxl.load_workbook(pred_path, data_only=True)
wb_gold = openpyxl.load_workbook(gold_path, data_only=True)
except Exception as e:
return f"Verification: could not open workbooks: {e}"
lines = ["## Output Verification"]
try:
for scr in (answer_position or "").split(","):
scr = scr.strip()
if not scr:
continue
if "!" in scr:
sheet_name, cell_range = scr.split("!", 1)
sheet_name = sheet_name.strip().strip("'\"")
else:
sheet_name = wb_gold.sheetnames[0]
cell_range = scr
cell_range = cell_range.strip().strip("'\"")
cell_names = _generate_cell_names(cell_range)
ws_pred = wb_pred[sheet_name] if sheet_name in wb_pred.sheetnames else None
ws_gold = wb_gold[sheet_name] if sheet_name in wb_gold.sheetnames else None
if ws_pred is None:
lines.append(f" Sheet '{sheet_name}' NOT FOUND in output.")
continue
for cn in cell_names:
gv = ws_gold[cn].value if ws_gold else "N/A"
pv = ws_pred[cn].value
match = "" if repr(gv) == repr(pv) else ""
lines.append(f" {sheet_name}!{cn}: got={pv!r}, expected={gv!r} {match}")
# Also check if any cells in the output contain formula strings
formula_cells = []
for sn in wb_pred.sheetnames:
ws = wb_pred[sn]
for row in ws.iter_rows(max_row=min(ws.max_row, 200), values_only=False):
for cell in row:
if isinstance(cell.value, str) and cell.value.startswith("="):
formula_cells.append(f"{sn}!{cell.coordinate}={cell.value}")
if len(formula_cells) >= 10:
break
if len(formula_cells) >= 10:
break
if len(formula_cells) >= 10:
break
if formula_cells:
lines.append(f"\n WARNING: {len(formula_cells)} cells contain Excel formulas (openpyxl cannot evaluate them):")
for fc in formula_cells[:5]:
lines.append(f" {fc}")
if len(formula_cells) > 5:
lines.append(f" ... and {len(formula_cells) - 5} more")
finally:
wb_pred.close()
wb_gold.close()
return "\n".join(lines)
# ── Per-task worker ──────────────────────────────────────────────────────────
def process_one(
item: dict,
data_root: str,
out_root: str,
skill_content: str,
max_turns: int,
diagnostic_mode: bool = False,
diagnostic_instruction: str = "",
diagnostic_trace_context: str = "",
) -> dict:
"""Run the ReAct agent on a single SpreadsheetBench task.
Returns a result dict compatible with ``compute_score()``.
"""
task_id = str(item["id"])
instruction = item["instruction"]
instruction_type = item.get("instruction_type", "")
answer_position = item.get("answer_position", "")
answer_sheet = item.get("answer_sheet", "")
if answer_position and answer_sheet and "!" not in answer_position:
answer_position_eval = f"{answer_sheet}!{answer_position}"
else:
answer_position_eval = answer_position
# Determine task_type from instruction_type
itype_lower = (instruction_type or "").lower()
if "cell" in itype_lower:
task_type = "cell_level"
elif "sheet" in itype_lower:
task_type = "sheet_level"
else:
task_type = "other"
sp = item.get("spreadsheet_path", f"spreadsheet/{task_id}")
task_dir = sp if os.path.isabs(sp) else os.path.join(data_root, sp)
result = {
"id": task_id,
"ok": False,
"instruction_type": instruction_type,
"task_type": task_type,
"task_description": instruction,
"phase": "setup",
"fail_reason": "",
"agent_ok": False,
"exec_ok": False,
"n_cases": 0,
"n_exec_pass": 0,
"n_pass": 0,
"soft": 0.0,
"hard": 0,
"n_turns": 0,
"cases": [],
"error": "",
}
try:
cases = _find_test_cases(task_dir)
result["n_cases"] = len(cases)
if not cases:
result["fail_reason"] = "no-test-cases"
return result
task_out_dir = os.path.join(out_root, "predictions", task_id)
os.makedirs(task_out_dir, exist_ok=True)
no1, ip1, _ = cases[0]
pred_path_1 = os.path.join(task_out_dir, f"{no1}_pred.xlsx")
student_prompt_parts = [
f"# Instruction\n{instruction}",
f"# Input file\n{ip1}",
f"# Output file\n{pred_path_1}",
]
if instruction_type:
student_prompt_parts.append(f"# Instruction type\n{instruction_type}")
if answer_position_eval:
student_prompt_parts.append(f"# Answer position\n{answer_position_eval}")
if diagnostic_trace_context.strip():
student_prompt_parts.insert(
0,
"# Previous Codex Trace Snapshot\n"
"This is a partial transcript from an earlier attempt. Use it as your current reasoning context.\n\n"
f"{diagnostic_trace_context.strip()}",
)
if diagnostic_mode and diagnostic_instruction.strip():
student_prompt_parts.append(f"# Training readout\n{diagnostic_instruction.strip()}")
student_user_prompt = "\n\n".join(student_prompt_parts)
try:
from skillopt.envs.spreadsheetbench.react_agent import _build_system
student_system_prompt = _build_system(skill_content)
except Exception:
student_system_prompt = ""
if student_system_prompt:
with open(os.path.join(task_out_dir, "student_system_prompt.txt"), "w") as f:
f.write(student_system_prompt)
result["student_system_prompt"] = student_system_prompt
with open(os.path.join(task_out_dir, "student_user_prompt.txt"), "w") as f:
f.write(student_user_prompt)
result["student_user_prompt"] = student_user_prompt
# ── Stage 1: run ReAct agent on test case 1 ─────────────────────
result["phase"] = "agent"
work_dir = tempfile.mkdtemp(prefix=f"react_{task_id}_")
try:
# Copy input so agent works in an isolated directory
work_input = os.path.join(work_dir, os.path.basename(ip1))
shutil.copy2(ip1, work_input)
agent_result = run_react(
instruction=instruction,
input_path=work_input,
output_path=pred_path_1,
work_dir=work_dir,
instruction_type=instruction_type,
answer_position=answer_position_eval,
skill_content=skill_content,
max_turns=max_turns,
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=diagnostic_trace_context,
)
result["n_turns"] = agent_result.get("n_turns", 0)
if agent_result.get("student_system_prompt"):
with open(os.path.join(task_out_dir, "student_system_prompt.txt"), "w") as f:
f.write(agent_result["student_system_prompt"])
result["student_system_prompt"] = agent_result["student_system_prompt"]
if agent_result.get("student_user_prompt"):
with open(os.path.join(task_out_dir, "student_user_prompt.txt"), "w") as f:
f.write(agent_result["student_user_prompt"])
result["student_user_prompt"] = agent_result["student_user_prompt"]
# Save conversation log
with open(os.path.join(task_out_dir, "conversation.json"), "w") as f:
json.dump(
agent_result.get("conversation", []),
f, ensure_ascii=False, indent=2,
)
# Copy solution.py if the agent wrote one
solution_src = os.path.join(work_dir, "solution.py")
solution_dst = os.path.join(task_out_dir, "solution.py")
if os.path.exists(solution_src):
shutil.copy2(solution_src, solution_dst)
except Exception as e:
result["fail_reason"] = f"agent-error: {type(e).__name__}: {e}"
result["error"] = traceback.format_exc()
return result
finally:
shutil.rmtree(work_dir, ignore_errors=True)
result["agent_ok"] = True
# ── Stage 2: evaluate all test cases ─────────────────────────────
result["phase"] = "eval"
solution_path = os.path.join(task_out_dir, "solution.py")
all_exec = True
for i, (no, ip, ap) in enumerate(cases):
pred_path = os.path.join(task_out_dir, f"{no}_pred.xlsx")
if i > 0:
# Re-apply solution.py to subsequent test cases
if not os.path.exists(solution_path):
all_exec = False
result["cases"].append(
{"no": no, "stage": "exec", "ok": False, "error": "no-solution-py"}
)
if not result["fail_reason"]:
result["fail_reason"] = "no-solution-py-for-other-cases"
continue
with open(solution_path) as f:
code = f.read()
# Prepend new INPUT_PATH / OUTPUT_PATH
preamble = (
f"INPUT_PATH = {ip!r}\n"
f"OUTPUT_PATH = {pred_path!r}\n"
)
full_code = preamble + code
ok_exec, err = run_generated_code(full_code, ip, pred_path)
if not ok_exec:
all_exec = False
result["cases"].append(
{"no": no, "stage": "exec", "ok": False, "error": err[:500]}
)
if not result["fail_reason"]:
tail = err.strip().splitlines()[-1][:200] if err.strip() else "unknown"
result["fail_reason"] = f"exec-error: {tail}"
continue
# ── Evaluate ─────────────────────────────────────────────────
if not os.path.exists(pred_path):
all_exec = False
result["cases"].append(
{"no": no, "stage": "exec", "ok": False, "error": "output-not-found"}
)
if not result["fail_reason"]:
result["fail_reason"] = "output-not-found"
continue
result["n_exec_pass"] += 1
try:
ev = evaluate(pred_path, ap, instruction_type, answer_position_eval)
except Exception as e: # noqa: BLE001
ev = {"ok": False, "reason": f"eval-exception: {type(e).__name__}: {e}"}
if ev["ok"]:
result["n_pass"] += 1
else:
if not result["fail_reason"]:
result["fail_reason"] = f"eval-mismatch: {ev['reason'][:200]}"
result["cases"].append(
{"no": no, "stage": "eval", "ok": ev["ok"], "reason": ev.get("reason", "")}
)
result["exec_ok"] = all_exec
n_cases = result["n_cases"]
n_pass = result["n_pass"]
result["soft"] = (n_pass / n_cases) if n_cases else 0.0
result["hard"] = 1 if (n_cases > 0 and n_pass == n_cases) else 0
result["ok"] = bool(result["hard"])
if result["ok"]:
result["fail_reason"] = ""
return result
except Exception as e: # noqa: BLE001
result["fail_reason"] = f"unexpected: {type(e).__name__}: {e}"
result["error"] = traceback.format_exc()
return result
# ── Batch runner ─────────────────────────────────────────────────────────────
def run_spreadsheet_batch(
items: list[dict],
data_root: str,
out_root: str,
skill_content: str,
max_turns: int = 30,
max_api_workers: int = 64,
task_timeout: int = 600,
diagnostic_mode: bool = False,
diagnostic_instruction: str = "",
diagnostic_trace_context_by_id: dict[str, str] | None = None,
) -> list[dict]:
"""Run the ReAct agent on all items with ThreadPoolExecutor.
Returns list of result dicts compatible with ``compute_score()``.
"""
os.makedirs(out_root, exist_ok=True)
# Check for already-done items (resume support)
results_path = os.path.join(out_root, "results.jsonl")
done_ids: set[str] = set()
existing: list[dict] = []
if os.path.exists(results_path):
with open(results_path) as f:
for line in f:
try:
r = json.loads(line)
done_ids.add(str(r["id"]))
existing.append(r)
except Exception:
pass
pending = [it for it in items if str(it["id"]) not in done_ids]
print(
f" [spreadsheet rollout] total={len(items)} done={len(done_ids)} "
f"pending={len(pending)} workers={max_api_workers} task_timeout={task_timeout}s"
)
if not pending:
return existing
t0 = time.time()
results = list(existing)
started_at: dict[str, float] = {}
def _timeout_result(item: dict) -> dict:
return {
"id": str(item["id"]),
"ok": False,
"phase": "timeout",
"fail_reason": f"task-timeout-{task_timeout}s",
"n_cases": 0, "n_pass": 0, "soft": 0.0, "hard": 0,
"n_turns": 0, "cases": [], "error": "timeout",
}
def _error_result(item: dict, exc: Exception) -> dict:
return {
"id": str(item["id"]),
"ok": False,
"phase": "error",
"fail_reason": f"unexpected: {type(exc).__name__}: {exc}",
"n_cases": 0, "n_pass": 0, "soft": 0.0, "hard": 0,
"n_turns": 0, "cases": [], "error": str(exc),
}
def _run_one(it: dict) -> dict:
started_at[str(it["id"])] = time.time()
return process_one(
it,
data_root,
out_root,
skill_content,
max_turns,
diagnostic_mode,
diagnostic_instruction,
(diagnostic_trace_context_by_id or {}).get(str(it["id"]), ""),
)
ex = ThreadPoolExecutor(max_workers=max_api_workers)
try:
futs = {ex.submit(_run_one, it): it for it in pending}
pending_futs = set(futs)
finished = 0
while pending_futs:
done, _ = wait(pending_futs, timeout=5, return_when=FIRST_COMPLETED)
now = time.time()
timed_out = [
fut for fut in pending_futs - done
if str(futs[fut]["id"]) in started_at
and now - started_at[str(futs[fut]["id"])] >= task_timeout
]
for fut in done:
pending_futs.remove(fut)
item = futs[fut]
try:
res = fut.result()
except FuturesTimeoutError:
res = _timeout_result(item)
except Exception as e: # noqa: BLE001
res = _error_result(item, e)
results.append(res)
finished += 1
status = "PASS" if res.get("hard") else ("TIMEOUT" if res.get("phase") == "timeout" else "FAIL")
dt = time.time() - t0
print(
f" {finished}/{len(pending)} id={res['id']:<10} {status} "
f"turns={res.get('n_turns', 0):<3} "
f"cases={res.get('n_pass', 0)}/{res.get('n_cases', 0)} "
f"dt={dt:.0f}s"
)
for fut in timed_out:
pending_futs.remove(fut)
res = _timeout_result(futs[fut])
results.append(res)
finished += 1
status = "TIMEOUT"
dt = time.time() - t0
print(
f" {finished}/{len(pending)} id={res['id']:<10} {status} "
f"turns={res.get('n_turns', 0):<3} "
f"cases={res.get('n_pass', 0)}/{res.get('n_cases', 0)} "
f"dt={dt:.0f}s"
)
finally:
ex.shutdown(wait=False, cancel_futures=True)
return results
# ── Codegen per-task worker (no tool-call) ──────────────────────────────────
def process_one_codegen(
item: dict,
data_root: str,
out_root: str,
skill_content: str,
mode: str = "single",
max_turns: int = 5,
use_eval_feedback: bool = False,
diagnostic_mode: bool = False,
diagnostic_instruction: str = "",
diagnostic_trace_context: str = "",
) -> dict:
"""Run codegen agent (single or multi-round) on one SpreadsheetBench task.
This matches the official evaluation setting: LLM generates a Python code
block, no function-calling / tool-use.
"""
from skillopt.envs.spreadsheetbench.codegen_agent import run_single, run_multi
task_id = str(item["id"])
instruction = item["instruction"]
instruction_type = item.get("instruction_type", "")
answer_position = item.get("answer_position", "")
answer_sheet = item.get("answer_sheet", "")
if answer_position and answer_sheet and "!" not in answer_position:
answer_position_eval = f"{answer_sheet}!{answer_position}"
else:
answer_position_eval = answer_position
itype_lower = (instruction_type or "").lower()
if "cell" in itype_lower:
task_type = "cell_level"
elif "sheet" in itype_lower:
task_type = "sheet_level"
else:
task_type = "other"
sp = item.get("spreadsheet_path", f"spreadsheet/{task_id}")
task_dir = sp if os.path.isabs(sp) else os.path.join(data_root, sp)
result = {
"id": task_id,
"ok": False,
"instruction_type": instruction_type,
"task_type": task_type,
"task_description": instruction,
"phase": "setup",
"fail_reason": "",
"llm_ok": False,
"code_ok": False,
"exec_ok": False,
"n_cases": 0,
"n_exec_pass": 0,
"n_pass": 0,
"soft": 0.0,
"hard": 0,
"n_turns": 0,
"cases": [],
"error": "",
}
try:
cases = _find_test_cases(task_dir)
result["n_cases"] = len(cases)
if not cases:
result["fail_reason"] = "no-test-cases"
return result
task_out_dir = os.path.join(out_root, "predictions", task_id)
os.makedirs(task_out_dir, exist_ok=True)
# ── Save context for Teacher (Reflect stage) ──────────────────
from skillopt.envs.spreadsheetbench.codegen_agent import (
_preview_workbook, _build_system, _build_user,
)
first_input_for_preview = cases[0][1]
try:
preview_text = _preview_workbook(first_input_for_preview)
except Exception:
preview_text = "(preview failed)"
student_system = _build_system(skill_content)
student_user = _build_user(
instruction,
first_input_for_preview,
instruction_type,
answer_position_eval,
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=diagnostic_trace_context,
)
with open(os.path.join(task_out_dir, "spreadsheet_preview.txt"), "w") as f:
f.write(preview_text)
with open(os.path.join(task_out_dir, "student_system_prompt.txt"), "w") as f:
f.write(student_system)
with open(os.path.join(task_out_dir, "student_user_prompt.txt"), "w") as f:
f.write(student_user)
result["spreadsheet_preview"] = preview_text
result["student_system_prompt"] = student_system
result["student_user_prompt"] = student_user
# ── LLM phase ──────────────────────────────────────────────────
result["phase"] = "llm"
first_input = cases[0][1]
first_gold = cases[0][2]
first_pred = os.path.join(task_out_dir, f"{cases[0][0]}_pred.xlsx")
try:
if mode == "multi":
agent_result = run_multi(
instruction=instruction,
input_xlsx=first_input,
output_path=first_pred,
instruction_type=instruction_type,
answer_position=answer_position_eval,
skill_content=skill_content,
max_turns=max_turns,
gold_path=first_gold if use_eval_feedback else "",
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=diagnostic_trace_context,
)
else:
agent_result = run_single(
instruction=instruction,
input_xlsx=first_input,
output_path=first_pred,
instruction_type=instruction_type,
answer_position=answer_position_eval,
skill_content=skill_content,
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=diagnostic_trace_context,
)
except Exception as e: # noqa: BLE001
result["fail_reason"] = f"llm-call-failed: {type(e).__name__}: {e}"
result["error"] = traceback.format_exc()
return result
result["llm_ok"] = True
result["n_turns"] = agent_result.get("n_turns", 1)
code = agent_result.get("code", "")
raw = agent_result.get("raw", "")
# Save artifacts
with open(os.path.join(task_out_dir, "code.py"), "w") as f:
f.write(code)
with open(os.path.join(task_out_dir, "raw.txt"), "w") as f:
f.write(raw)
if agent_result.get("conversation"):
with open(os.path.join(task_out_dir, "conversation.json"), "w") as f:
json.dump(agent_result["conversation"], f, ensure_ascii=False, indent=2)
if not code.strip():
result["phase"] = "extract"
result["fail_reason"] = "empty-code-block"
return result
result["code_ok"] = True
# ── Exec + eval per test case ──────────────────────────────────
result["phase"] = "exec"
all_exec = True
# Collect enrichment info for the conversation/trajectory
enrichment_parts: list[str] = []
for no, ip, ap in cases:
pred_path = os.path.join(task_out_dir, f"{no}_pred.xlsx")
# For multi mode, the first case may already be produced
if not os.path.exists(pred_path):
ok_exec, err = run_generated_code(code, ip, pred_path)
if not ok_exec:
all_exec = False
result["cases"].append(
{"no": no, "stage": "exec", "ok": False, "error": err[:500]}
)
if not result["fail_reason"]:
tail = err.strip().splitlines()[-1][:200] if err.strip() else "unknown"
result["fail_reason"] = f"exec-error: {tail}"
enrichment_parts.append(
f"## Execution (case {no})\nERROR: {err[:500]}"
)
continue
if not os.path.exists(pred_path):
all_exec = False
result["cases"].append(
{"no": no, "stage": "exec", "ok": False, "error": "output-not-found"}
)
if not result["fail_reason"]:
result["fail_reason"] = "output-not-found"
continue
result["n_exec_pass"] += 1
try:
ev = evaluate(pred_path, ap, instruction_type, answer_position_eval)
except Exception as e: # noqa: BLE001
ev = {"ok": False, "reason": f"eval-exception: {type(e).__name__}: {e}"}
if ev["ok"]:
result["n_pass"] += 1
else:
if not result["fail_reason"]:
result["fail_reason"] = f"eval-mismatch: {ev['reason'][:200]}"
result["cases"].append(
{"no": no, "stage": "eval", "ok": ev["ok"], "reason": ev.get("reason", "")}
)
# Auto-verify: reopen output and compare cells at answer_position
if answer_position_eval:
verify_report = _auto_verify_output(pred_path, ap, answer_position_eval)
enrichment_parts.append(
f"## Eval Result (case {no}): {'PASS' if ev['ok'] else 'FAIL'}\n"
f"{ev.get('reason', '')}\n\n{verify_report}"
)
result["exec_ok"] = all_exec
# ── Enrich conversation with eval details ──────────────────────
if enrichment_parts:
enrichment_msg = "\n\n---\n\n".join(enrichment_parts)
conversation = agent_result.get("conversation", [])
conversation.append({
"role": "system",
"content": f"[POST-EXECUTION VERIFICATION]\n\n{enrichment_msg}",
})
# Re-save the enriched conversation
with open(os.path.join(task_out_dir, "conversation.json"), "w") as f:
json.dump(conversation, f, ensure_ascii=False, indent=2)
n_cases = result["n_cases"]
n_pass = result["n_pass"]
result["soft"] = (n_pass / n_cases) if n_cases else 0.0
result["hard"] = 1 if (n_cases > 0 and n_pass == n_cases) else 0
result["ok"] = bool(result["hard"])
if result["ok"]:
result["fail_reason"] = ""
return result
except Exception as e: # noqa: BLE001
result["fail_reason"] = f"unexpected: {type(e).__name__}: {e}"
result["error"] = traceback.format_exc()
return result
# ── Codegen batch runner ────────────────────────────────────────────────────
def run_spreadsheet_batch_codegen(
items: list[dict],
data_root: str,
out_root: str,
skill_content: str,
mode: str = "single",
max_turns: int = 5,
max_api_workers: int = 32,
task_timeout: int = 0,
use_eval_feedback: bool = False,
diagnostic_mode: bool = False,
diagnostic_instruction: str = "",
diagnostic_trace_context_by_id: dict[str, str] | None = None,
) -> list[dict]:
"""Run codegen agent on all items (no tool-call).
Args:
mode: "single" or "multi".
task_timeout: Hard per-task timeout in seconds at the future level.
0 = auto (single: 300s, multi: 600s).
"""
if task_timeout <= 0:
task_timeout = 300 if mode == "single" else 600
os.makedirs(out_root, exist_ok=True)
results_path = os.path.join(out_root, "results.jsonl")
done_ids: set[str] = set()
existing: list[dict] = []
if os.path.exists(results_path):
with open(results_path) as f:
for line in f:
try:
r = json.loads(line)
done_ids.add(str(r["id"]))
existing.append(r)
except Exception:
pass
pending = [it for it in items if str(it["id"]) not in done_ids]
print(
f" [spreadsheet codegen-{mode}] total={len(items)} done={len(done_ids)} "
f"pending={len(pending)} workers={max_api_workers} task_timeout={task_timeout}s"
)
if not pending:
return existing
t0 = time.time()
results = list(existing)
started_at: dict[str, float] = {}
def _run_one(it: dict) -> dict:
started_at[str(it["id"])] = time.time()
return process_one_codegen(
it,
data_root,
out_root,
skill_content,
mode,
max_turns,
use_eval_feedback,
diagnostic_mode,
diagnostic_instruction,
(diagnostic_trace_context_by_id or {}).get(str(it["id"]), ""),
)
def _timeout_result(item: dict) -> dict:
return {
"id": str(item["id"]),
"ok": False,
"instruction_type": item.get("instruction_type", ""),
"task_type": "other",
"phase": "timeout",
"fail_reason": f"task-timeout-{task_timeout}s",
"n_cases": 0, "n_pass": 0, "soft": 0.0, "hard": 0,
"n_turns": 0, "cases": [], "error": "timeout",
}
def _error_result(item: dict, e: Exception) -> dict:
return {
"id": str(item["id"]),
"ok": False,
"instruction_type": item.get("instruction_type", ""),
"task_type": "other",
"phase": "error",
"fail_reason": f"unexpected: {type(e).__name__}: {e}",
"n_cases": 0, "n_pass": 0, "soft": 0.0, "hard": 0,
"n_turns": 0, "cases": [], "error": str(e),
}
def _record(res: dict, i: int) -> None:
results.append(res)
status = "PASS" if res.get("hard") else ("TIMEOUT" if res.get("phase") == "timeout" else "FAIL")
dt = time.time() - t0
print(
f" {i}/{len(pending)} id={res['id']:<10} {status} "
f"turns={res.get('n_turns', 0):<3} "
f"cases={res.get('n_pass', 0)}/{res.get('n_cases', 0)} "
f"dt={dt:.0f}s"
)
ex = ThreadPoolExecutor(max_workers=max_api_workers)
try:
futs = {ex.submit(_run_one, it): it for it in pending}
pending_futs = set(futs)
finished = 0
while pending_futs:
done, _ = wait(pending_futs, timeout=5, return_when=FIRST_COMPLETED)
now = time.time()
timed_out = [
fut for fut in pending_futs - done
if str(futs[fut]["id"]) in started_at
and now - started_at[str(futs[fut]["id"])] >= task_timeout
]
for fut in done:
pending_futs.remove(fut)
item = futs[fut]
try:
res = fut.result()
except FuturesTimeoutError:
res = _timeout_result(item)
except Exception as e: # noqa: BLE001
res = _error_result(item, e)
finished += 1
_record(res, finished)
for fut in timed_out:
pending_futs.remove(fut)
fut.cancel()
finished += 1
_record(_timeout_result(futs[fut]), finished)
finally:
ex.shutdown(wait=False, cancel_futures=True)
return results
@@ -0,0 +1,56 @@
# Spreadsheet Manipulation Skill (xlsx)
## Overview
This skill guides agents in manipulating Excel (.xlsx) spreadsheets using Python.
**Primary libraries**: `openpyxl` (structure-preserving read/write), `pandas` (data transformation).
Never use any other third-party libraries.
---
## Common Workflow
1. **Explore** the input file: list sheets, inspect headers, check dimensions.
2. **Write `solution.py`** with `INPUT_PATH` and `OUTPUT_PATH` defined at the top.
3. **Execute** `python solution.py` and verify the output file was created.
4. **Confirm** the target cells/range contain the expected values.
---
## Library Selection
| Use case | Library |
|----------|---------|
| Preserve formulas, formatting, named ranges | `openpyxl` |
| Bulk data transformation, aggregation, sorting | `pandas` → write back with `openpyxl` |
| Simple cell read/write | `openpyxl` |
**Warning**: `pandas.to_excel()` silently destroys existing formulas and named ranges.
When writing back to a spreadsheet that contains formulas, always use `openpyxl.save()`.
---
## solution.py Template
```python
import openpyxl
import pandas as pd
INPUT_PATH = "..." # set to the actual input path
OUTPUT_PATH = "..." # set to the actual output path
wb = openpyxl.load_workbook(INPUT_PATH)
ws = wb.active # or wb["SheetName"]
# --- perform manipulation ---
wb.save(OUTPUT_PATH)
```
---
## Output Requirements
- Save the result to `OUTPUT_PATH`.
- Do not hardcode row counts or column letters — iterate over actual rows in the workbook.
- Preserve sheets and cells not mentioned in the instruction.
@@ -0,0 +1,56 @@
# Spreadsheet Manipulation Skill (xlsx)
## Overview
This skill guides agents in manipulating Excel (.xlsx) spreadsheets using Python.
**Primary libraries**: `openpyxl` (structure-preserving read/write), `pandas` (data transformation).
Never use any other third-party libraries.
---
## Common Workflow
1. **Explore** the input file: list sheets, inspect headers, check dimensions.
2. **Write `solution.py`** with `INPUT_PATH` and `OUTPUT_PATH` defined at the top.
3. **Execute** `python solution.py` and verify the output file was created.
4. **Confirm** the target cells/range contain the expected values.
---
## Library Selection
| Use case | Library |
|----------|---------|
| Preserve formulas, formatting, named ranges | `openpyxl` |
| Bulk data transformation, aggregation, sorting | `pandas` → write back with `openpyxl` |
| Simple cell read/write | `openpyxl` |
**Warning**: `pandas.to_excel()` silently destroys existing formulas and named ranges.
When writing back to a spreadsheet that contains formulas, always use `openpyxl.save()`.
---
## solution.py Template
```python
import openpyxl
import pandas as pd
INPUT_PATH = "..." # set to the actual input path
OUTPUT_PATH = "..." # set to the actual output path
wb = openpyxl.load_workbook(INPUT_PATH)
ws = wb.active # or wb["SheetName"]
# --- perform manipulation ---
wb.save(OUTPUT_PATH)
```
---
## Output Requirements
- Save the result to `OUTPUT_PATH`.
- Do not hardcode row counts or column letters — iterate over actual rows in the workbook.
- Preserve sheets and cells not mentioned in the instruction.
@@ -0,0 +1,4 @@
# No Skill
This is a placeholder. No domain skill is loaded for this run.
The agent relies solely on its parametric knowledge.