Agent Handlers
Pluggable multi-framework agent architecture.
HyperSaaS uses a framework-agnostic agent pattern. The service layer never imports any AI framework — each handler converts its native output to a common AgentMessage at the boundary.
Architecture
services.py (zero framework imports)
│
▼
AgentMessage dataclass ← common boundary type
│
├── LangGraphHandler → converts LangChain messages
├── PydanticAIHandler → converts PydanticAI results
└── YourCustomHandler → convert anythingAgentMessage
The framework-agnostic message type defined in chat/handlers/base.py:
@dataclass
class AgentMessage:
role: str # "assistant" | "tool"
content: str
tool_calls: list[dict] | None = None # [{"id", "name", "args"}]
tool_call_id: str | None = NoneBaseAgentHandler
The abstract base class all handlers implement:
class BaseAgentHandler(ABC):
def __init__(self, session: ChatSession):
self.session = session
@abstractmethod
def invoke(self, user_message: str) -> tuple[list[AgentMessage], dict[str, int]]:
"""Synchronous invocation. Returns (messages, usage_dict).
Raises AgentInvocationError on timeout or failure.
"""
...
@abstractmethod
async def astream(self, user_message: str) -> AsyncGenerator:
"""Async streaming. Yields (chunk, usage) tuples."""
...Failure contract
Handlers raise AgentInvocationError (also in handlers/base.py) on timeout or any failure — never an empty result. This lets callers distinguish "the agent failed" (surface an error to the user) from "the agent legitimately produced nothing." The DRF views convert the exception into a user-visible validation error; the Celery task retries once before reporting failure.
Shared Modules
Framework handlers stay thin because the cross-cutting concerns live in shared modules:
| Module | Responsibility |
|---|---|
chat/handlers/prompts.py | System-prompt assembly for all frameworks: knowledge-base policy, citation rules, the map_locations JSON protocol, and error-handling instructions. It auto-detects whether the session has knowledge bases attached and tells the model definitively — KBs attached → an unconditional "search the knowledge base first" instruction; none → "don't call the search tool." |
chat/handlers/history.py | One DB fetch (fetch_history) returning framework-neutral message dicts (role, content, tool_calls, tool_call_id). Each handler converts these to its native message types, so history behavior can't drift between frameworks. |
Because both frameworks share the prompt builder, sessions behave the same regardless of agent_framework — same retrieval policy, same citation rules, same map protocol.
LangGraph Handler
Located in chat/handlers/langgraph/. Uses LangGraph's graph-based execution with tool calling.
Key files:
handler.py—LangGraphHandler(BaseAgentHandler), converts output toAgentMessagegraph.py— Defines the LangGraph state machine (llm node → tool node → loop)nodes.py— LLM call node, tool execution node, routing logicstate.py— Graph state definitiontools.py— Wraps plain Python tools with LangChain@tooldecorators
Flow:
User message → LLM node → should_continue? → tool node → LLM node → ... → ENDThe LLM decides when to call tools. The graph loops until the LLM produces a final response without tool calls (bounded by recursion_limit=10 and a 120s invoke timeout).
The handler prebuilds the tool-bound LLM client and the system prompt once per invoke and passes them through the graph config — nodes never reconstruct them on each loop iteration.
PydanticAI Handler
Located in chat/handlers/pydantic_ai/. Uses PydanticAI's agent abstraction.
Key files:
handler.py—PydanticAIHandler(BaseAgentHandler), converts PydanticAI output toAgentMessageagent.py—create_agent(session), builds apydantic_ai.Agentwith tool closures
PydanticAI uses "provider:model" format (e.g., "openai:gpt-4o", "anthropic:claude-sonnet-4-5", "deepseek:deepseek-chat"). Each supported provider maps to its native pydantic-ai prefix; an unmapped provider raises ValueError rather than silently falling back to OpenAI. The session is captured in closures for tool access.
History replay includes tool calls (ToolCallPart) and tool results (ToolReturnPart), so the agent keeps context from earlier retrievals across turns — a follow-up question about a document doesn't trigger a redundant re-search. Provider-dependent quirks are normalised at the boundary: ToolCallPart.args may arrive as a raw JSON string and is parsed to a dict before persistence.
Handler Factory
# chat/handlers/__init__.py
FRAMEWORK_CHOICES = [
("none", "None"),
("langgraph", "LangGraph"),
("pydantic_ai", "PydanticAI"),
]
def get_agent_handler(session) -> BaseAgentHandler | None:
framework = getattr(session, "agent_framework", "none")
if framework == "langgraph":
return LangGraphHandler(session)
elif framework == "pydantic_ai":
return PydanticAIHandler(session)
return NoneAdding a New Framework
- Create
chat/handlers/your_framework/handler.py:
class YourHandler(BaseAgentHandler):
def invoke(self, user_message: str):
# Call your framework
result = your_framework.run(user_message)
# Convert to AgentMessage at the boundary
messages = [AgentMessage(role="assistant", content=result.text)]
usage = {"input_tokens": result.input_tokens, "output_tokens": result.output_tokens}
return messages, usage
async def astream(self, user_message: str):
async for chunk in your_framework.stream(user_message):
yield chunk.text, {}- Register it in
chat/handlers/__init__.py:
elif framework == "your_framework":
return YourHandler(session)- Add the choice to
FRAMEWORK_CHOICESand runmakemigrations.
The tools, service layer, and frontend all work without changes.