ContactOS — Multi-Tenant Conversation Engine

Deep dive into the conversation orchestration layer: tenant registry, session lifecycle, LangGraph ReAct agent, handoff mechanics, button rules, and deterministic response formatting.

1. Tenant Registry

Each tenant is a self-contained directory under tenants/ with its own config, tools, prompts, and button rules. The registry lazy-loads tenant runtimes on first access to prevent circular imports.

# tenants/registry.py TenantRuntime = { config: AgentConfig, # model, tools, prompt, timezone tool_implementations: Dict[str, StructuredTool], button_rules: List[ButtonRule], tool_formatters: Dict[str, Callable], # tool → deterministic formatter } get_tenant_runtime(tenant_id: str) TenantRuntime # Lazy-load on first access via _LazyDict # Currently supports: "tsg", "tsg-001" (backward compat)

Tenant Directory Structure

File Purpose
tenants/tsg/config.py AgentConfig: model name, temperature, session expiry, greeting words, timezones
tenants/tsg/prompts.py System prompt template + per-tool description strings
tenants/tsg/button_rules.py Declarative ButtonRule list — attach UI buttons after tool execution
tenants/tsg/formatters.py Deterministic response formatters that replace LLM text for critical outputs
tenants/tsg/tools/ StructuredTool implementations: document_tools, operator_tools, service_tools
tenants/tsg/services/ Business logic layer: document validation, service management

Adding a New Tenant

  1. Create tenant directory
    Copy tenants/tsg/ as a template. Rename to tenants/{new_id}/.
  2. Define AgentConfig
    Set model, temperature, session expiry, greeting words, and timezone in config.py.
  3. Write system prompt
    Customize the persona, domain knowledge, and tool descriptions in prompts.py.
  4. Implement tools
    Create LangChain StructuredTools with Pydantic input schemas. Register in tools/__init__.py.
  5. Define button rules & formatters
    Declarative rules for UI buttons and deterministic output formatting for critical tool results.

2. Session Lifecycle

SessionManager handles creation, expiry, reset, and handoff resume. Sessions are stored in MongoDB and keyed by phone number + tenant.

Lifecycle Session State Machine
New Message First contact
Active AI conversation
Handed Off Human agent
Resumed AI + agent history
Expired / Reset 30min idle or greeting
Trigger Action Details
First message from number Create session New session in MongoDB, fresh LangGraph state
30 minutes idle Expire session Configurable via session_expiry_minutes in AgentConfig
Greeting word detected Reset session Words like "hello", "hi", "hola", "start" trigger full state wipe
Handoff signal Transfer to human User requested agent OR 5+ consecutive no-tool responses
Agent returns conversation Resume with history Full agent conversation injected into LangGraph state before AI takes over

3. ConversationEngine

Central orchestrator at core/engine/engine.py. Receives a NormalizedMessage and returns an EngineResponse.

  1. Get or Create Session
    SessionManager looks up by phone_number + tenant_id. Creates new if not found or expired.
  2. Check Reset Conditions
    If message text is a greeting word (configured per tenant), wipe session and start fresh.
  3. Deterministic Greeting
    If first message in session AND is a greeting word, return hardcoded greeting response. Skips LLM entirely for faster response.
  4. Handoff Resume Check
    If session was in handoff state, inject the human agent's conversation history into AgentState before invoking the graph.
  5. Invoke LangGraph
    await graph.ainvoke(agent_state, config) — runs the ReAct loop (chatbot → tools → chatbot...) until completion or handoff.
  6. Apply Button Rules
    Declarative ButtonRules scan the response and attach UI buttons based on tool results or text patterns.
  7. Apply Response Formatters
    Deterministic formatters replace LLM-generated text for critical outputs (service confirmations, document validations).

4. LangGraph ReAct Agent

Three-node graph at core/agent/graph.py implementing the ReAct pattern with a conditional router.

Graph build_graph() ReAct loop with handoff branch
START AgentState input
chatbot LLM generates response
route_after_chatbot() Conditional router
tools Execute StructuredTools

After tools node completes, control returns to chatbot (loop). Router exits to END when no tool calls remain, or to handoff when escalation is needed.

Router Logic (route_after_chatbot)

Condition Route To Details
Tool calls detected in LLM response "tools" node Execute the requested tools, then loop back to chatbot
needs_handoff flag set OR 5+ consecutive no-tool responses "handoff" node Route to human agent queue. Infinite loop guardrail.
No tool calls and no handoff signal END Conversation turn complete, return response

5. Deterministic Response Formatting

Critical tool outputs bypass LLM interpretation. Each tool can register a formatter function that produces the exact response text.

Before Without Formatters
LLM generates natural language describing tool results. Risk of hallucinating service details, prices, or confirmation status. Critical for domains where accuracy is non-negotiable (service management, document validation).
# LLM might say: "I've confirmed your 3 services including internet at $49.99/mo..." # But the actual price was $59.99
After With Formatters
Formatter function reads raw tool output and produces exact response text. LLM output is completely replaced. Zero hallucination risk on critical data paths.
# Formatter produces: "Servicios confirmados (3): 1. Internet - $59.99/mes 2. TV Basica - $29.99/mes 3. Telefono - $19.99/mes" # Exact data from tool result

6. Button Rules Framework

Declarative rules that attach interactive buttons to responses based on conditions. Defined per tenant in button_rules.py.

ButtonRule = { name: "confirm_services", # Rule identifier match_condition: Callable, # Pattern/text/tool-result matcher buttons: [ { label: "Confirmar", action: "confirm", data: { service_ids: [...] } }, { label: "Editar", action: "edit" }, { label: "Cancelar", action: "cancel" } ] } # Applied in ConversationEngine post-processing: for rule in tenant_runtime.button_rules: if rule.match_condition(engine_response): engine_response.buttons = rule.buttons

7. Handoff & Resume Mechanics

Seamless transition between AI and human agents with full context preservation.

Trigger Handoff Initiation
Two ways a conversation escalates to a human agent:
ExplicitUser requests to speak with a person
Guardrail5+ consecutive LLM responses without any tool calls
During Human Agent Phase
While handed off, the AI is dormant. Human agents use the dashboard to chat directly.
DashboardChatPanel shows agent messages
StateSession marked as "handed_off"
Resume AI Takes Back Over
When agent returns the conversation, full agent chat history is injected into AgentState.
ContextAI sees everything the agent discussed
ContinuityNo context loss across transitions

8. MongoDB LangGraph Checkpointer

Custom checkpointer at core/engine/mongodb_checkpointer.py that persists full LangGraph state to MongoDB. Enables session resumption across process restarts and provides full conversation replay for debugging.

Feature Details
Persistence Full graph state (messages, user_data, tool results) serialized to lg_sessions collection
Session Resume On reconnect, loads last checkpoint and continues conversation from exact point
History Replay All intermediate states stored — enables step-by-step conversation replay for debugging
Keying Checkpoints keyed by session_id (derived from phone_number + tenant_id)