flowent 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/backend/pyproject.toml +1 -1
  2. package/backend/src/flowent/agent.py +82 -16
  3. package/backend/src/flowent/app.py +7 -2
  4. package/backend/src/flowent/mcp.py +4 -3
  5. package/backend/src/flowent/permissions.py +61 -39
  6. package/backend/src/flowent/routes/workflow_routes.py +9 -41
  7. package/backend/src/flowent/sandbox.py +63 -19
  8. package/backend/src/flowent/state/models.py +2 -3
  9. package/backend/src/flowent/state/schema.py +116 -0
  10. package/backend/src/flowent/static/assets/index-CCf0mo80.css +2 -0
  11. package/backend/src/flowent/static/assets/index-CROofCFl.js +102 -0
  12. package/backend/src/flowent/static/index.html +2 -2
  13. package/backend/src/flowent/tools.py +142 -35
  14. package/backend/src/flowent/usage.py +66 -0
  15. package/backend/src/flowent/workflow_service.py +93 -0
  16. package/backend/src/flowent/workflow_tools.py +271 -0
  17. package/backend/src/flowent/workflows.py +71 -3
  18. package/backend/src/flowent/workspace/context.py +14 -7
  19. package/backend/src/flowent/workspace/output.py +4 -1
  20. package/backend/src/flowent/workspace/runtime.py +164 -13
  21. package/backend/uv.lock +1 -1
  22. package/dist/frontend/assets/index-CCf0mo80.css +2 -0
  23. package/dist/frontend/assets/index-CROofCFl.js +102 -0
  24. package/dist/frontend/index.html +2 -2
  25. package/package.json +8 -10
  26. package/backend/src/flowent/static/assets/index-BaZmIi2Y.js +0 -98
  27. package/backend/src/flowent/static/assets/index-EC37agAH.css +0 -2
  28. package/dist/frontend/assets/index-BaZmIi2Y.js +0 -98
  29. package/dist/frontend/assets/index-EC37agAH.css +0 -2
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowent"
3
- version = "0.3.1"
3
+ version = "0.3.3"
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
@@ -25,6 +27,8 @@ from flowent.tools import (
25
27
  new_tool_item,
26
28
  parse_tool_arguments,
27
29
  run_tool_async,
30
+ text_tool_result,
31
+ tool_result_model_content,
28
32
  tool_specs,
29
33
  )
30
34
 
@@ -42,6 +46,10 @@ Use tools deliberately:
42
46
  - Apply structured patches for file edits.
43
47
  - Run shell commands for diagnostics, builds, tests, and operations that require the local environment.
44
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 the content to process, pass that content as the run_workflow input. Use inputs only when you need to target specific input node ids.
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.
45
53
  - Search the web only when current external information is needed.
46
54
  - Update the plan when a task has multiple meaningful steps.
47
55
 
@@ -277,7 +285,12 @@ async def run_agent_stream(
277
285
  arguments = parse_tool_arguments(tool_call.arguments)
278
286
  except Exception as error:
279
287
  arguments = {}
280
- result_content = str(error)
288
+ result = ToolResult(
289
+ result=text_tool_result(str(error)),
290
+ ok=False,
291
+ title=tool_call.name or "Tool failed",
292
+ )
293
+ result_content = tool_result_model_content(result)
281
294
  tool_item = new_tool_item(tool_call.name, arguments)
282
295
  logger.debug("Tool call argument parse failed name=%s", tool_call.name)
283
296
  logger.log(TRACE_LEVEL, "Tool start item=%r", tool_item)
@@ -292,10 +305,9 @@ async def run_agent_stream(
292
305
  event="tool_error",
293
306
  data={
294
307
  "id": tool_item["id"],
295
- "content": result_content,
296
- "data": {},
308
+ "result": result.result,
297
309
  "status": "failed",
298
- "title": tool_call.name or "Tool failed",
310
+ "title": result.title,
299
311
  },
300
312
  )
301
313
  else:
@@ -309,15 +321,35 @@ async def run_agent_stream(
309
321
  )
310
322
  logger.log(TRACE_LEVEL, "Tool start item=%r", tool_item)
311
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
+
312
338
  extra_result = (
313
339
  await extra_tool_runner(tool_call.name, arguments)
314
340
  if extra_tool_runner is not None
315
341
  else None
316
342
  )
317
- result = extra_result if isinstance(extra_result, ToolResult) else None
318
- if result is None:
319
- context = ToolContext(cwd=cwd, web_searcher=web_searcher)
320
- result = await (
343
+ tool_result: ToolResult | None = (
344
+ extra_result if isinstance(extra_result, ToolResult) else None
345
+ )
346
+ if tool_result is None:
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(
321
353
  tool_runner(
322
354
  tool_call.name,
323
355
  arguments,
@@ -330,27 +362,61 @@ async def run_agent_stream(
330
362
  context,
331
363
  )
332
364
  )
333
- result_content = result.content
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
+ )
400
+ result_content = tool_result_model_content(tool_result)
334
401
  logger.debug(
335
402
  "Tool call finished name=%s id=%s ok=%s",
336
403
  tool_call.name,
337
404
  tool_item["id"],
338
- result.ok,
405
+ tool_result.ok,
339
406
  )
340
407
  logger.log(
341
408
  TRACE_LEVEL,
342
409
  "Tool result id=%s result=%r",
343
410
  tool_item["id"],
344
- result.model_dump(),
411
+ tool_result.model_dump(),
345
412
  )
346
413
  yield AgentStreamEvent(
347
- event="tool_done" if result.ok else "tool_error",
414
+ event="tool_done" if tool_result.ok else "tool_error",
348
415
  data={
349
416
  "id": tool_item["id"],
350
- "content": result.content,
351
- "data": result.data,
352
- "status": "success" if result.ok else "failed",
353
- "title": result.title,
417
+ "result": tool_result.result,
418
+ "status": "success" if tool_result.ok else "failed",
419
+ "title": tool_result.title,
354
420
  },
355
421
  )
356
422
  conversation.append(tool_result_message(tool_call_id, result_content))
@@ -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,10 @@ 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
+ store=store,
64
+ )
60
65
 
61
66
  static_dir = frontend_static_directory().resolve(strict=False)
62
67
  logger.debug("Flowent app created serve_frontend=%s", serve_frontend)
@@ -69,6 +74,7 @@ def create_app(
69
74
  cwd=cwd,
70
75
  mcp_manager=mcp_manager,
71
76
  store=store,
77
+ workflow_service=workflow_service,
72
78
  )
73
79
 
74
80
  telegram_bot_manager = TelegramBotManager(
@@ -121,8 +127,7 @@ def create_app(
121
127
  )
122
128
  register_workflow_routes(
123
129
  app,
124
- chat_completion=chat_completion,
125
- store=store,
130
+ workflow_service=workflow_service,
126
131
  )
127
132
  register_permission_routes(app, cwd=cwd, store=store)
128
133
  register_workspace_routes(app, runtime=runtime, store=store)
@@ -662,11 +662,12 @@ class McpManager:
662
662
  content = mcp_result_content(result)
663
663
  server_name = self._server_names.get(server_id, server_id)
664
664
  return ToolResult(
665
- content=content,
666
- data={
665
+ result={
666
+ "type": "mcp",
667
+ "output": content,
667
668
  "server": server_name,
668
669
  "tool": tool_name,
669
- "result": result,
670
+ "raw_result": result,
670
671
  },
671
672
  ok=not mcp_result_is_error(result),
672
673
  title=f"Calling {server_name}.{tool_name}",
@@ -14,12 +14,16 @@ 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,
20
+ command_tool_result,
19
21
  number_argument,
20
22
  patch_title_from_result,
21
23
  run_tool_async,
24
+ text_tool_result,
22
25
  tool_failure_content,
26
+ tool_result_model_content,
23
27
  )
24
28
 
25
29
  SANDBOX_WITH_ADDITIONAL_PERMISSIONS = "with_additional_permissions"
@@ -66,7 +70,7 @@ def validate_additional_permissions(arguments: dict[str, object]) -> ToolResult
66
70
  return None
67
71
  if sandbox_permissions != SANDBOX_WITH_ADDITIONAL_PERMISSIONS:
68
72
  return ToolResult(
69
- content=(
73
+ result=text_tool_result(
70
74
  "additional_permissions requires sandbox_permissions to be "
71
75
  "with_additional_permissions."
72
76
  ),
@@ -130,8 +134,10 @@ async def review_missing_write_paths(
130
134
  return (
131
135
  effective_paths,
132
136
  ToolResult(
133
- content=approval_denial_content(decision),
134
- data=review_data,
137
+ result=text_tool_result(
138
+ approval_denial_content(decision),
139
+ **review_data,
140
+ ),
135
141
  ok=False,
136
142
  title="Denied by reviewer",
137
143
  ),
@@ -196,7 +202,7 @@ async def run_shell_command_with_permissions(
196
202
  arguments, context, effective_paths
197
203
  )
198
204
  if approval_data is not None:
199
- result = tool_result_with_data(result, approval_data)
205
+ result = tool_result_with_fields(result, approval_data)
200
206
  if result.ok or not is_likely_sandbox_denied_result(result):
201
207
  return result
202
208
  review_request = ApprovalReviewRequest(
@@ -210,13 +216,16 @@ async def run_shell_command_with_permissions(
210
216
  review_data = approval_result_data(review_request, decision)
211
217
  if decision.decision == "denied":
212
218
  return ToolResult(
213
- content=approval_denial_content(decision),
214
- data={**result.data, **review_data},
219
+ result=text_tool_result(
220
+ approval_denial_content(decision),
221
+ previous_result=result.result,
222
+ **review_data,
223
+ ),
215
224
  ok=False,
216
225
  title="Denied by reviewer",
217
226
  )
218
227
  retry_result = await shell_command_without_sandbox(arguments, context)
219
- return tool_result_with_data(retry_result, review_data)
228
+ return tool_result_with_fields(retry_result, review_data)
220
229
 
221
230
 
222
231
  async def run_apply_patch_with_permissions(
@@ -244,7 +253,7 @@ async def run_apply_patch_with_permissions(
244
253
 
245
254
  result = await apply_patch_with_writable_paths(arguments, context, effective_paths)
246
255
  if approval_data is not None:
247
- result = tool_result_with_data(result, approval_data)
256
+ result = tool_result_with_fields(result, approval_data)
248
257
  return result
249
258
 
250
259
 
@@ -268,18 +277,23 @@ async def apply_patch_with_writable_paths(
268
277
  input_text=patch,
269
278
  )
270
279
  except SandboxError as error:
271
- return ToolResult(content=str(error), ok=False, title="Edit failed")
280
+ return ToolResult(
281
+ result=text_tool_result(str(error)), ok=False, title="Edit failed"
282
+ )
272
283
 
273
284
  if result.exit_code != 0:
274
285
  return ToolResult(
275
- content=tool_failure_content(result),
286
+ result=text_tool_result(tool_failure_content(result)),
276
287
  ok=False,
277
288
  title="Edit failed",
278
289
  )
279
290
  data = json.loads(result.stdout or "{}")
280
291
  return ToolResult(
281
- content=result.stdout,
282
- data=data if isinstance(data, dict) else {},
292
+ result={
293
+ "type": "patch",
294
+ "output": result.stdout,
295
+ **(data if isinstance(data, dict) else {}),
296
+ },
283
297
  title=patch_title_from_result(data),
284
298
  )
285
299
 
@@ -292,32 +306,38 @@ async def shell_command_with_writable_paths(
292
306
  command = str(arguments["command"])
293
307
  timeout_seconds = number_argument(arguments, "timeout_seconds", 30)
294
308
  invocation = shell_invocation(command)
309
+ collector = CommandOutputCollector(command, context.emit_event)
295
310
  result = await SandboxRunner(
296
311
  cwd=context.cwd,
297
312
  writable_roots=writable_paths,
298
- ).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
+ )
299
320
  ok = result.exit_code == 0
300
- content = result.stdout or result.stderr
301
321
  return ToolResult(
302
- content=content,
303
- data={
304
- "command": command,
305
- "exit_code": result.exit_code,
306
- "stderr": result.stderr,
307
- "stdout": result.stdout,
308
- },
322
+ result=command_tool_result(
323
+ command=command,
324
+ exit_code=result.exit_code,
325
+ output_chunks=collector.output_chunks,
326
+ stderr=result.stderr or collector.stderr,
327
+ stdout=result.stdout or collector.stdout,
328
+ ),
309
329
  ok=ok,
310
330
  title=f"Ran {command}",
311
331
  )
312
332
 
313
333
 
314
334
  def is_likely_sandbox_denied_result(result: ToolResult) -> bool:
315
- data = result.data
316
- exit_code = int_result_field(data.get("exit_code"))
335
+ payload = result.result
336
+ exit_code = int_result_field(payload.get("exit_code"))
317
337
  if exit_code == 0:
318
338
  return False
319
339
  output = "\n".join(
320
- str(data.get(name, "") or "") for name in ["stderr", "stdout"]
340
+ str(payload.get(name, "") or "") for name in ["stderr", "stdout", "output"]
321
341
  ).lower()
322
342
  return any(
323
343
  keyword in output
@@ -345,13 +365,17 @@ def int_result_field(value: object) -> int:
345
365
 
346
366
 
347
367
  def tool_failure_text(result: ToolResult) -> str:
348
- stderr = str(result.data.get("stderr", "") or "").strip()
349
- stdout = str(result.data.get("stdout", "") or "").strip()
350
- content = result.content.strip()
368
+ payload = result.result
369
+ stderr = str(payload.get("stderr", "") or "").strip()
370
+ stdout = str(payload.get("stdout", "") or "").strip()
351
371
  parts: list[str] = []
352
- for part in [stderr, stdout, content]:
372
+ for part in [stderr, stdout]:
353
373
  if part and part not in parts:
354
374
  parts.append(part)
375
+ if not parts:
376
+ content = tool_result_model_content(result).strip()
377
+ if content:
378
+ parts.append(content)
355
379
  return "\n".join(parts)
356
380
 
357
381
 
@@ -366,15 +390,13 @@ async def shell_command_without_sandbox(
366
390
  invocation.args, env=invocation.env, timeout_seconds=timeout_seconds
367
391
  )
368
392
  ok = result.exit_code == 0
369
- content = result.stdout or result.stderr
370
393
  return ToolResult(
371
- content=content,
372
- data={
373
- "command": command,
374
- "exit_code": result.exit_code,
375
- "stderr": result.stderr,
376
- "stdout": result.stdout,
377
- },
394
+ result=command_tool_result(
395
+ command=command,
396
+ exit_code=result.exit_code,
397
+ stderr=result.stderr,
398
+ stdout=result.stdout,
399
+ ),
378
400
  ok=ok,
379
401
  title=f"Ran {command}",
380
402
  )
@@ -403,7 +425,7 @@ def approval_result_data(
403
425
  }
404
426
 
405
427
 
406
- def tool_result_with_data(
407
- result: ToolResult, extra_data: dict[str, object]
428
+ def tool_result_with_fields(
429
+ result: ToolResult, extra_fields: dict[str, object]
408
430
  ) -> ToolResult:
409
- return result.model_copy(update={"data": {**result.data, **extra_data}})
431
+ return result.model_copy(update={"result": {**result.result, **extra_fields}})
@@ -1,63 +1,31 @@
1
1
  from fastapi import FastAPI, HTTPException
2
2
 
3
- from flowent.llm import CompletionCallable
4
- from flowent.provider_connections import selected_connection
5
- from flowent.storage import StateStore, StoredWorkflow
6
- from flowent.workflows import (
7
- WorkflowRunResponse,
8
- run_workflow_definition,
9
- validate_workflow_draft,
10
- workflow_requires_connection,
11
- )
3
+ from flowent.storage import StoredWorkflow
4
+ from flowent.workflow_service import WorkflowService
5
+ from flowent.workflows import WorkflowRunResponse
12
6
 
13
7
 
14
8
  def register_workflow_routes(
15
9
  app: FastAPI,
16
10
  *,
17
- chat_completion: CompletionCallable | None,
18
- store: StateStore,
11
+ workflow_service: WorkflowService,
19
12
  ) -> None:
20
13
  @app.put("/api/workflows")
21
14
  async def save_workflow(workflow: StoredWorkflow) -> StoredWorkflow:
22
15
  try:
23
- return store.save_workflow(
24
- validate_workflow_draft(
25
- workflow.model_copy(
26
- update={"name": workflow.name.strip() or "Untitled Workflow"}
27
- )
28
- )
29
- )
16
+ return workflow_service.save_workflow(workflow)
30
17
  except ValueError as error:
31
18
  raise HTTPException(status_code=400, detail=str(error)) from error
32
19
 
33
20
  @app.delete("/api/workflows/{workflow_id}")
34
21
  async def delete_workflow(workflow_id: str) -> dict[str, bool]:
35
- store.delete_workflow(workflow_id)
22
+ workflow_service.store.delete_workflow(workflow_id)
36
23
  return {"ok": True}
37
24
 
38
25
  @app.post("/api/workflows/{workflow_id}/run")
39
26
  async def run_workflow(workflow_id: str) -> WorkflowRunResponse:
40
- workflow = next(
41
- (
42
- current_workflow
43
- for current_workflow in store.read_workflows()
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.")
50
27
  try:
51
- connection = (
52
- selected_connection(store.read_state())
53
- if workflow_requires_connection(workflow.definition)
54
- else None
55
- )
56
- return await run_workflow_definition(
57
- completion=chat_completion,
58
- connection=connection,
59
- definition=workflow.definition,
60
- workflow_id=workflow.id,
61
- )
28
+ return await workflow_service.run_workflow(workflow_id)
62
29
  except ValueError as error:
63
- raise HTTPException(status_code=400, detail=str(error)) from error
30
+ status_code = 404 if str(error) == "Workflow not found." else 400
31
+ raise HTTPException(status_code=status_code, detail=str(error)) from error