Skip to content

LangChain middleware reference

Module: piighost.integrations.langchain

Moved in 1.4.0

This integration moved here from piighost.integrations.middleware. The old import path still works but emits a DeprecationWarning. Update imports to piighost.integrations.langchain.

PIIAnonymizationMiddleware is a LangChain AgentMiddleware that de-identifies PII around the model and tool boundary of an agent. It reads the thread id from the LangGraph config, de-identifies messages before the model sees them, restores them after for display, and routes tool calls by a chosen strategy. All detection, token assignment, and replacement is delegated to a ThreadAnonymizationPipeline.

from piighost.integrations.langchain import (
    EntityCreateByAssistantStrategy,
    InventedPlaceholderStrategy,
    PIIAnonymizationMiddleware,
    ToolCallStrategy,
)

Needs the middleware extra (pip install piighost[langchain]), which pulls in langchain. Importing the package never pulls langchain in; the middleware class is imported on demand, so a missing extra raises an ImportError naming the extra.


PIIAnonymizationMiddleware

Extends AgentMiddleware and hooks the agent loop at three points.

Hook When Operation
abefore_model Before each model call De-identifies the user and model messages
aafter_model After each model response Restores the user and model messages for display
awrap_tool_call Around each tool call De-identifies arguments, de-identifies the response, per strategy

Constructor

PIIAnonymizationMiddleware(
    pipeline: AnyThreadPipeline,
    tool_strategy: ToolCallStrategy = ToolCallStrategy.FULL,
    require_thread_id: bool = True,
    invented_strategy: InventedPlaceholderStrategy = InventedPlaceholderStrategy.RAISE,
    assistant_strategy: EntityCreateByAssistantStrategy = EntityCreateByAssistantStrategy.PRESERVE,
)
Parameter Type Description
pipeline AnyThreadPipeline The thread pipeline that de-identifies and restores (required)
tool_strategy ToolCallStrategy How the two directions of a tool call are handled
require_thread_id bool Whether a missing thread id raises, rather than falling back to a shared thread
invented_strategy InventedPlaceholderStrategy How a token the pipeline never issued is treated after restoration
assistant_strategy EntityCreateByAssistantStrategy How values the assistant introduces are treated

The pipeline must expose a delimited token recognizer through pipeline.recognizer, so a token the model invented can be found again. A pipeline whose placeholder factory is not delimited (a mask, for example) has no recognizer, and the constructor raises UnrecognizableFactoryError. The IdentityT type bound enforces the same at type-check time for typed callers.

require_thread_id defaults to True, so a missing thread id raises MissingThreadIdError rather than routing every conversation into the shared "default" thread, which would leak placeholder state across conversations. Pass False to opt into that shared fallback for single-conversation or stateless use.


Hooks

abefore_model(state, runtime) -> dict | None

De-identifies the user and model messages before the model sees them. Each message is passed through pipeline.anonymize() under the role its type contributes. A ToolMessage is never rewritten here, only in the tool wrapper. Under EntityCreateByAssistantStrategy.IGNORE, AIMessage content is skipped entirely.

Returns {"messages": [...]} when a message changed, None otherwise.

# before: [HumanMessage("Email Patrick in Paris")]
# after:  [HumanMessage("Email <<PERSON:1>> in <<LOCATION:1>>")]

aafter_model(state, runtime) -> dict | None

Restores the user and model messages for display through pipeline.deanonymize(), then applies invented_strategy to the restored text. Returns {"messages": [...]} when a message changed, None otherwise.

# before: [AIMessage("Sent to <<PERSON:1>>.")]
# after:  [AIMessage("Sent to Patrick.")]

awrap_tool_call(request, handler) -> ToolMessage | Command

Routes the tool call by tool_strategy. When the strategy de-identifies input, the tool arguments are restored to real values before the tool runs. When it de-identifies output, a string tool response is passed through pipeline.anonymize() after the tool runs. PASSTHROUGH touches neither.

Argument restoration recurses through nested dict, list, and tuple containers. Only str leaves are restored; other types pass through unchanged.

# model calls  : send_email(to="<<PERSON:1>>", subject="Hi")
#                       restore args
# tool receives: send_email(to="Patrick", subject="Hi")
# tool returns : "Sent to Patrick."
#                       de-identify response
# model sees   : "Sent to <<PERSON:1>>."

Strategies

Plain enums in piighost.integrations.langchain.strategy, importable without langchain.

ToolCallStrategy

How the two directions of a tool call are handled. The directions are independent, and the middleware acts only in the tool wrapper.

Value Arguments Response
INPUT restored to real values left as the tool returned it
OUTPUT left tokenized de-identified
FULL restored to real values de-identified
PASSTHROUGH untouched untouched

FULL is the default. A strategy that does not de-identify the response leaves it as the tool returned it, and the model sees it that way.

InventedPlaceholderStrategy

How a token the pipeline never issued is treated. After restoration, every issued token has been replaced by its value, so any token still matching the placeholder grammar was invented by the model, whether hallucinated or injected.

Value Effect
KEEP leave the invented token in the text
DROP remove the invented token
RAISE raise InventedPlaceholderError

RAISE is the default.

EntityCreateByAssistantStrategy

How values the assistant introduces are treated. A value's provenance is the role of its first occurrence in the thread. A value the assistant introduced is not user PII, so de-identifying it strips the model of its world knowledge of that entity. Formerly named AssistantEntityStrategy, kept as a deprecated alias.

Value Effect
PRESERVE leave assistant-introduced values in clear
ANONYMIZE de-identify them like user PII
IGNORE do not analyze assistant messages at all, saving the detector

PRESERVE is the default.


Full flow

sequenceDiagram
    participant U as User
    participant M as PIIAnonymizationMiddleware
    participant L as Model
    participant T as Tool

    U->>M: User message (clear text)
    M->>M: abefore_model()
    M->>L: De-identified message (tokens)
    L->>M: Tool call with tokenized args
    M->>M: awrap_tool_call() restore args
    M->>T: Tool call with real values
    T->>M: Tool response (real values)
    M->>M: awrap_tool_call() de-identify response
    M->>L: De-identified tool response
    L->>M: Final response (tokens)
    M->>M: aafter_model() restore for display
    M->>U: Final response (clear text)

From the user message to the restored response, through the model and the tool.


Example

from langchain.agents import create_agent
from langchain_core.tools import tool

from piighost.config import load_thread_pipeline
from piighost.integrations.langchain import PIIAnonymizationMiddleware


@tool
def get_info(person: str) -> str:
    """Return information about a person."""
    return f"{person} is a software engineer in Paris."


pipeline = load_thread_pipeline("pipeline.toml")
middleware = PIIAnonymizationMiddleware(pipeline)

agent = create_agent(
    model="openai:gpt-5.6-terra",
    system_prompt="You are a helpful assistant. Treat placeholders as real values.",
    tools=[get_info],
    middleware=[middleware],
)

config = {"configurable": {"thread_id": "conv-1"}}
result = await agent.ainvoke(
    {"messages": [{"role": "user", "content": "Who is Patrick?"}]},
    config,
)
print(result["messages"][-1].content)

The pipeline must be a thread pipeline whose placeholder factory is delimited, such as label, label_counter, or label_hash. Pass a thread id on every call through config["configurable"]["thread_id"], since require_thread_id defaults to True.


Streaming

The abefore_model and aafter_model hooks see the whole message, so a live display that streams the reply would show placeholders until it completes. For a token-by-token display, wrap deanonymize_stream around your own streaming loop. It buffers only a token split across chunks, restores each token once it completes, and applies invented_strategy per restored token.

deanonymize_stream(source, thread_id) -> AsyncIterator[str]

source is an async iterator of the model's text chunks; thread_id is the id you ran the agent with, since a manual stream loop is outside the LangGraph config the hooks read.

config = {"configurable": {"thread_id": "conv-1"}}


async def model_text():
    async for chunk, _meta in agent.astream(
        {"messages": [{"role": "user", "content": "Who is Patrick?"}]},
        config,
        stream_mode="messages",
    ):
        if isinstance(chunk.content, str):
            yield chunk.content


async for restored in middleware.deanonymize_stream(model_text(), "conv-1"):
    print(restored, end="", flush=True)

A token split across chunks, <<PER then SON:1>>, is held until it completes and restored to Patrick, so the display never shows a broken token.

For another framework, the same restoration is one step lower: pipeline.recognizer.async_stream_decoder(replace) builds the decoder over any factory's grammar, with replace a coroutine that deanonymizes one token.


See also