flowent 0.1.2 → 0.1.3

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__/permissions.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/agent.py +1 -0
  21. package/backend/src/flowent/main.py +297 -55
  22. package/backend/src/flowent/permissions.py +259 -0
  23. package/backend/src/flowent/sandbox.py +14 -4
  24. package/backend/src/flowent/static/assets/index-DjF2KBwE.js +81 -0
  25. package/backend/src/flowent/static/assets/index-P-bBpJG8.css +2 -0
  26. package/backend/src/flowent/static/index.html +2 -2
  27. package/backend/src/flowent/storage.py +64 -0
  28. package/backend/src/flowent/tools.py +24 -1
  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_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_permissions.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_permissions.py +443 -0
  42. package/backend/tests/test_workspace_chat.py +127 -0
  43. package/backend/uv.lock +1 -1
  44. package/dist/frontend/assets/index-DjF2KBwE.js +81 -0
  45. package/dist/frontend/assets/index-P-bBpJG8.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-BhHdc2d_.js +0 -81
  49. package/backend/src/flowent/static/assets/index-C89n9qe2.css +0 -2
  50. package/dist/frontend/assets/index-BhHdc2d_.js +0 -81
  51. package/dist/frontend/assets/index-C89n9qe2.css +0 -2
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowent"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  description = "A workflow orchestration platform for multi-agent collaboration."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -39,6 +39,7 @@ Use tools deliberately:
39
39
  - Search files when you need to find definitions, references, or related behavior.
40
40
  - Apply structured patches for file edits.
41
41
  - Run shell commands for diagnostics, builds, tests, and operations that require the local environment.
42
+ - When a shell command needs to write outside the current workspace, declare each needed writable directory with sandbox_permissions set to with_additional_permissions and additional_permissions.file_system.write.
42
43
  - Search the web only when current external information is needed.
43
44
  - Update the plan when a task has multiple meaningful steps.
44
45
 
@@ -4,11 +4,12 @@ import logging
4
4
  import os
5
5
  from collections.abc import AsyncIterator
6
6
  from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass, field
7
8
  from pathlib import Path
8
9
  from typing import Literal
9
10
  from uuid import uuid4
10
11
 
11
- from fastapi import FastAPI, HTTPException
12
+ from fastapi import FastAPI, HTTPException, Query
12
13
  from fastapi.responses import FileResponse, StreamingResponse
13
14
  from fastapi.staticfiles import StaticFiles
14
15
  from pydantic import BaseModel, ConfigDict
@@ -28,6 +29,7 @@ from flowent.llm import (
28
29
  from flowent.logging import TRACE_LEVEL, ensure_logging_configured
29
30
  from flowent.mcp import McpManager, McpTransport
30
31
  from flowent.mcp_import import McpImportDiscovery, discover_imported_mcp_servers
32
+ from flowent.permissions import WritablePathDecision, run_tool_with_path_permissions
31
33
  from flowent.sandbox import ensure_sandbox_available
32
34
  from flowent.skills import (
33
35
  discover_skills,
@@ -45,7 +47,9 @@ from flowent.storage import (
45
47
  StoredTelegramBot,
46
48
  StoredTelegramSession,
47
49
  StoredToolItem,
50
+ StoredWritablePath,
48
51
  )
52
+ from flowent.tools import ToolContext
49
53
 
50
54
  logger = logging.getLogger("flowent.main")
51
55
 
@@ -79,6 +83,12 @@ class WorkspaceRespondRequest(BaseModel):
79
83
  content: str
80
84
 
81
85
 
86
+ class WorkspaceRunResponse(BaseModel):
87
+ model_config = ConfigDict(extra="forbid")
88
+
89
+ run_id: str
90
+
91
+
82
92
  class WorkspaceCompactResponse(BaseModel):
83
93
  model_config = ConfigDict(extra="forbid")
84
94
 
@@ -116,6 +126,48 @@ class McpImportPreviewRequest(BaseModel):
116
126
  source: Literal["claude_code", "codex"]
117
127
 
118
128
 
129
+ class WritablePathRequest(BaseModel):
130
+ model_config = ConfigDict(extra="forbid")
131
+
132
+ path: str
133
+
134
+
135
+ class WritablePathListResponse(BaseModel):
136
+ model_config = ConfigDict(extra="forbid")
137
+
138
+ writable_paths: list[StoredWritablePath]
139
+
140
+
141
+ class WorkspacePermissionDecisionRequest(BaseModel):
142
+ model_config = ConfigDict(extra="forbid")
143
+
144
+ decision: Literal["allow_once", "always_allow", "deny"]
145
+ id: str
146
+
147
+
148
+ @dataclass
149
+ class PendingWorkspacePermission:
150
+ future: asyncio.Future[WritablePathDecision]
151
+ path: Path
152
+
153
+
154
+ @dataclass
155
+ class WorkspaceRun:
156
+ condition: asyncio.Condition
157
+ discard_on_cancel: bool = False
158
+ events: list[tuple[int, str, dict[str, object]]] = field(default_factory=list)
159
+ id: str = field(default_factory=lambda: str(uuid4()))
160
+ is_done: bool = False
161
+ pending_permissions: dict[str, PendingWorkspacePermission] = field(
162
+ default_factory=dict
163
+ )
164
+ task: asyncio.Task[None] | None = None
165
+
166
+ @property
167
+ def latest_event_index(self) -> int:
168
+ return self.events[-1][0] if self.events else 0
169
+
170
+
119
171
  def stream_event(event: str, data: dict[str, object]) -> str:
120
172
  return f"event: {event}\ndata: {json.dumps(data)}\n\n"
121
173
 
@@ -210,6 +262,13 @@ def workspace_chat_messages(
210
262
  return chat_messages
211
263
 
212
264
 
265
+ def normalized_request_path(path: str, cwd: Path) -> Path:
266
+ raw_path = Path(path).expanduser()
267
+ if not raw_path.is_absolute():
268
+ raw_path = cwd / raw_path
269
+ return raw_path.resolve(strict=False)
270
+
271
+
213
272
  def compact_prompt_messages(
214
273
  messages: list[StoredMessage],
215
274
  compacted_context: str,
@@ -249,6 +308,8 @@ def create_app(
249
308
  store = StateStore()
250
309
  mcp_manager = McpManager(store=store, transport=mcp_transport)
251
310
  telegram_bot_manager: TelegramBotManager | None = None
311
+ workspace_runs: dict[str, WorkspaceRun] = {}
312
+ active_workspace_run_id: str | None = None
252
313
 
253
314
  static_dir = frontend_static_directory().resolve(strict=False)
254
315
  logger.debug("Flowent app created serve_frontend=%s", serve_frontend)
@@ -363,7 +424,18 @@ def create_app(
363
424
  @app.get("/api/state")
364
425
  async def app_state() -> StoredState:
365
426
  state = store.read_state()
427
+ active_run = (
428
+ workspace_runs.get(active_workspace_run_id)
429
+ if active_workspace_run_id
430
+ else None
431
+ )
366
432
  update: dict[str, object] = {
433
+ "active_run_event_index": active_run.latest_event_index
434
+ if active_run
435
+ else 0,
436
+ "active_run_id": active_run.id
437
+ if active_run and not active_run.is_done
438
+ else None,
367
439
  "mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
368
440
  "skills": discover_skills(Path.cwd(), store),
369
441
  }
@@ -472,67 +544,77 @@ def create_app(
472
544
  async def save_settings(settings: StoredSettings) -> StoredSettings:
473
545
  return store.save_settings(settings)
474
546
 
547
+ @app.post("/api/permissions/writable-paths")
548
+ async def save_writable_path(
549
+ request: WritablePathRequest,
550
+ ) -> StoredWritablePath:
551
+ return store.save_writable_path(
552
+ normalized_request_path(request.path, Path.cwd())
553
+ )
554
+
555
+ @app.delete("/api/permissions/writable-paths")
556
+ async def delete_writable_path(
557
+ request: WritablePathRequest,
558
+ ) -> WritablePathListResponse:
559
+ return WritablePathListResponse(
560
+ writable_paths=store.delete_writable_path(
561
+ normalized_request_path(request.path, Path.cwd())
562
+ )
563
+ )
564
+
565
+ @app.post("/api/workspace/permissions/approve")
566
+ async def approve_workspace_permission(
567
+ request: WorkspacePermissionDecisionRequest,
568
+ ) -> dict[str, bool]:
569
+ run = active_workspace_run()
570
+ if run is None:
571
+ raise HTTPException(status_code=404, detail="Request not found.")
572
+ pending = run.pending_permissions.pop(request.id, None)
573
+ if pending is None:
574
+ raise HTTPException(status_code=404, detail="Request not found.")
575
+ path = pending.path
576
+ if request.decision == "always_allow":
577
+ saved_path = store.save_writable_path(path)
578
+ path = Path(saved_path.path)
579
+ pending.future.set_result(
580
+ WritablePathDecision(decision=request.decision, path=path)
581
+ )
582
+ return {"ok": True}
583
+
475
584
  @app.put("/api/workspace/messages")
476
585
  async def save_workspace_messages(
477
586
  request: WorkspaceMessagesRequest,
478
587
  ) -> WorkspaceMessagesRequest:
588
+ nonlocal active_workspace_run_id
589
+ if not request.messages:
590
+ run = active_workspace_run()
591
+ if run is not None and run.task is not None and not run.task.done():
592
+ run.discard_on_cancel = True
593
+ run.task.cancel()
594
+ active_workspace_run_id = None
479
595
  return WorkspaceMessagesRequest(messages=store.save_messages(request.messages))
480
596
 
481
- @app.post("/api/workspace/compact")
482
- async def compact_workspace() -> WorkspaceCompactResponse:
483
- logger.info("Workspace compact requested")
484
- state = store.read_state()
485
- connection = selected_connection(state)
486
- compacted_context = store.read_compacted_context()
487
- cwd = Path.cwd()
488
-
489
- try:
490
- summary = await complete_chat(
491
- connection,
492
- compact_prompt_messages(
493
- state.messages,
494
- compacted_context,
495
- runtime_context_messages(cwd),
496
- ),
497
- completion=chat_completion,
498
- )
499
- except HTTPException:
500
- raise
501
- except Exception as error:
502
- logger.exception("Workspace compact failed")
503
- raise HTTPException(
504
- status_code=500,
505
- detail="Context could not be compacted.",
506
- ) from error
597
+ async def append_run_event(
598
+ run: WorkspaceRun, event: str, data: dict[str, object]
599
+ ) -> None:
600
+ async with run.condition:
601
+ run.events.append((run.latest_event_index + 1, event, data))
602
+ run.condition.notify_all()
507
603
 
508
- marker = StoredMessage(
509
- author="system",
510
- content=COMPACTED_CONTEXT_MARKER,
511
- id=str(uuid4()),
512
- )
513
- store.save_compacted_context(summary.content)
514
- store.save_messages([*state.messages, marker])
515
- logger.info(
516
- "Workspace compact completed summary_length=%s", len(summary.content)
517
- )
518
- logger.log(TRACE_LEVEL, "Workspace compact summary=%r", summary.content)
519
- return WorkspaceCompactResponse(message=marker)
604
+ def active_workspace_run() -> WorkspaceRun | None:
605
+ if active_workspace_run_id is None:
606
+ return None
607
+ return workspace_runs.get(active_workspace_run_id)
520
608
 
521
- @app.post("/api/workspace/respond")
522
- async def respond_to_workspace(
523
- request: WorkspaceRespondRequest,
524
- ) -> StreamingResponse:
525
- logger.info(
526
- "Workspace response requested content_length=%s", len(request.content)
527
- )
528
- logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
609
+ def create_workspace_run(content: str) -> WorkspaceRun:
610
+ nonlocal active_workspace_run_id
529
611
  state = store.read_state()
530
612
  connection = selected_connection(state)
531
613
  cwd = Path.cwd()
532
614
 
533
615
  user_message = StoredMessage(
534
616
  author="user",
535
- content=request.content,
617
+ content=content,
536
618
  id=str(uuid4()),
537
619
  )
538
620
  next_messages = [*state.messages, user_message]
@@ -545,12 +627,16 @@ def create_app(
545
627
  message.model_dump()
546
628
  for message in [
547
629
  *runtime_context_messages(cwd),
548
- *explicit_skill_messages(cwd, store, request.content),
630
+ *explicit_skill_messages(cwd, store, content),
549
631
  *chat_messages,
550
632
  ]
551
633
  ]
634
+ run = WorkspaceRun(condition=asyncio.Condition())
635
+ workspace_runs[run.id] = run
636
+ active_workspace_run_id = run.id
552
637
 
553
- async def response_stream() -> AsyncIterator[str]:
638
+ async def run_task() -> None:
639
+ nonlocal active_workspace_run_id
554
640
  assistant_tools: dict[str, StoredToolItem] = {}
555
641
  assistant_message = StoredMessage(
556
642
  author="assistant",
@@ -577,6 +663,42 @@ def create_app(
577
663
  store.upsert_message(assistant_message)
578
664
 
579
665
  try:
666
+
667
+ async def request_writable_path(
668
+ path: Path, reason: str
669
+ ) -> WritablePathDecision:
670
+ permission_id = str(uuid4())
671
+ future = asyncio.get_running_loop().create_future()
672
+ run.pending_permissions[permission_id] = PendingWorkspacePermission(
673
+ future=future,
674
+ path=path,
675
+ )
676
+ await append_run_event(
677
+ run,
678
+ "permission_request",
679
+ {
680
+ "id": permission_id,
681
+ "path": str(path),
682
+ "reason": reason,
683
+ },
684
+ )
685
+ return await future
686
+
687
+ async def tool_runner(
688
+ name: str,
689
+ arguments: dict[str, object],
690
+ context: ToolContext,
691
+ ):
692
+ return await run_tool_with_path_permissions(
693
+ name,
694
+ arguments,
695
+ context,
696
+ request_writable_path=request_writable_path,
697
+ writable_paths=[
698
+ Path(path.path) for path in store.read_writable_paths()
699
+ ],
700
+ )
701
+
580
702
  async for event in run_agent_stream(
581
703
  completion=chat_completion,
582
704
  connection=connection,
@@ -585,6 +707,7 @@ def create_app(
585
707
  extra_tool_specs=mcp_manager.tool_specs(),
586
708
  extra_tool_title=mcp_manager.tool_title,
587
709
  messages=request_messages,
710
+ tool_runner=tool_runner,
588
711
  ):
589
712
  if event.event == "start":
590
713
  event_id = event.data.get("id")
@@ -634,22 +757,141 @@ def create_app(
634
757
  message.get("thinking") or assistant_thinking
635
758
  )
636
759
  persist_assistant("completed")
637
- yield stream_event(event.event, event.data)
760
+ await append_run_event(run, event.event, event.data)
638
761
  except asyncio.CancelledError:
639
- logger.info("Workspace response interrupted")
640
- persist_assistant("interrupted")
762
+ logger.info("Workspace run stopped")
763
+ if not run.discard_on_cancel:
764
+ persist_assistant("interrupted")
765
+ await append_run_event(
766
+ run,
767
+ "error",
768
+ {"message": "Response stopped."},
769
+ )
641
770
  raise
642
771
  except Exception as error:
643
772
  logger.exception("Workspace response failed")
644
773
  persist_assistant("failed")
645
- yield stream_event(
774
+ await append_run_event(
775
+ run,
646
776
  "error",
647
777
  {"message": str(error) or "Message could not be sent."},
648
778
  )
779
+ finally:
780
+ run.is_done = True
781
+ async with run.condition:
782
+ run.condition.notify_all()
783
+ if active_workspace_run_id == run.id:
784
+ active_workspace_run_id = None
785
+
786
+ run.task = asyncio.create_task(run_task())
787
+ return run
788
+
789
+ async def workspace_run_stream(
790
+ run: WorkspaceRun, after: int = 0
791
+ ) -> AsyncIterator[str]:
792
+ next_event_index = after + 1
793
+ while True:
794
+ async with run.condition:
795
+
796
+ def has_next_event(index: int = next_event_index) -> bool:
797
+ return run.is_done or any(
798
+ event_index >= index for event_index, _, _ in run.events
799
+ )
800
+
801
+ await run.condition.wait_for(has_next_event)
802
+ events = [event for event in run.events if event[0] >= next_event_index]
803
+
804
+ for index, event, data in events:
805
+ next_event_index = index + 1
806
+ yield stream_event(event, data)
807
+ if event in {"done", "error"}:
808
+ return
809
+
810
+ if run.is_done and not events:
649
811
  return
650
812
 
813
+ @app.post("/api/workspace/runs")
814
+ async def start_workspace_run(
815
+ request: WorkspaceRespondRequest,
816
+ ) -> WorkspaceRunResponse:
817
+ logger.info("Workspace run requested content_length=%s", len(request.content))
818
+ logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
819
+ run = create_workspace_run(request.content)
820
+ return WorkspaceRunResponse(run_id=run.id)
821
+
822
+ @app.get("/api/workspace/runs/{run_id}/stream")
823
+ async def stream_workspace_run(
824
+ run_id: str,
825
+ after: int = Query(default=0, ge=0),
826
+ ) -> StreamingResponse:
827
+ run = workspace_runs.get(run_id)
828
+ if run is None:
829
+ raise HTTPException(status_code=404, detail="Run not found.")
830
+ return StreamingResponse(
831
+ workspace_run_stream(run, after),
832
+ media_type="text/event-stream",
833
+ )
834
+
835
+ @app.post("/api/workspace/runs/{run_id}/stop")
836
+ async def stop_workspace_run(run_id: str) -> dict[str, bool]:
837
+ run = workspace_runs.get(run_id)
838
+ if run is None:
839
+ raise HTTPException(status_code=404, detail="Run not found.")
840
+ if run.task is not None and not run.task.done():
841
+ run.task.cancel()
842
+ return {"ok": True}
843
+
844
+ @app.post("/api/workspace/compact")
845
+ async def compact_workspace() -> WorkspaceCompactResponse:
846
+ logger.info("Workspace compact requested")
847
+ state = store.read_state()
848
+ connection = selected_connection(state)
849
+ compacted_context = store.read_compacted_context()
850
+ cwd = Path.cwd()
851
+
852
+ try:
853
+ summary = await complete_chat(
854
+ connection,
855
+ compact_prompt_messages(
856
+ state.messages,
857
+ compacted_context,
858
+ runtime_context_messages(cwd),
859
+ ),
860
+ completion=chat_completion,
861
+ )
862
+ except HTTPException:
863
+ raise
864
+ except Exception as error:
865
+ logger.exception("Workspace compact failed")
866
+ raise HTTPException(
867
+ status_code=500,
868
+ detail="Context could not be compacted.",
869
+ ) from error
870
+
871
+ marker = StoredMessage(
872
+ author="system",
873
+ content=COMPACTED_CONTEXT_MARKER,
874
+ id=str(uuid4()),
875
+ )
876
+ store.save_compacted_context(summary.content)
877
+ store.save_messages([*state.messages, marker])
878
+ logger.info(
879
+ "Workspace compact completed summary_length=%s", len(summary.content)
880
+ )
881
+ logger.log(TRACE_LEVEL, "Workspace compact summary=%r", summary.content)
882
+ return WorkspaceCompactResponse(message=marker)
883
+
884
+ @app.post("/api/workspace/respond")
885
+ async def respond_to_workspace(
886
+ request: WorkspaceRespondRequest,
887
+ ) -> StreamingResponse:
888
+ logger.info(
889
+ "Workspace response requested content_length=%s", len(request.content)
890
+ )
891
+ logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
892
+ run = create_workspace_run(request.content)
651
893
  return StreamingResponse(
652
- response_stream(),
894
+ workspace_run_stream(run),
653
895
  media_type="text/event-stream",
654
896
  )
655
897