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.
- {flowent-0.2.4 → flowent-0.3.0}/PKG-INFO +4 -4
- {flowent-0.2.4 → flowent-0.3.0}/README.md +3 -3
- {flowent-0.2.4 → flowent-0.3.0}/pyproject.toml +1 -1
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/agent.py +1 -1
- flowent-0.3.0/src/flowent/api_models.py +103 -0
- flowent-0.3.0/src/flowent/app.py +151 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/cli.py +13 -4
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/compact.py +34 -13
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/llm.py +2 -0
- flowent-0.3.0/src/flowent/main.py +19 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/mcp.py +100 -2
- flowent-0.3.0/src/flowent/network.py +5 -0
- flowent-0.3.0/src/flowent/provider_connections.py +42 -0
- flowent-0.3.0/src/flowent/routes/__init__.py +0 -0
- flowent-0.3.0/src/flowent/routes/integrations.py +105 -0
- flowent-0.3.0/src/flowent/routes/permissions.py +36 -0
- flowent-0.3.0/src/flowent/routes/providers.py +30 -0
- flowent-0.3.0/src/flowent/routes/system.py +49 -0
- flowent-0.3.0/src/flowent/routes/workflow_routes.py +63 -0
- flowent-0.3.0/src/flowent/routes/workspace.py +105 -0
- flowent-0.3.0/src/flowent/state/__init__.py +53 -0
- flowent-0.3.0/src/flowent/state/models.py +257 -0
- flowent-0.3.0/src/flowent/state/schema.py +186 -0
- flowent-0.2.4/src/flowent/storage.py → flowent-0.3.0/src/flowent/state/store.py +78 -384
- flowent-0.3.0/src/flowent/static/assets/index-CvWZZMtK.css +2 -0
- flowent-0.3.0/src/flowent/static/assets/index-ma2v8oW7.js +90 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/static/index.html +2 -2
- flowent-0.3.0/src/flowent/storage.py +53 -0
- flowent-0.3.0/src/flowent/system_tools.py +25 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/tools.py +4 -2
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/usage.py +9 -4
- flowent-0.3.0/src/flowent/workflows.py +282 -0
- flowent-0.3.0/src/flowent/workspace/__init__.py +0 -0
- flowent-0.3.0/src/flowent/workspace/context.py +249 -0
- flowent-0.3.0/src/flowent/workspace/events.py +180 -0
- flowent-0.3.0/src/flowent/workspace/output.py +274 -0
- flowent-0.3.0/src/flowent/workspace/runtime.py +1041 -0
- flowent-0.2.4/src/flowent/main.py +0 -1995
- flowent-0.2.4/src/flowent/static/assets/index-BH30iLzb.css +0 -2
- flowent-0.2.4/src/flowent/static/assets/index-sBFt3ORj.js +0 -84
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/__init__.py +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/_version.py +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/approval.py +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/channels.py +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/context.py +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/logging.py +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/mcp_import.py +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/patch.py +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/paths.py +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/permissions.py +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/sandbox.py +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/shell.py +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/skills.py +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/static/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/static/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- {flowent-0.2.4 → flowent-0.3.0}/src/flowent/static/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- {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.
|
|
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
|
|
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
|
|
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:
|
|
@@ -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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
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
|
-
|
|
176
|
-
if len(content) <=
|
|
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 =
|
|
179
|
-
right_budget =
|
|
180
|
-
|
|
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"{
|
|
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,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
|