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.
- package/README.md +3 -0
- package/backend/README.md +3 -0
- package/backend/pyproject.toml +1 -1
- package/backend/src/flowent/agent.py +61 -2
- package/backend/src/flowent/agent_runtime.py +220 -0
- package/backend/src/flowent/api_models.py +9 -1
- package/backend/src/flowent/app.py +9 -2
- package/backend/src/flowent/permissions.py +12 -3
- package/backend/src/flowent/routes/workflow_routes.py +22 -42
- package/backend/src/flowent/sandbox.py +63 -19
- package/backend/src/flowent/state/models.py +1 -1
- package/backend/src/flowent/static/assets/index-ByGH1ZWH.css +2 -0
- package/backend/src/flowent/static/assets/index-D3WSbctU.js +98 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/tools.py +60 -4
- package/backend/src/flowent/workflow_service.py +115 -0
- package/backend/src/flowent/workflow_tools.py +279 -0
- package/backend/src/flowent/workflows.py +182 -19
- package/backend/src/flowent/workspace/runtime.py +81 -93
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-ByGH1ZWH.css +2 -0
- package/dist/frontend/assets/index-D3WSbctU.js +98 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-BX18a4Jz.js +0 -100
- package/backend/src/flowent/static/assets/index-EC37agAH.css +0 -2
- package/dist/frontend/assets/index-BX18a4Jz.js +0 -100
- package/dist/frontend/assets/index-EC37agAH.css +0 -2
package/README.md
CHANGED
|
@@ -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>
|
package/backend/README.md
CHANGED
|
@@ -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>
|
package/backend/pyproject.toml
CHANGED
|
@@ -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(
|
|
328
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
318
|
-
|
|
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}",
|
|
@@ -1,63 +1,43 @@
|
|
|
1
|
-
from fastapi import FastAPI, HTTPException
|
|
1
|
+
from fastapi import Body, FastAPI, HTTPException
|
|
2
2
|
|
|
3
|
-
from flowent.
|
|
4
|
-
from flowent.
|
|
5
|
-
from flowent.
|
|
6
|
-
from flowent.workflows import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
validate_workflow_draft,
|
|
10
|
-
workflow_requires_connection,
|
|
11
|
-
)
|
|
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)
|
|
12
9
|
|
|
13
10
|
|
|
14
11
|
def register_workflow_routes(
|
|
15
12
|
app: FastAPI,
|
|
16
13
|
*,
|
|
17
|
-
|
|
18
|
-
store: StateStore,
|
|
14
|
+
workflow_service: WorkflowService,
|
|
19
15
|
) -> None:
|
|
20
16
|
@app.put("/api/workflows")
|
|
21
17
|
async def save_workflow(workflow: StoredWorkflow) -> StoredWorkflow:
|
|
22
18
|
try:
|
|
23
|
-
return
|
|
24
|
-
validate_workflow_draft(
|
|
25
|
-
workflow.model_copy(
|
|
26
|
-
update={"name": workflow.name.strip() or "Untitled Workflow"}
|
|
27
|
-
)
|
|
28
|
-
)
|
|
29
|
-
)
|
|
19
|
+
return workflow_service.save_workflow(workflow)
|
|
30
20
|
except ValueError as error:
|
|
31
21
|
raise HTTPException(status_code=400, detail=str(error)) from error
|
|
32
22
|
|
|
33
23
|
@app.delete("/api/workflows/{workflow_id}")
|
|
34
24
|
async def delete_workflow(workflow_id: str) -> dict[str, bool]:
|
|
35
|
-
store.delete_workflow(workflow_id)
|
|
25
|
+
workflow_service.store.delete_workflow(workflow_id)
|
|
36
26
|
return {"ok": True}
|
|
37
27
|
|
|
38
28
|
@app.post("/api/workflows/{workflow_id}/run")
|
|
39
|
-
async def run_workflow(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if current_workflow.id == workflow_id
|
|
45
|
-
),
|
|
46
|
-
None,
|
|
47
|
-
)
|
|
48
|
-
if workflow is None:
|
|
49
|
-
raise HTTPException(status_code=404, detail="Workflow not found.")
|
|
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()
|
|
50
34
|
try:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return await run_workflow_definition(
|
|
57
|
-
completion=chat_completion,
|
|
58
|
-
connection=connection,
|
|
59
|
-
definition=workflow.definition,
|
|
60
|
-
workflow_id=workflow.id,
|
|
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,
|
|
61
40
|
)
|
|
62
41
|
except ValueError as error:
|
|
63
|
-
|
|
42
|
+
status_code = 404 if str(error) == "Workflow not found." else 400
|
|
43
|
+
raise HTTPException(status_code=status_code, detail=str(error)) from error
|