flowent 0.2.3 → 0.3.0

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 (49) hide show
  1. package/README.md +3 -3
  2. package/backend/README.md +3 -3
  3. package/backend/pyproject.toml +1 -1
  4. package/backend/src/flowent/agent.py +1 -1
  5. package/backend/src/flowent/api_models.py +103 -0
  6. package/backend/src/flowent/app.py +151 -0
  7. package/backend/src/flowent/cli.py +13 -4
  8. package/backend/src/flowent/compact.py +34 -13
  9. package/backend/src/flowent/llm.py +6 -8
  10. package/backend/src/flowent/logging.py +7 -1
  11. package/backend/src/flowent/main.py +18 -1989
  12. package/backend/src/flowent/mcp.py +231 -44
  13. package/backend/src/flowent/network.py +5 -0
  14. package/backend/src/flowent/permissions.py +5 -1
  15. package/backend/src/flowent/provider_connections.py +42 -0
  16. package/backend/src/flowent/routes/__init__.py +0 -0
  17. package/backend/src/flowent/routes/integrations.py +105 -0
  18. package/backend/src/flowent/routes/permissions.py +36 -0
  19. package/backend/src/flowent/routes/providers.py +30 -0
  20. package/backend/src/flowent/routes/system.py +49 -0
  21. package/backend/src/flowent/routes/workflow_routes.py +63 -0
  22. package/backend/src/flowent/routes/workspace.py +105 -0
  23. package/backend/src/flowent/sandbox.py +1 -1
  24. package/backend/src/flowent/state/__init__.py +53 -0
  25. package/backend/src/flowent/state/models.py +257 -0
  26. package/backend/src/flowent/state/schema.py +186 -0
  27. package/backend/src/flowent/state/store.py +1013 -0
  28. package/backend/src/flowent/static/assets/index-CvWZZMtK.css +2 -0
  29. package/backend/src/flowent/static/assets/index-ma2v8oW7.js +90 -0
  30. package/backend/src/flowent/static/index.html +2 -2
  31. package/backend/src/flowent/storage.py +52 -1254
  32. package/backend/src/flowent/system_tools.py +25 -0
  33. package/backend/src/flowent/tools.py +4 -2
  34. package/backend/src/flowent/usage.py +9 -4
  35. package/backend/src/flowent/workflows.py +282 -0
  36. package/backend/src/flowent/workspace/__init__.py +0 -0
  37. package/backend/src/flowent/workspace/context.py +249 -0
  38. package/backend/src/flowent/workspace/events.py +180 -0
  39. package/backend/src/flowent/workspace/output.py +274 -0
  40. package/backend/src/flowent/workspace/runtime.py +1041 -0
  41. package/backend/uv.lock +1 -1
  42. package/dist/frontend/assets/index-CvWZZMtK.css +2 -0
  43. package/dist/frontend/assets/index-ma2v8oW7.js +90 -0
  44. package/dist/frontend/index.html +2 -2
  45. package/package.json +1 -1
  46. package/backend/src/flowent/static/assets/index-D7t9qNrC.js +0 -82
  47. package/backend/src/flowent/static/assets/index-DufpDl8x.css +0 -2
  48. package/dist/frontend/assets/index-D7t9qNrC.js +0 -82
  49. package/dist/frontend/assets/index-DufpDl8x.css +0 -2
@@ -0,0 +1,180 @@
1
+ import asyncio
2
+ import copy
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from typing import Literal
6
+ from uuid import uuid4
7
+
8
+ from flowent.storage import StoredMessage
9
+
10
+
11
+ @dataclass
12
+ class WorkspaceRun:
13
+ condition: asyncio.Condition
14
+ active_output: Literal["text", "thinking"] | None = None
15
+ discard_on_cancel: bool = False
16
+ events: list[tuple[int, str, dict[str, object]]] = field(default_factory=list)
17
+ generation: int = 0
18
+ id: str = field(default_factory=lambda: str(uuid4()))
19
+ is_done: bool = False
20
+ latest_snapshot: StoredMessage | None = None
21
+ task: asyncio.Task[None] | None = None
22
+
23
+ @property
24
+ def latest_event_index(self) -> int:
25
+ return self.events[-1][0] if self.events else 0
26
+
27
+
28
+ def stream_event(
29
+ event: str, data: dict[str, object], event_id: int | None = None
30
+ ) -> str:
31
+ id_line = f"id: {event_id}\n" if event_id is not None else ""
32
+ return f"{id_line}event: {event}\ndata: {json.dumps(data)}\n\n"
33
+
34
+
35
+ def stream_message_data(
36
+ message: StoredMessage, active_output: Literal["text", "thinking"] | None = None
37
+ ) -> dict[str, object]:
38
+ data = {**message.model_dump(), "status": message.status}
39
+ if active_output is not None:
40
+ data["active_output"] = active_output
41
+ return data
42
+
43
+
44
+ def append_or_replace_message(
45
+ messages: list[StoredMessage], message: StoredMessage
46
+ ) -> list[StoredMessage]:
47
+ return [
48
+ *(current for current in messages if current.id != message.id),
49
+ message,
50
+ ]
51
+
52
+
53
+ def run_snapshot_data_at(
54
+ run: WorkspaceRun, event_index: int
55
+ ) -> dict[str, object] | None:
56
+ snapshot_event_index = 0
57
+ snapshot: dict[str, object] | None = None
58
+ for current_event_index, event, data in run.events:
59
+ if current_event_index > event_index:
60
+ break
61
+ if event != "snapshot":
62
+ if event == "start" and snapshot is None:
63
+ assistant_id = data.get("id")
64
+ if isinstance(assistant_id, str):
65
+ snapshot_event_index = current_event_index
66
+ snapshot = {
67
+ "author": "assistant",
68
+ "content": "",
69
+ "groups": [],
70
+ "id": assistant_id,
71
+ "status": "running",
72
+ "tools": [],
73
+ }
74
+ continue
75
+ message = data.get("message")
76
+ if isinstance(message, dict):
77
+ snapshot_event_index = current_event_index
78
+ snapshot = copy.deepcopy(message)
79
+ if snapshot is None:
80
+ return None
81
+ for current_event_index, event, data in run.events:
82
+ if current_event_index <= snapshot_event_index:
83
+ continue
84
+ if current_event_index > event_index:
85
+ break
86
+ apply_stream_event_to_snapshot(snapshot, event, data)
87
+ return snapshot
88
+
89
+
90
+ def apply_stream_event_to_snapshot(
91
+ snapshot: dict[str, object], event: str, data: dict[str, object]
92
+ ) -> None:
93
+ if event == "output_start":
94
+ snapshot.pop("active_output", None)
95
+ index = data.get("index")
96
+ if isinstance(index, int):
97
+ append_snapshot_group(snapshot, index)
98
+ if event == "delta":
99
+ append_snapshot_text(snapshot, str(data.get("content") or ""))
100
+ if event == "thinking_delta":
101
+ append_snapshot_thinking(snapshot, str(data.get("content") or ""))
102
+ if event == "output_done":
103
+ snapshot.pop("active_output", None)
104
+
105
+
106
+ def snapshot_groups(snapshot: dict[str, object]) -> list[dict[str, object]]:
107
+ groups = snapshot.get("groups")
108
+ if not isinstance(groups, list):
109
+ groups = []
110
+ snapshot["groups"] = groups
111
+ return groups
112
+
113
+
114
+ def append_snapshot_group(
115
+ snapshot: dict[str, object], index: int | None = None
116
+ ) -> None:
117
+ groups = snapshot_groups(snapshot)
118
+ assistant_id = str(snapshot.get("id") or "assistant")
119
+ group_index = index if index is not None else len(groups) + 1
120
+ group_id = f"{assistant_id}-group-{group_index}"
121
+ if groups and groups[-1].get("id") == group_id:
122
+ return
123
+ groups.append({"id": group_id, "items": []})
124
+
125
+
126
+ def append_snapshot_text(snapshot: dict[str, object], content: str) -> None:
127
+ if not content:
128
+ return
129
+ snapshot["active_output"] = "text"
130
+ snapshot["content"] = f"{snapshot.get('content') or ''}{content}"
131
+ append_snapshot_item_content(snapshot, content, "text")
132
+
133
+
134
+ def append_snapshot_thinking(snapshot: dict[str, object], content: str) -> None:
135
+ if not content:
136
+ return
137
+ snapshot["active_output"] = "thinking"
138
+ snapshot["thinking"] = f"{snapshot.get('thinking') or ''}{content}"
139
+ append_snapshot_item_content(snapshot, content, "thinking")
140
+
141
+
142
+ def append_snapshot_item_content(
143
+ snapshot: dict[str, object], content: str, item_type: Literal["text", "thinking"]
144
+ ) -> None:
145
+ groups = snapshot_groups(snapshot)
146
+ if not groups:
147
+ append_snapshot_group(snapshot)
148
+ group = groups[-1]
149
+ items = group.get("items")
150
+ if not isinstance(items, list):
151
+ items = []
152
+ group["items"] = items
153
+ item = next(
154
+ (
155
+ current
156
+ for current in reversed(items)
157
+ if isinstance(current, dict) and current.get("type") == item_type
158
+ ),
159
+ None,
160
+ )
161
+ if item is None:
162
+ assistant_id = str(snapshot.get("id") or "assistant")
163
+ snapshot_item_count = 0
164
+ for current_group in groups:
165
+ current_items = current_group.get("items")
166
+ if not isinstance(current_items, list):
167
+ continue
168
+ snapshot_item_count += sum(
169
+ 1
170
+ for current_item in current_items
171
+ if isinstance(current_item, dict)
172
+ and current_item.get("type") == item_type
173
+ )
174
+ item = {
175
+ "content": "",
176
+ "id": f"{assistant_id}-{item_type}-{snapshot_item_count + 1}",
177
+ "type": item_type,
178
+ }
179
+ items.append(item)
180
+ item["content"] = f"{item.get('content') or ''}{content}"
@@ -0,0 +1,274 @@
1
+ from collections.abc import Sequence
2
+ from typing import Literal
3
+
4
+ from flowent.approval import ApprovalTranscriptEntry
5
+ from flowent.logging import redact_diagnostic_value
6
+ from flowent.storage import (
7
+ StoredAssistantOutputGroup,
8
+ StoredErrorOutputItem,
9
+ StoredMessage,
10
+ StoredTextOutputItem,
11
+ StoredThinkingOutputItem,
12
+ StoredToolItem,
13
+ StoredToolOutputItem,
14
+ )
15
+
16
+ APPROVAL_TRANSCRIPT_MESSAGE_LIMIT = 12
17
+ APPROVAL_TRANSCRIPT_TEXT_LIMIT = 2_000
18
+ USER_VISIBLE_RUN_ERROR_TITLE = "Request failed"
19
+ USER_VISIBLE_RUN_ERROR_MESSAGE = "Check the model connection settings and try again."
20
+ USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE = "Context could not be optimized."
21
+ EMPTY_MODEL_RESPONSE_DETAIL = "The model did not return a response."
22
+
23
+
24
+ def user_visible_run_error_message(detail: str) -> str:
25
+ if detail.strip() == USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE:
26
+ return USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE
27
+ return USER_VISIBLE_RUN_ERROR_MESSAGE
28
+
29
+
30
+ def run_error_output_item(
31
+ assistant_id: str,
32
+ detail: str,
33
+ index: int = 1,
34
+ ) -> StoredErrorOutputItem:
35
+ redacted_detail = redact_diagnostic_value(detail.strip())
36
+ message = user_visible_run_error_message(redacted_detail)
37
+ return StoredErrorOutputItem(
38
+ detail="" if redacted_detail == message else redacted_detail,
39
+ id=f"{assistant_id}-error-{index}",
40
+ message=message,
41
+ title=USER_VISIBLE_RUN_ERROR_TITLE,
42
+ type="error",
43
+ )
44
+
45
+
46
+ def run_error_event_data(error: StoredErrorOutputItem) -> dict[str, object]:
47
+ return {
48
+ "error": error.model_dump(exclude_none=True),
49
+ "message": error.message,
50
+ }
51
+
52
+
53
+ def message_error_items(message: StoredMessage) -> list[StoredErrorOutputItem]:
54
+ return [
55
+ item for group in message.groups for item in group.items if item.type == "error"
56
+ ]
57
+
58
+
59
+ def error_context_summary(error: StoredErrorOutputItem) -> str:
60
+ parts = [f"Previous response failed: {error.title}.", error.message]
61
+ if error.detail and error.detail != error.message:
62
+ parts.append(f"Detail: {error.detail}")
63
+ return " ".join(part.strip() for part in parts if part.strip())
64
+
65
+
66
+ def approval_transcript_text(content: str | None) -> str:
67
+ text = (content or "").strip()
68
+ if len(text) <= APPROVAL_TRANSCRIPT_TEXT_LIMIT:
69
+ return text
70
+ return f"{text[:APPROVAL_TRANSCRIPT_TEXT_LIMIT]}\n[truncated]"
71
+
72
+
73
+ def approval_transcript(
74
+ messages: Sequence[StoredMessage],
75
+ ) -> list[ApprovalTranscriptEntry]:
76
+ entries: list[ApprovalTranscriptEntry] = []
77
+ for message in messages[-APPROVAL_TRANSCRIPT_MESSAGE_LIMIT:]:
78
+ if message.author in ("user", "assistant"):
79
+ role: Literal["user", "assistant"] = (
80
+ "user" if message.author == "user" else "assistant"
81
+ )
82
+ content = approval_transcript_text(message.content)
83
+ if content:
84
+ entries.append(ApprovalTranscriptEntry(role=role, content=content))
85
+ for tool in message.tools:
86
+ tool_content = approval_transcript_text(tool.content)
87
+ if tool_content:
88
+ entries.append(
89
+ ApprovalTranscriptEntry(
90
+ role="tool",
91
+ content=tool_content,
92
+ name=tool.name,
93
+ )
94
+ )
95
+ return entries
96
+
97
+
98
+ class AssistantOutputBuilder:
99
+ def __init__(self, assistant_id: str = "") -> None:
100
+ self.assistant_id = assistant_id
101
+ self.content = ""
102
+ self.groups: list[StoredAssistantOutputGroup] = []
103
+ self.text_item_index = 0
104
+ self.text_item_id = ""
105
+ self.thinking = ""
106
+ self.thinking_item_index = 0
107
+ self.thinking_item_id = ""
108
+ self.error_item_index = 0
109
+ self.tools: dict[str, StoredToolItem] = {}
110
+
111
+ def set_assistant_id(self, assistant_id: str) -> None:
112
+ self.assistant_id = assistant_id
113
+
114
+ def start_group(self, index: int) -> None:
115
+ group_id = f"{self.assistant_id or 'assistant'}-group-{index}"
116
+ if self.groups and self.groups[-1].id == group_id:
117
+ return
118
+ self.text_item_id = ""
119
+ self.thinking_item_id = ""
120
+ self.groups.append(StoredAssistantOutputGroup(id=group_id, items=[]))
121
+
122
+ def append_text(self, content: str) -> None:
123
+ if not content:
124
+ return
125
+ self._ensure_group()
126
+ if not self.text_item_id:
127
+ self.text_item_index += 1
128
+ self.text_item_id = f"{self.assistant_id}-text-{self.text_item_index}"
129
+ self._append_current_item(
130
+ StoredTextOutputItem(content="", id=self.text_item_id, type="text")
131
+ )
132
+ self.content += content
133
+ self.groups[-1] = self.groups[-1].model_copy(
134
+ update={
135
+ "items": [
136
+ item.model_copy(update={"content": item.content + content})
137
+ if item.type == "text" and item.id == self.text_item_id
138
+ else item
139
+ for item in self.groups[-1].items
140
+ ]
141
+ }
142
+ )
143
+
144
+ def append_thinking(self, content: str) -> None:
145
+ if not content:
146
+ return
147
+ self._ensure_group()
148
+ if not self.thinking_item_id:
149
+ self.thinking_item_index += 1
150
+ self.thinking_item_id = (
151
+ f"{self.assistant_id}-thinking-{self.thinking_item_index}"
152
+ )
153
+ self._append_current_item(
154
+ StoredThinkingOutputItem(
155
+ content="", id=self.thinking_item_id, type="thinking"
156
+ )
157
+ )
158
+ self.thinking += content
159
+ self.groups[-1] = self.groups[-1].model_copy(
160
+ update={
161
+ "items": [
162
+ item.model_copy(update={"content": item.content + content})
163
+ if item.type == "thinking" and item.id == self.thinking_item_id
164
+ else item
165
+ for item in self.groups[-1].items
166
+ ]
167
+ }
168
+ )
169
+
170
+ def start_tool(self, tool: StoredToolItem) -> None:
171
+ self._ensure_group()
172
+ self.text_item_id = ""
173
+ self.thinking_item_id = ""
174
+ self.tools[tool.id] = tool
175
+ self._append_current_item(
176
+ StoredToolOutputItem(id=f"tool-{tool.id}", tool=tool, type="tool")
177
+ )
178
+
179
+ def update_tool(self, tool_id: str, data: dict[str, object]) -> None:
180
+ current_tool = self.tools.get(tool_id)
181
+ if current_tool is None:
182
+ return
183
+ updated_tool = StoredToolItem.model_validate(
184
+ {**current_tool.model_dump(exclude_none=True), **data}
185
+ )
186
+ self.tools[tool_id] = updated_tool
187
+ self.groups = [
188
+ group.model_copy(
189
+ update={
190
+ "items": [
191
+ item.model_copy(update={"tool": updated_tool})
192
+ if item.type == "tool" and item.tool.id == tool_id
193
+ else item
194
+ for item in group.items
195
+ ]
196
+ }
197
+ )
198
+ for group in self.groups
199
+ ]
200
+
201
+ def append_error(self, error: StoredErrorOutputItem) -> StoredErrorOutputItem:
202
+ self.error_item_index += 1
203
+ if not error.id:
204
+ error = error.model_copy(
205
+ update={"id": f"{self.assistant_id}-error-{self.error_item_index}"}
206
+ )
207
+ error_group_id = f"{self.assistant_id}-errors"
208
+ if self.groups and self.groups[-1].id == error_group_id:
209
+ self.groups[-1] = self.groups[-1].model_copy(
210
+ update={"items": [*self.groups[-1].items, error]}
211
+ )
212
+ else:
213
+ self.groups.append(
214
+ StoredAssistantOutputGroup(id=error_group_id, items=[error])
215
+ )
216
+ return error
217
+
218
+ def has_output(self) -> bool:
219
+ return any(group.items for group in self.groups)
220
+
221
+ def apply_done_message(self, message: dict[str, object]) -> None:
222
+ final_content = str(message.get("content") or self.content)
223
+ final_thinking = str(message.get("thinking") or self.thinking)
224
+ self._append_missing_done_text(final_content)
225
+ self._append_missing_done_thinking(final_thinking)
226
+ self.content = final_content
227
+ self.thinking = final_thinking
228
+
229
+ def _append_missing_done_text(self, final_content: str) -> None:
230
+ streamed_text = "".join(
231
+ item.content
232
+ for group in self.groups
233
+ for item in group.items
234
+ if item.type == "text"
235
+ )
236
+ if not final_content or streamed_text == final_content:
237
+ return
238
+ missing_text = (
239
+ final_content[len(streamed_text) :]
240
+ if final_content.startswith(streamed_text)
241
+ else final_content
242
+ )
243
+ self.append_text(missing_text)
244
+
245
+ def _append_missing_done_thinking(self, final_thinking: str) -> None:
246
+ streamed_thinking = "".join(
247
+ item.content
248
+ for group in self.groups
249
+ for item in group.items
250
+ if item.type == "thinking"
251
+ )
252
+ if not final_thinking or streamed_thinking == final_thinking:
253
+ return
254
+ missing_thinking = (
255
+ final_thinking[len(streamed_thinking) :]
256
+ if final_thinking.startswith(streamed_thinking)
257
+ else final_thinking
258
+ )
259
+ self.append_thinking(missing_thinking)
260
+
261
+ def _ensure_group(self) -> None:
262
+ if not self.groups:
263
+ self.start_group(1)
264
+
265
+ def _append_current_item(
266
+ self,
267
+ item: StoredTextOutputItem
268
+ | StoredThinkingOutputItem
269
+ | StoredErrorOutputItem
270
+ | StoredToolOutputItem,
271
+ ) -> None:
272
+ self.groups[-1] = self.groups[-1].model_copy(
273
+ update={"items": [*self.groups[-1].items, item]}
274
+ )