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.
- package/README.md +3 -3
- package/backend/README.md +3 -3
- package/backend/pyproject.toml +1 -1
- package/backend/src/flowent/agent.py +1 -1
- package/backend/src/flowent/api_models.py +108 -0
- package/backend/src/flowent/app.py +151 -0
- package/backend/src/flowent/cli.py +13 -4
- package/backend/src/flowent/compact.py +34 -13
- package/backend/src/flowent/llm.py +52 -6
- package/backend/src/flowent/main.py +18 -1994
- package/backend/src/flowent/mcp.py +100 -2
- package/backend/src/flowent/network.py +5 -0
- package/backend/src/flowent/provider_connections.py +42 -0
- package/backend/src/flowent/routes/__init__.py +0 -0
- package/backend/src/flowent/routes/integrations.py +105 -0
- package/backend/src/flowent/routes/permissions.py +36 -0
- package/backend/src/flowent/routes/providers.py +53 -0
- package/backend/src/flowent/routes/system.py +48 -0
- package/backend/src/flowent/routes/workflow_routes.py +63 -0
- package/backend/src/flowent/routes/workspace.py +115 -0
- package/backend/src/flowent/state/__init__.py +53 -0
- package/backend/src/flowent/state/models.py +258 -0
- package/backend/src/flowent/state/schema.py +191 -0
- package/backend/src/flowent/state/store.py +1019 -0
- package/backend/src/flowent/static/assets/index-BaZmIi2Y.js +98 -0
- package/backend/src/flowent/static/assets/index-EC37agAH.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +52 -1318
- package/backend/src/flowent/system_tools.py +25 -0
- package/backend/src/flowent/tools.py +4 -2
- package/backend/src/flowent/usage.py +9 -4
- package/backend/src/flowent/workflows.py +282 -0
- package/backend/src/flowent/workspace/__init__.py +0 -0
- package/backend/src/flowent/workspace/context.py +335 -0
- package/backend/src/flowent/workspace/events.py +178 -0
- package/backend/src/flowent/workspace/output.py +396 -0
- package/backend/src/flowent/workspace/runtime.py +1160 -0
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-BaZmIi2Y.js +98 -0
- package/dist/frontend/assets/index-EC37agAH.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-BH30iLzb.css +0 -2
- package/backend/src/flowent/static/assets/index-sBFt3ORj.js +0 -84
- package/dist/frontend/assets/index-BH30iLzb.css +0 -2
- package/dist/frontend/assets/index-sBFt3ORj.js +0 -84
|
@@ -1,1995 +1,19 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
]
|