flowent 0.2.4 → 0.3.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 (46) hide show
  1. package/README.md +3 -3
  2. package/backend/README.md +3 -3
  3. package/backend/pyproject.toml +1 -1
  4. package/backend/src/flowent/agent.py +1 -1
  5. package/backend/src/flowent/api_models.py +103 -0
  6. package/backend/src/flowent/app.py +151 -0
  7. package/backend/src/flowent/cli.py +13 -4
  8. package/backend/src/flowent/compact.py +34 -13
  9. package/backend/src/flowent/llm.py +2 -0
  10. package/backend/src/flowent/main.py +18 -1994
  11. package/backend/src/flowent/mcp.py +100 -2
  12. package/backend/src/flowent/network.py +5 -0
  13. package/backend/src/flowent/provider_connections.py +42 -0
  14. package/backend/src/flowent/routes/__init__.py +0 -0
  15. package/backend/src/flowent/routes/integrations.py +105 -0
  16. package/backend/src/flowent/routes/permissions.py +36 -0
  17. package/backend/src/flowent/routes/providers.py +30 -0
  18. package/backend/src/flowent/routes/system.py +49 -0
  19. package/backend/src/flowent/routes/workflow_routes.py +63 -0
  20. package/backend/src/flowent/routes/workspace.py +105 -0
  21. package/backend/src/flowent/state/__init__.py +53 -0
  22. package/backend/src/flowent/state/models.py +257 -0
  23. package/backend/src/flowent/state/schema.py +186 -0
  24. package/backend/src/flowent/state/store.py +1013 -0
  25. package/backend/src/flowent/static/assets/index-CvWZZMtK.css +2 -0
  26. package/backend/src/flowent/static/assets/index-ma2v8oW7.js +90 -0
  27. package/backend/src/flowent/static/index.html +2 -2
  28. package/backend/src/flowent/storage.py +52 -1318
  29. package/backend/src/flowent/system_tools.py +25 -0
  30. package/backend/src/flowent/tools.py +4 -2
  31. package/backend/src/flowent/usage.py +9 -4
  32. package/backend/src/flowent/workflows.py +282 -0
  33. package/backend/src/flowent/workspace/__init__.py +0 -0
  34. package/backend/src/flowent/workspace/context.py +249 -0
  35. package/backend/src/flowent/workspace/events.py +180 -0
  36. package/backend/src/flowent/workspace/output.py +274 -0
  37. package/backend/src/flowent/workspace/runtime.py +1041 -0
  38. package/backend/uv.lock +1 -1
  39. package/dist/frontend/assets/index-CvWZZMtK.css +2 -0
  40. package/dist/frontend/assets/index-ma2v8oW7.js +90 -0
  41. package/dist/frontend/index.html +2 -2
  42. package/package.json +1 -1
  43. package/backend/src/flowent/static/assets/index-BH30iLzb.css +0 -2
  44. package/backend/src/flowent/static/assets/index-sBFt3ORj.js +0 -84
  45. package/dist/frontend/assets/index-BH30iLzb.css +0 -2
  46. package/dist/frontend/assets/index-sBFt3ORj.js +0 -84
@@ -0,0 +1,1041 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ from collections.abc import AsyncIterator, Mapping, Sequence
5
+ from contextlib import suppress
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+ from uuid import uuid4
10
+
11
+ from fastapi import HTTPException
12
+
13
+ from flowent.agent import AgentContextUpdate, run_agent_stream
14
+ from flowent.approval import ApprovalReviewRequest, review_approval_request
15
+ from flowent.compact import CompactInput, CompactProvider
16
+ from flowent.context import runtime_context_messages
17
+ from flowent.llm import ChatMessage, CompletionCallable, ProviderConnection
18
+ from flowent.logging import TRACE_LEVEL
19
+ from flowent.mcp import McpManager
20
+ from flowent.permissions import run_tool_with_path_permissions
21
+ from flowent.provider_connections import selected_connection
22
+ from flowent.skills import explicit_skill_messages
23
+ from flowent.storage import (
24
+ StateStore,
25
+ StoredCompactionCheckpoint,
26
+ StoredMessage,
27
+ StoredState,
28
+ StoredToolItem,
29
+ )
30
+ from flowent.tools import ToolContext
31
+ from flowent.usage import (
32
+ TokenUsage,
33
+ TokenUsageInfo,
34
+ append_token_usage,
35
+ recompute_context_usage,
36
+ )
37
+ from flowent.workspace.context import (
38
+ COMPACTED_CONTEXT_MARKER,
39
+ OPTIMIZED_CONTEXT_MARKER,
40
+ context_window_for_settings,
41
+ should_auto_compact,
42
+ update_context_usage_for_response,
43
+ usage_event_data,
44
+ workspace_chat_messages,
45
+ )
46
+ from flowent.workspace.events import (
47
+ WorkspaceRun,
48
+ append_or_replace_message,
49
+ run_snapshot_data_at,
50
+ stream_event,
51
+ stream_message_data,
52
+ )
53
+ from flowent.workspace.output import (
54
+ EMPTY_MODEL_RESPONSE_DETAIL,
55
+ AssistantOutputBuilder,
56
+ approval_transcript,
57
+ run_error_event_data,
58
+ run_error_output_item,
59
+ )
60
+
61
+ logger = logging.getLogger("flowent.workspace.runtime")
62
+
63
+ AUTO_COMPACT_RETAINED_MESSAGE_TOKEN_BUDGET = 20_000
64
+ WORKSPACE_PROGRESS_FLUSH_INTERVAL_SECONDS = 0.5
65
+
66
+
67
+ @dataclass
68
+ class WorkspaceCompactTask:
69
+ task: asyncio.Task[tuple[StoredMessage, TokenUsageInfo]]
70
+
71
+
72
+ class WorkspaceRuntime:
73
+ def __init__(
74
+ self,
75
+ *,
76
+ chat_completion: CompletionCallable | None,
77
+ compact_provider: CompactProvider,
78
+ cwd: Path,
79
+ mcp_manager: McpManager,
80
+ store: StateStore,
81
+ ) -> None:
82
+ self.chat_completion = chat_completion
83
+ self.compact_provider = compact_provider
84
+ self.cwd = cwd
85
+ self.mcp_manager = mcp_manager
86
+ self.store = store
87
+ self.runs: dict[str, WorkspaceRun] = {}
88
+ self.active_run_id: str | None = None
89
+ self.generation = 0
90
+ self.active_compact_task: WorkspaceCompactTask | None = None
91
+
92
+ def request_messages_for_content(
93
+ self,
94
+ state: StoredState,
95
+ messages: list[StoredMessage],
96
+ content: str,
97
+ ) -> list[dict[str, object]]:
98
+ compacted_context = self.store.read_compacted_context()
99
+ checkpoint = self.store.read_active_compaction_checkpoint()
100
+ chat_messages = workspace_chat_messages(
101
+ messages,
102
+ compacted_context,
103
+ checkpoint,
104
+ )
105
+ return [
106
+ message.model_dump()
107
+ for message in [
108
+ *runtime_context_messages(self.cwd, state.settings.agent_prompt),
109
+ *explicit_skill_messages(self.cwd, self.store, content),
110
+ *chat_messages,
111
+ ]
112
+ ]
113
+
114
+ async def save_context_checkpoint(
115
+ self,
116
+ *,
117
+ connection: ProviderConnection,
118
+ context_window_limit: int,
119
+ messages: list[StoredMessage],
120
+ model_history: list[ChatMessage],
121
+ marker_content: str,
122
+ source_message_id: str | None = None,
123
+ trigger: Literal["manual", "auto"],
124
+ ) -> tuple[StoredMessage, list[dict[str, object]], TokenUsageInfo]:
125
+ compact_result = await self.compact_provider.compact(
126
+ connection,
127
+ CompactInput(
128
+ messages=messages,
129
+ model_history=model_history,
130
+ retained_message_token_budget=AUTO_COMPACT_RETAINED_MESSAGE_TOKEN_BUDGET,
131
+ trigger=trigger,
132
+ ),
133
+ completion=self.chat_completion,
134
+ )
135
+ usage_info = self.store.read_usage_info()
136
+ if compact_result.summary_usage is not None:
137
+ usage_info = append_token_usage(
138
+ usage_info,
139
+ compact_result.summary_usage,
140
+ model_context_window=context_window_limit,
141
+ )
142
+ usage_info = recompute_context_usage(
143
+ usage_info,
144
+ compact_result.token_after,
145
+ model_context_window=context_window_limit,
146
+ )
147
+ self.store.save_usage_info(usage_info)
148
+ marker = StoredMessage(
149
+ author="system",
150
+ content=marker_content,
151
+ id=str(uuid4()),
152
+ usage_info=usage_info,
153
+ )
154
+ self.store.save_compaction_checkpoint(
155
+ StoredCompactionCheckpoint(
156
+ id=str(uuid4()),
157
+ method=compact_result.method,
158
+ replacement_history=compact_result.replacement_history,
159
+ source_message_id=source_message_id or marker.id,
160
+ summary=compact_result.summary,
161
+ token_after=compact_result.token_after,
162
+ token_before=compact_result.token_before,
163
+ trigger=trigger,
164
+ )
165
+ )
166
+ logger.info(
167
+ "Workspace compact checkpoint saved trigger=%s method=%s summary_length=%s token_before=%s token_after=%s",
168
+ trigger,
169
+ compact_result.method,
170
+ len(compact_result.summary),
171
+ compact_result.token_before,
172
+ compact_result.token_after,
173
+ )
174
+ logger.log(TRACE_LEVEL, "Workspace compact summary=%r", compact_result.summary)
175
+ return (
176
+ marker,
177
+ [message.model_dump() for message in compact_result.replacement_history],
178
+ usage_info,
179
+ )
180
+
181
+ async def auto_compact_messages(
182
+ self,
183
+ *,
184
+ connection: ProviderConnection,
185
+ context_window_limit: int,
186
+ messages: list[StoredMessage],
187
+ model_history: list[ChatMessage],
188
+ source_message_id: str | None = None,
189
+ ) -> tuple[StoredMessage, list[dict[str, object]], TokenUsageInfo] | None:
190
+ if not should_auto_compact(
191
+ model_history,
192
+ context_window=context_window_limit,
193
+ ):
194
+ return None
195
+ logger.info("Workspace auto compact requested")
196
+ try:
197
+ return await self.save_context_checkpoint(
198
+ connection=connection,
199
+ context_window_limit=context_window_limit,
200
+ marker_content=OPTIMIZED_CONTEXT_MARKER,
201
+ messages=messages,
202
+ model_history=model_history,
203
+ source_message_id=source_message_id,
204
+ trigger="auto",
205
+ )
206
+ except Exception as error:
207
+ logger.exception("Workspace auto compact failed")
208
+ raise RuntimeError("Context could not be optimized.") from error
209
+
210
+ async def run_turn(self, content: str) -> StoredMessage:
211
+ state = self.store.read_state()
212
+ connection = selected_connection(state)
213
+ context_window_limit = context_window_for_settings(state.settings)
214
+ user_message = StoredMessage(
215
+ author="user",
216
+ content=content,
217
+ id=str(uuid4()),
218
+ )
219
+ next_messages = [*state.messages, user_message]
220
+ self.store.save_messages(next_messages)
221
+ model_history = [
222
+ *runtime_context_messages(self.cwd, state.settings.agent_prompt),
223
+ *workspace_chat_messages(
224
+ state.messages,
225
+ self.store.read_compacted_context(),
226
+ self.store.read_active_compaction_checkpoint(),
227
+ ),
228
+ ]
229
+ auto_compaction = await self.auto_compact_messages(
230
+ connection=connection,
231
+ context_window_limit=context_window_limit,
232
+ messages=state.messages,
233
+ model_history=model_history,
234
+ source_message_id=None,
235
+ )
236
+ if auto_compaction is not None:
237
+ marker, _, _ = auto_compaction
238
+ next_messages = [*state.messages, marker, user_message]
239
+ self.store.save_messages(next_messages)
240
+ request_messages = self.request_messages_for_content(
241
+ state, next_messages, content
242
+ )
243
+ assistant_id = str(uuid4())
244
+ assistant_output = AssistantOutputBuilder(assistant_id)
245
+ turn_usage_info: TokenUsageInfo | None = None
246
+ current_output_index = 0
247
+ latest_usage_output_index: int | None = None
248
+
249
+ async def review_tool_approval(request: ApprovalReviewRequest):
250
+ return await review_approval_request(
251
+ connection,
252
+ request.model_copy(
253
+ update={
254
+ "transcript": approval_transcript(next_messages),
255
+ "user_request": content,
256
+ }
257
+ ),
258
+ completion=self.chat_completion,
259
+ )
260
+
261
+ async def tool_runner(
262
+ name: str,
263
+ arguments: dict[str, object],
264
+ context: ToolContext,
265
+ ):
266
+ return await run_tool_with_path_permissions(
267
+ name,
268
+ arguments,
269
+ context,
270
+ review_approval=review_tool_approval,
271
+ writable_paths=[
272
+ Path(path.path) for path in self.store.read_writable_paths()
273
+ ],
274
+ )
275
+
276
+ async for event in run_agent_stream(
277
+ completion=self.chat_completion,
278
+ connection=connection,
279
+ cwd=self.cwd,
280
+ extra_tool_runner=self.mcp_manager.run_tool,
281
+ extra_tool_specs=self.mcp_manager.tool_specs(),
282
+ extra_tool_title=self.mcp_manager.tool_title,
283
+ messages=request_messages,
284
+ tool_runner=tool_runner,
285
+ ):
286
+ if event.event == "start":
287
+ event_id = event.data.get("id")
288
+ if isinstance(event_id, str):
289
+ assistant_id = event_id
290
+ assistant_output.set_assistant_id(event_id)
291
+ if event.event == "output_start":
292
+ index = event.data.get("index")
293
+ if isinstance(index, int):
294
+ current_output_index = index
295
+ assistant_output.start_group(index)
296
+ if event.event == "delta":
297
+ assistant_output.append_text(str(event.data.get("content") or ""))
298
+ if event.event == "thinking_delta":
299
+ assistant_output.append_thinking(str(event.data.get("content") or ""))
300
+ if event.event == "usage":
301
+ usage_data = event.data.get("usage")
302
+ if isinstance(usage_data, dict):
303
+ usage_info = append_token_usage(
304
+ self.store.read_usage_info(),
305
+ TokenUsage.model_validate(usage_data),
306
+ model_context_window=context_window_limit,
307
+ )
308
+ self.store.save_usage_info(usage_info)
309
+ turn_usage_info = usage_info
310
+ latest_usage_output_index = current_output_index
311
+ if event.event == "tool_start":
312
+ tool = event.data.get("tool")
313
+ if isinstance(tool, dict) and isinstance(tool.get("id"), str):
314
+ assistant_output.start_tool(StoredToolItem.model_validate(tool))
315
+ if event.event in {"tool_done", "tool_error"}:
316
+ tool_id = event.data.get("id")
317
+ if isinstance(tool_id, str):
318
+ assistant_output.update_tool(tool_id, event.data)
319
+ if event.event == "done":
320
+ message = event.data.get("message")
321
+ if isinstance(message, dict):
322
+ assistant_id = str(message.get("id") or assistant_id)
323
+ assistant_output.set_assistant_id(assistant_id)
324
+ assistant_output.apply_done_message(message)
325
+
326
+ final_usage_info = turn_usage_info
327
+ if (
328
+ final_usage_info is None
329
+ or latest_usage_output_index != current_output_index
330
+ ):
331
+ final_usage_info = update_context_usage_for_response(
332
+ final_usage_info or self.store.read_usage_info(),
333
+ messages=request_messages,
334
+ output_content=assistant_output.content,
335
+ output_tools=[
336
+ tool.model_dump(exclude_none=True)
337
+ for tool in assistant_output.tools.values()
338
+ ],
339
+ model_context_window=context_window_limit,
340
+ )
341
+ self.store.save_usage_info(final_usage_info)
342
+
343
+ assistant_message = StoredMessage(
344
+ author="assistant",
345
+ content=assistant_output.content,
346
+ groups=assistant_output.groups,
347
+ id=assistant_id,
348
+ status="completed",
349
+ thinking=assistant_output.thinking,
350
+ tools=list(assistant_output.tools.values()),
351
+ usage_info=final_usage_info,
352
+ )
353
+ self.store.save_messages([*next_messages, assistant_message])
354
+ return assistant_message
355
+
356
+ async def reply_text(self, content: str) -> str:
357
+ return (await self.run_turn(content)).content
358
+
359
+ async def gather_shutdown_tasks(
360
+ self, label: str, tasks: Sequence[asyncio.Task[Any]]
361
+ ) -> None:
362
+ if not tasks:
363
+ return
364
+ results = await asyncio.gather(*tasks, return_exceptions=True)
365
+ for result in results:
366
+ if result is None or isinstance(result, asyncio.CancelledError):
367
+ continue
368
+ if isinstance(result, BaseException):
369
+ logger.error(
370
+ "%s cleanup task failed",
371
+ label,
372
+ exc_info=(type(result), result, result.__traceback__),
373
+ )
374
+
375
+ async def stop_runs_for_shutdown(self) -> None:
376
+ tasks: list[asyncio.Task[None]] = []
377
+ for run in self.runs.values():
378
+ if run.task is None or run.task.done():
379
+ continue
380
+ run.task.cancel()
381
+ tasks.append(run.task)
382
+ await self.gather_shutdown_tasks("Workspace run", tasks)
383
+
384
+ async def stop_compact_for_shutdown(self) -> None:
385
+ if self.active_compact_task is None:
386
+ self.store.save_is_compacting(False)
387
+ return
388
+ task = self.active_compact_task.task
389
+ self.active_compact_task = None
390
+ if not task.done():
391
+ task.cancel()
392
+ await self.gather_shutdown_tasks("Workspace compact", [task])
393
+ self.store.save_is_compacting(False)
394
+
395
+ async def stop_for_shutdown(self) -> None:
396
+ await self.stop_runs_for_shutdown()
397
+ await self.stop_compact_for_shutdown()
398
+
399
+ def active_run(self) -> WorkspaceRun | None:
400
+ if self.active_run_id is None:
401
+ return None
402
+ run = self.runs.get(self.active_run_id)
403
+ if run is None or run.is_done:
404
+ return None
405
+ return run
406
+
407
+ def has_active_run(self) -> bool:
408
+ return any(
409
+ not run.is_done and run.task is not None and not run.task.done()
410
+ for run in self.runs.values()
411
+ )
412
+
413
+ def clear(self) -> list[StoredMessage]:
414
+ self.generation += 1
415
+ for run in self.runs.values():
416
+ run.is_done = True
417
+ if run.task is not None and not run.task.done():
418
+ run.discard_on_cancel = True
419
+ run.task.cancel()
420
+ self.active_run_id = None
421
+ return self.store.save_messages([])
422
+
423
+ async def notify_cleared_runs(self) -> None:
424
+ for run in self.runs.values():
425
+ async with run.condition:
426
+ run.condition.notify_all()
427
+
428
+ async def append_event(
429
+ self, run: WorkspaceRun, event: str, data: dict[str, object]
430
+ ) -> None:
431
+ async with run.condition:
432
+ run.events.append((run.latest_event_index + 1, event, data))
433
+ run.condition.notify_all()
434
+
435
+ async def append_snapshot(self, run: WorkspaceRun, message: StoredMessage) -> None:
436
+ if message.author != "assistant":
437
+ return
438
+ run.latest_snapshot = message
439
+ await self.append_event(
440
+ run,
441
+ "snapshot",
442
+ {"message": stream_message_data(message, run.active_output)},
443
+ )
444
+
445
+ def create_run(
446
+ self, content: str, *, message_id: str | None = None
447
+ ) -> WorkspaceRun:
448
+ if self.has_active_run():
449
+ active_run = self.active_run()
450
+ raise HTTPException(
451
+ status_code=409,
452
+ detail="Response in progress",
453
+ headers={"X-Flowent-Run-Id": active_run.id if active_run else ""},
454
+ )
455
+ state = self.store.read_state()
456
+ user_message_id = message_id or str(uuid4())
457
+ if any(message.id == user_message_id for message in state.messages):
458
+ raise HTTPException(status_code=409, detail="Message already exists.")
459
+ user_message = StoredMessage(
460
+ author="user",
461
+ content=content,
462
+ id=user_message_id,
463
+ )
464
+ next_messages = [*state.messages, user_message]
465
+ self.store.save_messages(next_messages)
466
+ return self._create_run_from_messages(
467
+ content=content,
468
+ next_messages=next_messages,
469
+ state=state,
470
+ user_message=user_message,
471
+ )
472
+
473
+ def edit_message(
474
+ self,
475
+ message_id: str,
476
+ *,
477
+ action: Literal["resend", "save"],
478
+ content: str,
479
+ ) -> tuple[list[StoredMessage], WorkspaceRun | None]:
480
+ if self.has_active_run():
481
+ active_run = self.active_run()
482
+ raise HTTPException(
483
+ status_code=409,
484
+ detail="Response in progress",
485
+ headers={"X-Flowent-Run-Id": active_run.id if active_run else ""},
486
+ )
487
+ state = self.store.read_state()
488
+ message_index = next(
489
+ (
490
+ index
491
+ for index, message in enumerate(state.messages)
492
+ if message.id == message_id
493
+ ),
494
+ -1,
495
+ )
496
+ if message_index < 0:
497
+ raise HTTPException(status_code=404, detail="Message not found.")
498
+ message = state.messages[message_index]
499
+ if message.author != "user":
500
+ raise HTTPException(
501
+ status_code=400, detail="Only user messages can be edited."
502
+ )
503
+
504
+ updated_message = message.model_copy(update={"content": content})
505
+ if action == "save":
506
+ next_messages = [
507
+ *state.messages[:message_index],
508
+ updated_message,
509
+ *state.messages[message_index + 1 :],
510
+ ]
511
+ return self.store.save_messages(next_messages), None
512
+
513
+ previous_messages = state.messages[:message_index]
514
+ next_messages = [*previous_messages, updated_message]
515
+ self.store.save_messages(next_messages)
516
+ run = self._create_run_from_messages(
517
+ content=content,
518
+ next_messages=next_messages,
519
+ state=state.model_copy(update={"messages": previous_messages}),
520
+ user_message=updated_message,
521
+ )
522
+ return next_messages, run
523
+
524
+ def _create_run_from_messages(
525
+ self,
526
+ *,
527
+ content: str,
528
+ next_messages: list[StoredMessage],
529
+ state: StoredState,
530
+ user_message: StoredMessage,
531
+ ) -> WorkspaceRun:
532
+ connection = selected_connection(state)
533
+ context_window_limit = context_window_for_settings(state.settings)
534
+ run = WorkspaceRun(
535
+ condition=asyncio.Condition(),
536
+ generation=self.generation,
537
+ )
538
+ self.runs[run.id] = run
539
+ self.active_run_id = run.id
540
+
541
+ async def run_task() -> None:
542
+ nonlocal next_messages
543
+ assistant_message = StoredMessage(
544
+ author="assistant",
545
+ content="",
546
+ id=str(uuid4()),
547
+ status="running",
548
+ )
549
+ assistant_output = AssistantOutputBuilder(assistant_message.id)
550
+ last_progress_flush_at = 0.0
551
+
552
+ def is_current_generation() -> bool:
553
+ return run.generation == self.generation
554
+
555
+ def update_assistant_message(
556
+ status: str = "running", *, persist: bool
557
+ ) -> StoredMessage | None:
558
+ nonlocal next_messages, assistant_message
559
+ if not is_current_generation() or run.discard_on_cancel:
560
+ return None
561
+ assistant_message = StoredMessage(
562
+ author="assistant",
563
+ content=assistant_output.content,
564
+ groups=assistant_output.groups,
565
+ id=assistant_message.id,
566
+ status=status,
567
+ thinking=assistant_output.thinking,
568
+ tools=list(assistant_output.tools.values()),
569
+ usage_info=self.store.read_usage_info(),
570
+ )
571
+ next_messages = append_or_replace_message(
572
+ next_messages, assistant_message
573
+ )
574
+ if persist:
575
+ self.store.upsert_message(assistant_message)
576
+ return assistant_message
577
+
578
+ def persist_assistant(status: str = "running") -> StoredMessage | None:
579
+ nonlocal last_progress_flush_at
580
+ message = update_assistant_message(status, persist=True)
581
+ if status == "running" and message is not None:
582
+ last_progress_flush_at = time.monotonic()
583
+ return message
584
+
585
+ def refresh_assistant(status: str = "running") -> StoredMessage | None:
586
+ return update_assistant_message(status, persist=False)
587
+
588
+ def persist_assistant_progress() -> StoredMessage | None:
589
+ nonlocal last_progress_flush_at
590
+ now = time.monotonic()
591
+ if (
592
+ last_progress_flush_at > 0
593
+ and now - last_progress_flush_at
594
+ < WORKSPACE_PROGRESS_FLUSH_INTERVAL_SECONDS
595
+ ):
596
+ refresh_assistant()
597
+ return None
598
+ last_progress_flush_at = now
599
+ return update_assistant_message("running", persist=True)
600
+
601
+ try:
602
+ current_tool_id: str | None = None
603
+ turn_usage_info: TokenUsageInfo | None = None
604
+ current_output_index = 0
605
+ latest_usage_output_index: int | None = None
606
+ current_request_messages = self.request_messages_for_content(
607
+ state,
608
+ next_messages,
609
+ content,
610
+ )
611
+ pre_turn_request_messages = self.request_messages_for_content(
612
+ state,
613
+ state.messages,
614
+ content,
615
+ )
616
+ auto_compaction = await self.auto_compact_messages(
617
+ connection=connection,
618
+ context_window_limit=context_window_limit,
619
+ messages=state.messages,
620
+ model_history=[
621
+ ChatMessage.model_validate(message)
622
+ for message in pre_turn_request_messages
623
+ ],
624
+ source_message_id=None,
625
+ )
626
+ if auto_compaction is not None:
627
+ marker, _, usage_info = auto_compaction
628
+ next_messages = [*state.messages, marker, user_message]
629
+ self.store.save_messages(next_messages)
630
+ await self.append_event(
631
+ run,
632
+ "context_optimized",
633
+ {
634
+ "message": marker.model_dump(),
635
+ **usage_event_data(usage_info),
636
+ },
637
+ )
638
+ current_request_messages = self.request_messages_for_content(
639
+ state,
640
+ next_messages,
641
+ content,
642
+ )
643
+
644
+ async def review_tool_approval(request: ApprovalReviewRequest):
645
+ return await review_approval_request(
646
+ connection,
647
+ request.model_copy(
648
+ update={
649
+ "transcript": approval_transcript(next_messages),
650
+ "user_request": content,
651
+ }
652
+ ),
653
+ completion=self.chat_completion,
654
+ )
655
+
656
+ async def tool_runner(
657
+ name: str,
658
+ arguments: dict[str, object],
659
+ context: ToolContext,
660
+ ):
661
+ return await run_tool_with_path_permissions(
662
+ name,
663
+ arguments,
664
+ context,
665
+ review_approval=review_tool_approval,
666
+ writable_paths=[
667
+ Path(path.path) for path in self.store.read_writable_paths()
668
+ ],
669
+ )
670
+
671
+ async def context_compactor(
672
+ conversation: Sequence[Mapping[str, object]],
673
+ ) -> AgentContextUpdate | None:
674
+ nonlocal next_messages
675
+ if not is_current_generation() or run.discard_on_cancel:
676
+ return None
677
+ assistant_snapshot = StoredMessage(
678
+ author="assistant",
679
+ content=assistant_output.content,
680
+ groups=assistant_output.groups,
681
+ id=assistant_message.id,
682
+ status="running",
683
+ thinking=assistant_output.thinking,
684
+ tools=list(assistant_output.tools.values()),
685
+ usage_info=self.store.read_usage_info(),
686
+ )
687
+ model_history: list[ChatMessage] = []
688
+ for message in conversation:
689
+ role_value = message.get("role")
690
+ content = str(message.get("content") or "")
691
+ if role_value == "system":
692
+ model_history.append(
693
+ ChatMessage(role="system", content=content)
694
+ )
695
+ if role_value == "user":
696
+ model_history.append(
697
+ ChatMessage(role="user", content=content)
698
+ )
699
+ if role_value == "assistant":
700
+ model_history.append(
701
+ ChatMessage(role="assistant", content=content)
702
+ )
703
+ if role_value == "tool":
704
+ model_history.append(
705
+ ChatMessage(
706
+ role="user",
707
+ content=f"Tool result: {content}",
708
+ )
709
+ )
710
+ auto_result = await self.auto_compact_messages(
711
+ connection=connection,
712
+ context_window_limit=context_window_limit,
713
+ messages=next_messages,
714
+ model_history=model_history,
715
+ source_message_id=assistant_snapshot.id,
716
+ )
717
+ if auto_result is None:
718
+ return None
719
+ marker, replacement_history, usage_info = auto_result
720
+ assistant_snapshot = assistant_snapshot.model_copy(
721
+ update={"usage_info": usage_info}
722
+ )
723
+ next_messages = append_or_replace_message(
724
+ [*next_messages, marker], assistant_snapshot
725
+ )
726
+ self.store.save_messages(next_messages)
727
+ compacted_conversation = [
728
+ dict(conversation[0]),
729
+ *replacement_history,
730
+ ]
731
+ return AgentContextUpdate(
732
+ conversation=compacted_conversation,
733
+ message={
734
+ **marker.model_dump(),
735
+ "usage_info": usage_info.model_dump(),
736
+ },
737
+ )
738
+
739
+ async for event in run_agent_stream(
740
+ completion=self.chat_completion,
741
+ connection=connection,
742
+ context_compactor=context_compactor,
743
+ cwd=self.cwd,
744
+ extra_tool_runner=self.mcp_manager.run_tool,
745
+ extra_tool_specs=self.mcp_manager.tool_specs(),
746
+ extra_tool_title=self.mcp_manager.tool_title,
747
+ messages=current_request_messages,
748
+ tool_runner=tool_runner,
749
+ ):
750
+ if not is_current_generation() or run.discard_on_cancel:
751
+ raise asyncio.CancelledError
752
+ run_event_data = event.data
753
+ should_append_run_event = event.event != "usage"
754
+ snapshot_after_event: StoredMessage | None = None
755
+ if event.event == "start":
756
+ event_id = event.data.get("id")
757
+ if isinstance(event_id, str):
758
+ assistant_message = assistant_message.model_copy(
759
+ update={"id": event_id}
760
+ )
761
+ assistant_output.set_assistant_id(event_id)
762
+ snapshot_after_event = persist_assistant()
763
+ if event.event == "output_start":
764
+ index = event.data.get("index")
765
+ if isinstance(index, int):
766
+ current_output_index = index
767
+ run.active_output = None
768
+ assistant_output.start_group(index)
769
+ snapshot_after_event = persist_assistant()
770
+ if event.event == "output_done":
771
+ run.active_output = None
772
+ if event.event == "tool_start":
773
+ tool = event.data.get("tool")
774
+ if isinstance(tool, dict) and isinstance(tool.get("id"), str):
775
+ run.active_output = None
776
+ current_tool_id = tool["id"]
777
+ assistant_output.start_tool(
778
+ StoredToolItem.model_validate(tool)
779
+ )
780
+ snapshot_after_event = persist_assistant()
781
+ if event.event in {"tool_done", "tool_error"}:
782
+ tool_id = event.data.get("id")
783
+ if (
784
+ isinstance(tool_id, str)
785
+ and tool_id in assistant_output.tools
786
+ ):
787
+ current_tool_id = (
788
+ None if current_tool_id == tool_id else current_tool_id
789
+ )
790
+ assistant_output.update_tool(tool_id, event.data)
791
+ snapshot_after_event = persist_assistant()
792
+ if event.event == "delta":
793
+ run.active_output = "text"
794
+ assistant_output.append_text(
795
+ str(event.data.get("content") or "")
796
+ )
797
+ snapshot_after_event = persist_assistant_progress()
798
+ if event.event == "thinking_delta":
799
+ run.active_output = "thinking"
800
+ assistant_output.append_thinking(
801
+ str(event.data.get("content") or "")
802
+ )
803
+ snapshot_after_event = persist_assistant_progress()
804
+ if event.event == "usage":
805
+ usage_data = event.data.get("usage")
806
+ if isinstance(usage_data, dict):
807
+ usage_info = append_token_usage(
808
+ self.store.read_usage_info(),
809
+ TokenUsage.model_validate(usage_data),
810
+ model_context_window=context_window_limit,
811
+ )
812
+ self.store.save_usage_info(usage_info)
813
+ turn_usage_info = usage_info
814
+ latest_usage_output_index = current_output_index
815
+ run_event_data = usage_event_data(usage_info)
816
+ should_append_run_event = True
817
+ snapshot_after_event = persist_assistant()
818
+ logger.log(
819
+ TRACE_LEVEL,
820
+ "Workspace stream event=%s data=%r",
821
+ event.event,
822
+ event.data,
823
+ )
824
+ if event.event == "done":
825
+ message = event.data.get("message")
826
+ if isinstance(message, dict):
827
+ run.active_output = None
828
+ assistant_output.apply_done_message(message)
829
+ response_usage_info = self.store.read_usage_info()
830
+ final_usage_info = turn_usage_info
831
+ if (
832
+ final_usage_info is None
833
+ or latest_usage_output_index != current_output_index
834
+ ):
835
+ final_usage_info = update_context_usage_for_response(
836
+ final_usage_info or response_usage_info,
837
+ messages=current_request_messages,
838
+ output_content=assistant_output.content,
839
+ output_tools=[
840
+ tool.model_dump(exclude_none=True)
841
+ for tool in assistant_output.tools.values()
842
+ ],
843
+ model_context_window=context_window_limit,
844
+ )
845
+ self.store.save_usage_info(final_usage_info)
846
+ snapshot_after_event = persist_assistant("completed")
847
+ if snapshot_after_event is not None:
848
+ run_event_data = {
849
+ "message": stream_message_data(snapshot_after_event)
850
+ }
851
+ if event.event == "done" and snapshot_after_event is not None:
852
+ await self.append_snapshot(run, snapshot_after_event)
853
+ await self.append_event(run, event.event, run_event_data)
854
+ else:
855
+ if should_append_run_event:
856
+ await self.append_event(run, event.event, run_event_data)
857
+ if snapshot_after_event is not None:
858
+ await self.append_snapshot(run, snapshot_after_event)
859
+ except asyncio.CancelledError:
860
+ logger.info("Workspace run stopped")
861
+ if not run.discard_on_cancel:
862
+ interrupted_snapshot = persist_assistant("interrupted")
863
+ if interrupted_snapshot is not None:
864
+ await self.append_snapshot(run, interrupted_snapshot)
865
+ await self.append_event(
866
+ run,
867
+ "error",
868
+ {"message": "Response stopped."},
869
+ )
870
+ raise
871
+ except Exception as error:
872
+ logger.exception("Workspace response failed")
873
+ if (
874
+ current_tool_id is not None
875
+ and current_tool_id in assistant_output.tools
876
+ and assistant_output.tools[current_tool_id].status == "running"
877
+ ):
878
+ assistant_output.update_tool(
879
+ current_tool_id,
880
+ {"content": str(error) or "Tool failed.", "status": "failed"},
881
+ )
882
+ error_item = assistant_output.append_error(
883
+ run_error_output_item(
884
+ assistant_message.id,
885
+ str(error) or EMPTY_MODEL_RESPONSE_DETAIL,
886
+ )
887
+ )
888
+ failed_snapshot = persist_assistant("failed")
889
+ if failed_snapshot is not None:
890
+ await self.append_snapshot(run, failed_snapshot)
891
+ await self.append_event(run, "error", run_error_event_data(error_item))
892
+ finally:
893
+ run.is_done = True
894
+ async with run.condition:
895
+ run.condition.notify_all()
896
+ if self.active_run_id == run.id:
897
+ self.active_run_id = None
898
+
899
+ run.task = asyncio.create_task(run_task())
900
+ return run
901
+
902
+ async def run_stream(
903
+ self, run: WorkspaceRun, after: int = 0, include_snapshots: bool = True
904
+ ) -> AsyncIterator[str]:
905
+ next_event_index = after + 1
906
+ reconnect_snapshot = run_snapshot_data_at(run, after) if after > 0 else None
907
+ if include_snapshots and reconnect_snapshot is not None:
908
+ yield stream_event(
909
+ "snapshot",
910
+ {"message": reconnect_snapshot},
911
+ event_id=after,
912
+ )
913
+ while True:
914
+ async with run.condition:
915
+
916
+ def has_next_event(index: int = next_event_index) -> bool:
917
+ return run.is_done or any(
918
+ event_index >= index for event_index, _, _ in run.events
919
+ )
920
+
921
+ await run.condition.wait_for(has_next_event)
922
+ events = [event for event in run.events if event[0] >= next_event_index]
923
+
924
+ for index, event, data in events:
925
+ next_event_index = index + 1
926
+ if event == "snapshot" and not include_snapshots:
927
+ continue
928
+ yield stream_event(event, data, event_id=index)
929
+ if event in {"done", "error"}:
930
+ return
931
+
932
+ if run.is_done and not events:
933
+ return
934
+
935
+ def run_by_id(self, run_id: str) -> WorkspaceRun:
936
+ run = self.runs.get(run_id)
937
+ if run is None:
938
+ raise HTTPException(status_code=404, detail="Run not found.")
939
+ return run
940
+
941
+ def stop_run(self, run_id: str) -> None:
942
+ run = self.run_by_id(run_id)
943
+ if run.task is not None and not run.task.done():
944
+ run.task.cancel()
945
+
946
+ def compact_stream(self) -> AsyncIterator[str]:
947
+ async def run_manual_compact(
948
+ *,
949
+ checkpoint: StoredCompactionCheckpoint | None,
950
+ connection: ProviderConnection,
951
+ context_window_limit: int,
952
+ state: StoredState,
953
+ ) -> tuple[StoredMessage, TokenUsageInfo]:
954
+ logger.info("Workspace compact requested")
955
+ try:
956
+ model_history = [
957
+ *runtime_context_messages(self.cwd, state.settings.agent_prompt),
958
+ *workspace_chat_messages(
959
+ state.messages,
960
+ self.store.read_compacted_context(),
961
+ checkpoint,
962
+ ),
963
+ ]
964
+
965
+ marker, _, usage_info = await self.save_context_checkpoint(
966
+ connection=connection,
967
+ context_window_limit=context_window_limit,
968
+ marker_content=COMPACTED_CONTEXT_MARKER,
969
+ messages=state.messages,
970
+ model_history=model_history,
971
+ source_message_id=None,
972
+ trigger="manual",
973
+ )
974
+ self.store.save_messages([*state.messages, marker])
975
+ logger.info("Workspace compact completed")
976
+ return marker, usage_info
977
+ except Exception:
978
+ logger.exception("Workspace compact failed")
979
+ raise
980
+ finally:
981
+ self.store.save_is_compacting(False)
982
+
983
+ def clear_active_compact_task(
984
+ task: asyncio.Task[tuple[StoredMessage, TokenUsageInfo]],
985
+ ) -> None:
986
+ if (
987
+ self.active_compact_task is not None
988
+ and self.active_compact_task.task is task
989
+ ):
990
+ self.active_compact_task = None
991
+ with suppress(asyncio.CancelledError):
992
+ task.exception()
993
+
994
+ compact_task: asyncio.Task[tuple[StoredMessage, TokenUsageInfo]]
995
+ if self.active_compact_task is not None:
996
+ if not self.active_compact_task.task.done():
997
+ compact_task = self.active_compact_task.task
998
+ else:
999
+ self.active_compact_task = None
1000
+
1001
+ if self.active_compact_task is None:
1002
+ if self.active_run() is not None:
1003
+ raise HTTPException(
1004
+ status_code=409,
1005
+ detail="Compact is unavailable while Flowent is responding.",
1006
+ )
1007
+ state = self.store.read_state()
1008
+ connection = selected_connection(state)
1009
+ context_window_limit = context_window_for_settings(state.settings)
1010
+ checkpoint = self.store.read_active_compaction_checkpoint()
1011
+ self.store.save_is_compacting(True)
1012
+ compact_task = asyncio.create_task(
1013
+ run_manual_compact(
1014
+ checkpoint=checkpoint,
1015
+ connection=connection,
1016
+ context_window_limit=context_window_limit,
1017
+ state=state,
1018
+ )
1019
+ )
1020
+ compact_task.add_done_callback(clear_active_compact_task)
1021
+ self.active_compact_task = WorkspaceCompactTask(task=compact_task)
1022
+
1023
+ async def compact_events() -> AsyncIterator[str]:
1024
+ try:
1025
+ marker, usage_info = await asyncio.shield(compact_task)
1026
+ except Exception:
1027
+ yield stream_event(
1028
+ "error",
1029
+ {"message": "Context could not be compacted."},
1030
+ )
1031
+ return
1032
+
1033
+ marker_data = marker.model_dump()
1034
+ yield stream_event("usage", usage_event_data(usage_info))
1035
+ yield stream_event(
1036
+ "context_optimized",
1037
+ {"message": marker_data, **usage_event_data(usage_info)},
1038
+ )
1039
+ yield stream_event("done", {"message": marker_data})
1040
+
1041
+ return compact_events()