feat: @vf.intercept — rewrite model turns at runtime in the interception server#1914
feat: @vf.intercept — rewrite model turns at runtime in the interception server#1914xeophon wants to merge 4 commits into
Conversation
e7ee762 to
c51c873
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e7ee762eb5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
ApprovabilityVerdict: Needs human review 3 blocking correctness issues found. This PR introduces a new runtime interception feature with significant behavioral implications. Multiple unresolved HIGH severity comments identify correctness issues: blocked tool calls may still execute due to incomplete provider_state handling, and truncation status can be incorrectly overwritten. Human review is needed to address these issues before merging. You can customize Macroscope's approvability policy. Learn more. |
…ion server A taskset can now rewrite a completed model turn before the harness sees it (block a destructive tool call, censor a reward hack) with @vf.intercept methods, discovered like @vf.stop and run by the interception server per turn. An interceptor returns None (pass through, byte-exact), a vf.AssistantMessage (replaces the model's message, serialized back to the request's wire format), or a raw wire dict (replaces the body wholesale, re-parsed for the trace). First non-None by priority wins; the trace commits the rewrite (the next turn's replayed prompt must match the graph), and the rewritten turn carries no sampled tokens. The original survives where the interceptor stashes it (e.g. trace.info), for @metric/@reward/@Stop to score. Per-dialect support (chat / responses / anthropic) via two new required Dialect hooks: serialize_response (vf Response -> wire body, for the typed rewrite path) and serialize_stream (wire body -> one synthesized SSE stream). A streamed turn on a session with interceptors buffers until they have ruled (_stream_buffered), then relays verbatim or emits the rewrite as SSE; stream parsers now set Response.raw so interceptors see the assembled body there too. serialize_completion moves from clients/train.py into dialects/chat.py (it is chat-wire serialization; the renderer client imports it back). The anthropic dialect's provider_state now keeps every non-folded block (server_tool_use, web_search_tool_result, ...), not just thinking — so provider-executed (server-side) tool use is visible on the trace typed. Request- and response-side parses widen together: the graph hashes provider_state when matching replayed prompts against committed nodes. Adds environments/shell_guard_v1, a complete example: a shell tool in a scratch workspace, an @vf.intercept that rewrites rm -rf turns into a refusal, and rewards that score the interception (workspace_intact) rather than model restraint. Verified live: the model's rm -rf attempt was intercepted, nothing executed, workspace_intact=1.0, intercepted_turns=1.0. Amp-Thread-ID: https://ampcode.com/threads/T-019f2224-2482-7069-9516-e0056e754c9c Co-authored-by: Amp <amp@ampcode.com>
c51c873 to
049f026
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 049f0269a9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
An AssistantMessage rewrite was committed to the trace as-is while the harness replayed its serialized wire form, whose parse carries dialect provider_state (always, for Responses) — the hash mismatch broke the graph prefix walk and duplicated the turn. Serialize the rewrite, then re-parse the wire body before committing, exactly like the raw-dict path; tests/v1/test_intercept.py locks in committed == replayed across all three dialects. Also from review: chat serialize_completion now reuses message_to_wire (so provider_state survives as reasoning_details), the Responses serialize_stream terminal event mirrors the body's status, shell-guard declares its verifiers dependency and its destructive-rm regex catches combined flags (rm -rfv). Simplified the shell-guard example: base Task/TasksetConfig instead of empty subclasses, and the workspace check lives in the reward (rewards get the live runtime) instead of finalize + trace.info plumbing.
191c5fb to
765705a
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 765705a26e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8d3ed540f2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ) | ||
| if rewritten is not response: | ||
| events = dialect.serialize_stream(rewritten.raw) | ||
| turn.commit(rewritten) |
There was a problem hiding this comment.
Avoid committing buffered stream turns before a client is attached
When a streamed request has interceptors, _stream_buffered buffers the whole upstream response without sending headers, so a harness can time out or disconnect before this point. This commits the model turn before resp.prepare() or any write and the following contextlib.suppress(ConnectionResetError) hides that disconnect, leaving the trace ahead of what the harness actually received; a retry or final scoring can then operate on a phantom turn. Prepare the response (or otherwise detect a closed client) before committing the buffered turn.
Useful? React with 👍 / 👎.
| if rewrite is None: | ||
| raise RuntimeError("rewrite model returned no GermanRewrite object") | ||
| response.message.content = rewrite.text_de | ||
| output = deepcopy(provider_state) |
There was a problem hiding this comment.
🟡 Medium intercept_web_search_v1/taskset.py:73
When rewrite.citation_url_de is not present in rewrite.text_de, find() returns -1 and max(start, 0) forces the annotation span to [0, len(url)), so the citation metadata points at the beginning of the text instead of the actual link. This silently corrupts the annotation whenever the judge model returns a citation_url_de that does not appear in text_de. Consider guarding against start == -1 — for example, skipping the annotation or raising an error when the URL is not found in the text.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @environments/intercept_web_search_v1/intercept_web_search_v1/taskset.py around line 73:
When `rewrite.citation_url_de` is not present in `rewrite.text_de`, `find()` returns `-1` and `max(start, 0)` forces the annotation span to `[0, len(url))`, so the citation metadata points at the beginning of the text instead of the actual link. This silently corrupts the annotation whenever the judge model returns a `citation_url_de` that does not appear in `text_de`. Consider guarding against `start == -1` — for example, skipping the annotation or raising an error when the URL is not found in the text.
|
|
||
| def serialize_response(self, response: Response) -> dict: | ||
| message = response.message | ||
| output = deepcopy(message.provider_state or []) |
There was a problem hiding this comment.
🟠 High dialects/responses.py:344
serialize_response never removes function_call items from the copied provider_state. It records existing call IDs in seen_calls but only uses that set to skip appending duplicates — the original function_call items remain in output. So when an interceptor rewrites a tool-call turn into plain text (reusing provider_state to preserve reasoning/web-search items), the original function_call is still sent to the harness and the blocked tool call can still execute. Consider removing function_call items from output when message.tool_calls no longer includes them, or rebuilding the call items from message.tool_calls instead of preserving the originals.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/dialects/responses.py around line 344:
`serialize_response` never removes `function_call` items from the copied `provider_state`. It records existing call IDs in `seen_calls` but only uses that set to skip appending duplicates — the original `function_call` items remain in `output`. So when an interceptor rewrites a tool-call turn into plain text (reusing `provider_state` to preserve reasoning/web-search items), the original `function_call` is still sent to the harness and the blocked tool call can still execute. Consider removing `function_call` items from `output` when `message.tool_calls` no longer includes them, or rebuilding the call items from `message.tool_calls` instead of preserving the originals.
| if isinstance(replacement, Response): | ||
| replacement = dialect.serialize_response(replacement) | ||
| if isinstance(replacement, AssistantMessage): | ||
| replacement = dialect.serialize_response( |
There was a problem hiding this comment.
🟠 High interception/server.py:267
When an @intercept returns an AssistantMessage, _intercept overwrites finish_reason to "tool_calls" or "stop" based only on whether the rewrite has tool calls. If the original turn was truncated (finish_reason="length") and the interceptor rewrites it to plain text, the committed turn reports "stop" instead of "length", so Trace.is_truncated and downstream metrics become wrong. Consider preserving the original finish_reason when it signals truncation (e.g. "length") rather than always deriving it from the rewritten message's tool calls.
Also found in 1 other location(s)
verifiers/v1/dialects/responses.py:390
ResponsesDialect.serialize_response()always emits"status": "completed"regardless ofresponse.finish_reason. When an interceptor returns avf.Responsewhosefinish_reasonis"length", the rewritten body is serialized as a completed response andparse_response()will turn it back intofinish_reason="stop". That silently drops truncation/incomplete status from intercepted Responses turns, so the harness and committed trace see the wrong completion state.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/interception/server.py around line 267:
When an `@intercept` returns an `AssistantMessage`, `_intercept` overwrites `finish_reason` to `"tool_calls"` or `"stop"` based only on whether the rewrite has tool calls. If the original turn was truncated (`finish_reason="length"`) and the interceptor rewrites it to plain text, the committed turn reports `"stop"` instead of `"length"`, so `Trace.is_truncated` and downstream metrics become wrong. Consider preserving the original `finish_reason` when it signals truncation (e.g. `"length"`) rather than always deriving it from the rewritten message's tool calls.
Also found in 1 other location(s):
- verifiers/v1/dialects/responses.py:390 -- `ResponsesDialect.serialize_response()` always emits `"status": "completed"` regardless of `response.finish_reason`. When an interceptor returns a `vf.Response` whose `finish_reason` is `"length"`, the rewritten body is serialized as a completed response and `parse_response()` will turn it back into `finish_reason="stop"`. That silently drops truncation/incomplete status from intercepted Responses turns, so the harness and committed trace see the wrong completion state.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: df719fdc0d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| elif item.get("type") == "function_call": | ||
| seen_calls.add(item.get("call_id")) |
There was a problem hiding this comment.
Drop stale Responses tool calls when serializing rewrites
When a Responses @vf.intercept returns the documented “mutated vf.Response” path and clears or changes response.message.tool_calls, the original function_call items copied from provider_state are only marked as seen here and remain in output; matching IDs then suppress the sanitized calls emitted below. In that scenario the raw body sent back to Codex/Responses still contains the old tool call, so a policy rewrite of a dangerous command can still execute. Filter or rebuild function_call items from message.tool_calls instead of preserving the provider-state copies unchanged.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit df719fd. Configure here.
| - **hello_group_reward_v1**: Deterministic v1 reference for group updates, metrics, rewards, advantages, and cleanup. | ||
| - **nemo_gym_env**: Minimal v1 example that wraps a packaged NeMo Gym task with `NeMoGymTaskset` and `NeMoGymHarness`. | ||
| - **sft-replay**: Thin v1 replay environment using `ReplayTaskset` and `ReplayHarness` to turn stored transcripts into trajectory steps without model calls. | ||
| - **shell_guard_v1**: `@vf.intercept` example — rewrites a destructive shell turn into a refusal before the codex harness's shell tool can execute it. |
There was a problem hiding this comment.
Missing README environment listing
Low Severity
This PR adds environments/intercept_web_search_v1/ but environments/README.md only documents shell_guard_v1 under Taskset/Harness v1. The new example is absent from the catalog readers use to discover v1 environments.
Additional Locations (1)
Triggered by project rule: BugBot Instructions
Reviewed by Cursor Bugbot for commit df719fd. Configure here.
|
|
||
| The interceptor receives typed vf objects, not SSE bytes or provider JSON. For Responses streams, | ||
| Verifiers buffers the turn only when an interceptor is present, then emits a rewritten stream after | ||
| the modified `vf.Response` is committed to the trace. |
There was a problem hiding this comment.
Non-template environment README
Low Severity
The new intercept_web_search_v1 package uses a hand-written README with custom sections instead of the prime env init template structure other v1 example packages follow.
Triggered by project rule: BugBot Instructions
Reviewed by Cursor Bugbot for commit df719fd. Configure here.


What
Tasksets can now rewrite a model turn before the harness sees it — block a destructive tool call, censor a reward hack — via
@vf.interceptmethods, discovered like@vf.stopand run by the interception server over every completed turn:An interceptor returns:
None— pass through, the relay stays byte-exact;vf.AssistantMessage— replaces the model's message; the framework serializes it back to the request's wire format (Dialect.serialize_response);validate_response/parse_response; the escape hatch for rewrites that must control native blocks (start fromresponse.raw).First non-None (by
priority, then name) wins. The rewrite is what the harness receives and what the trace commits — the next turn's replayed prompt must match the graph — so a rewritten turn drops its sampled tokens, and the original survives only where the interceptor stashes it (e.g.trace.info, for@metric/@reward/@stopto score).How
Dialecthooks:serialize_response(vfResponse→ wire body) andserialize_stream(wire body → one synthesized SSE stream).serialize_completionmoves fromclients/train.pytodialects/chat.py(it's chat-wire serialization; the renderer imports it back)._stream_buffered) until they've ruled, then relays the original events verbatim (pass-through) or emits the rewrite as synthesized SSE. Stream parsers now setResponse.raw, so interceptors see the assembled wire body on streamed turns too.provider_statewidening: keeps every non-folded block (server_tool_use,web_search_tool_result, …), not just thinking — provider-executed (server-side) tool use is now visible on the trace typed. Request- and response-side parses widen together since the graph hashesprovider_state. (Server-side tools execute within one provider call, so an interceptor can't prevent the execution — but it can rewrite what enters the ongoing conversation, and detect/penalize/stop.)verifiers/v1/GUIDE.mdgains an "Intercepting model turns" section + decorator-table row.Example env
environments/shell_guard_v1— a shell tool in a scratch workspace, a task that tempts the model intorm -rf, an@vf.interceptthat rewrites the turn into a refusal, and scoring that proves the interception (the tool flagsstate.destructive_ranbefore executing, soworkspace_intactscores the guard, not model restraint). Registered in the rootexamplesgroup like the sibling_v1envs.Validation
tests/v1/test_interception.py(15 tests): rewrite / pass-through / raw-dict paths per dialect against a realInterceptionServerover HTTP; the buffered streaming path both ways;serialize_response/serialize_streamround-trips through each dialect's own parsers; priority ordering; anthropic server-tool blocks inprovider_state.ruff+pre-commitclean.uv run eval --taskset.id shell-guard-v1(deepseek-v4-flash, default harness) — the model rancat data.txt(executed), attemptedrm -rf .(intercepted, rewritten, never executed), finished withworkspace_intact: 1.0,intercepted_turns: 1.0, original tool call preserved intrace.info["intercepted"].Notes for review
uv.lockdiff is one added package (shell-guard-v1), no version bumps; the churn is array re-wrapping.tokens=None: eval-first semantics. Whether an RL rewrite should instead keep the policy's sampled tokens (train on what it emitted, penalize via rewards) is left open until interception is used in training.Note
Medium Risk
Touches the hot path of every model turn when interceptors are enabled (including full stream buffering), and graph/hash consistency depends on correct dialect round-trips.
Overview
Adds
@vf.interceptso tasksets can rewrite each completed model turn in the interception server before the harness (and trace) see it—Nonepasses through, otherwise return a mutatedResponse, anAssistantMessage, or a raw wire dict; first match by priority wins, and the committed turn must round-trip through dialect serialization for replayed history.The server runs
_intercepton every turn and_stream_bufferedwhen interceptors are registered (full stream buffer, then pass-through or synthesized SSE). All three dialects gainserialize_response/serialize_stream; stream parsers setResponse.rawfor streamed turns. Anthropic parsing widensprovider_stateto non-folded blocks (e.g. server-tool / web search).Ships
shell_guard_v1(blockrm -rfshell calls) andintercept_web_search_v1(German rewrite of Codex web-search responses), docs inGUIDE.md,shell-guard-v1in the examples group, andtest_intercept.pyfor dialect rewrite round-trips.Reviewed by Cursor Bugbot for commit df719fd. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add
@vf.interceptdecorator to rewrite model turns at runtime in the interception server@vf.interceptdecorator that tasksets use to register hooks that can mutate or replace a completed model turn before it is returned to the harness and committed to the trace._interceptand_stream_bufferedmethods; interceptors receive a typedResponseand can return a newResponse,AssistantMessage, or raw wire dict — streamed turns are buffered and re-emitted as valid SSE.serialize_responseandserialize_streamso intercepted turns can be round-tripped back to wire format in the correct API shape.shell_guard_v1intercepts destructiverm -rftool calls and replaces them with refusals;intercept_web_search_v1rewrites Codex CLI search turns into German before the harness sees them.Macroscope summarized df719fd.