flowent 0.1.3 → 0.1.4

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 (55) 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__/channels.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/cli.py +14 -2
  22. package/backend/src/flowent/compact.py +183 -0
  23. package/backend/src/flowent/main.py +125 -50
  24. package/backend/src/flowent/mcp.py +3 -1
  25. package/backend/src/flowent/paths.py +12 -0
  26. package/backend/src/flowent/sandbox.py +91 -12
  27. package/backend/src/flowent/static/assets/index-BREidonU.css +2 -0
  28. package/backend/src/flowent/static/assets/index-DSniOrhL.js +81 -0
  29. package/backend/src/flowent/static/index.html +2 -2
  30. package/backend/src/flowent/storage.py +154 -1
  31. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  33. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  34. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/test_agent_tools.py +235 -0
  44. package/backend/tests/test_mcp.py +76 -10
  45. package/backend/tests/test_startup_requirements.py +42 -0
  46. package/backend/tests/test_workspace_chat.py +316 -9
  47. package/backend/uv.lock +1 -1
  48. package/dist/frontend/assets/index-BREidonU.css +2 -0
  49. package/dist/frontend/assets/index-DSniOrhL.js +81 -0
  50. package/dist/frontend/index.html +2 -2
  51. package/package.json +2 -2
  52. package/backend/src/flowent/static/assets/index-DjF2KBwE.js +0 -81
  53. package/backend/src/flowent/static/assets/index-P-bBpJG8.css +0 -2
  54. package/dist/frontend/assets/index-DjF2KBwE.js +0 -81
  55. package/dist/frontend/assets/index-P-bBpJG8.css +0 -2
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowent"
3
- version = "0.1.3"
3
+ version = "0.1.4"
4
4
  description = "A workflow orchestration platform for multi-agent collaboration."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -5,6 +5,8 @@ import os
5
5
  import sys
6
6
  from pathlib import Path
7
7
 
8
+ from flowent.paths import WORKDIR_ENV_VAR, resolve_workdir
9
+
8
10
 
9
11
  def main(argv: list[str] | None = None) -> None:
10
12
  parser = argparse.ArgumentParser(
@@ -18,8 +20,8 @@ def main(argv: list[str] | None = None) -> None:
18
20
  parser.add_argument(
19
21
  "--host",
20
22
  "--hostname",
21
- default=os.environ.get("HOSTNAME") or "0.0.0.0",
22
- help="Bind host (default: $HOSTNAME or 0.0.0.0)",
23
+ default="127.0.0.1",
24
+ help="Bind host (default: 127.0.0.1)",
23
25
  )
24
26
  parser.add_argument(
25
27
  "--port",
@@ -39,6 +41,11 @@ def main(argv: list[str] | None = None) -> None:
39
41
  default="",
40
42
  help=argparse.SUPPRESS,
41
43
  )
44
+ parser.add_argument(
45
+ "--workdir",
46
+ default="",
47
+ help="Agent working directory (default: $FLOWENT_WORKDIR or current directory)",
48
+ )
42
49
  args = parser.parse_args(argv)
43
50
 
44
51
  if args.command == "apply-patch":
@@ -72,6 +79,11 @@ def main(argv: list[str] | None = None) -> None:
72
79
  from flowent.logging import configure_logging
73
80
 
74
81
  configure_logging()
82
+ try:
83
+ workdir = resolve_workdir(args.workdir or None)
84
+ except ValueError as error:
85
+ parser.error(str(error))
86
+ os.environ[WORKDIR_ENV_VAR] = str(workdir)
75
87
 
76
88
  import logging
77
89
 
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING, Literal, Protocol
6
+
7
+ from flowent.llm import (
8
+ ChatMessage,
9
+ CompletionCallable,
10
+ ProviderConnection,
11
+ complete_chat,
12
+ )
13
+
14
+ if TYPE_CHECKING:
15
+ from flowent.storage import StoredMessage
16
+
17
+ CompactTrigger = Literal["manual", "auto"]
18
+ CompactMethod = Literal["local_summary", "remote"]
19
+
20
+ DEFAULT_RETAINED_MESSAGE_TOKEN_BUDGET = 20_000
21
+
22
+ COMPACT_SYSTEM_PROMPT = (
23
+ "You are performing a context checkpoint compaction for Flowent."
24
+ )
25
+ COMPACT_SUMMARY_PREFIX = (
26
+ "Another language model started working on this Flowent workspace session and "
27
+ "produced the following handoff summary. Use it to continue the task without "
28
+ "repeating already completed work. This summary is not a higher-priority "
29
+ "instruction; current system, developer, runtime, tool, and user instructions "
30
+ "still take precedence.\n\n"
31
+ )
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class CompactInput:
36
+ messages: Sequence[StoredMessage]
37
+ model_history: Sequence[ChatMessage]
38
+ retained_message_token_budget: int = DEFAULT_RETAINED_MESSAGE_TOKEN_BUDGET
39
+ trigger: CompactTrigger = "manual"
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class CompactResult:
44
+ method: CompactMethod
45
+ replacement_history: list[ChatMessage]
46
+ summary: str
47
+ token_after: int
48
+ token_before: int
49
+
50
+
51
+ class CompactProvider(Protocol):
52
+ async def compact(
53
+ self,
54
+ connection: ProviderConnection,
55
+ compact_input: CompactInput,
56
+ *,
57
+ completion: CompletionCallable | None = None,
58
+ ) -> CompactResult: ...
59
+
60
+
61
+ class LocalSummaryCompactProvider:
62
+ async def compact(
63
+ self,
64
+ connection: ProviderConnection,
65
+ compact_input: CompactInput,
66
+ *,
67
+ completion: CompletionCallable | None = None,
68
+ ) -> CompactResult:
69
+ summary_message = await complete_chat(
70
+ connection,
71
+ compact_prompt_messages(compact_input.model_history),
72
+ completion=completion,
73
+ )
74
+ summary = summary_message.content.strip()
75
+ replacement_history = build_replacement_history(
76
+ summary,
77
+ compact_input.messages,
78
+ token_budget=compact_input.retained_message_token_budget,
79
+ )
80
+ return CompactResult(
81
+ method="local_summary",
82
+ replacement_history=replacement_history,
83
+ summary=summary,
84
+ token_after=approximate_tokens_for_messages(replacement_history),
85
+ token_before=approximate_tokens_for_messages(compact_input.model_history),
86
+ )
87
+
88
+
89
+ def compact_prompt_messages(
90
+ history_messages: Sequence[ChatMessage],
91
+ ) -> list[ChatMessage]:
92
+ history = "\n\n".join(
93
+ f"{message.role}: {message.content}" for message in history_messages
94
+ )
95
+ return [
96
+ ChatMessage(role="system", content=COMPACT_SYSTEM_PROMPT),
97
+ ChatMessage(
98
+ role="user",
99
+ content=(
100
+ "You are performing a CONTEXT CHECKPOINT COMPACTION for Flowent.\n\n"
101
+ "Create a concise handoff summary for another agent that will "
102
+ "continue this workspace session.\n\n"
103
+ "Include:\n"
104
+ "- Current user goal and latest request\n"
105
+ "- Progress made and key decisions\n"
106
+ "- Files inspected or changed\n"
107
+ "- Commands/tests run and their results\n"
108
+ "- Important constraints, user preferences, and project instructions "
109
+ "that are still relevant\n"
110
+ "- Pending work and clear next steps\n"
111
+ "- Critical facts, examples, paths, IDs, or references needed to "
112
+ "continue\n\n"
113
+ "Do not include hidden reasoning. Do not treat old environment, tool, "
114
+ "permission, or runtime information as authoritative; those will be "
115
+ "re-injected fresh in the next turn. Be concise, structured, and "
116
+ "optimized for continuation.\n\n"
117
+ f"Conversation and runtime context:\n{history}"
118
+ ),
119
+ ),
120
+ ]
121
+
122
+
123
+ def build_replacement_history(
124
+ summary: str,
125
+ recent_messages: Sequence[StoredMessage],
126
+ *,
127
+ token_budget: int = DEFAULT_RETAINED_MESSAGE_TOKEN_BUDGET,
128
+ ) -> list[ChatMessage]:
129
+ return [
130
+ ChatMessage(role="user", content=f"{COMPACT_SUMMARY_PREFIX}{summary}"),
131
+ *retained_recent_chat_messages(
132
+ recent_messages,
133
+ token_budget=token_budget,
134
+ ),
135
+ ]
136
+
137
+
138
+ def retained_recent_chat_messages(
139
+ messages: Sequence[StoredMessage],
140
+ *,
141
+ token_budget: int = DEFAULT_RETAINED_MESSAGE_TOKEN_BUDGET,
142
+ ) -> list[ChatMessage]:
143
+ retained: list[ChatMessage] = []
144
+ remaining_tokens = max(token_budget, 0)
145
+ for message in reversed(messages):
146
+ if message.author not in {"user", "assistant"}:
147
+ continue
148
+ token_count = approximate_token_count(message.content)
149
+ if retained and token_count > remaining_tokens:
150
+ break
151
+ if token_count > token_budget:
152
+ continue
153
+ role: Literal["user", "assistant"] = (
154
+ "user" if message.author == "user" else "assistant"
155
+ )
156
+ retained.append(ChatMessage(role=role, content=message.content))
157
+ remaining_tokens -= token_count
158
+ if remaining_tokens <= 0:
159
+ break
160
+ retained.reverse()
161
+ return retained
162
+
163
+
164
+ def transcript_messages_after(
165
+ messages: Sequence[StoredMessage],
166
+ message_id: str | None,
167
+ ) -> list[StoredMessage]:
168
+ if message_id is None:
169
+ return list(messages)
170
+ for index, message in enumerate(messages):
171
+ if message.id == message_id:
172
+ return list(messages[index + 1 :])
173
+ return list(messages)
174
+
175
+
176
+ def approximate_tokens_for_messages(messages: Sequence[ChatMessage]) -> int:
177
+ return sum(approximate_token_count(message.content) for message in messages)
178
+
179
+
180
+ def approximate_token_count(content: str) -> int:
181
+ if not content:
182
+ return 0
183
+ return max(1, (len(content) + 3) // 4)
@@ -17,18 +17,23 @@ from pydantic import BaseModel, ConfigDict
17
17
  from flowent._version import __version__
18
18
  from flowent.agent import run_agent_stream
19
19
  from flowent.channels import TelegramBotManager, TelegramTransport
20
+ from flowent.compact import (
21
+ CompactInput,
22
+ LocalSummaryCompactProvider,
23
+ transcript_messages_after,
24
+ )
20
25
  from flowent.context import runtime_context_messages
21
26
  from flowent.llm import (
22
27
  ChatMessage,
23
28
  CompletionCallable,
24
29
  ProviderConnection,
25
30
  ProviderFormat,
26
- complete_chat,
27
31
  list_provider_models,
28
32
  )
29
33
  from flowent.logging import TRACE_LEVEL, ensure_logging_configured
30
34
  from flowent.mcp import McpManager, McpTransport
31
35
  from flowent.mcp_import import McpImportDiscovery, discover_imported_mcp_servers
36
+ from flowent.paths import resolve_workdir
32
37
  from flowent.permissions import WritablePathDecision, run_tool_with_path_permissions
33
38
  from flowent.sandbox import ensure_sandbox_available
34
39
  from flowent.skills import (
@@ -38,8 +43,10 @@ from flowent.skills import (
38
43
  )
39
44
  from flowent.storage import (
40
45
  StateStore,
46
+ StoredCompactionCheckpoint,
41
47
  StoredMcpServer,
42
48
  StoredMessage,
49
+ StoredPermissionRequest,
43
50
  StoredProvider,
44
51
  StoredSettings,
45
52
  StoredSkill,
@@ -56,7 +63,6 @@ logger = logging.getLogger("flowent.main")
56
63
 
57
64
  DEFAULT_STATIC_DIR = Path(__file__).parent / "static"
58
65
  COMPACTED_CONTEXT_MARKER = "Context compacted"
59
- COMPACT_SYSTEM_PROMPT = "You are compacting Flowent workspace context."
60
66
 
61
67
 
62
68
  class ProviderModelsRequest(BaseModel):
@@ -149,6 +155,8 @@ class WorkspacePermissionDecisionRequest(BaseModel):
149
155
  class PendingWorkspacePermission:
150
156
  future: asyncio.Future[WritablePathDecision]
151
157
  path: Path
158
+ reason: str
159
+ tool_call_id: str | None = None
152
160
 
153
161
 
154
162
  @dataclass
@@ -167,6 +175,17 @@ class WorkspaceRun:
167
175
  def latest_event_index(self) -> int:
168
176
  return self.events[-1][0] if self.events else 0
169
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
+
170
189
 
171
190
  def stream_event(event: str, data: dict[str, object]) -> str:
172
191
  return f"event: {event}\ndata: {json.dumps(data)}\n\n"
@@ -236,8 +255,34 @@ def latest_compacted_context_index(messages: list[StoredMessage]) -> int | None:
236
255
  def workspace_chat_messages(
237
256
  messages: list[StoredMessage],
238
257
  compacted_context: str = "",
258
+ checkpoint: StoredCompactionCheckpoint | None = None,
239
259
  ) -> list[ChatMessage]:
240
260
  chat_messages: list[ChatMessage] = []
261
+
262
+ if checkpoint is not None:
263
+ chat_messages.extend(checkpoint.replacement_history)
264
+ visible_messages = transcript_messages_after(
265
+ messages,
266
+ checkpoint.source_message_id,
267
+ )
268
+ for message in visible_messages:
269
+ if (
270
+ message.author == "system"
271
+ and message.content == COMPACTED_CONTEXT_MARKER
272
+ ):
273
+ continue
274
+ if message.author not in ("user", "assistant"):
275
+ raise HTTPException(
276
+ status_code=400, detail="Message history is invalid."
277
+ )
278
+ checkpoint_role: Literal["user", "assistant"] = (
279
+ "user" if message.author == "user" else "assistant"
280
+ )
281
+ chat_messages.append(
282
+ ChatMessage(role=checkpoint_role, content=message.content)
283
+ )
284
+ return chat_messages
285
+
241
286
  marker_index = latest_compacted_context_index(messages)
242
287
  visible_messages = messages
243
288
 
@@ -269,43 +314,20 @@ def normalized_request_path(path: str, cwd: Path) -> Path:
269
314
  return raw_path.resolve(strict=False)
270
315
 
271
316
 
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
317
  def create_app(
299
318
  *,
300
319
  serve_frontend: bool = True,
301
320
  chat_completion: CompletionCallable | None = None,
302
321
  mcp_transport: McpTransport | None = None,
303
322
  telegram_transport: TelegramTransport | None = None,
323
+ workdir: Path | str | None = None,
304
324
  ) -> FastAPI:
305
325
  ensure_logging_configured()
306
326
  ensure_sandbox_available()
307
327
 
328
+ cwd = resolve_workdir(workdir)
308
329
  store = StateStore()
330
+ compact_provider = LocalSummaryCompactProvider()
309
331
  mcp_manager = McpManager(store=store, transport=mcp_transport)
310
332
  telegram_bot_manager: TelegramBotManager | None = None
311
333
  workspace_runs: dict[str, WorkspaceRun] = {}
@@ -313,12 +335,12 @@ def create_app(
313
335
 
314
336
  static_dir = frontend_static_directory().resolve(strict=False)
315
337
  logger.debug("Flowent app created serve_frontend=%s", serve_frontend)
338
+ logger.info("Workdir: %s", cwd)
316
339
  logger.info("Static directory: %s", static_dir)
317
340
 
318
341
  async def run_workspace_turn(content: str) -> StoredMessage:
319
342
  state = store.read_state()
320
343
  connection = selected_connection(state)
321
- cwd = Path.cwd()
322
344
  user_message = StoredMessage(
323
345
  author="user",
324
346
  content=content,
@@ -329,6 +351,7 @@ def create_app(
329
351
  chat_messages = workspace_chat_messages(
330
352
  next_messages,
331
353
  store.read_compacted_context(),
354
+ store.read_active_compaction_checkpoint(),
332
355
  )
333
356
  skill_messages = explicit_skill_messages(cwd, store, content)
334
357
  request_messages = [
@@ -437,7 +460,10 @@ def create_app(
437
460
  if active_run and not active_run.is_done
438
461
  else None,
439
462
  "mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
440
- "skills": discover_skills(Path.cwd(), store),
463
+ "permission_requests": active_run.permission_requests()
464
+ if active_run and not active_run.is_done
465
+ else [],
466
+ "skills": discover_skills(cwd, store),
441
467
  }
442
468
  if telegram_bot_manager is not None:
443
469
  update["telegram_bot"] = telegram_bot_manager.bot_with_status(
@@ -462,12 +488,12 @@ def create_app(
462
488
  async def preview_mcp_import(
463
489
  request: McpImportPreviewRequest,
464
490
  ) -> McpImportDiscovery:
465
- return discover_imported_mcp_servers(Path.cwd(), source=request.source)
491
+ return discover_imported_mcp_servers(cwd, source=request.source)
466
492
 
467
493
  @app.post("/api/mcp/import")
468
494
  async def import_mcp_servers(request: McpImportRequest) -> list[StoredMcpServer]:
469
495
  imported_servers = discover_imported_mcp_servers(
470
- Path.cwd(),
496
+ cwd,
471
497
  source=request.source,
472
498
  ).servers
473
499
  existing_servers = {server.id for server in store.read_mcp_servers()}
@@ -498,7 +524,7 @@ def create_app(
498
524
 
499
525
  @app.post("/api/skills/reload")
500
526
  async def reload_skills() -> list[StoredSkill]:
501
- return discover_skills(Path.cwd(), store)
527
+ return discover_skills(cwd, store)
502
528
 
503
529
  @app.put("/api/skills/{skill_id:path}")
504
530
  async def save_skill_settings(
@@ -506,7 +532,7 @@ def create_app(
506
532
  request: SkillSettingsRequest,
507
533
  ) -> StoredSkill:
508
534
  try:
509
- return update_skill_enabled(Path.cwd(), store, skill_id, request.enabled)
535
+ return update_skill_enabled(cwd, store, skill_id, request.enabled)
510
536
  except KeyError as error:
511
537
  raise HTTPException(status_code=404, detail="Skill not found.") from error
512
538
 
@@ -548,9 +574,7 @@ def create_app(
548
574
  async def save_writable_path(
549
575
  request: WritablePathRequest,
550
576
  ) -> StoredWritablePath:
551
- return store.save_writable_path(
552
- normalized_request_path(request.path, Path.cwd())
553
- )
577
+ return store.save_writable_path(normalized_request_path(request.path, cwd))
554
578
 
555
579
  @app.delete("/api/permissions/writable-paths")
556
580
  async def delete_writable_path(
@@ -558,7 +582,7 @@ def create_app(
558
582
  ) -> WritablePathListResponse:
559
583
  return WritablePathListResponse(
560
584
  writable_paths=store.delete_writable_path(
561
- normalized_request_path(request.path, Path.cwd())
585
+ normalized_request_path(request.path, cwd)
562
586
  )
563
587
  )
564
588
 
@@ -610,7 +634,6 @@ def create_app(
610
634
  nonlocal active_workspace_run_id
611
635
  state = store.read_state()
612
636
  connection = selected_connection(state)
613
- cwd = Path.cwd()
614
637
 
615
638
  user_message = StoredMessage(
616
639
  author="user",
@@ -622,6 +645,7 @@ def create_app(
622
645
  chat_messages = workspace_chat_messages(
623
646
  next_messages,
624
647
  store.read_compacted_context(),
648
+ store.read_active_compaction_checkpoint(),
625
649
  )
626
650
  request_messages = [
627
651
  message.model_dump()
@@ -663,6 +687,7 @@ def create_app(
663
687
  store.upsert_message(assistant_message)
664
688
 
665
689
  try:
690
+ current_tool_id: str | None = None
666
691
 
667
692
  async def request_writable_path(
668
693
  path: Path, reason: str
@@ -672,7 +697,21 @@ def create_app(
672
697
  run.pending_permissions[permission_id] = PendingWorkspacePermission(
673
698
  future=future,
674
699
  path=path,
700
+ reason=reason,
701
+ tool_call_id=current_tool_id,
675
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()
676
715
  await append_run_event(
677
716
  run,
678
717
  "permission_request",
@@ -680,9 +719,13 @@ def create_app(
680
719
  "id": permission_id,
681
720
  "path": str(path),
682
721
  "reason": reason,
722
+ "tool_call_id": current_tool_id,
683
723
  },
684
724
  )
685
- return await future
725
+ try:
726
+ return await future
727
+ finally:
728
+ run.pending_permissions.pop(permission_id, None)
686
729
 
687
730
  async def tool_runner(
688
731
  name: str,
@@ -719,6 +762,7 @@ def create_app(
719
762
  if event.event == "tool_start":
720
763
  tool = event.data.get("tool")
721
764
  if isinstance(tool, dict) and isinstance(tool.get("id"), str):
765
+ current_tool_id = tool["id"]
722
766
  assistant_tools[tool["id"]] = StoredToolItem.model_validate(
723
767
  tool
724
768
  )
@@ -726,6 +770,9 @@ def create_app(
726
770
  if event.event in {"tool_done", "tool_error"}:
727
771
  tool_id = event.data.get("id")
728
772
  if isinstance(tool_id, str) and tool_id in assistant_tools:
773
+ current_tool_id = (
774
+ None if current_tool_id == tool_id else current_tool_id
775
+ )
729
776
  assistant_tools[tool_id] = StoredToolItem.model_validate(
730
777
  {
731
778
  **assistant_tools[tool_id].model_dump(
@@ -843,19 +890,31 @@ def create_app(
843
890
 
844
891
  @app.post("/api/workspace/compact")
845
892
  async def compact_workspace() -> WorkspaceCompactResponse:
893
+ if active_workspace_run() is not None:
894
+ raise HTTPException(
895
+ status_code=409,
896
+ detail="Compact is unavailable while Flowent is responding.",
897
+ )
846
898
  logger.info("Workspace compact requested")
847
899
  state = store.read_state()
848
900
  connection = selected_connection(state)
849
- compacted_context = store.read_compacted_context()
850
- cwd = Path.cwd()
901
+ checkpoint = store.read_active_compaction_checkpoint()
902
+ model_history = [
903
+ *runtime_context_messages(cwd),
904
+ *workspace_chat_messages(
905
+ state.messages,
906
+ store.read_compacted_context(),
907
+ checkpoint,
908
+ ),
909
+ ]
851
910
 
852
911
  try:
853
- summary = await complete_chat(
912
+ compact_result = await compact_provider.compact(
854
913
  connection,
855
- compact_prompt_messages(
856
- state.messages,
857
- compacted_context,
858
- runtime_context_messages(cwd),
914
+ CompactInput(
915
+ messages=state.messages,
916
+ model_history=model_history,
917
+ trigger="manual",
859
918
  ),
860
919
  completion=chat_completion,
861
920
  )
@@ -873,12 +932,28 @@ def create_app(
873
932
  content=COMPACTED_CONTEXT_MARKER,
874
933
  id=str(uuid4()),
875
934
  )
876
- store.save_compacted_context(summary.content)
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
+ )
877
948
  store.save_messages([*state.messages, marker])
878
949
  logger.info(
879
- "Workspace compact completed summary_length=%s", len(summary.content)
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,
880
955
  )
881
- logger.log(TRACE_LEVEL, "Workspace compact summary=%r", summary.content)
956
+ logger.log(TRACE_LEVEL, "Workspace compact summary=%r", compact_result.summary)
882
957
  return WorkspaceCompactResponse(message=marker)
883
958
 
884
959
  @app.post("/api/workspace/respond")