fix: propagate plugin payload mutations instead of inferring them from text - #157
Open
araujof wants to merge 6 commits into
Open
fix: propagate plugin payload mutations instead of inferring them from text#157araujof wants to merge 6 commits into
araujof wants to merge 6 commits into
Conversation
`modified_payload` is `Some` on every allowed pipeline, carrying the final payload whether or not a plugin touched it, so callers had no way to tell a mutation from an echo. Record acceptance where it happens and report it as `PipelineResult.payload_modified`. Also note on `get_text_content` that it reads text parts only and is not a change detector. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
A `do:` block field op passed `args.city` while the `args:` / `result:` sections passed `city`. One convention everywhere lets an invoker look the field up in its own payload projection; stripping the prefix defensively could not work, since `args` is a legal argument name. Exports `get_dotted` so host bridges read fields back with the same path semantics the evaluator writes them. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
…arts The route handler decided whether a plugin had mutated the payload by comparing concatenated text content, so a rewritten tool result, tool call, or thinking block looked identical to no mutation and was dropped. Redactors are exactly the plugins that rewrite tool results, so this failed open on the path that matters most. It now reads the signal the invoker records when it accepts a mutation. Two more mutation-losing paths in the same block: - Folding an `args:` / `result:` pipeline's rewrite back into the message replaced the whole argument map or result content, clobbering a plugin's edits to other fields of it. Only the paths the pipeline changed are applied now. - Pipeline modification is read from the decision's own flags rather than re-derived by projecting and diffing. A plugin invoked as a pipeline stage now reports a value for the field it was pointed at instead of the message's concatenated text, which for a structured tool call overwrote an argument with chat text. Moves the message projections into their own module, shared by the handler and the invoker so they cannot drift. Closes #151 Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
A pipeline's interim edits live only in the pipeline, never in the payload, so comparing a field readback against the stage's current value reported the payload's untouched original as the plugin's new value. A `mask | plugin(...)` chain handed the plaintext back and forwarded it. Compare against the field as the payload held it before dispatch instead. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
The per-path merge silently let pipeline edits override a plugin's edit to the same field, and reinstated keys the plugin removed. Document the precedence and warn on both, naming the field. Also removes the write-nothing-is-safer fallback that would have folded in an unattributable diff, phase-gates the pre-projections so neither is computed where it cannot be read, seals PipelineResult as #[non_exhaustive] mirroring RouteDecision, and covers the Post-phase field readback. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Non-Rust hosts could not tell an accepted mutation from a payload the pipeline merely carried, so they were left inferring it from content -- the failure this fix removes for Rust callers. Additive on the MessagePack wire, so the ABI version is unchanged. Also corrects the Go Invoke doc example, which presented `ModifiedPayload != nil` as a mutation test; it is true on every allowed pipeline. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #151.
A plugin that mutated anything other than a message's text had its mutation dropped: the APL route handler decided "was this modified?" by comparing
get_text_content(), which readsTextparts and ignores the other elevenContentPartvariants. Redactors are the plugins that rewrite tool results, so the failure was fail-open this path, and the plugin reported a successful redaction and the host forwarded the original secret.What the fix turned out to need
The issue suggested the invoker already knows, since
result.modified_payloadisSomewhen a plugin returns a mutation. It isn't a signal:PipelineResult::allowed_withsetsmodified_payload: Some(payload)on every allowed pipeline. Acceptance is only observable insideExecutor::run_serial_phase, so that is where it is now recorded, surfaced asPipelineResult.payload_modified.That also exposed another bug: because the old code keyed off
is_some(), everyplugin(...)stage in a field pipeline reported a new field value whether or not the plugin mutated, so a non-mutating stage overwrote its field with the message's concatenated text.Fixes
Each replaces an inferred change with a signal from the code that performed it.
args:andresult:.RouteDecision.args_modified/result_modified, which the handler previously ignored in favour of projecting and diffing.Tests
New tests across three layers. Each mechanism was verified by reverting it and confirming the tests fail:
Coverage includes the content-part variants a plugin realistically rewrites (text, thinking, tool call arguments, tool result), both phases, several plugins accumulating, a denied route forwarding nothing, an audit-mode plugin whose mutation the executor rejects not being reported,
omit, and nested paths. The other eightContentPartvariants are not exercised.make lintclean; full workspace suite green (73 binaries). Each commit compiles on its own.Behaviour notes for reviewers
PipelineResultgains a field. Construction goes throughallowed_with/denied, so callers using those are unaffected.PluginInvocation::Field.nameis now root-relative fromdo:-block field ops (city, notargs.city).One known limitation is documented in code but not fixed: a field pipeline's interim edits are not visible to a
plugin(...)stage later in the same chain. Fixing it changes what every downstream plugin sees, so it wants its own issue.Plan:
docs/plans/2026-08-06-001-fix-modify-payload-propagation-plan.md, including a note on where the plan's premise was wrong.Additional issues found during self-review:
plugin(...)stage undid an earliermask/redact/hashstage in the same chain. The field readback was compared against the pipeline's interim value, but interim edits never reach the payload — so the payload's untouched original looked like the plugin's new value and was written back. Pre-change this path wrote the message's concatenated text, so the fix had turned field destroyed into plaintext restored. Now compared against the field as the payload held it before dispatch, with amask | plugin(...)regression test that fails without it. Three reviewers converged on this independently.Also addressed:
args:/result:pipeline edits take precedence over a plugin's edit to the same path (config-author-wins), and a key the plugin removed is reinstated if the pipeline rewrote it. Both warn with the field name instead of resolving silently.payload_modifiednow crosses the FFI to the Python and Go bindings; the GoInvokedoc example no longer presentsModifiedPayload != nilas a mutation test (it is true on every allowed pipeline). Additive on the wire, ABI version unchanged.PipelineResultsealed#[non_exhaustive], mirroringRouteDecision.Field.namesemantic change marked BREAKING.Deferred: #158 (parallel plugin branches lose each other's payload mutations, and
validate_parallel_plugin_modesis never wired into registration, mostly pre-existing, and the reject-at-load vs hold-the-lock choice wants a deliberate call).