chatflow-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chatflow_agent/__init__.py +57 -0
- chatflow_agent/channels/__init__.py +14 -0
- chatflow_agent/channels/base.py +58 -0
- chatflow_agent/channels/cli.py +62 -0
- chatflow_agent/channels/telegram.py +115 -0
- chatflow_agent/channels/whatsapp.py +213 -0
- chatflow_agent/core/__init__.py +18 -0
- chatflow_agent/core/agent.py +135 -0
- chatflow_agent/core/engine.py +5 -0
- chatflow_agent/core/engines/__init__.py +16 -0
- chatflow_agent/core/engines/anthropic.py +151 -0
- chatflow_agent/core/engines/base.py +44 -0
- chatflow_agent/core/engines/factory.py +75 -0
- chatflow_agent/core/engines/gemini.py +132 -0
- chatflow_agent/core/engines/openai_compatible.py +231 -0
- chatflow_agent/core/memory.py +107 -0
- chatflow_agent/core/runner.py +225 -0
- chatflow_agent/core/tools.py +152 -0
- chatflow_agent/exceptions.py +38 -0
- chatflow_agent/py.typed +1 -0
- chatflow_agent/types.py +65 -0
- chatflow_agent-0.1.0.dist-info/METADATA +564 -0
- chatflow_agent-0.1.0.dist-info/RECORD +25 -0
- chatflow_agent-0.1.0.dist-info/WHEEL +4 -0
- chatflow_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""chatflow-agent: Lightweight, async multi-agent framework with native handoffs."""
|
|
2
|
+
|
|
3
|
+
from chatflow_agent.channels.base import BaseChannel, ChannelError
|
|
4
|
+
from chatflow_agent.channels.cli import CLIChannel
|
|
5
|
+
from chatflow_agent.channels.telegram import TelegramChannel
|
|
6
|
+
from chatflow_agent.channels.whatsapp import WhatsAppChannel
|
|
7
|
+
from chatflow_agent.core.agent import Agent
|
|
8
|
+
from chatflow_agent.core.engines import AnthropicEngine, BaseEngine, GeminiEngine, OpenAIEngine
|
|
9
|
+
from chatflow_agent.core.memory import SessionContext, SessionStore
|
|
10
|
+
from chatflow_agent.core.runner import Runner
|
|
11
|
+
from chatflow_agent.core.tools import Tool, tool
|
|
12
|
+
from chatflow_agent.exceptions import (
|
|
13
|
+
AgentHandoffError,
|
|
14
|
+
ChatFlowError,
|
|
15
|
+
DependencyError,
|
|
16
|
+
ProviderError,
|
|
17
|
+
ToolExecutionError,
|
|
18
|
+
)
|
|
19
|
+
from chatflow_agent.types import (
|
|
20
|
+
AgentResponse,
|
|
21
|
+
Handoff,
|
|
22
|
+
Message,
|
|
23
|
+
Role,
|
|
24
|
+
ToolCall,
|
|
25
|
+
ToolResult,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
__all__ = [
|
|
30
|
+
"__version__",
|
|
31
|
+
"Agent",
|
|
32
|
+
"AgentResponse",
|
|
33
|
+
"BaseChannel",
|
|
34
|
+
"ChannelError",
|
|
35
|
+
"CLIChannel",
|
|
36
|
+
"AnthropicEngine",
|
|
37
|
+
"BaseEngine",
|
|
38
|
+
"GeminiEngine",
|
|
39
|
+
"OpenAIEngine",
|
|
40
|
+
"Handoff",
|
|
41
|
+
"Message",
|
|
42
|
+
"Role",
|
|
43
|
+
"Runner",
|
|
44
|
+
"SessionContext",
|
|
45
|
+
"SessionStore",
|
|
46
|
+
"TelegramChannel",
|
|
47
|
+
"Tool",
|
|
48
|
+
"tool",
|
|
49
|
+
"ToolCall",
|
|
50
|
+
"ToolResult",
|
|
51
|
+
"WhatsAppChannel",
|
|
52
|
+
"ChatFlowError",
|
|
53
|
+
"DependencyError",
|
|
54
|
+
"ToolExecutionError",
|
|
55
|
+
"AgentHandoffError",
|
|
56
|
+
"ProviderError",
|
|
57
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Communication channels for chatflow-agent: CLI, WhatsApp, Telegram, and Webhook."""
|
|
2
|
+
|
|
3
|
+
from chatflow_agent.channels.base import BaseChannel, ChannelError
|
|
4
|
+
from chatflow_agent.channels.cli import CLIChannel
|
|
5
|
+
from chatflow_agent.channels.telegram import TelegramChannel
|
|
6
|
+
from chatflow_agent.channels.whatsapp import WhatsAppChannel
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BaseChannel",
|
|
10
|
+
"CLIChannel",
|
|
11
|
+
"ChannelError",
|
|
12
|
+
"TelegramChannel",
|
|
13
|
+
"WhatsAppChannel",
|
|
14
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Abstract BaseChannel definition for chatflow-agent."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from chatflow_agent.core.runner import Runner
|
|
7
|
+
from chatflow_agent.exceptions import ChatFlowError
|
|
8
|
+
from chatflow_agent.types import AgentResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ChannelError(ChatFlowError):
|
|
12
|
+
"""Raised when an operation on a channel fails or runner is detached."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseChannel(ABC):
|
|
16
|
+
"""Abstract base class for all communication channels (CLI, WhatsApp, Telegram, Webhook)."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self.runner: Runner | None = None
|
|
20
|
+
|
|
21
|
+
def attach(self, runner: Runner) -> "BaseChannel":
|
|
22
|
+
"""Attach a multi-agent Runner instance to this channel."""
|
|
23
|
+
self.runner = runner
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
def _ensure_runner_attached(self) -> Runner:
|
|
27
|
+
"""Validate that a runner is properly bound."""
|
|
28
|
+
if not self.runner:
|
|
29
|
+
raise ChannelError(
|
|
30
|
+
f"No Runner is attached to channel '{self.__class__.__name__}'. "
|
|
31
|
+
"Call 'channel.attach(runner)' before running or dispatching messages."
|
|
32
|
+
)
|
|
33
|
+
return self.runner
|
|
34
|
+
|
|
35
|
+
async def dispatch_async(
|
|
36
|
+
self,
|
|
37
|
+
session_id: str,
|
|
38
|
+
message: str,
|
|
39
|
+
metadata: dict[str, Any] | None = None,
|
|
40
|
+
) -> AgentResponse:
|
|
41
|
+
"""Forward an incoming message to the attached runner asynchronously."""
|
|
42
|
+
runner = self._ensure_runner_attached()
|
|
43
|
+
return await runner.run_async(session_id, message, metadata=metadata)
|
|
44
|
+
|
|
45
|
+
def dispatch(
|
|
46
|
+
self,
|
|
47
|
+
session_id: str,
|
|
48
|
+
message: str,
|
|
49
|
+
metadata: dict[str, Any] | None = None,
|
|
50
|
+
) -> AgentResponse:
|
|
51
|
+
"""Forward an incoming message to the attached runner synchronously."""
|
|
52
|
+
runner = self._ensure_runner_attached()
|
|
53
|
+
return runner.run(session_id, message, metadata=metadata)
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def run(self, **kwargs: Any) -> None:
|
|
57
|
+
"""Start listening for incoming messages or launch the channel event loop."""
|
|
58
|
+
pass
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Interactive Command-Line Interface (CLI) channel for chatflow-agent."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from chatflow_agent.channels.base import BaseChannel
|
|
7
|
+
from chatflow_agent.types import AgentResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CLIChannel(BaseChannel):
|
|
11
|
+
"""Terminal-based interactive channel for real-time multi-agent testing and debugging."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
session_id: str = "cli_user",
|
|
16
|
+
prompt_prefix: str = "\nYou: ",
|
|
17
|
+
) -> None:
|
|
18
|
+
super().__init__()
|
|
19
|
+
self.session_id = session_id
|
|
20
|
+
self.prompt_prefix = prompt_prefix
|
|
21
|
+
|
|
22
|
+
def send_message(self, text: str) -> AgentResponse:
|
|
23
|
+
"""Convenience method to dispatch a single message synchronously."""
|
|
24
|
+
return self.dispatch(self.session_id, text)
|
|
25
|
+
|
|
26
|
+
def run(self, **kwargs: Any) -> None:
|
|
27
|
+
"""Start an interactive terminal conversation session."""
|
|
28
|
+
runner = self._ensure_runner_attached()
|
|
29
|
+
|
|
30
|
+
print("=" * 65)
|
|
31
|
+
print(f"ChatFlow CLI Session Started [Starting: {runner.starting_agent.name}]")
|
|
32
|
+
print("Type 'exit', 'quit' or 'salir' to end the session.")
|
|
33
|
+
print("=" * 65)
|
|
34
|
+
|
|
35
|
+
while True:
|
|
36
|
+
try:
|
|
37
|
+
user_input = input(self.prompt_prefix).strip()
|
|
38
|
+
except (KeyboardInterrupt, EOFError):
|
|
39
|
+
print("\n\nSession ended by user.")
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
if not user_input:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
if user_input.lower() in ("exit", "quit", "salir"):
|
|
46
|
+
print("\nGoodbye!")
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
# Dispatch to runner
|
|
50
|
+
try:
|
|
51
|
+
response = self.dispatch(self.session_id, user_input)
|
|
52
|
+
# Show handoff indicator if a transfer occurred
|
|
53
|
+
if response.handoff:
|
|
54
|
+
print(f"[Transferred to {response.handoff.target_agent_name}]")
|
|
55
|
+
if response.handoff.reason:
|
|
56
|
+
print(f" Reason: {response.handoff.reason}")
|
|
57
|
+
|
|
58
|
+
# Print final text response
|
|
59
|
+
print(f"\n[{response.active_agent_name}]: {response.content}")
|
|
60
|
+
|
|
61
|
+
except Exception as err:
|
|
62
|
+
print(f"\nError during processing: {err}", file=sys.stderr)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Telegram channel adapter using python-telegram-bot."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from chatflow_agent.channels.base import BaseChannel
|
|
7
|
+
from chatflow_agent.exceptions import DependencyError
|
|
8
|
+
from chatflow_agent.types import AgentResponse
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("chatflow_agent.telegram")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TelegramChannel(BaseChannel):
|
|
14
|
+
"""Telegram bot integration supporting async polling and multi-agent dispatching."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
token: str,
|
|
19
|
+
start_message: str = "Hello! I am your AI multi-agent assistant. How can I help you today?",
|
|
20
|
+
fallback_message: str = (
|
|
21
|
+
"Disculpa, ocurrió un inconveniente al procesar tu solicitud. "
|
|
22
|
+
"Por favor intenta nuevamente en unos momentos."
|
|
23
|
+
),
|
|
24
|
+
allowed_updates: list[str] | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__()
|
|
27
|
+
self.token = token
|
|
28
|
+
self.start_message = start_message
|
|
29
|
+
self.fallback_message = fallback_message
|
|
30
|
+
self.allowed_updates = allowed_updates
|
|
31
|
+
|
|
32
|
+
# Verify optional dependencies
|
|
33
|
+
try:
|
|
34
|
+
from telegram import Update
|
|
35
|
+
from telegram.constants import ChatAction
|
|
36
|
+
from telegram.ext import Application, filters
|
|
37
|
+
except ImportError as err:
|
|
38
|
+
raise DependencyError(
|
|
39
|
+
feature="TelegramChannel",
|
|
40
|
+
extra_package="telegram",
|
|
41
|
+
) from err
|
|
42
|
+
|
|
43
|
+
self._update_cls = Update
|
|
44
|
+
self._action_cls = ChatAction
|
|
45
|
+
self._filters = filters
|
|
46
|
+
|
|
47
|
+
# Initialize python-telegram-bot application
|
|
48
|
+
self.app = Application.builder().token(self.token).build()
|
|
49
|
+
self._setup_handlers()
|
|
50
|
+
|
|
51
|
+
def _setup_handlers(self) -> None:
|
|
52
|
+
"""Register Telegram command and message handlers."""
|
|
53
|
+
from telegram.ext import CommandHandler, MessageHandler, filters
|
|
54
|
+
|
|
55
|
+
self.app.add_handler(CommandHandler("start", self._handle_start))
|
|
56
|
+
self.app.add_handler(CommandHandler("reset", self._handle_reset))
|
|
57
|
+
self.app.add_handler(
|
|
58
|
+
MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def _handle_start(self, update: Any, context: Any) -> None:
|
|
62
|
+
"""Respond to /start command."""
|
|
63
|
+
if update.message:
|
|
64
|
+
await update.message.reply_text(self.start_message)
|
|
65
|
+
|
|
66
|
+
async def _handle_reset(self, update: Any, context: Any) -> None:
|
|
67
|
+
"""Clear memory for current chat via /reset command."""
|
|
68
|
+
if not update.effective_chat or not update.message:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
chat_id = str(update.effective_chat.id)
|
|
72
|
+
if self.runner:
|
|
73
|
+
session = self.runner.get_session(chat_id)
|
|
74
|
+
if session:
|
|
75
|
+
session.clear_history()
|
|
76
|
+
await update.message.reply_text("Conversation memory has been reset.")
|
|
77
|
+
|
|
78
|
+
async def _handle_message(self, update: Any, context: Any) -> None:
|
|
79
|
+
"""Process standard incoming text messages from Telegram with error resilience."""
|
|
80
|
+
if not update.effective_chat or not update.message or not update.message.text:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
chat_id = str(update.effective_chat.id)
|
|
84
|
+
user_text = update.message.text
|
|
85
|
+
user_name = update.effective_user.username if update.effective_user else None
|
|
86
|
+
|
|
87
|
+
# Display typing indicator while processing
|
|
88
|
+
try:
|
|
89
|
+
await context.bot.send_chat_action(
|
|
90
|
+
chat_id=update.effective_chat.id,
|
|
91
|
+
action=self._action_cls.TYPING,
|
|
92
|
+
)
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
response: AgentResponse = await self.dispatch_async(
|
|
98
|
+
session_id=chat_id,
|
|
99
|
+
message=user_text,
|
|
100
|
+
metadata={
|
|
101
|
+
"channel": "telegram",
|
|
102
|
+
"chat_id": chat_id,
|
|
103
|
+
"username": user_name,
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
await update.message.reply_text(response.content)
|
|
107
|
+
except Exception as err:
|
|
108
|
+
logger.error(f"Error processing Telegram message: {err}")
|
|
109
|
+
await update.message.reply_text(self.fallback_message)
|
|
110
|
+
|
|
111
|
+
def run(self, **kwargs: Any) -> None:
|
|
112
|
+
"""Start polling for Telegram updates."""
|
|
113
|
+
self._ensure_runner_attached()
|
|
114
|
+
logger.info("Starting Telegram bot polling...")
|
|
115
|
+
self.app.run_polling(allowed_updates=self.allowed_updates, **kwargs)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""WhatsApp channel adapter using FastAPI and Meta Cloud API webhooks."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from chatflow_agent.channels.base import BaseChannel
|
|
7
|
+
from chatflow_agent.exceptions import DependencyError
|
|
8
|
+
from chatflow_agent.types import AgentResponse
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("chatflow_agent.whatsapp")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WhatsAppChannel(BaseChannel):
|
|
14
|
+
"""WhatsApp integration supporting Meta Cloud API and generic webhooks with resilience."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
verify_token: str,
|
|
19
|
+
access_token: str | None = None,
|
|
20
|
+
phone_number_id: str | None = None,
|
|
21
|
+
api_version: str = "v21.0",
|
|
22
|
+
fallback_message: str = (
|
|
23
|
+
"Disculpa, estamos experimentando una demora temporal con nuestro servicio. "
|
|
24
|
+
"Por favor intenta nuevamente en unos momentos."
|
|
25
|
+
),
|
|
26
|
+
unsupported_media_message: str = (
|
|
27
|
+
"Por el momento solo puedo procesar mensajes de texto."
|
|
28
|
+
),
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.verify_token = verify_token
|
|
32
|
+
self.access_token = access_token
|
|
33
|
+
self.phone_number_id = phone_number_id
|
|
34
|
+
self.api_version = api_version
|
|
35
|
+
self.fallback_message = fallback_message
|
|
36
|
+
self.unsupported_media_message = unsupported_media_message
|
|
37
|
+
|
|
38
|
+
# Verify optional dependencies
|
|
39
|
+
try:
|
|
40
|
+
import httpx
|
|
41
|
+
import uvicorn
|
|
42
|
+
from fastapi import FastAPI
|
|
43
|
+
except ImportError as err:
|
|
44
|
+
raise DependencyError(
|
|
45
|
+
feature="WhatsAppChannel",
|
|
46
|
+
extra_package="whatsapp",
|
|
47
|
+
) from err
|
|
48
|
+
|
|
49
|
+
self._httpx = httpx
|
|
50
|
+
self._uvicorn = uvicorn
|
|
51
|
+
self.app = FastAPI(title="ChatFlow WhatsApp Webhook Server")
|
|
52
|
+
self._setup_routes()
|
|
53
|
+
|
|
54
|
+
def _setup_routes(self) -> None:
|
|
55
|
+
"""Register webhook endpoints on the FastAPI application."""
|
|
56
|
+
from fastapi import HTTPException, Query, Request, Response, status
|
|
57
|
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
58
|
+
|
|
59
|
+
@self.app.get("/health")
|
|
60
|
+
async def health_check() -> dict[str, str]:
|
|
61
|
+
return {"status": "healthy", "channel": "whatsapp"}
|
|
62
|
+
|
|
63
|
+
@self.app.get("/webhook")
|
|
64
|
+
async def verify_webhook(
|
|
65
|
+
request: Request,
|
|
66
|
+
hub_mode: str | None = Query(None, alias="hub.mode"),
|
|
67
|
+
hub_challenge: str | None = Query(None, alias="hub.challenge"),
|
|
68
|
+
hub_verify_token: str | None = Query(None, alias="hub.verify_token"),
|
|
69
|
+
) -> Response:
|
|
70
|
+
"""Meta webhook verification challenge endpoint."""
|
|
71
|
+
if hub_mode == "subscribe" and hub_verify_token == self.verify_token:
|
|
72
|
+
logger.info("WhatsApp webhook verified successfully.")
|
|
73
|
+
return PlainTextResponse(content=hub_challenge or "")
|
|
74
|
+
raise HTTPException(
|
|
75
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
76
|
+
detail="Verification token mismatch",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@self.app.post("/webhook")
|
|
80
|
+
async def handle_incoming_message(request: Request) -> Response:
|
|
81
|
+
"""Process incoming WhatsApp message events with anti-500 error shield."""
|
|
82
|
+
payload = await request.json()
|
|
83
|
+
extracted = self._extract_message_and_sender(payload)
|
|
84
|
+
|
|
85
|
+
if not extracted:
|
|
86
|
+
# Return 200 to acknowledge status notifications (delivered, read, etc.)
|
|
87
|
+
return JSONResponse(content={"status": "ignored_or_status_update"})
|
|
88
|
+
|
|
89
|
+
sender_phone, user_text = extracted
|
|
90
|
+
|
|
91
|
+
# 1. Handle non-text media (audios, images, stickers, documents)
|
|
92
|
+
if user_text is None:
|
|
93
|
+
if self.access_token and self.phone_number_id:
|
|
94
|
+
await self._send_outbound_whatsapp(
|
|
95
|
+
to_phone=sender_phone, text=self.unsupported_media_message
|
|
96
|
+
)
|
|
97
|
+
return JSONResponse(
|
|
98
|
+
content={
|
|
99
|
+
"status": "unsupported_media_handled",
|
|
100
|
+
"sender": sender_phone,
|
|
101
|
+
"reply": self.unsupported_media_message,
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# 2. Process message through attached runner with anti-500 error shield
|
|
106
|
+
reply_text: str
|
|
107
|
+
active_agent: str
|
|
108
|
+
try:
|
|
109
|
+
response: AgentResponse = await self.dispatch_async(
|
|
110
|
+
session_id=sender_phone,
|
|
111
|
+
message=user_text,
|
|
112
|
+
metadata={"channel": "whatsapp", "phone": sender_phone},
|
|
113
|
+
)
|
|
114
|
+
reply_text = response.content
|
|
115
|
+
active_agent = response.active_agent_name
|
|
116
|
+
except Exception as err:
|
|
117
|
+
logger.error(f"Error dispatching WhatsApp message from {sender_phone}: {err}")
|
|
118
|
+
reply_text = self.fallback_message
|
|
119
|
+
active_agent = "fallback"
|
|
120
|
+
|
|
121
|
+
# If Meta credentials are provided, send outbound reply to WhatsApp API
|
|
122
|
+
if self.access_token and self.phone_number_id:
|
|
123
|
+
await self._send_outbound_whatsapp(
|
|
124
|
+
to_phone=sender_phone, text=reply_text
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Always return HTTP 200 to Meta to avoid retry storms
|
|
128
|
+
return JSONResponse(
|
|
129
|
+
content={
|
|
130
|
+
"status": "success",
|
|
131
|
+
"sender": sender_phone,
|
|
132
|
+
"reply": reply_text,
|
|
133
|
+
"active_agent": active_agent,
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _extract_message_and_sender(
|
|
138
|
+
self, payload: dict[str, Any]
|
|
139
|
+
) -> tuple[str, str | None] | None:
|
|
140
|
+
"""Extract sender phone number and message from payload.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
(phone, text) for text messages.
|
|
144
|
+
(phone, None) for non-text media messages.
|
|
145
|
+
None for status updates or empty payloads.
|
|
146
|
+
"""
|
|
147
|
+
# 1. Standard Meta Cloud API webhook format
|
|
148
|
+
entry = payload.get("entry")
|
|
149
|
+
if isinstance(entry, list) and entry:
|
|
150
|
+
changes = entry[0].get("changes")
|
|
151
|
+
if isinstance(changes, list) and changes:
|
|
152
|
+
value = changes[0].get("value", {})
|
|
153
|
+
messages = value.get("messages")
|
|
154
|
+
if isinstance(messages, list) and messages:
|
|
155
|
+
msg = messages[0]
|
|
156
|
+
sender = msg.get("from")
|
|
157
|
+
if not sender:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
msg_type = msg.get("type", "text")
|
|
161
|
+
if msg_type == "text":
|
|
162
|
+
text = msg.get("text", {}).get("body")
|
|
163
|
+
if text:
|
|
164
|
+
return str(sender), str(text)
|
|
165
|
+
# Non-text media message received
|
|
166
|
+
return str(sender), None
|
|
167
|
+
|
|
168
|
+
# 2. Generic / Evolution API webhook format
|
|
169
|
+
sender = (
|
|
170
|
+
payload.get("phone")
|
|
171
|
+
or payload.get("from")
|
|
172
|
+
or payload.get("sender")
|
|
173
|
+
)
|
|
174
|
+
if sender:
|
|
175
|
+
text = (
|
|
176
|
+
payload.get("message")
|
|
177
|
+
or payload.get("text")
|
|
178
|
+
or payload.get("body")
|
|
179
|
+
)
|
|
180
|
+
if text:
|
|
181
|
+
return str(sender), str(text)
|
|
182
|
+
return str(sender), None
|
|
183
|
+
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
async def _send_outbound_whatsapp(self, to_phone: str, text: str) -> None:
|
|
187
|
+
"""Send message back to user via Meta WhatsApp Cloud API."""
|
|
188
|
+
url = f"https://graph.facebook.com/{self.api_version}/{self.phone_number_id}/messages"
|
|
189
|
+
headers = {
|
|
190
|
+
"Authorization": f"Bearer {self.access_token}",
|
|
191
|
+
"Content-Type": "application/json",
|
|
192
|
+
}
|
|
193
|
+
body = {
|
|
194
|
+
"messaging_product": "whatsapp",
|
|
195
|
+
"to": to_phone,
|
|
196
|
+
"type": "text",
|
|
197
|
+
"text": {"body": text},
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
async with self._httpx.AsyncClient() as client:
|
|
202
|
+
res = await client.post(url, json=body, headers=headers, timeout=10.0)
|
|
203
|
+
if res.status_code >= 400:
|
|
204
|
+
logger.error(
|
|
205
|
+
f"Failed to send outbound WhatsApp message: {res.status_code} - {res.text}"
|
|
206
|
+
)
|
|
207
|
+
except Exception as err:
|
|
208
|
+
logger.error(f"Error calling WhatsApp Cloud API: {err}")
|
|
209
|
+
|
|
210
|
+
def run(self, host: str = "0.0.0.0", port: int = 8000, **kwargs: Any) -> None:
|
|
211
|
+
"""Launch the FastAPI webhook server using uvicorn."""
|
|
212
|
+
self._ensure_runner_attached()
|
|
213
|
+
self._uvicorn.run(self.app, host=host, port=port, **kwargs)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Core modules for chatflow-agent: tools, memory, agents, engine, and runner."""
|
|
2
|
+
|
|
3
|
+
from chatflow_agent.core.agent import Agent
|
|
4
|
+
from chatflow_agent.core.engine import EngineTurnResult, GeminiEngine
|
|
5
|
+
from chatflow_agent.core.memory import SessionContext, SessionStore
|
|
6
|
+
from chatflow_agent.core.runner import Runner
|
|
7
|
+
from chatflow_agent.core.tools import Tool, tool
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Agent",
|
|
11
|
+
"EngineTurnResult",
|
|
12
|
+
"GeminiEngine",
|
|
13
|
+
"Runner",
|
|
14
|
+
"SessionContext",
|
|
15
|
+
"SessionStore",
|
|
16
|
+
"Tool",
|
|
17
|
+
"tool",
|
|
18
|
+
]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Agent abstraction and peer handoff orchestration for chatflow-agent."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from chatflow_agent.core.tools import Tool
|
|
8
|
+
from chatflow_agent.types import Handoff
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _sanitize_tool_name(name: str) -> str:
|
|
12
|
+
"""Convert an agent name into a valid tool identifier (alphanumeric and underscores)."""
|
|
13
|
+
clean = re.sub(r"[^a-zA-Z0-9_]", "_", name.strip().lower())
|
|
14
|
+
clean = re.sub(r"_+", "_", clean).strip("_")
|
|
15
|
+
return f"transfer_to_{clean}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Agent:
|
|
19
|
+
"""Represents an autonomous specialized agent with its own tools and peer handoffs."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
name: str,
|
|
24
|
+
instructions: str | Callable[[], str],
|
|
25
|
+
model: str = "gemini-2.5-flash",
|
|
26
|
+
tools: list[Tool | Callable[..., Any]] | None = None,
|
|
27
|
+
handoffs: list["Agent"] | None = None,
|
|
28
|
+
provider: str | None = None,
|
|
29
|
+
base_url: str | None = None,
|
|
30
|
+
api_key: str | None = None,
|
|
31
|
+
engine: Any | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.name = name
|
|
34
|
+
self.instructions = instructions
|
|
35
|
+
self.model = model
|
|
36
|
+
self.provider = provider
|
|
37
|
+
self.base_url = base_url
|
|
38
|
+
self.api_key = api_key
|
|
39
|
+
self.engine = engine
|
|
40
|
+
self.tools: list[Tool] = []
|
|
41
|
+
self.handoffs: list[Agent] = []
|
|
42
|
+
|
|
43
|
+
if tools:
|
|
44
|
+
for t in tools:
|
|
45
|
+
self.add_tool(t)
|
|
46
|
+
|
|
47
|
+
if handoffs:
|
|
48
|
+
for h in handoffs:
|
|
49
|
+
self.add_handoff(h)
|
|
50
|
+
|
|
51
|
+
def get_instructions(self) -> str:
|
|
52
|
+
"""Resolve and return the system instructions."""
|
|
53
|
+
if callable(self.instructions):
|
|
54
|
+
return self.instructions()
|
|
55
|
+
return self.instructions
|
|
56
|
+
|
|
57
|
+
def add_tool(self, tool_or_callable: Tool | Callable[..., Any]) -> Tool:
|
|
58
|
+
"""Register a tool or callable to this agent."""
|
|
59
|
+
if isinstance(tool_or_callable, Tool):
|
|
60
|
+
t = tool_or_callable
|
|
61
|
+
else:
|
|
62
|
+
t = Tool(tool_or_callable)
|
|
63
|
+
|
|
64
|
+
# Avoid duplicate tools by name
|
|
65
|
+
self.tools = [existing for existing in self.tools if existing.name != t.name]
|
|
66
|
+
self.tools.append(t)
|
|
67
|
+
return t
|
|
68
|
+
|
|
69
|
+
def tool(
|
|
70
|
+
self,
|
|
71
|
+
name_or_func: str | Callable[..., Any] | None = None,
|
|
72
|
+
*,
|
|
73
|
+
name: str | None = None,
|
|
74
|
+
description: str | None = None,
|
|
75
|
+
) -> Any:
|
|
76
|
+
"""Decorator to register a tool directly onto this agent instance.
|
|
77
|
+
|
|
78
|
+
Usage:
|
|
79
|
+
@agent.tool
|
|
80
|
+
def query_db(id: int) -> dict: ...
|
|
81
|
+
|
|
82
|
+
@agent.tool(name="custom_query", description="Fetch db row")
|
|
83
|
+
def query_db(id: int) -> dict: ...
|
|
84
|
+
"""
|
|
85
|
+
if callable(name_or_func):
|
|
86
|
+
t = Tool(name_or_func)
|
|
87
|
+
self.add_tool(t)
|
|
88
|
+
return t
|
|
89
|
+
|
|
90
|
+
def decorator(fn: Callable[..., Any]) -> Tool:
|
|
91
|
+
tool_name = name or (name_or_func if isinstance(name_or_func, str) else None)
|
|
92
|
+
t = Tool(fn, name=tool_name, description=description)
|
|
93
|
+
self.add_tool(t)
|
|
94
|
+
return t
|
|
95
|
+
|
|
96
|
+
return decorator
|
|
97
|
+
|
|
98
|
+
def add_handoff(self, target_agent: "Agent") -> None:
|
|
99
|
+
"""Register a peer agent to which this agent can hand off dialogue."""
|
|
100
|
+
if target_agent.name == self.name:
|
|
101
|
+
raise ValueError(f"Agent '{self.name}' cannot register a handoff to itself.")
|
|
102
|
+
|
|
103
|
+
# Avoid duplicates
|
|
104
|
+
self.handoffs = [h for h in self.handoffs if h.name != target_agent.name]
|
|
105
|
+
self.handoffs.append(target_agent)
|
|
106
|
+
|
|
107
|
+
def _create_handoff_tool(self, target_agent: "Agent") -> Tool:
|
|
108
|
+
"""Generate a synthetic routing tool that transfers control to target_agent."""
|
|
109
|
+
tool_name = _sanitize_tool_name(target_agent.name)
|
|
110
|
+
desc = (
|
|
111
|
+
f"Hand off the dialogue to '{target_agent.name}'. "
|
|
112
|
+
f"Target agent specialty: {target_agent.get_instructions()[:120]}..."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def handoff_executor(reason: str | None = None) -> Handoff:
|
|
116
|
+
"""Transfer the conversation to another specialized agent."""
|
|
117
|
+
return Handoff(
|
|
118
|
+
target_agent_name=target_agent.name,
|
|
119
|
+
reason=reason,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return Tool(func=handoff_executor, name=tool_name, description=desc)
|
|
123
|
+
|
|
124
|
+
def get_all_tools(self) -> list[Tool]:
|
|
125
|
+
"""Return all user tools combined with synthetic handoff tools."""
|
|
126
|
+
combined: list[Tool] = list(self.tools)
|
|
127
|
+
for peer in self.handoffs:
|
|
128
|
+
combined.append(self._create_handoff_tool(peer))
|
|
129
|
+
return combined
|
|
130
|
+
|
|
131
|
+
def __repr__(self) -> str:
|
|
132
|
+
return (
|
|
133
|
+
f"<Agent name={self.name!r} model={self.model!r} "
|
|
134
|
+
f"tools={len(self.tools)} handoffs={len(self.handoffs)}>"
|
|
135
|
+
)
|