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.
- package/README.md +3 -3
- package/backend/README.md +3 -3
- package/backend/pyproject.toml +1 -1
- package/backend/src/flowent/agent.py +1 -1
- package/backend/src/flowent/api_models.py +108 -0
- package/backend/src/flowent/app.py +151 -0
- package/backend/src/flowent/cli.py +13 -4
- package/backend/src/flowent/compact.py +34 -13
- package/backend/src/flowent/llm.py +52 -6
- package/backend/src/flowent/main.py +18 -1994
- package/backend/src/flowent/mcp.py +100 -2
- package/backend/src/flowent/network.py +5 -0
- package/backend/src/flowent/provider_connections.py +42 -0
- package/backend/src/flowent/routes/__init__.py +0 -0
- package/backend/src/flowent/routes/integrations.py +105 -0
- package/backend/src/flowent/routes/permissions.py +36 -0
- package/backend/src/flowent/routes/providers.py +53 -0
- package/backend/src/flowent/routes/system.py +48 -0
- package/backend/src/flowent/routes/workflow_routes.py +63 -0
- package/backend/src/flowent/routes/workspace.py +115 -0
- package/backend/src/flowent/state/__init__.py +53 -0
- package/backend/src/flowent/state/models.py +258 -0
- package/backend/src/flowent/state/schema.py +191 -0
- package/backend/src/flowent/state/store.py +1019 -0
- package/backend/src/flowent/static/assets/index-BaZmIi2Y.js +98 -0
- package/backend/src/flowent/static/assets/index-EC37agAH.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +52 -1318
- package/backend/src/flowent/system_tools.py +25 -0
- package/backend/src/flowent/tools.py +4 -2
- package/backend/src/flowent/usage.py +9 -4
- package/backend/src/flowent/workflows.py +282 -0
- package/backend/src/flowent/workspace/__init__.py +0 -0
- package/backend/src/flowent/workspace/context.py +335 -0
- package/backend/src/flowent/workspace/events.py +178 -0
- package/backend/src/flowent/workspace/output.py +396 -0
- package/backend/src/flowent/workspace/runtime.py +1160 -0
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-BaZmIi2Y.js +98 -0
- package/dist/frontend/assets/index-EC37agAH.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-BH30iLzb.css +0 -2
- package/backend/src/flowent/static/assets/index-sBFt3ORj.js +0 -84
- package/dist/frontend/assets/index-BH30iLzb.css +0 -2
- 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)
|