flowent 0.2.4 → 0.3.1

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 (46) 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 +108 -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 +52 -6
  10. package/backend/src/flowent/main.py +18 -1994
  11. package/backend/src/flowent/mcp.py +100 -2
  12. package/backend/src/flowent/network.py +5 -0
  13. package/backend/src/flowent/provider_connections.py +42 -0
  14. package/backend/src/flowent/routes/__init__.py +0 -0
  15. package/backend/src/flowent/routes/integrations.py +105 -0
  16. package/backend/src/flowent/routes/permissions.py +36 -0
  17. package/backend/src/flowent/routes/providers.py +53 -0
  18. package/backend/src/flowent/routes/system.py +48 -0
  19. package/backend/src/flowent/routes/workflow_routes.py +63 -0
  20. package/backend/src/flowent/routes/workspace.py +115 -0
  21. package/backend/src/flowent/state/__init__.py +53 -0
  22. package/backend/src/flowent/state/models.py +258 -0
  23. package/backend/src/flowent/state/schema.py +191 -0
  24. package/backend/src/flowent/state/store.py +1019 -0
  25. package/backend/src/flowent/static/assets/index-BaZmIi2Y.js +98 -0
  26. package/backend/src/flowent/static/assets/index-EC37agAH.css +2 -0
  27. package/backend/src/flowent/static/index.html +2 -2
  28. package/backend/src/flowent/storage.py +52 -1318
  29. package/backend/src/flowent/system_tools.py +25 -0
  30. package/backend/src/flowent/tools.py +4 -2
  31. package/backend/src/flowent/usage.py +9 -4
  32. package/backend/src/flowent/workflows.py +282 -0
  33. package/backend/src/flowent/workspace/__init__.py +0 -0
  34. package/backend/src/flowent/workspace/context.py +335 -0
  35. package/backend/src/flowent/workspace/events.py +178 -0
  36. package/backend/src/flowent/workspace/output.py +396 -0
  37. package/backend/src/flowent/workspace/runtime.py +1160 -0
  38. package/backend/uv.lock +1 -1
  39. package/dist/frontend/assets/index-BaZmIi2Y.js +98 -0
  40. package/dist/frontend/assets/index-EC37agAH.css +2 -0
  41. package/dist/frontend/index.html +2 -2
  42. package/package.json +1 -1
  43. package/backend/src/flowent/static/assets/index-BH30iLzb.css +0 -2
  44. package/backend/src/flowent/static/assets/index-sBFt3ORj.js +0 -84
  45. package/dist/frontend/assets/index-BH30iLzb.css +0 -2
  46. package/dist/frontend/assets/index-sBFt3ORj.js +0 -84
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.4"
3
+ version = "0.3.1"
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,108 @@
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 ProviderModelsFailureResponse(BaseModel):
23
+ model_config = ConfigDict(extra="forbid")
24
+
25
+ code: Literal[
26
+ "connection_failed",
27
+ "access_denied",
28
+ "rate_limited",
29
+ "provider_unavailable",
30
+ "request_failed",
31
+ ]
32
+
33
+
34
+ class WorkspaceMessagesRequest(BaseModel):
35
+ model_config = ConfigDict(extra="forbid")
36
+
37
+ messages: list[StoredMessage]
38
+
39
+
40
+ class WorkspaceMessageEditRequest(BaseModel):
41
+ model_config = ConfigDict(extra="forbid")
42
+
43
+ action: Literal["resend", "save"]
44
+ content: str
45
+
46
+
47
+ class WorkspaceMessageEditResponse(BaseModel):
48
+ model_config = ConfigDict(extra="forbid")
49
+
50
+ is_responding: bool = False
51
+ messages: list[StoredMessage]
52
+
53
+
54
+ class WorkspaceRespondRequest(BaseModel):
55
+ model_config = ConfigDict(extra="forbid")
56
+
57
+ content: str
58
+ message_id: str | None = None
59
+
60
+
61
+ class WorkspaceClearResponse(BaseModel):
62
+ model_config = ConfigDict(extra="forbid")
63
+
64
+ messages: list[StoredMessage]
65
+ usage_info: TokenUsageInfo | None = None
66
+
67
+
68
+ class AboutResponse(BaseModel):
69
+ model_config = ConfigDict(extra="forbid")
70
+
71
+ version: str
72
+
73
+
74
+ class TelegramSessionApproveRequest(BaseModel):
75
+ model_config = ConfigDict(extra="forbid")
76
+
77
+ chat_id: str
78
+
79
+
80
+ class SkillSettingsRequest(BaseModel):
81
+ model_config = ConfigDict(extra="forbid")
82
+
83
+ enabled: bool
84
+
85
+
86
+ class McpImportRequest(BaseModel):
87
+ model_config = ConfigDict(extra="forbid")
88
+
89
+ server_id: str
90
+ source: Literal["claude_code", "codex"]
91
+
92
+
93
+ class McpImportPreviewRequest(BaseModel):
94
+ model_config = ConfigDict(extra="forbid")
95
+
96
+ source: Literal["claude_code", "codex"]
97
+
98
+
99
+ class WritablePathRequest(BaseModel):
100
+ model_config = ConfigDict(extra="forbid")
101
+
102
+ path: str
103
+
104
+
105
+ class WritablePathListResponse(BaseModel):
106
+ model_config = ConfigDict(extra="forbid")
107
+
108
+ 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
 
@@ -84,6 +85,20 @@ class LLMStreamError(RuntimeError):
84
85
  pass
85
86
 
86
87
 
88
+ class ProviderModelFetchFailure(StrEnum):
89
+ CONNECTION_FAILED = "connection_failed"
90
+ ACCESS_DENIED = "access_denied"
91
+ RATE_LIMITED = "rate_limited"
92
+ PROVIDER_UNAVAILABLE = "provider_unavailable"
93
+ REQUEST_FAILED = "request_failed"
94
+
95
+
96
+ class ProviderModelFetchError(RuntimeError):
97
+ def __init__(self, failure: ProviderModelFetchFailure) -> None:
98
+ super().__init__(failure.value)
99
+ self.failure = failure
100
+
101
+
87
102
  async def wait_before_llm_retry(attempt_number: int) -> None:
88
103
  await asyncio.sleep(LLM_RETRY_BASE_DELAY_SECONDS * attempt_number)
89
104
 
@@ -247,6 +262,31 @@ def unique_model_names(provider: ProviderFormat, models: Sequence[str]) -> list[
247
262
  return normalized_models
248
263
 
249
264
 
265
+ def classify_provider_model_fetch_failure(
266
+ exception: Exception,
267
+ ) -> ProviderModelFetchFailure:
268
+ try:
269
+ import litellm
270
+ except Exception:
271
+ return ProviderModelFetchFailure.REQUEST_FAILED
272
+
273
+ if isinstance(exception, (litellm.APIConnectionError, litellm.Timeout)):
274
+ return ProviderModelFetchFailure.CONNECTION_FAILED
275
+ if isinstance(
276
+ exception, (litellm.AuthenticationError, litellm.PermissionDeniedError)
277
+ ):
278
+ return ProviderModelFetchFailure.ACCESS_DENIED
279
+ if isinstance(exception, litellm.RateLimitError):
280
+ return ProviderModelFetchFailure.RATE_LIMITED
281
+ if isinstance(
282
+ exception, (litellm.ServiceUnavailableError, litellm.InternalServerError)
283
+ ):
284
+ return ProviderModelFetchFailure.PROVIDER_UNAVAILABLE
285
+ if isinstance(exception, (ConnectionError, TimeoutError)):
286
+ return ProviderModelFetchFailure.CONNECTION_FAILED
287
+ return ProviderModelFetchFailure.REQUEST_FAILED
288
+
289
+
250
290
  def list_provider_models(
251
291
  *,
252
292
  provider: ProviderFormat,
@@ -260,12 +300,17 @@ def list_provider_models(
260
300
  configure_litellm_logging()
261
301
  model_lister = get_valid_models
262
302
 
263
- models = model_lister(
264
- api_base=normalize_provider_base_url(provider, base_url),
265
- api_key=secret_reference,
266
- check_provider_endpoint=True,
267
- custom_llm_provider=provider_litellm_name(provider),
268
- )
303
+ try:
304
+ models = model_lister(
305
+ api_base=normalize_provider_base_url(provider, base_url),
306
+ api_key=secret_reference,
307
+ check_provider_endpoint=True,
308
+ custom_llm_provider=provider_litellm_name(provider),
309
+ )
310
+ except Exception as exc:
311
+ raise ProviderModelFetchError(
312
+ classify_provider_model_fetch_failure(exc)
313
+ ) from exc
269
314
  return unique_model_names(provider, models)
270
315
 
271
316
 
@@ -300,6 +345,7 @@ def build_litellm_request(
300
345
  )
301
346
  request: dict[str, Any] = {
302
347
  "api_key": connection.secret_reference,
348
+ "extra_headers": {"User-Agent": flowent_user_agent()},
303
349
  "messages": request_messages,
304
350
  "model": provider_model_name(connection),
305
351
  }