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.
@@ -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
+ )
@@ -0,0 +1,5 @@
1
+ """Backward-compatible re-exports for chatflow_agent.core.engine."""
2
+
3
+ from chatflow_agent.core.engines import BaseEngine, EngineTurnResult, GeminiEngine
4
+
5
+ __all__ = ["BaseEngine", "EngineTurnResult", "GeminiEngine"]