Skip to content

feat(agentic_mme): add the Agentic-MME environment, agent and benchmark - #5523

Open
DanialTaheri wants to merge 33 commits into
NVIDIA-NeMo:mainfrom
DanialTaheri:agentic-mme-env
Open

DanialTaheri wants to merge 33 commits into
NVIDIA-NeMo:mainfrom
DanialTaheri:agentic-mme-env

Conversation

@DanialTaheri

@DanialTaheri DanialTaheri commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds the Agentic-MME benchmark: multi-turn visual reasoning where the policy answers image questions using atomic image tools and, optionally, web retrieval. It is onboarded as a manifest-backed benchmark (experimental: true). No existing environment covers this task, so there is no linked issue; this PR is the proposal.

  • resources_servers/agentic_mme: task-only verification. The terminal answer is scored by an LLM judge, with the released evaluator's exact / contains / numeric string match reported alongside as string_match_reward. Verifier metadata never enters the model context. Exports a VERIFIER_FIXTURE.
  • responses_api_agents/agentic_mme_agent: native multi-turn agent with append-only history, so every model call's prompt extends the previous one (compatible with token capture for RL). It is fronted by a legacy_agent environment server.
    • 14 atomic image tools (crop, rotate, flip, resize, enhance, grayscale, autocontrast, blur, sharpen, denoise, edge_detect, invert, equalize, threshold) over a per-episode image workspace with pixel budgets. Each successful operation appends an image the model sees next turn.
    • Optional retrieval: web search (Serper / SerpAPI / Tavily / Exa), Google Lens (Serper / SerpAPI), page fetch (Jina / Exa), with a benchmark-contamination guard that scans the whole fetched page.
    • Replay mode that feeds recorded retrieval results back in order, for reproducible evaluation.
    • Shared, persistent retrieval cache (content-addressed flat files, safe across processes and nodes). Exact query match by default; fuzzy matching (FTS5 + rapidfuzz) is opt-in and shows the model which cached query a fuzzy hit answers.
    • Failed policy calls (HTTP errors, timeouts, connection loss, unparseable responses) end the episode and score 0, with the error recorded in policy_error, so no rollout is silently dropped.
  • benchmarks/agentic_mme: manifest.yaml, config.yaml (judge binding, no round or tool-call limit), prepare.py, retrieval overlays, and a README with the measurement contract.

Tasks and licensing. 418 tasks: the full release of Crystal1047/Agentic-MME at pinned revision b9ea9d3f, unfiltered. The release's only split is named train but is an evaluation set. Domains include charts, architecture, news, music and anime, at levels L1 to L3. Dataset license: Apache-2.0 (dataset card). The reference harness (ChoS3nE11ven/Agentic-MME) declares no code license, so nothing is vendored: the tools are an independent implementation on Pillow (HPND), NumPy (BSD) and OpenCV (Apache-2.0). The five checked-in example tasks are templated colour-identification questions on generated solid-colour images; no model was used to generate task data.

Validation

Run against the final diff (branch merged with current main):

  • pre-commit run --all-files: all hooks pass (ruff, ruff-format, import ordering, verified flag, README table, import-linter).
  • gym env validate agentic_mme: manifest, components and 418 prepared rows OK; inferred profile custom-gym-agent-loop matches the declared one.
  • gym env test agentic_mme: verifier fixture, 3 cases pass. gym env publish agentic_mme: publication checks pass (local only).
  • gym env test +entrypoint=resources_servers/agentic_mme +should_validate_data=true: 32 passed, data validated. gym env test +entrypoint=responses_api_agents/agentic_mme_agent: 122 passed.
  • scripts/add_legacy_agent_environment_servers.py --check: clean repo-wide.
  • Component tests also pass with -W error on nemo_gym deprecation warnings (imports use the current module paths).

Rollouts

Smoke rollouts on the final code, policy Kimi K3 through an OpenAI-compatible endpoint, judge gpt-4o-mini, retrieval disabled, servers started with gym env start --config benchmarks/agentic_mme/config.yaml and collected with gym eval run --no-serve --agent agentic_mme_agent:

  • Example data (resources_servers/agentic_mme/data/example_rollouts.jsonl): 5 of 5 correct. Each episode calls crop, receives the cropped image in the next turn, and answers in <answer> tags; judge and string match agree.

  • First 5 benchmark tasks (--limit 5): 3 of 5 correct, no failed rollouts, no policy_error.

    Task Gold Answer Tools (all succeeded) Reward
    L1 Charts Yes Yes none 1
    L2 Architecture Conrad Wahn Conrad Wahn none 1
    L2 News 44.6 million $44.6 million none 1
    L1 Music Rachel's The Velvet Underground crop ×8, enhance ×1 0
    L2 Anime Kodansha USA Seven Seas Entertainment crop ×2 0

    The judge accepted the unit difference ($44.6 million) and rejected both wrong entities, matching string match on all five.

A full evaluation and reward profile are not included. No published result has been reproduced yet, so the manifest stays experimental: true.

Compatibility and benchmark impact

None for existing environments: all changes are new packages plus one new README table row. Retrieval is opt-in through the benchmark overlays and needs provider API keys only when enabled.

Proposed labels: feature, area:environment, benchmark-onboarding.

Checklist

  • I have read the contributing guidelines.
  • The change is focused; unrelated "drive-by" edits are tracked as separate issues/PRs.
  • Tests added or updated and pass locally, or N/A for docs-only / non-code changes (so CI unit/server checks pass when applicable).
  • Documentation added or updated, or N/A with justification.
  • Pre-commit checks pass locally (pre-commit run --all-files) (so CI lint/format checks pass).
  • New source files include the required Apache-2.0 SPDX header.
  • All commits have DCO sign-off (git commit -s) (so the DCO check passes).

Agentic-MME (Track B, task-only scoring) as a native Gym environment:
- resources_servers/agentic_mme: verifier with the released contains/exact/numeric
  matching, an optional LLM correctness judge, synthetic smoke examples, configs.
- responses_api_agents/agentic_mme_agent: the 14 atomic image tools plus optional
  retrieval (google_search, google_lens_search, fetch_webpage), multi-turn image
  observations, refused model calls end the episode unanswered, explicit item types.
- benchmarks/agentic_mme: prepare script and benchmark config (no round or tool-call
  limit; judge key defaulted so gym eval prepare loads the config).

Signed-off-by: smohsenitahe <smohsenitahe@nvidia.com>
…l cache

- retrieval.search_provider: serper | serpapi | tavily | exa; lens_provider for Google
  Lens (serper | serpapi | none), answers normalised to the Serper shape.
- Shared on-disk cache (search_cache.py + sqlite_fuzzy_index.py): exact + fuzzy query
  matching, Lens keyed by image pixels, pages by URL; index rebuild command; writes are
  best-effort and keep the shared directory readable across users.
- benchmarks/agentic_mme retrieval overlays (incl. JINA_API_KEY for fetch_webpage) and docs.

Signed-off-by: smohsenitahe <smohsenitahe@nvidia.com>
…en-capture model path

- retrieval.fetch_provider (jina | exa): Exa /contents for fetch_webpage.
- Contamination guard (on by default): items quoting Agentic-MME itself are dropped on
  every read; the cache keeps raw answers.
- fetch_webpage without a cache no longer returns the whole page to the model.
- Model calls go through url_path_for_run / url_path_for_request (NeMo RL token capture).

Signed-off-by: smohsenitahe <smohsenitahe@nvidia.com>
Every model call keeps the same tools and tool_choice, so each call's prompt extends the
previous one (NeMo RL token capture links a rollout's calls by that prefix; the tools-
stripped answer-only call broke the chain). The episode ends unanswered when the rounds
run out or the model calls a tool beyond max_tool_calls; using the whole budget still
allows an answer on the next call.

Signed-off-by: smohsenitahe <smohsenitahe@nvidia.com>
- The shared input fixture sets detail on input_image parts (now required), like the
  agent's own image observations do.
- The test Request carries a path: url_path_for_request reads request.url.path to keep
  token-capture routes.

Signed-off-by: smohsenitahe <smohsenitahe@nvidia.com>
… for SerpAPI Lens

SerpAPI's Image API (POST /image, multipart, JPEG/PNG/WebP up to 500 KB) returns an
image_id that google_lens takes instead of a public URL, so lens_provider=serpapi
needs only the SerpAPI key. retrieval.image_host (auto | serpapi | imgbb): auto uses
SerpAPI for SerpAPI Lens and ImgBB for Serper, whose Lens needs a public URL. Images
are re-encoded as JPEG under 500 KB (quality first, then size). allow_image_upload
stays required. The SerpAPI-Lens overlays drop IMGBB_API_KEY.

Signed-off-by: smohsenitahe <smohsenitahe@nvidia.com>
(cherry picked from commit 853bb654f30483af2424c162d5c948ecc6d54e00)
… exa), no Jina

retrieval_exa.yaml and retrieval_exa_serpapi_lens.yaml set fetch_provider: exa, so
fetch_webpage calls Exa /contents and Exa is the only text provider, as in the
RL search configs; JINA_API_KEY is no longer needed with these overlays.

Signed-off-by: smohsenitahe <smohsenitahe@nvidia.com>
…status as 0

A policy call that failed before giving an HTTP status (connection lost,
timeout after the client's retries, a body that is not JSON or not a
Response) raised out of run(), so rollout collection wrote it to the failures
sidecar and left it out of the mean. Every failed episode should score 0.

The post, body read and Response validation now sit in one guard that ends the
episode through the existing policy_error path, exactly like an HTTP refusal.

Tests: all five failure types, on the first call and mid-episode.
Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
…calls

tool_trace records every retrieval call, including calls rejected before the
provider (local or benchmark URL, bad image_index, invalid arguments). Replay
validated and short-circuited before consuming its entry, so after such a call
every later call raised a replay mismatch.

In replay mode the entry is now matched and consumed first and its recorded
output, a recorded error included, is returned. Arguments match as sent or
once normalized; a mismatch still raises without advancing. README updated.

Test: four kinds of rejected call followed by a valid one, no network.
Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
…tamination

fetch_webpage checked only the max_chars prefix the model sees, so an
answer-key page whose Agentic-MME attribution came after the cut passed the
guard. With a cache configured, a live page was also checked only on its
prefix, because _cache_store dropped full_text before _decontaminate ran.

_decontaminate now scans full_text (the live page or the cached copy) and then
drops it; _cache_store keeps it until then. full_text never reaches the model.
The scan costs about 0.13 ms at the 50K-character Exa cap.

Tests: attribution past max_chars, live and through a cache miss and hit.
Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
…zzy hit's query

With cache_fuzzy_threshold 0.8, a cached query such as 'capital of austria'
answered 'capital of australia' (token_sort_ratio about 95), tagged only
cache: fuzzy, so the model reasoned over results about a different entity.

Fuzzy matching is now opt-in: cache_fuzzy_threshold defaults to 0 (exact only)
in RetrievalConfig, SearchCache and retrieval_cache.yaml. When enabled, a fuzzy
hit carries matched_query, the cached query the results answer, in the tool
output the model sees.

The fuzzy index also loses its dead code: add_many, master_count, local_count,
last_candidate_count, the unreachable rapidfuzz ImportError fallback and the
unused tokenizer and candidate_limit knobs, plus the docstring's reference to
a document in an archived repo.

Tests: the near miss is a miss by default and a fuzzy hit when opted in; a
fuzzy hit through Retrieval shows matched_query without a provider call; the
process-local tier matches before any rebuild and writes each norm to its shard
once; rebuild_index() indexes norms that exist only in another process's shard.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
README env table regenerated with scripts/update_env_list.py.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Nov 3, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

- Front agentic_mme_agent with a legacy_agent environment server, which
  rollout collection now requires.
- Export a VERIFIER_FIXTURE (full reward, zero reward, malformed request)
  and add benchmarks/agentic_mme/manifest.yaml (experimental) and README
  with the measurement contract.
- Add data/example_rollouts.jsonl from a live smoke run.
- Import from the current nemo_gym module paths instead of the
  deprecated aliases.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
@DanialTaheri
DanialTaheri marked this pull request as ready for review November 4, 2026 20:40
@yfw

yfw commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/ok to test b9b90d0

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot added the sla:triage-overdue Review assignment is over the one-business-day SLA label Nov 5, 2026
Comment thread resources_servers/agentic_mme/app.py Outdated
Comment on lines +15 to +25
from nemo_gym.config.config_types import ModelServerRef
from nemo_gym.core.responses_api_types import NeMoGymChatCompletion
from nemo_gym.server_runtime.base_resources_server import (
BaseResourcesServerConfig,
BaseVerifyRequest,
BaseVerifyResponse,
ReverifyMode,
SimpleResourcesServer,
)
from nemo_gym.verification import VerifierFixture
from nemo_gym.verification.judge import JudgeError, call_judge

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resources_servers/agentic_mme/app.py:15

These imports name nemo_gym submodules, so a core unit test fails at this head.

AGENTS.md asks code outside nemo_gym/ to import public names from each package's public API. test_code_outside_the_library_imports_exported_names_from_the_public_api enforces that rule, and it lists 26 names in 13 import statements of this PR. The Core tests job runs this test for server-only changes too (unit-tests.yml), so that job fails once CI runs.

Suggested fix: import each name from its package. With the same change in the 3 files listed below, the test passes, and the 154 component tests still pass.

Suggested change
from nemo_gym.config.config_types import ModelServerRef
from nemo_gym.core.responses_api_types import NeMoGymChatCompletion
from nemo_gym.server_runtime.base_resources_server import (
BaseResourcesServerConfig,
BaseVerifyRequest,
BaseVerifyResponse,
ReverifyMode,
SimpleResourcesServer,
)
from nemo_gym.verification import VerifierFixture
from nemo_gym.verification.judge import JudgeError, call_judge
from nemo_gym.config import ModelServerRef
from nemo_gym.core import NeMoGymChatCompletion
from nemo_gym.server_runtime import (
BaseResourcesServerConfig,
BaseVerifyRequest,
BaseVerifyResponse,
ReverifyMode,
SimpleResourcesServer,
)
from nemo_gym.verification import JudgeError, VerifierFixture, call_judge
The same change in the agent app.py, retrieval.py and the resources tests

responses_api_agents/agentic_mme_agent/app.py L16-L26:

from nemo_gym.agents import BaseResponsesAPIAgentConfig, Body, SimpleResponsesAPIAgent
from nemo_gym.client import get_response_json, raise_for_status
from nemo_gym.config import ModelServerRef, ResourcesServerRef
from nemo_gym.core import (
    NeMoGymEasyInputMessage,
    NeMoGymFunctionCallOutput,
    NeMoGymResponse,
    NeMoGymResponseCreateParamsNonStreaming,
    accumulate_response_usage,
)
from nemo_gym.server_runtime import BaseRunRequest

responses_api_agents/agentic_mme_agent/retrieval.py L43: from nemo_gym.client import request

resources_servers/agentic_mme/tests/test_app.py: L8 from nemo_gym.core import NeMoGymResponse, L106 from nemo_gym.core import NeMoGymChatCompletion and L184 from nemo_gym.verification import JudgeError.

scripts/migrate_imports.py does not make this change, because these are current paths, not moved paths.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 4d1cd0b5e. Every nemo_gym name is now imported from its package, and test_public_api_facades passes.

Comment thread resources_servers/agentic_mme/app.py Outdated
from resources_servers.agentic_mme.verifier_fixture import create_server


class GoldenAnswer(BaseModel):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resources_servers/agentic_mme/app.py:29

This server has no task_data.py, so two core data tests fail at this head.

A resources server ships a task_data.py module next to its app.py exporting a single
symbol TaskData

That is the contract in nemo_gym/data/task_data.py. The agent names agentic_mme as its resources_server (configs/agentic_mme.yaml), so this server owns the schema of the example rows and the benchmark rows. test_every_resources_server_ships_a_schema fails with "resources servers without a task_data.py schema: ['agentic_mme']". test_all_committed_rows_validate fails with "agentic_mme: no loadable schema".

Today nothing validates a row's golden_answer before a run. A bad gold value becomes reward 0.0 with failure_reason="invalid_golden_answer" (app.py:195-199), and that row counts in the mean.

Suggested fix: add this resources_servers/agentic_mme/task_data.py. It owns GoldenAnswer, so the schema and the verifier share one class. It adds a file, so it cannot be a suggestion block.

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Agentic-MME row fields: grading data inside verifier_metadata, and the agent's optional retrieval replay."""

import math
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator


class GoldenAnswer(BaseModel):
    value: str | int | float
    match_type: Literal["exact", "contains", "numeric"] = "contains"
    tolerance: float = Field(default=0.0, ge=0, allow_inf_nan=False)

    @field_validator("value", mode="before")
    @classmethod
    def valid_target(cls, value: Any) -> str | int | float:
        if isinstance(value, bool) or not isinstance(value, (str, int, float)) or not str(value).strip():
            raise ValueError("golden_answer.value must be a nonempty string or finite number")
        if isinstance(value, float) and not math.isfinite(value):
            raise ValueError("golden_answer.value must be finite")
        return value


class TaskData(BaseModel):
    model_config = ConfigDict(extra="allow")

    golden_answer: GoldenAnswer = Field(json_schema_extra={"legacy_location": "verifier_metadata"})
    # The agent reads process_evaluation.efficiency.reference_tool_calls for the overthink metric.
    process_evaluation: dict[str, Any] | None = Field(
        default=None, json_schema_extra={"legacy_location": "verifier_metadata"}
    )
    task_id: str | None = Field(default=None, json_schema_extra={"legacy_location": "verifier_metadata"})
    level: int | None = Field(default=None, json_schema_extra={"legacy_location": "verifier_metadata"})
    domain: str | None = Field(default=None, json_schema_extra={"legacy_location": "verifier_metadata"})
    synthetic: bool = Field(default=False, json_schema_extra={"legacy_location": "verifier_metadata"})
    # Read by the agent's run request at the row's top level when retrieval.mode is replay.
    retrieval_replay: list[dict[str, Any]] = Field(default_factory=list)

In app.py, please delete GoldenAnswer (L29-L41) and import it with from resources_servers.agentic_mme.task_data import GoldenAnswer. Then please drop the unused Literal, BaseModel and field_validator imports.

Why `level` is an int and why the schema declares `synthetic`

The pinned release stores meta.level as 1, 2 or 3 in all 418 tasks. With level: str, all 418 prepared rows fail the schema. With int | None, all 418 pass. The drift test also rejects keys that the schema does not declare, so the five example rows need synthetic. With this file, both core tests pass, and a row with {"value": ""} or match_type: "exact_match" fails at collate time instead of scoring 0.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 387379d0a. task_data.py exports TaskData, which validates golden_answer (value, match type, tolerance) and the other verifier_metadata fields. A malformed gold answer now fails data validation instead of scoring 0. All 418 prepared rows and the committed example rows validate, and both core data tests pass.

Comment thread resources_servers/agentic_mme/app.py Outdated
Comment on lines +215 to +223
try:
judged = await self.judge(
question_text(body.responses_create_params), str(golden.value), result.extracted_answer, final_text
)
except JudgeError as exc:
result.reward = 0.0
result.failure_reason = "judge_error"
result.judge_reason = str(exc)[:500]
return result

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resources_servers/agentic_mme/app.py:215

At the PR head, a failed judge call scores the answer 0 here, so a judge outage counts against the model.

Gym wraps every verify in judge_failsafe. It turns a JudgeError into a masked judge_failed row, because "A failed judge call is a distinct outcome, not a wrong answer" (judge.py). This block catches the error first. With the judge unreachable, <answer>44.6 million</answer> for gold 44.6 million returns reward: 0.0, string_match_reward: 1.0 and mask_sample: false. The row then counts in mean/reward. If OPENAI_API_KEY is unset, the judge answers HTTP 401, and every answered task scores 0.

The add-benchmark skill at this PR's merge base already asks for this (SKILL.md):

For an environment failure (sandbox down, judge unreachable), do not score 0.0. Return mask_sample, failure_kind (as <server>:<kind>) and failure_reason so the row leaves the aggregate. Add one test case that masks a row

On current main, call_judge raises a judge_failed event instead of JudgeError (#5072, judge.py on main), so after a merge with main this block catches nothing.

Suggested fix: let the error reach judge_failsafe.

Suggested change
try:
judged = await self.judge(
question_text(body.responses_create_params), str(golden.value), result.extracted_answer, final_text
)
except JudgeError as exc:
result.reward = 0.0
result.failure_reason = "judge_error"
result.judge_reason = str(exc)[:500]
return result
# A failed judge call propagates: judge_failsafe masks the row instead of scoring the answer 0.
judged = await self.judge(
question_text(body.responses_create_params), str(golden.value), result.extracted_answer, final_text
)

Please also drop JudgeError from the nemo_gym.verification import (line 25). Please also replace test_judge_error_scores_zero with this test through judge_failsafe. It needs import json at the top of the file.

@pytest.mark.asyncio
async def test_judge_error_is_masked_through_the_verify_route(judged_server, monkeypatch) -> None:
    from nemo_gym.verification import JudgeError, judge_failsafe

    async def failing_call_judge(*args, **kwargs):
        raise JudgeError("judge down")

    monkeypatch.setattr("resources_servers.agentic_mme.app.call_judge", failing_call_judge)
    # /verify serves judge_failsafe(self.verify).
    result = await judge_failsafe(judged_server.verify)(body=judged_request("<answer>44.6 million</answer>"))
    data = json.loads(result.body)
    assert data["reward"] == 0.0 and data["mask_sample"] is True and data["failure_kind"] == "judge_failed"
Steps from a failed judge call to an unmasked zero, and the analogs on main
  1. AgenticMMEServer.verify calls self.judge (app.py L214-L218).
  2. At this head, call_judge wraps any failure as JudgeError (judge.py L68-L107).
  3. The handler sets reward 0.0 and returns (app.py L219-L223), so judge_failsafe (base_resources_server.py L568) never sees the error.
  4. mask_sample stays false, so select_measured keeps the row in the quality metrics (reward_profile.py L888).
  5. NeMo RL drops a row from the loss only through mask_sample (rollouts.py), so it trains on this row as a wrong answer.

On main, simpleqa raises JudgeError and lets judge_failsafe handle it (simpleqa/app.py L284-L286). opencode_search retries and then raises "rather than recording a fabricated zero" (opencode_search/app.py L400-L408).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 971d64983. The except JudgeError block is gone, so judge_failsafe turns a failed judge call into a masked judge_failed row. test_judge_error_goes_to_failures_sidecar covers it.

Comment thread benchmarks/agentic_mme/prepare.py Outdated
Comment on lines +83 to +95
answer_checks = [
c["answer_check"] for c in task["process_evaluation"].get("checkpoints", []) if "answer_check" in c
]
match_type = MATCH_TYPES.get((answer_checks[-1].get("match_type") if answer_checks else None) or "", "contains")
return {
"responses_create_params": {
"input": [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": content}]
},
"verifier_metadata": {
"task_id": task["task_id"],
"level": task["meta"].get("level"),
"domain": task["meta"].get("domain"),
"golden_answer": {"value": task["golden_answer"]["value"], "match_type": match_type},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

benchmarks/agentic_mme/prepare.py:83

The match type comes from the last checkpoint's answer_check, but the released Track B scorer reads it from golden_answer.

The PR's own matcher states the contract (matches_answer):

Match the released evaluator's exact/contains/numeric task semantics.

The paper says normalized exact match, but the released dataset/evaluator
default to contains.

The released evaluator scores final answers in check_final_answer_only, which reads golden.get("match_type", "contains"). In the pinned release, golden_answer has only value in all 418 tasks, so the reference uses contains for every task.

This code writes exact for 202 tasks and numeric for 132. Task 0004 (gold Yes) gets string_match_reward 0.0 for yes. Task 0009 (gold 202) gets 0.0 for 202 people. The reference marks both correct. Without a judge, this value is the reward.

Suggested fix: read the match type from golden_answer, as the reference does. MATCH_TYPES on line 38 is then unused, so please delete it and its comment.

Suggested change
answer_checks = [
c["answer_check"] for c in task["process_evaluation"].get("checkpoints", []) if "answer_check" in c
]
match_type = MATCH_TYPES.get((answer_checks[-1].get("match_type") if answer_checks else None) or "", "contains")
return {
"responses_create_params": {
"input": [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": content}]
},
"verifier_metadata": {
"task_id": task["task_id"],
"level": task["meta"].get("level"),
"domain": task["meta"].get("domain"),
"golden_answer": {"value": task["golden_answer"]["value"], "match_type": match_type},
golden = task["golden_answer"]
# Track B accuracy (the reference check_final_answer_only) reads golden_answer's own match_type and
# tolerance, and scores any other label as contains.
match_type = golden.get("match_type") if golden.get("match_type") in ("exact", "numeric") else "contains"
return {
"responses_create_params": {
"input": [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": content}]
},
"verifier_metadata": {
"task_id": task["task_id"],
"level": task["meta"].get("level"),
"domain": task["meta"].get("domain"),
"golden_answer": {
"value": golden["value"],
"match_type": match_type,
"tolerance": float(golden.get("tolerance", 0.0)),
},
Where the reference scorer and the READMEs say this
  • Track B "Final Answer Accuracy" is check_final_answer_only (eval_runs_search.py L428-L504), counted in aggregate_shards.py L74. It scores any label other than exact, contains and numeric as contains.
  • The resources-server README shows match_type inside golden_answer as "the released shape" (README L36-L45).
  • The benchmark README presents string_match_reward as the released evaluator's string match (README L40-L42).
  • On the pinned release, this fix writes contains for all 418 rows, which matches the reference. It also keeps a golden tolerance if a later release adds one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Done in 0c29c6783. golden_answer is now the release's value only, so string_match_reward uses "contains" for every task, as check_final_answer_only does. MATCH_TYPES is removed. The author's numeric tolerance from the final answer check moves to verifier_metadata.answer_tolerance (29 tasks) and is shown only to the judge.

Comment on lines +136 to +146
except (TimeoutError, ClientError, ValueError) as exc:
# The call failed without an HTTP status (connection lost, timeout after the client's
# retries, a body that is not JSON or not a Response): it ends the episode like a refusal,
# so the rollout scores 0 instead of escaping run() into the failures sidecar.
policy_error = f"{type(exc).__name__}: {str(exc)[:1000]}"
break
if not response.ok:
# A refused model call (e.g. an endpoint's request-size limit once many tool images
# accumulate) ends this episode unanswered instead of failing the whole collection.
policy_error = f"HTTP {response.status}: {error_body[:1000]}"
break

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

responses_api_agents/agentic_mme_agent/app.py:136

A failed policy call on the serving side scores 0 like a wrong answer, so outages lower the score and give RL a false zero.

HTTP 408, 425, 429 and 5xx replies end the episode with reward: 0.0, mask_sample: false and no failure_kind. Lost connections, timeouts and unreadable bodies end it the same way. Gym's own vLLM model server answers 503 when vLLM aborts a generation, with "vLLM cancelled the generation; retry the rollout." (vllm_model/app.py). Here that 503 becomes a scored zero. A wrong policy API key gives HTTP 401 on every call, and the benchmark then reads 0.0 instead of failing. NeMo RL reads only mask_sample (rollouts.py), so it trains on these rows as wrong answers.

Gym's default agent outcome policy grades only 400, 413 and 422 replies, retries serving failures and fails the rest (agent_outcome.py). Commit 4831dc6a made the failures without an HTTP status score 0, because rollout collection left a raised /run out of the mean. A raised /run still lands in the failures sidecar, and health counts it. To count failed rollouts in the mean, rollout collection already has count_failure_classes_as_zero (rollout_collection.py).

Suggested fix: keep the scored 0 only for the statuses that Gym's default policy grades, and add failure_kind. Let every other failure fail /run, so collection and trainers retry or report it. The change also touches L283 and needs imports and a module constant, so it is a plain block. Its import lines already include the public-API import change for this file. The ten test_failed_model_call_without_status_scores_zero cases then need to expect the error.

from nemo_gym.agents import (
    AgentOutcomePolicyConfig,
    BaseResponsesAPIAgentConfig,
    Body,
    OutcomeAction,
    SimpleResponsesAPIAgent,
)
from nemo_gym.client import get_response_json, raise_for_status
from nemo_gym.config import ModelServerRef, ResourcesServerRef
from nemo_gym.core import (
    MODEL_REQUEST_ERROR,
    NeMoGymEasyInputMessage,
    NeMoGymFunctionCallOutput,
    NeMoGymResponse,
    NeMoGymResponseCreateParamsNonStreaming,
    accumulate_response_usage,
)
...
# Gym's default outcome policy grades a refused policy request with these statuses (400, 413, 422).
_OUTCOME_POLICY = AgentOutcomePolicyConfig()


class AgenticMMEAgentConfig(BaseResponsesAPIAgentConfig):
...
            response = await self.server_client.post(
                server_name=self.config.model_server.name,
                # Under token capture this is /ng-rollout/<id>/training-token-capture/v1/responses, so the
                # policy model server records every call of this rollout; otherwise plain /v1/responses.
                url_path=model_url_path,
                json=new_params,
                cookies=cookies,
            )
            if not response.ok and _OUTCOME_POLICY.by_status_code.get(response.status) == OutcomeAction.VERIFY:
                # The policy's own request was refused (a full context, or too many images for the endpoint).
                # The episode ends unanswered and scores 0.
                error_body = (await response.read()).decode(errors="replace")
                policy_error = f"HTTP {response.status}: {error_body[:1000]}"
                break
            # Any other failure (408, 429, 5xx, 401, a lost connection, a body that is not a Response) is not the
            # policy's answer. It fails /run, so rollout collection and NeMo RL retry it or report it.
            await raise_for_status(response)
            reply = NeMoGymResponse.model_validate(await get_response_json(response))
...
                **({"failure_reason": "policy_error", "failure_kind": MODEL_REQUEST_ERROR} if policy_error else {}),
What each policy-call failure becomes with this change, and why it raises instead of masking
Failure At this head With this change
400 (for example a full context), 413 request too large, 422 0, not masked, no failure_kind 0, failure_kind: model_request_error
401 bad key, 403, 404 wrong model 0 on every row, so the run reads 0.0 /run raises, so the run fails loudly
408, 425, 429, 5xx (for example a vLLM abort) 0, not masked /run raises, and collection and NeMo RL retry it
lost connection, timeout, a body that is not a Response 0, not masked /run raises, and collection and NeMo RL retry it

A 400 for too many images still scores 0. The max_images_per_request change at app.py:41 removes that cause when you set it to the server's image limit.

Under NeMo RL's default masked_reward_policy: include (grpo.py), a masked row still votes in the group baseline (grpo.py). A raised /run reaches NeMo RL as an infrastructure failure, and the SingleController retries the prompt under its infrastructure retry limit (rollout_manager.py). The sync path retries the whole batch instead (rollouts.py).

On main, simple_agent raises out of /run (simple_agent/app.py), and vcqa_agent masks model-server errors (vcqa_agent/app.py). Gym's harness guide leaves a provider or runtime failure to the Environment Server, which decides whether to grade it or retry it (add-or-update-a-harness.mdx). Behind legacy_agent, the agent has to make that decision itself. The verify response contract sets mask_sample "when the environment or its infrastructure failed rather than the evaluated system" (base_resources_server.py).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 971d64983. A serving-side failure raises PolicyRequestError, so the row goes to the failures sidecar and is retried on resume. That covers 401, 408, 425, 429, 5xx (including vLLM's 503), timeouts, lost connections, unreadable bodies, and any 400 that isn't a context limit. Only a context or request-size refusal still ends the episode and scores 0.

raise ValueError("image upload did not return an image_id")
payload = {"image_id": str(uploaded["image_id"])}
else:
data_url = image_data_url(image)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

responses_api_agents/agentic_mme_agent/retrieval.py:459

The ImgBB Lens path encodes the PNG on the event loop, which pauses every other rollout on this server.

The SerpAPI branch above already moves its encode to a thread (retrieval.py:450). With a 10 ms ticker on the loop, one google_lens_search stalled it for 0.45 s on a 2048x1536 image. A 4000x4000 image stalled it for 2.1 s. The SerpAPI path stalled it for 0.02 s on both.

Suggested fix:

Suggested change
data_url = image_data_url(image)
data_url = await asyncio.to_thread(image_data_url, image)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by removing the ImgBB path in 6306b56fd. Google Lens runs only through SerpAPI's Image API, with the JPEG encoding in a thread.

tools = dict(IMAGE_TOOLS)
if self.config.retrieval.mode != "disabled":
tools.update(RETRIEVAL_TOOLS)
if self.config.retrieval.mode == "live" and not self.config.retrieval.lens_available():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

responses_api_agents/agentic_mme_agent/app.py:64

Replay of a run recorded with Tavily or Exa offers google_lens_search, so the replayed prompt differs from the recorded one.

With retrieval_tavily.yaml and mode: live, the model sees 16 tools. With the same overlay and mode: replay, it sees 17, because this line removes Lens only in live mode. A replay is then not the same experiment, and a Lens call ends in "retrieval replay mismatch". The README presents replay as the way to repeat a run (README). With the fix, a Serper replay still offers Lens.

Suggested fix:

Suggested change
if self.config.retrieval.mode == "live" and not self.config.retrieval.lens_available():
if not self.config.retrieval.lens_available():

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 126df0122. Google Lens is now always offered with text search, in live and replay mode alike, so a replay offers exactly the recorded run's tools. Live retrieval therefore requires the SerpAPI key and upload consent. The Lens-less Tavily and Exa overlays are removed.

responses_api_agents:
agentic_mme_agent:
retrieval:
cache_root: ${oc.env:AGENTIC_MME_SEARCH_CACHE,""}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

benchmarks/agentic_mme/retrieval_cache.yaml:16

If AGENTIC_MME_SEARCH_CACHE is not set, this overlay turns the cache off and gives no error.

An empty cache_root means "no cache" (retrieval.py L100, L296-L300). So a run that lists this overlay but misses the variable sends every query to the paid provider. The only sign is that tool outputs have no "cache" key.

Suggested fix: drop the empty default, so configuration resolution names the missing variable. OmegaConf 2.3.0 raises when the variable is missing and there is no default (resolvers). Without the variable, this overlay does nothing, so this blocks no working run.

Suggested change
cache_root: ${oc.env:AGENTIC_MME_SEARCH_CACHE,""}
cache_root: ${oc.env:AGENTIC_MME_SEARCH_CACHE}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 3a5049c62. The empty default is gone, so a missing AGENTIC_MME_SEARCH_CACHE fails at configuration time.

Match QUALITY is therefore unchanged -- same scorer, same threshold, same
token-order insensitivity. The only new failure mode is RECALL: FTS5 may not
surface a candidate the full scan would have found. That costs one live provider
call, never a wrong answer. `bench/parity_recall.py` measures it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

responses_api_agents/agentic_mme_agent/sqlite_fuzzy_index.py:37

This file looks adapted from another code base, and its docstrings cite files and runs that are not in Gym.

The header says 2025 and carries the long Apache text (L1-L14), unlike the other new files. The docstrings cite bench/parity_recall.py (L37, L70), "leg 6562317" (L20-L25) and "run 6677570" (L144). None of these exist in Gym. The PR body says that nothing from the Agentic-MME reference is vendored, but it does not mention this file.

AGENTS.md "Code provenance" asks for the source URL, the commit and the license in the PR description and in the file header.

Suggested fix: please name the source repository and commit in the file header and the PR description. Please also drop the references to bench/parity_recall.py and the leg and run IDs. This is a docstring change, so it is not a suggestion block.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file is adapted from an internal NVIDIA training codebase (same copyright holder, Apache-2.0), not from the Agentic-MME reference. 3c41afae6 switches it to the standard two-line SPDX header.


monkeypatch.setattr("resources_servers.agentic_mme.app.call_judge", unexpected_call)
result = await judged_server.verify(judged_request("<answer></answer>"))
assert result.reward == 0.0 and result.failure_reason == "missing_answer"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resources_servers/agentic_mme/tests/test_app.py:202

No component test runs VERIFIER_FIXTURE, so CI never runs its three cases.

VERIFIER_FIXTURE (app.py:232-236) runs only from gym env test agentic_mme, which needs the gitignored 418-row file. So CI and a fresh checkout never run it. On main, 11 of the 12 servers that export a fixture also run it in their component tests, for example chembench. The scaffold template generates that test too (scaffold.py). For example, a change from <= to < in the numeric match at app.py:69 scores 2225 against gold 2225 as 0. All 32 resources-server tests still pass with that change, and only the fixture's full_reward case catches it.

Suggested fix: add the fixture test at the end of this file.

Suggested change
assert result.reward == 0.0 and result.failure_reason == "missing_answer"
assert result.reward == 0.0 and result.failure_reason == "missing_answer"
@pytest.mark.asyncio
async def test_verifier_fixture() -> None:
from nemo_gym.verification import exercise_verifier_fixture
from resources_servers.agentic_mme.app import VERIFIER_FIXTURE
results = await exercise_verifier_fixture(
VERIFIER_FIXTURE, reward_range=(0.0, 1.0), higher_is_better=True, determinism="unknown"
)
assert [(r.kind, r.observed_rewards) for r in results] == [
("full_reward", (1.0,)),
("zero_reward", (0.0,)),
("malformed", ()),
]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 3a5049c62. The component tests now run exercise_verifier_fixture(VERIFIER_FIXTURE, ...), so CI covers all three cases.

@github-actions github-actions Bot removed the sla:triage-overdue Review assignment is over the one-business-day SLA label Nov 5, 2026
The release keeps each task's match type and numeric tolerance on its final
answer check; golden_answer holds only the value. prepare.py already read the
match type from there but dropped the tolerance, so all 29 numeric tasks that
set one (for example 7556 +/- 150 km) were graded as exact. Carry the
tolerance into golden_answer and state it in the judge prompt, so the judge
and string_match_reward accept a number inside the range.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
A failed judge call or policy call says nothing about the model, but both
were scored 0 and kept in the denominator.

- verify() no longer catches JudgeError. The judge_failsafe wrapper the base
  resources server installs around /verify masks the row as judge_failed and
  keeps the response, so it can be regraded with reverify.
- The agent raises PolicyRequestError out of /run when a policy call fails
  for a reason outside the model: auth, rate limit, server error, timeout,
  lost connection or an unreadable reply. Rollout collection records the
  task as agent_run_error in the failures sidecar and retries it on resume.
- A 400/413 that names the context or request-size limit, and a response cut
  off at the output-token limit, still end the episode and score 0: they
  measure the policy.

To keep a fixed denominator, a run can count what still fails after retries
as 0 with count_failure_classes_as_zero.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
yfw added a commit that referenced this pull request Nov 6, 2026
…entic-MME stack

Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com>
Every tool result went to the model as a full-resolution PNG, and each call
resends every image in the conversation. In a full Kimi K3 run, 4 of 418
tool-heavy episodes (8 to 15 tool calls) hit the hosted gateway's
413 Request Entity Too Large and scored 0.

Tool results are now capped at 2048x2048 pixels, the same cap as the input
images, and sent as the smaller of PNG and JPEG (quality 95). Photo-like
crops shrink about 3-4x at PSNR above 42 dB; flat or binary outputs such as
threshold stay PNG. The cap is fixed, unlike the reference harness's schedule
that shrinks later images toward 800x800 at JPEG quality 55.

Each image is encoded once and the same bytes are resent, so the request
prefix is unchanged turn to turn (prefix caching and RL token capture). The
workspace keeps the lossless image, so later operations never work on a
re-encoded copy.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
…he answer box

Search and Lens results reached the model as the provider's full JSON,
twice (as "context" and again as "raw" in the tool observation), up to
50,000 characters per call. That included Google's answer box, which often
states the answer outright and makes search tasks easier than the benchmark
intends.

The model now sees the reference harness's compact text: the knowledge
graph's title, description and website, the top 5 results (title, date,
link, snippet), and "people also ask"; Lens shows the top 5 visual matches.
The answer box is never shown, and SerpAPI answers no longer map it. The
provider's answer stays in the tool trace and cache as "raw" but is no
longer part of the model's observation. The contamination guard still
filters items before formatting.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
When every search result quoted the benchmark, or a fetched page did,
the model was told "Results withheld: every result referenced the
benchmark itself", and every filtered result carried a "contaminated"
count in the tool observation. Both told the model it was being
evaluated, and on which benchmark.

A search left empty now reads "No results." like any empty search. A
filtered page, or a fetch of a benchmark URL, fails with the same error
as a page without content, and a failed call shows the model only its
error. The tool trace still records "contaminated" for monitoring.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
Code outside nemo_gym/ imports public names from each package
(AGENTS.md); test_public_api_facades listed 26 names imported from
submodules in the resources server, the agent and their tests.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
Resources servers ship a task_data.py describing their rows; without it
test_every_resources_server_ships_a_schema and
test_all_committed_rows_validate failed for agentic_mme, and a malformed
golden answer was only caught at verify time, as reward 0.

TaskData validates the verifier_metadata fields (golden_answer with its
match type and tolerance, task_id, level, domain, process_evaluation)
and the agent's optional retrieval_replay. GoldenAnswer moves into
task_data.py and the server imports it from there. All 418 prepared
rows and the committed example rows validate.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
…th cut

An episode that used up its tool or round budget, or hit a context-limit
refusal, was marked incomplete with reason max_output_tokens. NeMo RL
reads that reason as truncation, so with overlong filtering the policy
got no loss for running out of budget, and Gym labelled the row
truncated_answer.

The response keeps status "incomplete" without a reason. The verifier
already scores it 0 as missing_answer, since no assistant message
follows the last tool call. A real length cut from the model keeps its
own incomplete_details.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
…er does

The released check_final_answer_only reads match_type from golden_answer,
which holds only the value in all 418 tasks, so the reference uses
contains everywhere. prepare.py took the match type from the final
answer check instead (202 exact, 132 numeric), so answers the reference
accepts, such as "yes" for gold "Yes" or "202 people" for gold "202",
got string_match_reward 0.

golden_answer is now the release's value only (contains). The author's
numeric tolerance from the final answer check moves to
verifier_metadata.answer_tolerance (29 tasks) and is shown to the judge
only. MATCH_TYPES is removed.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
Every image stays in every later model request. A serving engine with a
per-prompt image limit (vLLM limit_mm_per_prompt) refuses the request
once tool images pass it, and that refusal was scored 0 as a
context-limit outcome, which would teach RL to avoid image tools.

max_images_per_request (off by default) caps input plus tool images: an
image tool call past it returns a tool error and the model answers with
the images it has; no earlier image is dropped, so the request prefix
never changes. vLLM's "image(s) may be provided" 400 is no longer
treated as a context limit: it is a serving configuration error and goes
to the failures sidecar.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
…om the model

tool_trace kept each new image's data URL, although the same image is
already in the next user message of response.output; a 2048x1536 input
with four zoomed crops gave a 56.6 MB row, 24.2 MB of it these copies.
The trace now stores tool results without image_url; new_image_index
links each call to its image.

The model saw "cache": "miss" on a cold run and "exact" on a warm rerun,
so the shared cache did not give repeatable prompts. The cache outcome
stays in the trace (provider usage is still the count of misses) and is
no longer part of the model's tool output.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
…cale

The shared cache stored the provider (and gl/hl) of each entry but looked
entries up by the query, pixels or URL alone, so a SerpAPI run could be
served Tavily's results as an exact hit, an hl=de call the English
results, and a Jina run pages that Exa fetched, all recorded under the
run's own provider.

Search entries are now keyed by provider, gl, hl and the query; Lens
entries by provider and pixels; pages by fetch provider and URL. Another
provider's or locale's entry is a miss. Entries written under the old
keys are still served when their stored provider (and gl/hl, taken as
us/en when missing) match, so an existing cache stays usable.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
Remove the ImgBB image-host path. Serper's Lens needed every task image
uploaded to ImgBB for a public URL, and its PNG encode ran on the event
loop (0.45-2.1 s stalls for every other rollout on the server). Google
Lens now always runs through SerpAPI, whose Image API hosts the image
(JPEG encoded in a thread). imgbb_api_key and image_host are gone,
lens_provider is serpapi | none, and the agentic_mme_search.yaml overlay
uses SerpAPI for search and Lens.

prepare.py downscales input images with Pillow, which a dev install of
Gym does not include, so `gym eval prepare --benchmark agentic_mme`
failed with No module named 'PIL'. The dataset now declares
prepare_dependencies: [pillow].

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
…re in CI

retrieval_cache.yaml defaulted cache_root to "" when
AGENTIC_MME_SEARCH_CACHE was unset, which turns the cache off without
an error and sends every query to the paid provider. Without the
default, configuration resolution names the missing variable.

VERIFIER_FIXTURE ran only from `gym env test agentic_mme`, which needs
the gitignored benchmark file, so CI never ran its three cases. The
component tests now exercise it, as the other servers' tests do.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
Main requires a committed pyproject.toml and uv.lock for every server
(test_servers_declare_locked_dependencies, test_servers_do_not_use_requirements_txt
and the Component locks job). Both components move from requirements.txt
to main's layout, with the shared uv policy synchronized by
scripts/component_locks.py. The root policy disables
opencv-python-headless; the agent's image tools need it (cv2), so
component-lock-policy.json records that one override for the agent.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
Reverse image search is part of the benchmark's retrieval tool set, so
google_lens_search now comes with every text search, in live and replay
mode alike. Before, Lens was dropped only in live mode when the text
provider had none (Tavily, Exa, and Serper since the ImgBB path went),
so a replay of such a run offered 17 tools where the recorded run had
16, and a Lens call ended in a replay mismatch.

Live retrieval now requires serpapi_api_key and allow_image_upload
whatever the text-search provider; lens_provider is removed. The
Lens-less retrieval_tavily.yaml and retrieval_exa.yaml overlays are
removed; retrieval_tavily_serpapi_lens.yaml and
retrieval_exa_serpapi_lens.yaml cover those providers.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
…ex.py

Use the two-line SPDX header of the other new files instead of the long
2025 Apache text.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>
@DanialTaheri

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. All 17 comments are addressed at 3c41afae6; each thread names its commit. Main is merged in (410dee600), and both components now ship pyproject.toml + uv.lock. Other changes since your review:

  • Tool images sent to the model are capped at 2048×2048 (5ab8ee478).
  • Search results reach the model as the reference harness's compact text, without the answer box (d3db4a6fc).
  • The leak guard is invisible to the model (a7a6a88c9).
  • Google Lens is always offered with text search (126df0122).

Could you trigger CI on the new head?

The pinned dataset revision (a commit hash) and a test URL with dummy
credentials, which retrieval must reject, are not secrets.

Signed-off-by: Danial MohseniTaheri <smohsenitahe@nvidia.com>

This branch was previously deployed

1 inactive (outdated) deployment
public — b9b90d0f Deployed Nov 5, 2026 by copy-pr-bot[bot] via release / finalize / notify #10482
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants