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.
- package/README.md +3 -3
- package/backend/README.md +3 -3
- package/backend/pyproject.toml +1 -1
- package/backend/src/flowent/agent.py +1 -1
- package/backend/src/flowent/api_models.py +108 -0
- package/backend/src/flowent/app.py +151 -0
- package/backend/src/flowent/cli.py +13 -4
- package/backend/src/flowent/compact.py +34 -13
- package/backend/src/flowent/llm.py +52 -6
- package/backend/src/flowent/main.py +18 -1994
- package/backend/src/flowent/mcp.py +100 -2
- package/backend/src/flowent/network.py +5 -0
- package/backend/src/flowent/provider_connections.py +42 -0
- package/backend/src/flowent/routes/__init__.py +0 -0
- package/backend/src/flowent/routes/integrations.py +105 -0
- package/backend/src/flowent/routes/permissions.py +36 -0
- package/backend/src/flowent/routes/providers.py +53 -0
- package/backend/src/flowent/routes/system.py +48 -0
- package/backend/src/flowent/routes/workflow_routes.py +63 -0
- package/backend/src/flowent/routes/workspace.py +115 -0
- package/backend/src/flowent/state/__init__.py +53 -0
- package/backend/src/flowent/state/models.py +258 -0
- package/backend/src/flowent/state/schema.py +191 -0
- package/backend/src/flowent/state/store.py +1019 -0
- package/backend/src/flowent/static/assets/index-BaZmIi2Y.js +98 -0
- package/backend/src/flowent/static/assets/index-EC37agAH.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +52 -1318
- package/backend/src/flowent/system_tools.py +25 -0
- package/backend/src/flowent/tools.py +4 -2
- package/backend/src/flowent/usage.py +9 -4
- package/backend/src/flowent/workflows.py +282 -0
- package/backend/src/flowent/workspace/__init__.py +0 -0
- package/backend/src/flowent/workspace/context.py +335 -0
- package/backend/src/flowent/workspace/events.py +178 -0
- package/backend/src/flowent/workspace/output.py +396 -0
- package/backend/src/flowent/workspace/runtime.py +1160 -0
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-BaZmIi2Y.js +98 -0
- package/dist/frontend/assets/index-EC37agAH.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-BH30iLzb.css +0 -2
- package/backend/src/flowent/static/assets/index-sBFt3ORj.js +0 -84
- package/dist/frontend/assets/index-BH30iLzb.css +0 -2
- package/dist/frontend/assets/index-sBFt3ORj.js +0 -84
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SystemToolError(RuntimeError):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
RIPGREP_INSTALL_HINT = (
|
|
11
|
+
"Install ripgrep and try again. Debian/Ubuntu: "
|
|
12
|
+
"sudo apt-get install ripgrep. Fedora: sudo dnf install ripgrep. "
|
|
13
|
+
"Arch: sudo pacman -S ripgrep."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ripgrep_binary() -> str | None:
|
|
18
|
+
return shutil.which("rg")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ensure_ripgrep_available() -> str:
|
|
22
|
+
rg = ripgrep_binary()
|
|
23
|
+
if not rg:
|
|
24
|
+
raise SystemToolError(f"Search is not available. {RIPGREP_INSTALL_HINT}")
|
|
25
|
+
return rg
|
|
@@ -13,9 +13,11 @@ from uuid import uuid4
|
|
|
13
13
|
|
|
14
14
|
from pydantic import BaseModel, ConfigDict
|
|
15
15
|
|
|
16
|
+
from flowent.network import flowent_user_agent
|
|
16
17
|
from flowent.patch import affected_paths
|
|
17
18
|
from flowent.sandbox import SandboxError, SandboxRunner
|
|
18
19
|
from flowent.shell import shell_invocation
|
|
20
|
+
from flowent.system_tools import ensure_ripgrep_available
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
class ToolResult(BaseModel):
|
|
@@ -285,7 +287,7 @@ def grep_files(arguments: dict[str, object], context: ToolContext) -> ToolResult
|
|
|
285
287
|
pattern = str(arguments["pattern"])
|
|
286
288
|
path = resolve_tool_path(str(arguments.get("path", ".") or "."), context.cwd)
|
|
287
289
|
limit = integer_argument(arguments, "limit", 100)
|
|
288
|
-
command = [
|
|
290
|
+
command = [ensure_ripgrep_available(), "--line-number", "--max-count", str(limit)]
|
|
289
291
|
include = arguments.get("include")
|
|
290
292
|
if include:
|
|
291
293
|
command.extend(["--glob", str(include)])
|
|
@@ -438,7 +440,7 @@ def default_web_search(query: str) -> list[dict[str, str]]:
|
|
|
438
440
|
encoded_query = urllib.parse.urlencode({"q": query})
|
|
439
441
|
request = urllib.request.Request(
|
|
440
442
|
f"https://duckduckgo.com/html/?{encoded_query}",
|
|
441
|
-
headers={"User-Agent":
|
|
443
|
+
headers={"User-Agent": flowent_user_agent()},
|
|
442
444
|
)
|
|
443
445
|
with urllib.request.urlopen(request, timeout=10) as response:
|
|
444
446
|
body = response.read().decode(errors="replace")
|
|
@@ -5,6 +5,7 @@ from typing import Any
|
|
|
5
5
|
from pydantic import BaseModel, ConfigDict, Field
|
|
6
6
|
|
|
7
7
|
DEFAULT_MODEL_CONTEXT_WINDOW = 120_000
|
|
8
|
+
APPROX_BYTES_PER_TOKEN = 4
|
|
8
9
|
|
|
9
10
|
MODEL_CONTEXT_WINDOWS: dict[str, int] = {
|
|
10
11
|
"claude-3-7-sonnet-20250219": 200_000,
|
|
@@ -251,12 +252,12 @@ def estimated_token_usage_for_messages(
|
|
|
251
252
|
*,
|
|
252
253
|
output_content: str = "",
|
|
253
254
|
) -> TokenUsage:
|
|
254
|
-
|
|
255
|
+
input_tokens = sum(estimate_mapping_message_tokens(message) for message in messages)
|
|
255
256
|
output_tokens = approximate_token_count(output_content)
|
|
256
257
|
return TokenUsage(
|
|
257
|
-
input_tokens=
|
|
258
|
+
input_tokens=input_tokens,
|
|
258
259
|
output_tokens=output_tokens,
|
|
259
|
-
total_tokens=
|
|
260
|
+
total_tokens=input_tokens + output_tokens,
|
|
260
261
|
)
|
|
261
262
|
|
|
262
263
|
|
|
@@ -273,7 +274,11 @@ def estimate_mapping_message_tokens(message: Mapping[str, object]) -> int:
|
|
|
273
274
|
def approximate_token_count(content: str) -> int:
|
|
274
275
|
if not content:
|
|
275
276
|
return 0
|
|
276
|
-
return max(
|
|
277
|
+
return max(
|
|
278
|
+
1,
|
|
279
|
+
(len(content.encode("utf-8")) + APPROX_BYTES_PER_TOKEN - 1)
|
|
280
|
+
// APPROX_BYTES_PER_TOKEN,
|
|
281
|
+
)
|
|
277
282
|
|
|
278
283
|
|
|
279
284
|
def string_content(value: object) -> str:
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from collections import defaultdict, deque
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
from flowent.llm import (
|
|
9
|
+
ChatMessage,
|
|
10
|
+
CompletionCallable,
|
|
11
|
+
ProviderConnection,
|
|
12
|
+
complete_chat,
|
|
13
|
+
)
|
|
14
|
+
from flowent.storage import (
|
|
15
|
+
StoredWorkflow,
|
|
16
|
+
StoredWorkflowDefinition,
|
|
17
|
+
StoredWorkflowEdge,
|
|
18
|
+
StoredWorkflowNode,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WorkflowNodeRunResult(BaseModel):
|
|
23
|
+
model_config = ConfigDict(extra="forbid")
|
|
24
|
+
|
|
25
|
+
error: str = ""
|
|
26
|
+
id: str
|
|
27
|
+
output: str = ""
|
|
28
|
+
status: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WorkflowRunResponse(BaseModel):
|
|
32
|
+
model_config = ConfigDict(extra="forbid")
|
|
33
|
+
|
|
34
|
+
node_results: list[WorkflowNodeRunResult] = Field(default_factory=list)
|
|
35
|
+
outputs: dict[str, str] = Field(default_factory=dict)
|
|
36
|
+
status: str
|
|
37
|
+
workflow_id: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([A-Za-z0-9_.-]+)\.output\s*\}\}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_workflow(workflow: StoredWorkflow) -> StoredWorkflow:
|
|
44
|
+
validate_workflow_definition(workflow.definition)
|
|
45
|
+
return workflow
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate_workflow_draft(workflow: StoredWorkflow) -> StoredWorkflow:
|
|
49
|
+
validate_workflow_draft_definition(workflow.definition)
|
|
50
|
+
return workflow
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_workflow_draft_definition(definition: StoredWorkflowDefinition) -> None:
|
|
54
|
+
node_ids = [node.id for node in definition.nodes]
|
|
55
|
+
if any(not node_id.strip() for node_id in node_ids):
|
|
56
|
+
raise ValueError("Workflow node ids must not be empty.")
|
|
57
|
+
if len(set(node_ids)) != len(node_ids):
|
|
58
|
+
raise ValueError("Workflow node ids must be unique.")
|
|
59
|
+
|
|
60
|
+
edge_ids = [edge.id for edge in definition.edges]
|
|
61
|
+
if any(not edge_id.strip() for edge_id in edge_ids):
|
|
62
|
+
raise ValueError("Workflow edge ids must not be empty.")
|
|
63
|
+
if len(set(edge_ids)) != len(edge_ids):
|
|
64
|
+
raise ValueError("Workflow edge ids must be unique.")
|
|
65
|
+
|
|
66
|
+
node_id_set = set(node_ids)
|
|
67
|
+
for edge in definition.edges:
|
|
68
|
+
if edge.source not in node_id_set or edge.target not in node_id_set:
|
|
69
|
+
raise ValueError("Workflow edges must connect existing nodes.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def validate_workflow_definition(definition: StoredWorkflowDefinition) -> list[str]:
|
|
73
|
+
node_ids = [node.id for node in definition.nodes]
|
|
74
|
+
if not node_ids:
|
|
75
|
+
raise ValueError("Workflow needs at least one node.")
|
|
76
|
+
if any(not node_id.strip() for node_id in node_ids):
|
|
77
|
+
raise ValueError("Workflow node ids must not be empty.")
|
|
78
|
+
if len(set(node_ids)) != len(node_ids):
|
|
79
|
+
raise ValueError("Workflow node ids must be unique.")
|
|
80
|
+
if not any(node.type == "input" for node in definition.nodes):
|
|
81
|
+
raise ValueError("Workflow needs an input node.")
|
|
82
|
+
if not any(node.type == "output" for node in definition.nodes):
|
|
83
|
+
raise ValueError("Workflow needs an output node.")
|
|
84
|
+
|
|
85
|
+
edge_ids = [edge.id for edge in definition.edges]
|
|
86
|
+
if any(not edge_id.strip() for edge_id in edge_ids):
|
|
87
|
+
raise ValueError("Workflow edge ids must not be empty.")
|
|
88
|
+
if len(set(edge_ids)) != len(edge_ids):
|
|
89
|
+
raise ValueError("Workflow edge ids must be unique.")
|
|
90
|
+
|
|
91
|
+
node_id_set = set(node_ids)
|
|
92
|
+
for edge in definition.edges:
|
|
93
|
+
if edge.source not in node_id_set or edge.target not in node_id_set:
|
|
94
|
+
raise ValueError("Workflow edges must connect existing nodes.")
|
|
95
|
+
|
|
96
|
+
return topological_node_ids(definition)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def workflow_requires_connection(definition: StoredWorkflowDefinition) -> bool:
|
|
100
|
+
return any(node.type == "agent" for node in definition.nodes)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def topological_node_ids(definition: StoredWorkflowDefinition) -> list[str]:
|
|
104
|
+
node_ids = [node.id for node in definition.nodes]
|
|
105
|
+
outgoing: dict[str, list[str]] = defaultdict(list)
|
|
106
|
+
indegree = {node_id: 0 for node_id in node_ids}
|
|
107
|
+
for edge in definition.edges:
|
|
108
|
+
outgoing[edge.source].append(edge.target)
|
|
109
|
+
indegree[edge.target] += 1
|
|
110
|
+
|
|
111
|
+
node_order = {node_id: index for index, node_id in enumerate(node_ids)}
|
|
112
|
+
ready = deque(
|
|
113
|
+
sorted(
|
|
114
|
+
[node_id for node_id, degree in indegree.items() if degree == 0],
|
|
115
|
+
key=lambda node_id: node_order[node_id],
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
ordered: list[str] = []
|
|
119
|
+
while ready:
|
|
120
|
+
node_id = ready.popleft()
|
|
121
|
+
ordered.append(node_id)
|
|
122
|
+
for target in sorted(
|
|
123
|
+
outgoing[node_id], key=lambda node_id: node_order[node_id]
|
|
124
|
+
):
|
|
125
|
+
indegree[target] -= 1
|
|
126
|
+
if indegree[target] == 0:
|
|
127
|
+
ready.append(target)
|
|
128
|
+
|
|
129
|
+
if len(ordered) != len(node_ids):
|
|
130
|
+
raise ValueError("Workflow cannot contain cycles.")
|
|
131
|
+
return ordered
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def run_workflow_definition(
|
|
135
|
+
*,
|
|
136
|
+
completion: CompletionCallable | None,
|
|
137
|
+
connection: ProviderConnection | None,
|
|
138
|
+
definition: StoredWorkflowDefinition,
|
|
139
|
+
workflow_id: str,
|
|
140
|
+
) -> WorkflowRunResponse:
|
|
141
|
+
ordered_ids = validate_workflow_definition(definition)
|
|
142
|
+
if workflow_requires_connection(definition) and connection is None:
|
|
143
|
+
raise ValueError("Choose a provider and model before running.")
|
|
144
|
+
|
|
145
|
+
nodes = {node.id: node for node in definition.nodes}
|
|
146
|
+
incoming_edges = edges_by_target(definition.edges)
|
|
147
|
+
results: dict[str, WorkflowNodeRunResult] = {
|
|
148
|
+
node.id: WorkflowNodeRunResult(id=node.id, status="pending")
|
|
149
|
+
for node in definition.nodes
|
|
150
|
+
}
|
|
151
|
+
outputs: dict[str, str] = {}
|
|
152
|
+
named_outputs: dict[str, str] = {}
|
|
153
|
+
|
|
154
|
+
for node_id in ordered_ids:
|
|
155
|
+
node = nodes[node_id]
|
|
156
|
+
results[node.id] = WorkflowNodeRunResult(id=node.id, status="running")
|
|
157
|
+
try:
|
|
158
|
+
output = await run_node(
|
|
159
|
+
completion=completion,
|
|
160
|
+
connection=connection,
|
|
161
|
+
incoming_edges=incoming_edges[node.id],
|
|
162
|
+
node=node,
|
|
163
|
+
outputs=outputs,
|
|
164
|
+
)
|
|
165
|
+
except Exception as error:
|
|
166
|
+
results[node.id] = WorkflowNodeRunResult(
|
|
167
|
+
error=str(error) or "Node could not be completed.",
|
|
168
|
+
id=node.id,
|
|
169
|
+
status="failed",
|
|
170
|
+
)
|
|
171
|
+
return WorkflowRunResponse(
|
|
172
|
+
node_results=list(results.values()),
|
|
173
|
+
outputs=named_outputs,
|
|
174
|
+
status="failed",
|
|
175
|
+
workflow_id=workflow_id,
|
|
176
|
+
)
|
|
177
|
+
outputs[node.id] = output
|
|
178
|
+
if node.type == "output":
|
|
179
|
+
named_outputs[node_output_key(node)] = output
|
|
180
|
+
results[node.id] = WorkflowNodeRunResult(
|
|
181
|
+
id=node.id,
|
|
182
|
+
output=output,
|
|
183
|
+
status="success",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return WorkflowRunResponse(
|
|
187
|
+
node_results=list(results.values()),
|
|
188
|
+
outputs=named_outputs,
|
|
189
|
+
status="success",
|
|
190
|
+
workflow_id=workflow_id,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def run_node(
|
|
195
|
+
*,
|
|
196
|
+
completion: CompletionCallable | None,
|
|
197
|
+
connection: ProviderConnection | None,
|
|
198
|
+
incoming_edges: list[StoredWorkflowEdge],
|
|
199
|
+
node: StoredWorkflowNode,
|
|
200
|
+
outputs: Mapping[str, str],
|
|
201
|
+
) -> str:
|
|
202
|
+
if node.type == "input":
|
|
203
|
+
return node_data_text(node, "default_value")
|
|
204
|
+
if node.type == "agent":
|
|
205
|
+
if connection is None:
|
|
206
|
+
raise ValueError("Choose a provider and model before running.")
|
|
207
|
+
prompt = render_template(
|
|
208
|
+
node_data_text(node, "prompt")
|
|
209
|
+
or joined_upstream_outputs(incoming_edges, outputs),
|
|
210
|
+
outputs,
|
|
211
|
+
)
|
|
212
|
+
response = await complete_chat(
|
|
213
|
+
connection,
|
|
214
|
+
[ChatMessage(role="user", content=prompt)],
|
|
215
|
+
completion=completion,
|
|
216
|
+
)
|
|
217
|
+
return response.content
|
|
218
|
+
if node.type == "merge":
|
|
219
|
+
upstream = upstream_outputs(incoming_edges, outputs)
|
|
220
|
+
if node_data_text(node, "merge_strategy") == "json":
|
|
221
|
+
return merge_json_outputs(upstream)
|
|
222
|
+
return "\n".join(output for output in upstream if output)
|
|
223
|
+
if node.type == "output":
|
|
224
|
+
return joined_upstream_outputs(incoming_edges, outputs)
|
|
225
|
+
raise ValueError("Node type is not supported.")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def edges_by_target(
|
|
229
|
+
edges: list[StoredWorkflowEdge],
|
|
230
|
+
) -> dict[str, list[StoredWorkflowEdge]]:
|
|
231
|
+
grouped: dict[str, list[StoredWorkflowEdge]] = defaultdict(list)
|
|
232
|
+
for edge in edges:
|
|
233
|
+
grouped[edge.target].append(edge)
|
|
234
|
+
return grouped
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def node_data_text(node: StoredWorkflowNode, key: str) -> str:
|
|
238
|
+
value = node.data.get(key, "")
|
|
239
|
+
if value is None:
|
|
240
|
+
return ""
|
|
241
|
+
if isinstance(value, str):
|
|
242
|
+
return value
|
|
243
|
+
return str(value)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def node_output_key(node: StoredWorkflowNode) -> str:
|
|
247
|
+
return node_data_text(node, "output_key") or node.id
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def upstream_outputs(
|
|
251
|
+
incoming_edges: list[StoredWorkflowEdge],
|
|
252
|
+
outputs: Mapping[str, str],
|
|
253
|
+
) -> list[str]:
|
|
254
|
+
return [outputs[edge.source] for edge in incoming_edges if edge.source in outputs]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def joined_upstream_outputs(
|
|
258
|
+
incoming_edges: list[StoredWorkflowEdge],
|
|
259
|
+
outputs: Mapping[str, str],
|
|
260
|
+
) -> str:
|
|
261
|
+
return "\n".join(
|
|
262
|
+
output for output in upstream_outputs(incoming_edges, outputs) if output
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def render_template(template: str, outputs: Mapping[str, str]) -> str:
|
|
267
|
+
return PLACEHOLDER_PATTERN.sub(
|
|
268
|
+
lambda match: outputs.get(match.group(1), ""),
|
|
269
|
+
template,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def merge_json_outputs(upstream: list[str]) -> str:
|
|
274
|
+
merged: dict[str, object] = {}
|
|
275
|
+
for output in upstream:
|
|
276
|
+
try:
|
|
277
|
+
parsed = json.loads(output)
|
|
278
|
+
except json.JSONDecodeError:
|
|
279
|
+
continue
|
|
280
|
+
if isinstance(parsed, dict):
|
|
281
|
+
merged.update(parsed)
|
|
282
|
+
return json.dumps(merged, ensure_ascii=False)
|
|
File without changes
|