flowent 0.1.2 → 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 (59) 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/agent.py +1 -0
  22. package/backend/src/flowent/cli.py +14 -2
  23. package/backend/src/flowent/compact.py +183 -0
  24. package/backend/src/flowent/main.py +405 -88
  25. package/backend/src/flowent/mcp.py +3 -1
  26. package/backend/src/flowent/paths.py +12 -0
  27. package/backend/src/flowent/permissions.py +259 -0
  28. package/backend/src/flowent/sandbox.py +105 -16
  29. package/backend/src/flowent/static/assets/index-BREidonU.css +2 -0
  30. package/backend/src/flowent/static/assets/index-DSniOrhL.js +81 -0
  31. package/backend/src/flowent/static/index.html +2 -2
  32. package/backend/src/flowent/storage.py +218 -1
  33. package/backend/src/flowent/tools.py +24 -1
  34. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  44. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  45. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  46. package/backend/tests/test_agent_tools.py +235 -0
  47. package/backend/tests/test_mcp.py +76 -10
  48. package/backend/tests/test_permissions.py +443 -0
  49. package/backend/tests/test_startup_requirements.py +42 -0
  50. package/backend/tests/test_workspace_chat.py +443 -9
  51. package/backend/uv.lock +1 -1
  52. package/dist/frontend/assets/index-BREidonU.css +2 -0
  53. package/dist/frontend/assets/index-DSniOrhL.js +81 -0
  54. package/dist/frontend/index.html +2 -2
  55. package/package.json +2 -2
  56. package/backend/src/flowent/static/assets/index-BhHdc2d_.js +0 -81
  57. package/backend/src/flowent/static/assets/index-C89n9qe2.css +0 -2
  58. package/dist/frontend/assets/index-BhHdc2d_.js +0 -81
  59. package/dist/frontend/assets/index-C89n9qe2.css +0 -2
@@ -4,11 +4,12 @@ import logging
4
4
  import os
5
5
  from collections.abc import AsyncIterator
6
6
  from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass, field
7
8
  from pathlib import Path
8
9
  from typing import Literal
9
10
  from uuid import uuid4
10
11
 
11
- from fastapi import FastAPI, HTTPException
12
+ from fastapi import FastAPI, HTTPException, Query
12
13
  from fastapi.responses import FileResponse, StreamingResponse
13
14
  from fastapi.staticfiles import StaticFiles
14
15
  from pydantic import BaseModel, ConfigDict
@@ -16,18 +17,24 @@ from pydantic import BaseModel, ConfigDict
16
17
  from flowent._version import __version__
17
18
  from flowent.agent import run_agent_stream
18
19
  from flowent.channels import TelegramBotManager, TelegramTransport
20
+ from flowent.compact import (
21
+ CompactInput,
22
+ LocalSummaryCompactProvider,
23
+ transcript_messages_after,
24
+ )
19
25
  from flowent.context import runtime_context_messages
20
26
  from flowent.llm import (
21
27
  ChatMessage,
22
28
  CompletionCallable,
23
29
  ProviderConnection,
24
30
  ProviderFormat,
25
- complete_chat,
26
31
  list_provider_models,
27
32
  )
28
33
  from flowent.logging import TRACE_LEVEL, ensure_logging_configured
29
34
  from flowent.mcp import McpManager, McpTransport
30
35
  from flowent.mcp_import import McpImportDiscovery, discover_imported_mcp_servers
36
+ from flowent.paths import resolve_workdir
37
+ from flowent.permissions import WritablePathDecision, run_tool_with_path_permissions
31
38
  from flowent.sandbox import ensure_sandbox_available
32
39
  from flowent.skills import (
33
40
  discover_skills,
@@ -36,8 +43,10 @@ from flowent.skills import (
36
43
  )
37
44
  from flowent.storage import (
38
45
  StateStore,
46
+ StoredCompactionCheckpoint,
39
47
  StoredMcpServer,
40
48
  StoredMessage,
49
+ StoredPermissionRequest,
41
50
  StoredProvider,
42
51
  StoredSettings,
43
52
  StoredSkill,
@@ -45,14 +54,15 @@ from flowent.storage import (
45
54
  StoredTelegramBot,
46
55
  StoredTelegramSession,
47
56
  StoredToolItem,
57
+ StoredWritablePath,
48
58
  )
59
+ from flowent.tools import ToolContext
49
60
 
50
61
  logger = logging.getLogger("flowent.main")
51
62
 
52
63
 
53
64
  DEFAULT_STATIC_DIR = Path(__file__).parent / "static"
54
65
  COMPACTED_CONTEXT_MARKER = "Context compacted"
55
- COMPACT_SYSTEM_PROMPT = "You are compacting Flowent workspace context."
56
66
 
57
67
 
58
68
  class ProviderModelsRequest(BaseModel):
@@ -79,6 +89,12 @@ class WorkspaceRespondRequest(BaseModel):
79
89
  content: str
80
90
 
81
91
 
92
+ class WorkspaceRunResponse(BaseModel):
93
+ model_config = ConfigDict(extra="forbid")
94
+
95
+ run_id: str
96
+
97
+
82
98
  class WorkspaceCompactResponse(BaseModel):
83
99
  model_config = ConfigDict(extra="forbid")
84
100
 
@@ -116,6 +132,61 @@ class McpImportPreviewRequest(BaseModel):
116
132
  source: Literal["claude_code", "codex"]
117
133
 
118
134
 
135
+ class WritablePathRequest(BaseModel):
136
+ model_config = ConfigDict(extra="forbid")
137
+
138
+ path: str
139
+
140
+
141
+ class WritablePathListResponse(BaseModel):
142
+ model_config = ConfigDict(extra="forbid")
143
+
144
+ writable_paths: list[StoredWritablePath]
145
+
146
+
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
+ @dataclass
163
+ class WorkspaceRun:
164
+ condition: asyncio.Condition
165
+ discard_on_cancel: bool = False
166
+ events: list[tuple[int, str, dict[str, object]]] = field(default_factory=list)
167
+ id: str = field(default_factory=lambda: str(uuid4()))
168
+ is_done: bool = False
169
+ pending_permissions: dict[str, PendingWorkspacePermission] = field(
170
+ default_factory=dict
171
+ )
172
+ task: asyncio.Task[None] | None = None
173
+
174
+ @property
175
+ def latest_event_index(self) -> int:
176
+ return self.events[-1][0] if self.events else 0
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
+
119
190
  def stream_event(event: str, data: dict[str, object]) -> str:
120
191
  return f"event: {event}\ndata: {json.dumps(data)}\n\n"
121
192
 
@@ -184,8 +255,34 @@ def latest_compacted_context_index(messages: list[StoredMessage]) -> int | None:
184
255
  def workspace_chat_messages(
185
256
  messages: list[StoredMessage],
186
257
  compacted_context: str = "",
258
+ checkpoint: StoredCompactionCheckpoint | None = None,
187
259
  ) -> list[ChatMessage]:
188
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
+
189
286
  marker_index = latest_compacted_context_index(messages)
190
287
  visible_messages = messages
191
288
 
@@ -210,30 +307,11 @@ def workspace_chat_messages(
210
307
  return chat_messages
211
308
 
212
309
 
213
- def compact_prompt_messages(
214
- messages: list[StoredMessage],
215
- compacted_context: str,
216
- runtime_messages: list[ChatMessage] | None = None,
217
- ) -> list[ChatMessage]:
218
- history_messages = [
219
- *(runtime_messages or []),
220
- *workspace_chat_messages(messages, compacted_context),
221
- ]
222
- history = "\n\n".join(
223
- f"{message.role}: {message.content}" for message in history_messages
224
- )
225
- return [
226
- ChatMessage(role="system", content=COMPACT_SYSTEM_PROMPT),
227
- ChatMessage(
228
- role="user",
229
- content=(
230
- "Compact the current Flowent workspace context for the next turn.\n\n"
231
- "Keep the details needed to continue accurately, including decisions, "
232
- "constraints, pending work, and referenced facts.\n\n"
233
- f"Conversation:\n{history}"
234
- ),
235
- ),
236
- ]
310
+ def normalized_request_path(path: str, cwd: Path) -> Path:
311
+ raw_path = Path(path).expanduser()
312
+ if not raw_path.is_absolute():
313
+ raw_path = cwd / raw_path
314
+ return raw_path.resolve(strict=False)
237
315
 
238
316
 
239
317
  def create_app(
@@ -242,22 +320,27 @@ def create_app(
242
320
  chat_completion: CompletionCallable | None = None,
243
321
  mcp_transport: McpTransport | None = None,
244
322
  telegram_transport: TelegramTransport | None = None,
323
+ workdir: Path | str | None = None,
245
324
  ) -> FastAPI:
246
325
  ensure_logging_configured()
247
326
  ensure_sandbox_available()
248
327
 
328
+ cwd = resolve_workdir(workdir)
249
329
  store = StateStore()
330
+ compact_provider = LocalSummaryCompactProvider()
250
331
  mcp_manager = McpManager(store=store, transport=mcp_transport)
251
332
  telegram_bot_manager: TelegramBotManager | None = None
333
+ workspace_runs: dict[str, WorkspaceRun] = {}
334
+ active_workspace_run_id: str | None = None
252
335
 
253
336
  static_dir = frontend_static_directory().resolve(strict=False)
254
337
  logger.debug("Flowent app created serve_frontend=%s", serve_frontend)
338
+ logger.info("Workdir: %s", cwd)
255
339
  logger.info("Static directory: %s", static_dir)
256
340
 
257
341
  async def run_workspace_turn(content: str) -> StoredMessage:
258
342
  state = store.read_state()
259
343
  connection = selected_connection(state)
260
- cwd = Path.cwd()
261
344
  user_message = StoredMessage(
262
345
  author="user",
263
346
  content=content,
@@ -268,6 +351,7 @@ def create_app(
268
351
  chat_messages = workspace_chat_messages(
269
352
  next_messages,
270
353
  store.read_compacted_context(),
354
+ store.read_active_compaction_checkpoint(),
271
355
  )
272
356
  skill_messages = explicit_skill_messages(cwd, store, content)
273
357
  request_messages = [
@@ -363,9 +447,23 @@ def create_app(
363
447
  @app.get("/api/state")
364
448
  async def app_state() -> StoredState:
365
449
  state = store.read_state()
450
+ active_run = (
451
+ workspace_runs.get(active_workspace_run_id)
452
+ if active_workspace_run_id
453
+ else None
454
+ )
366
455
  update: dict[str, object] = {
456
+ "active_run_event_index": active_run.latest_event_index
457
+ if active_run
458
+ else 0,
459
+ "active_run_id": active_run.id
460
+ if active_run and not active_run.is_done
461
+ else None,
367
462
  "mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
368
- "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),
369
467
  }
370
468
  if telegram_bot_manager is not None:
371
469
  update["telegram_bot"] = telegram_bot_manager.bot_with_status(
@@ -390,12 +488,12 @@ def create_app(
390
488
  async def preview_mcp_import(
391
489
  request: McpImportPreviewRequest,
392
490
  ) -> McpImportDiscovery:
393
- return discover_imported_mcp_servers(Path.cwd(), source=request.source)
491
+ return discover_imported_mcp_servers(cwd, source=request.source)
394
492
 
395
493
  @app.post("/api/mcp/import")
396
494
  async def import_mcp_servers(request: McpImportRequest) -> list[StoredMcpServer]:
397
495
  imported_servers = discover_imported_mcp_servers(
398
- Path.cwd(),
496
+ cwd,
399
497
  source=request.source,
400
498
  ).servers
401
499
  existing_servers = {server.id for server in store.read_mcp_servers()}
@@ -426,7 +524,7 @@ def create_app(
426
524
 
427
525
  @app.post("/api/skills/reload")
428
526
  async def reload_skills() -> list[StoredSkill]:
429
- return discover_skills(Path.cwd(), store)
527
+ return discover_skills(cwd, store)
430
528
 
431
529
  @app.put("/api/skills/{skill_id:path}")
432
530
  async def save_skill_settings(
@@ -434,7 +532,7 @@ def create_app(
434
532
  request: SkillSettingsRequest,
435
533
  ) -> StoredSkill:
436
534
  try:
437
- return update_skill_enabled(Path.cwd(), store, skill_id, request.enabled)
535
+ return update_skill_enabled(cwd, store, skill_id, request.enabled)
438
536
  except KeyError as error:
439
537
  raise HTTPException(status_code=404, detail="Skill not found.") from error
440
538
 
@@ -472,67 +570,74 @@ def create_app(
472
570
  async def save_settings(settings: StoredSettings) -> StoredSettings:
473
571
  return store.save_settings(settings)
474
572
 
573
+ @app.post("/api/permissions/writable-paths")
574
+ async def save_writable_path(
575
+ request: WritablePathRequest,
576
+ ) -> StoredWritablePath:
577
+ return store.save_writable_path(normalized_request_path(request.path, cwd))
578
+
579
+ @app.delete("/api/permissions/writable-paths")
580
+ async def delete_writable_path(
581
+ request: WritablePathRequest,
582
+ ) -> WritablePathListResponse:
583
+ return WritablePathListResponse(
584
+ writable_paths=store.delete_writable_path(
585
+ normalized_request_path(request.path, cwd)
586
+ )
587
+ )
588
+
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
+
475
608
  @app.put("/api/workspace/messages")
476
609
  async def save_workspace_messages(
477
610
  request: WorkspaceMessagesRequest,
478
611
  ) -> WorkspaceMessagesRequest:
612
+ nonlocal active_workspace_run_id
613
+ if not request.messages:
614
+ run = active_workspace_run()
615
+ if run is not None and run.task is not None and not run.task.done():
616
+ run.discard_on_cancel = True
617
+ run.task.cancel()
618
+ active_workspace_run_id = None
479
619
  return WorkspaceMessagesRequest(messages=store.save_messages(request.messages))
480
620
 
481
- @app.post("/api/workspace/compact")
482
- async def compact_workspace() -> WorkspaceCompactResponse:
483
- logger.info("Workspace compact requested")
484
- state = store.read_state()
485
- connection = selected_connection(state)
486
- compacted_context = store.read_compacted_context()
487
- cwd = Path.cwd()
621
+ async def append_run_event(
622
+ run: WorkspaceRun, event: str, data: dict[str, object]
623
+ ) -> None:
624
+ async with run.condition:
625
+ run.events.append((run.latest_event_index + 1, event, data))
626
+ run.condition.notify_all()
488
627
 
489
- try:
490
- summary = await complete_chat(
491
- connection,
492
- compact_prompt_messages(
493
- state.messages,
494
- compacted_context,
495
- runtime_context_messages(cwd),
496
- ),
497
- completion=chat_completion,
498
- )
499
- except HTTPException:
500
- raise
501
- except Exception as error:
502
- logger.exception("Workspace compact failed")
503
- raise HTTPException(
504
- status_code=500,
505
- detail="Context could not be compacted.",
506
- ) from error
628
+ def active_workspace_run() -> WorkspaceRun | None:
629
+ if active_workspace_run_id is None:
630
+ return None
631
+ return workspace_runs.get(active_workspace_run_id)
507
632
 
508
- marker = StoredMessage(
509
- author="system",
510
- content=COMPACTED_CONTEXT_MARKER,
511
- id=str(uuid4()),
512
- )
513
- store.save_compacted_context(summary.content)
514
- store.save_messages([*state.messages, marker])
515
- logger.info(
516
- "Workspace compact completed summary_length=%s", len(summary.content)
517
- )
518
- logger.log(TRACE_LEVEL, "Workspace compact summary=%r", summary.content)
519
- return WorkspaceCompactResponse(message=marker)
520
-
521
- @app.post("/api/workspace/respond")
522
- async def respond_to_workspace(
523
- request: WorkspaceRespondRequest,
524
- ) -> StreamingResponse:
525
- logger.info(
526
- "Workspace response requested content_length=%s", len(request.content)
527
- )
528
- logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
633
+ def create_workspace_run(content: str) -> WorkspaceRun:
634
+ nonlocal active_workspace_run_id
529
635
  state = store.read_state()
530
636
  connection = selected_connection(state)
531
- cwd = Path.cwd()
532
637
 
533
638
  user_message = StoredMessage(
534
639
  author="user",
535
- content=request.content,
640
+ content=content,
536
641
  id=str(uuid4()),
537
642
  )
538
643
  next_messages = [*state.messages, user_message]
@@ -540,17 +645,22 @@ def create_app(
540
645
  chat_messages = workspace_chat_messages(
541
646
  next_messages,
542
647
  store.read_compacted_context(),
648
+ store.read_active_compaction_checkpoint(),
543
649
  )
544
650
  request_messages = [
545
651
  message.model_dump()
546
652
  for message in [
547
653
  *runtime_context_messages(cwd),
548
- *explicit_skill_messages(cwd, store, request.content),
654
+ *explicit_skill_messages(cwd, store, content),
549
655
  *chat_messages,
550
656
  ]
551
657
  ]
658
+ run = WorkspaceRun(condition=asyncio.Condition())
659
+ workspace_runs[run.id] = run
660
+ active_workspace_run_id = run.id
552
661
 
553
- async def response_stream() -> AsyncIterator[str]:
662
+ async def run_task() -> None:
663
+ nonlocal active_workspace_run_id
554
664
  assistant_tools: dict[str, StoredToolItem] = {}
555
665
  assistant_message = StoredMessage(
556
666
  author="assistant",
@@ -577,6 +687,61 @@ def create_app(
577
687
  store.upsert_message(assistant_message)
578
688
 
579
689
  try:
690
+ 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()
715
+ await append_run_event(
716
+ run,
717
+ "permission_request",
718
+ {
719
+ "id": permission_id,
720
+ "path": str(path),
721
+ "reason": reason,
722
+ "tool_call_id": current_tool_id,
723
+ },
724
+ )
725
+ try:
726
+ return await future
727
+ finally:
728
+ run.pending_permissions.pop(permission_id, None)
729
+
730
+ async def tool_runner(
731
+ name: str,
732
+ arguments: dict[str, object],
733
+ context: ToolContext,
734
+ ):
735
+ return await run_tool_with_path_permissions(
736
+ name,
737
+ arguments,
738
+ context,
739
+ request_writable_path=request_writable_path,
740
+ writable_paths=[
741
+ Path(path.path) for path in store.read_writable_paths()
742
+ ],
743
+ )
744
+
580
745
  async for event in run_agent_stream(
581
746
  completion=chat_completion,
582
747
  connection=connection,
@@ -585,6 +750,7 @@ def create_app(
585
750
  extra_tool_specs=mcp_manager.tool_specs(),
586
751
  extra_tool_title=mcp_manager.tool_title,
587
752
  messages=request_messages,
753
+ tool_runner=tool_runner,
588
754
  ):
589
755
  if event.event == "start":
590
756
  event_id = event.data.get("id")
@@ -596,6 +762,7 @@ def create_app(
596
762
  if event.event == "tool_start":
597
763
  tool = event.data.get("tool")
598
764
  if isinstance(tool, dict) and isinstance(tool.get("id"), str):
765
+ current_tool_id = tool["id"]
599
766
  assistant_tools[tool["id"]] = StoredToolItem.model_validate(
600
767
  tool
601
768
  )
@@ -603,6 +770,9 @@ def create_app(
603
770
  if event.event in {"tool_done", "tool_error"}:
604
771
  tool_id = event.data.get("id")
605
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
+ )
606
776
  assistant_tools[tool_id] = StoredToolItem.model_validate(
607
777
  {
608
778
  **assistant_tools[tool_id].model_dump(
@@ -634,22 +804,169 @@ def create_app(
634
804
  message.get("thinking") or assistant_thinking
635
805
  )
636
806
  persist_assistant("completed")
637
- yield stream_event(event.event, event.data)
807
+ await append_run_event(run, event.event, event.data)
638
808
  except asyncio.CancelledError:
639
- logger.info("Workspace response interrupted")
640
- persist_assistant("interrupted")
809
+ logger.info("Workspace run stopped")
810
+ if not run.discard_on_cancel:
811
+ persist_assistant("interrupted")
812
+ await append_run_event(
813
+ run,
814
+ "error",
815
+ {"message": "Response stopped."},
816
+ )
641
817
  raise
642
818
  except Exception as error:
643
819
  logger.exception("Workspace response failed")
644
820
  persist_assistant("failed")
645
- yield stream_event(
821
+ await append_run_event(
822
+ run,
646
823
  "error",
647
824
  {"message": str(error) or "Message could not be sent."},
648
825
  )
826
+ finally:
827
+ run.is_done = True
828
+ async with run.condition:
829
+ run.condition.notify_all()
830
+ if active_workspace_run_id == run.id:
831
+ active_workspace_run_id = None
832
+
833
+ run.task = asyncio.create_task(run_task())
834
+ return run
835
+
836
+ async def workspace_run_stream(
837
+ run: WorkspaceRun, after: int = 0
838
+ ) -> AsyncIterator[str]:
839
+ next_event_index = after + 1
840
+ while True:
841
+ async with run.condition:
842
+
843
+ def has_next_event(index: int = next_event_index) -> bool:
844
+ return run.is_done or any(
845
+ event_index >= index for event_index, _, _ in run.events
846
+ )
847
+
848
+ await run.condition.wait_for(has_next_event)
849
+ events = [event for event in run.events if event[0] >= next_event_index]
850
+
851
+ for index, event, data in events:
852
+ next_event_index = index + 1
853
+ yield stream_event(event, data)
854
+ if event in {"done", "error"}:
855
+ return
856
+
857
+ if run.is_done and not events:
649
858
  return
650
859
 
860
+ @app.post("/api/workspace/runs")
861
+ async def start_workspace_run(
862
+ request: WorkspaceRespondRequest,
863
+ ) -> WorkspaceRunResponse:
864
+ logger.info("Workspace run requested content_length=%s", len(request.content))
865
+ logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
866
+ run = create_workspace_run(request.content)
867
+ return WorkspaceRunResponse(run_id=run.id)
868
+
869
+ @app.get("/api/workspace/runs/{run_id}/stream")
870
+ async def stream_workspace_run(
871
+ run_id: str,
872
+ after: int = Query(default=0, ge=0),
873
+ ) -> StreamingResponse:
874
+ run = workspace_runs.get(run_id)
875
+ if run is None:
876
+ raise HTTPException(status_code=404, detail="Run not found.")
877
+ return StreamingResponse(
878
+ workspace_run_stream(run, after),
879
+ media_type="text/event-stream",
880
+ )
881
+
882
+ @app.post("/api/workspace/runs/{run_id}/stop")
883
+ async def stop_workspace_run(run_id: str) -> dict[str, bool]:
884
+ run = workspace_runs.get(run_id)
885
+ if run is None:
886
+ raise HTTPException(status_code=404, detail="Run not found.")
887
+ if run.task is not None and not run.task.done():
888
+ run.task.cancel()
889
+ return {"ok": True}
890
+
891
+ @app.post("/api/workspace/compact")
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
+ )
898
+ logger.info("Workspace compact requested")
899
+ state = store.read_state()
900
+ connection = selected_connection(state)
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
+ ]
910
+
911
+ 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,
920
+ )
921
+ except HTTPException:
922
+ raise
923
+ except Exception as error:
924
+ logger.exception("Workspace compact failed")
925
+ raise HTTPException(
926
+ status_code=500,
927
+ detail="Context could not be compacted.",
928
+ ) from error
929
+
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
+ 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)
957
+ return WorkspaceCompactResponse(message=marker)
958
+
959
+ @app.post("/api/workspace/respond")
960
+ async def respond_to_workspace(
961
+ request: WorkspaceRespondRequest,
962
+ ) -> StreamingResponse:
963
+ logger.info(
964
+ "Workspace response requested content_length=%s", len(request.content)
965
+ )
966
+ logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
967
+ run = create_workspace_run(request.content)
651
968
  return StreamingResponse(
652
- response_stream(),
969
+ workspace_run_stream(run),
653
970
  media_type="text/event-stream",
654
971
  )
655
972