Skip to content

fix(stirrup_agent): DSv4 GDPVal multi-node run fixes - #1497

Closed
agronskiy wants to merge 6 commits into
mainfrom
agronskiy/feat/gdpval-ref-files-dir-param
Closed

fix(stirrup_agent): DSv4 GDPVal multi-node run fixes#1497
agronskiy wants to merge 6 commits into
mainfrom
agronskiy/feat/gdpval-ref-files-dir-param

Conversation

@agronskiy

@agronskiy agronskiy commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Five fixes that unblock DeepSeek-V4-Pro GDPVal rollouts on multi-node sglang deployments. The Stirrup agent is the upstream agent loop used for GDPVal-style code+filesystem tasks; on the multi-node sglang path each of these issues independently caused 100% rollout failure.

1. Pass GDPVAL_REF_FILES_DIR as a Ray task param

The ref-file staging directory was read from os.environ inside a @ray.remote(runtime_env=...) task. When the Ray cluster is started standalone next to a sglang model server (not Ray-managed), the remote worker doesn't inherit the deployment-container env, so staging silently fell back to a node-local /tmp and cross-node ref-file uploads broke. Now resolved in the server process and threaded through params, mirroring how persist_deliverables_dir is passed.

2. Coerce finish.paths string→list

vLLM 0.20.0's --tool-call-parser deepseek_v4 ignores the string="false" flag on tool-call argument tags and forwards JSON-encoded args as bare strings. Stirrup's FinishParams then rejects paths with "Input should be a valid list, type=list_type" and the agent retries the same broken shape indefinitely. Added a CoercingFinishParams with a pydantic field_validator(mode="before") that accepts a real list (passthrough), a JSON-encoded array string, or a bare filename string, and monkey-patched it in place of SIMPLE_FINISH_TOOL.

Upstream fix landed in vLLM #41801; the coercion becomes a no-op once the inference image is rebuilt against that.

3. Tool lookup via self._active_tools (not self._tools)

The ValidationError-surfacing wrapper (#4 below) iterated self._tools to locate the failing tool. That list contains both Tool instances and provider objects (e.g. ApptainerCodeExecToolProvider) which don't expose .name, so the lookup raised AttributeError on every failed-finish path. Switched to self._active_tools.get(tool_call.name), which is the dict upstream Stirrup itself uses inside run_tool.

4. Surface pydantic ValidationError detail in failed tool-call results

Upstream Stirrup catches pydantic.ValidationError in Agent.run_tool and returns the bare string "Tool arguments are not valid" as the tool result, dropping all field-level error detail. The model has no signal about what shape was expected and loops on the same malformed call. Added an import-time monkey-patch (idempotent) that re-runs tool.parameters.model_validate_json on the failure path and rebuilds the ToolMessage with per-field error messages plus a truncated preview of the submitted arguments.

5. Fall back to msg.reasoning when reasoning_content is absent

DynamicMaxTokensChatCompletionsClient only read msg.reasoning_content. vLLM ≥ 0.16.0 with --reasoning-parser deepseek_v4 emits the field as reasoning (Responses-API convention), so reasoning was silently dropped from the AssistantMessage and never threaded into the next turn. The agent effectively forgot its plan every turn and walked to max_turns without ever calling finish successfully. Added an elif hasattr(msg, "reasoning") fallback and pinned reasoning = None in the test fixture so MagicMock auto-attr access doesn't trigger the new branch in unrelated tests.

Test plan

  • pytest responses_api_agents/stirrup_agent/tests/ -x — including the new test_finish_tool_coercing.py (12 cases: passthrough, four observed broken shapes, required-field, dict-reject, non-string-item stringification)
  • DSv4-Pro GDPVal multi-node sglang rollouts now produce successful finish calls end-to-end
  • Re-verify against an inference image built with vLLM main ≥ #41801 — the coercion path should be a no-op and the monkey-patch can be removed
  • Confirm no regression on single-node / Ray-resident deployments where the env-inheritance path was already working

🤖 Generated with Claude Code

agronskiy and others added 4 commits May 23, 2026 10:56
… absent

DynamicMaxTokensChatCompletionsClient.__call__ only checked
msg.reasoning_content when parsing the response. vLLM >= 0.16.0 (and
specifically DeepSeek-V4's `--reasoning-parser deepseek_v4`) emits the
field as `reasoning` per the Responses-API convention. Without the
fallback, reasoning silently dropped from the AssistantMessage and was
never threaded back into the next-turn request — the agent forgot its
plan every turn and walked the max_turns ceiling without ever
successfully calling `finish`.

Observed end-to-end on DSv4-Pro GDPVal r3 (slurm job 2855690): across
~3 h of cluster wall-time and 1118 successful code_exec invocations,
zero successful `finish` calls and zero rollouts persisted; the
ResponseReasoningInterceptor reported reasoning_words=0 for every
single response across the run while cache.db responses showed
message.reasoning populated.

Also pin `choice.message.reasoning = None` in the `_make_response` test
fixture (mirroring the existing `reasoning_content = None` pin) so the
new `elif hasattr(msg, "reasoning")` branch is not triggered by
MagicMock auto-attr access in the three sampling/cap tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Gronskiy <agronskiy@nvidia.com>
… tool-call results

Upstream stirrup catches `pydantic.ValidationError` in `Agent.run_tool`
and returns the bare string "Tool arguments are not valid" as the
`ToolResult` content, dropping all the pydantic error detail on the
floor (e.g. "paths: Input should be a valid list, input_type=str").
The agent has no signal about which field failed or what type was
expected, so it retries the same broken shape forever.

Observed on DSv4-Pro GDPVal r5/r7: the model consistently emitted
`paths` as a JSON string literal ('"[]"', '"[\"foo.txt\"]"',
'"single.pdf"', etc.) instead of a JSON array. All ~660 finish-tool
calls in r5 (2h7m elapsed) failed with the same bare-string error;
zero ✓ finishes, max_turn stuck at 18 for 71 min as rollouts looped
on the same malformed shape. r7 (concurrency=48) hit the same wall
just sooner.

This patch installs a one-shot monkey-patch on
`stirrup.core.agent.Agent.run_tool` at import time (guarded by an
`_gym_surfacing_patched` attribute to prevent re-application). On the
failure path it re-runs `tool.parameters.model_validate_json` to
capture the pydantic error, then rebuilds the ToolMessage with a
detailed content including field-by-field error messages and a 500-char
preview of the submitted arguments. Success path is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Gronskiy <agronskiy@nvidia.com>
…not self._tools)

The ValidationError-surfacing wrapper iterated self._tools to find the
tool for the failed call. That list mixes plain Tool instances with
provider objects (e.g. ApptainerCodeExecToolProvider) which don't have
a .name attribute — the `t.name` access raised AttributeError on every
failed-finish path, breaking 45 rollouts on r8 before scancel.

Switch to self._active_tools.get(tool_call.name) — the same dict
upstream stirrup uses in its own run_tool. _active_tools is built
during __aenter__ via `if isinstance(tool, Tool): self._active_tools[tool.name] = tool`,
so it's the safe lookup path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Gronskiy <agronskiy@nvidia.com>
…epseek_v4 parser bug

DeepSeek-V4-Pro served by vLLM 0.20.0 (the wedu image
vllm-deepseekv4-v0200-cu130-ray-arm64.sqsh) emits non-string-typed
tool-call args as JSON-encoded strings. The model produces
`<|DSML|parameter ... string="false">[...]</|DSML|parameter>` per the
chat template, but vLLM's --tool-call-parser deepseek_v4 in 0.20.0
doesn't honor the string="false" flag and forwards the inner JSON
verbatim as a literal string. Stirrup's FinishParams rejects with
"paths: Input should be a valid list, type=list_type" and the agent
loops forever on the same broken shape (1605 ✗ finish / 0 ✓ finish
across r9's full 4h walltime).

Upstream fix landed in vLLM PR #41801 (merged 2026-05-06), but the
wedu image predates it. Until the image is rebuilt:

- responses_api_agents/stirrup_agent/finish_tool_coercing.py: new
  module with CoercingFinishParams (pydantic field_validator(mode=
  "before") on `paths` that accepts list (passthrough), JSON-encoded
  string array, or bare filename string) and COERCING_FINISH_TOOL
  wrapping stirrup's _validating_finish_executor.

- responses_api_agents/stirrup_agent/nemo_client.py: third monkey-patch
  at module-import time replaces SIMPLE_FINISH_TOOL in stirrup.tools
  .finish, stirrup.tools, and stirrup.core.agent with the coercing
  variant. Agent.__init__ defaults pick it up via the existing
  ``finish_tool if finish_tool is not None else SIMPLE_FINISH_TOOL``
  fallback. Idempotency tag on the tool object prevents double-patching.

- responses_api_agents/stirrup_agent/tests/test_finish_tool_coercing.py:
  12 cases covering the 4 known broken shapes from r5 client log
  (`"[]"`, `"[\"a.txt\"]"`, `"[\"a.txt\",\"b.pdf\"]"`, bare filename)
  plus correct-shape passthrough, required-field enforcement, dict
  rejection, and non-string item stringification.

When the wedu image is rebuilt against vLLM main >= #41801, this
coercion becomes a no-op (the first isinstance(v, list) branch always
takes) and both this module and the monkey-patch can be removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Gronskiy <agronskiy@nvidia.com>
The Stirrup agent's ref-file staging dir was read only from the worker's
os.environ (GDPVAL_REF_FILES_DIR) inside _run_stirrup_agent, which runs as a
@ray.remote(runtime_env={py_executable}) task. On a non-Ray model deployment
(sglang), where a bare `ray start` cluster is launched just for Gym, that
worker does not reliably inherit the deployment-container env, so the read
returned None and staging fell back to node-local /tmp — breaking the
cross-node upload of reference files (100% rollout failure on multi-node).

Resolve it in the server process (which has the env var) and thread it through
`params` into the task, exactly like persist_deliverables_dir — so the worker
no longer depends on env inheritance. Falls back to os.environ if unset.

Signed-off-by: Alex Gronskiy <agronskiy@nvidia.com>
@agronskiy
agronskiy force-pushed the agronskiy/feat/gdpval-ref-files-dir-param branch from be98528 to de712ae Compare June 2, 2026 13:00
@cmunley1

cmunley1 commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

it seems 2-5 are missing

@cmunley1

Copy link
Copy Markdown
Contributor

following up, is 2-5 expected here?

@cmunley1

Copy link
Copy Markdown
Contributor

cc @vadam5

@agronskiy agronskiy closed this Jul 9, 2026
@agronskiy

Copy link
Copy Markdown
Contributor Author

We now run with sglang, vllm is too buggy for DSv4, closing. Thanks @marta-sd for the ping!

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