Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,18 @@ on:

jobs:
build:
runs-on: ubuntu-latest
# Pinned: ubuntu-latest (26.04) has no Python 3.9 builds (3.9 is end-of-life)
runs-on: ubuntu-24.04

strategy:
matrix:
python-version: ["3.9", "3.10"]

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,8 @@ tutorials/01_audio/dscaper_data_impulse_response/*
*audio_dialog.json
./src/sdialog/sdialog
tutorials/01_audio/*.png

# SQLite
*.db
*.db-wal
*.db-shm
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to SDialog will be documented here.

---

## [Unreleased]

### Added
- **sdialog.agents**:
- `Agent.serve_whatsapp()` (and `Server.serve_whatsapp()`) to chat with agents over WhatsApp through the Meta WhatsApp Cloud API, with one conversation per user saved in SQLite (#135, contributed by @amitbhakt). See [Serving Agents on WhatsApp](https://sdialog.readthedocs.io/en/latest/sdialog/index.html#serving-agents-on-whatsapp).
- `Agent.get_state()` / `Agent.set_state()` to save a conversation (memory, finished flag and orchestrator state) and restore it later, e.g. on a fresh or reset agent.
- **sdialog.orchestrators**:
- `get_state()` / `set_state()` hooks for stateful orchestrators; `ChangeMindOrchestrator` saves its trigger counter.

---

## [0.5.4] 2026-10-26 🩹

### Fixed
Expand Down
37 changes: 37 additions & 0 deletions docs/sdialog/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,43 @@ For serving multiple agents under a single endpoint, use ``Server``'s :meth:`~sd
# Serve both agents on port 1333 (select them by name from the GUI)
Server.serve([agent1, agent2], port=1333)

.. _serving_agents_whatsapp:

Serving Agents on WhatsApp
--------------------------

Agents can also chat with real users over WhatsApp, through the Meta WhatsApp Cloud API, with :meth:`~sdialog.agents.Agent.serve_whatsapp`. Meta sends each incoming message to a webhook served by SDialog, the agent replies, and the reply is sent back through Meta's API. Each user (phone number) has their own conversation, saved in a local SQLite database and restored on their next message with :meth:`~sdialog.agents.Agent.get_state` / :meth:`~sdialog.agents.Agent.set_state`. Conversations reset after ``session_timeout`` seconds of inactivity (30 minutes by default), or when the user sends ``/reset``.

To try it with a free Meta test account:

1. At `developers.facebook.com <https://developers.facebook.com/>`_, create an app of type "Business" and add the **WhatsApp** product. Meta provides a test phone number, its **Phone Number ID**, and a temporary **access token**. Add your own phone number as an allowed recipient.
2. Copy the **App Secret** from *App settings → Basic*. It is required, since it is used to check that webhook requests really come from Meta.
3. Expose the server on a public HTTPS URL. For local testing, ``tunnel=True`` opens an `ngrok <https://ngrok.com/>`_ tunnel (``pip install pyngrok``).
4. In the app's *WhatsApp → Configuration* page, set the callback URL (tunnel URL + ``/webhook``) and the verify token, and subscribe to the ``messages`` field.

.. code-block:: python

import os
from sdialog.agents import Agent

os.environ["WHATSAPP_ACCESS_TOKEN"] = "..."
os.environ["WHATSAPP_PHONE_NUMBER_ID"] = "..."
os.environ["WHATSAPP_APP_SECRET"] = "..."
os.environ["WHATSAPP_VERIFY_TOKEN"] = "any-random-string" # same value as in Meta's webhook settings

support = Agent(name="Support")
support.serve_whatsapp(tunnel=True)

Then send a WhatsApp message to the test number from your phone.

Some things to keep in mind:

- Only text messages are supported; users sending other media get a short notice.
- A single agent serves all users, so messages are processed one at a time (like :meth:`~sdialog.agents.Agent.serve`).
- Each user can send up to ``max_messages_per_minute`` messages per minute (6 by default, enough for a few quick messages in a row) and ``max_messages_per_hour`` per hour (60 by default). Extra messages are dropped without calling the LLM, and the user gets a single notice; set either limit to ``None`` to disable it.
- Conversations are stored in plain text in ``db_path`` (``whatsapp_sessions.db`` by default), so treat that file as personal data.
- With ``auto_setup=True`` (and ``tunnel=True``), SDialog overwrites the webhook URL of your WhatsApp Business Account and phone number with the tunnel URL. Use it only with test accounts.


----

Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ syllables>=1.0.4
# === Web server (optional) ===
fastapi
uvicorn
httpx

# === LangChain ecosystem ===
# LangChain ≥0.3.0 officially targets Python 3.9+, but later releases may require 3.10+.
Expand Down
138 changes: 137 additions & 1 deletion src/sdialog/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from langchain_core.tools import tool
from langchain_core.language_models.base import BaseLanguageModel
from langchain_core.messages.base import BaseMessage, messages_to_dict
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, ToolMessage
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, ToolMessage, messages_from_dict

from .config import config
from jinja2 import Template
Expand Down Expand Up @@ -670,6 +670,76 @@ def serve(self,

return Server.serve(agents=self, host=host, port=port, stateless=stateless, debug=debug, log_level=log_level)

def serve_whatsapp(self,
port: int = 1234,
host: str = "0.0.0.0",
phone_number_id: Optional[str] = None,
access_token: Optional[str] = None,
verify_token: Optional[str] = None,
app_secret: Optional[str] = None,
db_path: str = "whatsapp_sessions.db",
webhook_path: str = "/webhook",
session_timeout: int = 1800,
tunnel: bool = False,
auto_setup: bool = False,
max_messages_per_minute: Optional[int] = 6,
max_messages_per_hour: Optional[int] = 60,
log_level: str = "info"):
"""
Starts a Meta WhatsApp Cloud API webhook server to interact with the agent.

:param port: Port number to bind the server to.
:type port: int
:param host: Host address to bind the server to.
:type host: str
:param phone_number_id: Meta WhatsApp Phone Number ID (or WHATSAPP_PHONE_NUMBER_ID env var).
:type phone_number_id: Optional[str]
:param access_token: Meta Graph API Access Token (or WHATSAPP_ACCESS_TOKEN env var).
:type access_token: Optional[str]
:param verify_token: Webhook verification token (or WHATSAPP_VERIFY_TOKEN env var; random if unset).
:type verify_token: Optional[str]
:param app_secret: Meta App Secret for HMAC validation (or WHATSAPP_APP_SECRET env var). Required.
:type app_secret: Optional[str]
:param db_path: Path to the SQLite session database.
:type db_path: str
:param webhook_path: Webhook URL path.
:type webhook_path: str
:param session_timeout: Seconds of inactivity before a user session resets.
:type session_timeout: int
:param tunnel: If True, attempts to spawn a public ngrok tunnel.
:type tunnel: bool
:param auto_setup: If True (with tunnel=True), overwrites the Meta webhook URL of the WhatsApp
Business Account and phone number with the tunnel URL. Use only with test accounts.
:type auto_setup: bool
:param max_messages_per_minute: Per-user limit for bursts of messages; extra messages are dropped
(None to disable).
:type max_messages_per_minute: Optional[int]
:param max_messages_per_hour: Per-user limit for sustained use; extra messages are dropped
(None to disable).
:type max_messages_per_hour: Optional[int]
:param log_level: Logging level for the server.
:type log_level: str
"""
from .whatsapp import WhatsAppServer, WhatsAppConfig

config = WhatsAppConfig(
port=port,
host=host,
phone_number_id=phone_number_id,
access_token=access_token,
verify_token=verify_token,
app_secret=app_secret,
db_path=db_path,
webhook_path=webhook_path,
session_timeout=session_timeout,
tunnel=tunnel,
auto_setup=auto_setup,
max_messages_per_minute=max_messages_per_minute,
max_messages_per_hour=max_messages_per_hour,
log_level=log_level
)
return WhatsAppServer.serve(agent=self, config=config)

def response_lookahead(self, message: str = None):
"""
Generates a response without updating the agent's memory.
Expand Down Expand Up @@ -991,5 +1061,71 @@ def memory_dump(self, as_dict: bool = False) -> list:
"""
return messages_to_dict(self.memory) if as_dict else self.memory.copy()

def get_state(self) -> dict:
"""
Returns a JSON-serializable snapshot of the agent's conversation state, which can be restored later
(e.g. on a fresh or reset agent) with :func:`set_state`.

The snapshot includes the conversation memory (excluding the system prompt, which is part of the agent's
configuration), the finished flag, and the internal state of attached orchestrators. It does not include
the persona, model, or other configuration (see :func:`json` and :func:`clone` for that).

Example:

.. code-block:: python

import json
from sdialog.agents import Agent

bot = Agent(name="Bot")
bot("Hi, my name is Alice")

saved = json.dumps(bot.get_state())

# Later, e.g. after a restart, continue the same conversation
bot.reset()
bot.set_state(json.loads(saved))
bot("Do you remember my name?")

:return: Serializable state dictionary.
:rtype: dict
"""
memory = self._memory[1:] if self._system_prompt is not None else self._memory
return {
"version": 1,
"memory": messages_to_dict(memory),
"finished": self._finished,
"orchestrators": [orchestrator.get_state() for orchestrator in self._orchestrators or []]
}

def set_state(self, state: dict):
"""
Restores a conversation state previously obtained with :func:`get_state`, replacing the current
conversation memory while keeping the agent's current system prompt.

Orchestrator states are restored by position; if the number of attached orchestrators differs from the
saved state, orchestrator states are not restored and a warning is logged.

:param state: State dictionary as returned by :func:`get_state`.
:type state: dict
:raises ValueError: If the state version is not supported.
"""
if state.get("version") != 1:
raise ValueError(f"Unsupported agent state version: {state.get('version')}")

system_prompt = self._memory[:1] if self._system_prompt is not None else []
self._memory[:] = system_prompt + messages_from_dict(state.get("memory", []))
self._stateless_memory = None
self._finished = state.get("finished", False)

orchestrators = self._orchestrators or []
orchestrator_states = state.get("orchestrators", [])
if len(orchestrators) == len(orchestrator_states):
for orchestrator, orchestrator_state in zip(orchestrators, orchestrator_states):
orchestrator.set_state(orchestrator_state)
else:
logger.warning(f"Agent has {len(orchestrators)} orchestrator(s) but the state has "
f"{len(orchestrator_states)}; orchestrator states were not restored.")

#: Alias for :func:`Agent.dialog_with`.
talk_with = dialog_with
20 changes: 20 additions & 0 deletions src/sdialog/orchestrators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,26 @@ def reset(self):
"""
self.times = 0

def get_state(self) -> dict:
"""
Return the internal counter of times triggered.

:return: State dictionary.
:rtype: dict
"""
return {"times": self.times}

def set_state(self, state: dict):
"""
Restore the internal counter of times triggered.

:param state: State dictionary as returned by :func:`get_state`.
:type state: dict
:return: None
:rtype: None
"""
self.times = state.get("times", 0)

def instruct(self, dialog: List[Turn], utterance: str) -> str:
"""
Possibly return a mind-change instruction based on probability and remaining allowance.
Expand Down
22 changes: 22 additions & 0 deletions src/sdialog/orchestrators/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,28 @@ def reset(self):
"""
pass

def get_state(self) -> dict:
"""
Return a JSON-serializable snapshot of the internal state (overridden in stateful orchestrators).
Used by :func:`sdialog.agents.Agent.get_state`.

:return: State dictionary (empty for stateless orchestrators).
:rtype: dict
"""
return {}

def set_state(self, state: dict):
"""
Restore internal state from :func:`get_state` output (overridden in stateful orchestrators).
Used by :func:`sdialog.agents.Agent.set_state`.

:param state: State dictionary as returned by :func:`get_state`.
:type state: dict
:return: None
:rtype: None
"""
pass


class BasePersistentOrchestrator(BaseOrchestrator):
"""
Expand Down
74 changes: 74 additions & 0 deletions src/sdialog/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,80 @@ async def serve_async(cls,
server = uvicorn.Server(config)
await server.serve()

@classmethod
def serve_whatsapp(cls,
agent: Agent,
port: int = 1234,
host: str = "0.0.0.0",
phone_number_id: Optional[str] = None,
access_token: Optional[str] = None,
verify_token: Optional[str] = None,
app_secret: Optional[str] = None,
db_path: str = "whatsapp_sessions.db",
webhook_path: str = "/webhook",
session_timeout: int = 1800,
tunnel: bool = False,
auto_setup: bool = False,
max_messages_per_minute: Optional[int] = 6,
max_messages_per_hour: Optional[int] = 60,
log_level: str = "info") -> None:
"""
Serve an SDialog agent via Meta WhatsApp Cloud API webhook.

:param agent: The SDialog agent to serve.
:type agent: Agent
:param port: Port number to listen on.
:type port: int
:param host: Host address to bind to.
:type host: str
:param phone_number_id: Meta WhatsApp Phone Number ID.
:type phone_number_id: Optional[str]
:param access_token: Meta Graph API Access Token.
:type access_token: Optional[str]
:param verify_token: Webhook verification token (or WHATSAPP_VERIFY_TOKEN env var; random if unset).
:type verify_token: Optional[str]
:param app_secret: Meta App Secret for HMAC validation (or WHATSAPP_APP_SECRET env var). Required.
:type app_secret: Optional[str]
:param db_path: Path to the SQLite session database.
:type db_path: str
:param webhook_path: Webhook URL path.
:type webhook_path: str
:param session_timeout: Inactivity seconds before resetting session.
:type session_timeout: int
:param tunnel: If True, attempts to spawn a public ngrok tunnel.
:type tunnel: bool
:param auto_setup: If True (with tunnel=True), overwrites the Meta webhook URL of the WhatsApp
Business Account and phone number with the tunnel URL. Use only with test accounts.
:type auto_setup: bool
:param max_messages_per_minute: Per-user limit for bursts of messages; extra messages are dropped
(None to disable).
:type max_messages_per_minute: Optional[int]
:param max_messages_per_hour: Per-user limit for sustained use; extra messages are dropped
(None to disable).
:type max_messages_per_hour: Optional[int]
:param log_level: Logging level for the server.
:type log_level: str
"""
from .whatsapp import WhatsAppServer, WhatsAppConfig

config = WhatsAppConfig(
port=port,
host=host,
phone_number_id=phone_number_id,
access_token=access_token,
verify_token=verify_token,
app_secret=app_secret,
db_path=db_path,
webhook_path=webhook_path,
session_timeout=session_timeout,
tunnel=tunnel,
auto_setup=auto_setup,
max_messages_per_minute=max_messages_per_minute,
max_messages_per_hour=max_messages_per_hour,
log_level=log_level
)
return WhatsAppServer.serve(agent=agent, config=config)

@classmethod
def serve_in_thread(cls,
agents: Union[Agent, List[Agent]],
Expand Down
Loading
Loading