Skip to content

feat: @vf.intercept — rewrite model turns at runtime in the interception server#1914

Open
xeophon wants to merge 4 commits into
mainfrom
feat/vf-intercept
Open

feat: @vf.intercept — rewrite model turns at runtime in the interception server#1914
xeophon wants to merge 4 commits into
mainfrom
feat/vf-intercept

Conversation

@xeophon

@xeophon xeophon commented Jul 2, 2026

Copy link
Copy Markdown
Member

What

Tasksets can now rewrite a model turn before the harness sees it — block a destructive tool call, censor a reward hack — via @vf.intercept methods, discovered like @vf.stop and run by the interception server over every completed turn:

@vf.intercept
async def block_destructive(self, response: vf.Response, trace: vf.Trace) -> vf.AssistantMessage | None:
    if not any("rm -rf" in c.arguments for c in response.message.tool_calls or []):
        return None                                   # pass through, byte-exact
    trace.info.setdefault("intercepted", []).append(response.message.model_dump())
    return vf.AssistantMessage(content="That command is blocked by policy.")

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);
  • a raw wire dict — replaces the response body wholesale, re-parsed through the dialect's existing validate_response/parse_response; the escape hatch for rewrites that must control native blocks (start from response.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/@stop to score).

How

  • All three dialects (chat / responses / anthropic) implement two new required Dialect hooks: serialize_response (vf Response → wire body) and serialize_stream (wire body → one synthesized SSE stream). serialize_completion moves from clients/train.py to dialects/chat.py (it's chat-wire serialization; the renderer imports it back).
  • Streaming: a session with interceptors buffers the stream (_stream_buffered) until they've ruled, then relays the original events verbatim (pass-through) or emits the rewrite as synthesized SSE. Stream parsers now set Response.raw, so interceptors see the assembled wire body on streamed turns too.
  • Anthropic provider_state widening: 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 hashes provider_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.)
  • Docs: verifiers/v1/GUIDE.md gains 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 into rm -rf, an @vf.intercept that rewrites the turn into a refusal, and scoring that proves the interception (the tool flags state.destructive_ran before executing, so workspace_intact scores the guard, not model restraint). Registered in the root examples group like the sibling _v1 envs.

Validation

  • tests/v1/test_interception.py (15 tests): rewrite / pass-through / raw-dict paths per dialect against a real InterceptionServer over HTTP; the buffered streaming path both ways; serialize_response/serialize_stream round-trips through each dialect's own parsers; priority ordering; anthropic server-tool blocks in provider_state.
  • Full non-e2e suite: 887 passed. ruff + pre-commit clean.
  • Live e2e: uv run eval --taskset.id shell-guard-v1 (deepseek-v4-flash, default harness) — the model ran cat data.txt (executed), attempted rm -rf . (intercepted, rewritten, never executed), finished with workspace_intact: 1.0, intercepted_turns: 1.0, original tool call preserved in trace.info["intercepted"].

Notes for review

  • uv.lock diff is one added package (shell-guard-v1), no version bumps; the churn is array re-wrapping.
  • A rewritten turn sets 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.
  • Live e2e covered the chat dialect; responses/anthropic are covered by the unit tests against the real server, not yet by a live codex/claude-code harness run.

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.intercept so tasksets can rewrite each completed model turn in the interception server before the harness (and trace) see it—None passes through, otherwise return a mutated Response, an AssistantMessage, 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 _intercept on every turn and _stream_buffered when interceptors are registered (full stream buffer, then pass-through or synthesized SSE). All three dialects gain serialize_response / serialize_stream; stream parsers set Response.raw for streamed turns. Anthropic parsing widens provider_state to non-folded blocks (e.g. server-tool / web search).

Ships shell_guard_v1 (block rm -rf shell calls) and intercept_web_search_v1 (German rewrite of Codex web-search responses), docs in GUIDE.md, shell-guard-v1 in the examples group, and test_intercept.py for 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.intercept decorator to rewrite model turns at runtime in the interception server

  • Introduces a @vf.intercept decorator 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.
  • The interception server gains _intercept and _stream_buffered methods; interceptors receive a typed Response and can return a new Response, AssistantMessage, or raw wire dict — streamed turns are buffered and re-emitted as valid SSE.
  • All three dialects (chat, Responses, Anthropic) implement serialize_response and serialize_stream so intercepted turns can be round-tripped back to wire format in the correct API shape.
  • Adds two example environments: shell_guard_v1 intercepts destructive rm -rf tool calls and replaces them with refusals; intercept_web_search_v1 rewrites Codex CLI search turns into German before the harness sees them.
  • Risk: streamed turns are fully buffered before delivery when an interceptor is active, increasing latency and memory use for long completions.

Macroscope summarized df719fd.

@xeophon xeophon force-pushed the feat/vf-intercept branch from e7ee762 to c51c873 Compare July 2, 2026 09:19

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread verifiers/v1/interception/server.py Outdated
Comment thread verifiers/v1/dialects/chat.py Outdated
Comment thread verifiers/v1/dialects/anthropic.py
Comment thread verifiers/v1/interception/server.py
@macroscopeapp

macroscopeapp Bot commented Jul 2, 2026

Copy link
Copy Markdown

Approvability

Verdict: 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.

Comment thread verifiers/v1/interception/server.py Outdated
Comment thread verifiers/v1/interception/server.py
…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>
@xeophon xeophon force-pushed the feat/vf-intercept branch from c51c873 to 049f026 Compare July 2, 2026 09:39
Comment thread verifiers/v1/interception/server.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread environments/shell_guard_v1/pyproject.toml Outdated
Comment thread environments/shell_guard_v1/shell_guard_v1/taskset.py Outdated
Comment thread verifiers/v1/dialects/anthropic.py
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.
@xeophon xeophon force-pushed the feat/vf-intercept branch from 191c5fb to 765705a Compare July 2, 2026 10:06
Comment thread environments/shell_guard_v1/shell_guard_v1/taskset.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread environments/shell_guard_v1/shell_guard_v1/taskset.py
Comment thread verifiers/v1/dialects/chat.py
Comment thread verifiers/v1/dialects/responses.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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 [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 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 &#34;status&#34;: &#34;completed&#34; regardless of response.finish_reason. When an interceptor returns a vf.Response whose finish_reason is &#34;length&#34;, the rewritten body is serialized as a completed response and parse_response() will turn it back into finish_reason=&#34;stop&#34;. 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +353 to +354
elif item.get("type") == "function_call":
seen_calls.add(item.get("call_id"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread environments/README.md
- **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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Triggered by project rule: BugBot Instructions

Reviewed by Cursor Bugbot for commit df719fd. Configure here.

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.

1 participant