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
@@ -2,6 +2,7 @@ import json
2
2
  import logging
3
3
  import os
4
4
  from collections.abc import AsyncIterator
5
+ from contextlib import asynccontextmanager
5
6
  from pathlib import Path
6
7
  from typing import Literal
7
8
  from uuid import uuid4
@@ -13,6 +14,7 @@ from pydantic import BaseModel, ConfigDict
13
14
 
14
15
  from flowent._version import __version__
15
16
  from flowent.agent import run_agent_stream
17
+ from flowent.channels import TelegramBotManager, TelegramTransport
16
18
  from flowent.context import runtime_context_messages
17
19
  from flowent.llm import (
18
20
  ChatMessage,
@@ -23,13 +25,24 @@ from flowent.llm import (
23
25
  list_provider_models,
24
26
  )
25
27
  from flowent.logging import TRACE_LEVEL, ensure_logging_configured
28
+ from flowent.mcp import McpManager, McpTransport
29
+ from flowent.mcp_import import McpImportDiscovery, discover_imported_mcp_servers
26
30
  from flowent.sandbox import ensure_sandbox_available
31
+ from flowent.skills import (
32
+ discover_skills,
33
+ explicit_skill_messages,
34
+ update_skill_enabled,
35
+ )
27
36
  from flowent.storage import (
28
37
  StateStore,
38
+ StoredMcpServer,
29
39
  StoredMessage,
30
40
  StoredProvider,
31
41
  StoredSettings,
42
+ StoredSkill,
32
43
  StoredState,
44
+ StoredTelegramBot,
45
+ StoredTelegramSession,
33
46
  StoredToolItem,
34
47
  )
35
48
 
@@ -77,6 +90,24 @@ class AboutResponse(BaseModel):
77
90
  version: str
78
91
 
79
92
 
93
+ class TelegramSessionApproveRequest(BaseModel):
94
+ model_config = ConfigDict(extra="forbid")
95
+
96
+ chat_id: str
97
+
98
+
99
+ class SkillSettingsRequest(BaseModel):
100
+ model_config = ConfigDict(extra="forbid")
101
+
102
+ enabled: bool
103
+
104
+
105
+ class McpImportRequest(BaseModel):
106
+ model_config = ConfigDict(extra="forbid")
107
+
108
+ duplicate_action: Literal["replace", "skip"] = "skip"
109
+
110
+
80
111
  def stream_event(event: str, data: dict[str, object]) -> str:
81
112
  return f"event: {event}\ndata: {json.dumps(data)}\n\n"
82
113
 
@@ -192,24 +223,137 @@ def create_app(
192
223
  *,
193
224
  serve_frontend: bool = True,
194
225
  chat_completion: CompletionCallable | None = None,
226
+ mcp_transport: McpTransport | None = None,
227
+ telegram_transport: TelegramTransport | None = None,
195
228
  ) -> FastAPI:
196
229
  ensure_logging_configured()
197
230
  ensure_sandbox_available()
198
231
 
199
- app = FastAPI(title="Flowent")
200
232
  store = StateStore()
233
+ mcp_manager = McpManager(store=store, transport=mcp_transport)
234
+ telegram_bot_manager: TelegramBotManager | None = None
201
235
 
202
236
  static_dir = frontend_static_directory().resolve(strict=False)
203
237
  logger.debug("Flowent app created serve_frontend=%s", serve_frontend)
204
238
  logger.info("Static directory: %s", static_dir)
205
239
 
240
+ async def run_workspace_turn(content: str) -> StoredMessage:
241
+ state = store.read_state()
242
+ connection = selected_connection(state)
243
+ cwd = Path.cwd()
244
+ user_message = StoredMessage(
245
+ author="user",
246
+ content=content,
247
+ id=str(uuid4()),
248
+ )
249
+ next_messages = [*state.messages, user_message]
250
+ store.save_messages(next_messages)
251
+ chat_messages = workspace_chat_messages(
252
+ next_messages,
253
+ store.read_compacted_context(),
254
+ )
255
+ skill_messages = explicit_skill_messages(cwd, store, content)
256
+ request_messages = [
257
+ message.model_dump()
258
+ for message in [
259
+ *runtime_context_messages(cwd),
260
+ *skill_messages,
261
+ *chat_messages,
262
+ ]
263
+ ]
264
+ assistant_content = ""
265
+ assistant_thinking = ""
266
+ assistant_tools: dict[str, StoredToolItem] = {}
267
+ assistant_id = str(uuid4())
268
+
269
+ async for event in run_agent_stream(
270
+ completion=chat_completion,
271
+ connection=connection,
272
+ cwd=cwd,
273
+ extra_tool_runner=mcp_manager.run_tool,
274
+ extra_tool_specs=mcp_manager.tool_specs(),
275
+ extra_tool_title=mcp_manager.tool_title,
276
+ messages=request_messages,
277
+ ):
278
+ if event.event == "delta":
279
+ assistant_content += str(event.data.get("content") or "")
280
+ if event.event == "thinking_delta":
281
+ assistant_thinking += str(event.data.get("content") or "")
282
+ if event.event == "tool_start":
283
+ tool = event.data.get("tool")
284
+ if isinstance(tool, dict) and isinstance(tool.get("id"), str):
285
+ assistant_tools[tool["id"]] = StoredToolItem.model_validate(tool)
286
+ if event.event in {"tool_done", "tool_error"}:
287
+ tool_id = event.data.get("id")
288
+ if isinstance(tool_id, str) and tool_id in assistant_tools:
289
+ assistant_tools[tool_id] = StoredToolItem.model_validate(
290
+ {
291
+ **assistant_tools[tool_id].model_dump(exclude_none=True),
292
+ **event.data,
293
+ }
294
+ )
295
+ if event.event == "done":
296
+ message = event.data.get("message")
297
+ if isinstance(message, dict):
298
+ assistant_id = str(message.get("id") or assistant_id)
299
+ assistant_content = str(message.get("content") or assistant_content)
300
+ assistant_thinking = str(
301
+ message.get("thinking") or assistant_thinking
302
+ )
303
+
304
+ assistant_message = StoredMessage(
305
+ author="assistant",
306
+ content=assistant_content,
307
+ id=assistant_id,
308
+ thinking=assistant_thinking,
309
+ tools=list(assistant_tools.values()),
310
+ )
311
+ store.save_messages([*next_messages, assistant_message])
312
+ return assistant_message
313
+
314
+ async def workspace_reply_text(content: str) -> str:
315
+ return (await run_workspace_turn(content)).content
316
+
317
+ telegram_bot_manager = TelegramBotManager(
318
+ message_handler=workspace_reply_text,
319
+ store=store,
320
+ telegram_transport=telegram_transport,
321
+ )
322
+
323
+ @asynccontextmanager
324
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
325
+ app.state.mcp_manager = mcp_manager
326
+ app.state.telegram_bot_manager = telegram_bot_manager
327
+ await mcp_manager.start_enabled()
328
+ if telegram_bot_manager is not None:
329
+ await telegram_bot_manager.start_enabled()
330
+ try:
331
+ yield
332
+ finally:
333
+ if telegram_bot_manager is not None:
334
+ await telegram_bot_manager.stop_all()
335
+ await mcp_manager.stop_all()
336
+
337
+ app = FastAPI(title="Flowent", lifespan=lifespan)
338
+ app.state.mcp_manager = mcp_manager
339
+ app.state.telegram_bot_manager = telegram_bot_manager
340
+
206
341
  @app.get("/api/health")
207
342
  async def health() -> dict[str, str]:
208
343
  return {"status": "ok"}
209
344
 
210
345
  @app.get("/api/state")
211
346
  async def app_state() -> StoredState:
212
- return store.read_state()
347
+ state = store.read_state()
348
+ update: dict[str, object] = {
349
+ "mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
350
+ "skills": discover_skills(Path.cwd(), store),
351
+ }
352
+ if telegram_bot_manager is not None:
353
+ update["telegram_bot"] = telegram_bot_manager.bot_with_status(
354
+ state.telegram_bot
355
+ )
356
+ return state.model_copy(update=update)
213
357
 
214
358
  @app.get("/api/about")
215
359
  async def about() -> AboutResponse:
@@ -219,6 +363,78 @@ def create_app(
219
363
  async def save_provider(provider: StoredProvider) -> StoredProvider:
220
364
  return store.save_provider(provider)
221
365
 
366
+ @app.put("/api/mcp/servers")
367
+ async def save_mcp_server(server: StoredMcpServer) -> StoredMcpServer:
368
+ saved_server = store.save_mcp_server(server)
369
+ return await mcp_manager.sync_server(saved_server)
370
+
371
+ @app.get("/api/mcp/import/preview")
372
+ async def preview_mcp_import() -> McpImportDiscovery:
373
+ return discover_imported_mcp_servers(Path.cwd())
374
+
375
+ @app.post("/api/mcp/import")
376
+ async def import_mcp_servers(request: McpImportRequest) -> list[StoredMcpServer]:
377
+ imported_servers = discover_imported_mcp_servers(Path.cwd()).servers
378
+ existing_servers = {server.id for server in store.read_mcp_servers()}
379
+ for server in imported_servers:
380
+ if request.duplicate_action == "skip" and server.id in existing_servers:
381
+ continue
382
+ if request.duplicate_action == "replace" and server.id in existing_servers:
383
+ await mcp_manager.delete_server(server.id)
384
+ store.save_mcp_server(server)
385
+ existing_servers.add(server.id)
386
+ return mcp_manager.servers_with_status(store.read_mcp_servers())
387
+
388
+ @app.delete("/api/mcp/servers/{server_id}")
389
+ async def delete_mcp_server(server_id: str) -> dict[str, bool]:
390
+ await mcp_manager.delete_server(server_id)
391
+ return {"ok": True}
392
+
393
+ @app.post("/api/mcp/servers/{server_id}/reconnect")
394
+ async def reconnect_mcp_server(server_id: str) -> StoredMcpServer:
395
+ try:
396
+ return await mcp_manager.reconnect_server(server_id)
397
+ except KeyError as error:
398
+ raise HTTPException(status_code=404, detail="Server not found.") from error
399
+
400
+ @app.post("/api/mcp/reload")
401
+ async def reload_mcp_servers() -> list[StoredMcpServer]:
402
+ return await mcp_manager.reload()
403
+
404
+ @app.post("/api/skills/reload")
405
+ async def reload_skills() -> list[StoredSkill]:
406
+ return discover_skills(Path.cwd(), store)
407
+
408
+ @app.put("/api/skills/{skill_id:path}")
409
+ async def save_skill_settings(
410
+ skill_id: str,
411
+ request: SkillSettingsRequest,
412
+ ) -> StoredSkill:
413
+ try:
414
+ return update_skill_enabled(Path.cwd(), store, skill_id, request.enabled)
415
+ except KeyError as error:
416
+ raise HTTPException(status_code=404, detail="Skill not found.") from error
417
+
418
+ @app.put("/api/telegram-bot")
419
+ async def save_telegram_bot(telegram_bot: StoredTelegramBot) -> StoredTelegramBot:
420
+ saved_bot = store.save_telegram_bot(telegram_bot)
421
+ if telegram_bot_manager is not None:
422
+ await telegram_bot_manager.sync_bot(saved_bot)
423
+ return telegram_bot_manager.bot_with_status(saved_bot)
424
+ return saved_bot
425
+
426
+ @app.post("/api/telegram-bot/approve")
427
+ async def approve_telegram_session(
428
+ request: TelegramSessionApproveRequest,
429
+ ) -> StoredTelegramSession:
430
+ try:
431
+ return store.approve_telegram_session(request.chat_id)
432
+ except KeyError as error:
433
+ raise HTTPException(
434
+ status_code=404,
435
+ detail="Conversation not found.",
436
+ ) from error
437
+
222
438
  @app.post("/api/providers/models")
223
439
  async def provider_models(request: ProviderModelsRequest) -> ProviderModelsResponse:
224
440
  return ProviderModelsResponse(
@@ -304,7 +520,11 @@ def create_app(
304
520
  )
305
521
  request_messages = [
306
522
  message.model_dump()
307
- for message in [*runtime_context_messages(cwd), *chat_messages]
523
+ for message in [
524
+ *runtime_context_messages(cwd),
525
+ *explicit_skill_messages(cwd, store, request.content),
526
+ *chat_messages,
527
+ ]
308
528
  ]
309
529
 
310
530
  async def response_stream() -> AsyncIterator[str]:
@@ -314,6 +534,9 @@ def create_app(
314
534
  completion=chat_completion,
315
535
  connection=connection,
316
536
  cwd=cwd,
537
+ extra_tool_runner=mcp_manager.run_tool,
538
+ extra_tool_specs=mcp_manager.tool_specs(),
539
+ extra_tool_title=mcp_manager.tool_title,
317
540
  messages=request_messages,
318
541
  ):
319
542
  if event.event == "tool_start":