flowent 0.1.0 → 0.1.2

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 (51) hide show
  1. package/backend/pyproject.toml +1 -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 +18 -9
  20. package/backend/src/flowent/main.py +81 -17
  21. package/backend/src/flowent/mcp_import.py +28 -13
  22. package/backend/src/flowent/sandbox.py +69 -2
  23. package/backend/src/flowent/static/assets/index-BhHdc2d_.js +81 -0
  24. package/backend/src/flowent/static/assets/index-C89n9qe2.css +2 -0
  25. package/backend/src/flowent/static/index.html +2 -2
  26. package/backend/src/flowent/storage.py +51 -3
  27. package/backend/src/flowent/tools.py +72 -1
  28. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  29. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  30. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  31. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  33. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  34. package/backend/tests/__pycache__/test_mcp.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_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/test_agent_tools.py +103 -2
  40. package/backend/tests/test_mcp.py +32 -20
  41. package/backend/tests/test_workspace_chat.py +269 -1
  42. package/backend/uv.lock +1 -1
  43. package/bin/flowent.mjs +1 -1
  44. package/dist/frontend/assets/index-BhHdc2d_.js +81 -0
  45. package/dist/frontend/assets/index-C89n9qe2.css +2 -0
  46. package/dist/frontend/index.html +2 -2
  47. package/package.json +1 -1
  48. package/backend/src/flowent/static/assets/index-DqTHSMBo.js +0 -81
  49. package/backend/src/flowent/static/assets/index-d3FBbOXX.css +0 -2
  50. package/dist/frontend/assets/index-DqTHSMBo.js +0 -81
  51. package/dist/frontend/assets/index-d3FBbOXX.css +0 -2
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowent"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "A workflow orchestration platform for multi-agent collaboration."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -23,7 +23,7 @@ from flowent.tools import (
23
23
  ToolResult,
24
24
  new_tool_item,
25
25
  parse_tool_arguments,
26
- run_tool,
26
+ run_tool_async,
27
27
  tool_specs,
28
28
  )
29
29
 
@@ -109,6 +109,8 @@ async def run_agent_stream(
109
109
  | None = None,
110
110
  extra_tool_specs: Sequence[Mapping[str, object]] | None = None,
111
111
  extra_tool_title: Callable[[str], str | None] | None = None,
112
+ tool_runner: Callable[[str, dict[str, object], ToolContext], Awaitable[ToolResult]]
113
+ | None = None,
112
114
  web_searcher: Callable[[str], Sequence[dict[str, str]]] | None = None,
113
115
  ) -> AsyncIterator[AgentStreamEvent]:
114
116
  conversation: list[Mapping[str, object]] = [
@@ -244,15 +246,22 @@ async def run_agent_stream(
244
246
  if extra_tool_runner is not None
245
247
  else None
246
248
  )
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),
249
+ result = extra_result if isinstance(extra_result, ToolResult) else None
250
+ if result is None:
251
+ context = ToolContext(cwd=cwd, web_searcher=web_searcher)
252
+ result = await (
253
+ tool_runner(
254
+ tool_call.name,
255
+ arguments,
256
+ context,
257
+ )
258
+ if tool_runner is not None
259
+ else run_tool_async(
260
+ tool_call.name,
261
+ arguments,
262
+ context,
263
+ )
254
264
  )
255
- )
256
265
  result_content = result.content
257
266
  logger.debug(
258
267
  "Tool call finished name=%s id=%s ok=%s",
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import json
2
3
  import logging
3
4
  import os
@@ -105,13 +106,29 @@ class SkillSettingsRequest(BaseModel):
105
106
  class McpImportRequest(BaseModel):
106
107
  model_config = ConfigDict(extra="forbid")
107
108
 
108
- duplicate_action: Literal["replace", "skip"] = "skip"
109
+ server_id: str
110
+ source: Literal["claude_code", "codex"]
111
+
112
+
113
+ class McpImportPreviewRequest(BaseModel):
114
+ model_config = ConfigDict(extra="forbid")
115
+
116
+ source: Literal["claude_code", "codex"]
109
117
 
110
118
 
111
119
  def stream_event(event: str, data: dict[str, object]) -> str:
112
120
  return f"event: {event}\ndata: {json.dumps(data)}\n\n"
113
121
 
114
122
 
123
+ def append_or_replace_message(
124
+ messages: list[StoredMessage], message: StoredMessage
125
+ ) -> list[StoredMessage]:
126
+ return [
127
+ *(current for current in messages if current.id != message.id),
128
+ message,
129
+ ]
130
+
131
+
115
132
  def frontend_static_directory() -> Path:
116
133
  configured_directory = os.environ.get("FLOWENT_STATIC_DIR")
117
134
  if configured_directory:
@@ -305,6 +322,7 @@ def create_app(
305
322
  author="assistant",
306
323
  content=assistant_content,
307
324
  id=assistant_id,
325
+ status="completed",
308
326
  thinking=assistant_thinking,
309
327
  tools=list(assistant_tools.values()),
310
328
  )
@@ -368,19 +386,24 @@ def create_app(
368
386
  saved_server = store.save_mcp_server(server)
369
387
  return await mcp_manager.sync_server(saved_server)
370
388
 
371
- @app.get("/api/mcp/import/preview")
372
- async def preview_mcp_import() -> McpImportDiscovery:
373
- return discover_imported_mcp_servers(Path.cwd())
389
+ @app.post("/api/mcp/import/preview")
390
+ async def preview_mcp_import(
391
+ request: McpImportPreviewRequest,
392
+ ) -> McpImportDiscovery:
393
+ return discover_imported_mcp_servers(Path.cwd(), source=request.source)
374
394
 
375
395
  @app.post("/api/mcp/import")
376
396
  async def import_mcp_servers(request: McpImportRequest) -> list[StoredMcpServer]:
377
- imported_servers = discover_imported_mcp_servers(Path.cwd()).servers
397
+ imported_servers = discover_imported_mcp_servers(
398
+ Path.cwd(),
399
+ source=request.source,
400
+ ).servers
378
401
  existing_servers = {server.id for server in store.read_mcp_servers()}
379
402
  for server in imported_servers:
380
- if request.duplicate_action == "skip" and server.id in existing_servers:
403
+ if server.id != request.server_id:
404
+ continue
405
+ if server.id in existing_servers:
381
406
  continue
382
- if request.duplicate_action == "replace" and server.id in existing_servers:
383
- await mcp_manager.delete_server(server.id)
384
407
  store.save_mcp_server(server)
385
408
  existing_servers.add(server.id)
386
409
  return mcp_manager.servers_with_status(store.read_mcp_servers())
@@ -529,6 +552,30 @@ def create_app(
529
552
 
530
553
  async def response_stream() -> AsyncIterator[str]:
531
554
  assistant_tools: dict[str, StoredToolItem] = {}
555
+ assistant_message = StoredMessage(
556
+ author="assistant",
557
+ content="",
558
+ id=str(uuid4()),
559
+ status="running",
560
+ )
561
+ assistant_content = ""
562
+ assistant_thinking = ""
563
+
564
+ def persist_assistant(status: str = "running") -> None:
565
+ nonlocal next_messages, assistant_message
566
+ assistant_message = StoredMessage(
567
+ author="assistant",
568
+ content=assistant_content,
569
+ id=assistant_message.id,
570
+ status=status,
571
+ thinking=assistant_thinking,
572
+ tools=list(assistant_tools.values()),
573
+ )
574
+ next_messages = append_or_replace_message(
575
+ next_messages, assistant_message
576
+ )
577
+ store.upsert_message(assistant_message)
578
+
532
579
  try:
533
580
  async for event in run_agent_stream(
534
581
  completion=chat_completion,
@@ -539,12 +586,20 @@ def create_app(
539
586
  extra_tool_title=mcp_manager.tool_title,
540
587
  messages=request_messages,
541
588
  ):
589
+ if event.event == "start":
590
+ event_id = event.data.get("id")
591
+ if isinstance(event_id, str):
592
+ assistant_message = assistant_message.model_copy(
593
+ update={"id": event_id}
594
+ )
595
+ persist_assistant()
542
596
  if event.event == "tool_start":
543
597
  tool = event.data.get("tool")
544
598
  if isinstance(tool, dict) and isinstance(tool.get("id"), str):
545
599
  assistant_tools[tool["id"]] = StoredToolItem.model_validate(
546
600
  tool
547
601
  )
602
+ persist_assistant()
548
603
  if event.event in {"tool_done", "tool_error"}:
549
604
  tool_id = event.data.get("id")
550
605
  if isinstance(tool_id, str) and tool_id in assistant_tools:
@@ -556,6 +611,13 @@ def create_app(
556
611
  **event.data,
557
612
  }
558
613
  )
614
+ persist_assistant()
615
+ if event.event == "delta":
616
+ assistant_content += str(event.data.get("content") or "")
617
+ persist_assistant()
618
+ if event.event == "thinking_delta":
619
+ assistant_thinking += str(event.data.get("content") or "")
620
+ persist_assistant()
559
621
  logger.log(
560
622
  TRACE_LEVEL,
561
623
  "Workspace stream event=%s data=%r",
@@ -565,19 +627,21 @@ def create_app(
565
627
  if event.event == "done":
566
628
  message = event.data.get("message")
567
629
  if isinstance(message, dict):
568
- next_messages.append(
569
- StoredMessage(
570
- author="assistant",
571
- content=str(message.get("content") or ""),
572
- id=str(message.get("id") or uuid4()),
573
- thinking=str(message.get("thinking") or ""),
574
- tools=list(assistant_tools.values()),
575
- )
630
+ assistant_content = str(
631
+ message.get("content") or assistant_content
632
+ )
633
+ assistant_thinking = str(
634
+ message.get("thinking") or assistant_thinking
576
635
  )
577
- store.save_messages(next_messages)
636
+ persist_assistant("completed")
578
637
  yield stream_event(event.event, event.data)
638
+ except asyncio.CancelledError:
639
+ logger.info("Workspace response interrupted")
640
+ persist_assistant("interrupted")
641
+ raise
579
642
  except Exception as error:
580
643
  logger.exception("Workspace response failed")
644
+ persist_assistant("failed")
581
645
  yield stream_event(
582
646
  "error",
583
647
  {"message": str(error) or "Message could not be sent."},
@@ -33,21 +33,26 @@ class McpImportDiscovery(BaseModel):
33
33
  def discover_imported_mcp_servers(
34
34
  cwd: Path | None = None,
35
35
  home: Path | None = None,
36
+ source: McpImportSource | None = None,
36
37
  ) -> McpImportDiscovery:
37
38
  workspace = (cwd or Path.cwd()).resolve(strict=False)
38
39
  user_home = (home or Path.home()).resolve(strict=False)
39
40
  sources: list[McpImportSourceResult] = []
40
41
 
41
- for path, source in candidate_mcp_config_files(workspace, user_home):
42
+ for path, config_source in candidate_mcp_config_files(
43
+ workspace,
44
+ user_home,
45
+ source,
46
+ ):
42
47
  if not path.is_file():
43
48
  continue
44
49
  try:
45
- servers = parse_mcp_config_file(path, source, workspace)
50
+ servers = parse_mcp_config_file(path, config_source, workspace)
46
51
  sources.append(
47
52
  McpImportSourceResult(
48
53
  path=str(path.resolve(strict=False)),
49
54
  servers=servers,
50
- source=source,
55
+ source=config_source,
51
56
  )
52
57
  )
53
58
  except Exception as error:
@@ -55,7 +60,7 @@ def discover_imported_mcp_servers(
55
60
  McpImportSourceResult(
56
61
  error=str(error),
57
62
  path=str(path.resolve(strict=False)),
58
- source=source,
63
+ source=config_source,
59
64
  )
60
65
  )
61
66
 
@@ -70,16 +75,26 @@ def discover_imported_mcp_servers(
70
75
  def candidate_mcp_config_files(
71
76
  cwd: Path,
72
77
  home: Path,
78
+ source: McpImportSource | None = None,
73
79
  ) -> list[tuple[Path, McpImportSource]]:
74
- candidates: list[tuple[Path, McpImportSource]] = [
75
- (cwd / ".mcp.json", "claude_code"),
76
- (cwd / ".claude" / "settings.local.json", "claude_code"),
77
- (cwd / ".claude" / "settings.json", "claude_code"),
78
- (home / ".claude.json", "claude_code"),
79
- (home / ".claude" / "settings.json", "claude_code"),
80
- (cwd / ".codex" / "config.toml", "codex"),
81
- (home / ".codex" / "config.toml", "codex"),
82
- ]
80
+ candidates: list[tuple[Path, McpImportSource]] = []
81
+ if source in (None, "claude_code"):
82
+ candidates.extend(
83
+ [
84
+ (cwd / ".mcp.json", "claude_code"),
85
+ (cwd / ".claude" / "settings.local.json", "claude_code"),
86
+ (cwd / ".claude" / "settings.json", "claude_code"),
87
+ (home / ".claude.json", "claude_code"),
88
+ (home / ".claude" / "settings.json", "claude_code"),
89
+ ]
90
+ )
91
+ if source in (None, "codex"):
92
+ candidates.extend(
93
+ [
94
+ (cwd / ".codex" / "config.toml", "codex"),
95
+ (home / ".codex" / "config.toml", "codex"),
96
+ ]
97
+ )
83
98
  seen: set[tuple[Path, McpImportSource]] = set()
84
99
  unique_candidates: list[tuple[Path, McpImportSource]] = []
85
100
  for path, source in candidates:
@@ -1,11 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import ctypes
4
5
  import errno
5
6
  import os
6
7
  import shutil
8
+ import signal
7
9
  import subprocess
8
10
  import tempfile
11
+ from contextlib import suppress
9
12
  from dataclasses import dataclass
10
13
  from pathlib import Path
11
14
  from typing import BinaryIO
@@ -127,7 +130,7 @@ class SandboxRunner:
127
130
  self,
128
131
  *,
129
132
  cwd: Path | None = None,
130
- timeout_seconds: int = 30,
133
+ timeout_seconds: float = 30,
131
134
  output_limit: int = 20000,
132
135
  ) -> None:
133
136
  self.cwd = (cwd or Path.cwd()).resolve(strict=False)
@@ -193,7 +196,7 @@ class SandboxRunner:
193
196
  *,
194
197
  env: dict[str, str] | None = None,
195
198
  input_text: str | None = None,
196
- timeout_seconds: int | None = None,
199
+ timeout_seconds: float | None = None,
197
200
  ) -> CommandResult:
198
201
  sandbox_command = self.build_command(command)
199
202
  pass_fds: tuple[int, ...] = ()
@@ -232,3 +235,67 @@ class SandboxRunner:
232
235
  stderr=completed.stderr[: self.output_limit],
233
236
  stdout=completed.stdout[: self.output_limit],
234
237
  )
238
+
239
+ async def run_async(
240
+ self,
241
+ command: list[str],
242
+ *,
243
+ env: dict[str, str] | None = None,
244
+ input_text: str | None = None,
245
+ timeout_seconds: float | None = None,
246
+ ) -> CommandResult:
247
+ sandbox_command = self.build_command(command)
248
+ pass_fds: tuple[int, ...] = ()
249
+ if sandbox_command.seccomp_file is not None:
250
+ pass_fds = (sandbox_command.seccomp_file.fileno(),)
251
+
252
+ process_env = os.environ.copy()
253
+ if env is not None:
254
+ process_env.update(env)
255
+ process = await asyncio.create_subprocess_exec(
256
+ *sandbox_command.args,
257
+ cwd=self.cwd,
258
+ env=process_env,
259
+ pass_fds=pass_fds,
260
+ start_new_session=True,
261
+ stdin=asyncio.subprocess.PIPE if input_text is not None else None,
262
+ stdout=asyncio.subprocess.PIPE,
263
+ stderr=asyncio.subprocess.PIPE,
264
+ )
265
+ try:
266
+ stdout, stderr = await asyncio.wait_for(
267
+ process.communicate(
268
+ input_text.encode() if input_text is not None else None
269
+ ),
270
+ timeout=timeout_seconds or self.timeout_seconds,
271
+ )
272
+ except TimeoutError as error:
273
+ with suppress(ProcessLookupError):
274
+ os.killpg(process.pid, signal.SIGKILL)
275
+ stdout, stderr = await process.communicate()
276
+ return CommandResult(
277
+ command=" ".join(command),
278
+ exit_code=124,
279
+ stderr=str(error) or "Command timed out.",
280
+ stdout=self._text_output(stdout)[: self.output_limit],
281
+ )
282
+ except asyncio.CancelledError:
283
+ with suppress(ProcessLookupError):
284
+ os.killpg(process.pid, signal.SIGTERM)
285
+ try:
286
+ await asyncio.wait_for(process.wait(), timeout=1)
287
+ except TimeoutError:
288
+ with suppress(ProcessLookupError):
289
+ os.killpg(process.pid, signal.SIGKILL)
290
+ await process.wait()
291
+ raise
292
+ finally:
293
+ if sandbox_command.seccomp_file is not None:
294
+ sandbox_command.seccomp_file.close()
295
+
296
+ return CommandResult(
297
+ command=" ".join(command),
298
+ exit_code=process.returncode or 0,
299
+ stderr=self._text_output(stderr)[: self.output_limit],
300
+ stdout=self._text_output(stdout)[: self.output_limit],
301
+ )