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
@@ -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-BaZmIi2Y.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-EC37agAH.css">
9
+ <script type="module" crossorigin src="/assets/index-CROofCFl.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-CCf0mo80.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -6,12 +6,12 @@ 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
13
13
 
14
- from pydantic import BaseModel, ConfigDict
14
+ from pydantic import BaseModel, ConfigDict, Field
15
15
 
16
16
  from flowent.network import flowent_user_agent
17
17
  from flowent.patch import affected_paths
@@ -23,18 +23,107 @@ from flowent.system_tools import ensure_ripgrep_available
23
23
  class ToolResult(BaseModel):
24
24
  model_config = ConfigDict(extra="forbid")
25
25
 
26
- content: str
27
- data: dict[str, object] = {}
26
+ result: dict[str, object] = Field(default_factory=dict)
28
27
  ok: bool = True
29
28
  title: str
30
29
 
31
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
+
32
78
  @dataclass(frozen=True)
33
79
  class ToolContext:
34
80
  cwd: Path
81
+ emit_event: ToolEventEmitter | None = None
35
82
  web_searcher: Callable[[str], Sequence[dict[str, str]]] | None = None
36
83
 
37
84
 
85
+ def text_tool_result(text: str, **metadata: object) -> dict[str, object]:
86
+ return {"type": "text", "text": text, **metadata}
87
+
88
+
89
+ def command_tool_result(
90
+ *,
91
+ command: str,
92
+ exit_code: int,
93
+ output_chunks: list[dict[str, str]] | None = None,
94
+ stderr: str,
95
+ stdout: str,
96
+ ) -> dict[str, object]:
97
+ return {
98
+ "type": "command",
99
+ "command": command,
100
+ "exit_code": exit_code,
101
+ "output_chunks": [dict(item) for item in output_chunks or []],
102
+ "stderr": stderr,
103
+ "stdout": stdout,
104
+ "output": stdout or stderr,
105
+ }
106
+
107
+
108
+ def tool_result_model_content(result: ToolResult | dict[str, object]) -> str:
109
+ payload = result.result if isinstance(result, ToolResult) else result
110
+ result_type = payload.get("type")
111
+ if result_type == "command":
112
+ output = str(payload.get("output") or "")
113
+ metadata: dict[str, object] = {}
114
+ if "exit_code" in payload:
115
+ metadata["exit_code"] = payload["exit_code"]
116
+ return json.dumps(
117
+ {"output": output, "metadata": metadata},
118
+ ensure_ascii=False,
119
+ )
120
+ for key in ("text", "output"):
121
+ value = payload.get(key)
122
+ if value is not None:
123
+ return str(value)
124
+ return json.dumps(payload, ensure_ascii=False)
125
+
126
+
38
127
  def tool_specs() -> list[dict[str, object]]:
39
128
  return [
40
129
  {
@@ -222,7 +311,7 @@ def run_tool(
222
311
  title = (
223
312
  "Edit failed" if name == "apply_patch" else tool_call_title(name, arguments)
224
313
  )
225
- return ToolResult(content=str(error), ok=False, title=title)
314
+ return ToolResult(result=text_tool_result(str(error)), ok=False, title=title)
226
315
 
227
316
 
228
317
  async def run_tool_async(
@@ -238,7 +327,7 @@ async def run_tool_async(
238
327
  title = (
239
328
  "Edit failed" if name == "apply_patch" else tool_call_title(name, arguments)
240
329
  )
241
- return ToolResult(content=str(error), ok=False, title=title)
330
+ return ToolResult(result=text_tool_result(str(error)), ok=False, title=title)
242
331
 
243
332
 
244
333
  def integer_argument(arguments: dict[str, object], name: str, default: int) -> int:
@@ -266,7 +355,10 @@ def read_file(arguments: dict[str, object], context: ToolContext) -> ToolResult:
266
355
  lines = path.read_text(errors="replace").splitlines()
267
356
  selected = lines[offset : offset + limit]
268
357
  content = "\n".join(selected)
269
- return ToolResult(content=content, data={"path": str(path)}, title=f"Read {path}")
358
+ return ToolResult(
359
+ result=text_tool_result(content, path=str(path)),
360
+ title=f"Read {path}",
361
+ )
270
362
 
271
363
 
272
364
  def list_dir(arguments: dict[str, object], context: ToolContext) -> ToolResult:
@@ -279,7 +371,8 @@ def list_dir(arguments: dict[str, object], context: ToolContext) -> ToolResult:
279
371
  f"{entry.name}/" if entry.is_dir() else entry.name for entry in entries[:limit]
280
372
  ]
281
373
  return ToolResult(
282
- content="\n".join(rendered), data={"path": str(path)}, title=f"Listed {path}"
374
+ result=text_tool_result("\n".join(rendered), path=str(path)),
375
+ title=f"Listed {path}",
283
376
  )
284
377
 
285
378
 
@@ -297,8 +390,7 @@ def grep_files(arguments: dict[str, object], context: ToolContext) -> ToolResult
297
390
  )
298
391
  output = completed.stdout or completed.stderr
299
392
  return ToolResult(
300
- content=output[:20000],
301
- data={"path": str(path), "pattern": pattern},
393
+ result=text_tool_result(output[:20000], path=str(path), pattern=pattern),
302
394
  title=f"Searched {pattern}",
303
395
  )
304
396
 
@@ -317,8 +409,11 @@ def apply_patch_tool(arguments: dict[str, object], context: ToolContext) -> Tool
317
409
  raise SandboxError(tool_failure_content(result))
318
410
  data = json.loads(result.stdout or "{}")
319
411
  return ToolResult(
320
- content=result.stdout,
321
- data=data if isinstance(data, dict) else {},
412
+ result={
413
+ "type": "patch",
414
+ "output": result.stdout,
415
+ **(data if isinstance(data, dict) else {}),
416
+ },
322
417
  title=patch_title_from_result(data),
323
418
  )
324
419
 
@@ -339,8 +434,11 @@ async def apply_patch_tool_async(
339
434
  raise SandboxError(tool_failure_content(result))
340
435
  data = json.loads(result.stdout or "{}")
341
436
  return ToolResult(
342
- content=result.stdout,
343
- data=data if isinstance(data, dict) else {},
437
+ result={
438
+ "type": "patch",
439
+ "output": result.stdout,
440
+ **(data if isinstance(data, dict) else {}),
441
+ },
344
442
  title=patch_title_from_result(data),
345
443
  )
346
444
 
@@ -388,15 +486,13 @@ def shell_command(arguments: dict[str, object], context: ToolContext) -> ToolRes
388
486
  invocation.args, env=invocation.env, timeout_seconds=timeout_seconds
389
487
  )
390
488
  ok = result.exit_code == 0
391
- content = result.stdout or result.stderr
392
489
  return ToolResult(
393
- content=content,
394
- data={
395
- "command": command,
396
- "exit_code": result.exit_code,
397
- "stderr": result.stderr,
398
- "stdout": result.stdout,
399
- },
490
+ result=command_tool_result(
491
+ command=command,
492
+ exit_code=result.exit_code,
493
+ stderr=result.stderr,
494
+ stdout=result.stdout,
495
+ ),
400
496
  ok=ok,
401
497
  title=f"Ran {command}",
402
498
  )
@@ -408,19 +504,23 @@ async def shell_command_async(
408
504
  command = str(arguments["command"])
409
505
  timeout_seconds = number_argument(arguments, "timeout_seconds", 30)
410
506
  invocation = shell_invocation(command)
507
+ collector = CommandOutputCollector(command, context.emit_event)
411
508
  result = await SandboxRunner(cwd=context.cwd).run_async(
412
- 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,
413
514
  )
414
515
  ok = result.exit_code == 0
415
- content = result.stdout or result.stderr
416
516
  return ToolResult(
417
- content=content,
418
- data={
419
- "command": command,
420
- "exit_code": result.exit_code,
421
- "stderr": result.stderr,
422
- "stdout": result.stdout,
423
- },
517
+ result=command_tool_result(
518
+ command=command,
519
+ exit_code=result.exit_code,
520
+ output_chunks=collector.output_chunks,
521
+ stderr=result.stderr or collector.stderr,
522
+ stdout=result.stdout or collector.stdout,
523
+ ),
424
524
  ok=ok,
425
525
  title=f"Ran {command}",
426
526
  )
@@ -430,8 +530,11 @@ def update_plan(arguments: dict[str, object]) -> ToolResult:
430
530
  items = arguments.get("items", [])
431
531
  content = json.dumps(items, ensure_ascii=False)
432
532
  return ToolResult(
433
- content=content,
434
- data={"items": items if isinstance(items, list) else []},
533
+ result={
534
+ "type": "plan",
535
+ "items": items if isinstance(items, list) else [],
536
+ "output": content,
537
+ },
435
538
  title="Updated plan",
436
539
  )
437
540
 
@@ -473,8 +576,12 @@ def web_search(arguments: dict[str, object], context: ToolContext) -> ToolResult
473
576
  for result in results
474
577
  )
475
578
  return ToolResult(
476
- content=content or "No results.",
477
- data={"query": query, "results": results},
579
+ result={
580
+ "type": "web_search",
581
+ "output": content or "No results.",
582
+ "query": query,
583
+ "results": results,
584
+ },
478
585
  title=f"Searched web for {query}",
479
586
  )
480
587
 
@@ -148,6 +148,9 @@ def current_model_context_window(model_name: str | None = None) -> int:
148
148
 
149
149
  def model_context_window_for(model_name: str | None = None) -> int:
150
150
  candidates = normalized_model_name_candidates(model_name)
151
+ metadata_context_window = litellm_input_context_window_for(candidates)
152
+ if metadata_context_window is not None:
153
+ return metadata_context_window
151
154
  for candidate in candidates:
152
155
  context_window = MODEL_CONTEXT_WINDOWS.get(candidate)
153
156
  if context_window is not None:
@@ -159,6 +162,22 @@ def model_context_window_for(model_name: str | None = None) -> int:
159
162
  return DEFAULT_MODEL_CONTEXT_WINDOW
160
163
 
161
164
 
165
+ def litellm_input_context_window_for(candidates: Sequence[str]) -> int | None:
166
+ try:
167
+ from litellm import model_cost
168
+ except Exception:
169
+ return None
170
+
171
+ for candidate in candidates:
172
+ metadata = model_cost.get(candidate)
173
+ if metadata is None:
174
+ continue
175
+ context_window = first_int_value(value_at(metadata, "max_input_tokens"))
176
+ if context_window is not None and context_window > 0:
177
+ return context_window
178
+ return None
179
+
180
+
162
181
  def normalized_model_name_candidates(model_name: str | None) -> tuple[str, ...]:
163
182
  if model_name is None:
164
183
  return ()
@@ -261,6 +280,53 @@ def estimated_token_usage_for_messages(
261
280
  )
262
281
 
263
282
 
283
+ def estimated_token_usage_for_request(
284
+ messages: Sequence[Mapping[str, object]],
285
+ *,
286
+ output_content: str = "",
287
+ tools: Sequence[Mapping[str, object]] = (),
288
+ ) -> TokenUsage:
289
+ message_usage = estimated_token_usage_for_messages(
290
+ messages,
291
+ output_content=output_content,
292
+ )
293
+ tool_tokens = sum(
294
+ approximate_token_count(json.dumps(tool, ensure_ascii=False)) for tool in tools
295
+ )
296
+ input_tokens = message_usage.input_tokens + tool_tokens
297
+ return TokenUsage(
298
+ input_tokens=input_tokens,
299
+ output_tokens=message_usage.output_tokens,
300
+ total_tokens=input_tokens + message_usage.output_tokens,
301
+ )
302
+
303
+
304
+ def full_context_usage(
305
+ usage_info: TokenUsageInfo | None,
306
+ *,
307
+ model_context_window: int,
308
+ ) -> TokenUsageInfo:
309
+ info = usage_info or TokenUsageInfo(model_context_window=model_context_window)
310
+ return TokenUsageInfo(
311
+ total_token_usage=info.total_token_usage,
312
+ last_token_usage=TokenUsage(total_tokens=max(0, model_context_window)),
313
+ model_context_window=model_context_window,
314
+ )
315
+
316
+
317
+ def is_context_window_error(error: BaseException) -> bool:
318
+ message = str(error).lower()
319
+ return any(
320
+ marker in message
321
+ for marker in (
322
+ "context window",
323
+ "context_length_exceeded",
324
+ "maximum context length",
325
+ "too many tokens",
326
+ )
327
+ )
328
+
329
+
264
330
  def estimate_mapping_message_tokens(message: Mapping[str, object]) -> int:
265
331
  total = approximate_token_count(string_content(message.get("content")))
266
332
  tool_calls = message.get("tool_calls")
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ from flowent.llm import CompletionCallable
6
+ from flowent.provider_connections import selected_connection
7
+ from flowent.storage import StateStore, StoredWorkflow, StoredWorkflowDefinition
8
+ from flowent.workflows import (
9
+ WorkflowRunResponse,
10
+ run_workflow_definition,
11
+ validate_workflow_draft,
12
+ workflow_requires_connection,
13
+ )
14
+
15
+
16
+ class WorkflowService:
17
+ def __init__(
18
+ self,
19
+ *,
20
+ chat_completion: CompletionCallable | None,
21
+ store: StateStore,
22
+ ) -> None:
23
+ self.chat_completion = chat_completion
24
+ self.store = store
25
+
26
+ def list_workflows(self) -> list[StoredWorkflow]:
27
+ return self.store.read_workflows()
28
+
29
+ def get_workflow(self, workflow_id: str) -> StoredWorkflow:
30
+ workflow = next(
31
+ (
32
+ current_workflow
33
+ for current_workflow in self.store.read_workflows()
34
+ if current_workflow.id == workflow_id
35
+ ),
36
+ None,
37
+ )
38
+ if workflow is None:
39
+ raise ValueError("Workflow not found.")
40
+ return workflow
41
+
42
+ def save_workflow(self, workflow: StoredWorkflow) -> StoredWorkflow:
43
+ return self.store.save_workflow(
44
+ validate_workflow_draft(
45
+ workflow.model_copy(
46
+ update={"name": workflow.name.strip() or "Untitled Workflow"}
47
+ )
48
+ )
49
+ )
50
+
51
+ async def run_workflow(
52
+ self,
53
+ workflow_id: str,
54
+ *,
55
+ default_input: str = "",
56
+ input_values: Mapping[str, str] | None = None,
57
+ ) -> WorkflowRunResponse:
58
+ workflow = self.get_workflow(workflow_id)
59
+ connection = (
60
+ selected_connection(self.store.read_state())
61
+ if workflow_requires_connection(workflow.definition)
62
+ else None
63
+ )
64
+ return await run_workflow_definition(
65
+ completion=self.chat_completion,
66
+ connection=connection,
67
+ default_input=default_input,
68
+ definition=workflow.definition,
69
+ input_values=input_values,
70
+ workflow_id=workflow.id,
71
+ )
72
+
73
+ async def run_workflow_definition(
74
+ self,
75
+ *,
76
+ default_input: str = "",
77
+ definition: StoredWorkflowDefinition,
78
+ input_values: Mapping[str, str] | None = None,
79
+ workflow_id: str,
80
+ ) -> WorkflowRunResponse:
81
+ connection = (
82
+ selected_connection(self.store.read_state())
83
+ if workflow_requires_connection(definition)
84
+ else None
85
+ )
86
+ return await run_workflow_definition(
87
+ completion=self.chat_completion,
88
+ connection=connection,
89
+ default_input=default_input,
90
+ definition=definition,
91
+ input_values=input_values,
92
+ workflow_id=workflow_id,
93
+ )