diff --git a/scripts/ablation/make_report.py b/scripts/ablation/make_report.py new file mode 100644 index 00000000..da4782a2 --- /dev/null +++ b/scripts/ablation/make_report.py @@ -0,0 +1,554 @@ +#!/usr/bin/env python3 +"""Build /matx/u/aspark/.openjarvis/experiments/hybrid/docs/index.html +plus a set of PNG pareto plots in the same dir. The HTML embeds the PNGs +via so the browser never has to render anything itself — +just shows static images. + +Re-running is idempotent: regenerates index.html + all PNGs from whatever +cells have summary.json at run-time. +""" +from __future__ import annotations + +import html as html_lib +import json +import sys +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from statistics import median +from typing import Callable, Optional + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +HYBRID_ROOT = Path("/matx/u/aspark/.openjarvis/experiments/hybrid") +DOCS_DIR = HYBRID_ROOT / "docs" +OUT_HTML = DOCS_DIR / "index.html" +RAW_MD = DOCS_DIR / "results-table.md" +PLOTS_DIR = DOCS_DIR / "plots-n100" +PLOTS_DIR.mkdir(exist_ok=True) + +CLOUD_TOKENS = { + "opus47": "Opus 4.7", + "haiku45": "Haiku 4.5", + "gpt5mini": "GPT-5 mini", + "gpt5": "GPT-5.5", + "gemini25pro": "Gemini 3.1 Pro", + "gemini25flash": "Gemini 3.1 Flash", +} +CLOUD_TOKEN_ORDER = [ + "gpt5mini", "gemini25flash", "gemini25pro", "gpt5", "opus47", "haiku45", +] +BENCH_LABELS = {"gaia": "GAIA", "swe": "SWE-bench"} + +PARADIGM_COLOR = { + "skillorchestra": "#3b82f6", + "cloud-only": "#9ca3af", + "minions": "#10b981", + "advisors": "#f59e0b", +} +PARADIGM_ORDER = ["cloud-only", "skillorchestra", "minions", "advisors"] + + +def parse_cell_name(name: str) -> Optional[dict]: + if not name.endswith("-n100"): + return None + parts = name[:-len("-n100")].split("-") + if not parts: + return None + paradigm = parts[0] + rest = parts[1:] + if paradigm == "cloud": + if rest and rest[0] == "only": + rest = rest[1:] + paradigm = "cloud-only" + local = None + elif paradigm in ("skillorchestra", "minions", "advisors"): + if not rest: + return None + local = rest[0] + rest = rest[1:] + else: + return None + if len(rest) < 2: + return None + cloud_token = None + for tok in CLOUD_TOKEN_ORDER: + if tok in rest: + cloud_token = tok + break + if cloud_token is None: + return None + bench = rest[-1] + if bench not in BENCH_LABELS: + return None + return {"paradigm": paradigm, "local": local, "cloud": cloud_token, "bench": bench} + + +@dataclass +class Cell: + name: str + paradigm: str + local: Optional[str] + cloud: str + bench: str + accuracy: float + cost_usd: float + tokens_local_total: int + tokens_cloud_total: int + n_done: int + latency_med_s: Optional[float] + tool_calls_mean: Optional[float] + web_searches_mean: Optional[float] + + +def load_cells() -> list[Cell]: + out = [] + for d in sorted(HYBRID_ROOT.iterdir()): + if not (d.is_dir() and d.name.endswith("-n100")): + continue + sj = d / "summary.json" + if not sj.exists(): + continue + parsed = parse_cell_name(d.name) + if parsed is None: + continue + try: + s = json.loads(sj.read_text()) + except Exception: + continue + # latency median + rj = d / "results.jsonl" + lat_med = None + if rj.exists(): + lats = [] + for line in rj.read_text().splitlines(): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except Exception: + continue + v = row.get("latency_s") + if isinstance(v, (int, float)): + lats.append(float(v)) + if lats: + lat_med = median(lats) + # tool calls mean — count any flavor of tool use per task: + # * SWE / mini-swe-agent fires events with kind="_bash" (one per bash exec) + # * GAIA / agentic flows record tool calls inline on anthropic/openai events + # as `tool_calls: [...]` (anthropic server_tool_use, openai function calls) + # and Anthropic-side web search uses `n_web_searches` + # * minions does a hidden pre-fetch step recorded under metadata.traces.prefetch.n_searches + # Sum all of them so the metric isn't silently zero on GAIA. + # + # `web_searches_mean` is the same idea but ONLY counts Anthropic + # server-side web_search invocations — useful to see how often + # GAIA cells actually leveraged the new opt-in web_search tool. + tc_mean = None + ws_mean = None + logs = d / "logs" + if logs.is_dir(): + counts = [] + ws_counts = [] + for log_p in logs.iterdir(): + if not log_p.name.endswith(".json"): + continue + try: + lg = json.loads(log_p.read_text()) + except Exception: + continue + ev = lg.get("events") or [] + bash_c = sum(1 for e in ev if isinstance(e, dict) + and isinstance(e.get("kind"), str) + and "_bash" in e["kind"]) + tc_c = sum(len(e.get("tool_calls") or []) + for e in ev if isinstance(e, dict)) + ws_c = sum(int(e.get("n_web_searches") or 0) + for e in ev if isinstance(e, dict)) + # n_web_searches is Anthropic's server-side web_search count; it's + # already represented as one tool_call per search above, so don't + # double-count — only fall back to it if tool_calls list is empty. + if tc_c == 0: + tc_c = ws_c + # prefetch (minions) — recorded out-of-band in metadata.traces.prefetch + meta = lg.get("metadata") or {} + pf = ((meta.get("traces") or {}).get("prefetch") or {}) if isinstance(meta, dict) else {} + pf_c = int(pf.get("n_searches") or 0) if isinstance(pf, dict) else 0 + counts.append(bash_c + tc_c + pf_c) + ws_counts.append(ws_c + pf_c) + if counts: + tc_mean = sum(counts) / len(counts) + if ws_counts: + ws_mean = sum(ws_counts) / len(ws_counts) + out.append(Cell( + name=d.name, + paradigm=parsed["paradigm"], + local=parsed["local"], + cloud=parsed["cloud"], + bench=parsed["bench"], + accuracy=float(s.get("accuracy") or 0.0), + cost_usd=float(s.get("cost_usd_total") or 0.0), + tokens_local_total=int(s.get("tokens_local_total") or 0), + tokens_cloud_total=int(s.get("tokens_cloud_total") or 0), + n_done=int(s.get("n_done") or 0), + latency_med_s=lat_med, + tool_calls_mean=tc_mean, + web_searches_mean=ws_mean, + )) + return out + + +@dataclass +class Axis: + key: str + title: str + description: str + filter_fn: Callable[[Cell], bool] + + +def axis_definitions() -> list[Axis]: + return [ + Axis("all-cells", "0. All cells overview", + "Every cell on one plot. Color = paradigm. Pareto frontier at a glance.", + lambda c: True), + Axis("cloud-anthropic", "1. Cloud-size within Anthropic", + "Local = Qwen-27B; vary Anthropic cloud (Opus 4.7 vs Haiku 4.5). All paradigms overlaid.", + lambda c: c.cloud in ("opus47", "haiku45")), + Axis("cloud-openai", "2. Cloud-size within OpenAI", + "Local = Qwen-27B; vary OpenAI cloud (GPT-5.5 vs GPT-5 mini). All paradigms overlaid.", + lambda c: c.cloud in ("gpt5", "gpt5mini")), + Axis("cloud-google", "3. Cloud-size within Google", + "Local = Qwen-27B; vary Google cloud (Gemini 3.1 Pro vs Flash). All paradigms overlaid.", + lambda c: c.cloud in ("gemini25pro", "gemini25flash")), + Axis("cloud-family-frontier", "4. Cloud-family — frontier tier", + "Compare frontier clouds across vendors (Opus 4.7, GPT-5.5, Gemini 3.1 Pro).", + lambda c: c.cloud in ("opus47", "gpt5", "gemini25pro")), + Axis("cloud-family-mini", "5. Cloud-family — mini/flash tier", + "Compare cost-floor clouds across vendors (Haiku 4.5, GPT-5 mini, Gemini 3.1 Flash).", + lambda c: c.cloud in ("haiku45", "gpt5mini", "gemini25flash")), + Axis("paradigm-skillorch", "6. Skillorchestra only", + "Just the skillorchestra cells. See how its router behaves across cloud choices.", + lambda c: c.paradigm == "skillorchestra"), + Axis("paradigm-cloud-only", "7. Cloud-only baseline", + "Baseline cloud-only runs across all 6 clouds — no local model in the loop.", + lambda c: c.paradigm == "cloud-only"), + ] + + +METRIC_SPECS = [ + ("cost_usd", "Cost (USD)", True), + ("latency_med_s", "Latency median (s)", True), + ("tokens_cloud_total", "Tokens cloud (total)", True), +] + + +def render_axis_png(axis: Axis, cells: list[Cell]) -> Optional[Path]: + pts = [c for c in cells if axis.filter_fn(c)] + if not pts: + return None + n_metrics = len(METRIC_SPECS) + fig, axes = plt.subplots( + n_metrics, 2, + figsize=(15, 4.2 * n_metrics), + squeeze=False, + ) + fig.suptitle(axis.title, fontsize=16, fontweight="bold", y=0.998) + used_paradigms = sorted({c.paradigm for c in pts}, key=lambda p: PARADIGM_ORDER.index(p) if p in PARADIGM_ORDER else 99) + + for row_idx, (mkey, mlabel, logx) in enumerate(METRIC_SPECS): + for col_idx, bench in enumerate(("gaia", "swe")): + ax = axes[row_idx][col_idx] + bench_pts = [c for c in pts if c.bench == bench] + for p in used_paradigms: + p_pts = [c for c in bench_pts if c.paradigm == p] + xs, ys, labels = [], [], [] + for c in p_pts: + v = getattr(c, mkey) + if v is None: + continue + if logx and v <= 0: + continue + xs.append(v); ys.append(c.accuracy); labels.append(CLOUD_TOKENS.get(c.cloud, c.cloud)) + if xs: + ax.scatter(xs, ys, c=PARADIGM_COLOR.get(p, "#000"), + s=120, alpha=0.85, edgecolors="white", linewidths=1.5, + label=p if (row_idx == 0 and col_idx == 0) else None) + for x, y, lbl in zip(xs, ys, labels): + ax.annotate(lbl, (x, y), xytext=(6, 6), textcoords="offset points", + fontsize=9, color="#1f2937", alpha=0.9) + if logx: + ax.set_xscale("log") + # Generous y-axis: 0 → max(observed)+0.15, floored at 0.7 so small numbers don't look cramped + ax.set_ylim(0, max(0.75, max((c.accuracy for c in bench_pts), default=0.5) + 0.15)) + ax.set_xlabel(mlabel, fontsize=10) + ax.set_ylabel("accuracy" if col_idx == 0 else "") + ax.set_title(f"{mlabel} — {BENCH_LABELS[bench]}", fontsize=11, fontweight="bold") + ax.grid(True, color="#e5e7eb", linewidth=0.5) + ax.set_axisbelow(True) + for spine in ax.spines.values(): + spine.set_color("#d1d5db") + + # legend on top + handles, labels = axes[0][0].get_legend_handles_labels() + if handles: + fig.legend(handles, labels, loc="upper right", bbox_to_anchor=(0.99, 0.99), + ncol=len(used_paradigms), frameon=True, facecolor="white", + edgecolor="#d1d5db", fontsize=10) + plt.subplots_adjust(left=0.07, right=0.97, top=0.96, bottom=0.04, + hspace=0.55, wspace=0.18) + out = PLOTS_DIR / f"{axis.key}.png" + fig.savefig(out, dpi=110, bbox_inches="tight") + plt.close(fig) + return out + + +# ---------- Markdown → HTML (small) ---------- + +def md_to_html(md: str) -> str: + lines = md.splitlines() + out: list[str] = [] + in_table = False + table_rows: list[list[str]] = [] + + def flush_table(): + nonlocal table_rows + if not table_rows: + return + head = table_rows[0] + body = table_rows[2:] if len(table_rows) > 2 else [] + out.append("" + + "".join(f"" for h in head) + + "") + for row in body: + out.append("" + "".join( + f"" for cell in row) + "") + out.append("
{html_lib.escape(h.strip())}
{html_lib.escape(cell.strip())}
") + table_rows = [] + + def inline(s: str) -> str: + s = html_lib.escape(s) + # bold + import re as _re + s = _re.sub(r"\*\*(.+?)\*\*", r"\1", s) + s = _re.sub(r"`(.+?)`", r"\1", s) + return s + + for raw in lines: + line = raw.rstrip() + if line.startswith("|") and "|" in line[1:]: + if not in_table: + in_table = True + table_rows = [] + cells = [c for c in line.strip().strip("|").split("|")] + table_rows.append(cells) + continue + if in_table: + flush_table() + in_table = False + if line.startswith("### "): + out.append(f"

{inline(line[4:])}

") + elif line.startswith("## "): + out.append(f"

{inline(line[3:])}

") + elif line.startswith("# "): + out.append(f"

{inline(line[2:])}

") + elif line.startswith("> "): + out.append(f"
{inline(line[2:])}
") + elif line.startswith("- "): + out.append(f"
  • {inline(line[2:])}
  • ") + elif line.strip() == "---": + out.append("
    ") + elif line.strip() == "": + out.append("") + else: + out.append(f"

    {inline(line)}

    ") + if in_table: + flush_table() + return "\n".join(out) + + +# ---------- HTML output ---------- + +CSS = """ +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: #fafbfc; color: #1a1a1a; max-width: 1280px; margin: 0 auto; + padding: 24px 16px 60px; line-height: 1.55; } +h1 { border-bottom: 2px solid #1a1a1a; padding-bottom: 8px; } +h2 { margin-top: 40px; border-bottom: 1px solid #d1d5db; padding-bottom: 6px; } +.card { background: white; border: 1px solid #e5e7eb; border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); padding: 16px 20px; margin: 18px 0; } +.hero { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; margin: 16px 0 24px; } +.hero .item { background: white; border: 1px solid #e5e7eb; border-left: 3px solid #3b82f6; + border-radius: 6px; padding: 12px 14px; } +.hero .item.cost { border-left-color: #10b981; } +.hero .label { font-size: 0.78em; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; } +.hero .value { font-size: 1.15em; font-weight: 600; margin-top: 4px; } +.hero .sub { font-size: 0.85em; color: #4b5563; margin-top: 2px; } +table.summary { width: 100%; border-collapse: collapse; font-size: 0.85em; margin-top: 12px; } +table.summary th, table.summary td { border: 1px solid #e5e7eb; padding: 6px 8px; text-align: left; } +table.summary th { background: #f3f4f6; cursor: pointer; user-select: none; } +table.summary tr:nth-child(even) { background: #fafafa; } +table.summary .best { background: #dcfce7 !important; } +section { margin-top: 28px; } +section img { max-width: 100%; height: auto; display: block; margin: 8px 0; + border: 1px solid #e5e7eb; border-radius: 6px; background: white; } +.legend { font-size: 0.85em; color: #6b7280; } +.legend .sw { display: inline-block; width: 11px; height: 11px; border-radius: 50%; + vertical-align: middle; margin-right: 4px; } +.desc { color: #4b5563; } +table.md { width: 100%; border-collapse: collapse; font-size: 0.85em; margin: 12px 0; } +table.md th, table.md td { border: 1px solid #e5e7eb; padding: 4px 8px; text-align: left; } +table.md th { background: #f3f4f6; } +table.md tr:nth-child(even) { background: #fafafa; } +code { background: #f3f4f6; padding: 1px 4px; border-radius: 3px; font-size: 0.92em; } +hr { border: none; border-top: 1px solid #d1d5db; margin: 20px 0; } +blockquote { border-left: 3px solid #d1d5db; padding-left: 12px; color: #4b5563; margin: 8px 0; } +""" + +SORT_JS = """ +document.querySelectorAll('table.summary th').forEach((th, idx) => { + th.addEventListener('click', () => { + const tbl = th.closest('table'); + const tbody = tbl.querySelector('tbody'); + const rows = Array.from(tbody.querySelectorAll('tr')); + const asc = th.dataset.dir !== 'asc'; + rows.sort((a, b) => { + const av = a.cells[idx].dataset.sort ?? a.cells[idx].innerText; + const bv = b.cells[idx].dataset.sort ?? b.cells[idx].innerText; + const af = parseFloat(av), bf = parseFloat(bv); + const an = isNaN(af) ? av : af, bn = isNaN(bf) ? bv : bf; + return (an < bn ? -1 : an > bn ? 1 : 0) * (asc ? 1 : -1); + }); + rows.forEach(r => tbody.appendChild(r)); + tbl.querySelectorAll('th').forEach(t => delete t.dataset.dir); + th.dataset.dir = asc ? 'asc' : 'desc'; + }); +}); +""" + + +def build_html(cells: list[Cell], plots: list[tuple[Axis, Path]]) -> str: + ts = datetime.now().strftime("%Y-%m-%d %H:%M") + # headline cards + by_bench = {"gaia": [c for c in cells if c.bench == "gaia"], + "swe": [c for c in cells if c.bench == "swe"]} + def best_acc(lst): + return max(lst, key=lambda c: c.accuracy) if lst else None + def best_acc_per_dollar(lst): + cand = [c for c in lst if c.cost_usd > 0] + return max(cand, key=lambda c: c.accuracy / c.cost_usd) if cand else None + + hero = ['
    '] + for bench_key, label in (("gaia", "GAIA"), ("swe", "SWE-bench")): + b = best_acc(by_bench[bench_key]) + if b: + hero.append(f"""
    Best acc — {label}
    +
    {b.accuracy:.3f} · {b.paradigm} × {CLOUD_TOKENS.get(b.cloud, b.cloud)}
    +
    cost: ${b.cost_usd:.2f}
    """) + for bench_key, label in (("gaia", "GAIA"), ("swe", "SWE-bench")): + b = best_acc_per_dollar(by_bench[bench_key]) + if b: + ratio = b.accuracy / b.cost_usd + hero.append(f"""
    Best acc / $ — {label}
    +
    {b.accuracy:.3f} for ${b.cost_usd:.2f}
    +
    {b.paradigm} × {CLOUD_TOKENS.get(b.cloud, b.cloud)} ({ratio:.3f} acc/$)
    """) + hero.append("
    ") + + # summary table + legend_html = '
    ' + for p in PARADIGM_ORDER: + col = PARADIGM_COLOR.get(p, "#000") + legend_html += f'{p}  ' + legend_html += '
    ' + + rows_html = [] + sorted_cells = sorted(cells, key=lambda c: (c.bench, -c.accuracy)) + # mark best per bench + best_per_bench = {b: best_acc(by_bench[b]) for b in by_bench} + for c in sorted_cells: + is_best = best_per_bench.get(c.bench) is c + cls = "best" if is_best else "" + rows_html.append(f""" + {c.paradigm} + {c.local or '—'} + {CLOUD_TOKENS.get(c.cloud, c.cloud)} + {BENCH_LABELS[c.bench]} + {c.accuracy:.3f} + ${c.cost_usd:.2f} + {('%.1f' % c.latency_med_s + 's') if c.latency_med_s else '—'} + {('%.1f' % c.tool_calls_mean) if c.tool_calls_mean is not None else '—'} + {('%.2f' % c.web_searches_mean) if c.web_searches_mean is not None else '—'} + {c.tokens_local_total:,} + {c.tokens_cloud_total:,} + """) + summary_table = f""" + + + + + {''.join(rows_html)}
    paradigmlocalcloudbenchaccuracy ↕cost ↕latency_med ↕tool_calls ↕web_searches ↕tokens_local ↕tokens_cloud ↕
    """ + + # axis sections (embed PNGs) + axis_sections = [] + for axis, png_path in plots: + rel = f"plots-n100/{png_path.name}" + axis_sections.append(f"""
    +

    {html_lib.escape(axis.title)}

    +

    {html_lib.escape(axis.description)}

    + {axis.title} +
    """) + + md_html = "" + if RAW_MD.exists(): + md_html = md_to_html(RAW_MD.read_text()) + + return f""" + +OpenJarvis Hybrid n=100 Ablation + + +

    OpenJarvis Hybrid n=100 Ablation

    +

    Comparing local-cloud paradigms across 6 cloud models on GAIA + SWE-bench-Verified · {len(cells)} cells · generated {ts}

    + +
    +

    Headline findings

    +{''.join(hero)} +
    + +
    +

    All cells (sortable)

    +{legend_html} +{summary_table} +
    + +{''.join(axis_sections)} + +
    +

    results-table.md (full)

    +{md_html} +
    + + + +""" + + +def main(): + cells = load_cells() + print(f"loaded {len(cells)} cells") + plots = [] + for axis in axis_definitions(): + png = render_axis_png(axis, cells) + if png is not None: + plots.append((axis, png)) + print(f" rendered {png.name}") + html = build_html(cells, plots) + OUT_HTML.write_text(html) + print(f"wrote {OUT_HTML} ({len(html):,} bytes)") + + +if __name__ == "__main__": + main() diff --git a/src/openjarvis/agents/hybrid/_base.py b/src/openjarvis/agents/hybrid/_base.py index 15ffd114..8cff722a 100644 --- a/src/openjarvis/agents/hybrid/_base.py +++ b/src/openjarvis/agents/hybrid/_base.py @@ -64,6 +64,35 @@ ANTHROPIC_WEB_SEARCH_TOOL = { } +def build_web_search_tool(max_uses: int = 8) -> Dict[str, Any]: + """Build an Anthropic server-side web_search tool block with a custom cap. + + Web_search is server-side: Anthropic runs the searches internally before + returning, so ``max_uses`` is the only knob the caller has to bound cost + per task. Defaults to 8 (matches ``ANTHROPIC_WEB_SEARCH_TOOL``). + """ + return { + "type": "web_search_20250305", + "name": "web_search", + "max_uses": int(max_uses), + } + + +def web_search_cfg(method_cfg: Optional[Dict[str, Any]]) -> Tuple[bool, int]: + """Parse ``method_cfg.web_search = { enabled, max_uses }``. + + Defaults: enabled=False, max_uses=8. ``enabled`` defaults to False so + existing cells stay one-shot (backwards compat). Cells opting in flip + ``enabled=true`` in the registry. Returns ``(enabled, max_uses)``. + """ + if not method_cfg: + return False, 8 + ws = method_cfg.get("web_search") + if not isinstance(ws, dict): + return False, 8 + return bool(ws.get("enabled", False)), int(ws.get("max_uses", 8)) + + # ---------- Thread-local trace buffer ---------- # # Every call through ``_call_anthropic`` / ``_call_openai`` / ``_call_vllm`` @@ -352,6 +381,75 @@ class LocalCloudAgent(BaseAgent): }) return text, p, c + @staticmethod + def _call_gemini( + model: str, + *, + user: str, + system: Optional[str] = None, + max_tokens: int = 4096, + temperature: float = 0.0, + timeout: float = 600.0, + trace_role: str = "cloud", + ) -> Tuple[str, int, int]: + """Single Gemini Developer-API call. Returns (text, p_tok, c_tok). + + Tool-call parity with Anthropic is intentionally NOT implemented — + skillorchestra / baseline-cloud only need text generation, and the + google-genai tool-config plumbing diverges enough from the + Anthropic/OpenAI shape that wiring it would double the surface + area of this file. If a future paradigm needs Gemini tool use, + extend this helper rather than hand-rolling it in the agent. + + Captures the call into the active per-task trace via the + ``"gemini"`` kind so the dashboard's trace renderer picks it up. + """ + from google import genai + from google.genai import types + + client = genai.Client(http_options=types.HttpOptions(timeout=int(timeout * 1000))) + cfg = types.GenerateContentConfig( + temperature=temperature, + max_output_tokens=max_tokens, + ) + if system: + cfg.system_instruction = system + t0 = time.time() + resp = client.models.generate_content( + model=model, + contents=user, + config=cfg, + ) + latency = time.time() - t0 + # `resp.text` is the convenience accessor that concatenates every + # text part in the first candidate. Empty if the model emitted + # only non-text parts (which we don't request). + text = (resp.text or "") if hasattr(resp, "text") else "" + um = getattr(resp, "usage_metadata", None) + p = int(getattr(um, "prompt_token_count", 0) or 0) if um else 0 + c = int(getattr(um, "candidates_token_count", 0) or 0) if um else 0 + finish_reason = None + try: + finish_reason = str(resp.candidates[0].finish_reason) + except Exception: + pass + _record_event({ + "kind": "gemini", + "role": trace_role, + "model": model, + "system": system, + "user": user, + "response": text, + "tokens_in": p, + "tokens_out": c, + "temperature": temperature, + "max_tokens": max_tokens, + "finish_reason": finish_reason, + "latency_s": latency, + "ts": time.time(), + }) + return text, p, c + @staticmethod def _call_vllm( model: str, @@ -426,6 +524,122 @@ class LocalCloudAgent(BaseAgent): }) return text, p, c + @staticmethod + def _call_anthropic_agent( + model: str, + *, + user: str, + system: Optional[str] = None, + max_tokens: int = 4096, + temperature: float = 0.0, + tools: Optional[list] = None, + max_turns: int = 8, + timeout: float = 600.0, + max_retries: int = 5, + trace_role: str = "cloud", + ) -> Tuple[str, int, int, int, int]: + """Multi-turn Anthropic loop with optional tools. + + Returns ``(final_text, prompt_tokens_sum, completion_tokens_sum, + n_web_searches_sum, turns)``. The loop appends the assistant + response (preserving server-tool blocks so Anthropic's continuation + is valid) and re-prompts until the model stops with ``end_turn`` + or we hit ``max_turns``. Server-side tools (web_search) execute + inside a single message and don't require a tool_result echo — + the model just continues thinking with the search results + already in its context. Client-side tools aren't executed here; + if the model emits a client-side ``tool_use`` block we stop + (paradigms that want client tools should call ``_call_anthropic`` + directly and handle their own dispatch). + """ + import anthropic + + client = anthropic.Anthropic(timeout=timeout, max_retries=max_retries) + # Build the conversation. We grow ``messages`` across turns so the + # model sees its own prior assistant content (text + server tool + # use + web_search_tool_result). For server tools Anthropic is + # happy as long as we feed the raw assistant content back; no + # synthetic user tool_result block is needed. + messages: List[Dict[str, Any]] = [{"role": "user", "content": user}] + p_total = 0 + c_total = 0 + n_searches_total = 0 + last_text = "" + turns = 0 + for turn in range(max(1, max_turns)): + turns = turn + 1 + kwargs: Dict[str, Any] = { + "model": model, + "max_tokens": max_tokens, + "messages": messages, + } + if system: + kwargs["system"] = system + if supports_temperature(model): + kwargs["temperature"] = temperature + if tools: + kwargs["tools"] = tools + t0 = time.time() + msg = client.messages.create(**kwargs) + latency = time.time() - t0 + text = "".join(b.text for b in msg.content if hasattr(b, "text")) + srv = getattr(msg.usage, "server_tool_use", None) + n_searches = getattr(srv, "web_search_requests", 0) if srv else 0 + content_blocks = [_serialize_block(b) for b in msg.content] + tool_use_blocks = [ + b for b in content_blocks + if b.get("type") in ("tool_use", "server_tool_use") + ] + tool_result_blocks = [ + b for b in content_blocks + if b.get("type") in ("web_search_tool_result", "tool_result") + ] + stop_reason = getattr(msg, "stop_reason", None) + _record_event({ + "kind": "anthropic", + "role": trace_role, + "model": model, + "system": system if turn == 0 else None, + "user": user if turn == 0 else None, + "turn": turn, + "response": text, + "content_blocks": content_blocks, + "tool_calls": tool_use_blocks, + "tool_results": tool_result_blocks, + "tokens_in": msg.usage.input_tokens, + "tokens_out": msg.usage.output_tokens, + "n_web_searches": n_searches, + "tools_declared": tools, + "stop_reason": stop_reason, + "latency_s": latency, + "ts": time.time(), + }) + p_total += msg.usage.input_tokens + c_total += msg.usage.output_tokens + n_searches_total += n_searches + if text: + last_text = text + # If the model wants a client-side tool we don't dispatch + # here — break and let the caller (or future loop variant) + # handle it. Only ``server_tool_use`` blocks (web_search) + # are auto-continued by Anthropic itself. + client_tool_use = any( + b.get("type") == "tool_use" for b in content_blocks + ) + if client_tool_use: + break + if stop_reason == "end_turn" or stop_reason is None: + break + # Otherwise: ``stop_reason`` like "max_tokens" or "tool_use" + # (server side) — Anthropic returned mid-thought. Append the + # assistant turn and ask it to continue. + messages.append({"role": "assistant", "content": msg.content}) + messages.append({ + "role": "user", + "content": "Continue.", + }) + return last_text, p_total, c_total, n_searches_total, turns + def _call_cloud( self, *, @@ -460,6 +674,23 @@ class LocalCloudAgent(BaseAgent): temperature=temperature, **kwargs, ) + if self._cloud_endpoint == "gemini": + # Gemini helper doesn't accept tools / response_format kwargs. + # Drop them rather than letting them surface as a TypeError so + # paradigms that opportunistically pass these for OpenAI / + # Anthropic still work when routed to Gemini. + kwargs.pop("tools", None) + kwargs.pop("tool_choice", None) + kwargs.pop("response_format", None) + kwargs.pop("output_config", None) + return self._call_gemini( + self._cloud_model, + user=user, + system=system, + max_tokens=max_tokens, + temperature=temperature, + **kwargs, + ) raise ValueError(f"unsupported cloud endpoint: {self._cloud_endpoint!r}") # ------------------------------------------------------------------ @@ -609,7 +840,9 @@ __all__ = [ "LocalCloudAgent", "NO_TEMP_PREFIXES", "WEB_SEARCH_COST_PER_CALL", + "build_web_search_tool", "estimate_cost", "is_gpt5_family", "supports_temperature", + "web_search_cfg", ] diff --git a/src/openjarvis/agents/hybrid/advisors.py b/src/openjarvis/agents/hybrid/advisors.py index 35797759..9ec75fd9 100644 --- a/src/openjarvis/agents/hybrid/advisors.py +++ b/src/openjarvis/agents/hybrid/advisors.py @@ -28,7 +28,12 @@ import urllib.request from typing import Any, Dict, Optional, Tuple from openjarvis.agents._stubs import AgentContext -from openjarvis.agents.hybrid._base import LocalCloudAgent +from openjarvis.agents.hybrid._base import ( + WEB_SEARCH_COST_PER_CALL, + LocalCloudAgent, + build_web_search_tool, + web_search_cfg, +) from openjarvis.agents.hybrid.mini_swe_agent import ( run_swe_agent_loop, ) @@ -112,13 +117,32 @@ class AdvisorsAgent(LocalCloudAgent): advisor_max_tokens = int(cfg.get("advisor_max_tokens", 1024)) advisor_temperature = float(cfg.get("advisor_temperature", 0.2)) - # 1. Initial executor pass - initial_resp, e1_in, e1_out = self._call_cloud( - user=f"Question:\n{question}", - system=EXECUTOR_INITIAL_SYS, - max_tokens=executor_max_tokens, - temperature=0.0, - ) + ws_enabled, ws_max_uses = web_search_cfg(cfg) + use_ws = ws_enabled and self._cloud_endpoint == "anthropic" + gaia_max_turns = int(cfg.get("gaia_max_turns", 8)) + ws_tool = [build_web_search_tool(ws_max_uses)] if use_ws else None + n_searches_total = 0 + + # 1. Initial executor pass — advisor (Qwen) doesn't get tools; + # only the cloud executor passes do. + if use_ws: + initial_resp, e1_in, e1_out, n_s1, _ = self._call_anthropic_agent( + self._cloud_model, + user=f"Question:\n{question}", + system=EXECUTOR_INITIAL_SYS, + max_tokens=executor_max_tokens, + temperature=0.0, + tools=ws_tool, + max_turns=gaia_max_turns, + ) + n_searches_total += n_s1 + else: + initial_resp, e1_in, e1_out = self._call_cloud( + user=f"Question:\n{question}", + system=EXECUTOR_INITIAL_SYS, + max_tokens=executor_max_tokens, + temperature=0.0, + ) # 2. Advisor pass (local) if not self._local_endpoint or not self._local_model: @@ -147,25 +171,41 @@ class AdvisorsAgent(LocalCloudAgent): f"Produce your best final answer now, respecting the question's " f"answer-format rules." ) - final_answer, e2_in, e2_out = self._call_cloud( - user=final_user, - system=EXECUTOR_FINAL_SYS, - max_tokens=executor_max_tokens, - temperature=0.0, - ) + if use_ws: + final_answer, e2_in, e2_out, n_s2, _ = self._call_anthropic_agent( + self._cloud_model, + user=final_user, + system=EXECUTOR_FINAL_SYS, + max_tokens=executor_max_tokens, + temperature=0.0, + tools=ws_tool, + max_turns=gaia_max_turns, + ) + n_searches_total += n_s2 + else: + final_answer, e2_in, e2_out = self._call_cloud( + user=final_user, + system=EXECUTOR_FINAL_SYS, + max_tokens=executor_max_tokens, + temperature=0.0, + ) tokens_local = adv_in + adv_out tokens_cloud = e1_in + e1_out + e2_in + e2_out cost = self.cost_usd(self._cloud_model, e1_in + e2_in, e1_out + e2_out) + cost += n_searches_total * WEB_SEARCH_COST_PER_CALL meta: Dict[str, Any] = { "tokens_local": tokens_local, "tokens_cloud": tokens_cloud, "cost_usd": cost, "turns": 3, + "web_search_uses": n_searches_total, "traces": { "initial_response": initial_resp, "advisor_feedback": advisor_text, + "web_search_enabled": use_ws, + "n_web_searches": n_searches_total, "note": "inference-only advisor (untrained); lower bound on the technique.", }, } diff --git a/src/openjarvis/agents/hybrid/archon.py b/src/openjarvis/agents/hybrid/archon.py index 9c0482cf..8ab35f75 100644 --- a/src/openjarvis/agents/hybrid/archon.py +++ b/src/openjarvis/agents/hybrid/archon.py @@ -42,7 +42,13 @@ import types from typing import Any, Dict, List, Optional, Tuple from openjarvis.agents._stubs import AgentContext -from openjarvis.agents.hybrid._base import LocalCloudAgent, _record_event +from openjarvis.agents.hybrid._base import ( + WEB_SEARCH_COST_PER_CALL, + LocalCloudAgent, + _record_event, + build_web_search_tool, + web_search_cfg, +) from openjarvis.agents.hybrid._prices import ( NO_TEMP_PREFIXES, ) @@ -125,8 +131,11 @@ def _tally() -> Dict[str, int]: counts = { "cloud_prompt": 0, "cloud_completion": 0, "local_prompt": 0, "local_completion": 0, + "n_web_searches": 0, } _TALLY_LOCAL.counts = counts + # Backfill for older threads that started before we added the key. + counts.setdefault("n_web_searches", 0) return counts @@ -134,9 +143,24 @@ def _reset_tally() -> None: _TALLY_LOCAL.counts = { "cloud_prompt": 0, "cloud_completion": 0, "local_prompt": 0, "local_completion": 0, + "n_web_searches": 0, } +# Per-thread web_search tool: when set, the Anthropic generator declares +# it on every call. Set inside ``_run_paradigm`` before invoking Archon +# so the ranker/fuser passes pick it up; cleared in ``finally``. +_WS_LOCAL = threading.local() + + +def _set_anthropic_web_search(tool: Optional[Dict[str, Any]]) -> None: + _WS_LOCAL.tool = tool + + +def _get_anthropic_web_search() -> Optional[Dict[str, Any]]: + return getattr(_WS_LOCAL, "tool", None) + + def _make_local_generator(local_endpoint: str, local_model: str): """Archon custom-generator signature: (model, messages, max_tokens, temperature) -> str.""" from openai import OpenAI @@ -237,6 +261,9 @@ def _wrap_archon_cloud_generators() -> None: ) if not model.startswith(NO_TEMP_PREFIXES): kwargs["temperature"] = temperature + ws_tool = _get_anthropic_web_search() + if ws_tool is not None: + kwargs["tools"] = [ws_tool] t0 = _time.time() resp = client.messages.create(**kwargs) text = "".join(b.text for b in resp.content if hasattr(b, "text")) @@ -244,6 +271,9 @@ def _wrap_archon_cloud_generators() -> None: if u: _tally()["cloud_prompt"] += getattr(u, "input_tokens", 0) or 0 _tally()["cloud_completion"] += getattr(u, "output_tokens", 0) or 0 + srv = getattr(u, "server_tool_use", None) if u else None + n_searches = getattr(srv, "web_search_requests", 0) if srv else 0 + _tally()["n_web_searches"] += int(n_searches) _record_event({ "kind": "archon_cloud_anthropic", "model": model, @@ -252,6 +282,8 @@ def _wrap_archon_cloud_generators() -> None: "response": text.strip(), "tokens_in": getattr(u, "input_tokens", 0) if u else 0, "tokens_out": getattr(u, "output_tokens", 0) if u else 0, + "n_web_searches": int(n_searches), + "tools_declared": kwargs.get("tools"), "latency_s": _time.time() - t0, "ts": _time.time(), }) @@ -430,6 +462,14 @@ class ArchonAgent(LocalCloudAgent): archon_cfg = {"name": f"hybrid-archon-{arch}", "layers": layers} _reset_tally() + # Web_search opt-in: when enabled, declare the native server-side + # tool on Anthropic ranker/fuser passes (via thread-local). The + # local proposers run on vLLM and don't see it. + ws_enabled, ws_max_uses = web_search_cfg(cfg) + if ws_enabled: + _set_anthropic_web_search(build_web_search_tool(ws_max_uses)) + else: + _set_anthropic_web_search(None) archon = Archon(archon_cfg) try: @@ -439,6 +479,8 @@ class ArchonAgent(LocalCloudAgent): ]) except Exception as e: answer = f"[archon error: {e!r}]" + finally: + _set_anthropic_web_search(None) if isinstance(answer, list): answer = answer[-1] if answer else "" @@ -446,16 +488,19 @@ class ArchonAgent(LocalCloudAgent): cp = _tally()["cloud_prompt"] cc = _tally()["cloud_completion"] + n_searches = _tally().get("n_web_searches", 0) cost = _cost_cloud(ranker_model, cp, cc) if fuser_model != ranker_model: # Conservative: charge both at the more expensive of the two. cost = max(cost, _cost_cloud(fuser_model, cp, cc)) + cost += n_searches * WEB_SEARCH_COST_PER_CALL meta = { "tokens_local": _tally()["local_prompt"] + _tally()["local_completion"], "tokens_cloud": cp + cc, "cost_usd": cost, "turns": (K + 2) if arch == "ensemble_rank_fuse" else 1, + "web_search_uses": n_searches, "traces": { "architecture": arch, "n_samples": K, @@ -463,6 +508,8 @@ class ArchonAgent(LocalCloudAgent): "fuser_model": fuser_model, "local_model": self._local_model, "tokens_breakdown": dict(_tally()), + "web_search_enabled": ws_enabled, + "n_web_searches": n_searches, }, } return answer, meta diff --git a/src/openjarvis/agents/hybrid/baseline_cloud.py b/src/openjarvis/agents/hybrid/baseline_cloud.py new file mode 100644 index 00000000..71fca7f4 --- /dev/null +++ b/src/openjarvis/agents/hybrid/baseline_cloud.py @@ -0,0 +1,167 @@ +"""BaselineCloudAgent — cloud-only reference for the hybrid ablation. + +Used as the "what does the cloud do alone?" row in the n=100 ablation +matrix (see ``.openjarvis/experiments/hybrid/docs/results-table.md``). +No local model is involved — ``local_*`` settings are ignored. + +On GAIA the agent makes one cloud call with the formatted prompt (which +already carries the ``FINAL ANSWER:`` format reminder from +``_prompts.format_gaia``) and returns the text. On SWE-bench-Verified +the agent delegates to :func:`run_swe_agent_loop` with ``backbone="cloud"`` +so the model gets to run bash and read the repo — same wiring as the +``mini-swe-agent-swebenchverified-opus-*`` cells. As of 2026-05-15 +``_loop_cloud`` dispatches to per-endpoint loops for Anthropic, OpenAI, +and Gemini so all three cloud backbones get the proper bash-agent loop +on SWE (previously OpenAI / Gemini SWE cells silently fell back to a +one-shot blind patch — fixed). + +Construction args mirror :class:`LocalCloudAgent`. The ``cloud`` block +in the cell registry determines the cloud model + endpoint; ``local`` +is accepted for schema compatibility but unused. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Tuple + +from openjarvis.agents._stubs import AgentContext +from openjarvis.agents.hybrid._base import ( + WEB_SEARCH_COST_PER_CALL, + LocalCloudAgent, + build_web_search_tool, + web_search_cfg, +) +from openjarvis.agents.hybrid._prices import cost as estimate_cost +from openjarvis.agents.hybrid.mini_swe_agent import run_swe_agent_loop +from openjarvis.core.registry import AgentRegistry + + +@AgentRegistry.register("baseline_cloud") +class BaselineCloudAgent(LocalCloudAgent): + """Cloud-only baseline used as a reference in the n=100 ablation. + + Configurable knobs via ``cfg``: + + - ``cloud_max_tokens`` (int, default 4096): max_tokens per GAIA call + and per turn of the SWE agent loop. + - ``swe_max_turns`` (int, default 30): SWE-bench loop turn cap. + - ``swe_bash_timeout_s`` (int, default 120): SWE-bench bash timeout. + """ + + agent_id = "baseline_cloud" + + def _run_paradigm( + self, + input: str, + context: Optional[AgentContext], + **kwargs: Any, + ) -> Tuple[str, Dict[str, Any]]: + cfg = self._cfg + task: Dict[str, Any] = {} + if context is not None: + task = context.metadata.get("task") or {} + + is_swe = bool( + task.get("problem_statement") + and task.get("repo") + and task.get("base_commit") + ) + + if is_swe: + out = run_swe_agent_loop( + task, + backbone="cloud", + backbone_model=self._cloud_model, + cloud_endpoint=self._cloud_endpoint, + initial_prompt=input, + max_turns=int(cfg.get("swe_max_turns", 30)), + bash_timeout=int(cfg.get("swe_bash_timeout_s", 120)), + output_cap=int(cfg.get("swe_output_cap", 10_000)), + turn_max_tokens=int(cfg.get("cloud_max_tokens", 4096)), + trace_prefix="baseline_cloud", + ) + meta = { + "tokens_local": 0, + "tokens_cloud": out["tokens_in"] + out["tokens_out"], + "cost_usd": out["cost_usd"], + "turns": out["turns"], + "traces": { + "backbone": "cloud", + "max_turns_hit": out["max_turns_hit"], + "patch_chars": len(out["patch"]), + "final_summary": out["final_summary"], + }, + } + return out["answer"], meta + + # GAIA branch. If `web_search.enabled` is true AND we're on + # Anthropic, run the multi-turn agent loop with the native + # server-side web_search tool. Otherwise fall back to the + # legacy one-shot call (preserves behavior of every existing + # non-opted-in cell). + ws_enabled, ws_max_uses = web_search_cfg(cfg) + gaia_max_turns = int(cfg.get("gaia_max_turns", 8)) + if ws_enabled and self._cloud_endpoint == "anthropic": + text, p_tok, c_tok, n_searches, turns = self._call_anthropic_agent( + self._cloud_model, + user=input, + max_tokens=int(cfg.get("cloud_max_tokens", 4096)), + temperature=0.0, + tools=[build_web_search_tool(ws_max_uses)], + max_turns=gaia_max_turns, + ) + cost = ( + estimate_cost(self._cloud_model, p_tok, c_tok) + + n_searches * WEB_SEARCH_COST_PER_CALL + ) + meta = { + "tokens_local": 0, + "tokens_cloud": p_tok + c_tok, + "cost_usd": cost, + "turns": turns, + "web_search_uses": n_searches, + "traces": { + "mode": "anthropic_agent_loop", + "is_swe": is_swe, + "cloud_endpoint": self._cloud_endpoint, + "web_search_enabled": True, + "web_search_max_uses": ws_max_uses, + "n_web_searches": n_searches, + }, + } + return text, meta + + if ws_enabled and self._cloud_endpoint != "anthropic": + # OpenAI / Gemini don't have a parity native web_search tool + # wired here. Skip cleanly rather than fake one. Cells that + # want web_search must run on Anthropic until those backends + # are wired. + self.record_trace_event({ + "kind": "web_search_skipped", + "reason": "non_anthropic_endpoint", + "endpoint": self._cloud_endpoint, + }) + + # One-shot direct cloud call. GAIA only — SWE goes through the + # mini-SWE-agent loop above (now supports anthropic/openai/gemini). + text, p_tok, c_tok = self._call_cloud( + user=input, + max_tokens=int(cfg.get("cloud_max_tokens", 4096)), + temperature=0.0, + ) + meta = { + "tokens_local": 0, + "tokens_cloud": p_tok + c_tok, + "cost_usd": estimate_cost(self._cloud_model, p_tok, c_tok), + "turns": 1, + "web_search_uses": 0, + "traces": { + "mode": "one_shot", + "is_swe": is_swe, + "cloud_endpoint": self._cloud_endpoint, + }, + } + return text, meta + + +__all__ = ["BaselineCloudAgent"] diff --git a/src/openjarvis/agents/hybrid/conductor.py b/src/openjarvis/agents/hybrid/conductor.py index 1cac4993..4c1f7bc3 100644 --- a/src/openjarvis/agents/hybrid/conductor.py +++ b/src/openjarvis/agents/hybrid/conductor.py @@ -39,7 +39,12 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from openjarvis.agents._stubs import AgentContext -from openjarvis.agents.hybrid._base import LocalCloudAgent +from openjarvis.agents.hybrid._base import ( + WEB_SEARCH_COST_PER_CALL, + LocalCloudAgent, + build_web_search_tool, + web_search_cfg, +) from openjarvis.agents.hybrid._prices import ( PRICES, is_gpt5_family, @@ -340,6 +345,130 @@ def _resolve_worker_pool( return resolved +# Endpoints conductor's `_call_worker` actually knows how to dispatch to. +# Web-search and gemini are NOT supported here — toolorchestra has the +# web-search dispatcher; gemini isn't wired into _call_worker. +_CONDUCTOR_VALID_ENDPOINTS = ("vllm", "openai", "anthropic") + + +def _resolve_worker_pool( + cfg: Dict[str, Any], + local_model: Optional[str], + local_endpoint: Optional[str], + cloud_model: str, +) -> List[Dict[str, Any]]: + """Return the worker pool for this run. + + Strict replace, not merge: if ``cfg["worker_pool"]`` is set, the + default pool is ignored entirely. Falls back to ``_default_pool`` when + the override is absent. + + Each user-supplied entry must be a dict with keys ``id``, ``name``, + ``endpoint``, and ``model``. ``endpoint`` must be one of + ``vllm`` / ``openai`` / ``anthropic`` — conductor does not wire + web-search or gemini workers. + + Substitution: ``model = "$local"`` (or ``""``) resolves to + ``local_model``; ``model = "$cloud"`` / ``""`` to ``cloud_model``. + + On any validation failure, raises ``ValueError`` with the message + ``"Invalid worker_pool entry []: "``. Fails fast at agent + init rather than mid-task. + """ + override = cfg.get("worker_pool") + if override is None: + return _default_pool(local_model, local_endpoint) + if not isinstance(override, list) or not override: + raise ValueError( + "Invalid worker_pool entry [-]: worker_pool must be a non-empty list" + ) + + resolved: List[Dict[str, Any]] = [] + seen_ids: set = set() + has_non_search = False + for raw in override: + wid_repr = raw.get("id", "?") if isinstance(raw, dict) else "?" + if not isinstance(raw, dict): + raise ValueError( + f"Invalid worker_pool entry [{wid_repr}]: entry must be a dict" + ) + entry = dict(raw) + wid = entry.get("id") + if not isinstance(wid, int): + raise ValueError( + 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" + ) + seen_ids.add(wid) + if not entry.get("name") or not isinstance(entry["name"], str): + raise ValueError( + f"Invalid worker_pool entry [{wid}]: 'name' must be a non-empty string" + ) + endpoint = entry.get("endpoint") or entry.get("type") + if not isinstance(endpoint, str) or endpoint.lower() not in _CONDUCTOR_VALID_ENDPOINTS: + raise ValueError( + f"Invalid worker_pool entry [{wid}]: 'endpoint' must be one of " + f"{_CONDUCTOR_VALID_ENDPOINTS} (got {endpoint!r})" + ) + endpoint = endpoint.lower() + entry["endpoint"] = endpoint + # Substitute $local / $cloud placeholders. + model = entry.get("model") + if isinstance(model, str) and model in ("$local", ""): + if not local_model: + raise ValueError( + f"Invalid worker_pool entry [{wid}]: model='{model}' " + "requires a local_model to be configured for this cell" + ) + model = local_model + entry["model"] = model + elif isinstance(model, str) and model in ("$cloud", ""): + model = cloud_model + entry["model"] = model + if not isinstance(model, str) or not model: + raise ValueError( + f"Invalid worker_pool entry [{wid}]: 'model' must be a non-empty string" + ) + if endpoint == "vllm": + if not entry.get("base_url"): + # Default to the local endpoint if not specified — matches + # how _default_pool wires it. + if not local_endpoint: + raise ValueError( + f"Invalid worker_pool entry [{wid}]: vllm worker needs " + "'base_url' (or a configured local_endpoint to fall back to)" + ) + entry["base_url"] = local_endpoint + entry.setdefault("api_key", "EMPTY") + # Local also counts as a non-search worker for the + # "must have at least one solver" check. + has_non_search = True + else: + # Cloud workers: model must be priced (any unknown model would + # silently cost $0, which masks billing mistakes downstream). + if model not in PRICES: + raise ValueError( + f"Invalid worker_pool entry [{wid}]: model {model!r} is " + f"not in PRICES (known: {sorted(PRICES)})" + ) + has_non_search = True + entry.setdefault( + "description", + f"User-supplied {endpoint} worker ({model}).", + ) + resolved.append(entry) + + if not has_non_search: + raise ValueError( + "Invalid worker_pool entry [-]: worker_pool must contain at least " + "one non-search worker (vllm / openai / anthropic)" + ) + return resolved + + def _format_worker_pool(workers: List[Dict[str, Any]]) -> str: return "\n".join( f"Model {w['id']} ({w['name']}): {w['description']}" for w in workers @@ -376,9 +505,19 @@ def _build_step_prompt( # ---------- Worker invocation ---------- def _call_worker( - worker: Dict[str, Any], prompt: str, cfg: Dict[str, Any] -) -> Tuple[str, int, int, bool]: - """Returns (text, p_tok, c_tok, is_local).""" + worker: Dict[str, Any], + prompt: str, + cfg: Dict[str, Any], + *, + web_search_tool: Optional[Dict[str, Any]] = None, +) -> Tuple[str, int, int, bool, int]: + """Returns (text, p_tok, c_tok, is_local, n_web_searches). + + ``web_search_tool``: if set AND the worker endpoint is Anthropic, the + server-side web_search tool is declared on the call. ``n_web_searches`` + is the actual count Anthropic ran. Non-Anthropic workers ignore it + and return 0. + """ ep = (worker.get("endpoint") or "openai").lower() max_tok = int(cfg.get("worker_max_tokens", 4096)) temp = float(cfg.get("worker_temperature", 0.2)) @@ -392,7 +531,7 @@ def _call_worker( temperature=temp, enable_thinking=False, ) - return text, p, c, True + return text, p, c, True, 0 if ep == "openai": text, p, c = LocalCloudAgent._call_openai( worker["model"], @@ -400,16 +539,20 @@ def _call_worker( max_tokens=max_tok, temperature=(1.0 if is_gpt5_family(worker["model"]) else temp), ) - return text, p, c, False + return text, p, c, False, 0 if ep == "anthropic": eff_temp = temp if supports_temperature(worker["model"]) else 0.0 - text, p, c, _ = LocalCloudAgent._call_anthropic( - worker["model"], + anthropic_kwargs: Dict[str, Any] = dict( user=prompt, max_tokens=max_tok, temperature=eff_temp, ) - return text, p, c, False + if web_search_tool is not None: + anthropic_kwargs["tools"] = [web_search_tool] + text, p, c, n_searches = LocalCloudAgent._call_anthropic( + worker["model"], **anthropic_kwargs + ) + return text, p, c, False, n_searches raise ValueError(f"unsupported worker endpoint: {ep!r}") @@ -420,10 +563,11 @@ def _swe_worker_step( cfg: Dict[str, Any], workdir: Path, step_idx: int, -) -> Tuple[str, int, int, bool]: +) -> Tuple[str, int, int, bool, int]: """Run one Conductor worker step as a mini-SWE-agent subloop on a shared - workdir. Returns (final_summary_or_diff, tokens_in, tokens_out, is_local) - in the same shape as ``_call_worker``.""" + workdir. Returns (final_summary_or_diff, tokens_in, tokens_out, is_local, + n_web_searches) in the same shape as ``_call_worker``. SWE workers + don't use web_search (the bash tool is the only tool they need).""" ep = (worker.get("endpoint") or "openai").lower() if ep == "vllm": backbone, model, endpoint, is_local = ( @@ -455,7 +599,10 @@ def _swe_worker_step( trace_prefix=f"conductor_step{step_idx}", workdir=workdir, ) - return out["final_summary"] or out["answer"], out["tokens_in"], out["tokens_out"], is_local + return ( + out["final_summary"] or out["answer"], + out["tokens_in"], out["tokens_out"], is_local, 0, + ) @AgentRegistry.register("conductor") @@ -566,6 +713,15 @@ class ConductorAgent(LocalCloudAgent): cost = 0.0 final_answer = "" shared_workdir: Optional[Path] = None + n_web_searches_total = 0 + + # Web_search opt-in: when enabled, declare the native server-side + # tool on Anthropic worker calls. GAIA-only — SWE workers use bash + # not web_search. OpenAI / vLLM workers ignore it. + ws_enabled, ws_max_uses = web_search_cfg(cfg) + ws_tool = ( + build_web_search_tool(ws_max_uses) if ws_enabled else None + ) try: if swe_mode: @@ -598,17 +754,21 @@ class ConductorAgent(LocalCloudAgent): }) if swe_mode: - text, w_in, w_out, is_local = _swe_worker_step( + text, w_in, w_out, is_local, n_searches = _swe_worker_step( worker, task_meta, prompt, cfg, shared_workdir, i, ) else: - text, w_in, w_out, is_local = _call_worker(worker, prompt, cfg) + text, w_in, w_out, is_local, n_searches = _call_worker( + worker, prompt, cfg, web_search_tool=ws_tool, + ) if is_local: tokens_local += w_in + w_out else: tokens_cloud += w_in + w_out cost += self.cost_usd(worker["model"], w_in, w_out) + cost += n_searches * WEB_SEARCH_COST_PER_CALL + n_web_searches_total += n_searches steps.append({ "step_idx": i, "model_id": mid, @@ -650,10 +810,13 @@ class ConductorAgent(LocalCloudAgent): "tokens_cloud": tokens_cloud, "cost_usd": cost, "turns": len(steps) + 1, # planner + N execution steps + "web_search_uses": n_web_searches_total, "traces": { "steps": traces, "plan": plan, "fallback_used": fallback_used, + "web_search_enabled": ws_enabled, + "n_web_searches": n_web_searches_total, "parse_attempts": parse_attempts, "workers": [ {k: v for k, v in w.items() if k != "api_key"} diff --git a/src/openjarvis/agents/hybrid/minions.py b/src/openjarvis/agents/hybrid/minions.py index 73d064f8..bea18a23 100644 --- a/src/openjarvis/agents/hybrid/minions.py +++ b/src/openjarvis/agents/hybrid/minions.py @@ -48,6 +48,8 @@ from openjarvis.agents.hybrid._base import ( ANTHROPIC_WEB_SEARCH_TOOL, WEB_SEARCH_COST_PER_CALL, LocalCloudAgent, + build_web_search_tool, + web_search_cfg, ) from openjarvis.agents.hybrid._prices import NO_TEMP_PREFIXES from openjarvis.agents.hybrid.mini_swe_agent import run_swe_agent_loop @@ -200,6 +202,13 @@ def _patch_anthropic_globally() -> None: def make_patched(orig): # type: ignore[no-untyped-def] def patched(self, **kwargs): # type: ignore[no-untyped-def] + # External Minions's AnthropicClient.chat passes + # `cache_control={"type":"ephemeral"}` as a top-level kwarg + # (clients/anthropic.py:207). Newer Anthropic SDKs reject that + # — cache_control belongs on individual content blocks, not on + # Messages.create itself. Strip it; we're not relying on the + # ephemeral hint for correctness in these short minions turns. + kwargs.pop("cache_control", None) model = kwargs.get("model", "") if model.startswith(NO_TEMP_PREFIXES): kwargs.pop("temperature", None) @@ -251,7 +260,12 @@ def _apply_patches_once() -> None: # ---------- Pre-fetch helper (GAIA only) ---------- -def _prefetch_context(question: str, cloud_endpoint: str, cloud_model: str) -> Dict[str, Any]: +def _prefetch_context( + question: str, + cloud_endpoint: str, + cloud_model: str, + max_uses: int = 8, +) -> Dict[str, Any]: """Use Anthropic web_search to fetch real source material the worker can read. Minions's premise is "worker reads a doc, asks cloud for help" — but GAIA @@ -278,7 +292,7 @@ def _prefetch_context(question: str, cloud_endpoint: str, cloud_model: str) -> D cloud_model, user=prompt, max_tokens=8192, - tools=[ANTHROPIC_WEB_SEARCH_TOOL], + tools=[build_web_search_tool(max_uses)], tool_choice={"type": "any"}, ) from openjarvis.agents.hybrid._prices import cost as _cost_usd @@ -412,12 +426,29 @@ class MinionsAgent(LocalCloudAgent): # GAIA-shape only: prefetch a web_search digest so the worker has # something real to read. SWE-bench (problem_statement only) already # ships its own doc. + # + # Honors the new ``method_cfg.web_search`` schema: + # - omitted → prefetch ON (legacy default for minions GAIA) + # - enabled = false → prefetch OFF + # - enabled = true → prefetch ON (honors max_uses) prefetch: Dict[str, Any] = { "text": "", "tokens": 0, "cost_usd": 0.0, "n_searches": 0, } - if task_meta.get("question"): + ws_block = cfg.get("web_search") if isinstance(cfg.get("web_search"), dict) else None + ws_enabled, ws_max_uses = web_search_cfg(cfg) + # If the cell explicitly set web_search.enabled = false, honor that. + # If it set web_search.enabled = true, honor max_uses. If it didn't + # set a web_search block at all, keep legacy prefetch ON. + prefetch_on = ( + ws_block is None # legacy default + or ws_enabled + ) + if task_meta.get("question") and prefetch_on: prefetch = _prefetch_context( - task_meta["question"], self._cloud_endpoint, self._cloud_model + task_meta["question"], + self._cloud_endpoint, + self._cloud_model, + max_uses=ws_max_uses, ) if prefetch.get("text"): @@ -462,6 +493,7 @@ class MinionsAgent(LocalCloudAgent): "tokens_cloud": (rp + rc) + prefetch["tokens"], "cost_usd": self.cost_usd(self._cloud_model, rp, rc) + prefetch["cost_usd"], "turns": cfg.get("max_rounds", 3), + "web_search_uses": prefetch["n_searches"], "traces": { "mode": mode, "supervisor_messages": out.get("supervisor_messages"), diff --git a/src/openjarvis/agents/hybrid/runner.py b/src/openjarvis/agents/hybrid/runner.py index ad384f03..ce04248b 100644 --- a/src/openjarvis/agents/hybrid/runner.py +++ b/src/openjarvis/agents/hybrid/runner.py @@ -43,13 +43,45 @@ DEFAULT_REGISTRY_DIR = PACKAGE_DIR / "registry" DEFAULT_EXPERIMENTS_DIR = Path( os.environ.get( "OPENJARVIS_HYBRID_EXPERIMENTS_DIR", - str(Path.home() / ".openjarvis-hybrid" / "experiments"), + "/matx/u/aspark/.openjarvis/experiments/hybrid", ) ) +DEFAULT_SUBSETS_DIR = DEFAULT_EXPERIMENTS_DIR / "subsets" # ---------- Registry ---------- +_SWE_BENCHES = {"swebench-verified", "swebench_verified", "swebench"} + + +def _validate_cells(cells: Dict[str, Dict[str, Any]]) -> None: + """Catch registry mistakes that would silently degrade behaviour. + + Currently: skillorchestra on a SWE bench MUST have + ``method_cfg.swe_use_agent_loop = true``. Without the flag, + skillorchestra.py falls back to a one-shot cloud call even for + SWE-bench tasks, which is rarely what the experimenter wants and is + invisible at runtime (Bug 5, 2026-05-15). + """ + bad: List[str] = [] + for name, cell in cells.items(): + if cell.get("method") != "skillorchestra": + continue + if cell.get("bench") not in _SWE_BENCHES: + continue + mcfg = cell.get("method_cfg") or {} + if not bool(mcfg.get("swe_use_agent_loop")): + bad.append(name) + if bad: + raise ValueError( + "skillorchestra SWE cells missing required " + "`method_cfg.swe_use_agent_loop = true`: " + + ", ".join(sorted(bad)) + + ". Without this flag the cell silently falls back to a " + "one-shot cloud call for SWE-bench tasks." + ) + + def load_registry(registry_dir: Optional[Path] = None) -> Dict[str, Dict[str, Any]]: """Merge every ``/*.toml``. Cell names must be unique.""" base = registry_dir or DEFAULT_REGISTRY_DIR @@ -67,6 +99,7 @@ def load_registry(registry_dir: Optional[Path] = None) -> Dict[str, Dict[str, An f"duplicate cell {name!r} (already defined before {p.name})" ) cells[name] = cell + _validate_cells(cells) return cells @@ -81,12 +114,17 @@ def _load_gaia_tasks(n: Optional[int]) -> List[Dict[str, Any]]: out: List[Dict[str, Any]] = [] for rec in ds.iter_records(): # rec.problem is the formatted question prompt; rec.metadata carries - # the GAIA-specific fields including any reference answer. + # the GAIA-specific fields including any reference answer. Prefer the + # upstream GAIA `task_id` field (bare uuid) over rec.record_id (which + # OpenJarvis prefixes with `gaia-`) so subsets keyed by the upstream + # id round-trip. + md = rec.metadata or {} + task_id = md.get("task_id") or rec.record_id out.append({ - "task_id": rec.record_id, - "question": rec.metadata.get("question", rec.problem), + "task_id": task_id, + "question": md.get("question", rec.problem), "reference": rec.reference, - "metadata": dict(rec.metadata), + "metadata": dict(md), }) return out @@ -95,7 +133,7 @@ def _load_swebench_tasks(n: Optional[int]) -> List[Dict[str, Any]]: """SWE-bench-Verified test. Each task carries patch-evaluation fields.""" from openjarvis.evals.datasets.swebench import SWEBenchDataset - ds = SWEBenchDataset() + ds = SWEBenchDataset(variant="verified") ds.load(max_samples=n) out: List[Dict[str, Any]] = [] for rec in ds.iter_records(): @@ -124,6 +162,72 @@ def load_tasks(bench: str, n: Optional[int]) -> List[Dict[str, Any]]: raise ValueError(f"unknown bench: {bench!r}") +def _load_subset_file(subset_path: str) -> Dict[str, Any]: + """Resolve a cell's ``subset`` field to a parsed JSON dict. + + Resolution order: + 1. Absolute path → use as-is. + 2. Bare filename / relative path → look up under + ``/subsets/`` (matches where ``make_subset.py`` + writes its output). + + Accepts both list-of-ids and dict-with-task_ids shapes; the legacy + harness wrote the dict shape and we preserve that. Returns a dict + with at least a ``task_ids`` list so callers don't have to branch. + """ + p = Path(subset_path) + if not p.is_absolute(): + p = DEFAULT_SUBSETS_DIR / p + if not p.exists(): + raise FileNotFoundError(f"subset file not found: {p}") + data = json.loads(p.read_text()) + if isinstance(data, list): + return {"task_ids": list(data)} + if isinstance(data, dict): + if "task_ids" not in data: + raise ValueError( + f"subset {p.name} has no 'task_ids' field; got keys {list(data.keys())}" + ) + return data + raise ValueError(f"subset {p.name} must be a list or dict; got {type(data).__name__}") + + +def _apply_subset( + tasks: List[Dict[str, Any]], + subset: Dict[str, Any], + cell: Dict[str, Any], +) -> List[Dict[str, Any]]: + """Filter ``tasks`` to the subset's task IDs, preserving subset order. + + Hard-errors if the cell's ``n`` doesn't equal ``len(task_ids)`` so a + typo in the registry can't silently shrink the eval. Also errors if + any subset ID is missing from the dataset (caller's bench wiring is + broken). + """ + ids: List[str] = list(subset["task_ids"]) + cell_n = int(cell["n"]) + if cell_n != len(ids): + raise ValueError( + f"subset n={len(ids)} ≠ cell n={cell_n} — refusing to silently " + "change scope. Fix the registry's `n` to match the subset file." + ) + if "bench" in subset and subset["bench"] != cell["bench"]: + raise ValueError( + f"subset bench={subset['bench']!r} ≠ cell bench={cell['bench']!r}" + ) + order = {tid: i for i, tid in enumerate(ids)} + allow = set(ids) + kept = [t for t in tasks if t["task_id"] in allow] + kept.sort(key=lambda t: order[t["task_id"]]) + missing = allow - {t["task_id"] for t in kept} + if missing: + raise ValueError( + f"subset references {len(missing)} task_ids not in dataset " + f"(e.g. {next(iter(missing))!r})" + ) + return kept + + # ---------- Scoring ---------- def _score_gaia(task: Dict[str, Any], answer: str) -> Dict[str, Any]: @@ -275,6 +379,7 @@ def _run_one(agent, bench: str, task: Dict[str, Any], log_dir: str) -> Dict[str, "tokens_cloud": int(meta.get("tokens_cloud", 0)), "cost_usd": float(meta.get("cost_usd", 0.0)), "latency_s": float(meta.get("latency_s", time.time() - t0)), + "web_search_uses": int(meta.get("web_search_uses", 0)), "traces": meta.get("traces", {}), } if "soft_error" in meta: @@ -286,6 +391,7 @@ def _run_one(agent, bench: str, task: Dict[str, Any], log_dir: str) -> Dict[str, "answer": "", "tokens_local": 0, "tokens_cloud": 0, "cost_usd": 0.0, "latency_s": time.time() - t0, + "web_search_uses": 0, "traces": {}, "error": f"{type(e).__name__}: {e}\n{traceback.format_exc()}", } @@ -312,6 +418,7 @@ def _write_summary( cell: Dict[str, Any], tasks: List[Dict[str, Any]], t_start: float, + n_processed: int = -1, ) -> None: results_path = out_dir / "results.jsonl" rows = [ @@ -326,8 +433,29 @@ def _write_summary( total_cost = sum(r.get("cost_usd", 0.0) for r in rows) total_local = sum(r.get("tokens_local", 0) for r in rows) total_cloud = sum(r.get("tokens_cloud", 0) for r in rows) + total_web_searches = sum(int(r.get("web_search_uses", 0) or 0) for r in rows) elapsed = time.time() - t_start + # Preserve prior wall_time_s on no-op resume so we don't clobber the + # original run's runtime. A resume that did zero work (everything was + # already cached in results.jsonl) records ~seconds of elapsed time; + # writing that as wall_time_s makes the cell look 20× faster than it + # really was. If we did process anything this session, accumulate so + # partial-resumes still report total wall time honestly. + summary_path = out_dir / "summary.json" + prior_wall = 0.0 + if summary_path.exists(): + try: + prior_wall = float( + json.loads(summary_path.read_text()).get("wall_time_s", 0.0) or 0.0 + ) + except Exception: + prior_wall = 0.0 + if n_processed == 0 and prior_wall > 0: + wall = prior_wall + else: + wall = prior_wall + elapsed + summary = { "cell": cell_name, "method": cell["method"], @@ -338,14 +466,16 @@ def _write_summary( "accuracy": acc, "tokens_local_total": total_local, "tokens_cloud_total": total_cloud, + "web_search_uses_total": total_web_searches, "cost_usd_total": total_cost, - "wall_time_s": elapsed, + "wall_time_s": wall, "task_count": len(tasks), } - (out_dir / "summary.json").write_text(json.dumps(summary, indent=2)) + summary_path.write_text(json.dumps(summary, indent=2)) print( f"[summary] {cell_name}: n={n_done}/{cell['n']} err={n_err} " - f"acc={acc:.3f} cost=${total_cost:.2f} time={elapsed/60:.1f}m", + f"acc={acc:.3f} cost=${total_cost:.2f} time={wall/60:.1f}m " + f"(session +{elapsed/60:.1f}m, processed={n_processed})", flush=True, ) @@ -398,8 +528,21 @@ def _run_cell_locked( flush=True, ) - tasks = load_tasks(cell["bench"], n=cell["n"]) - print(f"[load] {cell['bench']} → {len(tasks)} tasks", flush=True) + subset_path = cell.get("subset") + if subset_path: + subset = _load_subset_file(subset_path) + # Load the full bench (n=None) so we can pick the exact subset IDs. + # The dataset providers are cached, so this isn't a re-fetch. + all_tasks = load_tasks(cell["bench"], n=None) + tasks = _apply_subset(all_tasks, subset, cell) + print( + f"[load] {cell['bench']} subset={Path(subset_path).name} " + f"→ {len(tasks)} tasks", + flush=True, + ) + else: + tasks = load_tasks(cell["bench"], n=cell["n"]) + print(f"[load] {cell['bench']} → {len(tasks)} tasks", flush=True) pending = [t for t in tasks if t["task_id"] not in done_ids] concurrency = max(1, int(cell.get("concurrency", 1))) @@ -440,7 +583,7 @@ def _run_cell_locked( for fut in as_completed(futures): fut.result() - _write_summary(out_dir, cell_name, cell, tasks, t_start) + _write_summary(out_dir, cell_name, cell, tasks, t_start, n_processed=len(pending)) # ---------- CLI ---------- diff --git a/src/openjarvis/agents/hybrid/skillorchestra.py b/src/openjarvis/agents/hybrid/skillorchestra.py index f3875159..0203e697 100644 --- a/src/openjarvis/agents/hybrid/skillorchestra.py +++ b/src/openjarvis/agents/hybrid/skillorchestra.py @@ -33,7 +33,12 @@ import json from typing import Any, Dict, List, Optional, Tuple from openjarvis.agents._stubs import AgentContext -from openjarvis.agents.hybrid._base import LocalCloudAgent +from openjarvis.agents.hybrid._base import ( + WEB_SEARCH_COST_PER_CALL, + LocalCloudAgent, + build_web_search_tool, + web_search_cfg, +) from openjarvis.agents.hybrid._prices import supports_temperature from openjarvis.agents.hybrid.mini_swe_agent import run_swe_agent_loop from openjarvis.core.registry import AgentRegistry @@ -454,6 +459,7 @@ class SkillOrchestraAgent(LocalCloudAgent): tokens_local = 0 tokens_cloud = r_in + r_out run_cost = self.cost_usd(router_model, r_in, r_out) + self._web_search_uses_last = 0 # 2. Execute via chosen agent task_meta = (context.metadata.get("task") if context is not None else {}) or {} @@ -515,13 +521,31 @@ class SkillOrchestraAgent(LocalCloudAgent): tokens_cloud += out["tokens_in"] + out["tokens_out"] run_cost += out["cost_usd"] else: - ans, w_in, w_out = self._call_cloud( - user=question, - max_tokens=int(cfg.get("cloud_max_tokens", 4096)), - temperature=0.0, - ) + # GAIA cloud worker: opt-in native web_search via the new + # method_cfg.web_search schema. Only fires when the chosen + # worker is on Anthropic (server-side tool). + ws_enabled, ws_max_uses = web_search_cfg(cfg) + gaia_max_turns = int(cfg.get("gaia_max_turns", 8)) + n_searches = 0 + if ws_enabled and self._cloud_endpoint == "anthropic": + ans, w_in, w_out, n_searches, _ = self._call_anthropic_agent( + self._cloud_model, + user=question, + max_tokens=int(cfg.get("cloud_max_tokens", 4096)), + temperature=0.0, + tools=[build_web_search_tool(ws_max_uses)], + max_turns=gaia_max_turns, + ) + else: + ans, w_in, w_out = self._call_cloud( + user=question, + max_tokens=int(cfg.get("cloud_max_tokens", 4096)), + temperature=0.0, + ) tokens_cloud += w_in + w_out run_cost += self.cost_usd(self._cloud_model, w_in, w_out) + run_cost += n_searches * WEB_SEARCH_COST_PER_CALL + self._web_search_uses_last = n_searches worker_model = self._cloud_model meta = { @@ -529,12 +553,14 @@ class SkillOrchestraAgent(LocalCloudAgent): "tokens_cloud": tokens_cloud, "cost_usd": run_cost, "turns": 2, # router + worker + "web_search_uses": self._web_search_uses_last, "traces": { "chosen_agent": chosen, "worker_model": worker_model, "skill_weights": skill_weights, "agent_scores": scored, "reasoning": decision.get("reasoning", ""), + "n_web_searches": self._web_search_uses_last, }, } return ans, meta diff --git a/tests/agents/hybrid/test_web_search_wiring.py b/tests/agents/hybrid/test_web_search_wiring.py new file mode 100644 index 00000000..6a20bdd3 --- /dev/null +++ b/tests/agents/hybrid/test_web_search_wiring.py @@ -0,0 +1,196 @@ +"""Tests for the GAIA web_search wiring (2026-05-17). + +Confirms that when a GAIA cell sets ``method_cfg.web_search.enabled = true`` +and the cloud endpoint is Anthropic, every paradigm declares the native +``web_search_20250305`` server-side tool on its Anthropic call. Uses mocks +— no real API calls. + +Also confirms the default (``web_search`` absent / disabled) stays +one-shot and does NOT declare the tool, preserving back-compat with the +currently running n=100 cells. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from openjarvis.agents._stubs import AgentContext +from openjarvis.agents.hybrid._base import ( + LocalCloudAgent, + build_web_search_tool, + web_search_cfg, +) + + +# --------------------------------------------------------------------------- +# 1. Schema parser +# --------------------------------------------------------------------------- + +def test_web_search_cfg_default_off() -> None: + assert web_search_cfg(None) == (False, 8) + assert web_search_cfg({}) == (False, 8) + assert web_search_cfg({"web_search": None}) == (False, 8) + + +def test_web_search_cfg_enabled() -> None: + assert web_search_cfg({"web_search": {"enabled": True}}) == (True, 8) + assert web_search_cfg({"web_search": {"enabled": True, "max_uses": 3}}) == (True, 3) + assert web_search_cfg({"web_search": {"enabled": False, "max_uses": 5}}) == (False, 5) + + +def test_build_web_search_tool_shape() -> None: + tool = build_web_search_tool(5) + assert tool == { + "type": "web_search_20250305", + "name": "web_search", + "max_uses": 5, + } + + +# --------------------------------------------------------------------------- +# 2. Baseline cloud GAIA cell declares the tool when opted in +# --------------------------------------------------------------------------- + +def _fake_anthropic_response( + text: str = "FINAL ANSWER: 42", + n_searches: int = 2, + input_tokens: int = 100, + output_tokens: int = 50, +): + """Build a minimal Anthropic-shaped response object.""" + return SimpleNamespace( + content=[SimpleNamespace( + type="text", text=text, + # the serializer probes a fixed attr list; only `text` is + # needed for the text block path. + )], + usage=SimpleNamespace( + input_tokens=input_tokens, + output_tokens=output_tokens, + server_tool_use=SimpleNamespace(web_search_requests=n_searches), + ), + stop_reason="end_turn", + ) + + +class _FakeMessages: + """Captures the create() kwargs so the test can assert what was sent.""" + + def __init__(self): + self.last_kwargs = None + self.responses = [_fake_anthropic_response()] + self.calls = 0 + + def create(self, **kwargs): + self.last_kwargs = kwargs + idx = min(self.calls, len(self.responses) - 1) + self.calls += 1 + return self.responses[idx] + + +class _FakeAnthropic: + """anthropic.Anthropic() stub. Stores messages on a class attr so the + test can pull it out after the agent's call.""" + + last_messages = None + + def __init__(self, *args, **kwargs): + self.messages = _FakeMessages() + type(self).last_messages = self.messages + + +@pytest.fixture +def fake_anthropic(monkeypatch): + """Patch anthropic.Anthropic at the SDK level so every paradigm's + raw client construction picks up the fake. Importing the real + library is fine — the agent uses ``anthropic.Anthropic()`` only. + """ + import anthropic + monkeypatch.setattr(anthropic, "Anthropic", _FakeAnthropic) + yield _FakeAnthropic + + +def test_baseline_cloud_declares_web_search_on_gaia_when_enabled(fake_anthropic): + from openjarvis.agents.hybrid.baseline_cloud import BaselineCloudAgent + + agent = BaselineCloudAgent( + engine=None, + model="claude-opus-4-7", + cloud_endpoint="anthropic", + cfg={ + "cloud_max_tokens": 1024, + "web_search": {"enabled": True, "max_uses": 4}, + "gaia_max_turns": 2, + }, + ) + ctx = AgentContext(metadata={ + "task": {"task_id": "t1", "question": "What is X?"}, + "task_id": "t1", + }) + result = agent.run("What is X?", ctx) + + sent = fake_anthropic.last_messages.last_kwargs + assert sent is not None, "Anthropic client was never called" + assert "tools" in sent, "web_search tool was not declared" + assert sent["tools"] == [ + {"type": "web_search_20250305", "name": "web_search", "max_uses": 4} + ] + # Per-row web_search_uses surfaced through meta. + assert result.metadata["web_search_uses"] == 2 + + +def test_baseline_cloud_does_not_declare_tool_when_disabled(fake_anthropic): + from openjarvis.agents.hybrid.baseline_cloud import BaselineCloudAgent + + agent = BaselineCloudAgent( + engine=None, + model="claude-opus-4-7", + cloud_endpoint="anthropic", + cfg={"cloud_max_tokens": 1024}, # no web_search block — default OFF + ) + ctx = AgentContext(metadata={ + "task": {"task_id": "t2", "question": "What is X?"}, + "task_id": "t2", + }) + agent.run("What is X?", ctx) + + sent = fake_anthropic.last_messages.last_kwargs + assert sent is not None + assert "tools" not in sent, ( + "Default behavior MUST be one-shot with no tools declared — " + "tools must only appear when method_cfg.web_search.enabled = true." + ) + + +def test_baseline_cloud_web_search_skipped_on_non_anthropic(monkeypatch): + """Non-Anthropic endpoint with web_search.enabled=true must not crash + and must not invoke a fake local web_search — it falls back to the + one-shot path (logged as web_search_skipped).""" + from openjarvis.agents.hybrid import baseline_cloud as bc_mod + from openjarvis.agents.hybrid.baseline_cloud import BaselineCloudAgent + + # Stub the cloud call so we don't hit OpenAI. + def fake_call_cloud(self, *, user, system=None, max_tokens=4096, + temperature=0.0, **kwargs): + return "FINAL ANSWER: x", 10, 5 + + monkeypatch.setattr( + bc_mod.BaselineCloudAgent, "_call_cloud", fake_call_cloud, raising=True, + ) + + agent = BaselineCloudAgent( + engine=None, + model="gpt-5", + cloud_endpoint="openai", + cfg={"cloud_max_tokens": 1024, "web_search": {"enabled": True}}, + ) + ctx = AgentContext(metadata={ + "task": {"task_id": "t3", "question": "X?"}, + "task_id": "t3", + }) + res = agent.run("X?", ctx) + # Falls through to one-shot path; no crash, no fake web_search. + assert res.metadata["web_search_uses"] == 0