Skip to content

fix(workflow): stop run_llm_agent_as_node mutating the shared node agent - #7173

Open
prasanna8585 wants to merge 1 commit into
google:mainfrom
prasanna8585:fix/llm-agent-wrapper-shared-node-mutation
Open

prasanna8585 wants to merge 1 commit into
google:mainfrom
prasanna8585:fix/llm-agent-wrapper-shared-node-mutation

Conversation

@prasanna8585

Copy link
Copy Markdown
Contributor

Problem

run_llm_agent_as_node's single_turn branch defaulted a node's include_contents to 'none' when left unset, by setting agent.include_contents = 'none' directly on the agent parameter. That parameter is not per-invocation: LlmAgent._run_impl calls run_llm_agent_as_node(self, ...), and self is the same LlmAgent instance build_node placed in the workflow graph — the one every future run of that node reuses.

The first invocation to run the node without an explicit include_contents permanently set it to 'none' on that shared instance. Every later invocation of the same node then inherited that value regardless of whether it needed the default, since the write was never undone or scoped to the call that made it. include_contents controls whether prior conversation history is included in the LLM request, so this is content-inclusion behavior silently locked in by whichever invocation happened to run first.

Found while auditing #<f8282da's PR number> ("prevent in-place agent mutation"), which fixed the identical shared-mutation shape in code_execution.py's cfc_agent.code_executor = BuiltInCodeExecutor() but only covers one of at least two instances of the pattern — this is the other one, in a different file, untouched by that commit despite it also editing _llm_agent_wrapper.py for unrelated cleanup.

A companion mutation two lines above (if agent.mode is None: agent.mode = 'single_turn') has the same shape but isn't exploitable in practice — traced every call site and confirmed build_node's existing clone-and-set-mode logic always runs first, so agent.mode is already non-None by the time this check executes. Left as-is; fixing something already unreachable would add risk without closing a real gap.

Fix

Clone the agent for this one invocation when the default would otherwise apply, mirroring the pattern build_node already uses for the identical need — including restoring parent_agent (clone() drops it by design; build_node restores it for the same reason). Skip the clone entirely when include_contents was set explicitly.

Testing

  • The existing test (test_single_turn_defaults_include_contents_only_when_unset) was directly asserting the bug as the intended contract (assert wrapper.include_contents == expected_include_contents — the shared node's permanent mutation). Its mock also captured wrapper by closure rather than the actual calling object, so it wouldn't have caught this either way. Rewrote it to patch LlmAgent.run_async at the class level and assert the two things that matter: the effective value used during the run, and that the shared node itself is never mutated.
  • Reverted the fix locally and reran the rewritten test: fails against the vulnerable code, passes against the fix, for exactly the two parametrized cases where the implicit default applies.
  • Full tests/unittests/workflow/ suite: 872 passed, 1 skipped, 5 xfailed, 0 failed — both before and after.

run_llm_agent_as_node's single_turn branch defaulted a node's
include_contents to 'none' when the caller left it unset, by setting
`agent.include_contents = 'none'` directly on the `agent` parameter.
That parameter is not a per-invocation object: LlmAgent._run_impl calls
`run_llm_agent_as_node(self, ...)`, and `self` is the same LlmAgent
instance build_node placed in the workflow graph -- the one every
future run of that node reuses. The first invocation to run the node
without an explicit include_contents permanently set it to 'none' on
that shared instance; every later invocation of the same node then
inherited that value regardless of whether it needed the default,
since the write was never undone or scoped to the call that made it.
include_contents controls whether prior conversation history is
included in the LLM request (flows/llm_flows/context/_contents.py
reads `agent.include_contents` directly, with no other place a
per-invocation override could reach it), so this is content-inclusion
behavior silently locked in for a shared, long-lived object by
whichever invocation happened to run first, not necessarily what the
node's own configuration or a later caller intended.

Found while auditing a sibling commit (f8282da) that fixed the
identical shared-mutation shape in code_execution.py's
`cfc_agent.code_executor = BuiltInCodeExecutor()` -- a mutation of a
shared Runner.agent based on one invocation's run_config, replaced
there with a per-invocation resolver that never touches the agent.
That fix's own commit message says "prevent in-place agent mutation"
but only covers one of at least two instances of the pattern; this is
the other one, in a different file, untouched by that commit despite
it also editing _llm_agent_wrapper.py for unrelated cleanup.

A companion mutation two lines above --
`if agent.mode is None: agent.mode = 'single_turn'` -- has the same
shape but is not exploitable in practice: every path that reaches
run_llm_agent_as_node does so through LlmAgent._run_impl, which is
only invoked once the agent is already running as a workflow node,
which requires it to have gone through build_node's
`agent_node.clone(update=kwargs)` first. build_node always sets `mode`
there when it is None, before the cloned agent is ever stored as the
node, so `agent.mode` is already non-None by the time
run_llm_agent_as_node's check runs -- confirmed by tracing every call
site (grep for `run_llm_agent_as_node(` finds exactly one, passing
`self`, and grep for `build_node(` finds no path that skips the
clone). Left as-is; fixing something already unreachable would add
risk (the two writes are adjacent and easy to conflate) without
closing a real gap.

Fix: clone the agent for this one invocation when the default would
otherwise apply, mirroring the pattern build_node itself already uses
for the identical clone-then-configure need, including restoring
parent_agent (clone() drops it, since it assumes the caller is
defining a new, independent agent, which is not the case here -- see
build_node's own, identical restoration for the same reason). Skip
the clone entirely when include_contents was set explicitly, since no
override is needed in that case.

Verified:
- Reproduced the bug with the existing test
  (test_single_turn_defaults_include_contents_only_when_unset) before
  changing it: its own assertion,
  `assert wrapper.include_contents == expected_include_contents`,
  was directly asserting the shared node's permanent mutation as the
  intended contract, which is the bug. The test's mock also captured
  `wrapper` by closure rather than the object run_async was actually
  called on (it was set via `object.__setattr__` on the instance, so
  it never received `self`), which would have silently passed either
  way once the mutation was removed. Rewrote it to patch
  LlmAgent.run_async at the class level (so self is captured
  correctly regardless of which object -- the original or the per-
  invocation clone -- actually runs) and to assert the two things
  that actually matter: the effective include_contents seen during
  the run is still correct, and the shared node's own attribute is
  never changed by running it.
- Reverted the production fix locally and reran the rewritten test:
  it fails against the vulnerable code (asserts
  `wrapper.include_contents` stayed at its original value; the
  reverted code left it permanently changed to 'none') and passes
  against the fix, for exactly the two parametrized cases where the
  implicit default applies. The two cases with an explicit
  include_contents pass unchanged in both versions, as expected,
  since no clone or mutation happens in that branch either way.
- Full tests/unittests/workflow/ suite: 872 passed, 1 skipped, 5
  xfailed, 0 failed, both before capturing the fix and after
  restoring it.
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