flowent 0.2.3 → 0.3.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 (49) hide show
  1. package/README.md +3 -3
  2. package/backend/README.md +3 -3
  3. package/backend/pyproject.toml +1 -1
  4. package/backend/src/flowent/agent.py +1 -1
  5. package/backend/src/flowent/api_models.py +103 -0
  6. package/backend/src/flowent/app.py +151 -0
  7. package/backend/src/flowent/cli.py +13 -4
  8. package/backend/src/flowent/compact.py +34 -13
  9. package/backend/src/flowent/llm.py +6 -8
  10. package/backend/src/flowent/logging.py +7 -1
  11. package/backend/src/flowent/main.py +18 -1989
  12. package/backend/src/flowent/mcp.py +231 -44
  13. package/backend/src/flowent/network.py +5 -0
  14. package/backend/src/flowent/permissions.py +5 -1
  15. package/backend/src/flowent/provider_connections.py +42 -0
  16. package/backend/src/flowent/routes/__init__.py +0 -0
  17. package/backend/src/flowent/routes/integrations.py +105 -0
  18. package/backend/src/flowent/routes/permissions.py +36 -0
  19. package/backend/src/flowent/routes/providers.py +30 -0
  20. package/backend/src/flowent/routes/system.py +49 -0
  21. package/backend/src/flowent/routes/workflow_routes.py +63 -0
  22. package/backend/src/flowent/routes/workspace.py +105 -0
  23. package/backend/src/flowent/sandbox.py +1 -1
  24. package/backend/src/flowent/state/__init__.py +53 -0
  25. package/backend/src/flowent/state/models.py +257 -0
  26. package/backend/src/flowent/state/schema.py +186 -0
  27. package/backend/src/flowent/state/store.py +1013 -0
  28. package/backend/src/flowent/static/assets/index-CvWZZMtK.css +2 -0
  29. package/backend/src/flowent/static/assets/index-ma2v8oW7.js +90 -0
  30. package/backend/src/flowent/static/index.html +2 -2
  31. package/backend/src/flowent/storage.py +52 -1254
  32. package/backend/src/flowent/system_tools.py +25 -0
  33. package/backend/src/flowent/tools.py +4 -2
  34. package/backend/src/flowent/usage.py +9 -4
  35. package/backend/src/flowent/workflows.py +282 -0
  36. package/backend/src/flowent/workspace/__init__.py +0 -0
  37. package/backend/src/flowent/workspace/context.py +249 -0
  38. package/backend/src/flowent/workspace/events.py +180 -0
  39. package/backend/src/flowent/workspace/output.py +274 -0
  40. package/backend/src/flowent/workspace/runtime.py +1041 -0
  41. package/backend/uv.lock +1 -1
  42. package/dist/frontend/assets/index-CvWZZMtK.css +2 -0
  43. package/dist/frontend/assets/index-ma2v8oW7.js +90 -0
  44. package/dist/frontend/index.html +2 -2
  45. package/package.json +1 -1
  46. package/backend/src/flowent/static/assets/index-D7t9qNrC.js +0 -82
  47. package/backend/src/flowent/static/assets/index-DufpDl8x.css +0 -2
  48. package/dist/frontend/assets/index-D7t9qNrC.js +0 -82
  49. package/dist/frontend/assets/index-DufpDl8x.css +0 -2
package/README.md CHANGED
@@ -17,11 +17,11 @@ 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:
20
+ Flowent requires Bubblewrap for local tool isolation and ripgrep for file
21
+ search. Install the system packages first:
22
22
 
23
23
  ```bash
24
- sudo apt-get install bubblewrap
24
+ sudo apt-get install bubblewrap ripgrep
25
25
  ```
26
26
 
27
27
  Install the CLI globally:
package/backend/README.md CHANGED
@@ -17,11 +17,11 @@ 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:
20
+ Flowent requires Bubblewrap for local tool isolation and ripgrep for file
21
+ search. Install the system packages first:
22
22
 
23
23
  ```bash
24
- sudo apt-get install bubblewrap
24
+ sudo apt-get install bubblewrap ripgrep
25
25
  ```
26
26
 
27
27
  Install the CLI globally:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowent"
3
- version = "0.2.3"
3
+ version = "0.3.0"
4
4
  description = "A workflow orchestration platform for multi-agent collaboration"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -45,7 +45,7 @@ Use tools deliberately:
45
45
  - Search the web only when current external information is needed.
46
46
  - Update the plan when a task has multiple meaningful steps.
47
47
 
48
- After each tool result, decide whether the task is complete, whether another tool is needed, or whether you need to explain a blocker. When no more tool work is needed, provide the final response."""
48
+ After each tool result, decide whether the task is complete, whether another tool is needed, or whether you need to explain a blocker. A tool call is not a final response. After every tool result, continue the same turn until you either call another tool, explain a blocker, or provide a final response. If a tool fails, use the error as context and continue deciding whether to retry, use another tool, or explain the blocker. When no more tool work is needed, provide the final response."""
49
49
 
50
50
 
51
51
  class AgentStreamEvent(BaseModel):
@@ -0,0 +1,103 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+ from flowent.llm import ProviderFormat
6
+ from flowent.storage import StoredMessage, StoredWritablePath
7
+ from flowent.usage import TokenUsageInfo
8
+
9
+
10
+ class ProviderModelsRequest(BaseModel):
11
+ model_config = ConfigDict(extra="forbid")
12
+
13
+ provider: ProviderFormat
14
+ secret_reference: str
15
+ base_url: str | None = None
16
+
17
+
18
+ class ProviderModelsResponse(BaseModel):
19
+ models: list[str]
20
+
21
+
22
+ class WorkspaceMessagesRequest(BaseModel):
23
+ model_config = ConfigDict(extra="forbid")
24
+
25
+ messages: list[StoredMessage]
26
+
27
+
28
+ class WorkspaceMessageEditRequest(BaseModel):
29
+ model_config = ConfigDict(extra="forbid")
30
+
31
+ action: Literal["resend", "save"]
32
+ content: str
33
+
34
+
35
+ class WorkspaceMessageEditResponse(BaseModel):
36
+ model_config = ConfigDict(extra="forbid")
37
+
38
+ messages: list[StoredMessage]
39
+ run_id: str | None = None
40
+
41
+
42
+ class WorkspaceRespondRequest(BaseModel):
43
+ model_config = ConfigDict(extra="forbid")
44
+
45
+ content: str
46
+ message_id: str | None = None
47
+
48
+
49
+ class WorkspaceRunResponse(BaseModel):
50
+ model_config = ConfigDict(extra="forbid")
51
+
52
+ run_id: str
53
+
54
+
55
+ class WorkspaceClearResponse(BaseModel):
56
+ model_config = ConfigDict(extra="forbid")
57
+
58
+ active_run_id: str | None = None
59
+ messages: list[StoredMessage]
60
+ usage_info: TokenUsageInfo | None = None
61
+
62
+
63
+ class AboutResponse(BaseModel):
64
+ model_config = ConfigDict(extra="forbid")
65
+
66
+ version: str
67
+
68
+
69
+ class TelegramSessionApproveRequest(BaseModel):
70
+ model_config = ConfigDict(extra="forbid")
71
+
72
+ chat_id: str
73
+
74
+
75
+ class SkillSettingsRequest(BaseModel):
76
+ model_config = ConfigDict(extra="forbid")
77
+
78
+ enabled: bool
79
+
80
+
81
+ class McpImportRequest(BaseModel):
82
+ model_config = ConfigDict(extra="forbid")
83
+
84
+ server_id: str
85
+ source: Literal["claude_code", "codex"]
86
+
87
+
88
+ class McpImportPreviewRequest(BaseModel):
89
+ model_config = ConfigDict(extra="forbid")
90
+
91
+ source: Literal["claude_code", "codex"]
92
+
93
+
94
+ class WritablePathRequest(BaseModel):
95
+ model_config = ConfigDict(extra="forbid")
96
+
97
+ path: str
98
+
99
+
100
+ class WritablePathListResponse(BaseModel):
101
+ model_config = ConfigDict(extra="forbid")
102
+
103
+ writable_paths: list[StoredWritablePath]
@@ -0,0 +1,151 @@
1
+ import logging
2
+ import os
3
+ from collections.abc import AsyncIterator, Awaitable
4
+ from contextlib import asynccontextmanager
5
+ from pathlib import Path
6
+
7
+ from fastapi import FastAPI
8
+ from fastapi.responses import FileResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+
11
+ from flowent.channels import TelegramBotManager, TelegramTransport
12
+ from flowent.compact import LocalSummaryCompactProvider
13
+ from flowent.llm import CompletionCallable
14
+ from flowent.logging import ensure_logging_configured
15
+ from flowent.mcp import McpManager, McpTransport
16
+ from flowent.paths import resolve_workdir
17
+ from flowent.routes.integrations import register_integration_routes
18
+ from flowent.routes.permissions import register_permission_routes
19
+ from flowent.routes.providers import register_provider_routes
20
+ from flowent.routes.system import register_system_routes
21
+ from flowent.routes.workflow_routes import register_workflow_routes
22
+ from flowent.routes.workspace import register_workspace_routes
23
+ from flowent.sandbox import ensure_sandbox_available
24
+ from flowent.storage import StateStore
25
+ from flowent.system_tools import ensure_ripgrep_available
26
+ from flowent.workspace.runtime import WorkspaceRuntime
27
+
28
+ logger = logging.getLogger("flowent.app")
29
+
30
+
31
+ DEFAULT_STATIC_DIR = Path(__file__).parent / "static"
32
+
33
+
34
+ def frontend_static_directory() -> Path:
35
+ configured_directory = os.environ.get("FLOWENT_STATIC_DIR")
36
+ if configured_directory:
37
+ return Path(configured_directory)
38
+ repository_frontend_dist = Path(__file__).resolve().parents[3] / "frontend" / "dist"
39
+ if repository_frontend_dist.is_dir():
40
+ return repository_frontend_dist
41
+ return DEFAULT_STATIC_DIR
42
+
43
+
44
+ def create_app(
45
+ *,
46
+ serve_frontend: bool = True,
47
+ chat_completion: CompletionCallable | None = None,
48
+ mcp_transport: McpTransport | None = None,
49
+ telegram_transport: TelegramTransport | None = None,
50
+ workdir: Path | str | None = None,
51
+ ) -> FastAPI:
52
+ ensure_logging_configured()
53
+ ensure_sandbox_available()
54
+ ensure_ripgrep_available()
55
+
56
+ cwd = resolve_workdir(workdir)
57
+ store = StateStore()
58
+ compact_provider = LocalSummaryCompactProvider()
59
+ mcp_manager = McpManager(store=store, transport=mcp_transport)
60
+
61
+ static_dir = frontend_static_directory().resolve(strict=False)
62
+ logger.debug("Flowent app created serve_frontend=%s", serve_frontend)
63
+ logger.info("Workdir: %s", cwd)
64
+ logger.info("Static directory: %s", static_dir)
65
+
66
+ runtime = WorkspaceRuntime(
67
+ chat_completion=chat_completion,
68
+ compact_provider=compact_provider,
69
+ cwd=cwd,
70
+ mcp_manager=mcp_manager,
71
+ store=store,
72
+ )
73
+
74
+ telegram_bot_manager = TelegramBotManager(
75
+ message_handler=runtime.reply_text,
76
+ store=store,
77
+ telegram_transport=telegram_transport,
78
+ )
79
+
80
+ async def run_shutdown_step(label: str, cleanup: Awaitable[object]) -> None:
81
+ try:
82
+ await cleanup
83
+ except Exception:
84
+ logger.exception("%s cleanup failed during shutdown", label)
85
+
86
+ async def graceful_shutdown() -> None:
87
+ await run_shutdown_step("Workspace", runtime.stop_for_shutdown())
88
+ await run_shutdown_step("Telegram", telegram_bot_manager.stop_all())
89
+ await run_shutdown_step("MCP", mcp_manager.stop_all())
90
+
91
+ @asynccontextmanager
92
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
93
+ app.state.mcp_manager = mcp_manager
94
+ app.state.telegram_bot_manager = telegram_bot_manager
95
+ await mcp_manager.start_enabled()
96
+ await telegram_bot_manager.start_enabled()
97
+ try:
98
+ yield
99
+ finally:
100
+ await graceful_shutdown()
101
+
102
+ app = FastAPI(title="Flowent", lifespan=lifespan)
103
+ app.state.mcp_manager = mcp_manager
104
+ app.state.telegram_bot_manager = telegram_bot_manager
105
+
106
+ register_system_routes(
107
+ app,
108
+ cwd=cwd,
109
+ mcp_manager=mcp_manager,
110
+ runtime=runtime,
111
+ store=store,
112
+ telegram_bot_manager=telegram_bot_manager,
113
+ )
114
+ register_provider_routes(app, store=store)
115
+ register_integration_routes(
116
+ app,
117
+ cwd=cwd,
118
+ mcp_manager=mcp_manager,
119
+ store=store,
120
+ telegram_bot_manager=telegram_bot_manager,
121
+ )
122
+ register_workflow_routes(
123
+ app,
124
+ chat_completion=chat_completion,
125
+ store=store,
126
+ )
127
+ register_permission_routes(app, cwd=cwd, store=store)
128
+ register_workspace_routes(app, runtime=runtime, store=store)
129
+
130
+ if serve_frontend and static_dir.is_dir():
131
+ assets_dir = static_dir / "assets"
132
+ if assets_dir.is_dir():
133
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
134
+
135
+ @app.get("/{path:path}")
136
+ async def spa_fallback(path: str) -> FileResponse:
137
+ file = (static_dir / path).resolve(strict=False)
138
+ if file.is_file() and file.is_relative_to(static_dir):
139
+ return FileResponse(file)
140
+ return FileResponse(static_dir / "index.html")
141
+
142
+ return app
143
+
144
+
145
+ app = create_app()
146
+
147
+
148
+ if __name__ == "__main__":
149
+ import uvicorn
150
+
151
+ uvicorn.run(app)
@@ -59,13 +59,22 @@ def main(argv: list[str] | None = None) -> None:
59
59
 
60
60
  if args.command == "doctor":
61
61
  from flowent.sandbox import SANDBOX_INSTALL_HINT, sandbox_binary
62
+ from flowent.system_tools import RIPGREP_INSTALL_HINT, ripgrep_binary
62
63
 
63
64
  bwrap = sandbox_binary()
65
+ rg = ripgrep_binary()
66
+
64
67
  if bwrap:
65
68
  print(f"Sandbox: {bwrap}")
66
- raise SystemExit(0)
67
- print(f"Sandbox: missing. {SANDBOX_INSTALL_HINT}", file=sys.stderr)
68
- raise SystemExit(1)
69
+ else:
70
+ print(f"Sandbox: missing. {SANDBOX_INSTALL_HINT}", file=sys.stderr)
71
+
72
+ if rg:
73
+ print(f"Search: {rg}")
74
+ else:
75
+ print(f"Search: missing. {RIPGREP_INSTALL_HINT}", file=sys.stderr)
76
+
77
+ raise SystemExit(0 if bwrap and rg else 1)
69
78
 
70
79
  if args.version:
71
80
  try:
@@ -95,7 +104,7 @@ def main(argv: list[str] | None = None) -> None:
95
104
  import uvicorn
96
105
 
97
106
  uvicorn.run(
98
- "flowent.main:app",
107
+ "flowent.app:app",
99
108
  host=args.host,
100
109
  port=args.port,
101
110
  )
@@ -10,7 +10,7 @@ from flowent.llm import (
10
10
  ProviderConnection,
11
11
  complete_chat_with_usage,
12
12
  )
13
- from flowent.usage import TokenUsage
13
+ from flowent.usage import APPROX_BYTES_PER_TOKEN, TokenUsage, approximate_token_count
14
14
 
15
15
  if TYPE_CHECKING:
16
16
  from flowent.storage import StoredMessage
@@ -172,14 +172,41 @@ def retained_recent_user_messages(
172
172
  def truncate_text_to_token_budget(content: str, token_budget: int) -> str:
173
173
  if token_budget <= 0 or not content:
174
174
  return ""
175
- character_budget = max(token_budget * 4, 1)
176
- if len(content) <= character_budget:
175
+ byte_budget = max(token_budget * APPROX_BYTES_PER_TOKEN, 1)
176
+ if len(content.encode("utf-8")) <= byte_budget:
177
177
  return content
178
- left_budget = character_budget // 2
179
- right_budget = character_budget - left_budget
180
- removed_tokens = approximate_token_count(content[left_budget:-right_budget])
178
+ left_budget = byte_budget // 2
179
+ right_budget = byte_budget - left_budget
180
+ prefix = text_prefix_for_byte_budget(content, left_budget)
181
+ suffix = text_suffix_for_byte_budget(content, right_budget)
182
+ middle_end = -len(suffix) if suffix else len(content)
183
+ removed_tokens = approximate_token_count(content[len(prefix) : middle_end])
181
184
  marker = f"…{removed_tokens} tokens truncated…"
182
- return f"{content[:left_budget]}{marker}{content[-right_budget:]}"
185
+ return f"{prefix}{marker}{suffix}"
186
+
187
+
188
+ def text_prefix_for_byte_budget(content: str, byte_budget: int) -> str:
189
+ used_bytes = 0
190
+ prefix: list[str] = []
191
+ for character in content:
192
+ character_bytes = len(character.encode("utf-8"))
193
+ if used_bytes + character_bytes > byte_budget:
194
+ break
195
+ prefix.append(character)
196
+ used_bytes += character_bytes
197
+ return "".join(prefix)
198
+
199
+
200
+ def text_suffix_for_byte_budget(content: str, byte_budget: int) -> str:
201
+ used_bytes = 0
202
+ suffix: list[str] = []
203
+ for character in reversed(content):
204
+ character_bytes = len(character.encode("utf-8"))
205
+ if used_bytes + character_bytes > byte_budget:
206
+ break
207
+ suffix.append(character)
208
+ used_bytes += character_bytes
209
+ return "".join(reversed(suffix))
183
210
 
184
211
 
185
212
  def transcript_messages_after(
@@ -196,9 +223,3 @@ def transcript_messages_after(
196
223
 
197
224
  def approximate_tokens_for_messages(messages: Sequence[ChatMessage]) -> int:
198
225
  return sum(approximate_token_count(message.content) for message in messages)
199
-
200
-
201
- def approximate_token_count(content: str) -> int:
202
- if not content:
203
- return 0
204
- return max(1, (len(content) + 3) // 4)
@@ -13,6 +13,7 @@ from flowent.logging import (
13
13
  configure_litellm_logging,
14
14
  write_llm_request_diagnostic,
15
15
  )
16
+ from flowent.network import flowent_user_agent
16
17
  from flowent.usage import TokenUsage, token_usage_from_response
17
18
 
18
19
 
@@ -175,17 +176,13 @@ def normalize_provider_model_name(provider: ProviderFormat, model: str) -> str:
175
176
 
176
177
 
177
178
  def stream_failure_message(chunk: Any) -> str:
178
- if isinstance(chunk, BaseModel):
179
- chunk = chunk.model_dump()
180
- if not isinstance(chunk, Mapping):
181
- return ""
182
-
183
- event_type = getattr(chunk.get("type"), "value", chunk.get("type"))
179
+ event_type = value_at(chunk, "type", "")
180
+ event_type = getattr(event_type, "value", event_type)
184
181
  event_type = str(event_type or "")
185
182
  if event_type == "error":
186
- error = chunk.get("error", {})
183
+ error = value_at(chunk, "error", {})
187
184
  elif event_type == "response.failed":
188
- response = chunk.get("response", {})
185
+ response = value_at(chunk, "response", {})
189
186
  error = value_at(response, "error", {})
190
187
  else:
191
188
  return ""
@@ -304,6 +301,7 @@ def build_litellm_request(
304
301
  )
305
302
  request: dict[str, Any] = {
306
303
  "api_key": connection.secret_reference,
304
+ "extra_headers": {"User-Agent": flowent_user_agent()},
307
305
  "messages": request_messages,
308
306
  "model": provider_model_name(connection),
309
307
  }
@@ -194,6 +194,12 @@ class ConsoleNoiseFilter(logging.Filter):
194
194
  return record.levelno > logging.DEBUG or record.name.startswith("flowent")
195
195
 
196
196
 
197
+ class ConsoleHandler(logging.StreamHandler):
198
+ def emit(self, record: logging.LogRecord) -> None:
199
+ self.setStream(sys.stderr if record.levelno >= logging.WARNING else sys.stdout)
200
+ super().emit(record)
201
+
202
+
197
203
  def configure_logging(*, directory: Path | None = None) -> Path:
198
204
  global _configured_log_file, _configured_log_process_id
199
205
 
@@ -218,7 +224,7 @@ def configure_logging(*, directory: Path | None = None) -> Path:
218
224
  )
219
225
  )
220
226
 
221
- console_handler = logging.StreamHandler(sys.stderr)
227
+ console_handler = ConsoleHandler()
222
228
  console_handler.setLevel(console_log_level())
223
229
  console_handler.setFormatter(
224
230
  RedactingFormatter("%(levelname)s %(name)s: %(message)s")