flowent 0.3.2 → 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.
@@ -6,8 +6,8 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>Flowent</title>
8
8
  <meta name="description" content="Flowent application" />
9
- <script type="module" crossorigin src="/assets/index-BX18a4Jz.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-EC37agAH.css">
9
+ <script type="module" crossorigin src="/assets/index-CROofCFl.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-CCf0mo80.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -6,7 +6,7 @@ import subprocess
6
6
  import sys
7
7
  import urllib.parse
8
8
  import urllib.request
9
- from collections.abc import Callable, Sequence
9
+ from collections.abc import Awaitable, Callable, Sequence
10
10
  from dataclasses import dataclass
11
11
  from pathlib import Path
12
12
  from uuid import uuid4
@@ -28,9 +28,57 @@ class ToolResult(BaseModel):
28
28
  title: str
29
29
 
30
30
 
31
+ ToolEventEmitter = Callable[[dict[str, object]], Awaitable[None]]
32
+
33
+
34
+ class CommandOutputCollector:
35
+ def __init__(
36
+ self, command: str, emit_event: ToolEventEmitter | None = None
37
+ ) -> None:
38
+ self.command = command
39
+ self.emit_event = emit_event
40
+ self.output_chunks: list[dict[str, str]] = []
41
+
42
+ @property
43
+ def stdout(self) -> str:
44
+ return "".join(
45
+ item["content"] for item in self.output_chunks if item["stream"] == "stdout"
46
+ )
47
+
48
+ @property
49
+ def stderr(self) -> str:
50
+ return "".join(
51
+ item["content"] for item in self.output_chunks if item["stream"] == "stderr"
52
+ )
53
+
54
+ def result(self) -> dict[str, object]:
55
+ return {
56
+ "type": "command",
57
+ "command": self.command,
58
+ "output_chunks": [dict(item) for item in self.output_chunks],
59
+ "stderr": self.stderr,
60
+ "stdout": self.stdout,
61
+ "output": self.stdout or self.stderr,
62
+ }
63
+
64
+ async def append(self, stream: str, content: str) -> None:
65
+ if not content:
66
+ return
67
+ self.output_chunks.append({"stream": stream, "content": content})
68
+ if self.emit_event is not None:
69
+ await self.emit_event({"result": self.result(), "status": "running"})
70
+
71
+ async def append_stderr(self, content: str) -> None:
72
+ await self.append("stderr", content)
73
+
74
+ async def append_stdout(self, content: str) -> None:
75
+ await self.append("stdout", content)
76
+
77
+
31
78
  @dataclass(frozen=True)
32
79
  class ToolContext:
33
80
  cwd: Path
81
+ emit_event: ToolEventEmitter | None = None
34
82
  web_searcher: Callable[[str], Sequence[dict[str, str]]] | None = None
35
83
 
36
84
 
@@ -42,6 +90,7 @@ def command_tool_result(
42
90
  *,
43
91
  command: str,
44
92
  exit_code: int,
93
+ output_chunks: list[dict[str, str]] | None = None,
45
94
  stderr: str,
46
95
  stdout: str,
47
96
  ) -> dict[str, object]:
@@ -49,6 +98,7 @@ def command_tool_result(
49
98
  "type": "command",
50
99
  "command": command,
51
100
  "exit_code": exit_code,
101
+ "output_chunks": [dict(item) for item in output_chunks or []],
52
102
  "stderr": stderr,
53
103
  "stdout": stdout,
54
104
  "output": stdout or stderr,
@@ -454,16 +504,22 @@ async def shell_command_async(
454
504
  command = str(arguments["command"])
455
505
  timeout_seconds = number_argument(arguments, "timeout_seconds", 30)
456
506
  invocation = shell_invocation(command)
507
+ collector = CommandOutputCollector(command, context.emit_event)
457
508
  result = await SandboxRunner(cwd=context.cwd).run_async(
458
- invocation.args, env=invocation.env, timeout_seconds=timeout_seconds
509
+ invocation.args,
510
+ env=invocation.env,
511
+ on_stderr=collector.append_stderr,
512
+ on_stdout=collector.append_stdout,
513
+ timeout_seconds=timeout_seconds,
459
514
  )
460
515
  ok = result.exit_code == 0
461
516
  return ToolResult(
462
517
  result=command_tool_result(
463
518
  command=command,
464
519
  exit_code=result.exit_code,
465
- stderr=result.stderr,
466
- stdout=result.stdout,
520
+ output_chunks=collector.output_chunks,
521
+ stderr=result.stderr or collector.stderr,
522
+ stdout=result.stdout or collector.stdout,
467
523
  ),
468
524
  ok=ok,
469
525
  title=f"Ran {command}",
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ from flowent.llm import CompletionCallable
6
+ from flowent.provider_connections import selected_connection
7
+ from flowent.storage import StateStore, StoredWorkflow, StoredWorkflowDefinition
8
+ from flowent.workflows import (
9
+ WorkflowRunResponse,
10
+ run_workflow_definition,
11
+ validate_workflow_draft,
12
+ workflow_requires_connection,
13
+ )
14
+
15
+
16
+ class WorkflowService:
17
+ def __init__(
18
+ self,
19
+ *,
20
+ chat_completion: CompletionCallable | None,
21
+ store: StateStore,
22
+ ) -> None:
23
+ self.chat_completion = chat_completion
24
+ self.store = store
25
+
26
+ def list_workflows(self) -> list[StoredWorkflow]:
27
+ return self.store.read_workflows()
28
+
29
+ def get_workflow(self, workflow_id: str) -> StoredWorkflow:
30
+ workflow = next(
31
+ (
32
+ current_workflow
33
+ for current_workflow in self.store.read_workflows()
34
+ if current_workflow.id == workflow_id
35
+ ),
36
+ None,
37
+ )
38
+ if workflow is None:
39
+ raise ValueError("Workflow not found.")
40
+ return workflow
41
+
42
+ def save_workflow(self, workflow: StoredWorkflow) -> StoredWorkflow:
43
+ return self.store.save_workflow(
44
+ validate_workflow_draft(
45
+ workflow.model_copy(
46
+ update={"name": workflow.name.strip() or "Untitled Workflow"}
47
+ )
48
+ )
49
+ )
50
+
51
+ async def run_workflow(
52
+ self,
53
+ workflow_id: str,
54
+ *,
55
+ default_input: str = "",
56
+ input_values: Mapping[str, str] | None = None,
57
+ ) -> WorkflowRunResponse:
58
+ workflow = self.get_workflow(workflow_id)
59
+ connection = (
60
+ selected_connection(self.store.read_state())
61
+ if workflow_requires_connection(workflow.definition)
62
+ else None
63
+ )
64
+ return await run_workflow_definition(
65
+ completion=self.chat_completion,
66
+ connection=connection,
67
+ default_input=default_input,
68
+ definition=workflow.definition,
69
+ input_values=input_values,
70
+ workflow_id=workflow.id,
71
+ )
72
+
73
+ async def run_workflow_definition(
74
+ self,
75
+ *,
76
+ default_input: str = "",
77
+ definition: StoredWorkflowDefinition,
78
+ input_values: Mapping[str, str] | None = None,
79
+ workflow_id: str,
80
+ ) -> WorkflowRunResponse:
81
+ connection = (
82
+ selected_connection(self.store.read_state())
83
+ if workflow_requires_connection(definition)
84
+ else None
85
+ )
86
+ return await run_workflow_definition(
87
+ completion=self.chat_completion,
88
+ connection=connection,
89
+ default_input=default_input,
90
+ definition=definition,
91
+ input_values=input_values,
92
+ workflow_id=workflow_id,
93
+ )
@@ -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: