Skip to content

feat(iorails): let per-tool rails rewrite tool calls and tool results - #2498

Draft
Nurozen wants to merge 17 commits into
NVIDIA-NeMo:developfrom
hiddenlayerai:feat/iorails-tool-payload-rewrites
Draft

Nurozen wants to merge 17 commits into
NVIDIA-NeMo:developfrom
hiddenlayerai:feat/iorails-tool-payload-rewrites

Conversation

@Nurozen

@Nurozen Nurozen commented Oct 24, 2026

Copy link
Copy Markdown

Description

Per-tool rails can only allow or block, so a rail whose policy is a mask must drop the whole turn.
This adds tool_call and tool_result transform targets reusing RailOutcome.transform, filling
in the NotImplementedError guard left by #2393. Rewriting rails run before judging rails, and
changed arguments are schema-validated. No library rail emits a tool rewrite — the in-tree users are
the engine and tests/guardrails/rail_stubs.py. Design and open questions are in the linked issue.

Related Issue(s)

Verification

make test: 10950 passed, 220 skipped. No live path or docs build was run. Rebase note: develop
now reads a check with model_type=None, so the write-back falls back to the module-level
rewriters, not the engine registry.

AI Assistance

  • No AI tools were used.
  • AI tools were used; a human reviewed and can explain every change (tool: Claude Code).

Disclosure per AI_POLICY.md. AI tools were used: Claude Code produced the bulk of the
implementation, tests and documentation in this PR, working from a human-authored design under
human direction. The second box is deliberately left unchecked — the author's line-by-line review
is still in progress, which is why this is opened as a draft. The box will be checked and the PR
marked ready for review once that review is complete.

Checklist

  • I've read the CONTRIBUTING guidelines.
  • This PR links to a triaged issue assigned to me.
  • My PR title follows the project commit convention.
  • I've updated the documentation if applicable.
  • I've added tests if applicable.
  • I've noted any verification beyond CI and any checks I couldn't run.
  • I did not update generated changelog files manually.
  • I addressed all CodeRabbit, Greptile, and other review comments, or replied with why no change is needed.
  • @mentions of the person or team responsible for reviewing proposed changes.

Justin Brooks added 16 commits October 23, 2026 23:16
Add TransformTarget.TOOL_RESULT, whose text replaces the whole content of
the tool message under check, and map the tool_input direction to it in
the compile gate. Any other target on a tool-result surface, and
TOOL_RESULT on any other direction, stays refused.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
Accept the tool_call, tool_result and tool_definition keywords a per-tool
rail is executed with, record the calls and results each stub was handed,
map the tool_input direction to TOOL_RESULT in the rewriting helpers, and
add a tool_result_rewrite verdict helper.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
Add RailResult.messages, the conversation as tool rails rewrote it, which
takes part in equality so a lost rewrite fails a comparison, and
messages_before_block, its capture-only counterpart on a block that
follows a rewrite, excluded from equality like rewrite_before_block.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
Add ModelEngine.rewrite_tool_results and its EngineRegistry delegator,
the inverse of tool-exchange extraction: each key is the message_index
extraction assigned, the tool message there takes the rewritten text as
its content, and the caller's list and messages are copied rather than
edited. A key that is not a tool message raises ValueError.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
Per-tool rails on tool results may now return a TOOL_RESULT transform.
Rewriting flows run first for each tool, each later check is handed the
result as the checks ahead of it left it, and a rewrite a later rail
undid counts as none. The rewritten contents are written back through
the engine registry: an allow carries the conversation as
RailResult.messages, and a block that follows a rewrite keeps it as
capture-only messages_before_block. A rewrite that cannot be written back
blocks, a tool-call rail's rewrite still raises, and parallel rails and
the global validators keep refusing rewrites.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
When the tool-result rails rewrite a result, generate and stream rebind
the conversation before the input rails run, so the input and output
rails, the main model and the generation record all read the rewrite. A
tool-result block that follows a rewrite records the rewritten
conversation on the request span, as an input block behind a mask
already does.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
A check whose tool-result rails rewrote a result threads the rewritten
conversation into the input, tool-call and output rails behind them and
onto the request span, and returns MODIFIED with RailsResult.messages:
the checked conversation as the rails left it, index-aligned with the
messages passed in. /v1/checks returns the same messages with the
prepended context message stripped. Both stay unset unless a tool rail
rewrote something.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
Document TransformTarget.TOOL_RESULT, the transform_target pairings
IORails accepts, how a per-tool rail's tool-result rewrite is applied
(ordering, history recomputation, text-part collapse, the sequential
fallback, capture on a block), and the MODIFIED status and messages field
a check returns for it.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
The request span recorded the tool results a conversation carries as the
caller sent them whenever a per-tool masking rail never decided on them:
the mask raised, the generation options turned the tool rails off, a
check's rail_types left tool_result out, or the manager blocked before
the per-tool rails ran. The withholding machinery only knew the input and
output directions, so nothing seeded or cleared a pending tool mask.

are_tool_results_safe now reports a configured mask that did not decide,
IORails seeds the tool-result direction from per_tool_transform_flows and
clears it from the rails' verdict, and a response to a tool result no
mask decided on is withheld too, since the model may repeat it.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
…once

A stream's request span records the conversation when the stream ends,
which a consumer can bring about at any point. The tool-result rewrite
was held in a local until the input rails returned, so a stream closed
while those rails waited on their model call recorded the tool result as
the caller sent it, undoing the mask. The block branch alongside it
already published the rewrite for this reason.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
Add TransformTarget.TOOL_CALL, whose text is the JSON object of the
complete arguments of the tool call under check, and map the tool_output
direction to it in the compile gate. Any other target on a tool-call
surface, and TOOL_CALL on any other direction, stays refused.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
…ersation

Add ModelEngine.rewrite_latest_tool_calls and its EngineRegistry
delegator, the inverse of extract_latest_tool_calls. Each rewritten
call on the last assistant message gets the new arguments as the JSON
string the wire carries; only the list, that message, its tool_calls
and the rewritten calls are copied, and a position with no call raises
ValueError.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
Per-tool rails on tool calls may now return a TOOL_CALL transform whose
text is the JSON object of the complete arguments. Rewriting flows run
first for each tool and each later check is handed the call as the
checks ahead of it left it. Only function.arguments is replaced, so id,
type and name cannot change; text that is not a JSON object blocks with
malformed_arguments. Because rails ahead of a rewrite judged the
original arguments, the final arguments are validated against the tool
again and a failure blocks with the validator's own violation, or
tool_not_allowed when the tool is undeclared.

An allow carries the final calls as RailResult.tool_calls; a block
behind a rewrite keeps them as capture-only tool_calls_before_block.
are_latest_tool_calls_safe writes the rewritten arguments back onto the
last assistant message and carries the conversation as messages, or
messages_before_block on a block. A rewrite a later rail undid counts as
none, compared as serialized JSON so 1 and true differ.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
…check

Generate swaps the rewritten calls into the model response, so the bare
assistant message (JSON-string arguments) and GenerationResponse (dict
arguments) both carry them. Stream sends them on the terminal tool-call
chunk through a new local, which content capture then records. A check
that runs tool-call rails rebinds the conversation for the output rails
and the request span, reports MODIFIED with messages when a call was
rewritten, and records the masked calls when a later rail blocks.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
Document TransformTarget.TOOL_CALL and the tool_output pairing IORails
accepts, how a per-tool rail's tool-call rewrite is applied (arguments
only, the JSON-object rule, ordering, schema re-validation, both
generate return shapes and the terminal stream chunk, capture), and the
MODIFIED status and messages a check returns for it.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
A per-tool masking rail that never decided on a tool call still let the
raw arguments reach the request span: a stream whose generation options
turned the tool-call rails off recorded the terminal tool-call chunk as
delivered, and a check recorded the conversation whenever its rail_types
left tool_call out or the mask failed.

are_tool_calls_safe and are_latest_tool_calls_safe now report a
configured mask that did not decide, a check seeds the tool-call
direction from the latest assistant turn's calls and clears it from the
rails' verdict, and a stream withholds its output when the calls it
delivers carry arguments no mask decided on.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>
@github-actions github-actions Bot added status: needs triage New issues that have not yet been reviewed or categorized. size: XL labels Oct 24, 2026

@Nurozen Nurozen left a comment •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Self-review pass turned up one bug, now fixed in 9f42179.

A whole-message tool-call extraction failure, such as a legacy function_call beside tool_calls, returned _blocked_before_rails(...) with the default rewrite_pending=False, so the block reported every configured call mask as decided and the request span recorded the raw arguments. Every other early return in that file already computes a masks_unrun flag; this one did not. masks_unrun is now hoisted above the try and passed to both extraction-failure branches.

A whole-message tool call extraction failure, such as a legacy
function_call beside tool_calls, blocked without reporting the
configured call masks as still owed a decision. The request span then
recorded the raw tool call arguments the mask never saw.

Report the masks as pending on both whole-message extraction failure
paths, as the per-call parse failure already does.

Signed-off-by: Justin Brooks <jbrooks@hiddenlayer.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size: XL status: needs triage New issues that have not yet been reviewed or categorized.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feature: let per-tool rails rewrite tool calls and tool results, not only allow or block

1 participant