Skip to content

[Bug]: AgentWorkflow stores agents by reference; one BaseTool instance shared across agents leaks per-tool mutations #22146

Description

@Fr3ya

Bug Description

AgentWorkflow.__init__ in llama-index-core/llama_index/core/agent/workflow/multi_agent_workflow.py stores agent configs by reference: self.agents = {cfg.name: cfg for cfg in agents}. No copy.deepcopy(cfg), no cfg.model_copy(deep=True), no per-agent copy of cfg.tools. Upstream, BaseWorkflowAgent.validate_tools builds a fresh outer list per agent but keeps each pre-existing BaseTool element by reference (validated_tools.append(tool)).

If a user passes the same FunctionTool instance into the tools= list of two AgentConfigs and hands those to one AgentWorkflow, the runtime tool-call lookup at multi_agent_workflow.py:252 (agent_tools = self.agents[agent_name].tools or []) reaches through the same shared object. Per-tool mutations performed by or on behalf of one agent (closure-captured fn state, tool.metadata.description, tool.partial_params, an MCP session handle, a cached retriever) are visible to every sibling agent that shares the tool.

The natural way to "share a knowledge-base tool across a researcher + writer pair" is:

kb = FunctionTool.from_defaults(fn=lookup_kb)
researcher = FunctionAgent(name="researcher", tools=[kb], llm=llm, ...)
writer     = FunctionAgent(name="writer",     tools=[kb], llm=llm, ...)
w = AgentWorkflow(agents=[researcher, writer], root_agent="researcher")

This pattern is documented as the multi-agent API entry point, but the framework neither isolates nor warns about the shared identity. Per-agent partial parameters (region="us-west" on agent A vs region="eu-central" on agent B over a shared HTTP tool) race; last writer wins for all agents.

Version

llama-index-core==0.14.22

Steps to Reproduce

No LLM, no network. The PoC constructs a real AgentWorkflow with two FunctionAgents sharing one FunctionTool and exhibits three independent mutation-leak channels.

from llama_index.core.agent.workflow import AgentWorkflow, FunctionAgent
from llama_index.core.llms.mock import MockLLM
from llama_index.core.tools import FunctionTool

counter = {"hits": 0}
def lookup(query: str) -> str:
    counter["hits"] += 1
    return f"result({query})#{counter['hits']}"

shared = FunctionTool.from_defaults(fn=lookup)
llm = MockLLM()
a = FunctionAgent(name="researcher", description="finds facts",
                  tools=[shared], llm=llm, can_handoff_to=["writer"])
b = FunctionAgent(name="writer",     description="writes summaries",
                  tools=[shared], llm=llm, can_handoff_to=["researcher"])
w = AgentWorkflow(agents=[a, b], root_agent="researcher")

t_a = w.agents["researcher"].tools[0]
t_b = w.agents["writer"].tools[0]
print("identity preserved through graph:", t_a is t_b is shared)

# Mutation 1: closure state behind tool.fn
w.agents["researcher"].tools[0].call(query="alpha")
w.agents["writer"].tools[0].call(query="beta")
print("closure counter:", counter)               # both incremented

# Mutation 2: tool.metadata.description
w.agents["researcher"].tools[0].metadata.description = "RESEARCHER-SCOPED"
print("writer sees description:", w.agents["writer"].tools[0].metadata.description)

# Mutation 3: tool.partial_params
w.agents["researcher"].tools[0].partial_params["region"] = "us-west"
print("writer sees partial_params:", w.agents["writer"].tools[0].partial_params)

# CONFIRMED BUG when identity is True and all three mutations leak

Relevant Logs/Tracebacks

identity preserved through graph: True
closure counter: {'hits': 2}
writer sees description: 'RESEARCHER-SCOPED'
writer sees partial_params: {'region': 'us-west'}

AST: AgentWorkflow.__init__ has no per-agent/per-tool deepcopy: BUG PATTERN PRESENT
AST: BaseWorkflowAgent.validate_tools preserves tool identity:  BUG PATTERN PRESENT
Runtime: shared tool identity + 3 mutation channels leak:       reproduced

CONFIRMED BUG: shared tool identity / mutation visible across agents
Class: C25 — shared-tool-or-vectordb-mutation-across-nodes

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtriageIssue needs to be triaged/prioritized

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions