Skip to content

[BUG] Global ToolManager can leak state across concurrent workflows #163

Description

@bossjoker1

Title: [BUG] Global ToolManager can leak state across concurrent workflows

Describe the bug

Paper2Any's workflow layer uses a process-wide singleton ToolManager, but GenericGraphBuilder._register_tools_for_role(...) registers pre-tools as lambdas that capture the current workflow state object.

Because those registrations are keyed only by (role, tool_name), one workflow can overwrite another workflow's state-bound pre-tool when they use the same role/tool pair.

That means overlapping workflows can execute with the wrong request state. In the reproduction below, workflow A registers paper_content = DOC_A, workflow B registers paper_content = DOC_B, and workflow A later executes against DOC_B.

To Reproduce

  1. From the repository root, save the following as probe_tool_manager_leak.py.
  2. Run python probe_tool_manager_leak.py.
  3. Observe that workflow A reads workflow B's paper_content.
import asyncio
import sys
import types


def ensure_mod(name):
    mod = types.ModuleType(name)
    sys.modules[name] = mod
    return mod


# Minimal stubs for import-time optional deps.
ensure_mod("langchain_core")
core_tools = ensure_mod("langchain_core.tools")
core_tools.Tool = type("Tool", (), {})
ensure_mod("langgraph")
lg_graph = ensure_mod("langgraph.graph")
lg_graph.StateGraph = type("StateGraph", (), {})
pyd = ensure_mod("pydantic")
pyd.BaseModel = type("BaseModel", (), {})

sys.path.insert(0, ".")

from dataflow_agent.graphbuilder.graph_builder import GenericGraphBuilder
from dataflow_agent.toolkits import tool_manager as tm_mod


# Reset the process-wide singleton for a clean repro.
tm_mod._tool_manager_instance = None
manager = tm_mod.get_tool_manager()

builder_a = GenericGraphBuilder(state_model=object, entry_point="start")
builder_b = GenericGraphBuilder(state_model=object, entry_point="start")

@builder_a.pre_tool("paper_content", "outline_agent")
def a_paper_content(state):
    return state["paper_content"]

@builder_b.pre_tool("paper_content", "outline_agent")
def b_paper_content(state):
    return state["paper_content"]

state_a = {"paper_content": "DOC_A"}
state_b = {"paper_content": "DOC_B"}

async def workflow_a():
    builder_a._register_tools_for_role("outline_agent", state_a)
    await asyncio.sleep(0.05)
    return await manager.execute_pre_tools("outline_agent")

async def workflow_b():
    await asyncio.sleep(0.01)
    builder_b._register_tools_for_role("outline_agent", state_b)
    return await manager.execute_pre_tools("outline_agent")

async def main():
    a_result, b_result = await asyncio.gather(workflow_a(), workflow_b())
    print("workflow_a_result=", a_result)
    print("workflow_b_result=", b_result)
    print("stored_callable_count=", len(manager.role_pre_tools["outline_agent"]))
    print("stored_tool_names=", list(manager.role_pre_tools["outline_agent"].keys()))

asyncio.run(main())

Observed output:

workflow_a_result= {'paper_content': 'DOC_B'}
workflow_b_result= {'paper_content': 'DOC_B'}
stored_callable_count= 1
stored_tool_names= ['paper_content']

Expected behavior

Each workflow should execute its pre-tools against its own state.

In the example above:

  • workflow A should read DOC_A
  • workflow B should read DOC_B

The global tool registry should not let one workflow overwrite another workflow's state-bound pre-tool.

Screenshots

Not applicable.

Environment (please complete the following information):

  • OS: Linux
  • Browser (if applicable): N/A
  • Version/Commit ID: reproduced against the current repository checkout on 2026-06-26

Related issues

I did not find a matching existing issue for this cross-workflow state leak.

Additional context

This looks especially risky because many shipped workflows register role-scoped pre-tools like:

  • paper_content
  • minueru_output
  • text_content
  • outline_feedback

and they all flow through the same singleton ToolManager.

So if two requests overlap in the same process and hit the same role/tool pair, one request can run with another request's state. In practice that can produce wrong outputs, but more importantly it can leak one user's document content or intermediate workflow state into another user's workflow.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    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