flowent 0.2.4 → 0.3.1

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 +108 -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 +52 -6
  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 +53 -0
  18. package/backend/src/flowent/routes/system.py +48 -0
  19. package/backend/src/flowent/routes/workflow_routes.py +63 -0
  20. package/backend/src/flowent/routes/workspace.py +115 -0
  21. package/backend/src/flowent/state/__init__.py +53 -0
  22. package/backend/src/flowent/state/models.py +258 -0
  23. package/backend/src/flowent/state/schema.py +191 -0
  24. package/backend/src/flowent/state/store.py +1019 -0
  25. package/backend/src/flowent/static/assets/index-BaZmIi2Y.js +98 -0
  26. package/backend/src/flowent/static/assets/index-EC37agAH.css +2 -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 +335 -0
  35. package/backend/src/flowent/workspace/events.py +178 -0
  36. package/backend/src/flowent/workspace/output.py +396 -0
  37. package/backend/src/flowent/workspace/runtime.py +1160 -0
  38. package/backend/uv.lock +1 -1
  39. package/dist/frontend/assets/index-BaZmIi2Y.js +98 -0
  40. package/dist/frontend/assets/index-EC37agAH.css +2 -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
@@ -1,1995 +1,19 @@
1
- import asyncio
2
- import copy
3
- import json
4
- import logging
5
- import os
6
- import time
7
- from collections.abc import AsyncIterator, Awaitable, Mapping, Sequence
8
- from contextlib import asynccontextmanager, suppress
9
- from dataclasses import dataclass, field
10
- from pathlib import Path
11
- from typing import Any, Literal
12
- from uuid import uuid4
13
-
14
- from fastapi import FastAPI, HTTPException, Query
15
- from fastapi.responses import FileResponse, StreamingResponse
16
- from fastapi.staticfiles import StaticFiles
17
- from pydantic import BaseModel, ConfigDict
18
-
19
- from flowent._version import __version__
20
- from flowent.agent import AgentContextUpdate, run_agent_stream
21
- from flowent.approval import (
22
- ApprovalReviewRequest,
23
- ApprovalTranscriptEntry,
24
- review_approval_request,
25
- )
26
- from flowent.channels import TelegramBotManager, TelegramTransport
27
- from flowent.compact import (
28
- CompactInput,
29
- LocalSummaryCompactProvider,
30
- transcript_messages_after,
31
- )
32
- from flowent.context import runtime_context_messages
33
- from flowent.llm import (
34
- ChatMessage,
35
- CompletionCallable,
36
- ProviderConnection,
37
- ProviderFormat,
38
- list_provider_models,
39
- )
40
- from flowent.logging import (
41
- TRACE_LEVEL,
42
- ensure_logging_configured,
43
- redact_diagnostic_value,
44
- )
45
- from flowent.mcp import McpManager, McpTransport
46
- from flowent.mcp_import import McpImportDiscovery, discover_imported_mcp_servers
47
- from flowent.paths import resolve_workdir
48
- from flowent.permissions import run_tool_with_path_permissions
49
- from flowent.sandbox import ensure_sandbox_available
50
- from flowent.skills import (
51
- discover_skills,
52
- explicit_skill_messages,
53
- update_skill_enabled,
54
- )
55
- from flowent.storage import (
56
- StateStore,
57
- StoredAssistantOutputGroup,
58
- StoredCompactionCheckpoint,
59
- StoredErrorOutputItem,
60
- StoredMcpServer,
61
- StoredMessage,
62
- StoredProvider,
63
- StoredSettings,
64
- StoredSkill,
65
- StoredState,
66
- StoredTelegramBot,
67
- StoredTelegramSession,
68
- StoredTextOutputItem,
69
- StoredThinkingOutputItem,
70
- StoredToolItem,
71
- StoredToolOutputItem,
72
- StoredWritablePath,
73
- )
74
- from flowent.tools import ToolContext
75
- from flowent.usage import (
76
- TokenUsage,
77
- TokenUsageInfo,
78
- append_token_usage,
79
- current_model_context_window,
80
- estimated_token_usage_for_messages,
81
- recompute_context_usage,
1
+ from flowent.app import (
2
+ app,
3
+ create_app,
4
+ frontend_static_directory,
82
5
  )
83
-
84
- logger = logging.getLogger("flowent.main")
85
-
86
-
87
- DEFAULT_STATIC_DIR = Path(__file__).parent / "static"
88
- COMPACTED_CONTEXT_MARKER = "Context compacted"
89
- OPTIMIZED_CONTEXT_MARKER = "Context optimized"
90
- DEFAULT_AUTO_COMPACT_CONTEXT_WINDOW_RATIO = 0.95
91
- AUTO_COMPACT_RETAINED_MESSAGE_TOKEN_BUDGET = 20_000
92
- APPROVAL_TRANSCRIPT_MESSAGE_LIMIT = 12
93
- APPROVAL_TRANSCRIPT_TEXT_LIMIT = 2_000
94
- WORKSPACE_PROGRESS_FLUSH_INTERVAL_SECONDS = 0.5
95
-
96
-
97
- class ProviderModelsRequest(BaseModel):
98
- model_config = ConfigDict(extra="forbid")
99
-
100
- provider: ProviderFormat
101
- secret_reference: str
102
- base_url: str | None = None
103
-
104
-
105
- class ProviderModelsResponse(BaseModel):
106
- models: list[str]
107
-
108
-
109
- class WorkspaceMessagesRequest(BaseModel):
110
- model_config = ConfigDict(extra="forbid")
111
-
112
- messages: list[StoredMessage]
113
-
114
-
115
- class WorkspaceRespondRequest(BaseModel):
116
- model_config = ConfigDict(extra="forbid")
117
-
118
- content: str
119
-
120
-
121
- class WorkspaceRunResponse(BaseModel):
122
- model_config = ConfigDict(extra="forbid")
123
-
124
- run_id: str
125
-
126
-
127
- class WorkspaceClearResponse(BaseModel):
128
- model_config = ConfigDict(extra="forbid")
129
-
130
- active_run_id: str | None = None
131
- messages: list[StoredMessage]
132
- usage_info: TokenUsageInfo | None = None
133
-
134
-
135
- @dataclass
136
- class WorkspaceCompactTask:
137
- task: asyncio.Task[tuple[StoredMessage, TokenUsageInfo]]
138
-
139
-
140
- class AboutResponse(BaseModel):
141
- model_config = ConfigDict(extra="forbid")
142
-
143
- version: str
144
-
145
-
146
- class TelegramSessionApproveRequest(BaseModel):
147
- model_config = ConfigDict(extra="forbid")
148
-
149
- chat_id: str
150
-
151
-
152
- class SkillSettingsRequest(BaseModel):
153
- model_config = ConfigDict(extra="forbid")
154
-
155
- enabled: bool
156
-
157
-
158
- class McpImportRequest(BaseModel):
159
- model_config = ConfigDict(extra="forbid")
160
-
161
- server_id: str
162
- source: Literal["claude_code", "codex"]
163
-
164
-
165
- class McpImportPreviewRequest(BaseModel):
166
- model_config = ConfigDict(extra="forbid")
167
-
168
- source: Literal["claude_code", "codex"]
169
-
170
-
171
- class WritablePathRequest(BaseModel):
172
- model_config = ConfigDict(extra="forbid")
173
-
174
- path: str
175
-
176
-
177
- class WritablePathListResponse(BaseModel):
178
- model_config = ConfigDict(extra="forbid")
179
-
180
- writable_paths: list[StoredWritablePath]
181
-
182
-
183
- @dataclass
184
- class WorkspaceRun:
185
- condition: asyncio.Condition
186
- active_output: Literal["text", "thinking"] | None = None
187
- discard_on_cancel: bool = False
188
- events: list[tuple[int, str, dict[str, object]]] = field(default_factory=list)
189
- generation: int = 0
190
- id: str = field(default_factory=lambda: str(uuid4()))
191
- is_done: bool = False
192
- latest_snapshot: StoredMessage | None = None
193
- task: asyncio.Task[None] | None = None
194
-
195
- @property
196
- def latest_event_index(self) -> int:
197
- return self.events[-1][0] if self.events else 0
198
-
199
-
200
- def stream_event(
201
- event: str, data: dict[str, object], event_id: int | None = None
202
- ) -> str:
203
- id_line = f"id: {event_id}\n" if event_id is not None else ""
204
- return f"{id_line}event: {event}\ndata: {json.dumps(data)}\n\n"
205
-
206
-
207
- def stream_message_data(
208
- message: StoredMessage, active_output: Literal["text", "thinking"] | None = None
209
- ) -> dict[str, object]:
210
- data = {**message.model_dump(), "status": message.status}
211
- if active_output is not None:
212
- data["active_output"] = active_output
213
- return data
214
-
215
-
216
- def append_or_replace_message(
217
- messages: list[StoredMessage], message: StoredMessage
218
- ) -> list[StoredMessage]:
219
- return [
220
- *(current for current in messages if current.id != message.id),
221
- message,
222
- ]
223
-
224
-
225
- def run_snapshot_data_at(
226
- run: WorkspaceRun, event_index: int
227
- ) -> dict[str, object] | None:
228
- snapshot_event_index = 0
229
- snapshot: dict[str, object] | None = None
230
- for current_event_index, event, data in run.events:
231
- if current_event_index > event_index:
232
- break
233
- if event != "snapshot":
234
- if event == "start" and snapshot is None:
235
- assistant_id = data.get("id")
236
- if isinstance(assistant_id, str):
237
- snapshot_event_index = current_event_index
238
- snapshot = {
239
- "author": "assistant",
240
- "content": "",
241
- "groups": [],
242
- "id": assistant_id,
243
- "status": "running",
244
- "tools": [],
245
- }
246
- continue
247
- message = data.get("message")
248
- if isinstance(message, dict):
249
- snapshot_event_index = current_event_index
250
- snapshot = copy.deepcopy(message)
251
- if snapshot is None:
252
- return None
253
- for current_event_index, event, data in run.events:
254
- if current_event_index <= snapshot_event_index:
255
- continue
256
- if current_event_index > event_index:
257
- break
258
- apply_stream_event_to_snapshot(snapshot, event, data)
259
- return snapshot
260
-
261
-
262
- def apply_stream_event_to_snapshot(
263
- snapshot: dict[str, object], event: str, data: dict[str, object]
264
- ) -> None:
265
- if event == "output_start":
266
- snapshot.pop("active_output", None)
267
- index = data.get("index")
268
- if isinstance(index, int):
269
- append_snapshot_group(snapshot, index)
270
- if event == "delta":
271
- append_snapshot_text(snapshot, str(data.get("content") or ""))
272
- if event == "thinking_delta":
273
- append_snapshot_thinking(snapshot, str(data.get("content") or ""))
274
- if event == "output_done":
275
- snapshot.pop("active_output", None)
276
-
277
-
278
- def snapshot_groups(snapshot: dict[str, object]) -> list[dict[str, object]]:
279
- groups = snapshot.get("groups")
280
- if not isinstance(groups, list):
281
- groups = []
282
- snapshot["groups"] = groups
283
- return groups
284
-
285
-
286
- def append_snapshot_group(
287
- snapshot: dict[str, object], index: int | None = None
288
- ) -> None:
289
- groups = snapshot_groups(snapshot)
290
- assistant_id = str(snapshot.get("id") or "assistant")
291
- group_index = index if index is not None else len(groups) + 1
292
- group_id = f"{assistant_id}-group-{group_index}"
293
- if groups and groups[-1].get("id") == group_id:
294
- return
295
- groups.append({"id": group_id, "items": []})
296
-
297
-
298
- def append_snapshot_text(snapshot: dict[str, object], content: str) -> None:
299
- if not content:
300
- return
301
- snapshot["active_output"] = "text"
302
- snapshot["content"] = f"{snapshot.get('content') or ''}{content}"
303
- append_snapshot_item_content(snapshot, content, "text")
304
-
305
-
306
- def append_snapshot_thinking(snapshot: dict[str, object], content: str) -> None:
307
- if not content:
308
- return
309
- snapshot["active_output"] = "thinking"
310
- snapshot["thinking"] = f"{snapshot.get('thinking') or ''}{content}"
311
- append_snapshot_item_content(snapshot, content, "thinking")
312
-
313
-
314
- def append_snapshot_item_content(
315
- snapshot: dict[str, object], content: str, item_type: Literal["text", "thinking"]
316
- ) -> None:
317
- groups = snapshot_groups(snapshot)
318
- if not groups:
319
- append_snapshot_group(snapshot)
320
- group = groups[-1]
321
- items = group.get("items")
322
- if not isinstance(items, list):
323
- items = []
324
- group["items"] = items
325
- item = next(
326
- (
327
- current
328
- for current in reversed(items)
329
- if isinstance(current, dict) and current.get("type") == item_type
330
- ),
331
- None,
332
- )
333
- if item is None:
334
- assistant_id = str(snapshot.get("id") or "assistant")
335
- snapshot_item_count = 0
336
- for current_group in groups:
337
- current_items = current_group.get("items")
338
- if not isinstance(current_items, list):
339
- continue
340
- snapshot_item_count += sum(
341
- 1
342
- for current_item in current_items
343
- if isinstance(current_item, dict)
344
- and current_item.get("type") == item_type
345
- )
346
- item = {
347
- "content": "",
348
- "id": f"{assistant_id}-{item_type}-{snapshot_item_count + 1}",
349
- "type": item_type,
350
- }
351
- items.append(item)
352
- item["content"] = f"{item.get('content') or ''}{content}"
353
-
354
-
355
- USER_VISIBLE_RUN_ERROR_TITLE = "Request failed"
356
- USER_VISIBLE_RUN_ERROR_MESSAGE = "Check the model connection settings and try again."
357
- USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE = "Context could not be optimized."
358
- EMPTY_MODEL_RESPONSE_DETAIL = "The model did not return a response."
359
-
360
-
361
- def user_visible_run_error_message(detail: str) -> str:
362
- if detail.strip() == USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE:
363
- return USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE
364
- return USER_VISIBLE_RUN_ERROR_MESSAGE
365
-
366
-
367
- def run_error_output_item(
368
- assistant_id: str,
369
- detail: str,
370
- index: int = 1,
371
- ) -> StoredErrorOutputItem:
372
- redacted_detail = redact_diagnostic_value(detail.strip())
373
- message = user_visible_run_error_message(redacted_detail)
374
- return StoredErrorOutputItem(
375
- detail="" if redacted_detail == message else redacted_detail,
376
- id=f"{assistant_id}-error-{index}",
377
- message=message,
378
- title=USER_VISIBLE_RUN_ERROR_TITLE,
379
- type="error",
380
- )
381
-
382
-
383
- def run_error_event_data(error: StoredErrorOutputItem) -> dict[str, object]:
384
- return {
385
- "error": error.model_dump(exclude_none=True),
386
- "message": error.message,
387
- }
388
-
389
-
390
- def message_error_items(message: StoredMessage) -> list[StoredErrorOutputItem]:
391
- return [
392
- item for group in message.groups for item in group.items if item.type == "error"
393
- ]
394
-
395
-
396
- def error_context_summary(error: StoredErrorOutputItem) -> str:
397
- parts = [f"Previous response failed: {error.title}.", error.message]
398
- if error.detail and error.detail != error.message:
399
- parts.append(f"Detail: {error.detail}")
400
- return " ".join(part.strip() for part in parts if part.strip())
401
-
402
-
403
- def approval_transcript_text(content: str | None) -> str:
404
- text = (content or "").strip()
405
- if len(text) <= APPROVAL_TRANSCRIPT_TEXT_LIMIT:
406
- return text
407
- return f"{text[:APPROVAL_TRANSCRIPT_TEXT_LIMIT]}\n[truncated]"
408
-
409
-
410
- def approval_transcript(
411
- messages: Sequence[StoredMessage],
412
- ) -> list[ApprovalTranscriptEntry]:
413
- entries: list[ApprovalTranscriptEntry] = []
414
- for message in messages[-APPROVAL_TRANSCRIPT_MESSAGE_LIMIT:]:
415
- if message.author in ("user", "assistant"):
416
- role: Literal["user", "assistant"] = (
417
- "user" if message.author == "user" else "assistant"
418
- )
419
- content = approval_transcript_text(message.content)
420
- if content:
421
- entries.append(ApprovalTranscriptEntry(role=role, content=content))
422
- for tool in message.tools:
423
- tool_content = approval_transcript_text(tool.content)
424
- if tool_content:
425
- entries.append(
426
- ApprovalTranscriptEntry(
427
- role="tool",
428
- content=tool_content,
429
- name=tool.name,
430
- )
431
- )
432
- return entries
433
-
434
-
435
- class AssistantOutputBuilder:
436
- def __init__(self, assistant_id: str = "") -> None:
437
- self.assistant_id = assistant_id
438
- self.content = ""
439
- self.groups: list[StoredAssistantOutputGroup] = []
440
- self.text_item_index = 0
441
- self.text_item_id = ""
442
- self.thinking = ""
443
- self.thinking_item_index = 0
444
- self.thinking_item_id = ""
445
- self.error_item_index = 0
446
- self.tools: dict[str, StoredToolItem] = {}
447
-
448
- def set_assistant_id(self, assistant_id: str) -> None:
449
- self.assistant_id = assistant_id
450
-
451
- def start_group(self, index: int) -> None:
452
- group_id = f"{self.assistant_id or 'assistant'}-group-{index}"
453
- if self.groups and self.groups[-1].id == group_id:
454
- return
455
- self.text_item_id = ""
456
- self.thinking_item_id = ""
457
- self.groups.append(StoredAssistantOutputGroup(id=group_id, items=[]))
458
-
459
- def append_text(self, content: str) -> None:
460
- if not content:
461
- return
462
- self._ensure_group()
463
- if not self.text_item_id:
464
- self.text_item_index += 1
465
- self.text_item_id = f"{self.assistant_id}-text-{self.text_item_index}"
466
- self._append_current_item(
467
- StoredTextOutputItem(content="", id=self.text_item_id, type="text")
468
- )
469
- self.content += content
470
- self.groups[-1] = self.groups[-1].model_copy(
471
- update={
472
- "items": [
473
- item.model_copy(update={"content": item.content + content})
474
- if item.type == "text" and item.id == self.text_item_id
475
- else item
476
- for item in self.groups[-1].items
477
- ]
478
- }
479
- )
480
-
481
- def append_thinking(self, content: str) -> None:
482
- if not content:
483
- return
484
- self._ensure_group()
485
- if not self.thinking_item_id:
486
- self.thinking_item_index += 1
487
- self.thinking_item_id = (
488
- f"{self.assistant_id}-thinking-{self.thinking_item_index}"
489
- )
490
- self._append_current_item(
491
- StoredThinkingOutputItem(
492
- content="", id=self.thinking_item_id, type="thinking"
493
- )
494
- )
495
- self.thinking += content
496
- self.groups[-1] = self.groups[-1].model_copy(
497
- update={
498
- "items": [
499
- item.model_copy(update={"content": item.content + content})
500
- if item.type == "thinking" and item.id == self.thinking_item_id
501
- else item
502
- for item in self.groups[-1].items
503
- ]
504
- }
505
- )
506
-
507
- def start_tool(self, tool: StoredToolItem) -> None:
508
- self._ensure_group()
509
- self.text_item_id = ""
510
- self.thinking_item_id = ""
511
- self.tools[tool.id] = tool
512
- self._append_current_item(
513
- StoredToolOutputItem(id=f"tool-{tool.id}", tool=tool, type="tool")
514
- )
515
-
516
- def update_tool(self, tool_id: str, data: dict[str, object]) -> None:
517
- current_tool = self.tools.get(tool_id)
518
- if current_tool is None:
519
- return
520
- updated_tool = StoredToolItem.model_validate(
521
- {**current_tool.model_dump(exclude_none=True), **data}
522
- )
523
- self.tools[tool_id] = updated_tool
524
- self.groups = [
525
- group.model_copy(
526
- update={
527
- "items": [
528
- item.model_copy(update={"tool": updated_tool})
529
- if item.type == "tool" and item.tool.id == tool_id
530
- else item
531
- for item in group.items
532
- ]
533
- }
534
- )
535
- for group in self.groups
536
- ]
537
-
538
- def append_error(self, error: StoredErrorOutputItem) -> StoredErrorOutputItem:
539
- self.error_item_index += 1
540
- if not error.id:
541
- error = error.model_copy(
542
- update={"id": f"{self.assistant_id}-error-{self.error_item_index}"}
543
- )
544
- error_group_id = f"{self.assistant_id}-errors"
545
- if self.groups and self.groups[-1].id == error_group_id:
546
- self.groups[-1] = self.groups[-1].model_copy(
547
- update={"items": [*self.groups[-1].items, error]}
548
- )
549
- else:
550
- self.groups.append(
551
- StoredAssistantOutputGroup(id=error_group_id, items=[error])
552
- )
553
- return error
554
-
555
- def has_output(self) -> bool:
556
- return any(group.items for group in self.groups)
557
-
558
- def apply_done_message(self, message: dict[str, object]) -> None:
559
- final_content = str(message.get("content") or self.content)
560
- final_thinking = str(message.get("thinking") or self.thinking)
561
- self._append_missing_done_text(final_content)
562
- self._append_missing_done_thinking(final_thinking)
563
- self.content = final_content
564
- self.thinking = final_thinking
565
-
566
- def _append_missing_done_text(self, final_content: str) -> None:
567
- streamed_text = "".join(
568
- item.content
569
- for group in self.groups
570
- for item in group.items
571
- if item.type == "text"
572
- )
573
- if not final_content or streamed_text == final_content:
574
- return
575
- missing_text = (
576
- final_content[len(streamed_text) :]
577
- if final_content.startswith(streamed_text)
578
- else final_content
579
- )
580
- self.append_text(missing_text)
581
-
582
- def _append_missing_done_thinking(self, final_thinking: str) -> None:
583
- streamed_thinking = "".join(
584
- item.content
585
- for group in self.groups
586
- for item in group.items
587
- if item.type == "thinking"
588
- )
589
- if not final_thinking or streamed_thinking == final_thinking:
590
- return
591
- missing_thinking = (
592
- final_thinking[len(streamed_thinking) :]
593
- if final_thinking.startswith(streamed_thinking)
594
- else final_thinking
595
- )
596
- self.append_thinking(missing_thinking)
597
-
598
- def _ensure_group(self) -> None:
599
- if not self.groups:
600
- self.start_group(1)
601
-
602
- def _append_current_item(
603
- self,
604
- item: StoredTextOutputItem
605
- | StoredThinkingOutputItem
606
- | StoredErrorOutputItem
607
- | StoredToolOutputItem,
608
- ) -> None:
609
- self.groups[-1] = self.groups[-1].model_copy(
610
- update={"items": [*self.groups[-1].items, item]}
611
- )
612
-
613
-
614
- def frontend_static_directory() -> Path:
615
- configured_directory = os.environ.get("FLOWENT_STATIC_DIR")
616
- if configured_directory:
617
- return Path(configured_directory)
618
- repository_frontend_dist = Path(__file__).resolve().parents[3] / "frontend" / "dist"
619
- if repository_frontend_dist.is_dir():
620
- return repository_frontend_dist
621
- return DEFAULT_STATIC_DIR
622
-
623
-
624
- def selected_connection(state: StoredState) -> ProviderConnection:
625
- provider = next(
626
- (
627
- stored_provider
628
- for stored_provider in state.providers
629
- if stored_provider.id == state.settings.selected_provider_id
630
- ),
631
- None,
632
- )
633
- if provider is None or not state.settings.selected_model:
634
- logger.warning("Workspace request blocked because provider or model is missing")
635
- raise HTTPException(
636
- status_code=400,
637
- detail="Choose a provider and model before sending.",
638
- )
639
- if not provider.api_key:
640
- logger.warning("Workspace request blocked because selected provider has no key")
641
- raise HTTPException(status_code=400, detail="Add a key before sending.")
642
-
643
- logger.debug(
644
- "Workspace request using provider=%s model=%s",
645
- provider.name,
646
- state.settings.selected_model,
647
- )
648
- return ProviderConnection(
649
- base_url=provider.base_url or None,
650
- model=state.settings.selected_model,
651
- name=provider.name,
652
- provider=provider.type,
653
- reasoning_effort=state.settings.reasoning_effort,
654
- secret_reference=provider.api_key,
655
- )
656
-
657
-
658
- def latest_compacted_context_index(messages: list[StoredMessage]) -> int | None:
659
- for index in range(len(messages) - 1, -1, -1):
660
- message = messages[index]
661
- if message.author == "system" and is_context_marker(message):
662
- return index
663
- return None
664
-
665
-
666
- def is_context_marker(message: StoredMessage) -> bool:
667
- return message.content in {COMPACTED_CONTEXT_MARKER, OPTIMIZED_CONTEXT_MARKER}
668
-
669
-
670
- def auto_compact_token_limit(context_window: int) -> int:
671
- raw_limit = os.environ.get("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "")
672
- if not raw_limit:
673
- return max(0, int(context_window * DEFAULT_AUTO_COMPACT_CONTEXT_WINDOW_RATIO))
674
- try:
675
- return max(0, int(raw_limit))
676
- except ValueError:
677
- return max(0, int(context_window * DEFAULT_AUTO_COMPACT_CONTEXT_WINDOW_RATIO))
678
-
679
-
680
- def should_auto_compact(
681
- messages: list[ChatMessage],
682
- *,
683
- context_window: int,
684
- ) -> bool:
685
- token_limit = auto_compact_token_limit(context_window)
686
- if token_limit <= 0:
687
- return False
688
- return (
689
- sum(max(1, (len(message.content) + 3) // 4) for message in messages)
690
- >= token_limit
691
- )
692
-
693
-
694
- def model_visible_messages_for_usage(
695
- messages: Sequence[Mapping[str, object]],
696
- ) -> list[dict[str, object]]:
697
- return [
698
- dict(message)
699
- for message in messages
700
- if message.get("role") in {"system", "user", "assistant", "tool"}
701
- ]
702
-
703
-
704
- def usage_event_data(usage_info: TokenUsageInfo) -> dict[str, object]:
705
- return {"usage_info": usage_info.model_dump()}
706
-
707
-
708
- def update_context_usage_for_response(
709
- usage_info: TokenUsageInfo | None,
710
- *,
711
- messages: Sequence[Mapping[str, object]],
712
- output_content: str,
713
- model_context_window: int,
714
- ) -> TokenUsageInfo:
715
- return recompute_context_usage(
716
- usage_info,
717
- estimated_token_usage_for_messages(
718
- model_visible_messages_for_usage(messages),
719
- output_content=output_content,
720
- ).total_tokens,
721
- model_context_window=model_context_window,
722
- )
723
-
724
-
725
- def usage_info_for_model(
726
- usage_info: TokenUsageInfo | None,
727
- model_context_window: int,
728
- ) -> TokenUsageInfo | None:
729
- if usage_info is None:
730
- return None
731
- return usage_info.model_copy(update={"model_context_window": model_context_window})
732
-
733
-
734
- def context_window_for_settings(settings: StoredSettings) -> int:
735
- if settings.context_window_limit is not None:
736
- return settings.context_window_limit
737
- return current_model_context_window(settings.selected_model)
738
-
739
-
740
- def state_with_current_model_context_window(state: StoredState) -> StoredState:
741
- model_context_window = context_window_for_settings(state.settings)
742
- return state.model_copy(
743
- update={
744
- "messages": [
745
- message.model_copy(
746
- update={
747
- "usage_info": usage_info_for_model(
748
- message.usage_info,
749
- model_context_window,
750
- )
751
- }
752
- )
753
- if message.usage_info is not None
754
- else message
755
- for message in state.messages
756
- ],
757
- "usage_info": usage_info_for_model(
758
- state.usage_info,
759
- model_context_window,
760
- ),
761
- }
762
- )
763
-
764
-
765
- def workspace_chat_messages(
766
- messages: list[StoredMessage],
767
- compacted_context: str = "",
768
- checkpoint: StoredCompactionCheckpoint | None = None,
769
- ) -> list[ChatMessage]:
770
- chat_messages: list[ChatMessage] = []
771
-
772
- if checkpoint is not None:
773
- chat_messages.extend(checkpoint.replacement_history)
774
- visible_messages = transcript_messages_after(
775
- messages,
776
- checkpoint.source_message_id,
777
- )
778
- for message in visible_messages:
779
- if message.author == "system" and is_context_marker(message):
780
- continue
781
- if message.author not in ("user", "assistant"):
782
- raise HTTPException(
783
- status_code=400, detail="Message history is invalid."
784
- )
785
- if message.author == "assistant":
786
- errors = message_error_items(message)
787
- if errors:
788
- chat_messages.extend(
789
- ChatMessage(
790
- role="assistant", content=error_context_summary(error)
791
- )
792
- for error in errors
793
- )
794
- continue
795
- checkpoint_role: Literal["user", "assistant"] = (
796
- "user" if message.author == "user" else "assistant"
797
- )
798
- chat_messages.append(
799
- ChatMessage(role=checkpoint_role, content=message.content)
800
- )
801
- return chat_messages
802
-
803
- marker_index = latest_compacted_context_index(messages)
804
- visible_messages = messages
805
-
806
- if compacted_context and marker_index is not None:
807
- chat_messages.extend(
808
- [
809
- ChatMessage(role="user", content=COMPACTED_CONTEXT_MARKER),
810
- ChatMessage(role="assistant", content=compacted_context),
811
- ]
812
- )
813
- visible_messages = messages[marker_index + 1 :]
814
-
815
- for message in visible_messages:
816
- if message.author == "system" and is_context_marker(message):
817
- continue
818
- if message.author not in ("user", "assistant"):
819
- raise HTTPException(status_code=400, detail="Message history is invalid.")
820
- if message.author == "assistant":
821
- errors = message_error_items(message)
822
- if errors:
823
- chat_messages.extend(
824
- ChatMessage(role="assistant", content=error_context_summary(error))
825
- for error in errors
826
- )
827
- continue
828
- role: Literal["user", "assistant"] = (
829
- "user" if message.author == "user" else "assistant"
830
- )
831
- chat_messages.append(ChatMessage(role=role, content=message.content))
832
- return chat_messages
833
-
834
-
835
- def normalized_request_path(path: str, cwd: Path) -> Path:
836
- raw_path = Path(path).expanduser()
837
- if not raw_path.is_absolute():
838
- raw_path = cwd / raw_path
839
- return raw_path.resolve(strict=False)
840
-
841
-
842
- def create_app(
843
- *,
844
- serve_frontend: bool = True,
845
- chat_completion: CompletionCallable | None = None,
846
- mcp_transport: McpTransport | None = None,
847
- telegram_transport: TelegramTransport | None = None,
848
- workdir: Path | str | None = None,
849
- ) -> FastAPI:
850
- ensure_logging_configured()
851
- ensure_sandbox_available()
852
-
853
- cwd = resolve_workdir(workdir)
854
- store = StateStore()
855
- compact_provider = LocalSummaryCompactProvider()
856
- mcp_manager = McpManager(store=store, transport=mcp_transport)
857
- telegram_bot_manager: TelegramBotManager | None = None
858
- workspace_runs: dict[str, WorkspaceRun] = {}
859
- active_workspace_run_id: str | None = None
860
- workspace_generation = 0
861
- active_compact_task: WorkspaceCompactTask | None = None
862
-
863
- static_dir = frontend_static_directory().resolve(strict=False)
864
- logger.debug("Flowent app created serve_frontend=%s", serve_frontend)
865
- logger.info("Workdir: %s", cwd)
866
- logger.info("Static directory: %s", static_dir)
867
-
868
- def request_messages_for_content(
869
- state: StoredState,
870
- messages: list[StoredMessage],
871
- content: str,
872
- ) -> list[dict[str, object]]:
873
- compacted_context = store.read_compacted_context()
874
- checkpoint = store.read_active_compaction_checkpoint()
875
- chat_messages = workspace_chat_messages(
876
- messages,
877
- compacted_context,
878
- checkpoint,
879
- )
880
- return [
881
- message.model_dump()
882
- for message in [
883
- *runtime_context_messages(cwd, state.settings.agent_prompt),
884
- *explicit_skill_messages(cwd, store, content),
885
- *chat_messages,
886
- ]
887
- ]
888
-
889
- async def save_context_checkpoint(
890
- *,
891
- connection: ProviderConnection,
892
- context_window_limit: int,
893
- messages: list[StoredMessage],
894
- model_history: list[ChatMessage],
895
- marker_content: str,
896
- source_message_id: str | None = None,
897
- trigger: Literal["manual", "auto"],
898
- ) -> tuple[StoredMessage, list[dict[str, object]], TokenUsageInfo]:
899
- compact_result = await compact_provider.compact(
900
- connection,
901
- CompactInput(
902
- messages=messages,
903
- model_history=model_history,
904
- retained_message_token_budget=AUTO_COMPACT_RETAINED_MESSAGE_TOKEN_BUDGET,
905
- trigger=trigger,
906
- ),
907
- completion=chat_completion,
908
- )
909
- usage_info = store.read_usage_info()
910
- if compact_result.summary_usage is not None:
911
- usage_info = append_token_usage(
912
- usage_info,
913
- compact_result.summary_usage,
914
- model_context_window=context_window_limit,
915
- )
916
- usage_info = recompute_context_usage(
917
- usage_info,
918
- compact_result.token_after,
919
- model_context_window=context_window_limit,
920
- )
921
- store.save_usage_info(usage_info)
922
- marker = StoredMessage(
923
- author="system",
924
- content=marker_content,
925
- id=str(uuid4()),
926
- usage_info=usage_info,
927
- )
928
- store.save_compaction_checkpoint(
929
- StoredCompactionCheckpoint(
930
- id=str(uuid4()),
931
- method=compact_result.method,
932
- replacement_history=compact_result.replacement_history,
933
- source_message_id=source_message_id or marker.id,
934
- summary=compact_result.summary,
935
- token_after=compact_result.token_after,
936
- token_before=compact_result.token_before,
937
- trigger=trigger,
938
- )
939
- )
940
- logger.info(
941
- "Workspace compact checkpoint saved trigger=%s method=%s summary_length=%s token_before=%s token_after=%s",
942
- trigger,
943
- compact_result.method,
944
- len(compact_result.summary),
945
- compact_result.token_before,
946
- compact_result.token_after,
947
- )
948
- logger.log(TRACE_LEVEL, "Workspace compact summary=%r", compact_result.summary)
949
- return (
950
- marker,
951
- [message.model_dump() for message in compact_result.replacement_history],
952
- usage_info,
953
- )
954
-
955
- async def auto_compact_workspace_messages(
956
- *,
957
- connection: ProviderConnection,
958
- context_window_limit: int,
959
- messages: list[StoredMessage],
960
- model_history: list[ChatMessage],
961
- source_message_id: str | None = None,
962
- ) -> tuple[StoredMessage, list[dict[str, object]], TokenUsageInfo] | None:
963
- if not should_auto_compact(
964
- model_history,
965
- context_window=context_window_limit,
966
- ):
967
- return None
968
- logger.info("Workspace auto compact requested")
969
- try:
970
- return await save_context_checkpoint(
971
- connection=connection,
972
- context_window_limit=context_window_limit,
973
- marker_content=OPTIMIZED_CONTEXT_MARKER,
974
- messages=messages,
975
- model_history=model_history,
976
- source_message_id=source_message_id,
977
- trigger="auto",
978
- )
979
- except Exception as error:
980
- logger.exception("Workspace auto compact failed")
981
- raise RuntimeError("Context could not be optimized.") from error
982
-
983
- async def run_workspace_turn(content: str) -> StoredMessage:
984
- state = store.read_state()
985
- connection = selected_connection(state)
986
- context_window_limit = context_window_for_settings(state.settings)
987
- user_message = StoredMessage(
988
- author="user",
989
- content=content,
990
- id=str(uuid4()),
991
- )
992
- next_messages = [*state.messages, user_message]
993
- store.save_messages(next_messages)
994
- model_history = [
995
- *runtime_context_messages(cwd, state.settings.agent_prompt),
996
- *workspace_chat_messages(
997
- state.messages,
998
- store.read_compacted_context(),
999
- store.read_active_compaction_checkpoint(),
1000
- ),
1001
- ]
1002
- auto_compaction = await auto_compact_workspace_messages(
1003
- connection=connection,
1004
- context_window_limit=context_window_limit,
1005
- messages=state.messages,
1006
- model_history=model_history,
1007
- source_message_id=None,
1008
- )
1009
- if auto_compaction is not None:
1010
- marker, _, _ = auto_compaction
1011
- next_messages = [*state.messages, marker, user_message]
1012
- store.save_messages(next_messages)
1013
- request_messages = request_messages_for_content(state, next_messages, content)
1014
- assistant_id = str(uuid4())
1015
- assistant_output = AssistantOutputBuilder(assistant_id)
1016
- turn_usage_info: TokenUsageInfo | None = None
1017
-
1018
- async def review_tool_approval(request: ApprovalReviewRequest):
1019
- return await review_approval_request(
1020
- connection,
1021
- request.model_copy(
1022
- update={
1023
- "transcript": approval_transcript(next_messages),
1024
- "user_request": content,
1025
- }
1026
- ),
1027
- completion=chat_completion,
1028
- )
1029
-
1030
- async def tool_runner(
1031
- name: str,
1032
- arguments: dict[str, object],
1033
- context: ToolContext,
1034
- ):
1035
- return await run_tool_with_path_permissions(
1036
- name,
1037
- arguments,
1038
- context,
1039
- review_approval=review_tool_approval,
1040
- writable_paths=[
1041
- Path(path.path) for path in store.read_writable_paths()
1042
- ],
1043
- )
1044
-
1045
- async for event in run_agent_stream(
1046
- completion=chat_completion,
1047
- connection=connection,
1048
- cwd=cwd,
1049
- extra_tool_runner=mcp_manager.run_tool,
1050
- extra_tool_specs=mcp_manager.tool_specs(),
1051
- extra_tool_title=mcp_manager.tool_title,
1052
- messages=request_messages,
1053
- tool_runner=tool_runner,
1054
- ):
1055
- if event.event == "start":
1056
- event_id = event.data.get("id")
1057
- if isinstance(event_id, str):
1058
- assistant_id = event_id
1059
- assistant_output.set_assistant_id(event_id)
1060
- if event.event == "output_start":
1061
- index = event.data.get("index")
1062
- if isinstance(index, int):
1063
- assistant_output.start_group(index)
1064
- if event.event == "delta":
1065
- assistant_output.append_text(str(event.data.get("content") or ""))
1066
- if event.event == "thinking_delta":
1067
- assistant_output.append_thinking(str(event.data.get("content") or ""))
1068
- if event.event == "usage":
1069
- usage_data = event.data.get("usage")
1070
- if isinstance(usage_data, dict):
1071
- usage_info = update_context_usage_for_response(
1072
- append_token_usage(
1073
- store.read_usage_info(),
1074
- TokenUsage.model_validate(usage_data),
1075
- model_context_window=context_window_limit,
1076
- ),
1077
- messages=request_messages,
1078
- output_content=assistant_output.content,
1079
- model_context_window=context_window_limit,
1080
- )
1081
- store.save_usage_info(usage_info)
1082
- turn_usage_info = usage_info
1083
- if event.event == "tool_start":
1084
- tool = event.data.get("tool")
1085
- if isinstance(tool, dict) and isinstance(tool.get("id"), str):
1086
- assistant_output.start_tool(StoredToolItem.model_validate(tool))
1087
- if event.event in {"tool_done", "tool_error"}:
1088
- tool_id = event.data.get("id")
1089
- if isinstance(tool_id, str):
1090
- assistant_output.update_tool(tool_id, event.data)
1091
- if event.event == "done":
1092
- message = event.data.get("message")
1093
- if isinstance(message, dict):
1094
- assistant_id = str(message.get("id") or assistant_id)
1095
- assistant_output.set_assistant_id(assistant_id)
1096
- assistant_output.apply_done_message(message)
1097
-
1098
- final_usage_info = turn_usage_info
1099
- if final_usage_info is None:
1100
- final_usage_info = update_context_usage_for_response(
1101
- store.read_usage_info(),
1102
- messages=request_messages,
1103
- output_content=assistant_output.content,
1104
- model_context_window=context_window_limit,
1105
- )
1106
- else:
1107
- final_usage_info = update_context_usage_for_response(
1108
- final_usage_info,
1109
- messages=request_messages,
1110
- output_content=assistant_output.content,
1111
- model_context_window=context_window_limit,
1112
- )
1113
- store.save_usage_info(final_usage_info)
1114
-
1115
- assistant_message = StoredMessage(
1116
- author="assistant",
1117
- content=assistant_output.content,
1118
- groups=assistant_output.groups,
1119
- id=assistant_id,
1120
- status="completed",
1121
- thinking=assistant_output.thinking,
1122
- tools=list(assistant_output.tools.values()),
1123
- usage_info=final_usage_info,
1124
- )
1125
- store.save_messages([*next_messages, assistant_message])
1126
- return assistant_message
1127
-
1128
- async def workspace_reply_text(content: str) -> str:
1129
- return (await run_workspace_turn(content)).content
1130
-
1131
- telegram_bot_manager = TelegramBotManager(
1132
- message_handler=workspace_reply_text,
1133
- store=store,
1134
- telegram_transport=telegram_transport,
1135
- )
1136
-
1137
- async def gather_shutdown_tasks(
1138
- label: str, tasks: Sequence[asyncio.Task[Any]]
1139
- ) -> None:
1140
- if not tasks:
1141
- return
1142
- results = await asyncio.gather(*tasks, return_exceptions=True)
1143
- for result in results:
1144
- if result is None or isinstance(result, asyncio.CancelledError):
1145
- continue
1146
- if isinstance(result, BaseException):
1147
- logger.error(
1148
- "%s cleanup task failed",
1149
- label,
1150
- exc_info=(type(result), result, result.__traceback__),
1151
- )
1152
-
1153
- async def stop_workspace_runs_for_shutdown() -> None:
1154
- tasks: list[asyncio.Task[None]] = []
1155
- for run in workspace_runs.values():
1156
- if run.task is None or run.task.done():
1157
- continue
1158
- run.task.cancel()
1159
- tasks.append(run.task)
1160
- await gather_shutdown_tasks("Workspace run", tasks)
1161
-
1162
- async def stop_workspace_compact_for_shutdown() -> None:
1163
- nonlocal active_compact_task
1164
- if active_compact_task is None:
1165
- store.save_is_compacting(False)
1166
- return
1167
- task = active_compact_task.task
1168
- active_compact_task = None
1169
- if not task.done():
1170
- task.cancel()
1171
- await gather_shutdown_tasks("Workspace compact", [task])
1172
- store.save_is_compacting(False)
1173
-
1174
- async def run_shutdown_step(label: str, cleanup: Awaitable[object]) -> None:
1175
- try:
1176
- await cleanup
1177
- except Exception:
1178
- logger.exception("%s cleanup failed during shutdown", label)
1179
-
1180
- async def graceful_shutdown() -> None:
1181
- await run_shutdown_step("Workspace run", stop_workspace_runs_for_shutdown())
1182
- await run_shutdown_step(
1183
- "Workspace compact", stop_workspace_compact_for_shutdown()
1184
- )
1185
- if telegram_bot_manager is not None:
1186
- await run_shutdown_step("Telegram", telegram_bot_manager.stop_all())
1187
- await run_shutdown_step("MCP", mcp_manager.stop_all())
1188
-
1189
- @asynccontextmanager
1190
- async def lifespan(app: FastAPI) -> AsyncIterator[None]:
1191
- app.state.mcp_manager = mcp_manager
1192
- app.state.telegram_bot_manager = telegram_bot_manager
1193
- await mcp_manager.start_enabled()
1194
- if telegram_bot_manager is not None:
1195
- await telegram_bot_manager.start_enabled()
1196
- try:
1197
- yield
1198
- finally:
1199
- await graceful_shutdown()
1200
-
1201
- app = FastAPI(title="Flowent", lifespan=lifespan)
1202
- app.state.mcp_manager = mcp_manager
1203
- app.state.telegram_bot_manager = telegram_bot_manager
1204
-
1205
- @app.get("/api/health")
1206
- async def health() -> dict[str, str]:
1207
- return {"status": "ok"}
1208
-
1209
- @app.get("/api/state")
1210
- async def app_state() -> StoredState:
1211
- state = state_with_current_model_context_window(store.read_state())
1212
- active_run = (
1213
- workspace_runs.get(active_workspace_run_id)
1214
- if active_workspace_run_id
1215
- else None
1216
- )
1217
- update: dict[str, object] = {
1218
- "active_run_event_index": active_run.latest_event_index
1219
- if active_run
1220
- else 0,
1221
- "active_run_id": active_run.id
1222
- if active_run and not active_run.is_done
1223
- else None,
1224
- "mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
1225
- "skills": discover_skills(cwd, store),
1226
- }
1227
- if telegram_bot_manager is not None:
1228
- update["telegram_bot"] = telegram_bot_manager.bot_with_status(
1229
- state.telegram_bot
1230
- )
1231
- return state.model_copy(update=update)
1232
-
1233
- @app.get("/api/about")
1234
- async def about() -> AboutResponse:
1235
- return AboutResponse(version=__version__)
1236
-
1237
- @app.post("/api/providers")
1238
- async def save_provider(provider: StoredProvider) -> StoredProvider:
1239
- return store.save_provider(provider)
1240
-
1241
- @app.delete("/api/providers/{provider_id}")
1242
- async def delete_provider(provider_id: str) -> dict[str, bool]:
1243
- store.delete_provider(provider_id)
1244
- return {"ok": True}
1245
-
1246
- @app.put("/api/mcp/servers")
1247
- async def save_mcp_server(server: StoredMcpServer) -> StoredMcpServer:
1248
- saved_server = store.save_mcp_server(server)
1249
- return await mcp_manager.sync_server(saved_server)
1250
-
1251
- @app.post("/api/mcp/import/preview")
1252
- async def preview_mcp_import(
1253
- request: McpImportPreviewRequest,
1254
- ) -> McpImportDiscovery:
1255
- return discover_imported_mcp_servers(cwd, source=request.source)
1256
-
1257
- @app.post("/api/mcp/import")
1258
- async def import_mcp_servers(request: McpImportRequest) -> list[StoredMcpServer]:
1259
- imported_servers = discover_imported_mcp_servers(
1260
- cwd,
1261
- source=request.source,
1262
- ).servers
1263
- existing_servers = {server.id for server in store.read_mcp_servers()}
1264
- for server in imported_servers:
1265
- if server.id != request.server_id:
1266
- continue
1267
- if server.id in existing_servers:
1268
- continue
1269
- store.save_mcp_server(server)
1270
- existing_servers.add(server.id)
1271
- return mcp_manager.servers_with_status(store.read_mcp_servers())
1272
-
1273
- @app.delete("/api/mcp/servers/{server_id}")
1274
- async def delete_mcp_server(server_id: str) -> dict[str, bool]:
1275
- await mcp_manager.delete_server(server_id)
1276
- return {"ok": True}
1277
-
1278
- @app.post("/api/mcp/servers/{server_id}/reconnect")
1279
- async def reconnect_mcp_server(server_id: str) -> StoredMcpServer:
1280
- try:
1281
- return await mcp_manager.reconnect_server(server_id)
1282
- except KeyError as error:
1283
- raise HTTPException(status_code=404, detail="Server not found.") from error
1284
-
1285
- @app.post("/api/mcp/reload")
1286
- async def reload_mcp_servers() -> list[StoredMcpServer]:
1287
- return await mcp_manager.reload()
1288
-
1289
- @app.post("/api/skills/reload")
1290
- async def reload_skills() -> list[StoredSkill]:
1291
- return discover_skills(cwd, store)
1292
-
1293
- @app.put("/api/skills/{skill_id:path}")
1294
- async def save_skill_settings(
1295
- skill_id: str,
1296
- request: SkillSettingsRequest,
1297
- ) -> StoredSkill:
1298
- try:
1299
- return update_skill_enabled(cwd, store, skill_id, request.enabled)
1300
- except KeyError as error:
1301
- raise HTTPException(status_code=404, detail="Skill not found.") from error
1302
-
1303
- @app.put("/api/telegram-bot")
1304
- async def save_telegram_bot(telegram_bot: StoredTelegramBot) -> StoredTelegramBot:
1305
- saved_bot = store.save_telegram_bot(telegram_bot)
1306
- if telegram_bot_manager is not None:
1307
- await telegram_bot_manager.sync_bot(saved_bot)
1308
- return telegram_bot_manager.bot_with_status(saved_bot)
1309
- return saved_bot
1310
-
1311
- @app.post("/api/telegram-bot/approve")
1312
- async def approve_telegram_session(
1313
- request: TelegramSessionApproveRequest,
1314
- ) -> StoredTelegramSession:
1315
- try:
1316
- return store.approve_telegram_session(request.chat_id)
1317
- except KeyError as error:
1318
- raise HTTPException(
1319
- status_code=404,
1320
- detail="Conversation not found.",
1321
- ) from error
1322
-
1323
- @app.post("/api/providers/models")
1324
- async def provider_models(request: ProviderModelsRequest) -> ProviderModelsResponse:
1325
- return ProviderModelsResponse(
1326
- models=list_provider_models(
1327
- base_url=request.base_url,
1328
- provider=request.provider,
1329
- secret_reference=request.secret_reference,
1330
- ),
1331
- )
1332
-
1333
- @app.put("/api/settings")
1334
- async def save_settings(settings: StoredSettings) -> StoredSettings:
1335
- return store.save_settings(settings)
1336
-
1337
- @app.post("/api/permissions/writable-paths")
1338
- async def save_writable_path(
1339
- request: WritablePathRequest,
1340
- ) -> StoredWritablePath:
1341
- return store.save_writable_path(normalized_request_path(request.path, cwd))
1342
-
1343
- @app.delete("/api/permissions/writable-paths")
1344
- async def delete_writable_path(
1345
- request: WritablePathRequest,
1346
- ) -> WritablePathListResponse:
1347
- return WritablePathListResponse(
1348
- writable_paths=store.delete_writable_path(
1349
- normalized_request_path(request.path, cwd)
1350
- )
1351
- )
1352
-
1353
- @app.put("/api/workspace/messages")
1354
- async def save_workspace_messages(
1355
- request: WorkspaceMessagesRequest,
1356
- ) -> WorkspaceMessagesRequest:
1357
- return WorkspaceMessagesRequest(messages=store.save_messages(request.messages))
1358
-
1359
- @app.post("/api/workspace/clear")
1360
- async def clear_workspace() -> WorkspaceClearResponse:
1361
- nonlocal active_workspace_run_id
1362
- nonlocal workspace_generation
1363
- workspace_generation += 1
1364
- for run in workspace_runs.values():
1365
- run.is_done = True
1366
- if run.task is not None and not run.task.done():
1367
- run.discard_on_cancel = True
1368
- run.task.cancel()
1369
- async with run.condition:
1370
- run.condition.notify_all()
1371
- active_workspace_run_id = None
1372
- messages = store.save_messages([])
1373
- return WorkspaceClearResponse(messages=messages)
1374
-
1375
- async def append_run_event(
1376
- run: WorkspaceRun, event: str, data: dict[str, object]
1377
- ) -> None:
1378
- async with run.condition:
1379
- run.events.append((run.latest_event_index + 1, event, data))
1380
- run.condition.notify_all()
1381
-
1382
- async def append_run_snapshot(run: WorkspaceRun, message: StoredMessage) -> None:
1383
- if message.author != "assistant":
1384
- return
1385
- run.latest_snapshot = message
1386
- await append_run_event(
1387
- run,
1388
- "snapshot",
1389
- {"message": stream_message_data(message, run.active_output)},
1390
- )
1391
-
1392
- def active_workspace_run() -> WorkspaceRun | None:
1393
- if active_workspace_run_id is None:
1394
- return None
1395
- run = workspace_runs.get(active_workspace_run_id)
1396
- if run is None or run.is_done:
1397
- return None
1398
- return run
1399
-
1400
- def has_active_workspace_run() -> bool:
1401
- return any(
1402
- not run.is_done and run.task is not None and not run.task.done()
1403
- for run in workspace_runs.values()
1404
- )
1405
-
1406
- def create_workspace_run(content: str) -> WorkspaceRun:
1407
- nonlocal active_workspace_run_id
1408
- if has_active_workspace_run():
1409
- active_run = active_workspace_run()
1410
- raise HTTPException(
1411
- status_code=409,
1412
- detail="Response in progress",
1413
- headers={"X-Flowent-Run-Id": active_run.id if active_run else ""},
1414
- )
1415
- state = store.read_state()
1416
- connection = selected_connection(state)
1417
- context_window_limit = context_window_for_settings(state.settings)
1418
-
1419
- user_message = StoredMessage(
1420
- author="user",
1421
- content=content,
1422
- id=str(uuid4()),
1423
- )
1424
- next_messages = [*state.messages, user_message]
1425
- store.save_messages(next_messages)
1426
- run = WorkspaceRun(
1427
- condition=asyncio.Condition(),
1428
- generation=workspace_generation,
1429
- )
1430
- workspace_runs[run.id] = run
1431
- active_workspace_run_id = run.id
1432
-
1433
- async def run_task() -> None:
1434
- nonlocal active_workspace_run_id
1435
- nonlocal next_messages
1436
- assistant_message = StoredMessage(
1437
- author="assistant",
1438
- content="",
1439
- id=str(uuid4()),
1440
- status="running",
1441
- )
1442
- assistant_output = AssistantOutputBuilder(assistant_message.id)
1443
- last_progress_flush_at = 0.0
1444
-
1445
- def is_current_generation() -> bool:
1446
- return run.generation == workspace_generation
1447
-
1448
- def update_assistant_message(
1449
- status: str = "running", *, persist: bool
1450
- ) -> StoredMessage | None:
1451
- nonlocal next_messages, assistant_message
1452
- if not is_current_generation() or run.discard_on_cancel:
1453
- return None
1454
- assistant_message = StoredMessage(
1455
- author="assistant",
1456
- content=assistant_output.content,
1457
- groups=assistant_output.groups,
1458
- id=assistant_message.id,
1459
- status=status,
1460
- thinking=assistant_output.thinking,
1461
- tools=list(assistant_output.tools.values()),
1462
- usage_info=store.read_usage_info(),
1463
- )
1464
- next_messages = append_or_replace_message(
1465
- next_messages, assistant_message
1466
- )
1467
- if persist:
1468
- store.upsert_message(assistant_message)
1469
- return assistant_message
1470
-
1471
- def persist_assistant(status: str = "running") -> StoredMessage | None:
1472
- nonlocal last_progress_flush_at
1473
- message = update_assistant_message(status, persist=True)
1474
- if status == "running" and message is not None:
1475
- last_progress_flush_at = time.monotonic()
1476
- return message
1477
-
1478
- def refresh_assistant(status: str = "running") -> StoredMessage | None:
1479
- return update_assistant_message(status, persist=False)
1480
-
1481
- def persist_assistant_progress() -> StoredMessage | None:
1482
- nonlocal last_progress_flush_at
1483
- now = time.monotonic()
1484
- if (
1485
- last_progress_flush_at > 0
1486
- and now - last_progress_flush_at
1487
- < WORKSPACE_PROGRESS_FLUSH_INTERVAL_SECONDS
1488
- ):
1489
- refresh_assistant()
1490
- return None
1491
- last_progress_flush_at = now
1492
- return update_assistant_message("running", persist=True)
1493
-
1494
- try:
1495
- current_tool_id: str | None = None
1496
- turn_usage_info: TokenUsageInfo | None = None
1497
- current_request_messages = request_messages_for_content(
1498
- state,
1499
- next_messages,
1500
- content,
1501
- )
1502
- pre_turn_request_messages = request_messages_for_content(
1503
- state,
1504
- state.messages,
1505
- content,
1506
- )
1507
- auto_compaction = await auto_compact_workspace_messages(
1508
- connection=connection,
1509
- context_window_limit=context_window_limit,
1510
- messages=state.messages,
1511
- model_history=[
1512
- ChatMessage.model_validate(message)
1513
- for message in pre_turn_request_messages
1514
- ],
1515
- source_message_id=None,
1516
- )
1517
- if auto_compaction is not None:
1518
- marker, _, usage_info = auto_compaction
1519
- next_messages = [*state.messages, marker, user_message]
1520
- store.save_messages(next_messages)
1521
- await append_run_event(
1522
- run,
1523
- "context_optimized",
1524
- {
1525
- "message": marker.model_dump(),
1526
- **usage_event_data(usage_info),
1527
- },
1528
- )
1529
- current_request_messages = request_messages_for_content(
1530
- state,
1531
- next_messages,
1532
- content,
1533
- )
1534
-
1535
- async def review_tool_approval(request: ApprovalReviewRequest):
1536
- return await review_approval_request(
1537
- connection,
1538
- request.model_copy(
1539
- update={
1540
- "transcript": approval_transcript(next_messages),
1541
- "user_request": content,
1542
- }
1543
- ),
1544
- completion=chat_completion,
1545
- )
1546
-
1547
- async def tool_runner(
1548
- name: str,
1549
- arguments: dict[str, object],
1550
- context: ToolContext,
1551
- ):
1552
- return await run_tool_with_path_permissions(
1553
- name,
1554
- arguments,
1555
- context,
1556
- review_approval=review_tool_approval,
1557
- writable_paths=[
1558
- Path(path.path) for path in store.read_writable_paths()
1559
- ],
1560
- )
1561
-
1562
- async def context_compactor(
1563
- conversation: Sequence[Mapping[str, object]],
1564
- ) -> AgentContextUpdate | None:
1565
- nonlocal next_messages
1566
- if not is_current_generation() or run.discard_on_cancel:
1567
- return None
1568
- assistant_snapshot = StoredMessage(
1569
- author="assistant",
1570
- content=assistant_output.content,
1571
- groups=assistant_output.groups,
1572
- id=assistant_message.id,
1573
- status="running",
1574
- thinking=assistant_output.thinking,
1575
- tools=list(assistant_output.tools.values()),
1576
- usage_info=store.read_usage_info(),
1577
- )
1578
- model_history: list[ChatMessage] = []
1579
- for message in conversation:
1580
- role_value = message.get("role")
1581
- content = str(message.get("content") or "")
1582
- if role_value == "system":
1583
- model_history.append(
1584
- ChatMessage(role="system", content=content)
1585
- )
1586
- if role_value == "user":
1587
- model_history.append(
1588
- ChatMessage(role="user", content=content)
1589
- )
1590
- if role_value == "assistant":
1591
- model_history.append(
1592
- ChatMessage(role="assistant", content=content)
1593
- )
1594
- if role_value == "tool":
1595
- model_history.append(
1596
- ChatMessage(
1597
- role="user",
1598
- content=f"Tool result: {content}",
1599
- )
1600
- )
1601
- auto_result = await auto_compact_workspace_messages(
1602
- connection=connection,
1603
- context_window_limit=context_window_limit,
1604
- messages=next_messages,
1605
- model_history=model_history,
1606
- source_message_id=assistant_snapshot.id,
1607
- )
1608
- if auto_result is None:
1609
- return None
1610
- marker, replacement_history, usage_info = auto_result
1611
- assistant_snapshot = assistant_snapshot.model_copy(
1612
- update={"usage_info": usage_info}
1613
- )
1614
- next_messages = append_or_replace_message(
1615
- [*next_messages, marker], assistant_snapshot
1616
- )
1617
- store.save_messages(next_messages)
1618
- compacted_conversation = [
1619
- dict(conversation[0]),
1620
- *replacement_history,
1621
- ]
1622
- return AgentContextUpdate(
1623
- conversation=compacted_conversation,
1624
- message={
1625
- **marker.model_dump(),
1626
- "usage_info": usage_info.model_dump(),
1627
- },
1628
- )
1629
-
1630
- async for event in run_agent_stream(
1631
- completion=chat_completion,
1632
- connection=connection,
1633
- context_compactor=context_compactor,
1634
- cwd=cwd,
1635
- extra_tool_runner=mcp_manager.run_tool,
1636
- extra_tool_specs=mcp_manager.tool_specs(),
1637
- extra_tool_title=mcp_manager.tool_title,
1638
- messages=current_request_messages,
1639
- tool_runner=tool_runner,
1640
- ):
1641
- if not is_current_generation() or run.discard_on_cancel:
1642
- raise asyncio.CancelledError
1643
- run_event_data = event.data
1644
- should_append_run_event = event.event != "usage"
1645
- snapshot_after_event: StoredMessage | None = None
1646
- if event.event == "start":
1647
- event_id = event.data.get("id")
1648
- if isinstance(event_id, str):
1649
- assistant_message = assistant_message.model_copy(
1650
- update={"id": event_id}
1651
- )
1652
- assistant_output.set_assistant_id(event_id)
1653
- snapshot_after_event = persist_assistant()
1654
- if event.event == "output_start":
1655
- index = event.data.get("index")
1656
- if isinstance(index, int):
1657
- run.active_output = None
1658
- assistant_output.start_group(index)
1659
- snapshot_after_event = persist_assistant()
1660
- if event.event == "output_done":
1661
- run.active_output = None
1662
- if event.event == "tool_start":
1663
- tool = event.data.get("tool")
1664
- if isinstance(tool, dict) and isinstance(tool.get("id"), str):
1665
- run.active_output = None
1666
- current_tool_id = tool["id"]
1667
- assistant_output.start_tool(
1668
- StoredToolItem.model_validate(tool)
1669
- )
1670
- snapshot_after_event = persist_assistant()
1671
- if event.event in {"tool_done", "tool_error"}:
1672
- tool_id = event.data.get("id")
1673
- if (
1674
- isinstance(tool_id, str)
1675
- and tool_id in assistant_output.tools
1676
- ):
1677
- current_tool_id = (
1678
- None if current_tool_id == tool_id else current_tool_id
1679
- )
1680
- assistant_output.update_tool(tool_id, event.data)
1681
- snapshot_after_event = persist_assistant()
1682
- if event.event == "delta":
1683
- run.active_output = "text"
1684
- assistant_output.append_text(
1685
- str(event.data.get("content") or "")
1686
- )
1687
- snapshot_after_event = persist_assistant_progress()
1688
- if event.event == "thinking_delta":
1689
- run.active_output = "thinking"
1690
- assistant_output.append_thinking(
1691
- str(event.data.get("content") or "")
1692
- )
1693
- snapshot_after_event = persist_assistant_progress()
1694
- if event.event == "usage":
1695
- usage_data = event.data.get("usage")
1696
- if isinstance(usage_data, dict):
1697
- usage_info = update_context_usage_for_response(
1698
- append_token_usage(
1699
- store.read_usage_info(),
1700
- TokenUsage.model_validate(usage_data),
1701
- model_context_window=context_window_limit,
1702
- ),
1703
- messages=current_request_messages,
1704
- output_content=assistant_output.content,
1705
- model_context_window=context_window_limit,
1706
- )
1707
- store.save_usage_info(usage_info)
1708
- turn_usage_info = usage_info
1709
- run_event_data = usage_event_data(usage_info)
1710
- should_append_run_event = True
1711
- snapshot_after_event = persist_assistant()
1712
- logger.log(
1713
- TRACE_LEVEL,
1714
- "Workspace stream event=%s data=%r",
1715
- event.event,
1716
- event.data,
1717
- )
1718
- if event.event == "done":
1719
- message = event.data.get("message")
1720
- if isinstance(message, dict):
1721
- run.active_output = None
1722
- assistant_output.apply_done_message(message)
1723
- response_usage_info = store.read_usage_info()
1724
- final_usage_info = turn_usage_info
1725
- if final_usage_info is None:
1726
- final_usage_info = update_context_usage_for_response(
1727
- response_usage_info,
1728
- messages=current_request_messages,
1729
- output_content=assistant_output.content,
1730
- model_context_window=context_window_limit,
1731
- )
1732
- else:
1733
- final_usage_info = update_context_usage_for_response(
1734
- final_usage_info,
1735
- messages=current_request_messages,
1736
- output_content=assistant_output.content,
1737
- model_context_window=context_window_limit,
1738
- )
1739
- store.save_usage_info(final_usage_info)
1740
- snapshot_after_event = persist_assistant("completed")
1741
- if snapshot_after_event is not None:
1742
- run_event_data = {
1743
- "message": stream_message_data(snapshot_after_event)
1744
- }
1745
- if event.event == "done" and snapshot_after_event is not None:
1746
- await append_run_snapshot(run, snapshot_after_event)
1747
- await append_run_event(run, event.event, run_event_data)
1748
- else:
1749
- if should_append_run_event:
1750
- await append_run_event(run, event.event, run_event_data)
1751
- if snapshot_after_event is not None:
1752
- await append_run_snapshot(run, snapshot_after_event)
1753
- except asyncio.CancelledError:
1754
- logger.info("Workspace run stopped")
1755
- if not run.discard_on_cancel:
1756
- interrupted_snapshot = persist_assistant("interrupted")
1757
- if interrupted_snapshot is not None:
1758
- await append_run_snapshot(run, interrupted_snapshot)
1759
- await append_run_event(
1760
- run,
1761
- "error",
1762
- {"message": "Response stopped."},
1763
- )
1764
- raise
1765
- except Exception as error:
1766
- logger.exception("Workspace response failed")
1767
- if (
1768
- current_tool_id is not None
1769
- and current_tool_id in assistant_output.tools
1770
- and assistant_output.tools[current_tool_id].status == "running"
1771
- ):
1772
- assistant_output.update_tool(
1773
- current_tool_id,
1774
- {"content": str(error) or "Tool failed.", "status": "failed"},
1775
- )
1776
- error_item = assistant_output.append_error(
1777
- run_error_output_item(
1778
- assistant_message.id,
1779
- str(error) or EMPTY_MODEL_RESPONSE_DETAIL,
1780
- )
1781
- )
1782
- failed_snapshot = persist_assistant("failed")
1783
- if failed_snapshot is not None:
1784
- await append_run_snapshot(run, failed_snapshot)
1785
- await append_run_event(run, "error", run_error_event_data(error_item))
1786
- finally:
1787
- run.is_done = True
1788
- async with run.condition:
1789
- run.condition.notify_all()
1790
- if active_workspace_run_id == run.id:
1791
- active_workspace_run_id = None
1792
-
1793
- run.task = asyncio.create_task(run_task())
1794
- return run
1795
-
1796
- async def workspace_run_stream(
1797
- run: WorkspaceRun, after: int = 0, include_snapshots: bool = True
1798
- ) -> AsyncIterator[str]:
1799
- next_event_index = after + 1
1800
- reconnect_snapshot = run_snapshot_data_at(run, after) if after > 0 else None
1801
- if include_snapshots and reconnect_snapshot is not None:
1802
- yield stream_event(
1803
- "snapshot",
1804
- {"message": reconnect_snapshot},
1805
- event_id=after,
1806
- )
1807
- while True:
1808
- async with run.condition:
1809
-
1810
- def has_next_event(index: int = next_event_index) -> bool:
1811
- return run.is_done or any(
1812
- event_index >= index for event_index, _, _ in run.events
1813
- )
1814
-
1815
- await run.condition.wait_for(has_next_event)
1816
- events = [event for event in run.events if event[0] >= next_event_index]
1817
-
1818
- for index, event, data in events:
1819
- next_event_index = index + 1
1820
- if event == "snapshot" and not include_snapshots:
1821
- continue
1822
- yield stream_event(event, data, event_id=index)
1823
- if event in {"done", "error"}:
1824
- return
1825
-
1826
- if run.is_done and not events:
1827
- return
1828
-
1829
- @app.post("/api/workspace/runs")
1830
- async def start_workspace_run(
1831
- request: WorkspaceRespondRequest,
1832
- ) -> WorkspaceRunResponse:
1833
- logger.info("Workspace run requested content_length=%s", len(request.content))
1834
- logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
1835
- run = create_workspace_run(request.content)
1836
- return WorkspaceRunResponse(run_id=run.id)
1837
-
1838
- @app.get("/api/workspace/runs/{run_id}/stream")
1839
- async def stream_workspace_run(
1840
- run_id: str,
1841
- after: int = Query(default=0, ge=0),
1842
- ) -> StreamingResponse:
1843
- run = workspace_runs.get(run_id)
1844
- if run is None:
1845
- raise HTTPException(status_code=404, detail="Run not found.")
1846
- return StreamingResponse(
1847
- workspace_run_stream(run, after),
1848
- media_type="text/event-stream",
1849
- )
1850
-
1851
- @app.post("/api/workspace/runs/{run_id}/stop")
1852
- async def stop_workspace_run(run_id: str) -> dict[str, bool]:
1853
- run = workspace_runs.get(run_id)
1854
- if run is None:
1855
- raise HTTPException(status_code=404, detail="Run not found.")
1856
- if run.task is not None and not run.task.done():
1857
- run.task.cancel()
1858
- return {"ok": True}
1859
-
1860
- @app.post("/api/workspace/compact", response_class=StreamingResponse)
1861
- async def compact_workspace() -> StreamingResponse:
1862
- nonlocal active_compact_task
1863
-
1864
- async def run_manual_compact(
1865
- *,
1866
- checkpoint: StoredCompactionCheckpoint | None,
1867
- connection: ProviderConnection,
1868
- context_window_limit: int,
1869
- state: StoredState,
1870
- ) -> tuple[StoredMessage, TokenUsageInfo]:
1871
- logger.info("Workspace compact requested")
1872
- try:
1873
- model_history = [
1874
- *runtime_context_messages(cwd, state.settings.agent_prompt),
1875
- *workspace_chat_messages(
1876
- state.messages,
1877
- store.read_compacted_context(),
1878
- checkpoint,
1879
- ),
1880
- ]
1881
-
1882
- marker, _, usage_info = await save_context_checkpoint(
1883
- connection=connection,
1884
- context_window_limit=context_window_limit,
1885
- marker_content=COMPACTED_CONTEXT_MARKER,
1886
- messages=state.messages,
1887
- model_history=model_history,
1888
- source_message_id=None,
1889
- trigger="manual",
1890
- )
1891
- store.save_messages([*state.messages, marker])
1892
- logger.info("Workspace compact completed")
1893
- return marker, usage_info
1894
- except Exception:
1895
- logger.exception("Workspace compact failed")
1896
- raise
1897
- finally:
1898
- store.save_is_compacting(False)
1899
-
1900
- def clear_active_compact_task(
1901
- task: asyncio.Task[tuple[StoredMessage, TokenUsageInfo]],
1902
- ) -> None:
1903
- nonlocal active_compact_task
1904
- if active_compact_task is not None and active_compact_task.task is task:
1905
- active_compact_task = None
1906
- with suppress(asyncio.CancelledError):
1907
- task.exception()
1908
-
1909
- if active_compact_task is not None:
1910
- if not active_compact_task.task.done():
1911
- compact_task = active_compact_task.task
1912
- else:
1913
- active_compact_task = None
1914
-
1915
- if active_compact_task is None:
1916
- if active_workspace_run() is not None:
1917
- raise HTTPException(
1918
- status_code=409,
1919
- detail="Compact is unavailable while Flowent is responding.",
1920
- )
1921
- state = store.read_state()
1922
- connection = selected_connection(state)
1923
- context_window_limit = context_window_for_settings(state.settings)
1924
- checkpoint = store.read_active_compaction_checkpoint()
1925
- store.save_is_compacting(True)
1926
- compact_task = asyncio.create_task(
1927
- run_manual_compact(
1928
- checkpoint=checkpoint,
1929
- connection=connection,
1930
- context_window_limit=context_window_limit,
1931
- state=state,
1932
- )
1933
- )
1934
- compact_task.add_done_callback(clear_active_compact_task)
1935
- active_compact_task = WorkspaceCompactTask(task=compact_task)
1936
-
1937
- async def compact_workspace_stream() -> AsyncIterator[str]:
1938
- try:
1939
- marker, usage_info = await asyncio.shield(compact_task)
1940
- except Exception:
1941
- yield stream_event(
1942
- "error",
1943
- {"message": "Context could not be compacted."},
1944
- )
1945
- return
1946
-
1947
- marker_data = marker.model_dump()
1948
- yield stream_event("usage", usage_event_data(usage_info))
1949
- yield stream_event(
1950
- "context_optimized",
1951
- {"message": marker_data, **usage_event_data(usage_info)},
1952
- )
1953
- yield stream_event("done", {"message": marker_data})
1954
-
1955
- return StreamingResponse(
1956
- compact_workspace_stream(),
1957
- media_type="text/event-stream",
1958
- )
1959
-
1960
- @app.post("/api/workspace/respond")
1961
- async def respond_to_workspace(
1962
- request: WorkspaceRespondRequest,
1963
- ) -> StreamingResponse:
1964
- logger.info(
1965
- "Workspace response requested content_length=%s", len(request.content)
1966
- )
1967
- logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
1968
- run = create_workspace_run(request.content)
1969
- return StreamingResponse(
1970
- workspace_run_stream(run, include_snapshots=False),
1971
- media_type="text/event-stream",
1972
- )
1973
-
1974
- if serve_frontend and static_dir.is_dir():
1975
- assets_dir = static_dir / "assets"
1976
- if assets_dir.is_dir():
1977
- app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
1978
-
1979
- @app.get("/{path:path}")
1980
- async def spa_fallback(path: str) -> FileResponse:
1981
- file = (static_dir / path).resolve(strict=False)
1982
- if file.is_file() and file.is_relative_to(static_dir):
1983
- return FileResponse(file)
1984
- return FileResponse(static_dir / "index.html")
1985
-
1986
- return app
1987
-
1988
-
1989
- app = create_app()
1990
-
1991
-
1992
- if __name__ == "__main__":
1993
- import uvicorn
1994
-
1995
- uvicorn.run(app)
6
+ from flowent.provider_connections import selected_connection
7
+ from flowent.routes.permissions import normalized_request_path
8
+ from flowent.workspace.context import should_auto_compact
9
+ from flowent.workspace.runtime import WORKSPACE_PROGRESS_FLUSH_INTERVAL_SECONDS
10
+
11
+ __all__ = [
12
+ "WORKSPACE_PROGRESS_FLUSH_INTERVAL_SECONDS",
13
+ "app",
14
+ "create_app",
15
+ "frontend_static_directory",
16
+ "normalized_request_path",
17
+ "selected_connection",
18
+ "should_auto_compact",
19
+ ]