flowent 0.3.2__tar.gz → 0.3.4__tar.gz

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 (60) hide show
  1. {flowent-0.3.2 → flowent-0.3.4}/PKG-INFO +4 -1
  2. {flowent-0.3.2 → flowent-0.3.4}/README.md +3 -0
  3. {flowent-0.3.2 → flowent-0.3.4}/pyproject.toml +1 -1
  4. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/agent.py +61 -2
  5. flowent-0.3.4/src/flowent/agent_runtime.py +220 -0
  6. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/api_models.py +9 -1
  7. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/app.py +9 -2
  8. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/permissions.py +12 -3
  9. flowent-0.3.4/src/flowent/routes/workflow_routes.py +43 -0
  10. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/sandbox.py +63 -19
  11. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/state/models.py +1 -1
  12. flowent-0.3.4/src/flowent/static/assets/index-ByGH1ZWH.css +2 -0
  13. flowent-0.3.4/src/flowent/static/assets/index-D3WSbctU.js +98 -0
  14. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/static/index.html +2 -2
  15. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/tools.py +60 -4
  16. flowent-0.3.4/src/flowent/workflow_service.py +115 -0
  17. flowent-0.3.4/src/flowent/workflow_tools.py +279 -0
  18. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/workflows.py +182 -19
  19. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/workspace/runtime.py +81 -93
  20. flowent-0.3.2/src/flowent/routes/workflow_routes.py +0 -63
  21. flowent-0.3.2/src/flowent/static/assets/index-BX18a4Jz.js +0 -100
  22. flowent-0.3.2/src/flowent/static/assets/index-EC37agAH.css +0 -2
  23. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/__init__.py +0 -0
  24. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/_version.py +0 -0
  25. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/approval.py +0 -0
  26. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/channels.py +0 -0
  27. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/cli.py +0 -0
  28. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/compact.py +0 -0
  29. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/context.py +0 -0
  30. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/llm.py +0 -0
  31. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/logging.py +0 -0
  32. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/main.py +0 -0
  33. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/mcp.py +0 -0
  34. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/mcp_import.py +0 -0
  35. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/network.py +0 -0
  36. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/patch.py +0 -0
  37. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/paths.py +0 -0
  38. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/provider_connections.py +0 -0
  39. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/routes/__init__.py +0 -0
  40. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/routes/integrations.py +0 -0
  41. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/routes/permissions.py +0 -0
  42. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/routes/providers.py +0 -0
  43. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/routes/system.py +0 -0
  44. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/routes/workspace.py +0 -0
  45. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/shell.py +0 -0
  46. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/skills.py +0 -0
  47. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/state/__init__.py +0 -0
  48. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/state/schema.py +0 -0
  49. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/state/store.py +0 -0
  50. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/static/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  51. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/static/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  52. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/static/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  53. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/static/flowent.png +0 -0
  54. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/storage.py +0 -0
  55. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/system_tools.py +0 -0
  56. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/usage.py +0 -0
  57. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/workspace/__init__.py +0 -0
  58. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/workspace/context.py +0 -0
  59. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/workspace/events.py +0 -0
  60. {flowent-0.3.2 → flowent-0.3.4}/src/flowent/workspace/output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowent
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: A workflow orchestration platform for multi-agent collaboration
5
5
  Keywords: agent,agents,ai,ai-agents,assistant,automation,code-generation,llm,mcp,orchestration,sandbox,web-application,workflow
6
6
  Author: ImFeH2
@@ -31,7 +31,10 @@ Description-Content-Type: text/markdown
31
31
 
32
32
  <p align="center">
33
33
  <a href="https://www.npmjs.com/package/flowent"><img src="https://img.shields.io/npm/v/flowent.svg?style=flat-square&label=npm" alt="npm version" /></a>
34
+ <a href="https://www.npmjs.com/package/flowent"><img src="https://img.shields.io/npm/dm/flowent.svg?style=flat-square&label=npm" alt="npm monthly downloads" /></a>
34
35
  <a href="https://pypi.org/project/flowent/"><img src="https://img.shields.io/pypi/v/flowent.svg?style=flat-square&label=PyPI" alt="PyPI version" /></a>
36
+ <a href="https://pypi.org/project/flowent/"><img src="https://img.shields.io/pypi/dm/flowent.svg?style=flat-square&label=PyPI" alt="PyPI monthly downloads" /></a>
37
+ <br />
35
38
  <a href="https://github.com/ImFeH2/flowent/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/flowent.svg?style=flat-square&label=License" alt="License" /></a>
36
39
  <a href="https://github.com/ImFeH2/flowent/actions/workflows/ci.yml"><img src="https://github.com/ImFeH2/flowent/workflows/CI/badge.svg" alt="CI" /></a>
37
40
  <a href="https://github.com/ImFeH2/flowent/actions/workflows/release.yml"><img src="https://github.com/ImFeH2/flowent/workflows/Release/badge.svg" alt="Release" /></a>
@@ -4,7 +4,10 @@
4
4
 
5
5
  <p align="center">
6
6
  <a href="https://www.npmjs.com/package/flowent"><img src="https://img.shields.io/npm/v/flowent.svg?style=flat-square&label=npm" alt="npm version" /></a>
7
+ <a href="https://www.npmjs.com/package/flowent"><img src="https://img.shields.io/npm/dm/flowent.svg?style=flat-square&label=npm" alt="npm monthly downloads" /></a>
7
8
  <a href="https://pypi.org/project/flowent/"><img src="https://img.shields.io/pypi/v/flowent.svg?style=flat-square&label=PyPI" alt="PyPI version" /></a>
9
+ <a href="https://pypi.org/project/flowent/"><img src="https://img.shields.io/pypi/dm/flowent.svg?style=flat-square&label=PyPI" alt="PyPI monthly downloads" /></a>
10
+ <br />
8
11
  <a href="https://github.com/ImFeH2/flowent/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/flowent.svg?style=flat-square&label=License" alt="License" /></a>
9
12
  <a href="https://github.com/ImFeH2/flowent/actions/workflows/ci.yml"><img src="https://github.com/ImFeH2/flowent/workflows/CI/badge.svg" alt="CI" /></a>
10
13
  <a href="https://github.com/ImFeH2/flowent/actions/workflows/release.yml"><img src="https://github.com/ImFeH2/flowent/workflows/Release/badge.svg" alt="Release" /></a>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowent"
3
- version = "0.3.2"
3
+ version = "0.3.4"
4
4
  description = "A workflow orchestration platform for multi-agent collaboration"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import logging
4
5
  from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
5
6
  from dataclasses import dataclass
6
7
  from pathlib import Path
8
+ from typing import cast
7
9
  from uuid import uuid4
8
10
 
9
11
  from pydantic import BaseModel, ConfigDict
@@ -44,6 +46,10 @@ Use tools deliberately:
44
46
  - Apply structured patches for file edits.
45
47
  - Run shell commands for diagnostics, builds, tests, and operations that require the local environment.
46
48
  - When a shell command needs to write outside the current workspace, declare each needed writable directory with sandbox_permissions set to with_additional_permissions and additional_permissions.file_system.write. Flowent reviews elevated permissions automatically, so keep the requested paths specific and tied to the task.
49
+ - Use workflow tools when the user asks to view, inspect, run, create, or modify saved workflows. List workflows first when you need the workflow id. Read a workflow before modifying it.
50
+ - When running a workflow and the user's current message contains one plain value to process, pass that content as the run_workflow input. When a workflow has multiple input nodes or the user provides separate values, use inputs with the exact input node ids from get_workflow.
51
+ - When creating or updating a workflow, save a complete workflow object with valid node ids and edges. If saving fails, use the validation error as context and explain what needs to change.
52
+ - Do not delete workflows. If the user asks to delete a workflow, say that you cannot do that directly.
47
53
  - Search the web only when current external information is needed.
48
54
  - Update the plan when a task has multiple meaningful steps.
49
55
 
@@ -315,6 +321,20 @@ async def run_agent_stream(
315
321
  )
316
322
  logger.log(TRACE_LEVEL, "Tool start item=%r", tool_item)
317
323
  yield AgentStreamEvent(event="tool_start", data={"tool": tool_item})
324
+ tool_event_queue: asyncio.Queue[dict[str, object]] = asyncio.Queue()
325
+
326
+ async def emit_tool_event(
327
+ data: dict[str, object],
328
+ *,
329
+ queue: asyncio.Queue[dict[str, object]] = tool_event_queue,
330
+ tool_id: str = str(tool_item["id"]),
331
+ ) -> None:
332
+ yield_data = {
333
+ "id": tool_id,
334
+ **data,
335
+ }
336
+ await queue.put(yield_data)
337
+
318
338
  extra_result = (
319
339
  await extra_tool_runner(tool_call.name, arguments)
320
340
  if extra_tool_runner is not None
@@ -324,8 +344,12 @@ async def run_agent_stream(
324
344
  extra_result if isinstance(extra_result, ToolResult) else None
325
345
  )
326
346
  if tool_result is None:
327
- context = ToolContext(cwd=cwd, web_searcher=web_searcher)
328
- tool_result = await (
347
+ context = ToolContext(
348
+ cwd=cwd,
349
+ emit_event=emit_tool_event,
350
+ web_searcher=web_searcher,
351
+ )
352
+ tool_task: asyncio.Future[ToolResult] = asyncio.ensure_future(
329
353
  tool_runner(
330
354
  tool_call.name,
331
355
  arguments,
@@ -338,6 +362,41 @@ async def run_agent_stream(
338
362
  context,
339
363
  )
340
364
  )
365
+ pending_event_task: asyncio.Future[dict[str, object]] | None = None
366
+ try:
367
+ while True:
368
+ if pending_event_task is None:
369
+ pending_event_task = asyncio.create_task(
370
+ tool_event_queue.get()
371
+ )
372
+ done, _ = await asyncio.wait(
373
+ {
374
+ cast(asyncio.Future[object], tool_task),
375
+ cast(asyncio.Future[object], pending_event_task),
376
+ },
377
+ return_when=asyncio.FIRST_COMPLETED,
378
+ )
379
+ if pending_event_task in done:
380
+ yield AgentStreamEvent(
381
+ event="tool_update",
382
+ data=pending_event_task.result(),
383
+ )
384
+ pending_event_task = None
385
+ if tool_task in done:
386
+ if pending_event_task is not None:
387
+ pending_event_task.cancel()
388
+ break
389
+ except asyncio.CancelledError:
390
+ tool_task.cancel()
391
+ if pending_event_task is not None:
392
+ pending_event_task.cancel()
393
+ raise
394
+ tool_result = await tool_task
395
+ while not tool_event_queue.empty():
396
+ yield AgentStreamEvent(
397
+ event="tool_update",
398
+ data=tool_event_queue.get_nowait(),
399
+ )
341
400
  result_content = tool_result_model_content(tool_result)
342
401
  logger.debug(
343
402
  "Tool call finished name=%s id=%s ok=%s",
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import (
4
+ AsyncIterator,
5
+ Awaitable,
6
+ Callable,
7
+ Mapping,
8
+ Sequence,
9
+ )
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Protocol
13
+
14
+ from flowent.agent import AgentContextUpdate, AgentStreamEvent, run_agent_stream
15
+ from flowent.approval import (
16
+ ApprovalReviewRequest,
17
+ ApprovalTranscriptEntry,
18
+ review_approval_request,
19
+ )
20
+ from flowent.llm import ChatMessage, CompletionCallable, ProviderConnection
21
+ from flowent.permissions import run_tool_with_path_permissions
22
+ from flowent.storage import StateStore
23
+ from flowent.tools import ToolContext, ToolResult, tool_specs
24
+ from flowent.workflow_tools import (
25
+ WorkflowAgentTools,
26
+ workflow_tool_specs,
27
+ workflow_tool_title,
28
+ )
29
+
30
+ if TYPE_CHECKING:
31
+ from flowent.workflow_service import WorkflowService
32
+
33
+
34
+ class AgentMcpManager(Protocol):
35
+ def tool_specs(self) -> Sequence[Mapping[str, object]]: ...
36
+
37
+ def tool_title(self, name: str) -> str | None: ...
38
+
39
+ async def run_tool(
40
+ self, name: str, arguments: dict[str, object]
41
+ ) -> ToolResult | None: ...
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class AgentRunResult:
46
+ content: str
47
+ thinking: str = ""
48
+
49
+
50
+ class FlowentAgentRuntime:
51
+ def __init__(
52
+ self,
53
+ *,
54
+ chat_completion: CompletionCallable | None,
55
+ cwd: Path,
56
+ mcp_manager: AgentMcpManager | None,
57
+ store: StateStore,
58
+ workflow_service: WorkflowService | None,
59
+ ) -> None:
60
+ self.chat_completion = chat_completion
61
+ self.cwd = cwd
62
+ self.mcp_manager = mcp_manager
63
+ self.store = store
64
+ self.workflow_service = workflow_service
65
+
66
+ def extra_tool_specs(
67
+ self, *, include_workflow_tools: bool = True
68
+ ) -> list[Mapping[str, object]]:
69
+ return [
70
+ *(
71
+ workflow_tool_specs()
72
+ if include_workflow_tools and self.workflow_service is not None
73
+ else []
74
+ ),
75
+ *list(
76
+ self.mcp_manager.tool_specs() if self.mcp_manager is not None else []
77
+ ),
78
+ ]
79
+
80
+ def model_tool_specs(
81
+ self, *, include_workflow_tools: bool = True
82
+ ) -> list[Mapping[str, object]]:
83
+ return [
84
+ *tool_specs(),
85
+ *self.extra_tool_specs(include_workflow_tools=include_workflow_tools),
86
+ ]
87
+
88
+ def extra_tool_title(self, name: str) -> str | None:
89
+ return (
90
+ workflow_tool_title(name) if self.workflow_service is not None else None
91
+ ) or (
92
+ self.mcp_manager.tool_title(name) if self.mcp_manager is not None else None
93
+ )
94
+
95
+ def workflow_tools(self, workflow_depth: int = 0) -> WorkflowAgentTools | None:
96
+ if self.workflow_service is None:
97
+ return None
98
+ return WorkflowAgentTools(
99
+ self.workflow_service,
100
+ workflow_depth=workflow_depth,
101
+ )
102
+
103
+ async def run_extra_tool(
104
+ self,
105
+ name: str,
106
+ arguments: dict[str, object],
107
+ *,
108
+ include_workflow_tools: bool = True,
109
+ workflow_depth: int = 0,
110
+ ) -> ToolResult | None:
111
+ workflow_tools = (
112
+ self.workflow_tools(workflow_depth) if include_workflow_tools else None
113
+ )
114
+ if workflow_tools is not None:
115
+ workflow_result = await workflow_tools.run_tool(name, arguments)
116
+ if workflow_result is not None:
117
+ return workflow_result
118
+ if self.mcp_manager is None:
119
+ return None
120
+ return await self.mcp_manager.run_tool(name, arguments)
121
+
122
+ async def stream(
123
+ self,
124
+ *,
125
+ approval_transcript: Sequence[ApprovalTranscriptEntry] = (),
126
+ connection: ProviderConnection,
127
+ context_compactor: Callable[
128
+ [Sequence[Mapping[str, object]]], Awaitable[AgentContextUpdate | None]
129
+ ]
130
+ | None = None,
131
+ include_workflow_tools: bool = True,
132
+ messages: Sequence[ChatMessage | Mapping[str, object]],
133
+ user_request: str,
134
+ workflow_depth: int = 0,
135
+ ) -> AsyncIterator[AgentStreamEvent]:
136
+ transcript = list(approval_transcript)
137
+ request_messages = [
138
+ message.model_dump() if isinstance(message, ChatMessage) else dict(message)
139
+ for message in messages
140
+ ]
141
+
142
+ async def review_tool_approval(request: ApprovalReviewRequest):
143
+ return await review_approval_request(
144
+ connection,
145
+ request.model_copy(
146
+ update={
147
+ "transcript": transcript,
148
+ "user_request": user_request,
149
+ }
150
+ ),
151
+ completion=self.chat_completion,
152
+ )
153
+
154
+ async def tool_runner(
155
+ name: str,
156
+ arguments: dict[str, object],
157
+ context: ToolContext,
158
+ ) -> ToolResult:
159
+ return await run_tool_with_path_permissions(
160
+ name,
161
+ arguments,
162
+ context,
163
+ review_approval=review_tool_approval,
164
+ writable_paths=[
165
+ Path(path.path) for path in self.store.read_writable_paths()
166
+ ],
167
+ )
168
+
169
+ async def extra_tool_runner(
170
+ name: str, arguments: dict[str, object]
171
+ ) -> ToolResult | None:
172
+ return await self.run_extra_tool(
173
+ name,
174
+ arguments,
175
+ include_workflow_tools=include_workflow_tools,
176
+ workflow_depth=workflow_depth,
177
+ )
178
+
179
+ async for event in run_agent_stream(
180
+ completion=self.chat_completion,
181
+ connection=connection,
182
+ context_compactor=context_compactor,
183
+ cwd=self.cwd,
184
+ extra_tool_runner=extra_tool_runner,
185
+ extra_tool_specs=self.extra_tool_specs(
186
+ include_workflow_tools=include_workflow_tools
187
+ ),
188
+ extra_tool_title=self.extra_tool_title,
189
+ messages=request_messages,
190
+ tool_runner=tool_runner,
191
+ ):
192
+ yield event
193
+
194
+ async def complete(
195
+ self,
196
+ *,
197
+ approval_transcript: Sequence[ApprovalTranscriptEntry] = (),
198
+ connection: ProviderConnection,
199
+ include_workflow_tools: bool = True,
200
+ messages: Sequence[ChatMessage | Mapping[str, object]],
201
+ user_request: str,
202
+ workflow_depth: int = 0,
203
+ ) -> AgentRunResult:
204
+ async for event in self.stream(
205
+ approval_transcript=approval_transcript,
206
+ connection=connection,
207
+ include_workflow_tools=include_workflow_tools,
208
+ messages=messages,
209
+ user_request=user_request,
210
+ workflow_depth=workflow_depth,
211
+ ):
212
+ if event.event != "done":
213
+ continue
214
+ message = event.data.get("message")
215
+ if isinstance(message, Mapping):
216
+ return AgentRunResult(
217
+ content=str(message.get("content") or ""),
218
+ thinking=str(message.get("thinking") or ""),
219
+ )
220
+ raise RuntimeError("Agent did not return a final response.")
@@ -1,6 +1,6 @@
1
1
  from typing import Literal
2
2
 
3
- from pydantic import BaseModel, ConfigDict
3
+ from pydantic import BaseModel, ConfigDict, Field
4
4
 
5
5
  from flowent.llm import ProviderFormat
6
6
  from flowent.storage import StoredMessage, StoredWritablePath
@@ -65,6 +65,14 @@ class WorkspaceClearResponse(BaseModel):
65
65
  usage_info: TokenUsageInfo | None = None
66
66
 
67
67
 
68
+ class WorkflowRunRequest(BaseModel):
69
+ model_config = ConfigDict(extra="forbid")
70
+
71
+ input: str = ""
72
+ inputs: dict[str, str] = Field(default_factory=dict)
73
+ timer_id: str = ""
74
+
75
+
68
76
  class AboutResponse(BaseModel):
69
77
  model_config = ConfigDict(extra="forbid")
70
78
 
@@ -23,6 +23,7 @@ from flowent.routes.workspace import register_workspace_routes
23
23
  from flowent.sandbox import ensure_sandbox_available
24
24
  from flowent.storage import StateStore
25
25
  from flowent.system_tools import ensure_ripgrep_available
26
+ from flowent.workflow_service import WorkflowService
26
27
  from flowent.workspace.runtime import WorkspaceRuntime
27
28
 
28
29
  logger = logging.getLogger("flowent.app")
@@ -57,6 +58,12 @@ def create_app(
57
58
  store = StateStore()
58
59
  compact_provider = LocalSummaryCompactProvider()
59
60
  mcp_manager = McpManager(store=store, transport=mcp_transport)
61
+ workflow_service = WorkflowService(
62
+ chat_completion=chat_completion,
63
+ cwd=cwd,
64
+ mcp_manager=mcp_manager,
65
+ store=store,
66
+ )
60
67
 
61
68
  static_dir = frontend_static_directory().resolve(strict=False)
62
69
  logger.debug("Flowent app created serve_frontend=%s", serve_frontend)
@@ -69,6 +76,7 @@ def create_app(
69
76
  cwd=cwd,
70
77
  mcp_manager=mcp_manager,
71
78
  store=store,
79
+ workflow_service=workflow_service,
72
80
  )
73
81
 
74
82
  telegram_bot_manager = TelegramBotManager(
@@ -121,8 +129,7 @@ def create_app(
121
129
  )
122
130
  register_workflow_routes(
123
131
  app,
124
- chat_completion=chat_completion,
125
- store=store,
132
+ workflow_service=workflow_service,
126
133
  )
127
134
  register_permission_routes(app, cwd=cwd, store=store)
128
135
  register_workspace_routes(app, runtime=runtime, store=store)
@@ -14,6 +14,7 @@ from flowent.patch import affected_paths
14
14
  from flowent.sandbox import SandboxError, SandboxRunner, path_is_within
15
15
  from flowent.shell import shell_invocation
16
16
  from flowent.tools import (
17
+ CommandOutputCollector,
17
18
  ToolContext,
18
19
  ToolResult,
19
20
  command_tool_result,
@@ -305,17 +306,25 @@ async def shell_command_with_writable_paths(
305
306
  command = str(arguments["command"])
306
307
  timeout_seconds = number_argument(arguments, "timeout_seconds", 30)
307
308
  invocation = shell_invocation(command)
309
+ collector = CommandOutputCollector(command, context.emit_event)
308
310
  result = await SandboxRunner(
309
311
  cwd=context.cwd,
310
312
  writable_roots=writable_paths,
311
- ).run_async(invocation.args, env=invocation.env, timeout_seconds=timeout_seconds)
313
+ ).run_async(
314
+ invocation.args,
315
+ env=invocation.env,
316
+ on_stderr=collector.append_stderr,
317
+ on_stdout=collector.append_stdout,
318
+ timeout_seconds=timeout_seconds,
319
+ )
312
320
  ok = result.exit_code == 0
313
321
  return ToolResult(
314
322
  result=command_tool_result(
315
323
  command=command,
316
324
  exit_code=result.exit_code,
317
- stderr=result.stderr,
318
- stdout=result.stdout,
325
+ output_chunks=collector.output_chunks,
326
+ stderr=result.stderr or collector.stderr,
327
+ stdout=result.stdout or collector.stdout,
319
328
  ),
320
329
  ok=ok,
321
330
  title=f"Ran {command}",
@@ -0,0 +1,43 @@
1
+ from fastapi import Body, FastAPI, HTTPException
2
+
3
+ from flowent.api_models import WorkflowRunRequest
4
+ from flowent.storage import StoredWorkflow
5
+ from flowent.workflow_service import WorkflowService
6
+ from flowent.workflows import WorkflowRunResponse
7
+
8
+ OPTIONAL_WORKFLOW_RUN_BODY = Body(default=None)
9
+
10
+
11
+ def register_workflow_routes(
12
+ app: FastAPI,
13
+ *,
14
+ workflow_service: WorkflowService,
15
+ ) -> None:
16
+ @app.put("/api/workflows")
17
+ async def save_workflow(workflow: StoredWorkflow) -> StoredWorkflow:
18
+ try:
19
+ return workflow_service.save_workflow(workflow)
20
+ except ValueError as error:
21
+ raise HTTPException(status_code=400, detail=str(error)) from error
22
+
23
+ @app.delete("/api/workflows/{workflow_id}")
24
+ async def delete_workflow(workflow_id: str) -> dict[str, bool]:
25
+ workflow_service.store.delete_workflow(workflow_id)
26
+ return {"ok": True}
27
+
28
+ @app.post("/api/workflows/{workflow_id}/run")
29
+ async def run_workflow(
30
+ workflow_id: str,
31
+ request: WorkflowRunRequest | None = OPTIONAL_WORKFLOW_RUN_BODY,
32
+ ) -> WorkflowRunResponse:
33
+ run_request = request or WorkflowRunRequest()
34
+ try:
35
+ return await workflow_service.run_workflow(
36
+ workflow_id,
37
+ default_input=run_request.input,
38
+ input_values=run_request.inputs,
39
+ timer_node_id=run_request.timer_id,
40
+ )
41
+ except ValueError as error:
42
+ status_code = 404 if str(error) == "Workflow not found." else 400
43
+ raise HTTPException(status_code=status_code, detail=str(error)) from error
@@ -8,7 +8,7 @@ import shutil
8
8
  import signal
9
9
  import subprocess
10
10
  import tempfile
11
- from collections.abc import Mapping
11
+ from collections.abc import Awaitable, Callable, Mapping
12
12
  from contextlib import suppress
13
13
  from dataclasses import dataclass
14
14
  from pathlib import Path
@@ -34,6 +34,9 @@ class SandboxError(RuntimeError):
34
34
  pass
35
35
 
36
36
 
37
+ OutputCallback = Callable[[str], Awaitable[None]]
38
+
39
+
37
40
  SANDBOX_INSTALL_HINT = (
38
41
  "Install bubblewrap and try again. Debian/Ubuntu: "
39
42
  "sudo apt-get install bubblewrap. Fedora: sudo dnf install bubblewrap. "
@@ -213,6 +216,29 @@ class SandboxRunner:
213
216
  return value.decode(errors="replace")
214
217
  return value
215
218
 
219
+ async def _read_stream(
220
+ self,
221
+ stream: asyncio.StreamReader | None,
222
+ callback: OutputCallback | None,
223
+ ) -> str:
224
+ if stream is None:
225
+ return ""
226
+ chunks: list[str] = []
227
+ remaining = self.output_limit
228
+ while True:
229
+ chunk = await stream.read(4096)
230
+ if not chunk:
231
+ break
232
+ text = self._text_output(chunk)
233
+ if remaining <= 0:
234
+ continue
235
+ limited = text[:remaining]
236
+ remaining -= len(limited)
237
+ chunks.append(limited)
238
+ if callback is not None and limited:
239
+ await callback(limited)
240
+ return "".join(chunks)
241
+
216
242
  def __init__(
217
243
  self,
218
244
  *,
@@ -334,6 +360,8 @@ class SandboxRunner:
334
360
  *,
335
361
  env: dict[str, str] | None = None,
336
362
  input_text: str | None = None,
363
+ on_stderr: OutputCallback | None = None,
364
+ on_stdout: OutputCallback | None = None,
337
365
  timeout_seconds: float | None = None,
338
366
  ) -> CommandResult:
339
367
  process_env = build_shell_environment(env)
@@ -346,22 +374,26 @@ class SandboxRunner:
346
374
  stdout=asyncio.subprocess.PIPE,
347
375
  stderr=asyncio.subprocess.PIPE,
348
376
  )
377
+ stdout_task = asyncio.create_task(self._read_stream(process.stdout, on_stdout))
378
+ stderr_task = asyncio.create_task(self._read_stream(process.stderr, on_stderr))
379
+ if input_text is not None and process.stdin is not None:
380
+ process.stdin.write(input_text.encode())
381
+ await process.stdin.drain()
382
+ process.stdin.close()
349
383
  try:
350
- stdout, stderr = await asyncio.wait_for(
351
- process.communicate(
352
- input_text.encode() if input_text is not None else None
353
- ),
354
- timeout=timeout_seconds or self.timeout_seconds,
384
+ await asyncio.wait_for(
385
+ process.wait(), timeout=timeout_seconds or self.timeout_seconds
355
386
  )
356
387
  except TimeoutError as error:
357
388
  with suppress(ProcessLookupError):
358
389
  os.killpg(process.pid, signal.SIGKILL)
359
- stdout, stderr = await process.communicate()
390
+ await process.wait()
391
+ stdout, stderr = await asyncio.gather(stdout_task, stderr_task)
360
392
  return CommandResult(
361
393
  command=" ".join(command),
362
394
  exit_code=124,
363
395
  stderr=str(error) or "Command timed out.",
364
- stdout=self._text_output(stdout)[: self.output_limit],
396
+ stdout=stdout,
365
397
  )
366
398
  except asyncio.CancelledError:
367
399
  with suppress(ProcessLookupError):
@@ -372,13 +404,16 @@ class SandboxRunner:
372
404
  with suppress(ProcessLookupError):
373
405
  os.killpg(process.pid, signal.SIGKILL)
374
406
  await process.wait()
407
+ for task in [stdout_task, stderr_task]:
408
+ task.cancel()
375
409
  raise
376
410
 
411
+ stdout, stderr = await asyncio.gather(stdout_task, stderr_task)
377
412
  return CommandResult(
378
413
  command=" ".join(command),
379
414
  exit_code=process.returncode or 0,
380
- stderr=self._text_output(stderr)[: self.output_limit],
381
- stdout=self._text_output(stdout)[: self.output_limit],
415
+ stderr=stderr,
416
+ stdout=stdout,
382
417
  )
383
418
 
384
419
  async def run_async(
@@ -387,6 +422,8 @@ class SandboxRunner:
387
422
  *,
388
423
  env: dict[str, str] | None = None,
389
424
  input_text: str | None = None,
425
+ on_stderr: OutputCallback | None = None,
426
+ on_stdout: OutputCallback | None = None,
390
427
  timeout_seconds: float | None = None,
391
428
  ) -> CommandResult:
392
429
  sandbox_command = self.build_command(command)
@@ -405,22 +442,26 @@ class SandboxRunner:
405
442
  stdout=asyncio.subprocess.PIPE,
406
443
  stderr=asyncio.subprocess.PIPE,
407
444
  )
445
+ stdout_task = asyncio.create_task(self._read_stream(process.stdout, on_stdout))
446
+ stderr_task = asyncio.create_task(self._read_stream(process.stderr, on_stderr))
447
+ if input_text is not None and process.stdin is not None:
448
+ process.stdin.write(input_text.encode())
449
+ await process.stdin.drain()
450
+ process.stdin.close()
408
451
  try:
409
- stdout, stderr = await asyncio.wait_for(
410
- process.communicate(
411
- input_text.encode() if input_text is not None else None
412
- ),
413
- timeout=timeout_seconds or self.timeout_seconds,
452
+ await asyncio.wait_for(
453
+ process.wait(), timeout=timeout_seconds or self.timeout_seconds
414
454
  )
415
455
  except TimeoutError as error:
416
456
  with suppress(ProcessLookupError):
417
457
  os.killpg(process.pid, signal.SIGKILL)
418
- stdout, stderr = await process.communicate()
458
+ await process.wait()
459
+ stdout, stderr = await asyncio.gather(stdout_task, stderr_task)
419
460
  return CommandResult(
420
461
  command=" ".join(command),
421
462
  exit_code=124,
422
463
  stderr=str(error) or "Command timed out.",
423
- stdout=self._text_output(stdout)[: self.output_limit],
464
+ stdout=stdout,
424
465
  )
425
466
  except asyncio.CancelledError:
426
467
  with suppress(ProcessLookupError):
@@ -431,14 +472,17 @@ class SandboxRunner:
431
472
  with suppress(ProcessLookupError):
432
473
  os.killpg(process.pid, signal.SIGKILL)
433
474
  await process.wait()
475
+ for task in [stdout_task, stderr_task]:
476
+ task.cancel()
434
477
  raise
435
478
  finally:
436
479
  if sandbox_command.seccomp_file is not None:
437
480
  sandbox_command.seccomp_file.close()
438
481
 
482
+ stdout, stderr = await asyncio.gather(stdout_task, stderr_task)
439
483
  return CommandResult(
440
484
  command=" ".join(command),
441
485
  exit_code=process.returncode or 0,
442
- stderr=self._text_output(stderr)[: self.output_limit],
443
- stdout=self._text_output(stdout)[: self.output_limit],
486
+ stderr=stderr,
487
+ stdout=stdout,
444
488
  )