Skip to content

Commit 6cd4e06

Browse files
committed
feat(runner): add task and persona selection
1 parent 584d344 commit 6cd4e06

4 files changed

Lines changed: 337 additions & 7 deletions

File tree

docs/evaluation.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,32 @@ docker compose -f docker/docker-compose.yaml run --rm workspace-bench \
7373
--dataset full
7474
```
7575

76+
### Running Selected Tasks
77+
78+
Use `--task-ids` to run an exact task subset. IDs may be separated by spaces or commas, and they run in the specified order:
79+
80+
```bash
81+
docker compose -f docker/docker-compose.yaml run --rm workspace-bench \
82+
bash /workspace/Workspace-Bench/evaluation/docker/run-benchmark.sh \
83+
--harness codex \
84+
--model kimi-k2.5 \
85+
--dataset lite \
86+
--task-ids 45 55 386
87+
```
88+
89+
Use `--persona` to run every task whose metadata `persona` exactly matches the supplied value:
90+
91+
```bash
92+
docker compose -f docker/docker-compose.yaml run --rm workspace-bench \
93+
bash /workspace/Workspace-Bench/evaluation/docker/run-benchmark.sh \
94+
--harness codex \
95+
--model kimi-k2.5 \
96+
--dataset lite \
97+
--persona "Product Manager"
98+
```
99+
100+
`--task-ids`, `--persona`, and `--task-limit` are mutually exclusive. Missing task IDs, duplicate task IDs, and unknown personas stop the run instead of silently changing the sample. The generated run name includes the selection, so a subset does not reuse the default Lite or Full output directory. Task selection changes which tasks execute; it does not shrink the standard profile workspace prepared from the dataset.
101+
76102
### Using Different Harnesses
77103

78104
```bash

evaluation/scripts/build_run_config.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env python3
22
import argparse
3+
import hashlib
34
import json
45
import re
56
from pathlib import Path
@@ -37,6 +38,34 @@ def _display_slug(value: str) -> str:
3738
return re.sub(r"[^A-Za-z0-9._-]+", "_", value.strip()) or "Custom"
3839

3940

41+
def _normalize_task_ids(values: list[str] | None) -> list[str]:
42+
task_ids: list[str] = []
43+
for value in values or []:
44+
task_ids.extend(part.strip() for part in str(value).split(",") if part.strip())
45+
if not task_ids:
46+
return []
47+
invalid = [task_id for task_id in task_ids if not re.fullmatch(r"[A-Za-z0-9._-]+", task_id)]
48+
if invalid:
49+
raise SystemExit(f"invalid task id(s): {', '.join(invalid)}")
50+
duplicates = sorted({task_id for task_id in task_ids if task_ids.count(task_id) > 1})
51+
if duplicates:
52+
raise SystemExit(f"duplicate task id(s): {', '.join(duplicates)}")
53+
return task_ids
54+
55+
56+
def _selection_suffix(*, task_ids: list[str], persona: str | None) -> tuple[str, str]:
57+
if task_ids:
58+
joined = "-".join(task_ids)
59+
if len(task_ids) <= 3 and len(joined) <= 48:
60+
return f"tasks-{_safe_slug(joined)}", f"Tasks-{_display_slug(joined)}"
61+
digest = hashlib.sha256("\0".join(task_ids).encode("utf-8")).hexdigest()[:10]
62+
return f"tasks-{len(task_ids)}-{digest}", f"Tasks-{len(task_ids)}-{digest}"
63+
if persona:
64+
slug = _safe_slug(persona)[:60]
65+
return f"persona-{slug}", f"Persona-{_display_slug(persona)[:60]}"
66+
return "", ""
67+
68+
4069
def _normalize_harness(value: str) -> str:
4170
mapping = {
4271
"codex": "Codex",
@@ -102,10 +131,23 @@ def build_config(args: argparse.Namespace) -> Path:
102131
if dataset not in {"smoke", "lite", "full"}:
103132
raise SystemExit(f"unsupported dataset: {args.dataset}")
104133

105-
run_name = args.run_name or {"smoke": "Smoke", "lite": "Lite", "full": "Full"}[dataset]
134+
task_ids = _normalize_task_ids(getattr(args, "task_ids", None))
135+
persona_value = getattr(args, "persona", None)
136+
persona = str(persona_value).strip() if persona_value is not None else None
137+
if persona_value is not None and not persona:
138+
raise SystemExit("--persona must not be empty")
139+
task_limit = getattr(args, "task_limit", None)
140+
selected = sum([task_limit is not None, bool(task_ids), persona is not None])
141+
if selected > 1:
142+
raise SystemExit("--task-limit, --task-ids, and --persona are mutually exclusive")
143+
144+
selection_slug, selection_name = _selection_suffix(task_ids=task_ids, persona=persona)
145+
default_run_name = {"smoke": "Smoke", "lite": "Lite", "full": "Full"}[dataset]
146+
run_name = args.run_name or (
147+
f"{default_run_name}-{selection_name}" if selection_name else default_run_name
148+
)
106149
task_path = eval_root / ("tasks" if dataset == "full" else "tasks_lite")
107-
task_limit = args.task_limit
108-
if task_limit is None and dataset == "smoke":
150+
if task_limit is None and not task_ids and persona is None and dataset == "smoke":
109151
task_limit = 1
110152
task_parallel = not bool(args.no_task_parallel)
111153
task_parallel_workers = max(1, int(args.task_parallel_workers or 10))
@@ -117,6 +159,10 @@ def build_config(args: argparse.Namespace) -> Path:
117159
fs_map_dir.mkdir(parents=True, exist_ok=True)
118160

119161
config_slug = f"{harness.lower()}-{_safe_slug(args.model)}-{dataset}"
162+
if selection_slug:
163+
config_slug = f"{config_slug}-{selection_slug}"
164+
elif args.run_name:
165+
config_slug = f"{config_slug}-{_safe_slug(args.run_name)}"
120166
fs_map_path = fs_map_dir / f"fs_map_{harness}_{_display_slug(model_name)}.json"
121167
fs_map_path.write_text(
122168
json.dumps(_fs_map(eval_root, harness, model_name), ensure_ascii=False, indent=2) + "\n",
@@ -146,6 +192,10 @@ def build_config(args: argparse.Namespace) -> Path:
146192
}
147193
if task_limit is not None:
148194
config["task_limit"] = int(task_limit)
195+
elif task_ids:
196+
config["task_ids"] = task_ids
197+
elif persona is not None:
198+
config["persona"] = persona
149199

150200
config_path = runs_dir / f"{config_slug}.yaml"
151201
config_path.write_text(yaml.safe_dump(config, allow_unicode=True, sort_keys=False), encoding="utf-8")
@@ -163,7 +213,14 @@ def main() -> None:
163213
parser.add_argument("--model-name", help="Display name used in output directory")
164214
parser.add_argument("--env-prefix", help="Environment variable prefix for BASE_URL/API_KEY")
165215
parser.add_argument("--run-name", help="Output run name; defaults to Smoke/Lite/Full")
166-
parser.add_argument("--task-limit", type=int)
216+
selection = parser.add_mutually_exclusive_group()
217+
selection.add_argument("--task-limit", type=int, help="Run the first N tasks in deterministic order")
218+
selection.add_argument(
219+
"--task-ids",
220+
nargs="+",
221+
help="Run exact task IDs; accepts spaces or comma-separated values",
222+
)
223+
selection.add_argument("--persona", help="Run every task whose metadata persona exactly matches this value")
167224
parser.add_argument("--timeout-sec", type=float, default=2000.0)
168225
parser.add_argument("--task-parallel-workers", type=int, help="Number of isolated task-level workers; defaults to 10")
169226
parser.add_argument("--no-task-parallel", action="store_true", help="Disable isolated task-level parallelism")

evaluation/src/agent_runner.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -331,9 +331,46 @@ def _iter_metadata_paths(root: str, *, limit: Optional[int] = None) -> List[str]
331331
return out
332332

333333

334-
def _load_metadatas(tasks_root: str, *, limit: Optional[int]) -> List[Dict[str, Json]]:
334+
def _metadata_task_id(meta: Dict[str, Json], metadata_path: str) -> str:
335+
value = meta.get("id")
336+
if value in (None, ""):
337+
value = meta.get("absolute_id")
338+
if value in (None, ""):
339+
value = os.path.basename(os.path.dirname(metadata_path))
340+
return str(value).strip()
341+
342+
343+
def _normalize_config_task_ids(value: Json) -> List[str]:
344+
if value is None:
345+
return []
346+
if not isinstance(value, list):
347+
raise ValueError("task_ids must be a list")
348+
task_ids = [str(item).strip() for item in value]
349+
if any(not task_id for task_id in task_ids):
350+
raise ValueError("task_ids must not contain empty values")
351+
duplicates = sorted({task_id for task_id in task_ids if task_ids.count(task_id) > 1})
352+
if duplicates:
353+
raise ValueError(f"duplicate task id(s): {', '.join(duplicates)}")
354+
return task_ids
355+
356+
357+
def _load_metadatas(
358+
tasks_root: str,
359+
*,
360+
limit: Optional[int] = None,
361+
task_ids: Json = None,
362+
persona: Json = None,
363+
) -> List[Dict[str, Json]]:
364+
requested_ids = _normalize_config_task_ids(task_ids)
365+
persona_value = None if persona is None else str(persona).strip()
366+
if persona is not None and not persona_value:
367+
raise ValueError("persona must not be empty")
368+
selected = sum([limit is not None, bool(requested_ids), persona_value is not None])
369+
if selected > 1:
370+
raise ValueError("task_limit, task_ids, and persona are mutually exclusive")
371+
335372
metas: List[Dict[str, Json]] = []
336-
for mp in _iter_metadata_paths(tasks_root, limit=limit):
373+
for mp in _iter_metadata_paths(tasks_root):
337374
try:
338375
meta = _read_json(mp)
339376
except Exception:
@@ -343,6 +380,35 @@ def _load_metadatas(tasks_root: str, *, limit: Optional[int]) -> List[Dict[str,
343380
m = dict(meta)
344381
m["__metadata_path"] = mp
345382
metas.append(m)
383+
384+
if requested_ids:
385+
by_id: Dict[str, Dict[str, Json]] = {}
386+
duplicate_metadata_ids: List[str] = []
387+
for meta in metas:
388+
metadata_path = str(meta["__metadata_path"])
389+
task_id = _metadata_task_id(meta, metadata_path)
390+
if task_id in by_id:
391+
duplicate_metadata_ids.append(task_id)
392+
else:
393+
by_id[task_id] = meta
394+
if duplicate_metadata_ids:
395+
duplicate_text = ", ".join(sorted(set(duplicate_metadata_ids)))
396+
raise ValueError(f"duplicate task id(s) in dataset: {duplicate_text}")
397+
missing = [task_id for task_id in requested_ids if task_id not in by_id]
398+
if missing:
399+
raise ValueError(f"task id(s) not found: {', '.join(missing)}")
400+
return [by_id[task_id] for task_id in requested_ids]
401+
402+
if persona_value is not None:
403+
matched = [meta for meta in metas if str(meta.get("persona") or "").strip() == persona_value]
404+
if not matched:
405+
available = sorted({str(meta.get("persona") or "").strip() for meta in metas} - {""})
406+
suffix = f"; available personas: {', '.join(available)}" if available else ""
407+
raise ValueError(f"persona not found: {persona_value}{suffix}")
408+
return matched
409+
410+
if limit is not None:
411+
return metas[: max(0, int(limit))]
346412
return metas
347413

348414

@@ -1417,6 +1483,8 @@ def main() -> None:
14171483
prompt_head_by_language = _language_text_map(cfg.get("prompt_head_by_language"))
14181484
prompt_tail_by_language = _language_text_map(cfg.get("prompt_tail_by_language"))
14191485
task_limit = cfg.get("task_limit")
1486+
task_ids = cfg.get("task_ids")
1487+
persona = cfg.get("persona")
14201488
timeout_sec = float(cfg.get("timeout_sec") or 300.0)
14211489
api_provider = cfg.get("api_provider") if isinstance(cfg.get("api_provider"), dict) else {}
14221490

@@ -1442,7 +1510,15 @@ def main() -> None:
14421510
raw_work_dir_map = fs_map_all.get("raw_work_dir", {})
14431511
standard_work_dir_map = fs_map_all.get("standard_work_dir", {})
14441512

1445-
metas = _load_metadatas(task_path, limit=int(task_limit) if task_limit is not None else None)
1513+
try:
1514+
metas = _load_metadatas(
1515+
task_path,
1516+
limit=int(task_limit) if task_limit is not None else None,
1517+
task_ids=task_ids,
1518+
persona=persona,
1519+
)
1520+
except (TypeError, ValueError) as e:
1521+
raise SystemExit(f"invalid task selection: {e}") from e
14461522
if not metas:
14471523
raise SystemExit("no tasks found")
14481524

0 commit comments

Comments
 (0)