flowent 0.2.4 → 0.3.1

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 (46) 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 +108 -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 +52 -6
  10. package/backend/src/flowent/main.py +18 -1994
  11. package/backend/src/flowent/mcp.py +100 -2
  12. package/backend/src/flowent/network.py +5 -0
  13. package/backend/src/flowent/provider_connections.py +42 -0
  14. package/backend/src/flowent/routes/__init__.py +0 -0
  15. package/backend/src/flowent/routes/integrations.py +105 -0
  16. package/backend/src/flowent/routes/permissions.py +36 -0
  17. package/backend/src/flowent/routes/providers.py +53 -0
  18. package/backend/src/flowent/routes/system.py +48 -0
  19. package/backend/src/flowent/routes/workflow_routes.py +63 -0
  20. package/backend/src/flowent/routes/workspace.py +115 -0
  21. package/backend/src/flowent/state/__init__.py +53 -0
  22. package/backend/src/flowent/state/models.py +258 -0
  23. package/backend/src/flowent/state/schema.py +191 -0
  24. package/backend/src/flowent/state/store.py +1019 -0
  25. package/backend/src/flowent/static/assets/index-BaZmIi2Y.js +98 -0
  26. package/backend/src/flowent/static/assets/index-EC37agAH.css +2 -0
  27. package/backend/src/flowent/static/index.html +2 -2
  28. package/backend/src/flowent/storage.py +52 -1318
  29. package/backend/src/flowent/system_tools.py +25 -0
  30. package/backend/src/flowent/tools.py +4 -2
  31. package/backend/src/flowent/usage.py +9 -4
  32. package/backend/src/flowent/workflows.py +282 -0
  33. package/backend/src/flowent/workspace/__init__.py +0 -0
  34. package/backend/src/flowent/workspace/context.py +335 -0
  35. package/backend/src/flowent/workspace/events.py +178 -0
  36. package/backend/src/flowent/workspace/output.py +396 -0
  37. package/backend/src/flowent/workspace/runtime.py +1160 -0
  38. package/backend/uv.lock +1 -1
  39. package/dist/frontend/assets/index-BaZmIi2Y.js +98 -0
  40. package/dist/frontend/assets/index-EC37agAH.css +2 -0
  41. package/dist/frontend/index.html +2 -2
  42. package/package.json +1 -1
  43. package/backend/src/flowent/static/assets/index-BH30iLzb.css +0 -2
  44. package/backend/src/flowent/static/assets/index-sBFt3ORj.js +0 -84
  45. package/dist/frontend/assets/index-BH30iLzb.css +0 -2
  46. package/dist/frontend/assets/index-sBFt3ORj.js +0 -84
@@ -0,0 +1,396 @@
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
+ StoredOutputItem,
11
+ StoredTextOutputItem,
12
+ StoredThinkingOutputItem,
13
+ StoredToolItem,
14
+ StoredToolOutputItem,
15
+ )
16
+
17
+ APPROVAL_TRANSCRIPT_MESSAGE_LIMIT = 12
18
+ APPROVAL_TRANSCRIPT_TEXT_LIMIT = 2_000
19
+ USER_VISIBLE_RUN_ERROR_TITLE = "Request failed"
20
+ USER_VISIBLE_RUN_ERROR_MESSAGE = "Check the model connection settings and try again."
21
+ USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE = "Context could not be optimized."
22
+ EMPTY_MODEL_RESPONSE_DETAIL = "The model did not return a response."
23
+
24
+
25
+ def user_visible_run_error_message(detail: str) -> str:
26
+ if detail.strip() == USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE:
27
+ return USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE
28
+ return USER_VISIBLE_RUN_ERROR_MESSAGE
29
+
30
+
31
+ def run_error_output_item(
32
+ assistant_id: str,
33
+ detail: str,
34
+ index: int = 1,
35
+ ) -> StoredErrorOutputItem:
36
+ redacted_detail = redact_diagnostic_value(detail.strip())
37
+ message = user_visible_run_error_message(redacted_detail)
38
+ return StoredErrorOutputItem(
39
+ detail="" if redacted_detail == message else redacted_detail,
40
+ id=f"{assistant_id}-error-{index}",
41
+ message=message,
42
+ title=USER_VISIBLE_RUN_ERROR_TITLE,
43
+ type="error",
44
+ )
45
+
46
+
47
+ def run_error_event_data(error: StoredErrorOutputItem) -> dict[str, object]:
48
+ return {
49
+ "error": error.model_dump(exclude_none=True),
50
+ "message": error.message,
51
+ }
52
+
53
+
54
+ def message_error_items(message: StoredMessage) -> list[StoredErrorOutputItem]:
55
+ return [
56
+ item for group in message.groups for item in group.items if item.type == "error"
57
+ ]
58
+
59
+
60
+ def error_context_summary(error: StoredErrorOutputItem) -> str:
61
+ parts = [f"Previous response failed: {error.title}.", error.message]
62
+ if error.detail and error.detail != error.message:
63
+ parts.append(f"Detail: {error.detail}")
64
+ return " ".join(part.strip() for part in parts if part.strip())
65
+
66
+
67
+ def approval_transcript_text(content: str | None) -> str:
68
+ text = (content or "").strip()
69
+ if len(text) <= APPROVAL_TRANSCRIPT_TEXT_LIMIT:
70
+ return text
71
+ return f"{text[:APPROVAL_TRANSCRIPT_TEXT_LIMIT]}\n[truncated]"
72
+
73
+
74
+ def approval_transcript(
75
+ messages: Sequence[StoredMessage],
76
+ ) -> list[ApprovalTranscriptEntry]:
77
+ entries: list[ApprovalTranscriptEntry] = []
78
+ for message in messages[-APPROVAL_TRANSCRIPT_MESSAGE_LIMIT:]:
79
+ if message.author in ("user", "assistant"):
80
+ role: Literal["user", "assistant"] = (
81
+ "user" if message.author == "user" else "assistant"
82
+ )
83
+ content = approval_transcript_text(message.content)
84
+ if content:
85
+ entries.append(ApprovalTranscriptEntry(role=role, content=content))
86
+ for tool in message.tools:
87
+ tool_content = approval_transcript_text(tool.content)
88
+ if tool_content:
89
+ entries.append(
90
+ ApprovalTranscriptEntry(
91
+ role="tool",
92
+ content=tool_content,
93
+ name=tool.name,
94
+ )
95
+ )
96
+ return entries
97
+
98
+
99
+ class AssistantOutputBuilder:
100
+ def __init__(self, assistant_id: str = "") -> None:
101
+ self.assistant_id = assistant_id
102
+ self.content = ""
103
+ self.groups: list[StoredAssistantOutputGroup] = []
104
+ self.text_item_index = 0
105
+ self.text_item_id = ""
106
+ self.thinking = ""
107
+ self.thinking_item_index = 0
108
+ self.thinking_item_id = ""
109
+ self.error_item_index = 0
110
+ self.tools: dict[str, StoredToolItem] = {}
111
+
112
+ @classmethod
113
+ def from_message(cls, message: StoredMessage) -> "AssistantOutputBuilder":
114
+ builder = cls(message.id)
115
+ builder.content = message.content
116
+ builder.groups = message.groups
117
+ builder.thinking = message.thinking
118
+ builder.tools = {tool.id: tool for tool in message.tools}
119
+ builder.text_item_index = sum(
120
+ 1 for group in message.groups for item in group.items if item.type == "text"
121
+ )
122
+ builder.thinking_item_index = sum(
123
+ 1
124
+ for group in message.groups
125
+ for item in group.items
126
+ if item.type == "thinking"
127
+ )
128
+ builder.error_item_index = sum(
129
+ 1
130
+ for group in message.groups
131
+ for item in group.items
132
+ if item.type == "error"
133
+ )
134
+ latest_item = next(
135
+ (
136
+ item
137
+ for group in reversed(message.groups)
138
+ for item in reversed(group.items)
139
+ ),
140
+ None,
141
+ )
142
+ if latest_item is not None and latest_item.type == "text":
143
+ builder.text_item_id = latest_item.id
144
+ if latest_item is not None and latest_item.type == "thinking":
145
+ builder.thinking_item_id = latest_item.id
146
+ return builder
147
+
148
+ def set_assistant_id(self, assistant_id: str) -> None:
149
+ self.assistant_id = assistant_id
150
+
151
+ def start_group(self, index: int) -> None:
152
+ group_id = f"{self.assistant_id or 'assistant'}-group-{index}"
153
+ if self.groups and self.groups[-1].id == group_id:
154
+ return
155
+ self.text_item_id = ""
156
+ self.thinking_item_id = ""
157
+ self.groups.append(StoredAssistantOutputGroup(id=group_id, items=[]))
158
+
159
+ def append_text(self, content: str) -> None:
160
+ if not content:
161
+ return
162
+ self._ensure_group()
163
+ if not self.text_item_id:
164
+ self.text_item_index += 1
165
+ self.text_item_id = f"{self.assistant_id}-text-{self.text_item_index}"
166
+ self._append_current_item(
167
+ StoredTextOutputItem(content="", id=self.text_item_id, type="text")
168
+ )
169
+ self.content += content
170
+ self.groups[-1] = self.groups[-1].model_copy(
171
+ update={
172
+ "items": [
173
+ item.model_copy(update={"content": item.content + content})
174
+ if item.type == "text" and item.id == self.text_item_id
175
+ else item
176
+ for item in self.groups[-1].items
177
+ ]
178
+ }
179
+ )
180
+
181
+ def append_thinking(self, content: str) -> None:
182
+ if not content:
183
+ return
184
+ self._ensure_group()
185
+ if not self.thinking_item_id:
186
+ self.thinking_item_index += 1
187
+ self.thinking_item_id = (
188
+ f"{self.assistant_id}-thinking-{self.thinking_item_index}"
189
+ )
190
+ self._append_current_item(
191
+ StoredThinkingOutputItem(
192
+ content="", id=self.thinking_item_id, type="thinking"
193
+ )
194
+ )
195
+ self.thinking += content
196
+ self.groups[-1] = self.groups[-1].model_copy(
197
+ update={
198
+ "items": [
199
+ item.model_copy(update={"content": item.content + content})
200
+ if item.type == "thinking" and item.id == self.thinking_item_id
201
+ else item
202
+ for item in self.groups[-1].items
203
+ ]
204
+ }
205
+ )
206
+
207
+ def start_tool(self, tool: StoredToolItem) -> None:
208
+ self._ensure_group()
209
+ self.text_item_id = ""
210
+ self.thinking_item_id = ""
211
+ self.tools[tool.id] = tool
212
+ self._append_current_item(
213
+ StoredToolOutputItem(id=f"tool-{tool.id}", tool=tool, type="tool")
214
+ )
215
+
216
+ def update_tool(self, tool_id: str, data: dict[str, object]) -> None:
217
+ current_tool = self.tools.get(tool_id)
218
+ if current_tool is None:
219
+ return
220
+ updated_tool = StoredToolItem.model_validate(
221
+ {**current_tool.model_dump(exclude_none=True), **data}
222
+ )
223
+ self.tools[tool_id] = updated_tool
224
+ self.groups = [
225
+ group.model_copy(
226
+ update={
227
+ "items": [
228
+ item.model_copy(update={"tool": updated_tool})
229
+ if item.type == "tool" and item.tool.id == tool_id
230
+ else item
231
+ for item in group.items
232
+ ]
233
+ }
234
+ )
235
+ for group in self.groups
236
+ ]
237
+
238
+ def append_error(self, error: StoredErrorOutputItem) -> StoredErrorOutputItem:
239
+ self.error_item_index += 1
240
+ if not error.id:
241
+ error = error.model_copy(
242
+ update={"id": f"{self.assistant_id}-error-{self.error_item_index}"}
243
+ )
244
+ error_group_id = f"{self.assistant_id}-errors"
245
+ if self.groups and self.groups[-1].id == error_group_id:
246
+ self.groups[-1] = self.groups[-1].model_copy(
247
+ update={"items": [*self.groups[-1].items, error]}
248
+ )
249
+ else:
250
+ self.groups.append(
251
+ StoredAssistantOutputGroup(id=error_group_id, items=[error])
252
+ )
253
+ return error
254
+
255
+ def has_output(self) -> bool:
256
+ return any(group.items for group in self.groups)
257
+
258
+ def apply_done_message(
259
+ self,
260
+ message: dict[str, object],
261
+ *,
262
+ content_prefix: str = "",
263
+ thinking_prefix: str = "",
264
+ ) -> None:
265
+ message_content = str(message.get("content") or "")
266
+ message_thinking = str(message.get("thinking") or "")
267
+ final_content = message_content or self.content
268
+ final_thinking = message_thinking or self.thinking
269
+ if (
270
+ content_prefix
271
+ and message_content
272
+ and not message_content.startswith(content_prefix)
273
+ ):
274
+ final_content = f"{content_prefix}{message_content}"
275
+ if (
276
+ thinking_prefix
277
+ and message_thinking
278
+ and not message_thinking.startswith(thinking_prefix)
279
+ ):
280
+ final_thinking = f"{thinking_prefix}{message_thinking}"
281
+ self._append_missing_done_text(final_content)
282
+ self._append_missing_done_thinking(final_thinking)
283
+ self.content = final_content
284
+ self.thinking = final_thinking
285
+
286
+ def _append_missing_done_text(self, final_content: str) -> None:
287
+ streamed_text = "".join(
288
+ item.content
289
+ for group in self.groups
290
+ for item in group.items
291
+ if item.type == "text"
292
+ )
293
+ if not final_content or streamed_text == final_content:
294
+ return
295
+ missing_text = (
296
+ final_content[len(streamed_text) :]
297
+ if final_content.startswith(streamed_text)
298
+ else final_content
299
+ )
300
+ self.append_text(missing_text)
301
+
302
+ def _append_missing_done_thinking(self, final_thinking: str) -> None:
303
+ streamed_thinking = "".join(
304
+ item.content
305
+ for group in self.groups
306
+ for item in group.items
307
+ if item.type == "thinking"
308
+ )
309
+ if not final_thinking or streamed_thinking == final_thinking:
310
+ return
311
+ missing_thinking = (
312
+ final_thinking[len(streamed_thinking) :]
313
+ if final_thinking.startswith(streamed_thinking)
314
+ else final_thinking
315
+ )
316
+ self.append_thinking(missing_thinking)
317
+
318
+ def _ensure_group(self) -> None:
319
+ if not self.groups:
320
+ self.start_group(1)
321
+
322
+ def _append_current_item(
323
+ self,
324
+ item: StoredTextOutputItem
325
+ | StoredThinkingOutputItem
326
+ | StoredErrorOutputItem
327
+ | StoredToolOutputItem,
328
+ ) -> None:
329
+ self.groups[-1] = self.groups[-1].model_copy(
330
+ update={"items": [*self.groups[-1].items, item]}
331
+ )
332
+
333
+
334
+ def trim_assistant_message_at_error(
335
+ message: StoredMessage,
336
+ error_id: str,
337
+ *,
338
+ status: str,
339
+ ) -> StoredMessage | None:
340
+ next_groups: list[StoredAssistantOutputGroup] = []
341
+ found_error = False
342
+ for group in message.groups:
343
+ next_items: list[StoredOutputItem] = []
344
+ for item in group.items:
345
+ if item.type == "error" and item.id == error_id:
346
+ found_error = True
347
+ break
348
+ next_items.append(item)
349
+ if found_error:
350
+ if next_items:
351
+ next_groups.append(group.model_copy(update={"items": next_items}))
352
+ break
353
+ next_groups.append(group)
354
+
355
+ if not found_error:
356
+ return None
357
+
358
+ text_content = "".join(
359
+ item.content
360
+ for group in next_groups
361
+ for item in group.items
362
+ if item.type == "text"
363
+ )
364
+ thinking_content = "".join(
365
+ item.content
366
+ for group in next_groups
367
+ for item in group.items
368
+ if item.type == "thinking"
369
+ )
370
+ tools = [
371
+ item.tool
372
+ for group in next_groups
373
+ for item in group.items
374
+ if item.type == "tool"
375
+ ]
376
+ return message.model_copy(
377
+ update={
378
+ "content": text_content,
379
+ "groups": next_groups,
380
+ "status": status,
381
+ "thinking": thinking_content,
382
+ "tools": tools,
383
+ }
384
+ )
385
+
386
+
387
+ def assistant_retry_output_start_index(message: StoredMessage) -> int:
388
+ prefix = f"{message.id}-group-"
389
+ indexes: list[int] = []
390
+ for group in message.groups:
391
+ if not group.id.startswith(prefix):
392
+ continue
393
+ raw_index = group.id.removeprefix(prefix)
394
+ if raw_index.isdigit():
395
+ indexes.append(int(raw_index))
396
+ return max(indexes, default=1)