flowent 0.3.1 → 0.3.3

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 (29) hide show
  1. package/backend/pyproject.toml +1 -1
  2. package/backend/src/flowent/agent.py +82 -16
  3. package/backend/src/flowent/app.py +7 -2
  4. package/backend/src/flowent/mcp.py +4 -3
  5. package/backend/src/flowent/permissions.py +61 -39
  6. package/backend/src/flowent/routes/workflow_routes.py +9 -41
  7. package/backend/src/flowent/sandbox.py +63 -19
  8. package/backend/src/flowent/state/models.py +2 -3
  9. package/backend/src/flowent/state/schema.py +116 -0
  10. package/backend/src/flowent/static/assets/index-CCf0mo80.css +2 -0
  11. package/backend/src/flowent/static/assets/index-CROofCFl.js +102 -0
  12. package/backend/src/flowent/static/index.html +2 -2
  13. package/backend/src/flowent/tools.py +142 -35
  14. package/backend/src/flowent/usage.py +66 -0
  15. package/backend/src/flowent/workflow_service.py +93 -0
  16. package/backend/src/flowent/workflow_tools.py +271 -0
  17. package/backend/src/flowent/workflows.py +71 -3
  18. package/backend/src/flowent/workspace/context.py +14 -7
  19. package/backend/src/flowent/workspace/output.py +4 -1
  20. package/backend/src/flowent/workspace/runtime.py +164 -13
  21. package/backend/uv.lock +1 -1
  22. package/dist/frontend/assets/index-CCf0mo80.css +2 -0
  23. package/dist/frontend/assets/index-CROofCFl.js +102 -0
  24. package/dist/frontend/index.html +2 -2
  25. package/package.json +8 -10
  26. package/backend/src/flowent/static/assets/index-BaZmIi2Y.js +0 -98
  27. package/backend/src/flowent/static/assets/index-EC37agAH.css +0 -2
  28. package/dist/frontend/assets/index-BaZmIi2Y.js +0 -98
  29. package/dist/frontend/assets/index-EC37agAH.css +0 -2
@@ -0,0 +1,271 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import ValidationError
4
+
5
+ from flowent.storage import StoredWorkflow
6
+ from flowent.tools import ToolResult, text_tool_result
7
+ from flowent.workflow_service import WorkflowService
8
+ from flowent.workflows import WorkflowRunResponse
9
+
10
+
11
+ def workflow_tool_specs() -> list[dict[str, object]]:
12
+ workflow_schema: dict[str, object] = {"type": "object"}
13
+ return [
14
+ {
15
+ "type": "function",
16
+ "function": {
17
+ "name": "list_workflows",
18
+ "description": "List saved workflows with their ids, names, node counts, and edge counts.",
19
+ "parameters": {"type": "object", "properties": {}},
20
+ },
21
+ },
22
+ {
23
+ "type": "function",
24
+ "function": {
25
+ "name": "get_workflow",
26
+ "description": "Read a saved workflow definition by id before answering questions or editing it.",
27
+ "parameters": {
28
+ "type": "object",
29
+ "properties": {"workflow_id": {"type": "string"}},
30
+ "required": ["workflow_id"],
31
+ },
32
+ },
33
+ },
34
+ {
35
+ "type": "function",
36
+ "function": {
37
+ "name": "run_workflow",
38
+ "description": "Run a saved workflow. Pass input when the user's current message contains the content the workflow should process.",
39
+ "parameters": {
40
+ "type": "object",
41
+ "properties": {
42
+ "workflow_id": {"type": "string"},
43
+ "input": {"type": "string"},
44
+ "inputs": {
45
+ "type": "object",
46
+ "additionalProperties": {"type": "string"},
47
+ },
48
+ },
49
+ "required": ["workflow_id"],
50
+ },
51
+ },
52
+ },
53
+ {
54
+ "type": "function",
55
+ "function": {
56
+ "name": "create_workflow",
57
+ "description": "Create a workflow. workflow must include id, name, and definition with version, nodes, and edges.",
58
+ "parameters": {
59
+ "type": "object",
60
+ "properties": {"workflow": workflow_schema},
61
+ "required": ["workflow"],
62
+ },
63
+ },
64
+ },
65
+ {
66
+ "type": "function",
67
+ "function": {
68
+ "name": "update_workflow",
69
+ "description": "Replace an existing workflow. Read it first and provide the complete updated workflow object.",
70
+ "parameters": {
71
+ "type": "object",
72
+ "properties": {"workflow": workflow_schema},
73
+ "required": ["workflow"],
74
+ },
75
+ },
76
+ },
77
+ ]
78
+
79
+
80
+ def workflow_tool_title(name: str) -> str | None:
81
+ if name == "list_workflows":
82
+ return "Listing workflows"
83
+ if name == "get_workflow":
84
+ return "Reading workflow"
85
+ if name == "run_workflow":
86
+ return "Running workflow"
87
+ if name == "create_workflow":
88
+ return "Creating workflow"
89
+ if name == "update_workflow":
90
+ return "Updating workflow"
91
+ return None
92
+
93
+
94
+ class WorkflowAgentTools:
95
+ def __init__(self, service: WorkflowService) -> None:
96
+ self.service = service
97
+
98
+ async def run_tool(
99
+ self, name: str, arguments: dict[str, object]
100
+ ) -> ToolResult | None:
101
+ title = workflow_tool_title(name)
102
+ if title is None:
103
+ return None
104
+ try:
105
+ if name == "list_workflows":
106
+ return self.list_workflows()
107
+ if name == "get_workflow":
108
+ return self.get_workflow(arguments)
109
+ if name == "run_workflow":
110
+ return await self.run_workflow(arguments)
111
+ if name == "create_workflow":
112
+ return self.create_workflow(arguments)
113
+ if name == "update_workflow":
114
+ return self.update_workflow(arguments)
115
+ except Exception as error:
116
+ return ToolResult(
117
+ result=text_tool_result(str(error) or "Workflow tool failed."),
118
+ ok=False,
119
+ title=title,
120
+ )
121
+ return None
122
+
123
+ def list_workflows(self) -> ToolResult:
124
+ workflows = self.service.list_workflows()
125
+ summaries = [workflow_summary(workflow) for workflow in workflows]
126
+ if summaries:
127
+ output = "\n".join(
128
+ f"{summary['id']}: {summary['name']} ({summary['node_count']} nodes, {summary['edge_count']} edges)"
129
+ for summary in summaries
130
+ )
131
+ else:
132
+ output = "No workflows are saved."
133
+ return ToolResult(
134
+ result={
135
+ "type": "workflow_list",
136
+ "output": output,
137
+ "workflows": summaries,
138
+ },
139
+ title="Listed workflows",
140
+ )
141
+
142
+ def get_workflow(self, arguments: dict[str, object]) -> ToolResult:
143
+ workflow = self.service.get_workflow(str(arguments["workflow_id"]))
144
+ summary = workflow_summary(workflow)
145
+ output = (
146
+ f"{workflow.name} has {summary['node_count']} nodes and "
147
+ f"{summary['edge_count']} edges."
148
+ )
149
+ return ToolResult(
150
+ result={
151
+ "type": "workflow",
152
+ "output": output,
153
+ "summary": summary,
154
+ "workflow": workflow.model_dump(mode="json"),
155
+ },
156
+ title=f"Read {workflow.name}",
157
+ )
158
+
159
+ async def run_workflow(self, arguments: dict[str, object]) -> ToolResult:
160
+ workflow_id = str(arguments["workflow_id"])
161
+ workflow = self.service.get_workflow(workflow_id)
162
+ result = await self.service.run_workflow(
163
+ workflow_id,
164
+ default_input=string_argument(arguments, "input"),
165
+ input_values=string_map_argument(arguments, "inputs"),
166
+ )
167
+ output = workflow_run_output(workflow.name, result)
168
+ return ToolResult(
169
+ result={
170
+ "type": "workflow_run",
171
+ "node_results": [
172
+ node_result.model_dump(mode="json")
173
+ for node_result in result.node_results
174
+ ],
175
+ "output": output,
176
+ "outputs": dict(result.outputs),
177
+ "status": result.status,
178
+ "workflow_id": workflow.id,
179
+ "workflow_name": workflow.name,
180
+ },
181
+ ok=result.status == "success",
182
+ title=f"Ran {workflow.name}",
183
+ )
184
+
185
+ def create_workflow(self, arguments: dict[str, object]) -> ToolResult:
186
+ workflow = workflow_argument(arguments)
187
+ try:
188
+ self.service.get_workflow(workflow.id)
189
+ except ValueError:
190
+ saved = self.service.save_workflow(workflow)
191
+ else:
192
+ raise ValueError("Workflow already exists.")
193
+ return saved_workflow_result(saved, "Created")
194
+
195
+ def update_workflow(self, arguments: dict[str, object]) -> ToolResult:
196
+ workflow = workflow_argument(arguments)
197
+ self.service.get_workflow(workflow.id)
198
+ saved = self.service.save_workflow(workflow)
199
+ return saved_workflow_result(saved, "Updated")
200
+
201
+
202
+ def string_argument(arguments: dict[str, object], name: str) -> str:
203
+ value = arguments.get(name, "")
204
+ if value is None:
205
+ return ""
206
+ return value if isinstance(value, str) else str(value)
207
+
208
+
209
+ def string_map_argument(
210
+ arguments: dict[str, object], name: str
211
+ ) -> dict[str, str] | None:
212
+ value = arguments.get(name)
213
+ if not isinstance(value, dict):
214
+ return None
215
+ return {str(key): str(item) for key, item in value.items()}
216
+
217
+
218
+ def workflow_argument(arguments: dict[str, object]) -> StoredWorkflow:
219
+ value = arguments.get("workflow")
220
+ if not isinstance(value, dict):
221
+ raise ValueError("Workflow must be an object.")
222
+ try:
223
+ return StoredWorkflow.model_validate(value)
224
+ except ValidationError as error:
225
+ raise ValueError(error.errors()[0]["msg"]) from error
226
+
227
+
228
+ def workflow_summary(workflow: StoredWorkflow) -> dict[str, object]:
229
+ return {
230
+ "edge_count": len(workflow.definition.edges),
231
+ "id": workflow.id,
232
+ "name": workflow.name,
233
+ "node_count": len(workflow.definition.nodes),
234
+ "nodes": [
235
+ {"id": node.id, "name": node.name, "type": node.type}
236
+ for node in workflow.definition.nodes
237
+ ],
238
+ }
239
+
240
+
241
+ def workflow_run_output(name: str, result: WorkflowRunResponse) -> str:
242
+ if result.status == "success":
243
+ if result.outputs:
244
+ rendered_outputs = "\n".join(
245
+ f"{key}: {value}" for key, value in result.outputs.items()
246
+ )
247
+ return f"{name} completed.\n{rendered_outputs}"
248
+ return f"{name} completed."
249
+ failures = [
250
+ f"{node_result.id}: {node_result.error}"
251
+ for node_result in result.node_results
252
+ if node_result.status == "failed"
253
+ ]
254
+ return f"{name} failed.\n" + ("\n".join(failures) or "No failure details.")
255
+
256
+
257
+ def saved_workflow_result(workflow: StoredWorkflow, action: str) -> ToolResult:
258
+ summary = workflow_summary(workflow)
259
+ output = (
260
+ f"{action} {workflow.name} with {summary['node_count']} nodes and "
261
+ f"{summary['edge_count']} edges."
262
+ )
263
+ return ToolResult(
264
+ result={
265
+ "type": "workflow",
266
+ "output": output,
267
+ "summary": summary,
268
+ "workflow": workflow.model_dump(mode="json"),
269
+ },
270
+ title=f"{action} {workflow.name}",
271
+ )
@@ -1,7 +1,10 @@
1
1
  import json
2
2
  import re
3
+ import sys
4
+ import tempfile
3
5
  from collections import defaultdict, deque
4
6
  from collections.abc import Mapping
7
+ from pathlib import Path
5
8
 
6
9
  from pydantic import BaseModel, ConfigDict, Field
7
10
 
@@ -11,6 +14,7 @@ from flowent.llm import (
11
14
  ProviderConnection,
12
15
  complete_chat,
13
16
  )
17
+ from flowent.sandbox import SandboxRunner
14
18
  from flowent.storage import (
15
19
  StoredWorkflow,
16
20
  StoredWorkflowDefinition,
@@ -38,6 +42,31 @@ class WorkflowRunResponse(BaseModel):
38
42
 
39
43
 
40
44
  PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([A-Za-z0-9_.-]+)\.output\s*\}\}")
45
+ PYTHON_CODE_RUNNER = r"""
46
+ import contextlib
47
+ import io
48
+ import json
49
+ import sys
50
+
51
+ payload = json.loads(sys.stdin.read() or "{}")
52
+ namespace = {
53
+ "input": payload.get("input", ""),
54
+ "inputs": payload.get("inputs", []),
55
+ "output": "",
56
+ }
57
+ stdout = io.StringIO()
58
+ with contextlib.redirect_stdout(stdout):
59
+ exec(str(payload.get("code", "")), namespace)
60
+ captured = stdout.getvalue()
61
+ result = namespace.get("output")
62
+ if result is None:
63
+ result = ""
64
+ if result == "" and captured:
65
+ result = captured.rstrip("\n")
66
+ if not isinstance(result, str):
67
+ result = json.dumps(result, ensure_ascii=False)
68
+ print(result, end="")
69
+ """
41
70
 
42
71
 
43
72
  def validate_workflow(workflow: StoredWorkflow) -> StoredWorkflow:
@@ -136,6 +165,8 @@ async def run_workflow_definition(
136
165
  completion: CompletionCallable | None,
137
166
  connection: ProviderConnection | None,
138
167
  definition: StoredWorkflowDefinition,
168
+ default_input: str = "",
169
+ input_values: Mapping[str, str] | None = None,
139
170
  workflow_id: str,
140
171
  ) -> WorkflowRunResponse:
141
172
  ordered_ids = validate_workflow_definition(definition)
@@ -150,6 +181,7 @@ async def run_workflow_definition(
150
181
  }
151
182
  outputs: dict[str, str] = {}
152
183
  named_outputs: dict[str, str] = {}
184
+ remaining_default_input = default_input
153
185
 
154
186
  for node_id in ordered_ids:
155
187
  node = nodes[node_id]
@@ -158,10 +190,14 @@ async def run_workflow_definition(
158
190
  output = await run_node(
159
191
  completion=completion,
160
192
  connection=connection,
193
+ default_input=remaining_default_input,
194
+ input_values=input_values or {},
161
195
  incoming_edges=incoming_edges[node.id],
162
196
  node=node,
163
197
  outputs=outputs,
164
198
  )
199
+ if node.type == "input" and remaining_default_input:
200
+ remaining_default_input = ""
165
201
  except Exception as error:
166
202
  results[node.id] = WorkflowNodeRunResult(
167
203
  error=str(error) or "Node could not be completed.",
@@ -195,11 +231,17 @@ async def run_node(
195
231
  *,
196
232
  completion: CompletionCallable | None,
197
233
  connection: ProviderConnection | None,
234
+ default_input: str,
235
+ input_values: Mapping[str, str],
198
236
  incoming_edges: list[StoredWorkflowEdge],
199
237
  node: StoredWorkflowNode,
200
238
  outputs: Mapping[str, str],
201
239
  ) -> str:
202
240
  if node.type == "input":
241
+ if node.id in input_values:
242
+ return input_values[node.id]
243
+ if default_input:
244
+ return default_input
203
245
  return node_data_text(node, "default_value")
204
246
  if node.type == "agent":
205
247
  if connection is None:
@@ -220,11 +262,35 @@ async def run_node(
220
262
  if node_data_text(node, "merge_strategy") == "json":
221
263
  return merge_json_outputs(upstream)
222
264
  return "\n".join(output for output in upstream if output)
265
+ if node.type == "code":
266
+ return await run_code_node(node, upstream_outputs(incoming_edges, outputs))
223
267
  if node.type == "output":
224
268
  return joined_upstream_outputs(incoming_edges, outputs)
225
269
  raise ValueError("Node type is not supported.")
226
270
 
227
271
 
272
+ async def run_code_node(node: StoredWorkflowNode, upstream: list[str]) -> str:
273
+ code = node_data_text(node, "code")
274
+ if not code.strip():
275
+ return joined_text(upstream)
276
+ with tempfile.TemporaryDirectory(prefix="flowent-workflow-code-") as code_dir:
277
+ result = await SandboxRunner(timeout_seconds=10, cwd=Path(code_dir)).run_async(
278
+ [sys.executable, "-I", "-c", PYTHON_CODE_RUNNER],
279
+ input_text=json.dumps(
280
+ {
281
+ "code": code,
282
+ "input": joined_text(upstream),
283
+ "inputs": upstream,
284
+ },
285
+ ensure_ascii=False,
286
+ ),
287
+ timeout_seconds=10,
288
+ )
289
+ if result.exit_code != 0:
290
+ raise ValueError((result.stderr or result.stdout).strip() or "Code failed.")
291
+ return result.stdout
292
+
293
+
228
294
  def edges_by_target(
229
295
  edges: list[StoredWorkflowEdge],
230
296
  ) -> dict[str, list[StoredWorkflowEdge]]:
@@ -258,9 +324,11 @@ def joined_upstream_outputs(
258
324
  incoming_edges: list[StoredWorkflowEdge],
259
325
  outputs: Mapping[str, str],
260
326
  ) -> str:
261
- return "\n".join(
262
- output for output in upstream_outputs(incoming_edges, outputs) if output
263
- )
327
+ return joined_text(upstream_outputs(incoming_edges, outputs))
328
+
329
+
330
+ def joined_text(values: list[str]) -> str:
331
+ return "\n".join(value for value in values if value)
264
332
 
265
333
 
266
334
  def render_template(template: str, outputs: Mapping[str, str]) -> str:
@@ -12,10 +12,11 @@ from flowent.storage import (
12
12
  StoredSettings,
13
13
  StoredState,
14
14
  )
15
+ from flowent.tools import tool_result_model_content
15
16
  from flowent.usage import (
16
17
  TokenUsageInfo,
17
18
  current_model_context_window,
18
- estimated_token_usage_for_messages,
19
+ estimated_token_usage_for_request,
19
20
  recompute_context_usage,
20
21
  )
21
22
  from flowent.workspace.output import error_context_summary, message_error_items
@@ -51,13 +52,15 @@ def should_auto_compact(
51
52
  messages: Sequence[ChatMessage | Mapping[str, object]],
52
53
  *,
53
54
  context_window: int,
55
+ tools: Sequence[Mapping[str, object]] = (),
54
56
  ) -> bool:
55
57
  token_limit = auto_compact_token_limit(context_window)
56
58
  if token_limit <= 0:
57
59
  return False
58
60
  return (
59
- estimated_token_usage_for_messages(
60
- model_request_messages_data(messages)
61
+ estimated_token_usage_for_request(
62
+ model_request_messages_data(messages),
63
+ tools=tools,
61
64
  ).total_tokens
62
65
  >= token_limit
63
66
  )
@@ -83,17 +86,19 @@ def update_context_usage_for_response(
83
86
  messages: Sequence[Mapping[str, object]],
84
87
  output_content: str,
85
88
  output_tools: Sequence[Mapping[str, object]] = (),
89
+ request_tools: Sequence[Mapping[str, object]] = (),
86
90
  model_context_window: int,
87
91
  ) -> TokenUsageInfo:
88
92
  return recompute_context_usage(
89
93
  usage_info,
90
- estimated_token_usage_for_messages(
94
+ estimated_token_usage_for_request(
91
95
  [
92
96
  *model_visible_messages_for_usage(messages),
93
97
  *model_visible_response_messages_for_usage(
94
98
  output_content, output_tools
95
99
  ),
96
100
  ],
101
+ tools=request_tools,
97
102
  ).total_tokens,
98
103
  model_context_window=model_context_window,
99
104
  )
@@ -107,6 +112,8 @@ def model_visible_response_messages_for_usage(
107
112
  for index, tool in enumerate(output_tools):
108
113
  tool_id = str(tool.get("id") or f"call_{index}")
109
114
  arguments = tool.get("arguments")
115
+ result_payload = tool.get("result")
116
+ tool_result = result_payload if isinstance(result_payload, dict) else {}
110
117
  visible_messages.append(
111
118
  {
112
119
  "role": "assistant",
@@ -130,7 +137,7 @@ def model_visible_response_messages_for_usage(
130
137
  {
131
138
  "role": "tool",
132
139
  "tool_call_id": tool_id,
133
- "content": str(tool.get("content") or ""),
140
+ "content": tool_result_model_content(tool_result),
134
141
  }
135
142
  )
136
143
  if output_content:
@@ -175,7 +182,7 @@ def model_visible_assistant_output_messages(
175
182
  {
176
183
  "role": "tool",
177
184
  "tool_call_id": tool.id,
178
- "content": tool.content or "",
185
+ "content": tool_result_model_content(tool.result or {}),
179
186
  }
180
187
  for tool in group_tools
181
188
  if tool.status != "running"
@@ -260,7 +267,7 @@ def usage_info_for_model(
260
267
  model_context_window: int,
261
268
  ) -> TokenUsageInfo | None:
262
269
  if usage_info is None:
263
- return None
270
+ return TokenUsageInfo(model_context_window=model_context_window)
264
271
  return usage_info.model_copy(update={"model_context_window": model_context_window})
265
272
 
266
273
 
@@ -13,6 +13,7 @@ from flowent.storage import (
13
13
  StoredToolItem,
14
14
  StoredToolOutputItem,
15
15
  )
16
+ from flowent.tools import tool_result_model_content
16
17
 
17
18
  APPROVAL_TRANSCRIPT_MESSAGE_LIMIT = 12
18
19
  APPROVAL_TRANSCRIPT_TEXT_LIMIT = 2_000
@@ -84,7 +85,9 @@ def approval_transcript(
84
85
  if content:
85
86
  entries.append(ApprovalTranscriptEntry(role=role, content=content))
86
87
  for tool in message.tools:
87
- tool_content = approval_transcript_text(tool.content)
88
+ tool_content = approval_transcript_text(
89
+ tool_result_model_content(tool.result or {})
90
+ )
88
91
  if tool_content:
89
92
  entries.append(
90
93
  ApprovalTranscriptEntry(