flowent 0.3.0 → 0.3.2

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 (30) hide show
  1. package/backend/pyproject.toml +1 -1
  2. package/backend/src/flowent/agent.py +22 -15
  3. package/backend/src/flowent/api_models.py +13 -8
  4. package/backend/src/flowent/llm.py +50 -6
  5. package/backend/src/flowent/mcp.py +4 -3
  6. package/backend/src/flowent/permissions.py +51 -38
  7. package/backend/src/flowent/routes/providers.py +33 -10
  8. package/backend/src/flowent/routes/system.py +5 -6
  9. package/backend/src/flowent/routes/workspace.py +33 -23
  10. package/backend/src/flowent/state/models.py +4 -4
  11. package/backend/src/flowent/state/schema.py +121 -0
  12. package/backend/src/flowent/state/store.py +9 -3
  13. package/backend/src/flowent/static/assets/index-BX18a4Jz.js +100 -0
  14. package/backend/src/flowent/static/assets/index-EC37agAH.css +2 -0
  15. package/backend/src/flowent/static/index.html +2 -2
  16. package/backend/src/flowent/tools.py +84 -33
  17. package/backend/src/flowent/usage.py +66 -0
  18. package/backend/src/flowent/workspace/context.py +140 -47
  19. package/backend/src/flowent/workspace/events.py +5 -7
  20. package/backend/src/flowent/workspace/output.py +129 -4
  21. package/backend/src/flowent/workspace/runtime.py +393 -185
  22. package/backend/uv.lock +1 -1
  23. package/dist/frontend/assets/index-BX18a4Jz.js +100 -0
  24. package/dist/frontend/assets/index-EC37agAH.css +2 -0
  25. package/dist/frontend/index.html +2 -2
  26. package/package.json +8 -10
  27. package/backend/src/flowent/static/assets/index-CvWZZMtK.css +0 -2
  28. package/backend/src/flowent/static/assets/index-ma2v8oW7.js +0 -90
  29. package/dist/frontend/assets/index-CvWZZMtK.css +0 -2
  30. package/dist/frontend/assets/index-ma2v8oW7.js +0 -90
@@ -1,7 +1,6 @@
1
1
  import json
2
2
  import os
3
3
  from collections.abc import Mapping, Sequence
4
- from typing import Literal
5
4
 
6
5
  from fastapi import HTTPException
7
6
 
@@ -13,10 +12,11 @@ from flowent.storage import (
13
12
  StoredSettings,
14
13
  StoredState,
15
14
  )
15
+ from flowent.tools import tool_result_model_content
16
16
  from flowent.usage import (
17
17
  TokenUsageInfo,
18
18
  current_model_context_window,
19
- estimated_token_usage_for_messages,
19
+ estimated_token_usage_for_request,
20
20
  recompute_context_usage,
21
21
  )
22
22
  from flowent.workspace.output import error_context_summary, message_error_items
@@ -49,16 +49,18 @@ def auto_compact_token_limit(context_window: int) -> int:
49
49
 
50
50
 
51
51
  def should_auto_compact(
52
- messages: list[ChatMessage],
52
+ messages: Sequence[ChatMessage | Mapping[str, object]],
53
53
  *,
54
54
  context_window: int,
55
+ tools: Sequence[Mapping[str, object]] = (),
55
56
  ) -> bool:
56
57
  token_limit = auto_compact_token_limit(context_window)
57
58
  if token_limit <= 0:
58
59
  return False
59
60
  return (
60
- estimated_token_usage_for_messages(
61
- [message.model_dump() for message in messages]
61
+ estimated_token_usage_for_request(
62
+ model_request_messages_data(messages),
63
+ tools=tools,
62
64
  ).total_tokens
63
65
  >= token_limit
64
66
  )
@@ -84,17 +86,19 @@ def update_context_usage_for_response(
84
86
  messages: Sequence[Mapping[str, object]],
85
87
  output_content: str,
86
88
  output_tools: Sequence[Mapping[str, object]] = (),
89
+ request_tools: Sequence[Mapping[str, object]] = (),
87
90
  model_context_window: int,
88
91
  ) -> TokenUsageInfo:
89
92
  return recompute_context_usage(
90
93
  usage_info,
91
- estimated_token_usage_for_messages(
94
+ estimated_token_usage_for_request(
92
95
  [
93
96
  *model_visible_messages_for_usage(messages),
94
97
  *model_visible_response_messages_for_usage(
95
98
  output_content, output_tools
96
99
  ),
97
100
  ],
101
+ tools=request_tools,
98
102
  ).total_tokens,
99
103
  model_context_window=model_context_window,
100
104
  )
@@ -108,6 +112,8 @@ def model_visible_response_messages_for_usage(
108
112
  for index, tool in enumerate(output_tools):
109
113
  tool_id = str(tool.get("id") or f"call_{index}")
110
114
  arguments = tool.get("arguments")
115
+ result_payload = tool.get("result")
116
+ tool_result = result_payload if isinstance(result_payload, dict) else {}
111
117
  visible_messages.append(
112
118
  {
113
119
  "role": "assistant",
@@ -131,7 +137,7 @@ def model_visible_response_messages_for_usage(
131
137
  {
132
138
  "role": "tool",
133
139
  "tool_call_id": tool_id,
134
- "content": str(tool.get("content") or ""),
140
+ "content": tool_result_model_content(tool_result),
135
141
  }
136
142
  )
137
143
  if output_content:
@@ -139,12 +145,129 @@ def model_visible_response_messages_for_usage(
139
145
  return visible_messages
140
146
 
141
147
 
148
+ def model_visible_assistant_output_messages(
149
+ message: StoredMessage,
150
+ ) -> list[dict[str, object]]:
151
+ visible_messages: list[dict[str, object]] = []
152
+ for group in message.groups:
153
+ group_content = "".join(
154
+ item.content for item in group.items if item.type == "text"
155
+ )
156
+ group_tools = [item.tool for item in group.items if item.type == "tool"]
157
+ if not group_tools:
158
+ if group_content:
159
+ visible_messages.append({"role": "assistant", "content": group_content})
160
+ continue
161
+ visible_messages.append(
162
+ {
163
+ "role": "assistant",
164
+ "content": group_content or None,
165
+ "tool_calls": [
166
+ {
167
+ "id": tool.id,
168
+ "type": "function",
169
+ "function": {
170
+ "name": tool.name,
171
+ "arguments": json.dumps(
172
+ tool.arguments or {},
173
+ ensure_ascii=False,
174
+ ),
175
+ },
176
+ }
177
+ for tool in group_tools
178
+ ],
179
+ }
180
+ )
181
+ visible_messages.extend(
182
+ {
183
+ "role": "tool",
184
+ "tool_call_id": tool.id,
185
+ "content": tool_result_model_content(tool.result or {}),
186
+ }
187
+ for tool in group_tools
188
+ if tool.status != "running"
189
+ )
190
+ if not visible_messages and message.content:
191
+ visible_messages.append({"role": "assistant", "content": message.content})
192
+ return visible_messages
193
+
194
+
195
+ def model_visible_workspace_message(message: StoredMessage) -> list[dict[str, object]]:
196
+ if message.author == "user":
197
+ return [{"role": "user", "content": message.content}]
198
+ if message.author != "assistant":
199
+ raise HTTPException(status_code=400, detail="Message history is invalid.")
200
+ errors = message_error_items(message)
201
+ if errors:
202
+ return [
203
+ {"role": "assistant", "content": error_context_summary(error)}
204
+ for error in errors
205
+ ]
206
+ return model_visible_assistant_output_messages(message)
207
+
208
+
209
+ def model_visible_workspace_messages(
210
+ messages: Sequence[StoredMessage],
211
+ ) -> list[dict[str, object]]:
212
+ visible_messages: list[dict[str, object]] = []
213
+ for message in messages:
214
+ visible_messages.extend(model_visible_workspace_message(message))
215
+ return visible_messages
216
+
217
+
218
+ def compact_prompt_chat_message(message: Mapping[str, object]) -> ChatMessage:
219
+ role_value = message.get("role")
220
+ content = str(message.get("content") or "")
221
+ if role_value == "system":
222
+ return ChatMessage(role="system", content=content)
223
+ if role_value == "assistant":
224
+ tool_calls = message.get("tool_calls")
225
+ if tool_calls:
226
+ return ChatMessage(
227
+ role="assistant",
228
+ content=(
229
+ f"Tool call: {json.dumps(tool_calls, ensure_ascii=False)}"
230
+ if not content
231
+ else f"{content}\nTool call: {json.dumps(tool_calls, ensure_ascii=False)}"
232
+ ),
233
+ )
234
+ return ChatMessage(role="assistant", content=content)
235
+ if role_value == "tool":
236
+ return ChatMessage(role="user", content=f"Tool result: {content}")
237
+ return ChatMessage(role="user", content=content)
238
+
239
+
240
+ def model_request_message_data(
241
+ message: ChatMessage | Mapping[str, object],
242
+ ) -> dict[str, object]:
243
+ if isinstance(message, ChatMessage):
244
+ return message.model_dump()
245
+ return dict(message)
246
+
247
+
248
+ def model_request_messages_data(
249
+ messages: Sequence[ChatMessage | Mapping[str, object]],
250
+ ) -> list[dict[str, object]]:
251
+ return [model_request_message_data(message) for message in messages]
252
+
253
+
254
+ def compact_prompt_chat_messages(
255
+ messages: Sequence[ChatMessage | Mapping[str, object]],
256
+ ) -> list[ChatMessage]:
257
+ return [
258
+ message
259
+ if isinstance(message, ChatMessage)
260
+ else compact_prompt_chat_message(message)
261
+ for message in messages
262
+ ]
263
+
264
+
142
265
  def usage_info_for_model(
143
266
  usage_info: TokenUsageInfo | None,
144
267
  model_context_window: int,
145
268
  ) -> TokenUsageInfo | None:
146
269
  if usage_info is None:
147
- return None
270
+ return TokenUsageInfo(model_context_window=model_context_window)
148
271
  return usage_info.model_copy(update={"model_context_window": model_context_window})
149
272
 
150
273
 
@@ -183,11 +306,13 @@ def workspace_chat_messages(
183
306
  messages: list[StoredMessage],
184
307
  compacted_context: str = "",
185
308
  checkpoint: StoredCompactionCheckpoint | None = None,
186
- ) -> list[ChatMessage]:
187
- chat_messages: list[ChatMessage] = []
309
+ ) -> list[dict[str, object]]:
310
+ chat_messages: list[dict[str, object]] = []
188
311
 
189
312
  if checkpoint is not None:
190
- chat_messages.extend(checkpoint.replacement_history)
313
+ chat_messages.extend(
314
+ model_request_messages_data(checkpoint.replacement_history)
315
+ )
191
316
  visible_messages = transcript_messages_after(
192
317
  messages,
193
318
  checkpoint.source_message_id,
@@ -195,26 +320,7 @@ def workspace_chat_messages(
195
320
  for message in visible_messages:
196
321
  if message.author == "system" and is_context_marker(message):
197
322
  continue
198
- if message.author not in ("user", "assistant"):
199
- raise HTTPException(
200
- status_code=400, detail="Message history is invalid."
201
- )
202
- if message.author == "assistant":
203
- errors = message_error_items(message)
204
- if errors:
205
- chat_messages.extend(
206
- ChatMessage(
207
- role="assistant", content=error_context_summary(error)
208
- )
209
- for error in errors
210
- )
211
- continue
212
- checkpoint_role: Literal["user", "assistant"] = (
213
- "user" if message.author == "user" else "assistant"
214
- )
215
- chat_messages.append(
216
- ChatMessage(role=checkpoint_role, content=message.content)
217
- )
323
+ chat_messages.extend(model_visible_workspace_message(message))
218
324
  return chat_messages
219
325
 
220
326
  marker_index = latest_compacted_context_index(messages)
@@ -223,8 +329,8 @@ def workspace_chat_messages(
223
329
  if compacted_context and marker_index is not None:
224
330
  chat_messages.extend(
225
331
  [
226
- ChatMessage(role="user", content=COMPACTED_CONTEXT_MARKER),
227
- ChatMessage(role="assistant", content=compacted_context),
332
+ {"role": "user", "content": COMPACTED_CONTEXT_MARKER},
333
+ {"role": "assistant", "content": compacted_context},
228
334
  ]
229
335
  )
230
336
  visible_messages = messages[marker_index + 1 :]
@@ -232,18 +338,5 @@ def workspace_chat_messages(
232
338
  for message in visible_messages:
233
339
  if message.author == "system" and is_context_marker(message):
234
340
  continue
235
- if message.author not in ("user", "assistant"):
236
- raise HTTPException(status_code=400, detail="Message history is invalid.")
237
- if message.author == "assistant":
238
- errors = message_error_items(message)
239
- if errors:
240
- chat_messages.extend(
241
- ChatMessage(role="assistant", content=error_context_summary(error))
242
- for error in errors
243
- )
244
- continue
245
- role: Literal["user", "assistant"] = (
246
- "user" if message.author == "user" else "assistant"
247
- )
248
- chat_messages.append(ChatMessage(role=role, content=message.content))
341
+ chat_messages.extend(model_visible_workspace_message(message))
249
342
  return chat_messages
@@ -3,19 +3,17 @@ import copy
3
3
  import json
4
4
  from dataclasses import dataclass, field
5
5
  from typing import Literal
6
- from uuid import uuid4
7
6
 
8
7
  from flowent.storage import StoredMessage
9
8
 
10
9
 
11
10
  @dataclass
12
- class WorkspaceRun:
11
+ class WorkspaceResponse:
13
12
  condition: asyncio.Condition
14
13
  active_output: Literal["text", "thinking"] | None = None
15
14
  discard_on_cancel: bool = False
16
15
  events: list[tuple[int, str, dict[str, object]]] = field(default_factory=list)
17
16
  generation: int = 0
18
- id: str = field(default_factory=lambda: str(uuid4()))
19
17
  is_done: bool = False
20
18
  latest_snapshot: StoredMessage | None = None
21
19
  task: asyncio.Task[None] | None = None
@@ -50,12 +48,12 @@ def append_or_replace_message(
50
48
  ]
51
49
 
52
50
 
53
- def run_snapshot_data_at(
54
- run: WorkspaceRun, event_index: int
51
+ def response_snapshot_data_at(
52
+ response: WorkspaceResponse, event_index: int
55
53
  ) -> dict[str, object] | None:
56
54
  snapshot_event_index = 0
57
55
  snapshot: dict[str, object] | None = None
58
- for current_event_index, event, data in run.events:
56
+ for current_event_index, event, data in response.events:
59
57
  if current_event_index > event_index:
60
58
  break
61
59
  if event != "snapshot":
@@ -78,7 +76,7 @@ def run_snapshot_data_at(
78
76
  snapshot = copy.deepcopy(message)
79
77
  if snapshot is None:
80
78
  return None
81
- for current_event_index, event, data in run.events:
79
+ for current_event_index, event, data in response.events:
82
80
  if current_event_index <= snapshot_event_index:
83
81
  continue
84
82
  if current_event_index > event_index:
@@ -7,11 +7,13 @@ from flowent.storage import (
7
7
  StoredAssistantOutputGroup,
8
8
  StoredErrorOutputItem,
9
9
  StoredMessage,
10
+ StoredOutputItem,
10
11
  StoredTextOutputItem,
11
12
  StoredThinkingOutputItem,
12
13
  StoredToolItem,
13
14
  StoredToolOutputItem,
14
15
  )
16
+ from flowent.tools import tool_result_model_content
15
17
 
16
18
  APPROVAL_TRANSCRIPT_MESSAGE_LIMIT = 12
17
19
  APPROVAL_TRANSCRIPT_TEXT_LIMIT = 2_000
@@ -83,7 +85,9 @@ def approval_transcript(
83
85
  if content:
84
86
  entries.append(ApprovalTranscriptEntry(role=role, content=content))
85
87
  for tool in message.tools:
86
- tool_content = approval_transcript_text(tool.content)
88
+ tool_content = approval_transcript_text(
89
+ tool_result_model_content(tool.result or {})
90
+ )
87
91
  if tool_content:
88
92
  entries.append(
89
93
  ApprovalTranscriptEntry(
@@ -108,6 +112,42 @@ class AssistantOutputBuilder:
108
112
  self.error_item_index = 0
109
113
  self.tools: dict[str, StoredToolItem] = {}
110
114
 
115
+ @classmethod
116
+ def from_message(cls, message: StoredMessage) -> "AssistantOutputBuilder":
117
+ builder = cls(message.id)
118
+ builder.content = message.content
119
+ builder.groups = message.groups
120
+ builder.thinking = message.thinking
121
+ builder.tools = {tool.id: tool for tool in message.tools}
122
+ builder.text_item_index = sum(
123
+ 1 for group in message.groups for item in group.items if item.type == "text"
124
+ )
125
+ builder.thinking_item_index = sum(
126
+ 1
127
+ for group in message.groups
128
+ for item in group.items
129
+ if item.type == "thinking"
130
+ )
131
+ builder.error_item_index = sum(
132
+ 1
133
+ for group in message.groups
134
+ for item in group.items
135
+ if item.type == "error"
136
+ )
137
+ latest_item = next(
138
+ (
139
+ item
140
+ for group in reversed(message.groups)
141
+ for item in reversed(group.items)
142
+ ),
143
+ None,
144
+ )
145
+ if latest_item is not None and latest_item.type == "text":
146
+ builder.text_item_id = latest_item.id
147
+ if latest_item is not None and latest_item.type == "thinking":
148
+ builder.thinking_item_id = latest_item.id
149
+ return builder
150
+
111
151
  def set_assistant_id(self, assistant_id: str) -> None:
112
152
  self.assistant_id = assistant_id
113
153
 
@@ -218,9 +258,29 @@ class AssistantOutputBuilder:
218
258
  def has_output(self) -> bool:
219
259
  return any(group.items for group in self.groups)
220
260
 
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)
261
+ def apply_done_message(
262
+ self,
263
+ message: dict[str, object],
264
+ *,
265
+ content_prefix: str = "",
266
+ thinking_prefix: str = "",
267
+ ) -> None:
268
+ message_content = str(message.get("content") or "")
269
+ message_thinking = str(message.get("thinking") or "")
270
+ final_content = message_content or self.content
271
+ final_thinking = message_thinking or self.thinking
272
+ if (
273
+ content_prefix
274
+ and message_content
275
+ and not message_content.startswith(content_prefix)
276
+ ):
277
+ final_content = f"{content_prefix}{message_content}"
278
+ if (
279
+ thinking_prefix
280
+ and message_thinking
281
+ and not message_thinking.startswith(thinking_prefix)
282
+ ):
283
+ final_thinking = f"{thinking_prefix}{message_thinking}"
224
284
  self._append_missing_done_text(final_content)
225
285
  self._append_missing_done_thinking(final_thinking)
226
286
  self.content = final_content
@@ -272,3 +332,68 @@ class AssistantOutputBuilder:
272
332
  self.groups[-1] = self.groups[-1].model_copy(
273
333
  update={"items": [*self.groups[-1].items, item]}
274
334
  )
335
+
336
+
337
+ def trim_assistant_message_at_error(
338
+ message: StoredMessage,
339
+ error_id: str,
340
+ *,
341
+ status: str,
342
+ ) -> StoredMessage | None:
343
+ next_groups: list[StoredAssistantOutputGroup] = []
344
+ found_error = False
345
+ for group in message.groups:
346
+ next_items: list[StoredOutputItem] = []
347
+ for item in group.items:
348
+ if item.type == "error" and item.id == error_id:
349
+ found_error = True
350
+ break
351
+ next_items.append(item)
352
+ if found_error:
353
+ if next_items:
354
+ next_groups.append(group.model_copy(update={"items": next_items}))
355
+ break
356
+ next_groups.append(group)
357
+
358
+ if not found_error:
359
+ return None
360
+
361
+ text_content = "".join(
362
+ item.content
363
+ for group in next_groups
364
+ for item in group.items
365
+ if item.type == "text"
366
+ )
367
+ thinking_content = "".join(
368
+ item.content
369
+ for group in next_groups
370
+ for item in group.items
371
+ if item.type == "thinking"
372
+ )
373
+ tools = [
374
+ item.tool
375
+ for group in next_groups
376
+ for item in group.items
377
+ if item.type == "tool"
378
+ ]
379
+ return message.model_copy(
380
+ update={
381
+ "content": text_content,
382
+ "groups": next_groups,
383
+ "status": status,
384
+ "thinking": thinking_content,
385
+ "tools": tools,
386
+ }
387
+ )
388
+
389
+
390
+ def assistant_retry_output_start_index(message: StoredMessage) -> int:
391
+ prefix = f"{message.id}-group-"
392
+ indexes: list[int] = []
393
+ for group in message.groups:
394
+ if not group.id.startswith(prefix):
395
+ continue
396
+ raw_index = group.id.removeprefix(prefix)
397
+ if raw_index.isdigit():
398
+ indexes.append(int(raw_index))
399
+ return max(indexes, default=1)