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
@@ -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 = ["rg", "--line-number", "--max-count", str(limit)]
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": "Flowent/0.1"},
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
- total_tokens = sum(estimate_mapping_message_tokens(message) for message in messages)
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=max(total_tokens - output_tokens, 0),
258
+ input_tokens=input_tokens,
258
259
  output_tokens=output_tokens,
259
- total_tokens=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(1, (len(content) + 3) // 4)
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