flowent 0.2.4__tar.gz → 0.3.0__tar.gz

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 (57) hide show
  1. {flowent-0.2.4 → flowent-0.3.0}/PKG-INFO +4 -4
  2. {flowent-0.2.4 → flowent-0.3.0}/README.md +3 -3
  3. {flowent-0.2.4 → flowent-0.3.0}/pyproject.toml +1 -1
  4. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/agent.py +1 -1
  5. flowent-0.3.0/src/flowent/api_models.py +103 -0
  6. flowent-0.3.0/src/flowent/app.py +151 -0
  7. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/cli.py +13 -4
  8. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/compact.py +34 -13
  9. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/llm.py +2 -0
  10. flowent-0.3.0/src/flowent/main.py +19 -0
  11. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/mcp.py +100 -2
  12. flowent-0.3.0/src/flowent/network.py +5 -0
  13. flowent-0.3.0/src/flowent/provider_connections.py +42 -0
  14. flowent-0.3.0/src/flowent/routes/__init__.py +0 -0
  15. flowent-0.3.0/src/flowent/routes/integrations.py +105 -0
  16. flowent-0.3.0/src/flowent/routes/permissions.py +36 -0
  17. flowent-0.3.0/src/flowent/routes/providers.py +30 -0
  18. flowent-0.3.0/src/flowent/routes/system.py +49 -0
  19. flowent-0.3.0/src/flowent/routes/workflow_routes.py +63 -0
  20. flowent-0.3.0/src/flowent/routes/workspace.py +105 -0
  21. flowent-0.3.0/src/flowent/state/__init__.py +53 -0
  22. flowent-0.3.0/src/flowent/state/models.py +257 -0
  23. flowent-0.3.0/src/flowent/state/schema.py +186 -0
  24. flowent-0.2.4/src/flowent/storage.py → flowent-0.3.0/src/flowent/state/store.py +78 -384
  25. flowent-0.3.0/src/flowent/static/assets/index-CvWZZMtK.css +2 -0
  26. flowent-0.3.0/src/flowent/static/assets/index-ma2v8oW7.js +90 -0
  27. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/static/index.html +2 -2
  28. flowent-0.3.0/src/flowent/storage.py +53 -0
  29. flowent-0.3.0/src/flowent/system_tools.py +25 -0
  30. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/tools.py +4 -2
  31. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/usage.py +9 -4
  32. flowent-0.3.0/src/flowent/workflows.py +282 -0
  33. flowent-0.3.0/src/flowent/workspace/__init__.py +0 -0
  34. flowent-0.3.0/src/flowent/workspace/context.py +249 -0
  35. flowent-0.3.0/src/flowent/workspace/events.py +180 -0
  36. flowent-0.3.0/src/flowent/workspace/output.py +274 -0
  37. flowent-0.3.0/src/flowent/workspace/runtime.py +1041 -0
  38. flowent-0.2.4/src/flowent/main.py +0 -1995
  39. flowent-0.2.4/src/flowent/static/assets/index-BH30iLzb.css +0 -2
  40. flowent-0.2.4/src/flowent/static/assets/index-sBFt3ORj.js +0 -84
  41. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/__init__.py +0 -0
  42. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/_version.py +0 -0
  43. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/approval.py +0 -0
  44. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/channels.py +0 -0
  45. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/context.py +0 -0
  46. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/logging.py +0 -0
  47. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/mcp_import.py +0 -0
  48. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/patch.py +0 -0
  49. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/paths.py +0 -0
  50. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/permissions.py +0 -0
  51. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/sandbox.py +0 -0
  52. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/shell.py +0 -0
  53. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/skills.py +0 -0
  54. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/static/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  55. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/static/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  56. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/static/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  57. {flowent-0.2.4 → flowent-0.3.0}/src/flowent/static/flowent.png +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowent
3
- Version: 0.2.4
3
+ Version: 0.3.0
4
4
  Summary: A workflow orchestration platform for multi-agent collaboration
5
5
  Keywords: agent,agents,ai,ai-agents,assistant,automation,code-generation,llm,mcp,orchestration,sandbox,web-application,workflow
6
6
  Author: ImFeH2
@@ -44,11 +44,11 @@ A workflow orchestration platform for multi-agent collaboration.
44
44
 
45
45
  ## Install
46
46
 
47
- Flowent requires Bubblewrap for local tool isolation. Install the system package
48
- first:
47
+ Flowent requires Bubblewrap for local tool isolation and ripgrep for file
48
+ search. Install the system packages first:
49
49
 
50
50
  ```bash
51
- sudo apt-get install bubblewrap
51
+ sudo apt-get install bubblewrap ripgrep
52
52
  ```
53
53
 
54
54
  Install the CLI globally:
@@ -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.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
 
@@ -300,6 +301,7 @@ def build_litellm_request(
300
301
  )
301
302
  request: dict[str, Any] = {
302
303
  "api_key": connection.secret_reference,
304
+ "extra_headers": {"User-Agent": flowent_user_agent()},
303
305
  "messages": request_messages,
304
306
  "model": provider_model_name(connection),
305
307
  }
@@ -0,0 +1,19 @@
1
+ from flowent.app import (
2
+ app,
3
+ create_app,
4
+ frontend_static_directory,
5
+ )
6
+ from flowent.provider_connections import selected_connection
7
+ from flowent.routes.permissions import normalized_request_path
8
+ from flowent.workspace.context import should_auto_compact
9
+ from flowent.workspace.runtime import WORKSPACE_PROGRESS_FLUSH_INTERVAL_SECONDS
10
+
11
+ __all__ = [
12
+ "WORKSPACE_PROGRESS_FLUSH_INTERVAL_SECONDS",
13
+ "app",
14
+ "create_app",
15
+ "frontend_static_directory",
16
+ "normalized_request_path",
17
+ "selected_connection",
18
+ "should_auto_compact",
19
+ ]
@@ -5,17 +5,23 @@ import json
5
5
  import logging
6
6
  import os
7
7
  import re
8
+ import sys
8
9
  from collections.abc import Callable
9
- from contextlib import AsyncExitStack
10
+ from contextlib import AsyncExitStack, suppress
10
11
  from dataclasses import dataclass
11
12
  from importlib import import_module
12
- from typing import Any, Protocol
13
+ from typing import Any, Protocol, TextIO, cast
13
14
 
14
15
  from flowent.storage import StateStore, StoredMcpServer, StoredMcpTool
15
16
  from flowent.tools import ToolResult
16
17
 
17
18
  logger = logging.getLogger("flowent.mcp")
18
19
  MCP_CONNECT_TIMEOUT_SECONDS = 10
20
+ PYTHON_TRACEBACK_START = "Traceback (most recent call last):"
21
+ PYTHON_TRACEBACK_TERMINAL_PATTERN = re.compile(
22
+ r"^(?:[A-Za-z_][A-Za-z0-9_.]*(?:Error|Exception|Interrupt|Warning)|"
23
+ r"BaseExceptionGroup|ExceptionGroup)(?::|$)"
24
+ )
19
25
 
20
26
 
21
27
  class McpTransport(Protocol):
@@ -128,6 +134,96 @@ class _McpConnection:
128
134
  session: Any = None
129
135
 
130
136
 
137
+ class _McpStdioErrorFilter:
138
+ def __init__(self, target: TextIO) -> None:
139
+ self.target = target
140
+ self.line_buffer = ""
141
+ self.traceback_lines: list[str] | None = None
142
+
143
+ def feed(self, text: str) -> None:
144
+ self.line_buffer += text
145
+ while "\n" in self.line_buffer:
146
+ line, self.line_buffer = self.line_buffer.split("\n", 1)
147
+ self.feed_line(f"{line}\n")
148
+
149
+ def finish(self) -> None:
150
+ if self.line_buffer:
151
+ self.feed_line(self.line_buffer)
152
+ self.line_buffer = ""
153
+ if self.traceback_lines is not None:
154
+ self.write("".join(self.traceback_lines))
155
+ self.traceback_lines = None
156
+
157
+ def feed_line(self, line: str) -> None:
158
+ stripped_line = line.rstrip("\r\n")
159
+ if self.traceback_lines is not None:
160
+ self.traceback_lines.append(line)
161
+ if stripped_line == "KeyboardInterrupt":
162
+ self.traceback_lines = None
163
+ return
164
+ if PYTHON_TRACEBACK_TERMINAL_PATTERN.match(stripped_line):
165
+ self.write("".join(self.traceback_lines))
166
+ self.traceback_lines = None
167
+ return
168
+ if stripped_line == PYTHON_TRACEBACK_START:
169
+ self.traceback_lines = [line]
170
+ return
171
+ self.write(line)
172
+
173
+ def write(self, text: str) -> None:
174
+ self.target.write(text)
175
+ self.target.flush()
176
+
177
+
178
+ class _McpStdioErrorLog:
179
+ def __init__(self, target: TextIO | None = None) -> None:
180
+ self.target = target or sys.stderr
181
+ self.filter = _McpStdioErrorFilter(self.target)
182
+ self.read_fd, write_fd = os.pipe()
183
+ self.write_file = os.fdopen(write_fd, "wb", buffering=0)
184
+ self.drain_task: asyncio.Task[None] | None = None
185
+
186
+ async def __aenter__(self) -> _McpStdioErrorLog:
187
+ self.drain_task = asyncio.create_task(self.drain())
188
+ return self
189
+
190
+ async def __aexit__(
191
+ self,
192
+ exc_type: type[BaseException] | None,
193
+ exc: BaseException | None,
194
+ traceback: object,
195
+ ) -> None:
196
+ self.close_write_file()
197
+ if self.drain_task is not None:
198
+ await asyncio.gather(self.drain_task, return_exceptions=True)
199
+ else:
200
+ self.close_read_fd()
201
+
202
+ def fileno(self) -> int:
203
+ return self.write_file.fileno()
204
+
205
+ async def drain(self) -> None:
206
+ try:
207
+ while True:
208
+ chunk = await asyncio.to_thread(os.read, self.read_fd, 4096)
209
+ if not chunk:
210
+ break
211
+ self.filter.feed(chunk.decode("utf-8", errors="replace"))
212
+ except OSError:
213
+ pass
214
+ finally:
215
+ self.filter.finish()
216
+ self.close_read_fd()
217
+
218
+ def close_write_file(self) -> None:
219
+ with suppress(OSError, ValueError):
220
+ self.write_file.close()
221
+
222
+ def close_read_fd(self) -> None:
223
+ with suppress(OSError):
224
+ os.close(self.read_fd)
225
+
226
+
131
227
  class DefaultMcpTransport:
132
228
  def __init__(self) -> None:
133
229
  self._connections: dict[str, _McpConnection] = {}
@@ -197,9 +293,11 @@ class DefaultMcpTransport:
197
293
  )
198
294
  )
199
295
  else:
296
+ stdio_errlog = await stack.enter_async_context(_McpStdioErrorLog())
200
297
  read_stream, write_stream = await stack.enter_async_context(
201
298
  stdio_client(
202
299
  self._stdio_parameters(server, config),
300
+ errlog=cast(TextIO, stdio_errlog),
203
301
  )
204
302
  )
205
303
  session = await stack.enter_async_context(
@@ -0,0 +1,5 @@
1
+ from flowent._version import __version__
2
+
3
+
4
+ def flowent_user_agent() -> str:
5
+ return f"Flowent/{__version__}"
@@ -0,0 +1,42 @@
1
+ import logging
2
+
3
+ from fastapi import HTTPException
4
+
5
+ from flowent.llm import ProviderConnection
6
+ from flowent.storage import StoredState
7
+
8
+ logger = logging.getLogger("flowent.provider_connections")
9
+
10
+
11
+ def selected_connection(state: StoredState) -> ProviderConnection:
12
+ provider = next(
13
+ (
14
+ stored_provider
15
+ for stored_provider in state.providers
16
+ if stored_provider.id == state.settings.selected_provider_id
17
+ ),
18
+ None,
19
+ )
20
+ if provider is None or not state.settings.selected_model:
21
+ logger.warning("Workspace request blocked because provider or model is missing")
22
+ raise HTTPException(
23
+ status_code=400,
24
+ detail="Choose a provider and model before sending.",
25
+ )
26
+ if not provider.api_key:
27
+ logger.warning("Workspace request blocked because selected provider has no key")
28
+ raise HTTPException(status_code=400, detail="Add a key before sending.")
29
+
30
+ logger.debug(
31
+ "Workspace request using provider=%s model=%s",
32
+ provider.name,
33
+ state.settings.selected_model,
34
+ )
35
+ return ProviderConnection(
36
+ base_url=provider.base_url or None,
37
+ model=state.settings.selected_model,
38
+ name=provider.name,
39
+ provider=provider.type,
40
+ reasoning_effort=state.settings.reasoning_effort,
41
+ secret_reference=provider.api_key,
42
+ )
File without changes