flowent 0.0.12 → 0.1.0

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.
Files changed (54) hide show
  1. package/backend/pyproject.toml +2 -1
  2. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  3. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  4. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/agent.py +28 -7
  20. package/backend/src/flowent/channels.py +296 -0
  21. package/backend/src/flowent/main.py +226 -3
  22. package/backend/src/flowent/mcp.py +484 -0
  23. package/backend/src/flowent/mcp_import.py +202 -0
  24. package/backend/src/flowent/skills.py +157 -0
  25. package/backend/src/flowent/static/assets/index-DqTHSMBo.js +81 -0
  26. package/backend/src/flowent/static/assets/index-d3FBbOXX.css +2 -0
  27. package/backend/src/flowent/static/index.html +2 -2
  28. package/backend/src/flowent/storage.py +419 -0
  29. package/backend/src/flowent/tools.py +34 -7
  30. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  31. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  33. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  34. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/test_agent_tools.py +54 -0
  42. package/backend/tests/test_channels.py +360 -0
  43. package/backend/tests/test_mcp.py +710 -0
  44. package/backend/tests/test_persistence.py +30 -0
  45. package/backend/tests/test_skills.py +462 -0
  46. package/backend/uv.lock +160 -1
  47. package/dist/frontend/assets/index-DqTHSMBo.js +81 -0
  48. package/dist/frontend/assets/index-d3FBbOXX.css +2 -0
  49. package/dist/frontend/index.html +2 -2
  50. package/package.json +1 -1
  51. package/backend/src/flowent/static/assets/index-BwQOML_0.css +0 -2
  52. package/backend/src/flowent/static/assets/index-DXQ_smj0.js +0 -81
  53. package/dist/frontend/assets/index-BwQOML_0.css +0 -2
  54. package/dist/frontend/assets/index-DXQ_smj0.js +0 -81
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowent"
3
- version = "0.0.12"
3
+ version = "0.1.0"
4
4
  description = "A workflow orchestration platform for multi-agent collaboration."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -11,6 +11,7 @@ license = "Apache-2.0"
11
11
  dependencies = [
12
12
  "fastapi[standard]>=0.136.1",
13
13
  "litellm>=1.84.0",
14
+ "mcp>=1.24.0",
14
15
  "uvicorn>=0.46.0",
15
16
  ]
16
17
 
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- from collections.abc import AsyncIterator, Callable, Mapping, Sequence
4
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
5
5
  from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
  from uuid import uuid4
@@ -20,6 +20,7 @@ from flowent.llm import (
20
20
  from flowent.logging import TRACE_LEVEL
21
21
  from flowent.tools import (
22
22
  ToolContext,
23
+ ToolResult,
23
24
  new_tool_item,
24
25
  parse_tool_arguments,
25
26
  run_tool,
@@ -104,6 +105,10 @@ async def run_agent_stream(
104
105
  connection: ProviderConnection,
105
106
  cwd: Path,
106
107
  messages: Sequence[Mapping[str, object]],
108
+ extra_tool_runner: Callable[[str, dict[str, object]], Awaitable[ToolResult | None]]
109
+ | None = None,
110
+ extra_tool_specs: Sequence[Mapping[str, object]] | None = None,
111
+ extra_tool_title: Callable[[str], str | None] | None = None,
107
112
  web_searcher: Callable[[str], Sequence[dict[str, str]]] | None = None,
108
113
  ) -> AsyncIterator[AgentStreamEvent]:
109
114
  conversation: list[Mapping[str, object]] = [
@@ -132,7 +137,10 @@ async def run_agent_stream(
132
137
  pending: dict[int, PendingToolCall] = {}
133
138
 
134
139
  async for chunk in stream_chat_chunks(
135
- connection, conversation, completion=completion, tools=tool_specs()
140
+ connection,
141
+ conversation,
142
+ completion=completion,
143
+ tools=[*tool_specs(), *list(extra_tool_specs or [])],
136
144
  ):
137
145
  reasoning = chunk_delta_reasoning(chunk)
138
146
  if reasoning:
@@ -221,16 +229,29 @@ async def run_agent_stream(
221
229
  },
222
230
  )
223
231
  else:
224
- tool_item = new_tool_item(tool_call.name, arguments)
232
+ tool_item = new_tool_item(
233
+ tool_call.name,
234
+ arguments,
235
+ extra_tool_title(tool_call.name) if extra_tool_title else None,
236
+ )
225
237
  logger.debug(
226
238
  "Tool call started name=%s id=%s", tool_call.name, tool_item["id"]
227
239
  )
228
240
  logger.log(TRACE_LEVEL, "Tool start item=%r", tool_item)
229
241
  yield AgentStreamEvent(event="tool_start", data={"tool": tool_item})
230
- result = run_tool(
231
- tool_call.name,
232
- arguments,
233
- ToolContext(cwd=cwd, web_searcher=web_searcher),
242
+ extra_result = (
243
+ await extra_tool_runner(tool_call.name, arguments)
244
+ if extra_tool_runner is not None
245
+ else None
246
+ )
247
+ result = (
248
+ extra_result
249
+ if isinstance(extra_result, ToolResult)
250
+ else run_tool(
251
+ tool_call.name,
252
+ arguments,
253
+ ToolContext(cwd=cwd, web_searcher=web_searcher),
254
+ )
234
255
  )
235
256
  result_content = result.content
236
257
  logger.debug(
@@ -0,0 +1,296 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import urllib.error
7
+ import urllib.request
8
+ from collections.abc import Awaitable, Callable
9
+ from contextlib import suppress
10
+ from dataclasses import dataclass
11
+ from enum import StrEnum
12
+ from typing import Any, Protocol
13
+
14
+ from flowent.storage import StateStore, StoredTelegramBot, StoredTelegramSession
15
+
16
+ logger = logging.getLogger("flowent.channels")
17
+
18
+ TELEGRAM_MESSAGE_LIMIT = 4096
19
+
20
+
21
+ class ChannelStatus(StrEnum):
22
+ DISABLED = "disabled"
23
+ ERROR = "error"
24
+ RUNNING = "running"
25
+ STARTING = "starting"
26
+
27
+
28
+ class TelegramTransport(Protocol):
29
+ async def get_updates(
30
+ self,
31
+ *,
32
+ offset: int | None,
33
+ timeout: int,
34
+ token: str,
35
+ ) -> list[dict[str, Any]]: ...
36
+
37
+ async def send_message(
38
+ self,
39
+ *,
40
+ chat_id: str,
41
+ text: str,
42
+ token: str,
43
+ ) -> None: ...
44
+
45
+
46
+ class TelegramBotTransport:
47
+ async def get_updates(
48
+ self,
49
+ *,
50
+ offset: int | None,
51
+ timeout: int,
52
+ token: str,
53
+ ) -> list[dict[str, Any]]:
54
+ payload: dict[str, object] = {
55
+ "allowed_updates": ["message"],
56
+ "timeout": timeout,
57
+ }
58
+ if offset is not None:
59
+ payload["offset"] = offset
60
+ response = await asyncio.to_thread(
61
+ self._post,
62
+ token,
63
+ "getUpdates",
64
+ payload,
65
+ )
66
+ result = response.get("result")
67
+ if not isinstance(result, list):
68
+ raise RuntimeError("Updates could not be fetched.")
69
+ return [update for update in result if isinstance(update, dict)]
70
+
71
+ async def send_message(
72
+ self,
73
+ *,
74
+ chat_id: str,
75
+ text: str,
76
+ token: str,
77
+ ) -> None:
78
+ await asyncio.to_thread(
79
+ self._post,
80
+ token,
81
+ "sendMessage",
82
+ {"chat_id": chat_id, "text": text},
83
+ )
84
+
85
+ def _post(
86
+ self,
87
+ token: str,
88
+ method: str,
89
+ payload: dict[str, object],
90
+ ) -> dict[str, Any]:
91
+ request = urllib.request.Request(
92
+ f"https://api.telegram.org/bot{token}/{method}",
93
+ data=json.dumps(payload).encode("utf-8"),
94
+ headers={"Content-Type": "application/json"},
95
+ method="POST",
96
+ )
97
+ try:
98
+ with urllib.request.urlopen(request, timeout=40) as response:
99
+ raw_body = response.read().decode("utf-8")
100
+ except urllib.error.HTTPError as error:
101
+ raw_body = error.read().decode("utf-8")
102
+ try:
103
+ body = json.loads(raw_body)
104
+ except json.JSONDecodeError as decode_error:
105
+ raise RuntimeError("Telegram request failed.") from decode_error
106
+ description = body.get("description")
107
+ raise RuntimeError(
108
+ str(description) if description else "Telegram request failed."
109
+ ) from error
110
+ except urllib.error.URLError as error:
111
+ raise RuntimeError(str(error.reason)) from error
112
+
113
+ body = json.loads(raw_body)
114
+ if not body.get("ok"):
115
+ description = body.get("description")
116
+ raise RuntimeError(
117
+ str(description) if description else "Telegram request failed."
118
+ )
119
+ return body
120
+
121
+
122
+ @dataclass
123
+ class ChannelRuntime:
124
+ error: str = ""
125
+ offset: int | None = None
126
+ status: ChannelStatus = ChannelStatus.DISABLED
127
+ task: asyncio.Task[None] | None = None
128
+
129
+
130
+ class TelegramBotManager:
131
+ def __init__(
132
+ self,
133
+ *,
134
+ message_handler: Callable[[str], Awaitable[str]],
135
+ store: StateStore,
136
+ telegram_transport: TelegramTransport | None = None,
137
+ ) -> None:
138
+ self.message_handler = message_handler
139
+ self.store = store
140
+ self.telegram_transport = telegram_transport or TelegramBotTransport()
141
+ self.runtime = ChannelRuntime()
142
+
143
+ def bot_with_status(self, bot: StoredTelegramBot) -> StoredTelegramBot:
144
+ if not bot.enabled:
145
+ return bot.model_copy(
146
+ update={"status": ChannelStatus.DISABLED, "error": ""}
147
+ )
148
+ if self.runtime.status == ChannelStatus.DISABLED:
149
+ return bot.model_copy(
150
+ update={"status": ChannelStatus.STARTING, "error": ""}
151
+ )
152
+ return bot.model_copy(
153
+ update={"status": self.runtime.status, "error": self.runtime.error}
154
+ )
155
+
156
+ async def start_enabled(self) -> None:
157
+ await self.sync_bot(self.store.read_telegram_bot())
158
+
159
+ async def stop_all(self) -> None:
160
+ if self.runtime.task is not None:
161
+ self.runtime.task.cancel()
162
+ with suppress(asyncio.CancelledError):
163
+ await self.runtime.task
164
+ self.runtime = ChannelRuntime()
165
+
166
+ async def sync_bot(self, bot: StoredTelegramBot) -> None:
167
+ if not bot.enabled:
168
+ if self.runtime.task is not None:
169
+ self.runtime.task.cancel()
170
+ self.runtime.status = ChannelStatus.DISABLED
171
+ self.runtime.error = ""
172
+ return
173
+ if self.runtime.task is not None and not self.runtime.task.done():
174
+ return
175
+ self.runtime.status = ChannelStatus.STARTING
176
+ self.runtime.error = ""
177
+ self.runtime.task = asyncio.create_task(self._run_bot(bot))
178
+
179
+ async def poll_once(self, bot: StoredTelegramBot) -> None:
180
+ if not bot.enabled:
181
+ self.runtime.status = ChannelStatus.DISABLED
182
+ self.runtime.error = ""
183
+ return
184
+
185
+ self.runtime.status = ChannelStatus.STARTING
186
+ self.runtime.error = ""
187
+ try:
188
+ updates = await self.telegram_transport.get_updates(
189
+ offset=self.runtime.offset,
190
+ timeout=30,
191
+ token=bot.bot_token,
192
+ )
193
+ self.runtime.status = ChannelStatus.RUNNING
194
+ for update in updates:
195
+ update_id = update.get("update_id")
196
+ if isinstance(update_id, int):
197
+ self.runtime.offset = max(self.runtime.offset or 0, update_id + 1)
198
+ await self._handle_telegram_update(bot, update)
199
+ except Exception as error:
200
+ self.runtime.status = ChannelStatus.ERROR
201
+ self.runtime.error = str(error) or "Connection failed."
202
+ logger.exception("Telegram polling failed")
203
+
204
+ async def _run_bot(self, bot: StoredTelegramBot) -> None:
205
+ while True:
206
+ await self.poll_once(bot)
207
+ if self.runtime.status == ChannelStatus.ERROR:
208
+ await asyncio.sleep(5)
209
+
210
+ async def _handle_telegram_update(
211
+ self,
212
+ bot: StoredTelegramBot,
213
+ update: dict[str, Any],
214
+ ) -> None:
215
+ message = update.get("message")
216
+ if not isinstance(message, dict):
217
+ return
218
+ text = message.get("text")
219
+ if not isinstance(text, str) or text == "":
220
+ return
221
+ chat = message.get("chat")
222
+ sender = message.get("from")
223
+ chat_id = str(chat.get("id")) if isinstance(chat, dict) else ""
224
+ if not chat_id:
225
+ return
226
+
227
+ session = self._telegram_session(
228
+ chat=chat if isinstance(chat, dict) else {},
229
+ message=text,
230
+ sender=sender if isinstance(sender, dict) else {},
231
+ )
232
+ if not self._is_approved(session.chat_id):
233
+ self.store.save_telegram_session(session)
234
+ await self._send_telegram_reply(
235
+ bot,
236
+ chat_id,
237
+ "Request received. Approve this conversation in Flowent.",
238
+ )
239
+ return
240
+
241
+ self.store.save_telegram_session(
242
+ session.model_copy(update={"status": "approved"})
243
+ )
244
+
245
+ reply = await self.message_handler(text)
246
+ await self._send_telegram_reply(bot, chat_id, reply)
247
+
248
+ def _is_approved(self, chat_id: str) -> bool:
249
+ return any(
250
+ session.chat_id == chat_id and session.status == "approved"
251
+ for session in self.store.read_telegram_bot().sessions
252
+ )
253
+
254
+ def _telegram_session(
255
+ self,
256
+ *,
257
+ chat: dict[str, Any],
258
+ message: str,
259
+ sender: dict[str, Any],
260
+ ) -> StoredTelegramSession:
261
+ first_name = str(sender.get("first_name") or "")
262
+ last_name = str(sender.get("last_name") or "")
263
+ title = str(chat.get("title") or "")
264
+ display_name = title or " ".join(
265
+ part for part in [first_name, last_name] if part
266
+ )
267
+ return StoredTelegramSession(
268
+ chat_id=str(chat.get("id") or ""),
269
+ display_name=display_name,
270
+ recent_message=message,
271
+ status="pending",
272
+ user_id=str(sender.get("id") or ""),
273
+ username=str(sender.get("username") or ""),
274
+ )
275
+
276
+ async def _send_telegram_reply(
277
+ self,
278
+ bot: StoredTelegramBot,
279
+ chat_id: str,
280
+ content: str,
281
+ ) -> None:
282
+ for part in split_telegram_message(content):
283
+ await self.telegram_transport.send_message(
284
+ chat_id=chat_id,
285
+ text=part,
286
+ token=bot.bot_token,
287
+ )
288
+
289
+
290
+ def split_telegram_message(content: str) -> list[str]:
291
+ if content == "":
292
+ return [""]
293
+ return [
294
+ content[index : index + TELEGRAM_MESSAGE_LIMIT]
295
+ for index in range(0, len(content), TELEGRAM_MESSAGE_LIMIT)
296
+ ]