flowent 0.1.4 → 0.2.0

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 (68) 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 +117 -34
  23. package/backend/src/flowent/approval.py +148 -0
  24. package/backend/src/flowent/cli.py +4 -2
  25. package/backend/src/flowent/context.py +19 -1
  26. package/backend/src/flowent/llm.py +176 -16
  27. package/backend/src/flowent/logging.py +60 -0
  28. package/backend/src/flowent/main.py +639 -210
  29. package/backend/src/flowent/patch.py +55 -31
  30. package/backend/src/flowent/permissions.py +185 -42
  31. package/backend/src/flowent/sandbox.py +55 -1
  32. package/backend/src/flowent/static/assets/index-BlaCigkZ.js +82 -0
  33. package/backend/src/flowent/static/assets/index-CRvbsH4K.css +2 -0
  34. package/backend/src/flowent/static/index.html +2 -2
  35. package/backend/src/flowent/storage.py +113 -18
  36. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  44. package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
  45. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  46. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  47. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  48. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  49. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  50. package/backend/tests/conftest.py +39 -0
  51. package/backend/tests/test_agent_tools.py +213 -1
  52. package/backend/tests/test_approval.py +283 -0
  53. package/backend/tests/test_llm_providers.py +377 -0
  54. package/backend/tests/test_logging.py +30 -0
  55. package/backend/tests/test_patch.py +112 -0
  56. package/backend/tests/test_permissions.py +198 -53
  57. package/backend/tests/test_persistence.py +78 -0
  58. package/backend/tests/test_startup_requirements.py +54 -0
  59. package/backend/tests/test_workspace_chat.py +902 -36
  60. package/backend/uv.lock +1 -1
  61. package/dist/frontend/assets/index-BlaCigkZ.js +82 -0
  62. package/dist/frontend/assets/index-CRvbsH4K.css +2 -0
  63. package/dist/frontend/index.html +2 -2
  64. package/package.json +1 -1
  65. package/backend/src/flowent/static/assets/index-BREidonU.css +0 -2
  66. package/backend/src/flowent/static/assets/index-DSniOrhL.js +0 -81
  67. package/dist/frontend/assets/index-BREidonU.css +0 -2
  68. package/dist/frontend/assets/index-DSniOrhL.js +0 -81
@@ -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,7 +15,12 @@ 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
20
25
  from flowent.compact import (
21
26
  CompactInput,
@@ -30,11 +35,15 @@ from flowent.llm import (
30
35
  ProviderFormat,
31
36
  list_provider_models,
32
37
  )
33
- 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
+ )
34
43
  from flowent.mcp import McpManager, McpTransport
35
44
  from flowent.mcp_import import McpImportDiscovery, discover_imported_mcp_servers
36
45
  from flowent.paths import resolve_workdir
37
- from flowent.permissions import WritablePathDecision, run_tool_with_path_permissions
46
+ from flowent.permissions import run_tool_with_path_permissions
38
47
  from flowent.sandbox import ensure_sandbox_available
39
48
  from flowent.skills import (
40
49
  discover_skills,
@@ -43,17 +52,21 @@ from flowent.skills import (
43
52
  )
44
53
  from flowent.storage import (
45
54
  StateStore,
55
+ StoredAssistantOutputGroup,
46
56
  StoredCompactionCheckpoint,
57
+ StoredErrorOutputItem,
47
58
  StoredMcpServer,
48
59
  StoredMessage,
49
- StoredPermissionRequest,
50
60
  StoredProvider,
51
61
  StoredSettings,
52
62
  StoredSkill,
53
63
  StoredState,
54
64
  StoredTelegramBot,
55
65
  StoredTelegramSession,
66
+ StoredTextOutputItem,
67
+ StoredThinkingOutputItem,
56
68
  StoredToolItem,
69
+ StoredToolOutputItem,
57
70
  StoredWritablePath,
58
71
  )
59
72
  from flowent.tools import ToolContext
@@ -63,6 +76,11 @@ logger = logging.getLogger("flowent.main")
63
76
 
64
77
  DEFAULT_STATIC_DIR = Path(__file__).parent / "static"
65
78
  COMPACTED_CONTEXT_MARKER = "Context compacted"
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
66
84
 
67
85
 
68
86
  class ProviderModelsRequest(BaseModel):
@@ -144,21 +162,6 @@ class WritablePathListResponse(BaseModel):
144
162
  writable_paths: list[StoredWritablePath]
145
163
 
146
164
 
147
- class WorkspacePermissionDecisionRequest(BaseModel):
148
- model_config = ConfigDict(extra="forbid")
149
-
150
- decision: Literal["allow_once", "always_allow", "deny"]
151
- id: str
152
-
153
-
154
- @dataclass
155
- class PendingWorkspacePermission:
156
- future: asyncio.Future[WritablePathDecision]
157
- path: Path
158
- reason: str
159
- tool_call_id: str | None = None
160
-
161
-
162
165
  @dataclass
163
166
  class WorkspaceRun:
164
167
  condition: asyncio.Condition
@@ -166,26 +169,12 @@ class WorkspaceRun:
166
169
  events: list[tuple[int, str, dict[str, object]]] = field(default_factory=list)
167
170
  id: str = field(default_factory=lambda: str(uuid4()))
168
171
  is_done: bool = False
169
- pending_permissions: dict[str, PendingWorkspacePermission] = field(
170
- default_factory=dict
171
- )
172
172
  task: asyncio.Task[None] | None = None
173
173
 
174
174
  @property
175
175
  def latest_event_index(self) -> int:
176
176
  return self.events[-1][0] if self.events else 0
177
177
 
178
- def permission_requests(self) -> list[StoredPermissionRequest]:
179
- return [
180
- StoredPermissionRequest(
181
- id=permission_id,
182
- path=str(permission.path),
183
- reason=permission.reason,
184
- tool_call_id=permission.tool_call_id,
185
- )
186
- for permission_id, permission in self.pending_permissions.items()
187
- ]
188
-
189
178
 
190
179
  def stream_event(event: str, data: dict[str, object]) -> str:
191
180
  return f"event: {event}\ndata: {json.dumps(data)}\n\n"
@@ -200,6 +189,265 @@ def append_or_replace_message(
200
189
  ]
201
190
 
202
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
+
203
451
  def frontend_static_directory() -> Path:
204
452
  configured_directory = os.environ.get("FLOWENT_STATIC_DIR")
205
453
  if configured_directory:
@@ -247,11 +495,33 @@ def selected_connection(state: StoredState) -> ProviderConnection:
247
495
  def latest_compacted_context_index(messages: list[StoredMessage]) -> int | None:
248
496
  for index in range(len(messages) - 1, -1, -1):
249
497
  message = messages[index]
250
- if message.author == "system" and message.content == COMPACTED_CONTEXT_MARKER:
498
+ if message.author == "system" and is_context_marker(message):
251
499
  return index
252
500
  return None
253
501
 
254
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
+
255
525
  def workspace_chat_messages(
256
526
  messages: list[StoredMessage],
257
527
  compacted_context: str = "",
@@ -266,15 +536,22 @@ def workspace_chat_messages(
266
536
  checkpoint.source_message_id,
267
537
  )
268
538
  for message in visible_messages:
269
- if (
270
- message.author == "system"
271
- and message.content == COMPACTED_CONTEXT_MARKER
272
- ):
539
+ if message.author == "system" and is_context_marker(message):
273
540
  continue
274
541
  if message.author not in ("user", "assistant"):
275
542
  raise HTTPException(
276
543
  status_code=400, detail="Message history is invalid."
277
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
278
555
  checkpoint_role: Literal["user", "assistant"] = (
279
556
  "user" if message.author == "user" else "assistant"
280
557
  )
@@ -296,10 +573,18 @@ def workspace_chat_messages(
296
573
  visible_messages = messages[marker_index + 1 :]
297
574
 
298
575
  for message in visible_messages:
299
- if message.author == "system" and message.content == COMPACTED_CONTEXT_MARKER:
576
+ if message.author == "system" and is_context_marker(message):
300
577
  continue
301
578
  if message.author not in ("user", "assistant"):
302
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
303
588
  role: Literal["user", "assistant"] = (
304
589
  "user" if message.author == "user" else "assistant"
305
590
  )
@@ -338,6 +623,99 @@ def create_app(
338
623
  logger.info("Workdir: %s", cwd)
339
624
  logger.info("Static directory: %s", static_dir)
340
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
+
341
719
  async def run_workspace_turn(content: str) -> StoredMessage:
342
720
  state = store.read_state()
343
721
  connection = selected_connection(state)
@@ -348,24 +726,54 @@ def create_app(
348
726
  )
349
727
  next_messages = [*state.messages, user_message]
350
728
  store.save_messages(next_messages)
351
- chat_messages = workspace_chat_messages(
352
- next_messages,
353
- store.read_compacted_context(),
354
- store.read_active_compaction_checkpoint(),
355
- )
356
- skill_messages = explicit_skill_messages(cwd, store, content)
357
- request_messages = [
358
- message.model_dump()
359
- for message in [
360
- *runtime_context_messages(cwd),
361
- *skill_messages,
362
- *chat_messages,
363
- ]
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
+ ),
364
736
  ]
365
- assistant_content = ""
366
- assistant_thinking = ""
367
- 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)
368
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
+ )
369
777
 
370
778
  async for event in run_agent_stream(
371
779
  completion=chat_completion,
@@ -375,40 +783,44 @@ def create_app(
375
783
  extra_tool_specs=mcp_manager.tool_specs(),
376
784
  extra_tool_title=mcp_manager.tool_title,
377
785
  messages=request_messages,
786
+ tool_runner=tool_runner,
378
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)
379
797
  if event.event == "delta":
380
- assistant_content += str(event.data.get("content") or "")
798
+ assistant_output.append_text(str(event.data.get("content") or ""))
381
799
  if event.event == "thinking_delta":
382
- assistant_thinking += str(event.data.get("content") or "")
800
+ assistant_output.append_thinking(str(event.data.get("content") or ""))
383
801
  if event.event == "tool_start":
384
802
  tool = event.data.get("tool")
385
803
  if isinstance(tool, dict) and isinstance(tool.get("id"), str):
386
- assistant_tools[tool["id"]] = StoredToolItem.model_validate(tool)
804
+ assistant_output.start_tool(StoredToolItem.model_validate(tool))
387
805
  if event.event in {"tool_done", "tool_error"}:
388
806
  tool_id = event.data.get("id")
389
- if isinstance(tool_id, str) and tool_id in assistant_tools:
390
- assistant_tools[tool_id] = StoredToolItem.model_validate(
391
- {
392
- **assistant_tools[tool_id].model_dump(exclude_none=True),
393
- **event.data,
394
- }
395
- )
807
+ if isinstance(tool_id, str):
808
+ assistant_output.update_tool(tool_id, event.data)
396
809
  if event.event == "done":
397
810
  message = event.data.get("message")
398
811
  if isinstance(message, dict):
399
812
  assistant_id = str(message.get("id") or assistant_id)
400
- assistant_content = str(message.get("content") or assistant_content)
401
- assistant_thinking = str(
402
- message.get("thinking") or assistant_thinking
403
- )
813
+ assistant_output.set_assistant_id(assistant_id)
814
+ assistant_output.apply_done_message(message)
404
815
 
405
816
  assistant_message = StoredMessage(
406
817
  author="assistant",
407
- content=assistant_content,
818
+ content=assistant_output.content,
819
+ groups=assistant_output.groups,
408
820
  id=assistant_id,
409
821
  status="completed",
410
- thinking=assistant_thinking,
411
- tools=list(assistant_tools.values()),
822
+ thinking=assistant_output.thinking,
823
+ tools=list(assistant_output.tools.values()),
412
824
  )
413
825
  store.save_messages([*next_messages, assistant_message])
414
826
  return assistant_message
@@ -460,9 +872,6 @@ def create_app(
460
872
  if active_run and not active_run.is_done
461
873
  else None,
462
874
  "mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
463
- "permission_requests": active_run.permission_requests()
464
- if active_run and not active_run.is_done
465
- else [],
466
875
  "skills": discover_skills(cwd, store),
467
876
  }
468
877
  if telegram_bot_manager is not None:
@@ -586,25 +995,6 @@ def create_app(
586
995
  )
587
996
  )
588
997
 
589
- @app.post("/api/workspace/permissions/approve")
590
- async def approve_workspace_permission(
591
- request: WorkspacePermissionDecisionRequest,
592
- ) -> dict[str, bool]:
593
- run = active_workspace_run()
594
- if run is None:
595
- raise HTTPException(status_code=404, detail="Request not found.")
596
- pending = run.pending_permissions.pop(request.id, None)
597
- if pending is None:
598
- raise HTTPException(status_code=404, detail="Request not found.")
599
- path = pending.path
600
- if request.decision == "always_allow":
601
- saved_path = store.save_writable_path(path)
602
- path = Path(saved_path.path)
603
- pending.future.set_result(
604
- WritablePathDecision(decision=request.decision, path=path)
605
- )
606
- return {"ok": True}
607
-
608
998
  @app.put("/api/workspace/messages")
609
999
  async def save_workspace_messages(
610
1000
  request: WorkspaceMessagesRequest,
@@ -642,44 +1032,31 @@ def create_app(
642
1032
  )
643
1033
  next_messages = [*state.messages, user_message]
644
1034
  store.save_messages(next_messages)
645
- chat_messages = workspace_chat_messages(
646
- next_messages,
647
- store.read_compacted_context(),
648
- store.read_active_compaction_checkpoint(),
649
- )
650
- request_messages = [
651
- message.model_dump()
652
- for message in [
653
- *runtime_context_messages(cwd),
654
- *explicit_skill_messages(cwd, store, content),
655
- *chat_messages,
656
- ]
657
- ]
658
1035
  run = WorkspaceRun(condition=asyncio.Condition())
659
1036
  workspace_runs[run.id] = run
660
1037
  active_workspace_run_id = run.id
661
1038
 
662
1039
  async def run_task() -> None:
663
1040
  nonlocal active_workspace_run_id
664
- assistant_tools: dict[str, StoredToolItem] = {}
1041
+ nonlocal next_messages
665
1042
  assistant_message = StoredMessage(
666
1043
  author="assistant",
667
1044
  content="",
668
1045
  id=str(uuid4()),
669
1046
  status="running",
670
1047
  )
671
- assistant_content = ""
672
- assistant_thinking = ""
1048
+ assistant_output = AssistantOutputBuilder(assistant_message.id)
673
1049
 
674
1050
  def persist_assistant(status: str = "running") -> None:
675
1051
  nonlocal next_messages, assistant_message
676
1052
  assistant_message = StoredMessage(
677
1053
  author="assistant",
678
- content=assistant_content,
1054
+ content=assistant_output.content,
1055
+ groups=assistant_output.groups,
679
1056
  id=assistant_message.id,
680
1057
  status=status,
681
- thinking=assistant_thinking,
682
- tools=list(assistant_tools.values()),
1058
+ thinking=assistant_output.thinking,
1059
+ tools=list(assistant_output.tools.values()),
683
1060
  )
684
1061
  next_messages = append_or_replace_message(
685
1062
  next_messages, assistant_message
@@ -688,44 +1065,51 @@ def create_app(
688
1065
 
689
1066
  try:
690
1067
  current_tool_id: str | None = None
691
-
692
- async def request_writable_path(
693
- path: Path, reason: str
694
- ) -> WritablePathDecision:
695
- permission_id = str(uuid4())
696
- future = asyncio.get_running_loop().create_future()
697
- run.pending_permissions[permission_id] = PendingWorkspacePermission(
698
- future=future,
699
- path=path,
700
- reason=reason,
701
- tool_call_id=current_tool_id,
702
- )
703
- if current_tool_id and current_tool_id in assistant_tools:
704
- assistant_tools[current_tool_id] = (
705
- StoredToolItem.model_validate(
706
- {
707
- **assistant_tools[current_tool_id].model_dump(
708
- exclude_none=True
709
- ),
710
- "status": "waiting",
711
- }
712
- )
713
- )
714
- persist_assistant()
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)
715
1091
  await append_run_event(
716
1092
  run,
717
- "permission_request",
718
- {
719
- "id": permission_id,
720
- "path": str(path),
721
- "reason": reason,
722
- "tool_call_id": current_tool_id,
723
- },
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,
724
1112
  )
725
- try:
726
- return await future
727
- finally:
728
- run.pending_permissions.pop(permission_id, None)
729
1113
 
730
1114
  async def tool_runner(
731
1115
  name: str,
@@ -736,20 +1120,79 @@ def create_app(
736
1120
  name,
737
1121
  arguments,
738
1122
  context,
739
- request_writable_path=request_writable_path,
1123
+ review_approval=review_tool_approval,
740
1124
  writable_paths=[
741
1125
  Path(path.path) for path in store.read_writable_paths()
742
1126
  ],
743
1127
  )
744
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
+
745
1187
  async for event in run_agent_stream(
746
1188
  completion=chat_completion,
747
1189
  connection=connection,
1190
+ context_compactor=context_compactor,
748
1191
  cwd=cwd,
749
1192
  extra_tool_runner=mcp_manager.run_tool,
750
1193
  extra_tool_specs=mcp_manager.tool_specs(),
751
1194
  extra_tool_title=mcp_manager.tool_title,
752
- messages=request_messages,
1195
+ messages=current_request_messages,
753
1196
  tool_runner=tool_runner,
754
1197
  ):
755
1198
  if event.event == "start":
@@ -758,35 +1201,41 @@ def create_app(
758
1201
  assistant_message = assistant_message.model_copy(
759
1202
  update={"id": event_id}
760
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)
761
1210
  persist_assistant()
762
1211
  if event.event == "tool_start":
763
1212
  tool = event.data.get("tool")
764
1213
  if isinstance(tool, dict) and isinstance(tool.get("id"), str):
765
1214
  current_tool_id = tool["id"]
766
- assistant_tools[tool["id"]] = StoredToolItem.model_validate(
767
- tool
1215
+ assistant_output.start_tool(
1216
+ StoredToolItem.model_validate(tool)
768
1217
  )
769
1218
  persist_assistant()
770
1219
  if event.event in {"tool_done", "tool_error"}:
771
1220
  tool_id = event.data.get("id")
772
- if isinstance(tool_id, str) and tool_id in assistant_tools:
1221
+ if (
1222
+ isinstance(tool_id, str)
1223
+ and tool_id in assistant_output.tools
1224
+ ):
773
1225
  current_tool_id = (
774
1226
  None if current_tool_id == tool_id else current_tool_id
775
1227
  )
776
- assistant_tools[tool_id] = StoredToolItem.model_validate(
777
- {
778
- **assistant_tools[tool_id].model_dump(
779
- exclude_none=True
780
- ),
781
- **event.data,
782
- }
783
- )
1228
+ assistant_output.update_tool(tool_id, event.data)
784
1229
  persist_assistant()
785
1230
  if event.event == "delta":
786
- assistant_content += str(event.data.get("content") or "")
1231
+ assistant_output.append_text(
1232
+ str(event.data.get("content") or "")
1233
+ )
787
1234
  persist_assistant()
788
1235
  if event.event == "thinking_delta":
789
- assistant_thinking += str(event.data.get("content") or "")
1236
+ assistant_output.append_thinking(
1237
+ str(event.data.get("content") or "")
1238
+ )
790
1239
  persist_assistant()
791
1240
  logger.log(
792
1241
  TRACE_LEVEL,
@@ -797,12 +1246,7 @@ def create_app(
797
1246
  if event.event == "done":
798
1247
  message = event.data.get("message")
799
1248
  if isinstance(message, dict):
800
- assistant_content = str(
801
- message.get("content") or assistant_content
802
- )
803
- assistant_thinking = str(
804
- message.get("thinking") or assistant_thinking
805
- )
1249
+ assistant_output.apply_done_message(message)
806
1250
  persist_assistant("completed")
807
1251
  await append_run_event(run, event.event, event.data)
808
1252
  except asyncio.CancelledError:
@@ -817,12 +1261,23 @@ def create_app(
817
1261
  raise
818
1262
  except Exception as error:
819
1263
  logger.exception("Workspace response failed")
820
- persist_assistant("failed")
821
- await append_run_event(
822
- run,
823
- "error",
824
- {"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
+ )
825
1278
  )
1279
+ persist_assistant("failed")
1280
+ await append_run_event(run, "error", run_error_event_data(error_item))
826
1281
  finally:
827
1282
  run.is_done = True
828
1283
  async with run.condition:
@@ -900,7 +1355,7 @@ def create_app(
900
1355
  connection = selected_connection(state)
901
1356
  checkpoint = store.read_active_compaction_checkpoint()
902
1357
  model_history = [
903
- *runtime_context_messages(cwd),
1358
+ *runtime_context_messages(cwd, state.settings.agent_prompt),
904
1359
  *workspace_chat_messages(
905
1360
  state.messages,
906
1361
  store.read_compacted_context(),
@@ -909,14 +1364,13 @@ def create_app(
909
1364
  ]
910
1365
 
911
1366
  try:
912
- compact_result = await compact_provider.compact(
913
- connection,
914
- CompactInput(
915
- messages=state.messages,
916
- model_history=model_history,
917
- trigger="manual",
918
- ),
919
- 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",
920
1374
  )
921
1375
  except HTTPException:
922
1376
  raise
@@ -927,33 +1381,8 @@ def create_app(
927
1381
  detail="Context could not be compacted.",
928
1382
  ) from error
929
1383
 
930
- marker = StoredMessage(
931
- author="system",
932
- content=COMPACTED_CONTEXT_MARKER,
933
- id=str(uuid4()),
934
- )
935
- source_message_id = state.messages[-1].id if state.messages else None
936
- store.save_compaction_checkpoint(
937
- StoredCompactionCheckpoint(
938
- id=str(uuid4()),
939
- method=compact_result.method,
940
- replacement_history=compact_result.replacement_history,
941
- source_message_id=source_message_id,
942
- summary=compact_result.summary,
943
- token_after=compact_result.token_after,
944
- token_before=compact_result.token_before,
945
- trigger="manual",
946
- )
947
- )
948
1384
  store.save_messages([*state.messages, marker])
949
- logger.info(
950
- "Workspace compact completed method=%s summary_length=%s token_before=%s token_after=%s",
951
- compact_result.method,
952
- len(compact_result.summary),
953
- compact_result.token_before,
954
- compact_result.token_after,
955
- )
956
- logger.log(TRACE_LEVEL, "Workspace compact summary=%r", compact_result.summary)
1385
+ logger.info("Workspace compact completed")
957
1386
  return WorkspaceCompactResponse(message=marker)
958
1387
 
959
1388
  @app.post("/api/workspace/respond")