flowent 0.3.1 → 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.
@@ -11,7 +11,7 @@ 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,8 +23,7 @@ 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
 
@@ -35,6 +34,46 @@ class ToolContext:
35
34
  web_searcher: Callable[[str], Sequence[dict[str, str]]] | None = None
36
35
 
37
36
 
37
+ def text_tool_result(text: str, **metadata: object) -> dict[str, object]:
38
+ return {"type": "text", "text": text, **metadata}
39
+
40
+
41
+ def command_tool_result(
42
+ *,
43
+ command: str,
44
+ exit_code: int,
45
+ stderr: str,
46
+ stdout: str,
47
+ ) -> dict[str, object]:
48
+ return {
49
+ "type": "command",
50
+ "command": command,
51
+ "exit_code": exit_code,
52
+ "stderr": stderr,
53
+ "stdout": stdout,
54
+ "output": stdout or stderr,
55
+ }
56
+
57
+
58
+ def tool_result_model_content(result: ToolResult | dict[str, object]) -> str:
59
+ payload = result.result if isinstance(result, ToolResult) else result
60
+ result_type = payload.get("type")
61
+ if result_type == "command":
62
+ output = str(payload.get("output") or "")
63
+ metadata: dict[str, object] = {}
64
+ if "exit_code" in payload:
65
+ metadata["exit_code"] = payload["exit_code"]
66
+ return json.dumps(
67
+ {"output": output, "metadata": metadata},
68
+ ensure_ascii=False,
69
+ )
70
+ for key in ("text", "output"):
71
+ value = payload.get(key)
72
+ if value is not None:
73
+ return str(value)
74
+ return json.dumps(payload, ensure_ascii=False)
75
+
76
+
38
77
  def tool_specs() -> list[dict[str, object]]:
39
78
  return [
40
79
  {
@@ -222,7 +261,7 @@ def run_tool(
222
261
  title = (
223
262
  "Edit failed" if name == "apply_patch" else tool_call_title(name, arguments)
224
263
  )
225
- return ToolResult(content=str(error), ok=False, title=title)
264
+ return ToolResult(result=text_tool_result(str(error)), ok=False, title=title)
226
265
 
227
266
 
228
267
  async def run_tool_async(
@@ -238,7 +277,7 @@ async def run_tool_async(
238
277
  title = (
239
278
  "Edit failed" if name == "apply_patch" else tool_call_title(name, arguments)
240
279
  )
241
- return ToolResult(content=str(error), ok=False, title=title)
280
+ return ToolResult(result=text_tool_result(str(error)), ok=False, title=title)
242
281
 
243
282
 
244
283
  def integer_argument(arguments: dict[str, object], name: str, default: int) -> int:
@@ -266,7 +305,10 @@ def read_file(arguments: dict[str, object], context: ToolContext) -> ToolResult:
266
305
  lines = path.read_text(errors="replace").splitlines()
267
306
  selected = lines[offset : offset + limit]
268
307
  content = "\n".join(selected)
269
- return ToolResult(content=content, data={"path": str(path)}, title=f"Read {path}")
308
+ return ToolResult(
309
+ result=text_tool_result(content, path=str(path)),
310
+ title=f"Read {path}",
311
+ )
270
312
 
271
313
 
272
314
  def list_dir(arguments: dict[str, object], context: ToolContext) -> ToolResult:
@@ -279,7 +321,8 @@ def list_dir(arguments: dict[str, object], context: ToolContext) -> ToolResult:
279
321
  f"{entry.name}/" if entry.is_dir() else entry.name for entry in entries[:limit]
280
322
  ]
281
323
  return ToolResult(
282
- content="\n".join(rendered), data={"path": str(path)}, title=f"Listed {path}"
324
+ result=text_tool_result("\n".join(rendered), path=str(path)),
325
+ title=f"Listed {path}",
283
326
  )
284
327
 
285
328
 
@@ -297,8 +340,7 @@ def grep_files(arguments: dict[str, object], context: ToolContext) -> ToolResult
297
340
  )
298
341
  output = completed.stdout or completed.stderr
299
342
  return ToolResult(
300
- content=output[:20000],
301
- data={"path": str(path), "pattern": pattern},
343
+ result=text_tool_result(output[:20000], path=str(path), pattern=pattern),
302
344
  title=f"Searched {pattern}",
303
345
  )
304
346
 
@@ -317,8 +359,11 @@ def apply_patch_tool(arguments: dict[str, object], context: ToolContext) -> Tool
317
359
  raise SandboxError(tool_failure_content(result))
318
360
  data = json.loads(result.stdout or "{}")
319
361
  return ToolResult(
320
- content=result.stdout,
321
- data=data if isinstance(data, dict) else {},
362
+ result={
363
+ "type": "patch",
364
+ "output": result.stdout,
365
+ **(data if isinstance(data, dict) else {}),
366
+ },
322
367
  title=patch_title_from_result(data),
323
368
  )
324
369
 
@@ -339,8 +384,11 @@ async def apply_patch_tool_async(
339
384
  raise SandboxError(tool_failure_content(result))
340
385
  data = json.loads(result.stdout or "{}")
341
386
  return ToolResult(
342
- content=result.stdout,
343
- data=data if isinstance(data, dict) else {},
387
+ result={
388
+ "type": "patch",
389
+ "output": result.stdout,
390
+ **(data if isinstance(data, dict) else {}),
391
+ },
344
392
  title=patch_title_from_result(data),
345
393
  )
346
394
 
@@ -388,15 +436,13 @@ def shell_command(arguments: dict[str, object], context: ToolContext) -> ToolRes
388
436
  invocation.args, env=invocation.env, timeout_seconds=timeout_seconds
389
437
  )
390
438
  ok = result.exit_code == 0
391
- content = result.stdout or result.stderr
392
439
  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
- },
440
+ result=command_tool_result(
441
+ command=command,
442
+ exit_code=result.exit_code,
443
+ stderr=result.stderr,
444
+ stdout=result.stdout,
445
+ ),
400
446
  ok=ok,
401
447
  title=f"Ran {command}",
402
448
  )
@@ -412,15 +458,13 @@ async def shell_command_async(
412
458
  invocation.args, env=invocation.env, timeout_seconds=timeout_seconds
413
459
  )
414
460
  ok = result.exit_code == 0
415
- content = result.stdout or result.stderr
416
461
  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
- },
462
+ result=command_tool_result(
463
+ command=command,
464
+ exit_code=result.exit_code,
465
+ stderr=result.stderr,
466
+ stdout=result.stdout,
467
+ ),
424
468
  ok=ok,
425
469
  title=f"Ran {command}",
426
470
  )
@@ -430,8 +474,11 @@ def update_plan(arguments: dict[str, object]) -> ToolResult:
430
474
  items = arguments.get("items", [])
431
475
  content = json.dumps(items, ensure_ascii=False)
432
476
  return ToolResult(
433
- content=content,
434
- data={"items": items if isinstance(items, list) else []},
477
+ result={
478
+ "type": "plan",
479
+ "items": items if isinstance(items, list) else [],
480
+ "output": content,
481
+ },
435
482
  title="Updated plan",
436
483
  )
437
484
 
@@ -473,8 +520,12 @@ def web_search(arguments: dict[str, object], context: ToolContext) -> ToolResult
473
520
  for result in results
474
521
  )
475
522
  return ToolResult(
476
- content=content or "No results.",
477
- data={"query": query, "results": results},
523
+ result={
524
+ "type": "web_search",
525
+ "output": content or "No results.",
526
+ "query": query,
527
+ "results": results,
528
+ },
478
529
  title=f"Searched web for {query}",
479
530
  )
480
531
 
@@ -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")
@@ -12,10 +12,11 @@ from flowent.storage import (
12
12
  StoredSettings,
13
13
  StoredState,
14
14
  )
15
+ from flowent.tools import tool_result_model_content
15
16
  from flowent.usage import (
16
17
  TokenUsageInfo,
17
18
  current_model_context_window,
18
- estimated_token_usage_for_messages,
19
+ estimated_token_usage_for_request,
19
20
  recompute_context_usage,
20
21
  )
21
22
  from flowent.workspace.output import error_context_summary, message_error_items
@@ -51,13 +52,15 @@ def should_auto_compact(
51
52
  messages: Sequence[ChatMessage | Mapping[str, object]],
52
53
  *,
53
54
  context_window: int,
55
+ tools: Sequence[Mapping[str, object]] = (),
54
56
  ) -> bool:
55
57
  token_limit = auto_compact_token_limit(context_window)
56
58
  if token_limit <= 0:
57
59
  return False
58
60
  return (
59
- estimated_token_usage_for_messages(
60
- model_request_messages_data(messages)
61
+ estimated_token_usage_for_request(
62
+ model_request_messages_data(messages),
63
+ tools=tools,
61
64
  ).total_tokens
62
65
  >= token_limit
63
66
  )
@@ -83,17 +86,19 @@ def update_context_usage_for_response(
83
86
  messages: Sequence[Mapping[str, object]],
84
87
  output_content: str,
85
88
  output_tools: Sequence[Mapping[str, object]] = (),
89
+ request_tools: Sequence[Mapping[str, object]] = (),
86
90
  model_context_window: int,
87
91
  ) -> TokenUsageInfo:
88
92
  return recompute_context_usage(
89
93
  usage_info,
90
- estimated_token_usage_for_messages(
94
+ estimated_token_usage_for_request(
91
95
  [
92
96
  *model_visible_messages_for_usage(messages),
93
97
  *model_visible_response_messages_for_usage(
94
98
  output_content, output_tools
95
99
  ),
96
100
  ],
101
+ tools=request_tools,
97
102
  ).total_tokens,
98
103
  model_context_window=model_context_window,
99
104
  )
@@ -107,6 +112,8 @@ def model_visible_response_messages_for_usage(
107
112
  for index, tool in enumerate(output_tools):
108
113
  tool_id = str(tool.get("id") or f"call_{index}")
109
114
  arguments = tool.get("arguments")
115
+ result_payload = tool.get("result")
116
+ tool_result = result_payload if isinstance(result_payload, dict) else {}
110
117
  visible_messages.append(
111
118
  {
112
119
  "role": "assistant",
@@ -130,7 +137,7 @@ def model_visible_response_messages_for_usage(
130
137
  {
131
138
  "role": "tool",
132
139
  "tool_call_id": tool_id,
133
- "content": str(tool.get("content") or ""),
140
+ "content": tool_result_model_content(tool_result),
134
141
  }
135
142
  )
136
143
  if output_content:
@@ -175,7 +182,7 @@ def model_visible_assistant_output_messages(
175
182
  {
176
183
  "role": "tool",
177
184
  "tool_call_id": tool.id,
178
- "content": tool.content or "",
185
+ "content": tool_result_model_content(tool.result or {}),
179
186
  }
180
187
  for tool in group_tools
181
188
  if tool.status != "running"
@@ -260,7 +267,7 @@ def usage_info_for_model(
260
267
  model_context_window: int,
261
268
  ) -> TokenUsageInfo | None:
262
269
  if usage_info is None:
263
- return None
270
+ return TokenUsageInfo(model_context_window=model_context_window)
264
271
  return usage_info.model_copy(update={"model_context_window": model_context_window})
265
272
 
266
273
 
@@ -13,6 +13,7 @@ from flowent.storage import (
13
13
  StoredToolItem,
14
14
  StoredToolOutputItem,
15
15
  )
16
+ from flowent.tools import tool_result_model_content
16
17
 
17
18
  APPROVAL_TRANSCRIPT_MESSAGE_LIMIT = 12
18
19
  APPROVAL_TRANSCRIPT_TEXT_LIMIT = 2_000
@@ -84,7 +85,9 @@ def approval_transcript(
84
85
  if content:
85
86
  entries.append(ApprovalTranscriptEntry(role=role, content=content))
86
87
  for tool in message.tools:
87
- tool_content = approval_transcript_text(tool.content)
88
+ tool_content = approval_transcript_text(
89
+ tool_result_model_content(tool.result or {})
90
+ )
88
91
  if tool_content:
89
92
  entries.append(
90
93
  ApprovalTranscriptEntry(
@@ -27,11 +27,13 @@ from flowent.storage import (
27
27
  StoredState,
28
28
  StoredToolItem,
29
29
  )
30
- from flowent.tools import ToolContext
30
+ from flowent.tools import ToolContext, text_tool_result, tool_specs
31
31
  from flowent.usage import (
32
32
  TokenUsage,
33
33
  TokenUsageInfo,
34
34
  append_token_usage,
35
+ full_context_usage,
36
+ is_context_window_error,
35
37
  recompute_context_usage,
36
38
  )
37
39
  from flowent.workspace.context import (
@@ -67,6 +69,7 @@ logger = logging.getLogger("flowent.workspace.runtime")
67
69
 
68
70
  AUTO_COMPACT_RETAINED_MESSAGE_TOKEN_BUDGET = 20_000
69
71
  WORKSPACE_PROGRESS_FLUSH_INTERVAL_SECONDS = 0.5
72
+ USER_VISIBLE_MANUAL_COMPACT_ERROR_MESSAGE = "Context could not be compacted."
70
73
 
71
74
 
72
75
  @dataclass
@@ -188,13 +191,16 @@ class WorkspaceRuntime:
188
191
  *,
189
192
  connection: ProviderConnection,
190
193
  context_window_limit: int,
194
+ budget_messages: Sequence[ChatMessage | Mapping[str, object]] | None = None,
191
195
  messages: list[StoredMessage],
192
196
  model_history: Sequence[ChatMessage | Mapping[str, object]],
193
197
  source_message_id: str | None = None,
198
+ tools: Sequence[Mapping[str, object]] = (),
194
199
  ) -> tuple[StoredMessage, list[dict[str, object]], TokenUsageInfo] | None:
195
200
  if not should_auto_compact(
196
- model_history,
201
+ budget_messages or model_history,
197
202
  context_window=context_window_limit,
203
+ tools=tools,
198
204
  ):
199
205
  return None
200
206
  logger.info("Workspace auto compact requested")
@@ -223,6 +229,10 @@ class WorkspaceRuntime:
223
229
  )
224
230
  next_messages = [*state.messages, user_message]
225
231
  self.store.save_messages(next_messages)
232
+ model_tool_specs = [
233
+ *tool_specs(),
234
+ *list(self.mcp_manager.tool_specs()),
235
+ ]
226
236
  model_history: list[ChatMessage | Mapping[str, object]] = [
227
237
  *runtime_context_messages(self.cwd, state.settings.agent_prompt),
228
238
  *workspace_chat_messages(
@@ -234,9 +244,13 @@ class WorkspaceRuntime:
234
244
  auto_compaction = await self.auto_compact_messages(
235
245
  connection=connection,
236
246
  context_window_limit=context_window_limit,
247
+ budget_messages=self.request_messages_for_content(
248
+ state, next_messages, content
249
+ ),
237
250
  messages=state.messages,
238
251
  model_history=model_history,
239
252
  source_message_id=None,
253
+ tools=model_tool_specs,
240
254
  )
241
255
  if auto_compaction is not None:
242
256
  marker, _, _ = auto_compaction
@@ -341,6 +355,7 @@ class WorkspaceRuntime:
341
355
  tool.model_dump(exclude_none=True)
342
356
  for tool in assistant_output.tools.values()
343
357
  ],
358
+ request_tools=model_tool_specs,
344
359
  model_context_window=context_window_limit,
345
360
  )
346
361
  self.store.save_usage_info(final_usage_info)
@@ -709,6 +724,10 @@ class WorkspaceRuntime:
709
724
  turn_usage_info: TokenUsageInfo | None = None
710
725
  current_output_index = 0
711
726
  latest_usage_output_index: int | None = None
727
+ model_tool_specs = [
728
+ *tool_specs(),
729
+ *list(self.mcp_manager.tool_specs()),
730
+ ]
712
731
  if request_messages is None:
713
732
  current_request_messages = self.request_messages_for_content(
714
733
  state,
@@ -723,9 +742,11 @@ class WorkspaceRuntime:
723
742
  auto_compaction = await self.auto_compact_messages(
724
743
  connection=connection,
725
744
  context_window_limit=context_window_limit,
745
+ budget_messages=current_request_messages,
726
746
  messages=state.messages,
727
747
  model_history=pre_turn_request_messages,
728
748
  source_message_id=None,
749
+ tools=model_tool_specs,
729
750
  )
730
751
  if auto_compaction is not None:
731
752
  marker, _, usage_info = auto_compaction
@@ -746,6 +767,42 @@ class WorkspaceRuntime:
746
767
  )
747
768
  else:
748
769
  current_request_messages = request_messages
770
+ auto_compaction = await self.auto_compact_messages(
771
+ connection=connection,
772
+ context_window_limit=context_window_limit,
773
+ messages=next_messages,
774
+ model_history=compact_prompt_chat_messages(
775
+ current_request_messages
776
+ ),
777
+ source_message_id=assistant_message.id,
778
+ tools=model_tool_specs,
779
+ )
780
+ if auto_compaction is not None:
781
+ marker, replacement_history, usage_info = auto_compaction
782
+ assistant_message = assistant_message.model_copy(
783
+ update={"usage_info": usage_info}
784
+ )
785
+ next_messages = append_or_replace_message(
786
+ [*next_messages, marker], assistant_message
787
+ )
788
+ self.store.save_messages(next_messages)
789
+ await self.append_event(
790
+ response,
791
+ "context_optimized",
792
+ {
793
+ "message": marker.model_dump(),
794
+ **usage_event_data(usage_info),
795
+ },
796
+ )
797
+ current_request_messages = model_request_messages_data(
798
+ [
799
+ *runtime_context_messages(
800
+ self.cwd, state.settings.agent_prompt
801
+ ),
802
+ *explicit_skill_messages(self.cwd, self.store, content),
803
+ *replacement_history,
804
+ ]
805
+ )
749
806
  context_usage_messages = (
750
807
  usage_request_messages
751
808
  if usage_request_messages is not None
@@ -801,6 +858,7 @@ class WorkspaceRuntime:
801
858
  messages=next_messages,
802
859
  model_history=compact_prompt_chat_messages(conversation),
803
860
  source_message_id=assistant_snapshot.id,
861
+ tools=model_tool_specs,
804
862
  )
805
863
  if auto_result is None:
806
864
  return None
@@ -944,6 +1002,7 @@ class WorkspaceRuntime:
944
1002
  tool.model_dump(exclude_none=True)
945
1003
  for tool in assistant_output.tools.values()
946
1004
  ],
1005
+ request_tools=model_tool_specs,
947
1006
  model_context_window=context_window_limit,
948
1007
  )
949
1008
  self.store.save_usage_info(final_usage_info)
@@ -976,6 +1035,12 @@ class WorkspaceRuntime:
976
1035
  raise
977
1036
  except Exception as error:
978
1037
  logger.exception("Workspace response failed")
1038
+ if is_context_window_error(error):
1039
+ usage_info = full_context_usage(
1040
+ self.store.read_usage_info(),
1041
+ model_context_window=context_window_limit,
1042
+ )
1043
+ self.store.save_usage_info(usage_info)
979
1044
  if (
980
1045
  current_tool_id is not None
981
1046
  and current_tool_id in assistant_output.tools
@@ -983,7 +1048,10 @@ class WorkspaceRuntime:
983
1048
  ):
984
1049
  assistant_output.update_tool(
985
1050
  current_tool_id,
986
- {"content": str(error) or "Tool failed.", "status": "failed"},
1051
+ {
1052
+ "result": text_tool_result(str(error) or "Tool failed."),
1053
+ "status": "failed",
1054
+ },
987
1055
  )
988
1056
  error_item = assistant_output.append_error(
989
1057
  run_error_output_item(
@@ -1142,10 +1210,31 @@ class WorkspaceRuntime:
1142
1210
  async def compact_events() -> AsyncIterator[str]:
1143
1211
  try:
1144
1212
  marker, usage_info = await asyncio.shield(compact_task)
1145
- except Exception:
1213
+ except Exception as error:
1214
+ assistant_id = str(uuid4())
1215
+ assistant_output = AssistantOutputBuilder(assistant_id)
1216
+ error_item = run_error_output_item(assistant_id, str(error)).model_copy(
1217
+ update={"message": USER_VISIBLE_MANUAL_COMPACT_ERROR_MESSAGE}
1218
+ )
1219
+ assistant_output.append_error(error_item)
1220
+ failed_message = StoredMessage(
1221
+ author="assistant",
1222
+ content="",
1223
+ groups=assistant_output.groups,
1224
+ id=assistant_id,
1225
+ status="failed",
1226
+ )
1227
+ self.store.save_messages(
1228
+ [*self.store.read_state().messages, failed_message]
1229
+ )
1230
+ failed_message_data = stream_message_data(failed_message)
1231
+ yield stream_event("snapshot", {"message": failed_message_data})
1146
1232
  yield stream_event(
1147
1233
  "error",
1148
- {"message": "Context could not be compacted."},
1234
+ {
1235
+ "error": error_item.model_dump(exclude_none=True),
1236
+ "message": USER_VISIBLE_MANUAL_COMPACT_ERROR_MESSAGE,
1237
+ },
1149
1238
  )
1150
1239
  return
1151
1240
 
package/backend/uv.lock CHANGED
@@ -701,7 +701,7 @@ wheels = [
701
701
 
702
702
  [[package]]
703
703
  name = "flowent"
704
- version = "0.3.1"
704
+ version = "0.3.2"
705
705
  source = { editable = "." }
706
706
  dependencies = [
707
707
  { name = "fastapi", extra = ["standard"] },