mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
toolorchestra: carry provider-aware search through the package split
The monolith->package split landed while #558 was adding provider-aware web search to toolorchestra.py upstream. Rebasing resolved that as delete-vs-modify, which would have silently dropped the feature: the package always routed search to Anthropic. Port it -- search now follows cloud_endpoint (openai-web-search / gemini-web-search / anthropic-web-search), with the matching worker types, per-search cost surcharges, and pool validation. _TOOLORCH_SEARCH_TYPES replaces the scattered == "anthropic-web-search" checks, so the one-shot guard and the "needs a solver" rule now cover every search type rather than just the Anthropic one -- previously a search worker could have been picked as the synthesis fallback. Also, to get back under the ruff gate CI now enforces (it landed in the 95 commits we were behind): formatting, import order, and one real bug -- calculator had "abs" twice in the same dict literal, and eval_backend had four imports stranded below a function body. The build_orchestrator_sft driver test read OJ_DATA_ROOT from the ambient environment, so it wrote to the real experiments tree instead of tmp_path for anyone who had sourced .env. Drop the var in the test.
This commit is contained in:
@@ -71,35 +71,58 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
# data/orchestrator/raw/{label}_{MMDD}[_{tag}]/ and the file inside is always
|
||||
# data.jsonl — see the run_dir block below. Use --out only to distinguish two
|
||||
# runs of the same orchestrator on the same day (e.g. --out balanced50).
|
||||
p.add_argument("--out", default=None,
|
||||
help="Optional tag appended to the run dir name (not a path).")
|
||||
p.add_argument(
|
||||
"--out",
|
||||
default=None,
|
||||
help="Optional tag appended to the run dir name (not a path).",
|
||||
)
|
||||
# The orchestrator == the local Qwen3-8B self-sampling over its own rollouts.
|
||||
p.add_argument("--orchestrator-endpoint", default="http://localhost:8001/v1",
|
||||
help="OpenAI-compatible vLLM base URL serving the orchestrator.")
|
||||
p.add_argument(
|
||||
"--orchestrator-endpoint",
|
||||
default="http://localhost:8001/v1",
|
||||
help="OpenAI-compatible vLLM base URL serving the orchestrator.",
|
||||
)
|
||||
p.add_argument("--orchestrator-model", default="qwen3-8b")
|
||||
p.add_argument("--orchestrator-api-key", default="EMPTY")
|
||||
# Provenance stamps: when set, every written record carries these fields so
|
||||
# the JSONL is self-identifying once pooled across model families (gemma vs
|
||||
# qwen). Default None -> no stamping (existing qwen runs unaffected).
|
||||
p.add_argument("--gen-model", default=None,
|
||||
help="Full HF id of the generating orchestrator, stamped as "
|
||||
"record['gen_model'] (e.g. google/gemma-4-26B-A4B-it).")
|
||||
p.add_argument("--orchestrator-label", default=None,
|
||||
help="Short label stamped as record['orchestrator_model'] "
|
||||
"(e.g. gemma-4-26b).")
|
||||
p.add_argument(
|
||||
"--gen-model",
|
||||
default=None,
|
||||
help="Full HF id of the generating orchestrator, stamped as "
|
||||
"record['gen_model'] (e.g. google/gemma-4-26B-A4B-it).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--orchestrator-label",
|
||||
default=None,
|
||||
help="Short label stamped as record['orchestrator_model'] (e.g. gemma-4-26b).",
|
||||
)
|
||||
# Local OSS model endpoints; repeatable "model_id=base_url" (e.g.
|
||||
# "Qwen/Qwen3.5-9B=http://localhost:8001/v1"). Unmapped local models are
|
||||
# still listed but served unconfigured (base_url=None).
|
||||
p.add_argument("--local-endpoint", action="append", default=[],
|
||||
metavar="MODEL_ID=URL",
|
||||
help="Local model id -> vLLM base URL (repeatable).")
|
||||
p.add_argument("--max-tasks", type=int, default=None,
|
||||
help="Cap on tasks (default: all of load_sft_tasks()).")
|
||||
p.add_argument("--skip-task-ids-from", action="append", default=[],
|
||||
metavar="GLOB",
|
||||
help="Glob of prior data.jsonl files; skip task_ids already "
|
||||
"generated there so this run only does unseen prompts "
|
||||
"(resume). Repeatable; applied before sharding.")
|
||||
p.add_argument(
|
||||
"--local-endpoint",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="MODEL_ID=URL",
|
||||
help="Local model id -> vLLM base URL (repeatable).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--max-tasks",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Cap on tasks (default: all of load_sft_tasks()).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--skip-task-ids-from",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="GLOB",
|
||||
help="Glob of prior data.jsonl files; skip task_ids already "
|
||||
"generated there so this run only does unseen prompts "
|
||||
"(resume). Repeatable; applied before sharding.",
|
||||
)
|
||||
p.add_argument("--samples-per-task", type=int, default=8)
|
||||
p.add_argument("--max-keep-per-task", type=int, default=1)
|
||||
p.add_argument("--max-turns", type=int, default=8)
|
||||
@@ -107,68 +130,107 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
# Orchestrator completion cap per turn. The library default (4096) intermittently
|
||||
# truncates the final answer mid-sentence on longer reasoning turns; bump it so
|
||||
# the trace ends cleanly. Raise further (e.g. 16384) if traces still cut off.
|
||||
p.add_argument("--max-tokens", type=int, default=8192,
|
||||
help="Per-turn completion cap for the orchestrator (default 8192).")
|
||||
p.add_argument(
|
||||
"--max-tokens",
|
||||
type=int,
|
||||
default=8192,
|
||||
help="Per-turn completion cap for the orchestrator (default 8192).",
|
||||
)
|
||||
# Number of tasks rolled out concurrently against vLLM. Each in-flight task
|
||||
# issues its samples sequentially, so ~concurrency requests hit the server at
|
||||
# once. ~30-50 is comfortable on 1xL40S (prefix caching + continuous batching).
|
||||
p.add_argument("--concurrency", type=int, default=32,
|
||||
help="Tasks rolled out in parallel (default 32).")
|
||||
p.add_argument(
|
||||
"--concurrency",
|
||||
type=int,
|
||||
default=32,
|
||||
help="Tasks rolled out in parallel (default 32).",
|
||||
)
|
||||
# Balanced-smoke pull: cap//4 from each of GeneralThought + OpenThoughts
|
||||
# code/math/science instead of the GeneralThought-only fast cap path. Use for a
|
||||
# representative smoke; the real run omits --max-tasks for the full 8K balanced set.
|
||||
p.add_argument("--balanced", action=argparse.BooleanOptionalAction, default=True,
|
||||
help="Draw an EVEN cross-domain sample (GeneralThought + "
|
||||
"OpenThoughts code/math/science). Default ON — pass "
|
||||
"--no-balanced for the old GeneralThought-only skew.")
|
||||
p.add_argument(
|
||||
"--balanced",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="Draw an EVEN cross-domain sample (GeneralThought + "
|
||||
"OpenThoughts code/math/science). Default ON — pass "
|
||||
"--no-balanced for the old GeneralThought-only skew.",
|
||||
)
|
||||
# Data-parallel sharding: split the (deterministic, seed-42) task list across
|
||||
# N independent driver processes, each pointed at its own orchestrator vLLM
|
||||
# replica. Shard i takes tasks[i::N] (a strided slice, so every shard stays
|
||||
# domain-balanced). Each writes its own --out file; concatenate the shard
|
||||
# JSONLs afterward. Lets the GPU-bound orchestrator scale across idle GPUs.
|
||||
p.add_argument("--shard-index", type=int, default=0,
|
||||
help="This shard's index in [0, shard-count).")
|
||||
p.add_argument("--shard-count", type=int, default=1,
|
||||
help="Total number of shards (default 1 = no sharding).")
|
||||
p.add_argument(
|
||||
"--shard-index",
|
||||
type=int,
|
||||
default=0,
|
||||
help="This shard's index in [0, shard-count).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--shard-count",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Total number of shards (default 1 = no sharding).",
|
||||
)
|
||||
# Throughput knob: stop sampling a task as soon as max-keep-per-task passing
|
||||
# trajectories are found, instead of always running all --samples-per-task.
|
||||
# ~3-4x faster when most tasks solve early, but keeps the *first* passers
|
||||
# rather than the *cheapest* of N (drops the cost-optimisation signal).
|
||||
p.add_argument("--stop-at-keep", action="store_true",
|
||||
help="Short-circuit a task once max-keep passing samples found.")
|
||||
p.add_argument(
|
||||
"--stop-at-keep",
|
||||
action="store_true",
|
||||
help="Short-circuit a task once max-keep passing samples found.",
|
||||
)
|
||||
# Anonymize model experts (opaque random labels, uniform description, no cost
|
||||
# line, shuffled order) so the policy can't route on a model's name/position/
|
||||
# cost. The anon->real map is saved per record (metrics.anon_map) for analysis.
|
||||
p.add_argument("--anonymize-experts", action="store_true",
|
||||
help="Hide expert identity (random labels + shuffle) when routing.")
|
||||
p.add_argument(
|
||||
"--anonymize-experts",
|
||||
action="store_true",
|
||||
help="Hide expert identity (random labels + shuffle) when routing.",
|
||||
)
|
||||
# By default we keep EVERY rolled-out trajectory (correct + incorrect), each
|
||||
# tagged with ``correct`` (verifier verdict) and ``kept`` (the cheapest-correct
|
||||
# sample the rejection sampler would pick). Lets you compute accuracy / inspect
|
||||
# failures from the JSONL directly. --rejection-only restores the old
|
||||
# drop-the-failures behaviour (only cheapest-correct written).
|
||||
p.add_argument("--rejection-only", action="store_true",
|
||||
help="Drop incorrect rollouts; write only the cheapest-correct "
|
||||
"sample per task (the original behaviour).")
|
||||
p.add_argument(
|
||||
"--rejection-only",
|
||||
action="store_true",
|
||||
help="Drop incorrect rollouts; write only the cheapest-correct "
|
||||
"sample per task (the original behaviour).",
|
||||
)
|
||||
# Rollouts call shell_exec / file_write with model-chosen relative paths
|
||||
# (e.g. ``solution.py``), which otherwise land in the repo root. Run them
|
||||
# from a throwaway scratch dir so generated files never dirty the tree.
|
||||
# gitignored via the existing ``scratch/`` rule.
|
||||
p.add_argument("--scratch-dir", default="scratch/sft-rollouts",
|
||||
help="CWD for rollouts; stray tool-written files go here.")
|
||||
p.add_argument(
|
||||
"--scratch-dir",
|
||||
default="scratch/sft-rollouts",
|
||||
help="CWD for rollouts; stray tool-written files go here.",
|
||||
)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
# Quiet the per-request HTTP / dataset-stream spam so the run log stays
|
||||
# readable (rollout calls and dataset shards otherwise flood it).
|
||||
for _noisy in ("httpx", "httpcore", "urllib3", "datasets", "fsspec",
|
||||
"huggingface_hub", "openai"):
|
||||
for _noisy in (
|
||||
"httpx",
|
||||
"httpcore",
|
||||
"urllib3",
|
||||
"datasets",
|
||||
"fsspec",
|
||||
"huggingface_hub",
|
||||
"openai",
|
||||
):
|
||||
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||||
|
||||
# Every run lands in its OWN folder under data/orchestrator/raw/, named to
|
||||
# MATCH the sft/ file it will eventually produce — only the stage word differs:
|
||||
# Every run lands in its OWN folder under <data-root>/raw/, named to MATCH the
|
||||
# sft/ file it will eventually produce — only the stage word differs:
|
||||
#
|
||||
# data/orchestrator/raw/qwen_0707/data.jsonl <- every rollout, incl. failures
|
||||
# data/orchestrator/sft/qwen_clean_0707.jsonl <- reject-sampled from it
|
||||
# raw/qwen_0707/data.jsonl <- every rollout, incl. failures
|
||||
# sft/qwen_clean_0707.jsonl <- reject-sampled from it
|
||||
#
|
||||
# so any curated file traces back to its generation run by eye. raw/ sits ABOVE
|
||||
# the sft/rl fork on purpose: SFT keeps only correct+clean rows, but GRPO needs
|
||||
@@ -177,9 +239,13 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
# suffix (creation order is preserved, and a MM-DD-HHMMpm stamp is redundant
|
||||
# with mtime). --out is an optional extra tag, NOT a path; the data file inside
|
||||
# is always data.jsonl.
|
||||
#
|
||||
# OJ_DATA_ROOT keeps the data OUT of the git checkout (repo-relative default so
|
||||
# a fresh clone still works); this workspace points it at the experiments tree.
|
||||
data_root = Path(os.getenv("OJ_DATA_ROOT", "data/orchestrator"))
|
||||
prefix = args.orchestrator_label or "orch"
|
||||
tag = f"_{Path(args.out).stem}" if args.out else ""
|
||||
base = Path("data/orchestrator/raw") / f"{prefix}_{time.strftime('%m%d')}{tag}"
|
||||
base = data_root / "raw" / f"{prefix}_{time.strftime('%m%d')}{tag}"
|
||||
run_dir = base.resolve()
|
||||
n = 1
|
||||
while run_dir.exists(): # same name+day (incl. parallel shards) -> disambiguate
|
||||
@@ -192,6 +258,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
lock_p.write_text(f"{time.strftime('%m-%d-%I%M%p').lower()} pid={os.getpid()}")
|
||||
args.out = str(out_p)
|
||||
import atexit
|
||||
|
||||
atexit.register(lambda: lock_p.exists() and lock_p.unlink())
|
||||
logging.info("Run dir: %s", run_dir)
|
||||
|
||||
@@ -199,8 +266,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
# tracing.run_context() reads these). setdefault so an explicit env wins.
|
||||
os.environ.setdefault("OJ_RUN_LABEL", label)
|
||||
os.environ.setdefault("OJ_GEN_MODEL", args.gen_model or args.orchestrator_model)
|
||||
os.environ.setdefault("OJ_RUN_STAGE",
|
||||
"smoke" if args.max_tasks else "prod")
|
||||
os.environ.setdefault("OJ_RUN_STAGE", "smoke" if args.max_tasks else "prod")
|
||||
os.environ["OJ_CFG_TEMPERATURE"] = str(args.temperature)
|
||||
os.environ["OJ_CFG_MAX_TURNS"] = str(args.max_turns)
|
||||
os.environ["OJ_CFG_ANONYMIZE"] = str(bool(args.anonymize_experts))
|
||||
@@ -247,8 +313,10 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
def rollout_fn(task):
|
||||
try:
|
||||
return run_unified_rollout(
|
||||
task.instruction, tools,
|
||||
call_orchestrator=call_orch, dispatch=dispatch,
|
||||
task.instruction,
|
||||
tools,
|
||||
call_orchestrator=call_orch,
|
||||
dispatch=dispatch,
|
||||
max_turns=args.max_turns,
|
||||
anonymize=args.anonymize_experts,
|
||||
)
|
||||
@@ -265,6 +333,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
# remaining unseen tasks stride evenly and never re-cover finished prompts.
|
||||
if args.skip_task_ids_from:
|
||||
import glob as _glob
|
||||
|
||||
skip_ids = set()
|
||||
for pattern in args.skip_task_ids_from:
|
||||
for fp in _glob.glob(pattern):
|
||||
@@ -285,18 +354,31 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
if skip_ids:
|
||||
n_before = len(tasks)
|
||||
tasks = [t for t in tasks if t.task_id not in skip_ids]
|
||||
logging.info("Resume: %d done task_ids -> skipping; %d of %d tasks remain",
|
||||
len(skip_ids), len(tasks), n_before)
|
||||
logging.info(
|
||||
"Resume: %d done task_ids -> skipping; %d of %d tasks remain",
|
||||
len(skip_ids),
|
||||
len(tasks),
|
||||
n_before,
|
||||
)
|
||||
if args.shard_count > 1:
|
||||
n_all = len(tasks)
|
||||
tasks = tasks[args.shard_index::args.shard_count]
|
||||
logging.info("Shard %d/%d -> %d of %d tasks",
|
||||
args.shard_index, args.shard_count, len(tasks), n_all)
|
||||
tasks = tasks[args.shard_index :: args.shard_count]
|
||||
logging.info(
|
||||
"Shard %d/%d -> %d of %d tasks",
|
||||
args.shard_index,
|
||||
args.shard_count,
|
||||
len(tasks),
|
||||
n_all,
|
||||
)
|
||||
from collections import Counter as _Counter
|
||||
|
||||
_dom = _Counter(getattr(t, "domain", "unknown") for t in tasks)
|
||||
logging.info("Loaded %d SFT tasks | balanced=%s | domain split: %s",
|
||||
len(tasks), args.balanced,
|
||||
", ".join(f"{d}={n}" for d, n in sorted(_dom.items())))
|
||||
logging.info(
|
||||
"Loaded %d SFT tasks | balanced=%s | domain split: %s",
|
||||
len(tasks),
|
||||
args.balanced,
|
||||
", ".join(f"{d}={n}" for d, n in sorted(_dom.items())),
|
||||
)
|
||||
|
||||
stats = generate_sft_dataset(
|
||||
args.out,
|
||||
@@ -321,8 +403,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
from render_sft_data import render
|
||||
if stats.get("records_written", 0) > 0:
|
||||
rinfo = render(args.out)
|
||||
logging.info("Rendered viewable traces: %s + %s",
|
||||
rinfo["pretty"], rinfo["txt"])
|
||||
logging.info("Rendered viewable traces: %s + %s", rinfo["pretty"], rinfo["txt"])
|
||||
|
||||
print(json.dumps(stats, indent=2))
|
||||
return 0
|
||||
|
||||
@@ -23,25 +23,43 @@ Usage:
|
||||
# point local expert tiers at their own vLLMs if you have them up (optional):
|
||||
# --local-endpoint Qwen/Qwen3.6-27B=http://localhost:8002/v1
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def _parse_args(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--endpoint", default="http://localhost:8020/v1",
|
||||
help="vLLM endpoint serving the orchestrator checkpoint.")
|
||||
p.add_argument("--model", default="sft-qwen-8k",
|
||||
help="Served-model-name of the checkpoint.")
|
||||
p.add_argument("--api-key", default="EMPTY", help="API key for the endpoint (local vLLM = EMPTY).")
|
||||
p = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
p.add_argument(
|
||||
"--endpoint",
|
||||
default="http://localhost:8020/v1",
|
||||
help="vLLM endpoint serving the orchestrator checkpoint.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--model", default="sft-qwen-8k", help="Served-model-name of the checkpoint."
|
||||
)
|
||||
p.add_argument(
|
||||
"--api-key",
|
||||
default="EMPTY",
|
||||
help="API key for the endpoint (local vLLM = EMPTY).",
|
||||
)
|
||||
p.add_argument("--temperature", type=float, default=0.0)
|
||||
p.add_argument("--max-turns", type=int, default=8, help="Same default as the eval.")
|
||||
p.add_argument("--max-tokens", type=int, default=3000,
|
||||
help="Keep high: the model reasons a lot before it emits the delegation.")
|
||||
p.add_argument("--local-endpoint", action="append", default=[],
|
||||
help="MODEL_ID=URL for a local expert tier. Repeatable.")
|
||||
p.add_argument(
|
||||
"--max-tokens",
|
||||
type=int,
|
||||
default=3000,
|
||||
help="Keep high: the model reasons a lot before it emits the delegation.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--local-endpoint",
|
||||
action="append",
|
||||
default=[],
|
||||
help="MODEL_ID=URL for a local expert tier. Repeatable.",
|
||||
)
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
@@ -68,8 +86,10 @@ def main(argv=None):
|
||||
temperature=args.temperature,
|
||||
)
|
||||
|
||||
print(f"orchestrator: {args.model} @ {args.endpoint} "
|
||||
f"(temp={args.temperature}, max_turns={args.max_turns})")
|
||||
print(
|
||||
f"orchestrator: {args.model} @ {args.endpoint} "
|
||||
f"(temp={args.temperature}, max_turns={args.max_turns})"
|
||||
)
|
||||
print("type a question and hit enter. Ctrl-D or 'quit' to exit.\n")
|
||||
|
||||
try:
|
||||
@@ -86,7 +106,9 @@ def main(argv=None):
|
||||
|
||||
started = time.time()
|
||||
full = backend.generate_full(
|
||||
prompt, model=args.model, temperature=args.temperature,
|
||||
prompt,
|
||||
model=args.model,
|
||||
temperature=args.temperature,
|
||||
max_tokens=args.max_tokens,
|
||||
)
|
||||
elapsed = time.time() - started
|
||||
@@ -96,10 +118,12 @@ def main(argv=None):
|
||||
continue
|
||||
|
||||
print(f"\norch> {full.get('content', '').strip()}\n")
|
||||
print(f" [turns={full.get('turn_count', '?')} "
|
||||
f"tool_calls={full.get('tool_calls', '?')} "
|
||||
f"cost=${full.get('cost_usd', 0.0):.4f} "
|
||||
f"{elapsed:.1f}s]\n")
|
||||
print(
|
||||
f" [turns={full.get('turn_count', '?')} "
|
||||
f"tool_calls={full.get('tool_calls', '?')} "
|
||||
f"cost=${full.get('cost_usd', 0.0):.4f} "
|
||||
f"{elapsed:.1f}s]\n"
|
||||
)
|
||||
finally:
|
||||
backend.close()
|
||||
return 0
|
||||
|
||||
@@ -258,9 +258,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
local_endpoints: Dict[str, str] = {}
|
||||
for pair in args.local_endpoint:
|
||||
if "=" not in pair:
|
||||
raise SystemExit(
|
||||
f"--local-endpoint expects MODEL_ID=URL, got: {pair!r}"
|
||||
)
|
||||
raise SystemExit(f"--local-endpoint expects MODEL_ID=URL, got: {pair!r}")
|
||||
model_id, url = pair.split("=", 1)
|
||||
local_endpoints[model_id.strip()] = url.strip()
|
||||
|
||||
@@ -307,6 +305,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
served_id, served_path = args.orchestrator_model, None
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
args.orchestrator_endpoint.rstrip("/") + "/models",
|
||||
headers={"Authorization": f"Bearer {args.orchestrator_api_key}"},
|
||||
|
||||
@@ -30,6 +30,7 @@ Usage:
|
||||
... --input <file> --all # one .txt per record
|
||||
... --input <file> --all --only-wrong # only incorrect samples
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -81,8 +82,10 @@ def build_question_map(benchmark: str, n: int, seed: int = 42) -> dict:
|
||||
ds.load(max_samples=n, seed=seed)
|
||||
return {r.record_id: r.problem for r in ds.iter_records()}
|
||||
except Exception as exc: # noqa: BLE001 - never fail the render over this
|
||||
print(f" [warn] could not load dataset for {benchmark!r}: "
|
||||
f"{type(exc).__name__}: {exc}")
|
||||
print(
|
||||
f" [warn] could not load dataset for {benchmark!r}: "
|
||||
f"{type(exc).__name__}: {exc}"
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -134,7 +137,11 @@ def parse_scoring(meta: dict) -> dict:
|
||||
def format_record(rec: dict, question: str | None) -> str:
|
||||
parsed = parse_scoring(rec.get("scoring_metadata"))
|
||||
is_correct = rec.get("is_correct")
|
||||
verdict = "CORRECT" if is_correct else ("INCORRECT" if is_correct is False else "UNSCORED")
|
||||
verdict = (
|
||||
"CORRECT"
|
||||
if is_correct
|
||||
else ("INCORRECT" if is_correct is False else "UNSCORED")
|
||||
)
|
||||
|
||||
def _fmt(v, fmt):
|
||||
try:
|
||||
@@ -142,14 +149,16 @@ def format_record(rec: dict, question: str | None) -> str:
|
||||
except (ValueError, TypeError):
|
||||
return str(v)
|
||||
|
||||
top = " · ".join([
|
||||
str(rec.get("record_id", "?")),
|
||||
str(rec.get("benchmark", "?")),
|
||||
verdict,
|
||||
f"score {_fmt(rec.get('score'), '{:.3f}')}",
|
||||
f"lat {_fmt(rec.get('latency_seconds'), '{:.1f}')}s",
|
||||
f"cost ${_fmt(rec.get('cost_usd'), '{:.4f}')}",
|
||||
])
|
||||
top = " · ".join(
|
||||
[
|
||||
str(rec.get("record_id", "?")),
|
||||
str(rec.get("benchmark", "?")),
|
||||
verdict,
|
||||
f"score {_fmt(rec.get('score'), '{:.3f}')}",
|
||||
f"lat {_fmt(rec.get('latency_seconds'), '{:.1f}')}s",
|
||||
f"cost ${_fmt(rec.get('cost_usd'), '{:.4f}')}",
|
||||
]
|
||||
)
|
||||
parts = [top]
|
||||
|
||||
err = rec.get("error")
|
||||
@@ -164,7 +173,13 @@ def format_record(rec: dict, question: str | None) -> str:
|
||||
|
||||
parts.append(_banner("GOLD"))
|
||||
gold = parsed["gold"]
|
||||
parts.append(_indent(str(gold) if gold not in (None, "") else "(gold unavailable — see SCORING below)"))
|
||||
parts.append(
|
||||
_indent(
|
||||
str(gold)
|
||||
if gold not in (None, "")
|
||||
else "(gold unavailable — see SCORING below)"
|
||||
)
|
||||
)
|
||||
|
||||
if parsed["kind"] == "judge" and parsed["judge"]:
|
||||
parts.append(_banner("JUDGE"))
|
||||
@@ -172,7 +187,9 @@ def format_record(rec: dict, question: str | None) -> str:
|
||||
|
||||
if not parsed["clean"]:
|
||||
parts.append(_banner("SCORING (raw)"))
|
||||
parts.append(_indent(json.dumps(rec.get("scoring_metadata"), indent=2, default=str)))
|
||||
parts.append(
|
||||
_indent(json.dumps(rec.get("scoring_metadata"), indent=2, default=str))
|
||||
)
|
||||
|
||||
return "\n".join(parts).rstrip() + "\n"
|
||||
|
||||
@@ -182,10 +199,17 @@ def main(argv=None):
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
p.add_argument("--input", required=True, help="Graded *_orchestrator.jsonl file.")
|
||||
p.add_argument("--out-dir", default="results/formatted", help="Where the .txt files go.")
|
||||
p.add_argument("--seed", type=int, default=42, help="Subset seed used at eval time.")
|
||||
p.add_argument("--only-wrong", action="store_true",
|
||||
help="Render only incorrect/unscored samples.")
|
||||
p.add_argument(
|
||||
"--out-dir", default="results/formatted", help="Where the .txt files go."
|
||||
)
|
||||
p.add_argument(
|
||||
"--seed", type=int, default=42, help="Subset seed used at eval time."
|
||||
)
|
||||
p.add_argument(
|
||||
"--only-wrong",
|
||||
action="store_true",
|
||||
help="Render only incorrect/unscored samples.",
|
||||
)
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument("--n", type=int, default=1, help="Format the first N records.")
|
||||
g.add_argument("--lines", help="Comma-separated 1-indexed line numbers.")
|
||||
@@ -213,7 +237,9 @@ def main(argv=None):
|
||||
if args.only_wrong:
|
||||
idxs = [i for i in idxs if not records[i].get("is_correct")]
|
||||
|
||||
benchmark = _normalize_benchmark(records[0].get("benchmark", src.stem.split("_")[0]))
|
||||
benchmark = _normalize_benchmark(
|
||||
records[0].get("benchmark", src.stem.split("_")[0])
|
||||
)
|
||||
# Load the whole subset so any selected id resolves (the eval-time subset is
|
||||
# the first len(records) of seed=42).
|
||||
qmap = build_question_map(benchmark, n=len(records), seed=args.seed)
|
||||
@@ -230,8 +256,10 @@ def main(argv=None):
|
||||
|
||||
for w in written:
|
||||
print(w)
|
||||
print(f"\nwrote {len(written)} file(s) to {out_dir}/"
|
||||
+ (" (dataset unavailable — questions omitted)" if not qmap else ""))
|
||||
print(
|
||||
f"\nwrote {len(written)} file(s) to {out_dir}/"
|
||||
+ (" (dataset unavailable — questions omitted)" if not qmap else "")
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ Deterministic (seed 42).
|
||||
--pool data/orchestrator/sft/qwen_clean_0711.jsonl \
|
||||
--pool data/orchestrator/sft/gemma_clean_0711.jsonl
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
@@ -36,7 +37,12 @@ from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
OUT = Path("data/orchestrator/sft")
|
||||
# Where the orchestrator data tree lives. Repo-relative by default so a fresh
|
||||
# clone works out of the box; set OJ_DATA_ROOT to keep the data OUT of the git
|
||||
# checkout (this workspace points it at ~/experiments/orchestrator/data, so a
|
||||
# stray `git reset` can't touch hundreds of GB of generations).
|
||||
DATA_ROOT = Path(os.getenv("OJ_DATA_ROOT", "data/orchestrator"))
|
||||
OUT = DATA_ROOT / "sft"
|
||||
SEED = 42
|
||||
OVERFIT_N = 100
|
||||
# Orchestrator that generated each pool — stamped onto every row so provenance
|
||||
@@ -46,16 +52,32 @@ ORCH_MODEL = {"qwen": "qwen3.5-9b", "gemma": "gemma-4-26b"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--name", required=True,
|
||||
help="split family: qwen | gemma | pooled (drives the filename)")
|
||||
ap.add_argument("--pool", action="append", required=True, metavar="PATH",
|
||||
help="clean pool jsonl; repeat to merge (use with --name pooled)")
|
||||
ap.add_argument("--holdout-frac", type=float, default=0.15,
|
||||
help="fraction held out, domain-stratified (default 0.15)")
|
||||
ap.add_argument("--date", default=datetime.now().strftime("%m%d"),
|
||||
help="date tag in the filename (default: today, MMDD)")
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
ap.add_argument(
|
||||
"--name",
|
||||
required=True,
|
||||
help="split family: qwen | gemma | pooled (drives the filename)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--pool",
|
||||
action="append",
|
||||
required=True,
|
||||
metavar="PATH",
|
||||
help="clean pool jsonl; repeat to merge (use with --name pooled)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--holdout-frac",
|
||||
type=float,
|
||||
default=0.15,
|
||||
help="fraction held out, domain-stratified (default 0.15)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--date",
|
||||
default=datetime.now().strftime("%m%d"),
|
||||
help="date tag in the filename (default: today, MMDD)",
|
||||
)
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
args = ap.parse_args()
|
||||
|
||||
@@ -94,8 +116,11 @@ def main() -> int:
|
||||
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
written = {}
|
||||
for split, data in (("train", train), ("holdout", holdout),
|
||||
(f"overfit{OVERFIT_N}", overfit)):
|
||||
for split, data in (
|
||||
("train", train),
|
||||
("holdout", holdout),
|
||||
(f"overfit{OVERFIT_N}", overfit),
|
||||
):
|
||||
f = args.out / f"{args.name}_{split}_{args.date}.jsonl"
|
||||
f.write_text("".join(json.dumps(r) + "\n" for r in data))
|
||||
written[split] = f
|
||||
@@ -109,8 +134,10 @@ def main() -> int:
|
||||
from upload_to_braintrust import autoupload
|
||||
|
||||
autoupload(
|
||||
[f"{written['train']}={args.name}_train_{args.date}",
|
||||
f"{written['holdout']}={args.name}_holdout_{args.date}"],
|
||||
[
|
||||
f"{written['train']}={args.name}_train_{args.date}",
|
||||
f"{written['holdout']}={args.name}_holdout_{args.date}",
|
||||
],
|
||||
run_label=os.getenv("OJ_RUN_LABEL", f"{args.name}_splits_{args.date}"),
|
||||
description=f"{args.name} orchestrator-SFT splits (make_splits.py)",
|
||||
)
|
||||
|
||||
@@ -64,7 +64,11 @@ def _fmt_tool_calls(content: str, anon_map=None) -> str:
|
||||
lines = [f"-> {_call_label(name, anon_map)}"]
|
||||
if isinstance(args, dict):
|
||||
for k, v in args.items():
|
||||
val = v if isinstance(v, str) else json.dumps(v, ensure_ascii=False, indent=2)
|
||||
val = (
|
||||
v
|
||||
if isinstance(v, str)
|
||||
else json.dumps(v, ensure_ascii=False, indent=2)
|
||||
)
|
||||
indented = "\n".join(" " + ln for ln in val.splitlines())
|
||||
lines.append(f" {k}:\n{indented}")
|
||||
blocks.append("\n".join(lines))
|
||||
@@ -136,7 +140,8 @@ def _system_header(records: List[dict]) -> str:
|
||||
if str(t.get("role", "")).lower() == "system":
|
||||
return (
|
||||
"=" * 80 + "\nSHARED SYSTEM PROMPT + TOOL CATALOG "
|
||||
"(identical for every record below)\n" + "=" * 80
|
||||
"(identical for every record below)\n"
|
||||
+ "=" * 80
|
||||
+ f"\n{t.get('content', '').strip()}\n"
|
||||
)
|
||||
return ""
|
||||
@@ -186,27 +191,42 @@ def _html_turns(record: dict) -> str:
|
||||
if role == "system":
|
||||
continue
|
||||
if role == "user":
|
||||
out.append(f'<div class="turn user"><div class="role">user</div>{_esc(content.strip())}</div>')
|
||||
out.append(
|
||||
f'<div class="turn user"><div class="role">user</div>{_esc(content.strip())}</div>'
|
||||
)
|
||||
elif role in ("tool", "function"):
|
||||
name = _esc(t.get("name", "tool"))
|
||||
out.append(
|
||||
f'<div class="tool"><details><summary>tool result · {name} '
|
||||
f'({len(content)} chars)</summary><pre>{_esc(content.strip())}</pre></details></div>'
|
||||
f"({len(content)} chars)</summary><pre>{_esc(content.strip())}</pre></details></div>"
|
||||
)
|
||||
elif role == "assistant":
|
||||
prose = _TOOL_CALL_RE.sub("", content).strip()
|
||||
if prose:
|
||||
out.append(f'<div class="turn assistant"><div class="role">assistant</div>{_esc(prose)}</div>')
|
||||
out.append(
|
||||
f'<div class="turn assistant"><div class="role">assistant</div>{_esc(prose)}</div>'
|
||||
)
|
||||
for name, args in _parse_tool_calls(content):
|
||||
real = anon_map.get(name)
|
||||
label = (f'{_esc(str(name))} <span class="real">→ {_esc(str(real))}</span>'
|
||||
if real else _esc(str(name)))
|
||||
label = (
|
||||
f'{_esc(str(name))} <span class="real">→ {_esc(str(real))}</span>'
|
||||
if real
|
||||
else _esc(str(name))
|
||||
)
|
||||
rows = [f'<div class="cname">→ {label}</div>']
|
||||
if isinstance(args, dict):
|
||||
for k, v in args.items():
|
||||
val = v if isinstance(v, str) else json.dumps(v, ensure_ascii=False, indent=2)
|
||||
rows.append(f'<div class="arg"><b>{_esc(str(k))}</b><pre>{_esc(val)}</pre></div>')
|
||||
out.append(f'<div class="turn call"><div class="role">tool call</div>{"".join(rows)}</div>')
|
||||
val = (
|
||||
v
|
||||
if isinstance(v, str)
|
||||
else json.dumps(v, ensure_ascii=False, indent=2)
|
||||
)
|
||||
rows.append(
|
||||
f'<div class="arg"><b>{_esc(str(k))}</b><pre>{_esc(val)}</pre></div>'
|
||||
)
|
||||
out.append(
|
||||
f'<div class="turn call"><div class="role">tool call</div>{"".join(rows)}</div>'
|
||||
)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
@@ -215,16 +235,26 @@ def _html_record(record: dict, index: int) -> str:
|
||||
pills = [f'<span class="pill dom">{_esc(str(record.get("domain")))}</span>']
|
||||
correct = record.get("correct")
|
||||
if correct is not None:
|
||||
pills.append('<span class="pill ok">correct</span>' if correct
|
||||
else '<span class="pill wrong">wrong</span>')
|
||||
pills.append(
|
||||
'<span class="pill ok">correct</span>'
|
||||
if correct
|
||||
else '<span class="pill wrong">wrong</span>'
|
||||
)
|
||||
if record.get("kept"):
|
||||
pills.append('<span class="pill kept">kept</span>')
|
||||
meta = (f'<span class="meta">cost ${m.get("cost_usd")} · {m.get("tokens")} tok · '
|
||||
f'{m.get("num_tool_calls")} calls · {m.get("num_turns")} turns</span>')
|
||||
summary = (f'<span>#{index}</span><span>{_esc(str(record.get("task_id")))}</span>'
|
||||
+ "".join(pills) + meta)
|
||||
return (f'<details class="rec"><summary>{summary}</summary>'
|
||||
f'<div class="body">{_html_turns(record)}</div></details>')
|
||||
meta = (
|
||||
f'<span class="meta">cost ${m.get("cost_usd")} · {m.get("tokens")} tok · '
|
||||
f"{m.get('num_tool_calls')} calls · {m.get('num_turns')} turns</span>"
|
||||
)
|
||||
summary = (
|
||||
f"<span>#{index}</span><span>{_esc(str(record.get('task_id')))}</span>"
|
||||
+ "".join(pills)
|
||||
+ meta
|
||||
)
|
||||
return (
|
||||
f'<details class="rec"><summary>{summary}</summary>'
|
||||
f'<div class="body">{_html_turns(record)}</div></details>'
|
||||
)
|
||||
|
||||
|
||||
def _html_doc(records: List[dict], title: str) -> str:
|
||||
@@ -262,9 +292,7 @@ def render(jsonl_path: str) -> dict:
|
||||
pretty.write_text(json.dumps(records, indent=2, ensure_ascii=False))
|
||||
|
||||
txt = src.with_suffix(".txt")
|
||||
banner = (
|
||||
f"ORCHESTRATOR SFT TRANSCRIPT — {src.name}\n{len(records)} records\n\n"
|
||||
)
|
||||
banner = f"ORCHESTRATOR SFT TRANSCRIPT — {src.name}\n{len(records)} records\n\n"
|
||||
sep = f"\n\n{_RULE}\n\n"
|
||||
parts = [banner + _system_header(records)]
|
||||
parts.append(sep.join(_transcript(r, i + 1) for i, r in enumerate(records)))
|
||||
@@ -273,8 +301,12 @@ def render(jsonl_path: str) -> dict:
|
||||
htmlp = src.with_suffix(".html")
|
||||
htmlp.write_text(_html_doc(records, src.name))
|
||||
|
||||
return {"records": len(records), "pretty": str(pretty),
|
||||
"txt": str(txt), "html": str(htmlp)}
|
||||
return {
|
||||
"records": len(records),
|
||||
"pretty": str(pretty),
|
||||
"txt": str(txt),
|
||||
"html": str(htmlp),
|
||||
}
|
||||
|
||||
|
||||
def main(argv: List[str]) -> int:
|
||||
|
||||
@@ -24,6 +24,7 @@ Reuses the conversation->tokens + assistant-only masking from sft_tokenize.py.
|
||||
On these no-NVLink L40S, the accelerate/FSDP NCCL env (NCCL_P2P_DISABLE=1 etc.)
|
||||
must be set by the launcher.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -39,8 +40,14 @@ from pathlib import Path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from sft_tokenize import build_example # noqa: E402
|
||||
|
||||
MODEL_EXPERTS = {"gpt_5_5", "claude_opus_4_8", "qwen3_5_9b", "qwen3_6_27b_fp8",
|
||||
"qwen3_5_122b_a10b_fp8", "qwen3_5_397b_a17b_fp8"}
|
||||
MODEL_EXPERTS = {
|
||||
"gpt_5_5",
|
||||
"claude_opus_4_8",
|
||||
"qwen3_5_9b",
|
||||
"qwen3_6_27b_fp8",
|
||||
"qwen3_5_122b_a10b_fp8",
|
||||
"qwen3_5_397b_a17b_fp8",
|
||||
}
|
||||
|
||||
|
||||
def record_is_correct(r: dict) -> bool:
|
||||
@@ -53,7 +60,9 @@ def record_is_correct(r: dict) -> bool:
|
||||
def record_routed_to_expert(r: dict) -> bool:
|
||||
"""True if the trajectory called a model expert (resolving anon labels)."""
|
||||
amap = r.get("metrics", {}).get("anon_map", {})
|
||||
names = re.findall(r'"name"\s*:\s*"([a-z0-9_]+)"', json.dumps(r.get("conversations", [])))
|
||||
names = re.findall(
|
||||
r'"name"\s*:\s*"([a-z0-9_]+)"', json.dumps(r.get("conversations", []))
|
||||
)
|
||||
for n in names:
|
||||
real = amap.get(n, n)
|
||||
if real in MODEL_EXPERTS:
|
||||
@@ -84,7 +93,9 @@ def select(records, variant: str, require_clean: bool = True):
|
||||
elif variant == "correct":
|
||||
base = [r for r in records if record_is_correct(r)]
|
||||
elif variant == "correct_routed":
|
||||
base = [r for r in records if record_is_correct(r) and record_routed_to_expert(r)]
|
||||
base = [
|
||||
r for r in records if record_is_correct(r) and record_routed_to_expert(r)
|
||||
]
|
||||
else:
|
||||
raise ValueError(f"unknown variant {variant}")
|
||||
if not require_clean:
|
||||
@@ -94,14 +105,25 @@ def select(records, variant: str, require_clean: bool = True):
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--data", action="append", required=True, help="JSONL(s) (repeatable)")
|
||||
p.add_argument("--val-data", default=None,
|
||||
help="Held-out JSONL for val-loss (excluded from --data). Eval'd per epoch.")
|
||||
p.add_argument("--variant", choices=["all", "correct", "correct_routed"], default="correct")
|
||||
p.add_argument("--require-clean", action=argparse.BooleanOptionalAction, default=True,
|
||||
help="Drop records whose `clean` flag is False (bloated / garbled / "
|
||||
"unrouted). Rows missing the flag (legacy data) are kept. "
|
||||
"Use --no-require-clean to disable.")
|
||||
p.add_argument(
|
||||
"--data", action="append", required=True, help="JSONL(s) (repeatable)"
|
||||
)
|
||||
p.add_argument(
|
||||
"--val-data",
|
||||
default=None,
|
||||
help="Held-out JSONL for val-loss (excluded from --data). Eval'd per epoch.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--variant", choices=["all", "correct", "correct_routed"], default="correct"
|
||||
)
|
||||
p.add_argument(
|
||||
"--require-clean",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="Drop records whose `clean` flag is False (bloated / garbled / "
|
||||
"unrouted). Rows missing the flag (legacy data) are kept. "
|
||||
"Use --no-require-clean to disable.",
|
||||
)
|
||||
p.add_argument("--model", default="Qwen/Qwen3.5-9B")
|
||||
p.add_argument("--out", required=True)
|
||||
p.add_argument("--epochs", type=float, default=3.0)
|
||||
@@ -109,9 +131,12 @@ def main() -> int:
|
||||
p.add_argument("--grad-accum", type=int, default=8)
|
||||
p.add_argument("--lr", type=float, default=1e-5)
|
||||
p.add_argument("--max-seq", type=int, default=8192)
|
||||
p.add_argument("--supervise-last-only", action="store_true",
|
||||
help="Legacy: supervise ONLY the final assistant turn. Default "
|
||||
"(off) supervises every assistant turn incl. routing.")
|
||||
p.add_argument(
|
||||
"--supervise-last-only",
|
||||
action="store_true",
|
||||
help="Legacy: supervise ONLY the final assistant turn. Default "
|
||||
"(off) supervises every assistant turn incl. routing.",
|
||||
)
|
||||
p.add_argument("--warmup-ratio", type=float, default=0.03)
|
||||
p.add_argument("--seed", type=int, default=42)
|
||||
p.add_argument("--wandb-project", default="orchestrator-sft")
|
||||
@@ -120,10 +145,11 @@ def main() -> int:
|
||||
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
import torch
|
||||
from torch.utils.data import DataLoader
|
||||
from accelerate import Accelerator
|
||||
from accelerate.utils import set_seed, InitProcessGroupKwargs
|
||||
from accelerate.utils import InitProcessGroupKwargs, set_seed
|
||||
from torch.utils.data import DataLoader
|
||||
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||||
|
||||
set_seed(args.seed)
|
||||
@@ -133,7 +159,10 @@ def main() -> int:
|
||||
pg_timeout_min = int(os.environ.get("SFT_PG_TIMEOUT_MIN", "30"))
|
||||
accelerator = Accelerator(
|
||||
gradient_accumulation_steps=args.grad_accum,
|
||||
kwargs_handlers=[InitProcessGroupKwargs(timeout=timedelta(minutes=pg_timeout_min))])
|
||||
kwargs_handlers=[
|
||||
InitProcessGroupKwargs(timeout=timedelta(minutes=pg_timeout_min))
|
||||
],
|
||||
)
|
||||
is_main = accelerator.is_main_process
|
||||
|
||||
def log(m):
|
||||
@@ -153,14 +182,20 @@ def main() -> int:
|
||||
if args.require_clean:
|
||||
pre_clean = select(records, args.variant, require_clean=False)
|
||||
n_unclean = len(pre_clean) - len(sel)
|
||||
log(f"require_clean=True: dropped {n_unclean} unclean rows "
|
||||
f"({len(pre_clean)}->{len(sel)}); use --no-require-clean to keep them")
|
||||
log(
|
||||
f"require_clean=True: dropped {n_unclean} unclean rows "
|
||||
f"({len(pre_clean)}->{len(sel)}); use --no-require-clean to keep them"
|
||||
)
|
||||
else:
|
||||
log("require_clean=False: NOT filtering on `clean` flag")
|
||||
examples = []
|
||||
for r in sel:
|
||||
ex = build_example(tok, r.get("conversations", []), args.max_seq,
|
||||
supervise_all_turns=not args.supervise_last_only)
|
||||
ex = build_example(
|
||||
tok,
|
||||
r.get("conversations", []),
|
||||
args.max_seq,
|
||||
supervise_all_turns=not args.supervise_last_only,
|
||||
)
|
||||
if ex:
|
||||
examples.append(ex)
|
||||
log(f"built {len(examples)} training examples")
|
||||
@@ -179,7 +214,9 @@ def main() -> int:
|
||||
am.append([1] * len(b["input_ids"]) + [0] * pad)
|
||||
return (torch.tensor(ii), torch.tensor(lab), torch.tensor(am))
|
||||
|
||||
dl = DataLoader(examples, batch_size=args.batch_size, shuffle=True, collate_fn=collate)
|
||||
dl = DataLoader(
|
||||
examples, batch_size=args.batch_size, shuffle=True, collate_fn=collate
|
||||
)
|
||||
|
||||
# ---- held-out val set (leak-free; excluded from --data upstream) ----
|
||||
val_dl = None
|
||||
@@ -187,21 +224,32 @@ def main() -> int:
|
||||
vrecs = [json.loads(l) for l in open(args.val_data) if l.strip()]
|
||||
vex = []
|
||||
for r in vrecs:
|
||||
ex = build_example(tok, r.get("conversations", []), args.max_seq,
|
||||
supervise_all_turns=not args.supervise_last_only)
|
||||
ex = build_example(
|
||||
tok,
|
||||
r.get("conversations", []),
|
||||
args.max_seq,
|
||||
supervise_all_turns=not args.supervise_last_only,
|
||||
)
|
||||
if ex:
|
||||
vex.append(ex)
|
||||
log(f"val: {len(vex)} examples from {args.val_data}")
|
||||
if vex:
|
||||
val_dl = DataLoader(vex, batch_size=args.batch_size, shuffle=False, collate_fn=collate)
|
||||
val_dl = DataLoader(
|
||||
vex, batch_size=args.batch_size, shuffle=False, collate_fn=collate
|
||||
)
|
||||
|
||||
model = AutoModelForCausalLM.from_pretrained(
|
||||
args.model, dtype=torch.bfloat16, trust_remote_code=True,
|
||||
attn_implementation="sdpa")
|
||||
args.model,
|
||||
dtype=torch.bfloat16,
|
||||
trust_remote_code=True,
|
||||
attn_implementation="sdpa",
|
||||
)
|
||||
if os.environ.get("SFT_NO_GRAD_CKPT") == "1":
|
||||
log("gradient checkpointing DISABLED (SFT_NO_GRAD_CKPT=1)")
|
||||
else:
|
||||
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
|
||||
model.gradient_checkpointing_enable(
|
||||
gradient_checkpointing_kwargs={"use_reentrant": False}
|
||||
)
|
||||
model.config.use_cache = False
|
||||
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.0)
|
||||
|
||||
@@ -234,17 +282,25 @@ def main() -> int:
|
||||
def lr_at(s):
|
||||
if s < warmup:
|
||||
return s / warmup
|
||||
return 0.5 * (1 + math.cos(math.pi * (s - warmup) / max(1, total_steps - warmup)))
|
||||
return 0.5 * (
|
||||
1 + math.cos(math.pi * (s - warmup) / max(1, total_steps - warmup))
|
||||
)
|
||||
|
||||
use_wandb = False
|
||||
if is_main:
|
||||
try:
|
||||
import wandb
|
||||
base_name = args.wandb_name or f"{Path(args.model).name}-{args.variant}-fsdp"
|
||||
|
||||
base_name = (
|
||||
args.wandb_name or f"{Path(args.model).name}-{args.variant}-fsdp"
|
||||
)
|
||||
run_name = f"{base_name}-{time.strftime('%m%d-%H%M', time.gmtime())}"
|
||||
wandb.init(project=args.wandb_project,
|
||||
name=run_name,
|
||||
config=vars(args) | {"n_examples": len(examples), "total_steps": total_steps})
|
||||
wandb.init(
|
||||
project=args.wandb_project,
|
||||
name=run_name,
|
||||
config=vars(args)
|
||||
| {"n_examples": len(examples), "total_steps": total_steps},
|
||||
)
|
||||
use_wandb = True
|
||||
except Exception as e:
|
||||
log(f"wandb off ({e})")
|
||||
@@ -262,9 +318,11 @@ def main() -> int:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
tok.save_pretrained(str(p))
|
||||
unwrapped.save_pretrained(
|
||||
str(p), is_main_process=is_main,
|
||||
str(p),
|
||||
is_main_process=is_main,
|
||||
save_function=accelerator.save,
|
||||
state_dict=accelerator.get_state_dict(model))
|
||||
state_dict=accelerator.get_state_dict(model),
|
||||
)
|
||||
if is_main:
|
||||
log(f"checkpoint saved -> {p}")
|
||||
|
||||
@@ -319,9 +377,11 @@ def main() -> int:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
tok.save_pretrained(str(out_dir))
|
||||
unwrapped.save_pretrained(
|
||||
str(out_dir), is_main_process=is_main,
|
||||
str(out_dir),
|
||||
is_main_process=is_main,
|
||||
save_function=accelerator.save,
|
||||
state_dict=accelerator.get_state_dict(model))
|
||||
state_dict=accelerator.get_state_dict(model),
|
||||
)
|
||||
if is_main:
|
||||
log(f"saved -> {out_dir}")
|
||||
if use_wandb:
|
||||
@@ -329,8 +389,17 @@ def main() -> int:
|
||||
wandb.finish()
|
||||
except Exception:
|
||||
pass
|
||||
print("FSDP_SFT_DONE " + json.dumps({"variant": args.variant, "steps": gstep,
|
||||
"wall_s": round(time.time() - t0, 1), "out": str(out_dir)}))
|
||||
print(
|
||||
"FSDP_SFT_DONE "
|
||||
+ json.dumps(
|
||||
{
|
||||
"variant": args.variant,
|
||||
"steps": gstep,
|
||||
"wall_s": round(time.time() - t0, 1),
|
||||
"out": str(out_dir),
|
||||
}
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ the path we actually use) doesn't depend on it. ``build_example`` tokenizes one
|
||||
``conversations`` record and returns ``input_ids`` + ``labels`` with everything
|
||||
but the supervised assistant turns masked to -100.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -102,8 +103,9 @@ def build_example(
|
||||
if len(msgs) < 2 or msgs[-1]["role"] != "assistant":
|
||||
return None
|
||||
try:
|
||||
full_text = tok.apply_chat_template(msgs, tokenize=False,
|
||||
add_generation_prompt=False)
|
||||
full_text = tok.apply_chat_template(
|
||||
msgs, tokenize=False, add_generation_prompt=False
|
||||
)
|
||||
full = tok(full_text, add_special_tokens=False)["input_ids"]
|
||||
except Exception:
|
||||
return None
|
||||
@@ -114,7 +116,7 @@ def build_example(
|
||||
if spans:
|
||||
selected = spans if supervise_all_turns else spans[-1:]
|
||||
labels = [-100] * len(full)
|
||||
for (s, e) in selected:
|
||||
for s, e in selected:
|
||||
for k in range(s, e):
|
||||
labels[k] = full[k]
|
||||
last_start = selected[-1][0]
|
||||
@@ -122,8 +124,9 @@ def build_example(
|
||||
# Non-ChatML template: proven last-turn boundary via longest-common-prefix
|
||||
# (robust to templates whose add_generation_prompt emits extra preamble).
|
||||
try:
|
||||
prompt_text = tok.apply_chat_template(msgs[:-1], tokenize=False,
|
||||
add_generation_prompt=True)
|
||||
prompt_text = tok.apply_chat_template(
|
||||
msgs[:-1], tokenize=False, add_generation_prompt=True
|
||||
)
|
||||
prompt = tok(prompt_text, add_special_tokens=False)["input_ids"]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -34,6 +34,7 @@ Programmatic (used by the pipeline auto-upload hook in make_splits.py):
|
||||
autoupload(["data/orchestrator/sft/qwen_train_0707.jsonl=qwen_train_0707"],
|
||||
run_label="...")
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
@@ -82,6 +83,7 @@ def _dataset_desc(dataset):
|
||||
from openjarvis.learning.intelligence.orchestrator.sft_data.reject_sample import (
|
||||
dataset_description,
|
||||
)
|
||||
|
||||
return dataset_description(dataset)
|
||||
except Exception:
|
||||
return ""
|
||||
@@ -89,10 +91,16 @@ def _dataset_desc(dataset):
|
||||
|
||||
def _git_sha():
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
cwd=Path(__file__).resolve().parent, stderr=subprocess.DEVNULL,
|
||||
).decode().strip() or None
|
||||
return (
|
||||
subprocess.check_output(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
cwd=Path(__file__).resolve().parent,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
.decode()
|
||||
.strip()
|
||||
or None
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -113,7 +121,17 @@ def _model_short(rows, path):
|
||||
|
||||
def _split_of(path):
|
||||
stem = Path(path).stem.lower()
|
||||
for s in ("holdout", "train", "clean", "overfit", "partial", "8k", "4k", "2k", "1k"):
|
||||
for s in (
|
||||
"holdout",
|
||||
"train",
|
||||
"clean",
|
||||
"overfit",
|
||||
"partial",
|
||||
"8k",
|
||||
"4k",
|
||||
"2k",
|
||||
"1k",
|
||||
):
|
||||
if s in stem:
|
||||
return s
|
||||
return "data"
|
||||
@@ -129,8 +147,16 @@ def _config_from_env():
|
||||
for env_key, meta_key, cast in [
|
||||
("OJ_CFG_TEMPERATURE", "temperature", float),
|
||||
("OJ_CFG_MAX_TURNS", "max_turns", int),
|
||||
("OJ_CFG_ANONYMIZE", "anonymize", lambda v: v.strip().lower() in ("1", "true", "yes")),
|
||||
("OJ_CFG_REJECTION_ONLY", "rejection_only", lambda v: v.strip().lower() in ("1", "true", "yes")),
|
||||
(
|
||||
"OJ_CFG_ANONYMIZE",
|
||||
"anonymize",
|
||||
lambda v: v.strip().lower() in ("1", "true", "yes"),
|
||||
),
|
||||
(
|
||||
"OJ_CFG_REJECTION_ONLY",
|
||||
"rejection_only",
|
||||
lambda v: v.strip().lower() in ("1", "true", "yes"),
|
||||
),
|
||||
]:
|
||||
v = os.getenv(env_key)
|
||||
if v not in (None, ""):
|
||||
@@ -148,7 +174,7 @@ def _row(rec):
|
||||
asst = [c["content"] for c in convs if c["role"] == "assistant"]
|
||||
fa = asst[-1] if asst else ""
|
||||
m = list(_FA.finditer(fa))
|
||||
model_answer = fa[m[-1].end():].strip() if m else fa.strip()
|
||||
model_answer = fa[m[-1].end() :].strip() if m else fa.strip()
|
||||
# Gold reference the verifier graded against (stamped at generation time as
|
||||
# `gold_answer`). `expected` in Braintrust is the GOLD so the UI shows
|
||||
# gold-vs-model; the model's own answer goes to metadata.model_answer.
|
||||
@@ -158,7 +184,9 @@ def _row(rec):
|
||||
routed = []
|
||||
for c in convs:
|
||||
if c["role"] == "assistant":
|
||||
for t in re.findall(r"<tool_call>(\{.*?\})</tool_call>", c["content"], re.DOTALL):
|
||||
for t in re.findall(
|
||||
r"<tool_call>(\{.*?\})</tool_call>", c["content"], re.DOTALL
|
||||
):
|
||||
try:
|
||||
real = am.get(json.loads(t).get("name"))
|
||||
if real:
|
||||
@@ -171,14 +199,18 @@ def _row(rec):
|
||||
correct = rec.get("correct")
|
||||
clean = rec.get("clean")
|
||||
kept = rec.get("kept")
|
||||
tags = [t for t in [
|
||||
gen_model,
|
||||
f"domain:{domain}" if domain else None,
|
||||
"correct" if correct else "incorrect",
|
||||
"clean" if clean else "dirty",
|
||||
"kept" if kept else "dropped",
|
||||
"gold" if has_gold else "no_gold",
|
||||
] if t]
|
||||
tags = [
|
||||
t
|
||||
for t in [
|
||||
gen_model,
|
||||
f"domain:{domain}" if domain else None,
|
||||
"correct" if correct else "incorrect",
|
||||
"clean" if clean else "dirty",
|
||||
"kept" if kept else "dropped",
|
||||
"gold" if has_gold else "no_gold",
|
||||
]
|
||||
if t
|
||||
]
|
||||
return {
|
||||
"input": question,
|
||||
"expected": gold,
|
||||
@@ -191,7 +223,8 @@ def _row(rec):
|
||||
"area": rec.get("area"),
|
||||
"difficulty": rec.get("difficulty") or None,
|
||||
"dataset": rec.get("dataset"),
|
||||
"dataset_description": rec.get("dataset_description") or _dataset_desc(rec.get("dataset")),
|
||||
"dataset_description": rec.get("dataset_description")
|
||||
or _dataset_desc(rec.get("dataset")),
|
||||
"subsector": rec.get("subsector"),
|
||||
"task_id": rec.get("task_id"),
|
||||
"correct": correct,
|
||||
@@ -222,7 +255,9 @@ def _routed_dist(rows):
|
||||
for cc in r.get("conversations", []):
|
||||
if cc.get("role") != "assistant":
|
||||
continue
|
||||
for t in re.findall(r"<tool_call>(\{.*?\})</tool_call>", cc.get("content", ""), re.DOTALL):
|
||||
for t in re.findall(
|
||||
r"<tool_call>(\{.*?\})</tool_call>", cc.get("content", ""), re.DOTALL
|
||||
):
|
||||
try:
|
||||
real = am.get(json.loads(t).get("name"))
|
||||
if real:
|
||||
@@ -257,7 +292,7 @@ def _dataset_metadata(rows, path, run_label, gen_model):
|
||||
"source_datasets": ["GeneralThought-430K-filtered", "OpenThoughts3-1.2M"],
|
||||
"task_mix": "balanced (GeneralThought + OpenThoughts code/math/science)",
|
||||
"filter": "correct + clean (rejects file-write echoes, garble, "
|
||||
"reasoning-degeneration, truncated tails, essays/markdown dumps)",
|
||||
"reasoning-degeneration, truncated tails, essays/markdown dumps)",
|
||||
"leak_free": "train/holdout disjoint by task_id",
|
||||
"n_total": n,
|
||||
"n_correct": sum(1 for r in rows if r.get("correct")),
|
||||
@@ -277,8 +312,16 @@ def _dataset_metadata(rows, path, run_label, gen_model):
|
||||
return {k: v for k, v in meta.items() if v is not None}
|
||||
|
||||
|
||||
def upload_dataset(path, name=None, *, project_id=None, project=None,
|
||||
run_label=None, gen_model=None, description=""):
|
||||
def upload_dataset(
|
||||
path,
|
||||
name=None,
|
||||
*,
|
||||
project_id=None,
|
||||
project=None,
|
||||
run_label=None,
|
||||
gen_model=None,
|
||||
description="",
|
||||
):
|
||||
"""Upload one JSONL file as a Braintrust dataset. Returns (name, n_rows, url).
|
||||
|
||||
Target project: ``project`` (name) if given, else ``project_id`` /
|
||||
@@ -288,24 +331,33 @@ def upload_dataset(path, name=None, *, project_id=None, project=None,
|
||||
|
||||
rows = [json.loads(l) for l in open(path) if l.strip()]
|
||||
name = name or _default_name(rows, path)
|
||||
gen_model = gen_model or os.getenv("OJ_GEN_MODEL") or \
|
||||
next((r.get("gen_model") for r in rows if r.get("gen_model")), None)
|
||||
gen_model = (
|
||||
gen_model
|
||||
or os.getenv("OJ_GEN_MODEL")
|
||||
or next((r.get("gen_model") for r in rows if r.get("gen_model")), None)
|
||||
)
|
||||
run_label = run_label or os.getenv("OJ_RUN_LABEL") or name
|
||||
|
||||
init_kwargs = {"name": name, "description": description or None,
|
||||
"metadata": _dataset_metadata(rows, path, run_label, gen_model)}
|
||||
init_kwargs = {
|
||||
"name": name,
|
||||
"description": description or None,
|
||||
"metadata": _dataset_metadata(rows, path, run_label, gen_model),
|
||||
}
|
||||
if project:
|
||||
init_kwargs["project"] = project
|
||||
else:
|
||||
init_kwargs["project_id"] = project_id or os.getenv(
|
||||
"OJ_BRAINTRUST_PROJECT_ID", DEFAULT_PROJECT_ID)
|
||||
"OJ_BRAINTRUST_PROJECT_ID", DEFAULT_PROJECT_ID
|
||||
)
|
||||
|
||||
ds = braintrust.init_dataset(**init_kwargs)
|
||||
for r in rows:
|
||||
ds.insert(**_row(r))
|
||||
ds.flush()
|
||||
summ = ds.summarize()
|
||||
url = getattr(summ, "dataset_url", None) or (project or init_kwargs.get("project_id"))
|
||||
url = getattr(summ, "dataset_url", None) or (
|
||||
project or init_kwargs.get("project_id")
|
||||
)
|
||||
return name, len(rows), url
|
||||
|
||||
|
||||
@@ -313,7 +365,13 @@ def autoupload(specs, *, run_label=None, gen_model=None, description=""):
|
||||
"""No-op-safe wrapper for pipeline hooks. Honors OJ_BRAINTRUST_AUTOUPLOAD
|
||||
(default ON) and never raises: any failure (missing key/pkg, network) is
|
||||
logged and swallowed so the data pipeline is never broken by telemetry."""
|
||||
if os.getenv("OJ_BRAINTRUST_AUTOUPLOAD", "1").strip().lower() in ("0", "false", "no", "off", ""):
|
||||
if os.getenv("OJ_BRAINTRUST_AUTOUPLOAD", "1").strip().lower() in (
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
"off",
|
||||
"",
|
||||
):
|
||||
logger.info("[braintrust] autoupload disabled (OJ_BRAINTRUST_AUTOUPLOAD)")
|
||||
return
|
||||
if not os.getenv("BRAINTRUST_API_KEY"):
|
||||
@@ -327,22 +385,37 @@ def autoupload(specs, *, run_label=None, gen_model=None, description=""):
|
||||
for spec in specs:
|
||||
path, _, name = spec.partition("=")
|
||||
try:
|
||||
nm, n, url = upload_dataset(path, name or None, run_label=run_label,
|
||||
gen_model=gen_model, description=description)
|
||||
nm, n, url = upload_dataset(
|
||||
path,
|
||||
name or None,
|
||||
run_label=run_label,
|
||||
gen_model=gen_model,
|
||||
description=description,
|
||||
)
|
||||
logger.info("[braintrust] uploaded %s: %d rows -> %s", nm, n, url)
|
||||
print(f"[braintrust] uploaded {nm}: {n} rows -> {url}")
|
||||
except Exception as exc:
|
||||
logger.warning("[braintrust] autoupload FAILED for %s (%s) — continuing", path, exc)
|
||||
print(f"[braintrust] autoupload FAILED for {path} ({exc}) — pipeline unaffected")
|
||||
logger.warning(
|
||||
"[braintrust] autoupload FAILED for %s (%s) — continuing", path, exc
|
||||
)
|
||||
print(
|
||||
f"[braintrust] autoupload FAILED for {path} ({exc}) — pipeline unaffected"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--project", default=None,
|
||||
help="Target project by NAME (overrides OJ_BRAINTRUST_PROJECT_ID).")
|
||||
p.add_argument("--project-id", default=None,
|
||||
help="Target project by id (default: OJ_BRAINTRUST_PROJECT_ID or research).")
|
||||
p.add_argument(
|
||||
"--project",
|
||||
default=None,
|
||||
help="Target project by NAME (overrides OJ_BRAINTRUST_PROJECT_ID).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--project-id",
|
||||
default=None,
|
||||
help="Target project by id (default: OJ_BRAINTRUST_PROJECT_ID or research).",
|
||||
)
|
||||
p.add_argument("--run-label", default=None)
|
||||
p.add_argument("--gen-model", default=None, help="Specific gen model id override.")
|
||||
p.add_argument("--description", default="")
|
||||
@@ -351,9 +424,14 @@ def main():
|
||||
for spec in args.specs:
|
||||
path, _, name = spec.partition("=")
|
||||
nm, n, url = upload_dataset(
|
||||
path, name or None, project_id=args.project_id, project=args.project,
|
||||
run_label=args.run_label, gen_model=args.gen_model,
|
||||
description=args.description)
|
||||
path,
|
||||
name or None,
|
||||
project_id=args.project_id,
|
||||
project=args.project,
|
||||
run_label=args.run_label,
|
||||
gen_model=args.gen_model,
|
||||
description=args.description,
|
||||
)
|
||||
print(f"[braintrust] {nm}: {n} rows -> {url}")
|
||||
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ PRICES: dict[str, tuple[float, float]] = {
|
||||
# OpenRouter instead of self-hosted vLLM. ESTIMATES — no public list price
|
||||
# exists yet for these Qwen3.5/3.6 builds; scaled by active-param size.
|
||||
# VERIFY against openrouter.ai before trusting the cost-aware reward numbers.
|
||||
"qwen/qwen3.5-9b": (0.05, 0.10),
|
||||
"qwen/qwen3.6-27b": (0.10, 0.30),
|
||||
"qwen/qwen3.5-9b": (0.05, 0.10),
|
||||
"qwen/qwen3.6-27b": (0.10, 0.30),
|
||||
"qwen/qwen3.5-122b-a10b": (0.20, 0.60),
|
||||
"qwen/qwen3.5-397b-a17b": (0.40, 1.20),
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from openjarvis.agents.hybrid._prices import PRICES
|
||||
@@ -57,8 +57,14 @@ CATEGORY_SPECIALIZED = "specialized_model"
|
||||
# ``openjarvis-tool`` bridge, dispatched in ``unified.make_dispatch`` via the
|
||||
# OpenJarvis ToolExecutor rather than ``_call_worker``).
|
||||
VALID_BACKENDS = (
|
||||
"vllm", "openai", "anthropic", "gemini", "openrouter",
|
||||
"anthropic-web-search", "tavily-search", "modal-python",
|
||||
"vllm",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"openrouter",
|
||||
"anthropic-web-search",
|
||||
"tavily-search",
|
||||
"modal-python",
|
||||
"openjarvis-tool",
|
||||
)
|
||||
|
||||
@@ -268,12 +274,22 @@ def anonymize_tools(tools, rng):
|
||||
while tag in anon_to_real:
|
||||
tag = "model_" + "".join(rng.choice(_ANON_ALPHABET) for _ in range(4))
|
||||
anon_to_real[tag] = t.name
|
||||
bits = [b for b in (_class_hint(t), _size_hint(t.model), _cost_tier_hint(t)) if b]
|
||||
experts.append(replace(
|
||||
t, name=tag,
|
||||
summary="Another model — " + ", ".join(bits) + ". Send it a sub-question.",
|
||||
category="model", hide_cost=True,
|
||||
))
|
||||
bits = [
|
||||
b
|
||||
for b in (_class_hint(t), _size_hint(t.model), _cost_tier_hint(t))
|
||||
if b
|
||||
]
|
||||
experts.append(
|
||||
replace(
|
||||
t,
|
||||
name=tag,
|
||||
summary="Another model — "
|
||||
+ ", ".join(bits)
|
||||
+ ". Send it a sub-question.",
|
||||
category="model",
|
||||
hide_cost=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
basics.append(t)
|
||||
# Shuffle WITHIN the experts to kill per-expert position bias, but keep all
|
||||
@@ -300,70 +316,135 @@ def default_catalog(
|
||||
|
||||
# ---- generalist / frontier models (one named tool per model VERSION) ----
|
||||
for model, summary, lat in [
|
||||
("gpt-5",
|
||||
"Frontier generalist (GPT-5). Strongest reasoning across domains.", 30.0),
|
||||
("gpt-5-mini",
|
||||
"Mid-tier generalist (GPT-5-mini). Solid reasoning, much cheaper.", 15.0),
|
||||
("gpt-4o",
|
||||
"Fast generalist (GPT-4o). Good for simple steps and formatting.", 8.0),
|
||||
("claude-opus-4-7",
|
||||
"Frontier generalist (Claude Opus 4.7). Strong long-horizon reasoning.", 26.0),
|
||||
("claude-sonnet-4-6",
|
||||
"Strong generalist (Claude Sonnet 4.6). Balanced cost/capability.", 15.0),
|
||||
("gemini-2.5-pro",
|
||||
"Frontier generalist (Gemini 2.5 Pro). Strong multimodal reasoning.", 20.0),
|
||||
("gemini-2.5-flash",
|
||||
"Cheap fast generalist (Gemini 2.5 Flash).", 8.0),
|
||||
("meta-llama/llama-3.3-70b-instruct",
|
||||
"Open generalist (Llama-3.3-70B). Decent general knowledge, low cost.", 10.0),
|
||||
("qwen/qwen3-32b",
|
||||
"Open generalist (Qwen3-32B). Strong math/science reasoning, low cost.", 9.0),
|
||||
(
|
||||
"gpt-5",
|
||||
"Frontier generalist (GPT-5). Strongest reasoning across domains.",
|
||||
30.0,
|
||||
),
|
||||
(
|
||||
"gpt-5-mini",
|
||||
"Mid-tier generalist (GPT-5-mini). Solid reasoning, much cheaper.",
|
||||
15.0,
|
||||
),
|
||||
(
|
||||
"gpt-4o",
|
||||
"Fast generalist (GPT-4o). Good for simple steps and formatting.",
|
||||
8.0,
|
||||
),
|
||||
(
|
||||
"claude-opus-4-7",
|
||||
"Frontier generalist (Claude Opus 4.7). Strong long-horizon reasoning.",
|
||||
26.0,
|
||||
),
|
||||
(
|
||||
"claude-sonnet-4-6",
|
||||
"Strong generalist (Claude Sonnet 4.6). Balanced cost/capability.",
|
||||
15.0,
|
||||
),
|
||||
(
|
||||
"gemini-2.5-pro",
|
||||
"Frontier generalist (Gemini 2.5 Pro). Strong multimodal reasoning.",
|
||||
20.0,
|
||||
),
|
||||
("gemini-2.5-flash", "Cheap fast generalist (Gemini 2.5 Flash).", 8.0),
|
||||
(
|
||||
"meta-llama/llama-3.3-70b-instruct",
|
||||
"Open generalist (Llama-3.3-70B). Decent general knowledge, low cost.",
|
||||
10.0,
|
||||
),
|
||||
(
|
||||
"qwen/qwen3-32b",
|
||||
"Open generalist (Qwen3-32B). Strong math/science reasoning, low cost.",
|
||||
9.0,
|
||||
),
|
||||
]:
|
||||
ep = ("openai" if model.startswith("gpt") else
|
||||
"anthropic" if model.startswith("claude") else
|
||||
"gemini" if model.startswith("gemini") else "openrouter")
|
||||
ep = (
|
||||
"openai"
|
||||
if model.startswith("gpt")
|
||||
else "anthropic"
|
||||
if model.startswith("claude")
|
||||
else "gemini"
|
||||
if model.startswith("gemini")
|
||||
else "openrouter"
|
||||
)
|
||||
pi, po = _price(model)
|
||||
cat.append(ExpertTool(
|
||||
name=_tool_name(model), kind=KIND_MODEL, backend_type=ep, summary=summary,
|
||||
model=model, price_in=pi, price_out=po, latency_s=lat,
|
||||
category=CATEGORY_GENERALIST,
|
||||
))
|
||||
cat.append(
|
||||
ExpertTool(
|
||||
name=_tool_name(model),
|
||||
kind=KIND_MODEL,
|
||||
backend_type=ep,
|
||||
summary=summary,
|
||||
model=model,
|
||||
price_in=pi,
|
||||
price_out=po,
|
||||
latency_s=lat,
|
||||
category=CATEGORY_GENERALIST,
|
||||
)
|
||||
)
|
||||
|
||||
# ---- specialized: code ----
|
||||
coder = "qwen/qwen-2.5-coder-32b-instruct"
|
||||
pi, po = _price(coder)
|
||||
cat.append(ExpertTool(
|
||||
name=_tool_name(coder), kind=KIND_MODEL, backend_type="openrouter",
|
||||
summary="Specialized code model (Qwen2.5-Coder-32B). Writes/debugs code.",
|
||||
model=coder, price_in=pi, price_out=po, latency_s=9.0,
|
||||
category=CATEGORY_SPECIALIZED,
|
||||
))
|
||||
cat.append(
|
||||
ExpertTool(
|
||||
name=_tool_name(coder),
|
||||
kind=KIND_MODEL,
|
||||
backend_type="openrouter",
|
||||
summary="Specialized code model (Qwen2.5-Coder-32B). Writes/debugs code.",
|
||||
model=coder,
|
||||
price_in=pi,
|
||||
price_out=po,
|
||||
latency_s=9.0,
|
||||
category=CATEGORY_SPECIALIZED,
|
||||
)
|
||||
)
|
||||
|
||||
# ---- local backbone as a tool (on-device vLLM), if served ----
|
||||
# Named after the actual served model (faithful "one named tool per model"),
|
||||
# not a generic "local_model" — e.g. "qwen3-8b" -> tool "qwen3_8b".
|
||||
if local_model and local_endpoint:
|
||||
cat.append(ExpertTool(
|
||||
name=_tool_name(local_model), kind=KIND_MODEL, backend_type="vllm",
|
||||
summary=(f"On-device open model ({local_model}) served locally. Cheap "
|
||||
"and private; good for extraction, formatting, arithmetic on "
|
||||
"given data."),
|
||||
model=local_model, base_url=local_endpoint,
|
||||
price_in=0.0, price_out=0.0, latency_s=2.0,
|
||||
category=CATEGORY_GENERALIST,
|
||||
))
|
||||
cat.append(
|
||||
ExpertTool(
|
||||
name=_tool_name(local_model),
|
||||
kind=KIND_MODEL,
|
||||
backend_type="vllm",
|
||||
summary=(
|
||||
f"On-device open model ({local_model}) served locally. Cheap "
|
||||
"and private; good for extraction, formatting, arithmetic on "
|
||||
"given data."
|
||||
),
|
||||
model=local_model,
|
||||
base_url=local_endpoint,
|
||||
price_in=0.0,
|
||||
price_out=0.0,
|
||||
latency_s=2.0,
|
||||
category=CATEGORY_GENERALIST,
|
||||
)
|
||||
)
|
||||
|
||||
# ---- basic tools ----
|
||||
cat.append(ExpertTool(
|
||||
name="web_search", kind=KIND_WEB_SEARCH, backend_type="tavily-search",
|
||||
summary="Web search (Tavily). Use for facts that need a live lookup.",
|
||||
model="tavily", latency_s=8.0, category=CATEGORY_BASIC,
|
||||
))
|
||||
cat.append(ExpertTool(
|
||||
name="code_interpreter", kind=KIND_CODE, backend_type="modal-python",
|
||||
summary="Python sandbox. Execute code and return stdout/stderr.",
|
||||
model="modal-python", latency_s=6.0, category=CATEGORY_BASIC,
|
||||
))
|
||||
cat.append(
|
||||
ExpertTool(
|
||||
name="web_search",
|
||||
kind=KIND_WEB_SEARCH,
|
||||
backend_type="tavily-search",
|
||||
summary="Web search (Tavily). Use for facts that need a live lookup.",
|
||||
model="tavily",
|
||||
latency_s=8.0,
|
||||
category=CATEGORY_BASIC,
|
||||
)
|
||||
)
|
||||
cat.append(
|
||||
ExpertTool(
|
||||
name="code_interpreter",
|
||||
kind=KIND_CODE,
|
||||
backend_type="modal-python",
|
||||
summary="Python sandbox. Execute code and return stdout/stderr.",
|
||||
model="modal-python",
|
||||
latency_s=6.0,
|
||||
category=CATEGORY_BASIC,
|
||||
)
|
||||
)
|
||||
|
||||
return cat
|
||||
|
||||
@@ -407,19 +488,29 @@ def _openjarvis_basic_tools() -> List[ExpertTool]:
|
||||
"calculator",
|
||||
summary="Evaluate an arithmetic / math expression and return the result.",
|
||||
params=obj(
|
||||
{"expression": {"type": "string",
|
||||
"description": "Math expression to evaluate."}},
|
||||
{
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "Math expression to evaluate.",
|
||||
}
|
||||
},
|
||||
["expression"],
|
||||
),
|
||||
latency_s=1.0,
|
||||
),
|
||||
openjarvis_tool(
|
||||
"shell_exec",
|
||||
summary=("Run a shell command and return its stdout/stderr. Critical "
|
||||
"for terminal / TerminalBench-style tasks."),
|
||||
summary=(
|
||||
"Run a shell command and return its stdout/stderr. Critical "
|
||||
"for terminal / TerminalBench-style tasks."
|
||||
),
|
||||
params=obj(
|
||||
{"command": {"type": "string",
|
||||
"description": "Shell command to execute."}},
|
||||
{
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Shell command to execute.",
|
||||
}
|
||||
},
|
||||
["command"],
|
||||
),
|
||||
latency_s=4.0,
|
||||
@@ -428,7 +519,12 @@ def _openjarvis_basic_tools() -> List[ExpertTool]:
|
||||
"file_read",
|
||||
summary="Read the contents of a file at the given path.",
|
||||
params=obj(
|
||||
{"path": {"type": "string", "description": "Path of the file to read."}},
|
||||
{
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path of the file to read.",
|
||||
}
|
||||
},
|
||||
["path"],
|
||||
),
|
||||
latency_s=1.0,
|
||||
@@ -437,8 +533,13 @@ def _openjarvis_basic_tools() -> List[ExpertTool]:
|
||||
"file_write",
|
||||
summary="Write content to a file at the given path.",
|
||||
params=obj(
|
||||
{"path": {"type": "string", "description": "Path of the file to write."},
|
||||
"content": {"type": "string", "description": "Content to write."}},
|
||||
{
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path of the file to write.",
|
||||
},
|
||||
"content": {"type": "string", "description": "Content to write."},
|
||||
},
|
||||
["path", "content"],
|
||||
),
|
||||
latency_s=1.0,
|
||||
@@ -450,8 +551,10 @@ def _openjarvis_basic_tools() -> List[ExpertTool]:
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "Request URL."},
|
||||
"method": {"type": "string",
|
||||
"description": "HTTP method (GET, POST, ...). Default GET."},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"description": "HTTP method (GET, POST, ...). Default GET.",
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
@@ -459,56 +562,85 @@ def _openjarvis_basic_tools() -> List[ExpertTool]:
|
||||
),
|
||||
openjarvis_tool(
|
||||
"think",
|
||||
summary=("Record a private reasoning step (scratchpad). No external "
|
||||
"effect; use to plan before acting on hard reasoning tasks."),
|
||||
summary=(
|
||||
"Record a private reasoning step (scratchpad). No external "
|
||||
"effect; use to plan before acting on hard reasoning tasks."
|
||||
),
|
||||
params=obj(
|
||||
{"thought": {"type": "string",
|
||||
"description": "Your reasoning or thought process."}},
|
||||
{
|
||||
"thought": {
|
||||
"type": "string",
|
||||
"description": "Your reasoning or thought process.",
|
||||
}
|
||||
},
|
||||
["thought"],
|
||||
),
|
||||
latency_s=0.5,
|
||||
),
|
||||
openjarvis_tool(
|
||||
"apply_patch",
|
||||
summary=("Apply a unified-diff patch to a file. Use to edit code for "
|
||||
"terminal / SWE-style tasks."),
|
||||
summary=(
|
||||
"Apply a unified-diff patch to a file. Use to edit code for "
|
||||
"terminal / SWE-style tasks."
|
||||
),
|
||||
params=obj(
|
||||
{"patch": {"type": "string",
|
||||
"description": "The unified diff patch text to apply."},
|
||||
"path": {"type": "string",
|
||||
"description": "Target file path (auto-detected from the "
|
||||
"patch header if omitted)."}},
|
||||
{
|
||||
"patch": {
|
||||
"type": "string",
|
||||
"description": "The unified diff patch text to apply.",
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Target file path (auto-detected from the "
|
||||
"patch header if omitted).",
|
||||
},
|
||||
},
|
||||
["patch"],
|
||||
),
|
||||
latency_s=2.0,
|
||||
),
|
||||
openjarvis_tool(
|
||||
"pdf_extract",
|
||||
summary=("Extract text from a PDF file. Use for GAIA-style tasks with "
|
||||
"PDF attachments."),
|
||||
summary=(
|
||||
"Extract text from a PDF file. Use for GAIA-style tasks with "
|
||||
"PDF attachments."
|
||||
),
|
||||
params=obj(
|
||||
{"file_path": {"type": "string",
|
||||
"description": "Path to the PDF file."},
|
||||
"pages": {"type": "string",
|
||||
"description": "Page range, e.g. '1-5' or '1,3,5'. "
|
||||
"Omit for all pages."}},
|
||||
{
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the PDF file.",
|
||||
},
|
||||
"pages": {
|
||||
"type": "string",
|
||||
"description": "Page range, e.g. '1-5' or '1,3,5'. "
|
||||
"Omit for all pages.",
|
||||
},
|
||||
},
|
||||
["file_path"],
|
||||
),
|
||||
latency_s=3.0,
|
||||
),
|
||||
openjarvis_tool(
|
||||
"db_query",
|
||||
summary=("Run a SQL query against a SQLite/Postgres database and return "
|
||||
"rows. Read-only by default."),
|
||||
summary=(
|
||||
"Run a SQL query against a SQLite/Postgres database and return "
|
||||
"rows. Read-only by default."
|
||||
),
|
||||
params=obj(
|
||||
{"query": {"type": "string",
|
||||
"description": "SQL query to execute."},
|
||||
"db_path": {"type": "string",
|
||||
"description": "Path to a SQLite DB file. Defaults to "
|
||||
"in-memory."},
|
||||
"read_only": {"type": "boolean",
|
||||
"description": "Restrict to SELECT/EXPLAIN/PRAGMA. "
|
||||
"Default: true."}},
|
||||
{
|
||||
"query": {"type": "string", "description": "SQL query to execute."},
|
||||
"db_path": {
|
||||
"type": "string",
|
||||
"description": "Path to a SQLite DB file. Defaults to "
|
||||
"in-memory.",
|
||||
},
|
||||
"read_only": {
|
||||
"type": "boolean",
|
||||
"description": "Restrict to SELECT/EXPLAIN/PRAGMA. "
|
||||
"Default: true.",
|
||||
},
|
||||
},
|
||||
["query"],
|
||||
),
|
||||
latency_s=3.0,
|
||||
@@ -540,10 +672,14 @@ _CLOUD_FRONTIER_MODELS = (
|
||||
# Neutral, uniform summaries (no capability ranking) so the orchestrator
|
||||
# doesn't just pick whichever model is labelled "strongest" — routing should
|
||||
# be learned from the reward, not hand-labelled here.
|
||||
("gpt-5.5", "openai", "openai/gpt-5.5",
|
||||
"Expert model (GPT-5.5).", 30.0),
|
||||
("claude-opus-4-8", "anthropic", "anthropic/claude-opus-4.8",
|
||||
"Expert model (Claude Opus 4.8).", 26.0),
|
||||
("gpt-5.5", "openai", "openai/gpt-5.5", "Expert model (GPT-5.5).", 30.0),
|
||||
(
|
||||
"claude-opus-4-8",
|
||||
"anthropic",
|
||||
"anthropic/claude-opus-4.8",
|
||||
"Expert model (Claude Opus 4.8).",
|
||||
26.0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -570,16 +706,26 @@ def _model_tool(
|
||||
backend = (
|
||||
model_backends.get(canonical)
|
||||
or ("vllm" if canonical in local_endpoints else None)
|
||||
or (native_backend if native_backend in ("openai", "anthropic", "gemini")
|
||||
else "openrouter")
|
||||
or (
|
||||
native_backend
|
||||
if native_backend in ("openai", "anthropic", "gemini")
|
||||
else "openrouter"
|
||||
)
|
||||
)
|
||||
name = _tool_name(canonical)
|
||||
if backend == "vllm":
|
||||
# Self-hosted: free per the cost model.
|
||||
return ExpertTool(
|
||||
name=name, kind=KIND_MODEL, backend_type="vllm", summary=summary,
|
||||
model=canonical, base_url=local_endpoints.get(canonical),
|
||||
price_in=0.0, price_out=0.0, latency_s=lat, category=category,
|
||||
name=name,
|
||||
kind=KIND_MODEL,
|
||||
backend_type="vllm",
|
||||
summary=summary,
|
||||
model=canonical,
|
||||
base_url=local_endpoints.get(canonical),
|
||||
price_in=0.0,
|
||||
price_out=0.0,
|
||||
latency_s=lat,
|
||||
category=category,
|
||||
)
|
||||
if backend == "openrouter":
|
||||
slug = openrouter_slugs.get(canonical, or_slug)
|
||||
@@ -587,15 +733,29 @@ def _model_tool(
|
||||
if (pi, po) == (0.0, 0.0): # fall back to the canonical id's price
|
||||
pi, po = _price(canonical)
|
||||
return ExpertTool(
|
||||
name=name, kind=KIND_MODEL, backend_type="openrouter", summary=summary,
|
||||
model=slug, base_url=None, price_in=pi, price_out=po,
|
||||
latency_s=lat, category=category,
|
||||
name=name,
|
||||
kind=KIND_MODEL,
|
||||
backend_type="openrouter",
|
||||
summary=summary,
|
||||
model=slug,
|
||||
base_url=None,
|
||||
price_in=pi,
|
||||
price_out=po,
|
||||
latency_s=lat,
|
||||
category=category,
|
||||
)
|
||||
# native provider API (openai / anthropic / gemini)
|
||||
pi, po = _price(canonical)
|
||||
return ExpertTool(
|
||||
name=name, kind=KIND_MODEL, backend_type=backend, summary=summary,
|
||||
model=canonical, price_in=pi, price_out=po, latency_s=lat, category=category,
|
||||
name=name,
|
||||
kind=KIND_MODEL,
|
||||
backend_type=backend,
|
||||
summary=summary,
|
||||
model=canonical,
|
||||
price_in=pi,
|
||||
price_out=po,
|
||||
latency_s=lat,
|
||||
category=category,
|
||||
)
|
||||
|
||||
|
||||
@@ -633,7 +793,12 @@ def orchestrator_catalog(
|
||||
# match is skipped from the catalog. Used to temporarily drop unreliable
|
||||
# experts (e.g. OpenRouter giants during a provider outage) without editing
|
||||
# the registry — unset the var to restore them.
|
||||
_excl = {s.strip().lower() for s in os.environ.get("OJ_EXCLUDE_EXPERTS", "").split(",") if s.strip()}
|
||||
_excl = {
|
||||
s.strip().lower()
|
||||
for s in os.environ.get("OJ_EXCLUDE_EXPERTS", "").split(",")
|
||||
if s.strip()
|
||||
}
|
||||
|
||||
def _excluded(canonical: str) -> bool:
|
||||
c = canonical.lower()
|
||||
return any(x in c for x in _excl)
|
||||
@@ -642,38 +807,63 @@ def orchestrator_catalog(
|
||||
for canonical, native_backend, or_slug, summary, lat in _CLOUD_FRONTIER_MODELS:
|
||||
if _excluded(canonical):
|
||||
continue
|
||||
cat.append(_model_tool(
|
||||
canonical, native_backend=native_backend, or_slug=or_slug,
|
||||
summary=summary, lat=lat, category=CATEGORY_CLOUD_FRONTIER,
|
||||
local_endpoints=local_endpoints, model_backends=model_backends,
|
||||
openrouter_slugs=openrouter_slugs,
|
||||
))
|
||||
cat.append(
|
||||
_model_tool(
|
||||
canonical,
|
||||
native_backend=native_backend,
|
||||
or_slug=or_slug,
|
||||
summary=summary,
|
||||
lat=lat,
|
||||
category=CATEGORY_CLOUD_FRONTIER,
|
||||
local_endpoints=local_endpoints,
|
||||
model_backends=model_backends,
|
||||
openrouter_slugs=openrouter_slugs,
|
||||
)
|
||||
)
|
||||
|
||||
# ---- open-source models (OpenRouter by default; vLLM when an endpoint or
|
||||
# a model_backends override is supplied) ----
|
||||
for canonical, or_slug in _LOCAL_OSS_MODELS:
|
||||
if _excluded(canonical):
|
||||
continue
|
||||
cat.append(_model_tool(
|
||||
canonical, native_backend="vllm", or_slug=or_slug,
|
||||
summary=f"Expert model ({canonical}).",
|
||||
lat=4.0, category=CATEGORY_LOCAL_OSS,
|
||||
local_endpoints=local_endpoints, model_backends=model_backends,
|
||||
openrouter_slugs=openrouter_slugs,
|
||||
))
|
||||
cat.append(
|
||||
_model_tool(
|
||||
canonical,
|
||||
native_backend="vllm",
|
||||
or_slug=or_slug,
|
||||
summary=f"Expert model ({canonical}).",
|
||||
lat=4.0,
|
||||
category=CATEGORY_LOCAL_OSS,
|
||||
local_endpoints=local_endpoints,
|
||||
model_backends=model_backends,
|
||||
openrouter_slugs=openrouter_slugs,
|
||||
)
|
||||
)
|
||||
|
||||
if include_tools:
|
||||
# ---- basic tools ----
|
||||
cat.append(ExpertTool(
|
||||
name="web_search", kind=KIND_WEB_SEARCH, backend_type="tavily-search",
|
||||
summary="Web search (Tavily). Use for facts that need a live lookup.",
|
||||
model="tavily", latency_s=8.0, category=CATEGORY_BASIC,
|
||||
))
|
||||
cat.append(ExpertTool(
|
||||
name="code_interpreter", kind=KIND_CODE, backend_type="modal-python",
|
||||
summary="Python sandbox. Execute code and return stdout/stderr.",
|
||||
model="modal-python", latency_s=6.0, category=CATEGORY_BASIC,
|
||||
))
|
||||
cat.append(
|
||||
ExpertTool(
|
||||
name="web_search",
|
||||
kind=KIND_WEB_SEARCH,
|
||||
backend_type="tavily-search",
|
||||
summary="Web search (Tavily). Use for facts that need a live lookup.",
|
||||
model="tavily",
|
||||
latency_s=8.0,
|
||||
category=CATEGORY_BASIC,
|
||||
)
|
||||
)
|
||||
cat.append(
|
||||
ExpertTool(
|
||||
name="code_interpreter",
|
||||
kind=KIND_CODE,
|
||||
backend_type="modal-python",
|
||||
summary="Python sandbox. Execute code and return stdout/stderr.",
|
||||
model="modal-python",
|
||||
latency_s=6.0,
|
||||
category=CATEGORY_BASIC,
|
||||
)
|
||||
)
|
||||
cat.extend(_openjarvis_basic_tools())
|
||||
|
||||
return cat
|
||||
@@ -736,13 +926,19 @@ def sample_tool_config(
|
||||
for t in chosen:
|
||||
if t.kind == KIND_MODEL and (t.price_in or t.price_out):
|
||||
f = rng.uniform(1.0 - price_jitter, 1.0 + price_jitter)
|
||||
jittered.append(ExpertTool(
|
||||
name=t.name, kind=t.kind, backend_type=t.backend_type,
|
||||
summary=t.summary, model=t.model, base_url=t.base_url,
|
||||
price_in=round(t.price_in * f, 4),
|
||||
price_out=round(t.price_out * f, 4),
|
||||
latency_s=t.latency_s,
|
||||
))
|
||||
jittered.append(
|
||||
ExpertTool(
|
||||
name=t.name,
|
||||
kind=t.kind,
|
||||
backend_type=t.backend_type,
|
||||
summary=t.summary,
|
||||
model=t.model,
|
||||
base_url=t.base_url,
|
||||
price_in=round(t.price_in * f, 4),
|
||||
price_out=round(t.price_out * f, 4),
|
||||
latency_s=t.latency_s,
|
||||
)
|
||||
)
|
||||
else:
|
||||
jittered.append(t)
|
||||
return jittered
|
||||
|
||||
@@ -47,10 +47,8 @@ Qwen if vLLM up, plus a web-search tool via Anthropic, Opus 4.7,
|
||||
gpt-5-mini).
|
||||
"""
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -62,41 +60,40 @@ from openjarvis.agents.hybrid._prices import PRICES
|
||||
from openjarvis.agents.hybrid.mini_swe_agent import (
|
||||
_clone_repo,
|
||||
_extract_diff,
|
||||
run_swe_agent_loop,
|
||||
)
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
from openjarvis.agents.hybrid.toolorchestra.prompts import (
|
||||
FORCE_FINAL_PROMPT,
|
||||
ORCHESTRATOR_SYS,
|
||||
RL_ALL_TOOLS,
|
||||
RL_ORCHESTRATOR_SYS,
|
||||
RL_TOOLS_SPEC,
|
||||
from openjarvis.agents.hybrid.toolorchestra.clients import (
|
||||
_call_orchestrator_with_tool_calls,
|
||||
)
|
||||
from openjarvis.agents.hybrid.toolorchestra.experts import (
|
||||
_PAPER_CODER_OPENROUTER,
|
||||
_expert_for,
|
||||
_paper_expert_for,
|
||||
)
|
||||
from openjarvis.agents.hybrid.toolorchestra.sandbox import (
|
||||
_call_modal_python,
|
||||
_extract_first_python_block,
|
||||
)
|
||||
from openjarvis.agents.hybrid.toolorchestra.clients import (
|
||||
_call_orchestrator_with_tool_calls,
|
||||
)
|
||||
from openjarvis.agents.hybrid.toolorchestra.parsing import (
|
||||
_build_user_prompt,
|
||||
_extract_final_answer_text,
|
||||
_parse_action,
|
||||
_parse_rl_tool_call,
|
||||
)
|
||||
from openjarvis.agents.hybrid.toolorchestra.prompts import (
|
||||
FORCE_FINAL_PROMPT,
|
||||
ORCHESTRATOR_SYS,
|
||||
RL_ALL_TOOLS,
|
||||
RL_ORCHESTRATOR_SYS,
|
||||
RL_TOOLS_SPEC,
|
||||
)
|
||||
from openjarvis.agents.hybrid.toolorchestra.sandbox import (
|
||||
_call_modal_python,
|
||||
_extract_first_python_block,
|
||||
)
|
||||
from openjarvis.agents.hybrid.toolorchestra.workers import (
|
||||
_TOOLORCH_SEARCH_TYPES,
|
||||
_call_worker,
|
||||
_default_pool,
|
||||
_resolve_worker_pool,
|
||||
_swe_call_worker,
|
||||
)
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
|
||||
@AgentRegistry.register("toolorchestra")
|
||||
class ToolOrchestraAgent(LocalCloudAgent):
|
||||
@@ -182,20 +179,24 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
)
|
||||
shared_workdir: Optional[Path] = None
|
||||
if swe_mode:
|
||||
shared_workdir = Path(tempfile.mkdtemp(
|
||||
prefix=f"toolorch-swe-{task_meta.get('task_id','x')}-"
|
||||
))
|
||||
shared_workdir = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f"toolorch-swe-{task_meta.get('task_id', 'x')}-"
|
||||
)
|
||||
)
|
||||
try:
|
||||
_clone_repo(task_meta["repo"], task_meta["base_commit"], shared_workdir)
|
||||
except Exception:
|
||||
shutil.rmtree(shared_workdir, ignore_errors=True)
|
||||
raise
|
||||
self.record_trace_event({
|
||||
"kind": "toolorchestra_swe_workdir",
|
||||
"workdir": str(shared_workdir),
|
||||
"repo": task_meta["repo"],
|
||||
"base_commit": task_meta["base_commit"],
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "toolorchestra_swe_workdir",
|
||||
"workdir": str(shared_workdir),
|
||||
"repo": task_meta["repo"],
|
||||
"base_commit": task_meta["base_commit"],
|
||||
}
|
||||
)
|
||||
|
||||
# try/finally guards ``shared_workdir`` against exceptions raised
|
||||
# anywhere in the turn loop, the worker calls, the fallback, or
|
||||
@@ -233,15 +234,22 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
cost += self.cost_usd(self._cloud_model, o_in, o_out)
|
||||
|
||||
action = _parse_action(text)
|
||||
history.append({
|
||||
"role": "orchestrator", "turn": turn, "raw": text, "action": action,
|
||||
})
|
||||
self.record_trace_event({
|
||||
"kind": "toolorchestra_action",
|
||||
"turn": turn,
|
||||
"action": action,
|
||||
"raw": text,
|
||||
})
|
||||
history.append(
|
||||
{
|
||||
"role": "orchestrator",
|
||||
"turn": turn,
|
||||
"raw": text,
|
||||
"action": action,
|
||||
}
|
||||
)
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "toolorchestra_action",
|
||||
"turn": turn,
|
||||
"action": action,
|
||||
"raw": text,
|
||||
}
|
||||
)
|
||||
|
||||
if action is None:
|
||||
parse_failures += 1
|
||||
@@ -265,12 +273,21 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
continue
|
||||
worker = workers[wid]
|
||||
if swe_mode and shared_workdir is not None:
|
||||
(w_text, w_in, w_out, is_local, extra_cost,
|
||||
n_searches, bash_turns) = (
|
||||
_swe_call_worker(
|
||||
worker, str(w_input), cfg, task_meta,
|
||||
shared_workdir, turn,
|
||||
)
|
||||
(
|
||||
w_text,
|
||||
w_in,
|
||||
w_out,
|
||||
is_local,
|
||||
extra_cost,
|
||||
n_searches,
|
||||
bash_turns,
|
||||
) = _swe_call_worker(
|
||||
worker,
|
||||
str(w_input),
|
||||
cfg,
|
||||
task_meta,
|
||||
shared_workdir,
|
||||
turn,
|
||||
)
|
||||
tool_calls += bash_turns
|
||||
else:
|
||||
@@ -284,17 +301,19 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
cost += self.cost_usd(worker["model"], w_in, w_out) + extra_cost
|
||||
n_web_searches_total += n_searches
|
||||
tool_calls += n_searches
|
||||
history.append({
|
||||
"role": "worker",
|
||||
"turn": turn,
|
||||
"worker_id": wid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"output": w_text,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"n_web_searches": n_searches,
|
||||
})
|
||||
history.append(
|
||||
{
|
||||
"role": "worker",
|
||||
"turn": turn,
|
||||
"worker_id": wid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"output": w_text,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"n_web_searches": n_searches,
|
||||
}
|
||||
)
|
||||
continue
|
||||
# Unknown action kind — treat as parse failure.
|
||||
parse_failures += 1
|
||||
@@ -306,17 +325,22 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
# Search workers are excluded — they answer fact-lookup
|
||||
# questions, not synthesis.
|
||||
non_search = [
|
||||
w for w in workers if w.get("type") != "anthropic-web-search"
|
||||
w for w in workers if w.get("type") not in _TOOLORCH_SEARCH_TYPES
|
||||
] or workers
|
||||
worker = max(
|
||||
non_search,
|
||||
key=lambda w: PRICES.get(w.get("model", ""), (0.0, 0.0))[1],
|
||||
)
|
||||
if swe_mode and shared_workdir is not None:
|
||||
(ans, w_in, w_out, is_local, extra_cost, _,
|
||||
bash_turns) = _swe_call_worker(
|
||||
worker, question, cfg, task_meta,
|
||||
shared_workdir, max_turns + 1,
|
||||
(ans, w_in, w_out, is_local, extra_cost, _, bash_turns) = (
|
||||
_swe_call_worker(
|
||||
worker,
|
||||
question,
|
||||
cfg,
|
||||
task_meta,
|
||||
shared_workdir,
|
||||
max_turns + 1,
|
||||
)
|
||||
)
|
||||
tool_calls += bash_turns
|
||||
else:
|
||||
@@ -328,17 +352,19 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
else:
|
||||
tokens_cloud += w_in + w_out
|
||||
cost += self.cost_usd(worker["model"], w_in, w_out) + extra_cost
|
||||
history.append({
|
||||
"role": "worker",
|
||||
"turn": max_turns + 1,
|
||||
"worker_id": worker["id"],
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"output": ans,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"fallback": True,
|
||||
})
|
||||
history.append(
|
||||
{
|
||||
"role": "worker",
|
||||
"turn": max_turns + 1,
|
||||
"worker_id": worker["id"],
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"output": ans,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"fallback": True,
|
||||
}
|
||||
)
|
||||
final_answer = ans
|
||||
|
||||
# In SWE mode, the authoritative output is the working-tree diff —
|
||||
@@ -348,7 +374,8 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
if patch.strip():
|
||||
final_answer = (
|
||||
f"{final_answer}\n\n```diff\n{patch}```"
|
||||
if final_answer else f"```diff\n{patch}```"
|
||||
if final_answer
|
||||
else f"```diff\n{patch}```"
|
||||
)
|
||||
|
||||
meta = {
|
||||
@@ -418,20 +445,24 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
)
|
||||
shared_workdir: Optional[Path] = None
|
||||
if swe_mode:
|
||||
shared_workdir = Path(tempfile.mkdtemp(
|
||||
prefix=f"toolorch-rl-swe-{task_meta.get('task_id','x')}-"
|
||||
))
|
||||
shared_workdir = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f"toolorch-rl-swe-{task_meta.get('task_id', 'x')}-"
|
||||
)
|
||||
)
|
||||
try:
|
||||
_clone_repo(task_meta["repo"], task_meta["base_commit"], shared_workdir)
|
||||
except Exception:
|
||||
shutil.rmtree(shared_workdir, ignore_errors=True)
|
||||
raise
|
||||
self.record_trace_event({
|
||||
"kind": "toolorchestra_rl_swe_workdir",
|
||||
"workdir": str(shared_workdir),
|
||||
"repo": task_meta["repo"],
|
||||
"base_commit": task_meta["base_commit"],
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "toolorchestra_rl_swe_workdir",
|
||||
"workdir": str(shared_workdir),
|
||||
"repo": task_meta["repo"],
|
||||
"base_commit": task_meta["base_commit"],
|
||||
}
|
||||
)
|
||||
|
||||
# ``context_str`` mirrors the upstream's running context — accumulates
|
||||
# search documents and code/exec snippets across turns. We keep this
|
||||
@@ -480,40 +511,53 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
temperature=orch_temp,
|
||||
tools=RL_TOOLS_SPEC,
|
||||
)
|
||||
self.record_trace_event({
|
||||
"kind": "vllm",
|
||||
"role": "orchestrator",
|
||||
"model": orch_model,
|
||||
"endpoint": orch_endpoint,
|
||||
"system": RL_ORCHESTRATOR_SYS,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": getattr(tc, "id", None),
|
||||
"type": getattr(tc, "type", None),
|
||||
"function": {
|
||||
"name": getattr(getattr(tc, "function", None), "name", None),
|
||||
"arguments": getattr(getattr(tc, "function", None), "arguments", None),
|
||||
},
|
||||
}
|
||||
for tc in (sdk_tool_calls or [])
|
||||
],
|
||||
"tokens_in": o_in,
|
||||
"tokens_out": o_out,
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "vllm",
|
||||
"role": "orchestrator",
|
||||
"model": orch_model,
|
||||
"endpoint": orch_endpoint,
|
||||
"system": RL_ORCHESTRATOR_SYS,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": getattr(tc, "id", None),
|
||||
"type": getattr(tc, "type", None),
|
||||
"function": {
|
||||
"name": getattr(
|
||||
getattr(tc, "function", None), "name", None
|
||||
),
|
||||
"arguments": getattr(
|
||||
getattr(tc, "function", None), "arguments", None
|
||||
),
|
||||
},
|
||||
}
|
||||
for tc in (sdk_tool_calls or [])
|
||||
],
|
||||
"tokens_in": o_in,
|
||||
"tokens_out": o_out,
|
||||
}
|
||||
)
|
||||
tokens_local += o_in + o_out
|
||||
|
||||
action = _parse_rl_tool_call(text, sdk_tool_calls)
|
||||
history.append({
|
||||
"role": "orchestrator", "turn": turn, "raw": text, "action": action,
|
||||
})
|
||||
self.record_trace_event({
|
||||
"kind": "toolorchestra_rl_action",
|
||||
"turn": turn,
|
||||
"action": action,
|
||||
"raw": text,
|
||||
})
|
||||
history.append(
|
||||
{
|
||||
"role": "orchestrator",
|
||||
"turn": turn,
|
||||
"raw": text,
|
||||
"action": action,
|
||||
}
|
||||
)
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "toolorchestra_rl_action",
|
||||
"turn": turn,
|
||||
"action": action,
|
||||
"raw": text,
|
||||
}
|
||||
)
|
||||
|
||||
if action is None:
|
||||
parse_failures += 1
|
||||
@@ -526,8 +570,10 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
slot = args.get("model", "")
|
||||
|
||||
# Validate against the upstream tool/arg schema.
|
||||
valid = name in RL_ALL_TOOLS and isinstance(slot, str) and (
|
||||
slot in RL_ALL_TOOLS[name]["model"]
|
||||
valid = (
|
||||
name in RL_ALL_TOOLS
|
||||
and isinstance(slot, str)
|
||||
and (slot in RL_ALL_TOOLS[name]["model"])
|
||||
)
|
||||
if not valid:
|
||||
parse_failures += 1
|
||||
@@ -548,8 +594,11 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
# framing).
|
||||
if paper_mode:
|
||||
worker = _paper_expert_for(
|
||||
slot, self._local_model, self._local_endpoint,
|
||||
self._cloud_model, self._cloud_endpoint,
|
||||
slot,
|
||||
self._local_model,
|
||||
self._local_endpoint,
|
||||
self._cloud_model,
|
||||
self._cloud_endpoint,
|
||||
)
|
||||
# In paper mode, `enhance_reasoning` is always the coder
|
||||
# specialist regardless of the orchestrator's chosen tier.
|
||||
@@ -563,7 +612,10 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
}
|
||||
else:
|
||||
worker = _expert_for(
|
||||
slot, self._local_model, self._local_endpoint, self._cloud_model,
|
||||
slot,
|
||||
self._local_model,
|
||||
self._local_endpoint,
|
||||
self._cloud_model,
|
||||
self._cloud_endpoint,
|
||||
)
|
||||
|
||||
@@ -619,13 +671,25 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
# bash_turns=0; vllm/anthropic-typed workers run the loop.
|
||||
bash_turns = 0
|
||||
if swe_mode and shared_workdir is not None and name != "search":
|
||||
(w_text, w_in, w_out, is_local, extra_cost,
|
||||
n_searches, bash_turns) = _swe_call_worker(
|
||||
worker, w_input, cfg, task_meta, shared_workdir, turn,
|
||||
(
|
||||
w_text,
|
||||
w_in,
|
||||
w_out,
|
||||
is_local,
|
||||
extra_cost,
|
||||
n_searches,
|
||||
bash_turns,
|
||||
) = _swe_call_worker(
|
||||
worker,
|
||||
w_input,
|
||||
cfg,
|
||||
task_meta,
|
||||
shared_workdir,
|
||||
turn,
|
||||
)
|
||||
else:
|
||||
w_text, w_in, w_out, is_local, extra_cost, n_searches = _call_worker(
|
||||
worker, w_input, cfg
|
||||
w_text, w_in, w_out, is_local, extra_cost, n_searches = (
|
||||
_call_worker(worker, w_input, cfg)
|
||||
)
|
||||
if is_local:
|
||||
tokens_local += w_in + w_out
|
||||
@@ -644,13 +708,13 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
# when no python block is found.
|
||||
modal_exec_output: Optional[str] = None
|
||||
modal_exec_rc: Optional[int] = None
|
||||
if (paper_mode and name == "enhance_reasoning"
|
||||
and not swe_mode):
|
||||
if paper_mode and name == "enhance_reasoning" and not swe_mode:
|
||||
code = _extract_first_python_block(w_text)
|
||||
if code:
|
||||
timeout_s = int(cfg.get("modal_python_timeout_s", 60))
|
||||
modal_exec_output, modal_exec_rc = _call_modal_python(
|
||||
code, timeout_s=timeout_s,
|
||||
code,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
tool_calls += 1
|
||||
w_text = (
|
||||
@@ -658,27 +722,29 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
f"(rc={modal_exec_rc})]\n{modal_exec_output}"
|
||||
)
|
||||
|
||||
history.append({
|
||||
"role": "worker",
|
||||
"turn": turn,
|
||||
"tool": name,
|
||||
"slot": slot,
|
||||
"worker_model": worker["model"],
|
||||
"worker_type": worker["type"],
|
||||
"output": w_text,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"n_web_searches": n_searches,
|
||||
"bash_turns": bash_turns,
|
||||
"modal_exec_rc": modal_exec_rc,
|
||||
})
|
||||
history.append(
|
||||
{
|
||||
"role": "worker",
|
||||
"turn": turn,
|
||||
"tool": name,
|
||||
"slot": slot,
|
||||
"worker_model": worker["model"],
|
||||
"worker_type": worker["type"],
|
||||
"output": w_text,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"n_web_searches": n_searches,
|
||||
"bash_turns": bash_turns,
|
||||
"modal_exec_rc": modal_exec_rc,
|
||||
}
|
||||
)
|
||||
|
||||
# Update accumulated context for the next turn.
|
||||
if name == "search":
|
||||
# Treat the search worker's response as a document.
|
||||
doc_list.append(w_text)
|
||||
ctx_docs = "\n\n".join(
|
||||
f"Doc {i+1}: {d}" for i, d in enumerate(doc_list)
|
||||
f"Doc {i + 1}: {d}" for i, d in enumerate(doc_list)
|
||||
)
|
||||
# Crude char-level cap mirrors the upstream's ~24k token cap.
|
||||
context_str = ("Documents:\n" + ctx_docs)[-24000:]
|
||||
@@ -695,15 +761,23 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
# it can still touch the workdir and emit a diff.
|
||||
expert_fn = _paper_expert_for if paper_mode else _expert_for
|
||||
worker = expert_fn(
|
||||
"answer-1", self._local_model, self._local_endpoint,
|
||||
self._cloud_model, self._cloud_endpoint,
|
||||
"answer-1",
|
||||
self._local_model,
|
||||
self._local_endpoint,
|
||||
self._cloud_model,
|
||||
self._cloud_endpoint,
|
||||
)
|
||||
fb_bash_turns = 0
|
||||
if swe_mode and shared_workdir is not None:
|
||||
(ans, w_in, w_out, is_local, extra_cost,
|
||||
_, fb_bash_turns) = _swe_call_worker(
|
||||
worker, question, cfg, task_meta,
|
||||
shared_workdir, max_turns + 1,
|
||||
(ans, w_in, w_out, is_local, extra_cost, _, fb_bash_turns) = (
|
||||
_swe_call_worker(
|
||||
worker,
|
||||
question,
|
||||
cfg,
|
||||
task_meta,
|
||||
shared_workdir,
|
||||
max_turns + 1,
|
||||
)
|
||||
)
|
||||
tool_calls += fb_bash_turns
|
||||
else:
|
||||
@@ -715,19 +789,21 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
else:
|
||||
tokens_cloud += w_in + w_out
|
||||
cost += self.cost_usd(worker["model"], w_in, w_out) + extra_cost
|
||||
history.append({
|
||||
"role": "worker",
|
||||
"turn": max_turns + 1,
|
||||
"tool": "answer",
|
||||
"slot": "answer-1",
|
||||
"worker_model": worker["model"],
|
||||
"worker_type": worker["type"],
|
||||
"output": ans,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"bash_turns": fb_bash_turns,
|
||||
"fallback": True,
|
||||
})
|
||||
history.append(
|
||||
{
|
||||
"role": "worker",
|
||||
"turn": max_turns + 1,
|
||||
"tool": "answer",
|
||||
"slot": "answer-1",
|
||||
"worker_model": worker["model"],
|
||||
"worker_type": worker["type"],
|
||||
"output": ans,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
"bash_turns": fb_bash_turns,
|
||||
"fallback": True,
|
||||
}
|
||||
)
|
||||
final_answer = ans
|
||||
|
||||
# In SWE mode, the authoritative output is the working-tree diff —
|
||||
@@ -737,7 +813,8 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
if patch.strip():
|
||||
final_answer = (
|
||||
f"{final_answer}\n\n```diff\n{patch}```"
|
||||
if final_answer else f"```diff\n{patch}```"
|
||||
if final_answer
|
||||
else f"```diff\n{patch}```"
|
||||
)
|
||||
|
||||
meta = {
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
|
||||
def _call_orchestrator_with_tool_calls(
|
||||
model: str,
|
||||
endpoint: str,
|
||||
|
||||
@@ -16,10 +16,14 @@ _DEFAULT_WEB_SEARCH_MODEL = "claude-haiku-4-5"
|
||||
# so the substitution is deferred until we know the cell's resolved local/cloud
|
||||
# pair. Worker dicts share the schema validated by `_resolve_worker_pool`.
|
||||
|
||||
def _expert_for(slot: str, local_model: Optional[str],
|
||||
local_endpoint: Optional[str],
|
||||
cloud_model: str,
|
||||
cloud_endpoint: str = "anthropic") -> Dict[str, Any]:
|
||||
|
||||
def _expert_for(
|
||||
slot: str,
|
||||
local_model: Optional[str],
|
||||
local_endpoint: Optional[str],
|
||||
cloud_model: str,
|
||||
cloud_endpoint: str = "anthropic",
|
||||
) -> Dict[str, Any]:
|
||||
"""Map an upstream model slot (`answer-1`, `search-3`, …) to a worker spec.
|
||||
|
||||
Routing policy:
|
||||
@@ -29,10 +33,23 @@ def _expert_for(slot: str, local_model: Optional[str],
|
||||
cost tier for mid OpenAI calls)
|
||||
- `*-3` (local tier) -> local vLLM (`local_model`)
|
||||
- `answer-math-*` -> same tiers as the numeric suffix
|
||||
- `search-*` -> always the Anthropic web_search tool (the
|
||||
upstream uses Tavily; we have web_search)
|
||||
- `search-*` -> provider-native web search when the cloud
|
||||
endpoint supports it; otherwise Anthropic
|
||||
"""
|
||||
if slot.startswith("search"):
|
||||
ep = (cloud_endpoint or "anthropic").lower()
|
||||
if ep == "openai":
|
||||
return {
|
||||
"name": f"search:{slot}",
|
||||
"type": "openai-web-search",
|
||||
"model": cloud_model,
|
||||
}
|
||||
if ep == "gemini":
|
||||
return {
|
||||
"name": f"search:{slot}",
|
||||
"type": "gemini-web-search",
|
||||
"model": cloud_model,
|
||||
}
|
||||
return {
|
||||
"name": f"search:{slot}",
|
||||
"type": "anthropic-web-search",
|
||||
|
||||
@@ -9,9 +9,7 @@ from typing import Any, Dict, List, Optional
|
||||
# Regex for ``<tool_call>{...}</tool_call>`` blocks emitted by Orchestrator-8B
|
||||
# when the vLLM tool parser doesn't catch them (e.g. `qwen3_xml` parser on a
|
||||
# hermes-style template). Captures the JSON payload.
|
||||
_TOOL_CALL_TAG_RE = re.compile(
|
||||
r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL
|
||||
)
|
||||
_TOOL_CALL_TAG_RE = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
|
||||
|
||||
# Fallback for a known failure mode: the SFT'd orchestrator often emits its
|
||||
# delegation as ``\boxed{expert_ab12: <sub-question>}`` (math-data habit bleeding
|
||||
@@ -28,8 +26,8 @@ _TOOL_CALL_TAG_RE = re.compile(
|
||||
# alone. ``group("key")`` is the explicit arg key when the model wrote ``, key:``.
|
||||
_BOXED_DELEGATION_RE = re.compile(
|
||||
r"\\boxed\{\s*([A-Za-z_][\w-]*)\s*"
|
||||
r"(?:,\s*(?P<key>\w+)\s*)?" # optional ", key" hint (e.g. file_read, path:)
|
||||
r"(?:query\s*)?" # optional literal "query" word before the colon
|
||||
r"(?:,\s*(?P<key>\w+)\s*)?" # optional ", key" hint (e.g. file_read, path:)
|
||||
r"(?:query\s*)?" # optional literal "query" word before the colon
|
||||
r":\s*(.+?)\s*\}\s*$",
|
||||
re.DOTALL,
|
||||
)
|
||||
@@ -113,7 +111,7 @@ def _strip_fences(s: str) -> str:
|
||||
if s.startswith("```"):
|
||||
first_nl = s.find("\n")
|
||||
if first_nl != -1:
|
||||
s = s[first_nl + 1:]
|
||||
s = s[first_nl + 1 :]
|
||||
if s.endswith("```"):
|
||||
s = s[:-3]
|
||||
s = s.strip()
|
||||
|
||||
@@ -98,8 +98,12 @@ RL_ALL_TOOLS: Dict[str, Dict[str, List[str]]] = {
|
||||
"enhance_reasoning": {"model": ["reasoner-1", "reasoner-2", "reasoner-3"]},
|
||||
"answer": {
|
||||
"model": [
|
||||
"answer-1", "answer-2", "answer-3", "answer-4",
|
||||
"answer-math-1", "answer-math-2",
|
||||
"answer-1",
|
||||
"answer-2",
|
||||
"answer-3",
|
||||
"answer-4",
|
||||
"answer-math-1",
|
||||
"answer-math-2",
|
||||
],
|
||||
},
|
||||
"search": {"model": ["search-1", "search-2", "search-3"]},
|
||||
|
||||
@@ -80,6 +80,7 @@ def build_system_prompt(specs: List[Dict[str, object]]) -> str:
|
||||
"\n</tool_call>"
|
||||
)
|
||||
|
||||
|
||||
# Char-level cap on the accumulated conversation (mirrors the paper's ~24k-token
|
||||
# cap) and a per-observation cap so one giant tool dump can't blow it out.
|
||||
_CONTEXT_CAP = 24000
|
||||
@@ -92,8 +93,10 @@ _SOFT_CALL_CAP = 6
|
||||
def _trim_history(messages: List[Dict[str, str]]) -> None:
|
||||
"""Keep the message history under ``_CONTEXT_CAP`` chars by dropping the
|
||||
OLDEST assistant/tool exchange, never the system prompt or the problem."""
|
||||
|
||||
def total() -> int:
|
||||
return sum(len(m.get("content") or "") for m in messages)
|
||||
|
||||
# messages[0]=system, messages[1]=user problem — always keep those two.
|
||||
while total() > _CONTEXT_CAP and len(messages) > 4:
|
||||
del messages[2:4]
|
||||
@@ -145,7 +148,9 @@ def _run_unified_rollout_inner(
|
||||
question: str,
|
||||
tools: List[ExpertTool],
|
||||
*,
|
||||
call_orchestrator: Callable[..., Tuple[str, List[Tuple[str, Dict[str, object]]], int, int]],
|
||||
call_orchestrator: Callable[
|
||||
..., Tuple[str, List[Tuple[str, Dict[str, object]]], int, int]
|
||||
],
|
||||
dispatch: Callable[[ExpertTool, Dict[str, object]], Tuple[str, float, int, bool]],
|
||||
max_turns: int = 50,
|
||||
system: str = RL_ORCHESTRATOR_SYS,
|
||||
@@ -192,9 +197,15 @@ def _run_unified_rollout_inner(
|
||||
if n_tool_calls == 0 and nudges < 2:
|
||||
nudges += 1
|
||||
messages.append({"role": "assistant", "content": text or ""})
|
||||
messages.append({"role": "user", "content": (
|
||||
"Make progress by delegating a concrete sub-question to one of the "
|
||||
"models now via a <tool_call>.")})
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Make progress by delegating a concrete sub-question to one of the "
|
||||
"models now via a <tool_call>."
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
# No tool call -> the orchestrator is answering. Terminate.
|
||||
final_answer = (text or "").strip()
|
||||
@@ -206,8 +217,12 @@ def _run_unified_rollout_inner(
|
||||
if name not in by_name:
|
||||
parse_failures += 1
|
||||
messages.append({"role": "assistant", "content": text or ""})
|
||||
messages.append({"role": "user",
|
||||
"content": f"[invalid tool {name!r} — choose one from the provided tool list]"})
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"[invalid tool {name!r} — choose one from the provided tool list]",
|
||||
}
|
||||
)
|
||||
if parse_failures >= 2:
|
||||
final_answer = (text or "").strip()
|
||||
break
|
||||
@@ -228,43 +243,68 @@ def _run_unified_rollout_inner(
|
||||
if not _has_input and empty_input_nudges < 3:
|
||||
empty_input_nudges += 1
|
||||
messages.append({"role": "assistant", "content": text or ""})
|
||||
messages.append({"role": "user", "content": (
|
||||
f"Your call to {name} had an empty 'input'. Resend the "
|
||||
"<tool_call> with a non-empty 'input' field containing the "
|
||||
"concrete sub-question to delegate.")})
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Your call to {name} had an empty 'input'. Resend the "
|
||||
"<tool_call> with a non-empty 'input' field containing the "
|
||||
"concrete sub-question to delegate."
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
obs, dcost, dtok, _is_local = dispatch(tool, arguments)
|
||||
cost += float(dcost)
|
||||
tokens += int(dtok)
|
||||
n_tool_calls += 1
|
||||
turns.append(UnifiedTurn(
|
||||
reasoning=text or "", tool_name=name, arguments=dict(arguments),
|
||||
observation=obs,
|
||||
))
|
||||
turns.append(
|
||||
UnifiedTurn(
|
||||
reasoning=text or "",
|
||||
tool_name=name,
|
||||
arguments=dict(arguments),
|
||||
observation=obs,
|
||||
)
|
||||
)
|
||||
# The model's own action as an assistant turn (the <tool_call> tag in
|
||||
# content, matching the SFT serialization), then the observation as a
|
||||
# distinct `tool` turn the model reads as a tool response.
|
||||
call_content = text or ""
|
||||
if "<tool_call>" not in call_content:
|
||||
call_content = (call_content + "\n" + tool_call_tag(name, arguments)).strip()
|
||||
call_content = (
|
||||
call_content + "\n" + tool_call_tag(name, arguments)
|
||||
).strip()
|
||||
messages.append({"role": "assistant", "content": call_content})
|
||||
obs_text = obs or ""
|
||||
if len(obs_text) > _OBS_CAP:
|
||||
obs_text = obs_text[:_OBS_CAP] + "\n…[truncated]"
|
||||
messages.append({"role": "tool", "name": name, "content": obs_text})
|
||||
if n_tool_calls >= _SOFT_CALL_CAP:
|
||||
messages.append({"role": "user", "content": (
|
||||
"You now have enough information from the models. Do NOT call any "
|
||||
"more tools — reply with your FINAL_ANSWER line only.")})
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"You now have enough information from the models. Do NOT call any "
|
||||
"more tools — reply with your FINAL_ANSWER line only."
|
||||
),
|
||||
}
|
||||
)
|
||||
_trim_history(messages)
|
||||
else:
|
||||
# Hit max_turns with no explicit answer: use the last observation/text.
|
||||
final_answer = (turns[-1].observation or turns[-1].reasoning).strip() if turns else ""
|
||||
final_answer = (
|
||||
(turns[-1].observation or turns[-1].reasoning).strip() if turns else ""
|
||||
)
|
||||
|
||||
return UnifiedRollout(
|
||||
turns=turns, final_answer=final_answer, cost_usd=cost, tokens=tokens,
|
||||
num_tool_calls=n_tool_calls, parse_failures=parse_failures,
|
||||
anon_map=anon_map, tool_specs=specs,
|
||||
turns=turns,
|
||||
final_answer=final_answer,
|
||||
cost_usd=cost,
|
||||
tokens=tokens,
|
||||
num_tool_calls=n_tool_calls,
|
||||
parse_failures=parse_failures,
|
||||
anon_map=anon_map,
|
||||
tool_specs=specs,
|
||||
)
|
||||
|
||||
|
||||
@@ -272,7 +312,9 @@ def run_unified_rollout(
|
||||
question: str,
|
||||
tools: List[ExpertTool],
|
||||
*,
|
||||
call_orchestrator: Callable[..., Tuple[str, List[Tuple[str, Dict[str, object]]], int, int]],
|
||||
call_orchestrator: Callable[
|
||||
..., Tuple[str, List[Tuple[str, Dict[str, object]]], int, int]
|
||||
],
|
||||
dispatch: Callable[[ExpertTool, Dict[str, object]], Tuple[str, float, int, bool]],
|
||||
max_turns: int = 50,
|
||||
system: str = RL_ORCHESTRATOR_SYS,
|
||||
@@ -285,33 +327,49 @@ def run_unified_rollout(
|
||||
_run_meta, _run_tags = run_context()
|
||||
_fields = dict(
|
||||
input={"question": question},
|
||||
metadata={"max_turns": max_turns, "anonymize": anonymize,
|
||||
"n_tools": len(tools), **_run_meta},
|
||||
metadata={
|
||||
"max_turns": max_turns,
|
||||
"anonymize": anonymize,
|
||||
"n_tools": len(tools),
|
||||
**_run_meta,
|
||||
},
|
||||
)
|
||||
if _run_tags:
|
||||
_fields["tags"] = _run_tags
|
||||
with span("toolorchestra.rollout", span_type="task", **_fields) as _s:
|
||||
roll = _run_unified_rollout_inner(
|
||||
question, tools, call_orchestrator=call_orchestrator, dispatch=dispatch,
|
||||
max_turns=max_turns, system=system, anonymize=anonymize,
|
||||
question,
|
||||
tools,
|
||||
call_orchestrator=call_orchestrator,
|
||||
dispatch=dispatch,
|
||||
max_turns=max_turns,
|
||||
system=system,
|
||||
anonymize=anonymize,
|
||||
)
|
||||
_s.log(
|
||||
output=roll.final_answer,
|
||||
metrics={"cost_usd": roll.cost_usd, "tokens": roll.tokens,
|
||||
"num_tool_calls": roll.num_tool_calls,
|
||||
"parse_failures": roll.parse_failures},
|
||||
metadata={"n_experts_available": len(roll.anon_map or {}),
|
||||
"answered": bool(roll.final_answer),
|
||||
# label -> real model, so every anonymized route span in this
|
||||
# trace can be decoded back to the model that actually ran.
|
||||
"anon_map": roll.anon_map or {}},
|
||||
metrics={
|
||||
"cost_usd": roll.cost_usd,
|
||||
"tokens": roll.tokens,
|
||||
"num_tool_calls": roll.num_tool_calls,
|
||||
"parse_failures": roll.parse_failures,
|
||||
},
|
||||
metadata={
|
||||
"n_experts_available": len(roll.anon_map or {}),
|
||||
"answered": bool(roll.final_answer),
|
||||
# label -> real model, so every anonymized route span in this
|
||||
# trace can be decoded back to the model that actually ran.
|
||||
"anon_map": roll.anon_map or {},
|
||||
},
|
||||
)
|
||||
return roll
|
||||
|
||||
|
||||
def tool_call_tag(name: str, arguments: Dict[str, object]) -> str:
|
||||
"""Render a tool call as the ``<tool_call>{...}</tool_call>`` text the model emits."""
|
||||
return f"<tool_call>{json.dumps({'name': name, 'arguments': arguments})}</tool_call>"
|
||||
return (
|
||||
f"<tool_call>{json.dumps({'name': name, 'arguments': arguments})}</tool_call>"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Optional, Tuple
|
||||
|
||||
# ---- Tavily + Modal helpers -------------------------------------------------
|
||||
|
||||
|
||||
def _call_tavily_search(query: str, max_results: int = 5) -> Tuple[str, int, int]:
|
||||
"""One-shot Tavily search. Returns (text, p_tok=0, c_tok=0).
|
||||
|
||||
@@ -43,7 +44,9 @@ def _call_modal_python(code: str, timeout_s: int = 60) -> Tuple[str, int]:
|
||||
# Python image too. We rely on stdlib only — no extra pip installs.
|
||||
image = modal.Image.debian_slim(python_version="3.12")
|
||||
sb = modal.Sandbox.create(
|
||||
"python", "-c", code,
|
||||
"python",
|
||||
"-c",
|
||||
code,
|
||||
app=app,
|
||||
image=image,
|
||||
timeout=int(timeout_s),
|
||||
|
||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,7 +36,9 @@ def _resolve() -> bool:
|
||||
if not _truthy(os.getenv("OJ_BRAINTRUST", "1")): # on by default
|
||||
return False
|
||||
if not os.getenv("BRAINTRUST_API_KEY"):
|
||||
logger.info("braintrust on-by-default but BRAINTRUST_API_KEY unset — tracing disabled")
|
||||
logger.info(
|
||||
"braintrust on-by-default but BRAINTRUST_API_KEY unset — tracing disabled"
|
||||
)
|
||||
return False
|
||||
try:
|
||||
import braintrust as _bt
|
||||
@@ -48,9 +50,12 @@ def _resolve() -> bool:
|
||||
_bt.init_logger(project=os.getenv("OJ_BRAINTRUST_PROJECT", "toolorchestra"))
|
||||
_STATE["bt"] = _bt
|
||||
_STATE["enabled"] = True
|
||||
logger.info("braintrust tracing ENABLED (%s)",
|
||||
f"project_id={proj_id}" if proj_id
|
||||
else f"project={os.getenv('OJ_BRAINTRUST_PROJECT', 'toolorchestra')}")
|
||||
logger.info(
|
||||
"braintrust tracing ENABLED (%s)",
|
||||
f"project_id={proj_id}"
|
||||
if proj_id
|
||||
else f"project={os.getenv('OJ_BRAINTRUST_PROJECT', 'toolorchestra')}",
|
||||
)
|
||||
except Exception as exc: # missing pkg / bad key / init failure — never crash
|
||||
logger.warning("braintrust init failed (%s) — tracing disabled", exc)
|
||||
return _STATE["enabled"]
|
||||
@@ -87,10 +92,12 @@ def run_context() -> tuple[dict, list]:
|
||||
if stage:
|
||||
meta["stage"] = stage
|
||||
cfg = {}
|
||||
for env_key, key in (("OJ_CFG_TEMPERATURE", "temperature"),
|
||||
("OJ_CFG_MAX_TURNS", "max_turns"),
|
||||
("OJ_CFG_ANONYMIZE", "anonymize"),
|
||||
("OJ_CFG_REJECTION_ONLY", "rejection_only")):
|
||||
for env_key, key in (
|
||||
("OJ_CFG_TEMPERATURE", "temperature"),
|
||||
("OJ_CFG_MAX_TURNS", "max_turns"),
|
||||
("OJ_CFG_ANONYMIZE", "anonymize"),
|
||||
("OJ_CFG_REJECTION_ONLY", "rejection_only"),
|
||||
):
|
||||
v = os.getenv(env_key)
|
||||
if v not in (None, ""):
|
||||
cfg[key] = v
|
||||
@@ -154,7 +161,9 @@ def span(name: str, *, span_type: str = "task", **fields: Any):
|
||||
try:
|
||||
cm = _STATE["bt"].start_span(name=name, type=span_type, **fields)
|
||||
except Exception as exc: # span creation failed — run trace-less, never break
|
||||
logger.warning("braintrust start_span(%s) failed (%s) — continuing untraced", name, exc)
|
||||
logger.warning(
|
||||
"braintrust start_span(%s) failed (%s) — continuing untraced", name, exc
|
||||
)
|
||||
yield _NullSpan()
|
||||
return
|
||||
with cm as s:
|
||||
|
||||
@@ -14,12 +14,6 @@ import json
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
# Guards the lazy, one-time build of the shared ToolExecutor in
|
||||
# ``_dispatch_openjarvis_tool``: with concurrent rollouts (parallel rejection
|
||||
# sampling) several threads can reach the build at once and would each instantiate
|
||||
# the full tool registry. The lock makes it build-once.
|
||||
_EXECUTOR_BUILD_LOCK = threading.Lock()
|
||||
|
||||
from openjarvis.agents.hybrid._prices import cost as _model_cost
|
||||
from openjarvis.agents.hybrid.expert_registry import ExpertTool, to_worker_dict
|
||||
from openjarvis.agents.hybrid.toolorchestra.parsing import _parse_rl_tool_call
|
||||
@@ -28,8 +22,14 @@ from openjarvis.agents.hybrid.toolorchestra.rollout import (
|
||||
build_system_prompt,
|
||||
run_unified_rollout,
|
||||
)
|
||||
from openjarvis.agents.hybrid.toolorchestra.workers import _call_worker
|
||||
from openjarvis.agents.hybrid.toolorchestra.tracing import span
|
||||
from openjarvis.agents.hybrid.toolorchestra.workers import _call_worker
|
||||
|
||||
# Guards the lazy, one-time build of the shared ToolExecutor in
|
||||
# ``_dispatch_openjarvis_tool``: with concurrent rollouts (parallel rejection
|
||||
# sampling) several threads can reach the build at once and would each instantiate
|
||||
# the full tool registry. The lock makes it build-once.
|
||||
_EXECUTOR_BUILD_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def make_call_orchestrator(
|
||||
@@ -73,7 +73,9 @@ def make_call_orchestrator(
|
||||
|
||||
from openjarvis.agents.hybrid.toolorchestra.tracing import wrap_client
|
||||
|
||||
client = wrap_client(OpenAI(base_url=base_url, api_key=api_key or "EMPTY", timeout=timeout))
|
||||
client = wrap_client(
|
||||
OpenAI(base_url=base_url, api_key=api_key or "EMPTY", timeout=timeout)
|
||||
)
|
||||
|
||||
def call_orchestrator(messages: List[Dict[str, Any]], specs: List[Dict[str, Any]]):
|
||||
# ``messages`` is the full running conversation (system/user/assistant/tool)
|
||||
@@ -93,7 +95,9 @@ def make_call_orchestrator(
|
||||
# repetition_penalty is a vLLM extra (not OpenAI-native); only send it to a
|
||||
# local vLLM endpoint (base_url set), never to the cloud frontier APIs.
|
||||
if base_url and repetition_penalty and repetition_penalty != 1.0:
|
||||
kwargs.setdefault("extra_body", {})["repetition_penalty"] = repetition_penalty
|
||||
kwargs.setdefault("extra_body", {})["repetition_penalty"] = (
|
||||
repetition_penalty
|
||||
)
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=send,
|
||||
@@ -204,10 +208,17 @@ def make_dispatch(
|
||||
# The orchestrator emitted a tool call with no/empty input. Don't hit
|
||||
# the API (it 400s on empty content) — return a usable error so the
|
||||
# rollout keeps going instead of dropping.
|
||||
return (f"[{tool.name}: no input provided — supply a non-empty "
|
||||
"'input' to delegate]", 0.0, 0, True)
|
||||
return (
|
||||
f"[{tool.name}: no input provided — supply a non-empty "
|
||||
"'input' to delegate]",
|
||||
0.0,
|
||||
0,
|
||||
True,
|
||||
)
|
||||
text, p, c, is_local, extra_cost, _n = _call_worker(worker, prompt, cfg)
|
||||
usd = (0.0 if is_local else _model_cost(str(tool.model), p, c)) + float(extra_cost)
|
||||
usd = (0.0 if is_local else _model_cost(str(tool.model), p, c)) + float(
|
||||
extra_cost
|
||||
)
|
||||
return text, usd, int(p) + int(c), bool(is_local)
|
||||
|
||||
def dispatch(tool: ExpertTool, arguments: Dict[str, Any]):
|
||||
@@ -218,12 +229,22 @@ def make_dispatch(
|
||||
# the trace reads clearly even under anonymization; ``anon_label`` in the
|
||||
# metadata records the opaque label the orchestrator actually saw/chose.
|
||||
real_model = str(tool.model)
|
||||
with span(f"route:{real_model}", span_type="tool", input=arguments,
|
||||
metadata={"real_model": real_model, "anon_label": tool.name,
|
||||
"backend": tool.backend_type}) as s:
|
||||
with span(
|
||||
f"route:{real_model}",
|
||||
span_type="tool",
|
||||
input=arguments,
|
||||
metadata={
|
||||
"real_model": real_model,
|
||||
"anon_label": tool.name,
|
||||
"backend": tool.backend_type,
|
||||
},
|
||||
) as s:
|
||||
obs, usd, toks, is_local = _dispatch_inner(tool, arguments)
|
||||
s.log(output=obs, metrics={"cost_usd": usd, "tokens": toks},
|
||||
metadata={"is_local": is_local})
|
||||
s.log(
|
||||
output=obs,
|
||||
metrics={"cost_usd": usd, "tokens": toks},
|
||||
metadata={"is_local": is_local},
|
||||
)
|
||||
return obs, usd, toks, is_local
|
||||
|
||||
return dispatch
|
||||
@@ -245,7 +266,10 @@ def teacher_rollout(
|
||||
question,
|
||||
tools,
|
||||
call_orchestrator=make_call_orchestrator(
|
||||
teacher_model, base_url=base_url, api_key=api_key, temperature=temperature,
|
||||
teacher_model,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
temperature=temperature,
|
||||
),
|
||||
dispatch=make_dispatch(cfg),
|
||||
max_turns=max_turns,
|
||||
|
||||
@@ -8,6 +8,8 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from openjarvis.agents.hybrid._base import (
|
||||
ANTHROPIC_WEB_SEARCH_TOOL,
|
||||
GEMINI_SEARCH_COST_PER_CALL,
|
||||
OPENAI_WEB_SEARCH_COST_PER_CALL,
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
LocalCloudAgent,
|
||||
)
|
||||
@@ -26,6 +28,7 @@ from openjarvis.agents.hybrid.toolorchestra.sandbox import (
|
||||
_call_tavily_search,
|
||||
)
|
||||
|
||||
|
||||
def _paper_pool(
|
||||
local_model: Optional[str],
|
||||
local_endpoint: Optional[str],
|
||||
@@ -39,46 +42,73 @@ def _paper_pool(
|
||||
"""
|
||||
pool: List[Dict[str, Any]] = []
|
||||
if local_model and local_endpoint:
|
||||
pool.append({
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "local-qwen",
|
||||
"type": "vllm",
|
||||
"model": local_model,
|
||||
"base_url": local_endpoint,
|
||||
"description": "Local Qwen vLLM (paper uses Qwen3-32B).",
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "local-qwen",
|
||||
"type": "vllm",
|
||||
"model": local_model,
|
||||
"base_url": local_endpoint,
|
||||
"description": "Local Qwen vLLM (paper uses Qwen3-32B).",
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool), "name": "tavily-search",
|
||||
"type": "tavily-search", "model": "tavily",
|
||||
"description": "Tavily web search.",
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool), "name": "modal-python",
|
||||
"type": "modal-python", "model": "modal-python",
|
||||
"description": "Modal Sandbox for one-shot Python exec.",
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool), "name": "code-specialist",
|
||||
"type": "openrouter", "model": _PAPER_CODER_OPENROUTER,
|
||||
"description": "Qwen-2.5-Coder-32B via OpenRouter (paper).",
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool), "name": "generalist-llama",
|
||||
"type": "openrouter", "model": _PAPER_GENERALIST_TIER3_OPENROUTER,
|
||||
"description": "Llama-3.3-70B-Instruct via OpenRouter (paper tier-3).",
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool), "name": "generalist-gpt5",
|
||||
"type": "openai", "model": "gpt-5",
|
||||
"description": "GPT-5 frontier generalist.",
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool), "name": "generalist-gpt5-mini",
|
||||
"type": "openai", "model": "gpt-5-mini",
|
||||
"description": "GPT-5-mini mid generalist.",
|
||||
})
|
||||
"name": "tavily-search",
|
||||
"type": "tavily-search",
|
||||
"model": "tavily",
|
||||
"description": "Tavily web search.",
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "modal-python",
|
||||
"type": "modal-python",
|
||||
"model": "modal-python",
|
||||
"description": "Modal Sandbox for one-shot Python exec.",
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "code-specialist",
|
||||
"type": "openrouter",
|
||||
"model": _PAPER_CODER_OPENROUTER,
|
||||
"description": "Qwen-2.5-Coder-32B via OpenRouter (paper).",
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "generalist-llama",
|
||||
"type": "openrouter",
|
||||
"model": _PAPER_GENERALIST_TIER3_OPENROUTER,
|
||||
"description": "Llama-3.3-70B-Instruct via OpenRouter (paper tier-3).",
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "generalist-gpt5",
|
||||
"type": "openai",
|
||||
"model": "gpt-5",
|
||||
"description": "GPT-5 frontier generalist.",
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "generalist-gpt5-mini",
|
||||
"type": "openai",
|
||||
"model": "gpt-5-mini",
|
||||
"description": "GPT-5-mini mid generalist.",
|
||||
}
|
||||
)
|
||||
return pool
|
||||
|
||||
|
||||
def _default_pool(
|
||||
local_model: Optional[str],
|
||||
local_endpoint: Optional[str],
|
||||
@@ -96,47 +126,67 @@ def _default_pool(
|
||||
ep = "anthropic"
|
||||
pool: List[Dict[str, Any]] = []
|
||||
if local_model and local_endpoint:
|
||||
pool.append({
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "local-qwen",
|
||||
"type": "vllm",
|
||||
"model": local_model,
|
||||
"base_url": local_endpoint,
|
||||
"description": (
|
||||
"Open-weights Qwen3.5 served locally. Cheap and fast. Good at "
|
||||
"concise extraction, formatting, arithmetic on given data."
|
||||
),
|
||||
}
|
||||
)
|
||||
if ep == "openai":
|
||||
search_type = "openai-web-search"
|
||||
search_model = cloud_model
|
||||
search_desc = "OpenAI hosted web search on the configured frontier model."
|
||||
elif ep == "gemini":
|
||||
search_type = "gemini-web-search"
|
||||
search_model = cloud_model
|
||||
search_desc = "Gemini Google Search grounding on the configured frontier model."
|
||||
else:
|
||||
search_type = "anthropic-web-search"
|
||||
search_model = _DEFAULT_WEB_SEARCH_MODEL
|
||||
search_desc = "Anthropic server-side web_search."
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "local-qwen",
|
||||
"type": "vllm",
|
||||
"model": local_model,
|
||||
"base_url": local_endpoint,
|
||||
"name": "web-search",
|
||||
"type": search_type,
|
||||
"model": search_model,
|
||||
"description": (
|
||||
"Open-weights Qwen3.5 served locally. Cheap and fast. Good at "
|
||||
"concise extraction, formatting, arithmetic on given data."
|
||||
f"{search_desc} Use for facts that need a lookup "
|
||||
"(recent events, rare names/dates, niche sources). Returns a digest."
|
||||
),
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "web-search",
|
||||
"type": "anthropic-web-search",
|
||||
"model": "claude-haiku-4-5",
|
||||
"description": (
|
||||
"Anthropic server-side web_search. Use for facts that need a lookup "
|
||||
"(recent events, rare names/dates, niche sources). Returns a digest."
|
||||
),
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": f"frontier-{ep}",
|
||||
"type": ep,
|
||||
"model": cloud_model,
|
||||
"description": (
|
||||
"Frontier reasoning model. Use for hard multi-step reasoning, "
|
||||
"code review, or a final synthesis pass. Expensive — use sparingly."
|
||||
),
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "frontier-openai-mini",
|
||||
"type": "openai",
|
||||
"model": "gpt-5-mini",
|
||||
"description": (
|
||||
"Mid-tier OpenAI model. Solid general knowledge and reasoning at a "
|
||||
"fraction of frontier cost."
|
||||
),
|
||||
})
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": f"frontier-{ep}",
|
||||
"type": ep,
|
||||
"model": cloud_model,
|
||||
"description": (
|
||||
"Frontier reasoning model. Use for hard multi-step reasoning, "
|
||||
"code review, or a final synthesis pass. Expensive — use sparingly."
|
||||
),
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "frontier-openai-mini",
|
||||
"type": "openai",
|
||||
"model": "gpt-5-mini",
|
||||
"description": (
|
||||
"Mid-tier OpenAI model. Solid general knowledge and reasoning at a "
|
||||
"fraction of frontier cost."
|
||||
),
|
||||
}
|
||||
)
|
||||
return pool
|
||||
|
||||
|
||||
@@ -150,8 +200,22 @@ def _default_pool(
|
||||
# `modal-python` — One-shot Python exec in a fresh Modal Sandbox (the
|
||||
# paper's "Python sandbox" inside `enhance_reasoning`).
|
||||
_TOOLORCH_VALID_TYPES = (
|
||||
"vllm", "openai", "anthropic", "anthropic-web-search", "gemini",
|
||||
"tavily-search", "openrouter", "modal-python",
|
||||
"vllm",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"anthropic-web-search",
|
||||
"openai-web-search",
|
||||
"gemini",
|
||||
"gemini-web-search",
|
||||
"tavily-search",
|
||||
"openrouter",
|
||||
"modal-python",
|
||||
)
|
||||
_TOOLORCH_SEARCH_TYPES = (
|
||||
"anthropic-web-search",
|
||||
"openai-web-search",
|
||||
"gemini-web-search",
|
||||
"tavily-search",
|
||||
)
|
||||
|
||||
# Default model used when an `anthropic-web-search` entry omits `model`.
|
||||
@@ -172,10 +236,12 @@ def _resolve_worker_pool(
|
||||
the override is absent.
|
||||
|
||||
Each user-supplied entry must be a dict with keys ``id``, ``name``,
|
||||
``type``, and (for non-search types) ``model``. ``type`` must be one
|
||||
of ``vllm`` / ``openai`` / ``anthropic`` / ``anthropic-web-search``.
|
||||
``anthropic-web-search`` entries may omit ``model`` — it defaults to
|
||||
``claude-haiku-4-5``.
|
||||
``type``, and (for non-search types) ``model``. Search worker types are
|
||||
``anthropic-web-search``, ``openai-web-search``, ``gemini-web-search``,
|
||||
and ``tavily-search``. ``anthropic-web-search`` entries may omit
|
||||
``model`` — it defaults to ``claude-haiku-4-5``. OpenAI and Gemini
|
||||
search workers default to the configured cloud model. Tavily does not
|
||||
require a model.
|
||||
|
||||
Substitution: ``model = "$local"`` (or ``"<local>"``) resolves to
|
||||
``local_model``; ``model = "$cloud"`` / ``"<cloud>"`` to ``cloud_model``.
|
||||
@@ -208,9 +274,7 @@ def _resolve_worker_pool(
|
||||
f"Invalid worker_pool entry [{wid_repr}]: 'id' must be an int"
|
||||
)
|
||||
if wid in seen_ids:
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: duplicate id"
|
||||
)
|
||||
raise ValueError(f"Invalid worker_pool entry [{wid}]: duplicate id")
|
||||
seen_ids.add(wid)
|
||||
if not entry.get("name") or not isinstance(entry["name"], str):
|
||||
raise ValueError(
|
||||
@@ -237,14 +301,27 @@ def _resolve_worker_pool(
|
||||
elif isinstance(model, str) and model in ("$cloud", "<cloud>"):
|
||||
model = cloud_model
|
||||
entry["model"] = model
|
||||
if wtype == "anthropic-web-search":
|
||||
if wtype in _TOOLORCH_SEARCH_TYPES:
|
||||
if model in (None, ""):
|
||||
model = _DEFAULT_WEB_SEARCH_MODEL
|
||||
if wtype == "anthropic-web-search":
|
||||
model = _DEFAULT_WEB_SEARCH_MODEL
|
||||
elif wtype in ("openai-web-search", "gemini-web-search"):
|
||||
model = cloud_model
|
||||
else:
|
||||
model = wtype
|
||||
entry["model"] = model
|
||||
elif not isinstance(model, str):
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: 'model' must be a string when set"
|
||||
)
|
||||
if (
|
||||
wtype in ("openai-web-search", "gemini-web-search")
|
||||
and model not in PRICES
|
||||
):
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: model {model!r} "
|
||||
f"is not in PRICES (known: {sorted(PRICES)})"
|
||||
)
|
||||
# Search workers don't satisfy the "needs a solver" requirement.
|
||||
else:
|
||||
if not isinstance(model, str) or not model:
|
||||
@@ -276,7 +353,7 @@ def _resolve_worker_pool(
|
||||
if not has_non_search:
|
||||
raise ValueError(
|
||||
"Invalid worker_pool entry [-]: worker_pool must contain at least "
|
||||
"one non-search worker (vllm / openai / anthropic)"
|
||||
"one non-search worker (vllm / openai / anthropic / gemini)"
|
||||
)
|
||||
return resolved
|
||||
|
||||
@@ -286,7 +363,9 @@ def _call_worker(
|
||||
) -> Tuple[str, int, int, bool, float, int]:
|
||||
"""Returns (text, p_tok, c_tok, is_local, extra_cost, n_web_searches)."""
|
||||
wtype = worker.get("type", "openai")
|
||||
max_tok = int(cfg.get("worker_max_tokens") or os.environ.get("OJ_WORKER_MAX_TOKENS", "4096"))
|
||||
max_tok = int(
|
||||
cfg.get("worker_max_tokens") or os.environ.get("OJ_WORKER_MAX_TOKENS", "4096")
|
||||
)
|
||||
temp = float(cfg.get("worker_temperature", 0.2))
|
||||
|
||||
if wtype == "vllm":
|
||||
@@ -343,6 +422,27 @@ def _call_worker(
|
||||
)
|
||||
extra = n_searches * WEB_SEARCH_COST_PER_CALL
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "openai-web-search":
|
||||
eff_temp = 1.0 if is_gpt5_family(worker["model"]) else temp
|
||||
text, p, c, n_searches, _ = LocalCloudAgent._call_openai_agent(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max(max_tok, 16384)
|
||||
if is_gpt5_family(worker["model"])
|
||||
else max_tok,
|
||||
temperature=eff_temp,
|
||||
)
|
||||
extra = n_searches * OPENAI_WEB_SEARCH_COST_PER_CALL
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "gemini-web-search":
|
||||
text, p, c, n_searches, _ = LocalCloudAgent._call_gemini_agent(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max_tok,
|
||||
temperature=temp,
|
||||
)
|
||||
extra = n_searches * GEMINI_SEARCH_COST_PER_CALL
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "tavily-search":
|
||||
# Tavily costs are flat per call; charge `WEB_SEARCH_COST_PER_CALL`
|
||||
# for parity with the Anthropic web-search worker. One call = one
|
||||
@@ -384,7 +484,7 @@ def _swe_call_worker(
|
||||
caller can surface ``tool_calls`` per row. Fallbacks to one-shot
|
||||
workers return 0 bash turns (no agent loop ran)."""
|
||||
wtype = worker.get("type", "openai")
|
||||
if wtype == "anthropic-web-search":
|
||||
if wtype in _TOOLORCH_SEARCH_TYPES:
|
||||
# Search workers stay one-shot.
|
||||
text, p, c, is_local, extra, n_searches = _call_worker(worker, prompt, cfg)
|
||||
return text, p, c, is_local, extra, n_searches, 0
|
||||
@@ -417,6 +517,10 @@ def _swe_call_worker(
|
||||
is_local = backbone == "local"
|
||||
return (
|
||||
out["final_summary"] or out["answer"],
|
||||
out["tokens_in"], out["tokens_out"],
|
||||
is_local, 0.0, 0, int(out["turns"]),
|
||||
out["tokens_in"],
|
||||
out["tokens_out"],
|
||||
is_local,
|
||||
0.0,
|
||||
0,
|
||||
int(out["turns"]),
|
||||
)
|
||||
|
||||
@@ -93,13 +93,18 @@ class LLMJudgeScorer(Scorer):
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - re-raised below
|
||||
last_exc = exc
|
||||
if attempt == _JUDGE_MAX_RETRIES - 1 or not _is_retryable_judge_error(exc):
|
||||
if attempt == _JUDGE_MAX_RETRIES - 1 or not _is_retryable_judge_error(
|
||||
exc
|
||||
):
|
||||
raise
|
||||
delay = min(_JUDGE_BASE_DELAY_S * (2 ** attempt), _JUDGE_MAX_DELAY_S)
|
||||
delay = min(_JUDGE_BASE_DELAY_S * (2**attempt), _JUDGE_MAX_DELAY_S)
|
||||
delay += random.uniform(0.0, delay * 0.25) # jitter
|
||||
LOGGER.warning(
|
||||
"judge call failed (attempt %d/%d): %s — retrying in %.1fs",
|
||||
attempt + 1, _JUDGE_MAX_RETRIES, exc, delay,
|
||||
attempt + 1,
|
||||
_JUDGE_MAX_RETRIES,
|
||||
exc,
|
||||
delay,
|
||||
)
|
||||
time.sleep(delay)
|
||||
# Unreachable (loop either returns or raises), but keeps type-checkers happy.
|
||||
|
||||
@@ -22,6 +22,14 @@ import re
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from openjarvis.agents.hybrid.expert_registry import orchestrator_catalog
|
||||
from openjarvis.agents.hybrid.toolorchestra import rollout as _rollout_mod
|
||||
from openjarvis.agents.hybrid.toolorchestra.unified import (
|
||||
make_call_orchestrator,
|
||||
make_dispatch,
|
||||
)
|
||||
from openjarvis.evals.core.backend import InferenceBackend
|
||||
|
||||
# The served fine-tuned model sometimes over-emits the answer marker, e.g.
|
||||
# "FINAL_ANSWER: FINAL_ANSWER: 42" — a doubled prefix that breaks answer
|
||||
# extraction and auto-scores the sample 0. Collapse any run of FINAL_ANSWER
|
||||
@@ -34,20 +42,12 @@ def _clean_final_answer(text: str) -> str:
|
||||
marks = list(_FA_MARK.finditer(t))
|
||||
if not marks:
|
||||
return t
|
||||
answer = t[marks[-1].end():].strip()
|
||||
answer = t[marks[-1].end() :].strip()
|
||||
return f"FINAL_ANSWER: {answer}" if answer else t
|
||||
|
||||
|
||||
_OBS_CAP = 4000 # cap persisted observations so the eval JSONL stays readable
|
||||
|
||||
from openjarvis.agents.hybrid.expert_registry import orchestrator_catalog
|
||||
from openjarvis.agents.hybrid.toolorchestra import rollout as _rollout_mod
|
||||
from openjarvis.agents.hybrid.toolorchestra.unified import (
|
||||
make_call_orchestrator,
|
||||
make_dispatch,
|
||||
)
|
||||
from openjarvis.evals.core.backend import InferenceBackend
|
||||
|
||||
DEFAULT_ENDPOINT = "http://localhost:8001/v1"
|
||||
DEFAULT_MODEL = "qwen3-8b"
|
||||
|
||||
@@ -215,11 +215,17 @@ class OrchestratorBackend(InferenceBackend):
|
||||
{
|
||||
"reasoning": t.reasoning or "",
|
||||
"tool_name": t.tool_name,
|
||||
"real_model": anon_map.get(t.tool_name) if t.tool_name else None,
|
||||
"real_model": anon_map.get(t.tool_name)
|
||||
if t.tool_name
|
||||
else None,
|
||||
"arguments": t.arguments,
|
||||
"observation": (
|
||||
(t.observation or "")[:_OBS_CAP]
|
||||
+ ("…[truncated]" if t.observation and len(t.observation) > _OBS_CAP else "")
|
||||
+ (
|
||||
"…[truncated]"
|
||||
if t.observation and len(t.observation) > _OBS_CAP
|
||||
else ""
|
||||
)
|
||||
),
|
||||
}
|
||||
for t in (getattr(rollout, "turns", []) or [])
|
||||
|
||||
@@ -38,10 +38,10 @@ class Task:
|
||||
task_id: str
|
||||
question: str
|
||||
answer: str
|
||||
domain: str # coarse area: math / code / science / medical / chat / misc
|
||||
difficulty: str = "" # OpenThoughts difficulty tier (GeneralThought has none)
|
||||
dataset: str = "" # source dataset: GeneralThought / OpenThoughts3
|
||||
subsector: str = "" # fine source: NuminaMath / NHSQA / TACO / glaive / ...
|
||||
domain: str # coarse area: math / code / science / medical / chat / misc
|
||||
difficulty: str = "" # OpenThoughts difficulty tier (GeneralThought has none)
|
||||
dataset: str = "" # source dataset: GeneralThought / OpenThoughts3
|
||||
subsector: str = "" # fine source: NuminaMath / NHSQA / TACO / glaive / ...
|
||||
|
||||
@property
|
||||
def instruction(self) -> str:
|
||||
@@ -194,7 +194,9 @@ def _normalize_openthoughts(row: Dict[str, Any], *, index: int = 0) -> Optional[
|
||||
if not question or not answer:
|
||||
return None
|
||||
domain = str(row.get("domain") or "unknown").strip().lower() or "unknown"
|
||||
task_id = str(row.get("id") or row.get("source") or f"openthoughts-{index}") + f"-{index}"
|
||||
task_id = (
|
||||
str(row.get("id") or row.get("source") or f"openthoughts-{index}") + f"-{index}"
|
||||
)
|
||||
return Task(
|
||||
task_id=task_id,
|
||||
question=question,
|
||||
@@ -324,9 +326,7 @@ def load_grpo_prompts(*, n: int = 30000, seed: int = 42) -> List[Task]:
|
||||
per = max(n // 4 + 1, 1)
|
||||
pool: List[Task] = []
|
||||
pool.extend(load_generalthought(n=per, seed=seed))
|
||||
pool.extend(
|
||||
load_openthoughts(n_code=per, n_math=per, n_science=per, seed=seed)
|
||||
)
|
||||
pool.extend(load_openthoughts(n_code=per, n_math=per, n_science=per, seed=seed))
|
||||
|
||||
rng = random.Random(seed)
|
||||
rng.shuffle(pool)
|
||||
|
||||
@@ -20,7 +20,7 @@ from __future__ import annotations
|
||||
import re
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Optional
|
||||
from typing import Any, Dict, Iterable, Iterator, Optional
|
||||
|
||||
DATASET_ID = "hotpotqa/hotpot_qa"
|
||||
CONFIG = "fullwiki"
|
||||
@@ -32,8 +32,8 @@ class HotpotTask:
|
||||
task_id: str
|
||||
question: str
|
||||
answer: str
|
||||
level: str = "" # easy | medium | hard
|
||||
qtype: str = "" # comparison | bridge
|
||||
level: str = "" # easy | medium | hard
|
||||
qtype: str = "" # comparison | bridge
|
||||
|
||||
# Parity with ToolScaleTask so the rejection-sampling loop is dataset-agnostic.
|
||||
@property
|
||||
|
||||
@@ -22,9 +22,7 @@ import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable, List, Optional
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Callable, Iterable, List, Optional
|
||||
|
||||
from openjarvis.agents.hybrid.expert_registry import ExpertTool
|
||||
from openjarvis.agents.hybrid.toolorchestra.rollout import UnifiedRollout
|
||||
@@ -107,7 +105,9 @@ def _target_is_clean(roll: UnifiedRollout) -> bool:
|
||||
return False
|
||||
if any(m in fa for m in _ERR_MARKERS):
|
||||
return False
|
||||
if fa.count("<think>") != fa.count("</think>"): # unbalanced think tags (truncated OR stray </think>)
|
||||
if fa.count("<think>") != fa.count(
|
||||
"</think>"
|
||||
): # unbalanced think tags (truncated OR stray </think>)
|
||||
return False
|
||||
if _TRUNCATED_TAIL_RE.search(fa): # truncated mid-expression (e.g. "a=1, b=")
|
||||
return False
|
||||
@@ -150,28 +150,36 @@ def _target_is_clean(roll: UnifiedRollout) -> bool:
|
||||
# the correct hard-task answers. Keep the STRUCTURAL essay signals (many
|
||||
# numbered sections, markdown headers, tables) which still catch real essays.
|
||||
_marks = list(re.finditer(r"(?im)FINAL[_\s]?ANSWER\s*:?", fa))
|
||||
_ans = fa[_marks[-1].end():].strip() if _marks else fa
|
||||
if len(_ans) > 2000: # was 700 — too tight for code/math
|
||||
_ans = fa[_marks[-1].end() :].strip() if _marks else fa
|
||||
if len(_ans) > 2000: # was 700 — too tight for code/math
|
||||
return False
|
||||
# Tool-status echo as the final answer: on code tasks the orchestrator
|
||||
# sometimes ends with a file_write/shell status line ("Successfully wrote to
|
||||
# /tmp/x.py") instead of the actual answer — a non-answer that slips the
|
||||
# length/format checks. Reject. (Audit: ~code/math trajectories where the
|
||||
# trace collapsed but the row was still scored correct.)
|
||||
if re.search(r"(?i)\b(successfully (wrote|created|saved|executed|ran)|written to /|"
|
||||
r"file (written|saved|created)|no further actions?)\b", _ans):
|
||||
if re.search(
|
||||
r"(?i)\b(successfully (wrote|created|saved|executed|ran)|written to /|"
|
||||
r"file (written|saved|created)|no further actions?)\b",
|
||||
_ans,
|
||||
):
|
||||
return False
|
||||
if len(re.findall(r"(?m)^\s*\d+\.\s", _ans)) >= 6: # many numbered sections = essay (was 4)
|
||||
if (
|
||||
len(re.findall(r"(?m)^\s*\d+\.\s", _ans)) >= 6
|
||||
): # many numbered sections = essay (was 4)
|
||||
return False
|
||||
if re.search(r"(?m)^\s*#{1,6}\s", _ans): # markdown headers = essay
|
||||
if re.search(r"(?m)^\s*#{1,6}\s", _ans): # markdown headers = essay
|
||||
return False
|
||||
# Garbled / shouty final: a multi-word answer that's mostly UPPERCASE, or has
|
||||
# an absurdly long merged all-letter token, is decode garble — the audit found
|
||||
# e.g. "RABES PEPTETANUS BOOSTERS" (for "rabies PEP, tetanus boosters") passing.
|
||||
if len(_ans) > 20:
|
||||
_alpha = [c for c in _ans if c.isalpha()]
|
||||
if (_alpha and sum(c.isupper() for c in _alpha) / len(_alpha) > 0.7
|
||||
and len(_ans.split()) >= 3):
|
||||
if (
|
||||
_alpha
|
||||
and sum(c.isupper() for c in _alpha) / len(_alpha) > 0.7
|
||||
and len(_ans.split()) >= 3
|
||||
):
|
||||
return False
|
||||
if any(len(w) > 25 and w.isalpha() for w in _ans.split()):
|
||||
return False
|
||||
@@ -184,7 +192,9 @@ def _target_is_clean(roll: UnifiedRollout) -> bool:
|
||||
for t in roll.turns:
|
||||
if t.observation:
|
||||
obs_head = re.sub(r"\s+", " ", t.observation[:200]).strip()
|
||||
if fa_head and fa_head == obs_head and len(fa) > 200: # long final = copied tool dump (short relays are legit)
|
||||
if (
|
||||
fa_head and fa_head == obs_head and len(fa) > 200
|
||||
): # long final = copied tool dump (short relays are legit)
|
||||
return False
|
||||
# "routed" = delegated to a model EXPERT (not just a utility like web_search).
|
||||
# When anonymized, expert calls are the anon labels in anon_map; otherwise
|
||||
@@ -310,8 +320,8 @@ def generate_sft_dataset(
|
||||
written = 0
|
||||
correct_written = 0
|
||||
incorrect_written = 0
|
||||
dropped = 0 # tasks that produced no kept record
|
||||
tasks_solved = 0 # tasks with >=1 correct sample
|
||||
dropped = 0 # tasks that produced no kept record
|
||||
tasks_solved = 0 # tasks with >=1 correct sample
|
||||
domain_counts: Counter[str] = Counter()
|
||||
|
||||
def _work(task: TaskLike) -> tuple[TaskLike, List[tuple[UnifiedRollout, bool]]]:
|
||||
@@ -329,8 +339,12 @@ def generate_sft_dataset(
|
||||
nonlocal written, correct_written, incorrect_written
|
||||
reward = reward_fn(roll) if reward_fn else 0.0
|
||||
record = trajectory_to_record(
|
||||
task.task_id, task.instruction, tools, roll,
|
||||
reward=reward, domain=task.domain,
|
||||
task.task_id,
|
||||
task.instruction,
|
||||
tools,
|
||||
roll,
|
||||
reward=reward,
|
||||
domain=task.domain,
|
||||
)
|
||||
record["correct"] = ok
|
||||
record["kept"] = kept
|
||||
@@ -380,8 +394,9 @@ def generate_sft_dataset(
|
||||
if not correct:
|
||||
dropped += 1
|
||||
return
|
||||
for roll in sorted((r for r, _ in correct),
|
||||
key=lambda r: r.cost_usd)[:max_keep_per_task]:
|
||||
for roll in sorted((r for r, _ in correct), key=lambda r: r.cost_usd)[
|
||||
:max_keep_per_task
|
||||
]:
|
||||
_emit(fh, task, roll, True, kept=(roll is cheapest))
|
||||
|
||||
with out.open("w") as fh:
|
||||
@@ -400,15 +415,22 @@ def generate_sft_dataset(
|
||||
try:
|
||||
_, results = fut.result()
|
||||
except Exception as exc: # a task's worker died; count + skip
|
||||
logger.warning("task %s failed: %s",
|
||||
getattr(task, "task_id", "?"), exc)
|
||||
logger.warning(
|
||||
"task %s failed: %s", getattr(task, "task_id", "?"), exc
|
||||
)
|
||||
results = []
|
||||
_write(fh, task, results)
|
||||
done += 1
|
||||
if done % 50 == 0 or done == seen:
|
||||
logger.info("rejection-sampling: %d/%d tasks done "
|
||||
"(%d written, %d solved, %d dropped)",
|
||||
done, seen, written, tasks_solved, dropped)
|
||||
logger.info(
|
||||
"rejection-sampling: %d/%d tasks done "
|
||||
"(%d written, %d solved, %d dropped)",
|
||||
done,
|
||||
seen,
|
||||
written,
|
||||
tasks_solved,
|
||||
dropped,
|
||||
)
|
||||
|
||||
stats = {
|
||||
"out_path": str(out),
|
||||
@@ -425,8 +447,13 @@ def generate_sft_dataset(
|
||||
"domain_distribution": dict(domain_counts),
|
||||
}
|
||||
out.with_suffix(out.suffix + ".stats.json").write_text(json.dumps(stats, indent=2))
|
||||
logger.info("Wrote %d SFT records to %s (%d correct, %d incorrect)",
|
||||
written, out, correct_written, incorrect_written)
|
||||
logger.info(
|
||||
"Wrote %d SFT records to %s (%d correct, %d incorrect)",
|
||||
written,
|
||||
out,
|
||||
correct_written,
|
||||
incorrect_written,
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
|
||||
@@ -76,19 +76,25 @@ def normalize_row(row: Dict[str, Any], *, index: int = 0) -> ToolScaleTask:
|
||||
gold: List[GoldAction] = []
|
||||
for a in _as_list(crit.get("actions")):
|
||||
if isinstance(a, dict) and a.get("name"):
|
||||
gold.append(GoldAction(
|
||||
name=str(a["name"]),
|
||||
arguments=a.get("arguments") or a.get("args") or {},
|
||||
action_id=a.get("action_id"),
|
||||
))
|
||||
gold.append(
|
||||
GoldAction(
|
||||
name=str(a["name"]),
|
||||
arguments=a.get("arguments") or a.get("args") or {},
|
||||
action_id=a.get("action_id"),
|
||||
)
|
||||
)
|
||||
|
||||
required = _str_list(crit.get("communicate_info"))
|
||||
nl = _str_list(crit.get("nl_assertions"))
|
||||
|
||||
task_id = str(row.get("id") or row.get("task_id") or f"toolscale-{index}")
|
||||
return ToolScaleTask(
|
||||
task_id=task_id, domain=str(domain), instruction=str(instruction),
|
||||
gold_actions=gold, required_info=required, nl_assertions=nl,
|
||||
task_id=task_id,
|
||||
domain=str(domain),
|
||||
instruction=str(instruction),
|
||||
gold_actions=gold,
|
||||
required_info=required,
|
||||
nl_assertions=nl,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ prompt), ``assistant`` (reasoning + a ``<tool_call>`` tag, or the final answer),
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List
|
||||
|
||||
@@ -79,23 +78,26 @@ def _debox(text: str) -> str:
|
||||
# reasoning about its own harness. Never a good target: drop the whole line.
|
||||
_LEAK_RE = re.compile(
|
||||
r"(?im)^.*\b(?:"
|
||||
r"the user (?:is|was|wants|has|had|needs|'s|would|will|asked|'ve asked|has asked)\b|"
|
||||
r"asked me to\b|" # echoes of an injected nudge turn
|
||||
r"the user (?:is|was|wants|has|had|needs|'s|would|will|asked"
|
||||
r"|'ve asked|has asked)\b|"
|
||||
r"asked me to\b|" # echoes of an injected nudge turn
|
||||
r"make progress by delegat|"
|
||||
r"delegat\w+[^\n]*sub-?question|"
|
||||
r"(?:to |ask |send .{0,20}to )?one of the models\b|"
|
||||
r"instructions?|" # any self-reference to the rules
|
||||
r"instructions?|" # any self-reference to the rules
|
||||
r"requirement|"
|
||||
r"at least one (?:model|tool|expert)|" # "(call|invoke) at least one model"
|
||||
r"at least one (?:model|tool|expert)|" # "(call|invoke) at least one model"
|
||||
r"(?:call|invoke|route to|delegate to|use)\s+(?:a|one|at least one|the)\s+model\b|"
|
||||
r"route\s+(?:this|it|that)\b[^\n]{0,40}\bthrough\s+(?:a|one|the)\s+model\b|"
|
||||
r"without mentioning (?:any )?(?:tools?|reasoning|meta|steps)|"
|
||||
r"in the required format\b|"
|
||||
r"based on the (?:model|response|analysis|expert)|" # response-acknowledgment
|
||||
r"the model (?:provided|confirmed|gave|returned|correctly|analy\w+|respon\w+|said|indicated|identified)|"
|
||||
r"i (?:got|received|have) (?:a |the )?(?:comprehensive |clear |good |detailed )?answer\b|"
|
||||
r"based on the (?:model|response|analysis|expert)|" # response-acknowledgment
|
||||
r"the model (?:provided|confirmed|gave|returned|correctly|analy\w+"
|
||||
r"|respon\w+|said|indicated|identified)|"
|
||||
r"i (?:got|received|have) (?:a |the )?"
|
||||
r"(?:comprehensive |clear |good |detailed )?answer\b|"
|
||||
r"now i can (?:confidently )?(?:give|provide|state|answer)|"
|
||||
r"perfect[!,]|great[!,]|excellent[!,]|" # narration exclamations
|
||||
r"perfect[!,]|great[!,]|excellent[!,]|" # narration exclamations
|
||||
r"as an orchestrator|"
|
||||
r"whose job is to (?:route|delegate|orchestrate)|"
|
||||
r"testing whether i (?:need|have|should|must)\b|"
|
||||
@@ -121,7 +123,7 @@ def _scrub_meta(text: str) -> str:
|
||||
m = re.match(r"(?is)^\s*(<think>)\s*", body)
|
||||
if m:
|
||||
think = "<think>\n"
|
||||
body = body[m.end():]
|
||||
body = body[m.end() :]
|
||||
body = _LEAK_RE.sub("", body)
|
||||
body = _META_OPEN_RE.sub("", body).lstrip()
|
||||
return (think + body).strip() if think else body.strip()
|
||||
@@ -139,9 +141,9 @@ def _scrub_meta(text: str) -> str:
|
||||
# match, so legitimate ``<``/``>`` in math/code (``x < 3 and y > 2``) is left
|
||||
# untouched.
|
||||
_CONTROL_TOKEN_RE = re.compile(
|
||||
r"<\|[^>]*?\|>" # closed pipe token
|
||||
r"|<\|[^|>]*>" # unclosed pipe token
|
||||
r"|</?(?:start_of_turn|end_of_turn|eos|bos|pad|unk|s)>" # named angle tokens
|
||||
r"<\|[^>]*?\|>" # closed pipe token
|
||||
r"|<\|[^|>]*>" # unclosed pipe token
|
||||
r"|</?(?:start_of_turn|end_of_turn|eos|bos|pad|unk|s)>" # named angle tokens
|
||||
)
|
||||
|
||||
|
||||
@@ -173,7 +175,7 @@ def _final_answer_block(text: str) -> str:
|
||||
marks = list(re.finditer(r"(?im)FINAL[_\s]?ANSWER\s*:?", text))
|
||||
if marks:
|
||||
last = marks[-1]
|
||||
answer = _strip_control_tokens(text[last.end():].strip())
|
||||
answer = _strip_control_tokens(text[last.end() :].strip())
|
||||
# Final turn = the bare short answer only. Drop EVERYTHING before
|
||||
# FINAL_ANSWER — both the visible "Perfect! Based on the model's response…"
|
||||
# narration AND the final-turn <think>, which is just confirmatory
|
||||
@@ -186,7 +188,11 @@ def _final_answer_block(text: str) -> str:
|
||||
# Drop the think block (same rationale as above) — keep only the answer.
|
||||
_, _, answer = text.rpartition("</think>")
|
||||
answer = _strip_control_tokens(answer.strip())
|
||||
return f"FINAL_ANSWER: {answer}" if answer else f"FINAL_ANSWER: {_strip_control_tokens(_scrub_meta(text))}"
|
||||
return (
|
||||
f"FINAL_ANSWER: {answer}"
|
||||
if answer
|
||||
else f"FINAL_ANSWER: {_strip_control_tokens(_scrub_meta(text))}"
|
||||
)
|
||||
return f"FINAL_ANSWER: {_strip_control_tokens(text)}"
|
||||
|
||||
|
||||
@@ -209,29 +215,39 @@ def trajectory_to_record(
|
||||
if turn.tool_name is None:
|
||||
# Final-answer turn. turn.reasoning is the model's actual output for
|
||||
# this turn (which already contains the answer); render it once.
|
||||
conversations.append({
|
||||
"role": "assistant",
|
||||
"content": _final_answer_block(turn.reasoning or rollout.final_answer),
|
||||
})
|
||||
conversations.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": _final_answer_block(
|
||||
turn.reasoning or rollout.final_answer
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
tag = tool_call_tag(turn.tool_name, turn.arguments)
|
||||
reasoning = _scrub_meta(_normalize_think((turn.reasoning or "").rstrip()))
|
||||
conversations.append({
|
||||
"role": "assistant",
|
||||
"content": (reasoning + "\n" + tag).strip(),
|
||||
})
|
||||
conversations.append({
|
||||
"role": "tool",
|
||||
"name": turn.tool_name,
|
||||
"content": turn.observation or "",
|
||||
})
|
||||
conversations.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (reasoning + "\n" + tag).strip(),
|
||||
}
|
||||
)
|
||||
conversations.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"name": turn.tool_name,
|
||||
"content": turn.observation or "",
|
||||
}
|
||||
)
|
||||
|
||||
# If the rollout terminated on max_turns (no None turn), append the answer.
|
||||
if not rollout.turns or rollout.turns[-1].tool_name is not None:
|
||||
conversations.append({
|
||||
"role": "assistant",
|
||||
"content": _final_answer_block(rollout.final_answer),
|
||||
})
|
||||
conversations.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": _final_answer_block(rollout.final_answer),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"conversations": conversations,
|
||||
|
||||
@@ -173,7 +173,9 @@ def _gemini_judge(task: Task, prediction: str) -> Optional[bool]:
|
||||
# caller falls back to string/f1.
|
||||
global _JUDGE_CLIENT
|
||||
if _JUDGE_CLIENT is None:
|
||||
_JUDGE_CLIENT = anthropic.Anthropic(api_key=api_key, max_retries=0, timeout=15)
|
||||
_JUDGE_CLIENT = anthropic.Anthropic(
|
||||
api_key=api_key, max_retries=0, timeout=15
|
||||
)
|
||||
client = _JUDGE_CLIENT
|
||||
prompt = (
|
||||
"You are grading a candidate answer against a gold reference.\n"
|
||||
@@ -203,7 +205,7 @@ def _gemini_judge(task: Task, prediction: str) -> Optional[bool]:
|
||||
|
||||
|
||||
def verify_answer(task: Task, prediction: str) -> bool:
|
||||
"""Domain-dispatched correctness check for ``prediction`` against ``task.answer``."""
|
||||
"""Domain-dispatched correctness check for ``prediction`` vs ``task.answer``."""
|
||||
pred = (prediction or "").strip()
|
||||
if not pred or not (task.answer or "").strip():
|
||||
return False
|
||||
|
||||
@@ -296,12 +296,14 @@ class OrchestratorSFTTrainer:
|
||||
make_call_orchestrator,
|
||||
make_dispatch,
|
||||
)
|
||||
from openjarvis.learning.intelligence.orchestrator.sft_data import (
|
||||
reject_sample as _reject_sample,
|
||||
)
|
||||
from openjarvis.learning.intelligence.orchestrator.sft_data.datasets import (
|
||||
load_sft_tasks,
|
||||
)
|
||||
from openjarvis.learning.intelligence.orchestrator.sft_data.reject_sample import (
|
||||
generate_sft_dataset,
|
||||
)
|
||||
|
||||
generate_sft_dataset = _reject_sample.generate_sft_dataset
|
||||
from openjarvis.learning.intelligence.orchestrator.sft_data.verify import (
|
||||
make_verifier,
|
||||
)
|
||||
@@ -321,8 +323,10 @@ class OrchestratorSFTTrainer:
|
||||
def rollout_fn(task: Any) -> Any:
|
||||
try:
|
||||
return run_unified_rollout(
|
||||
task.instruction, tools,
|
||||
call_orchestrator=call_orch, dispatch=dispatch,
|
||||
task.instruction,
|
||||
tools,
|
||||
call_orchestrator=call_orch,
|
||||
dispatch=dispatch,
|
||||
max_turns=self.config.max_rollout_turns,
|
||||
)
|
||||
except Exception as exc: # network/key failures shouldn't kill the run
|
||||
|
||||
@@ -52,7 +52,6 @@ _MATH_FUNCS = {
|
||||
"radians": math.radians,
|
||||
"degrees": math.degrees,
|
||||
"exp": math.exp,
|
||||
"abs": abs,
|
||||
"fabs": math.fabs,
|
||||
"hypot": math.hypot,
|
||||
"factorial": math.factorial,
|
||||
|
||||
@@ -123,7 +123,7 @@ class WebSearchTool(BaseTool):
|
||||
request (DDGS timeout) AND wrap the call in a hard wall-clock deadline so
|
||||
a flaky search fast-fails instead of stalling data generation.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as _FTO
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from ddgs import DDGS
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ from openjarvis.agents.hybrid.expert_registry import (
|
||||
CATEGORY_BASIC,
|
||||
CATEGORY_CLOUD_FRONTIER,
|
||||
CATEGORY_LOCAL_OSS,
|
||||
ExpertTool,
|
||||
KIND_MODEL,
|
||||
KIND_TOOL,
|
||||
ExpertTool,
|
||||
build_tool_specs,
|
||||
default_catalog,
|
||||
openjarvis_tool,
|
||||
@@ -28,8 +28,14 @@ def test_each_model_is_its_own_tool():
|
||||
cat = default_catalog()
|
||||
names = {t.name for t in cat}
|
||||
# Distinct model tools, each with its own name.
|
||||
for n in ("gpt_5", "gpt_5_mini", "qwen3_32b", "qwen_2_5_coder_32b_instruct",
|
||||
"llama_3_3_70b_instruct", "claude_opus_4_7"):
|
||||
for n in (
|
||||
"gpt_5",
|
||||
"gpt_5_mini",
|
||||
"qwen3_32b",
|
||||
"qwen_2_5_coder_32b_instruct",
|
||||
"llama_3_3_70b_instruct",
|
||||
"claude_opus_4_7",
|
||||
):
|
||||
assert n in names, f"missing model tool {n}"
|
||||
# No meta-tool / slot vocabulary leaks in.
|
||||
assert "answer" not in names and "enhance_reasoning" not in names
|
||||
@@ -56,7 +62,9 @@ def test_invalid_tool_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
ExpertTool(name="x", kind="bogus", backend_type="openai", summary="", model="m")
|
||||
with pytest.raises(ValueError):
|
||||
ExpertTool(name="x", kind=KIND_MODEL, backend_type="openai", summary="", model=None)
|
||||
ExpertTool(
|
||||
name="x", kind=KIND_MODEL, backend_type="openai", summary="", model=None
|
||||
)
|
||||
|
||||
|
||||
def test_specs_shape_and_pricing_in_description():
|
||||
@@ -79,20 +87,31 @@ def test_sample_is_deterministic_and_well_formed():
|
||||
b = sample_tool_config(cat, rng=random.Random(0), min_tools=4)
|
||||
assert [t.name for t in a] == [t.name for t in b] # deterministic
|
||||
assert len(a) >= 4
|
||||
assert any(t.kind == KIND_MODEL for t in a) # can reason
|
||||
assert any(t.kind != KIND_MODEL for t in a) # can act
|
||||
assert any(t.kind == KIND_MODEL for t in a) # can reason
|
||||
assert any(t.kind != KIND_MODEL for t in a) # can act
|
||||
assert {t.name for t in a} <= {t.name for t in cat} # subset
|
||||
|
||||
|
||||
def test_price_jitter_changes_prices_reproducibly():
|
||||
cat = default_catalog()
|
||||
base = {t.name: t for t in sample_tool_config(cat, rng=random.Random(3), min_tools=8)}
|
||||
jit = {t.name: t for t in sample_tool_config(
|
||||
cat, rng=random.Random(3), min_tools=8, price_jitter=0.5)}
|
||||
base = {
|
||||
t.name: t for t in sample_tool_config(cat, rng=random.Random(3), min_tools=8)
|
||||
}
|
||||
jit = {
|
||||
t.name: t
|
||||
for t in sample_tool_config(
|
||||
cat, rng=random.Random(3), min_tools=8, price_jitter=0.5
|
||||
)
|
||||
}
|
||||
# Same subset (same seed/sequence up to jitter draws), but model prices move.
|
||||
moved = [n for n in base
|
||||
if base[n].kind == KIND_MODEL and base[n].price_in
|
||||
and n in jit and jit[n].price_in != base[n].price_in]
|
||||
moved = [
|
||||
n
|
||||
for n in base
|
||||
if base[n].kind == KIND_MODEL
|
||||
and base[n].price_in
|
||||
and n in jit
|
||||
and jit[n].price_in != base[n].price_in
|
||||
]
|
||||
assert moved, "expected jitter to change at least one model price"
|
||||
for n in moved:
|
||||
f = jit[n].price_in / base[n].price_in
|
||||
@@ -137,8 +156,12 @@ def test_orchestrator_catalog_two_model_classes_plus_basics():
|
||||
assert by["gpt_5_5"].category == CATEGORY_CLOUD_FRONTIER
|
||||
assert by["claude_opus_4_8"].category == CATEGORY_CLOUD_FRONTIER
|
||||
# Default routing for every model tool is OpenRouter (no servers required).
|
||||
for n in ("qwen3_5_9b", "qwen3_6_27b_fp8", "qwen3_5_122b_a10b_fp8",
|
||||
"qwen3_5_397b_a17b_fp8"):
|
||||
for n in (
|
||||
"qwen3_5_9b",
|
||||
"qwen3_6_27b_fp8",
|
||||
"qwen3_5_122b_a10b_fp8",
|
||||
"qwen3_5_397b_a17b_fp8",
|
||||
):
|
||||
assert by[n].category == CATEGORY_LOCAL_OSS
|
||||
assert by[n].backend_type == "openrouter"
|
||||
assert by[n].base_url is None
|
||||
@@ -175,7 +198,8 @@ def test_orchestrator_local_models_get_base_url_when_provided():
|
||||
local_endpoints={
|
||||
"Qwen/Qwen3.5-9B": "http://x/v1",
|
||||
"Qwen/Qwen3.6-27B-FP8": "http://y/v1",
|
||||
})
|
||||
}
|
||||
)
|
||||
by = tools_by_name(cat)
|
||||
assert by["qwen3_5_9b"].backend_type == "vllm"
|
||||
assert by["qwen3_5_9b"].base_url == "http://x/v1"
|
||||
@@ -213,7 +237,8 @@ def test_orchestrator_model_backends_override():
|
||||
|
||||
def test_orchestrator_openrouter_slug_override():
|
||||
cat = orchestrator_catalog(
|
||||
openrouter_slugs={"Qwen/Qwen3.5-9B": "qwen/qwen3.5-9b-custom"})
|
||||
openrouter_slugs={"Qwen/Qwen3.5-9B": "qwen/qwen3.5-9b-custom"}
|
||||
)
|
||||
by = tools_by_name(cat)
|
||||
assert by["qwen3_5_9b"].model == "qwen/qwen3.5-9b-custom"
|
||||
|
||||
@@ -236,12 +261,19 @@ def test_openjarvis_tool_bridges_real_tool_with_custom_schema():
|
||||
|
||||
|
||||
def test_build_tool_specs_includes_category_for_bridged_tools():
|
||||
specs = build_tool_specs([
|
||||
openjarvis_tool("calculator", summary="Math.",
|
||||
params={"type": "object",
|
||||
"properties": {"expression": {"type": "string"}},
|
||||
"required": ["expression"]}),
|
||||
])
|
||||
specs = build_tool_specs(
|
||||
[
|
||||
openjarvis_tool(
|
||||
"calculator",
|
||||
summary="Math.",
|
||||
params={
|
||||
"type": "object",
|
||||
"properties": {"expression": {"type": "string"}},
|
||||
"required": ["expression"],
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
assert specs[0]["function"]["category"] == CATEGORY_BASIC
|
||||
assert "expression" in specs[0]["function"]["parameters"]["properties"]
|
||||
|
||||
@@ -250,6 +282,9 @@ def test_to_worker_dict_maps_backend():
|
||||
cat = default_catalog(local_model="qwen3:8b", local_endpoint="http://x/v1")
|
||||
by = tools_by_name(cat)
|
||||
assert to_worker_dict(by["gpt_5"]) == {
|
||||
"name": "gpt_5", "type": "openai", "model": "gpt-5"}
|
||||
"name": "gpt_5",
|
||||
"type": "openai",
|
||||
"model": "gpt-5",
|
||||
}
|
||||
local = to_worker_dict(by["qwen3_8b"])
|
||||
assert local["type"] == "vllm" and local["base_url"] == "http://x/v1"
|
||||
|
||||
@@ -60,7 +60,10 @@ OPENTHOUGHTS_ROWS = [
|
||||
"difficulty": 5,
|
||||
"conversations": [
|
||||
{"from": "human", "value": "What is 6 times 7?"},
|
||||
{"from": "gpt", "value": "<think>6*7=42</think> The answer is \\boxed{42}."},
|
||||
{
|
||||
"from": "gpt",
|
||||
"value": "<think>6*7=42</think> The answer is \\boxed{42}.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -99,9 +102,7 @@ def test_load_generalthought_respects_n():
|
||||
|
||||
def test_load_openthoughts_fields_and_domains():
|
||||
tasks = list(
|
||||
load_openthoughts(
|
||||
n_code=5, n_math=5, n_science=5, source=OPENTHOUGHTS_ROWS
|
||||
)
|
||||
load_openthoughts(n_code=5, n_math=5, n_science=5, source=OPENTHOUGHTS_ROWS)
|
||||
)
|
||||
assert all(isinstance(t, Task) for t in tasks)
|
||||
assert len(tasks) == 3
|
||||
|
||||
@@ -55,7 +55,7 @@ def test_run_unified_rollout_terminates_on_no_tool_call():
|
||||
assert name in by
|
||||
|
||||
scripted = [
|
||||
(f"reason 1\n", [(name, {"input": "do step 1"})], 5, 5),
|
||||
("reason 1\n", [(name, {"input": "do step 1"})], 5, 5),
|
||||
("here is the answer", [], 3, 3), # no tool call -> terminate
|
||||
]
|
||||
calls = iter(scripted)
|
||||
@@ -69,7 +69,11 @@ def test_run_unified_rollout_terminates_on_no_tool_call():
|
||||
return (f"OBS for {tool.name}", 0.01, 10, False)
|
||||
|
||||
roll = run_unified_rollout(
|
||||
"What is X?", tools, call_orchestrator=call_orch, dispatch=dispatch, max_turns=5,
|
||||
"What is X?",
|
||||
tools,
|
||||
call_orchestrator=call_orch,
|
||||
dispatch=dispatch,
|
||||
max_turns=5,
|
||||
)
|
||||
assert roll.final_answer == "here is the answer"
|
||||
assert roll.num_tool_calls == 1
|
||||
@@ -81,23 +85,34 @@ def test_serialize_record_shape_and_tool_call_tags():
|
||||
tools = default_catalog()
|
||||
roll = UnifiedRollout(
|
||||
turns=[
|
||||
UnifiedTurn(reasoning="think", tool_name="qwen3_32b",
|
||||
arguments={"input": "q"}, observation="obs"),
|
||||
UnifiedTurn(
|
||||
reasoning="think",
|
||||
tool_name="qwen3_32b",
|
||||
arguments={"input": "q"},
|
||||
observation="obs",
|
||||
),
|
||||
# The final turn's reasoning IS the model's real final output (it
|
||||
# already contains the answer). The serializer now renders this turn
|
||||
# via _final_answer_block(turn.reasoning), not rollout.final_answer.
|
||||
UnifiedTurn(reasoning="The result is 42.\nFINAL_ANSWER: 42",
|
||||
tool_name=None),
|
||||
UnifiedTurn(
|
||||
reasoning="The result is 42.\nFINAL_ANSWER: 42", tool_name=None
|
||||
),
|
||||
],
|
||||
final_answer="42", cost_usd=0.02, tokens=30, num_tool_calls=1,
|
||||
final_answer="42",
|
||||
cost_usd=0.02,
|
||||
tokens=30,
|
||||
num_tool_calls=1,
|
||||
)
|
||||
rec = trajectory_to_record("t1", "Q?", tools, roll, reward=0.5, domain="math")
|
||||
roles = [m["role"] for m in rec["conversations"]]
|
||||
assert roles[0] == "system" and roles[1] == "user"
|
||||
assert "tool" in roles and roles[-1] == "assistant"
|
||||
# Tool call is emitted as a <tool_call> tag (what the parser reads back).
|
||||
assert any("<tool_call>" in m["content"] and "qwen3_32b" in m["content"]
|
||||
for m in rec["conversations"] if m["role"] == "assistant")
|
||||
assert any(
|
||||
"<tool_call>" in m["content"] and "qwen3_32b" in m["content"]
|
||||
for m in rec["conversations"]
|
||||
if m["role"] == "assistant"
|
||||
)
|
||||
assert "FINAL_ANSWER: 42" in rec["conversations"][-1]["content"]
|
||||
assert rec["reward"] == 0.5 and rec["domain"] == "math"
|
||||
|
||||
@@ -112,10 +127,15 @@ def test_gold_coverage_verify():
|
||||
final_answer="done",
|
||||
)
|
||||
missing = UnifiedRollout(
|
||||
turns=[UnifiedTurn("", "cancel", {}, "ok")], final_answer="done")
|
||||
turns=[UnifiedTurn("", "cancel", {}, "ok")], final_answer="done"
|
||||
)
|
||||
empty_ans = UnifiedRollout(
|
||||
turns=[UnifiedTurn("", "cancel", {}, "ok"),
|
||||
UnifiedTurn("", "refund", {}, "ok")], final_answer="")
|
||||
turns=[
|
||||
UnifiedTurn("", "cancel", {}, "ok"),
|
||||
UnifiedTurn("", "refund", {}, "ok"),
|
||||
],
|
||||
final_answer="",
|
||||
)
|
||||
assert gold_coverage_verify(t, good) is True
|
||||
assert gold_coverage_verify(t, missing) is False
|
||||
assert gold_coverage_verify(t, empty_ans) is False
|
||||
@@ -123,10 +143,16 @@ def test_gold_coverage_verify():
|
||||
|
||||
def test_generate_sft_dataset_end_to_end(tmp_path):
|
||||
tools = default_catalog()
|
||||
tasks = [normalize_row(_RAW_ROW), normalize_row({
|
||||
**_RAW_ROW, "id": "unsolvable",
|
||||
"evaluation_criteria": {"actions": [{"name": "never_called"}]},
|
||||
})]
|
||||
tasks = [
|
||||
normalize_row(_RAW_ROW),
|
||||
normalize_row(
|
||||
{
|
||||
**_RAW_ROW,
|
||||
"id": "unsolvable",
|
||||
"evaluation_criteria": {"actions": [{"name": "never_called"}]},
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
def rollout_fn(task):
|
||||
# Solve the first task; always miss the gold action of the second.
|
||||
@@ -139,18 +165,26 @@ def test_generate_sft_dataset_end_to_end(tmp_path):
|
||||
],
|
||||
# num_tool_calls must reflect the two routed calls: the structural
|
||||
# _target_is_clean gate drops a trajectory with num_tool_calls < 1.
|
||||
final_answer="refunded $20.90", cost_usd=0.03, num_tool_calls=2,
|
||||
final_answer="refunded $20.90",
|
||||
cost_usd=0.03,
|
||||
num_tool_calls=2,
|
||||
)
|
||||
return UnifiedRollout(turns=[UnifiedTurn("x", "cancel", {}, "ok")],
|
||||
final_answer="nope", cost_usd=0.05)
|
||||
return UnifiedRollout(
|
||||
turns=[UnifiedTurn("x", "cancel", {}, "ok")],
|
||||
final_answer="nope",
|
||||
cost_usd=0.05,
|
||||
)
|
||||
|
||||
out = tmp_path / "sft.jsonl"
|
||||
stats = generate_sft_dataset(
|
||||
str(out), tasks=tasks, tools=tools, rollout_fn=rollout_fn,
|
||||
str(out),
|
||||
tasks=tasks,
|
||||
tools=tools,
|
||||
rollout_fn=rollout_fn,
|
||||
samples_per_task=2,
|
||||
)
|
||||
assert stats["tasks_seen"] == 2
|
||||
assert stats["records_written"] == 1 # only the solvable task
|
||||
assert stats["records_written"] == 1 # only the solvable task
|
||||
assert stats["tasks_dropped"] == 1
|
||||
lines = out.read_text().strip().splitlines()
|
||||
assert len(lines) == 1
|
||||
|
||||
@@ -30,11 +30,23 @@ def _canned_rollout(task: Task) -> UnifiedRollout:
|
||||
# One tool turn + a final-answer turn that echoes the gold answer.
|
||||
return UnifiedRollout(
|
||||
turns=[
|
||||
UnifiedTurn(reasoning="let me compute", tool_name="code_interpreter",
|
||||
arguments={"code": "print(6*7)"}, observation="42"),
|
||||
UnifiedTurn(reasoning=f"The tool returned {task.answer}. FINAL_ANSWER: {task.answer}", tool_name=None),
|
||||
UnifiedTurn(
|
||||
reasoning="let me compute",
|
||||
tool_name="code_interpreter",
|
||||
arguments={"code": "print(6*7)"},
|
||||
observation="42",
|
||||
),
|
||||
UnifiedTurn(
|
||||
reasoning=(
|
||||
f"The tool returned {task.answer}. FINAL_ANSWER: {task.answer}"
|
||||
),
|
||||
tool_name=None,
|
||||
),
|
||||
],
|
||||
final_answer=task.answer, cost_usd=0.01, tokens=20, num_tool_calls=1,
|
||||
final_answer=task.answer,
|
||||
cost_usd=0.01,
|
||||
tokens=20,
|
||||
num_tool_calls=1,
|
||||
)
|
||||
|
||||
|
||||
@@ -44,7 +56,7 @@ def test_build_v1_writes_expected_conversations_shape(tmp_path, monkeypatch):
|
||||
# Accept-all verifier (the real make_verifier may hit Gemini / math checkers).
|
||||
monkeypatch.setattr(
|
||||
"openjarvis.learning.intelligence.orchestrator.sft_data.verify.make_verifier",
|
||||
lambda: (lambda task, rollout: True),
|
||||
lambda: lambda task, rollout: True,
|
||||
)
|
||||
from openjarvis.learning.intelligence.orchestrator.sft_data.verify import (
|
||||
make_verifier,
|
||||
@@ -105,23 +117,36 @@ def test_driver_runs_end_to_end_with_fakes(tmp_path, monkeypatch):
|
||||
drv = _load_driver()
|
||||
|
||||
monkeypatch.setattr(drv, "load_sft_tasks", _fake_tasks)
|
||||
monkeypatch.setattr(drv, "run_unified_rollout",
|
||||
lambda question, tools, **kw: _canned_rollout(_fake_tasks()[0]))
|
||||
monkeypatch.setattr(drv, "make_verifier", lambda: (lambda task, rollout: True))
|
||||
monkeypatch.setattr(
|
||||
drv,
|
||||
"run_unified_rollout",
|
||||
lambda question, tools, **kw: _canned_rollout(_fake_tasks()[0]),
|
||||
)
|
||||
monkeypatch.setattr(drv, "make_verifier", lambda: lambda task, rollout: True)
|
||||
# make_call_orchestrator builds an OpenAI client lazily, so it never connects
|
||||
# here (run_unified_rollout is stubbed out).
|
||||
|
||||
# The driver treats --out as a TAG and always writes to
|
||||
# data/orchestrator/raw/<label>_<MMDD>[_<tag>]/data.jsonl (relative to cwd).
|
||||
# chdir into tmp_path so the run folder lands in the test's temp dir.
|
||||
# <OJ_DATA_ROOT>/raw/<label>_<MMDD>[_<tag>]/data.jsonl, defaulting to a
|
||||
# cwd-relative data/orchestrator. Drop OJ_DATA_ROOT (the repo .env exports
|
||||
# it) so the run folder lands in the test's temp dir and not the real
|
||||
# experiments tree, then chdir there.
|
||||
monkeypatch.delenv("OJ_DATA_ROOT", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
rc = drv.main([
|
||||
"--out", "v1",
|
||||
"--samples-per-task", "1",
|
||||
"--max-tasks", "2",
|
||||
])
|
||||
rc = drv.main(
|
||||
[
|
||||
"--out",
|
||||
"v1",
|
||||
"--samples-per-task",
|
||||
"1",
|
||||
"--max-tasks",
|
||||
"2",
|
||||
]
|
||||
)
|
||||
assert rc == 0
|
||||
produced = list((tmp_path / "data" / "orchestrator" / "raw").glob("*_v1/data.jsonl"))
|
||||
produced = list(
|
||||
(tmp_path / "data" / "orchestrator" / "raw").glob("*_v1/data.jsonl")
|
||||
)
|
||||
assert len(produced) == 1
|
||||
lines = produced[0].read_text().strip().splitlines()
|
||||
assert len(lines) == 2
|
||||
|
||||
@@ -32,8 +32,12 @@ class _CannedRollout:
|
||||
# arguments / observation), so turns must be real UnifiedTurn-shaped
|
||||
# objects, not bare object() placeholders.
|
||||
self.turns = [
|
||||
UnifiedTurn(reasoning="let me search", tool_name="web_search",
|
||||
arguments={"query": "x"}, observation="result"),
|
||||
UnifiedTurn(
|
||||
reasoning="let me search",
|
||||
tool_name="web_search",
|
||||
arguments={"query": "x"},
|
||||
observation="result",
|
||||
),
|
||||
]
|
||||
|
||||
def tool_calls(self):
|
||||
|
||||
@@ -27,9 +27,9 @@ from openjarvis.learning.intelligence.orchestrator.sft_data.unified_serialize im
|
||||
_strip_control_tokens,
|
||||
)
|
||||
|
||||
|
||||
# --- serializer strips control tokens, leaving a clean FINAL_ANSWER line ------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, expected_answer",
|
||||
[
|
||||
@@ -74,16 +74,24 @@ def test_strip_helper_handles_nested_and_whitespace() -> None:
|
||||
|
||||
# --- clean gate: salvage vs reject -------------------------------------------
|
||||
|
||||
|
||||
def _roll(final_answer: str) -> UnifiedRollout:
|
||||
"""A minimal well-formed rollout (one expert call, clean obs) whose only
|
||||
variable is the final answer."""
|
||||
return UnifiedRollout(
|
||||
turns=[
|
||||
UnifiedTurn(reasoning="route it", tool_name="expert_a",
|
||||
arguments={"input": "q"}, observation="valid observation"),
|
||||
UnifiedTurn(
|
||||
reasoning="route it",
|
||||
tool_name="expert_a",
|
||||
arguments={"input": "q"},
|
||||
observation="valid observation",
|
||||
),
|
||||
UnifiedTurn(reasoning=final_answer, tool_name=None),
|
||||
],
|
||||
final_answer=final_answer, cost_usd=0.01, tokens=10, num_tool_calls=1,
|
||||
final_answer=final_answer,
|
||||
cost_usd=0.01,
|
||||
tokens=10,
|
||||
num_tool_calls=1,
|
||||
anon_map={"expert_a": "gpt"},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user