flowent 0.3.2 → 0.3.4

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-D3WSbctU.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-ByGH1ZWH.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,115 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from pathlib import Path
5
+
6
+ from flowent.agent_runtime import FlowentAgentRuntime
7
+ from flowent.llm import CompletionCallable
8
+ from flowent.mcp import McpManager
9
+ from flowent.provider_connections import selected_connection
10
+ from flowent.storage import StateStore, StoredWorkflow, StoredWorkflowDefinition
11
+ from flowent.workflows import (
12
+ WorkflowRunResponse,
13
+ run_workflow_definition,
14
+ validate_workflow_draft,
15
+ workflow_requires_connection,
16
+ )
17
+
18
+
19
+ class WorkflowService:
20
+ def __init__(
21
+ self,
22
+ *,
23
+ chat_completion: CompletionCallable | None,
24
+ cwd: Path,
25
+ mcp_manager: McpManager,
26
+ store: StateStore,
27
+ ) -> None:
28
+ self.chat_completion = chat_completion
29
+ self.cwd = cwd
30
+ self.mcp_manager = mcp_manager
31
+ self.store = store
32
+ self.agent_runtime = FlowentAgentRuntime(
33
+ chat_completion=chat_completion,
34
+ cwd=cwd,
35
+ mcp_manager=mcp_manager,
36
+ store=store,
37
+ workflow_service=self,
38
+ )
39
+
40
+ def list_workflows(self) -> list[StoredWorkflow]:
41
+ return self.store.read_workflows()
42
+
43
+ def get_workflow(self, workflow_id: str) -> StoredWorkflow:
44
+ workflow = next(
45
+ (
46
+ current_workflow
47
+ for current_workflow in self.store.read_workflows()
48
+ if current_workflow.id == workflow_id
49
+ ),
50
+ None,
51
+ )
52
+ if workflow is None:
53
+ raise ValueError("Workflow not found.")
54
+ return workflow
55
+
56
+ def save_workflow(self, workflow: StoredWorkflow) -> StoredWorkflow:
57
+ return self.store.save_workflow(
58
+ validate_workflow_draft(
59
+ workflow.model_copy(
60
+ update={"name": workflow.name.strip() or "Untitled Workflow"}
61
+ )
62
+ )
63
+ )
64
+
65
+ async def run_workflow(
66
+ self,
67
+ workflow_id: str,
68
+ *,
69
+ default_input: str = "",
70
+ input_values: Mapping[str, str] | None = None,
71
+ timer_node_id: str = "",
72
+ workflow_depth: int = 0,
73
+ ) -> WorkflowRunResponse:
74
+ workflow = self.get_workflow(workflow_id)
75
+ connection = (
76
+ selected_connection(self.store.read_state())
77
+ if workflow_requires_connection(workflow.definition)
78
+ else None
79
+ )
80
+ return await run_workflow_definition(
81
+ connection=connection,
82
+ default_input=default_input,
83
+ definition=workflow.definition,
84
+ input_values=input_values,
85
+ runtime=self.agent_runtime,
86
+ timer_node_id=timer_node_id,
87
+ workflow_depth=workflow_depth,
88
+ workflow_id=workflow.id,
89
+ )
90
+
91
+ async def run_workflow_definition(
92
+ self,
93
+ *,
94
+ default_input: str = "",
95
+ definition: StoredWorkflowDefinition,
96
+ input_values: Mapping[str, str] | None = None,
97
+ timer_node_id: str = "",
98
+ workflow_depth: int = 0,
99
+ workflow_id: str,
100
+ ) -> WorkflowRunResponse:
101
+ connection = (
102
+ selected_connection(self.store.read_state())
103
+ if workflow_requires_connection(definition)
104
+ else None
105
+ )
106
+ return await run_workflow_definition(
107
+ connection=connection,
108
+ default_input=default_input,
109
+ definition=definition,
110
+ input_values=input_values,
111
+ runtime=self.agent_runtime,
112
+ timer_node_id=timer_node_id,
113
+ workflow_depth=workflow_depth,
114
+ workflow_id=workflow_id,
115
+ )
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from uuid import uuid4
5
+
6
+ from pydantic import ValidationError
7
+
8
+ from flowent.storage import StoredWorkflow
9
+ from flowent.tools import ToolResult, text_tool_result
10
+ from flowent.workflows import WorkflowRunResponse
11
+
12
+ if TYPE_CHECKING:
13
+ from flowent.workflow_service import WorkflowService
14
+
15
+ MAX_WORKFLOW_TOOL_DEPTH = 3
16
+
17
+
18
+ def workflow_tool_specs() -> list[dict[str, object]]:
19
+ workflow_schema: dict[str, object] = {"type": "object"}
20
+ return [
21
+ {
22
+ "type": "function",
23
+ "function": {
24
+ "name": "list_workflows",
25
+ "description": "List saved workflows with their ids, names, node counts, and edge counts.",
26
+ "parameters": {"type": "object", "properties": {}},
27
+ },
28
+ },
29
+ {
30
+ "type": "function",
31
+ "function": {
32
+ "name": "get_workflow",
33
+ "description": "Read a saved workflow definition by id before answering questions or editing it.",
34
+ "parameters": {
35
+ "type": "object",
36
+ "properties": {"workflow_id": {"type": "string"}},
37
+ "required": ["workflow_id"],
38
+ },
39
+ },
40
+ },
41
+ {
42
+ "type": "function",
43
+ "function": {
44
+ "name": "run_workflow",
45
+ "description": "Run a saved workflow. Pass input when the user's current message contains the content the workflow should process.",
46
+ "parameters": {
47
+ "type": "object",
48
+ "properties": {
49
+ "workflow_id": {"type": "string"},
50
+ "input": {"type": "string"},
51
+ "inputs": {
52
+ "type": "object",
53
+ "additionalProperties": {"type": "string"},
54
+ },
55
+ },
56
+ "required": ["workflow_id"],
57
+ },
58
+ },
59
+ },
60
+ {
61
+ "type": "function",
62
+ "function": {
63
+ "name": "create_workflow",
64
+ "description": "Create a workflow. workflow must include id, name, and definition with version, nodes, and edges.",
65
+ "parameters": {
66
+ "type": "object",
67
+ "properties": {"workflow": workflow_schema},
68
+ "required": ["workflow"],
69
+ },
70
+ },
71
+ },
72
+ {
73
+ "type": "function",
74
+ "function": {
75
+ "name": "update_workflow",
76
+ "description": "Replace an existing workflow. Read it first and provide the complete updated workflow object.",
77
+ "parameters": {
78
+ "type": "object",
79
+ "properties": {"workflow": workflow_schema},
80
+ "required": ["workflow"],
81
+ },
82
+ },
83
+ },
84
+ ]
85
+
86
+
87
+ def workflow_tool_title(name: str) -> str | None:
88
+ if name == "list_workflows":
89
+ return "Listing workflows"
90
+ if name == "get_workflow":
91
+ return "Reading workflow"
92
+ if name == "run_workflow":
93
+ return "Running workflow"
94
+ if name == "create_workflow":
95
+ return "Creating workflow"
96
+ if name == "update_workflow":
97
+ return "Updating workflow"
98
+ return None
99
+
100
+
101
+ class WorkflowAgentTools:
102
+ def __init__(self, service: WorkflowService, *, workflow_depth: int = 0) -> None:
103
+ self.service = service
104
+ self.workflow_depth = workflow_depth
105
+
106
+ async def run_tool(
107
+ self, name: str, arguments: dict[str, object]
108
+ ) -> ToolResult | None:
109
+ title = workflow_tool_title(name)
110
+ if title is None:
111
+ return None
112
+ try:
113
+ if name == "list_workflows":
114
+ return self.list_workflows()
115
+ if name == "get_workflow":
116
+ return self.get_workflow(arguments)
117
+ if name == "run_workflow":
118
+ return await self.run_workflow(arguments)
119
+ if name == "create_workflow":
120
+ return self.create_workflow(arguments)
121
+ if name == "update_workflow":
122
+ return self.update_workflow(arguments)
123
+ except Exception as error:
124
+ return ToolResult(
125
+ result=text_tool_result(str(error) or "Workflow tool failed."),
126
+ ok=False,
127
+ title=title,
128
+ )
129
+ return None
130
+
131
+ def list_workflows(self) -> ToolResult:
132
+ workflows = self.service.list_workflows()
133
+ summaries = [workflow_summary(workflow) for workflow in workflows]
134
+ if summaries:
135
+ output = "\n".join(
136
+ f"{summary['id']}: {summary['name']} ({summary['node_count']} nodes, {summary['edge_count']} edges)"
137
+ for summary in summaries
138
+ )
139
+ else:
140
+ output = "No workflows are saved."
141
+ return ToolResult(
142
+ result={
143
+ "type": "workflow_list",
144
+ "output": output,
145
+ "workflows": summaries,
146
+ },
147
+ title="Listed workflows",
148
+ )
149
+
150
+ def get_workflow(self, arguments: dict[str, object]) -> ToolResult:
151
+ workflow = self.service.get_workflow(str(arguments["workflow_id"]))
152
+ summary = workflow_summary(workflow)
153
+ output = (
154
+ f"{workflow.name} has {summary['node_count']} nodes and "
155
+ f"{summary['edge_count']} edges."
156
+ )
157
+ return ToolResult(
158
+ result={
159
+ "type": "workflow",
160
+ "output": output,
161
+ "summary": summary,
162
+ "workflow": workflow.model_dump(mode="json"),
163
+ },
164
+ title=f"Read {workflow.name}",
165
+ )
166
+
167
+ async def run_workflow(self, arguments: dict[str, object]) -> ToolResult:
168
+ if self.workflow_depth >= MAX_WORKFLOW_TOOL_DEPTH:
169
+ raise ValueError("Workflow nesting is too deep.")
170
+ workflow_id = str(arguments["workflow_id"])
171
+ workflow = self.service.get_workflow(workflow_id)
172
+ result = await self.service.run_workflow(
173
+ workflow_id,
174
+ default_input=string_argument(arguments, "input"),
175
+ input_values=string_map_argument(arguments, "inputs"),
176
+ workflow_depth=self.workflow_depth + 1,
177
+ )
178
+ output = workflow_run_output(workflow.name, result)
179
+ return ToolResult(
180
+ result={
181
+ "type": "workflow_run",
182
+ "node_results": [
183
+ node_result.model_dump(mode="json")
184
+ for node_result in result.node_results
185
+ ],
186
+ "output": output,
187
+ "outputs": dict(result.outputs),
188
+ "status": result.status,
189
+ "workflow_id": workflow.id,
190
+ "workflow_name": workflow.name,
191
+ },
192
+ ok=result.status == "success",
193
+ title=f"Ran {workflow.name}",
194
+ )
195
+
196
+ def create_workflow(self, arguments: dict[str, object]) -> ToolResult:
197
+ workflow = workflow_argument(arguments)
198
+ saved = self.service.save_workflow(
199
+ workflow.model_copy(update={"id": str(uuid4())})
200
+ )
201
+ return saved_workflow_result(saved, "Created")
202
+
203
+ def update_workflow(self, arguments: dict[str, object]) -> ToolResult:
204
+ workflow = workflow_argument(arguments)
205
+ self.service.get_workflow(workflow.id)
206
+ saved = self.service.save_workflow(workflow)
207
+ return saved_workflow_result(saved, "Updated")
208
+
209
+
210
+ def string_argument(arguments: dict[str, object], name: str) -> str:
211
+ value = arguments.get(name, "")
212
+ if value is None:
213
+ return ""
214
+ return value if isinstance(value, str) else str(value)
215
+
216
+
217
+ def string_map_argument(
218
+ arguments: dict[str, object], name: str
219
+ ) -> dict[str, str] | None:
220
+ value = arguments.get(name)
221
+ if not isinstance(value, dict):
222
+ return None
223
+ return {str(key): str(item) for key, item in value.items()}
224
+
225
+
226
+ def workflow_argument(arguments: dict[str, object]) -> StoredWorkflow:
227
+ value = arguments.get("workflow")
228
+ if not isinstance(value, dict):
229
+ raise ValueError("Workflow must be an object.")
230
+ try:
231
+ return StoredWorkflow.model_validate(value)
232
+ except ValidationError as error:
233
+ raise ValueError(error.errors()[0]["msg"]) from error
234
+
235
+
236
+ def workflow_summary(workflow: StoredWorkflow) -> dict[str, object]:
237
+ return {
238
+ "edge_count": len(workflow.definition.edges),
239
+ "id": workflow.id,
240
+ "name": workflow.name,
241
+ "node_count": len(workflow.definition.nodes),
242
+ "nodes": [
243
+ {"id": node.id, "name": node.name, "type": node.type}
244
+ for node in workflow.definition.nodes
245
+ ],
246
+ }
247
+
248
+
249
+ def workflow_run_output(name: str, result: WorkflowRunResponse) -> str:
250
+ if result.status == "success":
251
+ if result.outputs:
252
+ rendered_outputs = "\n".join(
253
+ f"{key}: {value}" for key, value in result.outputs.items()
254
+ )
255
+ return f"{name} completed.\n{rendered_outputs}"
256
+ return f"{name} completed."
257
+ failures = [
258
+ f"{node_result.id}: {node_result.error}"
259
+ for node_result in result.node_results
260
+ if node_result.status == "failed"
261
+ ]
262
+ return f"{name} failed.\n" + ("\n".join(failures) or "No failure details.")
263
+
264
+
265
+ def saved_workflow_result(workflow: StoredWorkflow, action: str) -> ToolResult:
266
+ summary = workflow_summary(workflow)
267
+ output = (
268
+ f"{action} {workflow.name} with {summary['node_count']} nodes and "
269
+ f"{summary['edge_count']} edges."
270
+ )
271
+ return ToolResult(
272
+ result={
273
+ "type": "workflow",
274
+ "output": output,
275
+ "summary": summary,
276
+ "workflow": workflow.model_dump(mode="json"),
277
+ },
278
+ title=f"{action} {workflow.name}",
279
+ )