flowent 0.1.3 → 0.1.5

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 (71) hide show
  1. package/backend/pyproject.toml +1 -1
  2. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  3. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  4. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/approval.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/agent.py +23 -1
  23. package/backend/src/flowent/approval.py +148 -0
  24. package/backend/src/flowent/cli.py +16 -2
  25. package/backend/src/flowent/compact.py +183 -0
  26. package/backend/src/flowent/context.py +19 -1
  27. package/backend/src/flowent/llm.py +51 -11
  28. package/backend/src/flowent/logging.py +60 -0
  29. package/backend/src/flowent/main.py +696 -192
  30. package/backend/src/flowent/mcp.py +3 -1
  31. package/backend/src/flowent/patch.py +55 -31
  32. package/backend/src/flowent/paths.py +12 -0
  33. package/backend/src/flowent/permissions.py +185 -42
  34. package/backend/src/flowent/sandbox.py +146 -13
  35. package/backend/src/flowent/static/assets/index-Cl20cARb.css +2 -0
  36. package/backend/src/flowent/static/assets/index-dsDDsEym.js +81 -0
  37. package/backend/src/flowent/static/index.html +2 -2
  38. package/backend/src/flowent/storage.py +257 -9
  39. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  44. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  45. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  46. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  47. package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
  48. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  49. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  50. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  51. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  52. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  53. package/backend/tests/test_agent_tools.py +312 -1
  54. package/backend/tests/test_approval.py +283 -0
  55. package/backend/tests/test_llm_providers.py +216 -0
  56. package/backend/tests/test_logging.py +30 -0
  57. package/backend/tests/test_mcp.py +76 -10
  58. package/backend/tests/test_patch.py +112 -0
  59. package/backend/tests/test_permissions.py +198 -53
  60. package/backend/tests/test_persistence.py +78 -0
  61. package/backend/tests/test_startup_requirements.py +96 -0
  62. package/backend/tests/test_workspace_chat.py +1265 -144
  63. package/backend/uv.lock +1 -1
  64. package/dist/frontend/assets/index-Cl20cARb.css +2 -0
  65. package/dist/frontend/assets/index-dsDDsEym.js +81 -0
  66. package/dist/frontend/index.html +2 -2
  67. package/package.json +2 -2
  68. package/backend/src/flowent/static/assets/index-DjF2KBwE.js +0 -81
  69. package/backend/src/flowent/static/assets/index-P-bBpJG8.css +0 -2
  70. package/dist/frontend/assets/index-DjF2KBwE.js +0 -81
  71. package/dist/frontend/assets/index-P-bBpJG8.css +0 -2
@@ -2,7 +2,7 @@ import asyncio
2
2
  import json
3
3
  import logging
4
4
  import os
5
- from collections.abc import AsyncIterator
5
+ from collections.abc import AsyncIterator, Mapping, Sequence
6
6
  from contextlib import asynccontextmanager
7
7
  from dataclasses import dataclass, field
8
8
  from pathlib import Path
@@ -15,21 +15,35 @@ from fastapi.staticfiles import StaticFiles
15
15
  from pydantic import BaseModel, ConfigDict
16
16
 
17
17
  from flowent._version import __version__
18
- from flowent.agent import run_agent_stream
18
+ from flowent.agent import AgentContextUpdate, run_agent_stream
19
+ from flowent.approval import (
20
+ ApprovalReviewRequest,
21
+ ApprovalTranscriptEntry,
22
+ review_approval_request,
23
+ )
19
24
  from flowent.channels import TelegramBotManager, TelegramTransport
25
+ from flowent.compact import (
26
+ CompactInput,
27
+ LocalSummaryCompactProvider,
28
+ transcript_messages_after,
29
+ )
20
30
  from flowent.context import runtime_context_messages
21
31
  from flowent.llm import (
22
32
  ChatMessage,
23
33
  CompletionCallable,
24
34
  ProviderConnection,
25
35
  ProviderFormat,
26
- complete_chat,
27
36
  list_provider_models,
28
37
  )
29
- from flowent.logging import TRACE_LEVEL, ensure_logging_configured
38
+ from flowent.logging import (
39
+ TRACE_LEVEL,
40
+ ensure_logging_configured,
41
+ redact_diagnostic_value,
42
+ )
30
43
  from flowent.mcp import McpManager, McpTransport
31
44
  from flowent.mcp_import import McpImportDiscovery, discover_imported_mcp_servers
32
- from flowent.permissions import WritablePathDecision, run_tool_with_path_permissions
45
+ from flowent.paths import resolve_workdir
46
+ from flowent.permissions import run_tool_with_path_permissions
33
47
  from flowent.sandbox import ensure_sandbox_available
34
48
  from flowent.skills import (
35
49
  discover_skills,
@@ -38,6 +52,9 @@ from flowent.skills import (
38
52
  )
39
53
  from flowent.storage import (
40
54
  StateStore,
55
+ StoredAssistantOutputGroup,
56
+ StoredCompactionCheckpoint,
57
+ StoredErrorOutputItem,
41
58
  StoredMcpServer,
42
59
  StoredMessage,
43
60
  StoredProvider,
@@ -46,7 +63,10 @@ from flowent.storage import (
46
63
  StoredState,
47
64
  StoredTelegramBot,
48
65
  StoredTelegramSession,
66
+ StoredTextOutputItem,
67
+ StoredThinkingOutputItem,
49
68
  StoredToolItem,
69
+ StoredToolOutputItem,
50
70
  StoredWritablePath,
51
71
  )
52
72
  from flowent.tools import ToolContext
@@ -56,7 +76,11 @@ logger = logging.getLogger("flowent.main")
56
76
 
57
77
  DEFAULT_STATIC_DIR = Path(__file__).parent / "static"
58
78
  COMPACTED_CONTEXT_MARKER = "Context compacted"
59
- COMPACT_SYSTEM_PROMPT = "You are compacting Flowent workspace context."
79
+ OPTIMIZED_CONTEXT_MARKER = "Context optimized"
80
+ DEFAULT_AUTO_COMPACT_TOKEN_LIMIT = 120_000
81
+ AUTO_COMPACT_RETAINED_MESSAGE_TOKEN_BUDGET = 20_000
82
+ APPROVAL_TRANSCRIPT_MESSAGE_LIMIT = 12
83
+ APPROVAL_TRANSCRIPT_TEXT_LIMIT = 2_000
60
84
 
61
85
 
62
86
  class ProviderModelsRequest(BaseModel):
@@ -138,19 +162,6 @@ class WritablePathListResponse(BaseModel):
138
162
  writable_paths: list[StoredWritablePath]
139
163
 
140
164
 
141
- class WorkspacePermissionDecisionRequest(BaseModel):
142
- model_config = ConfigDict(extra="forbid")
143
-
144
- decision: Literal["allow_once", "always_allow", "deny"]
145
- id: str
146
-
147
-
148
- @dataclass
149
- class PendingWorkspacePermission:
150
- future: asyncio.Future[WritablePathDecision]
151
- path: Path
152
-
153
-
154
165
  @dataclass
155
166
  class WorkspaceRun:
156
167
  condition: asyncio.Condition
@@ -158,9 +169,6 @@ class WorkspaceRun:
158
169
  events: list[tuple[int, str, dict[str, object]]] = field(default_factory=list)
159
170
  id: str = field(default_factory=lambda: str(uuid4()))
160
171
  is_done: bool = False
161
- pending_permissions: dict[str, PendingWorkspacePermission] = field(
162
- default_factory=dict
163
- )
164
172
  task: asyncio.Task[None] | None = None
165
173
 
166
174
  @property
@@ -181,6 +189,265 @@ def append_or_replace_message(
181
189
  ]
182
190
 
183
191
 
192
+ USER_VISIBLE_RUN_ERROR_TITLE = "Request failed"
193
+ USER_VISIBLE_RUN_ERROR_MESSAGE = "Check the model connection settings and try again."
194
+ USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE = "Context could not be optimized."
195
+ EMPTY_MODEL_RESPONSE_DETAIL = "The model did not return a response."
196
+
197
+
198
+ def user_visible_run_error_message(detail: str) -> str:
199
+ if detail.strip() == USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE:
200
+ return USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE
201
+ return USER_VISIBLE_RUN_ERROR_MESSAGE
202
+
203
+
204
+ def run_error_output_item(
205
+ assistant_id: str,
206
+ detail: str,
207
+ index: int = 1,
208
+ ) -> StoredErrorOutputItem:
209
+ redacted_detail = redact_diagnostic_value(detail.strip())
210
+ message = user_visible_run_error_message(redacted_detail)
211
+ return StoredErrorOutputItem(
212
+ detail="" if redacted_detail == message else redacted_detail,
213
+ id=f"{assistant_id}-error-{index}",
214
+ message=message,
215
+ title=USER_VISIBLE_RUN_ERROR_TITLE,
216
+ type="error",
217
+ )
218
+
219
+
220
+ def run_error_event_data(error: StoredErrorOutputItem) -> dict[str, object]:
221
+ return {
222
+ "error": error.model_dump(exclude_none=True),
223
+ "message": error.message,
224
+ }
225
+
226
+
227
+ def message_error_items(message: StoredMessage) -> list[StoredErrorOutputItem]:
228
+ return [
229
+ item for group in message.groups for item in group.items if item.type == "error"
230
+ ]
231
+
232
+
233
+ def error_context_summary(error: StoredErrorOutputItem) -> str:
234
+ parts = [f"Previous response failed: {error.title}.", error.message]
235
+ if error.detail and error.detail != error.message:
236
+ parts.append(f"Detail: {error.detail}")
237
+ return " ".join(part.strip() for part in parts if part.strip())
238
+
239
+
240
+ def approval_transcript_text(content: str | None) -> str:
241
+ text = (content or "").strip()
242
+ if len(text) <= APPROVAL_TRANSCRIPT_TEXT_LIMIT:
243
+ return text
244
+ return f"{text[:APPROVAL_TRANSCRIPT_TEXT_LIMIT]}\n[truncated]"
245
+
246
+
247
+ def approval_transcript(
248
+ messages: Sequence[StoredMessage],
249
+ ) -> list[ApprovalTranscriptEntry]:
250
+ entries: list[ApprovalTranscriptEntry] = []
251
+ for message in messages[-APPROVAL_TRANSCRIPT_MESSAGE_LIMIT:]:
252
+ if message.author in ("user", "assistant"):
253
+ role: Literal["user", "assistant"] = (
254
+ "user" if message.author == "user" else "assistant"
255
+ )
256
+ content = approval_transcript_text(message.content)
257
+ if content:
258
+ entries.append(ApprovalTranscriptEntry(role=role, content=content))
259
+ for tool in message.tools:
260
+ tool_content = approval_transcript_text(tool.content)
261
+ if tool_content:
262
+ entries.append(
263
+ ApprovalTranscriptEntry(
264
+ role="tool",
265
+ content=tool_content,
266
+ name=tool.name,
267
+ )
268
+ )
269
+ return entries
270
+
271
+
272
+ class AssistantOutputBuilder:
273
+ def __init__(self, assistant_id: str = "") -> None:
274
+ self.assistant_id = assistant_id
275
+ self.content = ""
276
+ self.groups: list[StoredAssistantOutputGroup] = []
277
+ self.text_item_index = 0
278
+ self.text_item_id = ""
279
+ self.thinking = ""
280
+ self.thinking_item_index = 0
281
+ self.thinking_item_id = ""
282
+ self.error_item_index = 0
283
+ self.tools: dict[str, StoredToolItem] = {}
284
+
285
+ def set_assistant_id(self, assistant_id: str) -> None:
286
+ self.assistant_id = assistant_id
287
+
288
+ def start_group(self, index: int) -> None:
289
+ group_id = f"{self.assistant_id or 'assistant'}-group-{index}"
290
+ if self.groups and self.groups[-1].id == group_id:
291
+ return
292
+ self.text_item_id = ""
293
+ self.thinking_item_id = ""
294
+ self.groups.append(StoredAssistantOutputGroup(id=group_id, items=[]))
295
+
296
+ def append_text(self, content: str) -> None:
297
+ if not content:
298
+ return
299
+ self._ensure_group()
300
+ if not self.text_item_id:
301
+ self.text_item_index += 1
302
+ self.text_item_id = f"{self.assistant_id}-text-{self.text_item_index}"
303
+ self._append_current_item(
304
+ StoredTextOutputItem(content="", id=self.text_item_id, type="text")
305
+ )
306
+ self.content += content
307
+ self.groups[-1] = self.groups[-1].model_copy(
308
+ update={
309
+ "items": [
310
+ item.model_copy(update={"content": item.content + content})
311
+ if item.type == "text" and item.id == self.text_item_id
312
+ else item
313
+ for item in self.groups[-1].items
314
+ ]
315
+ }
316
+ )
317
+
318
+ def append_thinking(self, content: str) -> None:
319
+ if not content:
320
+ return
321
+ self._ensure_group()
322
+ if not self.thinking_item_id:
323
+ self.thinking_item_index += 1
324
+ self.thinking_item_id = (
325
+ f"{self.assistant_id}-thinking-{self.thinking_item_index}"
326
+ )
327
+ self._append_current_item(
328
+ StoredThinkingOutputItem(
329
+ content="", id=self.thinking_item_id, type="thinking"
330
+ )
331
+ )
332
+ self.thinking += content
333
+ self.groups[-1] = self.groups[-1].model_copy(
334
+ update={
335
+ "items": [
336
+ item.model_copy(update={"content": item.content + content})
337
+ if item.type == "thinking" and item.id == self.thinking_item_id
338
+ else item
339
+ for item in self.groups[-1].items
340
+ ]
341
+ }
342
+ )
343
+
344
+ def start_tool(self, tool: StoredToolItem) -> None:
345
+ self._ensure_group()
346
+ self.text_item_id = ""
347
+ self.thinking_item_id = ""
348
+ self.tools[tool.id] = tool
349
+ self._append_current_item(
350
+ StoredToolOutputItem(id=f"tool-{tool.id}", tool=tool, type="tool")
351
+ )
352
+
353
+ def update_tool(self, tool_id: str, data: dict[str, object]) -> None:
354
+ current_tool = self.tools.get(tool_id)
355
+ if current_tool is None:
356
+ return
357
+ updated_tool = StoredToolItem.model_validate(
358
+ {**current_tool.model_dump(exclude_none=True), **data}
359
+ )
360
+ self.tools[tool_id] = updated_tool
361
+ self.groups = [
362
+ group.model_copy(
363
+ update={
364
+ "items": [
365
+ item.model_copy(update={"tool": updated_tool})
366
+ if item.type == "tool" and item.tool.id == tool_id
367
+ else item
368
+ for item in group.items
369
+ ]
370
+ }
371
+ )
372
+ for group in self.groups
373
+ ]
374
+
375
+ def append_error(self, error: StoredErrorOutputItem) -> StoredErrorOutputItem:
376
+ self.error_item_index += 1
377
+ if not error.id:
378
+ error = error.model_copy(
379
+ update={"id": f"{self.assistant_id}-error-{self.error_item_index}"}
380
+ )
381
+ error_group_id = f"{self.assistant_id}-errors"
382
+ if self.groups and self.groups[-1].id == error_group_id:
383
+ self.groups[-1] = self.groups[-1].model_copy(
384
+ update={"items": [*self.groups[-1].items, error]}
385
+ )
386
+ else:
387
+ self.groups.append(
388
+ StoredAssistantOutputGroup(id=error_group_id, items=[error])
389
+ )
390
+ return error
391
+
392
+ def has_output(self) -> bool:
393
+ return any(group.items for group in self.groups)
394
+
395
+ def apply_done_message(self, message: dict[str, object]) -> None:
396
+ final_content = str(message.get("content") or self.content)
397
+ final_thinking = str(message.get("thinking") or self.thinking)
398
+ self._append_missing_done_text(final_content)
399
+ self._append_missing_done_thinking(final_thinking)
400
+ self.content = final_content
401
+ self.thinking = final_thinking
402
+
403
+ def _append_missing_done_text(self, final_content: str) -> None:
404
+ streamed_text = "".join(
405
+ item.content
406
+ for group in self.groups
407
+ for item in group.items
408
+ if item.type == "text"
409
+ )
410
+ if not final_content or streamed_text == final_content:
411
+ return
412
+ missing_text = (
413
+ final_content[len(streamed_text) :]
414
+ if final_content.startswith(streamed_text)
415
+ else final_content
416
+ )
417
+ self.append_text(missing_text)
418
+
419
+ def _append_missing_done_thinking(self, final_thinking: str) -> None:
420
+ streamed_thinking = "".join(
421
+ item.content
422
+ for group in self.groups
423
+ for item in group.items
424
+ if item.type == "thinking"
425
+ )
426
+ if not final_thinking or streamed_thinking == final_thinking:
427
+ return
428
+ missing_thinking = (
429
+ final_thinking[len(streamed_thinking) :]
430
+ if final_thinking.startswith(streamed_thinking)
431
+ else final_thinking
432
+ )
433
+ self.append_thinking(missing_thinking)
434
+
435
+ def _ensure_group(self) -> None:
436
+ if not self.groups:
437
+ self.start_group(1)
438
+
439
+ def _append_current_item(
440
+ self,
441
+ item: StoredTextOutputItem
442
+ | StoredThinkingOutputItem
443
+ | StoredErrorOutputItem
444
+ | StoredToolOutputItem,
445
+ ) -> None:
446
+ self.groups[-1] = self.groups[-1].model_copy(
447
+ update={"items": [*self.groups[-1].items, item]}
448
+ )
449
+
450
+
184
451
  def frontend_static_directory() -> Path:
185
452
  configured_directory = os.environ.get("FLOWENT_STATIC_DIR")
186
453
  if configured_directory:
@@ -228,16 +495,71 @@ def selected_connection(state: StoredState) -> ProviderConnection:
228
495
  def latest_compacted_context_index(messages: list[StoredMessage]) -> int | None:
229
496
  for index in range(len(messages) - 1, -1, -1):
230
497
  message = messages[index]
231
- if message.author == "system" and message.content == COMPACTED_CONTEXT_MARKER:
498
+ if message.author == "system" and is_context_marker(message):
232
499
  return index
233
500
  return None
234
501
 
235
502
 
503
+ def is_context_marker(message: StoredMessage) -> bool:
504
+ return message.content in {COMPACTED_CONTEXT_MARKER, OPTIMIZED_CONTEXT_MARKER}
505
+
506
+
507
+ def auto_compact_token_limit() -> int:
508
+ raw_limit = os.environ.get("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "")
509
+ try:
510
+ return max(0, int(raw_limit))
511
+ except ValueError:
512
+ return DEFAULT_AUTO_COMPACT_TOKEN_LIMIT
513
+
514
+
515
+ def should_auto_compact(messages: list[ChatMessage]) -> bool:
516
+ token_limit = auto_compact_token_limit()
517
+ if token_limit <= 0:
518
+ return False
519
+ return (
520
+ sum(max(1, (len(message.content) + 3) // 4) for message in messages)
521
+ >= token_limit
522
+ )
523
+
524
+
236
525
  def workspace_chat_messages(
237
526
  messages: list[StoredMessage],
238
527
  compacted_context: str = "",
528
+ checkpoint: StoredCompactionCheckpoint | None = None,
239
529
  ) -> list[ChatMessage]:
240
530
  chat_messages: list[ChatMessage] = []
531
+
532
+ if checkpoint is not None:
533
+ chat_messages.extend(checkpoint.replacement_history)
534
+ visible_messages = transcript_messages_after(
535
+ messages,
536
+ checkpoint.source_message_id,
537
+ )
538
+ for message in visible_messages:
539
+ if message.author == "system" and is_context_marker(message):
540
+ continue
541
+ if message.author not in ("user", "assistant"):
542
+ raise HTTPException(
543
+ status_code=400, detail="Message history is invalid."
544
+ )
545
+ if message.author == "assistant":
546
+ errors = message_error_items(message)
547
+ if errors:
548
+ chat_messages.extend(
549
+ ChatMessage(
550
+ role="assistant", content=error_context_summary(error)
551
+ )
552
+ for error in errors
553
+ )
554
+ continue
555
+ checkpoint_role: Literal["user", "assistant"] = (
556
+ "user" if message.author == "user" else "assistant"
557
+ )
558
+ chat_messages.append(
559
+ ChatMessage(role=checkpoint_role, content=message.content)
560
+ )
561
+ return chat_messages
562
+
241
563
  marker_index = latest_compacted_context_index(messages)
242
564
  visible_messages = messages
243
565
 
@@ -251,10 +573,18 @@ def workspace_chat_messages(
251
573
  visible_messages = messages[marker_index + 1 :]
252
574
 
253
575
  for message in visible_messages:
254
- if message.author == "system" and message.content == COMPACTED_CONTEXT_MARKER:
576
+ if message.author == "system" and is_context_marker(message):
255
577
  continue
256
578
  if message.author not in ("user", "assistant"):
257
579
  raise HTTPException(status_code=400, detail="Message history is invalid.")
580
+ if message.author == "assistant":
581
+ errors = message_error_items(message)
582
+ if errors:
583
+ chat_messages.extend(
584
+ ChatMessage(role="assistant", content=error_context_summary(error))
585
+ for error in errors
586
+ )
587
+ continue
258
588
  role: Literal["user", "assistant"] = (
259
589
  "user" if message.author == "user" else "assistant"
260
590
  )
@@ -269,43 +599,20 @@ def normalized_request_path(path: str, cwd: Path) -> Path:
269
599
  return raw_path.resolve(strict=False)
270
600
 
271
601
 
272
- def compact_prompt_messages(
273
- messages: list[StoredMessage],
274
- compacted_context: str,
275
- runtime_messages: list[ChatMessage] | None = None,
276
- ) -> list[ChatMessage]:
277
- history_messages = [
278
- *(runtime_messages or []),
279
- *workspace_chat_messages(messages, compacted_context),
280
- ]
281
- history = "\n\n".join(
282
- f"{message.role}: {message.content}" for message in history_messages
283
- )
284
- return [
285
- ChatMessage(role="system", content=COMPACT_SYSTEM_PROMPT),
286
- ChatMessage(
287
- role="user",
288
- content=(
289
- "Compact the current Flowent workspace context for the next turn.\n\n"
290
- "Keep the details needed to continue accurately, including decisions, "
291
- "constraints, pending work, and referenced facts.\n\n"
292
- f"Conversation:\n{history}"
293
- ),
294
- ),
295
- ]
296
-
297
-
298
602
  def create_app(
299
603
  *,
300
604
  serve_frontend: bool = True,
301
605
  chat_completion: CompletionCallable | None = None,
302
606
  mcp_transport: McpTransport | None = None,
303
607
  telegram_transport: TelegramTransport | None = None,
608
+ workdir: Path | str | None = None,
304
609
  ) -> FastAPI:
305
610
  ensure_logging_configured()
306
611
  ensure_sandbox_available()
307
612
 
613
+ cwd = resolve_workdir(workdir)
308
614
  store = StateStore()
615
+ compact_provider = LocalSummaryCompactProvider()
309
616
  mcp_manager = McpManager(store=store, transport=mcp_transport)
310
617
  telegram_bot_manager: TelegramBotManager | None = None
311
618
  workspace_runs: dict[str, WorkspaceRun] = {}
@@ -313,12 +620,105 @@ def create_app(
313
620
 
314
621
  static_dir = frontend_static_directory().resolve(strict=False)
315
622
  logger.debug("Flowent app created serve_frontend=%s", serve_frontend)
623
+ logger.info("Workdir: %s", cwd)
316
624
  logger.info("Static directory: %s", static_dir)
317
625
 
626
+ def request_messages_for_content(
627
+ state: StoredState,
628
+ messages: list[StoredMessage],
629
+ content: str,
630
+ ) -> list[dict[str, object]]:
631
+ compacted_context = store.read_compacted_context()
632
+ checkpoint = store.read_active_compaction_checkpoint()
633
+ chat_messages = workspace_chat_messages(
634
+ messages,
635
+ compacted_context,
636
+ checkpoint,
637
+ )
638
+ return [
639
+ message.model_dump()
640
+ for message in [
641
+ *runtime_context_messages(cwd, state.settings.agent_prompt),
642
+ *explicit_skill_messages(cwd, store, content),
643
+ *chat_messages,
644
+ ]
645
+ ]
646
+
647
+ async def save_context_checkpoint(
648
+ *,
649
+ connection: ProviderConnection,
650
+ messages: list[StoredMessage],
651
+ model_history: list[ChatMessage],
652
+ marker_content: str,
653
+ source_message_id: str | None = None,
654
+ trigger: Literal["manual", "auto"],
655
+ ) -> tuple[StoredMessage, list[dict[str, object]]]:
656
+ marker = StoredMessage(
657
+ author="system",
658
+ content=marker_content,
659
+ id=str(uuid4()),
660
+ )
661
+ compact_result = await compact_provider.compact(
662
+ connection,
663
+ CompactInput(
664
+ messages=messages,
665
+ model_history=model_history,
666
+ retained_message_token_budget=AUTO_COMPACT_RETAINED_MESSAGE_TOKEN_BUDGET,
667
+ trigger=trigger,
668
+ ),
669
+ completion=chat_completion,
670
+ )
671
+ store.save_compaction_checkpoint(
672
+ StoredCompactionCheckpoint(
673
+ id=str(uuid4()),
674
+ method=compact_result.method,
675
+ replacement_history=compact_result.replacement_history,
676
+ source_message_id=source_message_id or marker.id,
677
+ summary=compact_result.summary,
678
+ token_after=compact_result.token_after,
679
+ token_before=compact_result.token_before,
680
+ trigger=trigger,
681
+ )
682
+ )
683
+ logger.info(
684
+ "Workspace compact checkpoint saved trigger=%s method=%s summary_length=%s token_before=%s token_after=%s",
685
+ trigger,
686
+ compact_result.method,
687
+ len(compact_result.summary),
688
+ compact_result.token_before,
689
+ compact_result.token_after,
690
+ )
691
+ logger.log(TRACE_LEVEL, "Workspace compact summary=%r", compact_result.summary)
692
+ return marker, [
693
+ message.model_dump() for message in compact_result.replacement_history
694
+ ]
695
+
696
+ async def auto_compact_workspace_messages(
697
+ *,
698
+ connection: ProviderConnection,
699
+ messages: list[StoredMessage],
700
+ model_history: list[ChatMessage],
701
+ source_message_id: str | None = None,
702
+ ) -> tuple[StoredMessage, list[dict[str, object]]] | None:
703
+ if not should_auto_compact(model_history):
704
+ return None
705
+ logger.info("Workspace auto compact requested")
706
+ try:
707
+ return await save_context_checkpoint(
708
+ connection=connection,
709
+ marker_content=OPTIMIZED_CONTEXT_MARKER,
710
+ messages=messages,
711
+ model_history=model_history,
712
+ source_message_id=source_message_id,
713
+ trigger="auto",
714
+ )
715
+ except Exception as error:
716
+ logger.exception("Workspace auto compact failed")
717
+ raise RuntimeError("Context could not be optimized.") from error
718
+
318
719
  async def run_workspace_turn(content: str) -> StoredMessage:
319
720
  state = store.read_state()
320
721
  connection = selected_connection(state)
321
- cwd = Path.cwd()
322
722
  user_message = StoredMessage(
323
723
  author="user",
324
724
  content=content,
@@ -326,23 +726,54 @@ def create_app(
326
726
  )
327
727
  next_messages = [*state.messages, user_message]
328
728
  store.save_messages(next_messages)
329
- chat_messages = workspace_chat_messages(
330
- next_messages,
331
- store.read_compacted_context(),
332
- )
333
- skill_messages = explicit_skill_messages(cwd, store, content)
334
- request_messages = [
335
- message.model_dump()
336
- for message in [
337
- *runtime_context_messages(cwd),
338
- *skill_messages,
339
- *chat_messages,
340
- ]
729
+ model_history = [
730
+ *runtime_context_messages(cwd, state.settings.agent_prompt),
731
+ *workspace_chat_messages(
732
+ state.messages,
733
+ store.read_compacted_context(),
734
+ store.read_active_compaction_checkpoint(),
735
+ ),
341
736
  ]
342
- assistant_content = ""
343
- assistant_thinking = ""
344
- assistant_tools: dict[str, StoredToolItem] = {}
737
+ auto_compaction = await auto_compact_workspace_messages(
738
+ connection=connection,
739
+ messages=state.messages,
740
+ model_history=model_history,
741
+ source_message_id=None,
742
+ )
743
+ if auto_compaction is not None:
744
+ marker, _ = auto_compaction
745
+ next_messages = [*state.messages, marker, user_message]
746
+ store.save_messages(next_messages)
747
+ request_messages = request_messages_for_content(state, next_messages, content)
345
748
  assistant_id = str(uuid4())
749
+ assistant_output = AssistantOutputBuilder(assistant_id)
750
+
751
+ async def review_tool_approval(request: ApprovalReviewRequest):
752
+ return await review_approval_request(
753
+ connection,
754
+ request.model_copy(
755
+ update={
756
+ "transcript": approval_transcript(next_messages),
757
+ "user_request": content,
758
+ }
759
+ ),
760
+ completion=chat_completion,
761
+ )
762
+
763
+ async def tool_runner(
764
+ name: str,
765
+ arguments: dict[str, object],
766
+ context: ToolContext,
767
+ ):
768
+ return await run_tool_with_path_permissions(
769
+ name,
770
+ arguments,
771
+ context,
772
+ review_approval=review_tool_approval,
773
+ writable_paths=[
774
+ Path(path.path) for path in store.read_writable_paths()
775
+ ],
776
+ )
346
777
 
347
778
  async for event in run_agent_stream(
348
779
  completion=chat_completion,
@@ -352,40 +783,44 @@ def create_app(
352
783
  extra_tool_specs=mcp_manager.tool_specs(),
353
784
  extra_tool_title=mcp_manager.tool_title,
354
785
  messages=request_messages,
786
+ tool_runner=tool_runner,
355
787
  ):
788
+ if event.event == "start":
789
+ event_id = event.data.get("id")
790
+ if isinstance(event_id, str):
791
+ assistant_id = event_id
792
+ assistant_output.set_assistant_id(event_id)
793
+ if event.event == "output_start":
794
+ index = event.data.get("index")
795
+ if isinstance(index, int):
796
+ assistant_output.start_group(index)
356
797
  if event.event == "delta":
357
- assistant_content += str(event.data.get("content") or "")
798
+ assistant_output.append_text(str(event.data.get("content") or ""))
358
799
  if event.event == "thinking_delta":
359
- assistant_thinking += str(event.data.get("content") or "")
800
+ assistant_output.append_thinking(str(event.data.get("content") or ""))
360
801
  if event.event == "tool_start":
361
802
  tool = event.data.get("tool")
362
803
  if isinstance(tool, dict) and isinstance(tool.get("id"), str):
363
- assistant_tools[tool["id"]] = StoredToolItem.model_validate(tool)
804
+ assistant_output.start_tool(StoredToolItem.model_validate(tool))
364
805
  if event.event in {"tool_done", "tool_error"}:
365
806
  tool_id = event.data.get("id")
366
- if isinstance(tool_id, str) and tool_id in assistant_tools:
367
- assistant_tools[tool_id] = StoredToolItem.model_validate(
368
- {
369
- **assistant_tools[tool_id].model_dump(exclude_none=True),
370
- **event.data,
371
- }
372
- )
807
+ if isinstance(tool_id, str):
808
+ assistant_output.update_tool(tool_id, event.data)
373
809
  if event.event == "done":
374
810
  message = event.data.get("message")
375
811
  if isinstance(message, dict):
376
812
  assistant_id = str(message.get("id") or assistant_id)
377
- assistant_content = str(message.get("content") or assistant_content)
378
- assistant_thinking = str(
379
- message.get("thinking") or assistant_thinking
380
- )
813
+ assistant_output.set_assistant_id(assistant_id)
814
+ assistant_output.apply_done_message(message)
381
815
 
382
816
  assistant_message = StoredMessage(
383
817
  author="assistant",
384
- content=assistant_content,
818
+ content=assistant_output.content,
819
+ groups=assistant_output.groups,
385
820
  id=assistant_id,
386
821
  status="completed",
387
- thinking=assistant_thinking,
388
- tools=list(assistant_tools.values()),
822
+ thinking=assistant_output.thinking,
823
+ tools=list(assistant_output.tools.values()),
389
824
  )
390
825
  store.save_messages([*next_messages, assistant_message])
391
826
  return assistant_message
@@ -437,7 +872,7 @@ def create_app(
437
872
  if active_run and not active_run.is_done
438
873
  else None,
439
874
  "mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
440
- "skills": discover_skills(Path.cwd(), store),
875
+ "skills": discover_skills(cwd, store),
441
876
  }
442
877
  if telegram_bot_manager is not None:
443
878
  update["telegram_bot"] = telegram_bot_manager.bot_with_status(
@@ -462,12 +897,12 @@ def create_app(
462
897
  async def preview_mcp_import(
463
898
  request: McpImportPreviewRequest,
464
899
  ) -> McpImportDiscovery:
465
- return discover_imported_mcp_servers(Path.cwd(), source=request.source)
900
+ return discover_imported_mcp_servers(cwd, source=request.source)
466
901
 
467
902
  @app.post("/api/mcp/import")
468
903
  async def import_mcp_servers(request: McpImportRequest) -> list[StoredMcpServer]:
469
904
  imported_servers = discover_imported_mcp_servers(
470
- Path.cwd(),
905
+ cwd,
471
906
  source=request.source,
472
907
  ).servers
473
908
  existing_servers = {server.id for server in store.read_mcp_servers()}
@@ -498,7 +933,7 @@ def create_app(
498
933
 
499
934
  @app.post("/api/skills/reload")
500
935
  async def reload_skills() -> list[StoredSkill]:
501
- return discover_skills(Path.cwd(), store)
936
+ return discover_skills(cwd, store)
502
937
 
503
938
  @app.put("/api/skills/{skill_id:path}")
504
939
  async def save_skill_settings(
@@ -506,7 +941,7 @@ def create_app(
506
941
  request: SkillSettingsRequest,
507
942
  ) -> StoredSkill:
508
943
  try:
509
- return update_skill_enabled(Path.cwd(), store, skill_id, request.enabled)
944
+ return update_skill_enabled(cwd, store, skill_id, request.enabled)
510
945
  except KeyError as error:
511
946
  raise HTTPException(status_code=404, detail="Skill not found.") from error
512
947
 
@@ -548,9 +983,7 @@ def create_app(
548
983
  async def save_writable_path(
549
984
  request: WritablePathRequest,
550
985
  ) -> StoredWritablePath:
551
- return store.save_writable_path(
552
- normalized_request_path(request.path, Path.cwd())
553
- )
986
+ return store.save_writable_path(normalized_request_path(request.path, cwd))
554
987
 
555
988
  @app.delete("/api/permissions/writable-paths")
556
989
  async def delete_writable_path(
@@ -558,29 +991,10 @@ def create_app(
558
991
  ) -> WritablePathListResponse:
559
992
  return WritablePathListResponse(
560
993
  writable_paths=store.delete_writable_path(
561
- normalized_request_path(request.path, Path.cwd())
994
+ normalized_request_path(request.path, cwd)
562
995
  )
563
996
  )
564
997
 
565
- @app.post("/api/workspace/permissions/approve")
566
- async def approve_workspace_permission(
567
- request: WorkspacePermissionDecisionRequest,
568
- ) -> dict[str, bool]:
569
- run = active_workspace_run()
570
- if run is None:
571
- raise HTTPException(status_code=404, detail="Request not found.")
572
- pending = run.pending_permissions.pop(request.id, None)
573
- if pending is None:
574
- raise HTTPException(status_code=404, detail="Request not found.")
575
- path = pending.path
576
- if request.decision == "always_allow":
577
- saved_path = store.save_writable_path(path)
578
- path = Path(saved_path.path)
579
- pending.future.set_result(
580
- WritablePathDecision(decision=request.decision, path=path)
581
- )
582
- return {"ok": True}
583
-
584
998
  @app.put("/api/workspace/messages")
585
999
  async def save_workspace_messages(
586
1000
  request: WorkspaceMessagesRequest,
@@ -610,7 +1024,6 @@ def create_app(
610
1024
  nonlocal active_workspace_run_id
611
1025
  state = store.read_state()
612
1026
  connection = selected_connection(state)
613
- cwd = Path.cwd()
614
1027
 
615
1028
  user_message = StoredMessage(
616
1029
  author="user",
@@ -619,43 +1032,31 @@ def create_app(
619
1032
  )
620
1033
  next_messages = [*state.messages, user_message]
621
1034
  store.save_messages(next_messages)
622
- chat_messages = workspace_chat_messages(
623
- next_messages,
624
- store.read_compacted_context(),
625
- )
626
- request_messages = [
627
- message.model_dump()
628
- for message in [
629
- *runtime_context_messages(cwd),
630
- *explicit_skill_messages(cwd, store, content),
631
- *chat_messages,
632
- ]
633
- ]
634
1035
  run = WorkspaceRun(condition=asyncio.Condition())
635
1036
  workspace_runs[run.id] = run
636
1037
  active_workspace_run_id = run.id
637
1038
 
638
1039
  async def run_task() -> None:
639
1040
  nonlocal active_workspace_run_id
640
- assistant_tools: dict[str, StoredToolItem] = {}
1041
+ nonlocal next_messages
641
1042
  assistant_message = StoredMessage(
642
1043
  author="assistant",
643
1044
  content="",
644
1045
  id=str(uuid4()),
645
1046
  status="running",
646
1047
  )
647
- assistant_content = ""
648
- assistant_thinking = ""
1048
+ assistant_output = AssistantOutputBuilder(assistant_message.id)
649
1049
 
650
1050
  def persist_assistant(status: str = "running") -> None:
651
1051
  nonlocal next_messages, assistant_message
652
1052
  assistant_message = StoredMessage(
653
1053
  author="assistant",
654
- content=assistant_content,
1054
+ content=assistant_output.content,
1055
+ groups=assistant_output.groups,
655
1056
  id=assistant_message.id,
656
1057
  status=status,
657
- thinking=assistant_thinking,
658
- tools=list(assistant_tools.values()),
1058
+ thinking=assistant_output.thinking,
1059
+ tools=list(assistant_output.tools.values()),
659
1060
  )
660
1061
  next_messages = append_or_replace_message(
661
1062
  next_messages, assistant_message
@@ -663,26 +1064,52 @@ def create_app(
663
1064
  store.upsert_message(assistant_message)
664
1065
 
665
1066
  try:
666
-
667
- async def request_writable_path(
668
- path: Path, reason: str
669
- ) -> WritablePathDecision:
670
- permission_id = str(uuid4())
671
- future = asyncio.get_running_loop().create_future()
672
- run.pending_permissions[permission_id] = PendingWorkspacePermission(
673
- future=future,
674
- path=path,
675
- )
1067
+ current_tool_id: str | None = None
1068
+ current_request_messages = request_messages_for_content(
1069
+ state,
1070
+ next_messages,
1071
+ content,
1072
+ )
1073
+ pre_turn_request_messages = request_messages_for_content(
1074
+ state,
1075
+ state.messages,
1076
+ content,
1077
+ )
1078
+ auto_compaction = await auto_compact_workspace_messages(
1079
+ connection=connection,
1080
+ messages=state.messages,
1081
+ model_history=[
1082
+ ChatMessage.model_validate(message)
1083
+ for message in pre_turn_request_messages
1084
+ ],
1085
+ source_message_id=None,
1086
+ )
1087
+ if auto_compaction is not None:
1088
+ marker, _ = auto_compaction
1089
+ next_messages = [*state.messages, marker, user_message]
1090
+ store.save_messages(next_messages)
676
1091
  await append_run_event(
677
1092
  run,
678
- "permission_request",
679
- {
680
- "id": permission_id,
681
- "path": str(path),
682
- "reason": reason,
683
- },
1093
+ "context_optimized",
1094
+ {"message": marker.model_dump()},
1095
+ )
1096
+ current_request_messages = request_messages_for_content(
1097
+ state,
1098
+ next_messages,
1099
+ content,
1100
+ )
1101
+
1102
+ async def review_tool_approval(request: ApprovalReviewRequest):
1103
+ return await review_approval_request(
1104
+ connection,
1105
+ request.model_copy(
1106
+ update={
1107
+ "transcript": approval_transcript(next_messages),
1108
+ "user_request": content,
1109
+ }
1110
+ ),
1111
+ completion=chat_completion,
684
1112
  )
685
- return await future
686
1113
 
687
1114
  async def tool_runner(
688
1115
  name: str,
@@ -693,20 +1120,79 @@ def create_app(
693
1120
  name,
694
1121
  arguments,
695
1122
  context,
696
- request_writable_path=request_writable_path,
1123
+ review_approval=review_tool_approval,
697
1124
  writable_paths=[
698
1125
  Path(path.path) for path in store.read_writable_paths()
699
1126
  ],
700
1127
  )
701
1128
 
1129
+ async def context_compactor(
1130
+ conversation: Sequence[Mapping[str, object]],
1131
+ ) -> AgentContextUpdate | None:
1132
+ nonlocal next_messages
1133
+ assistant_snapshot = StoredMessage(
1134
+ author="assistant",
1135
+ content=assistant_output.content,
1136
+ groups=assistant_output.groups,
1137
+ id=assistant_message.id,
1138
+ status="running",
1139
+ thinking=assistant_output.thinking,
1140
+ tools=list(assistant_output.tools.values()),
1141
+ )
1142
+ model_history: list[ChatMessage] = []
1143
+ for message in conversation:
1144
+ role_value = message.get("role")
1145
+ content = str(message.get("content") or "")
1146
+ if role_value == "system":
1147
+ model_history.append(
1148
+ ChatMessage(role="system", content=content)
1149
+ )
1150
+ if role_value == "user":
1151
+ model_history.append(
1152
+ ChatMessage(role="user", content=content)
1153
+ )
1154
+ if role_value == "assistant":
1155
+ model_history.append(
1156
+ ChatMessage(role="assistant", content=content)
1157
+ )
1158
+ if role_value == "tool":
1159
+ model_history.append(
1160
+ ChatMessage(
1161
+ role="user",
1162
+ content=f"Tool result: {content}",
1163
+ )
1164
+ )
1165
+ auto_result = await auto_compact_workspace_messages(
1166
+ connection=connection,
1167
+ messages=next_messages,
1168
+ model_history=model_history,
1169
+ source_message_id=assistant_snapshot.id,
1170
+ )
1171
+ if auto_result is None:
1172
+ return None
1173
+ marker, replacement_history = auto_result
1174
+ next_messages = append_or_replace_message(
1175
+ [*next_messages, marker], assistant_snapshot
1176
+ )
1177
+ store.save_messages(next_messages)
1178
+ compacted_conversation = [
1179
+ dict(conversation[0]),
1180
+ *replacement_history,
1181
+ ]
1182
+ return AgentContextUpdate(
1183
+ conversation=compacted_conversation,
1184
+ message=marker.model_dump(),
1185
+ )
1186
+
702
1187
  async for event in run_agent_stream(
703
1188
  completion=chat_completion,
704
1189
  connection=connection,
1190
+ context_compactor=context_compactor,
705
1191
  cwd=cwd,
706
1192
  extra_tool_runner=mcp_manager.run_tool,
707
1193
  extra_tool_specs=mcp_manager.tool_specs(),
708
1194
  extra_tool_title=mcp_manager.tool_title,
709
- messages=request_messages,
1195
+ messages=current_request_messages,
710
1196
  tool_runner=tool_runner,
711
1197
  ):
712
1198
  if event.event == "start":
@@ -715,31 +1201,41 @@ def create_app(
715
1201
  assistant_message = assistant_message.model_copy(
716
1202
  update={"id": event_id}
717
1203
  )
1204
+ assistant_output.set_assistant_id(event_id)
1205
+ persist_assistant()
1206
+ if event.event == "output_start":
1207
+ index = event.data.get("index")
1208
+ if isinstance(index, int):
1209
+ assistant_output.start_group(index)
718
1210
  persist_assistant()
719
1211
  if event.event == "tool_start":
720
1212
  tool = event.data.get("tool")
721
1213
  if isinstance(tool, dict) and isinstance(tool.get("id"), str):
722
- assistant_tools[tool["id"]] = StoredToolItem.model_validate(
723
- tool
1214
+ current_tool_id = tool["id"]
1215
+ assistant_output.start_tool(
1216
+ StoredToolItem.model_validate(tool)
724
1217
  )
725
1218
  persist_assistant()
726
1219
  if event.event in {"tool_done", "tool_error"}:
727
1220
  tool_id = event.data.get("id")
728
- if isinstance(tool_id, str) and tool_id in assistant_tools:
729
- assistant_tools[tool_id] = StoredToolItem.model_validate(
730
- {
731
- **assistant_tools[tool_id].model_dump(
732
- exclude_none=True
733
- ),
734
- **event.data,
735
- }
1221
+ if (
1222
+ isinstance(tool_id, str)
1223
+ and tool_id in assistant_output.tools
1224
+ ):
1225
+ current_tool_id = (
1226
+ None if current_tool_id == tool_id else current_tool_id
736
1227
  )
1228
+ assistant_output.update_tool(tool_id, event.data)
737
1229
  persist_assistant()
738
1230
  if event.event == "delta":
739
- assistant_content += str(event.data.get("content") or "")
1231
+ assistant_output.append_text(
1232
+ str(event.data.get("content") or "")
1233
+ )
740
1234
  persist_assistant()
741
1235
  if event.event == "thinking_delta":
742
- assistant_thinking += str(event.data.get("content") or "")
1236
+ assistant_output.append_thinking(
1237
+ str(event.data.get("content") or "")
1238
+ )
743
1239
  persist_assistant()
744
1240
  logger.log(
745
1241
  TRACE_LEVEL,
@@ -750,12 +1246,7 @@ def create_app(
750
1246
  if event.event == "done":
751
1247
  message = event.data.get("message")
752
1248
  if isinstance(message, dict):
753
- assistant_content = str(
754
- message.get("content") or assistant_content
755
- )
756
- assistant_thinking = str(
757
- message.get("thinking") or assistant_thinking
758
- )
1249
+ assistant_output.apply_done_message(message)
759
1250
  persist_assistant("completed")
760
1251
  await append_run_event(run, event.event, event.data)
761
1252
  except asyncio.CancelledError:
@@ -770,12 +1261,23 @@ def create_app(
770
1261
  raise
771
1262
  except Exception as error:
772
1263
  logger.exception("Workspace response failed")
773
- persist_assistant("failed")
774
- await append_run_event(
775
- run,
776
- "error",
777
- {"message": str(error) or "Message could not be sent."},
1264
+ if (
1265
+ current_tool_id is not None
1266
+ and current_tool_id in assistant_output.tools
1267
+ and assistant_output.tools[current_tool_id].status == "running"
1268
+ ):
1269
+ assistant_output.update_tool(
1270
+ current_tool_id,
1271
+ {"content": str(error) or "Tool failed.", "status": "failed"},
1272
+ )
1273
+ error_item = assistant_output.append_error(
1274
+ run_error_output_item(
1275
+ assistant_message.id,
1276
+ str(error) or EMPTY_MODEL_RESPONSE_DETAIL,
1277
+ )
778
1278
  )
1279
+ persist_assistant("failed")
1280
+ await append_run_event(run, "error", run_error_event_data(error_item))
779
1281
  finally:
780
1282
  run.is_done = True
781
1283
  async with run.condition:
@@ -843,21 +1345,32 @@ def create_app(
843
1345
 
844
1346
  @app.post("/api/workspace/compact")
845
1347
  async def compact_workspace() -> WorkspaceCompactResponse:
1348
+ if active_workspace_run() is not None:
1349
+ raise HTTPException(
1350
+ status_code=409,
1351
+ detail="Compact is unavailable while Flowent is responding.",
1352
+ )
846
1353
  logger.info("Workspace compact requested")
847
1354
  state = store.read_state()
848
1355
  connection = selected_connection(state)
849
- compacted_context = store.read_compacted_context()
850
- cwd = Path.cwd()
1356
+ checkpoint = store.read_active_compaction_checkpoint()
1357
+ model_history = [
1358
+ *runtime_context_messages(cwd, state.settings.agent_prompt),
1359
+ *workspace_chat_messages(
1360
+ state.messages,
1361
+ store.read_compacted_context(),
1362
+ checkpoint,
1363
+ ),
1364
+ ]
851
1365
 
852
1366
  try:
853
- summary = await complete_chat(
854
- connection,
855
- compact_prompt_messages(
856
- state.messages,
857
- compacted_context,
858
- runtime_context_messages(cwd),
859
- ),
860
- completion=chat_completion,
1367
+ marker, _ = await save_context_checkpoint(
1368
+ connection=connection,
1369
+ marker_content=COMPACTED_CONTEXT_MARKER,
1370
+ messages=state.messages,
1371
+ model_history=model_history,
1372
+ source_message_id=None,
1373
+ trigger="manual",
861
1374
  )
862
1375
  except HTTPException:
863
1376
  raise
@@ -868,17 +1381,8 @@ def create_app(
868
1381
  detail="Context could not be compacted.",
869
1382
  ) from error
870
1383
 
871
- marker = StoredMessage(
872
- author="system",
873
- content=COMPACTED_CONTEXT_MARKER,
874
- id=str(uuid4()),
875
- )
876
- store.save_compacted_context(summary.content)
877
1384
  store.save_messages([*state.messages, marker])
878
- logger.info(
879
- "Workspace compact completed summary_length=%s", len(summary.content)
880
- )
881
- logger.log(TRACE_LEVEL, "Workspace compact summary=%r", summary.content)
1385
+ logger.info("Workspace compact completed")
882
1386
  return WorkspaceCompactResponse(message=marker)
883
1387
 
884
1388
  @app.post("/api/workspace/respond")