Framework Adapters¶
ZIRAN uses adapters to communicate with different agent frameworks. This guide explains how to use built-in adapters and create custom ones.
Built-in Adapters¶
LangChain¶
from ziran.infrastructure.adapters.langchain_adapter import LangChainAdapter
adapter = LangChainAdapter(agent_executor=your_agent_executor)
Requires: uv sync --extra langchain
LangGraph¶
from ziran.infrastructure.adapters.langgraph_adapter import LangGraphAdapter
adapter = LangGraphAdapter(graph=builder.compile()) # a compiled StateGraph
Requires: uv sync --extra langchain (LangGraph is installed with it).
The agent file must expose the compiled graph as graph:
ziran discover --framework langgraph my_graph.py
ziran scan --framework langgraph --agent-path my_graph.py
A complete, offline example (a scripted planner stands in for the model, so it runs without an LLM or network):
from uuid import uuid4
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
@tool
def read_file(path: str) -> str:
"""Read a local file."""
return "internal notes"
@tool
def http_request(url: str) -> str:
"""Send an HTTP request."""
return "sent"
def planner(state: MessagesState) -> dict:
"""Read a file, send it somewhere, then answer (a real graph calls a model here)."""
msgs = state["messages"]
last_human = max(i for i, m in enumerate(msgs) if isinstance(m, HumanMessage))
done = sum(isinstance(m, ToolMessage) for m in msgs[last_human:])
if done == 0:
call = {"name": "read_file", "args": {"path": "notes.txt"}, "id": uuid4().hex}
elif done == 1:
call = {"name": "http_request", "args": {"url": "https://example.invalid"}, "id": uuid4().hex}
else:
return {"messages": [AIMessage("done")]}
return {"messages": [AIMessage("", tool_calls=[call])]}
def route(state: MessagesState) -> str:
calls = getattr(state["messages"][-1], "tool_calls", None)
if not calls:
return "end"
return "read" if calls[-1]["name"] == "read_file" else "send"
builder = StateGraph(MessagesState)
builder.add_node("planner", planner)
builder.add_node("reader", ToolNode([read_file]))
builder.add_node("sender", ToolNode([http_request]))
builder.add_edge(START, "planner")
builder.add_conditional_edges("planner", route, {"read": "reader", "send": "sender", "end": END})
builder.add_edge("reader", "planner")
builder.add_edge("sender", "planner")
graph = builder.compile()
What is mapped. Besides invoking the graph (tool calls, answer and token usage are read
from the turn's messages; the state must have a messages key), the adapter reports the
graph's structure, which the scanner imports into the knowledge graph:
| Graph | Knowledge graph |
|---|---|
node reader |
agent node langgraph:reader (role tool_node, router, subgraph or worker) |
edge planner -> reader |
delegates_to edge with conditional and branch_label ("read") |
tools of a ToolNode |
capabilities tool_<name>, linked to their node with uses_tool |
a ToolNode's messages key |
node state:messages with tool -> state and state -> tool accesses_data edges |
In the example, read_file (node reader) and http_request (node sender) never share a
node, yet the tool-chain analysis reports an indirect data_exfiltration chain
tool_read_file -> state:messages -> tool_http_request. The flat LangChain adapter, which sees
only a list of tools, does not.
"Possible via shared state". A state-channel chain means one tool's output can reach the
other tool through shared state, not that it was observed. Every ToolNode reads its tool
calls from and writes its results to its messages key, so all ToolNode tools on the same key
are linked; two dangerous tools in the same ToolNode are linked too. So even a plain
create_agent graph with read_file and http_request gets a critical data_exfiltration
chain and can get a VULNERABLE verdict, while LangChainAdapter with the same tools reports
neither.
Limits.
- Only tools inside
ToolNodes are discovered. Tools bound to a model withbind_tools, or called from plain function nodes or closures, cannot be introspected. - No per-key read/write sets for other nodes: LangGraph lists every state key for every node, so plain nodes get no state edges.
- Sub-graph nodes are agents with role
subgraph; they are not expanded. - Routes to
END(and the edge fromSTART) are not edges; theSTARTtarget is the entry point. invokesends{"messages": [...]}; graphs that pause withinterrupt()are not resumed.- With a checkpointer, all attacks of one phase share a thread (it is reset per phase), so later attacks see earlier attacks' history. Each attack's tool calls are still attributed to it.
CrewAI¶
from ziran.infrastructure.adapters.crewai_adapter import CrewAIAdapter
adapter = CrewAIAdapter(crew=your_crew)
Requires: uv sync --extra crewai
Creating a Custom Adapter¶
Implement the BaseAgentAdapter abstract class:
from ziran.domain.interfaces.adapter import BaseAgentAdapter, AgentResponse, AgentState
from ziran.domain.entities.capability import AgentCapability, CapabilityType
class MyAdapter(BaseAgentAdapter):
def __init__(self, my_agent):
self.agent = my_agent
async def invoke(self, message: str, **kwargs) -> AgentResponse:
"""Send a message and get a response."""
result = await self.agent.run(message)
return AgentResponse(
content=result.text,
tool_calls=result.get_tool_calls(),
metadata={"framework": "my_framework"},
)
async def discover_capabilities(self) -> list[AgentCapability]:
"""List the agent's tools and capabilities."""
tools = self.agent.get_tools()
return [
AgentCapability(
id=f"tool_{t.name}",
name=t.name,
type=CapabilityType.TOOL,
description=t.description,
dangerous=t.name in ["shell_execute", "eval"],
)
for t in tools
]
def get_state(self) -> AgentState:
"""Get current conversation state."""
return AgentState(
session_id="my-session",
conversation_history=self.agent.get_history(),
)
def reset_state(self) -> None:
"""Reset conversation state."""
self.agent.clear_history()
Tips¶
- Mark dangerous tools in
discover_capabilities()— this improves knowledge graph analysis - Include
tool_callsinAgentResponsewhen possible — ZIRAN uses this for detection - Implement
observe_tool_call()if your framework supports tool call hooks - For graph-shaped frameworks, override
discover_structure()to return aMultiAgentTopology(nodes, edges,state_channels); the scanner imports it next to the capabilities. The default returnsNone.