flowent 0.0.11 → 0.0.13

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 (53) hide show
  1. package/README.md +14 -0
  2. package/backend/README.md +14 -0
  3. package/backend/pyproject.toml +1 -1
  4. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/main.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__/storage.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/agent.py +15 -0
  19. package/backend/src/flowent/channels.py +296 -0
  20. package/backend/src/flowent/cli.py +11 -0
  21. package/backend/src/flowent/llm.py +49 -1
  22. package/backend/src/flowent/main.py +143 -2
  23. package/backend/src/flowent/sandbox.py +18 -3
  24. package/backend/src/flowent/static/assets/index-CEZrWoDG.css +2 -0
  25. package/backend/src/flowent/static/assets/index-S5a0Rkj1.js +81 -0
  26. package/backend/src/flowent/static/index.html +2 -2
  27. package/backend/src/flowent/storage.py +217 -8
  28. package/backend/src/flowent/tools.py +28 -5
  29. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  30. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  31. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  33. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  34. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/conftest.py +21 -0
  39. package/backend/tests/test_agent_tools.py +99 -0
  40. package/backend/tests/test_channels.py +360 -0
  41. package/backend/tests/test_llm_providers.py +58 -0
  42. package/backend/tests/test_persistence.py +46 -0
  43. package/backend/tests/test_startup_requirements.py +48 -0
  44. package/backend/tests/test_workspace_chat.py +28 -0
  45. package/backend/uv.lock +1 -1
  46. package/dist/frontend/assets/index-CEZrWoDG.css +2 -0
  47. package/dist/frontend/assets/index-S5a0Rkj1.js +81 -0
  48. package/dist/frontend/index.html +2 -2
  49. package/package.json +1 -1
  50. package/backend/src/flowent/static/assets/index-C76K95ty.js +0 -81
  51. package/backend/src/flowent/static/assets/index-iUMNKvlU.css +0 -2
  52. package/dist/frontend/assets/index-C76K95ty.js +0 -81
  53. package/dist/frontend/assets/index-iUMNKvlU.css +0 -2
package/README.md CHANGED
@@ -17,6 +17,13 @@ A workflow orchestration platform for multi-agent collaboration.
17
17
 
18
18
  ## Install
19
19
 
20
+ Flowent requires Bubblewrap for local tool isolation. Install the system package
21
+ first:
22
+
23
+ ```bash
24
+ sudo apt-get install bubblewrap
25
+ ```
26
+
20
27
  Install the CLI globally:
21
28
 
22
29
  ```bash
@@ -35,6 +42,12 @@ Start the server:
35
42
  flowent
36
43
  ```
37
44
 
45
+ Check system requirements:
46
+
47
+ ```bash
48
+ flowent doctor
49
+ ```
50
+
38
51
  ## Docker Compose
39
52
 
40
53
  Run the server with Docker Compose:
@@ -59,6 +72,7 @@ docker compose up
59
72
  Install dependencies and start the local development server:
60
73
 
61
74
  ```bash
75
+ sudo apt-get install bubblewrap ripgrep
62
76
  pnpm install
63
77
  uv sync --project backend
64
78
  pnpm dev
package/backend/README.md CHANGED
@@ -17,6 +17,13 @@ A workflow orchestration platform for multi-agent collaboration.
17
17
 
18
18
  ## Install
19
19
 
20
+ Flowent requires Bubblewrap for local tool isolation. Install the system package
21
+ first:
22
+
23
+ ```bash
24
+ sudo apt-get install bubblewrap
25
+ ```
26
+
20
27
  Install the CLI globally:
21
28
 
22
29
  ```bash
@@ -35,6 +42,12 @@ Start the server:
35
42
  flowent
36
43
  ```
37
44
 
45
+ Check system requirements:
46
+
47
+ ```bash
48
+ flowent doctor
49
+ ```
50
+
38
51
  ## Docker Compose
39
52
 
40
53
  Run the server with Docker Compose:
@@ -59,6 +72,7 @@ docker compose up
59
72
  Install dependencies and start the local development server:
60
73
 
61
74
  ```bash
75
+ sudo apt-get install bubblewrap ripgrep
62
76
  pnpm install
63
77
  uv sync --project backend
64
78
  pnpm dev
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowent"
3
- version = "0.0.11"
3
+ version = "0.0.13"
4
4
  description = "A workflow orchestration platform for multi-agent collaboration."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -13,6 +13,7 @@ from flowent.llm import (
13
13
  ProviderConnection,
14
14
  ToolCallDelta,
15
15
  chunk_delta_content,
16
+ chunk_delta_reasoning,
16
17
  chunk_delta_tool_calls,
17
18
  stream_chat_chunks,
18
19
  )
@@ -120,6 +121,7 @@ async def run_agent_stream(
120
121
  yield AgentStreamEvent(event="start", data={"id": assistant_id})
121
122
 
122
123
  final_content = ""
124
+ final_thinking = ""
123
125
 
124
126
  round_number = 0
125
127
  while True:
@@ -132,6 +134,18 @@ async def run_agent_stream(
132
134
  async for chunk in stream_chat_chunks(
133
135
  connection, conversation, completion=completion, tools=tool_specs()
134
136
  ):
137
+ reasoning = chunk_delta_reasoning(chunk)
138
+ if reasoning:
139
+ final_thinking += reasoning
140
+ logger.log(
141
+ TRACE_LEVEL,
142
+ "Agent stream reasoning id=%s content=%r",
143
+ assistant_id,
144
+ reasoning,
145
+ )
146
+ yield AgentStreamEvent(
147
+ event="thinking_delta", data={"content": reasoning}
148
+ )
135
149
  content = chunk_delta_content(chunk)
136
150
  if content:
137
151
  round_content += content
@@ -172,6 +186,7 @@ async def run_agent_stream(
172
186
  "author": "assistant",
173
187
  "content": final_content,
174
188
  "id": assistant_id,
189
+ "thinking": final_thinking,
175
190
  }
176
191
  },
177
192
  )
@@ -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
+ ]
@@ -14,6 +14,7 @@ def main(argv: list[str] | None = None) -> None:
14
14
  subparsers = parser.add_subparsers(dest="command")
15
15
  apply_patch_parser = subparsers.add_parser("apply-patch", help=argparse.SUPPRESS)
16
16
  apply_patch_parser.add_argument("--cwd", required=True)
17
+ subparsers.add_parser("doctor", help="Check system requirements")
17
18
  parser.add_argument(
18
19
  "--host",
19
20
  "--hostname",
@@ -47,6 +48,16 @@ def main(argv: list[str] | None = None) -> None:
47
48
  run_apply_patch_cli(cwd=Path(args.cwd), patch=sys.stdin.read())
48
49
  )
49
50
 
51
+ if args.command == "doctor":
52
+ from flowent.sandbox import SANDBOX_INSTALL_HINT, sandbox_binary
53
+
54
+ bwrap = sandbox_binary()
55
+ if bwrap:
56
+ print(f"Sandbox: {bwrap}")
57
+ raise SystemExit(0)
58
+ print(f"Sandbox: missing. {SANDBOX_INSTALL_HINT}", file=sys.stderr)
59
+ raise SystemExit(1)
60
+
50
61
  if args.version:
51
62
  try:
52
63
  from importlib.metadata import version
@@ -15,6 +15,14 @@ class ProviderFormat(StrEnum):
15
15
  GEMINI = "gemini"
16
16
 
17
17
 
18
+ class ReasoningEffort(StrEnum):
19
+ DEFAULT = "default"
20
+ LOW = "low"
21
+ MEDIUM = "medium"
22
+ HIGH = "high"
23
+ XHIGH = "xhigh"
24
+
25
+
18
26
  class ProviderConnection(BaseModel):
19
27
  model_config = ConfigDict(extra="forbid")
20
28
 
@@ -23,6 +31,7 @@ class ProviderConnection(BaseModel):
23
31
  model: str = Field(min_length=1)
24
32
  secret_reference: str = Field(min_length=1)
25
33
  base_url: str | None = None
34
+ reasoning_effort: ReasoningEffort = ReasoningEffort.DEFAULT
26
35
 
27
36
 
28
37
  class ChatMessage(BaseModel):
@@ -132,14 +141,17 @@ def build_litellm_request(
132
141
  request["stream"] = True
133
142
  if connection.base_url:
134
143
  request["api_base"] = connection.base_url
144
+ if connection.reasoning_effort != ReasoningEffort.DEFAULT:
145
+ request["reasoning_effort"] = connection.reasoning_effort.value
135
146
  logger.log(
136
147
  TRACE_LEVEL,
137
- "Built LiteLLM request provider=%s model=%s base_url=%s stream=%s tools=%s messages=%r",
148
+ "Built LiteLLM request provider=%s model=%s base_url=%s stream=%s tools=%s reasoning_effort=%s messages=%r",
138
149
  connection.provider,
139
150
  connection.model,
140
151
  connection.base_url or "",
141
152
  stream,
142
153
  bool(tools),
154
+ connection.reasoning_effort,
143
155
  request_messages,
144
156
  )
145
157
  return request
@@ -188,6 +200,42 @@ def chunk_delta_content(chunk: Any) -> str:
188
200
  return content if isinstance(content, str) else ""
189
201
 
190
202
 
203
+ def chunk_delta_reasoning(chunk: Any) -> str:
204
+ try:
205
+ choice = chunk.choices[0]
206
+ delta = choice.delta
207
+ except (AttributeError, IndexError, TypeError):
208
+ try:
209
+ delta = chunk["choices"][0]["delta"]
210
+ except (KeyError, IndexError, TypeError):
211
+ return ""
212
+
213
+ content = value_at(delta, "reasoning_content", "")
214
+ if isinstance(content, str) and content:
215
+ return content
216
+
217
+ return reasoning_text_from_items(
218
+ [
219
+ *list(value_at(delta, "thinking_blocks", []) or []),
220
+ *list(value_at(delta, "reasoning_items", []) or []),
221
+ ]
222
+ )
223
+
224
+
225
+ def reasoning_text_from_items(items: Sequence[Any]) -> str:
226
+ parts: list[str] = []
227
+ for item in items:
228
+ for key in ["thinking", "text", "content", "summary"]:
229
+ value = value_at(item, key, "")
230
+ if isinstance(value, str) and value:
231
+ parts.append(value)
232
+ elif isinstance(value, Sequence) and not isinstance(
233
+ value, str | bytes | bytearray
234
+ ):
235
+ parts.append(reasoning_text_from_items(value))
236
+ return "".join(parts)
237
+
238
+
191
239
  def chunk_delta_tool_calls(chunk: Any) -> list[ToolCallDelta]:
192
240
  try:
193
241
  choice = chunk.choices[0]