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.
- package/backend/pyproject.toml +2 -1
- package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
- package/backend/src/flowent/agent.py +28 -7
- package/backend/src/flowent/channels.py +296 -0
- package/backend/src/flowent/main.py +226 -3
- package/backend/src/flowent/mcp.py +484 -0
- package/backend/src/flowent/mcp_import.py +202 -0
- package/backend/src/flowent/skills.py +157 -0
- package/backend/src/flowent/static/assets/index-DqTHSMBo.js +81 -0
- package/backend/src/flowent/static/assets/index-d3FBbOXX.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +419 -0
- package/backend/src/flowent/tools.py +34 -7
- package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/test_agent_tools.py +54 -0
- package/backend/tests/test_channels.py +360 -0
- package/backend/tests/test_mcp.py +710 -0
- package/backend/tests/test_persistence.py +30 -0
- package/backend/tests/test_skills.py +462 -0
- package/backend/uv.lock +160 -1
- package/dist/frontend/assets/index-DqTHSMBo.js +81 -0
- package/dist/frontend/assets/index-d3FBbOXX.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-BwQOML_0.css +0 -2
- package/backend/src/flowent/static/assets/index-DXQ_smj0.js +0 -81
- package/dist/frontend/assets/index-BwQOML_0.css +0 -2
- package/dist/frontend/assets/index-DXQ_smj0.js +0 -81
package/backend/pyproject.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "flowent"
|
|
3
|
-
version = "0.0
|
|
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
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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,
|
|
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(
|
|
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
|
-
|
|
231
|
-
tool_call.name,
|
|
232
|
-
|
|
233
|
-
|
|
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
|
+
]
|